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