dsh-motion 0.1.1 → 0.3.0

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.
Files changed (39) hide show
  1. package/NOTICE.md +3 -0
  2. package/README.md +114 -25
  3. package/docs/ai-native-motion.md +301 -0
  4. package/docs/dev-loop.md +55 -0
  5. package/lib/doctor.js +26 -1
  6. package/lib/fresh.js +21 -0
  7. package/lib/index.js +44 -6
  8. package/lib/ops.js +164 -8
  9. package/package.json +10 -3
  10. package/skills/animation-router/SKILL.md +30 -12
  11. package/skills/design-spec/SKILL.md +33 -1
  12. package/skills/qa-rubric/SKILL.md +14 -8
  13. package/styles/3b1b/STYLE.md +68 -0
  14. package/styles/3b1b/preview.jpg +0 -0
  15. package/styles/3b1b/style.json +50 -0
  16. package/styles/3b1b/theme.py +43 -0
  17. package/styles/3b1b/tokens.json +32 -0
  18. package/styles/README.md +28 -0
  19. package/styles/blueprint/STYLE.md +59 -0
  20. package/styles/blueprint/preview.jpg +0 -0
  21. package/styles/blueprint/style.json +47 -0
  22. package/styles/blueprint/theme.py +65 -0
  23. package/styles/blueprint/tokens.json +27 -0
  24. package/styles/dataviz/STYLE.md +58 -0
  25. package/styles/dataviz/preview.jpg +0 -0
  26. package/styles/dataviz/style.json +50 -0
  27. package/styles/dataviz/theme.py +49 -0
  28. package/styles/dataviz/tokens.json +31 -0
  29. package/styles/registry.json +212 -0
  30. package/styles/swiss-motion/STYLE.md +59 -0
  31. package/styles/swiss-motion/preview.jpg +0 -0
  32. package/styles/swiss-motion/style.json +47 -0
  33. package/styles/swiss-motion/theme.py +59 -0
  34. package/styles/swiss-motion/tokens.json +33 -0
  35. package/styles/whiteboard/STYLE.md +58 -0
  36. package/styles/whiteboard/preview.jpg +0 -0
  37. package/styles/whiteboard/style.json +47 -0
  38. package/styles/whiteboard/theme.py +53 -0
  39. package/styles/whiteboard/tokens.json +27 -0
package/lib/index.js CHANGED
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * dsh-motion —— 代码生成的数学 / 科普动画
3
3
  *
4
- * 四个工具:
4
+ * 五个工具:
5
5
  * motion_doctor 环境自检(先跑这个)
6
+ * motion_frames 极简模式:直接从场景出帧(默认,不生成视频)
6
7
  * motion_render 渲染 Manim / manimgx 场景
7
- * motion_probe 抽帧自检(空镜 / 过曝)
8
+ * motion_probe 成片抽帧自检(空镜 / 过曝)
8
9
  * formula_check 公式恒等校验(sympy)
9
10
  *
10
11
  * 知识层在 skills/ 里:animation-router / design-spec / qa-rubric。
@@ -14,8 +15,12 @@ import { appendFileSync, mkdirSync } from 'node:fs'
14
15
  import { homedir } from 'node:os'
15
16
  import { join } from 'node:path'
16
17
 
17
- import { doctor } from './doctor.js'
18
- import { checkMath, probeVideo, renderScene } from './ops.js'
18
+ import { importFresh } from './fresh.js'
19
+
20
+ const OPS_URL = new URL('./ops.js', import.meta.url)
21
+ const DOCTOR_URL = new URL('./doctor.js', import.meta.url)
22
+
23
+ const ops = () => importFresh(OPS_URL)
19
24
 
20
25
  export const name = 'motion'
21
26
  export const inject = ['tools']
@@ -47,12 +52,42 @@ export function apply(ctx, config = {}) {
47
52
  output: textOutput,
48
53
  timeoutMs: 120_000,
49
54
  async execute() {
55
+ const { doctor } = await importFresh(DOCTOR_URL)
50
56
  const result = await doctor(config)
51
57
  log(`doctor ready=${JSON.stringify(result.ready)}`)
52
58
  return JSON.stringify(result, null, 2)
53
59
  },
54
60
  })
55
61
 
