feat: 接入分镜导演设计与即时视觉状态工作台
增加单集和批量生成、修复诊断、正式镜头检查、参考图、生成规格与视频提示词。 补齐接口契约、竞态保护和确认交互测试,保持现有两条工作流。
This commit is contained in:
+18
-2
@@ -1,7 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { Clapperboard, FolderOpen, FileText, Layers, PanelLeftClose, PanelLeftOpen, Settings2 } from '@lucide/vue'
|
||||
import {
|
||||
Clapperboard,
|
||||
Camera,
|
||||
FolderOpen,
|
||||
FileText,
|
||||
Layers,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
Settings2
|
||||
} from '@lucide/vue'
|
||||
import { DialogTrigger } from 'reka-ui'
|
||||
import { AppDialog } from './components/ui'
|
||||
|
||||
@@ -46,6 +55,13 @@ const apiBase = import.meta.env.VITE_API_BASE_URL || '/api'
|
||||
title="剧本拆解"
|
||||
><Layers :size="18" /><span class="sidebar-label">剧本拆解</span
|
||||
><span class="sidebar-label ml-auto text-[10px] text-stone-500">02</span></RouterLink
|
||||
><RouterLink
|
||||
:to="`/projects/${projectId}/storyboard`"
|
||||
class="side-link"
|
||||
active-class="selected"
|
||||
title="分镜设计"
|
||||
><Camera :size="18" /><span class="sidebar-label">分镜设计</span
|
||||
><span class="sidebar-label ml-auto text-[10px] text-stone-500">03</span></RouterLink
|
||||
>
|
||||
</nav></template
|
||||
>
|
||||
@@ -67,7 +83,7 @@ const apiBase = import.meta.env.VITE_API_BASE_URL || '/api'
|
||||
<dt>开发环境</dt>
|
||||
<dd>复制 .env.example 为 .env,将 API_PROXY_TARGET 指向你的后端服务,然后重启 Vite。</dd>
|
||||
<dt>生产环境</dt>
|
||||
<dd>为 /api 配置反向代理,并为拆解和恢复接口设置足够长的读取超时。</dd>
|
||||
<dd>为 /api 和 /storage 配置反向代理,并为生成、拆解和恢复接口设置足够长的读取超时。</dd>
|
||||
<dt>状态更新</dt>
|
||||
<dd>项目详情每 6 秒刷新一次;checkpoint 只在节点或批次结束后更新。</dd>
|
||||
</dl></AppDialog
|
||||
|
||||
@@ -344,7 +344,12 @@ function exportResult() {
|
||||
:subjects="subjects.filter(item => item.module === option.value)"
|
||||
:forms="forms" /></TabsContent
|
||||
><TabsContent value="storyboard"
|
||||
><StoryboardList :plans="plans" :episodes="shots" /></TabsContent
|
||||
><div class="mb-4 flex justify-end">
|
||||
<RouterLink :to="`/projects/${project?.id}/storyboard`" class="text-button"
|
||||
>进入分镜设计<ArrowRight :size="14"
|
||||
/></RouterLink>
|
||||
</div>
|
||||
<StoryboardList :plans="plans" :episodes="shots" /></TabsContent
|
||||
><TabsContent value="tasks" class="p-5"
|
||||
><div v-if="snapshot?.tasks?.length" class="table-scroll">
|
||||
<table class="project-table">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, provide } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ArrowLeft, RefreshCw, FileText, Layers, LoaderCircle } from '@lucide/vue'
|
||||
import { ArrowLeft, RefreshCw, FileText, Layers, Camera, LoaderCircle } from '@lucide/vue'
|
||||
import { StatusBadge } from '../../components/ui'
|
||||
import { projectContextKey, useProjectData } from './context'
|
||||
import { getOperation } from '../workflows/operations'
|
||||
@@ -45,6 +45,9 @@ const operation = computed(() => getOperation(id.value))
|
||||
<RouterLink :to="`/projects/${id}/breakdown`"
|
||||
><Layers :size="17" />剧本拆解<span class="nav-code">breakdown</span></RouterLink
|
||||
>
|
||||
<RouterLink :to="`/projects/${id}/storyboard`"
|
||||
><Camera :size="17" />分镜设计<span class="nav-code">storyboard</span></RouterLink
|
||||
>
|
||||
</nav>
|
||||
<p v-if="context.error.value" class="alert alert-error mt-4" role="alert">
|
||||
{{ context.error.value }}<span v-if="context.project.value"> 当前保留上次成功读取的数据。</span>
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { ProjectDetail } from './types'
|
||||
import type { Checkpoint } from '../workflows/types'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
/** 同一项目的两个 graph 共用一份查询,避免每个面板重复请求。 */
|
||||
/** 同一项目的各个 graph 共用一份查询,避免每个面板重复请求。 */
|
||||
export function useProjectData(id: Ref<string>) {
|
||||
const query = usePolling(id, async (projectId, signal) => {
|
||||
const [project, checkpoints] = await Promise.all([
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,71 @@
|
||||
import { request } from '../../lib/http'
|
||||
import type {
|
||||
DirectionGeneration,
|
||||
EpisodeDirections,
|
||||
EpisodeVisualStates,
|
||||
PromptBatchResult,
|
||||
ShotPromptResult,
|
||||
ShotReferences,
|
||||
StoryboardBatchInput,
|
||||
StoryboardBatchResult,
|
||||
VisualStateBatchInput,
|
||||
VisualStateGeneration
|
||||
} from './types'
|
||||
import type { ShotGenerationSpec } from './generation-spec.types'
|
||||
|
||||
/** 项目路径编码集中处理,所有 storyboard 接口以 /api 为前缀。 */
|
||||
function projectPath(id: string) {
|
||||
return `/projects/${encodeURIComponent(id)}`
|
||||
}
|
||||
|
||||
/** 正式 Shot ID 必须来自数据库 GET,不使用 checkpoint 中的编号替代。 */
|
||||
function shotPath(id: string) {
|
||||
return `/storyboard-shots/${encodeURIComponent(id)}`
|
||||
}
|
||||
|
||||
/** Storyboard 路由契约;单集入口虽名为 generate-test,但 persist=true 才适用于工作台。 */
|
||||
export const storyboardApi = {
|
||||
directions: (id: string, episodeNo: number, signal?: AbortSignal) =>
|
||||
request<EpisodeDirections>(`${projectPath(id)}/storyboard-directions?episodeNo=${episodeNo}`, { signal }),
|
||||
visualStates: (id: string, episodeNo: number, signal?: AbortSignal) =>
|
||||
request<EpisodeVisualStates>(`${projectPath(id)}/storyboard-visual-states?episodeNo=${episodeNo}`, { signal }),
|
||||
generateDirection: (id: string, episodeNo: number) =>
|
||||
request<DirectionGeneration>(`${projectPath(id)}/storyboard-directions/generate-test`, {
|
||||
method: 'POST',
|
||||
body: { episodeNo, persist: true },
|
||||
timeoutMs: 0
|
||||
}),
|
||||
generateDirections: (id: string, input: StoryboardBatchInput) =>
|
||||
request<StoryboardBatchResult>(`${projectPath(id)}/storyboard-directions/generate`, {
|
||||
method: 'POST',
|
||||
body: input,
|
||||
timeoutMs: 0
|
||||
}),
|
||||
generateVisualState: (id: string, episodeNo: number, maxRepairAttempts: number) =>
|
||||
request<VisualStateGeneration>(`${projectPath(id)}/storyboard-visual-states/generate-test`, {
|
||||
method: 'POST',
|
||||
body: { episodeNo, persist: true, maxRepairAttempts },
|
||||
timeoutMs: 0
|
||||
}),
|
||||
generateVisualStates: (id: string, input: VisualStateBatchInput) =>
|
||||
request<StoryboardBatchResult>(`${projectPath(id)}/storyboard-visual-states/generate`, {
|
||||
method: 'POST',
|
||||
body: input,
|
||||
timeoutMs: 0
|
||||
}),
|
||||
references: (id: string, signal?: AbortSignal) => request<ShotReferences>(`${shotPath(id)}/references`, { signal }),
|
||||
generationSpec: (id: string, signal?: AbortSignal) =>
|
||||
request<ShotGenerationSpec>(`${shotPath(id)}/generation-spec`, { signal }),
|
||||
generatePrompt: (id: string, force: boolean) =>
|
||||
request<ShotPromptResult | null>(`${shotPath(id)}/video-prompt`, {
|
||||
method: 'POST',
|
||||
body: { force },
|
||||
timeoutMs: 0
|
||||
}),
|
||||
generatePrompts: (id: string, input: StoryboardBatchInput) =>
|
||||
request<PromptBatchResult>(`${projectPath(id)}/video-prompts/generate`, {
|
||||
method: 'POST',
|
||||
body: input,
|
||||
timeoutMs: 0
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Download } from '@lucide/vue'
|
||||
import { downloadText } from '../../../lib/format'
|
||||
import type { StoryboardIssue, StoryboardReceipt } from '../types'
|
||||
|
||||
/** 独立展示提交结果,不能将 HTTP 成功或局部成功显示为整批完成。 */
|
||||
const props = defineProps<{ receipt: StoryboardReceipt }>()
|
||||
const issues = computed<StoryboardIssue[]>(() => {
|
||||
if (props.receipt.kind === 'direction') return props.receipt.result.validation.issues
|
||||
if (props.receipt.kind === 'visual-state')
|
||||
return props.receipt.result.remainingIssues.length
|
||||
? props.receipt.result.remainingIssues
|
||||
: props.receipt.result.validation.issues
|
||||
return []
|
||||
})
|
||||
const failed = computed(() => {
|
||||
if (props.receipt.kind === 'batch') return props.receipt.result.failedEpisodes > 0
|
||||
if (props.receipt.kind === 'prompts') return props.receipt.result.failed > 0
|
||||
if (props.receipt.kind === 'visual-state' && !props.receipt.result.persisted) return true
|
||||
return !props.receipt.result.validation.valid
|
||||
})
|
||||
|
||||
/** 导出原始回执,保留后端提供的完整失败诊断。 */
|
||||
function exportReceipt() {
|
||||
downloadText('storyboard-generation-receipt.json', JSON.stringify(props.receipt, null, 2), 'application/json')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="panel mt-5 p-5" aria-label="生成回执">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h3 class="text-sm font-semibold">{{ receipt.title }} · 本次回执</h3>
|
||||
<button class="text-button" @click="exportReceipt"><Download :size="13" />导出诊断</button>
|
||||
</div>
|
||||
<p v-if="failed" class="alert alert-error mt-3" role="alert">
|
||||
本次存在失败、校验未通过或未保存项。关闭“批量覆盖已有结果”后再补齐,可跳过已完整保存项。
|
||||
</p>
|
||||
<template v-if="receipt.kind === 'batch'">
|
||||
<p class="my-4 text-xs text-muted">
|
||||
共 {{ receipt.result.episodeCount }} 集 · 完成 {{ receipt.result.completedEpisodes }} · 跳过
|
||||
{{ receipt.result.skippedEpisodes }} · 失败 {{ receipt.result.failedEpisodes }}
|
||||
</p>
|
||||
<div class="table-scroll">
|
||||
<table class="project-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>剧集</th>
|
||||
<th>结果</th>
|
||||
<th>回执数量 / 镜头</th>
|
||||
<th>自动修复</th>
|
||||
<th>诊断</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in receipt.result.episodes" :key="item.episodeNo">
|
||||
<td>第 {{ item.episodeNo }} 集</td>
|
||||
<td :class="item.status === 'failed' ? 'text-danger' : 'text-success'">
|
||||
{{ { completed: '完成', skipped: '跳过已有', failed: '失败' }[item.status] }}
|
||||
</td>
|
||||
<td>{{ item.directionCount ?? item.visualStateCount ?? 0 }} / {{ item.shotCount }}</td>
|
||||
<td>
|
||||
{{ item.repairAttempts === undefined ? '—' : item.repairAttempts + ' 次'
|
||||
}}<span v-if="item.repaired"> · 已修复</span>
|
||||
</td>
|
||||
<td class="min-w-48 whitespace-pre-wrap text-xs">
|
||||
<p>{{ item.error || '—' }}</p>
|
||||
<p v-for="(issue, index) in item.remainingIssues" :key="index" class="mt-2 text-danger">
|
||||
Beat {{ issue.beatNo ?? '—' }} / Shot {{ issue.shotNo ?? '—' }}
|
||||
{{ issue.subjectRef }}:{{ issue.message }}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="receipt.kind === 'prompts'">
|
||||
<p class="mt-4 text-sm">
|
||||
共 {{ receipt.result.total }} 个镜头 · 本次目标 {{ receipt.result.targetCount }} · 处理成功
|
||||
{{ receipt.result.generated }} · 跳过 {{ receipt.result.skipped }} · 失败 {{ receipt.result.failed }}
|
||||
</p>
|
||||
<ul class="mt-3 space-y-2 text-xs text-danger">
|
||||
<li v-for="item in receipt.result.failures" :key="item.shotId">
|
||||
<code>{{ item.shotId }}</code
|
||||
>:{{ item.error }}
|
||||
</li>
|
||||
</ul>
|
||||
<p class="mt-3 text-xs text-muted">
|
||||
当前批量接口不返回成功镜头的 Prompt 正文;选择镜头后点击“读取/生成提示词”,已有内容会直接复用。
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="mt-4 text-sm" :class="failed ? 'text-danger' : 'text-success'">
|
||||
{{ receipt.result.validation.valid ? '生成校验通过' : '校验失败,未保存本次生成结果'
|
||||
}}<span v-if="receipt.kind === 'visual-state'">
|
||||
· 自动修复 {{ receipt.result.repairAttempts }} 次 ·
|
||||
{{ receipt.result.persisted ? '已持久化' : '未持久化' }}</span
|
||||
>
|
||||
</p>
|
||||
<p v-if="receipt.kind === 'direction' && receipt.result.validation.valid" class="mt-2 text-xs text-muted">
|
||||
已请求持久化,数据库实际覆盖率以下方刷新后的数据为准。
|
||||
</p>
|
||||
<ul class="mt-3 space-y-2 text-xs text-danger">
|
||||
<li v-for="(issue, index) in issues" :key="index">
|
||||
第 {{ issue.episodeNo }} 集 / Beat {{ issue.beatNo ?? '—' }} / Shot {{ issue.shotNo ?? '—' }}
|
||||
{{ issue.subjectRef }}:{{ issue.message }}
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<p class="mt-4 text-[11px] text-muted">回执仅保留在当前浏览器会话中。数据库结果是覆盖率和镜头详情的依据。</p>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
import { directionLabel } from '../model'
|
||||
import type { DesignedShot } from '../types'
|
||||
|
||||
/** 正式镜头设计的只读检查;内容编辑尚无后端写接口。 */
|
||||
defineProps<{ shot: DesignedShot }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-7">
|
||||
<section>
|
||||
<h3 class="mb-4 text-sm font-semibold">
|
||||
导演摄影设计 <span class="ml-2 font-mono text-[10px] font-normal text-muted">Direction</span>
|
||||
</h3>
|
||||
<template v-if="shot.direction"
|
||||
><div class="mb-5 flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="field in [
|
||||
{ key: 'shotSize', label: '景别' },
|
||||
{ key: 'cameraAngle', label: '角度' },
|
||||
{ key: 'cameraMovement', label: '运镜' },
|
||||
{ key: 'lensIntent', label: '焦段' },
|
||||
{ key: 'depthOfField', label: '景深' }
|
||||
] as const"
|
||||
:key="field.key"
|
||||
class="tag"
|
||||
>{{ field.label }} · {{ directionLabel(shot.direction[field.key], field.key) }}</span
|
||||
>
|
||||
</div>
|
||||
<dl class="detail-grid">
|
||||
<dt>构图</dt>
|
||||
<dd>{{ shot.direction.composition }}</dd>
|
||||
<dt>机位</dt>
|
||||
<dd>{{ shot.direction.cameraPosition }}</dd>
|
||||
<dt>视觉意图</dt>
|
||||
<dd>{{ shot.direction.visualIntent }}</dd>
|
||||
</dl></template
|
||||
>
|
||||
<p v-else class="text-sm text-muted">此镜头尚无已保存的导演设计。</p>
|
||||
</section>
|
||||
<section class="border-t border-line pt-6">
|
||||
<h3 class="mb-4 text-sm font-semibold">
|
||||
即时视觉状态 <span class="ml-2 font-mono text-[10px] font-normal text-muted">VisualState</span>
|
||||
</h3>
|
||||
<template v-if="shot.visualState"
|
||||
><dl class="detail-grid">
|
||||
<dt>时间</dt>
|
||||
<dd>{{ shot.visualState.timeOfDay || '未指定' }}</dd>
|
||||
<dt>天气</dt>
|
||||
<dd>{{ shot.visualState.weather || '不适用 / 未指定' }}</dd>
|
||||
<dt>氛围</dt>
|
||||
<dd>{{ shot.visualState.atmosphere || '未指定' }}</dd>
|
||||
<dt>环境变化</dt>
|
||||
<dd>{{ shot.visualState.environmentTransientState || '无临时变化' }}</dd>
|
||||
<dt>连续性</dt>
|
||||
<dd>{{ shot.visualState.continuityNote || '未附说明' }}</dd>
|
||||
</dl>
|
||||
<article
|
||||
v-for="subject in shot.visualState.subjects"
|
||||
:key="subject.subjectId"
|
||||
class="mt-5 border-l-2 border-line pl-4"
|
||||
>
|
||||
<div class="mb-3 flex flex-wrap items-center gap-2">
|
||||
<code class="subject-ref">{{ subject.subjectRef }}</code
|
||||
><span class="text-xs font-medium">{{ subject.formName }}</span>
|
||||
</div>
|
||||
<dl class="detail-grid">
|
||||
<template
|
||||
v-for="field in [
|
||||
{ key: 'expression', label: '表情' },
|
||||
{ key: 'pose', label: '姿态' },
|
||||
{ key: 'gaze', label: '视线' },
|
||||
{ key: 'position', label: '位置' },
|
||||
{ key: 'transientState', label: '临时变化' }
|
||||
] as const"
|
||||
:key="field.key"
|
||||
><dt>{{ field.label }}</dt>
|
||||
<dd>{{ subject[field.key] || '不适用 / 未指定' }}</dd></template
|
||||
>
|
||||
</dl>
|
||||
</article></template
|
||||
>
|
||||
<p v-else class="text-sm leading-6 text-muted">
|
||||
此镜头尚无已保存的即时状态。先补齐本集导演设计,再生成 VisualState。
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,226 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onScopeDispose, ref, watch } from 'vue'
|
||||
import { Download, ExternalLink, RefreshCw } from '@lucide/vue'
|
||||
import { storyboardApi } from '../api'
|
||||
import { getStoryboardSession, referenceImageUrl } from '../model'
|
||||
import type { DesignedShot, ShotReferences } from '../types'
|
||||
import type { ShotGenerationSpec } from '../generation-spec.types'
|
||||
import { errorMessage } from '../../../lib/http'
|
||||
import { downloadText } from '../../../lib/format'
|
||||
import { getOperation, runOperation } from '../../workflows/operations'
|
||||
import ConfirmAction from '../../workflows/ConfirmAction.vue'
|
||||
|
||||
/** 镜头级只读资源与显式 Prompt 生成;查询与耗费模型的操作分开。 */
|
||||
const props = defineProps<{ projectId: string; shot: DesignedShot; disabled: boolean }>()
|
||||
const references = ref<ShotReferences | null>(null)
|
||||
const spec = ref<ShotGenerationSpec | null>(null)
|
||||
const errors = ref({ references: '', spec: '' })
|
||||
const busy = ref({ references: false, spec: false })
|
||||
const brokenImages = ref<string[]>([])
|
||||
const force = ref(false)
|
||||
const prompt = computed(() => getStoryboardSession(props.projectId).prompts[props.shot.shotId])
|
||||
const operation = computed(() => getOperation(props.projectId))
|
||||
const locked = computed(() => props.disabled || operation.value.pending)
|
||||
const controllers: Partial<Record<'references' | 'spec', AbortController>> = {}
|
||||
let revision = 0
|
||||
|
||||
/** 切换镜头或已保存设计改变时废弃查询,防止旧编译结果覆盖新设计。 */
|
||||
function reset() {
|
||||
revision++
|
||||
for (const controller of Object.values(controllers)) controller.abort()
|
||||
references.value = null
|
||||
spec.value = null
|
||||
errors.value = { references: '', spec: '' }
|
||||
busy.value = { references: false, spec: false }
|
||||
brokenImages.value = []
|
||||
}
|
||||
watch(() => JSON.stringify([props.projectId, props.shot.shotId, props.shot.direction, props.shot.visualState]), reset)
|
||||
onScopeDispose(reset)
|
||||
|
||||
/** 只在用户点击后读取参考图或编译规格;两种 GET 均不调用生成模型。 */
|
||||
async function load(kind: 'references' | 'spec') {
|
||||
if (busy.value[kind] || locked.value || (kind === 'spec' && (!props.shot.direction || !props.shot.visualState)))
|
||||
return
|
||||
const version = revision
|
||||
const shotId = props.shot.shotId
|
||||
const controller = new AbortController()
|
||||
controllers[kind] = controller
|
||||
busy.value[kind] = true
|
||||
errors.value[kind] = ''
|
||||
try {
|
||||
if (kind === 'references') {
|
||||
const result = await storyboardApi.references(shotId, controller.signal)
|
||||
if (version !== revision) return
|
||||
if (result.shotId !== shotId) throw new Error('参考图返回的 Shot ID 不匹配。')
|
||||
references.value = result
|
||||
brokenImages.value = []
|
||||
} else {
|
||||
const result = await storyboardApi.generationSpec(shotId, controller.signal)
|
||||
if (version !== revision) return
|
||||
if (result.shotId !== shotId) throw new Error('生成规格返回的 Shot ID 不匹配。')
|
||||
spec.value = result
|
||||
}
|
||||
} catch (error) {
|
||||
if (version === revision) errors.value[kind] = errorMessage(error)
|
||||
} finally {
|
||||
if (version === revision) busy.value[kind] = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 没有独立 GET Prompt 接口:force=false 会复用已有正文,否则仍会调用模型。 */
|
||||
async function generatePrompt() {
|
||||
if (locked.value) return
|
||||
const projectId = props.projectId
|
||||
const shotId = props.shot.shotId
|
||||
const overwrite = force.value
|
||||
await runOperation(projectId, `读取/生成镜头 ${props.shot.shotNo} 提示词`, async () => {
|
||||
const session = getStoryboardSession(projectId)
|
||||
// 请求结果不确定时不要保留可能已被覆盖的旧正文。
|
||||
delete session.prompts[shotId]
|
||||
const result = await storyboardApi.generatePrompt(shotId, overwrite)
|
||||
if (!result || result.id !== shotId || !result.videoPrompt)
|
||||
throw new Error('接口未返回此镜头的有效提示词,请检查后端记录后重试。')
|
||||
session.prompts[shotId] = result
|
||||
})
|
||||
}
|
||||
|
||||
/** 仅导出已返回的真实规格或提示词,不在前端补造内容。 */
|
||||
function exportResult(kind: 'spec' | 'prompt') {
|
||||
if (kind === 'spec' && spec.value)
|
||||
downloadText(`shot-${props.shot.shotId}-spec.json`, JSON.stringify(spec.value, null, 2), 'application/json')
|
||||
if (kind === 'prompt' && prompt.value)
|
||||
downloadText(
|
||||
`shot-${props.shot.shotId}-prompt.txt`,
|
||||
`${prompt.value.videoPrompt}\n\nNegative prompt:\n${prompt.value.negativePrompt || ''}`
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-7">
|
||||
<section>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 class="text-sm font-semibold">主体参考图</h3>
|
||||
<button
|
||||
class="button button-secondary"
|
||||
:disabled="busy.references || locked"
|
||||
@click="load('references')"
|
||||
>
|
||||
<RefreshCw :size="13" :class="{ 'animate-spin': busy.references }" />{{
|
||||
references ? '刷新参考图' : '读取参考图'
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-2 text-xs leading-6 text-muted">
|
||||
读取镜头已绑定形态的图片,不生成新图。参考图缺失会影响视频提示词生成。
|
||||
</p>
|
||||
<p v-if="errors.references" class="alert alert-error mt-3" role="alert">{{ errors.references }}</p>
|
||||
<template v-if="references">
|
||||
<div class="reference-grid mt-4">
|
||||
<article v-for="item in references.references" :key="item.shotSubjectId" class="reference-card">
|
||||
<a
|
||||
v-if="referenceImageUrl(item.imageUrl) && !brokenImages.includes(item.imageId)"
|
||||
:href="referenceImageUrl(item.imageUrl)!"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="block"
|
||||
><img
|
||||
:src="referenceImageUrl(item.imageUrl)!"
|
||||
:alt="`${item.subjectName} · ${item.subjectFormName}`"
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
@error="brokenImages.push(item.imageId)"
|
||||
/></a>
|
||||
<div v-else class="reference-placeholder">图片不可用</div>
|
||||
<div class="p-3">
|
||||
<p class="text-xs font-medium">{{ item.subjectName }} · {{ item.subjectFormName }}</p>
|
||||
<code class="mt-1 block text-[10px] text-muted">{{ item.subjectRef }}</code
|
||||
><a
|
||||
v-if="referenceImageUrl(item.imageUrl)"
|
||||
:href="referenceImageUrl(item.imageUrl)!"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-button mt-2"
|
||||
>原图 <ExternalLink :size="11"
|
||||
/></a>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<ul v-if="references.missing.length" class="alert mt-3 space-y-2 text-xs">
|
||||
<li v-for="item in references.missing" :key="item.subjectId">
|
||||
{{ item.subjectName }}({{ item.subjectRef }}):{{ item.reason }}
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="!references.references.length && !references.missing.length" class="mt-3 text-xs text-muted">
|
||||
后端未返回参考图或缺失记录。
|
||||
</p>
|
||||
<p v-if="references.missing.length" class="mt-3 text-xs text-muted">
|
||||
请先在后端补齐对应形态的图片,再刷新。本页暂不包含主体图生成。
|
||||
</p>
|
||||
</template>
|
||||
</section>
|
||||
<section class="border-t border-line pt-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 class="text-sm font-semibold">镜头生成规格</h3>
|
||||
<button
|
||||
class="button button-secondary"
|
||||
:disabled="locked || busy.spec || !shot.direction || !shot.visualState"
|
||||
@click="load('spec')"
|
||||
>
|
||||
{{ busy.spec ? '编译中…' : '读取生成规格' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-2 text-xs leading-6 text-muted">
|
||||
后端合并主体、形态、摄影设计与即时状态,编译为 GenerationSpec,不调用模型。需要本镜头已有 Direction 和
|
||||
VisualState。
|
||||
</p>
|
||||
<p v-if="errors.spec" class="alert alert-error mt-3" role="alert">{{ errors.spec }}</p>
|
||||
<template v-if="spec"
|
||||
><div class="mt-4 flex items-center justify-between">
|
||||
<span class="text-xs text-muted"
|
||||
>{{ spec.subjects.length }} 个主体 · {{ spec.durationSeconds }} 秒</span
|
||||
><button class="text-button" @click="exportResult('spec')"><Download :size="13" />导出规格</button>
|
||||
</div>
|
||||
<pre class="storyboard-json mt-3">{{ JSON.stringify(spec, null, 2) }}</pre>
|
||||
</template>
|
||||
</section>
|
||||
<section class="border-t border-line pt-6">
|
||||
<h3 class="text-sm font-semibold">视频提示词</h3>
|
||||
<p class="mt-2 text-xs leading-6 text-muted">
|
||||
已有提示词直接读取,没有则调用模型生成。当前后端仍使用镜头描述和参考图生成 Prompt,尚未接入上方
|
||||
GenerationSpec;此操作不会生成视频。
|
||||
</p>
|
||||
<div class="mt-4 flex flex-wrap items-center gap-4">
|
||||
<ConfirmAction
|
||||
label="读取/生成提示词"
|
||||
:disabled="locked"
|
||||
acknowledgement
|
||||
:description="
|
||||
force
|
||||
? '将覆盖此镜头已保存的视频提示词,并调用模型重新生成。'
|
||||
: '读取此镜头已有提示词;若不存在,将调用模型并保存结果。后端会检查参考图是否齐全。'
|
||||
"
|
||||
@confirm="generatePrompt"
|
||||
/><label class="flex items-center gap-2 text-xs"
|
||||
><input
|
||||
v-model="force"
|
||||
type="checkbox"
|
||||
class="accent-accent"
|
||||
:disabled="locked"
|
||||
/>覆盖此镜头提示词</label
|
||||
>
|
||||
</div>
|
||||
<div v-if="prompt" class="mt-5">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<span class="text-xs font-medium">本会话已读取的正文</span
|
||||
><button class="text-button" @click="exportResult('prompt')"><Download :size="13" />导出</button>
|
||||
</div>
|
||||
<p class="whitespace-pre-wrap text-sm leading-7">{{ prompt.videoPrompt }}</p>
|
||||
<template v-if="prompt.negativePrompt"
|
||||
><h4 class="mb-2 mt-5 text-xs font-medium text-muted">Negative prompt</h4>
|
||||
<p class="whitespace-pre-wrap text-sm leading-6">{{ prompt.negativePrompt }}</p></template
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* 单个主体的稳定形态与当前镜头即时状态。
|
||||
*/
|
||||
export interface ShotGenerationSubjectSpec {
|
||||
subjectId: string
|
||||
subjectRef: string
|
||||
subjectName: string
|
||||
module: string
|
||||
subjectFormId: string
|
||||
subjectFormName: string
|
||||
appearancePrompt: string
|
||||
generationPrompt: string
|
||||
expression: string
|
||||
pose: string
|
||||
gaze: string
|
||||
position: string
|
||||
transientState: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前 Shot 的摄影设计。
|
||||
*/
|
||||
export interface ShotGenerationDirectionSpec {
|
||||
shotSize: string
|
||||
cameraAngle: string
|
||||
cameraMovement: string
|
||||
composition: string
|
||||
cameraPosition: string
|
||||
lensIntent: string
|
||||
depthOfField: string
|
||||
visualIntent: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前 Shot 的环境即时状态。
|
||||
*/
|
||||
export interface ShotGenerationEnvironmentSpec {
|
||||
timeOfDay: string
|
||||
weather: string
|
||||
atmosphere: string
|
||||
transientState: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 供后续图片 / 视频生成阶段消费的确定性 Shot Generation Spec。
|
||||
*
|
||||
* 此结构只负责编译已有正式数据,不调用模型,也不重新解释剧情。
|
||||
*/
|
||||
export interface ShotGenerationSpec {
|
||||
projectId: string
|
||||
episodeNo: number
|
||||
beatNo: number
|
||||
shotId: string
|
||||
shotNo: number
|
||||
title: string
|
||||
description: string
|
||||
visualFocus: string
|
||||
durationSeconds: number
|
||||
subjects: ShotGenerationSubjectSpec[]
|
||||
direction: ShotGenerationDirectionSpec
|
||||
environment: ShotGenerationEnvironmentSpec
|
||||
continuityNote: string
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
/** Storyboard 模块公共入口。 */
|
||||
export { storyboardApi } from './api'
|
||||
export type { EpisodeDirections, EpisodeVisualStates, ShotDirection, ShotVisualState } from './types'
|
||||
@@ -0,0 +1,64 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { directionLabel, mergeDesignedShots, referenceImageUrl, storyboardPrerequisites } from './model'
|
||||
import { directionsResult, episodeShots, storyboardCheckpoint, visualStatesResult } from './testing/fixtures'
|
||||
|
||||
afterEach(() => vi.unstubAllEnvs())
|
||||
|
||||
describe('分镜正式数据与生成依赖', () => {
|
||||
it('用 Shot ID 关联状态,用 Beat + Shot 编号补充描述,不串同编号镜头', () => {
|
||||
const directions = directionsResult('p')
|
||||
const states = visualStatesResult('p', 1, true)
|
||||
states.beats.reverse()
|
||||
states.beats[0]!.shots[0]!.visualState!.continuityNote = '第二个 Beat'
|
||||
const shots = mergeDesignedShots(directions, states, episodeShots())
|
||||
expect(shots.map(shot => [shot.shotId, shot.title, shot.visualState?.continuityNote])).toEqual([
|
||||
['shot-db-1-1', '第1集镜头1', '信封始终在右手'],
|
||||
['shot-db-1-2', '第1集镜头2', '第二个 Beat']
|
||||
])
|
||||
})
|
||||
|
||||
it('没有 Direction 的正式 Shot 仍可显示,不用 checkpoint 伪造数据库 ID', () => {
|
||||
const rows = mergeDesignedShots(directionsResult('p', 1, false), visualStatesResult('p'), episodeShots())
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows[0]?.direction).toBeNull()
|
||||
expect(mergeDesignedShots(null, null, episodeShots())).toEqual([])
|
||||
})
|
||||
|
||||
it('仅最新 Breakdown 决定生成前置条件,不能回退到历史完整快照', () => {
|
||||
const ready = storyboardCheckpoint()
|
||||
expect(storyboardPrerequisites([ready], 1)).toMatchObject({ directionEpisode: true, visualEpisode: true })
|
||||
expect(storyboardPrerequisites([ready], 3)).toMatchObject({ directionEpisode: false, visualEpisode: false })
|
||||
const latest = { ...ready, checkpointId: 'failed', createdAt: '2026-08-28T01:00:00Z', state: {} }
|
||||
expect(storyboardPrerequisites([latest, ready], 1)).toMatchObject({
|
||||
directionProject: false,
|
||||
visualProject: false
|
||||
})
|
||||
expect(storyboardPrerequisites([{ ...latest, workflowName: 'create-drama' }, ready], 1)).toMatchObject({
|
||||
directionProject: true
|
||||
})
|
||||
})
|
||||
|
||||
it('保留后端嵌套 Direction 与顶层 VisualState 的不同依赖', () => {
|
||||
const checkpoint = storyboardCheckpoint()
|
||||
delete checkpoint.state.breakdownResult
|
||||
expect(storyboardPrerequisites([checkpoint], 1)).toMatchObject({ directionEpisode: false, visualEpisode: true })
|
||||
checkpoint.state.subjectForms = []
|
||||
expect(storyboardPrerequisites([checkpoint], 1).visualEpisode).toBe(false)
|
||||
expect(directionLabel('future-camera-mode')).toBe('future-camera-mode')
|
||||
})
|
||||
|
||||
it('参考图只允许 http(s) 或后端 storage 地址,拦截不可信协议和路径逃逸', () => {
|
||||
vi.stubEnv('VITE_API_BASE_URL', 'https://api.example.test/api')
|
||||
expect(referenceImageUrl('/storage/image.png')).toBe('https://api.example.test/storage/image.png')
|
||||
expect(referenceImageUrl('https://images.example.test/a.png')).toBe('https://images.example.test/a.png')
|
||||
for (const value of [
|
||||
'javascript:alert(1)',
|
||||
'data:image/svg+xml,anything',
|
||||
'//evil.test/img',
|
||||
'/storage/../api/projects',
|
||||
'/storage/\\evil.test/img'
|
||||
]) {
|
||||
expect(referenceImageUrl(value)).toBeNull()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,128 @@
|
||||
import { reactive } from 'vue'
|
||||
import type { EpisodeShots } from '../breakdown/types'
|
||||
import type { Checkpoint } from '../workflows/types'
|
||||
import { workflowCheckpoints } from '../workflows/selectors'
|
||||
import type { DesignedShot, EpisodeDirections, EpisodeVisualStates, StoryboardSession } from './types'
|
||||
|
||||
/** 仅保存本会话回执和已返回的 Prompt,切换路由不丢失长请求结果。 */
|
||||
const sessions = reactive<Record<string, StoryboardSession>>({})
|
||||
|
||||
/** 获取指定项目的独立回执容器。 */
|
||||
export function getStoryboardSession(id: string): StoryboardSession {
|
||||
return (sessions[id] ??= { receipt: null, prompts: {} })
|
||||
}
|
||||
|
||||
/** 后端生成服务只读取最新 Breakdown;不能用历史可恢复快照冒充当前生成前置条件。 */
|
||||
export function storyboardPrerequisites(checkpoints: Checkpoint[], episodeNo: number) {
|
||||
const latest = workflowCheckpoints(checkpoints, 'breakdown').at(-1)
|
||||
const state = latest?.state
|
||||
const directionEpisodes = state?.breakdownResult?.storyboardEpisodeShots ?? []
|
||||
const visualEpisodes = state?.storyboardEpisodeShots ?? []
|
||||
return {
|
||||
directionEpisode: directionEpisodes.some(item => item.episodeNo === episodeNo),
|
||||
directionProject: directionEpisodes.length > 0,
|
||||
visualEpisode:
|
||||
visualEpisodes.some(item => item.episodeNo === episodeNo) &&
|
||||
!!state?.subjectCandidates?.length &&
|
||||
!!state?.subjectForms?.length,
|
||||
visualProject: visualEpisodes.length > 0 && !!state?.subjectCandidates?.length && !!state?.subjectForms?.length,
|
||||
breakdownRunning: state?.workflowExecution?.status === 'running'
|
||||
}
|
||||
}
|
||||
|
||||
/** 以正式 Shot ID 关联设计和状态;checkpoint 仅补足标题与剧情描述。 */
|
||||
export function mergeDesignedShots(
|
||||
directions: EpisodeDirections | null,
|
||||
states: EpisodeVisualStates | null,
|
||||
source?: EpisodeShots
|
||||
): DesignedShot[] {
|
||||
const rows = new Map<string, DesignedShot>()
|
||||
for (const beat of directions?.beats ?? []) {
|
||||
for (const shot of beat.shots) {
|
||||
const detail = source?.beatShots
|
||||
.find(item => item.beatNo === beat.beatNo)
|
||||
?.shots.find(item => item.shotNo === shot.shotNo)
|
||||
rows.set(shot.shotId, {
|
||||
shotId: shot.shotId,
|
||||
shotNo: shot.shotNo,
|
||||
beatNo: beat.beatNo,
|
||||
title: detail?.title || `镜头 ${shot.shotNo}`,
|
||||
description: detail?.description || '',
|
||||
visualFocus: detail?.visualFocus || '',
|
||||
durationSeconds: detail?.durationSeconds,
|
||||
direction: shot.direction,
|
||||
visualState: null
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const beat of states?.beats ?? []) {
|
||||
for (const shot of beat.shots) {
|
||||
const row = rows.get(shot.shotId)
|
||||
if (row) row.visualState = shot.visualState
|
||||
else
|
||||
rows.set(shot.shotId, {
|
||||
shotId: shot.shotId,
|
||||
shotNo: shot.shotNo,
|
||||
beatNo: beat.beatNo,
|
||||
title: `镜头 ${shot.shotNo}`,
|
||||
description: '',
|
||||
visualFocus: '',
|
||||
direction: null,
|
||||
visualState: shot.visualState
|
||||
})
|
||||
}
|
||||
}
|
||||
return [...rows.values()].toSorted((a, b) => a.beatNo - b.beatNo || a.shotNo - b.shotNo)
|
||||
}
|
||||
|
||||
/** 摄影枚举翻译;新枚举保留原值,避免静默丢失字段。 */
|
||||
export function directionLabel(value: string, field?: string): string {
|
||||
if (value === 'wide') return field === 'lensIntent' ? '广角' : '远景'
|
||||
const labels: Record<string, string> = {
|
||||
'extreme-wide': '大远景',
|
||||
full: '全景',
|
||||
medium: '中景',
|
||||
'medium-close-up': '近景',
|
||||
'close-up': '特写',
|
||||
'extreme-close-up': '大特写',
|
||||
'eye-level': '平视',
|
||||
'high-angle': '俯拍',
|
||||
'low-angle': '仰拍',
|
||||
overhead: '顶拍',
|
||||
'dutch-angle': '倾斜机位',
|
||||
static: '固定',
|
||||
pan: '水平摇镜',
|
||||
tilt: '垂直摇镜',
|
||||
'push-in': '推进',
|
||||
'pull-out': '拉远',
|
||||
tracking: '跟拍',
|
||||
dolly: '移动摄影',
|
||||
handheld: '手持',
|
||||
crane: '升降',
|
||||
'ultra-wide': '超广角',
|
||||
normal: '标准',
|
||||
portrait: '人像焦段',
|
||||
telephoto: '长焦',
|
||||
deep: '深景深',
|
||||
moderate: '中等景深',
|
||||
shallow: '浅景深'
|
||||
}
|
||||
return labels[value] ?? value
|
||||
}
|
||||
|
||||
/** 只开放 http(s) 与同源 /storage 路径,拦截模型/数据中的 javascript 或协议相对 URL。 */
|
||||
export function referenceImageUrl(value: string): string | null {
|
||||
const raw = value.trim()
|
||||
try {
|
||||
if (raw.startsWith('/storage/') && !raw.includes('\\')) {
|
||||
const api = import.meta.env.VITE_API_BASE_URL || '/api'
|
||||
const origin = /^https?:\/\//i.test(api) ? new URL(api).origin : window.location.origin
|
||||
const url = new URL(raw, origin)
|
||||
return url.pathname.startsWith('/storage/') ? url.href : null
|
||||
}
|
||||
const url = new URL(raw)
|
||||
return ['http:', 'https:'].includes(url.protocol) ? url.href : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { Checkpoint } from '../../workflows/types'
|
||||
import type { EpisodeShots } from '../../breakdown/types'
|
||||
import type { DesignedShot, EpisodeDirections, EpisodeVisualStates, ShotDirection, ShotVisualState } from '../types'
|
||||
|
||||
/** 仅供自动测试导入的确定性 fixture,不在应用入口引用。 */
|
||||
export const direction: ShotDirection = {
|
||||
shotSize: 'medium',
|
||||
cameraAngle: 'eye-level',
|
||||
cameraMovement: 'static',
|
||||
composition: '人物居左',
|
||||
cameraPosition: '门口',
|
||||
lensIntent: 'normal',
|
||||
depthOfField: 'shallow',
|
||||
visualIntent: '观察来信'
|
||||
}
|
||||
export const visualState: ShotVisualState = {
|
||||
timeOfDay: '夜晚',
|
||||
weather: '小雨',
|
||||
atmosphere: '安静',
|
||||
environmentTransientState: '雨水沿门框滴落',
|
||||
continuityNote: '信封始终在右手',
|
||||
subjects: [
|
||||
{
|
||||
subjectId: 'subject-db',
|
||||
subjectFormId: 'form-db',
|
||||
subjectRef: '@CH0001',
|
||||
formName: '日常',
|
||||
expression: '疑惑',
|
||||
pose: '站立',
|
||||
gaze: '信封',
|
||||
position: '左侧',
|
||||
transientState: '衣角湿润'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
/** 一个有效的两 Beat 镜头快照,两个 Beat 都从 Shot 1 开始编号。 */
|
||||
export function episodeShots(episodeNo = 1): EpisodeShots {
|
||||
return {
|
||||
episodeNo,
|
||||
episodePlan: {
|
||||
episodeNo,
|
||||
episodeTitle: '来信',
|
||||
storyGoal: '',
|
||||
centralConflict: '',
|
||||
emotionalArc: '',
|
||||
pacing: '',
|
||||
endingHook: '',
|
||||
beats: []
|
||||
},
|
||||
beatShots: [1, 2].map(beatNo => ({
|
||||
beatNo,
|
||||
shots: [
|
||||
{
|
||||
shotNo: 1,
|
||||
title: `第${episodeNo}集镜头${beatNo}`,
|
||||
description: '门口收到一封信',
|
||||
visualFocus: '信封',
|
||||
subjectRefs: ['@CH0001'],
|
||||
durationSeconds: 5
|
||||
}
|
||||
]
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/** 同时具备后端两个生成服务各自所需的嵌套与顶层字段。 */
|
||||
export function storyboardCheckpoint(): Checkpoint {
|
||||
const shots = [episodeShots(), episodeShots(2)]
|
||||
return {
|
||||
checkpointId: 'storyboard-ready',
|
||||
workflowName: 'breakdown',
|
||||
createdAt: '2026-08-28T00:00:00Z',
|
||||
state: {
|
||||
breakdownResult: { storyboardEpisodeShots: shots },
|
||||
storyboardEpisodeShots: shots,
|
||||
subjectCandidates: [
|
||||
{
|
||||
profileId: 'profile-domain',
|
||||
name: '林知夏',
|
||||
ref: '@CH0001',
|
||||
description: '',
|
||||
module: 'character',
|
||||
appearance_prompt: ''
|
||||
}
|
||||
],
|
||||
subjectForms: [
|
||||
{
|
||||
formId: 'form-domain',
|
||||
profileId: 'profile-domain',
|
||||
type: 'character',
|
||||
name: '日常',
|
||||
isDefault: true,
|
||||
description: '',
|
||||
appearancePrompt: ''
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 使用正式数据库 ID,与 checkpoint 中的 shotNo 明确区分。 */
|
||||
export function directionsResult(projectId: string, episodeNo = 1, complete = true): EpisodeDirections {
|
||||
return {
|
||||
projectId,
|
||||
episodeNo,
|
||||
shotCount: 2,
|
||||
directionCount: complete ? 2 : 0,
|
||||
coverage: complete ? 1 : 0,
|
||||
beats: [1, 2].map(beatNo => ({
|
||||
beatNo,
|
||||
shots: [
|
||||
{ shotId: `shot-db-${episodeNo}-${beatNo}`, shotNo: 1, direction: complete ? { ...direction } : null }
|
||||
]
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/** VisualState GET 使用扁平环境字段,不能用领域输出代替。 */
|
||||
export function visualStatesResult(projectId: string, episodeNo = 1, complete = false): EpisodeVisualStates {
|
||||
return {
|
||||
projectId,
|
||||
episodeNo,
|
||||
shotCount: 2,
|
||||
visualStateCount: complete ? 2 : 0,
|
||||
coverage: complete ? 1 : 0,
|
||||
beats: [1, 2].map(beatNo => ({
|
||||
beatNo,
|
||||
shots: [
|
||||
{
|
||||
shotId: `shot-db-${episodeNo}-${beatNo}`,
|
||||
shotNo: 1,
|
||||
visualState: complete ? structuredClone(visualState) : null
|
||||
}
|
||||
]
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/** 镜头工具组件的正式数据输入。 */
|
||||
export function designedShot(id = 'shot-db-1-1'): DesignedShot {
|
||||
return {
|
||||
shotId: id,
|
||||
shotNo: 1,
|
||||
beatNo: 1,
|
||||
title: '来信',
|
||||
description: '',
|
||||
visualFocus: '',
|
||||
direction: { ...direction },
|
||||
visualState: structuredClone(visualState)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
/** 导演摄影设计的正式数据库字段。 */
|
||||
export interface ShotDirection {
|
||||
shotSize: string
|
||||
cameraAngle: string
|
||||
cameraMovement: string
|
||||
composition: string
|
||||
cameraPosition: string
|
||||
lensIntent: string
|
||||
depthOfField: string
|
||||
visualIntent: string
|
||||
}
|
||||
|
||||
/** 正式 VisualState 的主体状态;subjectFormId 是数据库 ID,不是领域 formId。 */
|
||||
export interface SubjectVisualState {
|
||||
subjectId: string
|
||||
subjectFormId: string | null
|
||||
subjectRef: string
|
||||
formName: string
|
||||
expression: string
|
||||
pose: string
|
||||
gaze: string
|
||||
position: string
|
||||
transientState: string
|
||||
}
|
||||
|
||||
/** GET 返回扁平化的环境字段,与 generate-test 的嵌套 environment 不同。 */
|
||||
export interface ShotVisualState {
|
||||
timeOfDay: string
|
||||
weather: string
|
||||
atmosphere: string
|
||||
environmentTransientState: string
|
||||
continuityNote: string
|
||||
subjects: SubjectVisualState[]
|
||||
}
|
||||
|
||||
/** 单集已持久化的摄影设计及覆盖率。 */
|
||||
export interface EpisodeDirections {
|
||||
projectId: string
|
||||
episodeNo: number
|
||||
shotCount: number
|
||||
directionCount: number
|
||||
coverage: number
|
||||
beats: { beatNo: number; shots: { shotId: string; shotNo: number; direction: ShotDirection | null }[] }[]
|
||||
}
|
||||
|
||||
/** 单集已持久化的即时视觉状态及覆盖率。 */
|
||||
export interface EpisodeVisualStates {
|
||||
projectId: string
|
||||
episodeNo: number
|
||||
shotCount: number
|
||||
visualStateCount: number
|
||||
coverage: number
|
||||
beats: { beatNo: number; shots: { shotId: string; shotNo: number; visualState: ShotVisualState | null }[] }[]
|
||||
}
|
||||
|
||||
/** 导演设计与视觉状态统一呈现的校验问题。 */
|
||||
export interface StoryboardIssue {
|
||||
episodeNo: number
|
||||
beatNo: number | null
|
||||
shotNo: number | null
|
||||
subjectRef?: string | null
|
||||
message: string
|
||||
}
|
||||
|
||||
/** 单集生成结果中的校验信息,HTTP 200 不代表 valid。 */
|
||||
export interface StoryboardValidation {
|
||||
valid: boolean
|
||||
issues: StoryboardIssue[]
|
||||
}
|
||||
|
||||
/** 单集导演设计返回领域结构;是否落库应再查 GET 覆盖率确认。 */
|
||||
export interface DirectionGeneration {
|
||||
episodeNo: number
|
||||
direction: {
|
||||
episodeNo: number
|
||||
beatDirections: { beatNo: number; shots: (ShotDirection & { shotNo: number })[] }[]
|
||||
}
|
||||
validation: StoryboardValidation
|
||||
}
|
||||
|
||||
/** 单集 VisualState 的自动修复与落库回执。 */
|
||||
export interface VisualStateGeneration {
|
||||
episodeNo: number
|
||||
visualState: unknown
|
||||
validation: StoryboardValidation
|
||||
repaired: boolean
|
||||
repairAttempts: number
|
||||
remainingIssues: StoryboardIssue[]
|
||||
persisted: boolean
|
||||
}
|
||||
|
||||
/** 项目批量生成中的单集结果;保留失败原因和修复诊断。 */
|
||||
export interface EpisodeGenerationResult {
|
||||
episodeNo: number
|
||||
status: 'completed' | 'skipped' | 'failed'
|
||||
shotCount: number
|
||||
directionCount?: number
|
||||
visualStateCount?: number
|
||||
error?: string
|
||||
repaired?: boolean
|
||||
repairAttempts?: number
|
||||
remainingIssues?: StoryboardIssue[]
|
||||
}
|
||||
|
||||
/** 两种设计批处理返回相同的汇总结构。 */
|
||||
export interface StoryboardBatchResult {
|
||||
projectId: string
|
||||
episodeCount: number
|
||||
completedEpisodes: number
|
||||
skippedEpisodes: number
|
||||
failedEpisodes: number
|
||||
shotCount: number
|
||||
directionCount?: number
|
||||
visualStateCount?: number
|
||||
coverage: number
|
||||
episodes: EpisodeGenerationResult[]
|
||||
}
|
||||
|
||||
/** 批处理配置:默认跳过完成项,force 才覆盖。 */
|
||||
export interface StoryboardBatchInput {
|
||||
concurrency: number
|
||||
force: boolean
|
||||
}
|
||||
|
||||
/** 即时状态生成增加有限次的模型自动修复。 */
|
||||
export interface VisualStateBatchInput extends StoryboardBatchInput {
|
||||
maxRepairAttempts: number
|
||||
}
|
||||
|
||||
/** 当前镜头已解析的主体参考图和缺失原因。 */
|
||||
export interface ShotReferences {
|
||||
shotId: string
|
||||
references: {
|
||||
shotSubjectId: string
|
||||
subjectId: string
|
||||
subjectRef: string
|
||||
subjectName: string
|
||||
module: string
|
||||
subjectFormId: string
|
||||
subjectFormName: string
|
||||
imageId: string
|
||||
imageUrl: string
|
||||
providerImageUrl?: string
|
||||
}[]
|
||||
missing: { subjectId: string; subjectRef: string; subjectName: string; reason: string }[]
|
||||
}
|
||||
|
||||
/** 视频提示词接口返回的 Shot 数据切片,不包含实际视频生成。 */
|
||||
export interface ShotPromptResult {
|
||||
id: string
|
||||
videoPrompt: string | null
|
||||
negativePrompt: string | null
|
||||
status: string
|
||||
}
|
||||
|
||||
/** 视频提示词批处理回执,不具备逐集覆盖率字段。 */
|
||||
export interface PromptBatchResult {
|
||||
total: number
|
||||
targetCount: number
|
||||
generated: number
|
||||
skipped: number
|
||||
failed: number
|
||||
failures: { shotId: string; error: string }[]
|
||||
}
|
||||
|
||||
/** 两个 GET 按数据库 shotId 合并后的镜头,不以 shotNo 跨 Beat 关联。 */
|
||||
export interface DesignedShot {
|
||||
shotId: string
|
||||
shotNo: number
|
||||
beatNo: number
|
||||
title: string
|
||||
description: string
|
||||
visualFocus: string
|
||||
durationSeconds?: number
|
||||
direction: ShotDirection | null
|
||||
visualState: ShotVisualState | null
|
||||
}
|
||||
|
||||
/** 生成回执按操作区分,切换剧集时仍保留原始目标信息。 */
|
||||
export type StoryboardReceipt =
|
||||
| { kind: 'direction'; title: string; episodeNo: number; result: DirectionGeneration }
|
||||
| { kind: 'visual-state'; title: string; episodeNo: number; result: VisualStateGeneration }
|
||||
| { kind: 'batch'; title: string; result: StoryboardBatchResult }
|
||||
| { kind: 'prompts'; title: string; result: PromptBatchResult }
|
||||
|
||||
/** 当前浏览器会话保存回执,不将其当作持久化运行状态。 */
|
||||
export interface StoryboardSession {
|
||||
receipt: StoryboardReceipt | null
|
||||
prompts: Record<string, ShotPromptResult>
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { usePolling } from '../../composables/usePolling'
|
||||
import { useProjectContext } from '../projects/context'
|
||||
import { breakdownSnapshot } from '../workflows/selectors'
|
||||
import { getOperation, runOperation } from '../workflows/operations'
|
||||
import { storyboardApi } from './api'
|
||||
import { getStoryboardSession, mergeDesignedShots, storyboardPrerequisites } from './model'
|
||||
|
||||
/** 分镜页允许的生成命令,保持单集与批量参数边界。 */
|
||||
export type StoryboardCommand = 'direction-one' | 'direction-all' | 'visual-one' | 'visual-all' | 'prompts'
|
||||
|
||||
/** 分镜查询、依赖判断与操作入口;不修改后端 graph。 */
|
||||
export function useStoryboard() {
|
||||
const context = useProjectContext()
|
||||
const id = computed(() => context.project.value?.id ?? '')
|
||||
const selectedEpisode = ref<number>()
|
||||
const selectedShot = ref('')
|
||||
const concurrency = ref(2)
|
||||
const maxRepairAttempts = 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]))
|
||||
const query = usePolling(queryKey, async (key, signal) => {
|
||||
const [projectId, number] = JSON.parse(key) as [string, number]
|
||||
if (!projectId || !number) return null
|
||||
const [directions, visualStates] = await Promise.all([
|
||||
storyboardApi.directions(projectId, number, signal),
|
||||
storyboardApi.visualStates(projectId, number, signal)
|
||||
])
|
||||
if (
|
||||
directions.projectId !== projectId ||
|
||||
visualStates.projectId !== projectId ||
|
||||
directions.episodeNo !== number ||
|
||||
visualStates.episodeNo !== number
|
||||
) {
|
||||
throw new Error('分镜查询返回了不匹配的项目或剧集,请刷新后重试。')
|
||||
}
|
||||
return { directions, visualStates }
|
||||
})
|
||||
const data = query.data
|
||||
const source = computed(() => sourceEpisodes.value.find(item => item.episodeNo === episodeNo.value))
|
||||
const shots = computed(() =>
|
||||
mergeDesignedShots(data.value?.directions ?? null, data.value?.visualStates ?? null, source.value)
|
||||
)
|
||||
const shot = computed(() => shots.value.find(item => item.shotId === selectedShot.value) ?? shots.value[0])
|
||||
const prerequisites = computed(() => storyboardPrerequisites(context.checkpoints.value, episodeNo.value))
|
||||
const operation = computed(() => getOperation(id.value))
|
||||
const session = computed(() => getStoryboardSession(id.value))
|
||||
const batchValid = computed(() => Number.isSafeInteger(concurrency.value) && concurrency.value > 0)
|
||||
const repairsValid = computed(() => Number.isSafeInteger(maxRepairAttempts.value) && maxRepairAttempts.value >= 0)
|
||||
const blocked = computed(
|
||||
() =>
|
||||
operation.value.pending ||
|
||||
!!context.error.value ||
|
||||
!!query.error.value ||
|
||||
!data.value ||
|
||||
context.project.value?.status === 'generating' ||
|
||||
prerequisites.value.breakdownRunning
|
||||
)
|
||||
const directionComplete = computed(
|
||||
() =>
|
||||
!!data.value?.directions.shotCount &&
|
||||
data.value.directions.directionCount === data.value.directions.shotCount &&
|
||||
data.value.directions.coverage === 1
|
||||
)
|
||||
const allowed = computed<Record<StoryboardCommand, boolean>>(() => ({
|
||||
'direction-one': !blocked.value && prerequisites.value.directionEpisode && !!data.value?.directions.shotCount,
|
||||
'direction-all': !blocked.value && prerequisites.value.directionProject && batchValid.value,
|
||||
'visual-one':
|
||||
!blocked.value && prerequisites.value.visualEpisode && directionComplete.value && repairsValid.value,
|
||||
'visual-all': !blocked.value && prerequisites.value.visualProject && batchValid.value && repairsValid.value,
|
||||
prompts: !blocked.value && shots.value.length > 0 && batchValid.value
|
||||
}))
|
||||
|
||||
/** 捕获提交时的目标与配置,切换剧集不改变正在运行的请求或回执。 */
|
||||
async function run(command: StoryboardCommand) {
|
||||
if (!allowed.value[command]) return
|
||||
const projectId = id.value
|
||||
const number = episodeNo.value
|
||||
const batch = { concurrency: concurrency.value, force: force.value }
|
||||
const repairs = maxRepairAttempts.value
|
||||
const target = getStoryboardSession(projectId)
|
||||
const titles: Record<StoryboardCommand, string> = {
|
||||
'direction-one': `第 ${number} 集导演设计`,
|
||||
'direction-all': '项目导演设计',
|
||||
'visual-one': `第 ${number} 集即时状态`,
|
||||
'visual-all': '项目即时状态',
|
||||
prompts: '项目视频提示词'
|
||||
}
|
||||
target.receipt = null
|
||||
await runOperation(projectId, `生成${titles[command]}`, async () => {
|
||||
switch (command) {
|
||||
case 'direction-one':
|
||||
target.receipt = {
|
||||
kind: 'direction',
|
||||
title: titles[command],
|
||||
episodeNo: number,
|
||||
result: await storyboardApi.generateDirection(projectId, number)
|
||||
}
|
||||
break
|
||||
case 'direction-all':
|
||||
target.receipt = {
|
||||
kind: 'batch',
|
||||
title: titles[command],
|
||||
result: await storyboardApi.generateDirections(projectId, batch)
|
||||
}
|
||||
break
|
||||
case 'visual-one':
|
||||
target.receipt = {
|
||||
kind: 'visual-state',
|
||||
title: titles[command],
|
||||
episodeNo: number,
|
||||
result: await storyboardApi.generateVisualState(projectId, number, repairs)
|
||||
}
|
||||
break
|
||||
case 'visual-all':
|
||||
target.receipt = {
|
||||
kind: 'batch',
|
||||
title: titles[command],
|
||||
result: await storyboardApi.generateVisualStates(projectId, {
|
||||
...batch,
|
||||
maxRepairAttempts: repairs
|
||||
})
|
||||
}
|
||||
break
|
||||
case 'prompts':
|
||||
// 断网时也可能已部分覆盖,先清空缓存,避免继续展示可能失效的正文。
|
||||
target.prompts = {}
|
||||
target.receipt = {
|
||||
kind: 'prompts',
|
||||
title: titles[command],
|
||||
result: await storyboardApi.generatePrompts(projectId, batch)
|
||||
}
|
||||
break
|
||||
}
|
||||
})
|
||||
await Promise.all([query.refresh(), context.refresh()])
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
selectedEpisode,
|
||||
episodeNo,
|
||||
episodeOptions,
|
||||
selectedShot,
|
||||
shots,
|
||||
shot,
|
||||
query,
|
||||
data,
|
||||
source,
|
||||
prerequisites,
|
||||
session,
|
||||
operation,
|
||||
concurrency,
|
||||
maxRepairAttempts,
|
||||
force,
|
||||
allowed,
|
||||
batchValid,
|
||||
repairsValid,
|
||||
blocked,
|
||||
directionComplete,
|
||||
run
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,16 @@ import ProjectsPage from '../projects/ProjectsPage.vue'
|
||||
import { projectContextKey, type useProjectData } from '../projects/context'
|
||||
import type { ProjectDetail } from '../projects/types'
|
||||
import type { Checkpoint } from './types'
|
||||
import StoryboardPage from '../storyboard/StoryboardPage.vue'
|
||||
import ShotTools from '../storyboard/components/ShotTools.vue'
|
||||
import {
|
||||
designedShot,
|
||||
directionsResult,
|
||||
storyboardCheckpoint,
|
||||
visualStatesResult
|
||||
} from '../storyboard/testing/fixtures'
|
||||
import { getStoryboardSession } from '../storyboard/model'
|
||||
import { getOperation } from './operations'
|
||||
|
||||
/** 模拟 API 响应仅用于测试,不会打包进生产页面。 */
|
||||
const fixture: ProjectDetail = {
|
||||
@@ -48,14 +58,365 @@ function button(label: string): HTMLButtonElement {
|
||||
return element
|
||||
}
|
||||
|
||||
/** 走真实确认弹窗,包括后端任务停止的显式确认。 */
|
||||
async function confirmGeneration(label: string) {
|
||||
button(label).click()
|
||||
await flushPromises()
|
||||
const acknowledgement = document.querySelector<HTMLInputElement>('[role="dialog"] input[type="checkbox"]')
|
||||
expect(button('确认' + label).disabled).toBe(true)
|
||||
acknowledgement!.click()
|
||||
await flushPromises()
|
||||
button('确认' + label).click()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
/** 挂载分镜页面并复用项目上下文 fixture。 */
|
||||
function mountStoryboard() {
|
||||
const provided = context()
|
||||
provided.data.value!.checkpoints = [storyboardCheckpoint()]
|
||||
wrapper = mount(StoryboardPage, {
|
||||
attachTo: document.body,
|
||||
global: { provide: { [projectContextKey as symbol]: provided }, stubs: { RouterLink: true } }
|
||||
})
|
||||
return provided
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
document.body.innerHTML = ''
|
||||
vi.unstubAllGlobals()
|
||||
Object.assign(getStoryboardSession(fixture.id), { receipt: null, prompts: {} })
|
||||
Object.assign(getOperation(fixture.id), { pending: false, label: '', error: '', notice: '' })
|
||||
})
|
||||
|
||||
describe('工作台页面交互', () => {
|
||||
it('参考图拦截危险地址,生成规格只通过 GET 读取正式形态和状态', async () => {
|
||||
const shot = designedShot()
|
||||
const fetcher = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
shotId: shot.shotId,
|
||||
references: [
|
||||
{
|
||||
shotSubjectId: 'binding-db',
|
||||
subjectId: 'subject-db',
|
||||
subjectRef: '@CH0001',
|
||||
subjectName: '林知夏',
|
||||
module: 'character',
|
||||
subjectFormId: 'form-db',
|
||||
subjectFormName: '日常',
|
||||
imageId: 'image-db',
|
||||
imageUrl: 'javascript:alert(1)'
|
||||
}
|
||||
],
|
||||
missing: []
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
projectId: fixture.id,
|
||||
episodeNo: 1,
|
||||
beatNo: 1,
|
||||
shotId: shot.shotId,
|
||||
shotNo: 1,
|
||||
title: '来信',
|
||||
description: '真实镜头',
|
||||
visualFocus: '信封',
|
||||
durationSeconds: 5,
|
||||
subjects: [{ subjectId: 'subject-db', subjectFormId: 'form-db' }],
|
||||
direction: shot.direction,
|
||||
environment: {
|
||||
timeOfDay: '夜晚',
|
||||
weather: '小雨',
|
||||
atmosphere: '安静',
|
||||
transientState: '地面积水'
|
||||
},
|
||||
continuityNote: '信封在右手'
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
wrapper = mount(ShotTools, { attachTo: document.body, props: { projectId: fixture.id, shot, disabled: false } })
|
||||
button('读取参考图').click()
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('图片不可用')
|
||||
expect(wrapper.find('.reference-card a').exists()).toBe(false)
|
||||
expect(wrapper.find('.reference-card img').exists()).toBe(false)
|
||||
button('读取生成规格').click()
|
||||
await flushPromises()
|
||||
expect(wrapper.get('pre').text()).toContain('form-db')
|
||||
expect(wrapper.get('pre').text()).toContain('地面积水')
|
||||
expect(fetcher.mock.calls[1]?.[0]).toBe('/api/storyboard-shots/shot-db-1-1/generation-spec')
|
||||
expect(fetcher.mock.calls.every(([, init]) => init?.method !== 'POST')).toBe(true)
|
||||
})
|
||||
|
||||
it('生成途中切换剧集并离开页面,不改变提交目标,也不丢失项目回执', async () => {
|
||||
let finish!: (response: Response) => void
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation((url, init) => {
|
||||
if (init?.method === 'POST')
|
||||
return new Promise(resolve => {
|
||||
finish = resolve
|
||||
})
|
||||
const episodeNo = Number(new URL(String(url), 'http://localhost').searchParams.get('episodeNo'))
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: String(url).includes('storyboard-directions')
|
||||
? directionsResult(fixture.id, episodeNo)
|
||||
: visualStatesResult(fixture.id, episodeNo)
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
mountStoryboard()
|
||||
await flushPromises()
|
||||
await confirmGeneration('生成本集导演设计')
|
||||
expect(button('补齐项目导演设计').disabled).toBe(true)
|
||||
await wrapper!.get('select').setValue('2')
|
||||
await flushPromises()
|
||||
wrapper!.unmount()
|
||||
wrapper = undefined
|
||||
finish(
|
||||
new Response(
|
||||
'{"data":{"episodeNo":1,"direction":{"episodeNo":1,"beatDirections":[]},"validation":{"valid":true,"issues":[]}}}'
|
||||
)
|
||||
)
|
||||
await flushPromises()
|
||||
expect(getStoryboardSession(fixture.id).receipt).toMatchObject({
|
||||
kind: 'direction',
|
||||
episodeNo: 1,
|
||||
title: '第 1 集导演设计'
|
||||
})
|
||||
expect(getOperation(fixture.id).pending).toBe(false)
|
||||
const posts = fetcher.mock.calls.filter(([, init]) => init?.method === 'POST')
|
||||
expect(posts).toHaveLength(1)
|
||||
expect(JSON.parse(posts[0]![1]!.body as string)).toEqual({ episodeNo: 1, persist: true })
|
||||
})
|
||||
|
||||
it('分镜必须先补齐导演设计,校验失败显示未保存,修复次数 0 不被默认值覆盖', async () => {
|
||||
let complete = false
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
|
||||
if (init?.method === 'POST')
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
episodeNo: 1,
|
||||
visualState: {},
|
||||
validation: {
|
||||
valid: false,
|
||||
issues: [{ episodeNo: 1, beatNo: 1, shotNo: 1, message: '缺少主体状态' }]
|
||||
},
|
||||
repaired: false,
|
||||
repairAttempts: 0,
|
||||
remainingIssues: [],
|
||||
persisted: false
|
||||
}
|
||||
})
|
||||
)
|
||||
const result = String(url).includes('storyboard-directions')
|
||||
? directionsResult(fixture.id, 1, complete)
|
||||
: visualStatesResult(fixture.id)
|
||||
return new Response(JSON.stringify({ data: result }))
|
||||
})
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
mountStoryboard()
|
||||
await flushPromises()
|
||||
expect(button('生成本集导演设计').disabled).toBe(false)
|
||||
expect(button('生成本集即时状态').disabled).toBe(true)
|
||||
expect(fetcher.mock.calls.every(([, init]) => init?.method !== 'POST')).toBe(true)
|
||||
complete = true
|
||||
button('刷新分镜').click()
|
||||
await flushPromises()
|
||||
expect(button('生成本集即时状态').disabled).toBe(false)
|
||||
await wrapper!.get('#storyboard-repairs').setValue('0')
|
||||
await confirmGeneration('生成本集即时状态')
|
||||
const post = fetcher.mock.calls.find(([, init]) => init?.method === 'POST')!
|
||||
expect(JSON.parse(post[1]!.body as string)).toEqual({ episodeNo: 1, persist: true, maxRepairAttempts: 0 })
|
||||
expect(wrapper!.text()).toContain('校验失败,未保存本次生成结果')
|
||||
expect(wrapper!.text()).toContain('缺少主体状态')
|
||||
})
|
||||
|
||||
it('批量 200 部分失败不显示全成功,默认补齐与显式覆盖参数独立', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
|
||||
if (init?.method === 'POST')
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
projectId: fixture.id,
|
||||
episodeCount: 2,
|
||||
completedEpisodes: 0,
|
||||
skippedEpisodes: 1,
|
||||
failedEpisodes: 1,
|
||||
shotCount: 4,
|
||||
directionCount: 2,
|
||||
coverage: 0.5,
|
||||
episodes: [
|
||||
{ episodeNo: 1, status: 'skipped', shotCount: 2, directionCount: 2 },
|
||||
{
|
||||
episodeNo: 2,
|
||||
status: 'failed',
|
||||
shotCount: 2,
|
||||
directionCount: 0,
|
||||
error: '模型校验失败'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
)
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: String(url).includes('storyboard-directions')
|
||||
? directionsResult(fixture.id)
|
||||
: visualStatesResult(fixture.id)
|
||||
})
|
||||
)
|
||||
})
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
mountStoryboard()
|
||||
await flushPromises()
|
||||
await confirmGeneration('补齐项目导演设计')
|
||||
expect(wrapper!.get('[aria-label="生成回执"] [role="alert"]').text()).toContain('存在失败')
|
||||
expect(wrapper!.text()).toContain('模型校验失败')
|
||||
await wrapper!.get('input[type="checkbox"]').setValue(true)
|
||||
await confirmGeneration('重生成全部导演设计')
|
||||
const posts = fetcher.mock.calls.filter(([, init]) => init?.method === 'POST')
|
||||
expect(posts.map(([, init]) => JSON.parse(init!.body as string))).toEqual([
|
||||
{ concurrency: 2, force: false },
|
||||
{ concurrency: 2, force: true }
|
||||
])
|
||||
})
|
||||
|
||||
it('切换剧集取消旧查询,迟到响应不会覆盖新剧集,也不自动提交生成', async () => {
|
||||
const oldRequests: { finish: (response: Response) => void; url: string; signal: AbortSignal }[] = []
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation((url, init) => {
|
||||
const address = String(url)
|
||||
if (address.endsWith('episodeNo=1'))
|
||||
return new Promise(resolve =>
|
||||
oldRequests.push({ finish: resolve, url: address, signal: init!.signal as AbortSignal })
|
||||
)
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: address.includes('storyboard-directions')
|
||||
? directionsResult(fixture.id, 2)
|
||||
: visualStatesResult(fixture.id, 2)
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
mountStoryboard()
|
||||
await flushPromises()
|
||||
await wrapper!.get('select[aria-label="选择分镜剧集"]').setValue('2')
|
||||
await flushPromises()
|
||||
expect(oldRequests.every(request => request.signal.aborted)).toBe(true)
|
||||
for (const request of oldRequests)
|
||||
request.finish(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: request.url.includes('storyboard-directions')
|
||||
? directionsResult(fixture.id)
|
||||
: visualStatesResult(fixture.id)
|
||||
})
|
||||
)
|
||||
)
|
||||
await flushPromises()
|
||||
expect(wrapper!.text()).toContain('shot-db-2-1')
|
||||
expect(wrapper!.text()).not.toContain('shot-db-1-1')
|
||||
expect(fetcher.mock.calls).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('查询失败保留已读取镜头并禁用生成,刷新恢复后才允许操作', async () => {
|
||||
let fail = false
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async url => {
|
||||
if (fail) return new Response('{"message":"数据库暂不可用"}', { status: 503 })
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: String(url).includes('storyboard-directions')
|
||||
? directionsResult(fixture.id)
|
||||
: visualStatesResult(fixture.id)
|
||||
})
|
||||
)
|
||||
})
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
mountStoryboard()
|
||||
await flushPromises()
|
||||
fail = true
|
||||
button('刷新分镜').click()
|
||||
await flushPromises()
|
||||
expect(wrapper!.text()).toContain('shot-db-1-1')
|
||||
expect(wrapper!.text()).toContain('数据库暂不可用')
|
||||
expect(button('生成本集导演设计').disabled).toBe(true)
|
||||
fail = false
|
||||
button('重试查询').click()
|
||||
await flushPromises()
|
||||
expect(button('生成本集导演设计').disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('镜头工具按需 GET,切换镜头会废弃旧参考图,未完成设计不能编译规格', async () => {
|
||||
let finish!: (response: Response) => void
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(
|
||||
() =>
|
||||
new Promise(resolve => {
|
||||
finish = resolve
|
||||
})
|
||||
)
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
wrapper = mount(ShotTools, {
|
||||
attachTo: document.body,
|
||||
props: { projectId: fixture.id, shot: designedShot(), disabled: false }
|
||||
})
|
||||
expect(fetcher).not.toHaveBeenCalled()
|
||||
button('读取参考图').click()
|
||||
await flushPromises()
|
||||
expect(fetcher.mock.calls[0]?.[0]).toBe('/api/storyboard-shots/shot-db-1-1/references')
|
||||
await wrapper.setProps({ shot: { ...designedShot('shot-db-1-2'), visualState: null } })
|
||||
expect(fetcher.mock.calls[0]?.[1]?.signal?.aborted).toBe(true)
|
||||
finish(
|
||||
new Response(
|
||||
'{"data":{"shotId":"shot-db-1-1","references":[],"missing":[{"subjectId":"x","subjectName":"旧镜头人物","reason":"缺图"}]}}'
|
||||
)
|
||||
)
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('旧镜头人物')
|
||||
expect(button('读取生成规格').disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('提示词必须确认才 POST,使用正式 Shot ID,返回内容以纯文本显示', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
id: 'shot-db-1-1',
|
||||
videoPrompt: '<script>模型内容</script>',
|
||||
negativePrompt: '模糊',
|
||||
status: 'prompt_ready'
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
wrapper = mount(ShotTools, {
|
||||
attachTo: document.body,
|
||||
props: { projectId: fixture.id, shot: designedShot(), disabled: false }
|
||||
})
|
||||
expect(fetcher).not.toHaveBeenCalled()
|
||||
await confirmGeneration('读取/生成提示词')
|
||||
expect(fetcher.mock.calls[0]?.[0]).toBe('/api/storyboard-shots/shot-db-1-1/video-prompt')
|
||||
expect(JSON.parse(fetcher.mock.calls[0]![1]!.body as string)).toEqual({ force: false })
|
||||
expect(wrapper.text()).toContain('<script>模型内容</script>')
|
||||
expect(wrapper.find('script').exists()).toBe(false)
|
||||
})
|
||||
it('项目筛选无结果时可清除条件并返回真实列表', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiError, optionalResource, request } from './http'
|
||||
import { projectsApi } from '../features/projects/api'
|
||||
import { breakdownApi } from '../features/breakdown/api'
|
||||
import { storyboardApi } from '../features/storyboard/api'
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
@@ -9,6 +10,66 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('后端 API 契约', () => {
|
||||
it('分镜单集显式持久化,批量保持 force 和零次修复参数', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async () => new Response('{"data":{}}'))
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
await storyboardApi.generateDirection('a/b', 2)
|
||||
await storyboardApi.generateDirections('p', { concurrency: 2, force: false })
|
||||
await storyboardApi.generateVisualState('p', 2, 0)
|
||||
await storyboardApi.generateVisualStates('p', { concurrency: 3, force: true, maxRepairAttempts: 0 })
|
||||
expect(fetcher.mock.calls.map(([url, init]) => [url, init?.method, JSON.parse(init!.body as string)])).toEqual([
|
||||
['/api/projects/a%2Fb/storyboard-directions/generate-test', 'POST', { episodeNo: 2, persist: true }],
|
||||
['/api/projects/p/storyboard-directions/generate', 'POST', { concurrency: 2, force: false }],
|
||||
[
|
||||
'/api/projects/p/storyboard-visual-states/generate-test',
|
||||
'POST',
|
||||
{ episodeNo: 2, persist: true, maxRepairAttempts: 0 }
|
||||
],
|
||||
[
|
||||
'/api/projects/p/storyboard-visual-states/generate',
|
||||
'POST',
|
||||
{ concurrency: 3, force: true, maxRepairAttempts: 0 }
|
||||
]
|
||||
])
|
||||
})
|
||||
|
||||
it('查询按剧集,镜头工具使用数据库 ID,读取提示词仍需显式 POST', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async () => new Response('{"data":{}}'))
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
await storyboardApi.directions('p', 4)
|
||||
await storyboardApi.visualStates('p', 4)
|
||||
await storyboardApi.references('shot/id')
|
||||
await storyboardApi.generationSpec('shot/id')
|
||||
await storyboardApi.generatePrompt('shot/id', false)
|
||||
await storyboardApi.generatePrompts('p', { concurrency: 2, force: false })
|
||||
expect(fetcher.mock.calls.map(([url]) => url)).toEqual([
|
||||
'/api/projects/p/storyboard-directions?episodeNo=4',
|
||||
'/api/projects/p/storyboard-visual-states?episodeNo=4',
|
||||
'/api/storyboard-shots/shot%2Fid/references',
|
||||
'/api/storyboard-shots/shot%2Fid/generation-spec',
|
||||
'/api/storyboard-shots/shot%2Fid/video-prompt',
|
||||
'/api/projects/p/video-prompts/generate'
|
||||
])
|
||||
expect(JSON.parse(fetcher.mock.calls[4]![1]!.body as string)).toEqual({ force: false })
|
||||
})
|
||||
|
||||
it('分镜生成保留 200 中的校验失败和持久化标志,长请求不自动超时或重试', async () => {
|
||||
vi.useFakeTimers()
|
||||
let finish!: (response: Response) => void
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(
|
||||
() =>
|
||||
new Promise(resolve => {
|
||||
finish = resolve
|
||||
})
|
||||
)
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
const pending = storyboardApi.generateVisualState('p', 1, 2)
|
||||
await vi.advanceTimersByTimeAsync(120_000)
|
||||
expect(fetcher).toHaveBeenCalledOnce()
|
||||
expect(fetcher.mock.calls[0]?.[1]?.signal?.aborted).toBe(false)
|
||||
finish(new Response('{"data":{"validation":{"valid":false,"issues":[]},"persisted":false}}'))
|
||||
await expect(pending).resolves.toMatchObject({ validation: { valid: false }, persisted: false })
|
||||
})
|
||||
it('解包 data,同时保留创建项目的顶层 202 结构', async () => {
|
||||
const fetcher = vi
|
||||
.fn<(url: string, options: RequestInit) => Promise<Response>>()
|
||||
|
||||
@@ -24,6 +24,11 @@ export const router = createRouter({
|
||||
path: 'breakdown',
|
||||
component: () => import('../features/breakdown/BreakdownPage.vue'),
|
||||
meta: { title: '剧本拆解' }
|
||||
},
|
||||
{
|
||||
path: 'storyboard',
|
||||
component: () => import('../features/storyboard/StoryboardPage.vue'),
|
||||
meta: { title: '分镜设计' }
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
+102
@@ -492,11 +492,13 @@
|
||||
}
|
||||
.workflow-nav {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
gap: 28px;
|
||||
border-bottom: 1px solid #dfe2d8;
|
||||
}
|
||||
.workflow-nav a {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 0 2px 16px;
|
||||
@@ -612,6 +614,86 @@
|
||||
background: #8a9b6f;
|
||||
box-shadow: 0 0 0 3px #e9edde;
|
||||
}
|
||||
.storyboard-controls {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 1fr) 100px 145px auto;
|
||||
gap: 18px;
|
||||
align-items: end;
|
||||
}
|
||||
.generation-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding-top: 20px;
|
||||
margin-top: 20px;
|
||||
border-top: 1px solid var(--color-line);
|
||||
}
|
||||
.generation-row > div:last-child {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.storyboard-workspace {
|
||||
display: grid;
|
||||
grid-template-columns: 220px minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
}
|
||||
.shot-list {
|
||||
max-height: 1000px;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid var(--color-line);
|
||||
background: #fdfefa;
|
||||
padding: 0 8px 20px;
|
||||
}
|
||||
.shot-link {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px 10px;
|
||||
border-radius: 4px;
|
||||
text-align: left;
|
||||
}
|
||||
.shot-link:hover {
|
||||
background: #f2f4ec;
|
||||
}
|
||||
.shot-link.selected {
|
||||
background: #eeeee0;
|
||||
color: #454f37;
|
||||
}
|
||||
.reference-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.reference-card {
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.reference-card img {
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
object-fit: contain;
|
||||
background: #f4f5ef;
|
||||
}
|
||||
.reference-placeholder {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
aspect-ratio: 4 / 3;
|
||||
font-size: 11px;
|
||||
color: var(--color-muted);
|
||||
background: #f4f5ef;
|
||||
}
|
||||
.storyboard-json {
|
||||
overflow: auto;
|
||||
max-height: 440px;
|
||||
padding: 16px;
|
||||
border-radius: 4px;
|
||||
background: #f6f7f1;
|
||||
font:
|
||||
11px/1.9 ui-monospace,
|
||||
monospace;
|
||||
}
|
||||
.script-workspace {
|
||||
display: grid;
|
||||
grid-template-columns: 166px minmax(0, 1fr);
|
||||
@@ -833,6 +915,14 @@
|
||||
|
||||
/* 中等宽度保留正文空间,执行记录下移,移动端导航收为图标。 */
|
||||
@media (max-width: 1200px) {
|
||||
.generation-row {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.storyboard-controls {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
}
|
||||
.page-container {
|
||||
padding-inline: 26px;
|
||||
}
|
||||
@@ -861,6 +951,18 @@
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.storyboard-controls {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
.storyboard-workspace {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.shot-list {
|
||||
max-height: 240px;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--color-line);
|
||||
}
|
||||
.app-shell {
|
||||
grid-template-columns: 62px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user