diff --git a/README.md b/README.md index 30f8907..d26d660 100644 --- a/README.md +++ b/README.md @@ -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 或浏览器视觉联调。请勿用正式项目随意制造失败以测试恢复。 diff --git a/src/App.vue b/src/App.vue index e2d3269..9a0a0f5 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,7 +1,16 @@ + + diff --git a/src/features/storyboard/api.ts b/src/features/storyboard/api.ts new file mode 100644 index 0000000..021ce4b --- /dev/null +++ b/src/features/storyboard/api.ts @@ -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(`${projectPath(id)}/storyboard-directions?episodeNo=${episodeNo}`, { signal }), + visualStates: (id: string, episodeNo: number, signal?: AbortSignal) => + request(`${projectPath(id)}/storyboard-visual-states?episodeNo=${episodeNo}`, { signal }), + generateDirection: (id: string, episodeNo: number) => + request(`${projectPath(id)}/storyboard-directions/generate-test`, { + method: 'POST', + body: { episodeNo, persist: true }, + timeoutMs: 0 + }), + generateDirections: (id: string, input: StoryboardBatchInput) => + request(`${projectPath(id)}/storyboard-directions/generate`, { + method: 'POST', + body: input, + timeoutMs: 0 + }), + generateVisualState: (id: string, episodeNo: number, maxRepairAttempts: number) => + request(`${projectPath(id)}/storyboard-visual-states/generate-test`, { + method: 'POST', + body: { episodeNo, persist: true, maxRepairAttempts }, + timeoutMs: 0 + }), + generateVisualStates: (id: string, input: VisualStateBatchInput) => + request(`${projectPath(id)}/storyboard-visual-states/generate`, { + method: 'POST', + body: input, + timeoutMs: 0 + }), + references: (id: string, signal?: AbortSignal) => request(`${shotPath(id)}/references`, { signal }), + generationSpec: (id: string, signal?: AbortSignal) => + request(`${shotPath(id)}/generation-spec`, { signal }), + generatePrompt: (id: string, force: boolean) => + request(`${shotPath(id)}/video-prompt`, { + method: 'POST', + body: { force }, + timeoutMs: 0 + }), + generatePrompts: (id: string, input: StoryboardBatchInput) => + request(`${projectPath(id)}/video-prompts/generate`, { + method: 'POST', + body: input, + timeoutMs: 0 + }) +} diff --git a/src/features/storyboard/components/GenerationReceipt.vue b/src/features/storyboard/components/GenerationReceipt.vue new file mode 100644 index 0000000..0ad2946 --- /dev/null +++ b/src/features/storyboard/components/GenerationReceipt.vue @@ -0,0 +1,113 @@ + + + diff --git a/src/features/storyboard/components/ShotDesign.vue b/src/features/storyboard/components/ShotDesign.vue new file mode 100644 index 0000000..b1a6e99 --- /dev/null +++ b/src/features/storyboard/components/ShotDesign.vue @@ -0,0 +1,88 @@ + + + diff --git a/src/features/storyboard/components/ShotTools.vue b/src/features/storyboard/components/ShotTools.vue new file mode 100644 index 0000000..7287b32 --- /dev/null +++ b/src/features/storyboard/components/ShotTools.vue @@ -0,0 +1,226 @@ + + + diff --git a/src/features/storyboard/generation-spec.types.ts b/src/features/storyboard/generation-spec.types.ts new file mode 100644 index 0000000..751b3a6 --- /dev/null +++ b/src/features/storyboard/generation-spec.types.ts @@ -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 +} diff --git a/src/features/storyboard/index.ts b/src/features/storyboard/index.ts new file mode 100644 index 0000000..a98372e --- /dev/null +++ b/src/features/storyboard/index.ts @@ -0,0 +1,3 @@ +/** Storyboard 模块公共入口。 */ +export { storyboardApi } from './api' +export type { EpisodeDirections, EpisodeVisualStates, ShotDirection, ShotVisualState } from './types' diff --git a/src/features/storyboard/model.test.ts b/src/features/storyboard/model.test.ts new file mode 100644 index 0000000..7793461 --- /dev/null +++ b/src/features/storyboard/model.test.ts @@ -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() + } + }) +}) diff --git a/src/features/storyboard/model.ts b/src/features/storyboard/model.ts new file mode 100644 index 0000000..6e8aa6c --- /dev/null +++ b/src/features/storyboard/model.ts @@ -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>({}) + +/** 获取指定项目的独立回执容器。 */ +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() + 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 = { + '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 + } +} diff --git a/src/features/storyboard/testing/fixtures.ts b/src/features/storyboard/testing/fixtures.ts new file mode 100644 index 0000000..13c5d7a --- /dev/null +++ b/src/features/storyboard/testing/fixtures.ts @@ -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) + } +} diff --git a/src/features/storyboard/types.ts b/src/features/storyboard/types.ts new file mode 100644 index 0000000..d6a7220 --- /dev/null +++ b/src/features/storyboard/types.ts @@ -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 +} diff --git a/src/features/storyboard/useStoryboard.ts b/src/features/storyboard/useStoryboard.ts new file mode 100644 index 0000000..f01f432 --- /dev/null +++ b/src/features/storyboard/useStoryboard.ts @@ -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() + 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>(() => ({ + '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 = { + '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 + } +} diff --git a/src/features/workflows/pages.test.ts b/src/features/workflows/pages.test.ts index c461ff1..0e27c07 100644 --- a/src/features/workflows/pages.test.ts +++ b/src/features/workflows/pages.test.ts @@ -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('[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() + .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().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().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().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().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().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().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().mockResolvedValue( + new Response( + JSON.stringify({ + data: { + id: 'shot-db-1-1', + videoPrompt: '', + 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('') + expect(wrapper.find('script').exists()).toBe(false) + }) it('项目筛选无结果时可清除条件并返回真实列表', async () => { vi.stubGlobal( 'fetch', diff --git a/src/lib/http.test.ts b/src/lib/http.test.ts index 49a1d66..3735673 100644 --- a/src/lib/http.test.ts +++ b/src/lib/http.test.ts @@ -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().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().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().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>() diff --git a/src/router/index.ts b/src/router/index.ts index a2a5163..ca86eda 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -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: '分镜设计' } } ] }, diff --git a/src/styles.css b/src/styles.css index a091b5b..2d32aaf 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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); } diff --git a/vite.config.ts b/vite.config.ts index 3fe38ef..f6a7606 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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 } } }