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 +4 -2
- package/docs/performance-budget.md +5 -3
- package/lib/cache.js +54 -0
- package/lib/doctor.js +101 -13
- package/lib/index.js +2 -2
- package/lib/ops.js +19 -11
- package/lib/preflight.js +115 -0
- package/lib/probe_env.py +112 -0
- package/package.json +3 -3
- package/scripts/check-meta.mjs +1 -1
- package/scripts/dev.mjs +5 -2
- package/scripts/smoke.mjs +3 -0
- package/scripts/test-preflight.mjs +51 -0
- package/skills/animation-router/SKILL.md +21 -2
- package/skills/qa-rubric/SKILL.md +7 -2
- package/styles/3b1b/theme.py +7 -8
- package/styles/README.md +1 -1
- package/styles/blueprint/theme.py +7 -8
- package/styles/dataviz/theme.py +7 -8
- package/styles/swiss-motion/theme.py +7 -8
- package/styles/whiteboard/theme.py +7 -8
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
| 工具 | 作用 |
|
|
14
14
|
|---|---|
|
|
15
15
|
| `motion_frames` | **极简模式(默认)**:直接从场景出帧,不生成视频、不用 HF;几秒拿到 storyboard PNG 与 layout 问题 |
|
|
16
|
-
| `motion_doctor` |
|
|
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] `
|
|
49
|
-
- [x]
|
|
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 {
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
'
|
|
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
|
|
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
|
-
/**
|
|
118
|
-
async function
|
|
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
|
|
124
|
-
|
|
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
|
-
|
|
141
|
-
|
|
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:
|
|
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
|
}
|
package/lib/preflight.js
ADDED
|
@@ -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
|
+
}
|
package/lib/probe_env.py
ADDED
|
@@ -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.
|
|
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",
|
package/scripts/check-meta.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
package/styles/3b1b/theme.py
CHANGED
|
@@ -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(
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
-
|
|
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(
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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:
|
package/styles/dataviz/theme.py
CHANGED
|
@@ -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(
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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(
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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(
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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:
|