@remixmate/cli 0.13.8 → 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.
- package/dist/handlers/gen-digital-human.js +18 -18
- package/dist/handlers/gen-voice.js +19 -15
- package/dist/manifest.json +142 -13
- package/package.json +1 -1
- package/skills/gen-digital-human/SKILL.md +10 -2
- package/skills/gen-digital-human/skill.json +33 -4
- package/skills/gen-script/SKILL.md +6 -0
- package/skills/gen-script/scripts/gen_script.py +382 -7
- package/skills/gen-script/skill.json +35 -0
- package/skills/gen-voice/SKILL.md +22 -10
- package/skills/gen-voice/skill.json +55 -6
- package/skills/render-video/scripts/render_video.py +100 -1
- package/skills/render-video/skill.json +4 -0
- package/skills/template-registry/scripts/registry_loader.py +99 -0
- package/skills/template-registry/video_dsl/runtime/dsl_validator.py +57 -10
- package/skills/template-registry/video_dsl/runtime/presenter_speech.py +104 -0
- package/skills/template-registry/video_dsl/runtime/timeline_compiler.py +1 -0
- package/skills/template-registry/video_dsl/schema/video-dsl-v1alpha1.json +26 -0
|
@@ -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
|
|
2153
|
-
|
|
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("
|
|
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"
|
|
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"
|
|
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("
|
|
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":
|
|
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",
|
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: gen-voice
|
|
3
3
|
description: |
|
|
4
|
-
Text-to-speech (TTS) skill: synthesize narration audio from text via ab-api's unified `/tool/tts` endpoint
|
|
4
|
+
Text-to-speech (TTS) skill: synthesize narration audio from text via ab-api's unified `/tool/tts` endpoint. Voices come from several providers (MiniMax, 豆包/Volcengine, HeyGen, plus the caller's cloned voices); the backend routes each voice id to its own provider.
|
|
5
5
|
|
|
6
6
|
Use this skill immediately whenever the user asks for any of:
|
|
7
7
|
- AI voice-over, TTS, text-to-speech, generate narration audio
|
|
8
|
-
- Use
|
|
8
|
+
- Use MiniMax / 豆包 (Doubao) / HeyGen voices for speech synthesis
|
|
9
9
|
- Read a piece of text aloud / produce an audio file from text
|
|
10
10
|
|
|
11
11
|
Even when the user does not explicitly say "AI", any request that turns text into speech should route here.
|
|
12
12
|
triggers:
|
|
13
13
|
- AI voice-over, TTS, text-to-speech, generate narration audio
|
|
14
|
-
- Use
|
|
14
|
+
- Use MiniMax / 豆包 (Doubao) / HeyGen voices for speech synthesis
|
|
15
15
|
- Read a piece of text aloud / produce an audio file from text
|
|
16
16
|
---
|
|
17
17
|
|
|
18
18
|
# Text-to-Speech (TTS) Skill
|
|
19
19
|
|
|
20
|
-
Wraps ab-api's unified `POST /tool/tts`
|
|
20
|
+
Wraps ab-api's unified `POST /tool/tts` (the same endpoint the web studio uses), authenticated with the **Tianyan privateToken**. You never pick a provider: ab-api looks the voice id up and synthesizes with that voice's provider (MiniMax, 豆包/Volcengine, HeyGen). Cloned voices (豆包 voice clones, HeyGen digital-twin voices) are usable only by their owner.
|
|
21
21
|
|
|
22
22
|
## Auth & environment
|
|
23
23
|
|
|
@@ -37,16 +37,21 @@ There is no skill-local env file — the executing process inherits the system e
|
|
|
37
37
|
> This skill was migrated from a Python script to an remixmate CLI HTTP handler (`entry.type: http`). The agent invocation is unchanged (same tool name `gen_voice`, same params as in `skill.json`); local repro goes through `remixmate gen-voice ...`. The legacy `--download` flag has been removed — audio URLs are persisted in the cloud and play directly.
|
|
38
38
|
|
|
39
39
|
1. **Text**: confirm what to synthesize. Punctuation drives pauses (commas short, periods long).
|
|
40
|
-
2. **Voice**: if the user prefers a specific voice, run `--list-voices` first and pick a matching id.
|
|
40
|
+
2. **Voice**: if the user prefers a specific voice, run `--list-voices` (with filters) first and pick a matching id.
|
|
41
41
|
3. **Output**: by default the audio URL is printed; add `--json-output` for a structured result that includes per-line subtitle timestamps.
|
|
42
42
|
|
|
43
43
|
### List available voices
|
|
44
44
|
|
|
45
45
|
```bash
|
|
46
|
-
remixmate gen-voice --list-voices
|
|
46
|
+
remixmate gen-voice --list-voices # page 1 of the public catalog
|
|
47
|
+
remixmate gen-voice --list-voices --provider volcengine --gender female --language 中文-普通话
|
|
48
|
+
remixmate gen-voice --list-voices --page 2 # next 100
|
|
49
|
+
remixmate gen-voice --list-voices --mine # your own cloned voices
|
|
47
50
|
```
|
|
48
51
|
|
|
49
|
-
|
|
52
|
+
Each row shows the voice id, provider, type (system / clone), gender, language and name; the footer gives the total and the next `--page` when there is more. The public catalog has a few hundred voices, so filter before paging. `--json-output` returns `{page, total, voices:[{id,name,provider,type,gender,language}]}`.
|
|
53
|
+
|
|
54
|
+
The live list is the **authoritative** catalog — a voice id is invalid only if it is missing from it (check the filters and later pages before concluding that).
|
|
50
55
|
|
|
51
56
|
For offline development (no token / no network) there is a `--local` escape hatch. It prints the resolver's two hardcoded fallback ids, **not** the available voices, so never use it to decide whether a voice exists:
|
|
52
57
|
|
|
@@ -83,9 +88,16 @@ remixmate gen-voice --text "<text-to-synthesize>" --json-output
|
|
|
83
88
|
|------|-------------|---------|
|
|
84
89
|
| `--text` | Text to synthesize (required, except with `--list-voices`) | — |
|
|
85
90
|
| `--voice-id` | Voice id (use `--list-voices` to discover) | `Chinese (Mandarin)_Male_Announcer` |
|
|
86
|
-
| `--speed` | Speech rate, 0.5–2.0 | `1.0` |
|
|
87
|
-
| `--language-boost` | CLI-only. Language hint
|
|
88
|
-
| `--list-voices` | List
|
|
91
|
+
| `--speed` | Speech rate, 0.5–2.0 (some HeyGen cloned voices ignore it) | `1.0` |
|
|
92
|
+
| `--language-boost` | CLI-only. Language hint (MiniMax voices only) (`auto` / `Chinese` / `English` / …). Mixed-script narration ("只有四个字:Agent Loop") mispronounces the Latin terms without one, so the service already applies its own default — pass this only to pin a specific language. Usually set from the DSL (`global.narration.languageBoost`, scene-level overrides) rather than by hand. Not exposed to the agent — see the handler comment. | service default |
|
|
93
|
+
| `--list-voices` | List voices and exit (100 per page) | — |
|
|
94
|
+
| `--mine` | With `--list-voices`: your own cloned voices instead of the public catalog | off |
|
|
95
|
+
| `--provider` | With `--list-voices`: `minimax` / `volcengine` (豆包) / `heygen` | all |
|
|
96
|
+
| `--voice-type` | With `--list-voices`: `system` / `clone` | all |
|
|
97
|
+
| `--gender` | With `--list-voices`: `male` / `female` | all |
|
|
98
|
+
| `--language` | With `--list-voices`: exact language tag, e.g. `中文-普通话`, `英语` | all |
|
|
99
|
+
| `--name` | With `--list-voices`: fuzzy name filter | — |
|
|
100
|
+
| `--page` | With `--list-voices`: page number | `1` |
|
|
89
101
|
| `--local` | CLI-only. With `--list-voices`: print the 2-entry hardcoded fallback list instead of querying the service (offline dev). Not exposed to the agent — see the handler comment. | off |
|
|
90
102
|
| `--json-output` | Emit JSON (url, audio_length_ms, subtitles) | off |
|
|
91
103
|
| `--priv-token` | Override token | env var |
|