@@ -74,6 +74,28 @@ func (h *SceneHandler) GenerateSceneImage(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func (h *SceneHandler) UpdateScenePrompt(c *gin.Context) {
|
||||
sceneID := c.Param("scene_id")
|
||||
|
||||
var req services2.UpdateScenePromptRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.sceneService.UpdateScenePrompt(sceneID, &req); err != nil {
|
||||
h.log.Errorw("Failed to update scene prompt", "error", err, "scene_id", sceneID)
|
||||
if err.Error() == "scene not found" {
|
||||
response.NotFound(c, "场景不存在")
|
||||
return
|
||||
}
|
||||
response.InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{"message": "场景提示词已更新"})
|
||||
}
|
||||
|
||||
func (h *SceneHandler) DeleteScene(c *gin.Context) {
|
||||
sceneID := c.Param("scene_id")
|
||||
|
||||
|
||||
@@ -137,6 +137,7 @@ func SetupRouter(cfg *config.Config, db *gorm.DB, log *logger.Logger, localStora
|
||||
scenes := api.Group("/scenes")
|
||||
{
|
||||
scenes.PUT("/:scene_id", sceneHandler.UpdateScene)
|
||||
scenes.PUT("/:scene_id/prompt", sceneHandler.UpdateScenePrompt)
|
||||
scenes.DELETE("/:scene_id", sceneHandler.DeleteScene)
|
||||
scenes.POST("/generate-image", sceneHandler.GenerateSceneImage)
|
||||
}
|
||||
|
||||
@@ -399,6 +399,28 @@ func (s *StoryboardCompositionService) GenerateSceneImage(req *GenerateSceneImag
|
||||
return nil, fmt.Errorf("image generation service not available")
|
||||
}
|
||||
|
||||
type UpdateScenePromptRequest struct {
|
||||
Prompt string `json:"prompt"`
|
||||
}
|
||||
|
||||
func (s *StoryboardCompositionService) UpdateScenePrompt(sceneID string, req *UpdateScenePromptRequest) error {
|
||||
var scene models.Scene
|
||||
if err := s.db.Where("id = ?", sceneID).First(&scene).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return fmt.Errorf("scene not found")
|
||||
}
|
||||
return fmt.Errorf("failed to find scene: %w", err)
|
||||
}
|
||||
|
||||
scene.Prompt = req.Prompt
|
||||
if err := s.db.Save(&scene).Error; err != nil {
|
||||
return fmt.Errorf("failed to update scene prompt: %w", err)
|
||||
}
|
||||
|
||||
s.log.Infow("Scene prompt updated", "scene_id", sceneID, "prompt", req.Prompt)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *StoryboardCompositionService) DeleteScene(sceneID string) error {
|
||||
var scene models.Scene
|
||||
if err := s.db.Where("id = ?", sceneID).First(&scene).Error; err != nil {
|
||||
|
||||
@@ -107,7 +107,7 @@ func NewGeminiImageClient(baseURL, apiKey, model, endpoint string) *GeminiImageC
|
||||
|
||||
func (c *GeminiImageClient) GenerateImage(prompt string, opts ...ImageOption) (*ImageResult, error) {
|
||||
options := &ImageOptions{
|
||||
Size: "1024x1024",
|
||||
Size: "1920x1920",
|
||||
Quality: "standard",
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ func NewVolcEngineImageClient(baseURL, apiKey, model, endpoint, queryEndpoint st
|
||||
|
||||
func (c *VolcEngineImageClient) GenerateImage(prompt string, opts ...ImageOption) (*ImageResult, error) {
|
||||
options := &ImageOptions{
|
||||
Size: "1024x1024",
|
||||
Size: "1920x1920",
|
||||
Quality: "standard",
|
||||
}
|
||||
|
||||
|
||||
@@ -108,8 +108,12 @@ export const dramaAPI = {
|
||||
return request.put(`/scenes/${sceneId}`, data)
|
||||
},
|
||||
|
||||
generateSceneImage(data: { scene_id: string; prompt?: string; model?: string }) {
|
||||
return request.post('/scenes/generate-image', data)
|
||||
generateSceneImage(data: { scene_id: number; prompt?: string; model?: string }) {
|
||||
return request.post<{ image_generation: { id: number } }>('/scenes/generate-image', data)
|
||||
},
|
||||
|
||||
updateScenePrompt(sceneId: string, prompt: string) {
|
||||
return request.put(`/scenes/${sceneId}/prompt`, { prompt })
|
||||
},
|
||||
|
||||
deleteScene(sceneId: string) {
|
||||
|
||||
@@ -68,7 +68,9 @@ export default {
|
||||
tip: '提示',
|
||||
status: '状态',
|
||||
createdAt: '创建时间',
|
||||
updatedAt: '更新时间'
|
||||
updatedAt: '更新时间',
|
||||
name: '名称',
|
||||
description: '描述'
|
||||
},
|
||||
settings: {
|
||||
title: '设置',
|
||||
|
||||
@@ -241,10 +241,10 @@
|
||||
<el-dialog v-model="addSceneDialogVisible" :title="$t('common.add')" width="600px">
|
||||
<el-form :model="newScene" label-width="100px">
|
||||
<el-form-item :label="$t('common.name')">
|
||||
<el-input v-model="newScene.name" :placeholder="$t('common.name')" />
|
||||
<el-input v-model="newScene.location" :placeholder="$t('common.name')" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('common.description')">
|
||||
<el-input v-model="newScene.description" type="textarea" :rows="4" :placeholder="$t('common.description')" />
|
||||
<el-input v-model="newScene.prompt" type="textarea" :rows="4" :placeholder="$t('common.description')" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
@@ -285,8 +285,8 @@ const newCharacter = ref({
|
||||
})
|
||||
|
||||
const newScene = ref({
|
||||
name: '',
|
||||
description: ''
|
||||
location: '',
|
||||
prompt: ''
|
||||
})
|
||||
|
||||
const episodesCount = computed(() => drama.value?.episodes?.length || 0)
|
||||
@@ -494,14 +494,14 @@ const deleteCharacter = async (character: any) => {
|
||||
|
||||
const openAddSceneDialog = () => {
|
||||
newScene.value = {
|
||||
name: '',
|
||||
description: ''
|
||||
location: '',
|
||||
prompt: ''
|
||||
}
|
||||
addSceneDialogVisible.value = true
|
||||
}
|
||||
|
||||
const addScene = async () => {
|
||||
if (!newScene.value.name.trim()) {
|
||||
if (!newScene.value.location.trim()) {
|
||||
ElMessage.warning('请输入场景名称')
|
||||
return
|
||||
}
|
||||
@@ -517,7 +517,11 @@ const addScene = async () => {
|
||||
}
|
||||
|
||||
const editScene = (scene: any) => {
|
||||
ElMessage.info('编辑功能开发中')
|
||||
newScene.value = {
|
||||
location: scene.location || scene.name || '',
|
||||
prompt: scene.prompt || scene.description || ''
|
||||
}
|
||||
addSceneDialogVisible.value = true
|
||||
}
|
||||
|
||||
const deleteScene = async (scene: any) => {
|
||||
|
||||
@@ -1601,8 +1601,9 @@ const saveShotEdit = async () => {
|
||||
// 对话框相关方法
|
||||
const openPromptDialog = (item: any, type: 'character' | 'scene') => {
|
||||
currentEditItem.value = item
|
||||
currentEditItem.value.name = item.name || item.location
|
||||
currentEditType.value = type
|
||||
editPrompt.value = item.appearance || item.description || ''
|
||||
editPrompt.value = item.prompt || item.appearance || item.description || ''
|
||||
promptDialogVisible.value = true
|
||||
}
|
||||
|
||||
@@ -1614,10 +1615,32 @@ const savePrompt = async () => {
|
||||
})
|
||||
await generateCharacterImage(currentEditItem.value.id)
|
||||
} else {
|
||||
await dramaAPI.generateSceneImage({
|
||||
// 1. 先保存场景提示词
|
||||
await dramaAPI.updateScenePrompt(currentEditItem.value.id.toString(), editPrompt.value)
|
||||
|
||||
// 2. 生成场景图片
|
||||
const model = selectedImageModel.value || undefined
|
||||
const response = await dramaAPI.generateSceneImage({
|
||||
scene_id: parseInt(currentEditItem.value.id),
|
||||
prompt: editPrompt.value
|
||||
prompt: editPrompt.value,
|
||||
model
|
||||
})
|
||||
const imageGenId = response.image_generation?.id
|
||||
|
||||
// 3. 轮询图片生成状态
|
||||
if (imageGenId) {
|
||||
ElMessage.info('场景图片生成中,请稍候...')
|
||||
generatingSceneImages.value[currentEditItem.value.id] = true
|
||||
pollImageStatus(imageGenId, async () => {
|
||||
await loadDramaData()
|
||||
ElMessage.success('场景图片生成完成!')
|
||||
}).finally(() => {
|
||||
generatingSceneImages.value[currentEditItem.value.id] = false
|
||||
})
|
||||
} else {
|
||||
ElMessage.success('场景图片生成已启动')
|
||||
await loadDramaData()
|
||||
}
|
||||
}
|
||||
promptDialogVisible.value = false
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -360,6 +360,32 @@
|
||||
<!-- 首帧 -->
|
||||
<div v-show="selectedVideoFrameType === 'first'" class="image-scroll-container"
|
||||
style="max-height: 280px; overflow-y: auto; overflow-x: hidden;">
|
||||
|
||||
<!-- 上一镜头尾帧推荐(紧凑版) -->
|
||||
<div v-if="previousStoryboardLastFrames.length > 0" class="previous-frame-section">
|
||||
<div style="display: flex; align-items: center; gap: 6px; margin-bottom: 6px;">
|
||||
<el-tag size="small" type="primary">
|
||||
上一镜头 #{{ previousStoryboard?.storyboard_number }} 尾帧
|
||||
</el-tag>
|
||||
<span class="hint-text">点击添加为首帧参考</span>
|
||||
</div>
|
||||
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
|
||||
<div v-for="img in previousStoryboardLastFrames" :key="'prev-' + img.id"
|
||||
class="reference-item"
|
||||
:class="{ selected: selectedImagesForVideo.includes(img.id) }"
|
||||
style="position: relative; border: 2px solid #1890ff; border-radius: 4px; overflow: hidden; cursor: pointer;"
|
||||
@click="selectPreviousLastFrame(img)">
|
||||
<el-image :src="img.image_url" fit="cover"
|
||||
style="width: 60px; height: 40px; display: block; pointer-events: none;" />
|
||||
<div v-if="selectedImagesForVideo.includes(img.id)"
|
||||
style="position: absolute; top: 0; right: 0; background: #52c41a; color: #fff; font-size: 10px; padding: 1px 4px;">
|
||||
✓
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 当前镜头首帧列表 -->
|
||||
<div class="reference-grid"
|
||||
style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; max-width: 600px;">
|
||||
<div
|
||||
@@ -378,7 +404,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<el-empty
|
||||
v-if="!videoReferenceImages.some(i => i.status === 'completed' && i.image_url && i.frame_type === 'first')"
|
||||
v-if="!videoReferenceImages.some(i => i.status === 'completed' && i.image_url && i.frame_type === 'first') && previousStoryboardLastFrames.length === 0"
|
||||
description="暂无首帧图片" size="small" />
|
||||
</div>
|
||||
|
||||
@@ -883,7 +909,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
ArrowLeft, Plus, Picture, VideoPlay, VideoPause, View, Setting,
|
||||
Upload, MagicStick, VideoCamera, ZoomIn, ZoomOut, Top, Bottom, Check, Close, Right,
|
||||
Timer, Calendar, Clock, Loading, WarningFilled, Delete
|
||||
Timer, Calendar, Clock, Loading, WarningFilled, Delete, Connection
|
||||
} from '@element-plus/icons-vue'
|
||||
import { dramaAPI } from '@/api/drama'
|
||||
import { generateFramePrompt, type FrameType } from '@/api/frame'
|
||||
@@ -1226,6 +1252,67 @@ const currentStoryboard = computed(() => {
|
||||
return storyboards.value.find(s => String(s.id) === String(currentStoryboardId.value)) || null
|
||||
})
|
||||
|
||||
// 获取上一个镜头
|
||||
const previousStoryboard = computed(() => {
|
||||
if (!currentStoryboardId.value || storyboards.value.length < 2) return null
|
||||
const currentIndex = storyboards.value.findIndex(s => String(s.id) === String(currentStoryboardId.value))
|
||||
if (currentIndex <= 0) return null
|
||||
return storyboards.value[currentIndex - 1]
|
||||
})
|
||||
|
||||
// 上一个镜头的尾帧图片列表(支持多个)
|
||||
const previousStoryboardLastFrames = ref<any[]>([])
|
||||
|
||||
// 加载上一个镜头的尾帧
|
||||
const loadPreviousStoryboardLastFrame = async () => {
|
||||
if (!previousStoryboard.value) {
|
||||
previousStoryboardLastFrames.value = []
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await imageAPI.listImages({
|
||||
storyboard_id: previousStoryboard.value.id,
|
||||
frame_type: 'last',
|
||||
page: 1,
|
||||
page_size: 10
|
||||
})
|
||||
const images = result.items || []
|
||||
previousStoryboardLastFrames.value = images.filter((img: any) => img.status === 'completed' && img.image_url)
|
||||
} catch (error) {
|
||||
console.error('加载上一镜头尾帧失败:', error)
|
||||
previousStoryboardLastFrames.value = []
|
||||
}
|
||||
}
|
||||
|
||||
// 选择上一镜头尾帧作为首帧参考
|
||||
const selectPreviousLastFrame = (img: any) => {
|
||||
// 检查是否已选中,已选中则取消
|
||||
const currentIndex = selectedImagesForVideo.value.indexOf(img.id)
|
||||
if (currentIndex > -1) {
|
||||
selectedImagesForVideo.value.splice(currentIndex, 1)
|
||||
ElMessage.success('已取消首帧参考')
|
||||
return
|
||||
}
|
||||
|
||||
// 参考handleImageSelect的逻辑,根据模式处理
|
||||
if (!selectedReferenceMode.value || selectedReferenceMode.value === 'single') {
|
||||
// 单图模式或未选模式:直接替换
|
||||
selectedImagesForVideo.value = [img.id]
|
||||
} else if (selectedReferenceMode.value === 'first_last') {
|
||||
// 首尾帧模式:作为首帧参考
|
||||
selectedImagesForVideo.value = [img.id]
|
||||
} else if (selectedReferenceMode.value === 'multiple') {
|
||||
// 多图模式:添加到列表
|
||||
const capability = currentModelCapability.value
|
||||
if (capability && selectedImagesForVideo.value.length >= capability.maxImages) {
|
||||
ElMessage.warning(`最多只能选择${capability.maxImages}张图片`)
|
||||
return
|
||||
}
|
||||
selectedImagesForVideo.value.push(img.id)
|
||||
}
|
||||
ElMessage.success('已添加为首帧参考')
|
||||
}
|
||||
|
||||
// 监听帧类型切换,从存储中加载或清空
|
||||
watch(selectedFrameType, (newType) => {
|
||||
// 切换帧类型时,停止之前的轮询,避免旧结果覆盖新帧类型
|
||||
@@ -1269,6 +1356,7 @@ watch(currentStoryboard, async (newStoryboard) => {
|
||||
generatedImages.value = []
|
||||
generatedVideos.value = []
|
||||
videoReferenceImages.value = []
|
||||
previousStoryboardLastFrames.value = []
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1297,6 +1385,9 @@ watch(currentStoryboard, async (newStoryboard) => {
|
||||
|
||||
// 加载该分镜的视频列表
|
||||
await loadStoryboardVideos(newStoryboard.id)
|
||||
|
||||
// 加载上一镜头的尾帧
|
||||
await loadPreviousStoryboardLastFrame()
|
||||
})
|
||||
|
||||
// 监听提示词变化,自动保存到sessionStorage
|
||||
@@ -1801,13 +1892,17 @@ const selectedImageObjects = computed(() => {
|
||||
const firstFrameSlotImage = computed(() => {
|
||||
if (selectedImagesForVideo.value.length === 0) return null
|
||||
const firstImageId = selectedImagesForVideo.value[0]
|
||||
return videoReferenceImages.value.find(img => img.id === firstImageId)
|
||||
// 同时搜索当前镜头图片和上一镜头尾帧
|
||||
return videoReferenceImages.value.find(img => img.id === firstImageId)
|
||||
|| previousStoryboardLastFrames.value.find(img => img.id === firstImageId)
|
||||
})
|
||||
|
||||
// 首尾帧模式:获取尾帧图片
|
||||
const lastFrameSlotImage = computed(() => {
|
||||
if (!selectedLastImageForVideo.value) return null
|
||||
// 同时搜索当前镜头图片和上一镜头尾帧
|
||||
return videoReferenceImages.value.find(img => img.id === selectedLastImageForVideo.value)
|
||||
|| previousStoryboardLastFrames.value.find(img => img.id === selectedLastImageForVideo.value)
|
||||
})
|
||||
|
||||
// 移除已选择的图片
|
||||
@@ -1846,7 +1941,9 @@ const generateVideo = async () => {
|
||||
// 获取第一张选中的图片(仅在需要图片的模式下)
|
||||
let selectedImage = null
|
||||
if (selectedReferenceMode.value !== 'none' && selectedImagesForVideo.value.length > 0) {
|
||||
// 同时搜索当前镜头图片和上一镜头尾帧
|
||||
selectedImage = videoReferenceImages.value.find(img => img.id === selectedImagesForVideo.value[0])
|
||||
|| previousStoryboardLastFrames.value.find(img => img.id === selectedImagesForVideo.value[0])
|
||||
if (!selectedImage || !selectedImage.image_url) {
|
||||
ElMessage.error('请选择有效的参考图片')
|
||||
return
|
||||
@@ -1878,9 +1975,11 @@ const generateVideo = async () => {
|
||||
break
|
||||
|
||||
case 'first_last':
|
||||
// 首尾帧模式
|
||||
// 首尾帧模式(同时搜索当前镜头图片和上一镜头尾帧)
|
||||
const firstImage = videoReferenceImages.value.find(img => img.id === selectedImagesForVideo.value[0])
|
||||
|| previousStoryboardLastFrames.value.find(img => img.id === selectedImagesForVideo.value[0])
|
||||
const lastImage = videoReferenceImages.value.find(img => img.id === selectedLastImageForVideo.value)
|
||||
|| previousStoryboardLastFrames.value.find(img => img.id === selectedLastImageForVideo.value)
|
||||
|
||||
if (firstImage?.image_url) {
|
||||
requestParams.first_frame_url = firstImage.image_url
|
||||
@@ -3947,6 +4046,19 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.previous-frame-section {
|
||||
margin-bottom: 12px;
|
||||
padding: 8px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 6px;
|
||||
|
||||
.hint-text {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.reference-grid {
|
||||
display: grid !important;
|
||||
grid-template-columns: repeat(4, 1fr) !important;
|
||||
|
||||
Reference in New Issue
Block a user