@remixmate/cli 0.13.9 → 0.13.10

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.
@@ -20,7 +20,9 @@ import json
20
20
  import re
21
21
  import math
22
22
  import os
23
+ import subprocess
23
24
  import sys
25
+ import urllib.request
24
26
  import uuid
25
27
  from copy import deepcopy
26
28
  from dataclasses import dataclass
@@ -85,6 +87,10 @@ except ImportError:
85
87
 
86
88
 
87
89
  try:
90
+ from video_dsl.runtime.presenter_speech import (
91
+ plan_speech_scenes as _plan_speech_scenes,
92
+ transcript_from_asr as _transcript_from_asr,
93
+ )
88
94
  from video_dsl.runtime.dsl_validator import (
89
95
  DEFAULT_MAX_SCENES,
90
96
  max_scenes_for_template,
@@ -107,6 +113,10 @@ try:
107
113
  from registry_loader import (
108
114
  load_registry_data as _load_registry_data,
109
115
  require_visible_template as _get_template,
116
+ declares_sources as _declares_sources,
117
+ load_contract as _load_contract,
118
+ template_for_source as _template_for_source,
119
+ api_base_url as _api_base_url,
110
120
  TemplateStatusGatedError,
111
121
  )
112
122
  _HAS_REGISTRY_LOADER = True
@@ -662,6 +672,88 @@ def _resolve_countdown(template_config: dict | None) -> dict:
662
672
  return resolve_countdown_spec(template_config)
663
673
 
664
674
 
675
+ def _stamp_presenter(dsl: dict, template_id: str | None, source: str | None, style: str | None = None) -> None:
676
+
677
+ if not (template_id and _HAS_REGISTRY_LOADER):
678
+ return
679
+ template_config = _load_template_config(template_id)
680
+ if not _declares_sources(template_config):
681
+ return
682
+ contract = _load_contract(template_config, source)
683
+ dsl["global"]["presenter"] = {"source": contract.get("source")}
684
+ dsl["global"]["configLock"] = contract.get("releaseId")
685
+ if style:
686
+ styles = (contract.get("templateConfig") or {}).get("styles") or []
687
+ options = [s.get("id") for s in styles]
688
+ if style not in options:
689
+ raise ValueError(f"unknown --template-style {style!r}; options: {', '.join(map(str, options))}")
690
+ dsl["global"]["templateStyle"] = style
691
+
692
+
693
+ def _recognize_speech(video_url: str) -> dict:
694
+
695
+ base = _api_base_url()
696
+ body = json.dumps({"audioUrl": video_url, "enableWords": True}).encode("utf-8")
697
+ headers = {"Content-Type": "application/json", "X-Priv-Token": os.environ.get("PRIV_TOKEN", "")}
698
+ req = urllib.request.Request(f"{base}/tool/asr", data=body, headers=headers, method="POST")
699
+ with urllib.request.urlopen(req, timeout=330) as resp:
700
+ result = json.loads(resp.read().decode("utf-8"))
701
+ if result.get("code") != 0:
702
+ raise ValueError(f"speech recognition failed: {result.get('msg')}")
703
+ return result.get("data") or {}
704
+
705
+
706
+ def _video_seconds(video_url: str) -> float:
707
+
708
+ try:
709
+ out = subprocess.run(
710
+ ["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", video_url],
711
+ capture_output=True, text=True, timeout=60,
712
+ )
713
+ return float(out.stdout.strip() or 0)
714
+ except (OSError, ValueError, subprocess.TimeoutExpired):
715
+ return 0.0
716
+
717
+
718
+ def _apply_real_footage(dsl: dict, video_url: str | None, scene_count: int | None) -> None:
719
+
720
+
721
+
722
+
723
+
724
+ if not video_url:
725
+ raise ValueError("--source real-footage needs --presenter-video")
726
+ sentences = _transcript_from_asr(_recognize_speech(video_url))
727
+ if not sentences:
728
+ raise ValueError("no speech was recognized in the video")
729
+ fps = dsl["global"].get("fps", 30)
730
+ skeleton = dsl["scenes"]
731
+ planned = _plan_speech_scenes(sentences, _video_seconds(video_url), scene_count or len(skeleton), fps)
732
+ scenes = []
733
+ for i, part in enumerate(planned):
734
+ last = i == len(planned) - 1
735
+ base = deepcopy(skeleton[-1] if last else skeleton[min(i, len(skeleton) - 2)])
736
+ base.pop("audio", None)
737
+ base.pop("visuals", None)
738
+ base["id"] = f"scene-{i + 1:02d}"
739
+ base["duration"] = (part["endFrame"] - part["startFrame"]) / fps
740
+ base["customPayload"] = {}
741
+ base["speech"] = {"startFrame": part["startFrame"], "endFrame": part["endFrame"]}
742
+ scenes.append(base)
743
+ asset_id = "presenter-video"
744
+ dsl["scenes"] = scenes
745
+ dsl["assets"] = [{"assetId": asset_id, "type": "video", "source": "existing", "status": "generated", "url": video_url}]
746
+ dsl["global"]["subtitle"]["enabled"] = True
747
+ dsl["global"].pop("narration", None)
748
+ dsl["global"]["presenter"].update(
749
+ {
750
+ "segments": [{"assetRef": asset_id, "sourceIn": 0, "at": 0}],
751
+ "transcript": sentences,
752
+ }
753
+ )
754
+ dsl["meta"]["targetDuration"] = round(planned[-1]["endFrame"] / fps)
755
+
756
+
665
757
  def _resolve_contract(template_config: dict | None) -> dict:
666
758
 
667
759
 
@@ -2149,21 +2241,43 @@ def _apply_avatar(
2149
2241
 
2150
2242
 
2151
2243
 
2152
- if not avatar_id:
2153
- return
2244
+ if avatar_id:
2245
+ _bind_presenter_asset(dsl, template_config, template_id, {"avatarId": avatar_id}, "--avatar-id")
2246
+
2247
+
2248
+ def _apply_lip_sync(
2249
+ dsl: dict,
2250
+ template_config: dict | None,
2251
+ template_id: str | None,
2252
+ video_url: str | None,
2253
+ mode: str,
2254
+ ) -> None:
2255
+
2256
+ if not video_url:
2257
+ raise ValueError("--source lip-sync needs --presenter-video")
2258
+ _bind_presenter_asset(dsl, template_config, template_id, {"videoUrl": video_url, "mode": mode}, "--presenter-video")
2259
+
2260
+
2261
+ def _bind_presenter_asset(
2262
+ dsl: dict,
2263
+ template_config: dict | None,
2264
+ template_id: str | None,
2265
+ payload: dict,
2266
+ flag: str,
2267
+ ) -> None:
2154
2268
  if not template_id or template_config is None:
2155
- raise ValueError("--avatar-id needs --template-id: the template decides where the presenter shows.")
2269
+ raise ValueError(f"{flag} needs --template-id: the template decides where the presenter shows.")
2156
2270
  support = ((template_config.get("capabilities") or {}).get("digitalHuman") or {}).get("support")
2157
2271
  if support not in ("optional", "required"):
2158
- raise ValueError(f"--avatar-id: template {template_id!r} does not declare capabilities.digitalHuman")
2272
+ raise ValueError(f"{flag}: template {template_id!r} does not declare capabilities.digitalHuman")
2159
2273
  scenes = [
2160
2274
  s for s in dsl.get("scenes", [])
2161
2275
  if _slot_extracts(template_config, str(s.get("purpose")), "visuals.avatar.assetRef")
2162
2276
  ]
2163
2277
  if not scenes:
2164
- raise ValueError(f"--avatar-id: no scene of template {template_id!r} reads visuals.avatar.assetRef")
2278
+ raise ValueError(f"{flag}: no scene of template {template_id!r} reads visuals.avatar.assetRef")
2165
2279
  if not any((s.get("audio") or {}).get("narration") for s in scenes):
2166
- raise ValueError("--avatar-id: the presenter is driven by the narration, and this script has none")
2280
+ raise ValueError(f"{flag}: the presenter is driven by the narration, and this script has none")
2167
2281
  for scene in scenes:
2168
2282
  visuals = scene.get("visuals") if isinstance(scene.get("visuals"), dict) else {}
2169
2283
  scene["visuals"] = {**visuals, "avatar": {"assetRef": AVATAR_ASSET_ID}}
@@ -2172,7 +2286,7 @@ def _apply_avatar(
2172
2286
  "type": "avatar",
2173
2287
  "source": "gen-digital-human",
2174
2288
  "status": "planned",
2175
- "payload": {"avatarId": avatar_id},
2289
+ "payload": payload,
2176
2290
  })
2177
2291
  _lock_scene_path(dsl, "visuals.avatar.assetRef")
2178
2292
 
@@ -2644,6 +2758,8 @@ def build_dsl(
2644
2758
  payload_writes: PayloadWrites | None = None,
2645
2759
  countdown_ending: bool = False,
2646
2760
  narration: str = "auto",
2761
+ presenter_source: str | None = None,
2762
+ presenter_video: str | None = None,
2647
2763
  ) -> dict:
2648
2764
 
2649
2765
 
@@ -2674,6 +2790,8 @@ def build_dsl(
2674
2790
 
2675
2791
 
2676
2792
  template_config = _load_template_config(template_id) if template_id else None
2793
+ if _HAS_REGISTRY_LOADER:
2794
+ template_config = _template_for_source(template_config, presenter_source)
2677
2795
  contract = _resolve_contract(template_config)
2678
2796
 
2679
2797
 
@@ -2766,6 +2884,7 @@ def build_dsl(
2766
2884
 
2767
2885
 
2768
2886
  provided_slots = [s.strip() for s in (scene_images or []) if s and s.strip()]
2887
+ _reject_video_scene_images(provided_slots, primary_visual_type, template_config)
2769
2888
  scene_plans = _plan_skeleton_scenes(
2770
2889
  contract, countdown, beat_count, topic, duration, scene_count, output_language,
2771
2890
  effective_headline, countdown_ending, provided_slots, has_visual,
@@ -2894,6 +3013,218 @@ def build_dsl(
2894
3013
  return dsl
2895
3014
 
2896
3015
 
3016
+
3017
+ BROLL_MODES = ("auto", "ai-image", "ai-video", "none")
3018
+
3019
+ BROLL_AI_VIDEO_MAX = 3
3020
+
3021
+ BROLL_AI_VIDEO_SECONDS = 4
3022
+ BROLL_VIDEO_MODEL = "seedance-mini"
3023
+ _BROLL_LAST_SCENES = ("opening", "cta", "ending")
3024
+ _VIDEO_SUFFIXES = (".mp4", ".mov", ".webm", ".avi", ".m4v")
3025
+ _SKELETON_PROMPT_HEAD = re.compile(r"\s*\[(?:骨架待填充|skeleton placeholder)\][^))]*[))]")
3026
+
3027
+
3028
+ def _style_broll_mode(template_config: dict | None, source: str | None, style_id: str | None) -> str:
3029
+
3030
+ if not _HAS_REGISTRY_LOADER or not _declares_sources(template_config):
3031
+ return "ai-image"
3032
+ config = _load_contract(template_config, source).get("templateConfig") or {}
3033
+ wanted = style_id or config.get("defaultStyle")
3034
+ style = next((s for s in config.get("styles") or [] if s.get("id") == wanted), {})
3035
+ return style.get("brollDefault") or "ai-image"
3036
+
3037
+
3038
+ def _reject_video_scene_images(slots: list[str], visual_type: str, template_config: dict | None) -> None:
3039
+
3040
+ if visual_type != "image":
3041
+ return
3042
+ videos = [s for s in slots if urlsplit(s).path.lower().endswith(_VIDEO_SUFFIXES)]
3043
+ if not videos:
3044
+ return
3045
+ binding = ((template_config or {}).get("capabilities") or {}).get("mediaBinding") or {}
3046
+ hint = "pass them as --broll-asset" if binding.get("assetRefKey") else "this template takes images here"
3047
+ raise ValueError(f"--scene-images got video URLs ({', '.join(videos)}): {hint}")
3048
+
3049
+
3050
+ def _broll_targets(scenes: list[dict]) -> list[dict]:
3051
+
3052
+ return sorted(scenes, key=lambda scene: scene.get("purpose") in _BROLL_LAST_SCENES)
3053
+
3054
+
3055
+ def _scene_seconds(scene: dict) -> float:
3056
+ return float(scene.get("duration") or 0)
3057
+
3058
+
3059
+ def _fit_broll_item(item: dict, scene: dict) -> None:
3060
+
3061
+ length = _scene_seconds(scene)
3062
+ span = min(float(item.get("durationSec") or 0), length)
3063
+ item["durationSec"] = span
3064
+ if float(item.get("atSec") or 0) + span > length:
3065
+ item["atSec"] = round((length - span) / 2, 3)
3066
+
3067
+
3068
+ def _scene_speech_text(dsl: dict, scene: dict) -> str:
3069
+
3070
+ speech = scene.get("speech")
3071
+ transcript = (dsl["global"].get("presenter") or {}).get("transcript")
3072
+ if not speech or not transcript:
3073
+ return ""
3074
+ fps = dsl["global"].get("fps", 30)
3075
+ start, end = speech["startFrame"] * 1000 / fps, speech["endFrame"] * 1000 / fps
3076
+ return "".join(s["text"] for s in transcript if start <= (s["startMs"] + s["endMs"]) / 2 < end)
3077
+
3078
+
3079
+ def _ai_image_broll(asset_id: str, scene: dict, topic: str, style: str, text: str, guide: dict, stub: str | None) -> dict:
3080
+ if stub:
3081
+ return {"assetId": asset_id, "type": "image", "source": "existing", "status": "generated", "url": stub}
3082
+ result = generate_image_prompt(scene.get("purpose", "point"), topic, style, narration_text=text, style_guide=guide)
3083
+ payload = {
3084
+ "prompt": result["prompt"],
3085
+ "model": os.environ.get("DEFAULT_IMAGE_MODEL", "doubao/doubao-seedream-5-0-260128"),
3086
+ "ratio": guide.get("ratio") or "1:1",
3087
+ }
3088
+ for key in ("negativePrompt", "guidanceScale"):
3089
+ if key in result:
3090
+ payload[key] = result[key]
3091
+ return {"assetId": asset_id, "type": "image", "source": "gen-image", "status": "planned", "payload": payload}
3092
+
3093
+
3094
+ def _video_subject(image_prompt: str, scene_text: str, topic: str) -> str:
3095
+
3096
+
3097
+
3098
+
3099
+ placeholder = _SKELETON_PROMPT_HEAD.match(image_prompt)
3100
+ return placeholder.group(0) if placeholder else (scene_text or topic)
3101
+
3102
+
3103
+ def _ai_video_broll(asset_id: str, subject: str, ratio: str, stub: str | None) -> dict:
3104
+ if stub:
3105
+ return {"assetId": asset_id, "type": "video", "source": "existing", "status": "generated", "url": stub}
3106
+ return {
3107
+ "assetId": asset_id,
3108
+ "type": "video",
3109
+ "source": "gen-video",
3110
+ "status": "planned",
3111
+ "payload": {
3112
+ "prompt": f"{subject},空镜,画面里没有人说话,没有文字,镜头缓慢运动",
3113
+ "model": os.environ.get("DEFAULT_VIDEO_MODEL", BROLL_VIDEO_MODEL),
3114
+ "ratio": ratio,
3115
+ "duration": BROLL_AI_VIDEO_SECONDS,
3116
+ },
3117
+ }
3118
+
3119
+
3120
+ def _library_broll(asset_id_index: int, url: str) -> tuple[dict, dict]:
3121
+
3122
+ if not urlsplit(url).path.lower().endswith(_VIDEO_SUFFIXES):
3123
+ asset_id = f"img-broll-{asset_id_index:02d}"
3124
+ return {"assetId": asset_id, "type": "image", "source": "existing", "status": "generated", "url": url}, {}
3125
+ asset_id = f"video-broll-{asset_id_index:02d}"
3126
+ asset = {"assetId": asset_id, "type": "video", "source": "existing", "status": "generated", "url": url}
3127
+ seconds = _probe_sequence_video_duration(url)
3128
+ return asset, {"type": "video", **({"sourceDurationSec": round(float(seconds), 3)} if seconds else {})}
3129
+
3130
+
3131
+ def _apply_brolls(
3132
+ dsl: dict,
3133
+ template_config: dict | None,
3134
+ template_id: str | None,
3135
+ urls: list[str] | None,
3136
+ mode: str | None,
3137
+ *,
3138
+ variant: str | None = None,
3139
+ stub_image_url: str | None = None,
3140
+ stub_video_url: str | None = None,
3141
+ ) -> None:
3142
+
3143
+
3144
+
3145
+
3146
+
3147
+ urls = [u.strip() for u in (urls or []) if u and u.strip()]
3148
+ if not urls and not mode:
3149
+ return
3150
+ binding = ((template_config or {}).get("capabilities") or {}).get("mediaBinding") or {}
3151
+ ref_key, items_path = binding.get("assetRefKey"), binding.get("itemsPath")
3152
+ if not ref_key or not items_path:
3153
+ raise ValueError(f"--broll-asset / --broll-mode: template {template_id!r} has no per-scene media")
3154
+ parts = items_path.split(".")
3155
+ guide = resolve_style_guide(template_config, variant or "") if _HAS_ENHANCER else {}
3156
+ meta = dsl["meta"]
3157
+ assets = dsl["assets"]
3158
+ ai_videos = 0
3159
+
3160
+ def items_of(scene: dict) -> list[dict]:
3161
+ items = _get_path(scene.get("customPayload") or {}, parts)
3162
+ return items if isinstance(items, list) else []
3163
+
3164
+ def set_items(scene: dict, items: list[dict]) -> None:
3165
+ _write_payload(scene.setdefault("customPayload", {}), [(parts, items)])
3166
+
3167
+ def drop_asset(asset_id: str | None) -> None:
3168
+ assets[:] = [a for a in assets if a.get("assetId") != asset_id]
3169
+
3170
+ def new_item(scene: dict) -> dict:
3171
+ item = deepcopy(binding.get("item") or {})
3172
+ set_items(scene, [*items_of(scene), item])
3173
+ return item
3174
+
3175
+ def place_library(scene: dict, index: int, url: str, item: dict) -> None:
3176
+ drop_asset(item.get(ref_key))
3177
+ asset, fields = _library_broll(index, url)
3178
+ assets.append(asset)
3179
+ item.update({ref_key: asset["assetId"], **fields})
3180
+ seconds = fields.get("sourceDurationSec")
3181
+ if seconds:
3182
+ item["durationSec"] = min(float(item.get("durationSec") or seconds), seconds)
3183
+ _fit_broll_item(item, scene)
3184
+
3185
+ targets = _broll_targets(dsl["scenes"])
3186
+ for index, scene in enumerate(targets):
3187
+ items = items_of(scene)
3188
+ item = items[0] if items else None
3189
+ if index < len(urls):
3190
+ place_library(scene, index + 1, urls[index], item or new_item(scene))
3191
+ continue
3192
+ if mode == "none":
3193
+ if item:
3194
+ drop_asset(item.get(ref_key))
3195
+ set_items(scene, items[1:])
3196
+ continue
3197
+ if mode not in ("ai-image", "ai-video"):
3198
+ continue
3199
+ if item is None:
3200
+ asset_id = f"img-{scene['id']}"
3201
+ assets.append(_ai_image_broll(
3202
+ asset_id, scene, meta.get("topic", ""), meta.get("style", ""),
3203
+ _scene_speech_text(dsl, scene), guide, stub_image_url,
3204
+ ))
3205
+ item = new_item(scene)
3206
+ item[ref_key] = asset_id
3207
+ _fit_broll_item(item, scene)
3208
+ if mode == "ai-video" and ai_videos < BROLL_AI_VIDEO_MAX:
3209
+ image = next(a for a in assets if a.get("assetId") == item.get(ref_key))
3210
+ subject = _video_subject(
3211
+ (image.get("payload") or {}).get("prompt") or "", _scene_speech_text(dsl, scene), meta.get("topic", ""),
3212
+ )
3213
+ video = _ai_video_broll(f"video-{scene['id']}", subject, dsl["global"].get("aspectRatio", "9:16"), stub_video_url)
3214
+ drop_asset(image["assetId"])
3215
+ assets.append(video)
3216
+ item.update({ref_key: video["assetId"], "type": "video"})
3217
+ item["durationSec"] = min(float(item.get("durationSec") or BROLL_AI_VIDEO_SECONDS), BROLL_AI_VIDEO_SECONDS)
3218
+ _fit_broll_item(item, scene)
3219
+ ai_videos += 1
3220
+
3221
+
3222
+ for offset, url in enumerate(urls[len(targets):]):
3223
+ index = len(targets) + offset + 1
3224
+ scene = targets[offset % len(targets)]
3225
+ place_library(scene, index, url, new_item(scene))
3226
+
3227
+
2897
3228
  def _build_arg_parser() -> argparse.ArgumentParser:
2898
3229
  parser = argparse.ArgumentParser(
2899
3230
  description="Video script generator — produces Video DSL v1alpha1 JSON",
@@ -3008,6 +3339,35 @@ Examples:
3008
3339
  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>=...")
3009
3340
  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.")
3010
3341
  parser.add_argument("--allow-ai-video", action="store_true", help="Allow AI-generated video assets")
3342
+ parser.add_argument(
3343
+ "--source", default=None, dest="presenter_source",
3344
+ help="Presenter source, for templates that declare capabilities.sources "
3345
+ "(digital-human, lip-sync, real-footage). Defaults to the template's defaultSource.",
3346
+ )
3347
+ parser.add_argument(
3348
+ "--presenter-video", default=None,
3349
+ help="URL of the user's talking-head video. Required with --source real-footage; "
3350
+ "used with --source lip-sync as the video to re-voice.",
3351
+ )
3352
+ parser.add_argument(
3353
+ "--lipsync-mode", default="speed", choices=("speed", "precision"),
3354
+ help="Lip-sync quality for --source lip-sync. precision costs about 4x as much.",
3355
+ )
3356
+ parser.add_argument(
3357
+ "--broll-asset", action="append", default=None,
3358
+ help="Repeatable. URL of an image or video the user picked from their asset library as a b-roll shot. "
3359
+ "For templates declaring capabilities.mediaBinding with assetRefKey, on every presenter source. "
3360
+ "All of them go into the DSL; anchor each to the sentence it matches (atText) from its description.",
3361
+ )
3362
+ parser.add_argument(
3363
+ "--broll-mode", default=None, choices=BROLL_MODES,
3364
+ help="Scenes without a --broll-asset: auto (the visual style's default), ai-image (gen-image), ai-video (gen-video on at most "
3365
+ f"{BROLL_AI_VIDEO_MAX} scenes, the rest stay images) or none. Omitted keeps the skeleton as it is.",
3366
+ )
3367
+ parser.add_argument(
3368
+ "--template-style", default=None,
3369
+ help="Visual style for templates that ship several (see templateConfig.styles in the template).",
3370
+ )
3011
3371
  parser.add_argument("--template-id", default=None, help="Template id. The template owns outputLanguage and may declare a defaultVoiceId.")
3012
3372
  parser.add_argument(
3013
3373
  "--template-source", default=None, choices=TEMPLATE_SOURCES,
@@ -3313,7 +3673,12 @@ def main():
3313
3673
  payload_writes=payload_writes,
3314
3674
  countdown_ending=bool(args.ending_payload),
3315
3675
  narration=args.narration,
3676
+ presenter_source=args.presenter_source,
3677
+ presenter_video=args.presenter_video,
3316
3678
  )
3679
+ _stamp_presenter(dsl, args.template_id, args.presenter_source, args.template_style)
3680
+ if args.presenter_source == "real-footage":
3681
+ _apply_real_footage(dsl, args.presenter_video, args.scenes)
3317
3682
  except ValueError as exc:
3318
3683
 
3319
3684
 
@@ -3333,6 +3698,16 @@ def main():
3333
3698
  _apply_scene_payload(dsl, look_config, args.template_id, args.payload, args.motion)
3334
3699
  _apply_ending_payload(dsl, look_config, args.template_id, args.ending_payload)
3335
3700
  _apply_avatar(dsl, look_config, args.template_id, args.avatar_id)
3701
+ if args.presenter_source == "lip-sync":
3702
+ _apply_lip_sync(dsl, look_config, args.template_id, args.presenter_video, args.lipsync_mode)
3703
+ if args.broll_asset or args.broll_mode:
3704
+ broll_mode = args.broll_mode
3705
+ if broll_mode == "auto":
3706
+ broll_mode = _style_broll_mode(look_config, args.presenter_source, args.template_style)
3707
+ _apply_brolls(
3708
+ dsl, look_config, args.template_id, args.broll_asset, broll_mode,
3709
+ variant=args.variant, stub_image_url=stub_image_url, stub_video_url=stub_video_url,
3710
+ )
3336
3711
  except ValueError as exc:
3337
3712
  parser.error(str(exc))
3338
3713
 
@@ -64,6 +64,35 @@
64
64
  "type": "string",
65
65
  "description": "Media fill mode for templates declaring capabilities.mediaBinding.options.fit; one of that option's enum in the template's customPayloadSchema (its descriptions explain each value). Beats the preset's own fit. **Must** pass when the user picked it: `显示方式: <名称>(<id>)` → pass `<id>`; `auto` keeps the template / preset default."
66
66
  },
67
+ "source": {
68
+ "type": "string",
69
+ "description": "Presenter source, for templates that declare capabilities.sources: digital-human, lip-sync or real-footage. Omit to use the template's default."
70
+ },
71
+ "template_style": {
72
+ "type": "string",
73
+ "description": "Visual style for templates that ship several, e.g. talking-head-studio: douyin-bold, news-anchor, live-ecommerce."
74
+ },
75
+ "presenter_video": {
76
+ "type": "string",
77
+ "description": "URL of the user's talking-head video. Required with source real-footage or lip-sync."
78
+ },
79
+ "lipsync_mode": {
80
+ "type": "string",
81
+ "enum": ["speed", "precision"],
82
+ "description": "Lip-sync quality for source lip-sync, default speed. precision costs about 4x as much."
83
+ },
84
+ "broll_asset": {
85
+ "type": "array",
86
+ "items": {
87
+ "type": "string"
88
+ },
89
+ "description": "URLs of images or videos the user picked as b-roll from their asset library (the replicate card's 空镜素材 asset lines), on any source. Not scene_images: scene_images treats every entry as an image."
90
+ },
91
+ "broll_mode": {
92
+ "type": "string",
93
+ "enum": ["auto", "ai-image", "ai-video", "none"],
94
+ "description": "Scenes without a broll_asset (the replicate card's 其余画面): auto (the visual style's default), ai-image, ai-video (at most 3 scenes, the rest stay images) or none."
95
+ },
67
96
  "narration": {
68
97
  "type": "string",
69
98
  "enum": ["auto", "on", "off"],
@@ -205,6 +234,10 @@
205
234
  "background_preset",
206
235
  "sequence_preset",
207
236
  "sequence_fit",
237
+ "source",
238
+ "template_style",
239
+ "presenter_video",
240
+ "lipsync_mode",
208
241
  "narration",
209
242
  "background_asset",
210
243
  "payload",
@@ -222,6 +255,8 @@
222
255
  "subheadline",
223
256
  "carousel_items",
224
257
  "scene_images",
258
+ "broll_asset",
259
+ "broll_mode",
225
260
  "caption_lines",
226
261
  "stub_image_url",
227
262
  "stub_video_url",