dsh-motion 0.4.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.
@@ -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')