dsh-motion 0.4.1 → 0.4.3

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
@@ -13,7 +13,7 @@
13
13
  | 工具 | 作用 |
14
14
  |---|---|
15
15
  | `motion_frames` | **极简模式(默认)**:直接从场景出帧,不生成视频、不用 HF;几秒拿到 storyboard PNG 与 layout 问题 |
16
- | `motion_doctor` | 环境自检(Node / FFmpeg / Python venv / manimgx / sympy / Chrome / HyperFrames) |
16
+ | `motion_doctor` | **环境预检(一次调用)**:运行时 + 中文字体 + 公式转换 + 常用 API 签名;按指纹缓存 12 h |
17
17
  | `motion_render` | 渲染 Manim / manimgx 场景;未通过 `APPROVALS.json` 确认门的项目会拒绝渲染 |
18
18
  | `motion_probe` | 成片抽帧拼 contact sheet,报告全黑 / 过曝的空镜 |
19
19
  | `formula_check` | 用 SymPy 判断两个表达式是否恒等 |
@@ -99,12 +99,14 @@ npm install -g dsh-motion
99
99
  # 或在 DSH 插件管理器里安装 dsh-motion
100
100
  ```
101
101
 
102
- 安装后重启 DeepSeek Harness,然后先跑一次自检:
102
+ 安装后重启 DeepSeek Harness,然后先跑一次预检:
103
103
 
104
104
  ```
105
105
  motion_doctor
106
106
  ```
107
107
 
108
+ 它把运行时、中文字体、公式转换和常用 API 签名一次带回(按机器指纹缓存 12 小时,重复调用几乎不耗时)。
109
+
108
110
  ## 依赖
109
111
 
110
112
  | 依赖 | 是否必需 | 说明 |
@@ -45,8 +45,9 @@
45
45
 
46
46
  已完成:
47
47
 
48
- - [x] `motion_frames` 按场景 fps 取帧:各风格 `theme.py` 在模块层设置 `m.config.frame_rate`;
49
- - [x] post 强制 CFR,并校验成片时长(`tpad + CFR` 修正后复验);
48
+ - [x] 环境预检合并为一次 `motion_doctor`:字体 + 公式转换 + API 签名进同一个 Python 进程,整份报告按指纹缓存 12 h(冷 0.5 s / 热 4 ms)。旧流程是 4–5 条独立 bash,光模型往返就 40–60 s;
49
+ - [x] 格式契约:`apply_config()` 只设背景色,不再覆盖分辨率/帧率;`motion_render` 的 `width/height/fps` 与 CLI 的 `-r/--fps` 生效;
50
+ - [x] CFR:post 强制 CFR;所有成片按 `nb_read_frames` 对比期望帧数,少帧时用 `tpad + CFR` 补齐并复验;
50
51
  - [x] storyboard 顶部 caption 条加提示,避免误判“裁切”。
51
52
 
52
53
  明确不做:
@@ -67,7 +68,8 @@
67
68
 
68
69
  ```text
69
70
  first_frame_s, total_s, model_s, tool_s, render_s,
