dsh-motion 0.1.1 → 0.2.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.
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.2.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,11 @@
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",
39
42
  "smoke": "node scripts/smoke.mjs",
43
+ "dev": "node scripts/dev.mjs",
40
44
  "check:meta": "node scripts/check-meta.mjs",
41
- "prepack": "npm run check && npm run smoke"
45
+ "prepack": "npm run check && npm test && npm run smoke"
42
46
  },
43
47
  "repository": {
44
48
  "type": "git",
@@ -20,21 +20,27 @@ description: 做代码动画/科普视频的总入口:先判题材(关系还
20
20
  | Canvas 2D 逐帧 | 光、影、粒子、材质、转场 | 文字排版成本高 |
21
21
  | HTML / SVG | 排版、图文、简单图示 | 做不出辉光与颗粒 |
22
22
 
23
- 三者都只是素材层;**交付统一在 HyperFrames 工程里完成**。
23
+ 三者都只是素材层;**需要交付时**再统一进 HyperFrames 工程,默认的极简模式不经过 HF。
24
24
 
25
- ## 3. 固定顺序
25
+ ## 3. 固定顺序(默认极简,先帧后片)
26
26
 
27
27
  ```
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
28
+ 1. DESIGN.md(design spec) ← 先定颜色/字体/禁用项/参考
29
+ 2. formula_check(数学题材) ← 每条上屏公式先验
30
+ 3. 画面 + motion_frames ← 默认:按秒出 3–6 帧,看帧改,不渲染视频
31
+ 4. 分镜表(仅用户要时) ← 关键镜头,用真实代码出帧,等确认
32
+ 5. motion_render ← 定稿后才编码 mp4
33
+ 6. HyperFrames composition ← 字幕/旁白/混音/排版(需要时才进)
34
+ 7. hyperframes check / motion_probe ← 只检查已经生成的成片
34
35
  ```
35
36
 
36
- ## 4. 三条硬规则
37
+ **默认不使用 HyperFrames**:只有需要字幕、旁白、混音、复杂图文编排时才进第 6 步。
38
+ `motion_frames` 内部是 `manimgx inspect`,只画请求的时刻并做版式检查,几秒出图;
39
+ `motion_probe` 是给已有 mp4 做体检的,不要用它替代看帧。
40
+
41
+ ## 4. 四条硬规则
37
42
 
38
43
  1. **没有 DESIGN.md 或参考语法,不开工**——否则每次都是同一个默认 look;
39
- 2. **字幕放在 composition 层**,不烧进 Manim 或 canvas;
40
- 3. **公式先过 `formula_check`**,再进画面。
44
+ 2. **默认先帧后片**:先用 `motion_frames` 把画面改对,再决定要不要渲染;
45
+ 3. **字幕放在 composition 层**,不烧进 Manim 或 canvas;
46
+ 4. **公式先过 `formula_check`**,再进画面。
@@ -52,10 +52,34 @@ spacing: { margin: 140, rule: 2 }
52
52
  - 具体到某类图版、某支片、某个年代的印刷品
53
53
  ```
54
54
 
55
- ## 三种已验证的语言
55
+ ## 先出三张风格帧
56
+
57
+ DESIGN.md 写完不等于风格成立。挑 3 个关键时刻(开场、转折、结论),用**真实绘制代码**出帧,对照 spec 检查:
58
+
59
+ - 用 `motion_frames` 直接出帧,不渲染视频;
60
+ - 三张里至少一张是 signature shot;
61
+ - 两轮还不对,就换更接近的参考语法,而不是继续调参。
62
+
63
+ ## 六种已验证的语言
56
64
 
57
65
  | 语言 | 适合 | 关键手段 |
58
66
  |---|---|---|
59
67
  | 纸感植物图版 | 生物 / 博物 | 纸纤维、水洗叠色、墨线、引线标注 |
60
68
  | 暗场发光 | 物理 / 天文 / 微观 | 近黑底、细线辉光、粒子、颗粒 + 暗角 |
61
69
  | 编辑排版 | 数学 / 数据 / 概念 | 网格、字阶、单一强调色 |
70
+ | 瑞士动态排版 | 公式 / 概念 | 12 栏网格、单一信号色、精确缓动 |
71
+ | 数据叙事 | 数据 / 科学 | 图表操作即转场、注释钉在数据上、蓝红语义 |
72
+ | 蓝图工程制图 | 几何 / 工程 | 青晒纸、白线、制图顺序、一次红印章 |
73
+
74
+ 本仓库现有三套,全部改编自 [Lemo-Opuscar](https://github.com/lemomo-ai/lemo-opuscar)(MIT):
75
+
76
+ - [`styles/swiss-motion/`](../../styles/swiss-motion/):公式/排版,默认;
77
+ - [`styles/dataviz/`](../../styles/dataviz/):图表/数据;
78
+ - [`styles/blueprint/`](../../styles/blueprint/):几何/工程。
79
+
80
+ 规范在 `STYLE.md`,数值在 `tokens.json`,`theme.py` 提供 `apply_config()` 与 `backdrop()`。
81
+ 项目 `DESIGN.md` 里写 `style: swiss-motion`(或 `dataviz` / `blueprint`)与 `style_version: 1.0.0`。
82
+
83
+ 参考与示例统一从 HyperFrames 取:[examples](https://hyperframes.heygen.com/examples)、
84
+ [catalog](https://hyperframes.heygen.com/catalog)、[frame.md](https://hyperframes.heygen.com/prompting/design-systems)。
85
+ 本仓库不再内置分集/样片。
@@ -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,23 @@
1
+ # 风格库
2
+
3
+ 可复用的视觉语言。当前有 3 套,全部**改编自 Lemo-Opuscar** 的公开风格(MIT),不是自创:
4
+
5
+ | 风格 | 适合 | 来源 | 入口 |
6
+ |---|---|---|---|
7
+ | `swiss-motion` / 瑞士动态排版(默认) | 公式、定义、数据结论 | [Lemo](https://github.com/lemomo-ai/lemo-opuscar/tree/main/styles/swiss-motion) | [STYLE](swiss-motion/STYLE.md) · [tokens](swiss-motion/tokens.json) · [theme](swiss-motion/theme.py) |
8
+ | `dataviz` / 数据叙事 | 趋势、比较、分布、科学数据 | [Lemo](https://github.com/lemomo-ai/lemo-opuscar/tree/main/styles/dataviz) | [STYLE](dataviz/STYLE.md) · [tokens](dataviz/tokens.json) · [theme](dataviz/theme.py) |
9
+ | `blueprint` / 蓝图工程制图 | 几何证明、结构、尺寸关系 | [Lemo](https://github.com/lemomo-ai/lemo-opuscar/tree/main/styles/blueprint) | [STYLE](blueprint/STYLE.md) · [tokens](blueprint/tokens.json) · [theme](blueprint/theme.py) |
10
+
11
+ ## 约定
12
+
13
+ - 每个风格目录:`style.json`(元数据)+ `STYLE.md`(不变量)+ `tokens.json` + `theme.py` + `preview.jpg`;
14
+ - `STYLE.md` 是**改编**,必须在上游基础上写清楚差异;不复制 demo 代码;
15
+ - 颜色、字体、时间表、音频以 `tokens.json` 为准;版面坐标与组件配方写在 `STYLE.md`;
16
+ - 新片在自己的项目目录写 `DESIGN.md`,头部 `style: swiss-motion` + `style_version: 1.0.0`;
17
+ - 参考与示例统一用 HyperFrames 的 [examples](https://hyperframes.heygen.com/examples) / [catalog](https://hyperframes.heygen.com/catalog),不在仓库里放样片(`showcase/` 只放可复现的演示)。
18
+
19
+ ## 许可
20
+
21
+ - 上游风格文档:MIT,署名 LemoLab;
22
+ - 上游 demo 静帧:CC BY 4.0,商用需署名;
23
+ - 本仓库的 `theme.py` / `tokens.json` / 场景代码:MIT。
@@ -0,0 +1,59 @@
1
+ ---
2
+ name: blueprint
3
+ version: 1.0.0
4
+ source: https://github.com/lemomo-ai/lemo-opuscar/tree/main/styles/blueprint
5
+ license: MIT(上游风格文档);demo 静帧 CC BY 4.0(LemoLab)
6
+ status: adapted
7
+ ---
8
+
9
+ # Blueprint · 蓝图 / 工程制图
10
+
11
+ > 上游:Lemo-Opuscar `styles/blueprint`(MIT)。保留青晒纸、白线、制图顺序与一次红色印章;
12
+ > 去掉了 WebGL 纸纹与 3D 投影。
13
+
14
+ ## 1. 定位
15
+
16
+ 工程图在青晒纸上活起来:普鲁士蓝上的白色技术线,按制图顺序画出,标注会动。
17
+
18
+ - 适合:几何证明、工程结构、尺寸关系、专利/发明叙事;
19
+ - 不适合:需要真实材质、角色表演、实拍的题材。
20
+
21
+ ## 2. 不变量
22
+
23
+ | 项 | 规则 |
24
+ |---|---|
25
+ | 桌 | 深色绘图桌 `#0D1116` |
26
+ | 纸 | 青晒蓝 `#18336B → #2A58A6`,可有轻微不均 |
27
+ | 线 | 白 `#E7F0F9`;四种线宽(轮廓/细节/细/发丝);虚线有制图含义 |
28
+ | 印章 | 全片只有一个非蓝颜色:红 `#B5442E`,只盖一次 |
29
+ | 字体 | 手写体注释(Chalkboard SE);技术字段(Menlo);公式 New Computer Modern |
30
+ | 运动 | draw-on:中心线 → 轮廓 → 细节 → 尺寸 → 标注;不做 3D 旋转、不做辉光 |
31
+ | 声音 | 纸张、钢笔、尺规、机械;−14 LUFS |
32
+
33
+ ## 3. manimgx 映射
34
+
35
+ - `theme.py`:`apply_config()`、`backdrop()`(青晒纸渐变 + 噪点)、`sheet()`、`title_block()`;
36
+ - 用 `Line`、`Polygon`、`DoubleArrow`、`Create` 实现制图与 draw-on;
37
+ - 上游的 3D 斜投影、纸纹 shader 没有实现;用 2D 制图语言近似;
38
+ - 字体替换:Architects Daughter → Chalkboard SE;B612 → Menlo。
39
+
40
+ ## 4. Do / Don't
41
+
42
+ **Do**
43
+
44
+ - 按制图顺序画:先中心线,再轮廓,最后尺寸与标注;
45
+ - 红色印章只出现一次,承担结论;
46
+ - 尺寸线、引出线、编号气球让画面“像图”;
47
+ - 标题块写图号/状态。
48
+
49
+ **Don't**
50
+
51
+ - 不做 3D 渲染、辉光、全息 HUD;
52
+ - 不用第二个非蓝颜色;
53
+ - 不让主线贴边;文字不压线。
54
+
55
+ ## 5. 与上游的差异
56
+
57
+ - 上游是 WebGL2 纸纹 + 3D 斜投影;这里是 2D 平涂 + 渐变;
58
+ - 上游 24 fps、30–60 秒;本实现 30 fps、7 秒量级;
59
+ - 上游手写字体不随包分发;这里用 Chalkboard SE / Menlo 近似。
Binary file
@@ -0,0 +1,11 @@
1
+ {
2
+ "slug": "blueprint",
3
+ "en": "Blueprint",
4
+ "cn": "蓝图 / 工程制图",
5
+ "category_en": "Graphic & Type",
6
+ "category_cn": "图形与排版",
7
+ "uses": ["Engineering", "Hardware launches", "Tech explainers"],
8
+ "source": "https://github.com/lemomo-ai/lemo-opuscar/tree/main/styles/blueprint",
9
+ "license": "style doc MIT; demo stills CC BY 4.0 (LemoLab)",
10
+ "implementation": "manimgx (theme.py + tokens.json)"
11
+ }
@@ -0,0 +1,65 @@
1
+ """blueprint 1.0.0 — Lemo-Opuscar 的 Blueprint,manimgx 实现层。
2
+
3
+ 上游规范(MIT):styles/blueprint/STYLE.md
4
+ 普鲁士蓝纸 + 白线 + 一次红色印章;不做 3D、不做辉光。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from pathlib import Path
11
+
12
+ import numpy as np
13
+
14
+ import manimgx as m
15
+
16
+ TOKENS = json.loads(Path(__file__).with_name("tokens.json").read_text(encoding="utf-8"))
17
+
18
+ TABLE = TOKENS["colors"]["table"]
19
+ PAPER = TOKENS["colors"]["paper"]
20
+ PAPER_LIGHT = TOKENS["colors"]["paper_light"]
21
+ LINE = TOKENS["colors"]["line"]
22
+ MUTED = TOKENS["colors"]["muted"]
23
+ STAMP = TOKENS["colors"]["stamp"]
24
+ LAYOUT = TOKENS["layout"]
25
+
26
+
27
+ def apply_config(width: int = 1920, height: int = 1080, fps: int = 30) -> None:
28
+ m.config.pixel_width = width
29
+ m.config.pixel_height = height
30
+ m.config.frame_rate = fps
31
+ m.config.background_color = TABLE
32
+
33
+
34
+ def backdrop(seed: int = 11) -> m.ImageMobject:
35
+ """青晒纸:两档普鲁士蓝 + 轻微噪点,压在深色绘图桌上。"""
36
+ w, h = m.config.pixel_width, m.config.pixel_height
37
+ yy, xx = np.mgrid[0:h, 0:w]
38
+ u = xx / (w - 1)
39
+ v = yy / (h - 1)
40
+ glow = np.exp(-(((u - 0.48) ** 2) / 0.16 + ((v - 0.44) ** 2) / 0.10))
41
+ dark = np.array([24, 51, 107], dtype=float)
42
+ light = np.array([42, 88, 166], dtype=float)
43
+ rgb = dark + glow[..., None] * (light - dark)
44
+ rng = np.random.default_rng(seed)
45
+ rgb = np.clip(rgb + rng.normal(0, 2.1, rgb.shape), 0, 255).astype(np.uint8)
46
+ img = m.ImageMobject(rgb, scale_to_resolution=min(w, h), resampling_algorithm=3)
47
+ return img.move_to(m.ORIGIN)
48
+
49
+
50
+ def sheet() -> m.Rectangle:
51
+ """纸边:一条细白线,标出图幅。"""
52
+ return m.Rectangle(
53
+ width=LAYOUT["frame"]["width"] - 0.9,
54
+ height=LAYOUT["frame"]["height"] - 0.9,
55
+ color=LINE,
56
+ stroke_width=1.2,
57
+ )
58
+
59
+
60
+ def title_block() -> m.VGroup:
61
+ x, y = LAYOUT["title_block"]["x"], LAYOUT["title_block"]["y"]
62
+ box = m.Rectangle(width=3.3, height=1.15, color=LINE, stroke_width=1.0).move_to([x, y, 0])
63
+ name = m.Text("DRAWING 001", font="Menlo", font_size=21, color=LINE).move_to([x - 0.35, y + 0.27, 0])
64
+ status = m.Text("STATUS: VERIFIED", font="Menlo", font_size=20, color=MUTED).move_to([x - 0.05, y - 0.27, 0])
65
+ return m.VGroup(box, name, status)