@remixmate/cli 0.9.45 → 0.9.46

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.9.45",
4
- "generatedAt": "2026-09-23T05:34:40.922Z",
3
+ "version": "0.9.46",
4
+ "generatedAt": "2026-09-23T08:47:39.701Z",
5
5
  "skills": [
6
6
  {
7
7
  "id": "doc-parse",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remixmate/cli",
3
- "version": "0.9.45",
3
+ "version": "0.9.46",
4
4
  "description": "AI media generation skills for Claude Code / Codex — 12 skills covering image, video, voice, digital human, web screenshot, web recording, script, template registry, rendering, Jianying export, and video deconstruction.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -328,7 +328,11 @@ def plan_scenes(
328
328
  opening_duration = max(3, round(duration * 0.12))
329
329
  ending_duration = max(3, round(duration * 0.12))
330
330
  content_total = duration - opening_duration - ending_duration
331
- per_content = max(3, round(content_total / content_scenes))
331
+ # 余数摊到前几段,保证各段加总正好等于 duration。以前是 round(均值) × 段数:
332
+ # 2026-09-23 线上 ppt-to-video 传 --duration 600 --scenes 23,21 段 × round(21.7)=22
333
+ # 加总 606s,撞上模板 supportedDurations.max=600 被自己的时长门禁拒掉。
334
+ # 每段仍有 3s 下限 —— 段数多到摊不开时加总会超出,那是真超出,交给门禁报。
335
+ base_content, extra_content = divmod(max(content_total, 0), content_scenes)
332
336
 
333
337
  scenes.append({
334
338
  "purpose": "opening",
@@ -339,7 +343,7 @@ def plan_scenes(
339
343
  for i in range(content_scenes):
340
344
  scenes.append({
341
345
  "purpose": "point",
342
- "duration": per_content,
346
+ "duration": max(3, base_content + (1 if i < extra_content else 0)),
343
347
  "label": _localize_label(language, "label_point", n=i + 1),
344
348
  })
345
349
 
@@ -617,6 +621,7 @@ def _resolve_contract(template_config: dict | None) -> dict:
617
621
  "payload_defaults": payload_defaults,
618
622
  "primary_visual_type": primary_visual_type,
619
623
  "default_layout": caps.get("defaultLayout"),
624
+ "authored_scenes": caps.get("authoredScenes") is True,
620
625
  }
621
626
 
622
627
 
@@ -791,6 +796,16 @@ def _plan_contract_scenes(
791
796
 
792
797
  fixed = contract["fixed_scenes"] or [{"purpose": "opening"}]
793
798
  n = len(fixed)
799
+ # 非 arc 策略的场景数由模板钉死,--scenes 不生效 —— 但必须出声。2026-09-23 线上
800
+ # screen-walkthrough 传 --scenes 6 拿回 2 场,没有任何提示,模型只能自己猜为什么。
801
+ if scene_count is not None and scene_count != n:
802
+ print(
803
+ f"⚠️ requested {scene_count} scene(s) but this template's sceneStrategy="
804
+ f"{contract['scene_strategy']} always plans {n}: --scenes is ignored here.\n"
805
+ f" The DSL below has {n} scenes — read the count back from it before showing "
806
+ f"the script to the user.",
807
+ file=sys.stderr,
808
+ )
794
809
  per = max(1, round(duration / n))
795
810
  plans = []
796
811
  for i, fs in enumerate(fixed):
@@ -1709,6 +1724,20 @@ def build_dsl(
1709
1724
 
1710
1725
  _record_source_document(dsl, template_config, template_id, source_document_url)
1711
1726
 
1727
+ if contract["authored_scenes"]:
1728
+ # 场景表由调用方按用户素材编写(如按操作时间点切录屏),骨架只提供 meta/global。
1729
+ # 这个标记必须由骨架携带:ab-agent 看到它才会整份采用模型提交的 DSL,
1730
+ # 否则它以骨架为基底合并,新增场景、existing 素材、visuals 都会被静默丢掉。
1731
+ dsl["meta"]["authoredScenes"] = True
1732
+ print(
1733
+ f"ℹ️ template '{template_id}' declares capabilities.authoredScenes: the scene list below "
1734
+ f"is only a placeholder.\n"
1735
+ f" Write the full DSL yourself from the user's material (scenes, assets, visuals, "
1736
+ f"customPayload) and submit all of it to prepare_video_assets — it is used as-is, "
1737
+ f"not merged onto this skeleton. Keep meta.templateId.",
1738
+ file=sys.stderr,
1739
+ )
1740
+
1712
1741
  _enforce_supported_duration(dsl, template_config, template_id)
1713
1742
 
1714
1743
  return dsl
@@ -110,7 +110,10 @@ def _rebalance_subtitle_segments(segs: list[dict]) -> list[dict]:
110
110
  for p in parts:
111
111
  ratio = len(p) / total_chars
112
112
  frames = max(int(total_frames * ratio), 1)
113
+ # `**seg` 而不是只写三个键:切出来的各部分仍属于**同一句**,
114
+ # 所以 sentenceIndex 等字段要原样继承(见 build_render_plan 处的说明)。
113
115
  expanded.append({
116
+ **seg,
114
117
  "text": _strip_subtitle_trailing_punct(p.strip()),
115
118
  "startFrame": cur_frame,
116
119
  "endFrame": cur_frame + frames,
@@ -848,9 +851,13 @@ def build_render_plan(dsl: dict, binding: dict) -> dict:
848
851
  local_subs = split_subtitle_from_lines(lines, duration_frames, fps)
849
852
  else:
850
853
  local_subs = split_subtitle(narration_text, duration_frames, fps)
854
+ # ⚠️ 平移帧号时**必须保留原有字段**(`**s`),不要只挑三个键重建。
855
+ # 这里曾经只搬 text/startFrame/endFrame,于是 timeline_compiler 给每段打上的
856
+ # `sentenceIndex`(模板用它把动作对齐到**句**而不是段)在这一步被静默丢掉 ——
857
+ # 下游模板拿不到句边界,只能退回按段对齐,也就是那个错位 bug 本身。
851
858
  subtitle_segments = [
852
859
  {
853
- "text": s["text"],
860
+ **s,
854
861
  "startFrame": s["startFrame"] + current_frame,
855
862
  "endFrame": s["endFrame"] + current_frame,
856
863
  }
@@ -1519,6 +1526,7 @@ def adjust_timeline_to_audio(render_plan: dict) -> dict:
1519
1526
  # 起点为终点。
1520
1527
  end_f = scene_dur if i == len(old_subs) - 1 else starts[i + 1]
1521
1528
  new_subs.append({
1529
+ **s,
1522
1530
  "text": text,
1523
1531
  "startFrame": current_frame + start_f,
1524
1532
  "endFrame": current_frame + end_f,
@@ -1538,6 +1546,7 @@ def adjust_timeline_to_audio(render_plan: dict) -> dict:
1538
1546
  ratio = _plain(text) / total_chars
1539
1547
  seg_frames = max(int(sub_span * ratio), fps // 2)
1540
1548
  new_subs.append({
1549
+ **s,
1541
1550
  "text": text,
1542
1551
  "startFrame": current_frame + cursor,
1543
1552
  "endFrame": current_frame + cursor + seg_frames,
@@ -1560,7 +1569,7 @@ def adjust_timeline_to_audio(render_plan: dict) -> dict:
1560
1569
  rel_start = s["startFrame"] - old_scene_start
1561
1570
  rel_end = s["endFrame"] - old_scene_start
1562
1571
  new_subs.append({
1563
- "text": s["text"],
1572
+ **s,
1564
1573
  "startFrame": current_frame + rel_start,
1565
1574
  "endFrame": current_frame + rel_end,
1566
1575
  })
@@ -2,6 +2,6 @@
2
2
  "skillName": "render-video",
3
3
  "repoName": "agent-skill-media-maker",
4
4
  "skillId": "473",
5
- "version": "V27",
5
+ "version": "V28",
6
6
  "skillDescription": "Final-render skill: loads a persisted RenderPlan by `job_id` and drives the Remotion engine to produce the final video.\n\nUse this skill as soon as the user mentions any of these intents (after assets are already prepared):\n- Render the video, composite the video, export the video\n- Turn the prepared assets into the final clip\n- Render with Remotion\n\nPrerequisite: assets must already be generated via `prepare_video_assets`. This skill never resolves or regenerates assets — pass it a `job_id` from a previous `prepare_video_assets` call.\n\n⚠️ Stop-and-confirm gate: never call this skill until the user has explicitly confirmed the assets prepared by `prepare_video_assets`. If those assets were prepared in the current turn and the user has not replied since, stop and ask instead of rendering."
7
7
  }
@@ -56,6 +56,44 @@ SUPPORTED_SOURCES = ("doc-extract",)
56
56
  DEFAULT_INDEX_FIELD = "slideIndex"
57
57
 
58
58
 
59
+ def declared_subfields(template_config: Optional[dict], payload_field: str) -> Optional[set[str]]:
60
+ """模板为 ``customPayload.<payload_field>`` 声明的子字段名集合。
61
+
62
+ 注入必须按这个集合**投影**,不能把抽取器返回的整个对象原样塞进去 ——
63
+ 两边的字段集根本不是一回事:
64
+
65
+ 抽取器返回(每页) slideIndex / title / text / rawNotes / verdict / coverage /
66
+ flags / nodes / beats / mode / modeReason / ambientIds /
67
+ background / recommendedSec / canvas …
68
+ 模板声明(slide) nodes / beats / mode / modeReason / ambientIds /
69
+ background / recommendedSec / canvas
70
+
71
+ 前者多出来的那些是**内容层与抽取器自己的记账字段**(备注原文、质检结论、覆盖率),
72
+ 渲染端一个都不读。而 ``dsl_validator._check_custom_payload_fields`` 会逐字段核对
73
+ 声明表,遇到没声明的就报「the slide does not read 'x' — the value is silently
74
+ dropped」并整单拒绝。2026-09-23 线上 conv_StrnauS7TaZNUQV4BfLX 就是这样:
75
+ 注入明明成功(23/23 页),却因为多了 5 个字段被校验整单拒掉,而且**重试必然复现**。
76
+
77
+ 为什么用投影而不是写个黑名单:抽取器以后再加字段(verdict / coverage 就是后加的)
78
+ 黑名单就会再漏一次。声明表是渲染端真正读的那一份,按它投影才不会二次失效。
79
+
80
+ 声明表读不到时返回 None = 不投影。这与校验层的行为一致:没有
81
+ ``customPayloadSchema`` 时它也不查字段名,所以不投影不会引入新的失败。
82
+ """
83
+ if not template_config:
84
+ return None
85
+ cps = template_config.get("customPayloadSchema")
86
+ if not isinstance(cps, dict):
87
+ return None
88
+ node = cps.get(payload_field)
89
+ if not isinstance(node, dict):
90
+ return None
91
+ props = node.get("properties")
92
+ if not isinstance(props, dict) or not props:
93
+ return None
94
+ return set(props.keys())
95
+
96
+
59
97
  class HydrationError(RuntimeError):
60
98
  """注入失败。message 必须直接告诉调用方下一步做什么。
61
99
 
@@ -206,6 +244,18 @@ def hydrate_dsl(
206
244
  filled = 0
207
245
  missing: list[str] = []
208
246
 
247
+ # 按模板声明投影。抽取结果里混着内容层与抽取器记账字段,渲染端一个都不读,
248
+ # 而校验层见到未声明字段会整单拒绝(见 declared_subfields 的说明)。
249
+ allowed = declared_subfields(template_config, field)
250
+ dropped: set[str] = set()
251
+
252
+ # 页号只有在模板**声明**了它的时候才写回去。没声明就是未知字段,写了会被校验
253
+ # 拒掉 —— 这正是上一版的第二个错:既投影不足,又多写了一个 customPayload.slideIndex。
254
+ index_is_declared = index_field in (declared_subfields(template_config, index_field) or ())
255
+ if not index_is_declared:
256
+ cps = (template_config or {}).get("customPayloadSchema")
257
+ index_is_declared = isinstance(cps, dict) and index_field in cps
258
+
209
259
  for position, scene in enumerate(scenes, start=1):
210
260
  payload = _scene_payload(scene)
211
261
  if payload.get(field) is not None:
@@ -216,9 +266,14 @@ def hydrate_dsl(
216
266
  if page is None:
217
267
  missing.append(f"scene {scene.get('id', position)} → page {page_no}")
218
268
  continue
219
- payload[field] = page
220
- # 把页号写回去,让 RenderPlan 里留下"这一场用的是第几页"的痕迹。
221
- payload.setdefault(index_field, page_no)
269
+ if allowed is None:
270
+ payload[field] = page
271
+ else:
272
+ dropped |= {k for k in page if k not in allowed}
273
+ payload[field] = {k: v for k, v in page.items() if k in allowed}
274
+ # 留下"这一场用的是第几页"的痕迹 —— 仅当模板声明了这个字段。
275
+ if index_is_declared:
276
+ payload.setdefault(index_field, page_no)
222
277
  filled += 1
223
278
 
224
279
  source_meta = result.get("source") or {}
@@ -227,6 +282,13 @@ def hydrate_dsl(
227
282
  f"{source_meta.get('slideCount', len(pages))} page(s) of "
228
283
  f"{source_meta.get('name', 'the source document')}"
229
284
  )
285
+ if dropped:
286
+ # 说清丢了什么:这些是内容层/记账字段,渲染端本来就不读。写出来是为了
287
+ # 抽取器哪天把一个**该读**的字段也归到这里时,有人看得见。
288
+ report.append(
289
+ f" (已按模板声明投影,忽略 {len(dropped)} 个渲染端不读的字段:"
290
+ f"{', '.join(sorted(dropped)[:8])})"
291
+ )
230
292
 
231
293
  if missing:
232
294
  # 页号对不上不是警告而是错误:少一页 = 成片里一个空白场景,而它渲得出来。
@@ -2,6 +2,6 @@
2
2
  "skillName": "template-registry",
3
3
  "repoName": "agent-skill-media-maker",
4
4
  "skillId": "475",
5
- "version": "V20",
5
+ "version": "V21",
6
6
  "skillDescription": "Video-template registry skill. Stores every video-template definition and lists the available templates (templateId / name / aspect ratio / style tags).\n\nUse this skill as soon as the user mentions any of these intents:\n- View available templates / list every template\n\nNote: DSL→TemplateBinding is no longer a separate exposed step — once prepare_video_assets receives a template_id it builds the binding internally."
7
7
  }