--- title: dsh-animation-studio-设计草案 --- # dsh 插件设计方案:Animation Studio(教学动画制作工作台) > 目标:做一个 dsh(DeepSeek Harness)插件,在其中内置编排一个「教学动画 / 视频制作 agent」, 提供脚本生成 → 时间线设置 → 动画效果微调 → 渲染导出 的完整工作台。 > 本文是**设计草案**,基于 dsh 官方一手文档与 npm 包实证(`@deepseek-ai/dsh@0.1.5-rc.1`、官方 client 插件包结构)写成。 --- ## 0. 已确认的三项选型 | 维度 | 决定 | 理由 | | --- | --- | --- | | 渲染底座 | **与后端无关的 IR 抽象 + Motion Canvas 首发适配器** | IR 让「时间线」成为可 CRUD 的数据而非代码,微调 = 改一个关键帧而不是让 LLM 重写整段动画代码;Motion Canvas 是 TS 栈、MIT、既有可视化编辑器又有 headless 渲染,与 dsh 同语言 | | 工作台 UI | **内嵌 dsh 自带 Web 客户端** | 用官方 `ConversationNodeDefinition` + `conversation.chat.node` keyed renderer 挂载,工作台状态与对话同源、可回放可 fork | | MVP 范围 | **全链路打通**:脚本 → 时间线 → 预览 → 导出 MP4 | 先跑通主干,动画微调(缓动/关键帧/转场面板)与 TTS 配音放到第二轮 | --- ## 1. 平台事实:dsh 给了什么(一手结论) 这些不是推测,是从官方文档与已发布包里核出来的,构成了整个设计的约束边界。 ### 1.1 插件即一切,扩展只能挂在扩展点上 dsh 由 vendored 的 **Cordis** 驱动,产品里没有特权核心:模型适配器、工具注册表、会话日志、甚至 agent 主循环本身都是插件。 新行为必须挂到文档化扩展点上,**不要改 loop**。官方 [architecture](https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/architecture.zh.md) 给了功能→机制映射表,与本方案相关的几条: | 目标 | 机制 | | --- | --- | | 新增模型可见能力 | `ctx.tools.register(defineTool(...))`,schema 自动流入 prompt 装配 | | 新增后台长任务 | `ctx.jobs.start({ kind, label, owner, run })`,配 `job_output` / `job_kill` | | 新增持久会话状态 | 扩展 `SessionEventMap`,从日志渲染与回放 | | 新增 Web 客户端业务节点 | 注册 `ConversationNodeDefinition` + `conversation.chat.node` keyed renderer | | 新增人类命令 | `ctx.commands` 注册,`/xxx` 不经过模型回合直接派发 | | 新增系统提示词段 | `ctx.systemPrompt.section()` | 插件的四个导出是 `name` / `inject`(依赖服务声明)/ `Config`(schemastery 校验)/ `apply(ctx, config)`。 注册全部走 `ctx.effect`,插件卸载时自动回滚——这是 HMR 与热插拔的基础。 ### 1.2 硬约束:Model-visible means logged **任何进入模型请求的内容,都必须能从会话日志重建**,运行时会断言这一点。 这条约束对本项目是**利好而非负担**:工作台的每一步修改(改一句脚本、拖一个关键帧、调一次缓动)都落成一条 durable session event,于是天然获得: - **回放**(replay):重看一部动画是怎么被做出来的 - **分叉**(fork):从某个时间线版本岔出另一版,非常适合「这版节奏太快,试试慢一点」 - **恢复**(resume):关掉浏览器下次接着改 - **审计**:教学内容的每次修改都有据可查 代价是:想让模型「看到」工作台状态,就必须为它设计事件,不能偷偷塞进内存里。 ### 1.3 客户端插件的真实形态(实证) 从官方 `@deepseek-ai/dsh-client-ui-agent-preset@0.0.1-rc.1` 拆包看到,一个带 UI 的包要这样声明: ```jsonc // package.json { "dsh": { "client": { "platform": "web", "inject": [ "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-conversation", // 我们要的 ConversationNodeDefinition 在这 "@deepseek-ai/dsh-client-ui-settings" ] } }, "exports": { ".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./client": { "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" } }, "peerDependencies": { "react": "*", "@deepseek-ai/dsh-client-ui-slots": "*", /* ... */ } } ``` `./client` 导出的是一个打成 CJS 的浏览器 bundle(产物形如 `window.__ModuleLoader__.load({ id, factory })`), `ctx.clientModules` 会扫描声明了 `dsh.client` 的包,组合成 `window.__DSH_BOOT__` 图,并由 `/plugins` 路由提供带版本号的 combo 脚本。 ### 1.4 后台任务(渲染必须走这里) 渲染一个 MP4 是几十秒到几分钟的长任务,官方的 `ctx.jobs` 正好对口: - `start({ kind, label, owner: exec.agent, run })` → 返回品牌化 `JobId`(形如 `anim-render-1`) - `run()` 返回 `{ cancel, done, readOutput? }`,`readOutput` 用来吐增量进度 - 模型侧通过 `job_output` / `job_kill` 收集与终止 - 任务归属 owner 的 session,owner 销毁时自动取消并 await **关键细节**:`ctx.jobs.start()` 一旦发布 id,就要用任务自己的取消信号,而不是 `exec.signal`(外层取消只是不再等待,不杀已发布的工作)。 --- ## 2. 核心设计决策:AnimationSpec(时间线 IR) **这是整个方案的灵魂。** 不要让 agent 直接生成 Motion Canvas 代码——那样「微调」就等于让 LLM 重写整个场景,既贵又不可控,还无法回放 diff。 改为三层: ``` 教学主题 │ anim.plan / anim.draft(LLM 产出) ▼ ┌─────────────────────────────────────┐ │ AnimationSpec ——与后端无关的 JSON IR │ ← 唯一真源,可回放、可 diff、可 CRUD └─────────────────────────────────────┘ │ renderer seam(适配器) ▼ Motion Canvas │ Remotion │ Manim(后续) ``` ### 2.1 IR 骨架(草案) ```ts interface AnimationSpec { version: 1 meta: { id: SpecId // 品牌化 id,事件关联用 title: string fps: 30 | 60 size: { width: 1920; height: 1080 } background: Color locale: 'zh-CN' | 'en-US' } theme: ThemeToken // 配色 / 字体 / 圆角,保证品牌一致 assets: Record // image | audio | font | svg scenes: Scene[] narration?: NarrationTrack // MVP 预留,不实现 subtitles?: SubtitleCue[] // MVP 预留 } interface Scene { id: SceneId name: string // 「概念引入:什么是梯度」 durationMs: number // 场景时长,绝对时间 transition?: Transition // { kind: 'fade'|'slide'|'none', durationMs, easing } camera?: Camera // 平移/缩放,MVP 可选 layers: Layer[] // 数组顺序即 z-order } interface Layer { id: LayerId name: string type: 'text' | 'shape' | 'image' | 'code' | 'math' | 'group' | 'audio' props: Record // 类型特定的静态属性(文案、颜色、字号、路径…) tracks: Track[] // 时间线:所有动画都在这里 } interface Track { id: TrackId target: string // 属性路径,如 'props.opacity' / 'props.x' / 'props.scale' keys: Keyframe[] } interface Keyframe { atMs: number // 场景内绝对时间 value: number | string | boolean ease?: 'linear' | 'easeInOut' | 'cubic-bezier(...)' | 'spring' } ``` ### 2.2 三个关键取舍 **① 时间用绝对毫秒,不用相对等待。** Motion Canvas 的 generator 是 `yield* waitFor(0.3)` 这种相对时长的写法,但 LLM 生成绝对时间线的准确率高得多,人类在面板上拖关键帧也更自然。IR 用绝对毫秒,由适配器负责转成后端的相对序列——这是适配器最重要的职责之一。 **② 一切可动画的属性都收进 `tracks`。** 不搞「特殊动画类型」的特例。淡入就是 `props.opacity` 上两个关键帧,移动就是 `props.x/props.y`。微调面板因此可以对任意图层任意属性统一处理,不需要为每个效果写一套 UI。 **③ 编辑走结构化 patch,绝不整篇重写。** LLM 重写 800 行 JSON 既烧 token 又容易破坏无关部分。工具设计成操作式: ```ts // 模型侧调用示例 anim.patch({ specId, ops: [ { op: 'set', path: '/scenes/1/layers/3/props/text', value: '梯度下降' }, { op: 'add', path: '/scenes/1/layers/3/tracks/-', value: {...} }, { op: 'move', path: '/scenes/1/layers/3/tracks/0/keys/1', value: { atMs: 800 } }, ]}) ``` 每条 patch 落成一条 `anim/spec-patched` 事件,天然可 diff、可撤销(反向 patch 可存)、可回放。 ### 2.3 坐标系契约:中心原点,不是左上角 `props.x/y` 的**原点在画布中心**:x 向右为正、y 向下为正,单位 px,画布左上角是 `(-width/2, -height/2)`;`rotation` 单位是度、正值顺时针;`scale` 1 = 原始大小。取中心原点是因为渲染底座 Motion Canvas 就是这么定义的,IR 原样透传、零转换——转换每处关键帧都要偏移,漏一处就是新的静默错位。 代价是它和调用方(LLM/人)的 web/CSS 左上角直觉相反,而「整片塌进右下象限」这类错误渲染不报错、只在看片时才发现。所以在三个接触面同时钉死: 1. `LayerProps` 的类型注释(`packages/spec/src/types.ts`); 2. `anim_draft_scene` / `anim_create_spec` 的工具描述——这是调用方模型必读的地方; 3. `validateSpec` 的启发式警告:一幕内所有 x/y 都不为负、且存在静止位置超出画布半径时,提示「疑似按左上角原点书写」(`anim_draft_scene` 会把 warnings 原样返回给模型)。 --- ## 3. 会话事件模型(可回放的工作台) 工作台状态不存内存,全部从事件流 fold 出来。扩展 `SessionEventMap`: | 事件 | 角色 | 持久化的事实 | | --- | --- | --- | | `anim/spec-created` | start | `specId`、标题、初始 spec(或首个场景)、Turn/Step 坐标 | | `anim/spec-patched` | update | `specId`、`rev`、patch ops、可选 narrative(「把标题入场放慢」) | | `anim/timeline-changed` | update | `specId`、`sceneId`、受影响的 track/关键帧(时间线专用语义,便于 UI 精细刷新) | | `anim/preview-ready` | update | `specId`、`sceneId`、帧图路径、耗时 | | `anim/render-start` | update | `specId`、`jobId`、渲染参数 | | `anim/render-progress` | update | `jobId`、百分比、当前帧 | | `anim/render-end` | update | `jobId`、输出路径、时长、失败原因 | 对应 client 侧一个 `ConversationNodeDefinition`: ```ts const animStudioDefinition: ConversationNodeDefinition = { kind: 'anim-studio', target: 'chat', match: (event) => { if (event.type === 'anim/spec-created') return { id: event.data.specId, role: 'start' } if (event.type.startsWith('anim/')) return { id: event.data.specId, role: 'update' } return null }, start: (_ctx, match) => ({ specId, rev: 0, scenes: [], renderJob: undefined, status: 'drafting' }), update: (ctx, match) => applyAnimEvent(ctx.state, match.event), // 纯函数 fold publication: (match) => match.event.type === 'anim/render-progress' ? 'animation-frame' : 'immediate', buildViewNode: (ctx) => ({ key: ctx.key, kind: 'anim-studio', target: 'chat', data: ctx.state, /* ... */ }), } ``` 注册渲染器: ```ts export const inject = ['uiConversation', 'slots'] export function apply(ctx: ClientContext) { ctx.uiConversation.events.register(animStudioDefinition) ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( { name: 'conversation.chat.node', key: 'anim-studio' }, AnimStudioPanel, )) } ``` **必须遵守的硬规则**:`match` / `start` / `update` / `presentCall` / `presentResult` 都是**纯函数**——它们同时跑在实时流与日志回放两条路径上,不能有 I/O、不能读时钟、不能用随机数。想拿旧文件内容或工作目录?停下,那属于持久元数据或适配器,不属于 presenter。 --- ## 4. 模型可见的工具集 | 工具 | 阶段 | 说明 | | --- | --- | --- | | `anim_plan` | 脚本生成 | 输入教学主题/目标受众/时长,产出分镜大纲(场景列表 + 每场景教学意图 + 旁白草稿) | | `anim_create_spec` | 脚本生成 | 从大纲建立 spec,发 `anim/spec-created` | | `anim_draft_scene` | 脚本生成 | 把某个场景细化为图层 + 轨道(可逐场景迭代,避免一次生成过长 JSON) | | `anim_get` | 全程 | 按 selector 读取 spec 片段(全量太大,支持 `/scenes/1` 这样的路径) | | `anim_patch` | 时间线/微调 | 结构化增删改,唯一推荐的写途径 | | `anim_preview` | 预览 | 低分辨率抽帧渲染,返回帧图序列(MVP 可同步,超时转后台任务) | | `anim_render` | 导出 | `ctx.jobs.start({ kind: 'anim-render' })`,返回 `{ kind: 'background', jobId }` | | `anim_asset_import` | 素材 | 导入图片/字体/音频,登记进 `assets` | 工具契约的硬性要求(`defineTool`): - `execute(args, exec)` 只返回**一个规范 JSON 值**,由 `output.schema` 声明;抛异常 = `isError` - 必须尊重 `exec.signal` - 模型看到的由 `output.render` 决定,UI 卡片由 `presentCall` / `presentResult` 决定,两者是**不同的关注点** - 不要在代码里写死 `run_in_background`,用部署配置开关;渲染这种长活走 `ctx.jobs` 对渲染工具,卡片的正确姿势是:`presentCall` 返回 `{ card: 'terminal', title: 'anim render --scene 1' }`, `presentResult` 返回视频/帧图信息,并用 `output.presentationMeta` 存可回放的结构化事实(输出路径、时长、分辨率), 这样回放旧会话时卡片照样能重建。 --- ## 5. 渲染:一个标准的 dsh 能力接缝(Seam) 按 dsh 的范式,渲染要做成**三角色齐全**的 seam,而不是一个硬编码函数: | 角色 | 实现 | | --- | --- | | Service Definition | `AnimRenderer`(`render(spec, opts) -> frames/video`,`preview(spec) -> frames`) | | Service Provider | `dsh-anim-render-motion-canvas`(首发)、后续 `dsh-anim-render-remotion` / `-manim` | | Consumer | `anim_preview` / `anim_render` 工具 | 适配器职责:把 IR 的**绝对时间线**翻译成后端的相对时序、把 IR 图层映射成后端节点、把主题 token 映射到后端样式。 Motion Canvas 适配器要点: - 用 `npx @motion-canvas/create` 的项目骨架做模板,把 IR 转成 `makeProject({ scenes })` 的 TS 源码,再走其 CLI 渲染 - headless 渲染依赖 **ffmpeg + 字体**,插件要显式检查依赖并给出可操作的报错(这是最常见的踩坑点) - 渲染进程通过 `ctx.subprocess` 派生,必要时用 `ctx.sandbox` 约束;耗时任务交给 `ctx.jobs`,`readOutput` 吐进度 > 一个务实的判断:先把「IR → Motion Canvas 源码 → 渲染 MP4」这条链路用**离线脚本**跑通并验证依赖(ffmpeg、字体、中文渲染), 再包进 dsh 插件。倒过来做的话,你会在插件热重载和渲染失败之间分不清是谁的锅。 --- ## 6. 工作台面板(client 半) 三栏布局,作为聊天流里的一个业务节点渲染: ``` ┌──────────────┬────────────────────────┬─────────────────┐ │ 场景 / 图层树 │ 预览画布 │ 属性 + 关键帧 │ │ │ (帧图 / 时间轴游标) │ (缓动、时长) │ ├──────────────┴────────────────────────┴─────────────────┤ │ 时间线:场景条 + 轨道 + 关键帧 │ └──────────────────────────────────────────────────────────┘ ``` **用户操作如何回到 host(两条路径,MVP 选后者):** 1. **自定义 loopback RPC**(像官方 `agentPreset.*` 那样):响应最快、体验最好,但需要在 host 注册新的 RPC 方法, 在 dsh 0.1.x 预览版上属于需要边写边验证的部分。 2. **转为一条用户指令**(MVP 采用):面板把操作翻译成自然语言或结构化指令,走 `agent.followup()` / `agent.steer()` 进入下一个模型回合,由模型调用 `anim_patch` 执行。 第 2 条路径的好处是**零新增机制**——完全复用官方已有的输入通道,改动天然落进事件流、天然可回放, 代价是多一个模型回合的延迟。等链路稳定后再升级成第 1 条,两条路径产出的事件完全相同,UI 不用改。 同样地,「在画布上选一个图层」这类轻量交互,可以直接调用受限 slot hook(如 `useTurnData`)从 Location data 读取, 不必去扫整个会话快照——官方明确要求 keyed renderer 只消费 `node.data`,不扫描事件窗口。 --- ## 7. 内置 agent 的编排形态 「内置一个教学动画制作 agent」在 dsh 里有三种落点,建议级联使用: 1. **插件本身**提供工具、事件、UI、渲染 seam(能力层) 2. **agent preset**(`config/agent-presets/anim-studio/`)固定一套组装:本插件的工具集 + 系统提示词段 + 权限策略 —— preset 在会话创建时固定,运行中的会话不会重新读取该文件 3. **skill**(可选)把「如何写教学分镜脚本」「如何安排讲解节奏」这类**方法论**沉淀成提示词段 + 调用约定 我的建议:**MVP 只做 1 + 2**。方法论先写进 preset 的系统提示词段里,等它稳定到值得单独维护时再抽成 skill。 过早抽象 skill 会让你在方法论天天变的时候多维护一层。 preset 里要一并考虑的还有权限:渲染会派生子进程、会写文件,需要配合 `tools/pre-execute` 策略与 `ctx.sandbox`。 --- ## 8. 包结构与落地路线 ### 8.1 推荐的包划分 | 包 | 半 | 职责 | | --- | --- | --- | | `dsh-anim-spec` | 纯类型 | IR schema、校验、时间计算、patch 应用。host 与 client 共享纯类型导出 | | `dsh-anim-tools` | host | `anim_*` 工具、事件生产、`SessionEventMap` 合并声明 | | `dsh-anim-render` | host | renderer seam(Service Definition + 注册表) | | `dsh-anim-render-motion-canvas` | host | 首个 Provider:IR → MC → MP4 | | `dsh-anim-jobs` | host | 渲染后台任务(kind `anim-render`),进度与产物管理 | | `dsh-client-ui-anim-studio` | client | 工作台面板(声明 `dsh.client`) | | `dsh-anim-bundle` | bundle | `cordis.patch.yml` 组装上述各行 | 事件生产方(host)通过**纯类型导出**声明 `SessionEventMap`,client 包只做类型副作用导入—— 官方明确要求 client 绝不 import host 的实现代码进浏览器 bundle。 > **落地时的实际包划分**(与上表的差异):没有拆成多包,单仓库单发布包内按 workspace 分层——`packages/spec`(纯数据)、`packages/store`(fold,host/client 共享)、`packages/render-mc`(Provider)、`packages/tools`(host 面 + `/dsh-anim` 路由)、`packages/client`(浏览器面,打成 `lib/client.js`);渲染后台化直接用宿主 `ctx.jobs`,不需要独立的 `dsh-anim-jobs`。`SessionEventMap` 合并声明在 `packages/tools/src/plugins.d.ts`。 ### 8.2 实施进度 > 活文档是 [`docs/0.4.0-规划.md`](./0.4.0-规划.md)(0.2.0 / 0.3.0 规划留档在旁,含各自 §实施进度 与真机结论);下表是草案视角的里程碑快照(2026-09-15)。 | 阶段 | 状态 | 证据 | | --- | --- | --- | | **0. 离线验证** | ✅ **完成** | `examples/hello-gradient` 一条命令出 MP4:240 帧 / 8.000s / 30fps / 1280x720 / H.264 yuv420p,中文渲染正常 | | 1. 插件骨架 | ✅ 完成 | 真机 `dsh web` 挂载验证(0.2.0 M0/M1 实证:工具注册、渲染出片、会话恢复) | | 2. 事件与 IR | ✅ 完成 | 事件改落插件 sidecar(宿主日志 fail-closed,见 README 事故记录);**面板已落**:9 个 anim_* 工具的会话卡片(keyed `tool.call.toolview`),数据走 `presentationMeta` | | 3. 预览闭环 | ✅ 完成 | `anim_preview` 只渲到最晚抽帧点;卡片显示缩略图墙(`/dsh-anim/media` 同源路由) | | 4. 渲染导出 | ✅ 完成 | `anim_render` 走 `ctx.jobs` 后台化(无 jobs 退同步),`anim/render-start/-progress/-finished` 事件序;成片卡片内嵌 `