62
+ ctx.tools.register({
63
+ name: 'motion_frames',
64
+ description:
65
+ '极简模式(默认先用它):直接从场景渲染关键帧,不生成视频、不用 HyperFrames。返回 storyboard PNG 路径与 layout 问题,几秒完成;先看帧改画面,满意后再决定是否渲染。',
66
+ parameters: {
67
+ type: 'object',
68
+ properties: {
69
+ file: { type: 'string', description: '场景 .py 文件的绝对路径' },
70
+ scene: { type: 'string', description: '场景类名;文件里只有一个场景时可省略' },
71
+ times: {
72
+ type: 'array',
73
+ items: { type: 'string' },
74
+ description: '要看的时间点(秒)或 "end",如 ["1.2","4.5","end"];省略则采样每个 play 的结束帧',
75
+ },
76
+ out: { type: 'string', description: 'storyboard PNG 输出路径,默认与场景同目录' },
77
+ },
78
+ required: ['file'],
79
+ additionalProperties: false,
80
+ },
81
+ output: textOutput,
82
+ timeoutMs: 300_000,
83
+ async execute(args) {
84
+ const { inspectFrames } = await ops()
85
+ const result = await inspectFrames(config, args)
86
+ log(`frames ${result.file} samples=${result.requestedTimes.length || 'auto'} issues=${result.layoutProblems.length} ${result.wallSec}s`)
87
+ return JSON.stringify(result, null, 2)
88
+ },
89
+ })
90
+
56
91
  ctx.tools.register({
57
92
  name: 'motion_render',
58
93
  description:
@@ -85,6 +120,7 @@ export function apply(ctx, config = {}) {
85
120
  output: textOutput,
86
121
  timeoutMs: 900_000,
87
122
  async execute(args) {
123
+ const { renderScene } = await ops()
88
124
  const result = await renderScene(config, args)
89
125
  log(`render ${result.output} ${result.durationSec}s engine=${result.engine} problems=${result.layoutProblems.length}`)
90
126
  return JSON.stringify(result, null, 2)
@@ -94,7 +130,7 @@ export function apply(ctx, config = {}) {
94
130
  ctx.tools.register({
95
131
  name: 'motion_probe',
96
132
  description:
97
- '成片自检:等间隔抽帧拼成 contact sheet,并报告全黑/过曝的空镜帧。正式交付前先跑一遍,看过图再收工。',
133
+ '成片自检:已有 mp4 时等间隔抽帧拼 contact sheet,报告全黑/过曝的空镜。写场景阶段请用 motion_frames 直接看帧,不必先渲染视频。',
98
134
  parameters: {
99
135
  type: 'object',
100
136
  properties: {
@@ -108,6 +144,7 @@ export function apply(ctx, config = {}) {
108
144
  output: textOutput,
109
145
  timeoutMs: 300_000,
110
146
  async execute(args) {
147
+ const { probeVideo } = await ops()
111
148
  const result = await probeVideo(config, args)
112
149
  log(`probe ${result.video} frames=${result.framesAnalyzed} blank=${result.blankFrames.length}`)
113
150
  return JSON.stringify(result, null, 2)
@@ -130,11 +167,12 @@ export function apply(ctx, config = {}) {
130
167
  output: textOutput,
131
168
  timeoutMs: 60_000,
132
169
  async execute(args) {
170
+ const { checkMath } = await ops()
133
171
  const result = await checkMath(config, args)
134
172
  log(`formula_check equal=${result.equal}`)
135
173
  return JSON.stringify(result, null, 2)
136
174
  },
137
175
  })
138
176
 
139
- log('registered 4 tools')
177
+ log('registered 5 tools')
140
178
  }
package/lib/ops.js CHANGED
@@ -97,6 +97,8 @@ export async function renderScene(config, args) {
97
97
  if (post.enabled) {
98
98
  const finalOut = out.replace(/\.mp4$/, '') + '-post.mp4'
99
99
  await applyPost(config, out, finalOut, fps, post)
100
+ // post 会改帧率与体积:摘要必须以成片为准,而不是中间产物
101
+ Object.assign(summary, await probeOutput(config, finalOut))
100
102
  summary.post = { ...post, output: finalOut }
101
103
  summary.output = finalOut
102
104
  summary.video = finalOut
@@ -106,7 +108,8 @@ export async function renderScene(config, args) {
106
108
  return summary
107
109
  }
108
110
 
109
- function normalizePost(raw) {
111
+ /** 归一化后期参数:枚举/上限之外的值一律夹到合法区间,交给 ffmpeg 之前就把脏输入挡掉。 */
112
+ export function normalizePost(raw) {
110
113
  const p = raw && typeof raw === 'object' ? raw : {}
111
114
  const subframes = clampInt(p.subframes ?? 1, 1, 8)
112
115
  const grain = p.grain === true ? 7 : Number(p.grain ?? 0)
@@ -137,6 +140,132 @@ async function applyPost(config, input, output, fps, post) {
137
140
  ], { timeout: 900_000, maxBuffer: 8 * 1024 * 1024 })
138
141
  }
139
142
 
143
+ /** 按帧数选一个接近方形的网格:2→2x1、4→2x2、6→3x2、12→4x3、24→6x4。 */
144
+ export function tileGrid(n) {
145
+ const cols = n <= 2 ? 2 : n <= 4 ? 2 : n <= 6 ? 3 : n <= 9 ? 3 : n <= 12 ? 4 : n <= 16 ? 4 : 6
146
+ return `${cols}x${Math.ceil(n / cols)}`
147
+ }
148
+
149
+ /** 解析 signalstats 的 metadata=print 输出:每帧一段 `frame:N` + 若干 `lavfi.signalstats.*`。 */
150
+ export function parseSignalStats(text) {
151
+ const frames = []
152
+ let current
153
+ for (const raw of text.split('\n')) {
154
+ const line = raw.trim()
155
+ const head = /^frame:(\d+)/.exec(line)
156
+ if (head) {
157
+ current = { frame: Number(head[1]) }
158
+ frames.push(current)
159
+ continue
160
+ }
161
+ const kv = /^lavfi\.signalstats\.(YMIN|YMAX|YAVG)=([\d.]+)$/.exec(line)
162
+ if (kv && current) current[kv[1].toLowerCase()] = Number(kv[2])
163
+ }
164
+ return frames
165
+ }
166
+
167
+ /**
168
+ * 归一化帧时间点:秒数或 "end"。`motion_frames` 只画这些帧,不编码视频。
169
+ * 上限 24 个:再多也看不过来,而且 manimgx 会拆成多张 sheet。
170
+ */
171
+ export function normalizeTimes(raw) {
172
+ const list = raw == null ? [] : Array.isArray(raw) ? raw : [raw]
173
+ if (list.length > 24) throw new Error('times 最多 24 个')
174
+ return list.map((item) => {
175
+ if (typeof item === 'string' && item.trim().toLowerCase() === 'end') return 'end'
176
+ const n = Number(item)
177
+ if (!Number.isFinite(n) || n < 0 || n > 3600) {
178
+ throw new Error(`无效时间点:${item}(秒或 "end")`)
179
+ }
180
+ return String(n)
181
+ })
182
+ }
183
+
184
+ /** 解析 manimgx inspect 的 stdout:片名/时长、storyboard 路径、layout 问题。 */
185
+ export function parseInspectOutput(stdout) {
186
+ const text = String(stdout ?? '')
187
+ const head = text.match(/^(.+?):\s+([A-Za-z_][\w.]*),\s+([\d.]+)\s*s,\s+(\d+)\s+plays?\b/m)
188
+ const sheet = text.match(/^storyboard:\s*(.+)$/m)
189
+ const layout = text.match(/^layout:\s*(.+)$/m)
190
+ const problems = [...text.matchAll(/^\s*\[\d+\]\s+(.+)$/gm)].map((m) => m[1].trim())
191
+ return {
192
+ scene: head?.[2] ?? null,
193
+ durationSec: head ? Number(head[3]) : null,
194
+ plays: head ? Number(head[4]) : null,
195
+ storyboard: sheet
196
+ ? sheet[1].split(',').map((s) => s.trim()).filter(Boolean)
197
+ : [],
198
+ layout: layout ? layout[1].trim() : null,
199
+ layoutOk: layout ? /no problems/.test(layout[1]) : null,
200
+ layoutProblems: problems,
201
+ }
202
+ }
203
+
204
+ /**
205
+ * 极简模式:直接从场景出帧,不生成视频、不依赖 HyperFrames。
206
+ *
207
+ * manimgx inspect 只画请求的时刻并做版式检查,通常几秒完成;返回的
208
+ * storyboard PNG 可以直接交给模型读图。退出码 1 表示有 layout 问题,
209
+ * 仍是有效结果,不当失败。
210
+ */
211
+ export async function inspectFrames(config, args) {
212
+ const file = resolve(args.file)
213
+ if (!existsSync(file)) throw new Error(`场景文件不存在: ${file}`)
214
+
215
+ requireVenv(config)
216
+ const venv = resolveVenv(config)
217
+ const command = venvBinary(config, 'manimgx')
218
+ if (!existsSync(command)) {
219
+ throw new Error(
220
+ `找不到渲染器: ${command}\n请在 venv 里安装:${venvBinary(config, 'pip')} install manimgx`,
221
+ )
222
+ }
223
+
224
+ const times = normalizeTimes(args.times)
225
+ const out = args.out ? resolve(args.out) : null
226
+ const argv = ['inspect', file, ...(args.scene ? [args.scene] : [])]
227
+ if (times.length) argv.push('-t', times.join(','))
228
+ if (out) argv.push('-o', out)
229
+
230
+ const binDir = join(venv, process.platform === 'win32' ? 'Scripts' : 'bin')
231
+ const started = Date.now()
232
+ let stdout = ''
233
+ try {
234
+ ({ stdout } = await exec(command, argv, {
235
+ timeout: 300_000,
236
+ maxBuffer: 8 * 1024 * 1024,
237
+ cwd: dirname(file),
238
+ env: {
239
+ ...process.env,
240
+ PATH: `${binDir}${process.platform === 'win32' ? ';' : ':'}${process.env.PATH ?? ''}`,
241
+ },
242
+ }))
243
+ } catch (error) {
244
+ // 有 layout 问题时 manimgx 返回 1,stdout 仍然是完整报告。
245
+ if (error.code !== 1 || !error.stdout) throw error
246
+ stdout = error.stdout
247
+ }
248
+
249
+ const parsed = parseInspectOutput(stdout)
250
+ return {
251
+ file,
252
+ mode: 'frames',
253
+ requestedTimes: times,
254
+ ...parsed,
255
+ wallSec: Number(((Date.now() - started) / 1000).toFixed(1)),
256
+ }
257
+ }
258
+
259
+ /**
260
+ * 空镜判据:yuv420p limited range 黑=16、白=235,用接近 0/255 的阈值永远判不出来。
261
+ * 看内容范围而不是平均值:全黑帧 YMAX 仍在 16 附近,全白帧 YMIN 在 235 附近。
262
+ */
263
+ export function isBlankFrame(frame) {
264
+ if (Number.isFinite(frame.ymax) && frame.ymax < 20) return true
265
+ if (Number.isFinite(frame.ymin) && frame.ymin > 230) return true
266
+ return false
267
+ }
268
+
140
269
  /** 抽帧自检:等间隔取 n 帧拼 contact sheet,并报告全黑/过曝帧。 */
141
270
  export async function probeVideo(config, args) {
142
271
  const video = resolve(args.video)
@@ -146,29 +275,56 @@ export async function probeVideo(config, args) {
146
275
  const ffmpeg = binaryOf(config, 'ffmpeg')
147
276
 
148
277
  const duration = await readDuration(config, video)
149
- const step = Math.max(1, Math.floor((duration * 30) / n))
278
+ // 用 fps 滤镜按整段时长重采样:与源帧率无关,60 fps 的成片也不会只覆盖前半段
279
+ const rate = (n / Math.max(duration, 0.001)).toFixed(6)
150
280
  await exec(ffmpeg, [
151
281
  '-y', '-loglevel', 'error', '-i', video,
152
- '-vf', `select='not(mod(n\\,${step}))',scale=270:-2,tile=4x3:margin=6:padding=4:color=0x222222`,
282
+ '-vf', `fps=${rate},scale=270:-2,tile=${tileGrid(n)}:margin=6:padding=4:color=0x222222`,
153
283
  '-frames:v', '1', sheet,
154
284
  ], { timeout: 120_000 })
155
285
 
156
286
  const { stdout } = await exec(ffmpeg, [
157
- '-hide_banner', '-i', video, '-vf', 'signalstats,metadata=print:key=lavfi.signalstats.YAVG:file=-',
287
+ '-hide_banner', '-i', video, '-vf', 'signalstats,metadata=print:file=-',
158
288
  '-f', 'null', '-',
159
- ], { timeout: 120_000, maxBuffer: 32 * 1024 * 1024 })
160
- const avgs = [...stdout.matchAll(/YAVG=([\d.]+)/g)].map((m) => Number(m[1]))
161
- const blank = avgs.map((v, i) => ({ frame: i, yavg: v })).filter((x) => x.yavg < 8 || x.yavg > 250)
289
+ ], { timeout: 120_000, maxBuffer: 64 * 1024 * 1024 })
290
+ const frames = parseSignalStats(stdout)
291
+ const blank = frames
292
+ .filter(isBlankFrame)
293
+ .map(({ frame, yavg, ymin, ymax }) => ({ frame, yavg, ymin, ymax }))
162
294
 
163
295
  return {
164
296
  video,
165
297
  durationSec: duration,
166
- framesAnalyzed: avgs.length,
298
+ framesAnalyzed: frames.length,
299
+ // 只回前 8 条明细,但总数要能看到——否则 300 个空镜和 3 个空镜长得一样
300
+ blankCount: blank.length,
167
301
  contactSheet: sheet,
168
302
  blankFrames: blank.slice(0, 8),
169
303
  }
170
304
  }
171
305
 
306
+ /** 用 ffprobe 重新读成片参数(post 之后帧率、时长、体积都可能与中间产物不同)。 */
307
+ export async function probeOutput(config, file) {
308
+ const { stdout } = await exec(binaryOf(config, 'ffprobe'), [
309
+ '-v', 'error', '-select_streams', 'v:0',
310
+ '-show_entries', 'stream=width,height,r_frame_rate:format=duration,size',
311
+ '-of', 'json', file,
312
+ ], { timeout: 60_000, maxBuffer: 1024 * 1024 })
313
+ const data = JSON.parse(stdout)
314
+ const stream = data.streams?.[0] ?? {}
315
+ const [num, den = '1'] = String(stream.r_frame_rate ?? '0/1').split('/')
316
+ const rate = Number(num) / Number(den)
317
+ const duration = Number(data.format?.duration)
318
+ const size = Number(data.format?.size)
319
+ return {
320
+ durationSec: Number.isFinite(duration) ? Math.round(duration * 100) / 100 : null,
321
+ width: stream.width ?? null,
322
+ height: stream.height ?? null,
323
+ fps: Number.isFinite(rate) && rate > 0 ? Math.round(rate * 100) / 100 : null,
324
+ megabytes: Number.isFinite(size) ? Math.round(size / 1e5) / 10 : null,
325
+ }
326
+ }
327
+
172
328
  async function readDuration(config, video) {
173
329
  try {
174
330
  const { stdout } = await exec(binaryOf(config, 'ffprobe'), [
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-motion",
3
- "version": "0.1.1",
4
- "description": "Code-generated math & science animations for DeepSeek Harness: render Manim clips, probe frames, verify formulas.",
3
+ "version": "0.3.0",
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",
7
7
  "engines": {
@@ -15,6 +15,8 @@
15
15
  "files": [
16
16
  "lib",
17
17
  "skills",
18
+ "styles",
19
+ "docs",
18
20
  "cordis.patch.yml",
19
21
  "README.md",
20
22
  "LICENSE",
@@ -36,9 +38,14 @@
36
38
  },
37
39
  "scripts": {
38
40
  "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",
39
42
  "smoke": "node scripts/smoke.mjs",
43
+ "dev": "node scripts/dev.mjs",
44
+ "route": "node scripts/route-style.mjs",
45
+ "styles:index": "node scripts/build-style-index.mjs",
46
+ "styles:check": "node scripts/build-style-index.mjs --check",
40
47
  "check:meta": "node scripts/check-meta.mjs",
41
- "prepack": "npm run check && npm run smoke"
48
+ "prepack": "npm run check && npm test && npm run smoke"
42
49
  },
43
50
  "repository": {
44
51
  "type": "git",
@@ -12,7 +12,19 @@ description: 做代码动画/科普视频的总入口:先判题材(关系还
12
12
  | 数学讲解 | 一个**关系** | 公式、坐标、推导、表格 |
13
13
  | 科普叙事 | 一个**意象** | 画面、比喻、节奏、材质 |
14
14
 
15
- ## 2. 选画面层
15
+ ## 2. 选风格
16
+
17
+ 先读 `styles/registry.json`,再按顺序:
18
+
19
+ 1. **用户点名**("用 3b1b / 瑞士动态排版")→ 直接用,不再打分;
20
+ 2. 没有点名 → `node scripts/route-style.mjs "<题材>" --json`;
21
+ 3. `confidence: high` → 写进 `DESIGN.md` 的 `style:`;
22
+ 4. `medium` / `low` / `needsConfirmation: true` → 给用户 top 2–3 和理由,等确认;
23
+ 5. 完全不命中 → 默认 `swiss-motion`,并说明这是默认。
24
+
25
+ 不要用"感觉像"选风格;理由是 registry 的关键词/uses 命中,不是形容词。
26
+
27
+ ## 3. 选画面层
16
28
 
17
29
  | 画面层 | 适合 | 不用它的理由 |
18
30
  |---|---|---|
@@ -20,21 +32,27 @@ description: 做代码动画/科普视频的总入口:先判题材(关系还
20
32
  | Canvas 2D 逐帧 | 光、影、粒子、材质、转场 | 文字排版成本高 |
21
33
  | HTML / SVG | 排版、图文、简单图示 | 做不出辉光与颗粒 |
22
34
 
23
- 三者都只是素材层;**交付统一在 HyperFrames 工程里完成**。
35
+ 三者都只是素材层;**需要交付时**再统一进 HyperFrames 工程,默认的极简模式不经过 HF。
24
36
 
25
- ## 3. 固定顺序
37
+ ## 4. 固定顺序(默认极简,先帧后片)
26
38
 
27
39
  ```
28
- 1. DESIGN.md(design spec) ← 先定颜色/字体/禁用项
29
- 2. 分镜表(storyboard) ← 拍/秒/画面/旁白/验收点,先给用户确认
30
- 3. 画面
31
- 4. hyperframes check ← 0 error 才算过
32
- 5. motion_probe ← 抽 12 帧看空镜/叠字
33
- 6. 旁白 + 字幕 + −14 LUFS
40
+ 1. DESIGN.md(design spec) ← 先定颜色/字体/禁用项/参考
41
+ 2. formula_check(数学题材) ← 每条上屏公式先验
42
+ 3. 画面 + motion_frames ← 默认:按秒出 3–6 帧,看帧改,不渲染视频
43
+ 4. 分镜表(仅用户要时) ← 关键镜头,用真实代码出帧,等确认
44
+ 5. motion_render ← 定稿后才编码 mp4
45
+ 6. HyperFrames composition ← 字幕/旁白/混音/排版(需要时才进)
46
+ 7. hyperframes check / motion_probe ← 只检查已经生成的成片
34
47
  ```
35
48
 
36
- ## 4. 三条硬规则
49
+ **默认不使用 HyperFrames**:只有需要字幕、旁白、混音、复杂图文编排时才进第 6 步。
50
+ `motion_frames` 内部是 `manimgx inspect`,只画请求的时刻并做版式检查,几秒出图;
51
+ `motion_probe` 是给已有 mp4 做体检的,不要用它替代看帧。
52
+
53
+ ## 5. 四条硬规则
37
54
 
38
55
  1. **没有 DESIGN.md 或参考语法,不开工**——否则每次都是同一个默认 look;
39
- 2. **字幕放在 composition 层**,不烧进 Manim 或 canvas;
40
- 3. **公式先过 `formula_check`**,再进画面。
56
+ 2. **默认先帧后片**:先用 `motion_frames` 把画面改对,再决定要不要渲染;
57
+ 3. **字幕放在 composition 层**,不烧进 Manim 或 canvas;
58
+ 4. **公式先过 `formula_check`**,再进画面。
@@ -19,6 +19,10 @@ HyperFrames 的 look 文档原话:**不给外部输入,相同的 brief 每
19
19
  | 4 | 主题自身的世界 | ✅ 年代、材质、展示场景 |
20
20
  | 5 | 官方预设(preset) | ⚠️ 幻灯片语言,仅点名或做 deck 时用 |
21
21
 
22
+ ## 先路由
23
+
24
+ 写 spec 之前先确定风格:`node scripts/route-style.mjs "<题材>"`。点名风格优先;没有点名就按 `styles/registry.json` 打分;`high` 才直接写,`medium`/`low` 先给用户 top 2–3 确认。
25
+
22
26
  ## 最小形态
23
27
 
24
28
  ```markdown
@@ -52,10 +56,38 @@ spacing: { margin: 140, rule: 2 }
52
56
  - 具体到某类图版、某支片、某个年代的印刷品
53
57
  ```
54
58
 
55
- ## 三种已验证的语言
59
+ ## 先出三张风格帧
60
+
61
+ DESIGN.md 写完不等于风格成立。挑 3 个关键时刻(开场、转折、结论),用**真实绘制代码**出帧,对照 spec 检查:
62
+
63
+ - 用 `motion_frames` 直接出帧,不渲染视频;
64
+ - 三张里至少一张是 signature shot;
65
+ - 两轮还不对,就换更接近的参考语法,而不是继续调参。
66
+
67
+ ## 八种已验证的语言
56
68
 
57
69
  | 语言 | 适合 | 关键手段 |
58
70
  |---|---|---|
59
71
  | 纸感植物图版 | 生物 / 博物 | 纸纤维、水洗叠色、墨线、引线标注 |
60
72
  | 暗场发光 | 物理 / 天文 / 微观 | 近黑底、细线辉光、粒子、颗粒 + 暗角 |
61
73
  | 编辑排版 | 数学 / 数据 / 概念 | 网格、字阶、单一强调色 |
74
+ | 瑞士动态排版 | 公式 / 概念 | 12 栏网格、单一信号色、精确缓动 |
75
+ | 数据叙事 | 数据 / 科学 | 图表操作即转场、注释钉在数据上、蓝红语义 |
76
+ | 蓝图工程制图 | 几何 / 工程 | 青晒纸、白线、制图顺序、一次红印章 |
77
+ | 白板讲解 | 科学 / 课程 | 暖白板、三色马克笔、书写顺序 |
78
+ | 3b1b 暗场数学 | 线代 / 微积分 | 近黑底、蓝黄语义、先直觉后公式 |
79
+
80
+ 本仓库现有五套,除 `3b1b` 外均改编自 [Lemo-Opuscar](https://github.com/lemomo-ai/lemo-opuscar)(MIT):
81
+
82
+ - [`styles/swiss-motion/`](../../styles/swiss-motion/):公式/排版,默认;
83
+ - [`styles/dataviz/`](../../styles/dataviz/):图表/数据;
84
+ - [`styles/blueprint/`](../../styles/blueprint/):几何/工程;
85
+ - [`styles/whiteboard/`](../../styles/whiteboard/):科学/白板讲解;
86
+ - [`styles/3b1b/`](../../styles/3b1b/):暗场数学(Manim 社区 3b1b skill)。
87
+
88
+ 规范在 `STYLE.md`,数值在 `tokens.json`,`theme.py` 提供 `apply_config()` 与 `backdrop()`。
89
+ 项目 `DESIGN.md` 里写 `style: swiss-motion`(或 `dataviz` / `blueprint` / `whiteboard` / `3b1b`)与 `style_version: 1.0.0`。
90
+
91
+ 参考与示例统一从 HyperFrames 取:[examples](https://hyperframes.heygen.com/examples)、
92
+ [catalog](https://hyperframes.heygen.com/catalog)、[frame.md](https://hyperframes.heygen.com/prompting/design-systems)。
93
+ 本仓库不再内置分集/样片。
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: qa-rubric
3
- description: 代码动画交付前的自检清单:环境、公式、工程、画面、音频、字幕。渲染完但还没交付时使用。
3
+ description: 代码动画的自检清单:环境、公式、画面、音频、字幕。默认先看帧(motion_frames),有成片时才查视频。
4
4
  ---
5
5
 
6
6
  # 交付前自检
@@ -14,28 +14,34 @@ description: 代码动画交付前的自检清单:环境、公式、工程、
14
14
  - 所有上屏公式先过 `formula_check`;
15
15
  - 逐条对照代码与画面,确认没有手改过的常数。
16
16
 
17
- ## 3. 工程
17
+ ## 3. 工程(仅当用了 HyperFrames)
18
18
 
19
19
  ```bash
20
20
  npx hyperframes check
21
21
  ```
22
22
 
23
23
  - 0 error 才算过;
24
- - 关注 lint(缺 `@font-face`)、layout(文字裁切/重叠)、contrast(对比度 < 3:1)。
24
+ - 关注 lint(缺 `@font-face`)、layout(文字裁切/重叠)、contrast(对比度 < 3:1);
25
+ - 没进 HF 的极简流程跳过本节。
25
26
 
26
27
  ## 4. 画面
27
28
 
28
- ```bash
29
- motion_probe --video <mp4>
29
+ **先帧后片**:场景阶段用 `motion_frames`,直接看 manimgx 画出的帧,不等视频。
30
+
31
+ ```jsonc
32
+ // motion_frames
33
+ { "file": "/abs/path/scene.py", "times": ["1.2", "4.5", "end"] }
30
34
  ```
31
35
 
32
- - 看 contact sheet:无叠字、无出画、无空白帧;
33
- - `blankFrames` 里的帧逐一看过再决定是否重渲。
36
+ - 返回的 storyboard PNG 逐张看过:无叠字、无出画、字号可读;
37
+ - `layoutProblems` 每一条都要处理或明确接受;
38
+ - 已经生成了 mp4 时,才用 `motion_probe` 抽 contact sheet + 查 `blankFrames`;
39
+ - 用了 HyperFrames 的项目,用 `npx hyperframes snapshot <project> --at 2.9,10.4` 抽帧,不要先 render 再截图。
34
40
 
35
41
  ## 5. 音频
36
42
 
37
43
  - 旁白最后一字早于结论画面;
38
- - 响度 ≈ −14 LUFS(±1),真峰值 ≤ −1.5 dBTP;
44
+ - 响度 ≈ −14 LUFS(±1),真峰值 ≤ −1 dBTP;具体以该片 STYLE.md 为准;
39
45
  - 没有爆音、没有截断。
40
46
 
41
47
  ## 6. 字幕
@@ -0,0 +1,68 @@
1
+ ---
2
+ name: 3b1b
3
+ version: 1.0.0
4
+ source: https://github.com/subinium/3b1b-style-animation-skill
5
+ license: MIT(上游风格 skill);非 3Blue1Brown 官方项目
6
+ status: adapted
7
+ ---
8
+
9
+ # 3Blue1Brown Style · 3b1b
10
+
11
+ > 配色表取自 [subinium/3b1b-style-animation-skill](https://github.com/subinium/3b1b-style-animation-skill)(MIT);
12
+ > 教学法与动画模式参考 [adithya-s-k/manim_skill](https://github.com/adithya-s-k/manim_skill)(MIT,约 1.2k stars,是这类 skill 里用得最多的)。
13
+ > 本文件是改编,不是原样复制;只保留不变量,实现换成 manimgx。
14
+
15
+ ## 1. 定位
16
+
17
+ 先建立直觉,再给公式。画面为“暗场 + 蓝黄高亮”的数学解释:
18
+
19
+ - 适合:线性代数、微积分、概率、神经网络、算法;
20
+ - 不适合:没有“为什么”的产品宣传、角色叙事。
21
+
22
+ ## 2. 不变量
23
+
24
+ | 项 | 规则 |
25
+ |---|---|
26
+ | 底色 | `#1c1c1c`(近黑灰,**不用纯黑**) |
27
+ | 主色 | 蓝 `#3b82f6`:主对象、向量、节点 |
28
+ | 强调 | 黄 `#fbbf24`:高亮、结论、公式 |
29
+ | 语义色 | 绿 `#22c55e` 正 / 红 `#ef4444` 负 / 灰 `#9ca3af` 非活动 |
30
+ | 公式 | 黄色 `#fde047` 或白色;一屏最多 4–5 种颜色 |
31
+ | 字体 | 无衬线(Noto Sans);公式 New Computer Modern |
32
+ | 运动 | `Create` / `FadeIn` / `Transform`;相关元素**同时**出现(`LaggedStartMap` 式级联),不要一个个 `play()` |
33
+ | 收尾 | 结尾至少 `wait(2)`,给结论停留 |
34
+
35
+ ## 3. 教学法
36
+
37
+ 1. **Hook**:先问一个有意思的问题;
38
+ 2. **Intuition**:用图形建立直觉,先不写公式;
39
+ 3. **Concrete**:用一个具体数字走一遍;
40
+ 4. **Formalization**:最后才给公式/命名。
41
+
42
+ ## 4. manimgx 映射
43
+
44
+ - `theme.py`:`apply_config()` 设 `#1c1c1c`、`backdrop()` 返回平涂底;
45
+ - 用 `Axes`、`Dot`、`Arrow`、`Line`、`always_redraw` 做旋转/投影/变换;
46
+ - 用 `MathTex` 排版公式,`Text` 做标题与注释;
47
+ - manimgx 的 `Write` 已经足够接近 Manim 的 `Write`;不需要 LaTeX 环境。
48
+
49
+ ## 5. Do / Don't
50
+
51
+ **Do**
52
+
53
+ - 先画“正在发生什么”,再写“它叫什么”;
54
+ - 蓝=对象,黄=高亮/结论;颜色有语义;
55
+ - 一次只讲一个变换;结尾停 2 秒。
56
+
57
+ **Don't**
58
+
59
+ - 不用纯黑 `#000000`;
60
+ - 不用高饱和纯红/纯绿/纯蓝;
61
+ - 不在建立直觉前抛公式;
62
+ - 不把每个元素拆成单独的 `play()`。
63
+
64
+ ## 6. 与上游的差异
65
+
66
+ - 上游是 ManimCE/ManimGL skill,包含 40+ 条规则;这里只抽取配色、运动与教学法;
67
+ - 上游使用 Manim 的 `LaggedStartMap` 等 API;manimgx 用 `AnimationGroup`/`LaggedStart` 近似;
68
+ - 不是 3Blue1Brown 官方风格指南,只是社区通行做法的整理。
Binary file
@@ -0,0 +1,50 @@
1
+ {
2
+ "slug": "3b1b",
3
+ "en": "3Blue1Brown Style",
4
+ "cn": "3b1b 数学动画",
5
+ "category_en": "Math explainer",
6
+ "category_cn": "数学讲解",
7
+ "uses": [
8
+ "Linear algebra",
9
+ "Calculus",
10
+ "Probability",
11
+ "Neural networks"
12
+ ],
13
+ "source": "https://github.com/subinium/3b1b-style-animation-skill",
14
+ "secondary_source": "https://github.com/adithya-s-k/manim_skill",
15
+ "license": "MIT (style skills); unofficial tribute",
16
+ "implementation": "manimgx (theme.py + tokens.json)",
17
+ "num": "05",
18
+ "film": "Rotation on the Unit Circle",
19
+ "line": "A rotation on the unit circle that lands on e^{iθ} = cosθ + i sinθ.",
20
+ "line_cn": "单位圆上的一次旋转,收束到 e^{iθ} = cosθ + i sinθ。",
21
+ "uses_cn": [
22
+ "线代",
23
+ "微积分",
24
+ "概率",
25
+ "神经网络"
26
+ ],
27
+ "keywords": [
28
+ "向量",
29
+ "矩阵",
30
+ "特征值",
31
+ "旋转",
32
+ "复数",
33
+ "单位圆",
34
+ "线代",
35
+ "概率",
36
+ "微积分",
37
+ "神经网络"
38
+ ],
39
+ "avoid": [
40
+ "产品发布",
41
+ "纯排版"
42
+ ],
43
+ "demo": "showcase/3b1b-unit-circle",
44
+ "demo_duration_sec": 10.3,
45
+ "aspect": [
46
+ "16:9"
47
+ ],
48
+ "fps": 30,
49
+ "complexity": "medium"
50
+ }