dsh-motion 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE.md +3 -0
- package/README.md +114 -25
- package/docs/ai-native-motion.md +301 -0
- package/docs/dev-loop.md +55 -0
- package/lib/doctor.js +26 -1
- package/lib/fresh.js +21 -0
- package/lib/index.js +44 -6
- package/lib/ops.js +164 -8
- package/package.json +10 -3
- package/skills/animation-router/SKILL.md +30 -12
- package/skills/design-spec/SKILL.md +33 -1
- package/skills/qa-rubric/SKILL.md +14 -8
- package/styles/3b1b/STYLE.md +68 -0
- package/styles/3b1b/preview.jpg +0 -0
- package/styles/3b1b/style.json +50 -0
- package/styles/3b1b/theme.py +43 -0
- package/styles/3b1b/tokens.json +32 -0
- package/styles/README.md +28 -0
- package/styles/blueprint/STYLE.md +59 -0
- package/styles/blueprint/preview.jpg +0 -0
- package/styles/blueprint/style.json +47 -0
- package/styles/blueprint/theme.py +65 -0
- package/styles/blueprint/tokens.json +27 -0
- package/styles/dataviz/STYLE.md +58 -0
- package/styles/dataviz/preview.jpg +0 -0
- package/styles/dataviz/style.json +50 -0
- package/styles/dataviz/theme.py +49 -0
- package/styles/dataviz/tokens.json +31 -0
- package/styles/registry.json +212 -0
- package/styles/swiss-motion/STYLE.md +59 -0
- package/styles/swiss-motion/preview.jpg +0 -0
- package/styles/swiss-motion/style.json +47 -0
- package/styles/swiss-motion/theme.py +59 -0
- package/styles/swiss-motion/tokens.json +33 -0
- package/styles/whiteboard/STYLE.md +58 -0
- package/styles/whiteboard/preview.jpg +0 -0
- package/styles/whiteboard/style.json +47 -0
- package/styles/whiteboard/theme.py +53 -0
- package/styles/whiteboard/tokens.json +27 -0
package/lib/index.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-motion —— 代码生成的数学 / 科普动画
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* 五个工具:
|
|
5
5
|
* motion_doctor 环境自检(先跑这个)
|
|
6
|
+
* motion_frames 极简模式:直接从场景出帧(默认,不生成视频)
|
|
6
7
|
* motion_render 渲染 Manim / manimgx 场景
|
|
7
|
-
* motion_probe
|
|
8
|
+
* motion_probe 成片抽帧自检(空镜 / 过曝)
|
|
8
9
|
* formula_check 公式恒等校验(sympy)
|
|
9
10
|
*
|
|
10
11
|
* 知识层在 skills/ 里:animation-router / design-spec / qa-rubric。
|
|
@@ -14,8 +15,12 @@ import { appendFileSync, mkdirSync } from 'node:fs'
|
|
|
14
15
|
import { homedir } from 'node:os'
|
|
15
16
|
import { join } from 'node:path'
|
|
16
17
|
|
|
17
|
-
import {
|
|
18
|
-
|
|
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
|
-
'
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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', `
|
|
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:
|
|
287
|
+
'-hide_banner', '-i', video, '-vf', 'signalstats,metadata=print:file=-',
|
|
158
288
|
'-f', 'null', '-',
|
|
159
|
-
], { timeout: 120_000, maxBuffer:
|
|
160
|
-
const
|
|
161
|
-
const blank =
|
|
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:
|
|
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.
|
|
4
|
-
"description": "Code-generated math & science animations for DeepSeek Harness:
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Code-generated math & science animations for DeepSeek Harness: frames-first preview, Manim clips, formula checks.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"engines": {
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
"files": [
|
|
16
16
|
"lib",
|
|
17
17
|
"skills",
|
|
18
|
+
"styles",
|
|
19
|
+
"docs",
|
|
18
20
|
"cordis.patch.yml",
|
|
19
21
|
"README.md",
|
|
20
22
|
"LICENSE",
|
|
@@ -36,9 +38,14 @@
|
|
|
36
38
|
},
|
|
37
39
|
"scripts": {
|
|
38
40
|
"check": "node --check lib/index.js && node --check lib/ops.js && node --check lib/env.js && node --check lib/doctor.js",
|
|
41
|
+
"test": "node scripts/test-ops.mjs && node scripts/test-route.mjs",
|
|
39
42
|
"smoke": "node scripts/smoke.mjs",
|
|
43
|
+
"dev": "node scripts/dev.mjs",
|
|
44
|
+
"route": "node scripts/route-style.mjs",
|
|
45
|
+
"styles:index": "node scripts/build-style-index.mjs",
|
|
46
|
+
"styles:check": "node scripts/build-style-index.mjs --check",
|
|
40
47
|
"check:meta": "node scripts/check-meta.mjs",
|
|
41
|
-
"prepack": "npm run check && npm run smoke"
|
|
48
|
+
"prepack": "npm run check && npm test && npm run smoke"
|
|
42
49
|
},
|
|
43
50
|
"repository": {
|
|
44
51
|
"type": "git",
|
|
@@ -12,7 +12,19 @@ description: 做代码动画/科普视频的总入口:先判题材(关系还
|
|
|
12
12
|
| 数学讲解 | 一个**关系** | 公式、坐标、推导、表格 |
|
|
13
13
|
| 科普叙事 | 一个**意象** | 画面、比喻、节奏、材质 |
|
|
14
14
|
|
|
15
|
-
## 2.
|
|
15
|
+
## 2. 选风格
|
|
16
|
+
|
|
17
|
+
先读 `styles/registry.json`,再按顺序:
|
|
18
|
+
|
|
19
|
+
1. **用户点名**("用 3b1b / 瑞士动态排版")→ 直接用,不再打分;
|
|
20
|
+
2. 没有点名 → `node scripts/route-style.mjs "<题材>" --json`;
|
|
21
|
+
3. `confidence: high` → 写进 `DESIGN.md` 的 `style:`;
|
|
22
|
+
4. `medium` / `low` / `needsConfirmation: true` → 给用户 top 2–3 和理由,等确认;
|
|
23
|
+
5. 完全不命中 → 默认 `swiss-motion`,并说明这是默认。
|
|
24
|
+
|
|
25
|
+
不要用"感觉像"选风格;理由是 registry 的关键词/uses 命中,不是形容词。
|
|
26
|
+
|
|
27
|
+
## 3. 选画面层
|
|
16
28
|
|
|
17
29
|
| 画面层 | 适合 | 不用它的理由 |
|
|
18
30
|
|---|---|---|
|
|
@@ -20,21 +32,27 @@ description: 做代码动画/科普视频的总入口:先判题材(关系还
|
|
|
20
32
|
| Canvas 2D 逐帧 | 光、影、粒子、材质、转场 | 文字排版成本高 |
|
|
21
33
|
| HTML / SVG | 排版、图文、简单图示 | 做不出辉光与颗粒 |
|
|
22
34
|
|
|
23
|
-
|
|
35
|
+
三者都只是素材层;**需要交付时**再统一进 HyperFrames 工程,默认的极简模式不经过 HF。
|
|
24
36
|
|
|
25
|
-
##
|
|
37
|
+
## 4. 固定顺序(默认极简,先帧后片)
|
|
26
38
|
|
|
27
39
|
```
|
|
28
|
-
1. DESIGN.md(design spec)
|
|
29
|
-
2.
|
|
30
|
-
3. 画面
|
|
31
|
-
4.
|
|
32
|
-
5.
|
|
33
|
-
6.
|
|
40
|
+
1. DESIGN.md(design spec) ← 先定颜色/字体/禁用项/参考
|
|
41
|
+
2. formula_check(数学题材) ← 每条上屏公式先验
|
|
42
|
+
3. 画面 + motion_frames ← 默认:按秒出 3–6 帧,看帧改,不渲染视频
|
|
43
|
+
4. 分镜表(仅用户要时) ← 关键镜头,用真实代码出帧,等确认
|
|
44
|
+
5. motion_render ← 定稿后才编码 mp4
|
|
45
|
+
6. HyperFrames composition ← 字幕/旁白/混音/排版(需要时才进)
|
|
46
|
+
7. hyperframes check / motion_probe ← 只检查已经生成的成片
|
|
34
47
|
```
|
|
35
48
|
|
|
36
|
-
|
|
49
|
+
**默认不使用 HyperFrames**:只有需要字幕、旁白、混音、复杂图文编排时才进第 6 步。
|
|
50
|
+
`motion_frames` 内部是 `manimgx inspect`,只画请求的时刻并做版式检查,几秒出图;
|
|
51
|
+
`motion_probe` 是给已有 mp4 做体检的,不要用它替代看帧。
|
|
52
|
+
|
|
53
|
+
## 5. 四条硬规则
|
|
37
54
|
|
|
38
55
|
1. **没有 DESIGN.md 或参考语法,不开工**——否则每次都是同一个默认 look;
|
|
39
|
-
2.
|
|
40
|
-
3.
|
|
56
|
+
2. **默认先帧后片**:先用 `motion_frames` 把画面改对,再决定要不要渲染;
|
|
57
|
+
3. **字幕放在 composition 层**,不烧进 Manim 或 canvas;
|
|
58
|
+
4. **公式先过 `formula_check`**,再进画面。
|
|
@@ -19,6 +19,10 @@ HyperFrames 的 look 文档原话:**不给外部输入,相同的 brief 每
|
|
|
19
19
|
| 4 | 主题自身的世界 | ✅ 年代、材质、展示场景 |
|
|
20
20
|
| 5 | 官方预设(preset) | ⚠️ 幻灯片语言,仅点名或做 deck 时用 |
|
|
21
21
|
|
|
22
|
+
## 先路由
|
|
23
|
+
|
|
24
|
+
写 spec 之前先确定风格:`node scripts/route-style.mjs "<题材>"`。点名风格优先;没有点名就按 `styles/registry.json` 打分;`high` 才直接写,`medium`/`low` 先给用户 top 2–3 确认。
|
|
25
|
+
|
|
22
26
|
## 最小形态
|
|
23
27
|
|
|
24
28
|
```markdown
|
|
@@ -52,10 +56,38 @@ spacing: { margin: 140, rule: 2 }
|
|
|
52
56
|
- 具体到某类图版、某支片、某个年代的印刷品
|
|
53
57
|
```
|
|
54
58
|
|
|
55
|
-
##
|
|
59
|
+
## 先出三张风格帧
|
|
60
|
+
|
|
61
|
+
DESIGN.md 写完不等于风格成立。挑 3 个关键时刻(开场、转折、结论),用**真实绘制代码**出帧,对照 spec 检查:
|
|
62
|
+
|
|
63
|
+
- 用 `motion_frames` 直接出帧,不渲染视频;
|
|
64
|
+
- 三张里至少一张是 signature shot;
|
|
65
|
+
- 两轮还不对,就换更接近的参考语法,而不是继续调参。
|
|
66
|
+
|
|
67
|
+
## 八种已验证的语言
|
|
56
68
|
|
|
57
69
|
| 语言 | 适合 | 关键手段 |
|
|
58
70
|
|---|---|---|
|
|
59
71
|
| 纸感植物图版 | 生物 / 博物 | 纸纤维、水洗叠色、墨线、引线标注 |
|
|
60
72
|
| 暗场发光 | 物理 / 天文 / 微观 | 近黑底、细线辉光、粒子、颗粒 + 暗角 |
|
|
61
73
|
| 编辑排版 | 数学 / 数据 / 概念 | 网格、字阶、单一强调色 |
|
|
74
|
+
| 瑞士动态排版 | 公式 / 概念 | 12 栏网格、单一信号色、精确缓动 |
|
|
75
|
+
| 数据叙事 | 数据 / 科学 | 图表操作即转场、注释钉在数据上、蓝红语义 |
|
|
76
|
+
| 蓝图工程制图 | 几何 / 工程 | 青晒纸、白线、制图顺序、一次红印章 |
|
|
77
|
+
| 白板讲解 | 科学 / 课程 | 暖白板、三色马克笔、书写顺序 |
|
|
78
|
+
| 3b1b 暗场数学 | 线代 / 微积分 | 近黑底、蓝黄语义、先直觉后公式 |
|
|
79
|
+
|
|
80
|
+
本仓库现有五套,除 `3b1b` 外均改编自 [Lemo-Opuscar](https://github.com/lemomo-ai/lemo-opuscar)(MIT):
|
|
81
|
+
|
|
82
|
+
- [`styles/swiss-motion/`](../../styles/swiss-motion/):公式/排版,默认;
|
|
83
|
+
- [`styles/dataviz/`](../../styles/dataviz/):图表/数据;
|
|
84
|
+
- [`styles/blueprint/`](../../styles/blueprint/):几何/工程;
|
|
85
|
+
- [`styles/whiteboard/`](../../styles/whiteboard/):科学/白板讲解;
|
|
86
|
+
- [`styles/3b1b/`](../../styles/3b1b/):暗场数学(Manim 社区 3b1b skill)。
|
|
87
|
+
|
|
88
|
+
规范在 `STYLE.md`,数值在 `tokens.json`,`theme.py` 提供 `apply_config()` 与 `backdrop()`。
|
|
89
|
+
项目 `DESIGN.md` 里写 `style: swiss-motion`(或 `dataviz` / `blueprint` / `whiteboard` / `3b1b`)与 `style_version: 1.0.0`。
|
|
90
|
+
|
|
91
|
+
参考与示例统一从 HyperFrames 取:[examples](https://hyperframes.heygen.com/examples)、
|
|
92
|
+
[catalog](https://hyperframes.heygen.com/catalog)、[frame.md](https://hyperframes.heygen.com/prompting/design-systems)。
|
|
93
|
+
本仓库不再内置分集/样片。
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: qa-rubric
|
|
3
|
-
description:
|
|
3
|
+
description: 代码动画的自检清单:环境、公式、画面、音频、字幕。默认先看帧(motion_frames),有成片时才查视频。
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# 交付前自检
|
|
@@ -14,28 +14,34 @@ description: 代码动画交付前的自检清单:环境、公式、工程、
|
|
|
14
14
|
- 所有上屏公式先过 `formula_check`;
|
|
15
15
|
- 逐条对照代码与画面,确认没有手改过的常数。
|
|
16
16
|
|
|
17
|
-
## 3.
|
|
17
|
+
## 3. 工程(仅当用了 HyperFrames)
|
|
18
18
|
|
|
19
19
|
```bash
|
|
20
20
|
npx hyperframes check
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
- 0 error 才算过;
|
|
24
|
-
- 关注 lint(缺 `@font-face`)、layout(文字裁切/重叠)、contrast(对比度 < 3:1
|
|
24
|
+
- 关注 lint(缺 `@font-face`)、layout(文字裁切/重叠)、contrast(对比度 < 3:1);
|
|
25
|
+
- 没进 HF 的极简流程跳过本节。
|
|
25
26
|
|
|
26
27
|
## 4. 画面
|
|
27
28
|
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
-
|
|
33
|
-
- `
|
|
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.
|
|
44
|
+
- 响度 ≈ −14 LUFS(±1),真峰值 ≤ −1 dBTP;具体以该片 STYLE.md 为准;
|
|
39
45
|
- 没有爆音、没有截断。
|
|
40
46
|
|
|
41
47
|
## 6. 字幕
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: 3b1b
|
|
3
|
+
version: 1.0.0
|
|
4
|
+
source: https://github.com/subinium/3b1b-style-animation-skill
|
|
5
|
+
license: MIT(上游风格 skill);非 3Blue1Brown 官方项目
|
|
6
|
+
status: adapted
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# 3Blue1Brown Style · 3b1b
|
|
10
|
+
|
|
11
|
+
> 配色表取自 [subinium/3b1b-style-animation-skill](https://github.com/subinium/3b1b-style-animation-skill)(MIT);
|
|
12
|
+
> 教学法与动画模式参考 [adithya-s-k/manim_skill](https://github.com/adithya-s-k/manim_skill)(MIT,约 1.2k stars,是这类 skill 里用得最多的)。
|
|
13
|
+
> 本文件是改编,不是原样复制;只保留不变量,实现换成 manimgx。
|
|
14
|
+
|
|
15
|
+
## 1. 定位
|
|
16
|
+
|
|
17
|
+
先建立直觉,再给公式。画面为“暗场 + 蓝黄高亮”的数学解释:
|
|
18
|
+
|
|
19
|
+
- 适合:线性代数、微积分、概率、神经网络、算法;
|
|
20
|
+
- 不适合:没有“为什么”的产品宣传、角色叙事。
|
|
21
|
+
|
|
22
|
+
## 2. 不变量
|
|
23
|
+
|
|
24
|
+
| 项 | 规则 |
|
|
25
|
+
|---|---|
|
|
26
|
+
| 底色 | `#1c1c1c`(近黑灰,**不用纯黑**) |
|
|
27
|
+
| 主色 | 蓝 `#3b82f6`:主对象、向量、节点 |
|
|
28
|
+
| 强调 | 黄 `#fbbf24`:高亮、结论、公式 |
|
|
29
|
+
| 语义色 | 绿 `#22c55e` 正 / 红 `#ef4444` 负 / 灰 `#9ca3af` 非活动 |
|
|
30
|
+
| 公式 | 黄色 `#fde047` 或白色;一屏最多 4–5 种颜色 |
|
|
31
|
+
| 字体 | 无衬线(Noto Sans);公式 New Computer Modern |
|
|
32
|
+
| 运动 | `Create` / `FadeIn` / `Transform`;相关元素**同时**出现(`LaggedStartMap` 式级联),不要一个个 `play()` |
|
|
33
|
+
| 收尾 | 结尾至少 `wait(2)`,给结论停留 |
|
|
34
|
+
|
|
35
|
+
## 3. 教学法
|
|
36
|
+
|
|
37
|
+
1. **Hook**:先问一个有意思的问题;
|
|
38
|
+
2. **Intuition**:用图形建立直觉,先不写公式;
|
|
39
|
+
3. **Concrete**:用一个具体数字走一遍;
|
|
40
|
+
4. **Formalization**:最后才给公式/命名。
|
|
41
|
+
|
|
42
|
+
## 4. manimgx 映射
|
|
43
|
+
|
|
44
|
+
- `theme.py`:`apply_config()` 设 `#1c1c1c`、`backdrop()` 返回平涂底;
|
|
45
|
+
- 用 `Axes`、`Dot`、`Arrow`、`Line`、`always_redraw` 做旋转/投影/变换;
|
|
46
|
+
- 用 `MathTex` 排版公式,`Text` 做标题与注释;
|
|
47
|
+
- manimgx 的 `Write` 已经足够接近 Manim 的 `Write`;不需要 LaTeX 环境。
|
|
48
|
+
|
|
49
|
+
## 5. Do / Don't
|
|
50
|
+
|
|
51
|
+
**Do**
|
|
52
|
+
|
|
53
|
+
- 先画“正在发生什么”,再写“它叫什么”;
|
|
54
|
+
- 蓝=对象,黄=高亮/结论;颜色有语义;
|
|
55
|
+
- 一次只讲一个变换;结尾停 2 秒。
|
|
56
|
+
|
|
57
|
+
**Don't**
|
|
58
|
+
|
|
59
|
+
- 不用纯黑 `#000000`;
|
|
60
|
+
- 不用高饱和纯红/纯绿/纯蓝;
|
|
61
|
+
- 不在建立直觉前抛公式;
|
|
62
|
+
- 不把每个元素拆成单独的 `play()`。
|
|
63
|
+
|
|
64
|
+
## 6. 与上游的差异
|
|
65
|
+
|
|
66
|
+
- 上游是 ManimCE/ManimGL skill,包含 40+ 条规则;这里只抽取配色、运动与教学法;
|
|
67
|
+
- 上游使用 Manim 的 `LaggedStartMap` 等 API;manimgx 用 `AnimationGroup`/`LaggedStart` 近似;
|
|
68
|
+
- 不是 3Blue1Brown 官方风格指南,只是社区通行做法的整理。
|
|
Binary file
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"slug": "3b1b",
|
|
3
|
+
"en": "3Blue1Brown Style",
|
|
4
|
+
"cn": "3b1b 数学动画",
|
|
5
|
+
"category_en": "Math explainer",
|
|
6
|
+
"category_cn": "数学讲解",
|
|
7
|
+
"uses": [
|
|
8
|
+
"Linear algebra",
|
|
9
|
+
"Calculus",
|
|
10
|
+
"Probability",
|
|
11
|
+
"Neural networks"
|
|
12
|
+
],
|
|
13
|
+
"source": "https://github.com/subinium/3b1b-style-animation-skill",
|
|
14
|
+
"secondary_source": "https://github.com/adithya-s-k/manim_skill",
|
|
15
|
+
"license": "MIT (style skills); unofficial tribute",
|
|
16
|
+
"implementation": "manimgx (theme.py + tokens.json)",
|
|
17
|
+
"num": "05",
|
|
18
|
+
"film": "Rotation on the Unit Circle",
|
|
19
|
+
"line": "A rotation on the unit circle that lands on e^{iθ} = cosθ + i sinθ.",
|
|
20
|
+
"line_cn": "单位圆上的一次旋转,收束到 e^{iθ} = cosθ + i sinθ。",
|
|
21
|
+
"uses_cn": [
|
|
22
|
+
"线代",
|
|
23
|
+
"微积分",
|
|
24
|
+
"概率",
|
|
25
|
+
"神经网络"
|
|
26
|
+
],
|
|
27
|
+
"keywords": [
|
|
28
|
+
"向量",
|
|
29
|
+
"矩阵",
|
|
30
|
+
"特征值",
|
|
31
|
+
"旋转",
|
|
32
|
+
"复数",
|
|
33
|
+
"单位圆",
|
|
34
|
+
"线代",
|
|
35
|
+
"概率",
|
|
36
|
+
"微积分",
|
|
37
|
+
"神经网络"
|
|
38
|
+
],
|
|
39
|
+
"avoid": [
|
|
40
|
+
"产品发布",
|
|
41
|
+
"纯排版"
|
|
42
|
+
],
|
|
43
|
+
"demo": "showcase/3b1b-unit-circle",
|
|
44
|
+
"demo_duration_sec": 10.3,
|
|
45
|
+
"aspect": [
|
|
46
|
+
"16:9"
|
|
47
|
+
],
|
|
48
|
+
"fps": 30,
|
|
49
|
+
"complexity": "medium"
|
|
50
|
+
}
|