feat: 同步镜头首帧与视频生产工作区

This commit is contained in:
GouJ
2026-08-31 15:02:31 +08:00
parent 669f4d9df0
commit aeb3ce2986
16 changed files with 1802 additions and 5 deletions
@@ -0,0 +1,412 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ExternalLink, FileSearch, ImagePlus, RefreshCw } from '@lucide/vue'
import { AssetImage, StatusBadge } from '../../../components/ui'
import { usePolling } from '../../../composables/usePolling'
import { mediaAssetUrl } from '../../../lib/assets'
import { errorMessage } from '../../../lib/http'
import { formatDate } from '../../../lib/format'
import type { DesignedShot } from '../../storyboard/types'
import { storyboardApi } from '../../storyboard/api'
import ConfirmAction from '../../workflows/ConfirmAction.vue'
import { getOperation, runOperation } from '../../workflows/operations'
import { productionApi } from '../api'
import { isActiveVideo, issueLabel, primaryKeyframe, primaryVideo, productionStatusLabel } from '../model'
import type {
GenerateKeyframeInput,
KeyframeReadinessItem,
KeyframeSpec,
PromptReadinessItem,
VideoGenerationSpec,
VideoReadinessItem
} from '../types'
import KeyframeDialog from './KeyframeDialog.vue'
/** 单镜头生产面板只负责正式资产,不修改上游导演设计与即时状态。 */
const props = defineProps<{
projectId: string
shot: DesignedShot
promptReadiness?: PromptReadinessItem
keyframeReadiness?: KeyframeReadinessItem
videoReadiness?: VideoReadinessItem
disabled: boolean
}>()
const emit = defineEmits<{ changed: [] }>()
const key = computed(() => props.shot.shotId)
const query = usePolling(key, async (shotId, signal) => {
const [keyframes, videos] = await Promise.all([
productionApi.listKeyframes(shotId, signal),
productionApi.listVideos(shotId, signal)
])
if (keyframes.some(item => item.shotId !== shotId) || videos.some(item => item.shotId !== shotId))
throw new Error('资产记录与当前镜头不匹配,请刷新后重试。')
return { keyframes, videos }
})
const keyframes = computed(() => query.data.value?.keyframes ?? [])
const videos = computed(() => query.data.value?.videos ?? [])
const currentKeyframe = computed(() => primaryKeyframe(keyframes.value))
const currentVideo = computed(() => primaryVideo(videos.value))
const activeVideo = computed(() => videos.value.find(isActiveVideo))
const operation = computed(() => getOperation(props.projectId))
const assetBlocked = computed(() => props.disabled || operation.value.pending || !!query.error.value)
const keyframeOpen = ref(false)
const confirmingKeyframe = ref('')
const confirmingVideo = ref('')
const inspecting = ref(false)
const specError = ref('')
const keyframeSpec = ref<KeyframeSpec | null>(null)
const videoSpec = ref<VideoGenerationSpec | null>(null)
const canGeneratePrompt = computed(
() => !assetBlocked.value && !!props.promptReadiness && props.promptReadiness.issues.length === 0
)
const canGenerateKeyframe = computed(
() => !assetBlocked.value && !!props.keyframeReadiness && props.keyframeReadiness.issues.length === 0
)
const canGenerateVideo = computed(
() =>
!assetBlocked.value && !activeVideo.value && !!props.videoReadiness && props.videoReadiness.issues.length === 0
)
watch(key, () => {
confirmingKeyframe.value = ''
confirmingVideo.value = ''
keyframeSpec.value = null
videoSpec.value = null
specError.value = ''
})
/** 只返回通过协议和 /storage 限制的视频地址。 */
function safeVideoUrl(value: string | null): string | null {
return value ? mediaAssetUrl(value) : null
}
/** 重新读取单镜头资产,并通知父页刷新项目就绪状态。 */
async function refreshAll() {
await query.refresh()
emit('changed')
}
/** 单镜头提示词根据现有状态决定是否 force 覆盖。 */
async function generatePrompt() {
if (!canGeneratePrompt.value) return
const shotId = props.shot.shotId
const overwrite = props.promptReadiness?.status === 'skipped'
await runOperation(props.projectId, `生成镜头 ${props.shot.shotNo} 视频提示词`, async () => {
const result = await storyboardApi.generatePrompt(shotId, overwrite)
if (!result || result.id !== shotId || !result.videoPrompt) throw new Error('接口未返回有效视频提示词。')
})
await refreshAll()
}
/** 调用 Seedream 生成单镜头首帧;成功后重新读取候选和主图。 */
async function generateKeyframe(input: GenerateKeyframeInput) {
if (!canGenerateKeyframe.value) return
const shotId = props.shot.shotId
await runOperation(props.projectId, `生成镜头 ${props.shot.shotNo} 首帧`, async () => {
const result = await productionApi.generateKeyframe(shotId, input)
if (!result || result.shotId !== shotId) throw new Error('接口未确认首帧生成结果。')
})
await refreshAll()
}
/** 切换主首帧不调用模型,但会改变后续视频生成输入。 */
async function setPrimaryKeyframe() {
const keyframeId = confirmingKeyframe.value
if (assetBlocked.value || !keyframeId) return
const shotId = props.shot.shotId
confirmingKeyframe.value = ''
await runOperation(props.projectId, `设置镜头 ${props.shot.shotNo} 主首帧`, async () => {
const result = await productionApi.setPrimaryKeyframe(shotId, keyframeId)
if (!result || result.id !== keyframeId || !result.isPrimary || result.status !== 'completed')
throw new Error('接口未确认主首帧切换。')
})
await refreshAll()
}
/** 创建单镜头 Seedance 视频任务;任务完成由后台轮询确认。 */
async function generateVideo() {
if (!canGenerateVideo.value) return
const shotId = props.shot.shotId
await runOperation(props.projectId, `提交镜头 ${props.shot.shotNo} 视频任务`, async () => {
const result = await productionApi.generateVideo(shotId)
if (!result || result.shotId !== shotId || !isActiveVideo(result))
throw new Error('接口未返回有效的视频生成任务。')
})
await refreshAll()
}
/** 手动向 Provider 查询活动视频状态,终态任务不重复查询。 */
async function refreshActiveVideo() {
const video = activeVideo.value
if (!video || assetBlocked.value) return
await runOperation(props.projectId, `刷新镜头 ${props.shot.shotNo} 视频状态`, async () => {
const result = await productionApi.refreshVideo(video.id)
if (!result || result.id !== video.id) throw new Error('接口未返回当前视频任务状态。')
})
await refreshAll()
}
/** 切换主视频只接受已完成并具有持久化地址的候选。 */
async function setPrimaryVideo() {
const videoId = confirmingVideo.value
if (assetBlocked.value || !videoId) return
const shotId = props.shot.shotId
confirmingVideo.value = ''
await runOperation(props.projectId, `设置镜头 ${props.shot.shotNo} 主视频`, async () => {
const result = await productionApi.setPrimaryVideo(shotId, videoId)
if (!result || result.id !== videoId || !result.isPrimary || result.status !== 'completed')
throw new Error('接口未确认主视频切换。')
})
await refreshAll()
}
/** 显式检查两份确定性生成规格;某一阶段未就绪时保留另一份可读结果。 */
async function inspectSpecs() {
inspecting.value = true
specError.value = ''
const shotId = props.shot.shotId
const [keyframeResult, videoResult] = await Promise.allSettled([
productionApi.keyframeSpec(shotId),
productionApi.videoGenerationSpec(shotId)
])
keyframeSpec.value = keyframeResult.status === 'fulfilled' ? keyframeResult.value : null
videoSpec.value = videoResult.status === 'fulfilled' ? videoResult.value : null
const errors = [keyframeResult, videoResult]
.filter(result => result.status === 'rejected')
.map(result => errorMessage(result.reason))
specError.value = [...new Set(errors)].join(' ')
inspecting.value = false
}
</script>
<template>
<section class="production-assets">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h4 class="text-sm font-semibold">镜头生产资产</h4>
<p class="mt-1 text-[11px] text-muted">记录每次候选与失败主资产决定下一阶段实际输入</p>
</div>
<div class="flex gap-3">
<button class="text-button" :disabled="inspecting" @click="inspectSpecs">
<FileSearch :size="13" />{{ inspecting ? '检查中…' : '检查生成规格' }}
</button>
<button class="text-button" :disabled="query.loading.value" @click="refreshAll">
<RefreshCw :size="13" :class="{ 'animate-spin': query.loading.value }" />刷新资产
</button>
</div>
</div>
<p v-if="query.error.value" class="alert alert-error mt-4" role="alert">
{{ query.error.value }} 当前保留上次成功读取的资产操作已暂停
</p>
<p v-if="specError" class="alert alert-error mt-4" role="alert">{{ specError }}</p>
<div class="production-stage mt-5">
<div class="production-stage-heading">
<div>
<p class="eyebrow">01 · VIDEO PROMPT</p>
<h5 class="mt-2 text-sm font-medium">视频提示词</h5>
</div>
<StatusBadge
v-if="promptReadiness"
:status="promptReadiness.status"
:label="productionStatusLabel(promptReadiness.status)"
/>
</div>
<ul v-if="promptReadiness?.issues.length" class="mt-3 space-y-2 text-xs text-danger">
<li v-for="issue in promptReadiness.issues" :key="issue.code">
{{ issueLabel(issue.code) }}{{ issue.reason }}
</li>
</ul>
<div class="mt-4">
<ConfirmAction
:label="promptReadiness?.status === 'skipped' ? '重新生成本镜提示词' : '生成本镜提示词'"
:disabled="!canGeneratePrompt"
acknowledgement
:description="
promptReadiness?.status === 'skipped'
? '依据当前 GenerationSpec 与参考图重写本镜提示词。旧提示词会被覆盖,已有首帧和视频不会自动更新。'
: '依据当前 GenerationSpec 与参考图生成本镜视频提示词。'
"
@confirm="generatePrompt"
/>
</div>
</div>
<div class="production-stage">
<div class="production-stage-heading">
<div>
<p class="eyebrow">02 · KEYFRAME</p>
<h5 class="mt-2 text-sm font-medium">主首帧与候选图</h5>
</div>
<StatusBadge
v-if="keyframeReadiness"
:status="keyframeReadiness.status"
:label="productionStatusLabel(keyframeReadiness.status)"
/>
</div>
<ul v-if="keyframeReadiness?.issues.length" class="mt-3 space-y-2 text-xs text-danger">
<li v-for="issue in keyframeReadiness.issues" :key="issue.code">
{{ issueLabel(issue.code) }}{{ issue.reason }}
</li>
</ul>
<div v-if="keyframes.length" class="keyframe-grid mt-4">
<article v-for="item in keyframes" :key="item.id" class="keyframe-card">
<AssetImage
:src="item.status === 'completed' ? item.imageUrl : null"
:alt="`镜头 ${shot.shotNo} 首帧`"
:empty-text="item.status === 'failed' ? '首帧生成失败' : '首帧生成中'"
/>
<div class="p-3">
<div class="flex flex-wrap items-center gap-2">
<StatusBadge :status="item.status" :label="productionStatusLabel(item.status)" />
<span v-if="item.isPrimary" class="tag">主首帧</span>
<span class="text-[10px] text-muted"
>{{ item.width || '—' }} × {{ item.height || '—' }}</span
>
</div>
<p v-if="item.error" class="mt-2 line-clamp-3 text-xs text-danger">{{ item.error }}</p>
<p class="mt-2 text-[10px] text-muted">{{ formatDate(item.createdAt) }}</p>
<button
v-if="item.status === 'completed' && item.imageUrl && !item.isPrimary"
class="text-button mt-2"
:disabled="assetBlocked"
@click="confirmingKeyframe = item.id"
>
设为主首帧
</button>
<div v-if="confirmingKeyframe === item.id" class="alert mt-2 text-xs">
<p>确认让后续视频使用此首帧已有视频不会自动重生成</p>
<div class="mt-2 flex gap-3">
<button class="button button-primary" @click="setPrimaryKeyframe">确认切换</button>
<button class="text-button" @click="confirmingKeyframe = ''">取消</button>
</div>
</div>
</div>
</article>
</div>
<p v-else class="mt-4 text-xs text-muted">尚无首帧记录</p>
<button class="button button-secondary mt-4" :disabled="!canGenerateKeyframe" @click="keyframeOpen = true">
<ImagePlus :size="14" />{{ currentKeyframe ? '再生成一张候选' : '生成首帧' }}
</button>
</div>
<div class="production-stage">
<div class="production-stage-heading">
<div>
<p class="eyebrow">03 · VIDEO</p>
<h5 class="mt-2 text-sm font-medium">Seedance 视频任务与成片</h5>
</div>
<StatusBadge
v-if="videoReadiness"
:status="videoReadiness.status"
:label="productionStatusLabel(videoReadiness.status)"
/>
</div>
<ul v-if="videoReadiness?.issues.length" class="mt-3 space-y-2 text-xs text-danger">
<li v-for="issue in videoReadiness.issues" :key="issue.code">
{{ issueLabel(issue.code) }}{{ issue.reason }}
</li>
</ul>
<div v-if="videos.length" class="mt-4 space-y-3">
<article v-for="item in videos" :key="item.id" class="video-record">
<video
v-if="item.status === 'completed' && safeVideoUrl(item.videoUrl)"
:src="safeVideoUrl(item.videoUrl) || undefined"
controls
preload="metadata"
class="production-video"
></video>
<div class="min-w-0 flex-1 p-4">
<div class="flex flex-wrap items-center gap-2">
<StatusBadge :status="item.status" :label="productionStatusLabel(item.status)" />
<span v-if="item.isPrimary" class="tag">主视频</span>
<span class="text-[10px] text-muted"
>{{ item.durationSeconds || shot.durationSeconds || '—' }}s</span
>
</div>
<p v-if="item.error" class="alert alert-error mt-3" role="alert">{{ item.error }}</p>
<p class="mt-3 break-all text-[10px] text-muted">
{{ item.provider }} · {{ item.model }} · {{ formatDate(item.createdAt) }}
</p>
<div class="mt-3 flex flex-wrap gap-3">
<a
v-if="safeVideoUrl(item.videoUrl)"
:href="safeVideoUrl(item.videoUrl) || undefined"
target="_blank"
rel="noopener noreferrer"
class="text-button"
>打开视频<ExternalLink :size="12"
/></a>
<button
v-if="item.status === 'completed' && item.videoUrl && !item.isPrimary"
class="text-button"
:disabled="assetBlocked"
@click="confirmingVideo = item.id"
>
设为主视频
</button>
</div>
<div v-if="confirmingVideo === item.id" class="alert mt-3 text-xs">
<p>确认把此候选设为当前镜头主视频旧成片仍会保留</p>
<div class="mt-2 flex gap-3">
<button class="button button-primary" @click="setPrimaryVideo">确认切换</button>
<button class="text-button" @click="confirmingVideo = ''">取消</button>
</div>
</div>
</div>
</article>
</div>
<p v-else class="mt-4 text-xs text-muted">尚无视频任务记录</p>
<div class="mt-4 flex flex-wrap gap-3">
<ConfirmAction
:label="currentVideo ? '再生成一个视频候选' : '提交本镜视频任务'"
:disabled="!canGenerateVideo"
acknowledgement
:description="
currentVideo
? '使用当前视频提示词、主首帧和主体参考图新增一个 Seedance 视频候选,不自动替换主视频。'
: '使用当前视频提示词、主首帧和主体参考图创建 Seedance 任务;任务会在后台继续运行。'
"
primary
@confirm="generateVideo"
/>
<button
v-if="activeVideo"
class="button button-secondary"
:disabled="assetBlocked"
@click="refreshActiveVideo"
>
<RefreshCw :size="14" />刷新任务状态
</button>
</div>
</div>
<details v-if="keyframeSpec || videoSpec" class="mt-5 border-t border-line pt-4 text-xs">
<summary class="cursor-pointer font-medium">本镜确定性生成规格</summary>
<div class="mt-4 grid gap-5 lg:grid-cols-2">
<div v-if="keyframeSpec">
<h5 class="font-medium">首帧 Prompt · {{ keyframeSpec.references.length }} 张参考图</h5>
<p class="mt-3 whitespace-pre-wrap leading-6 text-muted">{{ keyframeSpec.prompt }}</p>
</div>
<div v-if="videoSpec">
<h5 class="font-medium">视频 Prompt · {{ videoSpec.durationSeconds }}s</h5>
<p class="mt-3 whitespace-pre-wrap leading-6 text-muted">{{ videoSpec.videoPrompt }}</p>
<p v-if="videoSpec.negativePrompt" class="mt-3 whitespace-pre-wrap leading-6 text-muted">
Negative prompt: {{ videoSpec.negativePrompt }}
</p>
<p class="mt-3 break-all font-mono text-[10px] text-muted">
Keyframe ID · {{ videoSpec.keyframe.id }}
</p>
</div>
</div>
</details>
<KeyframeDialog
v-model:open="keyframeOpen"
:shot-id="shot.shotId"
:shot-title="shot.title"
:keyframes="keyframes"
:disabled="!canGenerateKeyframe"
@generate="generateKeyframe"
/>
</section>
</template>