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.
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
 
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-motion",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
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('元数据检查通过')
@@ -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
+ }