feat: 接入分镜导演设计与即时视觉状态工作台
增加单集和批量生成、修复诊断、正式镜头检查、参考图、生成规格与视频提示词。 补齐接口契约、竞态保护和确认交互测试,保持现有两条工作流。
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { TabsRoot, TabsList, TabsTrigger, TabsContent, ProgressRoot, ProgressIndicator } from 'reka-ui'
|
||||
import { ArrowLeft, Download, RefreshCw } from '@lucide/vue'
|
||||
import { EmptyState } from '../../components/ui'
|
||||
import { downloadText } from '../../lib/format'
|
||||
import ConfirmAction from '../workflows/ConfirmAction.vue'
|
||||
import GenerationReceipt from './components/GenerationReceipt.vue'
|
||||
import ShotDesign from './components/ShotDesign.vue'
|
||||
import ShotTools from './components/ShotTools.vue'
|
||||
import { useStoryboard } from './useStoryboard'
|
||||
|
||||
/** 分镜工作区按单集查看正式数据,生成配置与镜头检查分开。 */
|
||||
const {
|
||||
id,
|
||||
selectedEpisode,
|
||||
episodeNo,
|
||||
episodeOptions,
|
||||
selectedShot,
|
||||
shots,
|
||||
shot,
|
||||
query,
|
||||
data,
|
||||
prerequisites,
|
||||
session,
|
||||
operation,
|
||||
concurrency,
|
||||
maxRepairAttempts,
|
||||
force,
|
||||
allowed,
|
||||
batchValid,
|
||||
repairsValid,
|
||||
blocked,
|
||||
directionComplete,
|
||||
run
|
||||
} = useStoryboard()
|
||||
const coverage = computed(() => [
|
||||
{
|
||||
title: '导演设计',
|
||||
count: data.value?.directions.directionCount ?? 0,
|
||||
total: data.value?.directions.shotCount ?? 0
|
||||
},
|
||||
{
|
||||
title: '即时状态',
|
||||
count: data.value?.visualStates.visualStateCount ?? 0,
|
||||
total: data.value?.visualStates.shotCount ?? 0
|
||||
}
|
||||
])
|
||||
|
||||
/** 导出当前剧集的正式镜头数据;标题与剧情描述来源于 Breakdown 快照。 */
|
||||
function exportEpisode() {
|
||||
downloadText(
|
||||
`episode-${episodeNo.value}-storyboard.json`,
|
||||
JSON.stringify(
|
||||
{
|
||||
projectId: id.value,
|
||||
episodeNo: episodeNo.value,
|
||||
directions: data.value?.directions,
|
||||
visualStates: data.value?.visualStates,
|
||||
shots: shots.value
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
'application/json'
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div 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}/breakdown`" class="text-button"
|
||||
><ArrowLeft :size="14" />回到拆解</RouterLink
|
||||
>
|
||||
</div>
|
||||
<section class="panel mt-5 p-5" aria-label="分镜生成配置">
|
||||
<div class="storyboard-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
|
||||
id="storyboard-concurrency"
|
||||
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
|
||||
><span class="field-label">状态自动修复次数</span
|
||||
><input
|
||||
id="storyboard-repairs"
|
||||
v-model.number="maxRepairAttempts"
|
||||
class="input"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
:disabled="operation.pending"
|
||||
/><span v-if="!repairsValid" class="mt-1 block text-xs text-danger"
|
||||
>请输入非负整数,0 表示不修复</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
|
||||
>
|
||||
</div>
|
||||
<div class="generation-row">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold">
|
||||
<span class="mr-2 font-mono text-xs text-muted">01</span>导演摄影设计
|
||||
</h3>
|
||||
<p class="mt-2 text-xs leading-6 text-muted">
|
||||
景别、机位、运镜与构图。单集操作会重新生成并覆盖;批量默认跳过完整剧集。
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<ConfirmAction
|
||||
label="生成本集导演设计"
|
||||
:disabled="!allowed['direction-one']"
|
||||
acknowledgement
|
||||
description="调用模型生成当前整集的导演设计,校验通过后保存。如果此集已有设计,将覆盖原结果。"
|
||||
@confirm="run('direction-one')"
|
||||
/><ConfirmAction
|
||||
:label="force ? '重生成全部导演设计' : '补齐项目导演设计'"
|
||||
:disabled="!allowed['direction-all']"
|
||||
acknowledgement
|
||||
:description="
|
||||
force
|
||||
? '重新生成项目全部剧集的导演设计,覆盖已有结果。'
|
||||
: '对整个项目生成导演设计,跳过已完整保存的剧集,可用于重试失败或未完成的剧集。'
|
||||
"
|
||||
@confirm="run('direction-all')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="generation-row">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold">
|
||||
<span class="mr-2 font-mono text-xs text-muted">02</span>即时视觉状态
|
||||
</h3>
|
||||
<p class="mt-2 text-xs leading-6 text-muted">
|
||||
表情、姿态、位置、环境与连续性。每集需要完整导演设计;批量中缺少前置设计的剧集会单独报错。
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<ConfirmAction
|
||||
label="生成本集即时状态"
|
||||
:disabled="!allowed['visual-one']"
|
||||
acknowledgement
|
||||
description="依据本集导演设计和主体形态生成即时视觉状态,按配置尝试自动修复。校验通过才保存,已有状态将被覆盖。"
|
||||
@confirm="run('visual-one')"
|
||||
/><ConfirmAction
|
||||
:label="force ? '重生成全部即时状态' : '补齐项目即时状态'"
|
||||
:disabled="!allowed['visual-all']"
|
||||
acknowledgement
|
||||
:description="
|
||||
force
|
||||
? '重新生成整个项目的即时状态,覆盖已有结果。每集必须先有完整导演设计。'
|
||||
: '补齐项目即时状态,跳过完整剧集;未完成导演设计的剧集会返回失败原因。'
|
||||
"
|
||||
@confirm="run('visual-all')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-4 text-[11px] leading-6 text-muted">
|
||||
修改或覆盖上游设计后,后端不会自动使旧状态、旧提示词失效;请按顺序重生成下游内容。自动修复次数仅用于
|
||||
VisualState。
|
||||
</p>
|
||||
<details class="mt-3 border-t border-line pt-3">
|
||||
<summary class="cursor-pointer text-xs font-medium">视频提示词批量操作</summary>
|
||||
<p class="my-3 text-xs leading-6 text-muted">
|
||||
对项目全部镜头操作,复用上方并发和覆盖配置。需要主体参考图;后端目前尚未将 GenerationSpec 接入
|
||||
Prompt 生成。这里只生成提示词,不提交视频任务。
|
||||
</p>
|
||||
<ConfirmAction
|
||||
:label="force ? '重生成全部视频提示词' : '补齐项目视频提示词'"
|
||||
:disabled="!allowed.prompts"
|
||||
acknowledgement
|
||||
:description="
|
||||
force
|
||||
? '调用模型重新生成全项目镜头的视频提示词,覆盖已有正文。'
|
||||
: '调用模型生成缺少视频提示词的镜头,已有提示词直接跳过。参考图缺失会返回失败。'
|
||||
"
|
||||
@confirm="run('prompts')"
|
||||
/>
|
||||
</details>
|
||||
</section>
|
||||
<p v-if="query.error.value" class="alert alert-error mt-4" role="alert">
|
||||
{{ query.error.value }} 当前保留上次成功读取的数据,暂停生成操作。<button
|
||||
class="text-button ml-3"
|
||||
@click="query.refresh"
|
||||
>
|
||||
重试查询
|
||||
</button>
|
||||
</p>
|
||||
<p v-if="!prerequisites.directionEpisode || !prerequisites.visualEpisode" class="alert mt-4">
|
||||
最新 Breakdown 缺少本集生成所需的镜头、主体或形态数据。已保存设计仍可查看;请先在拆解页完成或恢复相应阶段。
|
||||
</p>
|
||||
<p v-else-if="data && !directionComplete && data.directions.shotCount" class="alert mt-4">
|
||||
本集导演设计尚未补齐,请先完成第 01 步,再生成本集即时状态。
|
||||
</p>
|
||||
<GenerationReceipt v-if="session.receipt" :receipt="session.receipt" />
|
||||
<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">
|
||||
当前剧集数据库结果每 6 秒刷新。覆盖率不是批量任务进度;批量诊断在请求返回后显示。
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button class="text-button" :disabled="query.loading.value" @click="query.refresh">
|
||||
<RefreshCw :size="13" :class="{ 'animate-spin': query.loading.value }" />刷新分镜</button
|
||||
><button class="text-button" :disabled="!shots.length" @click="exportEpisode">
|
||||
<Download :size="13" />导出本集
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-5 grid gap-4 sm:grid-cols-2">
|
||||
<div v-for="item in coverage" :key="item.title" class="panel px-5 py-4">
|
||||
<div class="mb-3 flex items-center justify-between text-xs">
|
||||
<span>{{ item.title }}</span
|
||||
><span class="font-mono text-muted">{{ item.count }} / {{ item.total }} 镜头</span>
|
||||
</div>
|
||||
<ProgressRoot
|
||||
:model-value="item.count"
|
||||
:max="Math.max(item.total, 1)"
|
||||
:aria-label="item.title + '已保存覆盖率'"
|
||||
class="progress-track"
|
||||
><ProgressIndicator
|
||||
class="progress-fill"
|
||||
:style="{ width: (item.total ? Math.min(100, (item.count / item.total) * 100) : 0) + '%' }"
|
||||
/></ProgressRoot>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!data && query.loading.value" class="panel p-8 text-sm text-muted" role="status">
|
||||
正在读取正式分镜数据……
|
||||
</div>
|
||||
<div v-else-if="shot" class="panel storyboard-workspace">
|
||||
<nav class="shot-list" aria-label="本集镜头">
|
||||
<p class="px-4 pb-3 pt-5 text-[10px] text-muted">{{ shots.length }} 个镜头 · 按 Beat 排列</p>
|
||||
<template v-for="(item, index) in shots" :key="item.shotId"
|
||||
><h4
|
||||
v-if="index === 0 || shots[index - 1]?.beatNo !== item.beatNo"
|
||||
class="px-4 pb-2 pt-4 font-mono text-[10px] text-muted"
|
||||
>
|
||||
BEAT {{ String(item.beatNo).padStart(2, '0') }}
|
||||
</h4>
|
||||
<button
|
||||
class="shot-link"
|
||||
:class="{ selected: shot.shotId === item.shotId }"
|
||||
:aria-current="shot.shotId === item.shotId ? 'true' : undefined"
|
||||
@click="selectedShot = item.shotId"
|
||||
>
|
||||
<span class="font-mono text-[10px] text-muted">{{ String(item.shotNo).padStart(2, '0') }}</span
|
||||
><span class="min-w-0"
|
||||
><span class="block truncate text-xs font-medium">{{ item.title }}</span
|
||||
><span class="mt-1 block text-[10px] text-muted"
|
||||
>{{ item.direction ? '设计已保存' : '待设计' }} ·
|
||||
{{ item.visualState ? '状态已保存' : '待状态' }}</span
|
||||
></span
|
||||
>
|
||||
</button></template
|
||||
>
|
||||
</nav>
|
||||
<article class="min-w-0 p-5 lg:p-7">
|
||||
<header class="mb-6">
|
||||
<p class="eyebrow">
|
||||
BEAT {{ shot.beatNo }} / SHOT {{ shot.shotNo }}
|
||||
<span v-if="shot.durationSeconds" class="ml-3">{{ shot.durationSeconds }}s</span>
|
||||
</p>
|
||||
<h3 class="mt-2 text-lg font-semibold">{{ shot.title }}</h3>
|
||||
<p v-if="shot.description" class="mt-3 whitespace-pre-wrap text-sm leading-7">
|
||||
{{ shot.description }}
|
||||
</p>
|
||||
<p v-if="shot.visualFocus" class="mt-2 text-xs leading-6 text-muted">
|
||||
视觉重点:{{ shot.visualFocus }}
|
||||
</p>
|
||||
<p class="mt-3 break-all font-mono text-[10px] text-muted">Shot ID · {{ shot.shotId }}</p>
|
||||
<p class="mt-1 text-[10px] text-muted">
|
||||
标题与剧情描述取自最近可用 Breakdown 快照;以下设计与状态取自数据库。
|
||||
</p>
|
||||
</header>
|
||||
<TabsRoot default-value="design"
|
||||
><TabsList class="tabs-list" aria-label="镜头详情"
|
||||
><TabsTrigger value="design" class="tab-trigger">设计与状态</TabsTrigger
|
||||
><TabsTrigger value="tools" class="tab-trigger">参考图与生成规格</TabsTrigger></TabsList
|
||||
><TabsContent value="design" class="pt-6"><ShotDesign :shot="shot" /></TabsContent
|
||||
><TabsContent value="tools" class="pt-6"
|
||||
><ShotTools :key="shot.shotId" :project-id="id" :shot="shot" :disabled="blocked" /></TabsContent
|
||||
></TabsRoot>
|
||||
</article>
|
||||
</div>
|
||||
<EmptyState
|
||||
v-else-if="data"
|
||||
title="本集还没有正式分镜"
|
||||
description="先完成剧本拆解与分镜持久化,再回来生成导演设计和即时状态。"
|
||||
><RouterLink :to="`/projects/${id}/breakdown`" class="button button-secondary mt-4"
|
||||
>前往拆解</RouterLink
|
||||
></EmptyState
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user