dsh-motion 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,7 +14,7 @@
14
14
  |---|---|
15
15
  | `motion_frames` | **极简模式(默认)**:直接从场景出帧,不生成视频、不用 HF;几秒拿到 storyboard PNG 与 layout 问题 |
16
16
  | `motion_doctor` | 环境自检(Node / FFmpeg / Python venv / manimgx / sympy / Chrome / HyperFrames) |
17
- | `motion_render` | 渲染 Manim / manimgx 场景,可加运动模糊、颗粒、暗角、调色 |
17
+ | `motion_render` | 渲染 Manim / manimgx 场景;未通过 `APPROVALS.json` 确认门的项目会拒绝渲染 |
18
18
  | `motion_probe` | 成片抽帧拼 contact sheet,报告全黑 / 过曝的空镜 |
19
19
  | `formula_check` | 用 SymPy 判断两个表达式是否恒等 |
20
20
 
@@ -48,6 +48,10 @@
48
48
 
49
49
  路由是显式的:`node scripts/route-style.mjs "<题材>"`(点名优先 → registry 打分 → 默认 `swiss-motion`;分数并列/为 0 时要求确认)。规则在 [styles/registry.json](styles/registry.json)。
50
50
 
51
+ 迭代预算(首帧 ≤60s、输出 ≤30k、上下文 ≤100k、读图 ≤10)见 [docs/performance-budget.md](docs/performance-budget.md)。
52
+
53
+ 开工前先过需求卡点:`npm run plan -- "竖屏抖音,讲导数,要旁白"`,会推断画幅、交付、风格的候选项,再交给 `ask_user_question`;能推断的不问。
54
+
51
55
  项目 `DESIGN.md` 头部写 `style: <slug>` + `style_version: 1.0.0`。风格来源、HF 集成、抽帧能力与 Lemo 调研见 [docs/ai-native-motion.md](docs/ai-native-motion.md)。
52
56
 
53
57
  ## 演示
@@ -62,6 +66,30 @@
62
66
 
63
67
  汇总见 [showcase/README.md](showcase/README.md)。
64
68
 
69
+ ## 开工前确认
70
+
71
+ 顺序是硬性的:
72
+
73
+ 1. 需求卡点(画幅 / 交付 / 风格 / 受众 / 旁白)
74
+ 2. 脚本 / 文案(逐句旁白、上屏文字、公式)
75
+ 3. 分镜(镜头、秒数、画面、运动、字幕)
76
+ 4. `DESIGN.md` + 3 张风格帧(只出帧,不渲染)
77
+ 5. 交付清单(尺寸 / fps / 时长 / 文件 / 音频 / 字幕)
78
+ 6. **用户明确说“可以开始 / 开始做 / 按这个执行”**
79
+
80
+ `motion_render` 会读项目里的 `APPROVALS.json`;没有,或 `delivery` / `start` 等阶段未全部 approved,直接报错,不会偷偷渲染。
81
+
82
+ 没听到第 6 步的明确指令,不写完整场景、不出全片。清单模板见
83
+ [skills/animation-router/references/confirmation.md](skills/animation-router/references/confirmation.md)。
84
+
85
+ 按体量分档:`light`(≤15 s 无旁白:脚本+分镜合并)、`standard`(15–60 s 或旁白)、`full`(>60 s 或多段,加 animatic)。确认状态落在项目里的 `APPROVALS.json`:
86
+
87
+ ```bash
88
+ npm run approvals -- init <project> --mode light
89
+ npm run approvals -- set <project> start approved --note "用户说可以开始"
90
+ npm run approvals -- check <project>
91
+ ```
92
+
65
93
  ## 安装
66
94
 
67
95
  ```bash
@@ -50,7 +50,7 @@ HF 的卖点是 "same input, same frames, same output":HTML + 可 seek 的动
50
50
 
51
51
  - Lemo-Opuscar 明确写着风格是为 Claude Opus 5.5 调的,别的模型不一定复现得好;代码 MIT、demo 与静帧 CC BY 4.0;
52
52
  - 它的 59 个风格覆盖纸感、印刷、像素、3D、纪录片等,适合当"语言菜单",不适合直接当品牌规范;
53
- - 可借鉴的三件事:`STYLE.md` 作为 style prompt、三张风格帧验收、分镜只在被要求时做;
53
+ - 可借鉴的三件事:`STYLE.md` 作为 style prompt、三张风格帧验收、分镜作为确认材料;
54
54
  - 授权上,demo 静帧可商用但需署名 LemoLab;风格文件本身是 MIT。
55
55
 
56
56
  本仓库的选择:删除自创的 `dark-editorial`,改为 4 套 Lemo 风格(`swiss-motion` / `dataviz` / `blueprint` / `whiteboard`)+ 1 套 Manim 社区的 `3b1b`,每套按 `STYLE.md` + `tokens.json` + `theme.py` + `preview.jpg` 落地;外部参考统一指向 HF。
@@ -125,7 +125,7 @@ manimgx inspect scene.py -t 1.2,4.5,end
125
125
  DESIGN.md
126
126
  → motion_frames(3–6 帧:开场 / 转折 / 结论)
127
127
  → 改场景 → 再出帧(秒级)
128
- → 需要给用户看分镜时:6–9 帧(真实代码)
128
+ → 确认门:脚本 → 分镜 → DESIGN/风格帧 → 用户明确“开始”
129
129
  → formula_check
130
130
  → motion_render(定稿后)
131
131
  → 需要字幕/旁白/混音:进 HF
@@ -150,15 +150,7 @@ DESIGN.md
150
150
  - 需要交付时才升级到 `motion_render` / HF;
151
151
  - 成片检查(`motion_probe`)只在已经有 mp4 时运行,不做"为了检查而渲染"。
152
152
 
153
- ## 参考
154
-
155
- - [HyperFrames design systems](https://hyperframes.heygen.com/prompting/design-systems)
156
- - [HyperFrames examples](https://hyperframes.heygen.com/examples) · [catalog](https://hyperframes.heygen.com/catalog)
157
- - [HyperFrames CLI rendering](https://hyperframes.heygen.com/guides/rendering)
158
- - [Lemo-Opuscar](https://github.com/lemomo-ai/lemo-opuscar) · [DIRECTOR.md](https://github.com/lemomo-ai/lemo-opuscar/blob/main/DIRECTOR.md) · [TECHNIQUE.md](https://github.com/lemomo-ai/lemo-opuscar/blob/main/TECHNIQUE.md)
159
- - 本地 `manimgx inspect --help`(manimgx 0.1.1)
160
-
161
- ## 4.5 效率:大家怎么缩短时间
153
+ ## 5. 效率:大家怎么缩短时间
162
154
 
163
155
  共识是**把检查点前移、把渲染延后**:
164
156
 
@@ -178,7 +170,7 @@ DESIGN.md
178
170
  - 关键不是"帧比视频快多少",而是**看帧成本与片长无关**,且不经过编码、起浏览器、抽帧三次搬运;
179
171
  - 如果已经在 HF 里,用 `hyperframes snapshot` 而不是"先 render 再 ffmpeg 截图"。
180
172
 
181
- ## 4.6 开发调试
173
+ ## 6. 开发调试
182
174
 
183
175
  服务端插件没有 HMR,但也不需要每次重装:
184
176
 
@@ -189,7 +181,50 @@ DESIGN.md
189
181
 
190
182
  细节见 [dev-loop.md](dev-loop.md)。
191
183
 
192
- ## 4.7 风格路由:目前怎么做,通用标准是什么
184
+ ## 7. 需求卡点:先问,再猜
185
+
186
+ 默认流程多了一个“卡点”,避免在没有信息时自己替用户决定:
187
+
188
+ ```bash
189
+ node scripts/plan-questions.mjs "<brief>" --json
190
+ ```
191
+
192
+ 输出可直接交给 `ask_user_question`:
193
+
194
+ - **画幅**:brief 已有“竖屏/横屏/16:9”就直接写进 assumptions,不再问;没有才给推断的 2 个;
195
+ - **交付**:brief 已有“旁白/静音”就跳过;没有才问;
196
+ - **风格**:`route-style` 的 top 2–3;点名风格时直接省略;
197
+ - **受众**:只对数学/科学、且 brief 未提“高中/大学/大众/专业”时问;选项按平台排(竖屏→大众科普,横屏→高中/大学);
198
+ - **旁白**:只在交付为旁白、且 brief 未提“中文/英文/双语”时问;选项是语言 + 声音;
199
+ - **时长/语言**:从 brief 提取,不单独问;未回答的写进 `assumptions`。
200
+
201
+ 最多 4 题。关键不是“问满四道”,而是**能推断的不问、选项按上下文生成、问完一次就开做**。同时输出 `assumptions.gateMode`:≤15s 且无旁白为 `light`,15–60s 或有旁白为 `standard`,>60s 或多段为 `full`。
202
+
203
+ ### 确认门(本仓库的硬规则,分档)
204
+
205
+ 需求卡点问完,**不等于开工**。按体量选一档:
206
+
207
+ | 模式 | 适用 | 阶段 |
208
+ |---|---|---|
209
+ | `light` | ≤15 s、无旁白 | 需求 → **脚本+分镜(合并)** → 视觉 → 开工 |
210
+ | `standard` | 15–60 s,或有旁白 | 需求 → 脚本 → 分镜 → 视觉 → 交付 → 开工 |
211
+ | `full` | >60 s、多段、复杂旁白 | 需求 → 脚本 → 分镜 → **animatic** → 视觉 → 交付 → 开工 |
212
+
213
+ 确认状态落在项目里的 `APPROVALS.json`:
214
+
215
+ ```bash
216
+ node scripts/approvals.mjs init <project> --mode light
217
+ node scripts/approvals.mjs set <project> script_storyboard approved --note "用户确认"
218
+ node scripts/approvals.mjs check <project>
219
+ ```
220
+
221
+ `check` 未全部 approved 时退出 1;不能跳过前置阶段;`start` 必须是用户明确授权。
222
+
223
+ **是不是太严?** 对 6 秒、无旁白的单公式短片,强制“脚本、分镜、交付清单”三份独立材料确实过重;所以 `light` 把脚本和分镜合并成一张表,保留“明确开工”这一道。对 30 秒以上或带旁白的片子,`standard` 的独立脚本/分镜是值得的;`full` 才加 animatic。
224
+
225
+ 模板在 [skills/animation-router/references/confirmation.md](../skills/animation-router/references/confirmation.md)。
226
+
227
+ ## 8. 风格路由:目前怎么做,通用标准是什么
193
228
 
194
229
  ### 本仓库
195
230
 
@@ -245,7 +280,7 @@ DESIGN.md
245
280
  - registry 的 `keywords` 需要随新题材维护;
246
281
  - 画幅/音频/复杂度字段目前只记录,还没参与硬约束(比如 9:16 未验证的风格应直接拒绝)。
247
282
 
248
- ## 5. 现成的风格体系(2026-10 可直接采用)
283
+ ## 9. 现成的风格体系(2026-10 可直接采用)
249
284
 
250
285
  不必从零写风格文档。这个生态已经沉淀出几套公开、可复用的体系:
251
286
 
@@ -263,7 +298,7 @@ DESIGN.md
263
298
  1. **风格 = 不变量 + 自由故事。** `STYLE.md` 锁死 look、颜色、字体、运动、声音;故事、镜头、节奏由导演决定(Lemo 的 AGENTS.md 原话:`STYLE.md` is fixed;everything else is yours to direct)。
264
299
  2. **风格按名字选,不按形容词选。** 用户说 "swiss-motion" / "瑞士动态排版",agent 查索引、读 `STYLE.md`;没选就推荐 2–3 个最接近的。
265
300
  3. **风格文档有固定骨架。** Lemo 是 11 节;mg-styles-15 是四段式,其中 signature features 必须可检验("cubic-bezier(0.34,1.56,0.64,1),错峰 2–4 帧",不是"要有弹性")。
266
- 4. **先风格帧,后分镜,最后成片。** 三张真实代码出的风格帧 → 用户要看才做 6–9 张分镜 → 成片;风格帧不过不往下走。
301
+ 4. **先确认,后执行。** 脚本 → 分镜 → DESIGN/风格帧 → 交付清单,逐项确认;用户明确说“开始”才写完整场景、出片。
267
302
  5. **独立评审。** 另一个 AI 用 rubric 按 7 个维度打分(风格保真、概念、运动、排版、质感、声画同步、技术),给 5–10 条带时间码的修改,再改两轮。
268
303
  6. **确定性。** 帧是 `render(t)` 的纯函数;同一输入同一输出,便于批量与回归。
269
304
 
@@ -299,3 +334,11 @@ DESIGN.md
299
334
  3. 画面仍由 manimgx 画,不复制它们的 Canvas/WebGL demo 代码,只取语法与 prompt;
300
335
  4. 交付层继续交给 HF,`frame.md` 只做品牌/版式契约;
301
336
  5. 标注来源与许可:Lemo 代码/文档 MIT,demo 静帧 CC BY 4.0(署名 LemoLab);mg-styles-15 MIT;HF Apache-2.0。
337
+
338
+ ## 参考
339
+
340
+ - [HyperFrames design systems](https://hyperframes.heygen.com/prompting/design-systems)
341
+ - [HyperFrames examples](https://hyperframes.heygen.com/examples) · [catalog](https://hyperframes.heygen.com/catalog)
342
+ - [HyperFrames CLI rendering](https://hyperframes.heygen.com/guides/rendering)
343
+ - [Lemo-Opuscar](https://github.com/lemomo-ai/lemo-opuscar) · [DIRECTOR.md](https://github.com/lemomo-ai/lemo-opuscar/blob/main/DIRECTOR.md) · [TECHNIQUE.md](https://github.com/lemomo-ai/lemo-opuscar/blob/main/TECHNIQUE.md)
344
+ - 本地 `manimgx inspect --help`(manimgx 0.1.1)
@@ -0,0 +1,73 @@
1
+ # 迭代预算(P0)
2
+
3
+ 依据:30.02 s 成片、882.1 s 端到端的一次真实诊断。
4
+
5
+ | 指标 | 那次 | 目标 |
6
+ |---|---|---|
7
+ | 首帧 | 261 s | **≤60 s** |
8
+ | 端到端 | 882 s | **≤400 s** |
9
+ | 模型时间 | 740.6 s(84%) | 不单独设目标,随步数/token 降 |
10
+ | 输出 tokens | 126,839 | **≤30k** |
11
+ | 上下文峰值 | 222k / 256k | **≤100k** |
12
+ | 读图 | 32 张 1080p | **≤10 张 sheet/局部** |
13
+ | 全文件重写 | 2 次 | **0 次**(例外见下) |
14
+ | `motion_probe` / `motion_render` | 3 / 4 次 | **1 / 2 次** |
15
+
16
+ ## P0 四条
17
+
18
+ ### 1. 先构图,后动画
19
+
20
+ - 先写“最小场景”:只放 6–8 个关键构图,不写完整动画;
21
+ - `motion_frames` 出 sheet,确认构图与版式;
22
+ - **60 s 内看不到第一帧就停下来**,检查是不是在盲写;
23
+ - 构图确认后才补时间轴、运动、转场。
24
+
25
+ ### 2. 只做局部 edit
26
+
27
+ - 默认用 `edit`,不整文件重写;
28
+ - 只有改动超过文件 50%,或文件 <40 行时,才允许重写;
29
+ - 重写必须在回复里说明原因,不能“顺手重写”。
30
+
31
+ ### 3. 图片预算
32
+
33
+ - 每次迭代只看 **1 张 contact sheet**,宽度约 960 px;
34
+ - 只有可疑格才读全分辨率;
35
+ - 同一张图不重复读;改了场景才重新出图;
36
+ - 目标:整个任务 ≤10 次读图。
37
+
38
+ ### 4. 上下文预算
39
+
40
+ - 只保留当前场景版本,旧版本用一两句话摘要;
41
+ - 不把旧代码、旧图、已经确认过的长文重复贴回上下文;
42
+ - 目标峰值 ≤100k;接近 100k 时先摘要再继续。
43
+
44
+ ## P1(工具)
45
+
46
+ 已完成:
47
+
48
+ - [x] `motion_frames` 按场景 fps 取帧:各风格 `theme.py` 在模块层设置 `m.config.frame_rate`;
49
+ - [x] post 强制 CFR,并校验成片时长(`tpad + CFR` 修正后复验);
50
+ - [x] storyboard 顶部 caption 条加提示,避免误判“裁切”。
51
+
52
+ 明确不做:
53
+
54
+ - CJK 字符级 `ReplacementTransform` / `Transform` 的字形变形。manimgx 核心按点插值,汉字轮廓点数与顺序不一致,修起来是字形级工程;本仓库按“不支持”处理。
55
+ - 中文标题/标签:`Write`、`FadeIn/Out`、`Indicate`、改颜色;
56
+ - 换词:`FadeOut(old)` + `FadeIn(new)`,或按词/行 stagger;
57
+ - 公式:`TransformMatchingTex`;中文注释放在 `Text`,不混进 MathTex。
58
+
59
+ 待做:
60
+
61
+ - [ ] `motion_probe` 只在最终成片跑一次;
62
+ - [ ] `--draft` 低分辨率/低帧率、分段渲染、`manimgx preview` 包装、animatic。
63
+
64
+ ## 怎么量
65
+
66
+ 每个项目记录:
67
+
68
+ ```text
69
+ first_frame_s, total_s, model_s, tool_s, render_s,
70
+ output_tokens, context_peak, image_reads, full_rewrites, probes, renders
71
+ ```
72
+
73
+ 复测时用同一台机器、同一支 30 s 片,先对比“首帧”和“输出 tokens”,再看总时长。
@@ -0,0 +1,60 @@
1
+ /**
2
+ * 读取项目里的 APPROVALS.json,供工具层做“开工确认”硬门。
3
+ *
4
+ * 脚本 scripts/approvals.mjs 负责写;这里只读,避免模型绕过。
5
+ */
6
+ import { existsSync, readFileSync } from 'node:fs'
7
+ import { dirname, join, resolve } from 'node:path'
8
+
9
+ const FILE = 'APPROVALS.json'
10
+
11
+ export function findApprovals(file) {
12
+ let dir = dirname(resolve(file))
13
+ for (let i = 0; i < 4; i += 1) {
14
+ const candidate = join(dir, FILE)
15
+ if (existsSync(candidate)) return candidate
16
+ const parent = dirname(dir)
17
+ if (parent === dir) break
18
+ dir = parent
19
+ }
20
+ return null
21
+ }
22
+
23
+ export function readApprovals(file) {
24
+ const path = findApprovals(file)
25
+ if (!path) return { status: 'missing', path: null, missing: [], mode: null }
26
+ const data = JSON.parse(readFileSync(path, 'utf8'))
27
+ const stages = Object.entries(data.stages ?? {})
28
+ const missing = stages
29
+ .filter(([, value]) => value.status !== 'approved')
30
+ .map(([key, value]) => value.label ?? key)
31
+ return {
32
+ status: missing.length ? 'pending' : 'approved',
33
+ path,
34
+ mode: data.mode ?? null,
35
+ project: data.project ?? null,
36
+ missing,
37
+ data,
38
+ }
39
+ }
40
+
41
+ export function assertApproved(file, { allow = false } = {}) {
42
+ if (allow) return null
43
+ const report = readApprovals(file)
44
+ if (report.status === 'missing') {
45
+ throw new Error(
46
+ `未找到 ${FILE}:这个项目还没过需求卡点/确认门。\n`
47
+ + '先跑:node scripts/plan-questions.mjs "<需求>" --json\n'
48
+ + '再初始化:node scripts/approvals.mjs init <project> --mode light|standard|full\n'
49
+ + '确认后把 delivery/start 等阶段置为 approved;或设置 DSH_MOTION_ALLOW_UNCONFIRMED=1 仅用于调试。',
50
+ )
51
+ }
52
+ if (report.status === 'pending') {
53
+ throw new Error(
54
+ `开工确认未通过(${report.path}):还缺 ${report.missing.join('、')}。\n`
55
+ + '用 node scripts/approvals.mjs set <project> <stage> approved --note "用户确认" 更新;\n'
56
+ + '全部 approved 后才能渲染。',
57
+ )
58
+ }
59
+ return report
60
+ }
package/lib/index.js CHANGED
@@ -15,6 +15,7 @@ import { appendFileSync, mkdirSync } from 'node:fs'
15
15
  import { homedir } from 'node:os'
16
16
  import { join } from 'node:path'
17
17
 
18
+ import { assertApproved } from './approvals.js'
18
19
  import { importFresh } from './fresh.js'
19
20
 
20
21
  const OPS_URL = new URL('./ops.js', import.meta.url)
@@ -91,7 +92,7 @@ export function apply(ctx, config = {}) {
91
92
  ctx.tools.register({
92
93
  name: 'motion_render',
93
94
  description:
94
- '渲染一支数学/科普场景。engine="manimgx"(默认,数学/公式/坐标,无需 LaTeX,自带 layout 检查)或 "manim"(Manim CE 回退)。post 可加运动模糊/颗粒/暗角。返回时长/体积/布局问题清单。',
95
+ '渲染一支数学/科普场景。engine="manimgx"(默认,数学/公式/坐标,无需 LaTeX,自带 layout 检查)或 "manim"(Manim CE 回退)。post 可加运动模糊/颗粒/暗角。返回时长/体积/布局问题清单。未通过 APPROVALS.json 确认门的项目会拒绝渲染。',
95
96
  parameters: {
96
97
  type: 'object',
97
98
  properties: {
@@ -120,6 +121,9 @@ export function apply(ctx, config = {}) {
120
121
  output: textOutput,
121
122
  timeoutMs: 900_000,
122
123
  async execute(args) {
124
+ assertApproved(args.file, {
125
+ allow: config.allowUnconfirmed === true || process.env.DSH_MOTION_ALLOW_UNCONFIRMED === '1',
126
+ })
123
127
  const { renderScene } = await ops()
124
128
  const result = await renderScene(config, args)
125
129
  log(`render ${result.output} ${result.durationSec}s engine=${result.engine} problems=${result.layoutProblems.length}`)
package/lib/ops.js CHANGED
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { execFile } from 'node:child_process'
11
- import { existsSync, statSync } from 'node:fs'
11
+ import { existsSync, renameSync, statSync } from 'node:fs'
12
12
  import { basename, dirname, join, resolve } from 'node:path'
13
13
  import { promisify } from 'node:util'
14
14
 
@@ -104,10 +104,47 @@ export async function renderScene(config, args) {
104
104
  summary.video = finalOut
105
105
  }
106
106
 
107
+ // post/ffmpeg 可能因 tmix/丢帧把时长压短;用 CFR 补齐并校验,避免"30s 变 27.4s"。
108
+ if (summary.durationSec) {
109
+ const fixed = await ensureDuration(config, summary, fps)
110
+ if (fixed) summary.normalized = true
111
+ }
112
+
107
113
  summary.wallSec = Number(((Date.now() - started) / 1000).toFixed(1))
108
114
  return summary
109
115
  }
110
116
 
117
+ /** 成片时长与渲染器报告的时长不一致时,用 tpad + CFR 修正;返回是否修过。 */
118
+ async function ensureDuration(config, summary, fps) {
119
+ const expected = Number(summary.durationSec)
120
+ if (!Number.isFinite(expected) || expected <= 0) return false
121
+ const file = summary.output
122
+ const before = await probeOutput(config, file)
123
+ const delta = expected - before.durationSec
124
+ if (!Number.isFinite(delta) || Math.abs(delta) <= 0.08) return false
125
+
126
+ const tmp = `${file}.cfr.mp4`
127
+ const chain = []
128
+ if (delta > 0.05) chain.push(`tpad=stop_mode=clone:stop_duration=${delta.toFixed(3)}`)
129
+ chain.push(`fps=${fps}`)
130
+ const args = [
131
+ '-y', '-loglevel', 'error', '-i', file,
132
+ '-vf', chain.join(','), '-fps_mode', 'cfr',
133
+ '-c:v', 'libx264', '-crf', '18', '-pix_fmt', 'yuv420p', '-an',
134
+ ]
135
+ if (delta < -0.05) args.push('-t', expected.toFixed(3))
136
+ args.push(tmp)
137
+ await exec(binaryOf(config, 'ffmpeg'), args, { timeout: 900_000, maxBuffer: 8 * 1024 * 1024 })
138
+
139
+ const after = await probeOutput(config, tmp)
140
+ if (!Number.isFinite(after.durationSec) || Math.abs(after.durationSec - expected) > 0.12) {
141
+ throw new Error(`CFR 修正后时长仍不对:期望 ${expected}s,实际 ${after.durationSec}s(${tmp})`)
142
+ }
143
+ renameSync(tmp, file)
144
+ Object.assign(summary, after)
145
+ return true
146
+ }
147
+
111
148
  /** 归一化后期参数:枚举/上限之外的值一律夹到合法区间,交给 ffmpeg 之前就把脏输入挡掉。 */
112
149
  export function normalizePost(raw) {
113
150
  const p = raw && typeof raw === 'object' ? raw : {}
@@ -135,7 +172,8 @@ async function applyPost(config, input, output, fps, post) {
135
172
  if (chain.length === 0) return
136
173
  await exec(binaryOf(config, 'ffmpeg'), [
137
174
  '-y', '-loglevel', 'error', '-i', input,
138
- '-vf', chain.join(','), '-c:v', 'libx264', '-crf', '19', '-pix_fmt', 'yuv420p',
175
+ '-vf', chain.join(','), '-fps_mode', 'cfr', '-r', String(fps),
176
+ '-c:v', 'libx264', '-crf', '19', '-pix_fmt', 'yuv420p',
139
177
  '-an', output,
140
178
  ], { timeout: 900_000, maxBuffer: 8 * 1024 * 1024 })
141
179
  }
@@ -252,6 +290,10 @@ export async function inspectFrames(config, args) {
252
290
  mode: 'frames',
253
291
  requestedTimes: times,
254
292
  ...parsed,
293
+ // 每格顶部约 28px 是 caption 条,不属于画面;不要据此判断"上沿被裁"。
294
+ storyboardNote: parsed.storyboard.length
295
+ ? '每格顶部是 caption 条,不是画面;怀疑裁切时看单帧或全图。'
296
+ : null,
255
297
  wallSec: Number(((Date.now() - started) / 1000).toFixed(1)),
256
298
  }
257
299
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-motion",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Code-generated math & science animations for DeepSeek Harness: frames-first preview, Manim clips, formula checks.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -17,6 +17,7 @@
17
17
  "skills",
18
18
  "styles",
19
19
  "docs",
20
+ "scripts",
20
21
  "cordis.patch.yml",
21
22
  "README.md",
22
23
  "LICENSE",
@@ -38,10 +39,12 @@
38
39
  },
39
40
  "scripts": {
40
41
  "check": "node --check lib/index.js && node --check lib/ops.js && node --check lib/env.js && node --check lib/doctor.js",
41
- "test": "node scripts/test-ops.mjs && node scripts/test-route.mjs",
42
+ "test": "node scripts/test-ops.mjs && node scripts/test-route.mjs && node scripts/test-plan.mjs && node scripts/test-approvals.mjs",
42
43
  "smoke": "node scripts/smoke.mjs",
43
44
  "dev": "node scripts/dev.mjs",
44
45
  "route": "node scripts/route-style.mjs",
46
+ "plan": "node scripts/plan-questions.mjs",
47
+ "approvals": "node scripts/approvals.mjs",
45
48
  "styles:index": "node scripts/build-style-index.mjs",
46
49
  "styles:check": "node scripts/build-style-index.mjs --check",
47
50
  "check:meta": "node scripts/check-meta.mjs",
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 项目级确认记录:把“用户确认过什么”落成可追溯的 APPROVALS.json。
4
+ *
5
+ * node scripts/approvals.mjs init <project> --mode light
6
+ * node scripts/approvals.mjs set <project> script_storyboard approved --note "用户确认"
7
+ * node scripts/approvals.mjs check <project>
8
+ * node scripts/approvals.mjs show <project>
9
+ *
10
+ * 模式:
11
+ * light ≤15s、无旁白:需求 → 脚本+分镜 → 视觉 → 开工
12
+ * standard 15–60s 或有旁白:需求 → 脚本 → 分镜 → 视觉 → 交付 → 开工
13
+ * full >60s 或多段/复杂旁白:再加 animatic
14
+ */
15
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
16
+ import { join, resolve } from 'node:path'
17
+
18
+ const FILE = 'APPROVALS.json'
19
+ const LABELS = {
20
+ requirements: '需求',
21
+ script: '脚本',
22
+ script_storyboard: '脚本 + 分镜',
23
+ storyboard: '分镜',
24
+ animatic: 'Animatic',
25
+ look: '视觉(DESIGN + 3 帧)',
26
+ delivery: '交付清单',
27
+ start: '开工',
28
+ }
29
+ const MODES = {
30
+ light: ['requirements', 'script_storyboard', 'look', 'start'],
31
+ standard: ['requirements', 'script', 'storyboard', 'look', 'delivery', 'start'],
32
+ full: ['requirements', 'script', 'storyboard', 'animatic', 'look', 'delivery', 'start'],
33
+ }
34
+ const STATUSES = new Set(['pending', 'approved', 'rejected'])
35
+
36
+ function fail(message) {
37
+ console.error(message)
38
+ process.exit(1)
39
+ }
40
+
41
+ function pathOf(project) {
42
+ return join(resolve(project), FILE)
43
+ }
44
+
45
+ function read(project) {
46
+ const file = pathOf(project)
47
+ if (!existsSync(file)) fail(`找不到 ${file};先运行 init`)
48
+ return JSON.parse(readFileSync(file, 'utf8'))
49
+ }
50
+
51
+ function write(project, data) {
52
+ const file = pathOf(project)
53
+ mkdirSync(resolve(project), { recursive: true })
54
+ writeFileSync(file, JSON.stringify(data, null, 2) + '\n')
55
+ return file
56
+ }
57
+
58
+ function init(project, mode) {
59
+ if (!MODES[mode]) fail(`mode 只能是 ${Object.keys(MODES).join(' / ')}`)
60
+ const file = pathOf(project)
61
+ if (existsSync(file)) fail(`${file} 已存在;要重建请先删除`)
62
+ const stages = {}
63
+ for (const stage of MODES[mode]) {
64
+ stages[stage] = { label: LABELS[stage], status: 'pending', at: null, by: null, note: '' }
65
+ }
66
+ const data = { version: 1, project: project, mode, createdAt: new Date().toISOString(), stages }
67
+ write(project, data)
68
+ console.log(`created ${file} (${mode})`)
69
+ console.log(`stages: ${MODES[mode].join(' → ')}`)
70
+ }
71
+
72
+ function set(project, stage, status, options) {
73
+ if (!STATUSES.has(status)) fail(`status 只能是 ${[...STATUSES].join(' / ')}`)
74
+ const data = read(project)
75
+ const order = MODES[data.mode]
76
+ if (!data.stages[stage]) fail(`${data.mode} 模式没有阶段: ${stage}(有: ${order.join(', ')})`)
77
+ if (status === 'approved') {
78
+ const index = order.indexOf(stage)
79
+ const missing = order.slice(0, index).filter((key) => data.stages[key].status !== 'approved')
80
+ if (missing.length) fail(`不能跳过前置阶段: ${missing.map((key) => LABELS[key]).join('、')}`)
81
+ }
82
+ data.stages[stage] = {
83
+ ...data.stages[stage],
84
+ status,
85
+ at: new Date().toISOString(),
86
+ by: options.by ?? 'user',
87
+ note: options.note ?? data.stages[stage].note ?? '',
88
+ }
89
+ write(project, data)
90
+ console.log(`${stage} → ${status}${options.note ? ` (${options.note})` : ''}`)
91
+ }
92
+
93
+ function table(data) {
94
+ const rows = Object.entries(data.stages).map(([key, value]) => {
95
+ const mark = value.status === 'approved' ? '✅' : value.status === 'rejected' ? '❌' : '⬜'
96
+ return `${mark} ${value.label.padEnd(14, ' ')} ${value.status.padEnd(8)} ${value.at ?? '-'} ${value.note ?? ''}`
97
+ })
98
+ return [`project: ${data.project} mode: ${data.mode}`, ...rows].join('\n')
99
+ }
100
+
101
+ function check(project) {
102
+ const data = read(project)
103
+ console.log(table(data))
104
+ const missing = Object.entries(data.stages)
105
+ .filter(([, value]) => value.status !== 'approved')
106
+ .map(([key]) => LABELS[key])
107
+ if (missing.length) {
108
+ console.error(`\n未确认: ${missing.join('、')}`)
109
+ process.exit(1)
110
+ }
111
+ console.log('\nall approved')
112
+ }
113
+
114
+ const [command, project, ...rest] = process.argv.slice(2)
115
+ if (!command || !project) fail('用法: node scripts/approvals.mjs init|set|check|show <project> ...')
116
+
117
+ if (command === 'init') {
118
+ const modeIndex = rest.indexOf('--mode')
119
+ const mode = modeIndex >= 0 ? rest[modeIndex + 1] : 'light'
120
+ init(project, mode)
121
+ } else if (command === 'set') {
122
+ const [stage, status] = rest
123
+ const noteIndex = rest.indexOf('--note')
124
+ const byIndex = rest.indexOf('--by')
125
+ if (!stage || !status) fail('用法: set <project> <stage> <approved|pending|rejected> [--note ...] [--by ...]')
126
+ set(project, stage, status, {
127
+ note: noteIndex >= 0 ? rest[noteIndex + 1] : undefined,
128
+ by: byIndex >= 0 ? rest[byIndex + 1] : undefined,
129
+ })
130
+ } else if (command === 'check') {
131
+ check(project)
132
+ } else if (command === 'show') {
133
+ console.log(table(read(project)))
134
+ } else {
135
+ fail(`未知命令: ${command}`)
136
+ }