70
- output_tokens, context_peak, image_reads, full_rewrites, probes, renders
71
+ output_tokens, context_peak, image_reads, full_rewrites, probes, renders,
72
+ preflight_calls(目标 1)
71
73
  ```
72
74
 
73
75
  复测时用同一台机器、同一支 30 s 片,先对比“首帧”和“输出 tokens”,再看总时长。
package/lib/cache.js ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * 极简 JSON 缓存:文件指纹一致且未过期才算命中。
3
+ *
4
+ * 只用于「重跑一次也不该花掉一个回合」的东西(环境预检)。
5
+ * 指纹里带 mtime + size,所以升级 manimgx、改探针脚本、换 venv 都会自动失效。
6
+ */
7
+
8
+ import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
9
+ import { dirname } from 'node:path'
10
+
11
+ /** 文件指纹;不存在时是 'missing',不会被误当成命中。 */
12
+ export function statSig(file) {
13
+ try {
14
+ const stat = statSync(file)
15
+ return `${stat.mtimeMs.toFixed(0)}:${stat.size}`
16
+ } catch {
17
+ return 'missing'
18
+ }
19
+ }
20
+
21
+ /** 目录指纹:只取 mtime,够用来发现「包被升级/重装」。 */
22
+ export function dirSig(dir) {
23
+ try {
24
+ return `dir:${statSync(dir).mtimeMs.toFixed(0)}`
25
+ } catch {
26
+ return 'missing'
27
+ }
28
+ }
29
+
30
+ export function readCache(file, fingerprint, ttlMs) {
31
+ let raw
32
+ try {
33
+ raw = JSON.parse(readFileSync(file, 'utf8'))
34
+ } catch {
35
+ return null
36
+ }
37
+ if (!raw || typeof raw !== 'object') return null
38
+ if (JSON.stringify(raw.fingerprint) !== JSON.stringify(fingerprint)) return null
39
+ const ageMs = Date.now() - Number(raw.at ?? 0)
40
+ // TTL 是上界:ttlMs=0 表示永不用缓存,不能靠「写读之间过了 1ms」才失效。
41
+ if (!Number.isFinite(ageMs) || ageMs < 0 || ageMs >= ttlMs) return null
42
+ return { value: raw.value, ageMs }
43
+ }
44
+
45
+ /** 写入成功返回路径;被沙箱/权限挡住时返回 null(调用方不该因此失败)。 */
46
+ export function writeCache(file, fingerprint, value) {
47
+ try {
48
+ mkdirSync(dirname(file), { recursive: true })
49
+ writeFileSync(file, JSON.stringify({ fingerprint, at: Date.now(), value }), 'utf8')
50
+ return file
51
+ } catch {
52
+ return null
53
+ }
54
+ }
package/lib/doctor.js CHANGED
@@ -1,10 +1,15 @@
1
1
  /**
2
- * 环境自检:装完插件先跑这个,别等渲染时报错。
3
- * 只读,不改任何东西;返回结构化表格 + 修法。
2
+ * 环境自检:装完插件、或渲染报错时跑这个。
3
+ *
4
+ * 一次调用给全:Node / FFmpeg / venv / manimgx / sympy / Chrome / HyperFrames
5
+ * + 中文字体 + 公式转换 + 常用 API 签名(后三项在 probe_env.py 里,一个进程)。
6
+ *
7
+ * 结果按文件指纹缓存(默认 12 h):同一台机器第二次调用不再 spawn python。
8
+ * 需要强制重测:MOTION_PREFLIGHT=force,或 config.preflightForce = true。
4
9
  */
5
10
 
6
11
  import { execFile } from 'node:child_process'
7
- import { existsSync, readFileSync } from 'node:fs'
12
+ import { readFileSync } from 'node:fs'
8
13
  import { dirname, join, sep } from 'node:path'
9
14
  import { fileURLToPath } from 'node:url'
10
15
  import { promisify } from 'node:util'
@@ -18,6 +23,7 @@ import {
18
23
  resolveVenv,
19
24
  venvBinary,
20
25
  } from './env.js'
26
+ import { cacheCandidates, fingerprint, probeRuntime, readAnyCache, writeAnyCache } from './preflight.js'
21
27
 
22
28
  const exec = promisify(execFile)
23
29
 
@@ -25,6 +31,8 @@ const exec = promisify(execFile)
25
31
  const LIB_DIR = dirname(fileURLToPath(import.meta.url))
26
32
  const PACKAGE_ROOT = dirname(LIB_DIR)
27
33
 
34
+ const DEFAULT_TTL_SEC = 12 * 60 * 60
35
+
28
36
  /**
29
37
  * 报告"正在跑的是哪一份 dsh-motion":版本 + lib 路径 + 安装形态。
30
38
  * 用 import.meta.url 推导,符号链接会被 Node 解析成真实路径,所以 link 安装会显示仓库路径。
@@ -61,7 +69,43 @@ async function version(bin, args) {
61
69
  return null
62
70
  }
63
71
 
72
+ function summarizeProbe(probe) {
73
+ const fonts = probe?.fonts ?? []
74
+ const formulas = probe?.formulas ?? []
75
+ return {
76
+ fonts,
77
+ formulas,
78
+ fontsOk: fonts.length > 0 && fonts.every((row) => row.ok),
79
+ formulasOk: formulas.length > 0 && formulas.every((row) => row.ok),
80
+ }
81
+ }
82
+
83
+ function firstFormulaError(probe) {
84
+ const row = (probe?.formulas ?? []).find((item) => !item.ok && item.error)
85
+ return row?.error ?? probe?.errors?.[0] ?? null
86
+ }
87
+
64
88
  export async function doctor(config = {}) {
89
+ const started = Date.now()
90
+ const fp = fingerprint(config)
91
+ const force = config.preflightForce === true || process.env.MOTION_PREFLIGHT === 'force'
92
+ const ttlSec = Math.max(
93
+ 0,
94
+ Number(config.preflightTtlSec ?? process.env.MOTION_PREFLIGHT_TTL ?? DEFAULT_TTL_SEC) || 0,
95
+ )
96
+ const candidates = cacheCandidates(config)
97
+
98
+ if (!force && ttlSec > 0) {
99
+ const hit = readAnyCache(candidates, fp, ttlSec * 1000)
100
+ if (hit) {
101
+ return {
102
+ ...hit.value,
103
+ cached: { hit: true, ageSec: Number((hit.ageMs / 1000).toFixed(1)), ttlSec, file: hit.file },
104
+ elapsedMs: Date.now() - started,
105
+ }
106
+ }
107
+ }
108
+
65
109
  const rows = []
66
110
  const add = (item, ok, detail, fix) => rows.push({ item, ok, detail, fix })
67
111
 
@@ -83,11 +127,38 @@ export async function doctor(config = {}) {
83
127
  const py = hasVenv(config)
84
128
  add('Python venv', py, venv, py ? null : `python3 -m venv ${venv}`)
85
129
 
130
+ let probe = { ok: false, fonts: [], formulas: [], api: {}, errors: ['venv 不可用,未运行探针'] }
86
131
  if (py) {
87
- const manimgx = await version(venvBinary(config, 'manimgx'))
88
- add('manimgx', Boolean(manimgx), manimgx ?? '未安装', manimgx ? null : `${venvBinary(config, 'pip')} install manimgx`)
89
- const sympy = await version(venvBinary(config, 'python'), ['-c', 'import sympy; print(sympy.__version__)'])
90
- add('sympy', Boolean(sympy), sympy ?? '未安装', sympy ? null : `${venvBinary(config, 'pip')} install sympy`)
132
+ probe = await probeRuntime(config)
133
+
134
+ for (const [name, found, fix] of [
135
+ ['manimgx', probe.manimgx, `${venvBinary(config, 'pip')} install manimgx`],
136
+ ['sympy', probe.sympy, `${venvBinary(config, 'pip')} install sympy`],
137
+ ]) {
138
+ add(name, Boolean(found), found ?? '未安装', found ? null : fix)
139
+ }
140
+
141
+ const { fonts, formulas, fontsOk, formulasOk } = summarizeProbe(probe)
142
+
143
+ const fontDetail = fonts.length
144
+ ? fonts.map((row) => `${row.family} ${row.ok ? '✓' : '✗'}`).join(' · ')
145
+ : (probe.errors?.[0] ?? '探针失败')
146
+ add('中文字体', fontsOk, fontDetail, fontsOk ? null : '安装 manimgx-fonts-cjk;macOS 需有 PingFang SC')
147
+
148
+ const formulaDetail = formulas.length
149
+ ? formulasOk
150
+ ? `${formulas.length}/${formulas.length} 条样本转换成功(Typst/mitex)`
151
+ : firstFormulaError(probe) ?? '公式转换失败'
152
+ : '未运行'
153
+ add('公式转换', formulasOk, formulaDetail, formulasOk ? null : '检查 Typst 后端;`\\lim_{...}` 这类写法在正式场景里再验一次')
154
+
155
+ const apiNames = Object.keys(probe.api ?? {})
156
+ add(
157
+ '渲染器 API',
158
+ apiNames.length > 0,
159
+ apiNames.length ? `${apiNames.length} 个签名已随报告返回:${apiNames.join(' / ')}` : '未取到签名',
160
+ null,
161
+ )
91
162
  }
92
163
 
93
164
  const chrome = chromePath()
@@ -98,19 +169,36 @@ export async function doctor(config = {}) {
98
169
 
99
170
  add('输出目录', true, motionHome(config), null)
100
171
 
172
+ const { fontsOk, formulasOk } = summarizeProbe(probe)
101
173
  const ready = {
102
174
  math: Boolean(ffmpegVersion && py),
103
175
  delivery: Boolean(ffmpegVersion && chrome),
176
+ fonts: fontsOk,
177
+ formulas: formulasOk,
104
178
  }
105
179
 
106
- return {
180
+ const summary = !ready.math
181
+ ? ready.delivery
182
+ ? '成片可用;数学渲染缺少 Python venv'
183
+ : '缺少 FFmpeg:渲染不可用'
184
+ : ready.fonts && ready.formulas
185
+ ? '数学渲染与成片可用;字体与公式已过构造检查'
186
+ : '数学渲染与成片可用;字体或公式有问题(见下表)'
187
+
188
+ const report = {
107
189
  ready,
108
190
  source,
109
- summary: ready.math
110
- ? '数学渲染与成片可用'
111
- : ready.delivery
112
- ? '成片可用;数学渲染缺少 Python venv'
113
- : '缺少 FFmpeg:渲染不可用',
191
+ summary,
114
192
  rows,
193
+ probe,
194
+ fingerprint: fp,
195
+ created: new Date().toISOString(),
196
+ }
197
+ const written = writeAnyCache(candidates, fp, report)
198
+
199
+ return {
200
+ ...report,
201
+ cached: { hit: false, ageSec: 0, ttlSec, file: written },
202
+ elapsedMs: Date.now() - started,
115
203
  }
116
204
  }
package/lib/index.js CHANGED
@@ -48,14 +48,14 @@ export function apply(ctx, config = {}) {
48
48
  ctx.tools.register({
49
49
  name: 'motion_doctor',
50
50
  description:
51
- '检查代码动画需要的环境:Node、FFmpeg、Python venv、manimgx、sympy、Chrome、HyperFrames。装完插件或渲染报错时先跑这个,返回表格与修法。',
51
+ '环境预检:一次调用给出 Node / FFmpeg / venv / manimgx / sympy / Chrome / HyperFrames,以及中文字体、公式转换(Typst)和常用 API 签名。开工前跑一次即可(结果按指纹缓存 12 h,重复调用几乎不耗时);渲染报错时也先跑这个。',
52
52
  parameters: { type: 'object', properties: {}, additionalProperties: false },
53
53
  output: textOutput,
54
54
  timeoutMs: 120_000,
55
55
  async execute() {
56
56
  const { doctor } = await importFresh(DOCTOR_URL)
57
57
  const result = await doctor(config)
58
- log(`doctor ready=${JSON.stringify(result.ready)}`)
58
+ log(`doctor ready=${JSON.stringify(result.ready)} cached=${result.cached?.hit ?? false} ${result.elapsedMs ?? '?'}ms`)
59
59
  return JSON.stringify(result, null, 2)
60
60
  },
61
61
  })
package/lib/ops.js CHANGED
@@ -106,7 +106,7 @@ export async function renderScene(config, args) {
106
106
 
107
107
  // post/ffmpeg 可能因 tmix/丢帧把时长压短;用 CFR 补齐并校验,避免"30s 变 27.4s"。
108
108
  if (summary.durationSec) {
109
- const fixed = await ensureDuration(config, summary, fps)
109
+ const fixed = await ensureCfr(config, summary, fps)
110
110
  if (fixed) summary.normalized = true
111
111
  }
112
112
 
@@ -114,22 +114,25 @@ export async function renderScene(config, args) {
114
114
  return summary
115
115
  }
116
116
 
117
- /** 成片时长与渲染器报告的时长不一致时,用 tpad + CFR 修正;返回是否修过。 */
118
- async function ensureDuration(config, summary, fps) {
117
+ /** 成片时长或帧数不对(VFR 少帧)时,用 tpad + CFR 修正;返回是否修过。 */
118
+ async function ensureCfr(config, summary, fps) {
119
119
  const expected = Number(summary.durationSec)
120
120
  if (!Number.isFinite(expected) || expected <= 0) return false
121
121
  const file = summary.output
122
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
123
+ const expectedFrames = Math.round(expected * fps)
124
+ const frameGap = Number.isFinite(before.frames) ? Math.abs(before.frames - expectedFrames) : 0
125
+ const durationGap = Math.abs(expected - before.durationSec)
126
+ if (frameGap <= 1 && durationGap <= 0.08) return false
125
127
 
128
+ const delta = expected - before.durationSec
126
129
  const tmp = `${file}.cfr.mp4`
127
130
  const chain = []
128
131
  if (delta > 0.05) chain.push(`tpad=stop_mode=clone:stop_duration=${delta.toFixed(3)}`)
129
132
  chain.push(`fps=${fps}`)
130
133
  const args = [
131
134
  '-y', '-loglevel', 'error', '-i', file,
132
- '-vf', chain.join(','), '-fps_mode', 'cfr',
135
+ '-vf', chain.join(','), '-fps_mode', 'cfr', '-r', String(fps),
133
136
  '-c:v', 'libx264', '-crf', '18', '-pix_fmt', 'yuv420p', '-an',
134
137
  ]
135
138
  if (delta < -0.05) args.push('-t', expected.toFixed(3))
@@ -137,8 +140,9 @@ async function ensureDuration(config, summary, fps) {
137
140
  await exec(binaryOf(config, 'ffmpeg'), args, { timeout: 900_000, maxBuffer: 8 * 1024 * 1024 })
138
141
 
139
142
  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})`)
