442 lines
23 KiB
Vue
442 lines
23 KiB
Vue
<script setup lang="ts">
|
||
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
|
||
import { NCollapse, NCollapseItem, NAlert, NButton, NCheckbox, NInputNumber, NProgress, NSelect } from 'naive-ui'
|
||
import { computed, ref, watch } from 'vue'
|
||
import { ArrowLeft, CheckCircle2, CircleAlert, Film, Image, MessageSquareText, RefreshCw } from '@lucide/vue'
|
||
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 staleVideos = computed(() => query.data.value?.videos.stalePrimaryVideo ?? 0)
|
||
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
|
||
? '为全部就绪镜头新增视频'
|
||
: staleVideos.value > 0
|
||
? `更新 ${staleVideos.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)
|
||
)
|
||
const identityBlocked = computed(
|
||
() =>
|
||
(query.data.value?.keyframes.missingIdentity ?? 0) +
|
||
(query.data.value?.keyframes.missingIdentityAnchor ?? 0) +
|
||
(query.data.value?.keyframes.identityUnlocked ?? 0)
|
||
)
|
||
const staleKeyframes = computed(() => query.data.value?.keyframes.stalePrimaryKeyframe ?? 0)
|
||
|
||
/** 父页查询包含整个项目的就绪统计,单镜头变更后立即刷新。 */
|
||
function refreshProject() {
|
||
void query.refresh()
|
||
}
|
||
/** 新回执自动展开,避免折叠区隐藏部分失败诊断。 */
|
||
const overview = ref<string[]>([])
|
||
watch(
|
||
() => session.value.receipt,
|
||
receipt => {
|
||
if (receipt) overview.value = ['overview']
|
||
},
|
||
{ immediate: true }
|
||
)
|
||
</script>
|
||
|
||
<template>
|
||
<WorkspacePage split
|
||
><template #header
|
||
><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>
|
||
<section class="mt-3" aria-label="项目生产配置">
|
||
<div class="production-controls">
|
||
<label>
|
||
<span class="field-label">当前剧集</span>
|
||
<NSelect
|
||
:value="episodeNo"
|
||
aria-label="选择生产剧集"
|
||
@update:value="selectedEpisode = Number($event)"
|
||
:options="[
|
||
...(!episodeOptions.length ? [{ label: String('暂无剧集'), value: 0 }] : []),
|
||
...episodeOptions.map(episode => ({
|
||
label: String(' 第 ' + episode.episodeNo + ' 集 · ' + episode.title),
|
||
value: episode.episodeNo
|
||
}))
|
||
]"
|
||
class="select-control"
|
||
></NSelect>
|
||
</label>
|
||
<label>
|
||
<span class="field-label">批量并发</span>
|
||
<NInputNumber
|
||
:disabled="operation.pending"
|
||
:value="typeof concurrency === 'number' ? concurrency : null"
|
||
@update:value="concurrency = $event ?? 0"
|
||
:min="1"
|
||
:step="1"
|
||
></NInputNumber>
|
||
<span v-if="!batchValid" class="mt-1 block text-xs text-danger">请输入正整数</span>
|
||
</label>
|
||
<NCheckbox
|
||
v-model:checked="force"
|
||
:disabled="operation.pending"
|
||
class="flex items-center gap-2 pb-3 text-xs"
|
||
>覆盖模式:为已有结果新增候选
|
||
</NCheckbox>
|
||
<NButton
|
||
:disabled="query.loading.value"
|
||
@click="query.refresh"
|
||
text
|
||
size="small"
|
||
class="mb-2 ml-auto"
|
||
><RefreshCw :size="13" :class="{ 'animate-spin': query.loading.value }" />刷新就绪状态
|
||
</NButton>
|
||
</div>
|
||
<p class="mt-3 text-[11px] leading-6 text-muted">
|
||
提示词与视频批量操作面向全项目;首帧默认补当前剧集,存在过期主首帧时优先更新全项目过期项,覆盖模式新增全项目候选。Seedream
|
||
与 Seedance 均可能产生费用。
|
||
</p>
|
||
</section></template
|
||
>
|
||
<NAlert v-if="query.error.value" role="alert" type="error" :show-icon="false" class="mt-4"
|
||
>{{ query.error.value
|
||
}}<span v-if="query.data.value"> 当前保留上次读取的数据,生产操作已暂停。</span></NAlert
|
||
>
|
||
<NCollapse v-model:expanded-names="overview" class="workspace-overview"
|
||
><NCollapseItem name="overview" title="项目生产总览与批量操作"
|
||
><div class="overview-content">
|
||
<NAlert type="info" :show-icon="false" class="mt-5 text-xs">
|
||
每一步都按后端就绪检查执行。覆盖只会新增首帧/视频候选,不会替换当前主资产;上游参考资产变化后,下游过期结果会被标记并阻止继续使用,需要重新生成后才能恢复就绪。
|
||
</NAlert>
|
||
<NAlert v-if="videoRunning" role="status" type="info" :show-icon="false" class="mt-4">
|
||
当前有 {{ videoRunning }} 个视频任务等待或生成中。后端正在轮询
|
||
Provider;同一镜头不会重复提交活动任务。
|
||
</NAlert>
|
||
<NAlert v-if="identityBlocked" type="info" :show-icon="false" class="mt-4">
|
||
当前有 {{ identityBlocked }} 项角色身份前置问题。Character 必须生成
|
||
Identity、确认演员母版并锁定后,首帧才能就绪。
|
||
<RouterLink :to="`/projects/${id}/subject-identity`" class="text-button ml-2"
|
||
>前往角色选角 →</RouterLink
|
||
></NAlert
|
||
>
|
||
<NAlert v-if="staleKeyframes" role="status" type="info" :show-icon="false" class="mt-4">
|
||
当前有
|
||
{{ staleKeyframes }}
|
||
个主首帧已过期。它们使用的主体参考资产已经变化,视频阶段会被阻止;可在下方逐镜重新生成,或使用“补齐
|
||
/ 更新主首帧”批量处理。
|
||
</NAlert>
|
||
<NAlert v-if="staleVideos" role="status" type="info" :show-icon="false" class="mt-4">
|
||
当前有
|
||
{{ staleVideos }}
|
||
个主视频已过期。它们使用的主首帧或主体参考图已经变化;非覆盖模式批量生成时会只重建这些过期主视频。
|
||
</NAlert>
|
||
<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>
|
||
<NProgress
|
||
:aria-label="`${stage.title}完成数量`"
|
||
type="line"
|
||
:show-indicator="false"
|
||
:height="4"
|
||
:percentage="
|
||
Math.min(
|
||
100,
|
||
Math.max(
|
||
0,
|
||
(stage.done / Math.max(1, Math.max(stage.data?.total ?? 0, 1))) * 100
|
||
)
|
||
)
|
||
"
|
||
class="mt-4"
|
||
></NProgress>
|
||
<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>
|
||
<p v-if="stage.key === 'keyframes' && staleKeyframes" class="mt-3 text-[11px] leading-5">
|
||
已过期 {{ staleKeyframes }} · 会按当前主体参考资产重新生成
|
||
</p>
|
||
<p v-if="stage.key === 'videos' && staleVideos" class="mt-3 text-[11px] leading-5">
|
||
主视频已过期 {{ staleVideos }} · 会按当前主首帧与参考资产重新生成
|
||
</p>
|
||
<p
|
||
v-if="stage.key === 'keyframes' && identityBlocked"
|
||
class="mt-3 text-[11px] leading-5 text-danger"
|
||
>
|
||
身份缺失 {{ query.data.value?.keyframes.missingIdentity ?? 0 }} · 母版缺失
|
||
{{ query.data.value?.keyframes.missingIdentityAnchor ?? 0 }} · 未锁定
|
||
{{ query.data.value?.keyframes.identityUnlocked ?? 0 }}
|
||
</p>
|
||
<ConfirmAction
|
||
class="mt-4"
|
||
:label="stage.action"
|
||
:disabled="blocked || !batchValid || !(stage.data?.ready ?? 0)"
|
||
acknowledgement
|
||
:description="
|
||
stage.key === 'videos'
|
||
? staleVideos && !force
|
||
? '当前存在过期主视频,非覆盖模式只会为这些镜头创建新的 Seedance 任务;新视频完成后会自动接替过期主视频。'
|
||
: '只为当前就绪镜头创建 Seedance 异步任务。创建成功不代表视频已经完成,任务会在后台继续运行。'
|
||
: stage.key === 'keyframes'
|
||
? '处理缺失主首帧和参考资产已变化的过期主首帧。过期镜头会按当前主体参考资产重新生成并恢复为可用于视频的主首帧;覆盖模式只新增候选。'
|
||
: '只处理通过视频提示词就绪检查的镜头;覆盖模式会重写已有提示词。'
|
||
"
|
||
: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></NCollapseItem
|
||
></NCollapse
|
||
>
|
||
<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="本集生产镜头">
|
||
<NButton
|
||
v-for="item in shots"
|
||
:key="item.shotId"
|
||
:aria-current="item.shotId === shot.shotId ? 'true' : undefined"
|
||
@click="selectedShot = item.shotId"
|
||
class="production-shot-link"
|
||
:class="{ selected: item.shotId === shot.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
|
||
v-if="
|
||
query.data.value?.keyframes.items.find(row => row.shotId === item.shotId)
|
||
?.primaryKeyframeStale
|
||
"
|
||
class="mt-2 flex items-center gap-2 text-[10px]"
|
||
>
|
||
<CircleAlert :size="12" />主首帧已过期 </span
|
||
><span
|
||
v-else-if="
|
||
query.data.value?.videos.items.find(row => row.shotId === item.shotId)?.primaryVideoStale
|
||
"
|
||
class="mt-2 flex items-center gap-2 text-[10px]"
|
||
>
|
||
<CircleAlert :size="12" />主视频已过期 </span
|
||
><span v-else 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></NButton
|
||
>
|
||
</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>
|
||
<NAlert
|
||
v-if="keyframeItem?.primaryKeyframeStale"
|
||
role="status"
|
||
type="info"
|
||
:show-icon="false"
|
||
class="mt-4 text-xs"
|
||
>
|
||
主首帧已过期:当前主体参考资产与生成该首帧时不一致。旧首帧会保留,但不能继续用于视频生成。
|
||
</NAlert>
|
||
<NAlert
|
||
v-if="videoItem?.primaryVideoStale"
|
||
role="status"
|
||
type="info"
|
||
:show-icon="false"
|
||
class="mt-4 text-xs"
|
||
>
|
||
主视频已过期:当前主首帧或主体参考图与生成该视频时不一致。旧视频仍保留,但需要重新生成后才能作为当前有效成片。
|
||
</NAlert>
|
||
<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></WorkspacePage
|
||
>
|
||
</template>
|