dsh-motion 0.3.0 → 0.4.1
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 +29 -1
- package/docs/ai-native-motion.md +58 -15
- package/docs/performance-budget.md +73 -0
- package/lib/approvals.js +60 -0
- package/lib/index.js +5 -1
- package/lib/ops.js +44 -2
- package/package.json +5 -2
- package/scripts/approvals.mjs +136 -0
- package/scripts/build-style-index.mjs +110 -0
- package/scripts/check-meta.mjs +25 -0
- package/scripts/dev.mjs +68 -0
- package/scripts/plan-questions.mjs +277 -0
- package/scripts/route-style.mjs +112 -0
- package/scripts/smoke.mjs +83 -0
- package/scripts/test-approvals.mjs +64 -0
- package/scripts/test-ops.mjs +269 -0
- package/scripts/test-plan.mjs +94 -0
- package/scripts/test-route.mjs +63 -0
- package/skills/animation-router/SKILL.md +70 -15
- package/skills/animation-router/references/confirmation.md +82 -0
- package/skills/design-spec/SKILL.md +7 -1
- package/skills/qa-rubric/SKILL.md +13 -0
- package/styles/3b1b/preview.jpg +0 -0
- package/styles/3b1b/theme.py +3 -0
- package/styles/README.md +2 -0
- package/styles/blueprint/theme.py +3 -0
- package/styles/dataviz/style.json +1 -0
- package/styles/dataviz/theme.py +3 -0
- package/styles/registry.json +1 -0
- package/styles/swiss-motion/theme.py +3 -0
- package/styles/whiteboard/theme.py +3 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { execFileSync } from 'node:child_process'
|
|
3
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
|
|
7
|
+
const SCRIPT = new URL('./approvals.mjs', import.meta.url).pathname
|
|
8
|
+
const project = mkdtempSync(join(tmpdir(), 'dsh-approvals-'))
|
|
9
|
+
let failed = 0
|
|
10
|
+
|
|
11
|
+
function run(args) {
|
|
12
|
+
try {
|
|
13
|
+
const out = execFileSync(process.execPath, [SCRIPT, ...args], { encoding: 'utf8', stdio: 'pipe' })
|
|
14
|
+
return { code: 0, out }
|
|
15
|
+
} catch (error) {
|
|
16
|
+
return { code: error.status ?? 1, out: `${error.stdout ?? ''}${error.stderr ?? ''}` }
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function test(name, fn) {
|
|
21
|
+
try {
|
|
22
|
+
fn()
|
|
23
|
+
console.log(`ok ${name}`)
|
|
24
|
+
} catch (error) {
|
|
25
|
+
failed++
|
|
26
|
+
console.log(`FAIL ${name}`)
|
|
27
|
+
console.error(` ${String(error.message).split('\n')[0]}`)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
run(['init', project, '--mode', 'light'])
|
|
32
|
+
|
|
33
|
+
test('刚 init 时 check 不通过', () => {
|
|
34
|
+
assert.equal(run(['check', project]).code, 1)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('不能跳过前置阶段', () => {
|
|
38
|
+
assert.equal(run(['set', project, 'script_storyboard', 'approved']).code, 1)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('按顺序全部确认后 check 通过', () => {
|
|
42
|
+
assert.equal(run(['set', project, 'requirements', 'approved', '--note', '需求']).code, 0)
|
|
43
|
+
assert.equal(run(['set', project, 'script_storyboard', 'approved', '--note', '脚本+分镜']).code, 0)
|
|
44
|
+
assert.equal(run(['set', project, 'look', 'approved', '--note', '3 帧']).code, 0)
|
|
45
|
+
assert.equal(run(['set', project, 'start', 'approved', '--note', '可以开始']).code, 0)
|
|
46
|
+
assert.equal(run(['check', project]).code, 0)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('standard 模式包含独立脚本/分镜/交付', () => {
|
|
50
|
+
const dir = mkdtempSync(join(tmpdir(), 'dsh-approvals-std-'))
|
|
51
|
+
assert.equal(run(['init', dir, '--mode', 'standard']).code, 0)
|
|
52
|
+
assert.match(run(['show', dir]).out, /脚本/)
|
|
53
|
+
assert.match(run(['show', dir]).out, /分镜/)
|
|
54
|
+
assert.match(run(['show', dir]).out, /交付清单/)
|
|
55
|
+
rmSync(dir, { recursive: true, force: true })
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
rmSync(project, { recursive: true, force: true })
|
|
59
|
+
|
|
60
|
+
if (failed > 0) {
|
|
61
|
+
console.error(`\n${failed} test(s) failed`)
|
|
62
|
+
process.exit(1)
|
|
63
|
+
}
|
|
64
|
+
console.log('\ntest-approvals passed')
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 纯 Node 单元测试:不联网、不跑 Python、不碰视频。
|
|
3
|
+
*
|
|
4
|
+
* 钉住 lib/ 里那些"模型看不到的不变量":
|
|
5
|
+
* - summarizeRender 能否从渲染器输出里稳定抽出时长/分辨率/布局问题;
|
|
6
|
+
* - normalizePost 是否把脏参数挡在 ffmpeg 之前;
|
|
7
|
+
* - probe 的网格、亮度解析与空镜判据是否符合 limited range;
|
|
8
|
+
* - pluginSource 报的是不是当前这份代码。
|
|
9
|
+
*
|
|
10
|
+
* 用法:node scripts/test-ops.mjs
|
|
11
|
+
*/
|
|
12
|
+
import assert from 'node:assert/strict'
|
|
13
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, utimesSync, writeFileSync } from 'node:fs'
|
|
14
|
+
import { tmpdir } from 'node:os'
|
|
15
|
+
import { join } from 'node:path'
|
|
16
|
+
|
|
17
|
+
import { assertApproved, readApprovals } from '../lib/approvals.js'
|
|
18
|
+
import { pluginSource } from '../lib/doctor.js'
|
|
19
|
+
import { importFresh } from '../lib/fresh.js'
|
|
20
|
+
import {
|
|
21
|
+
isBlankFrame,
|
|
22
|
+
normalizePost,
|
|
23
|
+
normalizeTimes,
|
|
24
|
+
parseInspectOutput,
|
|
25
|
+
parseSignalStats,
|
|
26
|
+
summarizeRender,
|
|
27
|
+
tileGrid,
|
|
28
|
+
} from '../lib/ops.js'
|
|
29
|
+
|
|
30
|
+
let failed = 0
|
|
31
|
+
|
|
32
|
+
function test(name, fn) {
|
|
33
|
+
try {
|
|
34
|
+
fn()
|
|
35
|
+
console.log(`ok ${name}`)
|
|
36
|
+
} catch (error) {
|
|
37
|
+
failed++
|
|
38
|
+
console.log(`FAIL ${name}`)
|
|
39
|
+
console.error(` ${String(error.message).split('\n')[0]}`)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// —— summarizeRender:真实渲染器输出的一行摘要 ——
|
|
44
|
+
test('summarizeRender 读出声画参数', () => {
|
|
45
|
+
const summary = summarizeRender(
|
|
46
|
+
'Derivative.mp4 12.5 s, 1280x720 at 30 fps, 4.2 MB, rendered in 61.3 s\n',
|
|
47
|
+
'',
|
|
48
|
+
)
|
|
49
|
+
assert.equal(summary.video, 'Derivative.mp4')
|
|
50
|
+
assert.equal(summary.durationSec, 12.5)
|
|
51
|
+
assert.equal(summary.width, 1280)
|
|
52
|
+
assert.equal(summary.height, 720)
|
|
53
|
+
assert.equal(summary.fps, 30)
|
|
54
|
+
assert.equal(summary.megabytes, 4.2)
|
|
55
|
+
assert.equal(summary.renderedSec, 61.3)
|
|
56
|
+
assert.deepEqual(summary.layoutProblems, [])
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('summarizeRender 也认 stderr', () => {
|
|
60
|
+
const summary = summarizeRender('', 'out.mp4 1.0 s, 640x360 at 15 fps, 0.1 MB')
|
|
61
|
+
assert.equal(summary.video, 'out.mp4')
|
|
62
|
+
assert.equal(summary.durationSec, 1)
|
|
63
|
+
assert.equal(summary.renderedSec, null)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('summarizeRender 抽布局问题', () => {
|
|
67
|
+
const summary = summarizeRender(
|
|
68
|
+
'noise\n [1] Text "Euler" overflows the frame\n [2] Axes label collides\n',
|
|
69
|
+
'',
|
|
70
|
+
)
|
|
71
|
+
assert.deepEqual(summary.layoutProblems, [
|
|
72
|
+
'Text "Euler" overflows the frame',
|
|
73
|
+
'Axes label collides',
|
|
74
|
+
])
|
|
75
|
+
assert.equal(summary.video, null)
|
|
76
|
+
assert.equal(summary.durationSec, null)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('summarizeRender 不把无关方括号当问题', () => {
|
|
80
|
+
const summary = summarizeRender('[build] starting\n[1]ok\n', '')
|
|
81
|
+
assert.deepEqual(summary.layoutProblems, ['ok'])
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
// —— normalizePost:脏参数一律夹到合法区间 ——
|
|
85
|
+
test('normalizePost 默认关闭', () => {
|
|
86
|
+
const post = normalizePost()
|
|
87
|
+
assert.deepEqual(post, {
|
|
88
|
+
subframes: 1,
|
|
89
|
+
grain: 0,
|
|
90
|
+
vignette: 0,
|
|
91
|
+
grade: '',
|
|
92
|
+
enabled: false,
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('normalizePost 夹住 subframes', () => {
|
|
97
|
+
assert.equal(normalizePost({ subframes: 99 }).subframes, 8)
|
|
98
|
+
assert.equal(normalizePost({ subframes: 0 }).subframes, 1)
|
|
99
|
+
assert.equal(normalizePost({ subframes: -3 }).subframes, 1)
|
|
100
|
+
assert.equal(normalizePost({ subframes: 'abc' }).subframes, 1)
|
|
101
|
+
assert.equal(normalizePost({ subframes: 3 }).subframes, 3)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('normalizePost 接受布尔简写', () => {
|
|
105
|
+
assert.equal(normalizePost({ grain: true }).grain, 7)
|
|
106
|
+
assert.equal(normalizePost({ vignette: true }).vignette, 4)
|
|
107
|
+
assert.equal(normalizePost({ grain: true }).enabled, true)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('normalizePost 不把负数当"开启"', () => {
|
|
111
|
+
const post = normalizePost({ grain: -5, vignette: -2 })
|
|
112
|
+
assert.equal(post.grain, -5)
|
|
113
|
+
assert.equal(post.enabled, false)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('normalizePost 只认已知调色预设', () => {
|
|
117
|
+
assert.equal(normalizePost({ grade: 'warm' }).enabled, true)
|
|
118
|
+
assert.equal(normalizePost({ grade: 42 }).enabled, false)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
// —— tileGrid:网格要装得下请求的帧数 ——
|
|
122
|
+
test('tileGrid 按帧数选网格', () => {
|
|
123
|
+
assert.equal(tileGrid(2), '2x1')
|
|
124
|
+
assert.equal(tileGrid(4), '2x2')
|
|
125
|
+
assert.equal(tileGrid(6), '3x2')
|
|
126
|
+
assert.equal(tileGrid(9), '3x3')
|
|
127
|
+
assert.equal(tileGrid(12), '4x3')
|
|
128
|
+
assert.equal(tileGrid(16), '4x4')
|
|
129
|
+
assert.equal(tileGrid(24), '6x4')
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
// —— signalstats:从 ffmpeg 的 metadata 输出里读回每帧亮度范围 ——
|
|
133
|
+
const SIGNAL_FIXTURE = `frame:0 pts:0 pts_time:0
|
|
134
|
+
lavfi.signalstats.YMIN=16
|
|
135
|
+
lavfi.signalstats.YLOW=16
|
|
136
|
+
lavfi.signalstats.YAVG=16
|
|
137
|
+
lavfi.signalstats.YHIGH=16
|
|
138
|
+
lavfi.signalstats.YMAX=16
|
|
139
|
+
frame:1 pts:512 pts_time:0.017
|
|
140
|
+
lavfi.signalstats.YMIN=16
|
|
141
|
+
lavfi.signalstats.YLOW=40
|
|
142
|
+
lavfi.signalstats.YAVG=120.5
|
|
143
|
+
lavfi.signalstats.YHIGH=200
|
|
144
|
+
lavfi.signalstats.YMAX=235
|
|
145
|
+
`
|
|
146
|
+
|
|
147
|
+
test('parseSignalStats 逐帧读回 YMIN/YAVG/YMAX', () => {
|
|
148
|
+
const frames = parseSignalStats(SIGNAL_FIXTURE)
|
|
149
|
+
assert.equal(frames.length, 2)
|
|
150
|
+
assert.deepEqual(frames[0], { frame: 0, ymin: 16, yavg: 16, ymax: 16 })
|
|
151
|
+
assert.deepEqual(frames[1], { frame: 1, ymin: 16, yavg: 120.5, ymax: 235 })
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test('parseSignalStats 空输出不报错', () => {
|
|
155
|
+
assert.deepEqual(parseSignalStats(''), [])
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
// —— 空镜判据:limited range 的黑是 16、白是 235 ——
|
|
159
|
+
test('isBlankFrame 认得出纯黑与纯白', () => {
|
|
160
|
+
assert.equal(isBlankFrame({ ymin: 16, ymax: 16 }), true)
|
|
161
|
+
assert.equal(isBlankFrame({ ymin: 16, yavg: 16, ymax: 18 }), true)
|
|
162
|
+
assert.equal(isBlankFrame({ ymin: 235, ymax: 235 }), true)
|
|
163
|
+
assert.equal(isBlankFrame({ ymin: 232, yavg: 234, ymax: 235 }), true)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test('isBlankFrame 不误杀正常画面', () => {
|
|
167
|
+
// 深色画面:黑底 16 + 亮线 235,是最常见的正常帧
|
|
168
|
+
assert.equal(isBlankFrame({ ymin: 16, yavg: 30, ymax: 235 }), false)
|
|
169
|
+
assert.equal(isBlankFrame({ ymin: 16, yavg: 120, ymax: 200 }), false)
|
|
170
|
+
assert.equal(isBlankFrame({}), false)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
// —— 极简模式:解析 manimgx inspect 的输出,只出帧不编码 ——
|
|
174
|
+
const INSPECT_FIXTURE = `/tmp/scene.py: T, 3.70 s, 3 plays
|
|
175
|
+
storyboard: /tmp/sheet.png, /tmp/sheet-2.png
|
|
176
|
+
#0 0.00– 1.20s scene.py:13 Create(Axes)
|
|
177
|
+
layout: 1 problem
|
|
178
|
+
[1] t=0.3–1.5s (scene.py:5) t (Text '长标题'): 1.48 past the right edge
|
|
179
|
+
`
|
|
180
|
+
|
|
181
|
+
test('parseInspectOutput 读出片名/时长/多张 sheet 与 layout 问题', () => {
|
|
182
|
+
const parsed = parseInspectOutput(INSPECT_FIXTURE)
|
|
183
|
+
assert.equal(parsed.scene, 'T')
|
|
184
|
+
assert.equal(parsed.durationSec, 3.7)
|
|
185
|
+
assert.equal(parsed.plays, 3)
|
|
186
|
+
assert.deepEqual(parsed.storyboard, ['/tmp/sheet.png', '/tmp/sheet-2.png'])
|
|
187
|
+
assert.equal(parsed.layoutOk, false)
|
|
188
|
+
assert.equal(parsed.layoutProblems.length, 1)
|
|
189
|
+
assert.match(parsed.layoutProblems[0], /past the right edge/)
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
test('parseInspectOutput 认 0 problem 与空输出', () => {
|
|
193
|
+
const ok = parseInspectOutput('scene.py: T, 1.00 s, 1 play\nstoryboard: a.png\nlayout: no problems in 4 samples\n')
|
|
194
|
+
assert.equal(ok.layoutOk, true)
|
|
195
|
+
assert.deepEqual(ok.layoutProblems, [])
|
|
196
|
+
assert.equal(parseInspectOutput('').scene, null)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
test('normalizeTimes 只放行秒数与 end', () => {
|
|
200
|
+
assert.deepEqual(normalizeTimes(['1.5', 'end', 2]), ['1.5', 'end', '2'])
|
|
201
|
+
assert.deepEqual(normalizeTimes(undefined), [])
|
|
202
|
+
assert.throws(() => normalizeTimes(['-1']))
|
|
203
|
+
assert.throws(() => normalizeTimes(['abc']))
|
|
204
|
+
assert.throws(() => normalizeTimes(Array.from({ length: 25 }, (_, i) => String(i))))
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
// —— pluginSource:报的就是当前这份代码 ——
|
|
208
|
+
test('pluginSource 版本与 lib 路径自洽', () => {
|
|
209
|
+
const source = pluginSource()
|
|
210
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
|
|
211
|
+
assert.equal(source.version, pkg.version)
|
|
212
|
+
assert.ok(['npm 安装', '本地 checkout'].includes(source.kind))
|
|
213
|
+
assert.ok(existsSync(join(source.lib, 'index.js')))
|
|
214
|
+
assert.ok(existsSync(join(source.lib, 'doctor.js')))
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
// —— approvals:工具层的开工确认硬门 ——
|
|
218
|
+
{
|
|
219
|
+
const dir = mkdtempSync(join(tmpdir(), 'dsh-approvals-gate-'))
|
|
220
|
+
const scene = join(dir, 'scene.py')
|
|
221
|
+
writeFileSync(scene, 'pass\n')
|
|
222
|
+
let missing = false
|
|
223
|
+
try { assertApproved(scene) } catch { missing = true }
|
|
224
|
+
if (missing) console.log('ok assertApproved 拒绝缺少 APPROVALS.json')
|
|
225
|
+
else { failed++; console.log('FAIL assertApproved 拒绝缺少 APPROVALS.json') }
|
|
226
|
+
|
|
227
|
+
mkdirSync(dir, { recursive: true })
|
|
228
|
+
writeFileSync(join(dir, 'APPROVALS.json'), JSON.stringify({
|
|
229
|
+
mode: 'standard',
|
|
230
|
+
stages: {
|
|
231
|
+
requirements: { label: '需求', status: 'approved' },
|
|
232
|
+
delivery: { label: '交付清单', status: 'pending' },
|
|
233
|
+
start: { label: '开工', status: 'pending' },
|
|
234
|
+
},
|
|
235
|
+
}))
|
|
236
|
+
const report = readApprovals(scene)
|
|
237
|
+
if (report.status === 'pending' && report.missing.includes('交付清单')) {
|
|
238
|
+
console.log('ok readApprovals 报出缺失的交付清单')
|
|
239
|
+
} else { failed++; console.log('FAIL readApprovals 报出缺失的交付清单') }
|
|
240
|
+
|
|
241
|
+
let blocked = false
|
|
242
|
+
try { assertApproved(scene) } catch { blocked = true }
|
|
243
|
+
if (blocked) console.log('ok assertApproved 拒绝 pending 阶段')
|
|
244
|
+
else { failed++; console.log('FAIL assertApproved 拒绝 pending 阶段') }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// —— importFresh:改文件后按 mtime 重新 import(热加载的基础) ——
|
|
248
|
+
{
|
|
249
|
+
const dir = mkdtempSync(join(tmpdir(), 'dsh-fresh-'))
|
|
250
|
+
const file = join(dir, 'mod.mjs')
|
|
251
|
+
const url = new URL(`file://${file}`)
|
|
252
|
+
writeFileSync(file, 'export const value = 1\n')
|
|
253
|
+
const first = await importFresh(url)
|
|
254
|
+
writeFileSync(file, 'export const value = 2\n')
|
|
255
|
+
utimesSync(file, new Date(), new Date(Date.now() + 2000))
|
|
256
|
+
const second = await importFresh(url)
|
|
257
|
+
if (first.value === 1 && second.value === 2) {
|
|
258
|
+
console.log('ok importFresh 按 mtime 重新加载')
|
|
259
|
+
} else {
|
|
260
|
+
failed++
|
|
261
|
+
console.log('FAIL importFresh 按 mtime 重新加载')
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (failed > 0) {
|
|
266
|
+
console.error(`\n${failed} test(s) failed`)
|
|
267
|
+
process.exit(1)
|
|
268
|
+
}
|
|
269
|
+
console.log('\ntest-ops passed')
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { planQuestions } from './plan-questions.mjs'
|
|
3
|
+
|
|
4
|
+
let failed = 0
|
|
5
|
+
function test(name, fn) {
|
|
6
|
+
try {
|
|
7
|
+
fn()
|
|
8
|
+
console.log(`ok ${name}`)
|
|
9
|
+
} catch (error) {
|
|
10
|
+
failed++
|
|
11
|
+
console.log(`FAIL ${name}`)
|
|
12
|
+
console.error(` ${String(error.message).split('\n')[0]}`)
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const vertical = planQuestions('做一条竖屏抖音短片,讲导数,要旁白')
|
|
17
|
+
|
|
18
|
+
test('竖屏 + 旁白:已推断的画幅/交付不再问', () => {
|
|
19
|
+
const ids = vertical.questions.map((q) => q.id)
|
|
20
|
+
assert.deepEqual(ids, ['style', 'audience', 'narration'])
|
|
21
|
+
assert.equal(vertical.assumptions.aspect, '9:16')
|
|
22
|
+
assert.equal(vertical.assumptions.delivery, 'narrated')
|
|
23
|
+
assert.equal(vertical.assumptions.captions, true)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('风格选项来自路由,至少两个', () => {
|
|
27
|
+
const style = vertical.questions.find((q) => q.id === 'style')
|
|
28
|
+
assert.ok(style)
|
|
29
|
+
assert.ok(style.options.length >= 2)
|
|
30
|
+
assert.match(style.options[0].label, /数据叙事/)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('受众选项按竖屏平台排序', () => {
|
|
34
|
+
const audience = vertical.questions.find((q) => q.id === 'audience')
|
|
35
|
+
assert.ok(audience)
|
|
36
|
+
assert.match(audience.options[0].label, /大众科普/)
|
|
37
|
+
assert.equal(vertical.assumptions.audience, 'general')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('旁白给语言 + 声音选项', () => {
|
|
41
|
+
const narration = vertical.questions.find((q) => q.id === 'narration')
|
|
42
|
+
assert.ok(narration)
|
|
43
|
+
assert.match(narration.options[0].label, /中文 · 中性/)
|
|
44
|
+
assert.equal(vertical.assumptions.narration.voice, 'neutral')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('点名风格时不再问风格', () => {
|
|
48
|
+
const named = planQuestions('用 3b1b 讲矩阵')
|
|
49
|
+
assert.equal(named.questions.some((q) => q.id === 'style'), false)
|
|
50
|
+
assert.equal(named.assumptions.style, '3b1b')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('几何证明:风格优先 blueprint,且不超过 4 题', () => {
|
|
54
|
+
const p = planQuestions('三角形 证明')
|
|
55
|
+
const style = p.questions.find((q) => q.id === 'style')
|
|
56
|
+
assert.ok(style)
|
|
57
|
+
assert.match(style.options[0].label, /蓝图/)
|
|
58
|
+
assert.ok(p.questions.length <= 4)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('完全没命中时给默认 + 让你决定', () => {
|
|
62
|
+
const unknown = planQuestions('做个咖啡广告')
|
|
63
|
+
const style = unknown.questions.find((q) => q.id === 'style')
|
|
64
|
+
assert.ok(style)
|
|
65
|
+
assert.ok(style.options.some((o) => o.label.includes('让你决定')))
|
|
66
|
+
assert.equal(unknown.questions.some((q) => q.id === 'audience'), false)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('时长从 brief 推断', () => {
|
|
70
|
+
assert.equal(planQuestions('做一条 30 秒的几何证明').assumptions.durationSec, 30)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('brief 已说明画幅时不重复问', () => {
|
|
74
|
+
const p = planQuestions('做一条 16:9 的欧拉公式短片')
|
|
75
|
+
assert.equal(p.questions.some((q) => q.id === 'aspect'), false)
|
|
76
|
+
assert.equal(p.assumptions.aspect, '16:9')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test('gateMode 按体量/旁白推断', () => {
|
|
80
|
+
assert.equal(vertical.assumptions.gateMode, 'standard')
|
|
81
|
+
assert.equal(planQuestions('做一条 10 秒欧拉公式,静音').assumptions.gateMode, 'light')
|
|
82
|
+
assert.equal(planQuestions('做一条 90 秒的微积分讲解').assumptions.gateMode, 'full')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('brief 已说明英文旁白时不重复问', () => {
|
|
86
|
+
const p = planQuestions('做一条英文旁白的导数短片')
|
|
87
|
+
assert.equal(p.questions.some((q) => q.id === 'narration'), false)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
if (failed > 0) {
|
|
91
|
+
console.error(`\n${failed} test(s) failed`)
|
|
92
|
+
process.exit(1)
|
|
93
|
+
}
|
|
94
|
+
console.log('\ntest-plan passed')
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { loadRegistry, routeStyle } from './route-style.mjs'
|
|
3
|
+
|
|
4
|
+
const registry = loadRegistry()
|
|
5
|
+
let failed = 0
|
|
6
|
+
|
|
7
|
+
function test(name, fn) {
|
|
8
|
+
try {
|
|
9
|
+
fn()
|
|
10
|
+
console.log(`ok ${name}`)
|
|
11
|
+
} catch (error) {
|
|
12
|
+
failed++
|
|
13
|
+
console.log(`FAIL ${name}`)
|
|
14
|
+
console.error(` ${String(error.message).split('\n')[0]}`)
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
test('点名风格优先', () => {
|
|
19
|
+
const r = routeStyle('用 3b1b 风格讲单位圆', registry)
|
|
20
|
+
assert.equal(r.recommendation, '3b1b')
|
|
21
|
+
assert.equal(r.confidence, 'high')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
test('几何证明路由到 blueprint', () => {
|
|
25
|
+
const r = routeStyle('三角形 证明', registry)
|
|
26
|
+
assert.equal(r.recommendation, 'blueprint')
|
|
27
|
+
assert.equal(r.confidence, 'high')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('欧拉公式路由到 swiss', () => {
|
|
31
|
+
assert.equal(routeStyle('欧拉公式', registry).recommendation, 'swiss-motion')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('科学解释路由到 whiteboard', () => {
|
|
35
|
+
assert.equal(routeStyle('为什么 天空 是 蓝的', registry).recommendation, 'whiteboard')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('无命中时回退默认并要求确认', () => {
|
|
39
|
+
const r = routeStyle('做个咖啡广告', registry)
|
|
40
|
+
assert.equal(r.recommendation, registry.default)
|
|
41
|
+
assert.equal(r.needsConfirmation, true)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('返回 top 3', () => {
|
|
45
|
+
assert.equal(routeStyle('切线', registry).ranked.length, 3)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('registry 元数据与 Lemo style.json 对齐', () => {
|
|
49
|
+
const slugs = registry.styles.map((s) => s.slug).sort()
|
|
50
|
+
assert.deepEqual(slugs, ['3b1b', 'blueprint', 'dataviz', 'swiss-motion', 'whiteboard'])
|
|
51
|
+
for (const style of registry.styles) {
|
|
52
|
+
assert.ok(style.num, `${style.slug} 缺少 num`)
|
|
53
|
+
assert.ok(style.name_en && style.name_cn, `${style.slug} 缺名字`)
|
|
54
|
+
assert.ok(style.category_cn, `${style.slug} 缺分类`)
|
|
55
|
+
assert.ok(Array.isArray(style.uses) && style.uses.length > 0)
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
if (failed > 0) {
|
|
60
|
+
console.error(`\n${failed} test(s) failed`)
|
|
61
|
+
process.exit(1)
|
|
62
|
+
}
|
|
63
|
+
console.log('\ntest-route passed')
|
|
@@ -5,6 +5,27 @@ description: 做代码动画/科普视频的总入口:先判题材(关系还
|
|
|
5
5
|
|
|
6
6
|
# 总入口:先分题材,再选画面层
|
|
7
7
|
|
|
8
|
+
## 0. 需求卡点(只问一次)
|
|
9
|
+
|
|
10
|
+
开工前先推断;能从 brief 看出来的,不要问。需要问时:
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
node scripts/plan-questions.mjs "<brief>" --json
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
把返回的 `questions` 原样交给 `ask_user_question`,`assumptions` 作为默认值。规则:
|
|
17
|
+
|
|
18
|
+
- **画幅**:brief 已说"竖屏/横屏/16:9"就跳过;没说才问,只给推断出的 2 个;
|
|
19
|
+
- **交付**:brief 已说"要旁白"或"静音"就跳过;没说才问;
|
|
20
|
+
- **风格**:从 `styles/registry.json` 路由出的 top 2–3;用户点名则跳过;
|
|
21
|
+
- **受众**:只对数学/科学题材、且 brief 没提"高中/大学/大众"时问;选项按平台排(抖音→大众科普,B站→高中);
|
|
22
|
+
- **旁白**:只在交付为旁白、且 brief 没说"中文/英文/双语"时问;选项是"语言 + 声音";
|
|
23
|
+
- **时长**:从 brief 提取("30 秒"/"1 分钟"),不单独问;
|
|
24
|
+
- 最多 4 题,问完不再追问;没回答的按 `assumptions` 执行。**问完不等于开工**,接着进 §4 确认门。
|
|
25
|
+
|
|
26
|
+
选项是**推出来的**,不是固定清单:画幅看平台线索,交付看有无旁白,风格看 registry 命中,受众看题材与平台。
|
|
27
|
+
用户也可以直接在 `Other` 里写别的。
|
|
28
|
+
|
|
8
29
|
## 1. 判题材
|
|
9
30
|
|
|
10
31
|
| 题材 | 观众记住 | 判据 |
|
|
@@ -34,25 +55,59 @@ description: 做代码动画/科普视频的总入口:先判题材(关系还
|
|
|
34
55
|
|
|
35
56
|
三者都只是素材层;**需要交付时**再统一进 HyperFrames 工程,默认的极简模式不经过 HF。
|
|
36
57
|
|
|
37
|
-
## 4.
|
|
58
|
+
## 4. 确认门(阻塞,分档)
|
|
59
|
+
|
|
60
|
+
写完整场景、出全片之前,按项目体量选一档,逐项确认。**不要跳步,不要用“我先做起来”代替确认。**
|
|
61
|
+
|
|
62
|
+
| 模式 | 适用 | 阶段 |
|
|
63
|
+
|---|---|---|
|
|
64
|
+
| `light`(默认) | ≤15 s、无旁白、一个结论 | 需求 → **脚本+分镜(合并)** → 视觉 → 开工 |
|
|
65
|
+
| `standard` | 15–60 s,或有旁白 | 需求 → 脚本 → 分镜 → 视觉 → 交付 → 开工 |
|
|
66
|
+
| `full` | >60 s、多段、复杂旁白 | 需求 → 脚本 → 分镜 → **animatic** → 视觉 → 交付 → 开工 |
|
|
67
|
+
|
|
68
|
+
- 脚本:逐句旁白、上屏文字、公式(先过 `formula_check`);
|
|
69
|
+
- 分镜:镜头号、起止秒、画面、运动、字幕/旁白、验收点;
|
|
70
|
+
- 视觉:`DESIGN.md` + 3 张风格帧(真实代码,只出帧);
|
|
71
|
+
- 交付清单:画幅 / fps / 时长 / 文件 / 音频 / 字幕;
|
|
72
|
+
- animatic:带时长的分镜 + 临时旁白,只在 `full` 或用户要求时做。
|
|
73
|
+
|
|
74
|
+
用账本记录,别靠上下文记忆:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
node scripts/approvals.mjs init <project> --mode light
|
|
78
|
+
node scripts/approvals.mjs set <project> script_storyboard approved --note "用户确认"
|
|
79
|
+
node scripts/approvals.mjs check <project> # 未全部 approved 时退出 1
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
- `check` 不通过,不进入执行;
|
|
83
|
+
- `start` 是独立阶段,只有用户**明确**说“可以开始 / 开始做 / 按这个执行 / 开工”才能置为 approved;
|
|
84
|
+
- “好 / 可以 / 行”如果只是回答某个选项,**不算**整体开工;
|
|
85
|
+
- 不确定就问:“脚本、分镜、风格帧都确认了,现在开始执行吗?”
|
|
86
|
+
|
|
87
|
+
模板与清单见 [references/confirmation.md](references/confirmation.md)。
|
|
88
|
+
|
|
89
|
+
## 5. 执行顺序(确认后才开始)
|
|
38
90
|
|
|
39
91
|
```
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
92
|
+
0. 最小场景:只放 6–8 个关键构图,motion_frames 出 sheet(首帧预算 ≤60s)
|
|
93
|
+
1. 补全场景代码 + formula_check
|
|
94
|
+
2. motion_frames(3–6 帧,局部改动后)
|
|
95
|
+
3. 确认过的分镜帧(6–9 帧,若要)
|
|
96
|
+
4. motion_render(draft → final)
|
|
97
|
+
5. HyperFrames composition(字幕/旁白/混音;需要时才进)
|
|
98
|
+
6. hyperframes check / motion_probe(只对最终成片跑一次)
|
|
47
99
|
```
|
|
48
100
|
|
|
49
|
-
|
|
50
|
-
`motion_frames` 内部是 `manimgx inspect`,只画请求的时刻并做版式检查,几秒出图;
|
|
101
|
+
`motion_frames` 内部是 `manimgx inspect`,只画请求的时刻并做版式检查;
|
|
51
102
|
`motion_probe` 是给已有 mp4 做体检的,不要用它替代看帧。
|
|
52
103
|
|
|
53
|
-
##
|
|
104
|
+
## 6. 五条硬规则
|
|
54
105
|
|
|
55
|
-
1.
|
|
56
|
-
2.
|
|
57
|
-
3.
|
|
58
|
-
4.
|
|
106
|
+
1. **没有明确开工指令,不执行**:没听到“可以开始”就不写完整场景、不出全片;
|
|
107
|
+
2. **没有 DESIGN.md 或参考语法,不开工**——否则每次都是同一个默认 look;
|
|
108
|
+
3. **默认先帧后片**:先用 `motion_frames` 把画面改对,再决定要不要渲染;
|
|
109
|
+
4. **字幕放在 composition 层**,不烧进 Manim 或 canvas;
|
|
110
|
+
5. **公式先过 `formula_check`**,再进画面;
|
|
111
|
+
6. **首帧预算 ≤60s**:先最小场景 + 构图 sheet,60s 看不到第一帧就停;
|
|
112
|
+
7. **只做局部 edit**:改动 <50% 不重写整个文件;
|
|
113
|
+
8. **图片/上下文预算**:每次迭代 1 张 sheet(约 960px),上下文 ≤100k。
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# 确认门清单
|
|
2
|
+
|
|
3
|
+
在写完整场景、出全片之前,逐项确认。每一轮只推进一项;用户改了哪项,就回到哪项重确认。
|
|
4
|
+
|
|
5
|
+
## 0. 选模式
|
|
6
|
+
|
|
7
|
+
| 模式 | 适用 | 阶段 |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| `light` | ≤15 s、无旁白、一个结论 | 需求 → 脚本+分镜 → 视觉 → 开工 |
|
|
10
|
+
| `standard` | 15–60 s,或有旁白 | 需求 → 脚本 → 分镜 → 视觉 → 交付 → 开工 |
|
|
11
|
+
| `full` | >60 s、多段、复杂旁白 | 需求 → 脚本 → 分镜 → animatic → 视觉 → 交付 → 开工 |
|
|
12
|
+
|
|
13
|
+
`light` 把“脚本”和“分镜”合成一张表:每个镜头一行,写秒数、画面、上屏文字/公式。
|
|
14
|
+
|
|
15
|
+
## 1. 需求
|
|
16
|
+
|
|
17
|
+
- [ ] 画幅与像素:16:9 / 9:16 / 1:1
|
|
18
|
+
- [ ] 时长
|
|
19
|
+
- [ ] 交付:先出帧 / 静音 mp4 / 旁白 + 字幕(HF)
|
|
20
|
+
- [ ] 风格(或“你决定”)
|
|
21
|
+
- [ ] 受众(数学/科学题材)
|
|
22
|
+
- [ ] 旁白语言与声音(仅旁白交付)
|
|
23
|
+
|
|
24
|
+
## 2. 脚本 / 文案
|
|
25
|
+
|
|
26
|
+
| # | 旁白(逐句) | 上屏文字 | 公式 | 备注 |
|
|
27
|
+
|---|---|---|---|---|
|
|
28
|
+
| 1 | | | | |
|
|
29
|
+
|
|
30
|
+
- 公式先过 `formula_check`;
|
|
31
|
+
- 旁白按“一句一个意思”拆,标出预计秒数;
|
|
32
|
+
- 没有旁白就只填上屏文字与公式。
|
|
33
|
+
|
|
34
|
+
## 3. 分镜
|
|
35
|
+
|
|
36
|
+
| # | 起止秒 | 画面 | 运动 | 字幕 / 旁白 | 验收点 |
|
|
37
|
+
|---|---|---|---|---|---|
|
|
38
|
+
| 1 | 0.0–2.0 | | | | |
|
|
39
|
+
|
|
40
|
+
- 镜头数按内容定,不为了凑时长加镜头;
|
|
41
|
+
- 每个镜头写清“观众看到什么变化”;
|
|
42
|
+
- 结论镜头要留够阅读时间(≥1.2 s)。
|
|
43
|
+
|
|
44
|
+
## 4. 视觉
|
|
45
|
+
|
|
46
|
+
- [ ] `DESIGN.md`:颜色、字体、禁用项、参考
|
|
47
|
+
- [ ] 3 张风格帧:开场 / 转折 / 结论(真实代码出帧,不渲染视频)
|
|
48
|
+
- [ ] 风格帧通过 `motion_frames` 的 layout 检查
|
|
49
|
+
|
|
50
|
+
## 5. 交付清单
|
|
51
|
+
|
|
52
|
+
- [ ] 尺寸 / fps / 时长
|
|
53
|
+
- [ ] 文件:mp4 / `.srt` / 场景源码 / DESIGN.md / 分镜表
|
|
54
|
+
- [ ] 音频:无 / 旁白 / 音乐;响度目标
|
|
55
|
+
- [ ] 字幕:烧录 / 外挂 / 两者
|
|
56
|
+
|
|
57
|
+
## 6. 账本
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
node scripts/approvals.mjs init <project> --mode light
|
|
61
|
+
node scripts/approvals.mjs set <project> <stage> approved --note "用户确认"
|
|
62
|
+
node scripts/approvals.mjs check <project>
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`check` 未全部 approved 时退出 1;不能跳过前置阶段;`start` 必须是用户明确授权。
|
|
66
|
+
|
|
67
|
+
## 7. 开工指令
|
|
68
|
+
|
|
69
|
+
只有用户**明确**说出下面这类话,才进入执行:
|
|
70
|
+
|
|
71
|
+
- “可以开始”
|
|
72
|
+
- “开始做”
|
|
73
|
+
- “按这个执行”
|
|
74
|
+
- “开工”
|
|
75
|
+
|
|
76
|
+
以下**不算**整体开工:
|
|
77
|
+
|
|
78
|
+
- 对某个选项说“好 / 可以 / 行”;
|
|
79
|
+
- 确认了脚本或分镜;
|
|
80
|
+
- 说“看着办”但没有明确授权执行。
|
|
81
|
+
|
|
82
|
+
不确定时问一句:“脚本、分镜、风格帧都确认了,现在开始执行吗?”
|