143
+ const afterGap = Number.isFinite(after.frames) ? Math.abs(after.frames - expectedFrames) : 0
144
+ if (afterGap > 1 || Math.abs(after.durationSec - expected) > 0.05) {
145
+ throw new Error(`CFR 修正后仍不对:期望 ${expectedFrames} 帧 / ${expected}s,实际 ${after.frames} 帧 / ${after.durationSec}s(${tmp})`)
142
146
  }
143
147
  renameSync(tmp, file)
144
148
  Object.assign(summary, after)
@@ -348,10 +352,10 @@ export async function probeVideo(config, args) {
348
352
  /** 用 ffprobe 重新读成片参数(post 之后帧率、时长、体积都可能与中间产物不同)。 */
349
353
  export async function probeOutput(config, file) {
350
354
  const { stdout } = await exec(binaryOf(config, 'ffprobe'), [
351
- '-v', 'error', '-select_streams', 'v:0',
352
- '-show_entries', 'stream=width,height,r_frame_rate:format=duration,size',
355
+ '-v', 'error', '-count_frames', '-select_streams', 'v:0',
356
+ '-show_entries', 'stream=width,height,r_frame_rate,avg_frame_rate,nb_read_frames:format=duration,size',
353
357
  '-of', 'json', file,
354
- ], { timeout: 60_000, maxBuffer: 1024 * 1024 })
358
+ ], { timeout: 120_000, maxBuffer: 4 * 1024 * 1024 })
355
359
  const data = JSON.parse(stdout)
