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
+336
View File
@@ -0,0 +1,336 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ArrowLeft, CheckCircle2, CircleAlert, Film, Image, MessageSquareText, RefreshCw } from '@lucide/vue'
import { ProgressRoot, ProgressIndicator } from 'reka-ui'
import { EmptyState, StatusBadge } from '../../components/ui'
import ConfirmAction from '../workflows/ConfirmAction.vue'
import { issueLabel, productionStatusLabel } from './model'
import { useProduction } from './useProduction'
import ProductionReceipt from './components/ProductionReceipt.vue'
import ShotProductionAssets from './components/ShotProductionAssets.vue'
/** 镜头生产页将项目批处理与单镜头资产管理放在同一条可核验链路中。 */
const {
id,
selectedEpisode,
selectedShot,
episodeNo,
episodeOptions,
shots,
shot,
promptItem,
keyframeItem,
videoItem,
videoStatusItem,
query,
operation,
session,
concurrency,
force,
batchValid,
blocked,
run
} = useProduction()
const stages = computed(() => {
const data = query.data.value
return [
{
key: 'prompts' as const,
code: '01',
title: '视频提示词',
description: '校验 GenerationSpec 与主体参考图后,由模型生成视频运动描述。',
icon: MessageSquareText,
data: data?.prompts,
done: data?.prompts.skipped ?? 0,
action: force.value ? '重生成全部提示词' : '补齐视频提示词'
},
{
key: 'keyframes' as const,
code: '02',
title: '镜头首帧',
description: '合并视觉风格、镜头规格和主体参考图,调用 Seedream 生成。',
icon: Image,
data: data?.keyframes,
done: data?.keyframes.skipped ?? 0,
action: force.value ? '为全部镜头新增首帧' : '补齐主首帧'
},
{
key: 'videos' as const,
code: '03',
title: '视频成片',
description: '以主首帧作为第一帧,连同提示词和主体参考图提交 Seedance。',
icon: Film,
data: data?.videos,
done: data?.videoStatus.completed ?? 0,
action: force.value ? '为全部就绪镜头新增视频' : '提交就绪视频任务'
}
]
})
const selectedIssues = computed(() =>
[promptItem.value, keyframeItem.value, videoItem.value].flatMap(item => item?.issues ?? [])
)
const videoRunning = computed(
() =>
(query.data.value?.videoStatus.pending ?? 0) +
(query.data.value?.videoStatus.queued ?? 0) +
(query.data.value?.videoStatus.running ?? 0)
)
/** 父页查询包含整个项目的就绪统计,单镜头变更后立即刷新。 */
function refreshProject() {
void query.refresh()
}
</script>
<template>
<section class="mt-7">
<div class="flex flex-wrap items-end justify-between gap-4">
<div>
<h2 class="text-lg font-semibold">镜头生产</h2>
<p class="mt-2 text-sm text-muted">从可生成的提示词和首帧到异步视频任务与最终成片</p>
</div>
<RouterLink :to="`/projects/${id}/storyboard`" class="text-button">
<ArrowLeft :size="14" />回到分镜设计
</RouterLink>
</div>
<p class="alert mt-5 text-xs">
每一步都按后端就绪检查执行覆盖只会新增首帧视频候选不会替换当前主资产重新生成上游内容后也不会自动更新已有下游结果
</p>
<section class="panel mt-5 p-5" aria-label="项目生产配置">
<div class="production-controls">
<label>
<span class="field-label">当前剧集</span>
<select
:value="episodeNo"
class="input"
aria-label="选择生产剧集"
@change="selectedEpisode = Number(($event.target as HTMLSelectElement).value)"
>
<option v-if="!episodeOptions.length" :value="0">暂无剧集</option>
<option v-for="episode in episodeOptions" :key="episode.episodeNo" :value="episode.episodeNo">
{{ episode.episodeNo }} · {{ episode.title }}
</option>
</select>
</label>
<label>
<span class="field-label">批量并发</span>
<input
v-model.number="concurrency"
class="input"
type="number"
min="1"
step="1"
:disabled="operation.pending"
/>
<span v-if="!batchValid" class="mt-1 block text-xs text-danger">请输入正整数</span>
</label>
<label class="flex items-center gap-2 pb-3 text-xs">
<input
v-model="force"
type="checkbox"
class="accent-accent"
:disabled="operation.pending"
/>覆盖模式为已有结果新增候选
</label>
<button class="text-button mb-2 ml-auto" :disabled="query.loading.value" @click="query.refresh">
<RefreshCw :size="13" :class="{ 'animate-spin': query.loading.value }" />刷新就绪状态
</button>
</div>
<p class="mt-3 text-[11px] leading-6 text-muted">
批量操作始终面向整个项目不受当前剧集选择影响当前剧集只控制下方逐镜检查Seedream Seedance
均可能产生费用
</p>
</section>
<p v-if="query.error.value" class="alert alert-error mt-4" role="alert">
{{ query.error.value }}<span v-if="query.data.value"> 当前保留上次读取的数据生产操作已暂停</span>
</p>
<p v-if="videoRunning" class="alert mt-4" role="status">
当前有 {{ videoRunning }} 个视频任务等待或生成中后端正在轮询 Provider同一镜头不会重复提交活动任务
</p>
<ProductionReceipt v-if="session.receipt" :receipt="session.receipt" />
<div v-if="query.data.value" class="production-pipeline mt-5">
<article v-for="stage in stages" :key="stage.key" class="panel production-pipeline-card">
<div class="flex items-start justify-between gap-3">
<div class="flex items-start gap-3">
<span class="production-step"><component :is="stage.icon" :size="17" /></span>
<div>
<p class="eyebrow">STEP {{ stage.code }}</p>
<h3 class="mt-2 text-sm font-semibold">{{ stage.title }}</h3>
</div>
</div>
<span class="font-mono text-xs text-muted">{{ stage.done }} / {{ stage.data?.total ?? 0 }}</span>
</div>
<p class="mt-4 min-h-10 text-xs leading-5 text-muted">{{ stage.description }}</p>
<ProgressRoot
:model-value="stage.done"
:max="Math.max(stage.data?.total ?? 0, 1)"
:aria-label="`${stage.title}完成数量`"
class="progress-track mt-4"
>
<ProgressIndicator
class="progress-fill"
:style="{
width: `${stage.data?.total ? Math.min(100, (stage.done / stage.data.total) * 100) : 0}%`
}"
/>
</ProgressRoot>
<dl class="production-stats mt-4">
<div>
<dt>可执行</dt>
<dd>{{ stage.data?.ready ?? 0 }}</dd>
</div>
<div>
<dt>已有结果</dt>
<dd>{{ stage.data?.skipped ?? 0 }}</dd>
</div>
<div>
<dt>阻塞</dt>
<dd>{{ stage.data?.blocked ?? 0 }}</dd>
</div>
</dl>
<ConfirmAction
class="mt-4"
:label="stage.action"
:disabled="blocked || !batchValid || !(stage.data?.ready ?? 0)"
acknowledgement
:description="
stage.key === 'videos'
? '只为当前就绪镜头创建 Seedance 异步任务。创建成功不代表视频已经完成,任务会在后台继续运行。'
: stage.key === 'keyframes'
? '只为当前就绪镜头调用 Seedream。覆盖模式会新增候选,不替换已有主首帧。'
: '只处理通过视频提示词就绪检查的镜头;覆盖模式会重写已有提示词。'
"
:primary="stage.key === 'videos'"
@confirm="run(stage.key)"
/>
</article>
</div>
<div v-if="query.data.value" class="panel mt-4 p-5">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 class="text-sm font-semibold">视频任务状态</h3>
<p class="mt-2 text-xs text-muted">状态统计取每个镜头最近一次任务不等同于主视频数量</p>
</div>
<ConfirmAction
label="重试失败视频"
:disabled="blocked || !batchValid || !query.data.value.videoStatus.failed"
acknowledgement
description="为当前最近一次状态为失败的镜头重新创建 Seedance 任务。成功镜头和进行中的镜头不会处理。"
@confirm="run('retry-videos')"
/>
</div>
<dl class="production-status-grid mt-4">
<div>
<dt>完成</dt>
<dd>{{ query.data.value.videoStatus.completed }}</dd>
</div>
<div>
<dt>排队生成</dt>
<dd>{{ videoRunning }}</dd>
</div>
<div>
<dt>失败</dt>
<dd>{{ query.data.value.videoStatus.failed }}</dd>
</div>
<div>
<dt>取消</dt>
<dd>{{ query.data.value.videoStatus.cancelled }}</dd>
</div>
<div>
<dt>未开始</dt>
<dd>{{ query.data.value.videoStatus.notStarted }}</dd>
</div>
</dl>
</div>
<div class="my-6 flex flex-wrap items-center justify-between gap-4">
<div>
<h3 class="text-sm font-semibold"> {{ episodeNo || '—' }} · 逐镜生产</h3>
<p class="mt-2 text-[11px] text-muted">选择镜头查看候选首帧视频任务成片和实际生成规格</p>
</div>
<span class="text-xs text-muted">{{ shots.length }} 个正式镜头</span>
</div>
<div v-if="shot" class="panel production-workspace">
<nav class="production-shot-list" aria-label="本集生产镜头">
<button
v-for="item in shots"
:key="item.shotId"
class="production-shot-link"
:class="{ selected: item.shotId === shot.shotId }"
:aria-current="item.shotId === shot.shotId ? 'true' : undefined"
@click="selectedShot = item.shotId"
>
<span class="font-mono text-[10px] text-muted">B{{ item.beatNo }} / S{{ item.shotNo }}</span>
<span class="mt-1 block truncate text-xs font-medium">{{ item.title }}</span>
<span class="mt-2 flex items-center gap-2 text-[10px] text-muted">
<CheckCircle2
v-if="
query.data.value?.videos.items.find(row => row.shotId === item.shotId)?.status ===
'skipped'
"
:size="12"
/>
<CircleAlert
v-else-if="
query.data.value?.videos.items.find(row => row.shotId === item.shotId)?.status ===
'blocked'
"
:size="12"
/>
{{
productionStatusLabel(
query.data.value?.videoStatus.items.find(row => row.shotId === item.shotId)?.status ||
'not_started'
)
}}
</span>
</button>
</nav>
<article class="min-w-0 p-5 lg:p-7">
<header class="mb-5 border-b border-line pb-5">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<p class="eyebrow">BEAT {{ shot.beatNo }} / SHOT {{ shot.shotNo }}</p>
<h3 class="mt-2 text-lg font-semibold">{{ shot.title }}</h3>
</div>
<StatusBadge
v-if="videoStatusItem"
:status="videoStatusItem.status"
:label="productionStatusLabel(videoStatusItem.status)"
/>
</div>
<p v-if="shot.description" class="mt-3 text-sm leading-7">{{ shot.description }}</p>
<p class="mt-3 break-all font-mono text-[10px] text-muted">Shot ID · {{ shot.shotId }}</p>
<ul v-if="selectedIssues.length" class="alert alert-error mt-4 space-y-1" role="alert">
<li v-for="(issue, index) in selectedIssues" :key="`${issue.code}-${index}`">
{{ issueLabel(issue.code) }}{{ issue.reason }}
</li>
</ul>
</header>
<ShotProductionAssets
:key="shot.shotId"
:project-id="id"
:shot="shot"
:prompt-readiness="promptItem"
:keyframe-readiness="keyframeItem"
:video-readiness="videoItem"
:disabled="blocked"
@changed="refreshProject"
/>
</article>
</div>
<EmptyState
v-else-if="query.data.value"
title="本集还没有正式镜头"
description="先完成剧本拆解、导演设计与正式镜头持久化,再进入镜头生产。"
>
<RouterLink :to="`/projects/${id}/storyboard`" class="button button-secondary">前往分镜设计</RouterLink>
</EmptyState>
<p v-else-if="query.loading.value" class="panel mt-5 p-8 text-sm text-muted" role="status">
正在检查全项目生产就绪状态
</p>
</section>
</template>