# dsh-short-video-studio 架构文档 > 本文档梳理 `dsh-short-video-studio`(v0.1.1)的整体架构:定位、模块划分、分层设计、关键数据流、目录职责与已知架构债。 > 配套文档:`docs/workflow-contract.md`(工作流契约设计)、`docs/architecture-review.md`(架构审查与优化意见)、`docs/consistency-optimization-plan.md`(一致性与镜头规划方案)、`docs/three-view-experiment.md`(参考图实验)。 --- ## 1. 项目定位 **一个完全本地、免费、零云端依赖的类 MiniMax-Design 短剧 / 动画画布工作室**,以 **DeepSeek Harness 双面插件**(宿主半 + 浏览器半)形式存在。 - **画布页**:每个会话多一个「画布」视图 tab,按生产顺序预览 / 编辑各步骤产物。 - **生成服务**:统一走**本地 ComfyUI API**——图片用本地 FLUX 2,视频用本地 MiniMax H3(音视频 AV 模型,默认带声音、支持参考图绑定与原生字幕)。 - **Agent 工具**:`comfy_generate_image` / `comfy_generate_video` / `comfy_render` / `canvas_*` / `asset_*`,Agent 与画布页读写同一份持久状态。 - **自带 skill**:安装插件时自动把 `skills/` 下的 skill 复制到 `~/.dsh/skills/`,供模型 / 技能中心发现;**带版本戳刷新**(见 §3 末),用户改过的副本永不覆盖。 **核心架构主张**(贯穿全篇):插件退化为一个**通用 ComfyUI 工作流执行器 + 注册表**。宿主不认识「FLUX」「H3」这些名字,只认识**能力(capability)**与**工作流清单(manifest,数据而非代码)**——换模型 / 换工作流 = 增删一份 JSON,不动 JS、不动工具描述、不动 systemPrompt。 --- ## 2. 总体结构 ``` dsh-short-video-studio/ ├── package.json # 双面插件声明(dsh.client / bundle patch / exports) ├── cordis.patch.yml # bundle patch:把插件行插入 web profile roster ├── lib/ │ ├── index.js # 宿主半:ComfyUI 引擎、渲染编排、画布存储、HTTP 路由、Agent 工具、GUIDANCE(约 1905 行) │ ├── manifest.js # 工作流绑定契约引擎:校验 / 资产解析 / 图编译 / 注册表加载(M1) │ ├── concat.js # 视频拼接(ffmpeg 优先 / ComfyUI 纯节点退化) │ ├── convert.js # ComfyUI「导出 API」JSON → workflow manifest 转换器 │ └── assets.js # 跨会话资产库(type × kind:角色/场景/风格锚点/片段/文本) ├── client.js → lib/client.js # 浏览器半:画布的家(conversation.view tab 或右侧栏 tab 类型,由 canvasHome 决定)+ settings.section「ComfyUI」设置 ├── studio/ # 画布页(自包含 HTML/CSS/JS,无构建) ├── workflows/ # 内置工作流清单(数据) │ ├── flux-text2image.json # image.text2image:FLUX 2 文生图(未分档) │ ├── minimax-h3-ref2v-.json # video.reference2video:H3 参考绑定(带声音),一档一 json:fast/balanced/balanced-sol/quality/quality-sol │ ├── minimax-h3-i2v-.json # video.image2video:H3 首/末帧串联(带声音):fast/balanced/balanced-sol/quality/quality-sol │ └── extract-frame.json # image.from_video:抽帧(末帧/首帧 → 图片) ├── schemas/ │ └── workflow-manifest.schema.json # manifest 权威 JSON Schema(外部工具/文档参照) ├── skills/ │ └── 3d-animation-short-generator/ # 自带 skill(安装时复制到 ~/.dsh/skills/) │ ├── SKILL.md # 流水线散文(Step 0-8、门控纪律、实践要点) │ └── references/ # 镜头表规范 / 分镜指南 / QC 清单 / 失败梯度 / 模型选择 ├── scripts/ # 冒烟 / e2e / 转换脚本(无 npm scripts 编排,手动执行) ├── docs/ # 设计文档与实验报告 └── examples/fox/ # 示例片《一只想当宇航员的小狐狸》产物(含反面示例卡) ``` **双半装配**:`cordis.patch.yml` 声明插件行 → 宿主 Node 进程加载 `lib/index.js`(`exports "."`),`package.json` 的 `dsh.client` 声明让浏览器半 `lib/client.js`(`exports "./client"`)以 `/plugins//client.js` 载入 Web GUI。 --- ## 3. 分层架构 ``` ┌────────────────────────────────────────────────────────────────┐ │ 展示层(浏览器半) lib/client.js + studio/ │ │ 「画布」会话 tab(iframe)+ 「ComfyUI」设置页(配置/注册表/导入) │ ├────────────────────────────────────────────────────────────────┤ │ 契约层(纯数据 + 校验) workflows/*.json + lib/manifest.js │ │ 能力词汇表 · 注入原语 · $model 哨兵 · $assets 占位 · 强校验 │ ├────────────────────────────────────────────────────────────────┤ │ 执行层(宿主半) lib/index.js │ │ ComfyUI 客户端 · 渲染编排 · 分辨率策略 · 注册表解析 │ ├────────────────────────────────────────────────────────────────┤ │ 存储层 │ │ 画布 project.json · 媒体文件 · 资产库 · 配置文件 │ ├────────────────────────────────────────────────────────────────┤ │ 集成层 │ │ cordis.patch · Agent 工具注册 · systemPrompt GUIDANCE · │ │ HTTP 路由 · skill 自动安装 │ └────────────────────────────────────────────────────────────────┘ ``` ### 3.1 契约层:三层正交契约(核心设计) 来源:`docs/workflow-contract.md`。**「Agent 想做什么」(语义)与「某个工作流怎么做」(实现)彻底切开。** | 层 | 内容 | 载体 | |---|---|---| | ① 任务契约 | Agent 只描述「要什么」:capability + 类型化输入(prompt / 宽高 / seed / refs…) | Agent 工具参数 | | ② 能力契约 | 抽象作业词汇表:`image.text2image` / `video.reference2video` / `audio.tts` …(开放集合) | `CAPABILITIES`(lib/manifest.js) | | ③ 工作流绑定契约 | 一份清单 = 一个 capability → 一个 ComfyUI 图 + 注入点 + 资产 + 质量档(数据非代码) | `workflows/*.json` | **注入原语**(`params` 的 `inject` 类型)——「任意工作流」数据驱动的落地关键: | inject | 语义 | 用例 | |---|---|---| | `scalar` | 任务值写到某节点字段(支持 `to[]` 多目标) | prompt / width / height / seed / steps / fps / prefix | | `image` `via:field` | 上传图片 → 文件名字符串写字段(dotted,`${i}` 索引展开) | H3 `ref_images.ref_image_${i}` | | `image` `via:node` | 上传 → 建 `LoadImage`(+ 可选 `preprocess` 链:ImageScale 等)→ 连线到字段 | `first_frame` / `last_frame` | | `video` `via:field` | 上传视频 → 文件名字符串写字段 | `extract-frame` 的 `LoadVideo.file` | **两个占位机制**: - `$assets.`:图内模型文件名占位,加载期按 **env > assetOverrides > default** 解析(换模型文件只改配置)。 - `["$model", 0]` 哨兵:图内所有「需要按质量档插 LoRA 链」的连线写哨兵,编译期按 `modes..loras` 在模型后动态插入 `LoraLoaderModelOnly` 节点并重定向(fast 档 4 步 turbo / quality 档 20 步无 LoRA 因此降级为纯数据)。 **强校验**:`validateManifest`(lib/manifest.js,与 JSON Schema 同源的手写零依赖实现)校验 id 格式、capability 词汇、graph 连线引用完整性、`$assets` 引用落地、params 注入点合法性、modes 的 loras 资产引用等;**校验失败即拒绝加载**(`loadBuiltinManifests` 报错不静默降级,`POST /workflows` 返回明确错误)。 **graph 模板**:ComfyUI API 格式(`{nodeId: {class_type, inputs}}`),不变结构照抄、每镜可变值留 `null` 由 params 注入。 ### 3.2 执行层(宿主半 lib/index.js) #### ComfyUI 客户端(纯 fetch,零第三方依赖) - `comfySubmit(graph)` → `POST /prompt`,返回 `prompt_id`;`comfyWait(promptId, signal)` 轮询 `/history/{id}` 直到 completed/error,带超时(默认 15min)与 AbortSignal;`comfyOutputs(entry)` 从 history 抽取 images/videos/gifs/audio;`comfyDownload(file)` 经 `/view` 拉二进制;`comfyUploadImage(buffer, filename)` 经 `/upload/image` 上传参考图。 - `apiKey` 非空时请求带 `Authorization: Bearer`(适配需鉴权的 ComfyUI 网关)。 #### 渲染编排(通用路径 `runRender`) ``` comfy_render / comfy_generate_* → runRender(ctx, opts) → resolveSessionRoot() 工作区定位(显式 workspaceId → agent cwd → workspaceRegistry 扫描) → getRegistry() 内置 workflows/ + 用户 ~/.dsh/dsh-short-video-studio/workflows/(同名遮蔽) → resolveManifest() 显式 workflow > preferred 顺序 > 该 capability 首个清单 → resolveMode() / computeManifestSize() 质量档 + 分辨率(显式宽高 > aspect-ratio 按 mode.longSide 推导 > default,snap32) → resolveRefImage() 参考图/首末帧:资产 id(character:xxx)或画布节点 id → 读取 → comfyUploadImage → buildRenderGraph() 纯函数:manifest + job + aspectRatio → {mode, job, graph} → comfySubmit / comfyWait / comfyDownload → persistMedia() 产物落盘 /canvas// → 写/更新画布节点(kind / media / params 含 workflow、mode、seed、assets,可复现) ``` `comfy_generate_image` / `comfy_generate_video` 是**薄别名**:前者固定 `capability=image.text2image`;后者按**显式必填的 `type`** 分派 —— `type=r2v` → `video.reference2video`(参考绑定,配 `ref_nodes`)、`type=i2v` → `video.image2video`(首末帧串联,配 `first_frame_node`)。 形状**不再由「传没传首帧」隐式推断**(旧行为会把冲突参数静默丢弃,见 `docs/video-shape-contract.md`):真值表集中在 `VIDEO_SHAPES`,校验由 `validateVideoArgs()` 实现,`runRender` 在**上传参考图之前**调用,工具 / HTTP 路由 / 脚本共用同一张表;冲突报错必须带修法。形状与「续接」(`continuity_from`)正交,跨形状续接刻意允许。 > 注:`runImageGeneration` / `runVideoGeneration` 与三个 legacy builder(`buildFluxImageWorkflow` / `buildH3VideoWorkflow` / `buildH3ImageToVideoWorkflow`)已无生产调用点,仅经 `_internals` 供 `smoke-manifest.mjs` 做「manifest 编译 vs legacy 构造」节点类别等价性比对(见 §7 架构债)。 #### 视频拼接(`runConcat` + `lib/concat.js`) 拼接是 **delivery 层的确定性操作,不是模型能力**,因此刻意不进 manifest 注册表:拼接图的节点数与连线拓扑随片段数变化(变长左折叠),超出 manifest「静态 graph 模板 + 定点注入」的表达能力,为一个确定性后处理扩展契约层不划算。 两条后端,`runConcat` 按环境自动选择,对 skill 透明: | 后端 | 条件 | 链路 | 代价 | |---|---|---|---| | ffmpeg | 本机 `ffmpeg -version` 成功 | concat demuxer + `-c copy`;copy 失败自动降级 libx264/aac 重编码 | 零重编码、零显存、秒级 | | ComfyUI | 无 ffmpeg(本机实测即此路径) | 逐段 `POST /upload/image` 进 input(该端点同时接受 mp4)→ `LoadVideo → GetVideoComponents` → `ImageBatch` / `AudioConcat` 左折叠 → `CreateVideo(images, fps, audio)` → `SaveVideo(mp4/h264)` | 整段素材作为 IMAGE 张量进内存(N×帧×W×H×3×4 字节) | `runConcat` 解析素材时强校验 `kind === 'video'` + `media` 存在 + 文件在盘(顺带堵住了「视频被当图上传」那条老路径)。已实测:3 段片段 7.6s 出片,总时长 14.085s → 产物 14.084s,`vide` + `soun` 双轨完整。限制:**只有硬切无溶解**、片段需同分辨率、不产 BGM(注册表无 `audio.music` 工作流)。 #### 跨场景转场(生成式转场镜) 不做后期溶解,而是用 `extract-frame` 抽前一镜末帧 + 后一镜首帧,喂给既有的 `minimax-h3-i2v-*`(首末帧串联,组「MiniMax H3 首末帧生成视频」)生成一个短过渡镜,当普通片段参与拼接。已端到端实跑:抽帧 1.5s ×2 → 转场镜 16.9s(fast/`length=39`)→ 拼接 4.5s;成片 11.250s = 5.167 + 1.625 + 4.459,双轨完整,中间帧是真实运镜、末帧精确落回后一镜首帧。 两条实测坑(已写进 SKILL.md 自检门与 Step 7): 1. **烧录字幕会被继承**——`dialogue` 镜的末帧带字幕,直接当转场首帧则字幕进转场镜。对策是在镜头表层面要求跨场景边界的前一镜最后 0.5s 无对白(自检门第 8 项)。 2. **`length` 走 17k+5 网格且训练区间 124–362**,短于 124 属未测区,`length=39`+fast 中段会糊、建筑形变。转场镜建议 ≥56,成片用 `quality`。 #### 分辨率策略 - fast 档长边 832 / quality 档长边 1344,按画布 `settings.aspectRatio`(16:9 / 9:16 / 1:1 等任意比例)推导宽高,snap 到 32 倍数;显式传 `width`/`height` 优先。 ### 3.3 存储层 | 存储 | 位置 | 机制 | |---|---|---| | 画布项目 | `/canvas//project.json` | `schemaVersion:1` + `settings`(aspectRatio / duration / audioMode / mode / groupOrder)+ `nodes[]`;**原子写**(tmp + rename)+ **per-session 写锁**(串行化并发) | | 媒体产物 | `/canvas//` | 节点只存相对路径,经 `/media` 路由带 token 伺服 | | 资产库 | `/.dsh-assets/library.json` + `images/` | 跨会话素材。id 规范 `:[/]`,`char:` 是 `character:` 别名。**type(语义类别)与 kind(载体)正交**:type ∈ character/scene/style/clip/text,kind ∈ image/video/text 由 type 推导(`TYPE_KINDS`);kind 决定存法(图片/视频拷文件、文本只存 `text`+`title`+`nodeKind`)、取回画布时的节点类型、以及能否作 `ref_nodes` 参考图(只有 image 能)。目录名 `images/` 是历史包袱,视频文件也放这里 | | 插件配置 | `~/.dsh/dsh-short-video-studio.json`(或 `DSH_SVS_CONFIG`) | canvasHome / baseUrl / apiKey / pollMs / timeoutMs / models / assetOverrides / preferred;**优先级 env > 配置 > 默认**,每次调用重读(设置即时生效)。例外:`canvasHome`(画布的家)由**浏览器半在插件启动时读一次**,改动后需刷新页面 | ### 3.4 展示层(浏览器半) `lib/client.js` 经 `window.__ModuleLoader__.load` 注册(React,无 JSX 语法),三个注入点: 0. **「画布的家」分支**(配置项 `canvasHome`,设置页「界面」段):启动时**只注册一个家**, 所以两种取值下都只可能有一个 studio iframe 实例(结构上排除双实例,见 [`right-sidebar-canvas-research.md`](right-sidebar-canvas-research.md) §13)。同步先注册默认家 `'tab'`,异步读到 `'sidebar'` 时注销它并改注册右栏 —— 零回归、配置请求失败即默认、不需要镜像。 改动该配置后需刷新页面(启动时读一次),设置页写明并给了一键刷新。 - `'tab'`(默认):注入点 1(`conversation.view`); - `'sidebar'`:`ctx.sidebarRightTabs` 注册 `short-video-canvas` 类型 + `sidebar.right.pane.tab` 正文席位(key 是**实现 id** `dsh-short-video-studio`,不是 kind)+ 引导页胶囊(`guide[].order=5`, files=10 / terminal=20)。入口**照抄 `ui-sidebar-files` 的原生做法**:用户展开右栏后由引导页 胶囊进入,不自造入口。`sidebarRightTabs` 走 `ctx.inject` **软注入**,绝不写进 `exports.inject` —— 否则宿主禁用右侧栏插件时整个浏览器半加载失败。 两种家复用同一个 `CanvasView`(session 作用域的席位同样拿到 `sessionId`/`useWorkspaces`/`inputActions`)。 1. **`conversation.view` 槽**(id `short-video-canvas`,order 20,label「画布」):渲染 iframe 指向宿主伺服的 `/dsh-short-video-studio/?sessionId=&workspaceId=`。iframe 内「重做」按钮通过 `postMessage`(channel `dsh-short-video-studio`,type `ask-ai`)把指令回填父页输入框(`inputActions.setDraft`)。 2. **`settings.section` 槽**(id `comfyui`,order 120):ComfyUI 设置卡——**界面(画布位置 canvasHome)**、连接参数、工作流注册表(按 capability 分组、设默认写回 `preferred`)、资产覆盖(选中工作流后按 manifest 动态渲染 assets 字段)、工作流导入(粘贴 manifest 或 ComfyUI「导出 API」原始 JSON,自动转换)、用户清单删除。 `studio/` 为**自包含无构建**画布页(index.html + app.js + app.css):读取 `/api/canvas` 全量渲染节点卡(文本/表格 markdown 解析、图片/视频媒体、参数摘要、状态),支持节点内编辑、重做(回填给 AI)、**入库**(表单选资产类型 + 资产名,AI 不自动入库)、分组改名(自由输入 + datalist 候选)、上移/下移/删除;**分组词汇由流程 skill 自由定义**,展示顺序取 `settings.groupOrder`(skill 经 `canvas_set_state` 声明),未声明的分组按首次出现顺序排在其后,缺省分组为 `ungrouped`。iframe 内禁用原生 alert/confirm/prompt,自绘 DOM 模态框。 顶栏三块内容是 **A 品牌(`.brand`)/ B 工程参数(`.meta`)/ C 按钮(`.topbar-bar`)**,DOM 顺序即 A B C,响应式只改排列方向(`app.css` 的 `.topbar` 及其 `@media (max-width: 960px)`): | 宽度 | 排法 | |---|---| | 宽(> 960) | 一行 A B C:B `flex:1` 吃掉中间剩余空间,C 靠右 | | 窄(≤ 960,如右侧面板 ~360) | 竖直 A B C:各占一行,谁都不挤谁 | 断点 960 的依据是三块的内容最小宽度(品牌 ~110 + 参数 ~400 + 按钮 ~450 + 间距),再挤就会让参数在行内折成两三行、按钮贴边。这条是回归断言(`preview-canvas` 的「布局」段,宽栏 1100 + 窄栏 360 两档,量中线对齐 / 左右顺序 / 靠右 / 竖排顺序 / 无横向滚动)。 已知的一处浏览器行为:参数行宽度**正好差几像素**装不下时(实测盒 488.5 / 文 494.1),Chrome 会让它溢出盒子而不是折行——换 `overflow-wrap` / `word-break` / `text-wrap` 都不改变。溢出量落在 B 与 C 之间 10px 的间隙内(实测余 4.5px),压不到按钮也不撑出页面滚动,故保留;断言相应量的是「不压到按钮」(宽栏)与「真折行」(窄栏)。 **画布主题跟随宿主浅/深**(`ui-theme` 的 `active.colorScheme`)。iframe 读不到宿主文档的 CSS 变量,所以: ``` ui-theme 主题服务 │ getTheme().active.colorScheme ① 首屏:读一次并**冻结**进 iframe 的 src │ on('theme/change') ② 运行中:postMessage 下发 ▼ lib/client.js watchTheme(ctx) → readScheme() / subscribeScheme() │ ?theme=dark (首屏,避免闪一下另一种底色) │ postMessage {channel, type:'theme'} (运行中;iframe onLoad 时也补发一次) ▼ studio/index.html 内联脚本:?theme= → 否则 prefers-color-scheme → 写 studio/app.js message 监听(同源 + channel/type 双重校验)→ 改 ▼ studio/app.css 两套调色板::root(浅,默认)/ :root[data-theme="dark"] ``` - 两套调色板**变量名必须完全对等**、调色板块之外**零颜色字面量**、低透明度底色一律 `color-mix()` 从语义色派生(DSH 自身也用这个写法)——三条都由 `scripts/smoke-canvas-theme.mjs` 强制,因为这类问题的表现是**静默变色**而不是报错。 - 宿主读不到主题服务(没装 `ui-theme`)时**不传** `?theme=`,让画布页按系统偏好兜底,不硬猜。 - 首屏主题**只读一次**:它进了 `src`,跟着变会让 iframe 重载(丢滚动位置)。运行中切换只走 `postMessage`。`color-scheme` 两套都声明,原生控件(下拉/滚动条/复选框)跟着走。 - 真浏览器验证:`scripts/preview-canvas.mjs` 起 stub 服务喂一份覆盖每种节点类型/状态/分区的 样例工程,两套主题各跑一遍 `lowContrast()`(合成半透明底后按 WCAG 算),并断言 postMessage 切换、非法取值不误改、无 `?theme=` 时跟随系统偏好。 ### 3.5 集成层 - **Agent 工具**(原生 ToolDefinition,parameters 直接写 JSON Schema,零 `@deepseek-ai/*` 运行时 import,全部走注入 `ctx`):15 个工具 = 6 生成/抽帧/拼接/查询(`comfy_generate_image` / `comfy_generate_video` / `comfy_render` / `extract_frame` / `video_concat` / `comfy_list_workflows`)+ 7 画布(`canvas_list_nodes` / `canvas_write_node` / `canvas_get_node` / `canvas_group_nodes` / `canvas_reorder` / `canvas_get_state` / `canvas_set_state`)+ 2 资产(`asset_list` / `asset_to_canvas`)。 - **systemPrompt GUIDANCE 段**(order 150):基本约定 + 工具契约 + 参考图硬规则(单视图/零文字等实测结论)+ 指向流程 skill 的指针。**不含任何流程、任何片型词汇、任何具体 skill 名**——流程的唯一真相在 skill。 - **HTTP 路由**(`/dsh-short-video-studio`): - `/api/config`、`/api/workflows`:**显式 tokenless**(设置页调用;威胁模型见 §7); - `/api/canvas`、`/api/canvas/node`、`/api/canvas/group`、`/api/canvas/reorder`、`DELETE /api/canvas/node`、`GET|POST|DELETE /api/assets`(入库 / 列表 / 删除)、`/api/generate/image|video`:需 `x-dsh-svs-token` 头; - `/media`:token 走 query(便于 `/