356
360
  const stream = data.streams?.[0] ?? {}
357
361
  const [num, den = '1'] = String(stream.r_frame_rate ?? '0/1').split('/')
@@ -363,6 +367,10 @@ export async function probeOutput(config, file) {
363
367
  width: stream.width ?? null,
364
368
  height: stream.height ?? null,
365
369
  fps: Number.isFinite(rate) && rate > 0 ? Math.round(rate * 100) / 100 : null,
370
+ avgFps: Number.isFinite(Number(stream.avg_frame_rate?.split?.('/')[0]))
371
+ ? Math.round((Number(stream.avg_frame_rate.split('/')[0]) / Number(stream.avg_frame_rate.split('/')[1] ?? 1)) * 100) / 100
372
+ : null,
373
+ frames: Number.isFinite(Number(stream.nb_read_frames)) ? Number(stream.nb_read_frames) : null,
366
374
  megabytes: Number.isFinite(size) ? Math.round(size / 1e5) / 10 : null,
367
375
  }
368
376
  }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * 环境预检:一次调用问清「能不能开工」。
3
+ *
4
+ * 为什么不是多次:每个独立检查都是一次模型往返(5–15 s),而不是一次 0.3 s 的子进程。
5
+ * 所以字体验证、公式转换、常用 API 签名、渲染器版本全部塞进 probe_env.py 一个进程,
6
+ * 外层再按文件指纹缓存整份报告——同一台机器上第二次调用直接命中,不再 spawn python。
7
+ *
8
+ * 只看「构造」不看「渲染」:渲染由项目自己的最小场景(首帧预算 ≤60 s)验证。
9
+ */
10
+
11
+ import { execFile } from 'node:child_process'
12
+ import { createHash } from 'node:crypto'
13
+ import { readdirSync } from 'node:fs'
14
+ import { tmpdir } from 'node:os'
15
+ import { dirname, join } from 'node:path'
16
+ import { fileURLToPath } from 'node:url'
17
+ import { promisify } from 'node:util'
18
+
19
+ import { dirSig, readCache, statSig, writeCache } from './cache.js'
20
+ import { motionHome, resolveVenv, venvBinary } from './env.js'
21
+
22
+ const exec = promisify(execFile)
23
+
24
+ const PROBE_SCRIPT = join(dirname(fileURLToPath(import.meta.url)), 'probe_env.py')
25
+
26
+ /** venv 里 manimgx 包的位置:Unix 是 lib/python3.x/site-packages,Windows 是 Lib/site-packages。 */
27
+ function packageDir(venv) {
28
+ try {
29
+ for (const entry of readdirSync(join(venv, 'lib'))) {
30
+ if (!entry.startsWith('python')) continue
31
+ const candidate = join(venv, 'lib', entry, 'site-packages', 'manimgx')
32
+ return candidate
33
+ }
34
+ } catch {
35
+ // 落到 Windows 布局
36
+ }
37
+ return join(venv, 'Lib', 'site-packages', 'manimgx')
38
+ }
39
+
40
+ /**
41
+ * 指纹:换机器、升级 manimgx、改探针脚本、重装 venv 都会变。
42
+ * 只做 stat,不起进程,所以命中判定本身是微秒级。
43
+ */
44
+ export function fingerprint(config = {}) {
45
+ const venv = resolveVenv(config)
46
+ return {
47
+ node: process.versions.node,
48
+ platform: `${process.platform}-${process.arch}`,
49
+ venv,
50
+ python: statSig(venvBinary(config, 'python')),
51
+ manimgx: dirSig(packageDir(venv)),
52
+ probe: statSig(PROBE_SCRIPT),
53
+ }
54
+ }
55
+
56
+ /**
57
+ * 缓存落点,按顺序尝试:显式配置 → 系统临时目录 → MOTION_HOME。
58
+ *
59
+ * 为什么不是 MOTION_HOME:Harness 的文件沙箱是 workspace-write,`~/.dsh` 通常写不进去
60
+ * (EPERM)。临时目录可写、且同一台机器跨会话有效,所以排在前面。
61
+ */
62
+ export function cacheCandidates(config = {}) {
63
+ const list = []
64
+ const explicit = config.cacheFile ?? process.env.MOTION_CACHE_FILE
65
+ if (explicit) list.push(explicit)
66
+ const tag = createHash('sha1').update(resolveVenv(config)).digest('hex').slice(0, 8)
67
+ list.push(join(tmpdir(), `dsh-motion-preflight-${tag}.json`))
68
+ list.push(join(motionHome(config), 'preflight.json'))
69
+ return list
70
+ }
71
+
72
+ /** 命中第一个指纹一致且未过期的缓存。 */
73
+ export function readAnyCache(candidates, fingerprintValue, ttlMs) {
74
+ for (const file of candidates) {
75
+ const hit = readCache(file, fingerprintValue, ttlMs)
76
+ if (hit) return { ...hit, file }
77
+ }
78
+ return null
79
+ }
80
+
81
+ /** 写入第一个可写落点;全被挡住时返回 null,不影响报告本身。 */
82
+ export function writeAnyCache(candidates, fingerprintValue, value) {
83
+ for (const file of candidates) {
84
+ const written = writeCache(file, fingerprintValue, value)
85
+ if (written) return written
86
+ }
87
+ return null
88
+ }
89
+
90
+ /** 跑一次 Python 探针。返回 {ok, fonts, formulas, api, ...},失败时带 errors。 */
91
+ export async function probeRuntime(config = {}) {
92
+ const venv = resolveVenv(config)
93
+ const python = venvBinary(config, 'python')
94
+ const binDir = join(venv, process.platform === 'win32' ? 'Scripts' : 'bin')
95
+
96
+ try {
97
+ const { stdout } = await exec(python, [PROBE_SCRIPT], {
98
+ timeout: 30_000,
99
+ maxBuffer: 2 * 1024 * 1024,
100
+ env: { ...process.env, PATH: `${binDir}${process.platform === 'win32' ? ';' : ':'}${process.env.PATH ?? ''}` },
101
+ })
102
+ const line = stdout.trim().split('\n').filter(Boolean).pop() ?? ''
103
+ const parsed = JSON.parse(line)
104
+ return { ok: parsed.ok !== false, ...parsed }
105
+ } catch (err) {
106
+ const detail = String(err.stderr || err.stdout || err.message || '').trim().split('\n').slice(-2).join(' ')
107
+ return {
108
+ ok: false,
109
+ errors: [`probe failed: ${detail.slice(0, 300)}`],
110
+ fonts: [],
111
+ formulas: [],
112
+ api: {},
113
+ }
114
+ }
115
+ }
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env python3
2
+ """环境探针:一个进程内回答「字体能不能排、公式能不能转、API 长什么样」。
3
+
4
+ 由 lib/doctor.js 调用(venv 里的 python),不单独给用户跑。
5
+ 只构造对象,不渲染、不写盘:约 0.25 s。真正的渲染验证留给项目自己的最小场景。
6
+
7
+ stdout 必须是单行 JSON。任何未捕获异常都由 JS 侧兜成 "probe failed"。
8
+ """
9
+
10
+ import json
11
+ import sys
12
+
13
+ # 代表三条最容易踩的路径:极限/分式、微分记号、积分。
14
+ MATH_SAMPLES = [
15
+ ("lim", r"f'(x)=\lim_{\Delta x\to 0}\frac{f(x+\Delta x)-f(x)}{\Delta x}"),
16
+ ("differential", r"dy=f'(x)\,dx"),
17
+ ("integral", r"\int_0^1 x^2\,dx"),
18
+ ]
19
+
20
+ FONT_SAMPLES = [
21
+ ("PingFang SC", "微分:把曲线拉成直线", "cjk"),
22
+ ("Helvetica Neue", "ABC 123", "latin"),
23
+ ]
24
+
25
+ # agent 常为这几个去翻 site-packages 源码;签名直接放进报告。
26
+ API_NAMES = ["MathTex", "Text", "DecimalNumber", "ValueTracker", "Axes"]
27
+
28
+
29
+ def package_version(name):
30
+ try:
31
+ from importlib.metadata import version
32
+
33
+ return version(name)
34
+ except Exception:
35
+ return None
36
+
37
+
38
+ def check_fonts(text_cls):
39
+ rows = []
40
+ for family, sample, kind in FONT_SAMPLES:
41
+ row = {"family": family, "kind": kind, "ok": False}
42
+ try:
43
+ obj = text_cls(sample, font=family, font_size=34)
44
+ row["ok"] = float(obj.width) > 0 and float(obj.height) > 0
45
+ row["width"] = round(float(obj.width), 2)
46
+ except Exception as exc: # noqa: BLE001 - 报告里要原文
47
+ row["error"] = f"{type(exc).__name__}: {exc}"[:160]
48
+ rows.append(row)
49
+ return rows
50
+
51
+
52
+ def check_formulas(math_tex_cls):
53
+ rows = []
54
+ for label, tex in MATH_SAMPLES:
55
+ row = {"label": label, "ok": False}
56
+ try:
57
+ obj = math_tex_cls(tex, font_size=40)
58
+ row["ok"] = float(obj.width) > 0
59
+ except Exception as exc: # noqa: BLE001
60
+ row["error"] = f"{type(exc).__name__}: {exc}"[:160]
61
+ rows.append(row)
62
+ return rows
63
+
64
+
65
+ def api_signatures(module):
66
+ import inspect
67
+
68
+ out = {}
69
+ for name in API_NAMES:
70
+ cls = getattr(module, name, None)
71
+ if cls is None:
72
+ continue
73
+ try:
74
+ sig = str(inspect.signature(cls.__init__))
75
+ except (TypeError, ValueError):
76
+ sig = "(signature unavailable)"
77
+ # 去掉 self,太长就截断:要的是参数名,不是注解全文。
78
+ sig = sig.removeprefix("(self, ").removeprefix("(self)")
79
+ out[name] = sig[:320]
80
+ return out
81
+
82
+
83
+ def main():
84
+ out = {"ok": True, "python": sys.version.split()[0], "errors": []}
85
+
86
+ try:
87
+ import manimgx as motion
88
+ from manimgx import Axes, DecimalNumber, MathTex, Text, ValueTracker # noqa: F401
89
+ except Exception as exc: # noqa: BLE001
90
+ out["ok"] = False
91
+ out["errors"].append(f"import manimgx: {type(exc).__name__}: {exc}"[:300])
92
+ print(json.dumps(out, ensure_ascii=False))
93
+ return 0
94
+
95
+ out["manimgx"] = package_version("manimgx")
96
+ out["sympy"] = package_version("sympy")
97
+ out["fonts"] = check_fonts(Text)
98
+ out["formulas"] = check_formulas(MathTex)
99
+ out["api"] = api_signatures(motion)
100
+
101
+ if not any(row["ok"] for row in out["fonts"]):
102
+ out["ok"] = False
103
+ out["errors"].append("字体全部构造失败:检查 manimgx-fonts 与系统字体")
104
+ if not all(row["ok"] for row in out["formulas"]):
105
+ out["ok"] = False
106
+
107
+ print(json.dumps(out, ensure_ascii=False))
108
+ return 0
109
+
110
+
111
+ if __name__ == "__main__":
112
+ sys.exit(main())
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-motion",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
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",
@@ -38,8 +38,8 @@
38
38
  }
