dsh-motion 0.4.0 → 0.4.2
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 +3 -1
- package/docs/performance-budget.md +2 -2
- package/lib/approvals.js +60 -0
- package/lib/index.js +5 -1
- package/lib/ops.js +19 -11
- package/package.json +2 -1
- 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/styles/3b1b/theme.py +7 -8
- package/styles/README.md +1 -1
- package/styles/blueprint/theme.py +7 -8
- package/styles/dataviz/theme.py +7 -8
- package/styles/swiss-motion/theme.py +7 -8
- package/styles/whiteboard/theme.py +7 -8
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|---|---|
|
|
15
15
|
| `motion_frames` | **极简模式(默认)**:直接从场景出帧,不生成视频、不用 HF;几秒拿到 storyboard PNG 与 layout 问题 |
|
|
16
16
|
| `motion_doctor` | 环境自检(Node / FFmpeg / Python venv / manimgx / sympy / Chrome / HyperFrames) |
|
|
17
|
-
| `motion_render` | 渲染 Manim / manimgx
|
|
17
|
+
| `motion_render` | 渲染 Manim / manimgx 场景;未通过 `APPROVALS.json` 确认门的项目会拒绝渲染 |
|
|
18
18
|
| `motion_probe` | 成片抽帧拼 contact sheet,报告全黑 / 过曝的空镜 |
|
|
19
19
|
| `formula_check` | 用 SymPy 判断两个表达式是否恒等 |
|
|
20
20
|
|
|
@@ -77,6 +77,8 @@
|
|
|
77
77
|
5. 交付清单(尺寸 / fps / 时长 / 文件 / 音频 / 字幕)
|
|
78
78
|
6. **用户明确说“可以开始 / 开始做 / 按这个执行”**
|
|
79
79
|
|
|
80
|
+
`motion_render` 会读项目里的 `APPROVALS.json`;没有,或 `delivery` / `start` 等阶段未全部 approved,直接报错,不会偷偷渲染。
|
|
81
|
+
|
|
80
82
|
没听到第 6 步的明确指令,不写完整场景、不出全片。清单模板见
|
|
81
83
|
[skills/animation-router/references/confirmation.md](skills/animation-router/references/confirmation.md)。
|
|
82
84
|
|
|
@@ -45,8 +45,8 @@
|
|
|
45
45
|
|
|
46
46
|
已完成:
|
|
47
47
|
|
|
48
|
-
- [x] `
|
|
49
|
-
- [x] post 强制 CFR
|
|
48
|
+
- [x] 格式契约:`apply_config()` 只设背景色,不再覆盖分辨率/帧率;`motion_render` 的 `width/height/fps` 与 CLI 的 `-r/--fps` 生效;
|
|
49
|
+
- [x] CFR:post 强制 CFR;所有成片按 `nb_read_frames` 对比期望帧数,少帧时用 `tpad + CFR` 补齐并复验;
|
|
50
50
|
- [x] storyboard 顶部 caption 条加提示,避免误判“裁切”。
|
|
51
51
|
|
|
52
52
|
明确不做:
|
package/lib/approvals.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 读取项目里的 APPROVALS.json,供工具层做“开工确认”硬门。
|
|
3
|
+
*
|
|
4
|
+
* 脚本 scripts/approvals.mjs 负责写;这里只读,避免模型绕过。
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
7
|
+
import { dirname, join, resolve } from 'node:path'
|
|
8
|
+
|
|
9
|
+
const FILE = 'APPROVALS.json'
|
|
10
|
+
|
|
11
|
+
export function findApprovals(file) {
|
|
12
|
+
let dir = dirname(resolve(file))
|
|
13
|
+
for (let i = 0; i < 4; i += 1) {
|
|
14
|
+
const candidate = join(dir, FILE)
|
|
15
|
+
if (existsSync(candidate)) return candidate
|
|
16
|
+
const parent = dirname(dir)
|
|
17
|
+
if (parent === dir) break
|
|
18
|
+
dir = parent
|
|
19
|
+
}
|
|
20
|
+
return null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function readApprovals(file) {
|
|
24
|
+
const path = findApprovals(file)
|
|
25
|
+
if (!path) return { status: 'missing', path: null, missing: [], mode: null }
|
|
26
|
+
const data = JSON.parse(readFileSync(path, 'utf8'))
|
|
27
|
+
const stages = Object.entries(data.stages ?? {})
|
|
28
|
+
const missing = stages
|
|
29
|
+
.filter(([, value]) => value.status !== 'approved')
|
|
30
|
+
.map(([key, value]) => value.label ?? key)
|
|
31
|
+
return {
|
|
32
|
+
status: missing.length ? 'pending' : 'approved',
|
|
33
|
+
path,
|
|
34
|
+
mode: data.mode ?? null,
|
|
35
|
+
project: data.project ?? null,
|
|
36
|
+
missing,
|
|
37
|
+
data,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function assertApproved(file, { allow = false } = {}) {
|
|
42
|
+
if (allow) return null
|
|
43
|
+
const report = readApprovals(file)
|
|
44
|
+
if (report.status === 'missing') {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`未找到 ${FILE}:这个项目还没过需求卡点/确认门。\n`
|
|
47
|
+
+ '先跑:node scripts/plan-questions.mjs "<需求>" --json\n'
|
|
48
|
+
+ '再初始化:node scripts/approvals.mjs init <project> --mode light|standard|full\n'
|
|
49
|
+
+ '确认后把 delivery/start 等阶段置为 approved;或设置 DSH_MOTION_ALLOW_UNCONFIRMED=1 仅用于调试。',
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
if (report.status === 'pending') {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`开工确认未通过(${report.path}):还缺 ${report.missing.join('、')}。\n`
|
|
55
|
+
+ '用 node scripts/approvals.mjs set <project> <stage> approved --note "用户确认" 更新;\n'
|
|
56
|
+
+ '全部 approved 后才能渲染。',
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
return report
|
|
60
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -15,6 +15,7 @@ import { appendFileSync, mkdirSync } from 'node:fs'
|
|
|
15
15
|
import { homedir } from 'node:os'
|
|
16
16
|
import { join } from 'node:path'
|
|
17
17
|
|
|
18
|
+
import { assertApproved } from './approvals.js'
|
|
18
19
|
import { importFresh } from './fresh.js'
|
|
19
20
|
|
|
20
21
|
const OPS_URL = new URL('./ops.js', import.meta.url)
|
|
@@ -91,7 +92,7 @@ export function apply(ctx, config = {}) {
|
|
|
91
92
|
ctx.tools.register({
|
|
92
93
|
name: 'motion_render',
|
|
93
94
|
description:
|
|
94
|
-
'渲染一支数学/科普场景。engine="manimgx"(默认,数学/公式/坐标,无需 LaTeX,自带 layout 检查)或 "manim"(Manim CE 回退)。post
|
|
95
|
+
'渲染一支数学/科普场景。engine="manimgx"(默认,数学/公式/坐标,无需 LaTeX,自带 layout 检查)或 "manim"(Manim CE 回退)。post 可加运动模糊/颗粒/暗角。返回时长/体积/布局问题清单。未通过 APPROVALS.json 确认门的项目会拒绝渲染。',
|
|
95
96
|
parameters: {
|
|
96
97
|
type: 'object',
|
|
97
98
|
properties: {
|
|
@@ -120,6 +121,9 @@ export function apply(ctx, config = {}) {
|
|
|
120
121
|
output: textOutput,
|
|
121
122
|
timeoutMs: 900_000,
|
|
122
123
|
async execute(args) {
|
|
124
|
+
assertApproved(args.file, {
|
|
125
|
+
allow: config.allowUnconfirmed === true || process.env.DSH_MOTION_ALLOW_UNCONFIRMED === '1',
|
|
126
|
+
})
|
|
123
127
|
const { renderScene } = await ops()
|
|
124
128
|
const result = await renderScene(config, args)
|
|
125
129
|
log(`render ${result.output} ${result.durationSec}s engine=${result.engine} problems=${result.layoutProblems.length}`)
|
package/lib/ops.js
CHANGED
|
@@ -106,7 +106,7 @@ export async function renderScene(config, args) {
|
|
|
106
106
|
|
|
107
107
|
// post/ffmpeg 可能因 tmix/丢帧把时长压短;用 CFR 补齐并校验,避免"30s 变 27.4s"。
|
|
108
108
|
if (summary.durationSec) {
|
|
109
|
-
const fixed = await
|
|
109
|
+
const fixed = await ensureCfr(config, summary, fps)
|
|
110
110
|
if (fixed) summary.normalized = true
|
|
111
111
|
}
|
|
112
112
|
|
|
@@ -114,22 +114,25 @@ export async function renderScene(config, args) {
|
|
|
114
114
|
return summary
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
-
/**
|
|
118
|
-
async function
|
|
117
|
+
/** 成片时长或帧数不对(VFR 少帧)时,用 tpad + CFR 修正;返回是否修过。 */
|
|
118
|
+
async function ensureCfr(config, summary, fps) {
|
|
119
119
|
const expected = Number(summary.durationSec)
|
|
120
120
|
if (!Number.isFinite(expected) || expected <= 0) return false
|
|
121
121
|
const file = summary.output
|
|
122
122
|
const before = await probeOutput(config, file)
|
|
123
|
-
const
|
|
124
|
-
|
|
123
|
+
const expectedFrames = Math.round(expected * fps)
|
|
124
|
+
const frameGap = Number.isFinite(before.frames) ? Math.abs(before.frames - expectedFrames) : 0
|
|
125
|
+
const durationGap = Math.abs(expected - before.durationSec)
|
|
126
|
+
if (frameGap <= 1 && durationGap <= 0.08) return false
|
|
125
127
|
|
|
128
|
+
const delta = expected - before.durationSec
|
|
126
129
|
const tmp = `${file}.cfr.mp4`
|
|
127
130
|
const chain = []
|
|
128
131
|
if (delta > 0.05) chain.push(`tpad=stop_mode=clone:stop_duration=${delta.toFixed(3)}`)
|
|
129
132
|
chain.push(`fps=${fps}`)
|
|
130
133
|
const args = [
|
|
131
134
|
'-y', '-loglevel', 'error', '-i', file,
|
|
132
|
-
'-vf', chain.join(','), '-fps_mode', 'cfr',
|
|
135
|
+
'-vf', chain.join(','), '-fps_mode', 'cfr', '-r', String(fps),
|
|
133
136
|
'-c:v', 'libx264', '-crf', '18', '-pix_fmt', 'yuv420p', '-an',
|
|
134
137
|
]
|
|
135
138
|
if (delta < -0.05) args.push('-t', expected.toFixed(3))
|
|
@@ -137,8 +140,9 @@ async function ensureDuration(config, summary, fps) {
|
|
|
137
140
|
await exec(binaryOf(config, 'ffmpeg'), args, { timeout: 900_000, maxBuffer: 8 * 1024 * 1024 })
|
|
138
141
|
|
|
139
142
|
const after = await probeOutput(config, tmp)
|
|
140
|
-
|
|
141
|
-
|
|
143
|
+
const afterGap = Number.isFinite(after.frames) ? Math.abs(after.frames - expectedFrames) : 0
|
|
144
|
+
if (afterGap > 1 || Math.abs(after.durationSec - expected) > 0.05) {
|
|
145
|
+
throw new Error(`CFR 修正后仍不对:期望 ${expectedFrames} 帧 / ${expected}s,实际 ${after.frames} 帧 / ${after.durationSec}s(${tmp})`)
|
|
142
146
|
}
|
|
143
147
|
renameSync(tmp, file)
|
|
144
148
|
Object.assign(summary, after)
|
|
@@ -348,10 +352,10 @@ export async function probeVideo(config, args) {
|
|
|
348
352
|
/** 用 ffprobe 重新读成片参数(post 之后帧率、时长、体积都可能与中间产物不同)。 */
|
|
349
353
|
export async function probeOutput(config, file) {
|
|
350
354
|
const { stdout } = await exec(binaryOf(config, 'ffprobe'), [
|
|
351
|
-
'-v', 'error', '-select_streams', 'v:0',
|
|
352
|
-
'-show_entries', 'stream=width,height,r_frame_rate:format=duration,size',
|
|
355
|
+
'-v', 'error', '-count_frames', '-select_streams', 'v:0',
|
|
356
|
+
'-show_entries', 'stream=width,height,r_frame_rate,avg_frame_rate,nb_read_frames:format=duration,size',
|
|
353
357
|
'-of', 'json', file,
|
|
354
|
-
], { timeout:
|
|
358
|
+
], { timeout: 120_000, maxBuffer: 4 * 1024 * 1024 })
|
|
355
359
|
const data = JSON.parse(stdout)
|
|
356
360
|
const stream = data.streams?.[0] ?? {}
|
|
357
361
|
const [num, den = '1'] = String(stream.r_frame_rate ?? '0/1').split('/')
|
|
@@ -363,6 +367,10 @@ export async function probeOutput(config, file) {
|
|
|
363
367
|
width: stream.width ?? null,
|
|
364
368
|
height: stream.height ?? null,
|
|
365
369
|
fps: Number.isFinite(rate) && rate > 0 ? Math.round(rate * 100) / 100 : null,
|
|
370
|
+
avgFps: Number.isFinite(Number(stream.avg_frame_rate?.split?.('/')[0]))
|
|
371
|
+
? Math.round((Number(stream.avg_frame_rate.split('/')[0]) / Number(stream.avg_frame_rate.split('/')[1] ?? 1)) * 100) / 100
|
|
372
|
+
: null,
|
|
373
|
+
frames: Number.isFinite(Number(stream.nb_read_frames)) ? Number(stream.nb_read_frames) : null,
|
|
366
374
|
megabytes: Number.isFinite(size) ? Math.round(size / 1e5) / 10 : null,
|
|
367
375
|
}
|
|
368
376
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-motion",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"description": "Code-generated math & science animations for DeepSeek Harness: frames-first preview, Manim clips, formula checks.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"skills",
|
|
18
18
|
"styles",
|
|
19
19
|
"docs",
|
|
20
|
+
"scripts",
|
|
20
21
|
"cordis.patch.yml",
|
|
21
22
|
"README.md",
|
|
22
23
|
"LICENSE",
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 项目级确认记录:把“用户确认过什么”落成可追溯的 APPROVALS.json。
|
|
4
|
+
*
|
|
5
|
+
* node scripts/approvals.mjs init <project> --mode light
|
|
6
|
+
* node scripts/approvals.mjs set <project> script_storyboard approved --note "用户确认"
|
|
7
|
+
* node scripts/approvals.mjs check <project>
|
|
8
|
+
* node scripts/approvals.mjs show <project>
|
|
9
|
+
*
|
|
10
|
+
* 模式:
|
|
11
|
+
* light ≤15s、无旁白:需求 → 脚本+分镜 → 视觉 → 开工
|
|
12
|
+
* standard 15–60s 或有旁白:需求 → 脚本 → 分镜 → 视觉 → 交付 → 开工
|
|
13
|
+
* full >60s 或多段/复杂旁白:再加 animatic
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
16
|
+
import { join, resolve } from 'node:path'
|
|
17
|
+
|
|
18
|
+
const FILE = 'APPROVALS.json'
|
|
19
|
+
const LABELS = {
|
|
20
|
+
requirements: '需求',
|
|
21
|
+
script: '脚本',
|
|
22
|
+
script_storyboard: '脚本 + 分镜',
|
|
23
|
+
storyboard: '分镜',
|
|
24
|
+
animatic: 'Animatic',
|
|
25
|
+
look: '视觉(DESIGN + 3 帧)',
|
|
26
|
+
delivery: '交付清单',
|
|
27
|
+
start: '开工',
|
|
28
|
+
}
|
|
29
|
+
const MODES = {
|
|
30
|
+
light: ['requirements', 'script_storyboard', 'look', 'start'],
|
|
31
|
+
standard: ['requirements', 'script', 'storyboard', 'look', 'delivery', 'start'],
|
|
32
|
+
full: ['requirements', 'script', 'storyboard', 'animatic', 'look', 'delivery', 'start'],
|
|
33
|
+
}
|
|
34
|
+
const STATUSES = new Set(['pending', 'approved', 'rejected'])
|
|
35
|
+
|
|
36
|
+
function fail(message) {
|
|
37
|
+
console.error(message)
|
|
38
|
+
process.exit(1)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function pathOf(project) {
|
|
42
|
+
return join(resolve(project), FILE)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function read(project) {
|
|
46
|
+
const file = pathOf(project)
|
|
47
|
+
if (!existsSync(file)) fail(`找不到 ${file};先运行 init`)
|
|
48
|
+
return JSON.parse(readFileSync(file, 'utf8'))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function write(project, data) {
|
|
52
|
+
const file = pathOf(project)
|
|
53
|
+
mkdirSync(resolve(project), { recursive: true })
|
|
54
|
+
writeFileSync(file, JSON.stringify(data, null, 2) + '\n')
|
|
55
|
+
return file
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function init(project, mode) {
|
|
59
|
+
if (!MODES[mode]) fail(`mode 只能是 ${Object.keys(MODES).join(' / ')}`)
|
|
60
|
+
const file = pathOf(project)
|
|
61
|
+
if (existsSync(file)) fail(`${file} 已存在;要重建请先删除`)
|
|
62
|
+
const stages = {}
|
|
63
|
+
for (const stage of MODES[mode]) {
|
|
64
|
+
stages[stage] = { label: LABELS[stage], status: 'pending', at: null, by: null, note: '' }
|
|
65
|
+
}
|
|
66
|
+
const data = { version: 1, project: project, mode, createdAt: new Date().toISOString(), stages }
|
|
67
|
+
write(project, data)
|
|
68
|
+
console.log(`created ${file} (${mode})`)
|
|
69
|
+
console.log(`stages: ${MODES[mode].join(' → ')}`)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function set(project, stage, status, options) {
|
|
73
|
+
if (!STATUSES.has(status)) fail(`status 只能是 ${[...STATUSES].join(' / ')}`)
|
|
74
|
+
const data = read(project)
|
|
75
|
+
const order = MODES[data.mode]
|
|
76
|
+
if (!data.stages[stage]) fail(`${data.mode} 模式没有阶段: ${stage}(有: ${order.join(', ')})`)
|
|
77
|
+
if (status === 'approved') {
|
|
78
|
+
const index = order.indexOf(stage)
|
|
79
|
+
const missing = order.slice(0, index).filter((key) => data.stages[key].status !== 'approved')
|
|
80
|
+
if (missing.length) fail(`不能跳过前置阶段: ${missing.map((key) => LABELS[key]).join('、')}`)
|
|
81
|
+
}
|
|
82
|
+
data.stages[stage] = {
|
|
83
|
+
...data.stages[stage],
|
|
84
|
+
status,
|
|
85
|
+
at: new Date().toISOString(),
|
|
86
|
+
by: options.by ?? 'user',
|
|
87
|
+
note: options.note ?? data.stages[stage].note ?? '',
|
|
88
|
+
}
|
|
89
|
+
write(project, data)
|
|
90
|
+
console.log(`${stage} → ${status}${options.note ? ` (${options.note})` : ''}`)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function table(data) {
|
|
94
|
+
const rows = Object.entries(data.stages).map(([key, value]) => {
|
|
95
|
+
const mark = value.status === 'approved' ? '✅' : value.status === 'rejected' ? '❌' : '⬜'
|
|
96
|
+
return `${mark} ${value.label.padEnd(14, ' ')} ${value.status.padEnd(8)} ${value.at ?? '-'} ${value.note ?? ''}`
|
|
97
|
+
})
|
|
98
|
+
return [`project: ${data.project} mode: ${data.mode}`, ...rows].join('\n')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function check(project) {
|
|
102
|
+
const data = read(project)
|
|
103
|
+
console.log(table(data))
|
|
104
|
+
const missing = Object.entries(data.stages)
|
|
105
|
+
.filter(([, value]) => value.status !== 'approved')
|
|
106
|
+
.map(([key]) => LABELS[key])
|
|
107
|
+
if (missing.length) {
|
|
108
|
+
console.error(`\n未确认: ${missing.join('、')}`)
|
|
109
|
+
process.exit(1)
|
|
110
|
+
}
|
|
111
|
+
console.log('\nall approved')
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const [command, project, ...rest] = process.argv.slice(2)
|
|
115
|
+
if (!command || !project) fail('用法: node scripts/approvals.mjs init|set|check|show <project> ...')
|
|
116
|
+
|
|
117
|
+
if (command === 'init') {
|
|
118
|
+
const modeIndex = rest.indexOf('--mode')
|
|
119
|
+
const mode = modeIndex >= 0 ? rest[modeIndex + 1] : 'light'
|
|
120
|
+
init(project, mode)
|
|
121
|
+
} else if (command === 'set') {
|
|
122
|
+
const [stage, status] = rest
|
|
123
|
+
const noteIndex = rest.indexOf('--note')
|
|
124
|
+
const byIndex = rest.indexOf('--by')
|
|
125
|
+
if (!stage || !status) fail('用法: set <project> <stage> <approved|pending|rejected> [--note ...] [--by ...]')
|
|
126
|
+
set(project, stage, status, {
|
|
127
|
+
note: noteIndex >= 0 ? rest[noteIndex + 1] : undefined,
|
|
128
|
+
by: byIndex >= 0 ? rest[byIndex + 1] : undefined,
|
|
129
|
+
})
|
|
130
|
+
} else if (command === 'check') {
|
|
131
|
+
check(project)
|
|
132
|
+
} else if (command === 'show') {
|
|
133
|
+
console.log(table(read(project)))
|
|
134
|
+
} else {
|
|
135
|
+
fail(`未知命令: ${command}`)
|
|
136
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 从 styles/<slug>/style.json 生成 styles/README.md 的风格表和 styles/registry.json。
|
|
4
|
+
*
|
|
5
|
+
* node scripts/build-style-index.mjs # 写入
|
|
6
|
+
* node scripts/build-style-index.mjs --check # 只检查是否过期(CI/smoke 用)
|
|
7
|
+
*
|
|
8
|
+
* style.json 是唯一事实来源;表与 registry 不再手写,避免漂移。
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync, readdirSync, writeFileSync } from 'node:fs'
|
|
11
|
+
import { dirname, join } from 'node:path'
|
|
12
|
+
import { fileURLToPath } from 'node:url'
|
|
13
|
+
|
|
14
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
15
|
+
const STYLES_DIR = join(ROOT, 'styles')
|
|
16
|
+
const README = join(STYLES_DIR, 'README.md')
|
|
17
|
+
const REGISTRY = join(STYLES_DIR, 'registry.json')
|
|
18
|
+
const DESIGN = join(ROOT, 'DESIGN.md')
|
|
19
|
+
|
|
20
|
+
function defaultStyle() {
|
|
21
|
+
const match = /^style:\s*(\S+)/m.exec(readFileSync(DESIGN, 'utf8'))
|
|
22
|
+
if (!match) throw new Error('DESIGN.md 缺少 style:')
|
|
23
|
+
return match[1]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function loadStyles() {
|
|
27
|
+
return readdirSync(STYLES_DIR, { withFileTypes: true })
|
|
28
|
+
.filter((entry) => entry.isDirectory())
|
|
29
|
+
.map((entry) => {
|
|
30
|
+
const file = join(STYLES_DIR, entry.name, 'style.json')
|
|
31
|
+
return JSON.parse(readFileSync(file, 'utf8'))
|
|
32
|
+
})
|
|
33
|
+
.sort((a, b) => String(a.num).localeCompare(String(b.num)))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function sourceLabel(style) {
|
|
37
|
+
const url = `${style.source ?? ''} ${style.secondary_source ?? ''}`
|
|
38
|
+
if (url.includes('lemomo-ai')) return 'Lemo-Opuscar'
|
|
39
|
+
if (url.includes('subinium')) return 'subinium + manim_skill'
|
|
40
|
+
return 'external'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function renderTable(styles) {
|
|
44
|
+
const lines = [
|
|
45
|
+
'<!-- styles:start -->',
|
|
46
|
+
'| 风格 | 适合 | 来源 | 入口 |',
|
|
47
|
+
'|---|---|---|---|',
|
|
48
|
+
]
|
|
49
|
+
for (const style of styles) {
|
|
50
|
+
const label = style.slug === 'swiss-motion' ? `${style.cn}(默认)` : style.cn
|
|
51
|
+
const uses = (style.uses_cn ?? style.uses ?? []).join('、')
|
|
52
|
+
lines.push(`| \`${style.slug}\` / ${label} | ${uses} | ${sourceLabel(style)} | [STYLE](${style.slug}/STYLE.md) · [tokens](${style.slug}/tokens.json) · [theme](${style.slug}/theme.py) |`)
|
|
53
|
+
}
|
|
54
|
+
lines.push('<!-- styles:end -->')
|
|
55
|
+
return lines.join('\n')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function updateReadme(styles) {
|
|
59
|
+
const text = readFileSync(README, 'utf8')
|
|
60
|
+
const start = text.indexOf('<!-- styles:start -->')
|
|
61
|
+
const end = text.indexOf('<!-- styles:end -->')
|
|
62
|
+
const block = renderTable(styles)
|
|
63
|
+
if (start >= 0 && end > start) {
|
|
64
|
+
return text.slice(0, start) + block + text.slice(end + '<!-- styles:end -->'.length)
|
|
65
|
+
}
|
|
66
|
+
return `${text.trimEnd()}\n\n${block}\n`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function buildRegistry(styles) {
|
|
70
|
+
return {
|
|
71
|
+
version: 1,
|
|
72
|
+
generated: true,
|
|
73
|
+
default: defaultStyle(),
|
|
74
|
+
styles: styles.map((style) => ({
|
|
75
|
+
slug: style.slug,
|
|
76
|
+
num: style.num,
|
|
77
|
+
name_cn: style.cn,
|
|
78
|
+
name_en: style.en,
|
|
79
|
+
category_cn: style.category_cn,
|
|
80
|
+
uses: style.uses ?? [],
|
|
81
|
+
uses_cn: style.uses_cn ?? [],
|
|
82
|
+
keywords: style.keywords ?? [],
|
|
83
|
+
avoid: style.avoid ?? [],
|
|
84
|
+
aspect: style.aspect ?? ['16:9'],
|
|
85
|
+
fps: style.fps ?? 30,
|
|
86
|
+
complexity: style.complexity ?? 'medium',
|
|
87
|
+
demo: style.demo ?? null,
|
|
88
|
+
source: style.source ?? null,
|
|
89
|
+
})),
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const styles = loadStyles()
|
|
94
|
+
const registry = buildRegistry(styles)
|
|
95
|
+
const readme = updateReadme(styles)
|
|
96
|
+
const registryText = JSON.stringify(registry, null, 2) + '\n'
|
|
97
|
+
|
|
98
|
+
if (process.argv.includes('--check')) {
|
|
99
|
+
const stale = readFileSync(README, 'utf8') !== readme
|
|
100
|
+
|| readFileSync(REGISTRY, 'utf8') !== registryText
|
|
101
|
+
if (stale) {
|
|
102
|
+
console.error('styles/README.md 或 styles/registry.json 已过期;运行 node scripts/build-style-index.mjs')
|
|
103
|
+
process.exit(1)
|
|
104
|
+
}
|
|
105
|
+
console.log('styles index is up to date')
|
|
106
|
+
} else {
|
|
107
|
+
writeFileSync(README, readme)
|
|
108
|
+
writeFileSync(REGISTRY, registryText)
|
|
109
|
+
console.log(`wrote styles/README.md table and styles/registry.json (${styles.length} styles, default ${registry.default})`)
|
|
110
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 发布前元数据检查:占位符、私有标记、绝对路径。
|
|
3
|
+
* 用法:node scripts/check-meta.mjs
|
|
4
|
+
*/
|
|
5
|
+
import { readFileSync } from 'node:fs'
|
|
6
|
+
import { join } from 'node:path'
|
|
7
|
+
|
|
8
|
+
const pkg = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf8'))
|
|
9
|
+
const problems = []
|
|
10
|
+
|
|
11
|
+
if (pkg.private) problems.push('package.json 里 private 仍为 true')
|
|
12
|
+
if (!pkg.license) problems.push('缺少 license')
|
|
13
|
+
if (!pkg.engines?.node) problems.push('缺少 engines.node')
|
|
14
|
+
|
|
15
|
+
for (const file of ['lib/index.js', 'lib/ops.js', 'lib/env.js', 'lib/doctor.js']) {
|
|
16
|
+
const text = readFileSync(join(process.cwd(), file), 'utf8')
|
|
17
|
+
if (/\/Users\/[^\s'"]+/.test(text)) problems.push(`${file} 含绝对路径 /Users/…`)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (problems.length) {
|
|
21
|
+
console.error('发布前检查未通过:')
|
|
22
|
+
for (const p of problems) console.error(` - ${p}`)
|
|
23
|
+
process.exit(1)
|
|
24
|
+
}
|
|
25
|
+
console.log('元数据检查通过')
|
package/scripts/dev.mjs
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 不经过 Harness 的开发 CLI:直接调用 lib/ops.js。
|
|
4
|
+
*
|
|
5
|
+
* node scripts/dev.mjs frames <scene.py> [t1,t2,...]
|
|
6
|
+
* node scripts/dev.mjs render <scene.py> [--fps 30] [--out path]
|
|
7
|
+
* node scripts/dev.mjs probe <video.mp4>
|
|
8
|
+
* node scripts/dev.mjs doctor
|
|
9
|
+
*/
|
|
10
|
+
import { mkdirSync } from 'node:fs'
|
|
11
|
+
import { basename, dirname, join } from 'node:path'
|
|
12
|
+
|
|
13
|
+
import { inspectFrames, probeVideo, renderScene } from '../lib/ops.js'
|
|
14
|
+
import { doctor } from '../lib/doctor.js'
|
|
15
|
+
|
|
16
|
+
const [cmd, file, ...rest] = process.argv.slice(2)
|
|
17
|
+
|
|
18
|
+
function fail(message) {
|
|
19
|
+
console.error(message)
|
|
20
|
+
process.exit(1)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function elapsed(start) {
|
|
24
|
+
return `${((Date.now() - start) / 1000).toFixed(1)}s`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!cmd) fail('用法: node scripts/dev.mjs frames|render|probe|doctor ...')
|
|
28
|
+
|
|
29
|
+
function outputDir() {
|
|
30
|
+
mkdirSync('out', { recursive: true })
|
|
31
|
+
return 'out'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function slugOf(path) {
|
|
35
|
+
const dir = dirname(path)
|
|
36
|
+
return dir === '.' ? basename(path, '.py') : basename(dir)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (cmd === 'frames') {
|
|
40
|
+
if (!file) fail('缺少场景文件')
|
|
41
|
+
const times = (rest[0] ?? '').split(',').filter(Boolean)
|
|
42
|
+
const started = Date.now()
|
|
43
|
+
const result = await inspectFrames({}, {
|
|
44
|
+
file,
|
|
45
|
+
times,
|
|
46
|
+
out: join(outputDir(), `${slugOf(file)}-frames.png`),
|
|
47
|
+
})
|
|
48
|
+
console.log(JSON.stringify({ ...result, elapsed: elapsed(started) }, null, 2))
|
|
49
|
+
} else if (cmd === 'render') {
|
|
50
|
+
if (!file) fail('缺少场景文件')
|
|
51
|
+
const fpsIndex = rest.indexOf('--fps')
|
|
52
|
+
const outIndex = rest.indexOf('--out')
|
|
53
|
+
const started = Date.now()
|
|
54
|
+
const result = await renderScene({}, {
|
|
55
|
+
file,
|
|
56
|
+
fps: fpsIndex >= 0 ? Number(rest[fpsIndex + 1]) : 30,
|
|
57
|
+
out: outIndex >= 0 ? rest[outIndex + 1] : join(outputDir(), `${slugOf(file)}.mp4`),
|
|
58
|
+
})
|
|
59
|
+
console.log(JSON.stringify({ ...result, elapsed: elapsed(started) }, null, 2))
|
|
60
|
+
} else if (cmd === 'probe') {
|
|
61
|
+
if (!file) fail('缺少视频文件')
|
|
62
|
+
const started = Date.now()
|
|
63
|
+
console.log(JSON.stringify({ ...(await probeVideo({}, { video: file })), elapsed: elapsed(started) }, null, 2))
|
|
64
|
+
} else if (cmd === 'doctor') {
|
|
65
|
+
console.log(JSON.stringify(await doctor({}), null, 2))
|
|
66
|
+
} else {
|
|
67
|
+
fail(`未知命令: ${cmd}`)
|
|
68
|
+
}
|