@remixmate/cli 0.9.44 → 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.
@@ -65,6 +65,27 @@ try:
65
65
  except ImportError:
66
66
  _HAS_ENHANCER = False
67
67
 
68
+ # 场景数上限的唯一实现在 dsl_validator(它同时是门禁方)。守护式导入的理由同上:
69
+ # 共享目录可能不在(单测 / 裁剪安装),那时退回历史常量 20,行为与改动前一致。
70
+ try:
71
+ from video_dsl.runtime.dsl_validator import (
72
+ DEFAULT_MAX_SCENES,
73
+ max_scenes_for_template,
74
+ )
75
+ except ImportError:
76
+ DEFAULT_MAX_SCENES = 20
77
+
78
+ def max_scenes_for_template(template_config): # type: ignore[misc]
79
+ return DEFAULT_MAX_SCENES
80
+
81
+ # payloadHydration 声明的读取。gen_script 只用它来判断"这个模板需不需要源文件引用",
82
+ # 真正的取数发生在 render-video 侧(见 payload_hydrator 模块头)。
83
+ try:
84
+ from payload_hydrator import hydration_spec # noqa: E402
85
+ _HAS_HYDRATOR = True
86
+ except ImportError:
87
+ _HAS_HYDRATOR = False
88
+
68
89
  try:
69
90
  from registry_loader import (
70
91
  load_registry_data as _load_registry_data,
@@ -257,8 +278,23 @@ def validate_dsl(dsl: dict) -> list:
257
278
  return errors_as_strings(validate_structural(dsl))
258
279
 
259
280
 
260
- def plan_scenes(topic: str, duration: int, scene_count: int | None, allow_digital_human: bool, language: str = DEFAULT_LANGUAGE) -> list:
261
- """Plan scene structure based on topic and constraints."""
281
+ def plan_scenes(
282
+ topic: str,
283
+ duration: int,
284
+ scene_count: int | None,
285
+ allow_digital_human: bool,
286
+ language: str = DEFAULT_LANGUAGE,
287
+ max_scenes: int = DEFAULT_MAX_SCENES,
288
+ ) -> list:
289
+ """Plan scene structure based on topic and constraints.
290
+
291
+ ``max_scenes`` 来自模板的 ``capabilities.maxScenes``(见 ``_resolve_contract``)。
292
+ 被 clamp 时**必须出声**:调用方要的场景数和拿到的不一样,而这个差值一路传到
293
+ 下游才会以别的形态爆出来——2026-09-23 线上一份 23 页 PPT 在这里被静默砍到 20,
294
+ 模型接着写了 23 段旁白、让用户确认了 23 个场景,最后 prepare_video_assets 报的是
295
+ ``scenes[0..19].customPayload.slide (required)``,跟"场景被砍了"半点关系看不出来。
296
+ """
297
+ requested = scene_count
262
298
  if scene_count is None:
263
299
  if duration <= 15:
264
300
  scene_count = 3
@@ -269,7 +305,22 @@ def plan_scenes(topic: str, duration: int, scene_count: int | None, allow_digita
269
305
  else:
270
306
  scene_count = min(10, duration // 8)
271
307
 
272
- scene_count = max(3, min(scene_count, 20))
308
+ scene_count = max(3, min(scene_count, max_scenes))
309
+
310
+ if requested is not None and requested != scene_count:
311
+ detail = (
312
+ f"raised to the {scene_count}-scene minimum"
313
+ if requested < scene_count
314
+ else f"clamped to the template's maximum of {max_scenes}"
315
+ )
316
+ print(
317
+ f"⚠️ requested {requested} scene(s) but planned {scene_count}: {detail}.\n"
318
+ f" The DSL below has {scene_count} scenes — read the count back from it before "
319
+ f"showing the script to the user, and do not describe scenes that are not there.\n"
320
+ f" If this template needs one scene per source page, raise "
321
+ f"capabilities.maxScenes in its template.json.",
322
+ file=sys.stderr,
323
+ )
273
324
 
274
325
  scenes = []
275
326
  content_scenes = scene_count - 2
@@ -277,7 +328,11 @@ def plan_scenes(topic: str, duration: int, scene_count: int | None, allow_digita
277
328
  opening_duration = max(3, round(duration * 0.12))
278
329
  ending_duration = max(3, round(duration * 0.12))
279
330
  content_total = duration - opening_duration - ending_duration
280
- 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)
281
336
 
282
337
  scenes.append({
283
338
  "purpose": "opening",
@@ -288,7 +343,7 @@ def plan_scenes(topic: str, duration: int, scene_count: int | None, allow_digita
288
343
  for i in range(content_scenes):
289
344
  scenes.append({
290
345
  "purpose": "point",
291
- "duration": per_content,
346
+ "duration": max(3, base_content + (1 if i < extra_content else 0)),
292
347
  "label": _localize_label(language, "label_point", n=i + 1),
293
348
  })
294
349
 
@@ -491,6 +546,8 @@ def _template_needs_image(template_config: dict | None) -> bool:
491
546
  # - needs_narration bool capabilities.needsNarration,默认 True
492
547
  # - scene_strategy str capabilities.sceneStrategy ∈ {arc, single, fixed, countdown},默认 arc
493
548
  # - fixed_scenes list[dict] capabilities.fixedScenes(single/fixed 用;缺省单 opening)
549
+ # - max_scenes int capabilities.maxScenes,默认 20(arc 的 --scenes 上限;
550
+ # "一页一场景"的模板由源文件页数决定场景数,应声明抬高)
494
551
  # - countdown dict|None countdown 策略的解析结果(见 countdown_spec 模块;
495
552
  # 非 countdown 模板为 None)
496
553
  # - payload_style str capabilities.payloadStyle ∈ {visual-overlay, slide, carousel-caption}
@@ -553,6 +610,8 @@ def _resolve_contract(template_config: dict | None) -> dict:
553
610
  "needs_narration": needs_narration,
554
611
  "scene_strategy": scene_strategy,
555
612
  "fixed_scenes": fixed_scenes,
613
+ # 上限读取放共享模块:dsl_validator 的门禁读的是同一个函数。
614
+ "max_scenes": max_scenes_for_template(template_config),
556
615
  # 声明解析放共享模块:render-video 的计划校验要读同一份声明,
557
616
  # 两边各抄一遍正是这个功能上一版栽过的跟头。
558
617
  "countdown": _resolve_countdown(template_config) if scene_strategy == "countdown" else None,
@@ -562,6 +621,7 @@ def _resolve_contract(template_config: dict | None) -> dict:
562
621
  "payload_defaults": payload_defaults,
563
622
  "primary_visual_type": primary_visual_type,
564
623
  "default_layout": caps.get("defaultLayout"),
624
+ "authored_scenes": caps.get("authoredScenes") is True,
565
625
  }
566
626
 
567
627
 
@@ -675,6 +735,46 @@ def _enforce_supported_duration(
675
735
  sys.exit(1)
676
736
 
677
737
 
738
+ def _record_source_document(
739
+ dsl: dict,
740
+ template_config: dict | None,
741
+ template_id: str | None,
742
+ source_document_url: str | None,
743
+ ) -> None:
744
+ """把源文件引用写进 ``meta.sourceDocument``,供渲染管线按引用取数。
745
+
746
+ 这个键必须由**骨架**携带,不能指望模型后面补:ab-agent 的合并层
747
+ (``applyNarrationToSkeleton``)以骨架为基底,只从模型提交的 dsl_json 里搬
748
+ narration 与 customPayload,``meta`` / ``global`` 归骨架所有。模型往 dsl_json 里写
749
+ ``meta.sourceDocument`` 会被静默丢掉。
750
+
751
+ 模板声明了 ``payloadHydration`` 却没拿到 URL 时在这里就警告,而不是等到
752
+ prepare_video_assets 才失败 —— 那时模型已经写完全部旁白并让用户确认过了。
753
+ """
754
+ if source_document_url:
755
+ dsl["meta"]["sourceDocument"] = {"url": source_document_url}
756
+ return
757
+
758
+ if not _HAS_HYDRATOR or not template_config:
759
+ return
760
+ try:
761
+ spec = hydration_spec(template_config)
762
+ except Exception: # noqa: BLE001 - 模板声明写坏了不该让脚本生成失败
763
+ return
764
+ if not spec:
765
+ return
766
+
767
+ print(
768
+ f"⚠️ template '{template_id}' builds customPayload.{spec['payload_field']} from a source "
769
+ f"document, but no --source-document-url was given.\n"
770
+ f" The skeleton below has no meta.sourceDocument, so prepare_video_assets will refuse it "
771
+ f"— and it will refuse it *after* you have written every scene's narration and asked the "
772
+ f"user to confirm.\n"
773
+ f" Re-run gen_script with --source-document-url <the same .pptx URL you gave doc_parse>.",
774
+ file=sys.stderr,
775
+ )
776
+
777
+
678
778
  def _plan_contract_scenes(
679
779
  contract: dict, topic: str, duration: int, scene_count: int | None, language: str
680
780
  ) -> list:
@@ -688,10 +788,24 @@ def _plan_contract_scenes(
688
788
  这类模板由此能产出真正的单场景,而不再被强行补到 3 段。``--scenes`` 只对 arc 生效。
689
789
  """
690
790
  if contract["scene_strategy"] == "arc":
691
- return plan_scenes(topic, duration, scene_count, False, language=language)
791
+ return plan_scenes(
792
+ topic, duration, scene_count, False,
793
+ language=language,
794
+ max_scenes=contract.get("max_scenes", DEFAULT_MAX_SCENES),
795
+ )
692
796
 
693
797
  fixed = contract["fixed_scenes"] or [{"purpose": "opening"}]
694
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
+ )
695
809
  per = max(1, round(duration / n))
696
810
  plans = []
697
811
  for i, fs in enumerate(fixed):
@@ -1154,6 +1268,7 @@ def build_dsl(
1154
1268
  caption_lines: list[str] | None = None,
1155
1269
  scene_images: list[str] | None = None,
1156
1270
  item_count: int | None = None,
1271
+ source_document_url: str | None = None,
1157
1272
  ) -> dict:
1158
1273
  """Build a complete Video DSL JSON.
1159
1274
 
@@ -1607,6 +1722,22 @@ def build_dsl(
1607
1722
  if template_id:
1608
1723
  dsl["meta"]["templateId"] = template_id
1609
1724
 
1725
+ _record_source_document(dsl, template_config, template_id, source_document_url)
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
+
1610
1741
  _enforce_supported_duration(dsl, template_config, template_id)
1611
1742
 
1612
1743
  return dsl
@@ -1628,7 +1759,15 @@ Examples:
1628
1759
  parser.add_argument("--ratio", default=None, choices=VALID_RATIOS, help="Aspect ratio. When omitted: if --template-id is set, use the template's supportedAspectRatios[0]; otherwise default 16:9.")
1629
1760
  parser.add_argument("--resolution", default="1080p", help="Resolution (default: 1080p)")
1630
1761
  parser.add_argument("--item-count", type=int, default=None, help="Number of content beats, for templates declaring capabilities.sceneStrategy=countdown (Top-N lists, '5 个技巧', '7 个常见错误'). Produces one opening plus N scenes counting down from #N to #1. Bounds and the default are declared by the template — this flag has no range of its own. Pass the count the user asked for, NOT the total scene count; --scenes is the total and the two are cross-checked.")
1631
- parser.add_argument("--scenes", type=int, default=None, help="Scene count (default: auto-planned)")
1762
+ parser.add_argument("--scenes", type=int, default=None, help="Scene count (default: auto-planned). Only applies to arc templates. Bounded below by 3 and above by the template's capabilities.maxScenes (default 20); a clamp is reported on stderr, so read the scene count back from the returned DSL rather than assuming you got what you asked for.")
1763
+ parser.add_argument(
1764
+ "--source-document-url", default=None,
1765
+ help="Source document URL (.pptx) for templates that build their per-scene payload from a "
1766
+ "document (capabilities.payloadHydration, e.g. ppt-to-video). Recorded at "
1767
+ "meta.sourceDocument; prepare_video_assets then fetches the layout itself and fills "
1768
+ "every scene's customPayload. Pass the same URL you gave doc_parse. Without it such a "
1769
+ "template's DSL is rejected downstream.",
1770
+ )
1632
1771
  parser.add_argument("--voice-id", default=None, help="Narration voice id. When omitted, the resolver picks template.defaultVoiceId, then the language-keyed fallback (zh→Chinese (Mandarin)_Male_Announcer, en→TBD English voice). gen_voice --list-voices prints the live catalog.")
1633
1772
  parser.add_argument(
1634
1773
  "--speed",
@@ -1843,6 +1982,7 @@ Examples:
1843
1982
  caption_lines=args.caption_lines,
1844
1983
  scene_images=args.scene_images,
1845
1984
  item_count=args.item_count,
1985
+ source_document_url=args.source_document_url,
1846
1986
  )
1847
1987
  except ValueError as exc:
1848
1988
  # Covers both a bad --item-count and an unusable countdown declaration
@@ -34,7 +34,7 @@
34
34
  },
35
35
  "scenes": {
36
36
  "type": "number",
37
- "description": "Scene count"
37
+ "description": "Scene count. Only applies to templates with capabilities.sceneStrategy=arc (the default); single / fixed / countdown templates own their own count. Bounded below by 3 and above by the template's capabilities.maxScenes (default 20) — a clamp is reported on stderr and is NOT an error, so always read the real count back from the returned DSL (scenes[].length) before describing the script to the user. For a template that maps one scene per source page (e.g. ppt-to-video), pass the page count you saw in doc_parse's summary."
38
38
  },
39
39
  "voice_id": {
40
40
  "type": "string",
@@ -48,6 +48,10 @@
48
48
  "type": "string",
49
49
  "description": "Template id (e.g. html-slide). The template owns outputLanguage and may also declare a defaultVoiceId; both flow into the produced DSL."
50
50
  },
51
+ "source_document_url": {
52
+ "type": "string",
53
+ "description": "Source document URL (.pptx) for a template that builds its per-scene picture from that document (capabilities.payloadHydration — today: ppt-to-video). **Must** pass it for such a template: it is recorded at meta.sourceDocument, and prepare_video_assets then fetches the layout itself and fills every scene's customPayload.slide. Pass the same URL you gave doc_parse. You are NOT expected to author or even read that layout data — one page is hundreds of KB, so it never travels through the conversation; without this reference the DSL is rejected downstream after you have already written all the narration."
54
+ },
51
55
  "headline": {
52
56
  "type": "string",
53
57
  "description": "On-screen headline. Stored at meta.headline and pushed into every scene's textLayers[role=headline] so the template can render it as the top big-text. **Must** be set when the user explicitly provided a headline / main title; without it, headline falls back to the long-form topic and overflows the top text layer. This is on-screen copy, so write it for a viewer who has never heard of the subject — a bare identifier (repo name, file name, product code) is not a title. Length limits, line breaks (\\n) and emphasis syntax are per-template: follow the chosen template's llmHint, which gen_script prints in full on stderr."
@@ -114,6 +118,7 @@
114
118
  "voice_id",
115
119
  "speed",
116
120
  "template_id",
121
+ "source_document_url",
117
122
  "headline",
118
123
  "subheadline",
119
124
  "carousel_items",
@@ -2,6 +2,6 @@
2
2
  "skillName": "gen-script",
3
3
  "repoName": "agent-skill-media-maker",
4
4
  "skillId": "474",
5
- "version": "V15",
5
+ "version": "V16",
6
6
  "skillDescription": "Video-script generation skill. Turns a user-supplied topic into a structured Video DSL (JSON) that describes the full video — scene structure, asset requirements, and narrative flow.\n\nUse this skill as soon as the user mentions any of these intents:\n- Write a video script, generate a video script, plan a video, write storyboards\n- Create a short video, plan video content, generate a Video DSL\n- Turn a topic into a video structure / video content plan\n\nEven when the user does not say \"generate the DSL\", use this skill whenever they want to turn a topic into a structured video plan.\n\n⚠️ Stop-and-confirm gate: after this skill returns a DSL, show the full script and wait for the user's explicit confirmation. Never call `prepare_video_assets` in the same turn."
7
7
  }
@@ -33,6 +33,10 @@
33
33
  "type": "string",
34
34
  "description": "Template id. When provided, the script invokes template-registry internally; no separate binding step is needed."
35
35
  },
36
+ "source_document_url": {
37
+ "type": "string",
38
+ "description": "Source document URL (.pptx) for a template that builds its per-scene picture from that document (capabilities.payloadHydration — today: ppt-to-video). Normally unnecessary: gen_script already recorded it at meta.sourceDocument when you passed source_document_url there, and the cached skeleton carries it through. Pass it here only to recover when the skeleton lacks the reference — it saves a full gen_script round trip. The layout data is fetched here and written into every scene's customPayload.slide; you neither author nor see it."
39
+ },
36
40
  "binding_json": {
37
41
  "type": "string",
38
42
  "description": "TemplateBinding JSON as an inline string (alternative to template_id; no file needed)."
@@ -2,6 +2,6 @@
2
2
  "skillName": "prepare-video-assets",
3
3
  "repoName": "agent-skill-media-maker",
4
4
  "skillId": "476",
5
- "version": "V1",
5
+ "version": "V3",
6
6
  "skillDescription": "Asset-preparation skill: resolves and generates every asset (image / audio / video) referenced by a Video DSL, persists a RenderPlan to the database, and returns a `job_id` for the subsequent `render_video` call.\n\nUse this skill as soon as the user mentions any of these intents:\n- Generate / prepare video assets, resolve assets, render-ready\n- \"Make me a video about X\" (the agent calls gen_script → prepare_video_assets → render_video)\n- Regenerate one asset (image / audio) for a specific scene\n\nNext step: after the user confirms the resolved assets, call `render_video` with the `job_id` returned by this skill.\n\n⚠️ Stop-and-confirm gate: this skill runs only after the user has confirmed the script, and after it returns you must show the resolved assets and wait for the user's explicit confirmation. Never call `render_video` in the same turn."
7
7
  }
@@ -63,6 +63,7 @@ from video_dsl.runtime.timeline_compiler import (
63
63
  split_subtitle,
64
64
  split_subtitle_from_lines,
65
65
  segment_narration,
66
+ assign_sentence_indices,
66
67
  )
67
68
 
68
69
  # 字幕末尾标点去除(与 timeline_compiler 中的 _strip_trailing_punct 同逻辑)
@@ -109,7 +110,10 @@ def _rebalance_subtitle_segments(segs: list[dict]) -> list[dict]:
109
110
  for p in parts:
110
111
  ratio = len(p) / total_chars
111
112
  frames = max(int(total_frames * ratio), 1)
113
+ # `**seg` 而不是只写三个键:切出来的各部分仍属于**同一句**,
114
+ # 所以 sentenceIndex 等字段要原样继承(见 build_render_plan 处的说明)。
112
115
  expanded.append({
116
+ **seg,
113
117
  "text": _strip_subtitle_trailing_punct(p.strip()),
114
118
  "startFrame": cur_frame,
115
119
  "endFrame": cur_frame + frames,
@@ -507,6 +511,82 @@ def validate_dsl(dsl: dict) -> list:
507
511
  return errors_as_strings([e for e in found if getattr(e, "severity", "error") == "error"])
508
512
 
509
513
 
514
+ def _hydrate_dsl_payload(
515
+ dsl: dict, template_id: str | None, source_document_url: str | None
516
+ ) -> None:
517
+ """按模板声明把源文件的每一页填进 ``customPayload``(就地改 dsl)。
518
+
519
+ 只对声明了 ``capabilities.payloadHydration`` 的模板生效(目前 ppt-to-video);
520
+ 其余模板这里是零成本 no-op —— 声明驱动,不写 ``if template_id == X``(AGENTS.md)。
521
+
522
+ 失败一律 exit 2 并把「下一步做什么」打全:这条路上所有失败形态的共同点是
523
+ *数据源没通*,而历史报错说的是"字段没填",两者要采取的行动完全不同。
524
+
525
+ 共享模块导入失败(裁剪安装 / 单测环境)时静默跳过:那时 payloadRequirements
526
+ 的校验会照常报必填缺失,行为退回改动前,不会更糟。
527
+ """
528
+ try:
529
+ from payload_hydrator import HydrationError, hydrate_dsl # noqa: E402
530
+ except ImportError:
531
+ return
532
+
533
+ try:
534
+ from registry_loader import get_template # noqa: E402
535
+ template = get_template(template_id) if template_id else None
536
+ except Exception: # noqa: BLE001 - registry 不可达时退回"不注入",由校验兜底报错
537
+ template = None
538
+ if not template:
539
+ return
540
+
541
+ try:
542
+ report = hydrate_dsl(
543
+ dsl,
544
+ template,
545
+ override_url=source_document_url,
546
+ log=lambda msg: LogPrint(f" {msg}", file=sys.stderr),
547
+ )
548
+ except HydrationError as exc:
549
+ LogPrint(f"❌ {exc}", file=sys.stderr)
550
+ sys.exit(2)
551
+
552
+ for line in report:
553
+ LogPrint(f" {line}", file=sys.stderr)
554
+
555
+
556
+ def _warn_step_sentence_mismatch(entry: dict, sentence_indices: list[int]) -> None:
557
+ """动作条数与旁白句数不等时出声。
558
+
559
+ 为什么值得单独打一条:这一类缺陷**渲得出来**。它不报错、不退非零、renderJob
560
+ completed、漏斗记 rendered —— 2026-09-23 线上那条鸡兔同笼成片就是这样交付的,
561
+ 10 步板面配 10 句旁白,听起来该一一对应,实际板面在音频中点就走完了。
562
+ 既然成片本身不会告状,那就必须有人在这里说一句。
563
+
564
+ 只警告不阻断:步数少于句数有时是作者的本意(末尾一句总结不需要新动作)。
565
+ 真正需要拦的是"作者以为一一对应、实际不是",而那个判断只有作者做得了 ——
566
+ 所以这里给的是两个数字,不是一个结论。
567
+ """
568
+ steps = ((entry.get("props") or {}).get("steps")
569
+ or (entry.get("props") or {}).get("beats"))
570
+ if not isinstance(steps, list) or not steps:
571
+ return
572
+ sentences = (sentence_indices[-1] + 1) if sentence_indices else 0
573
+ if sentences == 0 or len(steps) == sentences:
574
+ return
575
+
576
+ scene_id = entry.get("sceneId", "?")
577
+ tail = (
578
+ f"末尾 {sentences - len(steps)} 句没有对应动作,画面会在那之前就走完并停住"
579
+ if len(steps) < sentences
580
+ else f"多出的 {len(steps) - sentences} 个动作会挤在最后一句上"
581
+ )
582
+ LogPrint(
583
+ f"⚠️ scene {scene_id}: {len(steps)} 个讲解动作 vs 旁白 {sentences} 句 —— {tail}。\n"
584
+ f" 本模板的动作按**句**对齐(第 i 个动作 = 第 i 句的起点)。要一一对应就让两者等数;"
585
+ f" 要刻意不等,忽略这条即可。",
586
+ file=sys.stderr,
587
+ )
588
+
589
+
510
590
  def resolve_dimensions(dsl: dict) -> tuple:
511
591
  ratio = dsl.get("global", {}).get("aspectRatio", "16:9")
512
592
  resolution = dsl.get("global", {}).get("resolution", "1080p")
@@ -771,9 +851,13 @@ def build_render_plan(dsl: dict, binding: dict) -> dict:
771
851
  local_subs = split_subtitle_from_lines(lines, duration_frames, fps)
772
852
  else:
773
853
  local_subs = split_subtitle(narration_text, duration_frames, fps)
854
+ # ⚠️ 平移帧号时**必须保留原有字段**(`**s`),不要只挑三个键重建。
855
+ # 这里曾经只搬 text/startFrame/endFrame,于是 timeline_compiler 给每段打上的
856
+ # `sentenceIndex`(模板用它把动作对齐到**句**而不是段)在这一步被静默丢掉 ——
857
+ # 下游模板拿不到句边界,只能退回按段对齐,也就是那个错位 bug 本身。
774
858
  subtitle_segments = [
775
859
  {
776
- "text": s["text"],
860
+ **s,
777
861
  "startFrame": s["startFrame"] + current_frame,
778
862
  "endFrame": s["endFrame"] + current_frame,
779
863
  }
@@ -1381,14 +1465,24 @@ def adjust_timeline_to_audio(render_plan: dict) -> dict:
1381
1465
 
1382
1466
  tts_subs = narration_asset.get("ttsSubtitles", [])
1383
1467
  if tts_subs:
1468
+ # 句归属必须在 _strip_subtitle_trailing_punct **之前**算:
1469
+ # 判据就是句末标点,去掉之后所有段都会被认成同一句。
1470
+ # 这一层是「第 i 个动作对齐第 i 句」类模板(math-principles-expl
1471
+ # 的 steps、ppt-to-video 的 beats)唯一能拿到句边界的地方 ——
1472
+ # TTS 行是按停顿切的,段数远多于句数。
1473
+ sentence_indices = assign_sentence_indices(
1474
+ [s.get("text") or "" for s in tts_subs]
1475
+ )
1384
1476
  entry["subtitleSegments"] = [
1385
1477
  {
1386
1478
  "text": _strip_subtitle_trailing_punct(s["text"].strip()),
1387
1479
  "startFrame": current_frame + int(s["timeBegin"] / 1000.0 * fps),
1388
1480
  "endFrame": current_frame + int(s["timeEnd"] / 1000.0 * fps),
1481
+ "sentenceIndex": sentence_indices[i],
1389
1482
  }
1390
- for s in tts_subs
1483
+ for i, s in enumerate(tts_subs)
1391
1484
  ]
1485
+ _warn_step_sentence_mismatch(entry, sentence_indices)
1392
1486
  else:
1393
1487
  # No TTS timestamps — re-distribute existing subtitle
1394
1488
  # lines across the REAL audio duration, by character
@@ -1432,6 +1526,7 @@ def adjust_timeline_to_audio(render_plan: dict) -> dict:
1432
1526
  # 起点为终点。
1433
1527
  end_f = scene_dur if i == len(old_subs) - 1 else starts[i + 1]
1434
1528
  new_subs.append({
1529
+ **s,
1435
1530
  "text": text,
1436
1531
  "startFrame": current_frame + start_f,
1437
1532
  "endFrame": current_frame + end_f,
@@ -1451,6 +1546,7 @@ def adjust_timeline_to_audio(render_plan: dict) -> dict:
1451
1546
  ratio = _plain(text) / total_chars
1452
1547
  seg_frames = max(int(sub_span * ratio), fps // 2)
1453
1548
  new_subs.append({
1549
+ **s,
1454
1550
  "text": text,
1455
1551
  "startFrame": current_frame + cursor,
1456
1552
  "endFrame": current_frame + cursor + seg_frames,
@@ -1473,7 +1569,7 @@ def adjust_timeline_to_audio(render_plan: dict) -> dict:
1473
1569
  rel_start = s["startFrame"] - old_scene_start
1474
1570
  rel_end = s["endFrame"] - old_scene_start
1475
1571
  new_subs.append({
1476
- "text": s["text"],
1572
+ **s,
1477
1573
  "startFrame": current_frame + rel_start,
1478
1574
  "endFrame": current_frame + rel_end,
1479
1575
  })
@@ -2177,6 +2273,13 @@ Examples:
2177
2273
  parser.add_argument("--dsl", help="Input DSL file path")
2178
2274
  parser.add_argument("--dsl-json", default=None, help="DSL JSON as an inline string (replaces --dsl; no file needed; preferred for multi-user concurrent flows)")
2179
2275
  parser.add_argument("--template-id", default=None, help="Template id (required unless --render-plan / --job-id is used)")
2276
+ parser.add_argument(
2277
+ "--source-document-url", default=None,
2278
+ help="Source document URL (.pptx) for templates that build their per-scene payload from a "
2279
+ "document (capabilities.payloadHydration, e.g. ppt-to-video). Overrides "
2280
+ "meta.sourceDocument in the DSL. The layout data is fetched here and written into "
2281
+ "every scene's customPayload — it is deliberately never routed through the model.",
2282
+ )
2180
2283
  parser.add_argument("--render-plan", help="Existing RenderPlan file path")
2181
2284
  parser.add_argument("--resolve-only", action="store_true", help="Only resolve assets; do not run the render")
2182
2285
  parser.add_argument("--skip-asset-resolve", action="store_true", help="Skip asset resolution")
@@ -2365,6 +2468,18 @@ Examples:
2365
2468
  )
2366
2469
  sys.exit(2)
2367
2470
 
2471
+ # ── 按引用注入模板声明的大 payload(必须在校验之前)─────────────
2472
+ #
2473
+ # ppt-to-video 这类模板的画面数据(customPayload.slide:一整页的节点树、
2474
+ # 坐标、配色、beats)只有一个合法产出方 —— ab-render 的 /extract layout 层,
2475
+ # 而它体积大到不能过模型(一页几百 KB,23 页好几 MB)。所以 DSL 里只带一个
2476
+ # 引用(meta.sourceDocument),在这里把全量取回来填进每个场景。
2477
+ #
2478
+ # 为什么必须在 validate_dsl 之前:那道校验读的就是 capabilities.payloadRequirements
2479
+ # 的必填项。放在后面 = 每次都被自己还没填的字段拒掉(2026-09-23 线上
2480
+ # conv_Sesw2g8oM81 的表现就是 scenes[0..19].customPayload.slide (required))。
2481
+ _hydrate_dsl_payload(dsl, args.template_id, args.source_document_url)
2482
+
2368
2483
  errors = validate_dsl(dsl)
2369
2484
  if errors:
2370
2485
  LogPrint("❌ DSL validation failed:", file=sys.stderr)
@@ -2,6 +2,6 @@
2
2
  "skillName": "render-video",
3
3
  "repoName": "agent-skill-media-maker",
4
4
  "skillId": "473",
5
- "version": "V25",
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
  }