39
39
  },
40
40
  "scripts": {
41
- "check": "node --check lib/index.js && node --check lib/ops.js && node --check lib/env.js && node --check lib/doctor.js",
42
- "test": "node scripts/test-ops.mjs && node scripts/test-route.mjs && node scripts/test-plan.mjs && node scripts/test-approvals.mjs",
41
+ "check": "node --check lib/index.js && node --check lib/ops.js && node --check lib/env.js && node --check lib/doctor.js && node --check lib/preflight.js && node --check lib/cache.js",
42
+ "test": "node scripts/test-ops.mjs && node scripts/test-route.mjs && node scripts/test-plan.mjs && node scripts/test-approvals.mjs && node scripts/test-preflight.mjs",
43
43
  "smoke": "node scripts/smoke.mjs",
44
44
  "dev": "node scripts/dev.mjs",
45
45
  "route": "node scripts/route-style.mjs",
@@ -12,7 +12,7 @@ if (pkg.private) problems.push('package.json 里 private 仍为 true')
12
12
  if (!pkg.license) problems.push('缺少 license')
13
13
  if (!pkg.engines?.node) problems.push('缺少 engines.node')
14
14
 
15
- for (const file of ['lib/index.js', 'lib/ops.js', 'lib/env.js', 'lib/doctor.js']) {
15
+ for (const file of ['lib/index.js', 'lib/ops.js', 'lib/env.js', 'lib/doctor.js', 'lib/preflight.js', 'lib/cache.js']) {
16
16
  const text = readFileSync(join(process.cwd(), file), 'utf8')
17
17
  if (/\/Users\/[^\s'"]+/.test(text)) problems.push(`${file} 含绝对路径 /Users/…`)
18
18
  }
package/scripts/dev.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  * node scripts/dev.mjs frames <scene.py> [t1,t2,...]
6
6
  * node scripts/dev.mjs render <scene.py> [--fps 30] [--out path]
7
7
  * node scripts/dev.mjs probe <video.mp4>
8
- * node scripts/dev.mjs doctor
8
+ * node scripts/dev.mjs doctor [--force]
9
9
  */
10
10
  import { mkdirSync } from 'node:fs'
11
11
  import { basename, dirname, join } from 'node:path'
@@ -62,7 +62,10 @@ if (cmd === 'frames') {
62
62
  const started = Date.now()
63
63
  console.log(JSON.stringify({ ...(await probeVideo({}, { video: file })), elapsed: elapsed(started) }, null, 2))
64
64
  } else if (cmd === 'doctor') {
65
- console.log(JSON.stringify(await doctor({}), null, 2))
65
+ if (rest.includes('--force')) process.env.MOTION_PREFLIGHT = 'force'
66
+ const started = Date.now()
67
+ const report = await doctor({})
68
+ console.log(JSON.stringify({ ...report, elapsed: elapsed(started) }, null, 2))
66
69
  } else {
67
70
  fail(`未知命令: ${cmd}`)
68
71
  }
package/scripts/smoke.mjs CHANGED
@@ -15,6 +15,9 @@ const required = [
15
15
  'lib/env.js',
16
16
  'lib/doctor.js',
17
17
  'lib/fresh.js',
18
+ 'lib/preflight.js',
19
+ 'lib/probe_env.py',
20
+ 'lib/cache.js',
18
21
  'skills/animation-router/SKILL.md',
19
22
  'skills/animation-router/references/confirmation.md',
20
23
  'skills/design-spec/SKILL.md',
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 预检缓存单元测试:指纹失效、TTL、坏文件不炸、落点顺序。
4
+ * 不依赖 Python 与网络:只测 cache.js / preflight.js 的纯逻辑。
5
+ */
6
+ import assert from 'node:assert/strict'
7
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
8
+ import { tmpdir } from 'node:os'
9
+ import { join } from 'node:path'
10
+
11
+ import { readCache, statSig, writeCache } from '../lib/cache.js'
12
+ import { cacheCandidates } from '../lib/preflight.js'
13
+
14
+ const dir = mkdtempSync(join(tmpdir(), 'dsh-motion-preflight-'))
15
+ const file = join(dir, 'cache.json')
16
+ const fp = { node: '26.0.0', venv: '/tmp/venv', probe: '1:2' }
17
+
18
+ try {
19
+ // 缺失文件
20
+ assert.equal(statSig(join(dir, 'nope.json')), 'missing')
21
+
22
+ // 写→读
23
+ assert.equal(writeCache(file, fp, { ready: { math: true } }), file)
24
+ const hit = readCache(file, fp, 60_000)
25
+ assert.ok(hit, '同指纹应命中')
26
+ assert.equal(hit.value.ready.math, true)
27
+ assert.ok(hit.ageMs >= 0 && hit.ageMs < 60_000)
28
+
29
+ // 指纹变了就失效
30
+ assert.equal(readCache(file, { ...fp, probe: '1:3' }, 60_000), null, '指纹变化应失效')
31
+
32
+ // 过期就失效
33
+ assert.equal(readCache(file, fp, 0), null, 'TTL=0 应失效')
34
+
35
+ // 坏文件不抛
36
+ writeFileSync(file, '{not json')
37
+ assert.equal(readCache(file, fp, 60_000), null, '坏 JSON 应返回 null')
38
+
39
+ // 写不进去返回 null,不抛
40
+ assert.equal(writeCache('/dev/null/nested/x.json', fp, {}), null)
41
+
42
+ // 落点:显式配置优先;否则临时目录在这台机器上比 MOTION_HOME 更可能可写
43
+ const explicit = cacheCandidates({ cacheFile: join(dir, 'explicit.json') })
44
+ assert.equal(explicit[0], join(dir, 'explicit.json'))
45
+ assert.ok(explicit.length >= 2, '至少两个候选落点')
46
+ assert.ok(cacheCandidates({}).some((p) => p.startsWith(tmpdir())), '应包含临时目录')
47
+ } finally {
48
+ rmSync(dir, { recursive: true, force: true })
49
+ }
50
+
51
+ console.log('ok preflight cache: fingerprint / ttl / corrupt / fallback')
@@ -26,6 +26,24 @@ node scripts/plan-questions.mjs "<brief>" --json
26
26
  选项是**推出来的**,不是固定清单:画幅看平台线索,交付看有无旁白,风格看 registry 命中,受众看题材与平台。
27
27
  用户也可以直接在 `Other` 里写别的。
28
28
 
29
+ ## 0.5 环境预检:跑一次,别再拆
30
+
31
+ 开工前只调用**一次** `motion_doctor`。它一口气返回:
32
+
33
+ - 运行时:Node / FFmpeg / venv / manimgx / sympy / Chrome / HyperFrames;
34
+ - **字体**:PingFang SC、Helvetica Neue 的构造结果;
35
+ - **公式**:`\lim_{}\frac{}{}`、微分记号、积分三条样本的 Typst 转换;
36
+ - **API 签名**:`probe.api` 里的 MathTex / Text / DecimalNumber / ValueTracker / Axes。
37
+
38
+ 规则:
39
+
40
+ - `ready.math` / `ready.delivery` / `ready.fonts` / `ready.formulas` 全为 true 才开工;
41
+ - **不要**把字体、公式、模块版本拆成多条 bash 去查:每条都是一次模型往返(5–15 s),而这次预检本身约 0.5 s;
42
+ - 结果按文件指纹缓存 12 h。`cached.hit: true` 表示环境没变,直接信;升级 venv / manimgx 会自动失效,要手动重测用 `MOTION_PREFLIGHT=force`;
43
+ - 要库 API 先看 `probe.api`,**不要读 site-packages 源码**;签名不够时用一条命令补:`python -c "import inspect, manimgx; print(inspect.signature(manimgx.DecimalNumber.__init__))"`;
44
+ - 构造通过 ≠ 画面没问题:排版仍以第一帧(`motion_frames`)为准;
45
+ - 所以**不要再单独写 probe 场景做冒烟渲染**:项目的第一帧就是冒烟,一次就够。
46
+
29
47
  ## 1. 判题材
30
48
 
31
49
  | 题材 | 观众记住 | 判据 |
@@ -101,7 +119,7 @@ node scripts/approvals.mjs check <project> # 未全部 approved 时退出 1
101
119
  `motion_frames` 内部是 `manimgx inspect`,只画请求的时刻并做版式检查;
102
120
  `motion_probe` 是给已有 mp4 做体检的,不要用它替代看帧。
103
121
 
104
- ## 6. 五条硬规则
122
+ ## 6. 硬规则(逐条对照)
105
123
 
106
124
  1. **没有明确开工指令,不执行**:没听到“可以开始”就不写完整场景、不出全片;
107
125
  2. **没有 DESIGN.md 或参考语法,不开工**——否则每次都是同一个默认 look;
@@ -110,4 +128,5 @@ node scripts/approvals.mjs check <project> # 未全部 approved 时退出 1
110
128
  5. **公式先过 `formula_check`**,再进画面;
111
129
  6. **首帧预算 ≤60s**:先最小场景 + 构图 sheet,60s 看不到第一帧就停;
112
130
  7. **只做局部 edit**:改动 <50% 不重写整个文件;
113
- 8. **图片/上下文预算**:每次迭代 1 张 sheet(约 960px),上下文 ≤100k。
131
+ 8. **图片/上下文预算**:每次迭代 1 张 sheet(约 960px),上下文 ≤100k;
132
+ 9. **预检只跑一次**:`motion_doctor` 已含字体/公式/API 签名并缓存 12 h,不拆成多条 bash,也不读库源码。
@@ -14,9 +14,14 @@ description: 代码动画的自检清单:环境、公式、画面、音频、
14
14
 
15
15
  详见 [performance-budget.md](../../docs/performance-budget.md)。
16
16
 
17
- ## 1. 环境
17
+ ## 1. 环境(一次调用)
18
18
 
19
- 先跑 `motion_doctor`,`ready.math` 与 `ready.delivery` 都为 true 再继续。
19
+ 开工前跑**一次** `motion_doctor`:运行时 + 中文字体 + 公式转换 + API 签名一起返回。
20
+
21
+ - `ready.math` / `ready.delivery` / `ready.fonts` / `ready.formulas` 全 true 再继续;
22
+ - `cached.hit: true` 表示环境未变(指纹含 venv / manimgx mtime),不必重跑;
23
+ - 不要为字体、公式、模块版本各跑一条 bash,也不要读 site-packages 查 API:签名在 `probe.api`;
24
+ - 换 venv 或升级 manimgx 后,用 `MOTION_PREFLIGHT=force motion_doctor` 强制重测。
20
25
 
21
26
  ## 2. 公式(仅数学题材)
22
27
 
@@ -13,9 +13,6 @@ import manimgx as m
13
13
 
14
14
  TOKENS = json.loads(Path(__file__).with_name("tokens.json").read_text(encoding="utf-8"))
15
15
 
16
- # inspect 在 import 时读 config.frame_rate;必须放在模块层,不能只在 construct 里。
17
- m.config.frame_rate = int(TOKENS["motion"]["fps"])
18
-
19
16
  BG = TOKENS["colors"]["bg"]
20
17
  TEXT = TOKENS["colors"]["text"]
21
18
  LABEL = TOKENS["colors"]["label"]
@@ -30,11 +27,13 @@ AXIS = TOKENS["colors"]["axis"]
30
27
  LAYOUT = TOKENS["layout"]
31
28
 
32
29
 
33
- def apply_config(width: int = 1920, height: int = 1080, fps: int = 30) -> None:
34
- m.config.pixel_width = width
35
- m.config.pixel_height = height
36
- m.config.frame_rate = fps
37
- m.config.background_color = BG
30
+ def apply_config(background: str = BG) -> None:
31
+ """只设背景色;分辨率/帧率由 CLI(-r / --fps)或调用方决定。
32
+
33
+ 不要在场景里写死 1920x1080/30:那会覆盖 motion_render 的 width/height/fps,
34
+ 也是 1280x720 崩溃与 --fps 60 静默变 5s 的根因。
35
+ """
36
+ m.config.background_color = background
38
37
 
39
38
 
40
39
  def backdrop() -> m.Rectangle:
package/styles/README.md CHANGED
@@ -18,7 +18,7 @@
18
18
  - `STYLE.md` 是**改编**,必须在上游基础上写清楚差异;不复制 demo 代码;
19
19
  - 颜色、字体、时间表、音频以 `tokens.json` 为准;版面坐标与组件配方写在 `STYLE.md`;
20
20
  - 中文文本不做字符级 `ReplacementTransform` / `Transform`(会出字形碎片);用 Fade/Write/Indicate,换词用 FadeOut+FadeIn;
21
- - `theme.py` 必须在**模块 import 时**设置 `m.config.frame_rate`(不能只在 `construct` 里),否则 `manimgx inspect` 会按默认 60fps 取帧,30fps 场景的后半段会报 no frame;
21
+ - **不要在场景/`theme.py` 里写死 1920×1080 / fps**:`apply_config()` 只设背景色;分辨率与帧率交给 `motion_render` 的 `width/height/fps` 或 CLI 的 `-r/--fps`。写死会覆盖 CLI,导致 `-r 1280x720` 崩溃、`--fps 60` 静默变 5s;
22
22
  - 路由:`styles/registry.json` 是元数据,`node scripts/route-style.mjs "<题材>"` 打分;点名优先,低置信度/并列先问用户;
23
23
  - 新片在自己的项目目录写 `DESIGN.md`,头部 `style: <slug>` + `style_version: 1.0.0`;
24
24
  - 参考与示例统一用 HyperFrames 的 [examples](https://hyperframes.heygen.com/examples) / [catalog](https://hyperframes.heygen.com/catalog),不在仓库里放样片(`showcase/` 只放可复现的演示)。
@@ -15,9 +15,6 @@ import manimgx as m
15
15
 
16
16
  TOKENS = json.loads(Path(__file__).with_name("tokens.json").read_text(encoding="utf-8"))
17
17
 
18
- # inspect 在 import 时读 config.frame_rate;必须放在模块层,不能只在 construct 里。
19
- m.config.frame_rate = int(TOKENS["motion"]["fps"])
20
-
21
18
  TABLE = TOKENS["colors"]["table"]
22
19
  PAPER = TOKENS["colors"]["paper"]
23
20
  PAPER_LIGHT = TOKENS["colors"]["paper_light"]
@@ -27,11 +24,13 @@ STAMP = TOKENS["colors"]["stamp"]
27
24
  LAYOUT = TOKENS["layout"]
28
25
 
29
26
 
30
- def apply_config(width: int = 1920, height: int = 1080, fps: int = 30) -> None:
31
- m.config.pixel_width = width
32
- m.config.pixel_height = height
33
- m.config.frame_rate = fps
34
- m.config.background_color = TABLE
27
+ def apply_config(background: str = TABLE) -> None:
28
+ """只设背景色;分辨率/帧率由 CLI(-r / --fps)或调用方决定。
29
+
30
+ 不要在场景里写死 1920x1080/30:那会覆盖 motion_render 的 width/height/fps,
31
+ 也是 1280x720 崩溃与 --fps 60 静默变 5s 的根因。
32
+ """
33
+ m.config.background_color = background
35
34
 
36
35
 
37
36
  def backdrop(seed: int = 11) -> m.ImageMobject:
@@ -13,9 +13,6 @@ import manimgx as m
13
13
 
14
14
  TOKENS = json.loads(Path(__file__).with_name("tokens.json").read_text(encoding="utf-8"))
15
15
 
16
- # inspect 在 import 时读 config.frame_rate;必须放在模块层,不能只在 construct 里。
17
- m.config.frame_rate = int(TOKENS["motion"]["fps"])
18
-
19
16
  PAPER = TOKENS["colors"]["paper"]
20
17
  PAPER_SHADOW = TOKENS["colors"]["paper_shadow"]
21
18
  INK = TOKENS["colors"]["ink"]
@@ -28,11 +25,13 @@ MONO = TOKENS["typography"]["mono"]["family"]
28
25
  LAYOUT = TOKENS["layout"]
29
26
 
30
27
 
31
- def apply_config(width: int = 1920, height: int = 1080, fps: int = 30) -> None:
32
- m.config.pixel_width = width
33
- m.config.pixel_height = height
34
- m.config.frame_rate = fps
35
- m.config.background_color = PAPER
28
+ def apply_config(background: str = PAPER) -> None:
29
+ """只设背景色;分辨率/帧率由 CLI(-r / --fps)或调用方决定。
30
+
31
+ 不要在场景里写死 1920x1080/30:那会覆盖 motion_render 的 width/height/fps,
32
+ 也是 1280x720 崩溃与 --fps 60 静默变 5s 的根因。
33
+ """
34
+ m.config.background_color = background
36
35
 
37
36
 
38
37
  def backdrop() -> m.Rectangle:
@@ -13,9 +13,6 @@ import manimgx as m
13
13
 
14
14
  TOKENS = json.loads(Path(__file__).with_name("tokens.json").read_text(encoding="utf-8"))
15
15
 
16
- # inspect 在 import 时读 config.frame_rate;必须放在模块层,不能只在 construct 里。
17
- m.config.frame_rate = int(TOKENS["motion"]["fps"])
18
-
19
16
  GROUND = TOKENS["colors"]["ground"]
20
17
  PAPER = TOKENS["colors"]["paper"]
21
18
  INK = TOKENS["colors"]["ink"]
@@ -27,11 +24,13 @@ MOTION = TOKENS["motion"]
27
24
  LAYOUT = TOKENS["layout"]
28
25
 
29
26
 
30
- def apply_config(width: int = 1920, height: int = 1080, fps: int = 30) -> None:
31
- m.config.pixel_width = width
32
- m.config.pixel_height = height
33
- m.config.frame_rate = fps
34
- m.config.background_color = GROUND
27
+ def apply_config(background: str = GROUND) -> None:
28
+ """只设背景色;分辨率/帧率由 CLI(-r / --fps)或调用方决定。
29
+
30
+ 不要在场景里写死 1920x1080/30:那会覆盖 motion_render 的 width/height/fps,
31
+ 也是 1280x720 崩溃与 --fps 60 静默变 5s 的根因。
32
+ """
33
+ m.config.background_color = background
35
34
 
36
35
 
37
36
  def backdrop() -> m.Rectangle:
@@ -15,9 +15,6 @@ import manimgx as m
15
15
 
16
16
  TOKENS = json.loads(Path(__file__).with_name("tokens.json").read_text(encoding="utf-8"))
17
17
 
18
- # inspect 在 import 时读 config.frame_rate;必须放在模块层,不能只在 construct 里。
19
- m.config.frame_rate = int(TOKENS["motion"]["fps"])
20
-
21
18
  BOARD = TOKENS["colors"]["board"]
22
19
  BOARD_EDGE = TOKENS["colors"]["board_edge"]
23
20
  INK = TOKENS["colors"]["ink"]
@@ -28,11 +25,13 @@ GHOST = TOKENS["colors"]["ghost"]
28
25
  LAYOUT = TOKENS["layout"]
29
26
 
30
27
 
31
- def apply_config(width: int = 1920, height: int = 1080, fps: int = 30) -> None:
32
- m.config.pixel_width = width
33
- m.config.pixel_height = height
34
- m.config.frame_rate = fps
35
- m.config.background_color = BOARD
28
+ def apply_config(background: str = BOARD) -> None:
29
+ """只设背景色;分辨率/帧率由 CLI(-r / --fps)或调用方决定。
30
+
31
+ 不要在场景里写死 1920x1080/30:那会覆盖 motion_render 的 width/height/fps,
32
+ 也是 1280x720 崩溃与 --fps 60 静默变 5s 的根因。
33
+ """
34
+ m.config.background_color = background
36
35
 
37
36
 
38
37
  def backdrop(seed: int = 5) -> m.ImageMobject: