dsh-motion 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-motion contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/NOTICE.md ADDED
@@ -0,0 +1,15 @@
1
+ # Third-party notices
2
+
3
+ 本包不包含以下项目的源码,但会调用或与其配合;分发或商用前请自行遵守各自许可。
4
+
5
+ | 项目 | 许可 | 关系 |
6
+ |---|---|---|
7
+ | [HyperFrames](https://github.com/heygen-com/hyperframes) | Apache-2.0 | 交付管线(组装 / 字幕 / 编码);通过 `npx hyperframes` 调用,不打包 |
8
+ | [Manim Community](https://github.com/ManimCommunity/manim) | MIT | 可选渲染后端 |
9
+ | [manimgx](https://github.com/academa-labs/manimgx) | MIT | 默认渲染后端 |
10
+ | [SymPy](https://github.com/sympy/sympy) | BSD-3-Clause | `formula_check` |
11
+ | [FFmpeg](https://ffmpeg.org) | LGPL/GPL(取决于构建) | 渲染、后期、抽帧;**不打包**,由用户自行安装 |
12
+
13
+ 字体不随包分发;若你的 `DESIGN.md` 指定了第三方字体,请确认其许可(OFL / 商用授权)。
14
+
15
+ 本包自身的代码以 MIT 许可发布(见 `LICENSE`)。
package/README.md CHANGED
@@ -1,3 +1,152 @@
1
- # Temporary Holding Version
1
+ # dsh-motion
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ ![npm](https://img.shields.io/npm/v/dsh-motion)
4
+
5
+ > **Unofficial community plugin.** Not affiliated with or endorsed by DeepSeek.
6
+ > The name is descriptive (`motion` = animation); if the upstream project needs it, this package will be renamed.
7
+
8
+ **代码生成的数学 / 科普动画**,给 [DeepSeek Harness](https://github.com/deepseek-ai) 用的插件。
9
+ 画面由代码逐帧生成,不经过任何文生视频(text-to-video)模型。
10
+
11
+ 提供四个工具:
12
+
13
+ | 工具 | 作用 |
14
+ |---|---|
15
+ | `motion_doctor` | 环境自检(Node / FFmpeg / Python venv / manimgx / sympy / Chrome / HyperFrames) |
16
+ | `motion_render` | 渲染 Manim / manimgx 场景,可加运动模糊、颗粒、暗角、调色 |
17
+ | `motion_probe` | 抽帧拼 contact sheet,报告全黑 / 过曝的空镜 |
18
+ | `formula_check` | 用 SymPy 判断两个表达式是否恒等 |
19
+
20
+ 外加三个技能:`animation-router`(题材路由)、`design-spec`(写 DESIGN.md)、`qa-rubric`(交付自检)。
21
+
22
+ ## 安装
23
+
24
+ ```bash
25
+ # npm
26
+ npm install -g dsh-motion
27
+
28
+ # 或在 DSH 插件管理器里安装 dsh-motion
29
+ ```
30
+
31
+ 安装后重启 DeepSeek Harness,然后先跑一次自检:
32
+
33
+ ```
34
+ motion_doctor
35
+ ```
36
+
37
+ ## 依赖
38
+
39
+ | 依赖 | 是否必需 | 说明 |
40
+ |---|---|---|
41
+ | Node.js ≥ 22 | 必需 | `engines` 声明 |
42
+ | FFmpeg + ffprobe | 必需 | 渲染与后期;不随包分发 |
43
+ | Python 3 + venv | 数学功能需要 | `manimgx`、`sympy` |
44
+ | Chrome | 成片渲染需要 | HyperFrames 优先复用系统 Chrome |
45
+ | HyperFrames 0.8.193 | 成片需要 | `npx hyperframes@0.8.193`,不随包分发 |
46
+
47
+ ### 准备 Python 环境
48
+
49
+ ```bash
50
+ python3 -m venv ~/.dsh/data/dsh-motion/venv
51
+ ~/.dsh/data/dsh-motion/venv/bin/pip install manimgx sympy
52
+ ```
53
+
54
+ Windows 把 `bin/` 换成 `Scripts\`,可执行文件加 `.exe`。
55
+
56
+ ## 配置
57
+
58
+ 插件配置(或在 profile 的 `cordis.patch.yml` 里传入):
59
+
60
+ ```yaml
61
+ - insert:
62
+ - id: motion
63
+ name: dsh-motion
64
+ config:
65
+ motionHome: ~/.dsh/data/dsh-motion
66
+ venv: ~/.dsh/data/dsh-motion/venv
67
+ ffmpeg: /opt/homebrew/bin/ffmpeg
68
+ ```
69
+
70
+ 解析顺序:插件配置 → 环境变量(`MOTION_HOME` / `MOTION_VENV` / `MOTION_FFMPEG`)→ 默认目录 → 系统 PATH。
71
+
72
+ ## 快速开始
73
+
74
+ ### 1. 写一个 Manim 场景
75
+
76
+ ```python
77
+ # scenes/derivative.py
78
+ import manimgx as m
79
+ from manimgx import *
80
+
81
+ class Derivative(Scene):
82
+ def construct(self):
83
+ ax = Axes(x_range=[-2, 2], y_range=[-1, 4])
84
+ self.play(Create(ax))
85
+ self.wait(1)
86
+ ```
87
+
88
+ ### 2. 渲染
89
+
90
+ ```jsonc
91
+ // motion_render
92
+ { "file": "/abs/path/scenes/derivative.py", "scene": "Derivative", "width": 1920, "height": 1080, "fps": 30 }
93
+ ```
94
+
95
+ ### 3. 自检
96
+
97
+ ```jsonc
98
+ // motion_probe
99
+ { "video": "/abs/path/scenes/derivative.mp4", "frames": 12 }
100
+ ```
101
+
102
+ ### 4. 公式先验
103
+
104
+ ```jsonc
105
+ // formula_check
106
+ { "left": "diff(0.5*exp(x), x)", "right": "0.5*exp(x)" }
107
+ ```
108
+
109
+ ## 和 HyperFrames 的关系
110
+
111
+ 本插件**不是**渲染框架。成片(合成、字幕、音频混流、编码)交给 HyperFrames:
112
+
113
+ ```bash
114
+ npx hyperframes@0.8.193 init <project> --example swiss-grid --video <clip>.mp4
115
+ npx hyperframes@0.8.193 check <project>
116
+ npx hyperframes@0.8.193 render <project> -o out.mp4 --quality delivery
117
+ ```
118
+
119
+ 字幕建议“一份 transcript 出两个产物”:烧录进画面 + 独立 `.srt`。
120
+
121
+ ## 故障排除
122
+
123
+ | 现象 | 原因 | 处理 |
124
+ |---|---|---|
125
+ | `找不到 Python 虚拟环境` | 未建 venv | 按上文创建,并 `pip install manimgx sympy` |
126
+ | `找不到渲染器: …/manimgx` | venv 里没装 | `pip install manimgx` |
127
+ | 渲染断字 / 字体回退 | 字体未声明 `@font-face` | 把 `.woff2` 放工程 `assets/fonts/` 并声明 |
128
+ | `ffprobe` 缺失 | 只装了 ffmpeg | 装完整 FFmpeg,插件会退回 `ffmpeg -i` 解析 |
129
+ | 抽帧全黑 | 片头/片尾空镜 | 用 `tpad=stop_mode=clone` 补帧,或剪掉 |
130
+
131
+ ## 发版(维护者)
132
+
133
+ 首次发布会把名字与 2FA 绑好;之后用 GitHub Actions 的 Trusted Publishing,不需要 token,也不需要 OTP。
134
+
135
+ 1. 在 npm 包设置里绑定可信发布者:**Package → Settings → Trusted Publisher → GitHub Actions**
136
+ - owner: `cogito0823`
137
+ - repository: `dsh-motion`
138
+ - workflow: `release.yml`
139
+ 2. 然后:
140
+
141
+ ```bash
142
+ npm version patch # 0.1.0 -> 0.1.1
143
+ git push --follow-tags # 触发 release.yml
144
+ ```
145
+
146
+ 工作流会校验 tag 与 `package.json` 一致、跑 check/smoke,再 `npm publish --provenance`。
147
+
148
+ 若工作流失败,退回手动发布:`npm publish --access public`(需 passkey 或 OTP)。
149
+
150
+ ## 许可
151
+
152
+ MIT(见 `LICENSE`)。第三方依赖与调用关系见 `NOTICE.md`。
@@ -0,0 +1,4 @@
1
+ - insert:
2
+ - id: motion
3
+ name: ./lib/index.js
4
+ config: {}
package/lib/doctor.js ADDED
@@ -0,0 +1,91 @@
1
+ /**
2
+ * 环境自检:装完插件先跑这个,别等渲染时报错。
3
+ * 只读,不改任何东西;返回结构化表格 + 修法。
4
+ */
5
+
6
+ import { execFile } from 'node:child_process'
7
+ import { existsSync } from 'node:fs'
8
+ import { promisify } from 'node:util'
9
+
10
+ import {
11
+ chromePath,
12
+ findHyperframes,
13
+ hasVenv,
14
+ motionHome,
15
+ resolveBinary,
16
+ resolveVenv,
17
+ venvBinary,
18
+ } from './env.js'
19
+
20
+ const exec = promisify(execFile)
21
+
22
+ async function runVersion(bin, args) {
23
+ try {
24
+ const { stdout, stderr } = await exec(bin, args, { timeout: 15_000 })
25
+ const line = `${stdout || ''}\n${stderr || ''}`.trim().split('\n')[0]
26
+ if (line) return line.slice(0, 80)
27
+ } catch (err) {
28
+ const line = String(err.stderr || err.stdout || '').trim().split('\n')[0]
29
+ if (line && /version/i.test(line)) return line.slice(0, 80)
30
+ }
31
+ return null
32
+ }
33
+
34
+ async function version(bin, args) {
35
+ if (Array.isArray(args)) return runVersion(bin, args)
36
+ for (const form of [['--version'], ['-version'], ['version']]) {
37
+ const found = await runVersion(bin, form)
38
+ if (found) return found
39
+ }
40
+ return null
41
+ }
42
+
43
+ export async function doctor(config = {}) {
44
+ const rows = []
45
+ const add = (item, ok, detail, fix) => rows.push({ item, ok, detail, fix })
46
+
47
+ const nodeMajor = Number(process.versions.node.split('.')[0])
48
+ add('Node.js', nodeMajor >= 22, `v${process.versions.node}`, nodeMajor >= 22 ? null : '升级到 Node 22 或更高')
49
+
50
+ const ffmpeg = resolveBinary(config, 'ffmpeg')
51
+ const ffmpegVersion = await version(ffmpeg)
52
+ add('FFmpeg', Boolean(ffmpegVersion), ffmpegVersion ?? ffmpeg, ffmpegVersion ? null : '安装 FFmpeg(macOS: brew install ffmpeg)')
53
+
54
+ const ffprobe = resolveBinary(config, 'ffprobe')
55
+ const ffprobeVersion = await version(ffprobe)
56
+ add('ffprobe', Boolean(ffprobeVersion), ffprobeVersion ?? ffprobe, ffprobeVersion ? null : '随 FFmpeg 一起安装')
57
+
58
+ const venv = resolveVenv(config)
59
+ const py = hasVenv(config)
60
+ add('Python venv', py, venv, py ? null : `python3 -m venv ${venv}`)
61
+
62
+ if (py) {
63
+ const manimgx = await version(venvBinary(config, 'manimgx'))
64
+ add('manimgx', Boolean(manimgx), manimgx ?? '未安装', manimgx ? null : `${venvBinary(config, 'pip')} install manimgx`)
65
+ const sympy = await version(venvBinary(config, 'python'), ['-c', 'import sympy; print(sympy.__version__)'])
66
+ add('sympy', Boolean(sympy), sympy ?? '未安装', sympy ? null : `${venvBinary(config, 'pip')} install sympy`)
67
+ }
68
+
69
+ const chrome = chromePath()
70
+ add('Chrome', Boolean(chrome), chrome ?? '未找到', chrome ? null : '安装 Chrome,或让 HyperFrames 自行下载渲染浏览器')
71
+
72
+ const hf = findHyperframes()
73
+ add('HyperFrames', Boolean(hf), hf ? '已缓存(用于组装/字幕/编码)' : '未安装', hf ? null : 'npx hyperframes@0.8.193 --version')
74
+
75
+ add('输出目录', true, motionHome(config), null)
76
+
77
+ const ready = {
78
+ math: Boolean(ffmpegVersion && py),
79
+ delivery: Boolean(ffmpegVersion && chrome),
80
+ }
81
+
82
+ return {
83
+ ready,
84
+ summary: ready.math
85
+ ? '数学渲染与成片可用'
86
+ : ready.delivery
87
+ ? '成片可用;数学渲染缺少 Python venv'
88
+ : '缺少 FFmpeg:渲染不可用',
89
+ rows,
90
+ }
91
+ }
package/lib/env.js ADDED
@@ -0,0 +1,94 @@
1
+ /**
2
+ * 路径与外部命令解析。不写死任何本机路径。
3
+ *
4
+ * 解析顺序:插件配置 → 环境变量 → 默认目录 → 系统 PATH。
5
+ */
6
+
7
+ import { accessSync, constants, existsSync, readdirSync, statSync } from 'node:fs'
8
+ import { homedir } from 'node:os'
9
+ import { delimiter, join } from 'node:path'
10
+
11
+ export function motionHome(config = {}) {
12
+ return config.motionHome
13
+ ?? process.env.MOTION_HOME
14
+ ?? join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'data', 'dsh-motion')
15
+ }
16
+
17
+ export function isExecutable(file) {
18
+ try {
19
+ accessSync(file, constants.X_OK)
20
+ return statSync(file).isFile()
21
+ } catch {
22
+ return false
23
+ }
24
+ }
25
+
26
+ /** 在 PATH 里查找可执行文件,不经过 shell。 */
27
+ export function findOnPath(name) {
28
+ const dirs = (process.env.PATH ?? '').split(delimiter).filter(Boolean)
29
+ const exts = process.platform === 'win32'
30
+ ? (process.env.PATHEXT ?? '.EXE;.CMD;.BAT').split(';').filter(Boolean)
31
+ : ['']
32
+ for (const dir of dirs) {
33
+ for (const ext of exts) {
34
+ const candidate = join(dir, name + ext)
35
+ if (isExecutable(candidate)) return candidate
36
+ }
37
+ }
38
+ return null
39
+ }
40
+
41
+ /** venv 根目录:配置 > 环境变量 > $DSH_HOME/data/dsh-motion/venv */
42
+ export function resolveVenv(config = {}) {
43
+ return config.venv ?? process.env.MOTION_VENV ?? join(motionHome(config), 'venv')
44
+ }
45
+
46
+ export function venvBinary(config, name) {
47
+ const root = resolveVenv(config)
48
+ return process.platform === 'win32'
49
+ ? join(root, 'Scripts', `${name}.exe`)
50
+ : join(root, 'bin', name)
51
+ }
52
+
53
+ export function hasVenv(config = {}) {
54
+ return isExecutable(venvBinary(config, 'python'))
55
+ }
56
+
57
+ /** 外部命令:配置 > 环境变量 > PATH > 常见安装位置。 */
58
+ export function resolveBinary(config = {}, name) {
59
+ const key = name.toLowerCase()
60
+ const configured = config[key]
61
+ ?? process.env[`MOTION_${key.toUpperCase()}`]
62
+ if (configured) return configured
63
+ const found = findOnPath(name)
64
+ if (found) return found
65
+ const fallbacks = process.platform === 'darwin'
66
+ ? ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin']
67
+ : ['/usr/local/bin', '/usr/bin', '/bin']
68
+ for (const dir of fallbacks) {
69
+ const candidate = join(dir, name)
70
+ if (isExecutable(candidate)) return candidate
71
+ }
72
+ return name
73
+ }
74
+
75
+ export function chromePath() {
76
+ const candidates = process.platform === 'darwin'
77
+ ? ['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome']
78
+ : process.platform === 'win32'
79
+ ? ['C:/Program Files/Google/Chrome/Application/chrome.exe']
80
+ : ['/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser']
81
+ return candidates.find((p) => existsSync(p)) ?? null
82
+ }
83
+
84
+ /** 最新 npx 缓存里的 hyperframes(只用于 doctor 报告,不作为依赖)。 */
85
+ export function findHyperframes() {
86
+ const npxRoot = join(process.env.HOME ?? homedir(), '.npm', '_npx')
87
+ if (existsSync(npxRoot)) {
88
+ for (const dir of readdirSync(npxRoot)) {
89
+ const candidate = join(npxRoot, dir, 'node_modules', 'hyperframes', 'package.json')
90
+ if (existsSync(candidate)) return candidate
91
+ }
92
+ }
93
+ return findOnPath('hyperframes')
94
+ }
package/lib/index.js ADDED
@@ -0,0 +1,140 @@
1
+ /**
2
+ * dsh-motion —— 代码生成的数学 / 科普动画
3
+ *
4
+ * 四个工具:
5
+ * motion_doctor 环境自检(先跑这个)
6
+ * motion_render 渲染 Manim / manimgx 场景
7
+ * motion_probe 抽帧自检(空镜 / 过曝)
8
+ * formula_check 公式恒等校验(sympy)
9
+ *
10
+ * 知识层在 skills/ 里:animation-router / design-spec / qa-rubric。
11
+ */
12
+
13
+ import { appendFileSync, mkdirSync } from 'node:fs'
14
+ import { homedir } from 'node:os'
15
+ import { join } from 'node:path'
16
+
17
+ import { doctor } from './doctor.js'
18
+ import { checkMath, probeVideo, renderScene } from './ops.js'
19
+
20
+ export const name = 'motion'
21
+ export const inject = ['tools']
22
+
23
+ const LOG_DIR = join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'logs')
24
+
25
+ function log(message) {
26
+ try {
27
+ mkdirSync(LOG_DIR, { recursive: true })
28
+ appendFileSync(join(LOG_DIR, 'motion.log'), `${new Date().toISOString()} ${message}\n`)
29
+ } catch {
30
+ // 日志失败不影响工具
31
+ }
32
+ }
33
+
34
+ function asText(value) {
35
+ const text = typeof value === 'string' ? value : JSON.stringify(value, null, 2)
36
+ return [{ type: 'text', text }]
37
+ }
38
+
39
+ const textOutput = { schema: { type: 'string' }, render: (_args, value) => asText(value) }
40
+
41
+ export function apply(ctx, config = {}) {
42
+ ctx.tools.register({
43
+ name: 'motion_doctor',
44
+ description:
45
+ '检查代码动画需要的环境:Node、FFmpeg、Python venv、manimgx、sympy、Chrome、HyperFrames。装完插件或渲染报错时先跑这个,返回表格与修法。',
46
+ parameters: { type: 'object', properties: {}, additionalProperties: false },
47
+ output: textOutput,
48
+ timeoutMs: 120_000,
49
+ async execute() {
50
+ const result = await doctor(config)
51
+ log(`doctor ready=${JSON.stringify(result.ready)}`)
52
+ return JSON.stringify(result, null, 2)
53
+ },
54
+ })
55
+
56
+ ctx.tools.register({
57
+ name: 'motion_render',
58
+ description:
59
+ '渲染一支数学/科普场景。engine="manimgx"(默认,数学/公式/坐标,无需 LaTeX,自带 layout 检查)或 "manim"(Manim CE 回退)。post 可加运动模糊/颗粒/暗角。返回时长/体积/布局问题清单。',
60
+ parameters: {
61
+ type: 'object',
62
+ properties: {
63
+ file: { type: 'string', description: '场景 .py 文件的绝对路径' },
64
+ scene: { type: 'string', description: '场景类名;文件里只有一个场景时可省略' },
65
+ engine: { type: 'string', enum: ['manimgx', 'manim'], description: '渲染器,默认 manimgx' },
66
+ post: {
67
+ type: 'object',
68
+ description: '管线层后期:运动模糊/颗粒/暗角/调色。subframes=3 表示 3 倍帧率渲染后 tmix 合成。',
69
+ properties: {
70
+ subframes: { type: 'integer', description: '子帧数 1-8,默认 1' },
71
+ grain: { type: 'number', description: '颗粒强度 0-30,0 为关' },
72
+ vignette: { type: 'number', description: '暗角强度 2-8,0 为关' },
73
+ grade: { type: 'string', enum: ['', 'cinematic', 'warm'], description: '调色预设' },
74
+ },
75
+ additionalProperties: false,
76
+ },
77
+ width: { type: 'integer', description: '像素宽,默认 1920' },
78
+ height: { type: 'integer', description: '像素高,默认 1080' },
79
+ fps: { type: 'integer', description: '帧率,默认 30,上限 60' },
80
+ out: { type: 'string', description: '输出 mp4 路径,默认与场景同目录' },
81
+ },
82
+ required: ['file'],
83
+ additionalProperties: false,
84
+ },
85
+ output: textOutput,
86
+ timeoutMs: 900_000,
87
+ async execute(args) {
88
+ const result = await renderScene(config, args)
89
+ log(`render ${result.output} ${result.durationSec}s engine=${result.engine} problems=${result.layoutProblems.length}`)
90
+ return JSON.stringify(result, null, 2)
91
+ },
92
+ })
93
+
94
+ ctx.tools.register({
95
+ name: 'motion_probe',
96
+ description:
97
+ '成片自检:等间隔抽帧拼成 contact sheet,并报告全黑/过曝的空镜帧。正式交付前先跑一遍,看过图再收工。',
98
+ parameters: {
99
+ type: 'object',
100
+ properties: {
101
+ video: { type: 'string', description: '要检查的 mp4 绝对路径' },
102
+ frames: { type: 'integer', description: '抽帧数量,默认 12(2–24)' },
103
+ out: { type: 'string', description: 'contact sheet 输出路径' },
104
+ },
105
+ required: ['video'],
106
+ additionalProperties: false,
107
+ },
108
+ output: textOutput,
109
+ timeoutMs: 300_000,
110
+ async execute(args) {
111
+ const result = await probeVideo(config, args)
112
+ log(`probe ${result.video} frames=${result.framesAnalyzed} blank=${result.blankFrames.length}`)
113
+ return JSON.stringify(result, null, 2)
114
+ },
115
+ })
116
+
117
+ ctx.tools.register({
118
+ name: 'formula_check',
119
+ description:
120
+ '数学正确性门:用 sympy 判断两个表达式是否恒等(自动化简差值)。渲染任何公式之前先过这一关,例如 left="diff(0.6*x**3-1.8*x, x)"、right="1.8*x**2-1.8"。',
121
+ parameters: {
122
+ type: 'object',
123
+ properties: {
124
+ left: { type: 'string', description: '左表达式(sympy 语法)' },
125
+ right: { type: 'string', description: '右表达式(sympy 语法)' },
126
+ },
127
+ required: ['left', 'right'],
128
+ additionalProperties: false,
129
+ },
130
+ output: textOutput,
131
+ timeoutMs: 60_000,
132
+ async execute(args) {
133
+ const result = await checkMath(config, args)
134
+ log(`formula_check equal=${result.equal}`)
135
+ return JSON.stringify(result, null, 2)
136
+ },
137
+ })
138
+
139
+ log('registered 4 tools')
140
+ }
package/lib/ops.js ADDED
@@ -0,0 +1,222 @@
1
+ /**
2
+ * 机制层:所有"不变量"都在这里,模型只能传有限的参数。
3
+ *
4
+ * 设计原则
5
+ * - 渲染规格(分辨率、fps、编码器、crf)只接受枚举和数字上限;
6
+ * - 每个函数返回结构化摘要(时长/体积/layout 问题),不让模型 grep 日志;
7
+ * - 外部命令全部走 execFile + 超时,不经 shell。
8
+ */
9
+
10
+ import { execFile } from 'node:child_process'
11
+ import { existsSync, statSync } from 'node:fs'
12
+ import { basename, dirname, join, resolve } from 'node:path'
13
+ import { promisify } from 'node:util'
14
+
15
+ import { hasVenv, resolveBinary, resolveVenv, venvBinary } from './env.js'
16
+
17
+ const exec = promisify(execFile)
18
+
19
+ function binaryOf(config, name) {
20
+ return resolveBinary(config, name)
21
+ }
22
+
23
+ function requireVenv(config) {
24
+ if (!hasVenv(config)) {
25
+ throw new Error(
26
+ `找不到 Python 虚拟环境:${resolveVenv(config)}\n`
27
+ + '数学功能需要 manimgx 与 sympy。创建方式:\n'
28
+ + ` python3 -m venv ${resolveVenv(config)}\n`
29
+ + ` ${venvBinary(config, 'pip')} install manimgx sympy\n`
30
+ + '或在插件配置里指定 venv 路径 / 设置 MOTION_VENV。',
31
+ )
32
+ }
33
+ }
34
+
35
+ /** 从渲染器 stdout 里抽布局问题和产物信息。 */
36
+ export function summarizeRender(stdout, stderr) {
37
+ const text = `${stdout}\n${stderr}`
38
+ const problems = [...text.matchAll(/^\s*\[(\d+)\]\s*(.+)$/gm)].map((m) => m[2].trim())
39
+ const line = text.match(/^(.*\.mp4)\s+([\d.]+) s,\s*(\d+)x(\d+) at ([\d.]+) fps,\s*([\d.]+) MB(?:.*rendered in ([\d.]+) s)?/m)
40
+ return {
41
+ video: line?.[1] ?? null,
42
+ durationSec: line ? Number(line[2]) : null,
43
+ width: line ? Number(line[3]) : null,
44
+ height: line ? Number(line[4]) : null,
45
+ fps: line ? Number(line[5]) : null,
46
+ megabytes: line ? Number(line[6]) : null,
47
+ renderedSec: line?.[7] ? Number(line[7]) : null,
48
+ layoutProblems: problems,
49
+ }
50
+ }
51
+
52
+ /** 渲染一支场景。engine = manimgx(默认,无需 LaTeX)或 manim(Manim CE 回退)。 */
53
+ export async function renderScene(config, args) {
54
+ const file = resolve(args.file)
55
+ if (!existsSync(file)) throw new Error(`场景文件不存在: ${file}`)
56
+
57
+ const engine = args.engine ?? 'manimgx'
58
+ if (engine !== 'manimgx' && engine !== 'manim') {
59
+ throw new Error('engine 只支持 manimgx 或 manim')
60
+ }
61
+
62
+ const width = clampInt(args.width ?? 1920, 320, 4096)
63
+ const height = clampInt(args.height ?? 1080, 320, 4096)
64
+ const fps = clampInt(args.fps ?? 30, 10, 60)
65
+ const out = resolve(args.out ?? join(dirname(file), `${basename(file, '.py')}.mp4`))
66
+
67
+ requireVenv(config)
68
+ const venv = resolveVenv(config)
69
+ const binDir = join(venv, process.platform === 'win32' ? 'Scripts' : 'bin')
70
+ const command = venvBinary(config, engine === 'manim' ? 'manim' : 'manimgx')
71
+ if (!existsSync(command)) {
72
+ throw new Error(`找不到渲染器: ${command}\n请在 venv 里安装:${venvBinary(config, 'pip')} install ${engine === 'manim' ? 'manim' : 'manimgx'}`)
73
+ }
74
+
75
+ const argv = engine === 'manim'
76
+ ? [file, ...(args.scene ? [args.scene] : []), '-o', basename(out)]
77
+ : ['render', file, ...(args.scene ? [args.scene] : []), '-r', `${width}x${height}`, '--fps', String(fps), '-o', out]
78
+
79
+ const post = normalizePost(args.post)
80
+ if (post.subframes > 1 && engine === 'manimgx') {
81
+ const i = argv.indexOf('--fps')
82
+ if (i >= 0) argv[i + 1] = String(fps * post.subframes)
83
+ }
84
+
85
+ const started = Date.now()
86
+ const { stdout, stderr } = await exec(command, argv, {
87
+ timeout: 900_000,
88
+ maxBuffer: 8 * 1024 * 1024,
89
+ env: { ...process.env, PATH: `${binDir}${process.platform === 'win32' ? ';' : ':'}${process.env.PATH ?? ''}` },
90
+ cwd: dirname(file),
91
+ })
92
+
93
+ const summary = summarizeRender(stdout, stderr)
94
+ summary.engine = engine
95
+ summary.output = out
96
+
97
+ if (post.enabled) {
98
+ const finalOut = out.replace(/\.mp4$/, '') + '-post.mp4'
99
+ await applyPost(config, out, finalOut, fps, post)
100
+ summary.post = { ...post, output: finalOut }
101
+ summary.output = finalOut
102
+ summary.video = finalOut
103
+ }
104
+
105
+ summary.wallSec = Number(((Date.now() - started) / 1000).toFixed(1))
106
+ return summary
107
+ }
108
+
109
+ function normalizePost(raw) {
110
+ const p = raw && typeof raw === 'object' ? raw : {}
111
+ const subframes = clampInt(p.subframes ?? 1, 1, 8)
112
+ const grain = p.grain === true ? 7 : Number(p.grain ?? 0)
113
+ const vignette = p.vignette === true ? 4 : Number(p.vignette ?? 0)
114
+ const grade = typeof p.grade === 'string' ? p.grade : ''
115
+ return {
116
+ subframes,
117
+ grain: Number.isFinite(grain) ? grain : 0,
118
+ vignette: Number.isFinite(vignette) ? vignette : 0,
119
+ grade,
120
+ enabled: subframes > 1 || grain > 0 || vignette > 0 || Boolean(grade),
121
+ }
122
+ }
123
+
124
+ /** 管线层后期:tmix 运动模糊 + 颗粒 + 暗角 + 调色,不写进场景代码。 */
125
+ async function applyPost(config, input, output, fps, post) {
126
+ const chain = []
127
+ if (post.subframes > 1) chain.push(`tmix=frames=${post.subframes}`, `fps=${fps}`)
128
+ if (post.vignette > 0) chain.push(`vignette=PI/${post.vignette}`)
129
+ if (post.grain > 0) chain.push(`noise=alls=${Math.round(post.grain)}:allf=t`)
130
+ if (post.grade === 'cinematic') chain.push('eq=contrast=1.06:saturation=1.12', 'curves=preset=medium_contrast')
131
+ if (post.grade === 'warm') chain.push('eq=contrast=1.03:saturation=1.06', 'colorbalance=rs=.04:bs=-.03')
132
+ if (chain.length === 0) return
133
+ await exec(binaryOf(config, 'ffmpeg'), [
134
+ '-y', '-loglevel', 'error', '-i', input,
135
+ '-vf', chain.join(','), '-c:v', 'libx264', '-crf', '19', '-pix_fmt', 'yuv420p',
136
+ '-an', output,
137
+ ], { timeout: 900_000, maxBuffer: 8 * 1024 * 1024 })
138
+ }
139
+
140
+ /** 抽帧自检:等间隔取 n 帧拼 contact sheet,并报告全黑/过曝帧。 */
141
+ export async function probeVideo(config, args) {
142
+ const video = resolve(args.video)
143
+ if (!existsSync(video)) throw new Error(`视频不存在: ${video}`)
144
+ const n = clampInt(args.frames ?? 12, 2, 24)
145
+ const sheet = resolve(args.out ?? join(dirname(video), `${basename(video, '.mp4')}-contact.png`))
146
+ const ffmpeg = binaryOf(config, 'ffmpeg')
147
+
148
+ const duration = await readDuration(config, video)
149
+ const step = Math.max(1, Math.floor((duration * 30) / n))
150
+ await exec(ffmpeg, [
151
+ '-y', '-loglevel', 'error', '-i', video,
152
+ '-vf', `select='not(mod(n\\,${step}))',scale=270:-2,tile=4x3:margin=6:padding=4:color=0x222222`,
153
+ '-frames:v', '1', sheet,
154
+ ], { timeout: 120_000 })
155
+
156
+ const { stdout } = await exec(ffmpeg, [
157
+ '-hide_banner', '-i', video, '-vf', 'signalstats,metadata=print:key=lavfi.signalstats.YAVG:file=-',
158
+ '-f', 'null', '-',
159
+ ], { timeout: 120_000, maxBuffer: 32 * 1024 * 1024 })
160
+ const avgs = [...stdout.matchAll(/YAVG=([\d.]+)/g)].map((m) => Number(m[1]))
161
+ const blank = avgs.map((v, i) => ({ frame: i, yavg: v })).filter((x) => x.yavg < 8 || x.yavg > 250)
162
+
163
+ return {
164
+ video,
165
+ durationSec: duration,
166
+ framesAnalyzed: avgs.length,
167
+ contactSheet: sheet,
168
+ blankFrames: blank.slice(0, 8),
169
+ }
170
+ }
171
+
172
+ async function readDuration(config, video) {
173
+ try {
174
+ const { stdout } = await exec(binaryOf(config, 'ffprobe'), [
175
+ '-v', 'error', '-show_entries', 'format=duration', '-of', 'default=nw=1:nk=1', video,
176
+ ], { timeout: 60_000 })
177
+ const n = Number(stdout.trim())
178
+ if (Number.isFinite(n) && n > 0) return n
179
+ } catch {
180
+ // ffprobe 不可用时退回 ffmpeg -i 的 stderr 解析
181
+ }
182
+ const { stderr } = await exec(binaryOf(config, 'ffmpeg'), ['-hide_banner', '-i', video], { timeout: 60_000 })
183
+ .catch((err) => ({ stderr: String(err.stderr ?? '') }))
184
+ const m = /Duration:\s*(\d+):(\d+):(\d+\.\d+)/.exec(stderr)
185
+ if (!m) throw new Error('无法读取视频时长:ffprobe 与 ffmpeg 都失败')
186
+ return Number(m[1]) * 3600 + Number(m[2]) * 60 + Number(m[3])
187
+ }
188
+
189
+ /** 用 sympy 判两个表达式是否恒等(数学正确性门)。 */
190
+ export async function checkMath(config, args) {
191
+ requireVenv(config)
192
+ const venv = resolveVenv(config)
193
+ const python = venvBinary(config, 'python')
194
+ const script = `
195
+ import sys, sympy as sp
196
+ from sympy.parsing.sympy_parser import parse_expr, standard_transformations, implicit_multiplication_application
197
+ t = standard_transformations + (implicit_multiplication_application,)
198
+ x, y, t_ = sp.symbols('x y t')
199
+ local = {'x': x, 'y': y, 't': t_, 'pi': sp.pi, 'e': sp.E}
200
+ left = sp.nsimplify(parse_expr(sys.argv[1], transformations=t, local_dict=local), rational=True)
201
+ right = sp.nsimplify(parse_expr(sys.argv[2], transformations=t, local_dict=local), rational=True)
202
+ diff = sp.simplify(left - right)
203
+ print('EQUAL' if diff == 0 else 'DIFF')
204
+ print(sp.srepr(diff)[:400])
205
+ `
206
+ const { stdout } = await exec(python, ['-c', script, args.left, args.right], {
207
+ timeout: 60_000,
208
+ maxBuffer: 1024 * 1024,
209
+ env: {
210
+ ...process.env,
211
+ PATH: `${join(venv, process.platform === 'win32' ? 'Scripts' : 'bin')}${process.platform === 'win32' ? ';' : ':'}${process.env.PATH ?? ''}`,
212
+ },
213
+ })
214
+ const [verdict, rest] = stdout.trim().split('\n')
215
+ return { equal: verdict === 'EQUAL', difference: rest ?? '', left: args.left, right: args.right }
216
+ }
217
+
218
+ function clampInt(value, min, max) {
219
+ const n = Math.trunc(Number(value))
220
+ if (!Number.isFinite(n)) return min
221
+ return Math.min(max, Math.max(min, n))
222
+ }
package/package.json CHANGED
@@ -1,6 +1,51 @@
1
1
  {
2
2
  "name": "dsh-motion",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Code-generated math & science animations for DeepSeek Harness: render Manim clips, probe frames, verify formulas.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "engines": {
8
+ "node": ">=22"
9
+ },
10
+ "main": "lib/index.js",
11
+ "exports": {
12
+ ".": "./lib/index.js",
13
+ "./package.json": "./package.json"
14
+ },
15
+ "files": [
16
+ "lib",
17
+ "skills",
18
+ "cordis.patch.yml",
19
+ "README.md",
20
+ "LICENSE",
21
+ "NOTICE.md"
22
+ ],
23
+ "keywords": [
24
+ "dsh-plugin",
25
+ "deepseek-harness",
26
+ "animation",
27
+ "manim",
28
+ "science",
29
+ "video",
30
+ "code-animation"
31
+ ],
32
+ "dsh": {
33
+ "bundle": {
34
+ "patch": "./cordis.patch.yml"
35
+ }
36
+ },
37
+ "scripts": {
38
+ "check": "node --check lib/index.js && node --check lib/ops.js && node --check lib/env.js && node --check lib/doctor.js",
39
+ "smoke": "node scripts/smoke.mjs",
40
+ "check:meta": "node scripts/check-meta.mjs",
41
+ "prepack": "npm run check && npm run smoke"
42
+ },
43
+ "repository": {
44
+ "type": "git",
45
+ "url": "git+https://github.com/cogito0823/dsh-motion.git"
46
+ },
47
+ "homepage": "https://github.com/cogito0823/dsh-motion#readme",
48
+ "bugs": {
49
+ "url": "https://github.com/cogito0823/dsh-motion/issues"
50
+ }
51
+ }
@@ -0,0 +1,40 @@
1
+ ---
2
+ name: animation-router
3
+ description: 做代码动画/科普视频的总入口:先判题材(关系还是意象),再选画面层与交付管线。任何"做个动画/视频/讲清楚一个概念"的请求都先看这里。
4
+ ---
5
+
6
+ # 总入口:先分题材,再选画面层
7
+
8
+ ## 1. 判题材
9
+
10
+ | 题材 | 观众记住 | 判据 |
11
+ |---|---|---|
12
+ | 数学讲解 | 一个**关系** | 公式、坐标、推导、表格 |
13
+ | 科普叙事 | 一个**意象** | 画面、比喻、节奏、材质 |
14
+
15
+ ## 2. 选画面层
16
+
17
+ | 画面层 | 适合 | 不用它的理由 |
18
+ |---|---|---|
19
+ | Manim 片段(clip) | 公式、坐标系、几何变换 | 纯叙事片会显得呆板 |
20
+ | Canvas 2D 逐帧 | 光、影、粒子、材质、转场 | 文字排版成本高 |
21
+ | HTML / SVG | 排版、图文、简单图示 | 做不出辉光与颗粒 |
22
+
23
+ 三者都只是素材层;**交付统一在 HyperFrames 工程里完成**。
24
+
25
+ ## 3. 固定顺序
26
+
27
+ ```
28
+ 1. DESIGN.md(design spec) ← 先定颜色/字体/禁用项
29
+ 2. 分镜表(storyboard) ← 拍/秒/画面/旁白/验收点,先给用户确认
30
+ 3. 画面
31
+ 4. hyperframes check ← 0 error 才算过
32
+ 5. motion_probe ← 抽 12 帧看空镜/叠字
33
+ 6. 旁白 + 字幕 + −14 LUFS
34
+ ```
35
+
36
+ ## 4. 三条硬规则
37
+
38
+ 1. **没有 DESIGN.md 或参考语法,不开工**——否则每次都是同一个默认 look;
39
+ 2. **字幕放在 composition 层**,不烧进 Manim 或 canvas;
40
+ 3. **公式先过 `formula_check`**,再进画面。
@@ -0,0 +1,61 @@
1
+ ---
2
+ name: design-spec
3
+ description: 为动画片写 DESIGN.md(设计规范):配色、字体、间距、禁用清单,以及从参考片/主题世界取语法。觉得画面丑、要统一系列风格、或开工前,读这个。
4
+ ---
5
+
6
+ # 写一份 DESIGN.md
7
+
8
+ ## 为什么必须写
9
+
10
+ HyperFrames 的 look 文档原话:**不给外部输入,相同的 brief 每次都会得到同一个 look;禁掉它只会滑到下一个默认。**
11
+
12
+ 外部输入按优先级:
13
+
14
+ | 顺位 | 来源 | 科普片适用性 |
15
+ |---|---|---|
16
+ | 1 | 项目根 `DESIGN.md` / `frame.md` | ✅ 首选 |
17
+ | 2 | 真实品牌 | ❌ 一般不适用 |
18
+ | 3 | 参考片 / 参考图 | ✅ 只取语法,不取内容 |
19
+ | 4 | 主题自身的世界 | ✅ 年代、材质、展示场景 |
20
+ | 5 | 官方预设(preset) | ⚠️ 幻灯片语言,仅点名或做 deck 时用 |
21
+
22
+ ## 最小形态
23
+
24
+ ```markdown
25
+ ---
26
+ colors:
27
+ ground: "#F3ECDD"
28
+ ink: "#20302A"
29
+ accent: "#B8442A"
30
+ typography:
31
+ display: { family: "Georgia", weight: 400 }
32
+ body: { family: "Helvetica Neue", weight: 400 }
33
+ spacing: { margin: 140, rule: 2 }
34
+ ---
35
+
36
+ # <片名> · <语言>
37
+
38
+ ## Do
39
+ - 图形先有墨线,再有填充;线宽 2–3px
40
+ - 只用 3–5 个颜色
41
+ - 运动是"生长",缓动一律 ease-out
42
+
43
+ ## Don't
44
+ - 不用高饱和色、渐变球、发光、投影
45
+ - 不把整段文字堆成卡片
46
+ - 不用 Inter / Space Grotesk 做标题(生成式默认字体)
47
+
48
+ ## 版面
49
+ - 页边距、标题基线、图注与字幕不重叠
50
+
51
+ ## 参考
52
+ - 具体到某类图版、某支片、某个年代的印刷品
53
+ ```
54
+
55
+ ## 三种已验证的语言
56
+
57
+ | 语言 | 适合 | 关键手段 |
58
+ |---|---|---|
59
+ | 纸感植物图版 | 生物 / 博物 | 纸纤维、水洗叠色、墨线、引线标注 |
60
+ | 暗场发光 | 物理 / 天文 / 微观 | 近黑底、细线辉光、粒子、颗粒 + 暗角 |
61
+ | 编辑排版 | 数学 / 数据 / 概念 | 网格、字阶、单一强调色 |
@@ -0,0 +1,49 @@
1
+ ---
2
+ name: qa-rubric
3
+ description: 代码动画交付前的自检清单:环境、公式、工程、画面、音频、字幕。渲染完但还没交付时使用。
4
+ ---
5
+
6
+ # 交付前自检
7
+
8
+ ## 1. 环境
9
+
10
+ 先跑 `motion_doctor`,`ready.math` 与 `ready.delivery` 都为 true 再继续。
11
+
12
+ ## 2. 公式(仅数学题材)
13
+
14
+ - 所有上屏公式先过 `formula_check`;
15
+ - 逐条对照代码与画面,确认没有手改过的常数。
16
+
17
+ ## 3. 工程
18
+
19
+ ```bash
20
+ npx hyperframes check
21
+ ```
22
+
23
+ - 0 error 才算过;
24
+ - 关注 lint(缺 `@font-face`)、layout(文字裁切/重叠)、contrast(对比度 < 3:1)。
25
+
26
+ ## 4. 画面
27
+
28
+ ```bash
29
+ motion_probe --video <mp4>
30
+ ```
31
+
32
+ - 看 contact sheet:无叠字、无出画、无空白帧;
33
+ - `blankFrames` 里的帧逐一看过再决定是否重渲。
34
+
35
+ ## 5. 音频
36
+
37
+ - 旁白最后一字早于结论画面;
38
+ - 响度 ≈ −14 LUFS(±1),真峰值 ≤ −1.5 dBTP;
39
+ - 没有爆音、没有截断。
40
+
41
+ ## 6. 字幕
42
+
43
+ - 烧录字幕不遮挡主体;
44
+ - `.srt` 与音频对齐,单行 ≤ 18 个汉字;
45
+ - 烧录与 `.srt` 来自同一份 transcript。
46
+
47
+ ## 7. 交付
48
+
49
+ mp4 + `.srt` + 场景源码 + `DESIGN.md` + 分镜表。