@remixmate/cli 0.13.8 → 0.13.10

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.
@@ -3,8 +3,8 @@
3
3
  "toolName": "gen_voice",
4
4
  "tier": "atomic",
5
5
  "category": "asset",
6
- "title": "Text-to-Speech (Minimax)",
7
- "description": "Text-to-speech (TTS): synthesize narration audio from text via the Minimax TTS model. Returns the persisted audio URL — no download needed.",
6
+ "title": "Text-to-Speech",
7
+ "description": "Text-to-speech (TTS): synthesize narration audio from text. The voice catalog spans several providers (MiniMax, 豆包/Volcengine, HeyGen, and the caller's own cloned voices); the provider is picked from the voice id automatically. Returns the persisted audio URL — no download needed.",
8
8
  "auth": "required",
9
9
  "joinsTake": true,
10
10
  "envVars": [
@@ -25,15 +25,57 @@
25
25
  },
26
26
  "voice_id": {
27
27
  "type": "string",
28
- "description": "Voice id. Default 'Chinese (Mandarin)_Male_Announcer'. When unsure, call with list_voices=true first to see what's available — do not invent ids, and do not declare a voice unavailable unless it is missing from that list."
28
+ "description": "Voice id from list_voices (any provider; synthesis routes to the voice's own provider). Default 'Chinese (Mandarin)_Male_Announcer'. When unsure, call with list_voices=true first — do not invent ids, and do not declare a voice unavailable unless it is missing from the filtered list."
29
29
  },
30
30
  "speed": {
31
31
  "type": "number",
32
- "description": "Speech rate, 0.5-2.0"
32
+ "description": "Speech rate, 0.5-2.0. Some HeyGen cloned voices ignore it."
33
33
  },
34
34
  "list_voices": {
35
35
  "type": "boolean",
36
- "description": "List the available voices and exit. This queries the live voice service and is the authoritative catalog — one call is enough, and its absence from this list is the only evidence that a voice id is invalid."
36
+ "description": "List voices and exit (one page of up to 100, with provider / type / gender / language columns and the total). This queries the live voice service and is the authoritative catalog. The public catalog has a few hundred voices, so narrow it with provider / gender / language / name, or page through with page; mine=true lists the caller's own cloned voices instead."
37
+ },
38
+ "mine": {
39
+ "type": "boolean",
40
+ "description": "With list_voices=true: list the caller's own cloned voices instead of the public catalog (provider / voice_type do not apply)."
41
+ },
42
+ "provider": {
43
+ "type": "string",
44
+ "enum": [
45
+ "minimax",
46
+ "volcengine",
47
+ "heygen"
48
+ ],
49
+ "description": "With list_voices=true: only voices from this provider (volcengine = 豆包). Not needed for synthesis."
50
+ },
51
+ "voice_type": {
52
+ "type": "string",
53
+ "enum": [
54
+ "system",
55
+ "clone"
56
+ ],
57
+ "description": "With list_voices=true: system (stock) or clone voices."
58
+ },
59
+ "gender": {
60
+ "type": "string",
61
+ "enum": [
62
+ "male",
63
+ "female"
64
+ ],
65
+ "description": "With list_voices=true: filter by gender."
66
+ },
67
+ "language": {
68
+ "type": "string",
69
+ "description": "With list_voices=true: filter by language tag, exact tag as shown in the Language column (e.g. 中文-普通话, 中文-粤语, 英语, 日语)."
70
+ },
71
+ "name": {
72
+ "type": "string",
73
+ "description": "With list_voices=true: fuzzy-filter by voice name."
74
+ },
75
+ "page": {
76
+ "type": "integer",
77
+ "minimum": 1,
78
+ "description": "With list_voices=true: page number (100 per page). Default 1."
37
79
  },
38
80
  "json_output": {
39
81
  "type": "boolean",
@@ -52,7 +94,14 @@
52
94
  ],
53
95
  "hidden": [
54
96
  "json_output",
55
- "list_voices"
97
+ "list_voices",
98
+ "mine",
99
+ "provider",
100
+ "voice_type",
101
+ "gender",
102
+ "language",
103
+ "name",
104
+ "page"
56
105
  ]
57
106
  }
58
107
  }
@@ -61,6 +61,7 @@ _SHARED_LIB_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "template-
61
61
 
62
62
  sys.path.insert(0, _SHARED_LIB_DIR)
63
63
  sys.path.insert(0, os.path.join(_SHARED_LIB_DIR, "scripts"))
64
+ from video_dsl.runtime.presenter_speech import subtitles_between
64
65
  from video_dsl.runtime.timeline_compiler import (
65
66
  split_subtitle,
66
67
  split_subtitle_from_lines,
@@ -919,6 +920,13 @@ def build_render_plan(dsl: dict, binding: dict) -> dict:
919
920
  if probed:
920
921
  asset["duration"] = probed
921
922
 
923
+ presenter = (dsl.get("global") or {}).get("presenter") or {}
924
+ presenter_transcript = presenter.get("transcript") or []
925
+
926
+ presenter_props = (
927
+ {"presenter": {k: v for k, v in presenter.items() if k != "transcript"}} if presenter.get("segments") else {}
928
+ )
929
+
922
930
  timeline = []
923
931
  current_frame = 0
924
932
 
@@ -927,6 +935,10 @@ def build_render_plan(dsl: dict, binding: dict) -> dict:
927
935
  duration = scene.get("duration", 5)
928
936
  duration_frames = math.floor(duration * fps + 0.5)
929
937
 
938
+ speech = scene.get("speech") if presenter_transcript else None
939
+ if speech:
940
+ duration_frames = speech["endFrame"] - speech["startFrame"]
941
+
930
942
  scene_binding = binding_map.get(scene_id, {})
931
943
 
932
944
 
@@ -959,6 +971,8 @@ def build_render_plan(dsl: dict, binding: dict) -> dict:
959
971
  }
960
972
  for s in local_subs
961
973
  ]
974
+ if speech:
975
+ subtitle_segments = subtitles_between(presenter_transcript, speech["startFrame"], speech["endFrame"], fps)
962
976
 
963
977
  transition_config = dsl.get("transitions", {})
964
978
  trans_type = transition_config.get("default", "fade")
@@ -998,7 +1012,7 @@ def build_render_plan(dsl: dict, binding: dict) -> dict:
998
1012
  "startTime": round(current_frame / fps, 2),
999
1013
  "endTime": round((current_frame + duration_frames) / fps, 2),
1000
1014
  "compositionId": scene_binding.get("compositionId", "GenericScene"),
1001
- "props": scene_binding.get("props", {}),
1015
+ "props": {**scene_binding.get("props", {}), **presenter_props},
1002
1016
  "subtitleSegments": subtitle_segments,
1003
1017
  "subtitleAtSec": subtitle_at_sec,
1004
1018
  "transition": {"type": scene_trans_type, "durationFrames": scene_trans_dur},
@@ -1046,6 +1060,7 @@ def build_render_plan(dsl: dict, binding: dict) -> dict:
1046
1060
  "motionPreset": binding.get("globalOverrides", {}).get("motionPreset", "smooth"),
1047
1061
  "colorScheme": binding.get("globalOverrides", {}).get("colorScheme", []),
1048
1062
  **({"variantId": binding["variantId"]} if binding.get("variantId") else {}),
1063
+ **({"templateConfig": binding["templateConfig"]} if binding.get("templateConfig") else {}),
1049
1064
  **(_build_bgm_props(dsl)),
1050
1065
  },
1051
1066
  },
@@ -1237,6 +1252,9 @@ def resolve_asset_digital_human(asset: dict, private_token: str, timeout: int) -
1237
1252
 
1238
1253
  if payload.get("avatarId"):
1239
1254
  cmd.extend(["--avatar-id", str(payload["avatarId"])])
1255
+ if payload.get("videoUrl"):
1256
+ cmd.extend(["--video-url", payload["videoUrl"]])
1257
+ cmd.extend(["--lipsync-mode", payload.get("mode", "speed")])
1240
1258
  if payload.get("text"):
1241
1259
  cmd.extend(["--text", payload["text"]])
1242
1260
  if payload.get("voiceId"):
@@ -2935,6 +2953,70 @@ def _poll_vod_playback_url(
2935
2953
  return poll_vod_playback_url(file_id, private_token, max_wait=max_wait, interval=interval)
2936
2954
 
2937
2955
 
2956
+ def _select_style(config: dict, style_id) -> tuple[dict, dict]:
2957
+
2958
+ styles = config.get("styles")
2959
+ if not isinstance(styles, list):
2960
+ return config, {}
2961
+ default = config.get("defaultStyle")
2962
+ wanted = style_id or default
2963
+ style = next((s for s in styles if s.get("id") == wanted), None)
2964
+ if style is None:
2965
+ options = ", ".join(s.get("id", "") for s in styles)
2966
+ raise ValueError(f"unknown template style {wanted!r}; options: {options}")
2967
+ snapshot = {k: v for k, v in config.items() if k not in ("styles", "defaultStyle")}
2968
+ snapshot["style"] = style
2969
+ return snapshot, ({} if wanted == default else dict(style.get("tokens") or {}))
2970
+
2971
+
2972
+ def _restyle_plan(render_plan: dict, style_id: str) -> dict:
2973
+
2974
+ from registry_loader import declares_sources, get_template, load_contract
2975
+
2976
+ template = get_template(render_plan.get("templateId") or "", include_all_statuses=True)
2977
+ if not declares_sources(template):
2978
+ raise ValueError(f"template {render_plan.get('templateId')!r} has no visual styles to switch")
2979
+ contract = load_contract(template, None)
2980
+ config = {**(contract.get("templateConfig") or {}), "releaseId": contract.get("releaseId")}
2981
+ snapshot, _ = _select_style(config, style_id)
2982
+ props = render_plan["remotionProps"]["inputProps"]
2983
+ props["templateConfig"] = snapshot
2984
+ props["globalTypography"] = {**(props.get("globalTypography") or {}), **(snapshot["style"].get("tokens") or {})}
2985
+ return render_plan
2986
+
2987
+
2988
+ def _bind_template_config(binding: dict, template: dict, dsl: dict) -> None:
2989
+
2990
+
2991
+
2992
+
2993
+ from registry_loader import declares_sources, load_contract
2994
+
2995
+ if not declares_sources(template):
2996
+ return
2997
+ global_cfg = dsl.get("global") or {}
2998
+ presenter = global_cfg.get("presenter")
2999
+ source = presenter.get("source") if isinstance(presenter, dict) else None
3000
+ try:
3001
+ contract = load_contract(template, source)
3002
+ release = contract.get("releaseId")
3003
+ if contract.get("templateConfig"):
3004
+ binding["templateConfig"], tokens = _select_style(
3005
+ {**contract["templateConfig"], "releaseId": release}, global_cfg.get("templateStyle")
3006
+ )
3007
+ if tokens:
3008
+
3009
+ overrides = binding.setdefault("globalOverrides", {})
3010
+ overrides["typography"] = {**(overrides.get("typography") or {}), **tokens}
3011
+ except Exception as exc:
3012
+ LogPrint(f"❌ cannot load template contract: {exc}", file=sys.stderr)
3013
+ sys.exit(1)
3014
+ lock = global_cfg.get("configLock")
3015
+ if lock and release and lock != release:
3016
+
3017
+ LogPrint(f"⚠️ configLock {lock} is not the current release {release}; using the current config", file=sys.stderr)
3018
+
3019
+
2938
3020
  def auto_bind_template(dsl: dict, template_id: str) -> dict:
2939
3021
 
2940
3022
 
@@ -2985,6 +3067,7 @@ def auto_bind_template(dsl: dict, template_id: str) -> dict:
2985
3067
  selected = match_template.load_full_template(selected)
2986
3068
  LogPrint(f"📋 auto-bound template: {selected.get('name', '')} ({template_id})", file=sys.stderr)
2987
3069
  binding = match_template.build_binding(selected, dsl)
3070
+ _bind_template_config(binding, selected, dsl)
2988
3071
  from required_props import validate_required_props
2989
3072
  try:
2990
3073
  validate_required_props(selected, binding.get("bindings", []))
@@ -3049,6 +3132,11 @@ Examples:
3049
3132
  parser.add_argument("--skip-asset-resolve", action="store_true", help="Skip asset resolution")
3050
3133
  parser.add_argument("--save-render-plan", action="store_true", help="Save the RenderPlan to a file")
3051
3134
  parser.add_argument("--render-plan-output", default=None, help="RenderPlan output path")
3135
+ parser.add_argument(
3136
+ "--restyle", default=None,
3137
+ help="With --job-id: render that job's plan again in this visual style as a new job. "
3138
+ "Nothing is regenerated, so only the render is billed.",
3139
+ )
3052
3140
  parser.add_argument("--job-id", type=int, default=None, help="Render job id (replaces --render-plan; loads the RenderPlan from the database)")
3053
3141
  parser.add_argument(
3054
3142
  "--save-job",
@@ -3699,6 +3787,17 @@ def main():
3699
3787
  dsl = binding = None
3700
3788
  if args.job_id is not None and args.job_id > 0:
3701
3789
  render_plan = _load_plan_from_job(args.job_id, private_token)
3790
+ if args.restyle:
3791
+ try:
3792
+ render_plan = _restyle_plan(render_plan, args.restyle)
3793
+ except ValueError as exc:
3794
+ LogPrint(f"❌ {exc}", file=sys.stderr)
3795
+ sys.exit(1)
3796
+ _validate_saved_plan(render_plan)
3797
+
3798
+ args.job_id = None
3799
+ args.save_job = True
3800
+ args.skip_asset_resolve = True
3702
3801
  elif args.render_plan:
3703
3802
  render_plan = _load_plan_from_file(args.render_plan, args.dsl)
3704
3803
  else:
@@ -27,6 +27,10 @@
27
27
  "minimum": 1,
28
28
  "description": "Render job id (positive integer, e.g. 8). This is the integer N parsed from prepare_video_assets's stdout line '📦 render job jobId: N' (also tolerated: legacy zh '📦 渲染任务 jobId: N'). Never pass 0, a placeholder string, or descriptive text."
29
29
  },
30
+ "restyle": {
31
+ "type": "string",
32
+ "description": "Render the same video again in another visual style (a template_style id). Reuses the script, assets and timing, so nothing is regenerated. Only for templates with visual styles such as talking-head-studio."
33
+ },
30
34
  "save_job": {
31
35
  "type": "boolean",
32
36
  "description": "Persist the render Manifest back to the database under the same jobId (default true). Auto-degrades to false when PRIV_TOKEN is missing. Pass false to opt out explicitly."
@@ -471,6 +471,104 @@ def list_templates(*, include_all_statuses: bool = False) -> list[dict]:
471
471
  return list(load_registry_indexed(include_all_statuses=include_all_statuses).values())
472
472
 
473
473
 
474
+
475
+
476
+
477
+
478
+
479
+ def declares_sources(tpl: Optional[dict]) -> bool:
480
+ caps = (tpl or {}).get("capabilities")
481
+ return isinstance(caps, dict) and isinstance(caps.get("sources"), dict) and bool(caps["sources"])
482
+
483
+
484
+ def _contract_url() -> str:
485
+ if override := os.environ.get("VIDEO_TEMPLATE_CONTRACT_URL"):
486
+ return override
487
+ url = os.environ.get("VIDEO_TEMPLATE_REGISTRY_URL") or _default_registry_url()
488
+ return url.rsplit("/", 1)[0] + "/contract"
489
+
490
+
491
+ def _local_contract(tpl: dict, source: Optional[str]) -> dict:
492
+
493
+ caps = tpl["capabilities"]
494
+ sources = caps["sources"]
495
+ default = caps.get("defaultSource")
496
+ source = source or default
497
+ if source not in sources:
498
+ raise ValueError(f"template {tpl.get('templateId')} has no source {source!r}; options: {sorted(sources)}")
499
+ resolved = {k: v for k, v in caps.items() if k not in ("sources", "defaultSource")}
500
+ for key in sources.get(default) or {}:
501
+ resolved.pop(key, None)
502
+ resolved.update(sources[source])
503
+ return {
504
+ "templateId": tpl.get("templateId"),
505
+ "source": source,
506
+ "sources": sorted(sources),
507
+ "releaseId": f"builtin:{tpl.get('templateId')}@{tpl.get('version')}",
508
+ "capabilities": resolved,
509
+ "llmHint": tpl.get("llmHint"),
510
+ "customPayloadSchema": tpl.get("customPayloadSchema"),
511
+ "templateConfig": tpl.get("templateConfig"),
512
+ }
513
+
514
+
515
+ def _fetch_contract(template_id: str, source: Optional[str]) -> dict:
516
+ url = _contract_url()
517
+ body = json.dumps({"templateId": template_id, **({"source": source} if source else {})}).encode("utf-8")
518
+ headers = {"Accept": "application/json", "Content-Type": "application/json"}
519
+ if token := _resolve_priv_token(url):
520
+ headers["X-Priv-Token"] = token
521
+ req = urllib.request.Request(url, data=body, headers=headers, method="POST")
522
+ try:
523
+ with urllib.request.urlopen(req, timeout=_DEFAULT_HTTP_TIMEOUT) as resp:
524
+ parsed = json.loads(resp.read().decode("utf-8"))
525
+ except urllib.error.HTTPError as e:
526
+ if e.code in (401, 403):
527
+ raise RegistryAuthError(f"HTTP {e.code}") from e
528
+ raise RegistryUnreachableError(f"template contract HTTP {e.code}: {url}") from e
529
+ except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as e:
530
+ raise RegistryUnreachableError(f"template contract unreachable: {url} ({e})") from e
531
+ code = parsed.get("code") if isinstance(parsed, dict) else None
532
+ if str(code) in ("401", "403"):
533
+ raise RegistryAuthError(str(parsed.get("msg") or f"code={code}"))
534
+ if code not in (0, "0") or not isinstance(parsed.get("data"), dict):
535
+
536
+ raise ValueError(str(parsed.get("msg") if isinstance(parsed, dict) else parsed))
537
+ return parsed["data"]
538
+
539
+
540
+ _contract_cache: dict[tuple[str, Optional[str]], dict] = {}
541
+
542
+
543
+ def load_contract(tpl: dict, source: Optional[str] = None) -> dict:
544
+
545
+ template_id = str(tpl.get("templateId"))
546
+ key = (template_id, source)
547
+ if key not in _contract_cache:
548
+ registry_url = os.environ.get("VIDEO_TEMPLATE_REGISTRY_URL", "")
549
+ local = registry_url.startswith("file:") and not os.environ.get("VIDEO_TEMPLATE_CONTRACT_URL")
550
+ _contract_cache[key] = _local_contract(tpl, source) if local else _fetch_contract(template_id, source)
551
+ return _contract_cache[key]
552
+
553
+
554
+ def template_for_source(tpl: Optional[dict], source: Optional[str] = None) -> Optional[dict]:
555
+
556
+
557
+
558
+
559
+ if not declares_sources(tpl):
560
+ if source:
561
+ raise ValueError(f"template {(tpl or {}).get('templateId')} does not declare presenter sources")
562
+ return tpl
563
+ contract = load_contract(tpl, source)
564
+ view = dict(tpl)
565
+ view["capabilities"] = contract.get("capabilities") or {}
566
+ for key in ("llmHint", "customPayloadSchema"):
567
+ if contract.get(key) is not None:
568
+ view[key] = contract[key]
569
+ return view
570
+
571
+
474
572
  def invalidate_cache() -> None:
475
573
 
476
574
 
@@ -478,3 +576,4 @@ def invalidate_cache() -> None:
478
576
 
479
577
 
480
578
  _indexed_view.cache_clear()
579
+ _contract_cache.clear()
@@ -168,6 +168,32 @@ def step_alignment_field(tpl: Optional[dict]) -> Optional[str]:
168
168
  return field if isinstance(field, str) and field else None
169
169
 
170
170
 
171
+ def _dsl_source(dsl: dict) -> Optional[str]:
172
+ presenter = (dsl.get("global") or {}).get("presenter")
173
+ source = presenter.get("source") if isinstance(presenter, dict) else None
174
+ return source if isinstance(source, str) and source else None
175
+
176
+
177
+ def _template_for_dsl(dsl: dict) -> Optional[dict]:
178
+
179
+
180
+
181
+
182
+
183
+ tpl = _get_template_cfg(_pick_template_id(dsl))
184
+ source = _dsl_source(dsl)
185
+ if not tpl or not source:
186
+ return tpl
187
+ caps = tpl.get("capabilities")
188
+ if not isinstance(caps, dict) or source == caps.get("defaultSource"):
189
+ return tpl
190
+ try:
191
+ from registry_loader import template_for_source
192
+ return template_for_source(tpl, source)
193
+ except Exception:
194
+ return tpl
195
+
196
+
171
197
  def _get_template_cfg(template_id: Optional[str]) -> Optional[dict]:
172
198
 
173
199
 
@@ -538,7 +564,7 @@ def _check_slide_id_chosen(dsl: dict) -> list[ValidationError]:
538
564
 
539
565
 
540
566
 
541
- tpl = _get_template_cfg(_pick_template_id(dsl))
567
+ tpl = _template_for_dsl(dsl)
542
568
  if not tpl:
543
569
  return []
544
570
  schema = tpl.get("customPayloadSchema")
@@ -611,7 +637,7 @@ def _check_slide_cross_field(dsl: dict) -> list[ValidationError]:
611
637
 
612
638
 
613
639
 
614
- tpl = _get_template_cfg(_pick_template_id(dsl))
640
+ tpl = _template_for_dsl(dsl)
615
641
  if not tpl:
616
642
  return []
617
643
  schemas = tpl.get("slideSchemas")
@@ -712,7 +738,7 @@ def _check_line_sync_narration(dsl: dict) -> list[ValidationError]:
712
738
 
713
739
 
714
740
 
715
- tpl = _get_template_cfg(_pick_template_id(dsl))
741
+ tpl = _template_for_dsl(dsl)
716
742
  if not tpl:
717
743
  return []
718
744
  line_sync = line_sync_slides(tpl)
@@ -789,7 +815,7 @@ def _check_step_narration_alignment(dsl: dict) -> list[ValidationError]:
789
815
 
790
816
 
791
817
 
792
- tpl = _get_template_cfg(_pick_template_id(dsl))
818
+ tpl = _template_for_dsl(dsl)
793
819
  if not tpl:
794
820
  return []
795
821
  field = step_alignment_field(tpl)
@@ -871,7 +897,7 @@ def _check_duration_estimate(dsl: dict) -> list[ValidationError]:
871
897
  narration_cfg = ((dsl.get("global") or {}).get("narration") or {})
872
898
  speed = narration_cfg.get("speed")
873
899
  speed = float(speed) if isinstance(speed, (int, float)) and not isinstance(speed, bool) and speed > 0 else 1.0
874
- tail = nb.template_tail_pad_sec(_get_template_cfg(_pick_template_id(dsl)))
900
+ tail = nb.template_tail_pad_sec(_template_for_dsl(dsl))
875
901
 
876
902
  total = 0.0
877
903
  narrated = 0
@@ -925,7 +951,7 @@ def _check_slide_payload_fields(dsl: dict) -> list[ValidationError]:
925
951
 
926
952
 
927
953
 
928
- tpl = _get_template_cfg(_pick_template_id(dsl))
954
+ tpl = _template_for_dsl(dsl)
929
955
  if not tpl:
930
956
  return []
931
957
  schemas = tpl.get("slideSchemas")
@@ -988,7 +1014,7 @@ def _check_custom_payload_fields(dsl: dict) -> list[ValidationError]:
988
1014
 
989
1015
 
990
1016
 
991
- tpl = _get_template_cfg(_pick_template_id(dsl))
1017
+ tpl = _template_for_dsl(dsl)
992
1018
  if not tpl:
993
1019
  return []
994
1020
  from .payload_contract import required_payload_errors
@@ -1240,7 +1266,7 @@ def _check_narration_language_strict(dsl: dict) -> list[ValidationError]:
1240
1266
 
1241
1267
 
1242
1268
 
1243
- tpl = _get_template_cfg(_pick_template_id(dsl))
1269
+ tpl = _template_for_dsl(dsl)
1244
1270
  if not tpl:
1245
1271
  return []
1246
1272
  strict = (tpl.get("capabilities") or {}).get("narrationLanguageStrict")
@@ -1273,12 +1299,31 @@ def _check_narration_language_strict(dsl: dict) -> list[ValidationError]:
1273
1299
  _TEXT_LAYER_SKELETON_PREFIXES = ("[骨架待填充]", "[skeleton placeholder]")
1274
1300
 
1275
1301
 
1302
+ def _check_presenter_source(dsl: dict) -> list[ValidationError]:
1303
+
1304
+ source = _dsl_source(dsl)
1305
+ if not source:
1306
+ return []
1307
+ tpl = _get_template_cfg(_pick_template_id(dsl))
1308
+ if tpl is None:
1309
+ return []
1310
+ caps = tpl.get("capabilities")
1311
+ sources = caps.get("sources") if isinstance(caps, dict) else None
1312
+ if not isinstance(sources, dict) or source not in sources:
1313
+ options = ", ".join(sorted(sources)) if isinstance(sources, dict) else "none"
1314
+ return [ValidationError(
1315
+ "global.presenter.source",
1316
+ f"unknown presenter source {source!r} for this template; options: {options}",
1317
+ )]
1318
+ return []
1319
+
1320
+
1276
1321
  def _check_presenter_visibility(dsl: dict) -> list[ValidationError]:
1277
1322
 
1278
1323
 
1279
1324
 
1280
1325
 
1281
- tpl = _get_template_cfg(_pick_template_id(dsl)) or {}
1326
+ tpl = _template_for_dsl(dsl) or {}
1282
1327
  caps = tpl.get("capabilities")
1283
1328
  caps = caps if isinstance(caps, dict) else {}
1284
1329
  spec = caps.get("digitalHuman") or {}
@@ -1325,7 +1370,7 @@ def _check_text_layer_rules(dsl: dict) -> list[ValidationError]:
1325
1370
 
1326
1371
 
1327
1372
 
1328
- tpl = _get_template_cfg(_pick_template_id(dsl))
1373
+ tpl = _template_for_dsl(dsl)
1329
1374
  if not tpl:
1330
1375
  return []
1331
1376
  rules = (tpl.get("capabilities") or {}).get("textLayerRules")
@@ -1436,6 +1481,7 @@ def validate_integrity(dsl: dict) -> list[ValidationError]:
1436
1481
  errors += _check_step_narration_alignment(dsl)
1437
1482
  errors += _check_slide_cross_field(dsl)
1438
1483
  errors += _check_text_layer_rules(dsl)
1484
+ errors += _check_presenter_source(dsl)
1439
1485
  errors += _check_presenter_visibility(dsl)
1440
1486
 
1441
1487
  errors += _check_duration_estimate(dsl)
@@ -1474,6 +1520,7 @@ def validate_dsl(dsl: dict) -> list[ValidationError]:
1474
1520
  errors += _check_step_narration_alignment(dsl)
1475
1521
  errors += _check_slide_cross_field(dsl)
1476
1522
  errors += _check_text_layer_rules(dsl)
1523
+ errors += _check_presenter_source(dsl)
1477
1524
  errors += _check_presenter_visibility(dsl)
1478
1525
 
1479
1526
  errors += _check_duration_estimate(dsl)
@@ -0,0 +1,104 @@
1
+
2
+
3
+
4
+
5
+
6
+
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+ _SENTENCE_END = re.compile(r"(?<=[。!?!?;;])")
13
+
14
+
15
+ def _spread_sentences(text: str, start_ms: int, end_ms: int) -> list[dict]:
16
+
17
+ pieces = [p.strip() for p in _SENTENCE_END.split(text) if p.strip()]
18
+ total = sum(len(p) for p in pieces) or 1
19
+ out = []
20
+ cursor = float(start_ms)
21
+ for piece in pieces:
22
+ span = (end_ms - start_ms) * len(piece) / total
23
+ out.append({"text": piece, "startMs": round(cursor), "endMs": round(cursor + span), "words": []})
24
+ cursor += span
25
+ return out
26
+
27
+
28
+ def _split_by_words(utterance: dict) -> list[dict]:
29
+
30
+ out: list[dict] = []
31
+ current: list[dict] = []
32
+ for word in utterance["words"]:
33
+ current.append({"text": word["text"], "startMs": word["startTime"], "endMs": word["endTime"]})
34
+ if _SENTENCE_END.search(word["text"] + " ") or word["text"][-1:] in "。!?!?;;":
35
+ out.append(current)
36
+ current = []
37
+ if current:
38
+ out.append(current)
39
+ return [
40
+ {"text": "".join(w["text"] for w in s), "startMs": s[0]["startMs"], "endMs": s[-1]["endMs"], "words": s}
41
+ for s in out
42
+ ]
43
+
44
+
45
+ def transcript_from_asr(asr: dict) -> list[dict]:
46
+
47
+ sentences: list[dict] = []
48
+ for utterance in asr.get("utterances") or []:
49
+ if utterance.get("words"):
50
+ sentences += _split_by_words(utterance)
51
+ elif utterance.get("text"):
52
+ sentences += _spread_sentences(utterance["text"], utterance.get("startTime", 0), utterance.get("endTime", 0))
53
+ return sentences
54
+
55
+
56
+ def plan_speech_scenes(sentences: list[dict], video_sec: float, count: int, fps: int) -> list[dict]:
57
+
58
+
59
+
60
+
61
+ if not sentences:
62
+ return []
63
+ count = max(1, min(count, len(sentences)))
64
+ total = sum(len(s["text"]) for s in sentences)
65
+ groups: list[list[dict]] = [[]]
66
+ seen = 0
67
+ for i, sentence in enumerate(sentences):
68
+ remaining_sentences = len(sentences) - i
69
+ remaining_groups = count - len(groups)
70
+ boundary = total * len(groups) / count
71
+ if groups[-1] and remaining_groups > 0 and (seen >= boundary or remaining_sentences <= remaining_groups):
72
+ groups.append([])
73
+ groups[-1].append(sentence)
74
+ seen += len(sentence["text"])
75
+ total_frames = max(round(video_sec * fps), round(sentences[-1]["endMs"] / 1000 * fps))
76
+ scenes = []
77
+ for i, group in enumerate(groups):
78
+ start = 0 if i == 0 else round(group[0]["startMs"] / 1000 * fps)
79
+ end = total_frames if i == len(groups) - 1 else round(groups[i + 1][0]["startMs"] / 1000 * fps)
80
+ scenes.append({"startFrame": start, "endFrame": end, "text": "".join(s["text"] for s in group)})
81
+ return scenes
82
+
83
+
84
+ def subtitles_between(sentences: list[dict], start_frame: int, end_frame: int, fps: int) -> list[dict]:
85
+
86
+ to_frame = lambda ms: round(ms / 1000 * fps)
87
+ segments = []
88
+ for index, sentence in enumerate(sentences):
89
+ start = to_frame(sentence["startMs"])
90
+ if not start_frame <= start < end_frame:
91
+ continue
92
+ segment = {
93
+ "text": sentence["text"],
94
+ "startFrame": start,
95
+ "endFrame": min(max(to_frame(sentence["endMs"]), start + 1), end_frame),
96
+ "sentenceIndex": index,
97
+ }
98
+ if sentence.get("words"):
99
+ segment["words"] = [
100
+ {"text": w["text"], "startFrame": to_frame(w["startMs"]), "endFrame": to_frame(w["endMs"])}
101
+ for w in sentence["words"]
102
+ ]
103
+ segments.append(segment)
104
+ return segments
@@ -442,6 +442,7 @@ def compile_timeline(dsl: dict, binding: dict) -> dict:
442
442
  "colorScheme": binding.get("globalOverrides", {}).get("colorScheme", []),
443
443
  **({"themeConfig": binding["themeConfig"]} if binding.get("themeConfig") else {}),
444
444
  **({"variantId": binding["variantId"]} if binding.get("variantId") else {}),
445
+ **({"templateConfig": binding["templateConfig"]} if binding.get("templateConfig") else {}),
445
446
  },
446
447
  },
447
448
  "errors": [],