@remixmate/cli 0.12.9 → 0.13.1

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(
@@ -581,6 +582,10 @@ def _template_primary_visual_type(template_config: dict | None) -> str:
581
582
  return "none"
582
583
 
583
584
 
585
+
586
+ NARRATION_MODES = ("auto", "on", "off")
587
+
588
+
584
589
  def _template_needs_narration(template_config: dict | None) -> bool:
585
590
 
586
591
 
@@ -1045,6 +1050,29 @@ def _estimate_carousel_min_sec(carousel: dict, model: dict) -> float:
1045
1050
  return total - (n - 1) * transition_sec
1046
1051
 
1047
1052
 
1053
+
1054
+
1055
+
1056
+
1057
+
1058
+
1059
+
1060
+ _DURATION_KINDS = ("typewriter", "slideshow", "frame-sequence")
1061
+ _LEGACY_DURATION_KINDS = {"caption": "typewriter", "carousel": "slideshow", "sequence": "frame-sequence"}
1062
+
1063
+
1064
+ def _duration_components(duration_model: dict | None, kind: str) -> list[tuple[str, dict]]:
1065
+
1066
+ out = []
1067
+ for key, comp in (duration_model or {}).items():
1068
+ if not isinstance(comp, dict):
1069
+ continue
1070
+ comp_kind = comp.get("kind") or _LEGACY_DURATION_KINDS.get(key)
1071
+ if comp_kind == kind:
1072
+ out.append((key, comp))
1073
+ return out
1074
+
1075
+
1048
1076
  def _estimate_fit_caption_duration(
1049
1077
  custom_payload: dict, duration_model: dict | None, template_id: str | None
1050
1078
  ) -> float:
@@ -1060,35 +1088,39 @@ def _estimate_fit_caption_duration(
1060
1088
  f"⚠️ template '{template_id}' declares durationStrategy=fit-caption but no "
1061
1089
  "capabilities.durationModel, so gen_script cannot compute the fitted "
1062
1090
  "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}).",
1091
+ " Fix in the template (not here): add capabilities.durationModel with "
1092
+ "headlineIntroSec / tailPaddingSec plus one component per paced payload field, "
1093
+ "keyed by that field: {kind: typewriter, charsPerSec, lineGapMs, startDelayMs, "
1094
+ "tailHoldSec, maxLines} or {kind: slideshow, transitionMs, defaultHoldSec, "
1095
+ "minPerImageSec, minPerVideoSec}.",
1067
1096
  file=sys.stderr,
1068
1097
  )
1069
1098
  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)
1099
+ parts = [duration_model.get("headlineIntroSec", 0)]
1100
+ for key, comp in _duration_components(duration_model, "typewriter"):
1101
+ parts.append(_estimate_caption_natural_sec(custom_payload.get(key) or {}, comp))
1102
+ for key, comp in _duration_components(duration_model, "slideshow"):
1103
+ parts.append(_estimate_carousel_min_sec(custom_payload.get(key) or {}, comp))
1104
+ return max(parts) + duration_model.get("tailPaddingSec", 0)
1078
1105
 
1079
1106
 
1080
- def _estimate_fit_images_duration(sequence: dict, model: dict | None, fps: int = 30) -> float:
1107
+ def _estimate_fit_images_duration(custom_payload: dict, model: dict | None, fps: int = 30) -> float:
1081
1108
 
1082
1109
 
1083
1110
 
1084
1111
 
1085
1112
 
1086
- timing = (model or {}).get("sequence", {})
1113
+
1114
+ comps = _duration_components(model, "frame-sequence")
1115
+ if not comps:
1116
+ raise ValueError("fit-images requires a durationModel component of kind frame-sequence")
1117
+ key, timing = comps[0]
1118
+ sequence = custom_payload.get(key) or {}
1087
1119
  tiers = timing.get("holdSecByCount", [])
1088
- count = len(sequence["items"])
1120
+ count = len(sequence.get("items") or [])
1089
1121
  rule_hold = next((tier["holdSec"] for tier in tiers if count <= tier["maxItems"]), None)
1090
1122
  if rule_hold is None:
1091
- raise ValueError("fit-images requires durationModel.sequence.holdSecByCount covering the image count")
1123
+ raise ValueError(f"fit-images requires durationModel.{key}.holdSecByCount covering the item count")
1092
1124
 
1093
1125
 
1094
1126
  preset_timing = (timing.get("presets") or {}).get(sequence.get("preset")) or {}
@@ -1096,7 +1128,7 @@ def _estimate_fit_images_duration(sequence: dict, model: dict | None, fps: int =
1096
1128
  transition_ms = sequence.get("transitionMs", preset_timing.get("transitionMs", timing.get("transitionMs")))
1097
1129
  margin = timing.get("minHoldMarginFrames")
1098
1130
  if transition_ms is None or margin is None:
1099
- raise ValueError("fit-images requires sequence transitionMs and durationModel.sequence.minHoldMarginFrames")
1131
+ raise ValueError(f"fit-images requires {key}.transitionMs and durationModel.{key}.minHoldMarginFrames")
1100
1132
 
1101
1133
  min_frames = max(2, math.floor(transition_ms / 1000 * fps + 0.5)) + margin
1102
1134
 
@@ -1125,8 +1157,8 @@ def _probe_sequence_video_duration(url: str) -> float | None:
1125
1157
  return probe_video_duration(url)
1126
1158
 
1127
1159
 
1128
- def _sequence_choice(sequence_schema: dict, key: str, value: str | None, flag: str,
1129
- template_id: str, passthrough: str) -> str | None:
1160
+ def _media_option_choice(option_schema: dict, value: str | None, flag: str,
1161
+ template_id: str, passthrough: str) -> str | None:
1130
1162
 
1131
1163
 
1132
1164
 
@@ -1134,8 +1166,7 @@ def _sequence_choice(sequence_schema: dict, key: str, value: str | None, flag: s
1134
1166
 
1135
1167
  if not value or value == passthrough:
1136
1168
  return None
1137
- prop = (sequence_schema.get("properties") or {}).get(key) or {}
1138
- allowed = [str(v) for v in prop.get("enum") or []]
1169
+ allowed = [str(v) for v in (option_schema or {}).get("enum") or []]
1139
1170
  if value not in allowed:
1140
1171
  raise ValueError(
1141
1172
  f"{flag} {value!r} is not supported by template {template_id!r}; "
@@ -1145,6 +1176,71 @@ def _sequence_choice(sequence_schema: dict, key: str, value: str | None, flag: s
1145
1176
 
1146
1177
 
1147
1178
 
1179
+
1180
+
1181
+
1182
+
1183
+
1184
+
1185
+
1186
+
1187
+
1188
+
1189
+
1190
+
1191
+
1192
+
1193
+
1194
+
1195
+ _MEDIA_OPTION_FLAGS = {"preset": "--sequence-preset", "fit": "--sequence-fit"}
1196
+ _LEGACY_MEDIA_BINDINGS = {
1197
+ "sequence": {
1198
+ "itemsPath": "sequence.items",
1199
+ "videoItem": {"type": "video"},
1200
+ "videoDurationKey": "sourceDurationSec",
1201
+ "captions": {"perItemKey": "caption"},
1202
+ "requireItems": True,
1203
+ "options": {"preset": {"none": "none", "replacesDefaults": True}, "fit": {"none": "auto"}},
1204
+ },
1205
+ "carousel": {
1206
+ "itemsPath": "carousel.items",
1207
+ "item": {"entrance": "random", "exit": "random"},
1208
+ "videoItem": {"kind": "video", "muted": True},
1209
+ "captions": {"linesPath": "caption.lines"},
1210
+ },
1211
+ }
1212
+
1213
+
1214
+ def _resolve_media_binding(template_config: dict | None) -> dict:
1215
+ caps = (template_config or {}).get("capabilities")
1216
+ caps = caps if isinstance(caps, dict) else {}
1217
+ binding = caps.get("mediaBinding")
1218
+ if isinstance(binding, dict) and isinstance(binding.get("itemsPath"), str) and binding["itemsPath"]:
1219
+ return deepcopy(binding)
1220
+ defaults = caps.get("payloadDefaults") if isinstance(caps.get("payloadDefaults"), dict) else {}
1221
+ legacy = "sequence" if isinstance(defaults.get("sequence"), dict) else "carousel"
1222
+ return deepcopy(_LEGACY_MEDIA_BINDINGS[legacy])
1223
+
1224
+
1225
+ def _schema_at(template_config: dict | None, parts: list[str]) -> dict:
1226
+
1227
+ node = (template_config or {}).get("customPayloadSchema") or {}
1228
+ for i, part in enumerate(parts):
1229
+ if not isinstance(node, dict):
1230
+ return {}
1231
+ node = node.get(part) if i == 0 else (node.get("properties") or {}).get(part)
1232
+ return node if isinstance(node, dict) else {}
1233
+
1234
+
1235
+ def _get_path(obj: dict, parts: list[str]):
1236
+ for part in parts:
1237
+ if not isinstance(obj, dict):
1238
+ return None
1239
+ obj = obj.get(part)
1240
+ return obj
1241
+
1242
+
1243
+
1148
1244
  PayloadWrites = list[tuple[list[str], object]]
1149
1245
 
1150
1246
 
@@ -1169,7 +1265,8 @@ def _build_carousel_caption_dsl(
1169
1265
  payload_defaults: dict,
1170
1266
  duration_strategy: str | None,
1171
1267
  duration_model: dict | None,
1172
- sequence_schema: dict | None = None,
1268
+ media_binding: dict,
1269
+ template_config: dict | None = None,
1173
1270
  sequence_preset: str | None = None,
1174
1271
  sequence_fit: str | None = None,
1175
1272
  payload_writes: PayloadWrites | None = None,
@@ -1180,33 +1277,51 @@ def _build_carousel_caption_dsl(
1180
1277
 
1181
1278
 
1182
1279
 
1280
+
1183
1281
  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")
1282
+ binding = media_binding
1283
+ items_parts = binding["itemsPath"].split(".")
1284
+ container_parts = items_parts[:-1]
1285
+ captions = binding.get("captions") if isinstance(binding.get("captions"), dict) else {}
1286
+ per_item_caption = captions.get("perItemKey")
1287
+ lines_parts = captions["linesPath"].split(".") if isinstance(captions.get("linesPath"), str) else None
1288
+ options = binding.get("options") if isinstance(binding.get("options"), dict) else {}
1289
+
1290
+ if binding.get("requireItems") and not carousel_items:
1291
+ raise ValueError("This template is driven by media items; pass --carousel-items (caption text alone cannot supply them)")
1292
+ if per_item_caption and len(caption_lines) > len(carousel_items):
1293
+ raise ValueError("More --caption-lines than --carousel-items; this template takes at most one caption per item")
1294
+
1295
+
1296
+
1297
+ picked: dict[str, str] = {}
1298
+ for key, value in (("preset", sequence_preset), ("fit", sequence_fit)):
1299
+ flag = _MEDIA_OPTION_FLAGS[key]
1300
+ opt = options.get(key)
1301
+ none_value = opt.get("none") if isinstance(opt, dict) else ("none" if key == "preset" else "auto")
1302
+ if not isinstance(opt, dict) or not container_parts:
1303
+ if value not in (None, none_value, "none", "auto"):
1304
+ raise ValueError(f"{flag} is not an option of template {template_id!r} (no mediaBinding.options.{key})")
1305
+ continue
1306
+ choice = _media_option_choice(_schema_at(template_config, container_parts + [key]),
1307
+ value, flag, template_id, none_value)
1308
+ if choice:
1309
+ picked[key] = choice
1196
1310
 
1197
1311
 
1312
+ duration_key = binding.get("videoDurationKey")
1198
1313
  assets = []
1199
- carousel_asset_ids = []
1314
+ media_assets = []
1200
1315
  video_seconds: dict[str, float] = {}
1201
1316
  for idx, url in enumerate(carousel_items):
1202
1317
  asset_id = f"carousel-img-{idx + 1:02d}"
1203
1318
 
1204
1319
  is_video = urlsplit(url).path.lower().endswith((".mp4", ".mov", ".webm", ".avi"))
1205
- if sequence_mode and is_video:
1320
+ if duration_key and is_video:
1206
1321
  seconds = _probe_sequence_video_duration(url)
1207
1322
  if not seconds or seconds <= 0:
1208
1323
  raise ValueError(
1209
- f"Cannot read the duration of video {url!r}; an image sequence needs it to time the clip. "
1324
+ f"Cannot read the duration of video {url!r}; this template needs it to time the clip. "
1210
1325
  f"Check the URL is reachable, or leave this video out."
1211
1326
  )
1212
1327
  video_seconds[asset_id] = round(float(seconds), 3)
@@ -1217,7 +1332,7 @@ def _build_carousel_caption_dsl(
1217
1332
  "status": "generated",
1218
1333
  "url": url,
1219
1334
  })
1220
- carousel_asset_ids.append((asset_id, "video" if is_video else "image"))
1335
+ media_assets.append((asset_id, "video" if is_video else "image"))
1221
1336
 
1222
1337
 
1223
1338
  narration_asset_id = "narration-scene-01"
@@ -1233,84 +1348,50 @@ def _build_carousel_caption_dsl(
1233
1348
  })
1234
1349
 
1235
1350
 
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
1351
 
1280
1352
 
1281
1353
 
1282
- if sequence_mode:
1283
1354
 
1284
1355
 
1356
+ custom_payload = defaults
1357
+ if container_parts:
1358
+ container = custom_payload
1359
+ for part in container_parts:
1360
+ if not isinstance(container.get(part), dict):
1361
+ container[part] = {}
1362
+ container = container[part]
1363
+ if any(isinstance(o, dict) and o.get("replacesDefaults") and k in picked for k, o in options.items()):
1364
+ container.clear()
1365
+ container.update(picked)
1366
+ else:
1367
+ container = custom_payload
1368
+
1369
+ item_extra = binding.get("item") if isinstance(binding.get("item"), dict) else {}
1370
+ video_extra = binding.get("videoItem") if isinstance(binding.get("videoItem"), dict) else {}
1371
+ container[items_parts[-1]] = [
1372
+ {"assetRef": asset_id,
1373
+ **item_extra,
1374
+ **(video_extra if kind == "video" else {}),
1375
+ **({duration_key: video_seconds[asset_id]} if duration_key and kind == "video" else {}),
1376
+ **({per_item_caption: caption_lines[index]}
1377
+ if per_item_caption and index < len(caption_lines) and caption_lines[index] else {})}
1378
+ for index, (asset_id, kind) in enumerate(media_assets)
1379
+ ]
1380
+ if lines_parts:
1285
1381
 
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}
1382
+ _write_payload(custom_payload, [(lines_parts, list(caption_lines))])
1296
1383
 
1297
1384
 
1298
1385
 
1299
1386
  _write_payload(custom_payload, payload_writes or [])
1300
1387
 
1301
1388
 
1302
- narration_text = _localize_label(output_language, "narration_opening",
1303
- label="", topic=_clip(topic, _PLACEHOLDER_TOPIC_CHARS), duration=duration)
1304
-
1305
-
1306
1389
 
1307
1390
 
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:
@@ -1322,6 +1403,33 @@ def _build_carousel_caption_dsl(
1322
1403
  )
1323
1404
 
1324
1405
 
1406
+
1407
+
1408
+ narration_text = _localize_label(
1409
+ output_language, "narration_opening", label="", topic=_clip(topic, _PLACEHOLDER_TOPIC_CHARS),
1410
+ duration=effective_duration,
1411
+ budget=_budget_suffix(effective_duration, output_language, narration_speed, None),
1412
+ )
1413
+ narration_locks: list[str] = []
1414
+ if narration_enabled and duration_strategy == "fit-images" and _schema_at(template_config, ["minDurationSec"]):
1415
+
1416
+
1417
+
1418
+ custom_payload["minDurationSec"] = effective_duration
1419
+ narration_locks.append("customPayload.minDurationSec")
1420
+
1421
+
1422
+
1423
+
1424
+ pacing_path = container_parts + ["pacing"]
1425
+ pacing_schema = _schema_at(template_config, pacing_path)
1426
+ explicit_pacing = any(parts == pacing_path for parts, _ in (payload_writes or []))
1427
+ if (container_parts and not video_seconds and "fit-scene" in (pacing_schema.get("enum") or [])
1428
+ and not explicit_pacing):
1429
+ container["pacing"] = "fit-scene"
1430
+ narration_locks.append("customPayload." + ".".join(container_parts + ["pacing"]))
1431
+
1432
+
1325
1433
  text_layers = [
1326
1434
  {"role": "headline", "content": headline, "animation": "slide-up"},
1327
1435
  ]
@@ -1378,6 +1486,8 @@ def _build_carousel_caption_dsl(
1378
1486
  "scenes": [scene],
1379
1487
  "transitions": {"default": "fade", "duration": 0.5},
1380
1488
  }
1489
+ for path in narration_locks:
1490
+ _lock_scene_path(dsl, path)
1381
1491
 
1382
1492
  return dsl
1383
1493
 
@@ -1785,7 +1895,7 @@ def _parse_payload_specs(template_config: dict, payload_specs: list[str] | None)
1785
1895
  key, sep, raw = spec.partition("=")
1786
1896
  parts = [p.strip() for p in key.split(".")]
1787
1897
  if not sep or not all(parts):
1788
- raise ValueError(f"--payload {spec!r}: expected KEY=VALUE, e.g. grade=四年级 or carousel.fit=cover")
1898
+ raise ValueError(f"--payload {spec!r}: expected KEY=VALUE, e.g. <field>=<value> or <object>.<field>=<value>")
1789
1899
  raw = raw.strip()
1790
1900
  if not raw:
1791
1901
  raise ValueError(f"--payload {spec!r}: empty value; leave the flag out to keep the template default")
@@ -1901,130 +2011,64 @@ def _apply_avatar(
1901
2011
  _lock_scene_path(dsl, "visuals.avatar.assetRef")
1902
2012
 
1903
2013
 
1904
- def build_dsl(
1905
- topic: str,
1906
- duration: int,
1907
- style: str,
1908
- ratio: str,
1909
- resolution: str,
1910
- voice_id: str,
1911
- font_id: str | None,
1912
- font_name: str | None,
1913
- scene_count: int | None,
1914
- allow_digital_human: bool,
1915
- allow_ai_video: bool,
1916
- speed: float | None = None,
1917
- template_id: str | None = None,
1918
- stub_image_url: str = "",
1919
- stub_video_url: str = "",
1920
- headline: str | None = None,
1921
- subheadline: str | None = None,
1922
- carousel_items: list[str] | None = None,
1923
- caption_lines: list[str] | None = None,
1924
- scene_images: list[str] | None = None,
1925
- item_count: int | None = None,
1926
- source_document_url: str | None = None,
1927
- variant: str | None = None,
1928
- sequence_preset: str | None = None,
1929
- sequence_fit: str | None = None,
1930
- payload_writes: PayloadWrites | None = None,
1931
- countdown_ending: bool = False,
1932
- ) -> dict:
1933
-
1934
-
1935
-
1936
-
1937
-
1938
-
1939
-
1940
-
1941
-
1942
-
1943
-
1944
-
1945
-
1946
-
2014
+ @dataclass(frozen=True)
2015
+ class _SkeletonContext:
1947
2016
 
1948
2017
 
1949
2018
 
1950
2019
 
1951
2020
 
2021
+ template_config: dict | None
2022
+ template_id: str | None
2023
+ contract: dict
2024
+ topic: str
2025
+ style: str
2026
+ ratio: str
2027
+ output_language: str
2028
+ primary_visual_type: str
2029
+ has_visual: bool
2030
+ needs_narration: bool
2031
+ headline: str
2032
+ subheadline: str
2033
+ resolved_voice_id: str
2034
+ narration_speed: float
2035
+ tail_pad_sec: float | None
2036
+ image_style_guide: dict
2037
+ image_compose: bool
2038
+ image_ratio: str
2039
+ stub_image_url: str
2040
+ stub_video_url: str
2041
+ shared_background: bool
2042
+ countdown: dict | None
2043
+ beat_count: int | None
1952
2044
 
1953
2045
 
2046
+ def _require_carousel_content(
2047
+ template_id: str | None,
2048
+ contract: dict,
2049
+ carousel_items: list[str] | None,
2050
+ caption_lines: list[str] | None,
2051
+ ) -> None:
1954
2052
 
1955
2053
 
1956
2054
 
1957
2055
 
1958
2056
 
1959
-
1960
-
1961
- template_config = _load_template_config(template_id) if template_id else None
1962
- contract = _resolve_contract(template_config)
1963
- countdown = contract["countdown"]
1964
- if countdown is None and item_count is not None:
1965
- raise ValueError(
1966
- "--item-count requires a template declaring capabilities.sceneStrategy=countdown"
1967
- )
1968
- beat_count = resolve_countdown_count(countdown, item_count, scene_count) if countdown else None
1969
-
1970
- countdown_ending = bool(countdown_ending and countdown)
1971
- if countdown_ending and not _template_has_ending_slot(template_config):
1972
- raise ValueError(f"--ending-payload: template {template_id!r} has no ending slot")
1973
-
1974
- primary_visual_type = contract["primary_visual_type"]
1975
- payload_style = contract["payload_style"]
1976
-
1977
- has_visual = payload_style == "visual-overlay"
1978
-
1979
-
1980
- needs_narration = contract["needs_narration"]
1981
-
1982
-
1983
-
1984
-
1985
- output_language, template_default_voice_id = _resolve_template_language_meta(template_config)
1986
- resolved_voice_id = _resolve_voice_id(voice_id, template_default_voice_id, output_language)
1987
- narration_speed = _resolve_narration_speed(speed)
1988
- if resolved_voice_id != voice_id:
1989
- print(
1990
- f"ℹ️ voice resolution: cli={voice_id!r} → resolved={resolved_voice_id!r} "
1991
- f"(templateDefault={template_default_voice_id!r}, language={output_language})",
1992
- file=sys.stderr,
1993
- )
1994
-
1995
-
1996
- effective_headline = _normalize_linebreaks(headline) or topic
1997
- effective_subheadline = _normalize_linebreaks(subheadline)
1998
- _echo_template_llm_hint(template_config, template_id)
1999
- _warn_on_url_text_layer(effective_headline, effective_subheadline)
2000
- if beat_count is not None:
2001
- _warn_on_count_mismatch(countdown, effective_headline, effective_subheadline, beat_count)
2002
-
2003
-
2004
- if payload_style == "carousel-caption":
2057
+ if not (carousel_items or []) and not (caption_lines or []):
2005
2058
  print(
2006
- f"ℹ️ contract: payloadStyle=carousel-caption for template {template_id}",
2059
+ "❌ carousel-caption template "
2060
+ f"'{template_id}' needs visual or text content, but received neither "
2061
+ "--carousel-items nor --caption-lines.\n"
2062
+ " This template renders a carousel of user-supplied media; with no items "
2063
+ "it produces a black, minimum-length (2s) clip.\n"
2064
+ " Fix: pass the user's image/video URLs via --carousel-items "
2065
+ "(repeat the flag per item), e.g.\n"
2066
+ " gen_script.py --topic <topic> --template-id "
2067
+ f"{template_id} --carousel-items <url1> --carousel-items <url2> ...\n"
2068
+ " Optionally add --caption-lines '<text>' for on-screen typewriter captions.",
2007
2069
  file=sys.stderr,
2008
2070
  )
2009
-
2010
-
2011
-
2012
-
2013
- if not (carousel_items or []) and not (caption_lines or []):
2014
- print(
2015
- "❌ carousel-caption template "
2016
- f"'{template_id}' needs visual or text content, but received neither "
2017
- "--carousel-items nor --caption-lines.\n"
2018
- " This template renders a carousel of user-supplied media; with no items "
2019
- "it produces a black, minimum-length (2s) clip.\n"
2020
- " Fix: pass the user's image/video URLs via --carousel-items "
2021
- "(repeat the flag per item), e.g.\n"
2022
- " gen_script.py --topic <topic> --template-id "
2023
- f"{template_id} --carousel-items <url1> --carousel-items <url2> ...\n"
2024
- " Optionally add --caption-lines '<text>' for on-screen typewriter captions.",
2025
- file=sys.stderr,
2026
- )
2027
- sys.exit(1)
2071
+ sys.exit(1)
2028
2072
 
2029
2073
 
2030
2074
 
@@ -2032,65 +2076,43 @@ def build_dsl(
2032
2076
 
2033
2077
 
2034
2078
 
2035
- if contract["duration_strategy"] == "fit-caption" and not (caption_lines or []):
2036
- print(
2037
- "❌ template "
2038
- f"'{template_id}' is typewriter-driven (capabilities.durationStrategy="
2039
- "fit-caption), but --caption-lines is empty.\n"
2040
- " For this template the bottom typewriter copy IS the content: it carries "
2041
- "the message and it decides the video length. With no lines the render "
2042
- "collapses to the carousel floor (~5s for 2 images) and shows no text at all.\n"
2043
- " Fix: pass one --caption-lines '<text>' per line. If the user did not "
2044
- "supply the copy, WRITE IT YOURSELF from the material you researched "
2045
- "(repo README, page screenshots, the user's topic) and pass it — do not leave "
2046
- "it empty, and do not describe lines you never passed.\n"
2047
- " gen_script.py --topic <topic> --template-id "
2048
- f"{template_id} --carousel-items <url> "
2049
- "--caption-lines '<line 1>' --caption-lines '<line 2>' ...",
2050
- file=sys.stderr,
2051
- )
2052
- sys.exit(1)
2053
- carousel_dsl = _build_carousel_caption_dsl(
2054
- template_id=template_id,
2055
- topic=topic,
2056
- headline=effective_headline,
2057
- subheadline=effective_subheadline,
2058
- carousel_items=carousel_items or [],
2059
- caption_lines=caption_lines or [],
2060
- duration=duration,
2061
- style=style,
2062
- ratio=ratio,
2063
- resolution=resolution,
2064
- output_language=output_language,
2065
- resolved_voice_id=resolved_voice_id,
2066
- narration_speed=narration_speed,
2067
- font_id=font_id,
2068
- font_name=font_name,
2069
- narration_enabled=needs_narration,
2070
- payload_defaults=contract["payload_defaults"],
2071
- duration_strategy=contract["duration_strategy"],
2072
- duration_model=contract["duration_model"],
2073
- sequence_schema=((template_config or {}).get("customPayloadSchema") or {}).get("sequence"),
2074
- sequence_preset=sequence_preset,
2075
- sequence_fit=sequence_fit,
2076
- payload_writes=payload_writes,
2079
+ if contract["duration_strategy"] == "fit-caption" and not (caption_lines or []):
2080
+ print(
2081
+ "❌ template "
2082
+ f"'{template_id}' is typewriter-driven (capabilities.durationStrategy="
2083
+ "fit-caption), but --caption-lines is empty.\n"
2084
+ " For this template the bottom typewriter copy IS the content: it carries "
2085
+ "the message and it decides the video length. With no lines the render "
2086
+ "collapses to the carousel floor (~5s for 2 images) and shows no text at all.\n"
2087
+ " Fix: pass one --caption-lines '<text>' per line. If the user did not "
2088
+ "supply the copy, WRITE IT YOURSELF from the material you researched "
2089
+ "(repo README, page screenshots, the user's topic) and pass it — do not leave "
2090
+ "it empty, and do not describe lines you never passed.\n"
2091
+ " gen_script.py --topic <topic> --template-id "
2092
+ f"{template_id} --carousel-items <url> "
2093
+ "--caption-lines '<line 1>' --caption-lines '<line 2>' ...",
2094
+ file=sys.stderr,
2077
2095
  )
2078
- _enforce_supported_duration(carousel_dsl, template_config, template_id)
2079
- return carousel_dsl
2080
-
2081
- if sequence_preset not in (None, "none") or sequence_fit not in (None, "auto"):
2082
- raise ValueError(f"--sequence-preset / --sequence-fit only apply to image-sequence templates; {template_id!r} is not one")
2083
-
2084
-
2085
-
2086
-
2096
+ sys.exit(1)
2087
2097
 
2088
2098
 
2089
- provided_slots = [s.strip() for s in (scene_images or []) if s and s.strip()]
2099
+ def _plan_skeleton_scenes(
2100
+ contract: dict,
2101
+ countdown: dict | None,
2102
+ beat_count: int | None,
2103
+ topic: str,
2104
+ duration: int,
2105
+ scene_count: int | None,
2106
+ output_language: str,
2107
+ headline: str,
2108
+ countdown_ending: bool,
2109
+ provided_slots: list[str],
2110
+ has_visual: bool,
2111
+ ) -> list[dict]:
2090
2112
 
2091
2113
  if beat_count is not None:
2092
2114
  scene_plans = _plan_countdown_scenes(
2093
- countdown, beat_count, duration, effective_headline, ending=countdown_ending
2115
+ countdown, beat_count, duration, headline, ending=countdown_ending
2094
2116
  )
2095
2117
  else:
2096
2118
  scene_plans = _plan_contract_scenes(contract, topic, duration, scene_count, output_language)
@@ -2107,7 +2129,7 @@ def build_dsl(
2107
2129
  provided_slots
2108
2130
  and scene_count is None
2109
2131
  and contract["scene_strategy"] == "arc"
2110
- and primary_visual_type == "image"
2132
+ and contract["primary_visual_type"] == "image"
2111
2133
  and has_visual
2112
2134
  and len(provided_slots) > len(scene_plans)
2113
2135
  ):
@@ -2119,236 +2141,212 @@ def build_dsl(
2119
2141
 
2120
2142
  if provided_slots and len(provided_slots) > len(scene_plans):
2121
2143
  print(
2122
- f"\u26a0\ufe0f {len(provided_slots)} scene-image entries provided but the template "
2144
+ f"⚠️ {len(provided_slots)} scene-image entries provided but the template "
2123
2145
  f"plans only {len(scene_plans)} scene(s); the extra "
2124
2146
  f"{len(provided_slots) - len(scene_plans)} will not be used. "
2125
2147
  f"Raise --scenes or pick a template with more scenes.",
2126
2148
  file=sys.stderr,
2127
2149
  )
2128
-
2129
- assets = []
2130
- scenes = []
2150
+ return scene_plans
2131
2151
 
2132
2152
 
2153
+ def _visual_asset_id(ctx: _SkeletonContext, scene_id: str) -> str:
2133
2154
 
2155
+ if ctx.primary_visual_type == "video":
2156
+ return f"video-{scene_id}"
2157
+ if ctx.shared_background:
2158
+ return "img-background"
2159
+ return f"img-{scene_id}"
2134
2160
 
2135
2161
 
2162
+ def _plan_visual_asset(
2163
+ ctx: _SkeletonContext, plan: dict, asset_id: str, slot: str, narration_text: str,
2164
+ ) -> dict | None:
2136
2165
 
2166
+ if ctx.primary_visual_type == "image":
2167
+ slot_is_ai, slot_prompt = _parse_ai_slot(slot)
2168
+ if slot and not slot_is_ai:
2137
2169
 
2138
- image_style_guide = (
2139
- resolve_style_guide(template_config, variant or "") if _HAS_ENHANCER else {}
2140
- )
2141
- image_compose = _HAS_ENHANCER and is_compose_mode(image_style_guide)
2142
- image_ratio = image_style_guide.get("ratio") or ratio
2143
2170
 
2144
- tail_pad_sec = _nb.template_tail_pad_sec(template_config) if _HAS_BUDGET else None
2171
+ return {
2172
+ "assetId": asset_id,
2173
+ "type": "image",
2174
+ "source": "existing",
2175
+ "status": "generated",
2176
+ "url": slot,
2177
+ }
2145
2178
 
2146
2179
 
2147
2180
 
2181
+ if ctx.stub_image_url:
2182
+ return {
2183
+ "assetId": asset_id,
2184
+ "type": "image",
2185
+ "source": "existing",
2186
+ "status": "generated",
2187
+ "url": ctx.stub_image_url,
2188
+ }
2189
+ if ctx.image_compose:
2190
+ image_result = {"prompt": COMPOSE_SUBJECT_PLACEHOLDER}
2191
+ elif ctx.shared_background:
2148
2192
 
2149
- shared_background = (
2150
- contract["background_strategy"] == "shared"
2151
- and primary_visual_type == "image"
2152
- and not provided_slots
2153
- )
2154
- shared_background_planned = False
2155
- for idx, plan in enumerate(scene_plans):
2156
- scene_id = f"scene-{idx + 1:02d}"
2157
2193
 
2158
- if primary_visual_type == "video":
2159
- visual_asset_id = f"video-{scene_id}"
2160
- elif shared_background:
2161
- visual_asset_id = "img-background"
2194
+ image_result = generate_image_prompt(
2195
+ "point", ctx.topic, ctx.style, narration_text="", style_guide=ctx.image_style_guide,
2196
+ )
2162
2197
  else:
2163
- visual_asset_id = f"img-{scene_id}"
2164
- narration_asset_id = f"narration-{scene_id}" if needs_narration else None
2165
- narration_text = (
2166
- estimate_narration_text(
2167
- plan["label"], plan["purpose"], plan["duration"], topic, language=output_language,
2168
- speed=narration_speed, tail_pad_sec=tail_pad_sec,
2198
+ image_result = generate_image_prompt(
2199
+ plan["purpose"], ctx.topic, ctx.style,
2200
+ narration_text=narration_text, style_guide=ctx.image_style_guide,
2169
2201
  )
2170
- if needs_narration else ""
2171
- )
2172
-
2173
2202
 
2174
- scene_has_visual = has_visual and _slot_reads_visual(template_config, plan["purpose"])
2175
2203
 
2176
2204
 
2177
- if scene_has_visual and shared_background and shared_background_planned:
2178
- pass
2179
- elif scene_has_visual and primary_visual_type == "image":
2180
- if shared_background:
2181
- shared_background_planned = True
2182
- slot = provided_slots[idx] if idx < len(provided_slots) else ""
2183
- slot_is_ai, slot_prompt = _parse_ai_slot(slot)
2184
- if slot and not slot_is_ai:
2185
2205
 
2186
2206
 
2187
- assets.append({
2188
- "assetId": visual_asset_id,
2189
- "type": "image",
2190
- "source": "existing",
2191
- "status": "generated",
2192
- "url": slot,
2193
- })
2207
+ if slot_prompt:
2208
+ image_result = {**image_result, "prompt": slot_prompt}
2194
2209
 
2210
+ img_payload = {
2211
+ "prompt": image_result["prompt"],
2212
+ "model": os.environ.get("DEFAULT_IMAGE_MODEL", "doubao/doubao-seedream-5-0-260128"),
2213
+ "ratio": ctx.image_ratio,
2214
+ }
2215
+ if "negativePrompt" in image_result:
2216
+ img_payload["negativePrompt"] = image_result["negativePrompt"]
2217
+ if "guidanceScale" in image_result:
2218
+ img_payload["guidanceScale"] = image_result["guidanceScale"]
2219
+ return {
2220
+ "assetId": asset_id,
2221
+ "type": "image",
2222
+ "source": "gen-image",
2223
+ "status": "planned",
2224
+ "payload": img_payload,
2225
+ }
2195
2226
 
2227
+ if ctx.primary_visual_type == "video":
2228
+ if ctx.stub_video_url:
2229
+ return {
2230
+ "assetId": asset_id,
2231
+ "type": "video",
2232
+ "source": "existing",
2233
+ "status": "generated",
2234
+ "url": ctx.stub_video_url,
2235
+ }
2196
2236
 
2197
- elif stub_image_url:
2198
- assets.append({
2199
- "assetId": visual_asset_id,
2200
- "type": "image",
2201
- "source": "existing",
2202
- "status": "generated",
2203
- "url": stub_image_url,
2204
- })
2205
- else:
2206
- if image_compose:
2207
- image_result = {"prompt": COMPOSE_SUBJECT_PLACEHOLDER}
2208
- elif shared_background:
2209
2237
 
2238
+ image_result = generate_image_prompt(plan["purpose"], ctx.topic, ctx.style, narration_text=narration_text)
2239
+ return {
2240
+ "assetId": asset_id,
2241
+ "type": "video",
2242
+ "source": "gen-video",
2243
+ "status": "planned",
2244
+ "payload": {
2245
+ "prompt": image_result["prompt"],
2210
2246
 
2211
- image_result = generate_image_prompt(
2212
- "point", topic, style, narration_text="", style_guide=image_style_guide,
2213
- )
2214
- else:
2215
- image_result = generate_image_prompt(
2216
- plan["purpose"], topic, style,
2217
- narration_text=narration_text, style_guide=image_style_guide,
2218
- )
2219
2247
 
2248
+ "model": os.environ.get("DEFAULT_VIDEO_MODEL", "seedance"),
2249
+ "ratio": ctx.ratio,
2220
2250
 
2251
+ "duration": max(4, min(int(plan["duration"]), 15)),
2252
+ },
2253
+ }
2254
+ return None
2221
2255
 
2222
2256
 
2257
+ def _visual_text_layers(ctx: _SkeletonContext, plan: dict) -> list[dict]:
2223
2258
 
2224
- if slot_prompt:
2225
- image_result = {**image_result, "prompt": slot_prompt}
2226
-
2227
- img_payload = {
2228
- "prompt": image_result["prompt"],
2229
- "model": os.environ.get("DEFAULT_IMAGE_MODEL", "doubao/doubao-seedream-5-0-260128"),
2230
- "ratio": image_ratio,
2231
- }
2232
- if "negativePrompt" in image_result:
2233
- img_payload["negativePrompt"] = image_result["negativePrompt"]
2234
- if "guidanceScale" in image_result:
2235
- img_payload["guidanceScale"] = image_result["guidanceScale"]
2236
-
2237
- assets.append({
2238
- "assetId": visual_asset_id,
2239
- "type": "image",
2240
- "source": "gen-image",
2241
- "status": "planned",
2242
- "payload": img_payload,
2243
- })
2244
- elif scene_has_visual and primary_visual_type == "video":
2245
- if stub_video_url:
2246
- assets.append({
2247
- "assetId": visual_asset_id,
2248
- "type": "video",
2249
- "source": "existing",
2250
- "status": "generated",
2251
- "url": stub_video_url,
2252
- })
2253
- else:
2259
+ if ctx.contract["text_layer_rules"]:
2260
+ return _placeholder_text_layers(ctx.contract["text_layer_rules"])
2261
+ text_layers = []
2262
+ if plan["purpose"] == "opening":
2263
+ text_layers = [
2264
+ {"role": "headline", "content": ctx.headline, "animation": "slide-up"},
2265
+ ]
2266
+ if ctx.subheadline:
2267
+ text_layers.append({"role": "subheadline", "content": ctx.subheadline, "animation": "fade-in"})
2268
+ elif plan["purpose"] == "point":
2269
+ text_layers = [
2270
+ {"role": "badge", "content": plan["label"], "animation": "slide-up"},
2271
+ ]
2254
2272
 
2255
2273
 
2256
- image_result = generate_image_prompt(plan["purpose"], topic, style, narration_text=narration_text)
2257
- video_prompt = image_result["prompt"]
2258
2274
 
2259
- video_duration = max(4, min(int(plan["duration"]), 15))
2260
- vid_payload = {
2261
- "prompt": video_prompt,
2275
+ text_layers.insert(0, {"role": "headline", "content": ctx.headline, "animation": "fade-in"})
2276
+ if ctx.subheadline:
2277
+ text_layers.append({"role": "subheadline", "content": ctx.subheadline, "animation": "fade-in"})
2278
+ elif plan["purpose"] == "cta":
2279
+ text_layers = [
2280
+ {"role": "headline", "content": _localize_label(ctx.output_language, "follow_us"), "animation": "fade-in"},
2281
+ ]
2282
+ if ctx.subheadline:
2283
+ text_layers.append({"role": "subheadline", "content": ctx.subheadline, "animation": "fade-in"})
2284
+ return text_layers
2262
2285
 
2263
2286
 
2264
- "model": os.environ.get("DEFAULT_VIDEO_MODEL", "seedance"),
2265
- "ratio": ratio,
2266
- "duration": video_duration,
2267
- }
2268
- assets.append({
2269
- "assetId": visual_asset_id,
2270
- "type": "video",
2271
- "source": "gen-video",
2272
- "status": "planned",
2273
- "payload": vid_payload,
2274
- })
2287
+ def _payload_text_layers(ctx: _SkeletonContext, plan: dict) -> list[dict]:
2275
2288
 
2276
2289
 
2277
2290
 
2278
2291
 
2279
2292
 
2280
- if needs_narration:
2281
- assets.append({
2282
- "assetId": narration_asset_id,
2283
- "type": "audio",
2284
- "source": "gen-voice",
2285
- "status": "planned",
2286
- "payload": {
2287
- "voiceId": resolved_voice_id,
2288
- },
2289
- })
2290
2293
 
2291
- if scene_has_visual:
2292
- layout = "text-overlay" if plan["purpose"] in ("opening", "cta") else "full-visual"
2293
- text_layers = []
2294
- if plan["purpose"] == "opening":
2295
- text_layers = [
2296
- {"role": "headline", "content": effective_headline, "animation": "slide-up"},
2297
- ]
2298
- if effective_subheadline:
2299
- text_layers.append({"role": "subheadline", "content": effective_subheadline, "animation": "fade-in"})
2300
- elif plan["purpose"] == "point":
2301
- text_layers = [
2302
- {"role": "badge", "content": plan["label"], "animation": "slide-up"},
2303
- ]
2304
-
2305
-
2306
-
2307
- text_layers.insert(0, {"role": "headline", "content": effective_headline, "animation": "fade-in"})
2308
- if effective_subheadline:
2309
- text_layers.append({"role": "subheadline", "content": effective_subheadline, "animation": "fade-in"})
2310
- elif plan["purpose"] == "cta":
2311
- text_layers = [
2312
- {"role": "headline", "content": _localize_label(output_language, "follow_us"), "animation": "fade-in"},
2313
- ]
2314
- if effective_subheadline:
2315
- text_layers.append({"role": "subheadline", "content": effective_subheadline, "animation": "fade-in"})
2316
- if contract["text_layer_rules"]:
2317
- text_layers = _placeholder_text_layers(contract["text_layer_rules"])
2318
-
2319
- scene = {
2320
- "id": scene_id,
2321
- "purpose": plan["purpose"],
2322
- "duration": plan["duration"],
2323
- "layout": layout,
2324
- "visuals": {"background": {"assetRef": visual_asset_id}},
2325
- "textLayers": text_layers,
2326
- "animationHints": {
2327
- "entrance": "fade",
2328
- "motion": "kenburns-in" if idx % 2 == 0 else "kenburns-out",
2329
- },
2330
- }
2331
- if needs_narration:
2332
- scene["audio"] = {
2333
- "narration": {
2334
- "text": narration_text,
2335
- "assetRef": narration_asset_id,
2336
-
2337
- "needsFill": True,
2338
- },
2339
- }
2340
- scenes.append(scene)
2341
- else:
2342
2294
 
2343
2295
 
2296
+ if ctx.contract["text_layer_rules"]:
2297
+ return _placeholder_text_layers(ctx.contract["text_layer_rules"])
2298
+ text_layers = []
2299
+ if plan["purpose"] == "opening":
2300
+ text_layers = [
2301
+ {"role": "headline", "content": ctx.headline, "animation": "fade-in"},
2302
+ ]
2303
+ if ctx.subheadline:
2304
+ text_layers.append({"role": "subheadline", "content": ctx.subheadline, "animation": "fade-in"})
2305
+ elif plan["purpose"] == "point":
2306
+ text_layers = [
2307
+ {"role": "headline", "content": plan["label"], "animation": "slide-up"},
2308
+ ]
2309
+ elif plan["purpose"] == "cta":
2310
+ text_layers = [
2311
+ {"role": "headline", "content": _localize_label(ctx.output_language, "follow_us"), "animation": "zoom-in"},
2312
+ ]
2313
+ if ctx.subheadline:
2314
+ text_layers.append({"role": "subheadline", "content": ctx.subheadline, "animation": "fade-in"})
2315
+ return text_layers
2344
2316
 
2345
2317
 
2318
+ def _attach_narration(scene: dict, narration_text: str, narration_asset_id: str | None) -> dict:
2346
2319
 
2320
+ if narration_asset_id:
2321
+ scene["audio"] = {
2322
+ "narration": {
2323
+ "text": narration_text,
2324
+ "assetRef": narration_asset_id,
2347
2325
 
2326
+ "needsFill": True,
2327
+ },
2328
+ }
2329
+ return scene
2348
2330
 
2349
2331
 
2332
+ def _visual_scene(
2333
+ ctx: _SkeletonContext, idx: int, scene_id: str, plan: dict, visual_asset_id: str,
2334
+ ) -> dict:
2335
+ return {
2336
+ "id": scene_id,
2337
+ "purpose": plan["purpose"],
2338
+ "duration": plan["duration"],
2339
+ "layout": "text-overlay" if plan["purpose"] in ("opening", "cta") else "full-visual",
2340
+ "visuals": {"background": {"assetRef": visual_asset_id}},
2341
+ "textLayers": _visual_text_layers(ctx, plan),
2342
+ "animationHints": {
2343
+ "entrance": "fade",
2344
+ "motion": "kenburns-in" if idx % 2 == 0 else "kenburns-out",
2345
+ },
2346
+ }
2350
2347
 
2351
2348
 
2349
+ def _payload_scene(ctx: _SkeletonContext, scene_id: str, plan: dict) -> dict:
2352
2350
 
2353
2351
 
2354
2352
 
@@ -2356,97 +2354,336 @@ def build_dsl(
2356
2354
 
2357
2355
 
2358
2356
 
2359
- _layout_cfg = (template_config or {}).get("capabilities") or {}
2360
- layout = _layout_cfg.get("defaultLayout") or template_id or "text-overlay"
2361
2357
 
2362
2358
 
2363
2359
 
2364
2360
 
2365
2361
 
2366
2362
 
2367
- text_layers = []
2368
- if plan["purpose"] == "opening":
2369
- text_layers = [
2370
- {"role": "headline", "content": effective_headline, "animation": "fade-in"},
2371
- ]
2372
- if effective_subheadline:
2373
- text_layers.append({"role": "subheadline", "content": effective_subheadline, "animation": "fade-in"})
2374
- elif plan["purpose"] == "point":
2375
- text_layers = [
2376
- {"role": "headline", "content": plan["label"], "animation": "slide-up"},
2377
- ]
2378
- elif plan["purpose"] == "cta":
2379
- text_layers = [
2380
- {"role": "headline", "content": _localize_label(output_language, "follow_us"), "animation": "zoom-in"},
2381
- ]
2382
- if effective_subheadline:
2383
- text_layers.append({"role": "subheadline", "content": effective_subheadline, "animation": "fade-in"})
2384
2363
 
2385
- if contract["text_layer_rules"]:
2386
- text_layers = _placeholder_text_layers(contract["text_layer_rules"])
2387
2364
 
2388
- custom_payload = _build_custom_payload(template_config)
2389
- if beat_count is not None:
2390
2365
 
2391
2366
 
2392
- custom_payload.update(
2393
- countdown_scene_payload(countdown, beat_count, plan.get("countdownIndex"))
2394
- )
2367
+ layout_cfg = (ctx.template_config or {}).get("capabilities") or {}
2368
+ scene = {
2369
+ "id": scene_id,
2370
+ "purpose": plan["purpose"],
2371
+ "duration": plan["duration"],
2372
+ "layout": layout_cfg.get("defaultLayout") or ctx.template_id or "text-overlay",
2373
+ "textLayers": _payload_text_layers(ctx, plan),
2374
+ }
2375
+ custom_payload = _build_custom_payload(ctx.template_config)
2376
+ if ctx.beat_count is not None:
2395
2377
 
2396
- scene = {
2397
- "id": scene_id,
2398
- "purpose": plan["purpose"],
2399
- "duration": plan["duration"],
2400
- "layout": layout,
2401
- "textLayers": text_layers,
2402
- }
2403
2378
 
2379
+ custom_payload.update(
2380
+ countdown_scene_payload(ctx.countdown, ctx.beat_count, plan.get("countdownIndex"))
2381
+ )
2404
2382
 
2405
- if custom_payload:
2406
- scene["customPayload"] = custom_payload
2407
- if needs_narration:
2408
- scene["audio"] = {
2409
- "narration": {
2410
- "text": narration_text,
2411
- "assetRef": narration_asset_id,
2412
2383
 
2413
- "needsFill": True,
2414
- },
2415
- }
2416
- scenes.append(scene)
2384
+ if custom_payload:
2385
+ scene["customPayload"] = custom_payload
2386
+ return scene
2417
2387
 
2418
- dsl = {
2388
+
2389
+ def _skeleton_document(
2390
+ ctx: _SkeletonContext,
2391
+ assets: list[dict],
2392
+ scenes: list[dict],
2393
+ *,
2394
+ headline_given: bool,
2395
+ duration: int,
2396
+ resolution: str,
2397
+ font_id: str | None,
2398
+ font_name: str | None,
2399
+ ) -> dict:
2400
+
2401
+ return {
2419
2402
  "version": "v1alpha1",
2420
2403
  "meta": {
2421
- "title": _video_title(effective_headline if headline else None, topic),
2422
- "topic": topic,
2423
- "headline": effective_headline,
2424
- "subheadline": effective_subheadline,
2404
+ "title": _video_title(ctx.headline if headline_given else None, ctx.topic),
2405
+ "topic": ctx.topic,
2406
+ "headline": ctx.headline,
2407
+ "subheadline": ctx.subheadline,
2425
2408
  "targetDuration": duration,
2426
- "language": LANGUAGE_TO_META[output_language],
2427
- "outputLanguage": output_language,
2428
- "style": style or "",
2429
- "description": _localize_label(output_language, "video_description", topic=topic, duration=duration),
2409
+ "language": LANGUAGE_TO_META[ctx.output_language],
2410
+ "outputLanguage": ctx.output_language,
2411
+ "style": ctx.style or "",
2412
+ "description": _localize_label(ctx.output_language, "video_description", topic=ctx.topic, duration=duration),
2430
2413
 
2431
- **({"characterSheet": ""} if image_compose and has_visual else {}),
2414
+ **({"characterSheet": ""} if ctx.image_compose and ctx.has_visual else {}),
2432
2415
  },
2433
2416
  "global": {
2434
- "aspectRatio": ratio,
2417
+ "aspectRatio": ctx.ratio,
2435
2418
  "resolution": resolution,
2436
2419
  "fps": 30,
2437
2420
 
2438
2421
 
2439
- "subtitle": {"enabled": needs_narration, "style": "bottom"},
2440
- **({"narration": {"voiceId": resolved_voice_id, "speed": narration_speed}} if needs_narration else {}),
2422
+ "subtitle": {"enabled": ctx.needs_narration, "style": "bottom"},
2423
+ **({"narration": {"voiceId": ctx.resolved_voice_id, "speed": ctx.narration_speed}} if ctx.needs_narration else {}),
2441
2424
  **({"font": {"fontId": font_id, **({"fontName": font_name} if font_name else {})}} if font_id else {}),
2442
2425
  "bgm": {"enabled": True, "volume": 0.12},
2443
2426
  },
2444
2427
  "assets": assets,
2445
2428
  "scenes": scenes,
2446
2429
 
2447
- "transitions": {"default": contract["default_transition"] or "fade", "duration": 0.5},
2430
+ "transitions": {"default": ctx.contract["default_transition"] or "fade", "duration": 0.5},
2448
2431
  }
2449
2432
 
2433
+
2434
+ def build_dsl(
2435
+ topic: str,
2436
+ duration: int,
2437
+ style: str,
2438
+ ratio: str,
2439
+ resolution: str,
2440
+ voice_id: str,
2441
+ font_id: str | None,
2442
+ font_name: str | None,
2443
+ scene_count: int | None,
2444
+ allow_digital_human: bool,
2445
+ allow_ai_video: bool,
2446
+ speed: float | None = None,
2447
+ template_id: str | None = None,
2448
+ stub_image_url: str = "",
2449
+ stub_video_url: str = "",
2450
+ headline: str | None = None,
2451
+ subheadline: str | None = None,
2452
+ carousel_items: list[str] | None = None,
2453
+ caption_lines: list[str] | None = None,
2454
+ scene_images: list[str] | None = None,
2455
+ item_count: int | None = None,
2456
+ source_document_url: str | None = None,
2457
+ variant: str | None = None,
2458
+ sequence_preset: str | None = None,
2459
+ sequence_fit: str | None = None,
2460
+ payload_writes: PayloadWrites | None = None,
2461
+ countdown_ending: bool = False,
2462
+ narration: str = "auto",
2463
+ ) -> dict:
2464
+
2465
+
2466
+
2467
+
2468
+
2469
+
2470
+
2471
+
2472
+
2473
+
2474
+
2475
+
2476
+
2477
+
2478
+
2479
+
2480
+
2481
+
2482
+
2483
+
2484
+
2485
+
2486
+
2487
+
2488
+
2489
+
2490
+
2491
+
2492
+ template_config = _load_template_config(template_id) if template_id else None
2493
+ contract = _resolve_contract(template_config)
2494
+
2495
+
2496
+ if narration not in NARRATION_MODES:
2497
+ raise ValueError(f"--narration must be one of {', '.join(NARRATION_MODES)}, got {narration!r}")
2498
+ if narration != "auto":
2499
+ contract["needs_narration"] = narration == "on"
2500
+ countdown = contract["countdown"]
2501
+ if countdown is None and item_count is not None:
2502
+ raise ValueError(
2503
+ "--item-count requires a template declaring capabilities.sceneStrategy=countdown"
2504
+ )
2505
+ beat_count = resolve_countdown_count(countdown, item_count, scene_count) if countdown else None
2506
+
2507
+ countdown_ending = bool(countdown_ending and countdown)
2508
+ if countdown_ending and not _template_has_ending_slot(template_config):
2509
+ raise ValueError(f"--ending-payload: template {template_id!r} has no ending slot")
2510
+
2511
+ primary_visual_type = contract["primary_visual_type"]
2512
+ payload_style = contract["payload_style"]
2513
+
2514
+ has_visual = payload_style == "visual-overlay"
2515
+
2516
+
2517
+ needs_narration = contract["needs_narration"]
2518
+
2519
+
2520
+
2521
+
2522
+ output_language, template_default_voice_id = _resolve_template_language_meta(template_config)
2523
+ resolved_voice_id = _resolve_voice_id(voice_id, template_default_voice_id, output_language)
2524
+ narration_speed = _resolve_narration_speed(speed)
2525
+ if resolved_voice_id != voice_id:
2526
+ print(
2527
+ f"ℹ️ voice resolution: cli={voice_id!r} → resolved={resolved_voice_id!r} "
2528
+ f"(templateDefault={template_default_voice_id!r}, language={output_language})",
2529
+ file=sys.stderr,
2530
+ )
2531
+
2532
+
2533
+ effective_headline = _normalize_linebreaks(headline) or topic
2534
+ effective_subheadline = _normalize_linebreaks(subheadline)
2535
+ _echo_template_llm_hint(template_config, template_id)
2536
+ _warn_on_url_text_layer(effective_headline, effective_subheadline)
2537
+ if beat_count is not None:
2538
+ _warn_on_count_mismatch(countdown, effective_headline, effective_subheadline, beat_count)
2539
+
2540
+
2541
+ if payload_style == "carousel-caption":
2542
+ print(
2543
+ f"ℹ️ contract: payloadStyle=carousel-caption for template {template_id}",
2544
+ file=sys.stderr,
2545
+ )
2546
+ _require_carousel_content(template_id, contract, carousel_items, caption_lines)
2547
+ carousel_dsl = _build_carousel_caption_dsl(
2548
+ template_id=template_id,
2549
+ topic=topic,
2550
+ headline=effective_headline,
2551
+ subheadline=effective_subheadline,
2552
+ carousel_items=carousel_items or [],
2553
+ caption_lines=caption_lines or [],
2554
+ duration=duration,
2555
+ style=style,
2556
+ ratio=ratio,
2557
+ resolution=resolution,
2558
+ output_language=output_language,
2559
+ resolved_voice_id=resolved_voice_id,
2560
+ narration_speed=narration_speed,
2561
+ font_id=font_id,
2562
+ font_name=font_name,
2563
+ narration_enabled=needs_narration,
2564
+ payload_defaults=contract["payload_defaults"],
2565
+ duration_strategy=contract["duration_strategy"],
2566
+ duration_model=contract["duration_model"],
2567
+ media_binding=_resolve_media_binding(template_config),
2568
+ template_config=template_config,
2569
+ sequence_preset=sequence_preset,
2570
+ sequence_fit=sequence_fit,
2571
+ payload_writes=payload_writes,
2572
+ )
2573
+ _enforce_supported_duration(carousel_dsl, template_config, template_id)
2574
+ return carousel_dsl
2575
+
2576
+ if sequence_preset not in (None, "none") or sequence_fit not in (None, "auto"):
2577
+ raise ValueError(f"--sequence-preset / --sequence-fit only apply to templates declaring mediaBinding.options; {template_id!r} does not")
2578
+
2579
+
2580
+
2581
+
2582
+
2583
+
2584
+ provided_slots = [s.strip() for s in (scene_images or []) if s and s.strip()]
2585
+ scene_plans = _plan_skeleton_scenes(
2586
+ contract, countdown, beat_count, topic, duration, scene_count, output_language,
2587
+ effective_headline, countdown_ending, provided_slots, has_visual,
2588
+ )
2589
+
2590
+
2591
+
2592
+
2593
+
2594
+
2595
+
2596
+ image_style_guide = (
2597
+ resolve_style_guide(template_config, variant or "") if _HAS_ENHANCER else {}
2598
+ )
2599
+ ctx = _SkeletonContext(
2600
+ template_config=template_config,
2601
+ template_id=template_id,
2602
+ contract=contract,
2603
+ topic=topic,
2604
+ style=style,
2605
+ ratio=ratio,
2606
+ output_language=output_language,
2607
+ primary_visual_type=primary_visual_type,
2608
+ has_visual=has_visual,
2609
+ needs_narration=needs_narration,
2610
+ headline=effective_headline,
2611
+ subheadline=effective_subheadline,
2612
+ resolved_voice_id=resolved_voice_id,
2613
+ narration_speed=narration_speed,
2614
+ tail_pad_sec=_nb.template_tail_pad_sec(template_config) if _HAS_BUDGET else None,
2615
+ image_style_guide=image_style_guide,
2616
+ image_compose=_HAS_ENHANCER and is_compose_mode(image_style_guide),
2617
+ image_ratio=image_style_guide.get("ratio") or ratio,
2618
+ stub_image_url=stub_image_url,
2619
+ stub_video_url=stub_video_url,
2620
+
2621
+
2622
+
2623
+
2624
+ shared_background=(
2625
+ contract["background_strategy"] == "shared"
2626
+ and primary_visual_type == "image"
2627
+ and not provided_slots
2628
+ ),
2629
+ countdown=countdown,
2630
+ beat_count=beat_count,
2631
+ )
2632
+
2633
+ assets = []
2634
+ scenes = []
2635
+ shared_background_planned = False
2636
+ for idx, plan in enumerate(scene_plans):
2637
+ scene_id = f"scene-{idx + 1:02d}"
2638
+ visual_asset_id = _visual_asset_id(ctx, scene_id)
2639
+ narration_asset_id = f"narration-{scene_id}" if needs_narration else None
2640
+ narration_text = (
2641
+ estimate_narration_text(
2642
+ plan["label"], plan["purpose"], plan["duration"], topic, language=output_language,
2643
+ speed=narration_speed, tail_pad_sec=ctx.tail_pad_sec,
2644
+ )
2645
+ if needs_narration else ""
2646
+ )
2647
+
2648
+
2649
+ scene_has_visual = has_visual and _slot_reads_visual(template_config, plan["purpose"])
2650
+
2651
+
2652
+
2653
+ if scene_has_visual and not (ctx.shared_background and shared_background_planned):
2654
+ shared_background_planned = ctx.shared_background
2655
+ slot = provided_slots[idx] if idx < len(provided_slots) else ""
2656
+ visual_asset = _plan_visual_asset(ctx, plan, visual_asset_id, slot, narration_text)
2657
+ if visual_asset:
2658
+ assets.append(visual_asset)
2659
+
2660
+
2661
+
2662
+
2663
+
2664
+ if needs_narration:
2665
+ assets.append({
2666
+ "assetId": narration_asset_id,
2667
+ "type": "audio",
2668
+ "source": "gen-voice",
2669
+ "status": "planned",
2670
+ "payload": {
2671
+ "voiceId": resolved_voice_id,
2672
+ },
2673
+ })
2674
+
2675
+ scene = (
2676
+ _visual_scene(ctx, idx, scene_id, plan, visual_asset_id)
2677
+ if scene_has_visual else _payload_scene(ctx, scene_id, plan)
2678
+ )
2679
+ scenes.append(_attach_narration(scene, narration_text, narration_asset_id))
2680
+
2681
+ dsl = _skeleton_document(
2682
+ ctx, assets, scenes,
2683
+ headline_given=bool(headline), duration=duration, resolution=resolution,
2684
+ font_id=font_id, font_name=font_name,
2685
+ )
2686
+
2450
2687
  if template_id:
2451
2688
  dsl["meta"]["templateId"] = template_id
2452
2689
 
@@ -2473,7 +2710,7 @@ def build_dsl(
2473
2710
  return dsl
2474
2711
 
2475
2712
 
2476
- def main():
2713
+ def _build_arg_parser() -> argparse.ArgumentParser:
2477
2714
  parser = argparse.ArgumentParser(
2478
2715
  description="Video script generator — produces Video DSL v1alpha1 JSON",
2479
2716
  formatter_class=argparse.RawDescriptionHelpFormatter,
@@ -2541,20 +2778,27 @@ Examples:
2541
2778
  "--background-preset", default=None,
2542
2779
  help="Scene background preset (one of the template's customPayloadSchema.background.preset enum). "
2543
2780
  "Written to every scene's customPayload.background.preset. When the user picked a background in "
2544
- "the replicate card (e.g. `纯色(solid)`), pass that id. Requires --template-id.",
2781
+ "the replicate card (`<名称>(<id>)`), pass that id. Requires --template-id.",
2545
2782
  )
2546
2783
  parser.add_argument(
2547
2784
  "--sequence-preset", default=None,
2548
- help="Image-sequence style preset (one of the template's customPayloadSchema.sequence.preset enum, "
2549
- "e.g. vlog / news / poster / cinematic / fast-cut), written to customPayload.sequence.preset; "
2550
- "`none` keeps the template defaults. When the user picked a style preset in the replicate card "
2551
- "(e.g. `快剪(fast-cut)`), pass that id. Only for templates declaring payloadDefaults.sequence.",
2785
+ help="Style preset for templates declaring capabilities.mediaBinding.options.preset (one of that "
2786
+ "option's enum in customPayloadSchema), written next to the template's media list; `none` keeps "
2787
+ "the template defaults. When the user picked a style preset in the replicate card "
2788
+ "(`<名称>(<id>)`), pass that id.",
2789
+ )
2790
+ parser.add_argument(
2791
+ "--narration", default="auto", choices=NARRATION_MODES,
2792
+ help="Narration on / off, overriding the template's capabilities.needsNarration (auto = the template "
2793
+ "decides). Templates where narration is optional default to none; pass `on` when the user picked "
2794
+ "narration in the replicate card (`旁白: <名称>(narrated)`), so the skeleton gets narration slots, "
2795
+ "a gen-voice asset and subtitles. `off` drops narration from a narrated template.",
2552
2796
  )
2553
2797
  parser.add_argument(
2554
2798
  "--sequence-fit", default=None,
2555
- help="How image-sequence media fill the frame (customPayloadSchema.sequence.fit enum: cover = fill and crop, "
2556
- "contain = show whole with a blurred backdrop), written to customPayload.sequence.fit; `auto` keeps "
2557
- "the template / preset default. When the user picked it in the replicate card, pass that id.",
2799
+ help="Media fill mode for templates declaring capabilities.mediaBinding.options.fit (one of that "
2800
+ "option's enum in customPayloadSchema); `auto` keeps the template / preset default. When the user "
2801
+ "picked it in the replicate card, pass that id.",
2558
2802
  )
2559
2803
  parser.add_argument(
2560
2804
  "--background-asset", default=None,
@@ -2565,8 +2809,8 @@ Examples:
2565
2809
  )
2566
2810
  parser.add_argument(
2567
2811
  "--payload", action="append", default=None, metavar="KEY=VALUE",
2568
- help="Set a template customPayload field on every scene, e.g. --payload grade=四年级 "
2569
- "--payload carousel.fit=cover --payload templateData.revealMode=progressive. KEY is a dotted path "
2812
+ help="Set a template customPayload field on every scene, e.g. --payload <field>=<value> "
2813
+ "--payload <object>.<field>=<value>. KEY is a dotted path "
2570
2814
  "checked against the template's customPayloadSchema; VALUE is converted to its declared type "
2571
2815
  "(true/false, numbers) and checked against its enum / range. Repeatable. Requires --template-id.",
2572
2816
  )
@@ -2577,7 +2821,7 @@ Examples:
2577
2821
  )
2578
2822
  parser.add_argument("--allow-digital-human", action="store_true", help="Allow digital-human assets")
2579
2823
  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>=...")
2580
- parser.add_argument("--ending-payload", action="append", default=None, metavar="KEY=VALUE", help="Like --payload, but written only to the ending scene (e.g. recap.cta=关注我,每周更新开源热榜). On countdown templates it also appends the ending scene after #1. Repeatable.")
2824
+ 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.")
2581
2825
  parser.add_argument("--allow-ai-video", action="store_true", help="Allow AI-generated video assets")
2582
2826
  parser.add_argument("--template-id", default=None, help="Template id. The template owns outputLanguage and may declare a defaultVoiceId.")
2583
2827
  parser.add_argument(
@@ -2624,12 +2868,11 @@ Examples:
2624
2868
  "--carousel-items",
2625
2869
  action="append",
2626
2870
  default=None,
2627
- help="Media URL for the template's carousel/gallery. Can be repeated: "
2871
+ help="Media URL for the template's image/video list. Can be repeated: "
2628
2872
  "--carousel-items url1 --carousel-items url2. "
2629
2873
  "For templates declaring capabilities.payloadStyle=carousel-caption, "
2630
- "these URLs are placed directly into "
2631
- "customPayload.sequence.items when payloadDefaults.sequence is declared, otherwise "
2632
- "customPayload.carousel.items, as existing assets without AI image generation.",
2874
+ "these URLs are placed directly into the list named by capabilities.mediaBinding.itemsPath, "
2875
+ "as existing assets without AI image generation.",
2633
2876
  )
2634
2877
  parser.add_argument(
2635
2878
  "--scene-images",
@@ -2652,40 +2895,38 @@ Examples:
2652
2895
  help="On-screen caption line. Can be repeated: "
2653
2896
  "--caption-lines 'line1' --caption-lines 'line2'. "
2654
2897
  "For templates declaring capabilities.payloadStyle=carousel-caption, "
2655
- "these are placed into customPayload.caption.lines, or sequence.items[].caption "
2656
- "in image order when payloadDefaults.sequence is declared. "
2898
+ "these go where capabilities.mediaBinding.captions says (one text block, or one caption "
2899
+ "per media item in order). "
2657
2900
  "Line count limits and whether **emphasis** is parsed are declared per "
2658
2901
  "template — see the chosen template's llmHint.",
2659
2902
  )
2903
+ return parser
2660
2904
 
2661
- args = parser.parse_args()
2662
2905
 
2906
+ def _run_validate(input_path: str | None) -> None:
2663
2907
 
2908
+ if not input_path:
2909
+ print("❌ validate mode requires --input", file=sys.stderr)
2910
+ sys.exit(1)
2911
+ if not os.path.exists(input_path):
2912
+ print(f"❌ file not found: {input_path}", file=sys.stderr)
2913
+ sys.exit(1)
2914
+ with open(input_path, "r", encoding="utf-8") as f:
2915
+ dsl = json.load(f)
2916
+ errors, warnings = validate_authored_dsl(dsl)
2917
+ for w in warnings:
2918
+ print(f"⚠️ {w}", file=sys.stderr)
2919
+ if errors:
2920
+ print("❌ DSL validation failed:", file=sys.stderr)
2921
+ for err in errors:
2922
+ print(f" - {err}", file=sys.stderr)
2923
+ sys.exit(1)
2924
+ else:
2925
+ print("✅ DSL validation passed")
2926
+ return
2664
2927
 
2665
- if args.list_fonts:
2666
- from font_catalog import print_font_catalog
2667
- sys.exit(print_font_catalog())
2668
2928
 
2669
- if args.validate:
2670
- if not args.input:
2671
- print("❌ validate mode requires --input", file=sys.stderr)
2672
- sys.exit(1)
2673
- if not os.path.exists(args.input):
2674
- print(f"❌ file not found: {args.input}", file=sys.stderr)
2675
- sys.exit(1)
2676
- with open(args.input, "r", encoding="utf-8") as f:
2677
- dsl = json.load(f)
2678
- errors, warnings = validate_authored_dsl(dsl)
2679
- for w in warnings:
2680
- print(f"⚠️ {w}", file=sys.stderr)
2681
- if errors:
2682
- print("❌ DSL validation failed:", file=sys.stderr)
2683
- for err in errors:
2684
- print(f" - {err}", file=sys.stderr)
2685
- sys.exit(1)
2686
- else:
2687
- print("✅ DSL validation passed")
2688
- return
2929
+ def _check_generation_args(args, parser: argparse.ArgumentParser) -> None:
2689
2930
 
2690
2931
  if not args.topic:
2691
2932
  print("❌ please provide --topic", file=sys.stderr)
@@ -2702,6 +2943,9 @@ Examples:
2702
2943
  sys.exit(1)
2703
2944
 
2704
2945
 
2946
+ def _resolve_template_selection(args) -> dict | None:
2947
+
2948
+
2705
2949
  try:
2706
2950
  template_selection = _template_selection(
2707
2951
  args.template_id, args.template_source, args.template_candidates,
@@ -2715,6 +2959,10 @@ Examples:
2715
2959
  "If the user did not name it, stop and let them confirm it before writing the script.",
2716
2960
  file=sys.stderr,
2717
2961
  )
2962
+ return template_selection
2963
+
2964
+
2965
+ def _resolve_ratio(args) -> str:
2718
2966
 
2719
2967
 
2720
2968
 
@@ -2740,14 +2988,10 @@ Examples:
2740
2988
  )
2741
2989
  else:
2742
2990
  resolved_ratio = "16:9"
2743
- args.ratio = resolved_ratio
2991
+ return resolved_ratio
2744
2992
 
2745
- print(f"📝 generating video script...", file=sys.stderr)
2746
- print(f" topic: {args.topic}", file=sys.stderr)
2747
- print(f" duration: {args.duration}s", file=sys.stderr)
2748
- print(f" ratio: {args.ratio}", file=sys.stderr)
2749
- if args.speed is not None:
2750
- print(f" narration speed: {args.speed}x", file=sys.stderr)
2993
+
2994
+ def _resolve_stub_urls(args) -> tuple[str, str]:
2751
2995
 
2752
2996
 
2753
2997
 
@@ -2779,6 +3023,10 @@ Examples:
2779
3023
  f"(image={stub_image_url}, video={stub_video_url}, audio=https://placeholder.local/stub.mp3)",
2780
3024
  file=sys.stderr,
2781
3025
  )
3026
+ return stub_image_url, stub_video_url
3027
+
3028
+
3029
+ def _parse_payload_writes(args, parser: argparse.ArgumentParser) -> list:
2782
3030
 
2783
3031
  try:
2784
3032
 
@@ -2789,6 +3037,66 @@ Examples:
2789
3037
  )
2790
3038
  except ValueError as exc:
2791
3039
  parser.error(str(exc))
3040
+ return payload_writes
3041
+
3042
+
3043
+ def _stub_generated_assets(dsl: dict, stub_video_url: str) -> None:
3044
+
3045
+
3046
+
3047
+ _AUDIO_STUB = "https://placeholder.local/stub.mp3"
3048
+ _VIDEO_STUB = stub_video_url
3049
+ for asset in dsl.get("assets", []):
3050
+ src = asset.get("source")
3051
+ if src in ("gen-voice",):
3052
+ asset["source"] = "existing"
3053
+ asset["status"] = "generated"
3054
+ asset["url"] = _AUDIO_STUB
3055
+
3056
+ elif src in ("gen-digital-human",):
3057
+ asset["source"] = "existing"
3058
+ asset["status"] = "generated"
3059
+ asset["url"] = _VIDEO_STUB
3060
+
3061
+
3062
+ def _check_output_language(dsl: dict) -> None:
3063
+
3064
+ expected_language = dsl.get("meta", {}).get("outputLanguage", DEFAULT_LANGUAGE)
3065
+ if expected_language in ("en", "zh"):
3066
+ narrations_for_check = [
3067
+ ((scene.get("audio") or {}).get("narration") or {}).get("text", "")
3068
+ for scene in dsl.get("scenes", [])
3069
+ ]
3070
+ _check_narration_language(narrations_for_check, expected_language)
3071
+
3072
+
3073
+ def main():
3074
+ parser = _build_arg_parser()
3075
+ args = parser.parse_args()
3076
+
3077
+
3078
+
3079
+ if args.list_fonts:
3080
+ from font_catalog import print_font_catalog
3081
+ sys.exit(print_font_catalog())
3082
+
3083
+ if args.validate:
3084
+ _run_validate(args.input)
3085
+ return
3086
+
3087
+ _check_generation_args(args, parser)
3088
+ template_selection = _resolve_template_selection(args)
3089
+ args.ratio = _resolve_ratio(args)
3090
+
3091
+ print(f"📝 generating video script...", file=sys.stderr)
3092
+ print(f" topic: {args.topic}", file=sys.stderr)
3093
+ print(f" duration: {args.duration}s", file=sys.stderr)
3094
+ print(f" ratio: {args.ratio}", file=sys.stderr)
3095
+ if args.speed is not None:
3096
+ print(f" narration speed: {args.speed}x", file=sys.stderr)
3097
+
3098
+ stub_image_url, stub_video_url = _resolve_stub_urls(args)
3099
+ payload_writes = _parse_payload_writes(args, parser)
2792
3100
 
2793
3101
  try:
2794
3102
  dsl = build_dsl(
@@ -2819,6 +3127,7 @@ Examples:
2819
3127
  sequence_fit=args.sequence_fit,
2820
3128
  payload_writes=payload_writes,
2821
3129
  countdown_ending=bool(args.ending_payload),
3130
+ narration=args.narration,
2822
3131
  )
2823
3132
  except ValueError as exc:
2824
3133
 
@@ -2845,23 +3154,8 @@ Examples:
2845
3154
  if template_selection:
2846
3155
  dsl.setdefault("meta", {})["templateSelection"] = template_selection
2847
3156
 
2848
-
2849
-
2850
-
2851
3157
  if args.skip_asset_generation:
2852
- _AUDIO_STUB = "https://placeholder.local/stub.mp3"
2853
- _VIDEO_STUB = stub_video_url
2854
- for asset in dsl.get("assets", []):
2855
- src = asset.get("source")
2856
- if src in ("gen-voice",):
2857
- asset["source"] = "existing"
2858
- asset["status"] = "generated"
2859
- asset["url"] = _AUDIO_STUB
2860
-
2861
- elif src in ("gen-digital-human",):
2862
- asset["source"] = "existing"
2863
- asset["status"] = "generated"
2864
- asset["url"] = _VIDEO_STUB
3158
+ _stub_generated_assets(dsl, stub_video_url)
2865
3159
 
2866
3160
  errors = validate_dsl(dsl)
2867
3161
  if errors:
@@ -2870,14 +3164,7 @@ Examples:
2870
3164
  print(f" - {err}", file=sys.stderr)
2871
3165
  sys.exit(1)
2872
3166
 
2873
-
2874
- expected_language = dsl.get("meta", {}).get("outputLanguage", DEFAULT_LANGUAGE)
2875
- if expected_language in ("en", "zh"):
2876
- narrations_for_check = [
2877
- ((scene.get("audio") or {}).get("narration") or {}).get("text", "")
2878
- for scene in dsl.get("scenes", [])
2879
- ]
2880
- _check_narration_language(narrations_for_check, expected_language)
3167
+ _check_output_language(dsl)
2881
3168
 
2882
3169
  output_json = json.dumps(dsl, ensure_ascii=False, indent=2)
2883
3170
  print(output_json)