Files
huobao-drama/web/src/views/drama/ProfessionalEditor.vue
2026-01-13 20:45:48 +08:00

4191 lines
132 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="professional-editor">
<!-- 顶部工具栏 -->
<div class="editor-toolbar">
<div class="toolbar-left">
<el-button link @click="goBack" class="back-btn">
<el-icon><ArrowLeft /></el-icon>
返回剧集编辑
</el-button>
<el-divider direction="vertical" />
<span class="episode-title">{{ drama?.title }} - {{ episodeNumber }}</span>
</div>
<div class="toolbar-right">
<el-button :icon="Setting" circle @click="showSettings = true" />
</div>
</div>
<!-- 主编辑区域 -->
<div class="editor-main">
<!-- 左侧分镜列表 -->
<div class="storyboard-panel">
<div class="panel-header">
<h3>剧本结构</h3>
<el-button text :icon="Plus" @click="handleAddStoryboard">添加</el-button>
</div>
<div class="storyboard-list">
<div
v-for="(shot, index) in storyboards"
:key="shot.id"
class="storyboard-item"
:class="{ active: currentStoryboardId === shot.id }"
@click="selectStoryboard(shot.id)"
>
<div class="shot-content">
<div class="shot-header">
<div class="shot-title-row">
<span class="shot-number">镜头 {{ shot.storyboard_number }}</span>
<span class="shot-title">{{ shot.title || '未命名镜头' }}</span>
</div>
<div class="shot-duration">{{ shot.duration }}s</div>
</div>
<div class="shot-action" v-if="shot.action">{{ shot.action }}</div>
</div>
</div>
</div>
</div>
<!-- 中间时间线编辑区域 -->
<div class="timeline-area">
<VideoTimelineEditor
ref="timelineEditorRef"
v-if="storyboards.length > 0"
:scenes="storyboards"
:episode-id="episodeId.toString()"
:drama-id="dramaId.toString()"
:assets="videoAssets"
@select-scene="handleTimelineSelect"
@asset-deleted="loadVideoAssets"
@merge-completed="handleMergeCompleted"
/>
<el-empty v-else description="暂无分镜" class="empty-timeline" />
</div>
<!-- 右侧编辑面板 -->
<div class="edit-panel">
<el-tabs v-model="activeTab" class="edit-tabs">
<!-- 镜头属性标签 -->
<el-tab-pane label="镜头属性" name="shot" v-if="currentStoryboard">
<div v-if="currentStoryboard" class="shot-editor-new">
<!-- 场景(Scene) -->
<div class="scene-section">
<div class="section-label">
场景 (Scene)
<el-button size="small" text @click="showSceneSelector = true">选择场景</el-button>
</div>
<div class="scene-preview" v-if="currentStoryboard.background?.image_url" @click="showSceneImage">
<img :src="currentStoryboard.background.image_url" alt="场景" style="cursor: pointer;" />
<div class="scene-info">
<div>{{ currentStoryboard.background.location }} · {{ currentStoryboard.background.time }}</div>
<div class="scene-id">场景ID: {{ currentStoryboard.scene_id || 'N/A' }}</div>
</div>
</div>
<div class="scene-preview-empty" v-else>
<el-icon :size="48" color="#666"><Picture /></el-icon>
<div>{{ currentStoryboard.background ? '场景图片生成中...' : '未关联背景' }}</div>
</div>
</div>
<!-- 登场角色(Cast) -->
<div class="cast-section">
<div class="section-label">
登场角色 (Cast)
<el-button size="small" text :icon="Plus" @click="showCharacterSelector = true">添加角色</el-button>
</div>
<div class="cast-list">
<div
v-for="char in currentStoryboardCharacters"
:key="char.id"
class="cast-item active"
>
<div class="cast-avatar" @click="showCharacterImage(char)">
<img v-if="char.image_url" :src="char.image_url" :alt="char.name" />
<span v-else>{{ char.name?.[0] || '?' }}</span>
</div>
<div class="cast-name">{{ char.name }}</div>
<div class="cast-remove" @click.stop="toggleCharacterInShot(char.id)" title="移除角色">
<el-icon :size="14"><Close /></el-icon>
</div>
</div>
<div v-if="!currentStoryboard?.characters || currentStoryboard.characters.length === 0" class="cast-empty">
未指定角色
</div>
</div>
</div>
<!-- 视效设置 -->
<div class="settings-section">
<div class="section-label">视效设置</div>
<div class="settings-grid">
<div class="setting-item">
<label>景别</label>
<el-select v-model="currentStoryboard.shot_type" size="small" placeholder="选择景别" @change="saveStoryboardField('shot_type')">
<el-option label="大远景" value="大远景" />
<el-option label="远景" value="远景" />
<el-option label="全景" value="全景" />
<el-option label="中全景" value="中全景" />
<el-option label="中景" value="中景" />
<el-option label="中近景" value="中近景" />
<el-option label="近景" value="近景" />
<el-option label="特写" value="特写" />
<el-option label="大特写" value="大特写" />
</el-select>
</div>
<div class="setting-item">
<label>运镜方式</label>
<el-select v-model="currentStoryboard.movement" size="small" placeholder="运镜方式" @change="saveStoryboardField('movement')">
<el-option label="固定镜头" value="固定镜头" />
<el-option label="推镜" value="推镜" />
<el-option label="拉镜" value="拉镜" />
<el-option label="摇镜" value="摇镜" />
<el-option label="移镜" value="移镜" />
<el-option label="跟镜" value="跟镜" />
<el-option label="升降镜头" value="升降镜头" />
<el-option label="环绕" value="环绕" />
<el-option label="甩镜" value="甩镜" />
<el-option label="变焦" value="变焦" />
<el-option label="手持晃动" value="手持晃动" />
<el-option label="稳定器运动" value="稳定器运动" />
<el-option label="轨道推拉" value="轨道推拉" />
<el-option label="航拍" value="航拍" />
</el-select>
</div>
<div class="setting-item">
<label>镜头角度</label>
<el-select v-model="currentStoryboard.angle" size="small" placeholder="镜头角度" @change="saveStoryboardField('angle')">
<el-option label="平视" value="平视" />
<el-option label="俯视" value="俯视" />
<el-option label="仰视" value="仰视" />
<el-option label="大俯视(鸟瞰)" value="大俯视(鸟瞰)" />
<el-option label="大仰视" value="大仰视" />
<el-option label="正侧面" value="正侧面" />
<el-option label="斜侧面" value="斜侧面" />
<el-option label="背面" value="背面" />
<el-option label="倾斜(荷兰角)" value="倾斜(荷兰角)" />
<el-option label="主观视角" value="主观视角" />
<el-option label="过肩" value="过肩" />
</el-select>
</div>
</div>
</div>
<!-- 叙事内容 -->
<div class="narrative-section">
<div class="section-label">动作描述 (Action)</div>
<el-input
v-model="currentStoryboard.action"
type="textarea"
:rows="3"
placeholder="描述角色的动作过程..."
/>
</div>
<div class="narrative-section">
<div class="section-label">动作结果 (Result)</div>
<el-input
v-model="currentStoryboard.result"
type="textarea"
:rows="2"
placeholder="描述动作完成后的状态..."
/>
</div>
<div class="dialogue-section">
<div class="section-label">对白 (Dialogue)</div>
<el-input
v-model="currentStoryboard.dialogue"
type="textarea"
:rows="3"
placeholder="角色对白内容..."
/>
</div>
<div class="narrative-section">
<div class="section-label">镜头描述 (Description)</div>
<el-input
v-model="currentStoryboard.description"
type="textarea"
:rows="3"
placeholder="整体镜头描述..."
/>
</div>
<!-- 音效设置 -->
<div class="settings-section">
<div class="section-label">音效</div>
<div class="audio-controls">
<el-input
v-model="currentStoryboard.sound_effect"
placeholder="描述音效,如:脚步声、关门声等"
size="small"
type="textarea"
:rows="2"
/>
</div>
</div>
<!-- 配乐设置 -->
<div class="settings-section">
<div class="section-label">配乐提示</div>
<div class="audio-controls">
<el-input
v-model="currentStoryboard.bgm_prompt"
placeholder="描述配乐氛围,如:紧张激烈的背景音乐"
size="small"
type="textarea"
:rows="2"
/>
</div>
</div>
<!-- 氛围设置 -->
<div class="settings-section">
<div class="section-label">环境氛围</div>
<div class="audio-controls">
<el-input
v-model="currentStoryboard.atmosphere"
placeholder="描述环境氛围,如:昏暗压抑、明亮温馨"
size="small"
type="textarea"
:rows="2"
/>
</div>
</div>
</div>
<el-empty v-else description="未选择镜头" />
</el-tab-pane>
<!-- 图片生成标签 -->
<el-tab-pane label="镜头图片" name="image">
<div class="tab-content" v-if="currentStoryboard">
<div class="image-generation-section">
<!-- 帧类型选择 -->
<div class="frame-type-selector">
<div class="section-label">选择帧类型</div>
<el-radio-group v-model="selectedFrameType" size="small">
<el-radio-button label="first">首帧</el-radio-button>
<el-radio-button label="last">尾帧</el-radio-button>
<el-radio-button label="panel">分镜板</el-radio-button>
<el-radio-button label="action">动作序列</el-radio-button>
<el-radio-button label="key">关键帧</el-radio-button>
</el-radio-group>
<el-input-number
v-if="selectedFrameType === 'panel'"
v-model="panelCount"
:min="2"
:max="6"
size="small"
class="panel-count-input"
style="margin-left: 10px; margin-top: 12px;"
/>
<span v-if="selectedFrameType === 'panel'" style="margin-left: 5px; font-size: 12px; color: #999;">格数</span>
</div>
<!-- 提示词区域 -->
<div class="prompt-section">
<div class="section-label">
提示词
<el-button
size="small"
type="primary"
:disabled="generatingPrompt"
:loading="generatingPrompt"
@click="extractFramePrompt"
style="margin-left: 10px;"
>
提取提示词
</el-button>
</div>
<el-input
v-model="currentFramePrompt"
type="textarea"
:rows="8"
placeholder="点击提取提示词按钮,系统将根据分镜内容生成图片提示词..."
/>
</div>
<!-- 生成控制 -->
<div class="generation-controls">
<el-button
type="success"
:icon="MagicStick"
:loading="generatingImage"
:disabled="!currentFramePrompt"
@click="generateFrameImage"
>
{{ generatingImage ? '生成中...' : '生成图片' }}
</el-button>
<el-button :icon="Upload" @click="uploadImage">上传图片</el-button>
</div>
<!-- 生成结果 -->
<div class="generation-result" v-if="generatedImages.length > 0">
<div class="section-label">生成结果 ({{ generatedImages.length }})</div>
<div class="image-grid">
<div
v-for="img in generatedImages"
:key="img.id"
class="image-item"
>
<el-image
v-if="img.image_url"
:src="img.image_url"
:preview-src-list="generatedImages.filter(i => i.image_url).map(i => i.image_url!)"
:initial-index="generatedImages.filter(i => i.image_url).findIndex(i => i.id === img.id)"
fit="cover"
preview-teleported
/>
<div v-else class="image-placeholder">
<el-icon :size="32"><Picture /></el-icon>
<p>生成中...</p>
</div>
<div class="image-info">
<el-tag :type="getStatusType(img.status)" size="small">{{ getStatusText(img.status) }}</el-tag>
<span v-if="img.frame_type" class="frame-type-tag">{{ getFrameTypeText(img.frame_type) }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
<el-empty v-else description="未选择镜头" />
</el-tab-pane>
<!-- 视频生成标签 -->
<el-tab-pane label="视频生成" name="video">
<div class="tab-content" v-if="currentStoryboard">
<div class="video-generation-section">
<!-- 生成提示词展示 -->
<div style="margin-bottom: 12px; padding: 10px; background: #f5f7fa; border-radius: 6px; border: 1px solid #e4e7ed; font-size: 12px; line-height: 1.6; color: #606266; word-break: break-word; max-height: 120px; overflow-y: auto;">
{{ currentStoryboard.video_prompt || '暂无提示词' }}
</div>
<!-- 视频参数设置 -->
<div class="video-params-section">
<div style="margin-bottom: 12px; display: flex; align-items: center; gap: 12px;">
<span style="min-width: 60px; font-size: 14px; color: #606266;">模型</span>
<el-select v-model="selectedVideoModel" placeholder="请选择视频生成模型" size="default" style="flex: 1;">
<el-option
v-for="model in videoModelCapabilities"
:key="model.id"
:label="model.name"
:value="model.id"
>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span>{{ model.name }}</span>
<div style="font-size: 12px; color: #909399;">
<el-tag v-if="model.supportMultipleImages" size="small" type="success" style="margin-left: 4px;">多图</el-tag>
<el-tag v-if="model.supportFirstLastFrame" size="small" type="primary" style="margin-left: 4px;">首尾帧</el-tag>
<el-tag size="small" type="info" style="margin-left: 4px;">最多{{ model.maxImages }}张</el-tag>
</div>
</div>
</el-option>
</el-select>
</div>
<!-- 参考图模式选择 -->
<div v-if="selectedVideoModel && availableReferenceModes.length > 0" style="margin-bottom: 12px; display: flex; align-items: center; gap: 12px;">
<span style="min-width: 60px; font-size: 14px; color: #606266;">参考图</span>
<el-select v-model="selectedReferenceMode" placeholder="请选择参考图模式" size="default" style="flex: 1;">
<el-option
v-for="mode in availableReferenceModes"
:key="mode.value"
:label="mode.label"
:value="mode.value"
>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span>{{ mode.label }}</span>
<span v-if="mode.description" style="font-size: 12px; color: #909399;">{{ mode.description }}</span>
</div>
</el-option>
</el-select>
</div>
<div style="margin-bottom: 8px; display: flex; align-items: center; gap: 12px;">
<span style="min-width: 60px; font-size: 14px; color: #606266;">时长</span>
<div style="flex: 1; display: flex; align-items: center;">
<el-slider v-model="videoDuration" :min="4" :max="10" :step="1" show-stops style="flex: 1;" />
<span style="margin-left: 10px; min-width: 40px;">{{ videoDuration }}秒</span>
</div>
</div>
</div>
<!-- 选择参考图片 -->
<div v-if="selectedReferenceMode && selectedReferenceMode !== 'none'" class="reference-images-section" style="margin-top: 0;">
<div class="frame-type-buttons" style="text-align: center; margin-bottom: 8px;">
<el-radio-group v-model="selectedVideoFrameType" size="default">
<el-radio-button label="first">首帧</el-radio-button>
<el-radio-button label="last">尾帧</el-radio-button>
<el-radio-button label="panel">分镜板</el-radio-button>
<el-radio-button label="action">动作序列</el-radio-button>
<el-radio-button label="key">关键帧</el-radio-button>
</el-radio-group>
</div>
<div class="frame-type-content">
<!-- 首帧 -->
<div v-show="selectedVideoFrameType === 'first'" class="image-scroll-container" style="max-height: 280px; overflow-y: auto; overflow-x: hidden;">
<div class="reference-grid" style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; max-width: 600px;">
<div
v-for="img in videoReferenceImages.filter(i => i.status === 'completed' && i.image_url && i.frame_type === 'first')"
:key="img.id"
class="reference-item"
:class="{ selected: selectedImagesForVideo.includes(img.id) }"
style="position: relative;"
@click="handleImageSelect(img.id)"
>
<el-image :src="img.image_url" fit="cover" style="max-width: 120px; width: 100%; display: block; pointer-events: none;" />
<div class="preview-icon" @click.stop="previewImage(img.image_url)" style="position: absolute; top: 4px; right: 4px; width: 24px; height: 24px; background: rgba(0,0,0,0.6); border-radius: 4px; display: flex; align-items: center; justify-content: center; cursor: pointer; z-index: 10;">
<el-icon :size="14" color="#fff"><ZoomIn /></el-icon>
</div>
</div>
</div>
<el-empty v-if="!videoReferenceImages.some(i => i.status === 'completed' && i.image_url && i.frame_type === 'first')"
description="暂无首帧图片" size="small" />
</div>
<!-- 关键帧 -->
<div v-show="selectedVideoFrameType === 'key'" class="image-scroll-container" style="max-height: 280px; overflow-y: auto; overflow-x: hidden;">
<div class="reference-grid" style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; max-width: 600px;">
<div
v-for="img in videoReferenceImages.filter(i => i.status === 'completed' && i.image_url && i.frame_type === 'key')"
:key="img.id"
class="reference-item"
:class="{ selected: selectedImagesForVideo.includes(img.id) }"
style="position: relative;"
@click="handleImageSelect(img.id)"
>
<el-image :src="img.image_url" fit="cover" style="max-width: 120px; width: 100%; display: block; pointer-events: none;" />
<div class="preview-icon" @click.stop="previewImage(img.image_url)" style="position: absolute; top: 4px; right: 4px; width: 24px; height: 24px; background: rgba(0,0,0,0.6); border-radius: 4px; display: flex; align-items: center; justify-content: center; cursor: pointer; z-index: 10;">
<el-icon :size="14" color="#fff"><ZoomIn /></el-icon>
</div>
</div>
</div>
<el-empty v-if="!videoReferenceImages.some(i => i.status === 'completed' && i.image_url && i.frame_type === 'key')"
description="暂无关键帧图片" size="small" />
</div>
<!-- 尾帧 -->
<div v-show="selectedVideoFrameType === 'last'" class="image-scroll-container" style="max-height: 280px; overflow-y: auto; overflow-x: hidden;">
<div class="reference-grid" style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; max-width: 600px;">
<div
v-for="img in videoReferenceImages.filter(i => i.status === 'completed' && i.image_url && i.frame_type === 'last')"
:key="img.id"
class="reference-item"
:class="{ selected: selectedImagesForVideo.includes(img.id) }"
style="position: relative;"
@click="handleImageSelect(img.id)"
>
<el-image :src="img.image_url" fit="cover" style="max-width: 120px; width: 100%; display: block; pointer-events: none;" />
<div class="preview-icon" @click.stop="previewImage(img.image_url)" style="position: absolute; top: 4px; right: 4px; width: 24px; height: 24px; background: rgba(0,0,0,0.6); border-radius: 4px; display: flex; align-items: center; justify-content: center; cursor: pointer; z-index: 10;">
<el-icon :size="14" color="#fff"><ZoomIn /></el-icon>
</div>
</div>
</div>
<el-empty v-if="!videoReferenceImages.some(i => i.status === 'completed' && i.image_url && i.frame_type === 'last')"
description="暂无尾帧图片" size="small" />
</div>
<!-- 分镜板 -->
<div v-show="selectedVideoFrameType === 'panel'" class="image-scroll-container" style="max-height: 280px; overflow-y: auto; overflow-x: hidden;">
<div class="reference-grid" style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; max-width: 600px;">
<div
v-for="img in videoReferenceImages.filter(i => i.status === 'completed' && i.image_url && i.frame_type === 'panel')"
:key="img.id"
class="reference-item"
:class="{ selected: selectedImagesForVideo.includes(img.id) }"
style="position: relative;"
@click="handleImageSelect(img.id)"
>
<el-image :src="img.image_url" fit="cover" style="max-width: 120px; width: 100%; display: block; pointer-events: none;" />
<div class="preview-icon" @click.stop="previewImage(img.image_url)" style="position: absolute; top: 4px; right: 4px; width: 24px; height: 24px; background: rgba(0,0,0,0.6); border-radius: 4px; display: flex; align-items: center; justify-content: center; cursor: pointer; z-index: 10;">
<el-icon :size="14" color="#fff"><ZoomIn /></el-icon>
</div>
</div>
</div>
<el-empty v-if="!videoReferenceImages.some(i => i.status === 'completed' && i.image_url && i.frame_type === 'panel')"
description="暂无分镜板图片" size="small" />
</div>
<!-- 动作序列 -->
<div v-show="selectedVideoFrameType === 'action'" class="image-scroll-container" style="max-height: 280px; overflow-y: auto; overflow-x: hidden;">
<div class="reference-grid" style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; max-width: 600px;">
<div
v-for="img in videoReferenceImages.filter(i => i.status === 'completed' && i.image_url && i.frame_type === 'action')"
:key="img.id"
class="reference-item"
:class="{ selected: selectedImagesForVideo.includes(img.id) }"
style="position: relative;"
@click="handleImageSelect(img.id)"
>
<el-image :src="img.image_url" fit="cover" style="max-width: 120px; width: 100%; display: block; pointer-events: none;" />
<div class="preview-icon" @click.stop="previewImage(img.image_url)" style="position: absolute; top: 4px; right: 4px; width: 24px; height: 24px; background: rgba(0,0,0,0.6); border-radius: 4px; display: flex; align-items: center; justify-content: center; cursor: pointer; z-index: 10;">
<el-icon :size="14" color="#fff"><ZoomIn /></el-icon>
</div>
</div>
</div>
<el-empty v-if="!videoReferenceImages.some(i => i.status === 'completed' && i.image_url && i.frame_type === 'action')"
description="暂无动作序列图片" size="small" />
</div>
</div>
</div>
<!-- 参考图片设置 -->
<div v-if="selectedReferenceMode && selectedReferenceMode !== 'none'" class="reference-config-section" style="margin-top: 24px;">
<!-- 图片框配置区 -->
<div class="image-slots-container" style="margin-top: 16px; margin-bottom: 24px;">
<!-- 单图模式 -->
<div v-if="selectedReferenceMode === 'single'"
style="text-align: center;">
<div style="margin-bottom: 12px; font-size: 13px; color: #606266; font-weight: 500;">单图参考</div>
<div style="display: inline-block;">
<div class="image-slot" style="position: relative; width: 140px; height: 90px; border: 2px dashed #dcdfe6; border-radius: 8px; overflow: hidden; cursor: pointer; background: #fff;" @click="selectedImagesForVideo.length > 0 && removeSelectedImage(selectedImagesForVideo[0])">
<img v-if="selectedImageObjects[0]" :src="selectedImageObjects[0].image_url" alt="" style="width: 100%; height: 100%; object-fit: cover;" />
<div v-else class="image-slot-placeholder">
<el-icon :size="32" color="#c0c4cc"><Plus /></el-icon>
<div style="margin-top: 8px; font-size: 12px; color: #909399;">点击上方选择图片</div>
</div>
<div v-if="selectedImageObjects[0]" class="image-slot-remove">
<el-icon :size="16" color="#fff"><Close /></el-icon>
</div>
</div>
</div>
</div>
<!-- 首尾帧模式 -->
<div v-else-if="selectedReferenceMode === 'first_last'" style="text-align: center;">
<div style="margin-bottom: 12px; font-size: 13px; color: #606266; font-weight: 500;">首尾帧</div>
<div style="display: flex; gap: 20px; justify-content: center; align-items: center;">
<div>
<div style="margin-bottom: 8px; font-size: 12px; color: #909399;">首帧</div>
<div class="image-slot" style="position: relative; width: 140px; height: 90px; border: 2px dashed #dcdfe6; border-radius: 8px; overflow: hidden; cursor: pointer; background: #fff;" @click="firstFrameSlotImage && removeSelectedImage(firstFrameSlotImage.id)">
<img v-if="firstFrameSlotImage" :src="firstFrameSlotImage.image_url" alt="" style="width: 100%; height: 100%; object-fit: cover;" />
<div v-else class="image-slot-placeholder">
<el-icon :size="32" color="#c0c4cc"><Plus /></el-icon>
<div style="margin-top: 8px; font-size: 12px; color: #909399;">选择首帧</div>
</div>
<div v-if="firstFrameSlotImage" class="image-slot-remove">
<el-icon :size="16" color="#fff"><Close /></el-icon>
</div>
</div>
</div>
<el-icon :size="24" color="#909399"><Right /></el-icon>
<div>
<div style="margin-bottom: 8px; font-size: 12px; color: #909399;">尾帧</div>
<div class="image-slot" style="position: relative; width: 140px; height: 90px; border: 2px dashed #dcdfe6; border-radius: 8px; overflow: hidden; cursor: pointer; background: #fff;" @click="lastFrameSlotImage && removeSelectedImage(lastFrameSlotImage.id)">
<img v-if="lastFrameSlotImage" :src="lastFrameSlotImage.image_url" alt="" style="width: 100%; height: 100%; object-fit: cover;" />
<div v-else class="image-slot-placeholder">
<el-icon :size="32" color="#c0c4cc"><Plus /></el-icon>
<div style="margin-top: 8px; font-size: 12px; color: #909399;">选择尾帧</div>
</div>
<div v-if="lastFrameSlotImage" class="image-slot-remove">
<el-icon :size="16" color="#fff"><Close /></el-icon>
</div>
</div>
</div>
</div>
</div>
<!-- 多图模式 -->
<div v-else-if="selectedReferenceMode === 'multiple'" style="text-align: center;">
<div style="margin-bottom: 12px; font-size: 13px; color: #606266; font-weight: 500;">
多图参考 ({{ selectedImagesForVideo.length }}/{{ currentModelCapability?.maxImages || 6 }})
</div>
<div style="display: flex; gap: 12px; justify-content: center; flex-wrap: wrap;">
<div v-for="index in (currentModelCapability?.maxImages || 6)" :key="index"
class="image-slot image-slot-small"
style="position: relative; width: 80px; height: 52px; border: 2px dashed #dcdfe6; border-radius: 8px; overflow: hidden; cursor: pointer; background: #fff;"
@click="selectedImageObjects[index - 1] && removeSelectedImage(selectedImageObjects[index - 1].id)">
<img v-if="selectedImageObjects[index - 1]"
:src="selectedImageObjects[index - 1].image_url"
alt=""
style="width: 100%; height: 100%; object-fit: cover;" />
<div v-else class="image-slot-placeholder">
<el-icon :size="20" color="#c0c4cc"><Plus /></el-icon>
<div style="margin-top: 4px; font-size: 10px; color: #909399;">{{ index }}</div>
</div>
<div v-if="selectedImageObjects[index - 1]" class="image-slot-remove">
<el-icon :size="14" color="#fff"><Close /></el-icon>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 生成控制 -->
<div class="generation-controls" style="margin-top: 32px; text-align: center;">
<el-button
type="primary"
:icon="VideoCamera"
:loading="generatingVideo"
:disabled="!selectedVideoModel || (selectedReferenceMode !== 'none' && selectedImagesForVideo.length === 0)"
@click="generateVideo"
>
{{ generatingVideo ? '生成中...' : '生成视频' }}
</el-button>
</div>
<!-- 生成的视频列表 -->
<div class="generation-result" v-if="generatedVideos.length > 0" style="margin-top: 24px;">
<div class="section-label" style="font-size: 13px; font-weight: 600; margin-bottom: 12px; display: flex; align-items: center; gap: 6px;">
<span style="width: 3px; height: 14px; background: linear-gradient(to bottom, #409eff, #66b1ff); border-radius: 2px;"></span>
生成结果 ({{ generatedVideos.length }})
</div>
<div class="image-grid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 10px;">
<div
v-for="video in generatedVideos"
:key="video.id"
class="image-item video-item"
style="position: relative; border-radius: 8px; overflow: hidden; background: #fff; border: 1px solid #e8e8e8; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06); cursor: pointer; transition: all 0.2s ease;"
>
<div class="video-thumbnail" v-if="video.video_url" style="position: relative; width: 100%; aspect-ratio: 16/9; overflow: hidden; cursor: pointer;"
@mouseenter="(e) => e.currentTarget.querySelector('.play-overlay').style.opacity = '1'"
@mouseleave="(e) => e.currentTarget.querySelector('.play-overlay').style.opacity = '0'"
@click="playVideo(video)">
<video
:src="video.video_url"
preload="metadata"
style="width: 100%; height: 100%; object-fit: cover; display: block; pointer-events: none;"
/>
<div class="play-overlay" style="position: absolute; top: 0; left: 0; right: 0; bottom: 0; display: flex; align-items: center; justify-content: center; background: rgba(0, 0, 0, 0.3); opacity: 0; transition: opacity 0.2s;">
<el-icon :size="32" color="#fff" style="filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.3));"><VideoPlay /></el-icon>
</div>
</div>
<div v-else class="image-placeholder" style="width: 100%; aspect-ratio: 16/9; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; background: linear-gradient(135deg, #f5f7fa 0%, #e8ecf0 100%); color: #909399;">
<el-icon :size="32"><VideoCamera /></el-icon>
<p style="margin: 0; font-size: 11px;">生成中...</p>
</div>
<div class="image-info" style="position: absolute; bottom: 0; left: 0; right: 0; padding: 6px 8px; background: linear-gradient(to top, rgba(0, 0, 0, 0.75), rgba(0, 0, 0, 0.2) 70%, transparent); display: flex; justify-content: space-between; align-items: center; gap: 4px;">
<div style="display: flex; align-items: center; gap: 4px;">
<el-tag :type="getStatusType(video.status)" size="small" style="font-size: 10px; height: 20px; padding: 0 6px;">{{ getStatusText(video.status) }}</el-tag>
</div>
<div style="display: flex; gap: 4px;">
<el-button
v-if="video.status === 'completed' && video.video_url"
type="success"
size="small"
:loading="addingToAssets.has(video.id)"
@click.stop="addVideoToAssets(video)"
>
{{ addingToAssets.has(video.id) ? '添加中...' : '添加到素材库' }}
</el-button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<el-empty v-else description="未选择镜头" />
</el-tab-pane>
<!-- 音效与配乐标签 -->
<el-tab-pane label="音效与配乐" name="audio">
<div class="tab-content">
<el-empty description="音效与配乐功能开发中" />
</div>
</el-tab-pane>
<!-- 视频合成列表标签 -->
<el-tab-pane label="视频合成" name="merges">
<div class="tab-content">
<div class="merges-list" v-loading="loadingMerges">
<el-empty v-if="videoMerges.length === 0" description="暂无视频合成记录" :image-size="120">
<template #description>
<div style="color: #909399; font-size: 14px; margin-top: 12px;">
<p style="margin: 0;">还没有合成过视频</p>
<p style="margin: 8px 0 0 0; font-size: 12px;">在时间线编辑器中排列好视频后点击"合成视频"即可</p>
</div>
</template>
</el-empty>
<div v-else class="merge-items">
<div v-for="merge in videoMerges" :key="merge.id" class="merge-item" :class="'merge-status-' + merge.status">
<!-- 状态指示条 -->
<div class="status-indicator"></div>
<!-- 主要内容区域 -->
<div class="merge-content">
<!-- 标题和状态 -->
<div class="merge-header">
<div class="title-section">
<el-icon :size="20" class="title-icon">
<VideoCamera v-if="merge.status === 'completed'" />
<Loading v-else-if="merge.status === 'processing'" class="rotating" />
<WarningFilled v-else-if="merge.status === 'failed'" />
<Clock v-else />
</el-icon>
<h3 class="merge-title">{{ merge.title }}</h3>
</div>
<el-tag
:type="merge.status === 'completed' ? 'success' : merge.status === 'failed' ? 'danger' : 'warning'"
effect="dark"
size="large"
round
>
{{ merge.status === 'pending' ? '等待中' : merge.status === 'processing' ? '合成中' : merge.status === 'completed' ? '已完成' : '失败' }}
</el-tag>
</div>
<!-- 详细信息网格 -->
<div class="merge-details">
<div class="detail-item">
<div class="detail-icon">
<el-icon :size="16"><Timer /></el-icon>
</div>
<div class="detail-content">
<div class="detail-label">视频时长</div>
<div class="detail-value">{{ merge.duration ? `${merge.duration} 秒` : '-' }}</div>
</div>
</div>
<div class="detail-item">
<div class="detail-icon">
<el-icon :size="16"><Calendar /></el-icon>
</div>
<div class="detail-content">
<div class="detail-label">创建时间</div>
<div class="detail-value">{{ formatDateTime(merge.created_at) }}</div>
</div>
</div>
<div class="detail-item" v-if="merge.completed_at">
<div class="detail-icon">
<el-icon :size="16"><Check /></el-icon>
</div>
<div class="detail-content">
<div class="detail-label">完成时间</div>
<div class="detail-value">{{ formatDateTime(merge.completed_at) }}</div>
</div>
</div>
</div>
<!-- 错误提示 -->
<div class="merge-error" v-if="merge.status === 'failed' && merge.error_msg">
<el-alert
type="error"
:closable="false"
show-icon
>
<template #title>
<div style="font-size: 13px; line-height: 1.5;">{{ merge.error_msg }}</div>
</template>
</el-alert>
</div>
<!-- 操作按钮 -->
<div class="merge-actions">
<template v-if="merge.status === 'completed' && merge.merged_url">
<el-button type="primary" :icon="VideoCamera" @click="downloadVideo(merge.merged_url, merge.title)" round>
下载视频
</el-button>
<el-button :icon="View" @click="previewMergedVideo(merge.merged_url)" round>
在线预览
</el-button>
</template>
<el-button
v-if="merge.status === 'failed'"
type="danger"
:icon="Close"
@click="deleteMerge(merge.id)"
plain
round
>
删除记录
</el-button>
</div>
</div>
</div>
</div>
</div>
</div>
</el-tab-pane>
</el-tabs>
</div>
</div>
<!-- 角色选择器对话框 -->
<el-dialog v-model="showCharacterImagePreview" :title="previewCharacter?.name" width="600px">
<div class="character-image-preview" v-if="previewCharacter">
<img v-if="previewCharacter.image_url" :src="previewCharacter.image_url" :alt="previewCharacter.name" />
<el-empty v-else description="暂无图片" />
</div>
<!-- ... -->
</el-dialog>
<!-- 场景大图预览对话框 -->
<el-dialog
v-model="showSceneImagePreview"
:title="currentStoryboard?.background ? `${currentStoryboard.background.location} · ${currentStoryboard.background.time}` : '场景预览'"
width="800px"
>
<div class="scene-image-preview" v-if="currentStoryboard?.background?.image_url">
<img :src="currentStoryboard.background.image_url" alt="场景" />
</div>
</el-dialog>
<!-- 角色选择对话框 -->
<el-dialog v-model="showCharacterSelector" title="添加角色到镜头" width="800px">
<div class="character-selector-grid">
<div
v-for="char in availableCharacters"
:key="char.id"
class="character-card"
:class="{ selected: isCharacterInCurrentShot(char.id) }"
@click="toggleCharacterInShot(char.id)"
>
<div class="character-avatar-large">
<img v-if="char.image_url" :src="char.image_url" :alt="char.name" />
<span v-else>{{ char.name?.[0] || '?' }}</span>
</div>
<div class="character-info">
<div class="character-name">{{ char.name }}</div>
<div class="character-role">{{ char.role || '角色' }}</div>
</div>
<div class="character-check" v-if="isCharacterInCurrentShot(char.id)">
<el-icon color="#409eff" :size="24"><Check /></el-icon>
</div>
</div>
<div v-if="availableCharacters.length === 0" class="empty-characters">
<el-empty description="暂无角色请先在剧集中创建角色" />
</div>
</div>
<template #footer>
<el-button @click="showCharacterSelector = false">关闭</el-button>
</template>
</el-dialog>
<!-- 场景选择对话框 -->
<el-dialog v-model="showSceneSelector" title="选择场景背景" width="800px">
<div class="scene-selector-grid">
<div
v-for="scene in availableScenes"
:key="scene.id"
class="scene-card"
:class="{ selected: currentStoryboard?.scene_id === scene.id }"
@click="selectScene(scene.id)"
>
<div class="scene-image">
<img v-if="scene.image_url" :src="scene.image_url" :alt="scene.location" />
<el-icon v-else :size="48" color="#ccc"><Picture /></el-icon>
</div>
<div class="scene-info">
<div class="scene-location">{{ scene.location }}</div>
<div class="scene-time">{{ scene.time }}</div>
</div>
</div>
<div v-if="availableScenes.length === 0" class="empty-scenes">
<el-empty description="暂无可用场景" />
</div>
</div>
</el-dialog>
<!-- 视频预览对话框 -->
<el-dialog
v-model="showVideoPreview"
title="视频预览"
width="800px"
:close-on-click-modal="true"
destroy-on-close
>
<div class="video-preview-container" v-if="previewVideo">
<video
v-if="previewVideo.video_url"
:src="previewVideo.video_url"
controls
autoplay
style="width: 100%; max-height: 70vh; display: block; background: #000; border-radius: 8px;"
/>
<div v-else style="text-align: center; padding: 40px;">
<el-icon :size="48" color="#ccc"><VideoCamera /></el-icon>
<p style="margin-top: 16px; color: #909399;">视频生成中...</p>
</div>
<div class="video-meta" style="margin-top: 16px; padding: 12px; background: #f5f7fa; border-radius: 8px;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<el-tag :type="getStatusType(previewVideo.status)" size="small">{{ getStatusText(previewVideo.status) }}</el-tag>
<span v-if="previewVideo.duration" style="margin-left: 12px; color: #606266; font-size: 14px;">时长: {{ previewVideo.duration }}秒</span>
</div>
<el-button v-if="previewVideo.video_url" size="small" @click="window.open(previewVideo.video_url, '_blank')">
下载视频
</el-button>
</div>
<div v-if="previewVideo.prompt" style="margin-top: 12px; font-size: 12px; color: #606266; line-height: 1.6;">
<strong>提示词:</strong>{{ previewVideo.prompt }}
</div>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
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
} from '@element-plus/icons-vue'
import { dramaAPI } from '@/api/drama'
import { generateFramePrompt, type FrameType } from '@/api/frame'
import { imageAPI } from '@/api/image'
import { videoAPI } from '@/api/video'
import { aiAPI } from '@/api/ai'
import { assetAPI } from '@/api/asset'
import { videoMergeAPI } from '@/api/videoMerge'
import type { ImageGeneration } from '@/types/image'
import type { VideoGeneration } from '@/types/video'
import type { AIServiceConfig } from '@/types/ai'
import type { Asset } from '@/types/asset'
import type { VideoMerge } from '@/api/videoMerge'
import VideoTimelineEditor from '@/components/editor/VideoTimelineEditor.vue'
import type { Drama, Episode, Storyboard } from '@/types/drama'
const route = useRoute()
const router = useRouter()
const dramaId = Number(route.params.dramaId)
const episodeNumber = Number(route.params.episodeNumber)
const episodeId = ref<number>(0)
const drama = ref<Drama | null>(null)
const episode = ref<Episode | null>(null)
const storyboards = ref<Storyboard[]>([])
const characters = ref<any[]>([])
const availableScenes = ref<any[]>([])
const currentStoryboardId = ref<number | null>(null)
const activeTab = ref('shot')
const showSceneSelector = ref(false)
const showCharacterSelector = ref(false)
const showCharacterImagePreview = ref(false)
const previewCharacter = ref<any>(null)
const showSceneImagePreview = ref(false)
const showSettings = ref(false)
const showVideoPreview = ref(false)
const previewVideo = ref<VideoGeneration | null>(null)
const addingToAssets = ref<Set<number>>(new Set())
const currentPlayState = ref<'playing' | 'paused'>('paused')
const currentTime = ref(0)
const totalDuration = computed(() => {
if (!Array.isArray(storyboards.value)) return 0
return storyboards.value.reduce((sum, s) => sum + (s.duration || 0), 0)
})
const selectedCharacters = ref<number[]>([])
const narrativeTab = ref('shot-prompt')
// 图片生成相关状态
const selectedFrameType = ref<FrameType>('first')
const panelCount = ref(3)
const generatingPrompt = ref(false)
const framePrompts = ref<Record<string, string>>({
key: '',
first: '',
last: '',
panel: ''
})
const currentFramePrompt = ref('')
const generatingImage = ref(false)
const generatedImages = ref<ImageGeneration[]>([])
const isSwitchingFrameType = ref(false) // 标志位:是否正在切换帧类型
const loadingImages = ref(false)
let pollingTimer: any = null
// 视频生成相关状态
const videoDuration = ref(5) // 默认5秒会根据镜头duration自动更新
const selectedVideoFrameType = ref<FrameType>('first')
const selectedImagesForVideo = ref<number[]>([])
const selectedLastImageForVideo = ref<number | null>(null)
const generatingVideo = ref(false)
const generatedVideos = ref<VideoGeneration[]>([])
const videoAssets = ref<Asset[]>([])
const loadingVideos = ref(false)
const timelineEditorRef = ref<InstanceType<typeof VideoTimelineEditor> | null>(null)
const videoReferenceImages = ref<ImageGeneration[]>([])
const selectedVideoModel = ref<string>('')
const selectedReferenceMode = ref<string>('') // 参考图模式single, first_last, multiple, none
const previewImageUrl = ref<string>('') // 预览大图的URL
const videoModelCapabilities = ref<VideoModelCapability[]>([])
let videoPollingTimer: any = null
let mergePollingTimer: any = null // 视频合成列表轮询定时器
// 视频合成列表
const videoMerges = ref<VideoMerge[]>([])
const loadingMerges = ref(false)
// 视频模型能力配置
interface VideoModelCapability {
id: string
name: string
supportMultipleImages: boolean // 支持多张图片
supportFirstLastFrame: boolean // 支持首尾帧
supportSingleImage: boolean // 支持单图
supportTextOnly: boolean // 支持纯文本
maxImages: number // 最多支持几张图片
}
// 模型能力默认配置(作为后备)
const defaultModelCapabilities: Record<string, Omit<VideoModelCapability, 'id' | 'name'>> = {
'kling': {
supportSingleImage: true,
supportMultipleImages: false,
supportFirstLastFrame: false,
supportTextOnly: true,
maxImages: 1
},
'runway': {
supportSingleImage: true,
supportMultipleImages: false,
supportFirstLastFrame: true,
supportTextOnly: true,
maxImages: 2
},
'pika': {
supportSingleImage: true,
supportMultipleImages: true,
supportFirstLastFrame: false,
supportTextOnly: true,
maxImages: 6
},
'doubao-seedance-1-5-pro-251215': {
supportSingleImage: true,
supportMultipleImages: false,
supportFirstLastFrame: true,
supportTextOnly: true,
maxImages: 2
},
'doubao-seedance-1-0-lite-i2v-250428': {
supportSingleImage: true,
supportMultipleImages: true,
supportFirstLastFrame: true,
supportTextOnly: false,
maxImages: 6
},
'doubao-seedance-1-0-lite-t2v-250428': {
supportSingleImage: false,
supportMultipleImages: false,
supportFirstLastFrame: false,
supportTextOnly: true,
maxImages: 0
},
'doubao-seedance-1-0-pro-250528': {
supportSingleImage: true,
supportMultipleImages: false,
supportFirstLastFrame: true,
supportTextOnly: true,
maxImages: 2
},
'doubao-seedance-1-0-pro-fast-251015': {
supportSingleImage: true,
supportMultipleImages: false,
supportFirstLastFrame: false,
supportTextOnly: true,
maxImages: 1
},
'sora-2': {
supportSingleImage: true,
supportMultipleImages: false,
supportFirstLastFrame: false,
supportTextOnly: true,
maxImages: 1
},
'sora-2-pro': {
supportSingleImage: true,
supportMultipleImages: false,
supportFirstLastFrame: true,
supportTextOnly: true,
maxImages: 2
},
'MiniMax-Hailuo-2.3': {
supportSingleImage: true,
supportMultipleImages: false,
supportFirstLastFrame: false,
supportTextOnly: true,
maxImages: 1
},
'MiniMax-Hailuo-2.3-Fast': {
supportSingleImage: true,
supportMultipleImages: false,
supportFirstLastFrame: false,
supportTextOnly: true,
maxImages: 1
},
'MiniMax-Hailuo-02': {
supportSingleImage: true,
supportMultipleImages: false,
supportFirstLastFrame: false,
supportTextOnly: true,
maxImages: 1
}
}
// 从模型名称提取provider
const extractProviderFromModel = (modelName: string): string => {
if (modelName.startsWith('doubao-') || modelName.startsWith('seedance')) {
return 'doubao'
}
if (modelName.startsWith('runway')) {
return 'runway'
}
if (modelName.startsWith('pika')) {
return 'pika'
}
if (modelName.startsWith('MiniMax-') || modelName.toLowerCase().startsWith('minimax') || modelName.startsWith('hailuo')) {
return 'minimax'
}
if (modelName.startsWith('sora')) {
return 'openai'
}
if (modelName.startsWith('kling')) {
return 'kling'
}
// 默认返回doubao
return 'doubao'
}
// 加载视频AI配置
const loadVideoModels = async () => {
try {
const configs = await aiAPI.list('video')
// 只显示启用的配置
const activeConfigs = configs.filter(c => c.is_active)
// 展开模型列表并去重
const allModels = activeConfigs.flatMap(config => {
const models = Array.isArray(config.model) ? config.model : [config.model]
return models.map(modelName => ({
modelName,
configName: config.name,
priority: config.priority || 0
}))
}).sort((a, b) => b.priority - a.priority)
// 按模型名称去重
const modelMap = new Map<string, { configName: string, priority: number }>()
allModels.forEach(model => {
if (!modelMap.has(model.modelName)) {
modelMap.set(model.modelName, { configName: model.configName, priority: model.priority })
}
})
// 构建模型能力列表
videoModelCapabilities.value = Array.from(modelMap.keys()).map(modelName => {
const capability = defaultModelCapabilities[modelName] || {
supportSingleImage: true,
supportMultipleImages: false,
supportFirstLastFrame: false,
supportTextOnly: true,
maxImages: 1
}
return {
id: modelName,
name: modelName,
...capability
}
})
} catch (error: any) {
console.error('加载视频模型配置失败:', error)
ElMessage.error('加载视频模型失败')
}
}
// 加载视频素材库
const loadVideoAssets = async () => {
try {
const result = await assetAPI.listAssets({
drama_id: dramaId.toString(),
episode_id: episodeId.value,
type: 'video',
page: 1,
page_size: 100
})
// 检查数据结构并正确赋值
videoAssets.value = result.items || []
} catch (error: any) {
console.error('加载视频素材库失败:', error)
}
}
// 当前模型能力
const currentModelCapability = computed(() => {
return videoModelCapabilities.value.find(m => m.id === selectedVideoModel.value)
})
// 当前模型支持的参考图模式
const availableReferenceModes = computed(() => {
const capability = currentModelCapability.value
if (!capability) return []
const modes: Array<{value: string, label: string, description?: string}> = []
if (capability.supportTextOnly) {
modes.push({ value: 'none', label: '纯文本', description: '不使用参考图' })
}
if (capability.supportSingleImage) {
modes.push({ value: 'single', label: '单图', description: '使用单张参考图' })
}
if (capability.supportFirstLastFrame) {
modes.push({ value: 'first_last', label: '首尾帧', description: '使用首帧和尾帧' })
}
if (capability.supportMultipleImages) {
modes.push({ value: 'multiple', label: '多图', description: `最多${capability.maxImages}张` })
}
return modes
})
// 帧提示词存储key生成函数
const getPromptStorageKey = (storyboardId: number | undefined, frameType: FrameType) => {
if (!storyboardId) return null
return `frame_prompt_${storyboardId}_${frameType}`
}
const isCharacterSelected = (charId: number) => {
return selectedCharacters.value.includes(charId)
}
const toggleCharacter = (charId: number) => {
const index = selectedCharacters.value.indexOf(charId)
if (index > -1) {
selectedCharacters.value.splice(index, 1)
} else {
selectedCharacters.value.push(charId)
}
}
const currentStoryboard = computed(() => {
if (!currentStoryboardId.value) return null
return storyboards.value.find(s => String(s.id) === String(currentStoryboardId.value)) || null
})
// 监听帧类型切换,从存储中加载或清空
watch(selectedFrameType, (newType) => {
if (!currentStoryboard.value) {
currentFramePrompt.value = ''
generatedImages.value = []
return
}
// 设置切换标志防止watch(currentFramePrompt)错误保存
isSwitchingFrameType.value = true
// 从 framePrompts 对象中加载该帧类型的提示词
currentFramePrompt.value = framePrompts.value[newType] || ''
// 从 sessionStorage 中加载该帧类型之前的提示词如果framePrompts中没有
if (!currentFramePrompt.value) {
const storageKey = `frame_prompt_${currentStoryboard.value.id}_${newType}`
const stored = sessionStorage.getItem(storageKey)
if (stored) {
currentFramePrompt.value = stored
framePrompts.value[newType] = stored
}
}
// 重新加载该帧类型的图片
loadStoryboardImages(currentStoryboard.value.id, newType)
// 重置切换标志
setTimeout(() => {
isSwitchingFrameType.value = false
}, 0)
})
// 监听当前分镜切换,重置提示词
watch(currentStoryboard, async (newStoryboard) => {
if (!newStoryboard) {
currentFramePrompt.value = ''
generatedImages.value = []
generatedVideos.value = []
videoReferenceImages.value = []
return
}
// 设置切换标志
isSwitchingFrameType.value = true
// 加载当前帧类型的提示词
const storageKey = getPromptStorageKey(newStoryboard.id, selectedFrameType.value)
if (storageKey) {
const stored = sessionStorage.getItem(storageKey)
currentFramePrompt.value = stored || ''
} else {
currentFramePrompt.value = ''
}
// 重置切换标志
setTimeout(() => {
isSwitchingFrameType.value = false
}, 0)
// 加载该分镜的图片列表(根据当前选择的帧类型)
await loadStoryboardImages(newStoryboard.id, selectedFrameType.value)
// 加载视频参考图片(所有帧类型)
await loadVideoReferenceImages(newStoryboard.id)
// 加载该分镜的视频列表
await loadStoryboardVideos(newStoryboard.id)
})
// 监听提示词变化自动保存到sessionStorage
watch(currentFramePrompt, (newPrompt) => {
// 如果正在切换帧类型或分镜,不要保存(避免错误保存到新帧类型)
if (isSwitchingFrameType.value) return
if (!currentStoryboard.value) return
const storageKey = getPromptStorageKey(currentStoryboard.value.id, selectedFrameType.value)
if (storageKey) {
if (newPrompt) {
sessionStorage.setItem(storageKey, newPrompt)
} else {
sessionStorage.removeItem(storageKey)
}
}
})
// 监听视频模型切换,清空已选图片和参考图模式
watch(selectedVideoModel, () => {
selectedImagesForVideo.value = []
selectedLastImageForVideo.value = null
selectedReferenceMode.value = ''
})
// 监听镜头切换,自动更新视频时长
watch(currentStoryboard, (newStoryboard) => {
if (newStoryboard?.duration) {
// 如果镜头有duration字段使用镜头的时长
videoDuration.value = Math.round(newStoryboard.duration)
} else {
// 否则使用默认值5秒
videoDuration.value = 5
}
})
// 监听参考图模式切换,清空已选图片
watch(selectedReferenceMode, () => {
selectedImagesForVideo.value = []
selectedLastImageForVideo.value = null
})
// 当前分镜的角色列表
const currentStoryboardCharacters = computed(() => {
if (!currentStoryboard.value?.characters) return []
// currentStoryboard.characters 是角色对象数组
if (Array.isArray(currentStoryboard.value.characters) && currentStoryboard.value.characters.length > 0) {
const firstItem = currentStoryboard.value.characters[0]
// 如果是对象数组包含id和name直接返回
if (typeof firstItem === 'object' && firstItem.id) {
return currentStoryboard.value.characters
}
// 如果是ID数组从characters中查找匹配的角色
if (typeof firstItem === 'number') {
return characters.value.filter(c => currentStoryboard.value.characters.includes(c.id))
}
}
return []
})
// 可选择的角色列表
const availableCharacters = computed(() => {
return characters.value || []
})
// 检查角色是否已在当前镜头中
const isCharacterInCurrentShot = (charId: number) => {
if (!currentStoryboard.value?.characters) return false
if (Array.isArray(currentStoryboard.value.characters) && currentStoryboard.value.characters.length > 0) {
const firstItem = currentStoryboard.value.characters[0]
if (typeof firstItem === 'object' && firstItem.id) {
return currentStoryboard.value.characters.some(c => c.id === charId)
}
if (typeof firstItem === 'number') {
return currentStoryboard.value.characters.includes(charId)
}
}
return false
}
// 切换角色在镜头中的状态
const showCharacterImage = (char: any) => {
previewCharacter.value = char
showCharacterImagePreview.value = true
}
// 展示场景大图
const showSceneImage = () => {
if (currentStoryboard.value?.background?.image_url) {
showSceneImagePreview.value = true
}
}
// 保存分镜字段
const saveStoryboardField = async (fieldName: string) => {
if (!currentStoryboard.value) return
try {
const updateData: any = {}
updateData[fieldName] = currentStoryboard.value[fieldName]
await dramaAPI.updateStoryboard(currentStoryboard.value.id.toString(), updateData)
} catch (error: any) {
ElMessage.error('保存失败: ' + (error.message || '未知错误'))
}
}
// 提取帧提示词
const extractFramePrompt = async () => {
if (!currentStoryboard.value) return
// 记录点击时的帧类型避免切换tab后提示词显示错位
const targetFrameType = selectedFrameType.value
generatingPrompt.value = true
try {
const params: any = { frame_type: targetFrameType }
if (targetFrameType === 'panel') {
params.panel_count = panelCount.value
}
const result = await generateFramePrompt(currentStoryboard.value.id, params)
// 根据记录的帧类型提取prompt确保更新到正确的位置
let extractedPrompt = ''
if (result.single_frame) {
extractedPrompt = result.single_frame.prompt
} else if (result.multi_frame && result.multi_frame.frames) {
// 多帧情况将所有帧的prompt合并
extractedPrompt = result.multi_frame.frames
.map((frame: any, index: number) => `${frame.description}: ${frame.prompt}`)
.join('\n\n')
}
// 只在当前仍然选中该帧类型时才更新显示
if (selectedFrameType.value === targetFrameType) {
currentFramePrompt.value = extractedPrompt
}
// 存储到对应帧类型的提示词中
framePrompts.value[targetFrameType] = extractedPrompt
ElMessage.success(`${getFrameTypeLabel(targetFrameType)}提示词提取成功`)
} catch (error: any) {
ElMessage.error('提取失败: ' + (error.message || '未知错误'))
} finally {
generatingPrompt.value = false
}
}
// 获取帧类型的中文标签
const getFrameTypeLabel = (frameType: string): string => {
const labels: Record<string, string> = {
key: '关键帧',
first: '首帧',
last: '尾帧',
panel: '分镜版'
}
return labels[frameType] || frameType
}
// 加载分镜的图片列表
const loadStoryboardImages = async (storyboardId: number, frameType?: string) => {
loadingImages.value = true
try {
const params: any = {
storyboard_id: storyboardId,
page: 1,
page_size: 50
}
// 如果指定了帧类型,添加过滤
if (frameType) {
params.frame_type = frameType
}
const result = await imageAPI.listImages(params)
generatedImages.value = result.items || []
// 如果有进行中的任务,启动轮询
const hasPendingOrProcessing = generatedImages.value.some(
img => img.status === 'pending' || img.status === 'processing'
)
if (hasPendingOrProcessing) {
startPolling()
}
} catch (error: any) {
console.error('加载图片列表失败:', error)
} finally {
loadingImages.value = false
}
}
// 启动状态轮询
const startPolling = () => {
if (pollingTimer) return
pollingTimer = setInterval(async () => {
if (!currentStoryboard.value) {
stopPolling()
return
}
try {
const params: any = {
storyboard_id: currentStoryboard.value.id,
page: 1,
page_size: 50
}
// 根据当前选择的帧类型过滤
if (selectedFrameType.value) {
params.frame_type = selectedFrameType.value
}
const result = await imageAPI.listImages(params)
generatedImages.value = result.items || []
// 如果没有进行中的任务,停止轮询
const hasPendingOrProcessing = generatedImages.value.some(
img => img.status === 'pending' || img.status === 'processing'
)
if (!hasPendingOrProcessing) {
stopPolling()
}
} catch (error) {
console.error('轮询图片状态失败:', error)
}
}, 3000) // 每3秒轮询一次
}
// 停止轮询
const stopPolling = () => {
if (pollingTimer) {
clearInterval(pollingTimer)
pollingTimer = null
}
}
// 生成图片
const generateFrameImage = async () => {
if (!currentStoryboard.value || !currentFramePrompt.value) return
generatingImage.value = true
try {
// 收集参考图片URL
const referenceImages: string[] = []
// 1. 添加场景图片从background字段获取
if (currentStoryboard.value.background?.image_url) {
referenceImages.push(currentStoryboard.value.background.image_url)
}
// 2. 添加当前镜头登场的角色图片
const storyboardCharacters = currentStoryboardCharacters.value
if (storyboardCharacters && storyboardCharacters.length > 0) {
storyboardCharacters.forEach((char: any) => {
if (char.image_url) {
referenceImages.push(char.image_url)
}
})
}
const result = await imageAPI.generateImage({
drama_id: dramaId.toString(),
prompt: currentFramePrompt.value,
storyboard_id: currentStoryboard.value.id,
image_type: 'storyboard',
frame_type: selectedFrameType.value,
reference_images: referenceImages.length > 0 ? referenceImages : undefined
})
generatedImages.value.unshift(result)
// 提示信息
const refMsg = referenceImages.length > 0
? ` (已添加${referenceImages.length}张参考图)`
: ''
ElMessage.success(`图片生成任务已提交${refMsg}`)
// 启动轮询
startPolling()
} catch (error: any) {
ElMessage.error('生成失败: ' + (error.message || '未知错误'))
} finally {
generatingImage.value = false
}
}
// 获取状态标签类型
const getStatusType = (status: string) => {
const statusMap: Record<string, any> = {
pending: 'info',
processing: 'warning',
completed: 'success',
failed: 'danger'
}
return statusMap[status] || 'info'
}
// 播放视频
const playVideo = (video: VideoGeneration) => {
previewVideo.value = video
showVideoPreview.value = true
}
// 添加视频到素材库
const addVideoToAssets = async (video: VideoGeneration) => {
if (video.status !== 'completed' || !video.video_url) {
ElMessage.warning('只能添加已完成的视频到素材库')
return
}
addingToAssets.value.add(video.id)
try {
// 检查该镜头是否已存在素材
let isReplacing = false
if (video.storyboard_id) {
const existingAsset = videoAssets.value.find(
(asset: any) => asset.storyboard_id === video.storyboard_id
)
if (existingAsset) {
isReplacing = true
// 自动替换:先删除旧素材
try {
await assetAPI.deleteAsset(existingAsset.id)
} catch (error) {
console.error('删除旧素材失败:', error)
}
}
}
// 添加新素材
await assetAPI.importFromVideo(video.id)
ElMessage.success('已添加到素材库')
// 重新加载素材库列表
await loadVideoAssets()
// 如果是替换操作,更新时间线中使用该分镜的所有视频片段
if (isReplacing && video.storyboard_id && video.video_url) {
console.log('=== 视频替换,准备更新时间线 ===')
console.log('timelineEditorRef.value:', timelineEditorRef.value)
console.log('video.storyboard_id:', video.storyboard_id)
console.log('video.video_url:', video.video_url)
if (timelineEditorRef.value) {
timelineEditorRef.value.updateClipsByStoryboardId(
video.storyboard_id,
video.video_url
)
} else {
console.warn('⚠️ timelineEditorRef.value 为空,无法更新时间线')
}
}
} catch (error: any) {
ElMessage.error(error.message || '添加失败')
} finally {
addingToAssets.value.delete(video.id)
}
}
// 获取状态中文文本
const getStatusText = (status: string) => {
const statusTextMap: Record<string, string> = {
pending: '等待中',
processing: '生成中',
completed: '已完成',
failed: '失败'
}
return statusTextMap[status] || status
}
// 获取帧类型中文文本
const getFrameTypeText = (frameType?: string) => {
if (!frameType) return ''
const frameTypeMap: Record<string, string> = {
first: '首帧',
key: '关键帧',
last: '尾帧',
panel: '分镜板',
action: '动作序列'
}
return frameTypeMap[frameType] || frameType
}
// 获取分镜缩略图
const getStoryboardThumbnail = (storyboard: any) => {
// 优先使用composed_image
if (storyboard.composed_image) {
return storyboard.composed_image
}
// 如果没有composed_image从image_url字段获取
if (storyboard.image_url) {
return storyboard.image_url
}
return null
}
// 处理图片选择(根据模型能力)
const handleImageSelect = (imageId: number) => {
if (!selectedReferenceMode.value) {
ElMessage.warning('请先选择参考图模式')
return
}
if (!currentModelCapability.value) {
ElMessage.warning('请先选择视频生成模型')
return
}
const capability = currentModelCapability.value
const currentIndex = selectedImagesForVideo.value.indexOf(imageId)
// 已选中,则取消选择
if (currentIndex > -1) {
selectedImagesForVideo.value.splice(currentIndex, 1)
return
}
// 获取当前点击的图片对象
const clickedImage = videoReferenceImages.value.find(img => img.id === imageId)
if (!clickedImage) return
// 根据选择的参考图模式处理
switch (selectedReferenceMode.value) {
case 'single':
// 单图模式只能选1张直接替换
selectedImagesForVideo.value = [imageId]
break
case 'first_last':
// 首尾帧模式:根据图片类型分别处理
const frameType = clickedImage.frame_type
if (frameType === 'first' || frameType === 'panel' || frameType === 'key') {
// 首帧:直接替换
selectedImagesForVideo.value = [imageId]
} else if (frameType === 'last') {
// 尾帧:设置到单独的变量
selectedLastImageForVideo.value = imageId
} else {
ElMessage.warning('首尾帧模式下,请选择首帧或尾帧类型的图片')
}
break
case 'multiple':
// 多图模式:检查是否超出最大数量
if (selectedImagesForVideo.value.length >= capability.maxImages) {
ElMessage.warning(`最多只能选择${capability.maxImages}张图片`)
return
}
selectedImagesForVideo.value.push(imageId)
break
default:
ElMessage.warning('未知的参考图模式')
}
}
// 预览图片
const previewImage = (url: string) => {
// 使用Element Plus的图片预览
const viewer = document.createElement('div')
viewer.innerHTML = `
<div style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 9999; background: rgba(0,0,0,0.8); display: flex; align-items: center; justify-content: center;" onclick="this.remove()">
<img src="${url}" style="max-width: 90vw; max-height: 90vh; object-fit: contain;" onclick="event.stopPropagation();" />
</div>
`
document.body.appendChild(viewer)
}
// 获取已选图片对象列表
const selectedImageObjects = computed(() => {
return selectedImagesForVideo.value
.map(id => videoReferenceImages.value.find(img => img.id === id))
.filter(img => img && img.image_url)
})
// 首尾帧模式:获取首帧图片
const firstFrameSlotImage = computed(() => {
if (selectedImagesForVideo.value.length === 0) return null
const firstImageId = selectedImagesForVideo.value[0]
return videoReferenceImages.value.find(img => img.id === firstImageId)
})
// 首尾帧模式:获取尾帧图片
const lastFrameSlotImage = computed(() => {
if (!selectedLastImageForVideo.value) return null
return videoReferenceImages.value.find(img => img.id === selectedLastImageForVideo.value)
})
// 移除已选择的图片
const removeSelectedImage = (imageId: number) => {
// 检查是否是尾帧
if (selectedLastImageForVideo.value === imageId) {
selectedLastImageForVideo.value = null
return
}
// 检查是否是首帧或其他图片
const index = selectedImagesForVideo.value.indexOf(imageId)
if (index > -1) {
selectedImagesForVideo.value.splice(index, 1)
}
}
// 生成视频
const generateVideo = async () => {
if (!selectedVideoModel.value) {
ElMessage.warning('请先选择视频生成模型')
return
}
if (!currentStoryboard.value) {
ElMessage.warning('请先选择分镜')
return
}
// 检查参考图模式
if (selectedReferenceMode.value !== 'none' && selectedImagesForVideo.value.length === 0) {
ElMessage.warning('请选择参考图片')
return
}
// 获取第一张选中的图片(仅在需要图片的模式下)
let selectedImage = null
if (selectedReferenceMode.value !== 'none' && selectedImagesForVideo.value.length > 0) {
selectedImage = videoReferenceImages.value.find(img => img.id === selectedImagesForVideo.value[0])
if (!selectedImage || !selectedImage.image_url) {
ElMessage.error('请选择有效的参考图片')
return
}
}
generatingVideo.value = true
try {
// 从模型名称提取正确的provider
const provider = extractProviderFromModel(selectedVideoModel.value)
// 构建请求参数
const requestParams: any = {
drama_id: dramaId.toString(),
storyboard_id: currentStoryboard.value.id,
prompt: currentStoryboard.value.video_prompt || currentStoryboard.value.action || currentStoryboard.value.description || '',
duration: videoDuration.value,
provider: provider,
model: selectedVideoModel.value,
reference_mode: selectedReferenceMode.value
}
// 根据参考图模式设置参数
switch (selectedReferenceMode.value) {
case 'single':
// 单图模式
requestParams.image_url = selectedImage.image_url
requestParams.image_gen_id = selectedImage.id
break
case 'first_last':
// 首尾帧模式
const firstImage = videoReferenceImages.value.find(img => img.id === selectedImagesForVideo.value[0])
const lastImage = videoReferenceImages.value.find(img => img.id === selectedLastImageForVideo.value)
if (firstImage?.image_url) {
requestParams.first_frame_url = firstImage.image_url
}
if (lastImage?.image_url) {
requestParams.last_frame_url = lastImage.image_url
}
break
case 'multiple':
// 多图模式
const selectedImages = selectedImagesForVideo.value
.map(id => videoReferenceImages.value.find(img => img.id === id))
.filter(img => img?.image_url)
.map(img => img!.image_url)
requestParams.reference_image_urls = selectedImages
break
case 'none':
// 无参考图模式
break
}
const result = await videoAPI.generateVideo(requestParams)
generatedVideos.value.unshift(result)
ElMessage.success('视频生成任务已提交')
// 启动视频轮询
startVideoPolling()
} catch (error: any) {
ElMessage.error('生成失败: ' + (error.message || '未知错误'))
} finally {
generatingVideo.value = false
}
}
// 加载分镜的视频参考图片(所有帧类型)
const loadVideoReferenceImages = async (storyboardId: number) => {
try {
const result = await imageAPI.listImages({
storyboard_id: storyboardId,
page: 1,
page_size: 100
})
videoReferenceImages.value = result.items || []
} catch (error: any) {
console.error('加载视频参考图片失败:', error)
}
}
// 加载分镜的视频列表
const loadStoryboardVideos = async (storyboardId: number) => {
loadingVideos.value = true
try {
const result = await videoAPI.listVideos({
storyboard_id: storyboardId.toString(),
page: 1,
page_size: 50
})
generatedVideos.value = result.items || []
// 如果有进行中的任务,启动轮询
const hasPendingOrProcessing = generatedVideos.value.some(
v => v.status === 'pending' || v.status === 'processing'
)
if (hasPendingOrProcessing) {
startVideoPolling()
}
} catch (error: any) {
console.error('加载视频列表失败:', error)
} finally {
loadingVideos.value = false
}
}
// 启动视频状态轮询
const startVideoPolling = () => {
if (videoPollingTimer) return
videoPollingTimer = setInterval(async () => {
if (!currentStoryboard.value) {
stopVideoPolling()
return
}
try {
const result = await videoAPI.listVideos({
storyboard_id: currentStoryboard.value.id.toString(),
page: 1,
page_size: 50
})
generatedVideos.value = result.items || []
// 如果没有进行中的任务,停止轮询
const hasPendingOrProcessing = generatedVideos.value.some(
v => v.status === 'pending' || v.status === 'processing'
)
if (!hasPendingOrProcessing) {
stopVideoPolling()
}
} catch (error) {
console.error('轮询视频状态失败:', error)
}
}, 5000) // 每5秒轮询一次
}
// 停止视频轮询
const stopVideoPolling = () => {
if (videoPollingTimer) {
clearInterval(videoPollingTimer)
videoPollingTimer = null
}
}
const toggleCharacterInShot = async (charId: number) => {
if (!currentStoryboard.value) return
// 初始化characters数组
if (!currentStoryboard.value.characters) {
currentStoryboard.value.characters = []
}
const char = characters.value.find(c => c.id === charId)
if (!char) return
// 检查是否已存在
const existIndex = currentStoryboard.value.characters.findIndex(c =>
typeof c === 'object' ? c.id === charId : c === charId
)
if (existIndex > -1) {
// 移除角色
currentStoryboard.value.characters.splice(existIndex, 1)
} else {
// 添加角色(作为对象)
currentStoryboard.value.characters.push(char)
}
// 保存到后端
try {
const characterIds = currentStoryboard.value.characters.map(c =>
typeof c === 'object' ? c.id : c
)
await dramaAPI.updateStoryboard(currentStoryboard.value.id.toString(), {
character_ids: characterIds
})
if (existIndex > -1) {
ElMessage.success(`已移除角色: ${char.name}`)
} else {
ElMessage.success(`已添加角色: ${char.name}`)
}
} catch (error: any) {
ElMessage.error('保存失败: ' + (error.message || '未知错误'))
// 回滚操作
if (existIndex > -1) {
currentStoryboard.value.characters.push(char)
} else {
currentStoryboard.value.characters.splice(currentStoryboard.value.characters.length - 1, 1)
}
}
}
const removeCharacterFromShot = async (charId: number) => {
if (!currentStoryboard.value) return
// 初始化characters数组
if (!currentStoryboard.value.characters) {
currentStoryboard.value.characters = []
}
const char = characters.value.find(c => c.id === charId)
if (!char) return
// 检查是否已存在
const existIndex = currentStoryboard.value.characters.findIndex(c =>
typeof c === 'object' ? c.id === charId : c === charId
)
if (existIndex > -1) {
// 移除角色
currentStoryboard.value.characters.splice(existIndex, 1)
}
// 保存到后端
try {
const characterIds = currentStoryboard.value.characters.map(c =>
typeof c === 'object' ? c.id : c
)
await dramaAPI.updateStoryboard(currentStoryboard.value.id.toString(), {
character_ids: characterIds
})
ElMessage.success(`已移除角色: ${char.name}`)
} catch (error: any) {
ElMessage.error('保存失败: ' + (error.message || '未知错误'))
// 回滚操作
currentStoryboard.value.characters.push(char)
}
}
const loadData = async () => {
try {
// 加载剧集信息
const dramaRes = await dramaAPI.get(dramaId.toString())
drama.value = dramaRes
// 找到当前章节
const ep = dramaRes.episodes?.find(e => e.episode_number === episodeNumber)
if (!ep) {
ElMessage.error('章节不存在')
router.back()
return
}
episode.value = ep
episodeId.value = ep.id
// 加载分镜列表
const storyboardsRes = await dramaAPI.getStoryboards(ep.id.toString())
// API返回格式: {storyboards: [...], total: number}
storyboards.value = storyboardsRes?.storyboards || []
// 默认选中第一个分镜
if (storyboards.value.length > 0 && !currentStoryboardId.value) {
currentStoryboardId.value = storyboards.value[0].id
}
// 加载角色列表
characters.value = dramaRes.characters || []
// 加载可用场景列表
availableScenes.value = dramaRes.scenes || []
// 加载视频素材库
await loadVideoAssets()
} catch (error: any) {
ElMessage.error('加载数据失败: ' + (error.message || '未知错误'))
}
}
const selectScene = async (sceneId: number) => {
if (!currentStoryboard.value) return
try {
// TODO: 调用API更新分镜的scene_id
await dramaAPI.updateScene(currentStoryboard.value.id.toString(), {
scene_id: sceneId
})
// 重新加载数据
await loadData()
showSceneSelector.value = false
ElMessage.success('场景关联成功')
} catch (error: any) {
ElMessage.error(error.message || '场景关联失败')
}
}
const selectStoryboard = (id: number) => {
currentStoryboardId.value = id
}
const handleTimelineSelect = (sceneId: number) => {
selectStoryboard(sceneId)
}
const handleAddStoryboard = async () => {
ElMessage.info('添加分镜功能开发中')
}
const togglePlay = () => {
if (currentPlayState.value === 'playing') {
currentPlayState.value = 'paused'
} else {
currentPlayState.value = 'playing'
}
}
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
const zoomIn = () => {
ElMessage.info('时间线缩放功能开发中')
}
const zoomOut = () => {
ElMessage.info('时间线缩放功能开发中')
}
const generateImage = async () => {
if (!currentStoryboard.value) return
try {
ElMessage.info('图片生成功能开发中')
} catch (error: any) {
ElMessage.error(error.message || '生成失败')
}
}
const uploadImage = () => {
ElMessage.info('上传图片功能开发中')
}
const goBack = () => {
router.replace({
name: 'EpisodeWorkflowNew',
params: { id: dramaId, episodeNumber }
})
}
// 加载视频合成列表
const loadVideoMerges = async () => {
if (!episodeId.value) return
try {
loadingMerges.value = true
const result = await videoMergeAPI.listMerges({
episode_id: episodeId.value.toString(),
page: 1,
page_size: 20
})
videoMerges.value = result.merges
// 检查是否有进行中的任务
const hasProcessingTasks = result.merges.some(
(merge: any) => merge.status === 'pending' || merge.status === 'processing'
)
if (hasProcessingTasks) {
startMergePolling()
} else {
stopMergePolling()
}
} catch (error: any) {
console.error('加载视频合成列表失败:', error)
ElMessage.error('加载视频合成列表失败')
} finally {
loadingMerges.value = false
}
}
// 启动视频合成列表轮询
const startMergePolling = () => {
if (mergePollingTimer) return
mergePollingTimer = setInterval(async () => {
if (!episodeId.value) {
stopMergePolling()
return
}
try {
const result = await videoMergeAPI.listMerges({
episode_id: episodeId.value.toString(),
page: 1,
page_size: 20
})
videoMerges.value = result.merges
// 检查是否还有进行中的任务
const hasProcessingTasks = result.merges.some(
(merge: any) => merge.status === 'pending' || merge.status === 'processing'
)
if (!hasProcessingTasks) {
stopMergePolling()
}
} catch (error) {
}
}, 3000) // 每3秒轮询一次
}
// 停止视频合成列表轮询
const stopMergePolling = () => {
if (mergePollingTimer) {
clearInterval(mergePollingTimer)
mergePollingTimer = null
}
}
// 处理视频合成完成事件
const handleMergeCompleted = async (mergeId: number) => {
// 刷新视频合成列表
await loadVideoMerges()
// 切换到视频合成标签页
activeTab.value = 'merges'
}
// 下载视频
const downloadVideo = async (url: string, title: string) => {
try {
const loadingMsg = ElMessage.info({
message: '正在准备下载...',
duration: 0
})
// 使用fetch获取视频blob
const response = await fetch(url)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const blob = await response.blob()
const blobUrl = window.URL.createObjectURL(blob)
// 创建下载链接
const link = document.createElement('a')
link.href = blobUrl
link.download = `${title}.mp4`
link.style.display = 'none'
document.body.appendChild(link)
link.click()
// 清理
setTimeout(() => {
document.body.removeChild(link)
window.URL.revokeObjectURL(blobUrl)
}, 100)
loadingMsg.close()
ElMessage.success('视频下载已开始')
} catch (error) {
console.error('下载视频失败:', error)
ElMessage.error('视频下载失败,请稍后重试')
}
}
// 预览合成视频
const previewMergedVideo = (url: string) => {
window.open(url, '_blank')
}
// 删除视频合成记录
const deleteMerge = async (mergeId: number) => {
try {
await ElMessageBox.confirm(
'确定要删除此合成记录吗?此操作不可恢复。',
'删除确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
await videoMergeAPI.deleteMerge(mergeId)
ElMessage.success('删除成功')
// 刷新列表
await loadVideoMerges()
} catch (error: any) {
if (error !== 'cancel') {
console.error('删除失败:', error)
ElMessage.error(error.response?.data?.message || '删除失败')
}
}
}
// 格式化日期时间
const formatDateTime = (dateStr: string) => {
const date = new Date(dateStr)
const now = new Date()
const diff = now.getTime() - date.getTime()
const minutes = Math.floor(diff / 60000)
const hours = Math.floor(diff / 3600000)
const days = Math.floor(diff / 86400000)
if (minutes < 1) return '刚刚'
if (minutes < 60) return `${minutes}分钟前`
if (hours < 24) return `${hours}小时前`
if (days < 7) return `${days}天前`
// 超过7天显示完整日期
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hour = String(date.getHours()).padStart(2, '0')
const minute = String(date.getMinutes()).padStart(2, '0')
return `${month}-${day} ${hour}:${minute}`
}
onMounted(async () => {
await loadData()
await loadVideoModels()
await loadVideoMerges()
})
// 组件卸载时停止轮询
onBeforeUnmount(() => {
stopPolling()
stopVideoPolling()
stopMergePolling()
})
</script>
<style scoped lang="scss">
// 镜头列表项样式 - 白色主题
.storyboard-item {
padding: 8px;
cursor: pointer;
border-radius: 6px;
transition: all 0.2s;
border: 1px solid #e0e0e0;
margin-bottom: 8px;
display: flex;
gap: 10px;
align-items: center;
background: #fff;
&:hover {
background: #f5f5f5;
border-color: #d0d0d0;
}
&.active {
background: #409eff;
border-color: #409eff;
.shot-number,
.shot-title {
color: #fff !important;
}
.shot-duration {
background: rgba(255, 255, 255, 0.2);
color: #fff;
}
}
.shot-thumbnail {
width: 80px;
height: 50px;
border-radius: 4px;
overflow: hidden;
background: #f0f0f0;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.shot-content {
flex: 1;
min-width: 0;
.shot-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
.shot-number {
font-size: 11px;
color: #666;
font-weight: 500;
}
.shot-duration {
font-size: 11px;
color: #666;
background: #f0f0f0;
padding: 2px 6px;
border-radius: 3px;
}
}
.shot-title {
font-size: 13px;
color: #333;
font-weight: 500;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
// 视频合成列表样式
.merges-list {
padding: 16px;
max-height: calc(100vh - 200px);
overflow-y: auto;
background: linear-gradient(to bottom, #f8f9fa 0%, #ffffff 100%);
.merge-items {
display: flex;
flex-direction: column;
gap: 16px;
}
.merge-item {
position: relative;
background: #fff;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06), 0 0 0 1px rgba(0, 0, 0, 0.02);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border: 1px solid transparent;
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1), 0 0 0 1px rgba(64, 158, 255, 0.3);
border-color: rgba(64, 158, 255, 0.2);
}
.status-indicator {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
transition: all 0.3s;
}
&.merge-status-completed .status-indicator {
background: linear-gradient(to bottom, #67c23a, #85ce61);
}
&.merge-status-processing .status-indicator {
background: linear-gradient(to bottom, #e6a23c, #f0c78a);
animation: pulse 2s ease-in-out infinite;
}
&.merge-status-failed .status-indicator {
background: linear-gradient(to bottom, #f56c6c, #f89898);
}
&.merge-status-pending .status-indicator {
background: linear-gradient(to bottom, #909399, #b1b3b8);
}
.merge-content {
padding: 20px 24px;
padding-left: 28px;
}
.merge-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 14px;
border-bottom: 1px solid #f0f2f5;
.title-section {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
.title-icon {
display: flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border-radius: 10px;
background: linear-gradient(135deg, #f5f7fa 0%, #e8eaf0 100%);
color: #606266;
transition: all 0.3s;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.04);
}
.merge-title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #303133;
line-height: 1.4;
}
}
:deep(.el-tag) {
font-weight: 500;
padding: 4px 12px;
font-size: 12px;
}
}
&.merge-status-completed .title-icon {
background: linear-gradient(135deg, #67c23a 0%, #85ce61 100%);
color: #fff;
box-shadow: 0 2px 8px rgba(103, 194, 58, 0.25);
}
&.merge-status-processing .title-icon {
background: linear-gradient(135deg, #e6a23c 0%, #f0c78a 100%);
color: #fff;
box-shadow: 0 2px 8px rgba(230, 162, 60, 0.25);
}
&.merge-status-failed .title-icon {
background: linear-gradient(135deg, #f56c6c 0%, #f89898 100%);
color: #fff;
box-shadow: 0 2px 8px rgba(245, 108, 108, 0.25);
}
.merge-details {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
margin-bottom: 16px;
.detail-item {
display: flex;
gap: 10px;
padding: 12px 14px;
background: linear-gradient(135deg, #f8f9fa 0%, #f1f3f5 100%);
border-radius: 8px;
border: 1px solid transparent;
transition: all 0.3s;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
&:hover {
background: linear-gradient(135deg, #e9ecef 0%, #dee2e6 100%);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
border-color: rgba(64, 158, 255, 0.15);
}
.detail-icon {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 6px;
background: #fff;
color: #409eff;
flex-shrink: 0;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.detail-content {
flex: 1;
min-width: 0;
.detail-label {
font-size: 11px;
color: #909399;
margin-bottom: 3px;
font-weight: 500;
}
.detail-value {
font-size: 13px;
color: #303133;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
}
.merge-error {
margin-bottom: 12px;
:deep(.el-alert) {
border-radius: 8px;
border: none;
box-shadow: 0 1px 4px rgba(245, 108, 108, 0.12);
padding: 8px 12px;
font-size: 12px;
}
}
.merge-actions {
display: flex;
gap: 8px;
margin-top: 12px;
:deep(.el-button) {
flex: 1;
max-width: 160px;
font-weight: 500;
padding: 8px 15px;
font-size: 13px;
}
}
}
}
// 旋转动画
@keyframes rotating {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.rotating {
animation: rotating 2s linear infinite;
}
// 脉冲动画
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
// 白色主题样式
.shot-editor-new {
padding: 16px;
height: 100%;
overflow-y: auto;
background: #fff;
.section-label {
font-size: 12px;
color: #666;
margin-bottom: 8px;
}
// 场景预览
.scene-section {
margin-bottom: 20px;
}
.scene-preview {
width: 100%;
height: 80px;
border-radius: 6px;
overflow: hidden;
position: relative;
background: #f5f5f5;
border: 1px solid #e0e0e0;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.scene-info {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 6px 8px;
background: linear-gradient(to top, rgba(0, 0, 0, 0.7), transparent);
font-size: 11px;
color: #fff;
.scene-id {
font-size: 10px;
color: #e0e0e0;
margin-top: 2px;
}
}
}
.scene-preview-empty {
width: 100%;
height: 80px;
border-radius: 6px;
border: 1px dashed #d0d0d0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
background: #fafafa;
.el-icon {
font-size: 32px !important;
color: #c0c0c0;
}
div {
font-size: 11px;
color: #999;
}
}
// 角色列表
.cast-section {
margin-bottom: 20px;
}
.cast-list {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 8px;
.cast-item {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
cursor: pointer;
transition: all 0.2s;
&:hover {
.cast-avatar {
border-color: #409eff;
}
.cast-remove {
opacity: 1;
visibility: visible;
}
}
&.active {
.cast-avatar {
border-color: #409eff;
background: #409eff;
}
}
.cast-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
border: 2px solid #e0e0e0;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
font-size: 14px;
font-weight: 500;
color: #666;
transition: all 0.2s;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.cast-name {
font-size: 10px;
color: #666;
max-width: 36px;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cast-remove {
position: absolute;
top: -3px;
right: -3px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #f56c6c;
color: white;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
z-index: 10;
opacity: 0;
visibility: hidden;
font-size: 12px;
&:hover {
background: #f23030;
transform: scale(1.1);
}
}
}
.cast-empty {
width: 100%;
text-align: center;
padding: 15px;
color: #999;
font-size: 11px;
}
}
// 视效设置
.settings-section {
margin-bottom: 16px;
.settings-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 10px;
.setting-item {
label {
display: block;
font-size: 11px;
color: #666;
margin-bottom: 6px;
}
}
}
.audio-controls {
margin-top: 8px;
}
}
// 叙事内容
.narrative-section {
margin-bottom: 14px;
}
.dialogue-section {
margin-bottom: 14px;
}
}
// 场景选择对话框样式
.scene-selector-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
max-height: 500px;
overflow-y: auto;
.scene-card {
border: 2px solid #ddd;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: #409eff;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
&.selected {
border-color: #409eff;
background: #ecf5ff;
}
.scene-image {
width: 100%;
height: 150px;
background: #f5f5f5;
display: flex;
align-items: center;
justify-content: center;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.scene-info {
padding: 12px;
background: #fff;
.scene-location {
font-size: 14px;
font-weight: 600;
color: #303133;
margin-bottom: 4px;
}
.scene-time {
font-size: 12px;
color: #909399;
}
}
}
.empty-scenes {
grid-column: 1 / -1;
padding: 40px 0;
}
}
// 更新section-label样式以支持按钮
.section-label {
display: flex;
justify-content: space-between;
align-items: center;
}
// 角色选择对话框样式
.character-selector-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
max-height: 500px;
overflow-y: auto;
.character-card {
position: relative;
border: 2px solid #ddd;
border-radius: 8px;
padding: 16px;
cursor: pointer;
transition: all 0.2s;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
&:hover {
border-color: #409eff;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
&.selected {
border-color: #409eff;
background: #ecf5ff;
}
.character-avatar-large {
width: 80px;
height: 80px;
border-radius: 50%;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
font-size: 32px;
font-weight: 600;
color: #409eff;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.character-info {
text-align: center;
.character-name {
font-size: 14px;
font-weight: 600;
color: #303133;
margin-bottom: 4px;
}
.character-role {
font-size: 12px;
color: #909399;
}
}
.character-check {
position: absolute;
top: 8px;
right: 8px;
}
}
.empty-characters {
grid-column: 1 / -1;
padding: 40px 0;
}
}
// 角色大图预览样式
.character-image-preview {
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
img {
max-width: 100%;
max-height: 500px;
border-radius: 8px;
object-fit: contain;
}
}
// 场景大图预览样式
.scene-image-preview {
display: flex;
justify-content: center;
align-items: center;
min-height: 450px;
background: #f5f5f5;
border-radius: 8px;
img {
max-width: 100%;
max-height: 600px;
border-radius: 8px;
object-fit: contain;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
}
// 设置部分样式
.settings-section {
margin-bottom: 20px;
.section-label {
font-size: 12px;
color: #666;
margin-bottom: 12px;
}
.settings-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
.setting-item {
label {
display: block;
font-size: 11px;
color: #666;
margin-bottom: 6px;
}
}
}
.audio-controls {
:deep(.el-textarea__inner) {
background: #fff;
border-color: #dcdfe6;
color: #333;
&::placeholder {
color: #999;
}
}
:deep(.el-select) {
width: 100%;
}
:deep(.el-slider__runway) {
background: #e4e7ed;
}
:deep(.el-slider__bar) {
background: #409eff;
}
:deep(.el-slider__button) {
border-color: #409eff;
}
}
}
.professional-editor {
height: 100vh;
display: flex;
flex-direction: column;
background: #f5f5f5;
color: #333;
.editor-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 20px;
background: #fff;
border-bottom: 1px solid #e0e0e0;
.toolbar-left {
display: flex;
align-items: center;
gap: 12px;
.back-btn {
color: #333;
&:hover {
color: #409eff;
}
}
.episode-title {
font-size: 14px;
color: #333;
}
}
.toolbar-right {
display: flex;
gap: 8px;
}
}
.editor-main {
flex: 1;
display: flex;
overflow: hidden;
height: calc(100vh - 60px);
.storyboard-panel {
width: 280px;
background: #fff;
border-right: 1px solid #e0e0e0;
display: flex;
flex-direction: column;
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid #e0e0e0;
h3 {
margin: 0;
font-size: 16px;
font-weight: 500;
}
}
.storyboard-list {
flex: 1;
overflow-y: auto;
padding: 8px;
.storyboard-item {
display: flex;
flex-direction: column;
padding: 12px;
margin-bottom: 8px;
background: #f9f9f9;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: #f0f0f0;
}
&.active {
background: #e6f7ff;
border-left: 3px solid #409eff;
.shot-content {
.shot-number,
.shot-title {
color: #0066cc !important;
}
.shot-action {
color: #333 !important;
}
.shot-duration {
background: rgba(64, 158, 255, 0.15);
color: #409eff;
}
}
}
.shot-content {
width: 100%;
.shot-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
gap: 8px;
.shot-title-row {
display: flex;
align-items: baseline;
gap: 8px;
flex: 1;
min-width: 0;
.shot-number {
font-size: 12px;
font-weight: 600;
color: #666;
flex-shrink: 0;
}
.shot-title {
font-size: 13px;
font-weight: 500;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.shot-duration {
font-size: 11px;
color: #999;
background: #f0f0f0;
padding: 2px 8px;
border-radius: 4px;
flex-shrink: 0;
}
}
.shot-action {
font-size: 11px;
color: #666;
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
}
}
}
}
.timeline-area {
flex: 1;
display: flex;
flex-direction: column;
background: #fafafa;
overflow: hidden;
.empty-timeline {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
}
.edit-panel {
width: 420px;
background: #fff;
border-left: 1px solid #e0e0e0;
overflow: hidden;
flex-shrink: 0;
.edit-tabs {
height: 100%;
:deep(.el-tabs__header) {
margin: 0;
background: #f9f9f9;
padding: 0 16px;
border-bottom: 1px solid #e0e0e0;
}
:deep(.el-tabs__content) {
height: calc(100% - 55px);
overflow-y: auto;
}
.tab-content {
padding: 16px;
}
.scene-editor,
.shot-editor {
.el-form-item {
margin-bottom: 16px;
}
}
}
}
}
}
// 图片生成界面样式
.image-generation-section {
.frame-type-selector {
margin-bottom: 20px;
.section-label {
font-size: 13px;
color: #333;
font-weight: 500;
margin-bottom: 12px;
}
:deep(.el-radio-group) {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.panel-count-input {
width: 80px;
}
}
.prompt-section {
margin-bottom: 20px;
.section-label {
font-size: 13px;
color: #333;
font-weight: 500;
margin-bottom: 12px;
display: flex;
align-items: center;
}
:deep(.el-textarea__inner) {
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
font-size: 12px;
line-height: 1.6;
}
}
.generation-controls {
margin-bottom: 20px;
display: flex;
gap: 10px;
}
.generation-result {
.section-label {
font-size: 13px;
color: #303133;
font-weight: 600;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 6px;
&::before {
content: '';
width: 3px;
height: 14px;
background: linear-gradient(to bottom, #409eff, #66b1ff);
border-radius: 2px;
}
}
.image-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 10px;
.image-item {
position: relative;
border-radius: 8px;
overflow: hidden;
background: #fff;
border: 1px solid #e8e8e8;
transition: all 0.2s ease;
cursor: pointer;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
border-color: #409eff;
}
:deep(.el-image) {
width: 100%;
aspect-ratio: 16 / 9;
background: #f5f7fa;
display: block;
}
.image-placeholder {
width: 100%;
aspect-ratio: 16 / 9;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
background: linear-gradient(135deg, #f5f7fa 0%, #e8ecf0 100%);
color: #909399;
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
width: 200%;
height: 200%;
background: linear-gradient(
45deg,
transparent 30%,
rgba(255, 255, 255, 0.3) 50%,
transparent 70%
);
animation: shimmer 2s infinite;
top: -50%;
left: -50%;
}
.el-icon {
position: relative;
z-index: 1;
font-size: 24px !important;
}
p {
margin: 0;
font-size: 11px;
font-weight: 500;
position: relative;
z-index: 1;
}
}
.image-info {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 6px 8px;
background: linear-gradient(to top, rgba(0, 0, 0, 0.75), rgba(0, 0, 0, 0.2) 70%, transparent);
display: flex;
justify-content: space-between;
align-items: center;
gap: 4px;
:deep(.el-tag) {
backdrop-filter: blur(8px);
font-size: 10px;
height: 20px;
padding: 0 6px;
}
.frame-type-tag {
padding: 2px 6px;
border-radius: 4px;
font-size: 10px;
font-weight: 500;
background: rgba(255, 255, 255, 0.25);
color: white;
backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.3);
text-transform: uppercase;
letter-spacing: 0.3px;
}
}
}
}
@keyframes shimmer {
0% {
transform: translateX(-100%) translateY(-100%) rotate(45deg);
}
100% {
transform: translateX(100%) translateY(100%) rotate(45deg);
}
}
}
// 视频生成样式
.video-generation-section {
.section-label {
font-size: 14px;
font-weight: 600;
color: #303133;
margin-bottom: 12px;
padding-left: 8px;
border-left: 3px solid #409eff;
}
// 视频生成结果样式
.generation-result {
margin-top: 24px;
.section-label {
font-size: 13px;
color: #303133;
font-weight: 600;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 6px;
&::before {
content: '';
width: 3px;
height: 14px;
background: linear-gradient(to bottom, #409eff, #66b1ff);
border-radius: 2px;
}
}
.image-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 10px;
.image-item {
position: relative;
border-radius: 8px;
overflow: hidden;
background: #fff;
border: 1px solid #e8e8e8;
transition: all 0.2s ease;
cursor: pointer;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
border-color: #409eff;
}
.image-placeholder {
width: 100%;
aspect-ratio: 16 / 9;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
background: linear-gradient(135deg, #f5f7fa 0%, #e8ecf0 100%);
color: #909399;
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
width: 200%;
height: 200%;
background: linear-gradient(
45deg,
transparent 30%,
rgba(255, 255, 255, 0.3) 50%,
transparent 70%
);
animation: shimmer 2s infinite;
top: -50%;
left: -50%;
}
.el-icon {
position: relative;
z-index: 1;
font-size: 24px !important;
}
p {
margin: 0;
font-size: 11px;
font-weight: 500;
position: relative;
z-index: 1;
}
}
.image-info {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 6px 8px;
background: linear-gradient(to top, rgba(0, 0, 0, 0.75), rgba(0, 0, 0, 0.2) 70%, transparent);
display: flex;
justify-content: space-between;
align-items: center;
gap: 4px;
:deep(.el-tag) {
backdrop-filter: blur(8px);
font-size: 10px;
height: 20px;
padding: 0 6px;
}
.frame-type-tag {
padding: 2px 6px;
border-radius: 4px;
font-size: 10px;
font-weight: 500;
background: rgba(255, 255, 255, 0.25);
color: white;
backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.3);
text-transform: uppercase;
letter-spacing: 0.3px;
}
}
// 视频缩略图特殊样式
&.video-item .video-thumbnail {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
overflow: hidden;
cursor: pointer;
video {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
pointer-events: none;
}
.play-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.3);
opacity: 0;
transition: opacity 0.2s ease;
.el-icon {
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.3));
}
}
&:hover .play-overlay {
opacity: 1;
}
}
}
}
}
.video-params-section {
margin-bottom: 24px;
padding: 20px;
background: linear-gradient(135deg, #f5f7fa 0%, #f9f9f9 100%);
border-radius: 12px;
border: 1px solid #e4e7ed;
}
.reference-images-section {
margin-top: 24px;
.image-slots-container {
padding: 20px;
background: #fafafa;
border-radius: 12px;
border: 1px dashed #dcdfe6;
}
.image-slot {
position: relative;
width: 140px;
height: 90px;
border: 2px dashed #dcdfe6;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: all 0.3s;
background: #fff;
&:hover {
border-color: #409eff;
box-shadow: 0 2px 12px rgba(64, 158, 255, 0.2);
}
&.image-slot-small {
width: 80px;
height: 52px;
}
}
.image-slot-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #909399;
}
.image-slot-remove {
position: absolute;
top: 4px;
right: 4px;
width: 24px;
height: 24px;
background: rgba(0, 0, 0, 0.6);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: rgba(255, 73, 73, 0.9);
transform: scale(1.1);
}
}
.frame-type-buttons {
margin-bottom: 20px;
text-align: center;
:deep(.el-radio-group) {
display: inline-flex;
flex-wrap: wrap;
gap: 10px;
}
:deep(.el-radio-button) {
border-radius: 6px;
overflow: hidden;
&:first-child .el-radio-button__inner {
border-radius: 6px 0 0 6px;
}
&:last-child .el-radio-button__inner {
border-radius: 0 6px 6px 0;
}
}
:deep(.el-radio-button__inner) {
padding: 10px 20px;
font-size: 13px;
font-weight: 500;
border-color: #dcdfe6;
transition: all 0.2s;
&:hover {
color: #409eff;
border-color: #409eff;
}
}
:deep(.el-radio-button.is-active .el-radio-button__inner) {
background: linear-gradient(135deg, #409eff 0%, #5eabff 100%);
border-color: #409eff;
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.3);
}
}
.frame-type-content {
padding: 20px;
background: #ffffff;
border-radius: 12px;
border: 1px solid #e4e7ed;
min-height: 200px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.image-scroll-container {
max-height: 280px;
overflow-y: auto;
overflow-x: hidden;
padding-right: 4px;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
&::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
&:hover {
background: #a8a8a8;
}
}
}
.reference-grid {
display: grid !important;
grid-template-columns: repeat(3, 1fr) !important;
gap: 12px !important;
.reference-item {
position: relative;
border-radius: 10px;
overflow: hidden;
cursor: pointer;
border: 3px solid transparent;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
width: 100% !important;
max-width: 180px !important;
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
&:hover {
transform: translateY(-4px) scale(1.02);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.12);
border-color: #409eff;
}
&.selected {
border-color: #409eff;
box-shadow: 0 4px 16px rgba(64, 158, 255, 0.4);
}
img {
width: 100%;
max-width: 180px;
aspect-ratio: 16 / 9;
object-fit: cover;
display: block;
transition: transform 0.3s;
}
&:hover img {
transform: scale(1.05);
}
.reference-label {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 4px 8px;
background: linear-gradient(to top, rgba(0, 0, 0, 0.7), transparent);
color: white;
font-size: 10px;
text-align: center;
}
}
}
}
.generation-controls {
margin-top: 40px;
padding-top: 0;
text-align: center;
:deep(.el-button) {
padding: 12px 32px;
font-size: 14px;
font-weight: 500;
border-radius: 8px;
transition: all 0.3s;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.3);
}
}
}
@keyframes shimmer {
0% {
transform: translateX(-100%) translateY(-100%) rotate(45deg);
}
100% {
transform: translateX(100%) translateY(100%) rotate(45deg);
}
}
}
// 视频合成列表样式
.merges-list {
min-height: 300px;
.merge-items {
display: flex;
flex-direction: column;
gap: 16px;
}
.merge-item {
position: relative;
background: #ffffff;
border-radius: 12px;
overflow: hidden;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border: 1px solid #e4e7ed;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
&:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08);
border-color: #c6e2ff;
}
// 状态指示条
.status-indicator {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
transition: all 0.3s ease;
}
&.merge-status-pending .status-indicator {
background: linear-gradient(to bottom, #909399, #b1b3b8);
}
&.merge-status-processing .status-indicator {
background: linear-gradient(to bottom, #e6a23c, #f0c78a);
animation: pulse 2s ease-in-out infinite;
}
&.merge-status-completed .status-indicator {
background: linear-gradient(to bottom, #67c23a, #95d475);
}
&.merge-status-failed .status-indicator {
background: linear-gradient(to bottom, #f56c6c, #f89898);
}
.merge-content {
padding: 20px 20px 20px 24px;
}
.merge-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
gap: 12px;
.title-section {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
.title-icon {
color: #409eff;
flex-shrink: 0;
&.rotating {
animation: rotate 1.5s linear infinite;
}
}
.merge-title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #303133;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
:deep(.el-tag) {
flex-shrink: 0;
font-weight: 500;
letter-spacing: 0.3px;
}
}
.merge-details {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 16px;
margin-bottom: 16px;
padding: 16px;
background: linear-gradient(135deg, #f5f7fa 0%, #fafbfc 100%);
border-radius: 8px;
border: 1px solid #ebeef5;
.detail-item {
display: flex;
align-items: flex-start;
gap: 10px;
.detail-icon {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: white;
border-radius: 8px;
color: #409eff;
flex-shrink: 0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.detail-content {
flex: 1;
min-width: 0;
.detail-label {
font-size: 12px;
color: #909399;
margin-bottom: 4px;
font-weight: 500;
}
.detail-value {
font-size: 14px;
color: #303133;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
}
.merge-error {
margin-bottom: 16px;
:deep(.el-alert) {
border-radius: 8px;
border-left: 4px solid #f56c6c;
}
}
.merge-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
padding-top: 16px;
border-top: 1px solid #ebeef5;
:deep(.el-button) {
font-weight: 500;
transition: all 0.3s ease;
&:hover {
transform: translateY(-1px);
}
&.el-button--primary {
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.2);
&:hover {
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.3);
}
}
}
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
}
}
</style>