feat: 接入分镜导演设计与即时视觉状态工作台

增加单集和批量生成、修复诊断、正式镜头检查、参考图、生成规格与视频提示词。
补齐接口契约、竞态保护和确认交互测试,保持现有两条工作流。
This commit is contained in:
GouJ
2026-08-28 14:14:25 +08:00
parent 2ee6f049f6
commit bae69c3c62
22 changed files with 2211 additions and 11 deletions
+42 -6
View File
@@ -1,6 +1,6 @@
# Short Drama Agent Front
短剧 Agent 的前端工作台。第一阶段支持 `create-drama``breakdown`,使用真实后端 API,不包含演示数据或浏览器端模型调用。
短剧 Agent 的前端工作台。支持 `create-drama``breakdown``storyboard` 工作区,使用真实后端 API,不包含演示数据或浏览器端模型调用。
## 启动
@@ -31,8 +31,11 @@ API_PROXY_TARGET=http://localhost:3412
| 拆解配置 | 每组集数、人物/场景/道具模块选择、后端分组预览 |
| 拆解结果 | 主体、别名、视觉形态、Episode → Beat → Shot、主体绑定、抽取任务状态、校验问题、JSON 导出 |
| 拆解恢复 | 失败抽取重试、缺失剧集镜头补齐、分镜引用与 Form 绑定修复 |
| 分镜检查 | 按剧集、Beat、正式 Shot ID 浏览;导演设计、环境与主体即时状态;单集覆盖率与 JSON 导出 |
| 分镜生成 | Direction / VisualState 单集生成、项目批量补齐、覆盖重生成;并发和自动修复次数;部分失败诊断 |
| 镜头工具 | 主体参考图与缺失原因、确定性 GenerationSpec 查询与导出、单镜头/批量视频提示词 |
本阶段没有实现:手工编辑剧本(后端暂无对应写接口)、StoryboardDirection graph、图像或视频生成、用户登录。正文中的模型输出按纯文本显示,避免执行不可信 HTML。
尚未实现:手工编辑剧本或分镜设计(后端暂无对应写接口)、主体图生成、实际视频生成、用户登录。模型输出按纯文本显示,避免执行不可信 HTML。参考图只接受 HTTP(S) 或后端 `/storage/` 路径。
## 技术与规范
@@ -61,6 +64,7 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S
| `src/features/projects` | 项目接口、类型、列表、新建弹窗、项目布局与共享上下文 |
| `src/features/create-drama` | 剧本创作 graph 页面 |
| `src/features/breakdown` | 拆解 graph 的 API、类型、配置页面、主体与分镜组件 |
| `src/features/storyboard` | 分镜 API、类型、生成操作、依赖判断、设计与状态、镜头资源、生成回执 |
| `src/features/workflows` | checkpoint 选择、执行记录、长请求互斥与操作确认 |
| `src/components/ui` | 跨业务使用的 Reka UI 封装与公共展示组件 |
| `src/composables` | 具备取消和竞态保护的轮询 |
@@ -72,7 +76,7 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S
## 接口基线
对齐后端 `qianlanse/short-drama-agent``dev`
`de47d42eddd5acdbd32a8b4ed3044f9155d18508`
`6ce284b9d93e8c9d0b7ca57d265148e354642095`
| HTTP | 路径(以 /api 为前缀) | 用途 |
| --- | --- | --- |
@@ -86,13 +90,23 @@ Oxfmt 不负责代码质量,Oxlint 不负责 Vue 的完整类型推导;Vue S
| POST | `/projects/:id/breakdown/retry` | 仅重试失败的抽取 Task |
| POST | `/projects/:id/breakdown/resume-shots` | 复用已成功剧集镜头,生成缺失剧集 |
| POST | `/projects/:id/breakdown/resume-storyboard` | 修复已有镜头的 SubjectRef / Form,再持久化 |
| GET | `/projects/:id/storyboard-directions?episodeNo=N` | 正式 Shot ID、导演设计、单集覆盖率 |
| POST | `/projects/:id/storyboard-directions/generate-test` | 单集导演设计;明确发送 `persist: true` |
| POST | `/projects/:id/storyboard-directions/generate` | 项目批量;`concurrency``force` |
| GET | `/projects/:id/storyboard-visual-states?episodeNo=N` | 已持久化的环境、主体状态、单集覆盖率 |
| POST | `/projects/:id/storyboard-visual-states/generate-test` | 单集即时状态;`persist: true``maxRepairAttempts` |
| POST | `/projects/:id/storyboard-visual-states/generate` | 项目批量;增加 `maxRepairAttempts` |
| GET | `/storyboard-shots/:shotId/references` | 主体形态参考图、缺失原因 |
| GET | `/storyboard-shots/:shotId/generation-spec` | 后端确定性编译结果,不调用模型 |
| POST | `/storyboard-shots/:shotId/video-prompt` | 复用已有 Prompt 或调用模型生成;`force` 控制覆盖 |
| POST | `/projects/:id/video-prompts/generate` | 项目批量 Prompt`concurrency``force` |
API 层也提供 `state``breakdown/latest` 方法。页面通过共享 checkpoint 查询取得阶段状态,避免重复拉取同样内容。
### 异步与恢复约定
1. `POST /projects` 返回顶层 `202 { projectId, status }`,其他接口通常返回 `{ data }`HTTP 层分别兼容。
2. Breakdown 和恢复接口目前是**长 HTTP 请求**。不设客户端短超时,不自动重试 POST。关闭页面只会丢失请求连接,**不会取消后端任务**。
2. Breakdown、Storyboard 和恢复接口目前是**长 HTTP 请求**。不设客户端短超时,不自动重试 POST。关闭页面只会丢失请求连接,**不会取消后端任务**。
3. 项目列表每 12 秒、项目详情每 6 秒串行刷新。隐藏页面暂停自动查询。切换项目或离开布局时取消查询,旧响应不会覆盖新项目。
4. 抽取任务进度不是全流程进度。抽取完成之后还需要主体合并、形态、节拍、镜头和持久化。
5. 后端失败 checkpoint 可能只有错误。页面回看最近可用的完整快照以保留成果,同时使用最新记录的 execution 状态。显示的是**最近可用快照**,并不保证全部成果来自最后一次执行。
@@ -100,6 +114,19 @@ API 层也提供 `state` 与 `breakdown/latest` 方法。页面通过共享 chec
7. 恢复操作要求确认后台已停止。当前只做浏览器会话内、项目级互斥,不能替代后端跨浏览器/跨用户的任务锁。断网后先检查后台和 checkpoint,不要立即重复点击。
8. 修改分组或模块后,必须重新预览再启动;重新拆解会调用完整流程,可能替换旧的主体和分镜。
### Storyboard 约定
1. 入口为 `/projects/:projectId/storyboard`。先完成 Breakdown 的主体、形态、镜头与持久化,再生成 Direction,最后生成 VisualState。
2. 当前 Direction 服务读取**最新** Breakdown 的 `state.breakdownResult.storyboardEpisodeShots`VisualState 读取顶层 `state.storyboardEpisodeShots``subjectCandidates``subjectForms`。页面不使用历史可恢复快照代替这些生成前置条件。历史快照只补充镜头标题和剧情描述。
3. 镜头 ID 来自正式数据库 GET,按 `shotId` 关联 Direction 和 VisualState。`shotNo` 仅在 Beat 内唯一,不能用它拼装接口 ID。VisualState 查询响应的环境字段是扁平结构,与单集生成返回的领域结构不同。
4. 单集入口虽然名为 `generate-test`,也是后端目前唯一的单集生成接口。页面发送 `persist: true`,通过校验才保存;**单集始终重生成**,不受批量的覆盖开关影响。
5. 批量默认 `force: false`,跳过完整剧集。重新点击“补齐”用于重试未完成项,没有虚构独立 retry 接口。`force: true` 才覆盖全部。VisualState 可设置非负整数修复次数,`0` 表示不修复。
6. VisualState 单集生成需要该集所有正式镜头都有 Direction;批量接口由后端逐集检查,缺少前置条件的集数会单独失败。HTTP 200 也可能包含校验失败或部分失败,页面展示逐集诊断和修复次数,不视为全成功。
7. 上游重生成**不会自动使下游旧数据失效**。覆盖 Direction 后请重生成 VisualState,修改设计/状态/参考图后按需覆盖 Prompt。覆盖率只说明字段已保存,不能证明下游与新版本一致。
8. 每 6 秒查询当前剧集的两类正式结果;这不是全项目实时进度。后端没有独立 Storyboard running / executionId 查询。回执只保留当前浏览器会话,切换页面不丢失,刷新浏览器会丢失。
9. 参考图和 GenerationSpec 按需 GET,切换镜头或设计变化时取消旧查询。GenerationSpec 需要有效的 Direction、VisualState 和形态关联,不生成图片或视频。
10. 后端当前的 Video Prompt 仍使用镜头描述和参考图,**尚未消费 GenerationSpec**。没有独立 GET Prompt 接口,“读取/生成提示词”仍是 POST;不存在 Prompt 时会调用模型,因此必须确认。`force: false` 复用已有正文;批量响应只有计数和失败列表,不能凭计数构造 Prompt 正文。`generated` 是后端处理成功数,可能包含已存在但状态不为 `prompt_ready` 的复用项。
### 后端限制
目前 `checkpoints` 接口返回完整历史与 state,没有分页或轻量状态接口。长篇剧本会增加轮询流量。下一步建议后端增加:
@@ -108,7 +135,7 @@ API 层也提供 `state` 与 `breakdown/latest` 方法。页面通过共享 chec
- 精简的进度接口及按 graph 分页的 checkpoint
- 将 Breakdown 改为 202 异步任务提交,再以轮询或 SSE 读取进度。
前端没有假装这些能力已经存在,也没有为了显示进度去调用 `stream-test` 等测试接口
前端未模拟这些能力,不调用 `stream-test` 来伪造进度。Storyboard 的单集 `generate-test` 仅在用户明确确认生成后调用
## 部署
@@ -134,6 +161,12 @@ server {
proxy_send_timeout 3600s;
proxy_buffering off;
}
location /storage/ {
# 参考图由同一后端的静态资源服务提供。
proxy_pass http://127.0.0.1:3412;
proxy_set_header Host $host;
}
}
```
@@ -146,5 +179,8 @@ server {
3. 查看角色、世界观与审核,再进入拆解页;预览分组与模块。
4. 启动拆解,查看 checkpoint、主体、形态、每集 Beat / Shot 和绑定。
5. 在可控测试项目中触发故障,确认 retry / resume-shots / resume-storyboard 各自对应正确失败阶段。
6. 进入分镜设计,确认正式镜头 ID 与 Beat 对应;先生成单集 Direction,再生成 VisualState,检查覆盖率和主体状态。
7. 用小型测试项目验证批量补齐、跳过完整集、失败诊断和明确覆盖;确认关闭覆盖后不会重生成完整设计。
8. 给主体形态准备参考图,读取镜头参考图和 GenerationSpec;确认 Prompt 无正文时经确认生成、有正文且不覆盖时复用。不提交实际视频任务。
自动测试使用模拟 API只验证前端契约与交互,不等同于真实模型、MySQL 或浏览器视觉联调。请勿用正式项目随意制造失败以测试恢复。
自动测试使用模拟 API覆盖两条既有流程及分镜 API 参数、依赖判断、正式 ID 合并、校验与部分失败、切换剧集的竞态、查询错误恢复、Prompt 确认和纯文本展示。不等同于真实模型、MySQL 或浏览器视觉联调。请勿用正式项目随意制造失败以测试恢复。
+18 -2
View File
@@ -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
+6 -1
View File
@@ -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">
+4 -1
View File
@@ -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>
+1 -1
View File
@@ -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([
+327
View File
@@ -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>
+71
View File
@@ -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
}
+3
View File
@@ -0,0 +1,3 @@
/** Storyboard 模块公共入口。 */
export { storyboardApi } from './api'
export type { EpisodeDirections, EpisodeVisualStates, ShotDirection, ShotVisualState } from './types'
+64
View File
@@ -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()
}
})
})
+128
View File
@@ -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
}
}
+152
View File
@@ -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)
}
}
+190
View File
@@ -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>
}
+182
View File
@@ -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
}
}
+361
View File
@@ -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',
+61
View File
@@ -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>>()
+5
View File
@@ -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
View File
@@ -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);
}
+4
View File
@@ -15,6 +15,10 @@ export default defineConfig(({ mode }) => {
changeOrigin: true,
timeout: 0,
proxyTimeout: 0
},
'/storage': {
target: env.API_PROXY_TARGET || 'http://localhost:3412',
changeOrigin: true
}
}
}