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