dsh-motion 0.4.1 → 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.
@@ -45,8 +45,8 @@
45
45
 
46
46
  已完成:
47
47
 
48
- - [x] `motion_frames` 按场景 fps 取帧:各风格 `theme.py` 在模块层设置 `m.config.frame_rate`;
49
- - [x] post 强制 CFR,并校验成片时长(`tpad + 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/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 ensureDuration(config, summary, fps)
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
- /** 成片时长与渲染器报告的时长不一致时,用 tpad + CFR 修正;返回是否修过。 */
118
- async function ensureDuration(config, summary, fps) {
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 delta = expected - before.durationSec
124
- if (!Number.isFinite(delta) || Math.abs(delta) <= 0.08) return false
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
- if (!Number.isFinite(after.durationSec) || Math.abs(after.durationSec - expected) > 0.12) {
141
- throw new Error(`CFR 修正后时长仍不对:期望 ${expected}s,实际 ${after.durationSec}s(${tmp})`)
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: 60_000, maxBuffer: 1024 * 1024 })
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.1",
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",
@@ -13,9 +13,6 @@ import manimgx as m
13
13
 
14
14
  TOKENS = json.loads(Path(__file__).with_name("tokens.json").read_text(encoding="utf-8"))
15
15
 
16
- # inspect 在 import 时读 config.frame_rate;必须放在模块层,不能只在 construct 里。
17
- m.config.frame_rate = int(TOKENS["motion"]["fps"])
18
-
19
16
  BG = TOKENS["colors"]["bg"]
20
17
  TEXT = TOKENS["colors"]["text"]
21
18
  LABEL = TOKENS["colors"]["label"]
@@ -30,11 +27,13 @@ AXIS = TOKENS["colors"]["axis"]
30
27
  LAYOUT = TOKENS["layout"]
31
28
 
32
29
 
33
- def apply_config(width: int = 1920, height: int = 1080, fps: int = 30) -> None:
34
- m.config.pixel_width = width
35
- m.config.pixel_height = height
36
- m.config.frame_rate = fps
37
- m.config.background_color = BG
30
+ def apply_config(background: str = BG) -> None:
31
+ """只设背景色;分辨率/帧率由 CLI(-r / --fps)或调用方决定。
32
+
33
+ 不要在场景里写死 1920x1080/30:那会覆盖 motion_render 的 width/height/fps,
34
+ 也是 1280x720 崩溃与 --fps 60 静默变 5s 的根因。
35
+ """
36
+ m.config.background_color = background
38
37
 
39
38
 
40
39
  def backdrop() -> m.Rectangle:
package/styles/README.md CHANGED
@@ -18,7 +18,7 @@
18
18
  - `STYLE.md` 是**改编**,必须在上游基础上写清楚差异;不复制 demo 代码;
19
19
  - 颜色、字体、时间表、音频以 `tokens.json` 为准;版面坐标与组件配方写在 `STYLE.md`;
20
20
  - 中文文本不做字符级 `ReplacementTransform` / `Transform`(会出字形碎片);用 Fade/Write/Indicate,换词用 FadeOut+FadeIn;
21
- - `theme.py` 必须在**模块 import 时**设置 `m.config.frame_rate`(不能只在 `construct` 里),否则 `manimgx inspect` 会按默认 60fps 取帧,30fps 场景的后半段会报 no frame;
21
+ - **不要在场景/`theme.py` 里写死 1920×1080 / fps**:`apply_config()` 只设背景色;分辨率与帧率交给 `motion_render` 的 `width/height/fps` 或 CLI 的 `-r/--fps`。写死会覆盖 CLI,导致 `-r 1280x720` 崩溃、`--fps 60` 静默变 5s;
22
22
  - 路由:`styles/registry.json` 是元数据,`node scripts/route-style.mjs "<题材>"` 打分;点名优先,低置信度/并列先问用户;
23
23
  - 新片在自己的项目目录写 `DESIGN.md`,头部 `style: <slug>` + `style_version: 1.0.0`;
24
24
  - 参考与示例统一用 HyperFrames 的 [examples](https://hyperframes.heygen.com/examples) / [catalog](https://hyperframes.heygen.com/catalog),不在仓库里放样片(`showcase/` 只放可复现的演示)。
@@ -15,9 +15,6 @@ import manimgx as m
15
15
 
16
16
  TOKENS = json.loads(Path(__file__).with_name("tokens.json").read_text(encoding="utf-8"))
17
17
 
18
- # inspect 在 import 时读 config.frame_rate;必须放在模块层,不能只在 construct 里。
19
- m.config.frame_rate = int(TOKENS["motion"]["fps"])
20
-
21
18
  TABLE = TOKENS["colors"]["table"]
22
19
  PAPER = TOKENS["colors"]["paper"]
23
20
  PAPER_LIGHT = TOKENS["colors"]["paper_light"]
@@ -27,11 +24,13 @@ STAMP = TOKENS["colors"]["stamp"]
27
24
  LAYOUT = TOKENS["layout"]
28
25
 
29
26
 
30
- def apply_config(width: int = 1920, height: int = 1080, fps: int = 30) -> None:
31
- m.config.pixel_width = width
32
- m.config.pixel_height = height
33
- m.config.frame_rate = fps
34
- m.config.background_color = TABLE
27
+ def apply_config(background: str = TABLE) -> None:
28
+ """只设背景色;分辨率/帧率由 CLI(-r / --fps)或调用方决定。
29
+
30
+ 不要在场景里写死 1920x1080/30:那会覆盖 motion_render 的 width/height/fps,
31
+ 也是 1280x720 崩溃与 --fps 60 静默变 5s 的根因。
32
+ """
33
+ m.config.background_color = background
35
34
 
36
35
 
37
36
  def backdrop(seed: int = 11) -> m.ImageMobject:
@@ -13,9 +13,6 @@ import manimgx as m
13
13
 
14
14
  TOKENS = json.loads(Path(__file__).with_name("tokens.json").read_text(encoding="utf-8"))
15
15
 
16
- # inspect 在 import 时读 config.frame_rate;必须放在模块层,不能只在 construct 里。
17
- m.config.frame_rate = int(TOKENS["motion"]["fps"])
18
-
19
16
  PAPER = TOKENS["colors"]["paper"]
20
17
  PAPER_SHADOW = TOKENS["colors"]["paper_shadow"]
21
18
  INK = TOKENS["colors"]["ink"]
@@ -28,11 +25,13 @@ MONO = TOKENS["typography"]["mono"]["family"]
28
25
  LAYOUT = TOKENS["layout"]
29
26
 
30
27
 
31
- def apply_config(width: int = 1920, height: int = 1080, fps: int = 30) -> None:
32
- m.config.pixel_width = width
33
- m.config.pixel_height = height
34
- m.config.frame_rate = fps
35
- m.config.background_color = PAPER
28
+ def apply_config(background: str = PAPER) -> None:
29
+ """只设背景色;分辨率/帧率由 CLI(-r / --fps)或调用方决定。
30
+
31
+ 不要在场景里写死 1920x1080/30:那会覆盖 motion_render 的 width/height/fps,
32
+ 也是 1280x720 崩溃与 --fps 60 静默变 5s 的根因。
33
+ """
34
+ m.config.background_color = background
36
35
 
37
36
 
38
37
  def backdrop() -> m.Rectangle:
@@ -13,9 +13,6 @@ import manimgx as m
13
13
 
14
14
  TOKENS = json.loads(Path(__file__).with_name("tokens.json").read_text(encoding="utf-8"))
15
15
 
16
- # inspect 在 import 时读 config.frame_rate;必须放在模块层,不能只在 construct 里。
17
- m.config.frame_rate = int(TOKENS["motion"]["fps"])
18
-
19
16
  GROUND = TOKENS["colors"]["ground"]
20
17
  PAPER = TOKENS["colors"]["paper"]
21
18
  INK = TOKENS["colors"]["ink"]
@@ -27,11 +24,13 @@ MOTION = TOKENS["motion"]
27
24
  LAYOUT = TOKENS["layout"]
28
25
 
29
26
 
30
- def apply_config(width: int = 1920, height: int = 1080, fps: int = 30) -> None:
31
- m.config.pixel_width = width
32
- m.config.pixel_height = height
33
- m.config.frame_rate = fps
34
- m.config.background_color = GROUND
27
+ def apply_config(background: str = GROUND) -> None:
28
+ """只设背景色;分辨率/帧率由 CLI(-r / --fps)或调用方决定。
29
+
30
+ 不要在场景里写死 1920x1080/30:那会覆盖 motion_render 的 width/height/fps,
31
+ 也是 1280x720 崩溃与 --fps 60 静默变 5s 的根因。
32
+ """
33
+ m.config.background_color = background
35
34
 
36
35
 
37
36
  def backdrop() -> m.Rectangle:
@@ -15,9 +15,6 @@ import manimgx as m
15
15
 
16
16
  TOKENS = json.loads(Path(__file__).with_name("tokens.json").read_text(encoding="utf-8"))
17
17
 
18
- # inspect 在 import 时读 config.frame_rate;必须放在模块层,不能只在 construct 里。
19
- m.config.frame_rate = int(TOKENS["motion"]["fps"])
20
-
21
18
  BOARD = TOKENS["colors"]["board"]
22
19
  BOARD_EDGE = TOKENS["colors"]["board_edge"]
23
20
  INK = TOKENS["colors"]["ink"]
@@ -28,11 +25,13 @@ GHOST = TOKENS["colors"]["ghost"]
28
25
  LAYOUT = TOKENS["layout"]
29
26
 
30
27
 
31
- def apply_config(width: int = 1920, height: int = 1080, fps: int = 30) -> None:
32
- m.config.pixel_width = width
33
- m.config.pixel_height = height
34
- m.config.frame_rate = fps
35
- m.config.background_color = BOARD
28
+ def apply_config(background: str = BOARD) -> None:
29
+ """只设背景色;分辨率/帧率由 CLI(-r / --fps)或调用方决定。
30
+
31
+ 不要在场景里写死 1920x1080/30:那会覆盖 motion_render 的 width/height/fps,
32
+ 也是 1280x720 崩溃与 --fps 60 静默变 5s 的根因。
33
+ """
34
+ m.config.background_color = background
36
35
 
37
36
 
38
37
  def backdrop(seed: int = 5) -> m.ImageMobject: