@remixmate/cli 0.12.9 → 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
1347
 
1279
1348
 
1280
1349
 
1281
1350
 
1282
- if sequence_mode:
1283
-
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:
@@ -1785,7 +1866,7 @@ def _parse_payload_specs(template_config: dict, payload_specs: list[str] | None)
1785
1866
  key, sep, raw = spec.partition("=")
1786
1867
  parts = [p.strip() for p in key.split(".")]
1787
1868
  if not sep or not all(parts):
1788
- 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>")
1789
1870
  raw = raw.strip()
1790
1871
  if not raw:
1791
1872
  raise ValueError(f"--payload {spec!r}: empty value; leave the flag out to keep the template default")
@@ -1901,6 +1982,426 @@ def _apply_avatar(
1901
1982
  _lock_scene_path(dsl, "visuals.avatar.assetRef")
1902
1983
 
1903
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:
2023
+
2024
+
2025
+
2026
+
2027
+
2028
+ if not (carousel_items or []) and not (caption_lines or []):
2029
+ print(
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.",
2040
+ file=sys.stderr,
2041
+ )
2042
+ sys.exit(1)
2043
+
2044
+
2045
+
2046
+
2047
+
2048
+
2049
+
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,
2066
+ )
2067
+ sys.exit(1)
2068
+
2069
+
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]:
2083
+
2084
+ if beat_count is not None:
2085
+ scene_plans = _plan_countdown_scenes(
2086
+ countdown, beat_count, duration, headline, ending=countdown_ending
2087
+ )
2088
+ else:
2089
+ scene_plans = _plan_contract_scenes(contract, topic, duration, scene_count, output_language)
2090
+
2091
+
2092
+
2093
+
2094
+
2095
+
2096
+
2097
+
2098
+
2099
+ if (
2100
+ provided_slots
2101
+ and scene_count is None
2102
+ and contract["scene_strategy"] == "arc"
2103
+ and contract["primary_visual_type"] == "image"
2104
+ and has_visual
2105
+ and len(provided_slots) > len(scene_plans)
2106
+ ):
2107
+ scene_plans = _plan_contract_scenes(
2108
+ contract, topic, duration, len(provided_slots), output_language
2109
+ )
2110
+
2111
+
2112
+
2113
+ if provided_slots and len(provided_slots) > len(scene_plans):
2114
+ print(
2115
+ f"⚠️ {len(provided_slots)} scene-image entries provided but the template "
2116
+ f"plans only {len(scene_plans)} scene(s); the extra "
2117
+ f"{len(provided_slots) - len(scene_plans)} will not be used. "
2118
+ f"Raise --scenes or pick a template with more scenes.",
2119
+ file=sys.stderr,
2120
+ )
2121
+ return scene_plans
2122
+
2123
+
2124
+ def _visual_asset_id(ctx: _SkeletonContext, scene_id: str) -> str:
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}"
2131
+
2132
+
2133
+ def _plan_visual_asset(
2134
+ ctx: _SkeletonContext, plan: dict, asset_id: str, slot: str, narration_text: str,
2135
+ ) -> dict | None:
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:
2140
+
2141
+
2142
+ return {
2143
+ "assetId": asset_id,
2144
+ "type": "image",
2145
+ "source": "existing",
2146
+ "status": "generated",
2147
+ "url": slot,
2148
+ }
2149
+
2150
+
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:
2163
+
2164
+
2165
+ image_result = generate_image_prompt(
2166
+ "point", ctx.topic, ctx.style, narration_text="", style_guide=ctx.image_style_guide,
2167
+ )
2168
+ else:
2169
+ image_result = generate_image_prompt(
2170
+ plan["purpose"], ctx.topic, ctx.style,
2171
+ narration_text=narration_text, style_guide=ctx.image_style_guide,
2172
+ )
2173
+
2174
+
2175
+
2176
+
2177
+
2178
+ if slot_prompt:
2179
+ image_result = {**image_result, "prompt": slot_prompt}
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
+ }
2197
+
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
+ }
2207
+
2208
+
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"],
2217
+
2218
+
2219
+ "model": os.environ.get("DEFAULT_VIDEO_MODEL", "seedance"),
2220
+ "ratio": ctx.ratio,
2221
+
2222
+ "duration": max(4, min(int(plan["duration"]), 15)),
2223
+ },
2224
+ }
2225
+ return None
2226
+
2227
+
2228
+ def _visual_text_layers(ctx: _SkeletonContext, plan: dict) -> list[dict]:
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
+ ]
2243
+
2244
+
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
2256
+
2257
+
2258
+ def _payload_text_layers(ctx: _SkeletonContext, plan: dict) -> list[dict]:
2259
+
2260
+
2261
+
2262
+
2263
+
2264
+
2265
+
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
2287
+
2288
+
2289
+ def _attach_narration(scene: dict, narration_text: str, narration_asset_id: str | None) -> dict:
2290
+
2291
+ if narration_asset_id:
2292
+ scene["audio"] = {
2293
+ "narration": {
2294
+ "text": narration_text,
2295
+ "assetRef": narration_asset_id,
2296
+
2297
+ "needsFill": True,
2298
+ },
2299
+ }
2300
+ return scene
2301
+
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
+ }
2318
+
2319
+
2320
+ def _payload_scene(ctx: _SkeletonContext, scene_id: str, plan: dict) -> dict:
2321
+
2322
+
2323
+
2324
+
2325
+
2326
+
2327
+
2328
+
2329
+
2330
+
2331
+
2332
+
2333
+
2334
+
2335
+
2336
+
2337
+
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:
2348
+
2349
+
2350
+ custom_payload.update(
2351
+ countdown_scene_payload(ctx.countdown, ctx.beat_count, plan.get("countdownIndex"))
2352
+ )
2353
+
2354
+
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 {
2373
+ "version": "v1alpha1",
2374
+ "meta": {
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,
2379
+ "targetDuration": 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),
2384
+
2385
+ **({"characterSheet": ""} if ctx.image_compose and ctx.has_visual else {}),
2386
+ },
2387
+ "global": {
2388
+ "aspectRatio": ctx.ratio,
2389
+ "resolution": resolution,
2390
+ "fps": 30,
2391
+
2392
+
2393
+ "subtitle": {"enabled": ctx.needs_narration, "style": "bottom"},
2394
+ **({"narration": {"voiceId": ctx.resolved_voice_id, "speed": ctx.narration_speed}} if ctx.needs_narration else {}),
2395
+ **({"font": {"fontId": font_id, **({"fontName": font_name} if font_name else {})}} if font_id else {}),
2396
+ "bgm": {"enabled": True, "volume": 0.12},
2397
+ },
2398
+ "assets": assets,
2399
+ "scenes": scenes,
2400
+
2401
+ "transitions": {"default": ctx.contract["default_transition"] or "fade", "duration": 0.5},
2402
+ }
2403
+
2404
+
1904
2405
  def build_dsl(
1905
2406
  topic: str,
1906
2407
  duration: int,
@@ -2006,50 +2507,7 @@ def build_dsl(
2006
2507
  f"ℹ️ contract: payloadStyle=carousel-caption for template {template_id}",
2007
2508
  file=sys.stderr,
2008
2509
  )
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)
2028
-
2029
-
2030
-
2031
-
2032
-
2033
-
2034
-
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)
2510
+ _require_carousel_content(template_id, contract, carousel_items, caption_lines)
2053
2511
  carousel_dsl = _build_carousel_caption_dsl(
2054
2512
  template_id=template_id,
2055
2513
  topic=topic,
@@ -2070,7 +2528,8 @@ def build_dsl(
2070
2528
  payload_defaults=contract["payload_defaults"],
2071
2529
  duration_strategy=contract["duration_strategy"],
2072
2530
  duration_model=contract["duration_model"],
2073
- sequence_schema=((template_config or {}).get("customPayloadSchema") or {}).get("sequence"),
2531
+ media_binding=_resolve_media_binding(template_config),
2532
+ template_config=template_config,
2074
2533
  sequence_preset=sequence_preset,
2075
2534
  sequence_fit=sequence_fit,
2076
2535
  payload_writes=payload_writes,
@@ -2079,7 +2538,7 @@ def build_dsl(
2079
2538
  return carousel_dsl
2080
2539
 
2081
2540
  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")
2541
+ raise ValueError(f"--sequence-preset / --sequence-fit only apply to templates declaring mediaBinding.options; {template_id!r} does not")
2083
2542
 
2084
2543
 
2085
2544
 
@@ -2087,47 +2546,10 @@ def build_dsl(
2087
2546
 
2088
2547
 
2089
2548
  provided_slots = [s.strip() for s in (scene_images or []) if s and s.strip()]
2090
-
2091
- if beat_count is not None:
2092
- scene_plans = _plan_countdown_scenes(
2093
- countdown, beat_count, duration, effective_headline, ending=countdown_ending
2094
- )
2095
- else:
2096
- scene_plans = _plan_contract_scenes(contract, topic, duration, scene_count, output_language)
2097
-
2098
-
2099
-
2100
-
2101
-
2102
-
2103
-
2104
-
2105
-
2106
- if (
2107
- provided_slots
2108
- and scene_count is None
2109
- and contract["scene_strategy"] == "arc"
2110
- and primary_visual_type == "image"
2111
- and has_visual
2112
- and len(provided_slots) > len(scene_plans)
2113
- ):
2114
- scene_plans = _plan_contract_scenes(
2115
- contract, topic, duration, len(provided_slots), output_language
2116
- )
2117
-
2118
-
2119
-
2120
- if provided_slots and len(provided_slots) > len(scene_plans):
2121
- print(
2122
- f"\u26a0\ufe0f {len(provided_slots)} scene-image entries provided but the template "
2123
- f"plans only {len(scene_plans)} scene(s); the extra "
2124
- f"{len(provided_slots) - len(scene_plans)} will not be used. "
2125
- f"Raise --scenes or pick a template with more scenes.",
2126
- file=sys.stderr,
2127
- )
2128
-
2129
- assets = []
2130
- scenes = []
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
+ )
2131
2553
 
2132
2554
 
2133
2555
 
@@ -2138,34 +2560,51 @@ def build_dsl(
2138
2560
  image_style_guide = (
2139
2561
  resolve_style_guide(template_config, variant or "") if _HAS_ENHANCER else {}
2140
2562
  )
2141
- image_compose = _HAS_ENHANCER and is_compose_mode(image_style_guide)
2142
- image_ratio = image_style_guide.get("ratio") or ratio
2143
-
2144
- tail_pad_sec = _nb.template_tail_pad_sec(template_config) if _HAS_BUDGET else None
2145
-
2146
-
2147
-
2148
-
2149
- shared_background = (
2150
- contract["background_strategy"] == "shared"
2151
- and primary_visual_type == "image"
2152
- and not provided_slots
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,
2153
2595
  )
2596
+
2597
+ assets = []
2598
+ scenes = []
2154
2599
  shared_background_planned = False
2155
2600
  for idx, plan in enumerate(scene_plans):
2156
2601
  scene_id = f"scene-{idx + 1:02d}"
2157
-
2158
- if primary_visual_type == "video":
2159
- visual_asset_id = f"video-{scene_id}"
2160
- elif shared_background:
2161
- visual_asset_id = "img-background"
2162
- else:
2163
- visual_asset_id = f"img-{scene_id}"
2602
+ visual_asset_id = _visual_asset_id(ctx, scene_id)
2164
2603
  narration_asset_id = f"narration-{scene_id}" if needs_narration else None
2165
2604
  narration_text = (
2166
2605
  estimate_narration_text(
2167
2606
  plan["label"], plan["purpose"], plan["duration"], topic, language=output_language,
2168
- speed=narration_speed, tail_pad_sec=tail_pad_sec,
2607
+ speed=narration_speed, tail_pad_sec=ctx.tail_pad_sec,
2169
2608
  )
2170
2609
  if needs_narration else ""
2171
2610
  )
@@ -2174,104 +2613,13 @@ def build_dsl(
2174
2613
  scene_has_visual = has_visual and _slot_reads_visual(template_config, plan["purpose"])
2175
2614
 
2176
2615
 
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
-
2186
-
2187
- assets.append({
2188
- "assetId": visual_asset_id,
2189
- "type": "image",
2190
- "source": "existing",
2191
- "status": "generated",
2192
- "url": slot,
2193
- })
2194
-
2195
-
2196
2616
 
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
-
2210
-
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
-
2220
-
2221
-
2222
-
2223
-
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:
2254
-
2255
-
2256
- image_result = generate_image_prompt(plan["purpose"], topic, style, narration_text=narration_text)
2257
- video_prompt = image_result["prompt"]
2258
-
2259
- video_duration = max(4, min(int(plan["duration"]), 15))
2260
- vid_payload = {
2261
- "prompt": video_prompt,
2262
-
2263
-
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
- })
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)
2275
2623
 
2276
2624
 
2277
2625
 
@@ -2288,164 +2636,17 @@ def build_dsl(
2288
2636
  },
2289
2637
  })
2290
2638
 
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
-
2343
-
2344
-
2345
-
2346
-
2347
-
2348
-
2349
-
2350
-
2351
-
2352
-
2353
-
2354
-
2355
-
2356
-
2357
-
2358
-
2359
- _layout_cfg = (template_config or {}).get("capabilities") or {}
2360
- layout = _layout_cfg.get("defaultLayout") or template_id or "text-overlay"
2361
-
2362
-
2363
-
2364
-
2365
-
2366
-
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
-
2385
- if contract["text_layer_rules"]:
2386
- text_layers = _placeholder_text_layers(contract["text_layer_rules"])
2387
-
2388
- custom_payload = _build_custom_payload(template_config)
2389
- if beat_count is not None:
2390
-
2391
-
2392
- custom_payload.update(
2393
- countdown_scene_payload(countdown, beat_count, plan.get("countdownIndex"))
2394
- )
2395
-
2396
- scene = {
2397
- "id": scene_id,
2398
- "purpose": plan["purpose"],
2399
- "duration": plan["duration"],
2400
- "layout": layout,
2401
- "textLayers": text_layers,
2402
- }
2403
-
2404
-
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
-
2413
- "needsFill": True,
2414
- },
2415
- }
2416
- scenes.append(scene)
2417
-
2418
- dsl = {
2419
- "version": "v1alpha1",
2420
- "meta": {
2421
- "title": _video_title(effective_headline if headline else None, topic),
2422
- "topic": topic,
2423
- "headline": effective_headline,
2424
- "subheadline": effective_subheadline,
2425
- "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),
2430
-
2431
- **({"characterSheet": ""} if image_compose and has_visual else {}),
2432
- },
2433
- "global": {
2434
- "aspectRatio": ratio,
2435
- "resolution": resolution,
2436
- "fps": 30,
2437
-
2438
-
2439
- "subtitle": {"enabled": needs_narration, "style": "bottom"},
2440
- **({"narration": {"voiceId": resolved_voice_id, "speed": narration_speed}} if needs_narration else {}),
2441
- **({"font": {"fontId": font_id, **({"fontName": font_name} if font_name else {})}} if font_id else {}),
2442
- "bgm": {"enabled": True, "volume": 0.12},
2443
- },
2444
- "assets": assets,
2445
- "scenes": scenes,
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))
2446
2644
 
2447
- "transitions": {"default": contract["default_transition"] or "fade", "duration": 0.5},
2448
- }
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
+ )
2449
2650
 
2450
2651
  if template_id:
2451
2652
  dsl["meta"]["templateId"] = template_id
@@ -2473,7 +2674,7 @@ def build_dsl(
2473
2674
  return dsl
2474
2675
 
2475
2676
 
2476
- def main():
2677
+ def _build_arg_parser() -> argparse.ArgumentParser:
2477
2678
  parser = argparse.ArgumentParser(
2478
2679
  description="Video script generator — produces Video DSL v1alpha1 JSON",
2479
2680
  formatter_class=argparse.RawDescriptionHelpFormatter,
@@ -2541,20 +2742,20 @@ Examples:
2541
2742
  "--background-preset", default=None,
2542
2743
  help="Scene background preset (one of the template's customPayloadSchema.background.preset enum). "
2543
2744
  "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.",
2745
+ "the replicate card (`<名称>(<id>)`), pass that id. Requires --template-id.",
2545
2746
  )
2546
2747
  parser.add_argument(
2547
2748
  "--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.",
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.",
2552
2753
  )
2553
2754
  parser.add_argument(
2554
2755
  "--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.",
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.",
2558
2759
  )
2559
2760
  parser.add_argument(
2560
2761
  "--background-asset", default=None,
@@ -2565,8 +2766,8 @@ Examples:
2565
2766
  )
2566
2767
  parser.add_argument(
2567
2768
  "--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 "
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 "
2570
2771
  "checked against the template's customPayloadSchema; VALUE is converted to its declared type "
2571
2772
  "(true/false, numbers) and checked against its enum / range. Repeatable. Requires --template-id.",
2572
2773
  )
@@ -2577,7 +2778,7 @@ Examples:
2577
2778
  )
2578
2779
  parser.add_argument("--allow-digital-human", action="store_true", help="Allow digital-human assets")
2579
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>=...")
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.")
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.")
2581
2782
  parser.add_argument("--allow-ai-video", action="store_true", help="Allow AI-generated video assets")
2582
2783
  parser.add_argument("--template-id", default=None, help="Template id. The template owns outputLanguage and may declare a defaultVoiceId.")
2583
2784
  parser.add_argument(
@@ -2624,12 +2825,11 @@ Examples:
2624
2825
  "--carousel-items",
2625
2826
  action="append",
2626
2827
  default=None,
2627
- 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: "
2628
2829
  "--carousel-items url1 --carousel-items url2. "
2629
2830
  "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.",
2831
+ "these URLs are placed directly into the list named by capabilities.mediaBinding.itemsPath, "
2832
+ "as existing assets without AI image generation.",
2633
2833
  )
2634
2834
  parser.add_argument(
2635
2835
  "--scene-images",
@@ -2652,40 +2852,38 @@ Examples:
2652
2852
  help="On-screen caption line. Can be repeated: "
2653
2853
  "--caption-lines 'line1' --caption-lines 'line2'. "
2654
2854
  "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. "
2855
+ "these go where capabilities.mediaBinding.captions says (one text block, or one caption "
2856
+ "per media item in order). "
2657
2857
  "Line count limits and whether **emphasis** is parsed are declared per "
2658
2858
  "template — see the chosen template's llmHint.",
2659
2859
  )
2860
+ return parser
2660
2861
 
2661
- args = parser.parse_args()
2662
2862
 
2863
+ def _run_validate(input_path: str | None) -> None:
2663
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
2664
2884
 
2665
- if args.list_fonts:
2666
- from font_catalog import print_font_catalog
2667
- sys.exit(print_font_catalog())
2668
2885
 
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
2886
+ def _check_generation_args(args, parser: argparse.ArgumentParser) -> None:
2689
2887
 
2690
2888
  if not args.topic:
2691
2889
  print("❌ please provide --topic", file=sys.stderr)
@@ -2702,6 +2900,9 @@ Examples:
2702
2900
  sys.exit(1)
2703
2901
 
2704
2902
 
2903
+ def _resolve_template_selection(args) -> dict | None:
2904
+
2905
+
2705
2906
  try:
2706
2907
  template_selection = _template_selection(
2707
2908
  args.template_id, args.template_source, args.template_candidates,
@@ -2715,6 +2916,10 @@ Examples:
2715
2916
  "If the user did not name it, stop and let them confirm it before writing the script.",
2716
2917
  file=sys.stderr,
2717
2918
  )
2919
+ return template_selection
2920
+
2921
+
2922
+ def _resolve_ratio(args) -> str:
2718
2923
 
2719
2924
 
2720
2925
 
@@ -2740,14 +2945,10 @@ Examples:
2740
2945
  )
2741
2946
  else:
2742
2947
  resolved_ratio = "16:9"
2743
- args.ratio = resolved_ratio
2948
+ return resolved_ratio
2744
2949
 
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)
2950
+
2951
+ def _resolve_stub_urls(args) -> tuple[str, str]:
2751
2952
 
2752
2953
 
2753
2954
 
@@ -2779,6 +2980,10 @@ Examples:
2779
2980
  f"(image={stub_image_url}, video={stub_video_url}, audio=https://placeholder.local/stub.mp3)",
2780
2981
  file=sys.stderr,
2781
2982
  )
2983
+ return stub_image_url, stub_video_url
2984
+
2985
+
2986
+ def _parse_payload_writes(args, parser: argparse.ArgumentParser) -> list:
2782
2987
 
2783
2988
  try:
2784
2989
 
@@ -2789,6 +2994,66 @@ Examples:
2789
2994
  )
2790
2995
  except ValueError as exc:
2791
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)
2792
3057
 
2793
3058
  try:
2794
3059
  dsl = build_dsl(
@@ -2845,23 +3110,8 @@ Examples:
2845
3110
  if template_selection:
2846
3111
  dsl.setdefault("meta", {})["templateSelection"] = template_selection
2847
3112
 
2848
-
2849
-
2850
-
2851
3113
  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
3114
+ _stub_generated_assets(dsl, stub_video_url)
2865
3115
 
2866
3116
  errors = validate_dsl(dsl)
2867
3117
  if errors:
@@ -2870,14 +3120,7 @@ Examples:
2870
3120
  print(f" - {err}", file=sys.stderr)
2871
3121
  sys.exit(1)
2872
3122
 
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)
3123
+ _check_output_language(dsl)
2881
3124
 
2882
3125
  output_json = json.dumps(dsl, ensure_ascii=False, indent=2)
2883
3126
  print(output_json)