@remixmate/cli 0.12.8 → 0.13.0

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.
@@ -23,6 +23,7 @@ import os
23
23
  import sys
24
24
  import uuid
25
25
  from copy import deepcopy
26
+ from dataclasses import dataclass
26
27
  from urllib.parse import urlsplit
27
28
 
28
29
  SCHEMA_PATH = os.environ.get(
@@ -1045,6 +1046,29 @@ def _estimate_carousel_min_sec(carousel: dict, model: dict) -> float:
1045
1046
  return total - (n - 1) * transition_sec
1046
1047
 
1047
1048
 
1049
+
1050
+
1051
+
1052
+
1053
+
1054
+
1055
+
1056
+ _DURATION_KINDS = ("typewriter", "slideshow", "frame-sequence")
1057
+ _LEGACY_DURATION_KINDS = {"caption": "typewriter", "carousel": "slideshow", "sequence": "frame-sequence"}
1058
+
1059
+
1060
+ def _duration_components(duration_model: dict | None, kind: str) -> list[tuple[str, dict]]:
1061
+
1062
+ out = []
1063
+ for key, comp in (duration_model or {}).items():
1064
+ if not isinstance(comp, dict):
1065
+ continue
1066
+ comp_kind = comp.get("kind") or _LEGACY_DURATION_KINDS.get(key)
1067
+ if comp_kind == kind:
1068
+ out.append((key, comp))
1069
+ return out
1070
+
1071
+
1048
1072
  def _estimate_fit_caption_duration(
1049
1073
  custom_payload: dict, duration_model: dict | None, template_id: str | None
1050
1074
  ) -> float:
@@ -1060,35 +1084,39 @@ def _estimate_fit_caption_duration(
1060
1084
  f"⚠️ template '{template_id}' declares durationStrategy=fit-caption but no "
1061
1085
  "capabilities.durationModel, so gen_script cannot compute the fitted "
1062
1086
  "duration and falls back to the requested --duration.\n"
1063
- " Fix in the template (not here): add capabilities.durationModel with the "
1064
- "component's own pacing numbers (headlineIntroSec / tailPaddingSec / "
1065
- "caption.{charsPerSec,lineGapMs,startDelayMs,tailHoldSec,maxLines} / "
1066
- "carousel.{transitionMs,defaultHoldSec,minPerImageSec,minPerVideoSec}).",
1087
+ " Fix in the template (not here): add capabilities.durationModel with "
1088
+ "headlineIntroSec / tailPaddingSec plus one component per paced payload field, "
1089
+ "keyed by that field: {kind: typewriter, charsPerSec, lineGapMs, startDelayMs, "
1090
+ "tailHoldSec, maxLines} or {kind: slideshow, transitionMs, defaultHoldSec, "
1091
+ "minPerImageSec, minPerVideoSec}.",
1067
1092
  file=sys.stderr,
1068
1093
  )
1069
1094
  return 0.0
1070
- caption_sec = _estimate_caption_natural_sec(
1071
- custom_payload.get("caption") or {}, duration_model.get("caption") or {}
1072
- )
1073
- carousel_sec = _estimate_carousel_min_sec(
1074
- custom_payload.get("carousel") or {}, duration_model.get("carousel") or {}
1075
- )
1076
- headline_intro = duration_model.get("headlineIntroSec", 0)
1077
- return max(headline_intro, caption_sec, carousel_sec) + duration_model.get("tailPaddingSec", 0)
1095
+ parts = [duration_model.get("headlineIntroSec", 0)]
1096
+ for key, comp in _duration_components(duration_model, "typewriter"):
1097
+ parts.append(_estimate_caption_natural_sec(custom_payload.get(key) or {}, comp))
1098
+ for key, comp in _duration_components(duration_model, "slideshow"):
1099
+ parts.append(_estimate_carousel_min_sec(custom_payload.get(key) or {}, comp))
1100
+ return max(parts) + duration_model.get("tailPaddingSec", 0)
1101
+
1078
1102
 
1103
+ def _estimate_fit_images_duration(custom_payload: dict, model: dict | None, fps: int = 30) -> float:
1079
1104
 
1080
- def _estimate_fit_images_duration(sequence: dict, model: dict | None, fps: int = 30) -> float:
1081
1105
 
1082
1106
 
1083
1107
 
1084
1108
 
1085
1109
 
1086
- timing = (model or {}).get("sequence", {})
1110
+ comps = _duration_components(model, "frame-sequence")
1111
+ if not comps:
1112
+ raise ValueError("fit-images requires a durationModel component of kind frame-sequence")
1113
+ key, timing = comps[0]
1114
+ sequence = custom_payload.get(key) or {}
1087
1115
  tiers = timing.get("holdSecByCount", [])
1088
- count = len(sequence["items"])
1116
+ count = len(sequence.get("items") or [])
1089
1117
  rule_hold = next((tier["holdSec"] for tier in tiers if count <= tier["maxItems"]), None)
1090
1118
  if rule_hold is None:
1091
- raise ValueError("fit-images requires durationModel.sequence.holdSecByCount covering the image count")
1119
+ raise ValueError(f"fit-images requires durationModel.{key}.holdSecByCount covering the item count")
1092
1120
 
1093
1121
 
1094
1122
  preset_timing = (timing.get("presets") or {}).get(sequence.get("preset")) or {}
@@ -1096,7 +1124,7 @@ def _estimate_fit_images_duration(sequence: dict, model: dict | None, fps: int =
1096
1124
  transition_ms = sequence.get("transitionMs", preset_timing.get("transitionMs", timing.get("transitionMs")))
1097
1125
  margin = timing.get("minHoldMarginFrames")
1098
1126
  if transition_ms is None or margin is None:
1099
- raise ValueError("fit-images requires sequence transitionMs and durationModel.sequence.minHoldMarginFrames")
1127
+ raise ValueError(f"fit-images requires {key}.transitionMs and durationModel.{key}.minHoldMarginFrames")
1100
1128
 
1101
1129
  min_frames = max(2, math.floor(transition_ms / 1000 * fps + 0.5)) + margin
1102
1130
 
@@ -1125,8 +1153,8 @@ def _probe_sequence_video_duration(url: str) -> float | None:
1125
1153
  return probe_video_duration(url)
1126
1154
 
1127
1155
 
1128
- def _sequence_choice(sequence_schema: dict, key: str, value: str | None, flag: str,
1129
- template_id: str, passthrough: str) -> str | None:
1156
+ def _media_option_choice(option_schema: dict, value: str | None, flag: str,
1157
+ template_id: str, passthrough: str) -> str | None:
1130
1158
 
1131
1159
 
1132
1160
 
@@ -1134,8 +1162,7 @@ def _sequence_choice(sequence_schema: dict, key: str, value: str | None, flag: s
1134
1162
 
1135
1163
  if not value or value == passthrough:
1136
1164
  return None
1137
- prop = (sequence_schema.get("properties") or {}).get(key) or {}
1138
- allowed = [str(v) for v in prop.get("enum") or []]
1165
+ allowed = [str(v) for v in (option_schema or {}).get("enum") or []]
1139
1166
  if value not in allowed:
1140
1167
  raise ValueError(
1141
1168
  f"{flag} {value!r} is not supported by template {template_id!r}; "
@@ -1145,6 +1172,71 @@ def _sequence_choice(sequence_schema: dict, key: str, value: str | None, flag: s
1145
1172
 
1146
1173
 
1147
1174
 
1175
+
1176
+
1177
+
1178
+
1179
+
1180
+
1181
+
1182
+
1183
+
1184
+
1185
+
1186
+
1187
+
1188
+
1189
+
1190
+
1191
+ _MEDIA_OPTION_FLAGS = {"preset": "--sequence-preset", "fit": "--sequence-fit"}
1192
+ _LEGACY_MEDIA_BINDINGS = {
1193
+ "sequence": {
1194
+ "itemsPath": "sequence.items",
1195
+ "videoItem": {"type": "video"},
1196
+ "videoDurationKey": "sourceDurationSec",
1197
+ "captions": {"perItemKey": "caption"},
1198
+ "requireItems": True,
1199
+ "options": {"preset": {"none": "none", "replacesDefaults": True}, "fit": {"none": "auto"}},
1200
+ },
1201
+ "carousel": {
1202
+ "itemsPath": "carousel.items",
1203
+ "item": {"entrance": "random", "exit": "random"},
1204
+ "videoItem": {"kind": "video", "muted": True},
1205
+ "captions": {"linesPath": "caption.lines"},
1206
+ },
1207
+ }
1208
+
1209
+
1210
+ def _resolve_media_binding(template_config: dict | None) -> dict:
1211
+ caps = (template_config or {}).get("capabilities")
1212
+ caps = caps if isinstance(caps, dict) else {}
1213
+ binding = caps.get("mediaBinding")
1214
+ if isinstance(binding, dict) and isinstance(binding.get("itemsPath"), str) and binding["itemsPath"]:
1215
+ return deepcopy(binding)
1216
+ defaults = caps.get("payloadDefaults") if isinstance(caps.get("payloadDefaults"), dict) else {}
1217
+ legacy = "sequence" if isinstance(defaults.get("sequence"), dict) else "carousel"
1218
+ return deepcopy(_LEGACY_MEDIA_BINDINGS[legacy])
1219
+
1220
+
1221
+ def _schema_at(template_config: dict | None, parts: list[str]) -> dict:
1222
+
1223
+ node = (template_config or {}).get("customPayloadSchema") or {}
1224
+ for i, part in enumerate(parts):
1225
+ if not isinstance(node, dict):
1226
+ return {}
1227
+ node = node.get(part) if i == 0 else (node.get("properties") or {}).get(part)
1228
+ return node if isinstance(node, dict) else {}
1229
+
1230
+
1231
+ def _get_path(obj: dict, parts: list[str]):
1232
+ for part in parts:
1233
+ if not isinstance(obj, dict):
1234
+ return None
1235
+ obj = obj.get(part)
1236
+ return obj
1237
+
1238
+
1239
+
1148
1240
  PayloadWrites = list[tuple[list[str], object]]
1149
1241
 
1150
1242
 
@@ -1169,7 +1261,8 @@ def _build_carousel_caption_dsl(
1169
1261
  payload_defaults: dict,
1170
1262
  duration_strategy: str | None,
1171
1263
  duration_model: dict | None,
1172
- sequence_schema: dict | None = None,
1264
+ media_binding: dict,
1265
+ template_config: dict | None = None,
1173
1266
  sequence_preset: str | None = None,
1174
1267
  sequence_fit: str | None = None,
1175
1268
  payload_writes: PayloadWrites | None = None,
@@ -1180,33 +1273,51 @@ def _build_carousel_caption_dsl(
1180
1273
 
1181
1274
 
1182
1275
 
1276
+
1183
1277
  defaults = deepcopy(payload_defaults or {})
1184
- sequence_mode = isinstance(defaults.get("sequence"), dict)
1185
- if sequence_mode and not carousel_items:
1186
- raise ValueError("Image sequence needs --carousel-items; caption text alone cannot supply images")
1187
- if sequence_mode and len(caption_lines) > len(carousel_items):
1188
- raise ValueError("Image sequence has more --caption-lines than images; use at most one caption per image")
1189
- if not sequence_mode and (sequence_preset not in (None, "none") or sequence_fit not in (None, "auto")):
1190
- raise ValueError(f"--sequence-preset / --sequence-fit only apply to image-sequence templates; {template_id!r} is not one")
1191
- if sequence_mode:
1192
- sequence_preset = _sequence_choice(sequence_schema or {}, "preset", sequence_preset,
1193
- "--sequence-preset", template_id, "none")
1194
- sequence_fit = _sequence_choice(sequence_schema or {}, "fit", sequence_fit,
1195
- "--sequence-fit", template_id, "auto")
1278
+ binding = media_binding
1279
+ items_parts = binding["itemsPath"].split(".")
1280
+ container_parts = items_parts[:-1]
1281
+ captions = binding.get("captions") if isinstance(binding.get("captions"), dict) else {}
1282
+ per_item_caption = captions.get("perItemKey")
1283
+ lines_parts = captions["linesPath"].split(".") if isinstance(captions.get("linesPath"), str) else None
1284
+ options = binding.get("options") if isinstance(binding.get("options"), dict) else {}
1285
+
1286
+ if binding.get("requireItems") and not carousel_items:
1287
+ raise ValueError("This template is driven by media items; pass --carousel-items (caption text alone cannot supply them)")
1288
+ if per_item_caption and len(caption_lines) > len(carousel_items):
1289
+ raise ValueError("More --caption-lines than --carousel-items; this template takes at most one caption per item")
1290
+
1291
+
1292
+
1293
+ picked: dict[str, str] = {}
1294
+ for key, value in (("preset", sequence_preset), ("fit", sequence_fit)):
1295
+ flag = _MEDIA_OPTION_FLAGS[key]
1296
+ opt = options.get(key)
1297
+ none_value = opt.get("none") if isinstance(opt, dict) else ("none" if key == "preset" else "auto")
1298
+ if not isinstance(opt, dict) or not container_parts:
1299
+ if value not in (None, none_value, "none", "auto"):
1300
+ raise ValueError(f"{flag} is not an option of template {template_id!r} (no mediaBinding.options.{key})")
1301
+ continue
1302
+ choice = _media_option_choice(_schema_at(template_config, container_parts + [key]),
1303
+ value, flag, template_id, none_value)
1304
+ if choice:
1305
+ picked[key] = choice
1196
1306
 
1197
1307
 
1308
+ duration_key = binding.get("videoDurationKey")
1198
1309
  assets = []
1199
- carousel_asset_ids = []
1310
+ media_assets = []
1200
1311
  video_seconds: dict[str, float] = {}
1201
1312
  for idx, url in enumerate(carousel_items):
1202
1313
  asset_id = f"carousel-img-{idx + 1:02d}"
1203
1314
 
1204
1315
  is_video = urlsplit(url).path.lower().endswith((".mp4", ".mov", ".webm", ".avi"))
1205
- if sequence_mode and is_video:
1316
+ if duration_key and is_video:
1206
1317
  seconds = _probe_sequence_video_duration(url)
1207
1318
  if not seconds or seconds <= 0:
1208
1319
  raise ValueError(
1209
- f"Cannot read the duration of video {url!r}; an image sequence needs it to time the clip. "
1320
+ f"Cannot read the duration of video {url!r}; this template needs it to time the clip. "
1210
1321
  f"Check the URL is reachable, or leave this video out."
1211
1322
  )
1212
1323
  video_seconds[asset_id] = round(float(seconds), 3)
@@ -1217,7 +1328,7 @@ def _build_carousel_caption_dsl(
1217
1328
  "status": "generated",
1218
1329
  "url": url,
1219
1330
  })
1220
- carousel_asset_ids.append((asset_id, "video" if is_video else "image"))
1331
+ media_assets.append((asset_id, "video" if is_video else "image"))
1221
1332
 
1222
1333
 
1223
1334
  narration_asset_id = "narration-scene-01"
@@ -1233,66 +1344,38 @@ def _build_carousel_caption_dsl(
1233
1344
  })
1234
1345
 
1235
1346
 
1236
- carousel_defaults = defaults.get("carousel", {})
1237
- carousel_config = {
1238
- "items": [],
1239
- **carousel_defaults,
1240
- }
1241
- for asset_id, kind in carousel_asset_ids:
1242
- item = {
1243
- "assetRef": asset_id,
1244
- "entrance": carousel_defaults.get("defaultEntrance", "random"),
1245
- "exit": carousel_defaults.get("defaultExit", "random"),
1246
- }
1247
- if kind == "video":
1248
- item["kind"] = "video"
1249
- item["muted"] = True
1250
- carousel_config["items"].append(item)
1251
-
1252
-
1253
- caption_defaults = defaults.get("caption", {})
1254
- caption_config = {
1255
- "lines": caption_lines if caption_lines else [],
1256
- **caption_defaults,
1257
- }
1258
-
1259
- if caption_lines:
1260
- caption_config["lines"] = caption_lines
1261
-
1262
-
1263
-
1264
-
1265
-
1266
-
1267
-
1268
-
1269
-
1270
-
1271
- custom_payload = {
1272
- "carousel": carousel_config,
1273
- "caption": caption_config,
1274
- }
1275
- for key in ("background", "headlineStyle"):
1276
- if defaults.get(key):
1277
- custom_payload[key] = defaults[key]
1278
-
1279
1347
 
1280
1348
 
1281
1349
 
1282
- if sequence_mode:
1283
1350
 
1284
1351
 
1352
+ custom_payload = defaults
1353
+ if container_parts:
1354
+ container = custom_payload
1355
+ for part in container_parts:
1356
+ if not isinstance(container.get(part), dict):
1357
+ container[part] = {}
1358
+ container = container[part]
1359
+ if any(isinstance(o, dict) and o.get("replacesDefaults") and k in picked for k, o in options.items()):
1360
+ container.clear()
1361
+ container.update(picked)
1362
+ else:
1363
+ container = custom_payload
1364
+
1365
+ item_extra = binding.get("item") if isinstance(binding.get("item"), dict) else {}
1366
+ video_extra = binding.get("videoItem") if isinstance(binding.get("videoItem"), dict) else {}
1367
+ container[items_parts[-1]] = [
1368
+ {"assetRef": asset_id,
1369
+ **item_extra,
1370
+ **(video_extra if kind == "video" else {}),
1371
+ **({duration_key: video_seconds[asset_id]} if duration_key and kind == "video" else {}),
1372
+ **({per_item_caption: caption_lines[index]}
1373
+ if per_item_caption and index < len(caption_lines) and caption_lines[index] else {})}
1374
+ for index, (asset_id, kind) in enumerate(media_assets)
1375
+ ]
1376
+ if lines_parts:
1285
1377
 
1286
- sequence_config = {"preset": sequence_preset} if sequence_preset else defaults["sequence"]
1287
- if sequence_fit:
1288
- sequence_config["fit"] = sequence_fit
1289
- sequence_config["items"] = [
1290
- {"assetRef": asset_id,
1291
- **({"type": "video", "sourceDurationSec": video_seconds[asset_id]} if kind == "video" else {}),
1292
- **({"caption": caption_lines[index]} if index < len(caption_lines) and caption_lines[index] else {})}
1293
- for index, (asset_id, kind) in enumerate(carousel_asset_ids)
1294
- ]
1295
- custom_payload = {"sequence": sequence_config}
1378
+ _write_payload(custom_payload, [(lines_parts, list(caption_lines))])
1296
1379
 
1297
1380
 
1298
1381
 
@@ -1308,9 +1391,7 @@ def _build_carousel_caption_dsl(
1308
1391
 
1309
1392
  effective_duration = duration
1310
1393
  if duration_strategy == "fit-images":
1311
- if not sequence_mode:
1312
- raise ValueError("fit-images requires capabilities.payloadDefaults.sequence")
1313
- effective_duration = _estimate_fit_images_duration(custom_payload["sequence"], duration_model)
1394
+ effective_duration = _estimate_fit_images_duration(custom_payload, duration_model)
1314
1395
  if duration_strategy == "fit-caption":
1315
1396
  est = _estimate_fit_caption_duration(custom_payload, duration_model, template_id)
1316
1397
  if est > 0:
@@ -1430,7 +1511,12 @@ def _build_custom_payload(template_config: dict | None) -> dict:
1430
1511
  return {}
1431
1512
 
1432
1513
 
1433
- def _plan_countdown_scenes(spec: dict, count: int, duration: float, headline: str) -> list:
1514
+ def _plan_countdown_scenes(
1515
+ spec: dict, count: int, duration: float, headline: str, ending: bool = False
1516
+ ) -> list:
1517
+
1518
+
1519
+
1434
1520
 
1435
1521
 
1436
1522
 
@@ -1440,11 +1526,14 @@ def _plan_countdown_scenes(spec: dict, count: int, duration: float, headline: st
1440
1526
 
1441
1527
 
1442
1528
  head_sec = countdown_opening_sec(spec, count, duration)
1443
- beat_sec = (duration - head_sec) / count
1444
- return [{"purpose": "opening", "duration": head_sec, "label": headline}] + [
1529
+ beat_sec = (duration - head_sec) / (count + (1 if ending else 0))
1530
+ plans = [{"purpose": "opening", "duration": head_sec, "label": headline}] + [
1445
1531
  {"purpose": "point", "duration": beat_sec, "label": f"#{index}", "countdownIndex": index}
1446
1532
  for index in range(count, 0, -1)
1447
1533
  ]
1534
+ if ending:
1535
+ plans.append({"purpose": "ending", "duration": beat_sec, "label": "#1", "countdownIndex": 1})
1536
+ return plans
1448
1537
 
1449
1538
 
1450
1539
  def _warn_on_count_mismatch(spec: dict, headline: str, subheadline: str, count: int) -> None:
@@ -1777,7 +1866,7 @@ def _parse_payload_specs(template_config: dict, payload_specs: list[str] | None)
1777
1866
  key, sep, raw = spec.partition("=")
1778
1867
  parts = [p.strip() for p in key.split(".")]
1779
1868
  if not sep or not all(parts):
1780
- raise ValueError(f"--payload {spec!r}: expected KEY=VALUE, e.g. grade=四年级 or carousel.fit=cover")
1869
+ raise ValueError(f"--payload {spec!r}: expected KEY=VALUE, e.g. <field>=<value> or <object>.<field>=<value>")
1781
1870
  raw = raw.strip()
1782
1871
  if not raw:
1783
1872
  raise ValueError(f"--payload {spec!r}: empty value; leave the flag out to keep the template default")
@@ -1797,49 +1886,16 @@ def _write_payload(custom_payload: dict, writes: PayloadWrites) -> None:
1797
1886
  node[parts[-1]] = value
1798
1887
 
1799
1888
 
1800
- def build_dsl(
1801
- topic: str,
1802
- duration: int,
1803
- style: str,
1804
- ratio: str,
1805
- resolution: str,
1806
- voice_id: str,
1807
- font_id: str | None,
1808
- font_name: str | None,
1809
- scene_count: int | None,
1810
- allow_digital_human: bool,
1811
- allow_ai_video: bool,
1812
- speed: float | None = None,
1813
- template_id: str | None = None,
1814
- stub_image_url: str = "",
1815
- stub_video_url: str = "",
1816
- headline: str | None = None,
1817
- subheadline: str | None = None,
1818
- carousel_items: list[str] | None = None,
1819
- caption_lines: list[str] | None = None,
1820
- scene_images: list[str] | None = None,
1821
- item_count: int | None = None,
1822
- source_document_url: str | None = None,
1823
- variant: str | None = None,
1824
- sequence_preset: str | None = None,
1825
- sequence_fit: str | None = None,
1826
- payload_writes: PayloadWrites | None = None,
1827
- ) -> dict:
1828
-
1829
-
1830
-
1831
-
1832
-
1833
-
1834
-
1835
-
1836
-
1837
-
1838
-
1839
-
1840
-
1889
+ def _template_has_ending_slot(template_config: dict | None) -> bool:
1890
+ return isinstance(((template_config or {}).get("slotMapping") or {}).get("ending"), dict)
1841
1891
 
1842
1892
 
1893
+ def _apply_ending_payload(
1894
+ dsl: dict,
1895
+ template_config: dict | None,
1896
+ template_id: str | None,
1897
+ payload_specs: list[str] | None,
1898
+ ) -> None:
1843
1899
 
1844
1900
 
1845
1901
 
@@ -1847,75 +1903,143 @@ def build_dsl(
1847
1903
 
1848
1904
 
1849
1905
 
1906
+ if not payload_specs:
1907
+ return
1908
+ if not template_id or template_config is None:
1909
+ raise ValueError("--ending-payload needs --template-id: the allowed keys come from the template.")
1910
+ try:
1911
+ writes = _parse_payload_specs(template_config, payload_specs)
1912
+ except ValueError as exc:
1913
+ raise ValueError(str(exc).replace("--payload ", "--ending-payload ", 1)) from None
1914
+ endings = [s for s in dsl.get("scenes", []) if s.get("purpose") == "ending"]
1915
+ if not endings:
1916
+ raise ValueError(
1917
+ "--ending-payload: this script has no ending scene to write to "
1918
+ f"(scene purposes: {', '.join(str(s.get('purpose')) for s in dsl.get('scenes', []))})."
1919
+ )
1920
+ for scene in endings:
1921
+ _write_payload(scene.setdefault("customPayload", {}), writes)
1922
+ for parts, _ in writes:
1923
+ _lock_scene_path(dsl, "customPayload." + ".".join(parts))
1850
1924
 
1851
1925
 
1926
+ AVATAR_ASSET_ID = "asset-avatar"
1852
1927
 
1853
1928
 
1929
+ def _slot_extracts(template_config: dict | None, purpose: str, source: str) -> bool:
1854
1930
 
1931
+ mapping = (template_config or {}).get("slotMapping")
1932
+ if not isinstance(mapping, dict):
1933
+ return False
1934
+ slot = mapping.get(purpose) or mapping.get("default") or {}
1935
+ seen: set = set()
1936
+ while isinstance(slot, dict) and "$ref" in slot and slot["$ref"] not in seen:
1937
+ seen.add(slot["$ref"])
1938
+ slot = mapping.get(slot["$ref"]) or {}
1939
+ extractors = slot.get("propExtractors") if isinstance(slot, dict) else None
1940
+ return isinstance(extractors, dict) and any(
1941
+ isinstance(ex, dict) and ex.get("from") == source for ex in extractors.values()
1942
+ )
1855
1943
 
1856
- template_config = _load_template_config(template_id) if template_id else None
1857
- contract = _resolve_contract(template_config)
1858
- countdown = contract["countdown"]
1859
- if countdown is None and item_count is not None:
1860
- raise ValueError(
1861
- "--item-count requires a template declaring capabilities.sceneStrategy=countdown"
1862
- )
1863
- beat_count = resolve_countdown_count(countdown, item_count, scene_count) if countdown else None
1864
1944
 
1865
- primary_visual_type = contract["primary_visual_type"]
1866
- payload_style = contract["payload_style"]
1945
+ def _apply_avatar(
1946
+ dsl: dict,
1947
+ template_config: dict | None,
1948
+ template_id: str | None,
1949
+ avatar_id: str | None,
1950
+ ) -> None:
1867
1951
 
1868
- has_visual = payload_style == "visual-overlay"
1869
1952
 
1870
1953
 
1871
- needs_narration = contract["needs_narration"]
1872
1954
 
1873
1955
 
1874
1956
 
1957
+ if not avatar_id:
1958
+ return
1959
+ if not template_id or template_config is None:
1960
+ raise ValueError("--avatar-id needs --template-id: the template decides where the presenter shows.")
1961
+ support = ((template_config.get("capabilities") or {}).get("digitalHuman") or {}).get("support")
1962
+ if support not in ("optional", "required"):
1963
+ raise ValueError(f"--avatar-id: template {template_id!r} does not declare capabilities.digitalHuman")
1964
+ scenes = [
1965
+ s for s in dsl.get("scenes", [])
1966
+ if _slot_extracts(template_config, str(s.get("purpose")), "visuals.avatar.assetRef")
1967
+ ]
1968
+ if not scenes:
1969
+ raise ValueError(f"--avatar-id: no scene of template {template_id!r} reads visuals.avatar.assetRef")
1970
+ if not any((s.get("audio") or {}).get("narration") for s in scenes):
1971
+ raise ValueError("--avatar-id: the presenter is driven by the narration, and this script has none")
1972
+ for scene in scenes:
1973
+ visuals = scene.get("visuals") if isinstance(scene.get("visuals"), dict) else {}
1974
+ scene["visuals"] = {**visuals, "avatar": {"assetRef": AVATAR_ASSET_ID}}
1975
+ dsl.setdefault("assets", []).append({
1976
+ "assetId": AVATAR_ASSET_ID,
1977
+ "type": "avatar",
1978
+ "source": "gen-digital-human",
1979
+ "status": "planned",
1980
+ "payload": {"avatarId": avatar_id},
1981
+ })
1982
+ _lock_scene_path(dsl, "visuals.avatar.assetRef")
1983
+
1984
+
1985
+ @dataclass(frozen=True)
1986
+ class _SkeletonContext:
1987
+
1988
+
1989
+
1990
+
1991
+
1992
+ template_config: dict | None
1993
+ template_id: str | None
1994
+ contract: dict
1995
+ topic: str
1996
+ style: str
1997
+ ratio: str
1998
+ output_language: str
1999
+ primary_visual_type: str
2000
+ has_visual: bool
2001
+ needs_narration: bool
2002
+ headline: str
2003
+ subheadline: str
2004
+ resolved_voice_id: str
2005
+ narration_speed: float
2006
+ tail_pad_sec: float | None
2007
+ image_style_guide: dict
2008
+ image_compose: bool
2009
+ image_ratio: str
2010
+ stub_image_url: str
2011
+ stub_video_url: str
2012
+ shared_background: bool
2013
+ countdown: dict | None
2014
+ beat_count: int | None
2015
+
2016
+
2017
+ def _require_carousel_content(
2018
+ template_id: str | None,
2019
+ contract: dict,
2020
+ carousel_items: list[str] | None,
2021
+ caption_lines: list[str] | None,
2022
+ ) -> None:
1875
2023
 
1876
- output_language, template_default_voice_id = _resolve_template_language_meta(template_config)
1877
- resolved_voice_id = _resolve_voice_id(voice_id, template_default_voice_id, output_language)
1878
- narration_speed = _resolve_narration_speed(speed)
1879
- if resolved_voice_id != voice_id:
1880
- print(
1881
- f"ℹ️ voice resolution: cli={voice_id!r} → resolved={resolved_voice_id!r} "
1882
- f"(templateDefault={template_default_voice_id!r}, language={output_language})",
1883
- file=sys.stderr,
1884
- )
1885
2024
 
1886
2025
 
1887
- effective_headline = _normalize_linebreaks(headline) or topic
1888
- effective_subheadline = _normalize_linebreaks(subheadline)
1889
- _echo_template_llm_hint(template_config, template_id)
1890
- _warn_on_url_text_layer(effective_headline, effective_subheadline)
1891
- if beat_count is not None:
1892
- _warn_on_count_mismatch(countdown, effective_headline, effective_subheadline, beat_count)
1893
2026
 
1894
2027
 
1895
- if payload_style == "carousel-caption":
2028
+ if not (carousel_items or []) and not (caption_lines or []):
1896
2029
  print(
1897
- f"ℹ️ contract: payloadStyle=carousel-caption for template {template_id}",
2030
+ "❌ carousel-caption template "
2031
+ f"'{template_id}' needs visual or text content, but received neither "
2032
+ "--carousel-items nor --caption-lines.\n"
2033
+ " This template renders a carousel of user-supplied media; with no items "
2034
+ "it produces a black, minimum-length (2s) clip.\n"
2035
+ " Fix: pass the user's image/video URLs via --carousel-items "
2036
+ "(repeat the flag per item), e.g.\n"
2037
+ " gen_script.py --topic <topic> --template-id "
2038
+ f"{template_id} --carousel-items <url1> --carousel-items <url2> ...\n"
2039
+ " Optionally add --caption-lines '<text>' for on-screen typewriter captions.",
1898
2040
  file=sys.stderr,
1899
2041
  )
1900
-
1901
-
1902
-
1903
-
1904
- if not (carousel_items or []) and not (caption_lines or []):
1905
- print(
1906
- "❌ carousel-caption template "
1907
- f"'{template_id}' needs visual or text content, but received neither "
1908
- "--carousel-items nor --caption-lines.\n"
1909
- " This template renders a carousel of user-supplied media; with no items "
1910
- "it produces a black, minimum-length (2s) clip.\n"
1911
- " Fix: pass the user's image/video URLs via --carousel-items "
1912
- "(repeat the flag per item), e.g.\n"
1913
- " gen_script.py --topic <topic> --template-id "
1914
- f"{template_id} --carousel-items <url1> --carousel-items <url2> ...\n"
1915
- " Optionally add --caption-lines '<text>' for on-screen typewriter captions.",
1916
- file=sys.stderr,
1917
- )
1918
- sys.exit(1)
2042
+ sys.exit(1)
1919
2043
 
1920
2044
 
1921
2045
 
@@ -1923,64 +2047,44 @@ def build_dsl(
1923
2047
 
1924
2048
 
1925
2049
 
1926
- if contract["duration_strategy"] == "fit-caption" and not (caption_lines or []):
1927
- print(
1928
- "❌ template "
1929
- f"'{template_id}' is typewriter-driven (capabilities.durationStrategy="
1930
- "fit-caption), but --caption-lines is empty.\n"
1931
- " For this template the bottom typewriter copy IS the content: it carries "
1932
- "the message and it decides the video length. With no lines the render "
1933
- "collapses to the carousel floor (~5s for 2 images) and shows no text at all.\n"
1934
- " Fix: pass one --caption-lines '<text>' per line. If the user did not "
1935
- "supply the copy, WRITE IT YOURSELF from the material you researched "
1936
- "(repo README, page screenshots, the user's topic) and pass it — do not leave "
1937
- "it empty, and do not describe lines you never passed.\n"
1938
- " gen_script.py --topic <topic> --template-id "
1939
- f"{template_id} --carousel-items <url> "
1940
- "--caption-lines '<line 1>' --caption-lines '<line 2>' ...",
1941
- file=sys.stderr,
1942
- )
1943
- sys.exit(1)
1944
- carousel_dsl = _build_carousel_caption_dsl(
1945
- template_id=template_id,
1946
- topic=topic,
1947
- headline=effective_headline,
1948
- subheadline=effective_subheadline,
1949
- carousel_items=carousel_items or [],
1950
- caption_lines=caption_lines or [],
1951
- duration=duration,
1952
- style=style,
1953
- ratio=ratio,
1954
- resolution=resolution,
1955
- output_language=output_language,
1956
- resolved_voice_id=resolved_voice_id,
1957
- narration_speed=narration_speed,
1958
- font_id=font_id,
1959
- font_name=font_name,
1960
- narration_enabled=needs_narration,
1961
- payload_defaults=contract["payload_defaults"],
1962
- duration_strategy=contract["duration_strategy"],
1963
- duration_model=contract["duration_model"],
1964
- sequence_schema=((template_config or {}).get("customPayloadSchema") or {}).get("sequence"),
1965
- sequence_preset=sequence_preset,
1966
- sequence_fit=sequence_fit,
1967
- payload_writes=payload_writes,
2050
+ if contract["duration_strategy"] == "fit-caption" and not (caption_lines or []):
2051
+ print(
2052
+ "❌ template "
2053
+ f"'{template_id}' is typewriter-driven (capabilities.durationStrategy="
2054
+ "fit-caption), but --caption-lines is empty.\n"
2055
+ " For this template the bottom typewriter copy IS the content: it carries "
2056
+ "the message and it decides the video length. With no lines the render "
2057
+ "collapses to the carousel floor (~5s for 2 images) and shows no text at all.\n"
2058
+ " Fix: pass one --caption-lines '<text>' per line. If the user did not "
2059
+ "supply the copy, WRITE IT YOURSELF from the material you researched "
2060
+ "(repo README, page screenshots, the user's topic) and pass it — do not leave "
2061
+ "it empty, and do not describe lines you never passed.\n"
2062
+ " gen_script.py --topic <topic> --template-id "
2063
+ f"{template_id} --carousel-items <url> "
2064
+ "--caption-lines '<line 1>' --caption-lines '<line 2>' ...",
2065
+ file=sys.stderr,
1968
2066
  )
1969
- _enforce_supported_duration(carousel_dsl, template_config, template_id)
1970
- return carousel_dsl
1971
-
1972
- if sequence_preset not in (None, "none") or sequence_fit not in (None, "auto"):
1973
- raise ValueError(f"--sequence-preset / --sequence-fit only apply to image-sequence templates; {template_id!r} is not one")
1974
-
1975
-
1976
-
1977
-
2067
+ sys.exit(1)
1978
2068
 
1979
2069
 
1980
- provided_slots = [s.strip() for s in (scene_images or []) if s and s.strip()]
2070
+ def _plan_skeleton_scenes(
2071
+ contract: dict,
2072
+ countdown: dict | None,
2073
+ beat_count: int | None,
2074
+ topic: str,
2075
+ duration: int,
2076
+ scene_count: int | None,
2077
+ output_language: str,
2078
+ headline: str,
2079
+ countdown_ending: bool,
2080
+ provided_slots: list[str],
2081
+ has_visual: bool,
2082
+ ) -> list[dict]:
1981
2083
 
1982
2084
  if beat_count is not None:
1983
- scene_plans = _plan_countdown_scenes(countdown, beat_count, duration, effective_headline)
2085
+ scene_plans = _plan_countdown_scenes(
2086
+ countdown, beat_count, duration, headline, ending=countdown_ending
2087
+ )
1984
2088
  else:
1985
2089
  scene_plans = _plan_contract_scenes(contract, topic, duration, scene_count, output_language)
1986
2090
 
@@ -1996,7 +2100,7 @@ def build_dsl(
1996
2100
  provided_slots
1997
2101
  and scene_count is None
1998
2102
  and contract["scene_strategy"] == "arc"
1999
- and primary_visual_type == "image"
2103
+ and contract["primary_visual_type"] == "image"
2000
2104
  and has_visual
2001
2105
  and len(provided_slots) > len(scene_plans)
2002
2106
  ):
@@ -2008,334 +2112,542 @@ def build_dsl(
2008
2112
 
2009
2113
  if provided_slots and len(provided_slots) > len(scene_plans):
2010
2114
  print(
2011
- f"\u26a0\ufe0f {len(provided_slots)} scene-image entries provided but the template "
2115
+ f"⚠️ {len(provided_slots)} scene-image entries provided but the template "
2012
2116
  f"plans only {len(scene_plans)} scene(s); the extra "
2013
2117
  f"{len(provided_slots) - len(scene_plans)} will not be used. "
2014
2118
  f"Raise --scenes or pick a template with more scenes.",
2015
2119
  file=sys.stderr,
2016
2120
  )
2017
-
2018
- assets = []
2019
- scenes = []
2121
+ return scene_plans
2020
2122
 
2021
2123
 
2124
+ def _visual_asset_id(ctx: _SkeletonContext, scene_id: str) -> str:
2022
2125
 
2126
+ if ctx.primary_visual_type == "video":
2127
+ return f"video-{scene_id}"
2128
+ if ctx.shared_background:
2129
+ return "img-background"
2130
+ return f"img-{scene_id}"
2023
2131
 
2024
2132
 
2133
+ def _plan_visual_asset(
2134
+ ctx: _SkeletonContext, plan: dict, asset_id: str, slot: str, narration_text: str,
2135
+ ) -> dict | None:
2025
2136
 
2137
+ if ctx.primary_visual_type == "image":
2138
+ slot_is_ai, slot_prompt = _parse_ai_slot(slot)
2139
+ if slot and not slot_is_ai:
2026
2140
 
2027
- image_style_guide = (
2028
- resolve_style_guide(template_config, variant or "") if _HAS_ENHANCER else {}
2029
- )
2030
- image_compose = _HAS_ENHANCER and is_compose_mode(image_style_guide)
2031
- image_ratio = image_style_guide.get("ratio") or ratio
2032
2141
 
2033
- tail_pad_sec = _nb.template_tail_pad_sec(template_config) if _HAS_BUDGET else None
2142
+ return {
2143
+ "assetId": asset_id,
2144
+ "type": "image",
2145
+ "source": "existing",
2146
+ "status": "generated",
2147
+ "url": slot,
2148
+ }
2034
2149
 
2035
2150
 
2036
2151
 
2152
+ if ctx.stub_image_url:
2153
+ return {
2154
+ "assetId": asset_id,
2155
+ "type": "image",
2156
+ "source": "existing",
2157
+ "status": "generated",
2158
+ "url": ctx.stub_image_url,
2159
+ }
2160
+ if ctx.image_compose:
2161
+ image_result = {"prompt": COMPOSE_SUBJECT_PLACEHOLDER}
2162
+ elif ctx.shared_background:
2037
2163
 
2038
- shared_background = (
2039
- contract["background_strategy"] == "shared"
2040
- and primary_visual_type == "image"
2041
- and not provided_slots
2042
- )
2043
- shared_background_planned = False
2044
- for idx, plan in enumerate(scene_plans):
2045
- scene_id = f"scene-{idx + 1:02d}"
2046
2164
 
2047
- if primary_visual_type == "video":
2048
- visual_asset_id = f"video-{scene_id}"
2049
- elif shared_background:
2050
- visual_asset_id = "img-background"
2165
+ image_result = generate_image_prompt(
2166
+ "point", ctx.topic, ctx.style, narration_text="", style_guide=ctx.image_style_guide,
2167
+ )
2051
2168
  else:
2052
- visual_asset_id = f"img-{scene_id}"
2053
- narration_asset_id = f"narration-{scene_id}" if needs_narration else None
2054
- narration_text = (
2055
- estimate_narration_text(
2056
- plan["label"], plan["purpose"], plan["duration"], topic, language=output_language,
2057
- speed=narration_speed, tail_pad_sec=tail_pad_sec,
2169
+ image_result = generate_image_prompt(
2170
+ plan["purpose"], ctx.topic, ctx.style,
2171
+ narration_text=narration_text, style_guide=ctx.image_style_guide,
2058
2172
  )
2059
- if needs_narration else ""
2060
- )
2061
2173
 
2062
2174
 
2063
- scene_has_visual = has_visual and _slot_reads_visual(template_config, plan["purpose"])
2064
-
2065
-
2066
- if scene_has_visual and shared_background and shared_background_planned:
2067
- pass
2068
- elif scene_has_visual and primary_visual_type == "image":
2069
- if shared_background:
2070
- shared_background_planned = True
2071
- slot = provided_slots[idx] if idx < len(provided_slots) else ""
2072
- slot_is_ai, slot_prompt = _parse_ai_slot(slot)
2073
- if slot and not slot_is_ai:
2074
2175
 
2075
2176
 
2076
- assets.append({
2077
- "assetId": visual_asset_id,
2078
- "type": "image",
2079
- "source": "existing",
2080
- "status": "generated",
2081
- "url": slot,
2082
- })
2083
2177
 
2178
+ if slot_prompt:
2179
+ image_result = {**image_result, "prompt": slot_prompt}
2084
2180
 
2181
+ img_payload = {
2182
+ "prompt": image_result["prompt"],
2183
+ "model": os.environ.get("DEFAULT_IMAGE_MODEL", "doubao/doubao-seedream-5-0-260128"),
2184
+ "ratio": ctx.image_ratio,
2185
+ }
2186
+ if "negativePrompt" in image_result:
2187
+ img_payload["negativePrompt"] = image_result["negativePrompt"]
2188
+ if "guidanceScale" in image_result:
2189
+ img_payload["guidanceScale"] = image_result["guidanceScale"]
2190
+ return {
2191
+ "assetId": asset_id,
2192
+ "type": "image",
2193
+ "source": "gen-image",
2194
+ "status": "planned",
2195
+ "payload": img_payload,
2196
+ }
2085
2197
 
2086
- elif stub_image_url:
2087
- assets.append({
2088
- "assetId": visual_asset_id,
2089
- "type": "image",
2090
- "source": "existing",
2091
- "status": "generated",
2092
- "url": stub_image_url,
2093
- })
2094
- else:
2095
- if image_compose:
2096
- image_result = {"prompt": COMPOSE_SUBJECT_PLACEHOLDER}
2097
- elif shared_background:
2198
+ if ctx.primary_visual_type == "video":
2199
+ if ctx.stub_video_url:
2200
+ return {
2201
+ "assetId": asset_id,
2202
+ "type": "video",
2203
+ "source": "existing",
2204
+ "status": "generated",
2205
+ "url": ctx.stub_video_url,
2206
+ }
2098
2207
 
2099
2208
 
2100
- image_result = generate_image_prompt(
2101
- "point", topic, style, narration_text="", style_guide=image_style_guide,
2102
- )
2103
- else:
2104
- image_result = generate_image_prompt(
2105
- plan["purpose"], topic, style,
2106
- narration_text=narration_text, style_guide=image_style_guide,
2107
- )
2209
+ image_result = generate_image_prompt(plan["purpose"], ctx.topic, ctx.style, narration_text=narration_text)
2210
+ return {
2211
+ "assetId": asset_id,
2212
+ "type": "video",
2213
+ "source": "gen-video",
2214
+ "status": "planned",
2215
+ "payload": {
2216
+ "prompt": image_result["prompt"],
2108
2217
 
2109
2218
 
2219
+ "model": os.environ.get("DEFAULT_VIDEO_MODEL", "seedance"),
2220
+ "ratio": ctx.ratio,
2110
2221
 
2222
+ "duration": max(4, min(int(plan["duration"]), 15)),
2223
+ },
2224
+ }
2225
+ return None
2111
2226
 
2112
2227
 
2113
- if slot_prompt:
2114
- image_result = {**image_result, "prompt": slot_prompt}
2115
-
2116
- img_payload = {
2117
- "prompt": image_result["prompt"],
2118
- "model": os.environ.get("DEFAULT_IMAGE_MODEL", "doubao/doubao-seedream-5-0-260128"),
2119
- "ratio": image_ratio,
2120
- }
2121
- if "negativePrompt" in image_result:
2122
- img_payload["negativePrompt"] = image_result["negativePrompt"]
2123
- if "guidanceScale" in image_result:
2124
- img_payload["guidanceScale"] = image_result["guidanceScale"]
2125
-
2126
- assets.append({
2127
- "assetId": visual_asset_id,
2128
- "type": "image",
2129
- "source": "gen-image",
2130
- "status": "planned",
2131
- "payload": img_payload,
2132
- })
2133
- elif scene_has_visual and primary_visual_type == "video":
2134
- if stub_video_url:
2135
- assets.append({
2136
- "assetId": visual_asset_id,
2137
- "type": "video",
2138
- "source": "existing",
2139
- "status": "generated",
2140
- "url": stub_video_url,
2141
- })
2142
- else:
2228
+ def _visual_text_layers(ctx: _SkeletonContext, plan: dict) -> list[dict]:
2143
2229
 
2230
+ if ctx.contract["text_layer_rules"]:
2231
+ return _placeholder_text_layers(ctx.contract["text_layer_rules"])
2232
+ text_layers = []
2233
+ if plan["purpose"] == "opening":
2234
+ text_layers = [
2235
+ {"role": "headline", "content": ctx.headline, "animation": "slide-up"},
2236
+ ]
2237
+ if ctx.subheadline:
2238
+ text_layers.append({"role": "subheadline", "content": ctx.subheadline, "animation": "fade-in"})
2239
+ elif plan["purpose"] == "point":
2240
+ text_layers = [
2241
+ {"role": "badge", "content": plan["label"], "animation": "slide-up"},
2242
+ ]
2144
2243
 
2145
- image_result = generate_image_prompt(plan["purpose"], topic, style, narration_text=narration_text)
2146
- video_prompt = image_result["prompt"]
2147
2244
 
2148
- video_duration = max(4, min(int(plan["duration"]), 15))
2149
- vid_payload = {
2150
- "prompt": video_prompt,
2151
2245
 
2246
+ text_layers.insert(0, {"role": "headline", "content": ctx.headline, "animation": "fade-in"})
2247
+ if ctx.subheadline:
2248
+ text_layers.append({"role": "subheadline", "content": ctx.subheadline, "animation": "fade-in"})
2249
+ elif plan["purpose"] == "cta":
2250
+ text_layers = [
2251
+ {"role": "headline", "content": _localize_label(ctx.output_language, "follow_us"), "animation": "fade-in"},
2252
+ ]
2253
+ if ctx.subheadline:
2254
+ text_layers.append({"role": "subheadline", "content": ctx.subheadline, "animation": "fade-in"})
2255
+ return text_layers
2152
2256
 
2153
- "model": os.environ.get("DEFAULT_VIDEO_MODEL", "seedance"),
2154
- "ratio": ratio,
2155
- "duration": video_duration,
2156
- }
2157
- assets.append({
2158
- "assetId": visual_asset_id,
2159
- "type": "video",
2160
- "source": "gen-video",
2161
- "status": "planned",
2162
- "payload": vid_payload,
2163
- })
2164
2257
 
2258
+ def _payload_text_layers(ctx: _SkeletonContext, plan: dict) -> list[dict]:
2165
2259
 
2166
2260
 
2167
2261
 
2168
2262
 
2169
- if needs_narration:
2170
- assets.append({
2171
- "assetId": narration_asset_id,
2172
- "type": "audio",
2173
- "source": "gen-voice",
2174
- "status": "planned",
2175
- "payload": {
2176
- "voiceId": resolved_voice_id,
2177
- },
2178
- })
2179
2263
 
2180
- if scene_has_visual:
2181
- layout = "text-overlay" if plan["purpose"] in ("opening", "cta") else "full-visual"
2182
- text_layers = []
2183
- if plan["purpose"] == "opening":
2184
- text_layers = [
2185
- {"role": "headline", "content": effective_headline, "animation": "slide-up"},
2186
- ]
2187
- if effective_subheadline:
2188
- text_layers.append({"role": "subheadline", "content": effective_subheadline, "animation": "fade-in"})
2189
- elif plan["purpose"] == "point":
2190
- text_layers = [
2191
- {"role": "badge", "content": plan["label"], "animation": "slide-up"},
2192
- ]
2193
-
2194
-
2195
-
2196
- text_layers.insert(0, {"role": "headline", "content": effective_headline, "animation": "fade-in"})
2197
- if effective_subheadline:
2198
- text_layers.append({"role": "subheadline", "content": effective_subheadline, "animation": "fade-in"})
2199
- elif plan["purpose"] == "cta":
2200
- text_layers = [
2201
- {"role": "headline", "content": _localize_label(output_language, "follow_us"), "animation": "fade-in"},
2202
- ]
2203
- if effective_subheadline:
2204
- text_layers.append({"role": "subheadline", "content": effective_subheadline, "animation": "fade-in"})
2205
- if contract["text_layer_rules"]:
2206
- text_layers = _placeholder_text_layers(contract["text_layer_rules"])
2207
-
2208
- scene = {
2209
- "id": scene_id,
2210
- "purpose": plan["purpose"],
2211
- "duration": plan["duration"],
2212
- "layout": layout,
2213
- "visuals": {"background": {"assetRef": visual_asset_id}},
2214
- "textLayers": text_layers,
2215
- "animationHints": {
2216
- "entrance": "fade",
2217
- "motion": "kenburns-in" if idx % 2 == 0 else "kenburns-out",
2218
- },
2219
- }
2220
- if needs_narration:
2221
- scene["audio"] = {
2222
- "narration": {
2223
- "text": narration_text,
2224
- "assetRef": narration_asset_id,
2225
-
2226
- "needsFill": True,
2227
- },
2228
- }
2229
- scenes.append(scene)
2230
- else:
2231
2264
 
2232
2265
 
2233
2266
 
2267
+ if ctx.contract["text_layer_rules"]:
2268
+ return _placeholder_text_layers(ctx.contract["text_layer_rules"])
2269
+ text_layers = []
2270
+ if plan["purpose"] == "opening":
2271
+ text_layers = [
2272
+ {"role": "headline", "content": ctx.headline, "animation": "fade-in"},
2273
+ ]
2274
+ if ctx.subheadline:
2275
+ text_layers.append({"role": "subheadline", "content": ctx.subheadline, "animation": "fade-in"})
2276
+ elif plan["purpose"] == "point":
2277
+ text_layers = [
2278
+ {"role": "headline", "content": plan["label"], "animation": "slide-up"},
2279
+ ]
2280
+ elif plan["purpose"] == "cta":
2281
+ text_layers = [
2282
+ {"role": "headline", "content": _localize_label(ctx.output_language, "follow_us"), "animation": "zoom-in"},
2283
+ ]
2284
+ if ctx.subheadline:
2285
+ text_layers.append({"role": "subheadline", "content": ctx.subheadline, "animation": "fade-in"})
2286
+ return text_layers
2234
2287
 
2235
2288
 
2289
+ def _attach_narration(scene: dict, narration_text: str, narration_asset_id: str | None) -> dict:
2236
2290
 
2291
+ if narration_asset_id:
2292
+ scene["audio"] = {
2293
+ "narration": {
2294
+ "text": narration_text,
2295
+ "assetRef": narration_asset_id,
2237
2296
 
2297
+ "needsFill": True,
2298
+ },
2299
+ }
2300
+ return scene
2238
2301
 
2239
2302
 
2303
+ def _visual_scene(
2304
+ ctx: _SkeletonContext, idx: int, scene_id: str, plan: dict, visual_asset_id: str,
2305
+ ) -> dict:
2306
+ return {
2307
+ "id": scene_id,
2308
+ "purpose": plan["purpose"],
2309
+ "duration": plan["duration"],
2310
+ "layout": "text-overlay" if plan["purpose"] in ("opening", "cta") else "full-visual",
2311
+ "visuals": {"background": {"assetRef": visual_asset_id}},
2312
+ "textLayers": _visual_text_layers(ctx, plan),
2313
+ "animationHints": {
2314
+ "entrance": "fade",
2315
+ "motion": "kenburns-in" if idx % 2 == 0 else "kenburns-out",
2316
+ },
2317
+ }
2240
2318
 
2241
2319
 
2320
+ def _payload_scene(ctx: _SkeletonContext, scene_id: str, plan: dict) -> dict:
2242
2321
 
2243
2322
 
2244
2323
 
2245
2324
 
2246
2325
 
2247
2326
 
2248
- _layout_cfg = (template_config or {}).get("capabilities") or {}
2249
- layout = _layout_cfg.get("defaultLayout") or template_id or "text-overlay"
2250
2327
 
2251
2328
 
2252
2329
 
2253
2330
 
2254
2331
 
2255
2332
 
2256
- text_layers = []
2257
- if plan["purpose"] == "opening":
2258
- text_layers = [
2259
- {"role": "headline", "content": effective_headline, "animation": "fade-in"},
2260
- ]
2261
- if effective_subheadline:
2262
- text_layers.append({"role": "subheadline", "content": effective_subheadline, "animation": "fade-in"})
2263
- elif plan["purpose"] == "point":
2264
- text_layers = [
2265
- {"role": "headline", "content": plan["label"], "animation": "slide-up"},
2266
- ]
2267
- elif plan["purpose"] == "cta":
2268
- text_layers = [
2269
- {"role": "headline", "content": _localize_label(output_language, "follow_us"), "animation": "zoom-in"},
2270
- ]
2271
- if effective_subheadline:
2272
- text_layers.append({"role": "subheadline", "content": effective_subheadline, "animation": "fade-in"})
2273
2333
 
2274
- if contract["text_layer_rules"]:
2275
- text_layers = _placeholder_text_layers(contract["text_layer_rules"])
2276
2334
 
2277
- custom_payload = _build_custom_payload(template_config)
2278
- if beat_count is not None:
2279
2335
 
2280
2336
 
2281
- custom_payload.update(
2282
- countdown_scene_payload(countdown, beat_count, plan.get("countdownIndex"))
2283
- )
2284
2337
 
2285
- scene = {
2286
- "id": scene_id,
2287
- "purpose": plan["purpose"],
2288
- "duration": plan["duration"],
2289
- "layout": layout,
2290
- "textLayers": text_layers,
2291
- }
2338
+ layout_cfg = (ctx.template_config or {}).get("capabilities") or {}
2339
+ scene = {
2340
+ "id": scene_id,
2341
+ "purpose": plan["purpose"],
2342
+ "duration": plan["duration"],
2343
+ "layout": layout_cfg.get("defaultLayout") or ctx.template_id or "text-overlay",
2344
+ "textLayers": _payload_text_layers(ctx, plan),
2345
+ }
2346
+ custom_payload = _build_custom_payload(ctx.template_config)
2347
+ if ctx.beat_count is not None:
2292
2348
 
2293
2349
 
2294
- if custom_payload:
2295
- scene["customPayload"] = custom_payload
2296
- if needs_narration:
2297
- scene["audio"] = {
2298
- "narration": {
2299
- "text": narration_text,
2300
- "assetRef": narration_asset_id,
2350
+ custom_payload.update(
2351
+ countdown_scene_payload(ctx.countdown, ctx.beat_count, plan.get("countdownIndex"))
2352
+ )
2301
2353
 
2302
- "needsFill": True,
2303
- },
2304
- }
2305
- scenes.append(scene)
2306
2354
 
2307
- dsl = {
2355
+ if custom_payload:
2356
+ scene["customPayload"] = custom_payload
2357
+ return scene
2358
+
2359
+
2360
+ def _skeleton_document(
2361
+ ctx: _SkeletonContext,
2362
+ assets: list[dict],
2363
+ scenes: list[dict],
2364
+ *,
2365
+ headline_given: bool,
2366
+ duration: int,
2367
+ resolution: str,
2368
+ font_id: str | None,
2369
+ font_name: str | None,
2370
+ ) -> dict:
2371
+
2372
+ return {
2308
2373
  "version": "v1alpha1",
2309
2374
  "meta": {
2310
- "title": _video_title(effective_headline if headline else None, topic),
2311
- "topic": topic,
2312
- "headline": effective_headline,
2313
- "subheadline": effective_subheadline,
2375
+ "title": _video_title(ctx.headline if headline_given else None, ctx.topic),
2376
+ "topic": ctx.topic,
2377
+ "headline": ctx.headline,
2378
+ "subheadline": ctx.subheadline,
2314
2379
  "targetDuration": duration,
2315
- "language": LANGUAGE_TO_META[output_language],
2316
- "outputLanguage": output_language,
2317
- "style": style or "",
2318
- "description": _localize_label(output_language, "video_description", topic=topic, duration=duration),
2380
+ "language": LANGUAGE_TO_META[ctx.output_language],
2381
+ "outputLanguage": ctx.output_language,
2382
+ "style": ctx.style or "",
2383
+ "description": _localize_label(ctx.output_language, "video_description", topic=ctx.topic, duration=duration),
2319
2384
 
2320
- **({"characterSheet": ""} if image_compose and has_visual else {}),
2385
+ **({"characterSheet": ""} if ctx.image_compose and ctx.has_visual else {}),
2321
2386
  },
2322
2387
  "global": {
2323
- "aspectRatio": ratio,
2388
+ "aspectRatio": ctx.ratio,
2324
2389
  "resolution": resolution,
2325
2390
  "fps": 30,
2326
2391
 
2327
2392
 
2328
- "subtitle": {"enabled": needs_narration, "style": "bottom"},
2329
- **({"narration": {"voiceId": resolved_voice_id, "speed": narration_speed}} if needs_narration else {}),
2393
+ "subtitle": {"enabled": ctx.needs_narration, "style": "bottom"},
2394
+ **({"narration": {"voiceId": ctx.resolved_voice_id, "speed": ctx.narration_speed}} if ctx.needs_narration else {}),
2330
2395
  **({"font": {"fontId": font_id, **({"fontName": font_name} if font_name else {})}} if font_id else {}),
2331
2396
  "bgm": {"enabled": True, "volume": 0.12},
2332
2397
  },
2333
2398
  "assets": assets,
2334
2399
  "scenes": scenes,
2335
2400
 
2336
- "transitions": {"default": contract["default_transition"] or "fade", "duration": 0.5},
2401
+ "transitions": {"default": ctx.contract["default_transition"] or "fade", "duration": 0.5},
2337
2402
  }
2338
2403
 
2404
+
2405
+ def build_dsl(
2406
+ topic: str,
2407
+ duration: int,
2408
+ style: str,
2409
+ ratio: str,
2410
+ resolution: str,
2411
+ voice_id: str,
2412
+ font_id: str | None,
2413
+ font_name: str | None,
2414
+ scene_count: int | None,
2415
+ allow_digital_human: bool,
2416
+ allow_ai_video: bool,
2417
+ speed: float | None = None,
2418
+ template_id: str | None = None,
2419
+ stub_image_url: str = "",
2420
+ stub_video_url: str = "",
2421
+ headline: str | None = None,
2422
+ subheadline: str | None = None,
2423
+ carousel_items: list[str] | None = None,
2424
+ caption_lines: list[str] | None = None,
2425
+ scene_images: list[str] | None = None,
2426
+ item_count: int | None = None,
2427
+ source_document_url: str | None = None,
2428
+ variant: str | None = None,
2429
+ sequence_preset: str | None = None,
2430
+ sequence_fit: str | None = None,
2431
+ payload_writes: PayloadWrites | None = None,
2432
+ countdown_ending: bool = False,
2433
+ ) -> dict:
2434
+
2435
+
2436
+
2437
+
2438
+
2439
+
2440
+
2441
+
2442
+
2443
+
2444
+
2445
+
2446
+
2447
+
2448
+
2449
+
2450
+
2451
+
2452
+
2453
+
2454
+
2455
+
2456
+
2457
+
2458
+
2459
+
2460
+
2461
+
2462
+ template_config = _load_template_config(template_id) if template_id else None
2463
+ contract = _resolve_contract(template_config)
2464
+ countdown = contract["countdown"]
2465
+ if countdown is None and item_count is not None:
2466
+ raise ValueError(
2467
+ "--item-count requires a template declaring capabilities.sceneStrategy=countdown"
2468
+ )
2469
+ beat_count = resolve_countdown_count(countdown, item_count, scene_count) if countdown else None
2470
+
2471
+ countdown_ending = bool(countdown_ending and countdown)
2472
+ if countdown_ending and not _template_has_ending_slot(template_config):
2473
+ raise ValueError(f"--ending-payload: template {template_id!r} has no ending slot")
2474
+
2475
+ primary_visual_type = contract["primary_visual_type"]
2476
+ payload_style = contract["payload_style"]
2477
+
2478
+ has_visual = payload_style == "visual-overlay"
2479
+
2480
+
2481
+ needs_narration = contract["needs_narration"]
2482
+
2483
+
2484
+
2485
+
2486
+ output_language, template_default_voice_id = _resolve_template_language_meta(template_config)
2487
+ resolved_voice_id = _resolve_voice_id(voice_id, template_default_voice_id, output_language)
2488
+ narration_speed = _resolve_narration_speed(speed)
2489
+ if resolved_voice_id != voice_id:
2490
+ print(
2491
+ f"ℹ️ voice resolution: cli={voice_id!r} → resolved={resolved_voice_id!r} "
2492
+ f"(templateDefault={template_default_voice_id!r}, language={output_language})",
2493
+ file=sys.stderr,
2494
+ )
2495
+
2496
+
2497
+ effective_headline = _normalize_linebreaks(headline) or topic
2498
+ effective_subheadline = _normalize_linebreaks(subheadline)
2499
+ _echo_template_llm_hint(template_config, template_id)
2500
+ _warn_on_url_text_layer(effective_headline, effective_subheadline)
2501
+ if beat_count is not None:
2502
+ _warn_on_count_mismatch(countdown, effective_headline, effective_subheadline, beat_count)
2503
+
2504
+
2505
+ if payload_style == "carousel-caption":
2506
+ print(
2507
+ f"ℹ️ contract: payloadStyle=carousel-caption for template {template_id}",
2508
+ file=sys.stderr,
2509
+ )
2510
+ _require_carousel_content(template_id, contract, carousel_items, caption_lines)
2511
+ carousel_dsl = _build_carousel_caption_dsl(
2512
+ template_id=template_id,
2513
+ topic=topic,
2514
+ headline=effective_headline,
2515
+ subheadline=effective_subheadline,
2516
+ carousel_items=carousel_items or [],
2517
+ caption_lines=caption_lines or [],
2518
+ duration=duration,
2519
+ style=style,
2520
+ ratio=ratio,
2521
+ resolution=resolution,
2522
+ output_language=output_language,
2523
+ resolved_voice_id=resolved_voice_id,
2524
+ narration_speed=narration_speed,
2525
+ font_id=font_id,
2526
+ font_name=font_name,
2527
+ narration_enabled=needs_narration,
2528
+ payload_defaults=contract["payload_defaults"],
2529
+ duration_strategy=contract["duration_strategy"],
2530
+ duration_model=contract["duration_model"],
2531
+ media_binding=_resolve_media_binding(template_config),
2532
+ template_config=template_config,
2533
+ sequence_preset=sequence_preset,
2534
+ sequence_fit=sequence_fit,
2535
+ payload_writes=payload_writes,
2536
+ )
2537
+ _enforce_supported_duration(carousel_dsl, template_config, template_id)
2538
+ return carousel_dsl
2539
+
2540
+ if sequence_preset not in (None, "none") or sequence_fit not in (None, "auto"):
2541
+ raise ValueError(f"--sequence-preset / --sequence-fit only apply to templates declaring mediaBinding.options; {template_id!r} does not")
2542
+
2543
+
2544
+
2545
+
2546
+
2547
+
2548
+ provided_slots = [s.strip() for s in (scene_images or []) if s and s.strip()]
2549
+ scene_plans = _plan_skeleton_scenes(
2550
+ contract, countdown, beat_count, topic, duration, scene_count, output_language,
2551
+ effective_headline, countdown_ending, provided_slots, has_visual,
2552
+ )
2553
+
2554
+
2555
+
2556
+
2557
+
2558
+
2559
+
2560
+ image_style_guide = (
2561
+ resolve_style_guide(template_config, variant or "") if _HAS_ENHANCER else {}
2562
+ )
2563
+ ctx = _SkeletonContext(
2564
+ template_config=template_config,
2565
+ template_id=template_id,
2566
+ contract=contract,
2567
+ topic=topic,
2568
+ style=style,
2569
+ ratio=ratio,
2570
+ output_language=output_language,
2571
+ primary_visual_type=primary_visual_type,
2572
+ has_visual=has_visual,
2573
+ needs_narration=needs_narration,
2574
+ headline=effective_headline,
2575
+ subheadline=effective_subheadline,
2576
+ resolved_voice_id=resolved_voice_id,
2577
+ narration_speed=narration_speed,
2578
+ tail_pad_sec=_nb.template_tail_pad_sec(template_config) if _HAS_BUDGET else None,
2579
+ image_style_guide=image_style_guide,
2580
+ image_compose=_HAS_ENHANCER and is_compose_mode(image_style_guide),
2581
+ image_ratio=image_style_guide.get("ratio") or ratio,
2582
+ stub_image_url=stub_image_url,
2583
+ stub_video_url=stub_video_url,
2584
+
2585
+
2586
+
2587
+
2588
+ shared_background=(
2589
+ contract["background_strategy"] == "shared"
2590
+ and primary_visual_type == "image"
2591
+ and not provided_slots
2592
+ ),
2593
+ countdown=countdown,
2594
+ beat_count=beat_count,
2595
+ )
2596
+
2597
+ assets = []
2598
+ scenes = []
2599
+ shared_background_planned = False
2600
+ for idx, plan in enumerate(scene_plans):
2601
+ scene_id = f"scene-{idx + 1:02d}"
2602
+ visual_asset_id = _visual_asset_id(ctx, scene_id)
2603
+ narration_asset_id = f"narration-{scene_id}" if needs_narration else None
2604
+ narration_text = (
2605
+ estimate_narration_text(
2606
+ plan["label"], plan["purpose"], plan["duration"], topic, language=output_language,
2607
+ speed=narration_speed, tail_pad_sec=ctx.tail_pad_sec,
2608
+ )
2609
+ if needs_narration else ""
2610
+ )
2611
+
2612
+
2613
+ scene_has_visual = has_visual and _slot_reads_visual(template_config, plan["purpose"])
2614
+
2615
+
2616
+
2617
+ if scene_has_visual and not (ctx.shared_background and shared_background_planned):
2618
+ shared_background_planned = ctx.shared_background
2619
+ slot = provided_slots[idx] if idx < len(provided_slots) else ""
2620
+ visual_asset = _plan_visual_asset(ctx, plan, visual_asset_id, slot, narration_text)
2621
+ if visual_asset:
2622
+ assets.append(visual_asset)
2623
+
2624
+
2625
+
2626
+
2627
+
2628
+ if needs_narration:
2629
+ assets.append({
2630
+ "assetId": narration_asset_id,
2631
+ "type": "audio",
2632
+ "source": "gen-voice",
2633
+ "status": "planned",
2634
+ "payload": {
2635
+ "voiceId": resolved_voice_id,
2636
+ },
2637
+ })
2638
+
2639
+ scene = (
2640
+ _visual_scene(ctx, idx, scene_id, plan, visual_asset_id)
2641
+ if scene_has_visual else _payload_scene(ctx, scene_id, plan)
2642
+ )
2643
+ scenes.append(_attach_narration(scene, narration_text, narration_asset_id))
2644
+
2645
+ dsl = _skeleton_document(
2646
+ ctx, assets, scenes,
2647
+ headline_given=bool(headline), duration=duration, resolution=resolution,
2648
+ font_id=font_id, font_name=font_name,
2649
+ )
2650
+
2339
2651
  if template_id:
2340
2652
  dsl["meta"]["templateId"] = template_id
2341
2653
 
@@ -2362,7 +2674,7 @@ def build_dsl(
2362
2674
  return dsl
2363
2675
 
2364
2676
 
2365
- def main():
2677
+ def _build_arg_parser() -> argparse.ArgumentParser:
2366
2678
  parser = argparse.ArgumentParser(
2367
2679
  description="Video script generator — produces Video DSL v1alpha1 JSON",
2368
2680
  formatter_class=argparse.RawDescriptionHelpFormatter,
@@ -2430,20 +2742,20 @@ Examples:
2430
2742
  "--background-preset", default=None,
2431
2743
  help="Scene background preset (one of the template's customPayloadSchema.background.preset enum). "
2432
2744
  "Written to every scene's customPayload.background.preset. When the user picked a background in "
2433
- "the replicate card (e.g. `纯色(solid)`), pass that id. Requires --template-id.",
2745
+ "the replicate card (`<名称>(<id>)`), pass that id. Requires --template-id.",
2434
2746
  )
2435
2747
  parser.add_argument(
2436
2748
  "--sequence-preset", default=None,
2437
- help="Image-sequence style preset (one of the template's customPayloadSchema.sequence.preset enum, "
2438
- "e.g. vlog / news / poster / cinematic / fast-cut), written to customPayload.sequence.preset; "
2439
- "`none` keeps the template defaults. When the user picked a style preset in the replicate card "
2440
- "(e.g. `快剪(fast-cut)`), pass that id. Only for templates declaring payloadDefaults.sequence.",
2749
+ help="Style preset for templates declaring capabilities.mediaBinding.options.preset (one of that "
2750
+ "option's enum in customPayloadSchema), written next to the template's media list; `none` keeps "
2751
+ "the template defaults. When the user picked a style preset in the replicate card "
2752
+ "(`<名称>(<id>)`), pass that id.",
2441
2753
  )
2442
2754
  parser.add_argument(
2443
2755
  "--sequence-fit", default=None,
2444
- help="How image-sequence media fill the frame (customPayloadSchema.sequence.fit enum: cover = fill and crop, "
2445
- "contain = show whole with a blurred backdrop), written to customPayload.sequence.fit; `auto` keeps "
2446
- "the template / preset default. When the user picked it in the replicate card, pass that id.",
2756
+ help="Media fill mode for templates declaring capabilities.mediaBinding.options.fit (one of that "
2757
+ "option's enum in customPayloadSchema); `auto` keeps the template / preset default. When the user "
2758
+ "picked it in the replicate card, pass that id.",
2447
2759
  )
2448
2760
  parser.add_argument(
2449
2761
  "--background-asset", default=None,
@@ -2454,8 +2766,8 @@ Examples:
2454
2766
  )
2455
2767
  parser.add_argument(
2456
2768
  "--payload", action="append", default=None, metavar="KEY=VALUE",
2457
- help="Set a template customPayload field on every scene, e.g. --payload grade=四年级 "
2458
- "--payload carousel.fit=cover --payload templateData.revealMode=progressive. KEY is a dotted path "
2769
+ help="Set a template customPayload field on every scene, e.g. --payload <field>=<value> "
2770
+ "--payload <object>.<field>=<value>. KEY is a dotted path "
2459
2771
  "checked against the template's customPayloadSchema; VALUE is converted to its declared type "
2460
2772
  "(true/false, numbers) and checked against its enum / range. Repeatable. Requires --template-id.",
2461
2773
  )
@@ -2465,6 +2777,8 @@ Examples:
2465
2777
  "pan-left / pan-right / float. Replaces the skeleton's alternating default.",
2466
2778
  )
2467
2779
  parser.add_argument("--allow-digital-human", action="store_true", help="Allow digital-human assets")
2780
+ parser.add_argument("--avatar-id", default=None, help="Digital-human avatar id (dh_xxx) for templates declaring capabilities.digitalHuman: adds one gen-digital-human asset referenced by every scene's visuals.avatar.assetRef (one continuous presenter driven by the narration). Placement stays the template default unless set with --payload digitalHuman.<field>=...")
2781
+ parser.add_argument("--ending-payload", action="append", default=None, metavar="KEY=VALUE", help="Like --payload, but written only to the ending scene (e.g. <object>.<field>=<closing line>). On countdown templates it also appends the ending scene after #1. Repeatable.")
2468
2782
  parser.add_argument("--allow-ai-video", action="store_true", help="Allow AI-generated video assets")
2469
2783
  parser.add_argument("--template-id", default=None, help="Template id. The template owns outputLanguage and may declare a defaultVoiceId.")
2470
2784
  parser.add_argument(
@@ -2511,12 +2825,11 @@ Examples:
2511
2825
  "--carousel-items",
2512
2826
  action="append",
2513
2827
  default=None,
2514
- help="Media URL for the template's carousel/gallery. Can be repeated: "
2828
+ help="Media URL for the template's image/video list. Can be repeated: "
2515
2829
  "--carousel-items url1 --carousel-items url2. "
2516
2830
  "For templates declaring capabilities.payloadStyle=carousel-caption, "
2517
- "these URLs are placed directly into "
2518
- "customPayload.sequence.items when payloadDefaults.sequence is declared, otherwise "
2519
- "customPayload.carousel.items, as existing assets without AI image generation.",
2831
+ "these URLs are placed directly into the list named by capabilities.mediaBinding.itemsPath, "
2832
+ "as existing assets without AI image generation.",
2520
2833
  )
2521
2834
  parser.add_argument(
2522
2835
  "--scene-images",
@@ -2539,40 +2852,38 @@ Examples:
2539
2852
  help="On-screen caption line. Can be repeated: "
2540
2853
  "--caption-lines 'line1' --caption-lines 'line2'. "
2541
2854
  "For templates declaring capabilities.payloadStyle=carousel-caption, "
2542
- "these are placed into customPayload.caption.lines, or sequence.items[].caption "
2543
- "in image order when payloadDefaults.sequence is declared. "
2855
+ "these go where capabilities.mediaBinding.captions says (one text block, or one caption "
2856
+ "per media item in order). "
2544
2857
  "Line count limits and whether **emphasis** is parsed are declared per "
2545
2858
  "template — see the chosen template's llmHint.",
2546
2859
  )
2860
+ return parser
2547
2861
 
2548
- args = parser.parse_args()
2549
2862
 
2863
+ def _run_validate(input_path: str | None) -> None:
2550
2864
 
2865
+ if not input_path:
2866
+ print("❌ validate mode requires --input", file=sys.stderr)
2867
+ sys.exit(1)
2868
+ if not os.path.exists(input_path):
2869
+ print(f"❌ file not found: {input_path}", file=sys.stderr)
2870
+ sys.exit(1)
2871
+ with open(input_path, "r", encoding="utf-8") as f:
2872
+ dsl = json.load(f)
2873
+ errors, warnings = validate_authored_dsl(dsl)
2874
+ for w in warnings:
2875
+ print(f"⚠️ {w}", file=sys.stderr)
2876
+ if errors:
2877
+ print("❌ DSL validation failed:", file=sys.stderr)
2878
+ for err in errors:
2879
+ print(f" - {err}", file=sys.stderr)
2880
+ sys.exit(1)
2881
+ else:
2882
+ print("✅ DSL validation passed")
2883
+ return
2551
2884
 
2552
- if args.list_fonts:
2553
- from font_catalog import print_font_catalog
2554
- sys.exit(print_font_catalog())
2555
2885
 
2556
- if args.validate:
2557
- if not args.input:
2558
- print("❌ validate mode requires --input", file=sys.stderr)
2559
- sys.exit(1)
2560
- if not os.path.exists(args.input):
2561
- print(f"❌ file not found: {args.input}", file=sys.stderr)
2562
- sys.exit(1)
2563
- with open(args.input, "r", encoding="utf-8") as f:
2564
- dsl = json.load(f)
2565
- errors, warnings = validate_authored_dsl(dsl)
2566
- for w in warnings:
2567
- print(f"⚠️ {w}", file=sys.stderr)
2568
- if errors:
2569
- print("❌ DSL validation failed:", file=sys.stderr)
2570
- for err in errors:
2571
- print(f" - {err}", file=sys.stderr)
2572
- sys.exit(1)
2573
- else:
2574
- print("✅ DSL validation passed")
2575
- return
2886
+ def _check_generation_args(args, parser: argparse.ArgumentParser) -> None:
2576
2887
 
2577
2888
  if not args.topic:
2578
2889
  print("❌ please provide --topic", file=sys.stderr)
@@ -2589,6 +2900,9 @@ Examples:
2589
2900
  sys.exit(1)
2590
2901
 
2591
2902
 
2903
+ def _resolve_template_selection(args) -> dict | None:
2904
+
2905
+
2592
2906
  try:
2593
2907
  template_selection = _template_selection(
2594
2908
  args.template_id, args.template_source, args.template_candidates,
@@ -2602,6 +2916,10 @@ Examples:
2602
2916
  "If the user did not name it, stop and let them confirm it before writing the script.",
2603
2917
  file=sys.stderr,
2604
2918
  )
2919
+ return template_selection
2920
+
2921
+
2922
+ def _resolve_ratio(args) -> str:
2605
2923
 
2606
2924
 
2607
2925
 
@@ -2627,14 +2945,10 @@ Examples:
2627
2945
  )
2628
2946
  else:
2629
2947
  resolved_ratio = "16:9"
2630
- args.ratio = resolved_ratio
2948
+ return resolved_ratio
2631
2949
 
2632
- print(f"📝 generating video script...", file=sys.stderr)
2633
- print(f" topic: {args.topic}", file=sys.stderr)
2634
- print(f" duration: {args.duration}s", file=sys.stderr)
2635
- print(f" ratio: {args.ratio}", file=sys.stderr)
2636
- if args.speed is not None:
2637
- print(f" narration speed: {args.speed}x", file=sys.stderr)
2950
+
2951
+ def _resolve_stub_urls(args) -> tuple[str, str]:
2638
2952
 
2639
2953
 
2640
2954
 
@@ -2666,6 +2980,10 @@ Examples:
2666
2980
  f"(image={stub_image_url}, video={stub_video_url}, audio=https://placeholder.local/stub.mp3)",
2667
2981
  file=sys.stderr,
2668
2982
  )
2983
+ return stub_image_url, stub_video_url
2984
+
2985
+
2986
+ def _parse_payload_writes(args, parser: argparse.ArgumentParser) -> list:
2669
2987
 
2670
2988
  try:
2671
2989
 
@@ -2676,6 +2994,66 @@ Examples:
2676
2994
  )
2677
2995
  except ValueError as exc:
2678
2996
  parser.error(str(exc))
2997
+ return payload_writes
2998
+
2999
+
3000
+ def _stub_generated_assets(dsl: dict, stub_video_url: str) -> None:
3001
+
3002
+
3003
+
3004
+ _AUDIO_STUB = "https://placeholder.local/stub.mp3"
3005
+ _VIDEO_STUB = stub_video_url
3006
+ for asset in dsl.get("assets", []):
3007
+ src = asset.get("source")
3008
+ if src in ("gen-voice",):
3009
+ asset["source"] = "existing"
3010
+ asset["status"] = "generated"
3011
+ asset["url"] = _AUDIO_STUB
3012
+
3013
+ elif src in ("gen-digital-human",):
3014
+ asset["source"] = "existing"
3015
+ asset["status"] = "generated"
3016
+ asset["url"] = _VIDEO_STUB
3017
+
3018
+
3019
+ def _check_output_language(dsl: dict) -> None:
3020
+
3021
+ expected_language = dsl.get("meta", {}).get("outputLanguage", DEFAULT_LANGUAGE)
3022
+ if expected_language in ("en", "zh"):
3023
+ narrations_for_check = [
3024
+ ((scene.get("audio") or {}).get("narration") or {}).get("text", "")
3025
+ for scene in dsl.get("scenes", [])
3026
+ ]
3027
+ _check_narration_language(narrations_for_check, expected_language)
3028
+
3029
+
3030
+ def main():
3031
+ parser = _build_arg_parser()
3032
+ args = parser.parse_args()
3033
+
3034
+
3035
+
3036
+ if args.list_fonts:
3037
+ from font_catalog import print_font_catalog
3038
+ sys.exit(print_font_catalog())
3039
+
3040
+ if args.validate:
3041
+ _run_validate(args.input)
3042
+ return
3043
+
3044
+ _check_generation_args(args, parser)
3045
+ template_selection = _resolve_template_selection(args)
3046
+ args.ratio = _resolve_ratio(args)
3047
+
3048
+ print(f"📝 generating video script...", file=sys.stderr)
3049
+ print(f" topic: {args.topic}", file=sys.stderr)
3050
+ print(f" duration: {args.duration}s", file=sys.stderr)
3051
+ print(f" ratio: {args.ratio}", file=sys.stderr)
3052
+ if args.speed is not None:
3053
+ print(f" narration speed: {args.speed}x", file=sys.stderr)
3054
+
3055
+ stub_image_url, stub_video_url = _resolve_stub_urls(args)
3056
+ payload_writes = _parse_payload_writes(args, parser)
2679
3057
 
2680
3058
  try:
2681
3059
  dsl = build_dsl(
@@ -2705,6 +3083,7 @@ Examples:
2705
3083
  sequence_preset=args.sequence_preset,
2706
3084
  sequence_fit=args.sequence_fit,
2707
3085
  payload_writes=payload_writes,
3086
+ countdown_ending=bool(args.ending_payload),
2708
3087
  )
2709
3088
  except ValueError as exc:
2710
3089
 
@@ -2723,29 +3102,16 @@ Examples:
2723
3102
  args.background_asset,
2724
3103
  )
2725
3104
  _apply_scene_payload(dsl, look_config, args.template_id, args.payload, args.motion)
3105
+ _apply_ending_payload(dsl, look_config, args.template_id, args.ending_payload)
3106
+ _apply_avatar(dsl, look_config, args.template_id, args.avatar_id)
2726
3107
  except ValueError as exc:
2727
3108
  parser.error(str(exc))
2728
3109
 
2729
3110
  if template_selection:
2730
3111
  dsl.setdefault("meta", {})["templateSelection"] = template_selection
2731
3112
 
2732
-
2733
-
2734
-
2735
3113
  if args.skip_asset_generation:
2736
- _AUDIO_STUB = "https://placeholder.local/stub.mp3"
2737
- _VIDEO_STUB = stub_video_url
2738
- for asset in dsl.get("assets", []):
2739
- src = asset.get("source")
2740
- if src in ("gen-voice",):
2741
- asset["source"] = "existing"
2742
- asset["status"] = "generated"
2743
- asset["url"] = _AUDIO_STUB
2744
-
2745
- elif src in ("gen-digital-human",):
2746
- asset["source"] = "existing"
2747
- asset["status"] = "generated"
2748
- asset["url"] = _VIDEO_STUB
3114
+ _stub_generated_assets(dsl, stub_video_url)
2749
3115
 
2750
3116
  errors = validate_dsl(dsl)
2751
3117
  if errors:
@@ -2754,14 +3120,7 @@ Examples:
2754
3120
  print(f" - {err}", file=sys.stderr)
2755
3121
  sys.exit(1)
2756
3122
 
2757
-
2758
- expected_language = dsl.get("meta", {}).get("outputLanguage", DEFAULT_LANGUAGE)
2759
- if expected_language in ("en", "zh"):
2760
- narrations_for_check = [
2761
- ((scene.get("audio") or {}).get("narration") or {}).get("text", "")
2762
- for scene in dsl.get("scenes", [])
2763
- ]
2764
- _check_narration_language(narrations_for_check, expected_language)
3123
+ _check_output_language(dsl)
2765
3124
 
2766
3125
  output_json = json.dumps(dsl, ensure_ascii=False, indent=2)
2767
3126
  print(output_json)