@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.
- package/README.md +7 -2
- package/README.zh-CN.md +1 -1
- package/dist/manifest.json +10 -10
- 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 +14 -14
- package/skills/gen-script/scripts/gen_script.py +773 -530
- package/skills/gen-script/skill.json +8 -8
- 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
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
|
-
|
|
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:
|
|
@@ -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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
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
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
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
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
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
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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="
|
|
2549
|
-
"
|
|
2550
|
-
"
|
|
2551
|
-
"(
|
|
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="
|
|
2556
|
-
"
|
|
2557
|
-
"
|
|
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
|
|
2569
|
-
"--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 "
|
|
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.
|
|
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
|
|
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
|
-
"
|
|
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
|
|
2656
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
2948
|
+
return resolved_ratio
|
|
2744
2949
|
|
|
2745
|
-
|
|
2746
|
-
|
|
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
|
-
|
|
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)
|