@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.
- package/dist/manifest.json +14 -4
- package/package.json +1 -1
- package/skills/doc-parse/SKILL.md +35 -5
- package/skills/doc-parse/scripts/extract_via_render.py +54 -157
- package/skills/gen-script/SKILL.md +31 -0
- package/skills/gen-script/scripts/gen_script.py +147 -7
- package/skills/gen-script/skill.json +6 -1
- package/skills/gen-script/version.json +1 -1
- package/skills/prepare-video-assets/skill.json +4 -0
- package/skills/prepare-video-assets/version.json +1 -1
- package/skills/render-video/scripts/render_video.py +118 -3
- package/skills/render-video/version.json +1 -1
- package/skills/template-registry/scripts/doc_extract.py +226 -0
- package/skills/template-registry/scripts/payload_hydrator.py +315 -0
- package/skills/template-registry/version.json +1 -1
- package/skills/template-registry/video_dsl/runtime/dsl_validator.py +136 -3
- package/skills/template-registry/video_dsl/runtime/timeline_compiler.py +51 -2
|
@@ -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(
|
|
261
|
-
|
|
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,
|
|
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
|
-
|
|
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":
|
|
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(
|
|
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": "
|
|
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": "
|
|
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
|
-
|
|
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
|
-
|
|
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": "
|
|
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
|
}
|