883 lines
47 KiB
Vue
883 lines
47 KiB
Vue
<script setup lang="ts">
|
||
import AppForm from '../../components/ui/AppForm.vue'
|
||
import { NFormItem } from 'naive-ui'
|
||
import { integerRule, sizeRules } from '../../lib/form-rules'
|
||
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
|
||
import WorkspaceTools from '../../components/ui/WorkspaceTools.vue'
|
||
import {
|
||
NScrollbar,
|
||
NCollapse,
|
||
NCollapseItem,
|
||
NAlert,
|
||
NButton,
|
||
NCheckbox,
|
||
NInputNumber,
|
||
NProgress,
|
||
NSelect
|
||
} from 'naive-ui'
|
||
import { computed, inject, ref, watch } from 'vue'
|
||
import { ArrowLeft, CheckCircle2, CircleAlert, Film, Image, MessageSquareText, RefreshCw } from '@lucide/vue'
|
||
import { EmptyState, StatusBadge } from '../../components/ui'
|
||
import BeatShotDirectory from '../storyboard/components/BeatShotDirectory.vue'
|
||
import ConfirmAction from '../workflows/ConfirmAction.vue'
|
||
import { productionStatusLabel } from './model'
|
||
import { useProduction } from './useProduction'
|
||
import ProductionReceipt from './components/ProductionReceipt.vue'
|
||
import ShotProductionAssets from './components/ShotProductionAssets.vue'
|
||
import AdvancedProduction from './components/AdvancedProduction.vue'
|
||
import StaleKeyframeNotice from './components/StaleKeyframeNotice.vue'
|
||
import { routeLocationKey } from 'vue-router'
|
||
import { queryText } from './asset-links'
|
||
import QualityDialog from './components/QualityDialog.vue'
|
||
import DetailDisclosure from '../../components/ui/DetailDisclosure.vue'
|
||
import CreativeProfileSettings from '../generation-config/components/CreativeProfileSettings.vue'
|
||
import EpisodeAssemblyPanel from './components/EpisodeAssemblyPanel.vue'
|
||
const qualityOpen = ref(false)
|
||
|
||
/** 镜头生产页将项目批处理与单镜头资产管理放在同一条可核验链路中。 */
|
||
const {
|
||
id,
|
||
selectedEpisode,
|
||
selectedShot,
|
||
episodeNo,
|
||
episodeOptions,
|
||
shots,
|
||
shot,
|
||
promptItem,
|
||
keyframeItem,
|
||
videoItem,
|
||
videoStatusItem,
|
||
query,
|
||
operation,
|
||
session,
|
||
concurrency,
|
||
force,
|
||
keyframeLimit,
|
||
keyframeWidth,
|
||
keyframeHeight,
|
||
videoLimit,
|
||
videoRepairAttempts,
|
||
keyframeValid,
|
||
videoQualityValid,
|
||
batchValid,
|
||
blocked,
|
||
run
|
||
} = useProduction()
|
||
const route = inject(routeLocationKey, undefined)
|
||
/** 从素材页返回时恢复到指定剧集与正式镜头,不按重复的 Shot 编号定位。 */
|
||
watch(
|
||
() => [route?.query.episodeNo, route?.query.shotId],
|
||
() => {
|
||
const episode = Number(queryText(route?.query.episodeNo))
|
||
selectedEpisode.value = Number.isSafeInteger(episode) && episode > 0 ? episode : undefined
|
||
selectedShot.value = queryText(route?.query.shotId)
|
||
},
|
||
{ immediate: true }
|
||
)
|
||
const linkedShotMissing = computed(
|
||
() =>
|
||
queryText(route?.query.shotId) &&
|
||
query.data.value &&
|
||
!shots.value.some(item => item.shotId === selectedShot.value)
|
||
)
|
||
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: '合并视觉风格、镜头规格和主体参考图,调用图片模型生成。',
|
||
icon: Image,
|
||
data: data?.keyframes,
|
||
done: data?.keyframes.skipped ?? 0,
|
||
action: force.value ? '为全部镜头新增首帧' : '补齐 / 更新主首帧'
|
||
},
|
||
{
|
||
key: 'videos' as const,
|
||
code: '03',
|
||
title: '视频成片',
|
||
description: '生成质量候选,完成后自动校验;失败时有限修复,通过后才晋升主视频。',
|
||
icon: Film,
|
||
data: data?.videos,
|
||
done: data?.videos.skipped ?? 0,
|
||
action: force.value
|
||
? '新增项目质量视频候选'
|
||
: staleVideos.value > 0
|
||
? `更新 ${staleVideos.value} 个过期主视频`
|
||
: '提交视频质量任务'
|
||
}
|
||
]
|
||
})
|
||
const selectedIssues = computed(() =>
|
||
[promptItem.value, keyframeItem.value, videoItem.value].flatMap(item => item?.issues ?? [])
|
||
)
|
||
const selectedKeyframeStale = computed(
|
||
() =>
|
||
!!keyframeItem.value?.primaryKeyframeStale ||
|
||
selectedIssues.value.some(issue => issue.code === 'stale_keyframe')
|
||
)
|
||
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()
|
||
}
|
||
/** 从质量回执定位本集正式镜头,关闭弹窗不会触发任何生成请求。 */
|
||
function locateQualityShot(shotId: string) {
|
||
selectedShot.value = shotId
|
||
qualityOpen.value = false
|
||
}
|
||
/** 新回执自动展开,避免折叠区隐藏部分失败诊断。 */
|
||
const toolsOpen = ref(false)
|
||
const overview = ref<string[]>(['overview'])
|
||
watch(
|
||
() => session.value.receipt,
|
||
receipt => {
|
||
if (receipt) {
|
||
overview.value = ['overview']
|
||
toolsOpen.value = true
|
||
}
|
||
}
|
||
)
|
||
watch(
|
||
() => session.value.pipelineReceipt,
|
||
receipt => {
|
||
if (receipt) toolsOpen.value = true
|
||
}
|
||
)
|
||
watch(
|
||
() => route?.query.configure,
|
||
value => {
|
||
if (value === 'profile') toolsOpen.value = true
|
||
},
|
||
{ immediate: true }
|
||
)
|
||
|
||
function profileSaved() {
|
||
operation.value.error = ''
|
||
}
|
||
const batchModel = computed(() => ({ concurrency: concurrency.value }))
|
||
const batchRules = { concurrency: integerRule('批量并发') }
|
||
const keyframeModel = computed(() => ({
|
||
keyframeLimit: keyframeLimit.value,
|
||
keyframeWidth: keyframeWidth.value,
|
||
keyframeHeight: keyframeHeight.value
|
||
}))
|
||
const dimensionRules = sizeRules(() => ({ width: keyframeWidth.value, height: keyframeHeight.value }))
|
||
const keyframeRules = {
|
||
keyframeLimit: integerRule('本批上限', 1, true),
|
||
keyframeWidth: dimensionRules.width,
|
||
keyframeHeight: dimensionRules.height
|
||
}
|
||
const videoModel = computed(() => ({ videoLimit: videoLimit.value, videoRepairAttempts: videoRepairAttempts.value }))
|
||
const videoRules = { videoLimit: integerRule('本批镜头上限'), videoRepairAttempts: integerRule('每镜最多自动修复') }
|
||
</script>
|
||
|
||
<template>
|
||
<WorkspacePage split compact class="production-workspace-page"
|
||
><template #header
|
||
><div class="workspace-toolbar">
|
||
<div class="toolbar-episode">
|
||
<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>
|
||
</div>
|
||
<span class="toolbar-summary"
|
||
>本集 {{ shots.length }} 镜<span v-if="videoRunning">
|
||
· {{ videoRunning }} 个视频任务进行中</span
|
||
></span
|
||
>
|
||
<div class="toolbar-actions">
|
||
<NButton :disabled="blocked || !episodeNo" @click="qualityOpen = true">首帧质量生成</NButton>
|
||
<NButton
|
||
:disabled="query.loading.value"
|
||
@click="query.refresh"
|
||
quaternary
|
||
class="icon-button"
|
||
aria-label="刷新就绪状态"
|
||
title="刷新就绪状态"
|
||
><RefreshCw :size="16" :class="{ 'animate-spin': query.loading.value }" />
|
||
</NButton>
|
||
<WorkspaceTools
|
||
v-model:open="toolsOpen"
|
||
title="项目生产总览与批量操作"
|
||
label="批量生产"
|
||
:has-receipt="!!session.receipt || !!session.pipelineReceipt"
|
||
>
|
||
<div class="production-tools-intro">
|
||
<div>
|
||
<p class="eyebrow">PRODUCTION</p>
|
||
<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="production-batch-config" aria-label="项目生产配置">
|
||
<div class="production-section-heading">
|
||
<div>
|
||
<h3 class="text-sm font-semibold">批量执行设置</h3>
|
||
<p class="mt-1 text-xs text-muted">控制本次手动批处理的并发与覆盖方式。</p>
|
||
</div>
|
||
</div>
|
||
<AppForm
|
||
:model="batchModel"
|
||
:rules="batchRules"
|
||
:disabled="operation.pending"
|
||
validate-on-change
|
||
>
|
||
<div class="form-controls production-controls">
|
||
<NFormItem path="concurrency" label="批量并发">
|
||
<NInputNumber
|
||
:input-props="{ 'aria-label': '批量并发' }"
|
||
:disabled="operation.pending"
|
||
:value="typeof concurrency === 'number' ? concurrency : null"
|
||
@update:value="concurrency = $event ?? 0"
|
||
:min="1"
|
||
:step="1"
|
||
></NInputNumber>
|
||
</NFormItem>
|
||
<NCheckbox
|
||
v-model:checked="force"
|
||
:disabled="operation.pending"
|
||
class="control-row-checkbox text-xs"
|
||
>覆盖模式:重写提示词/新增图视频候选
|
||
</NCheckbox>
|
||
</div>
|
||
</AppForm>
|
||
</section>
|
||
<div class="production-tool-stack">
|
||
<CreativeProfileSettings :project-id="id" @saved="profileSaved" />
|
||
<DetailDisclosure title="处理范围与覆盖规则" class="production-tool-section"
|
||
><p class="text-xs leading-6 text-muted">
|
||
提示词与视频就绪检查面向全项目;视频实际提交受本批镜头上限控制,并使用严格质量流水线。首帧默认补当前剧集,存在过期主首帧时优先更新全项目过期项,覆盖模式处理全项目;过期首帧成功后会自动接替旧主图,其余新增候选。图片模型、视频模型与视觉校验均可能产生费用。
|
||
</p></DetailDisclosure
|
||
>
|
||
<div class="workspace-overview-panel">
|
||
<NCollapse v-model:expanded-names="overview" class="production-tool-section"
|
||
><NCollapseItem name="overview" title="项目生产总览与批量操作"
|
||
><div class="overview-content">
|
||
<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.key === 'keyframes' && !keyframeValid) ||
|
||
(stage.key === 'videos' && !videoQualityValid) ||
|
||
!(stage.data?.ready ?? 0)
|
||
"
|
||
acknowledgement
|
||
:description="
|
||
stage.key === 'videos'
|
||
? staleVideos && !force
|
||
? `当前存在过期主视频,非覆盖模式只会为其中至多 ${videoLimit || '—'} 个镜头启动严格质量流水线;候选自动校验并有限修复,通过后才接替旧主视频。`
|
||
: `只为至多 ${videoLimit || '—'} 个就绪镜头启动严格质量流水线。任务创建后由后端自动校验,最多修复 ${videoRepairAttempts || '—'} 次,通过后才晋升主视频。`
|
||
: stage.key === 'keyframes'
|
||
? `处理缺失或过期主首帧。过期首帧成功后自动接替旧主图(覆盖模式也如此),其余已有主图只新增候选。本批上限:${keyframeLimit || '不限制'};尺寸:${keyframeWidth && keyframeHeight ? `${keyframeWidth} × ${keyframeHeight}` : '后端默认'}。`
|
||
: '只处理通过视频提示词就绪检查的镜头;覆盖模式会重写已有提示词。'
|
||
"
|
||
:primary="stage.key === 'videos'"
|
||
@confirm="run(stage.key)"
|
||
/>
|
||
<AppForm
|
||
v-if="stage.key === 'keyframes'"
|
||
:model="keyframeModel"
|
||
:rules="keyframeRules"
|
||
:disabled="operation.pending"
|
||
validate-on-change
|
||
class="mt-4 grid gap-3"
|
||
>
|
||
<NFormItem path="keyframeLimit" label="本批上限(可选)"
|
||
><NInputNumber
|
||
:input-props="{ 'aria-label': '本批上限(可选)' }"
|
||
:value="keyframeLimit === '' ? null : keyframeLimit"
|
||
@update:value="keyframeLimit = $event ?? ''"
|
||
:min="1"
|
||
:precision="0"
|
||
:disabled="operation.pending"
|
||
placeholder="留空不限制"
|
||
/></NFormItem>
|
||
<div class="grid grid-cols-2 gap-3">
|
||
<NFormItem path="keyframeWidth" label="宽度(像素)"
|
||
><NInputNumber
|
||
:input-props="{ 'aria-label': '宽度(像素)' }"
|
||
:value="keyframeWidth === '' ? null : keyframeWidth"
|
||
@update:value="keyframeWidth = $event ?? ''"
|
||
:min="1"
|
||
:disabled="operation.pending"
|
||
placeholder="默认" /></NFormItem
|
||
><NFormItem path="keyframeHeight" label="高度(像素)"
|
||
><NInputNumber
|
||
:input-props="{ 'aria-label': '高度(像素)' }"
|
||
:value="
|
||
keyframeHeight === '' ? null : keyframeHeight
|
||
"
|
||
@update:value="keyframeHeight = $event ?? ''"
|
||
:min="1"
|
||
:disabled="operation.pending"
|
||
placeholder="默认"
|
||
/></NFormItem>
|
||
</div>
|
||
</AppForm>
|
||
<AppForm
|
||
v-if="stage.key === 'videos'"
|
||
:model="videoModel"
|
||
:rules="videoRules"
|
||
:disabled="operation.pending"
|
||
validate-on-change
|
||
class="mt-4 grid gap-3"
|
||
>
|
||
<NFormItem path="videoLimit" label="本批镜头上限"
|
||
><NInputNumber
|
||
v-model:value="videoLimit"
|
||
:min="1"
|
||
:precision="0"
|
||
:disabled="operation.pending"
|
||
:input-props="{ 'aria-label': '视频质量任务镜头上限' }"
|
||
/></NFormItem>
|
||
<NFormItem path="videoRepairAttempts" label="每镜最多自动修复"
|
||
><NInputNumber
|
||
v-model:value="videoRepairAttempts"
|
||
:min="1"
|
||
:precision="0"
|
||
:disabled="operation.pending"
|
||
:input-props="{ 'aria-label': '视频自动修复次数' }"
|
||
/></NFormItem>
|
||
</AppForm>
|
||
</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="仅重新提交后端判定可重试的失败视频;不可重试项会跳过,并在回执中显示数量。"
|
||
@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>
|
||
<EpisodeAssemblyPanel :project-id="id" />
|
||
<AdvancedProduction />
|
||
</div>
|
||
</WorkspaceTools>
|
||
</div></div
|
||
></template>
|
||
<NScrollbar v-if="query.error.value" class="workspace-feedback"
|
||
><NAlert role="alert" type="error" :show-icon="false"
|
||
>{{ query.error.value
|
||
}}<span v-if="query.data.value"> 当前保留上次读取的数据,生产操作已暂停。</span></NAlert
|
||
></NScrollbar
|
||
>
|
||
<div v-if="staleKeyframes || staleVideos || identityBlocked" class="workspace-inline-status">
|
||
<span>过期首帧 {{ staleKeyframes }} · 过期视频 {{ staleVideos }} · 身份前置问题 {{ identityBlocked }}</span>
|
||
<NButton text size="small" @click="toolsOpen = true">查看处理方式</NButton>
|
||
</div>
|
||
<div v-if="shot" class="panel production-workspace">
|
||
<nav class="production-shot-list directory-panel" aria-label="本集生产镜头">
|
||
<div class="directory-heading">
|
||
<h3>
|
||
生产镜头 <span>{{ shots.length }} 镜</span>
|
||
</h3>
|
||
</div>
|
||
<NScrollbar
|
||
class="panel-scroll directory-scroll"
|
||
content-class="directory-list-content production-shot-list-content"
|
||
x-scrollable
|
||
>
|
||
<BeatShotDirectory
|
||
:shots="shots"
|
||
:active-id="shot.shotId"
|
||
@select="selectedShot = $event"
|
||
item-class="production-shot-link"
|
||
>
|
||
<template #meta="{ shot: item }"
|
||
><span
|
||
v-if="
|
||
query.data.value?.keyframes.items.find(row => row.shotId === item.shotId)
|
||
?.primaryKeyframeStale
|
||
"
|
||
class="directory-status"
|
||
>
|
||
<CircleAlert :size="12" />主首帧已过期 </span
|
||
><span
|
||
v-else-if="
|
||
query.data.value?.videos.items.find(row => row.shotId === item.shotId)
|
||
?.primaryVideoStale
|
||
"
|
||
class="directory-status"
|
||
>
|
||
<CircleAlert :size="12" />主视频已过期 </span
|
||
><span v-else class="directory-status">
|
||
<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></template
|
||
></BeatShotDirectory
|
||
>
|
||
</NScrollbar>
|
||
</nav>
|
||
<article class="production-detail min-w-0">
|
||
<NScrollbar class="panel-scroll" content-class="p-5 lg:p-7">
|
||
<header class="mb-5 pb-5">
|
||
<NAlert v-if="linkedShotMissing" type="warning" :show-icon="false" class="mb-4"
|
||
>链接中的镜头已不存在或不属于该剧集,请在左侧重新选择。</NAlert
|
||
>
|
||
<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="videoStatusItem?.status === 'failed'"
|
||
type="error"
|
||
:show-icon="false"
|
||
class="mt-3"
|
||
>
|
||
{{ videoStatusItem.error || '最近一次视频任务失败' }}
|
||
<p v-if="videoStatusItem.retryable === false" class="mt-2">
|
||
此失败不支持普通重试,请先处理失败原因。
|
||
</p>
|
||
<p v-else-if="videoStatusItem.retryable === true" class="mt-2">后端允许重试此任务。</p>
|
||
</NAlert>
|
||
<StaleKeyframeNotice
|
||
v-if="selectedKeyframeStale"
|
||
:key="shot.shotId"
|
||
:project-id="id"
|
||
:shot-id="shot.shotId"
|
||
:issues="selectedIssues"
|
||
:inconsistent="
|
||
keyframeItem?.primaryKeyframeStale === false &&
|
||
selectedIssues.some(issue => issue.code === 'stale_keyframe')
|
||
"
|
||
/>
|
||
<NAlert
|
||
v-if="videoItem?.primaryVideoStale"
|
||
role="status"
|
||
type="info"
|
||
:show-icon="false"
|
||
class="mt-4 text-xs"
|
||
>
|
||
主视频已过期:当前主首帧或主体参考图与生成该视频时不一致。旧视频仍保留,但需要重新生成后才能作为当前有效成片。
|
||
</NAlert>
|
||
</header>
|
||
<ShotProductionAssets
|
||
:key="shot.shotId"
|
||
:project-id="id"
|
||
:shot="shot"
|
||
:prompt-readiness="promptItem"
|
||
:keyframe-readiness="keyframeItem"
|
||
:video-readiness="videoItem"
|
||
:disabled="blocked || !!linkedShotMissing"
|
||
@changed="refreshProject"
|
||
/>
|
||
</NScrollbar>
|
||
</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>
|
||
<QualityDialog
|
||
v-model:open="qualityOpen"
|
||
:project-id="id"
|
||
:target="episodeNo ? { kind: 'batch', episodeNo, title: `第 ${episodeNo} 集首帧` } : null"
|
||
:disabled="blocked"
|
||
@changed="refreshProject"
|
||
@locate="locateQualityShot"
|
||
/>
|
||
</WorkspacePage>
|
||
</template>
|
||
|
||
<style>
|
||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||
@reference "../../styles/styles.css";
|
||
.production-tools-intro {
|
||
@apply flex flex-wrap items-end justify-between gap-4 p-[18px] bg-(--app-surface);
|
||
}
|
||
.production-tools-intro .eyebrow {
|
||
@apply mb-2;
|
||
}
|
||
.production-batch-config {
|
||
@apply mt-3 p-4 bg-(--app-surface);
|
||
}
|
||
.production-section-heading {
|
||
@apply flex items-start justify-between gap-4;
|
||
}
|
||
.workspace-tools-body .production-batch-config .production-controls {
|
||
@apply mt-4 mb-0;
|
||
}
|
||
.production-tool-stack {
|
||
@apply mt-3 px-4 bg-(--app-surface);
|
||
}
|
||
.production-tool-stack .production-tool-section.n-collapse {
|
||
@apply m-0;
|
||
box-shadow: inset 0 1px var(--app-border);
|
||
}
|
||
.production-tool-stack > :first-child.production-tool-section.n-collapse {
|
||
box-shadow: none;
|
||
}
|
||
.production-tool-stack .production-tool-section .n-collapse-item__header {
|
||
@apply min-h-12 py-0;
|
||
}
|
||
.production-tool-stack .production-tool-section .n-collapse-item__header-main {
|
||
@apply text-[13px] font-medium;
|
||
}
|
||
.production-tool-stack .production-tool-section .n-collapse-item__content-inner {
|
||
@apply pt-1 pb-4 pl-6;
|
||
}
|
||
.production-tool-stack .detail-disclosure-content {
|
||
@apply py-3 px-4 bg-(--app-subtle);
|
||
}
|
||
.production-tool-stack .workspace-overview-panel {
|
||
@apply m-0;
|
||
}
|
||
.production-controls {
|
||
@apply grid grid-cols-[minmax(220px,_1.6fr)_minmax(110px,_0.5fr)_minmax(230px,_1fr)_auto] items-end gap-[18px];
|
||
}
|
||
.production-pipeline {
|
||
@apply grid grid-cols-[repeat(3,_minmax(0,_1fr))] gap-3.5;
|
||
}
|
||
.production-pipeline-card {
|
||
@apply p-5;
|
||
}
|
||
.production-stats {
|
||
@apply grid grid-cols-[repeat(3,_1fr)] gap-2;
|
||
}
|
||
.production-stats div,
|
||
.production-status-grid div {
|
||
@apply p-2.5 rounded-none bg-(--app-subtle);
|
||
}
|
||
.production-stats dt,
|
||
.production-status-grid dt {
|
||
@apply text-muted text-[10px];
|
||
}
|
||
.production-stats dd,
|
||
.production-status-grid dd {
|
||
@apply mt-[5px] text-[13px] font-mono;
|
||
}
|
||
.production-status-grid {
|
||
@apply grid grid-cols-[repeat(5,_minmax(0,_1fr))] gap-2.5;
|
||
}
|
||
.production-workspace {
|
||
@apply grid grid-cols-[220px_minmax(0,_1fr)] items-start overflow-hidden;
|
||
}
|
||
.production-shot-list {
|
||
@apply max-h-[760px] overflow-hidden bg-(--app-surface) py-2;
|
||
}
|
||
@media (max-width: 1100px) {
|
||
.production-pipeline {
|
||
@apply grid-cols-[1fr];
|
||
}
|
||
.production-controls {
|
||
@apply grid-cols-[repeat(2,_minmax(0,_1fr))];
|
||
}
|
||
}
|
||
@media (max-width: 760px) {
|
||
.production-tools-intro,
|
||
.production-section-heading {
|
||
@apply items-start;
|
||
}
|
||
.production-tool-stack {
|
||
@apply px-3;
|
||
}
|
||
.production-tool-stack .production-tool-section .n-collapse-item__content-inner {
|
||
@apply pl-4;
|
||
}
|
||
.production-controls,
|
||
.production-workspace {
|
||
@apply grid-cols-[minmax(0,_1fr)];
|
||
}
|
||
.production-shot-list {
|
||
@apply flex max-h-none overflow-hidden;
|
||
border-right: none;
|
||
}
|
||
.production-status-grid {
|
||
@apply grid-cols-[repeat(2,_minmax(0,_1fr))];
|
||
}
|
||
}
|
||
.workspace-inline-status {
|
||
@apply flex items-center flex-wrap justify-between gap-y-2 gap-x-4 py-[7px] px-3 shrink-0 rounded-none bg-(--app-subtle) text-xs;
|
||
}
|
||
.production-pipeline-card > .confirm-action {
|
||
@apply mt-4 mb-1;
|
||
}
|
||
.production-workspace {
|
||
@apply flex-1 min-h-0 items-stretch overflow-hidden;
|
||
}
|
||
.production-workspace {
|
||
@apply grid-cols-[clamp(240px,_22%,_280px)_minmax(0,_1fr)];
|
||
}
|
||
.production-shot-list {
|
||
@apply max-h-none overflow-hidden min-h-0;
|
||
}
|
||
.production-workspace > article {
|
||
@apply overflow-hidden min-h-0 overscroll-contain;
|
||
}
|
||
.production-shot-list-content {
|
||
@apply gap-3 pt-0 px-0 pb-3;
|
||
}
|
||
.production-shot-list {
|
||
@apply p-0;
|
||
}
|
||
@media (max-width: 1000px) {
|
||
.production-controls {
|
||
@apply grid-cols-[minmax(130px,_1fr)_90px];
|
||
}
|
||
}
|
||
@media (max-width: 760px) {
|
||
/* 移动端隐藏可由镜头目录获知的重复统计,并让操作从左侧连续排列。 */
|
||
.production-workspace-page .workspace-toolbar {
|
||
@apply grid grid-cols-[minmax(0,_1fr)] gap-2;
|
||
}
|
||
.production-workspace-page .toolbar-episode,
|
||
.production-workspace-page .toolbar-actions {
|
||
@apply w-full min-w-0 m-0;
|
||
}
|
||
.production-workspace-page .toolbar-summary {
|
||
@apply hidden;
|
||
}
|
||
.production-workspace-page .toolbar-actions {
|
||
@apply justify-start;
|
||
}
|
||
.production-workspace {
|
||
@apply grid-cols-[minmax(0,_1fr)] grid-rows-[184px_minmax(0,_1fr)];
|
||
}
|
||
.production-pipeline {
|
||
@apply grid-cols-[1fr];
|
||
}
|
||
}
|
||
</style>
|