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>
+94
View File
@@ -0,0 +1,94 @@
import { request } from '../../lib/http'
import type { PromptBatchResult, StoryboardBatchInput } from '../storyboard/types'
import type {
GenerateKeyframeInput,
GenerateKeyframesInput,
GenerateVideosInput,
KeyframeBatchResult,
KeyframeReadiness,
KeyframeSpec,
ProjectVideoStatus,
PromptReadiness,
RetryVideosResult,
ShotKeyframe,
ShotVideo,
VideoBatchResult,
VideoGenerationSpec,
VideoReadiness
} from './types'
/** 项目路径统一编码,避免业务组件手工拼接 ID。 */
function projectPath(id: string) {
return `/projects/${encodeURIComponent(id)}`
}
/** 正式镜头路径统一编码,只接受数据库 Shot ID。 */
function shotPath(id: string) {
return `/storyboard-shots/${encodeURIComponent(id)}`
}
/** 生产链 API;生成请求不设置客户端短超时,也不自动重试。 */
export const productionApi = {
promptReadiness: (projectId: string, force: boolean, signal?: AbortSignal) =>
request<PromptReadiness>(`${projectPath(projectId)}/video-prompts/readiness?force=${force}`, { signal }),
generatePrompts: (projectId: string, input: StoryboardBatchInput) =>
request<PromptBatchResult>(`${projectPath(projectId)}/video-prompts/generate`, {
method: 'POST',
body: input,
timeoutMs: 0
}),
keyframeReadiness: (projectId: string, force: boolean, signal?: AbortSignal) =>
request<KeyframeReadiness>(`${projectPath(projectId)}/keyframes/readiness?force=${force}`, { signal }),
generateKeyframes: (projectId: string, input: GenerateKeyframesInput) =>
request<KeyframeBatchResult>(`${projectPath(projectId)}/keyframes/generate`, {
method: 'POST',
body: input,
timeoutMs: 0
}),
videoReadiness: (projectId: string, force: boolean, signal?: AbortSignal) =>
request<VideoReadiness>(`${projectPath(projectId)}/videos/readiness?force=${force}`, { signal }),
generateVideos: (projectId: string, input: GenerateVideosInput) =>
request<VideoBatchResult>(`${projectPath(projectId)}/videos/generate`, {
method: 'POST',
body: input,
timeoutMs: 0
}),
projectVideoStatus: (projectId: string, signal?: AbortSignal) =>
request<ProjectVideoStatus>(`${projectPath(projectId)}/videos/status`, { signal }),
retryVideos: (projectId: string, concurrency: number) =>
request<RetryVideosResult>(`${projectPath(projectId)}/videos/retry-failed`, {
method: 'POST',
body: { provider: 'seedance', concurrency },
timeoutMs: 0
}),
listKeyframes: (shotId: string, signal?: AbortSignal) =>
request<ShotKeyframe[]>(`${shotPath(shotId)}/keyframes`, { signal }),
keyframeSpec: (shotId: string, signal?: AbortSignal) =>
request<KeyframeSpec>(`${shotPath(shotId)}/keyframe-spec`, { signal }),
generateKeyframe: (shotId: string, input: GenerateKeyframeInput) =>
request<ShotKeyframe | null>(`${shotPath(shotId)}/keyframe`, {
method: 'POST',
body: input,
timeoutMs: 0
}),
setPrimaryKeyframe: (shotId: string, keyframeId: string) =>
request<ShotKeyframe | null>(`${shotPath(shotId)}/keyframes/${encodeURIComponent(keyframeId)}/primary`, {
method: 'PUT'
}),
videoGenerationSpec: (shotId: string, signal?: AbortSignal) =>
request<VideoGenerationSpec>(`${shotPath(shotId)}/video-generation-spec`, { signal }),
listVideos: (shotId: string, signal?: AbortSignal) =>
request<ShotVideo[]>(`${shotPath(shotId)}/videos`, { signal }),
generateVideo: (shotId: string) =>
request<ShotVideo | null>(`${shotPath(shotId)}/videos`, {
method: 'POST',
body: { provider: 'seedance' },
timeoutMs: 0
}),
refreshVideo: (videoId: string, signal?: AbortSignal) =>
request<ShotVideo | null>(`/storyboard-shot-videos/${encodeURIComponent(videoId)}/status`, { signal }),
setPrimaryVideo: (shotId: string, videoId: string) =>
request<ShotVideo | null>(`${shotPath(shotId)}/videos/${encodeURIComponent(videoId)}/primary`, {
method: 'PUT'
})
}
@@ -0,0 +1,77 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { AppDialog } from '../../../components/ui'
import { validOptionalSize } from '../model'
import type { GenerateKeyframeInput, ShotKeyframe } from '../types'
/** 单镜头首帧表单;是否替换主首帧必须由用户明确选择。 */
const props = defineProps<{ shotId: string; shotTitle: string; keyframes: ShotKeyframe[]; disabled: boolean }>()
const open = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{ generate: [input: GenerateKeyframeInput] }>()
const width = ref<number | ''>('')
const height = ref<number | ''>('')
const setPrimary = ref(false)
const confirmed = ref(false)
const hasPrimary = computed(() => props.keyframes.some(item => item.isPrimary && item.status === 'completed'))
const sizeValid = computed(() => validOptionalSize(width.value, height.value))
const canSubmit = computed(() => !props.disabled && sizeValid.value && confirmed.value)
/** 切换镜头或重新打开时清空费用确认,避免沿用上个镜头的覆盖选择。 */
function reset() {
width.value = ''
height.value = ''
setPrimary.value = !hasPrimary.value
confirmed.value = false
}
watch([() => props.shotId, open], reset, { immediate: true })
/** 只发送后端允许的字段,未填写尺寸时使用 Provider 默认值。 */
function submit() {
if (!canSubmit.value) return
emit('generate', {
provider: 'seedream',
...(width.value === '' ? {} : { width: width.value, height: height.value as number }),
setPrimary: setPrimary.value
})
open.value = false
reset()
}
</script>
<template>
<AppDialog
v-model:open="open"
:title="`生成首帧 · ${shotTitle}`"
description="依据视觉风格、GenerationSpec 和主体主参考图编译首帧提示词,并调用 Seedream。"
>
<div class="mt-5 grid grid-cols-2 gap-4">
<label
><span class="field-label">宽度可选</span
><input v-model.number="width" class="input" type="number" min="1"
/></label>
<label
><span class="field-label">高度可选</span
><input v-model.number="height" class="input" type="number" min="1"
/></label>
</div>
<p v-if="!sizeValid" class="mt-2 text-xs text-danger">宽高需要同时留空或同时填写正整数</p>
<label class="mt-5 flex items-start gap-3 text-sm leading-6">
<input v-model="setPrimary" class="mt-1 accent-accent" type="checkbox" />生成成功后设为当前主首帧
</label>
<p v-if="hasPrimary && setPrimary" class="alert mt-3 text-xs">
当前已有主首帧新图成功后将成为视频生成使用的首帧旧图仍保留为候选
</p>
<label class="mt-5 flex items-start gap-3 text-sm leading-6">
<input
id="confirm-keyframe-cost"
v-model="confirmed"
class="mt-1 accent-accent"
type="checkbox"
/>我已确认调用 Seedream 可能产生费用且当前没有相同镜头的生图任务
</label>
<div class="dialog-footer mt-6">
<button class="button button-secondary" @click="open = false">取消</button>
<button class="button button-primary" :disabled="!canSubmit" @click="submit">确认生成首帧</button>
</div>
</AppDialog>
</template>
@@ -0,0 +1,52 @@
<script setup lang="ts">
import { Download } from '@lucide/vue'
import { downloadText } from '../../../lib/format'
import type { ProductionReceipt } from '../types'
/** 批量回执只描述本次请求,不能替代持续轮询的数据库状态。 */
const props = defineProps<{ receipt: ProductionReceipt }>()
/** 导出完整回执,保留逐镜失败 ID 供后端排障。 */
function exportReceipt() {
downloadText('shot-production-receipt.json', JSON.stringify(props.receipt, null, 2), 'application/json')
}
</script>
<template>
<section class="panel mt-4 p-5" aria-label="镜头生产回执">
<div class="flex flex-wrap items-center justify-between gap-3">
<h3 class="text-sm font-medium">{{ receipt.title }} · 本次回执</h3>
<button class="text-button" @click="exportReceipt"><Download :size="13" />导出诊断</button>
</div>
<p v-if="receipt.kind === 'prompts'" class="mt-3 text-xs text-muted">
全项目 {{ receipt.result.total }} · 本次目标 {{ receipt.result.targetCount }} · 已生成
{{ receipt.result.generated }} · 跳过 {{ receipt.result.skipped }} · 失败 {{ receipt.result.failed }}
</p>
<p v-else-if="receipt.kind === 'keyframes'" class="mt-3 text-xs text-muted">
全项目 {{ receipt.result.total }} · 本次目标 {{ receipt.result.targetCount }} · 已生成
{{ receipt.result.generated }} · 跳过 {{ receipt.result.skipped }} · 阻塞 {{ receipt.result.blocked }} ·
失败
{{ receipt.result.failed }}
</p>
<p v-else-if="receipt.kind === 'videos'" class="mt-3 text-xs text-muted">
全项目 {{ receipt.result.total }} · 本次目标 {{ receipt.result.targetCount }} · 已创建任务
{{ receipt.result.created }} · 跳过 {{ receipt.result.skipped }} · 创建失败 {{ receipt.result.failed }}
</p>
<p v-else class="mt-3 text-xs text-muted">
检测到 {{ receipt.result.totalFailed }} 个失败镜头 · 已重新提交 {{ receipt.result.retried }} · 提交失败
{{ receipt.result.failed }}
</p>
<p v-if="receipt.kind === 'videos' && receipt.result.created" class="alert mt-3" role="status">
视频任务已经创建但尚未代表成片完成后台会继续查询 Seedance以下方任务状态和视频资产为准
</p>
<p v-if="receipt.result.failed" class="alert alert-error mt-3" role="alert">
本次存在部分失败成功提交或生成的结果不会回滚请查看逐镜错误后再重试
</p>
<ul v-if="receipt.result.failures.length" class="mt-3 space-y-2 text-xs text-danger">
<li v-for="failure in receipt.result.failures" :key="failure.shotId">
<code>{{ failure.shotId }}</code
>{{ failure.error }}
</li>
</ul>
</section>
</template>
@@ -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>
+4
View File
@@ -0,0 +1,4 @@
/** 镜头生产功能的公共导出入口。 */
export { productionApi } from './api'
export * from './model'
export type * from './types'
+68
View File
@@ -0,0 +1,68 @@
import { reactive } from 'vue'
import type { ProductionIssueCode, ProductionSession, ShotKeyframe, ShotVideo } from './types'
/** 按项目隔离生产回执,切换页面后仍能查看上一次批量操作结果。 */
const sessions = reactive<Record<string, ProductionSession>>({})
/** 取得项目的生产页会话容器。 */
export function getProductionSession(projectId: string): ProductionSession {
return (sessions[projectId] ??= { receipt: null })
}
/** 生成规格尺寸必须成对留空或成对填写正整数。 */
export function validOptionalSize(width: number | '', height: number | ''): boolean {
if (width === '' && height === '') return true
return (
Number.isSafeInteger(width) &&
Number.isSafeInteger(height) &&
typeof width === 'number' &&
typeof height === 'number' &&
width > 0 &&
height > 0
)
}
/** 返回当前成功主首帧;异常的重复主图只取最新列表中的第一条。 */
export function primaryKeyframe(items: ShotKeyframe[]): ShotKeyframe | undefined {
return items.find(item => item.isPrimary && item.status === 'completed' && item.imageUrl)
}
/** 返回当前成功主视频。 */
export function primaryVideo(items: ShotVideo[]): ShotVideo | undefined {
return items.find(item => item.isPrimary && item.status === 'completed' && item.videoUrl)
}
/** 活动视频状态与后端重复任务保护保持一致。 */
export function isActiveVideo(item: ShotVideo): boolean {
return ['pending', 'queued', 'running'].includes(item.status)
}
/** 就绪问题转为短标签,后端详细 reason 仍在页面原样显示。 */
export function issueLabel(code: ProductionIssueCode): string {
const labels: Record<ProductionIssueCode, string> = {
invalid_generation_spec: '生成规格不完整',
missing_visual_style: '缺少视觉风格',
missing_reference: '缺少主体参考图',
missing_prompt: '缺少视频提示词',
missing_keyframe: '缺少主首帧'
}
return labels[code]
}
/** 生产状态中文标签,未知值由状态组件回退显示原文。 */
export function productionStatusLabel(status: string): string {
const labels: Record<string, string> = {
ready: '已就绪',
skipped: '已有结果',
in_progress: '任务进行中',
blocked: '前置条件不足',
pending: '等待提交',
queued: '排队中',
running: '生成中',
completed: '已完成',
failed: '失败',
cancelled: '已取消',
not_started: '未开始'
}
return labels[status] ?? status
}
+132
View File
@@ -0,0 +1,132 @@
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mediaAssetUrl } from '../../lib/assets'
import KeyframeDialog from './components/KeyframeDialog.vue'
import { productionApi } from './api'
import {
isActiveVideo,
issueLabel,
primaryKeyframe,
primaryVideo,
productionStatusLabel,
validOptionalSize
} from './model'
import { keyframeFixture, videoFixture } from './testing/fixtures'
let wrapper: VueWrapper | undefined
afterEach(() => {
wrapper?.unmount()
wrapper = undefined
document.body.innerHTML = ''
vi.unstubAllGlobals()
})
/** 从 Reka Portal 中查找精确按钮。 */
function button(label: string): HTMLButtonElement {
const item = [...document.querySelectorAll('button')].find(element => element.textContent?.trim() === label)
if (!item) throw new Error(`缺少按钮 ${label}`)
return item
}
describe('镜头生产数据契约', () => {
it('可选尺寸必须成对留空或填写正整数', () => {
expect(validOptionalSize('', '')).toBe(true)
expect(validOptionalSize(1920, 1080)).toBe(true)
expect(validOptionalSize(1920, '')).toBe(false)
expect(validOptionalSize('', 1080)).toBe(false)
expect(validOptionalSize(0, 1080)).toBe(false)
expect(validOptionalSize(10.5, 1080)).toBe(false)
})
it('主资产只接受已完成且具有地址的记录,活动视频覆盖三种状态', () => {
expect(primaryKeyframe([keyframeFixture()])?.id).toBe('keyframe-1')
expect(primaryKeyframe([keyframeFixture({ status: 'failed' })])).toBeUndefined()
expect(primaryVideo([videoFixture()])?.id).toBe('video-1')
expect(primaryVideo([videoFixture({ videoUrl: null })])).toBeUndefined()
for (const status of ['pending', 'queued', 'running'] as const)
expect(isActiveVideo(videoFixture({ status }))).toBe(true)
expect(isActiveVideo(videoFixture())).toBe(false)
})
it('就绪问题和异步任务状态提供中文标签', () => {
expect(issueLabel('missing_keyframe')).toBe('缺少主首帧')
expect(issueLabel('invalid_generation_spec')).toBe('生成规格不完整')
expect(productionStatusLabel('in_progress')).toBe('任务进行中')
expect(productionStatusLabel('unknown')).toBe('unknown')
})
it('视频地址与图片使用相同的安全协议限制', () => {
expect(mediaAssetUrl('/storage/videos/a.mp4')).toContain('/storage/videos/a.mp4')
expect(mediaAssetUrl('https://cdn.example.com/a.mp4')).toBe('https://cdn.example.com/a.mp4')
expect(mediaAssetUrl('javascript:alert(1)')).toBeNull()
expect(mediaAssetUrl('/storage/../admin')).toBeNull()
})
it('项目接口传递 force、Provider 与并发,视频创建不冒充同步完成', async () => {
const fetcher = vi.fn<typeof fetch>().mockImplementation(async url => {
const path = String(url)
if (path.includes('/readiness'))
return new Response(
JSON.stringify({
data: {
total: 1,
ready: 1,
skipped: 0,
inProgress: 0,
blocked: 0,
missingPrompt: 0,
missingKeyframe: 0,
missingReference: 0,
items: []
}
})
)
return new Response(
JSON.stringify({
data: {
total: 1,
targetCount: 1,
created: 1,
skipped: 0,
readiness: {},
failed: 0,
failures: []
}
})
)
})
vi.stubGlobal('fetch', fetcher)
await productionApi.videoReadiness('project/1', true)
await productionApi.generateVideos('project/1', { provider: 'seedance', concurrency: 3, force: true })
expect(fetcher.mock.calls[0]?.[0]).toBe('/api/projects/project%2F1/videos/readiness?force=true')
expect(fetcher.mock.calls[1]?.[0]).toBe('/api/projects/project%2F1/videos/generate')
expect(JSON.parse(String(fetcher.mock.calls[1]?.[1]?.body))).toEqual({
provider: 'seedance',
concurrency: 3,
force: true
})
})
it('首个首帧默认设主图,已有主图时默认只新增候选,并要求费用确认', async () => {
wrapper = mount(KeyframeDialog, {
attachTo: document.body,
props: { open: true, shotId: 'shot-1', shotTitle: '开场', keyframes: [], disabled: false }
})
await flushPromises()
expect(button('确认生成首帧').disabled).toBe(true)
document.querySelector<HTMLInputElement>('#confirm-keyframe-cost')!.click()
await flushPromises()
button('确认生成首帧').click()
await flushPromises()
expect(wrapper.emitted('generate')).toEqual([[{ provider: 'seedream', setPrimary: true }]])
await wrapper.setProps({ open: false })
await flushPromises()
await wrapper.setProps({ open: true, shotId: 'shot-2', keyframes: [keyframeFixture({ shotId: 'shot-2' })] })
await flushPromises()
document.querySelector<HTMLInputElement>('#confirm-keyframe-cost')!.click()
await flushPromises()
button('确认生成首帧').click()
expect(wrapper.emitted('generate')?.at(-1)).toEqual([{ provider: 'seedream', setPrimary: false }])
})
})
@@ -0,0 +1,44 @@
import type { ShotKeyframe, ShotVideo } from '../types'
/** 首帧测试记录,覆盖主图、候选与失败状态时只需覆写关心字段。 */
export function keyframeFixture(patch: Partial<ShotKeyframe> = {}): ShotKeyframe {
return {
id: 'keyframe-1',
shotId: 'shot-1',
source: 'generated',
provider: 'seedream',
model: 'seedream-test',
prompt: '首帧提示词',
imageUrl: '/storage/keyframes/keyframe-1.png',
width: 1920,
height: 1080,
status: 'completed',
isPrimary: true,
providerTaskId: null,
error: null,
createdAt: '2026-08-31T00:00:00.000Z',
updatedAt: '2026-08-31T00:00:00.000Z',
...patch
}
}
/** 视频测试记录,默认是已完成的主视频。 */
export function videoFixture(patch: Partial<ShotVideo> = {}): ShotVideo {
return {
id: 'video-1',
shotId: 'shot-1',
provider: 'seedance',
model: 'seedance-test',
providerTaskId: 'provider-task-1',
prompt: '视频提示词',
negativePrompt: null,
status: 'completed',
videoUrl: '/storage/videos/video-1.mp4',
durationSeconds: 5,
isPrimary: true,
error: null,
createdAt: '2026-08-31T00:00:00.000Z',
updatedAt: '2026-08-31T00:00:00.000Z',
...patch
}
}
+239
View File
@@ -0,0 +1,239 @@
/** 项目生产链的就绪状态;进行中只会出现在视频阶段。 */
export type ProductionReadinessStatus = 'ready' | 'skipped' | 'in_progress' | 'blocked'
/** 三个阶段可能返回的正式问题代码。 */
export type ProductionIssueCode =
| 'invalid_generation_spec'
| 'missing_visual_style'
| 'missing_reference'
| 'missing_prompt'
| 'missing_keyframe'
/** 就绪检查中的具体阻塞原因;部分参考图问题会附带主体引用。 */
export interface ProductionIssue {
code: ProductionIssueCode
reason: string
missingSubjects?: string[]
}
/** 视频提示词就绪检查的逐镜结果。 */
export interface PromptReadinessItem {
shotId: string
shotNo: number
status: Exclude<ProductionReadinessStatus, 'in_progress'>
issues: ProductionIssue[]
}
/** 视频提示词项目级就绪汇总。 */
export interface PromptReadiness {
total: number
ready: number
skipped: number
blocked: number
invalidGenerationSpec: number
missingReference: number
items: PromptReadinessItem[]
}
/** 镜头首帧就绪检查的逐镜结果。 */
export interface KeyframeReadinessItem extends PromptReadinessItem {
primaryKeyframeId?: string | null
}
/** 镜头首帧项目级就绪汇总。 */
export interface KeyframeReadiness extends PromptReadiness {
missingVisualStyle: number
items: KeyframeReadinessItem[]
}
/** 视频任务就绪检查的逐镜结果。 */
export interface VideoReadinessItem {
shotId: string
shotNo: number
status: ProductionReadinessStatus
issues: ProductionIssue[]
primaryVideoId?: string | null
activeVideoId?: string | null
}
/** 视频任务项目级就绪汇总。 */
export interface VideoReadiness {
total: number
ready: number
skipped: number
inProgress: number
blocked: number
missingPrompt: number
missingKeyframe: number
missingReference: number
items: VideoReadinessItem[]
}
/** 首帧生成记录;成功图片与失败历史都保留。 */
export interface ShotKeyframe {
id: string
shotId: string
source: string
provider: string | null
model: string | null
prompt: string | null
imageUrl: string | null
width: number | null
height: number | null
status: 'pending' | 'generating' | 'completed' | 'failed'
isPrimary: boolean
providerTaskId: string | null
error: string | null
createdAt: string
updatedAt: string
}
/** 视频生成记录;完成后地址指向后端持久化的视频资产。 */
export interface ShotVideo {
id: string
shotId: string
provider: string
model: string
providerTaskId: string | null
prompt: string
negativePrompt: string | null
status: 'pending' | 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'
videoUrl: string | null
durationSeconds: number | null
isPrimary: boolean
error: string | null
createdAt: string
updatedAt: string
}
/** 首帧生成规格中的主体参考图。 */
export interface KeyframeReference {
subjectId: string
subjectRef: string
module: string
subjectFormId: string
imageId: string
imageUrl: string
providerImageUrl?: string
}
/** 后端确定性编译的首帧生成规格,不会在查询时调用图片模型。 */
export interface KeyframeSpec {
projectId: string
shotId: string
episodeNo: number
beatNo: number
shotNo: number
prompt: string
references: KeyframeReference[]
}
/** 视频生成规格中的主体参考图。 */
export interface VideoReference extends KeyframeReference {
subjectName: string
subjectFormName: string
}
/** 后端实际提交 Seedance 前编译的完整视频生成规格。 */
export interface VideoGenerationSpec {
projectId: string
shotId: string
episodeNo: number
beatNo: number
shotNo: number
durationSeconds: number
videoPrompt: string
negativePrompt?: string
keyframe: { id: string; imageUrl: string; width?: number; height?: number }
references: VideoReference[]
}
/** 项目首帧批量生成参数。 */
export interface GenerateKeyframesInput {
provider: 'seedream'
concurrency: number
force: boolean
width?: number
height?: number
}
/** 单镜头首帧生成参数。 */
export interface GenerateKeyframeInput {
provider: 'seedream'
width?: number
height?: number
setPrimary: boolean
}
/** 首帧批量生成回执;详细阻塞原因应配合 readiness 查看。 */
export interface KeyframeBatchResult {
total: number
targetCount: number
generated: number
skipped: number
blocked: number
missingReference: number
failed: number
failures: { shotId: string; error: string }[]
}
/** 项目视频批量生成参数。 */
export interface GenerateVideosInput {
provider: 'seedance'
concurrency: number
force: boolean
}
/** 视频批量任务创建回执;created 只表示已提交任务,不表示视频已完成。 */
export interface VideoBatchResult {
total: number
targetCount: number
created: number
skipped: number
readiness: Omit<VideoReadiness, 'total' | 'items'>
failed: number
failures: { shotId: string; error: string }[]
}
/** 项目最近一次视频任务的逐镜状态。 */
export interface ProjectVideoStatusItem {
shotId: string
shotNo: number
status: ShotVideo['status'] | 'not_started'
videoId: string | null
videoUrl: string | null
error: string | null
}
/** 项目视频任务状态汇总;与 readiness 的前置条件统计不同。 */
export interface ProjectVideoStatus {
total: number
completed: number
queued: number
running: number
failed: number
cancelled: number
pending: number
notStarted: number
items: ProjectVideoStatusItem[]
}
/** 失败视频批量重试回执。 */
export interface RetryVideosResult {
totalFailed: number
retried: number
failed: number
failures: { shotId: string; error: string }[]
}
/** 页面回执按生成阶段区分,避免把已创建任务误认为已完成资产。 */
export type ProductionReceipt =
| { kind: 'prompts'; title: string; result: import('../storyboard/types').PromptBatchResult }
| { kind: 'keyframes'; title: string; result: KeyframeBatchResult }
| { kind: 'videos'; title: string; result: VideoBatchResult }
| { kind: 'retry'; title: string; result: RetryVideosResult }
/** 项目生产页的浏览器会话数据。 */
export interface ProductionSession {
receipt: ProductionReceipt | null
}
+143
View File
@@ -0,0 +1,143 @@
import { computed, ref } from 'vue'
import { usePolling } from '../../composables/usePolling'
import { useProjectContext } from '../projects/context'
import { mergeDesignedShots } from '../storyboard/model'
import { storyboardApi } from '../storyboard/api'
import { breakdownSnapshot } from '../workflows/selectors'
import { getOperation, runOperation } from '../workflows/operations'
import { productionApi } from './api'
import { getProductionSession } from './model'
/** 项目级生产动作;每次请求都由统一项目锁防止重复提交。 */
export type ProductionCommand = 'prompts' | 'keyframes' | 'videos' | 'retry-videos'
/** 镜头生产页的查询、筛选、就绪状态与批量操作。 */
export function useProduction() {
const context = useProjectContext()
const id = computed(() => context.project.value?.id ?? '')
const selectedEpisode = ref<number>()
const selectedShot = ref('')
const concurrency = ref(2)
const force = ref(false)
const snapshot = computed(() => breakdownSnapshot(context.checkpoints.value))
const sourceEpisodes = computed(
() => snapshot.value?.storyboardEpisodeShots ?? snapshot.value?.breakdownResult?.storyboardEpisodeShots ?? []
)
const episodeOptions = computed(() => {
const entries = new Map(context.project.value?.episodes.map(episode => [episode.episode, episode.title]) ?? [])
for (const episode of sourceEpisodes.value) {
if (!entries.has(episode.episodeNo)) entries.set(episode.episodeNo, episode.episodePlan.episodeTitle)
}
return [...entries]
.map(([episodeNo, title]) => ({ episodeNo, title }))
.toSorted((a, b) => a.episodeNo - b.episodeNo)
})
const episodeNo = computed(
() =>
episodeOptions.value.find(item => item.episodeNo === selectedEpisode.value)?.episodeNo ??
episodeOptions.value[0]?.episodeNo ??
0
)
const queryKey = computed(() => JSON.stringify([id.value, episodeNo.value, force.value]))
const query = usePolling(queryKey, async (key, signal) => {
const [projectId, number, overwrite] = JSON.parse(key) as [string, number, boolean]
if (!projectId || !number) return null
const [directions, prompts, keyframes, videos, videoStatus] = await Promise.all([
storyboardApi.directions(projectId, number, signal),
productionApi.promptReadiness(projectId, overwrite, signal),
productionApi.keyframeReadiness(projectId, overwrite, signal),
productionApi.videoReadiness(projectId, overwrite, signal),
productionApi.projectVideoStatus(projectId, signal)
])
if (directions.projectId !== projectId || directions.episodeNo !== number)
throw new Error('镜头生产查询返回了不匹配的项目或剧集,请刷新后重试。')
return { directions, prompts, keyframes, videos, videoStatus }
})
const source = computed(() => sourceEpisodes.value.find(item => item.episodeNo === episodeNo.value))
const shots = computed(() => mergeDesignedShots(query.data.value?.directions ?? null, null, source.value))
const shot = computed(() => shots.value.find(item => item.shotId === selectedShot.value) ?? shots.value[0])
const promptItem = computed(() => query.data.value?.prompts.items.find(item => item.shotId === shot.value?.shotId))
const keyframeItem = computed(() =>
query.data.value?.keyframes.items.find(item => item.shotId === shot.value?.shotId)
)
const videoItem = computed(() => query.data.value?.videos.items.find(item => item.shotId === shot.value?.shotId))
const videoStatusItem = computed(() =>
query.data.value?.videoStatus.items.find(item => item.shotId === shot.value?.shotId)
)
const operation = computed(() => getOperation(id.value))
const session = computed(() => getProductionSession(id.value))
const batchValid = computed(() => Number.isSafeInteger(concurrency.value) && concurrency.value > 0)
const blocked = computed(
() =>
operation.value.pending ||
!!context.error.value ||
!!query.error.value ||
!query.data.value ||
context.project.value?.status === 'generating'
)
/** 批量操作只向就绪镜头提交;最终资产状态由轮询查询确认。 */
async function run(command: ProductionCommand) {
if (blocked.value || !batchValid.value) return
const projectId = id.value
const input = { concurrency: concurrency.value, force: force.value }
const target = getProductionSession(projectId)
target.receipt = null
const labels: Record<ProductionCommand, string> = {
prompts: force.value ? '重生成项目视频提示词' : '补齐项目视频提示词',
keyframes: force.value ? '新增项目首帧候选' : '补齐项目主首帧',
videos: force.value ? '新增项目视频候选' : '提交就绪视频任务',
'retry-videos': '重试失败视频任务'
}
await runOperation(projectId, labels[command], async () => {
if (command === 'prompts') {
target.receipt = {
kind: 'prompts',
title: labels[command],
result: await productionApi.generatePrompts(projectId, input)
}
} else if (command === 'keyframes') {
target.receipt = {
kind: 'keyframes',
title: labels[command],
result: await productionApi.generateKeyframes(projectId, { provider: 'seedream', ...input })
}
} else if (command === 'videos') {
target.receipt = {
kind: 'videos',
title: labels[command],
result: await productionApi.generateVideos(projectId, { provider: 'seedance', ...input })
}
} else {
target.receipt = {
kind: 'retry',
title: labels[command],
result: await productionApi.retryVideos(projectId, concurrency.value)
}
}
})
await query.refresh()
}
return {
id,
selectedEpisode,
selectedShot,
episodeNo,
episodeOptions,
shots,
shot,
promptItem,
keyframeItem,
videoItem,
videoStatusItem,
query,
operation,
session,
concurrency,
force,
batchValid,
blocked,
run
}
}
+13 -1
View File
@@ -1,7 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, provide } from 'vue' import { computed, provide } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { ArrowLeft, RefreshCw, FileText, Layers, Camera, Images, Palette, Fingerprint, LoaderCircle } from '@lucide/vue' import {
ArrowLeft,
RefreshCw,
FileText,
Layers,
Camera,
Images,
Palette,
Fingerprint,
LoaderCircle,
Clapperboard
} from '@lucide/vue'
import { StatusBadge } from '../../components/ui' import { StatusBadge } from '../../components/ui'
import { projectContextKey, useProjectData } from './context' import { projectContextKey, useProjectData } from './context'
import { getOperation } from '../workflows/operations' import { getOperation } from '../workflows/operations'
@@ -45,6 +56,7 @@ const operation = computed(() => getOperation(id.value))
<RouterLink :to="`/projects/${id}/subject-identity`"><Fingerprint :size="17" />主体身份</RouterLink> <RouterLink :to="`/projects/${id}/subject-identity`"><Fingerprint :size="17" />主体身份</RouterLink>
<RouterLink :to="`/projects/${id}/subject-images`"><Images :size="17" />形态图片</RouterLink> <RouterLink :to="`/projects/${id}/subject-images`"><Images :size="17" />形态图片</RouterLink>
<RouterLink :to="`/projects/${id}/storyboard`"><Camera :size="17" />分镜设计</RouterLink> <RouterLink :to="`/projects/${id}/storyboard`"><Camera :size="17" />分镜设计</RouterLink>
<RouterLink :to="`/projects/${id}/production`"><Clapperboard :size="17" />镜头生产</RouterLink>
</nav> </nav>
<p v-if="context.error.value" class="alert alert-error mt-4" role="alert"> <p v-if="context.error.value" class="alert alert-error mt-4" role="alert">
{{ context.error.value }}<span v-if="context.project.value"> 当前保留上次成功读取的数据</span> {{ context.error.value }}<span v-if="context.project.value"> 当前保留上次成功读取的数据</span>
+3 -2
View File
@@ -194,8 +194,9 @@ function exportEpisode() {
<details class="mt-3 border-t border-line pt-3"> <details class="mt-3 border-t border-line pt-3">
<summary class="cursor-pointer text-xs font-medium">视频提示词批量操作</summary> <summary class="cursor-pointer text-xs font-medium">视频提示词批量操作</summary>
<p class="my-3 text-xs leading-6 text-muted"> <p class="my-3 text-xs leading-6 text-muted">
对项目全部镜头操作复用上方并发和覆盖配置需要主体参考图后端目前尚未将 GenerationSpec 接入 后端会先校验 GenerationSpec
Prompt 生成这里只生成提示词不提交视频任务 与主体参考图这里只生成提示词首帧视频任务就绪诊断与失败重试已整理到
<RouterLink :to="`/projects/${id}/production`" class="text-button mx-1">镜头生产</RouterLink>
</p> </p>
<ConfirmAction <ConfirmAction
:label="force ? '重生成全部视频提示词' : '补齐项目视频提示词'" :label="force ? '重生成全部视频提示词' : '补齐项目视频提示词'"
+6 -1
View File
@@ -1,5 +1,5 @@
/** 只允许 HTTP(S) 与后端 /storage 路径;生产环境绝对 API 地址使用其源站。 */ /** 只允许 HTTP(S) 与后端 /storage 路径;生产环境绝对 API 地址使用其源站。 */
export function referenceImageUrl(value: string): string | null { export function mediaAssetUrl(value: string): string | null {
const raw = value.trim() const raw = value.trim()
try { try {
if (raw.startsWith('/storage/') && !raw.includes('\\')) { if (raw.startsWith('/storage/') && !raw.includes('\\')) {
@@ -14,3 +14,8 @@ export function referenceImageUrl(value: string): string | null {
return null return null
} }
} }
/** 图片组件保留语义化入口,底层与视频共用同一套资源地址校验。 */
export function referenceImageUrl(value: string): string | null {
return mediaAssetUrl(value)
}
+5
View File
@@ -44,6 +44,11 @@ export const router = createRouter({
path: 'storyboard', path: 'storyboard',
component: () => import('../features/storyboard/StoryboardPage.vue'), component: () => import('../features/storyboard/StoryboardPage.vue'),
meta: { title: '分镜设计' } meta: { title: '分镜设计' }
},
{
path: 'production',
component: () => import('../features/production/ProductionPage.vue'),
meta: { title: '镜头生产' }
} }
] ]
}, },
+174 -1
View File
@@ -429,7 +429,10 @@
color: #557b50; color: #557b50;
} }
.status-badge[data-status='generating'], .status-badge[data-status='generating'],
.status-badge[data-status='running'] { .status-badge[data-status='running'],
.status-badge[data-status='queued'],
.status-badge[data-status='pending'],
.status-badge[data-status='in_progress'] {
color: #aa8137; color: #aa8137;
} }
.status-badge[data-status='failed'] { .status-badge[data-status='failed'] {
@@ -438,6 +441,176 @@
.status-badge[data-status='need_review'] { .status-badge[data-status='need_review'] {
color: #977536; color: #977536;
} }
.status-badge[data-status='ready'],
.status-badge[data-status='skipped'] {
color: #557b50;
}
.status-badge[data-status='blocked'],
.status-badge[data-status='cancelled'] {
color: #8b6f64;
}
.production-controls {
display: grid;
grid-template-columns: minmax(220px, 1.6fr) minmax(110px, 0.5fr) minmax(230px, 1fr) auto;
align-items: end;
gap: 18px;
}
.production-pipeline {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}
.production-pipeline-card {
padding: 20px;
}
.production-step {
display: grid;
place-items: center;
width: 36px;
height: 36px;
flex-shrink: 0;
border: 1px solid #dfe2d8;
border-radius: 5px;
background: #f6f7f2;
color: #69755d;
}
.production-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.production-stats div,
.production-status-grid div {
padding: 10px;
border-radius: 4px;
background: #f7f8f4;
}
.production-stats dt,
.production-status-grid dt {
color: var(--color-muted);
font-size: 10px;
}
.production-stats dd,
.production-status-grid dd {
margin-top: 5px;
font-size: 13px;
font-family: ui-monospace, monospace;
}
.production-status-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 10px;
}
.production-workspace {
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
align-items: start;
overflow: hidden;
}
.production-shot-list {
max-height: 760px;
overflow-y: auto;
border-right: 1px solid var(--color-line);
background: #fafbf7;
padding-block: 8px;
}
.production-shot-link {
display: block;
width: 100%;
padding: 13px 15px;
border-left: 2px solid transparent;
text-align: left;
}
.production-shot-link:hover {
background: #f2f4ed;
}
.production-shot-link.selected {
border-left-color: #687b58;
background: #eef1e7;
}
.production-stage {
padding: 20px 0;
border-top: 1px solid var(--color-line);
}
.production-stage:first-of-type {
border-top: none;
}
.production-stage-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.keyframe-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.keyframe-card {
overflow: hidden;
border: 1px solid var(--color-line);
border-radius: 5px;
background: #fafbf7;
}
.keyframe-card .asset-image {
aspect-ratio: 16 / 9;
border-radius: 0;
}
.video-record {
display: flex;
overflow: hidden;
border: 1px solid var(--color-line);
border-radius: 5px;
background: #fafbf7;
}
.production-video {
width: min(44%, 390px);
min-height: 170px;
background: #1e211e;
object-fit: contain;
}
@media (max-width: 1100px) {
.production-pipeline {
grid-template-columns: 1fr;
}
.production-controls {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 760px) {
.production-controls,
.production-workspace {
grid-template-columns: minmax(0, 1fr);
}
.production-shot-list {
display: flex;
max-height: none;
overflow-x: auto;
border-right: none;
border-bottom: 1px solid var(--color-line);
}
.production-shot-link {
width: 155px;
min-width: 155px;
border-left: none;
border-bottom: 2px solid transparent;
}
.production-shot-link.selected {
border-bottom-color: #687b58;
}
.production-status-grid,
.keyframe-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.video-record {
display: block;
}
.production-video {
width: 100%;
}
}
.empty-state { .empty-state {
display: flex; display: flex;
flex-direction: column; flex-direction: column;