@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
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
"toolName": "gen_voice",
|
|
4
4
|
"tier": "atomic",
|
|
5
5
|
"category": "asset",
|
|
6
|
-
"title": "Text-to-Speech
|
|
7
|
-
"description": "Text-to-speech (TTS): synthesize narration audio from text
|
|
6
|
+
"title": "Text-to-Speech",
|
|
7
|
+
"description": "Text-to-speech (TTS): synthesize narration audio from text. The voice catalog spans several providers (MiniMax, 豆包/Volcengine, HeyGen, and the caller's own cloned voices); the provider is picked from the voice id automatically. Returns the persisted audio URL — no download needed.",
|
|
8
8
|
"auth": "required",
|
|
9
9
|
"joinsTake": true,
|
|
10
10
|
"envVars": [
|
|
@@ -25,15 +25,57 @@
|
|
|
25
25
|
},
|
|
26
26
|
"voice_id": {
|
|
27
27
|
"type": "string",
|
|
28
|
-
"description": "Voice id. Default 'Chinese (Mandarin)_Male_Announcer'. When unsure, call with list_voices=true first
|
|
28
|
+
"description": "Voice id from list_voices (any provider; synthesis routes to the voice's own provider). Default 'Chinese (Mandarin)_Male_Announcer'. When unsure, call with list_voices=true first — do not invent ids, and do not declare a voice unavailable unless it is missing from the filtered list."
|
|
29
29
|
},
|
|
30
30
|
"speed": {
|
|
31
31
|
"type": "number",
|
|
32
|
-
"description": "Speech rate, 0.5-2.0"
|
|
32
|
+
"description": "Speech rate, 0.5-2.0. Some HeyGen cloned voices ignore it."
|
|
33
33
|
},
|
|
34
34
|
"list_voices": {
|
|
35
35
|
"type": "boolean",
|
|
36
|
-
"description": "List
|
|
36
|
+
"description": "List voices and exit (one page of up to 100, with provider / type / gender / language columns and the total). This queries the live voice service and is the authoritative catalog. The public catalog has a few hundred voices, so narrow it with provider / gender / language / name, or page through with page; mine=true lists the caller's own cloned voices instead."
|
|
37
|
+
},
|
|
38
|
+
"mine": {
|
|
39
|
+
"type": "boolean",
|
|
40
|
+
"description": "With list_voices=true: list the caller's own cloned voices instead of the public catalog (provider / voice_type do not apply)."
|
|
41
|
+
},
|
|
42
|
+
"provider": {
|
|
43
|
+
"type": "string",
|
|
44
|
+
"enum": [
|
|
45
|
+
"minimax",
|
|
46
|
+
"volcengine",
|
|
47
|
+
"heygen"
|
|
48
|
+
],
|
|
49
|
+
"description": "With list_voices=true: only voices from this provider (volcengine = 豆包). Not needed for synthesis."
|
|
50
|
+
},
|
|
51
|
+
"voice_type": {
|
|
52
|
+
"type": "string",
|
|
53
|
+
"enum": [
|
|
54
|
+
"system",
|
|
55
|
+
"clone"
|
|
56
|
+
],
|
|
57
|
+
"description": "With list_voices=true: system (stock) or clone voices."
|
|
58
|
+
},
|
|
59
|
+
"gender": {
|
|
60
|
+
"type": "string",
|
|
61
|
+
"enum": [
|
|
62
|
+
"male",
|
|
63
|
+
"female"
|
|
64
|
+
],
|
|
65
|
+
"description": "With list_voices=true: filter by gender."
|
|
66
|
+
},
|
|
67
|
+
"language": {
|
|
68
|
+
"type": "string",
|
|
69
|
+
"description": "With list_voices=true: filter by language tag, exact tag as shown in the Language column (e.g. 中文-普通话, 中文-粤语, 英语, 日语)."
|
|
70
|
+
},
|
|
71
|
+
"name": {
|
|
72
|
+
"type": "string",
|
|
73
|
+
"description": "With list_voices=true: fuzzy-filter by voice name."
|
|
74
|
+
},
|
|
75
|
+
"page": {
|
|
76
|
+
"type": "integer",
|
|
77
|
+
"minimum": 1,
|
|
78
|
+
"description": "With list_voices=true: page number (100 per page). Default 1."
|
|
37
79
|
},
|
|
38
80
|
"json_output": {
|
|
39
81
|
"type": "boolean",
|
|
@@ -52,7 +94,14 @@
|
|
|
52
94
|
],
|
|
53
95
|
"hidden": [
|
|
54
96
|
"json_output",
|
|
55
|
-
"list_voices"
|
|
97
|
+
"list_voices",
|
|
98
|
+
"mine",
|
|
99
|
+
"provider",
|
|
100
|
+
"voice_type",
|
|
101
|
+
"gender",
|
|
102
|
+
"language",
|
|
103
|
+
"name",
|
|
104
|
+
"page"
|
|
56
105
|
]
|
|
57
106
|
}
|
|
58
107
|
}
|
|
@@ -61,6 +61,7 @@ _SHARED_LIB_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "template-
|
|
|
61
61
|
|
|
62
62
|
sys.path.insert(0, _SHARED_LIB_DIR)
|
|
63
63
|
sys.path.insert(0, os.path.join(_SHARED_LIB_DIR, "scripts"))
|
|
64
|
+
from video_dsl.runtime.presenter_speech import subtitles_between
|
|
64
65
|
from video_dsl.runtime.timeline_compiler import (
|
|
65
66
|
split_subtitle,
|
|
66
67
|
split_subtitle_from_lines,
|
|
@@ -919,6 +920,13 @@ def build_render_plan(dsl: dict, binding: dict) -> dict:
|
|
|
919
920
|
if probed:
|
|
920
921
|
asset["duration"] = probed
|
|
921
922
|
|
|
923
|
+
presenter = (dsl.get("global") or {}).get("presenter") or {}
|
|
924
|
+
presenter_transcript = presenter.get("transcript") or []
|
|
925
|
+
|
|
926
|
+
presenter_props = (
|
|
927
|
+
{"presenter": {k: v for k, v in presenter.items() if k != "transcript"}} if presenter.get("segments") else {}
|
|
928
|
+
)
|
|
929
|
+
|
|
922
930
|
timeline = []
|
|
923
931
|
current_frame = 0
|
|
924
932
|
|
|
@@ -927,6 +935,10 @@ def build_render_plan(dsl: dict, binding: dict) -> dict:
|
|
|
927
935
|
duration = scene.get("duration", 5)
|
|
928
936
|
duration_frames = math.floor(duration * fps + 0.5)
|
|
929
937
|
|
|
938
|
+
speech = scene.get("speech") if presenter_transcript else None
|
|
939
|
+
if speech:
|
|
940
|
+
duration_frames = speech["endFrame"] - speech["startFrame"]
|
|
941
|
+
|
|
930
942
|
scene_binding = binding_map.get(scene_id, {})
|
|
931
943
|
|
|
932
944
|
|
|
@@ -959,6 +971,8 @@ def build_render_plan(dsl: dict, binding: dict) -> dict:
|
|
|
959
971
|
}
|
|
960
972
|
for s in local_subs
|
|
961
973
|
]
|
|
974
|
+
if speech:
|
|
975
|
+
subtitle_segments = subtitles_between(presenter_transcript, speech["startFrame"], speech["endFrame"], fps)
|
|
962
976
|
|
|
963
977
|
transition_config = dsl.get("transitions", {})
|
|
964
978
|
trans_type = transition_config.get("default", "fade")
|
|
@@ -998,7 +1012,7 @@ def build_render_plan(dsl: dict, binding: dict) -> dict:
|
|
|
998
1012
|
"startTime": round(current_frame / fps, 2),
|
|
999
1013
|
"endTime": round((current_frame + duration_frames) / fps, 2),
|
|
1000
1014
|
"compositionId": scene_binding.get("compositionId", "GenericScene"),
|
|
1001
|
-
"props": scene_binding.get("props", {}),
|
|
1015
|
+
"props": {**scene_binding.get("props", {}), **presenter_props},
|
|
1002
1016
|
"subtitleSegments": subtitle_segments,
|
|
1003
1017
|
"subtitleAtSec": subtitle_at_sec,
|
|
1004
1018
|
"transition": {"type": scene_trans_type, "durationFrames": scene_trans_dur},
|
|
@@ -1046,6 +1060,7 @@ def build_render_plan(dsl: dict, binding: dict) -> dict:
|
|
|
1046
1060
|
"motionPreset": binding.get("globalOverrides", {}).get("motionPreset", "smooth"),
|
|
1047
1061
|
"colorScheme": binding.get("globalOverrides", {}).get("colorScheme", []),
|
|
1048
1062
|
**({"variantId": binding["variantId"]} if binding.get("variantId") else {}),
|
|
1063
|
+
**({"templateConfig": binding["templateConfig"]} if binding.get("templateConfig") else {}),
|
|
1049
1064
|
**(_build_bgm_props(dsl)),
|
|
1050
1065
|
},
|
|
1051
1066
|
},
|
|
@@ -1237,6 +1252,9 @@ def resolve_asset_digital_human(asset: dict, private_token: str, timeout: int) -
|
|
|
1237
1252
|
|
|
1238
1253
|
if payload.get("avatarId"):
|
|
1239
1254
|
cmd.extend(["--avatar-id", str(payload["avatarId"])])
|
|
1255
|
+
if payload.get("videoUrl"):
|
|
1256
|
+
cmd.extend(["--video-url", payload["videoUrl"]])
|
|
1257
|
+
cmd.extend(["--lipsync-mode", payload.get("mode", "speed")])
|
|
1240
1258
|
if payload.get("text"):
|
|
1241
1259
|
cmd.extend(["--text", payload["text"]])
|
|
1242
1260
|
if payload.get("voiceId"):
|
|
@@ -2935,6 +2953,70 @@ def _poll_vod_playback_url(
|
|
|
2935
2953
|
return poll_vod_playback_url(file_id, private_token, max_wait=max_wait, interval=interval)
|
|
2936
2954
|
|
|
2937
2955
|
|
|
2956
|
+
def _select_style(config: dict, style_id) -> tuple[dict, dict]:
|
|
2957
|
+
|
|
2958
|
+
styles = config.get("styles")
|
|
2959
|
+
if not isinstance(styles, list):
|
|
2960
|
+
return config, {}
|
|
2961
|
+
default = config.get("defaultStyle")
|
|
2962
|
+
wanted = style_id or default
|
|
2963
|
+
style = next((s for s in styles if s.get("id") == wanted), None)
|
|
2964
|
+
if style is None:
|
|
2965
|
+
options = ", ".join(s.get("id", "") for s in styles)
|
|
2966
|
+
raise ValueError(f"unknown template style {wanted!r}; options: {options}")
|
|
2967
|
+
snapshot = {k: v for k, v in config.items() if k not in ("styles", "defaultStyle")}
|
|
2968
|
+
snapshot["style"] = style
|
|
2969
|
+
return snapshot, ({} if wanted == default else dict(style.get("tokens") or {}))
|
|
2970
|
+
|
|
2971
|
+
|
|
2972
|
+
def _restyle_plan(render_plan: dict, style_id: str) -> dict:
|
|
2973
|
+
|
|
2974
|
+
from registry_loader import declares_sources, get_template, load_contract
|
|
2975
|
+
|
|
2976
|
+
template = get_template(render_plan.get("templateId") or "", include_all_statuses=True)
|
|
2977
|
+
if not declares_sources(template):
|
|
2978
|
+
raise ValueError(f"template {render_plan.get('templateId')!r} has no visual styles to switch")
|
|
2979
|
+
contract = load_contract(template, None)
|
|
2980
|
+
config = {**(contract.get("templateConfig") or {}), "releaseId": contract.get("releaseId")}
|
|
2981
|
+
snapshot, _ = _select_style(config, style_id)
|
|
2982
|
+
props = render_plan["remotionProps"]["inputProps"]
|
|
2983
|
+
props["templateConfig"] = snapshot
|
|
2984
|
+
props["globalTypography"] = {**(props.get("globalTypography") or {}), **(snapshot["style"].get("tokens") or {})}
|
|
2985
|
+
return render_plan
|
|
2986
|
+
|
|
2987
|
+
|
|
2988
|
+
def _bind_template_config(binding: dict, template: dict, dsl: dict) -> None:
|
|
2989
|
+
|
|
2990
|
+
|
|
2991
|
+
|
|
2992
|
+
|
|
2993
|
+
from registry_loader import declares_sources, load_contract
|
|
2994
|
+
|
|
2995
|
+
if not declares_sources(template):
|
|
2996
|
+
return
|
|
2997
|
+
global_cfg = dsl.get("global") or {}
|
|
2998
|
+
presenter = global_cfg.get("presenter")
|
|
2999
|
+
source = presenter.get("source") if isinstance(presenter, dict) else None
|
|
3000
|
+
try:
|
|
3001
|
+
contract = load_contract(template, source)
|
|
3002
|
+
release = contract.get("releaseId")
|
|
3003
|
+
if contract.get("templateConfig"):
|
|
3004
|
+
binding["templateConfig"], tokens = _select_style(
|
|
3005
|
+
{**contract["templateConfig"], "releaseId": release}, global_cfg.get("templateStyle")
|
|
3006
|
+
)
|
|
3007
|
+
if tokens:
|
|
3008
|
+
|
|
3009
|
+
overrides = binding.setdefault("globalOverrides", {})
|
|
3010
|
+
overrides["typography"] = {**(overrides.get("typography") or {}), **tokens}
|
|
3011
|
+
except Exception as exc:
|
|
3012
|
+
LogPrint(f"❌ cannot load template contract: {exc}", file=sys.stderr)
|
|
3013
|
+
sys.exit(1)
|
|
3014
|
+
lock = global_cfg.get("configLock")
|
|
3015
|
+
if lock and release and lock != release:
|
|
3016
|
+
|
|
3017
|
+
LogPrint(f"⚠️ configLock {lock} is not the current release {release}; using the current config", file=sys.stderr)
|
|
3018
|
+
|
|
3019
|
+
|
|
2938
3020
|
def auto_bind_template(dsl: dict, template_id: str) -> dict:
|
|
2939
3021
|
|
|
2940
3022
|
|
|
@@ -2985,6 +3067,7 @@ def auto_bind_template(dsl: dict, template_id: str) -> dict:
|
|
|
2985
3067
|
selected = match_template.load_full_template(selected)
|
|
2986
3068
|
LogPrint(f"📋 auto-bound template: {selected.get('name', '')} ({template_id})", file=sys.stderr)
|
|
2987
3069
|
binding = match_template.build_binding(selected, dsl)
|
|
3070
|
+
_bind_template_config(binding, selected, dsl)
|
|
2988
3071
|
from required_props import validate_required_props
|
|
2989
3072
|
try:
|
|
2990
3073
|
validate_required_props(selected, binding.get("bindings", []))
|
|
@@ -3049,6 +3132,11 @@ Examples:
|
|
|
3049
3132
|
parser.add_argument("--skip-asset-resolve", action="store_true", help="Skip asset resolution")
|
|
3050
3133
|
parser.add_argument("--save-render-plan", action="store_true", help="Save the RenderPlan to a file")
|
|
3051
3134
|
parser.add_argument("--render-plan-output", default=None, help="RenderPlan output path")
|
|
3135
|
+
parser.add_argument(
|
|
3136
|
+
"--restyle", default=None,
|
|
3137
|
+
help="With --job-id: render that job's plan again in this visual style as a new job. "
|
|
3138
|
+
"Nothing is regenerated, so only the render is billed.",
|
|
3139
|
+
)
|
|
3052
3140
|
parser.add_argument("--job-id", type=int, default=None, help="Render job id (replaces --render-plan; loads the RenderPlan from the database)")
|
|
3053
3141
|
parser.add_argument(
|
|
3054
3142
|
"--save-job",
|
|
@@ -3699,6 +3787,17 @@ def main():
|
|
|
3699
3787
|
dsl = binding = None
|
|
3700
3788
|
if args.job_id is not None and args.job_id > 0:
|
|
3701
3789
|
render_plan = _load_plan_from_job(args.job_id, private_token)
|
|
3790
|
+
if args.restyle:
|
|
3791
|
+
try:
|
|
3792
|
+
render_plan = _restyle_plan(render_plan, args.restyle)
|
|
3793
|
+
except ValueError as exc:
|
|
3794
|
+
LogPrint(f"❌ {exc}", file=sys.stderr)
|
|
3795
|
+
sys.exit(1)
|
|
3796
|
+
_validate_saved_plan(render_plan)
|
|
3797
|
+
|
|
3798
|
+
args.job_id = None
|
|
3799
|
+
args.save_job = True
|
|
3800
|
+
args.skip_asset_resolve = True
|
|
3702
3801
|
elif args.render_plan:
|
|
3703
3802
|
render_plan = _load_plan_from_file(args.render_plan, args.dsl)
|
|
3704
3803
|
else:
|
|
@@ -27,6 +27,10 @@
|
|
|
27
27
|
"minimum": 1,
|
|
28
28
|
"description": "Render job id (positive integer, e.g. 8). This is the integer N parsed from prepare_video_assets's stdout line '📦 render job jobId: N' (also tolerated: legacy zh '📦 渲染任务 jobId: N'). Never pass 0, a placeholder string, or descriptive text."
|
|
29
29
|
},
|
|
30
|
+
"restyle": {
|
|
31
|
+
"type": "string",
|
|
32
|
+
"description": "Render the same video again in another visual style (a template_style id). Reuses the script, assets and timing, so nothing is regenerated. Only for templates with visual styles such as talking-head-studio."
|
|
33
|
+
},
|
|
30
34
|
"save_job": {
|
|
31
35
|
"type": "boolean",
|
|
32
36
|
"description": "Persist the render Manifest back to the database under the same jobId (default true). Auto-degrades to false when PRIV_TOKEN is missing. Pass false to opt out explicitly."
|
|
@@ -471,6 +471,104 @@ def list_templates(*, include_all_statuses: bool = False) -> list[dict]:
|
|
|
471
471
|
return list(load_registry_indexed(include_all_statuses=include_all_statuses).values())
|
|
472
472
|
|
|
473
473
|
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def declares_sources(tpl: Optional[dict]) -> bool:
|
|
480
|
+
caps = (tpl or {}).get("capabilities")
|
|
481
|
+
return isinstance(caps, dict) and isinstance(caps.get("sources"), dict) and bool(caps["sources"])
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _contract_url() -> str:
|
|
485
|
+
if override := os.environ.get("VIDEO_TEMPLATE_CONTRACT_URL"):
|
|
486
|
+
return override
|
|
487
|
+
url = os.environ.get("VIDEO_TEMPLATE_REGISTRY_URL") or _default_registry_url()
|
|
488
|
+
return url.rsplit("/", 1)[0] + "/contract"
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _local_contract(tpl: dict, source: Optional[str]) -> dict:
|
|
492
|
+
|
|
493
|
+
caps = tpl["capabilities"]
|
|
494
|
+
sources = caps["sources"]
|
|
495
|
+
default = caps.get("defaultSource")
|
|
496
|
+
source = source or default
|
|
497
|
+
if source not in sources:
|
|
498
|
+
raise ValueError(f"template {tpl.get('templateId')} has no source {source!r}; options: {sorted(sources)}")
|
|
499
|
+
resolved = {k: v for k, v in caps.items() if k not in ("sources", "defaultSource")}
|
|
500
|
+
for key in sources.get(default) or {}:
|
|
501
|
+
resolved.pop(key, None)
|
|
502
|
+
resolved.update(sources[source])
|
|
503
|
+
return {
|
|
504
|
+
"templateId": tpl.get("templateId"),
|
|
505
|
+
"source": source,
|
|
506
|
+
"sources": sorted(sources),
|
|
507
|
+
"releaseId": f"builtin:{tpl.get('templateId')}@{tpl.get('version')}",
|
|
508
|
+
"capabilities": resolved,
|
|
509
|
+
"llmHint": tpl.get("llmHint"),
|
|
510
|
+
"customPayloadSchema": tpl.get("customPayloadSchema"),
|
|
511
|
+
"templateConfig": tpl.get("templateConfig"),
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _fetch_contract(template_id: str, source: Optional[str]) -> dict:
|
|
516
|
+
url = _contract_url()
|
|
517
|
+
body = json.dumps({"templateId": template_id, **({"source": source} if source else {})}).encode("utf-8")
|
|
518
|
+
headers = {"Accept": "application/json", "Content-Type": "application/json"}
|
|
519
|
+
if token := _resolve_priv_token(url):
|
|
520
|
+
headers["X-Priv-Token"] = token
|
|
521
|
+
req = urllib.request.Request(url, data=body, headers=headers, method="POST")
|
|
522
|
+
try:
|
|
523
|
+
with urllib.request.urlopen(req, timeout=_DEFAULT_HTTP_TIMEOUT) as resp:
|
|
524
|
+
parsed = json.loads(resp.read().decode("utf-8"))
|
|
525
|
+
except urllib.error.HTTPError as e:
|
|
526
|
+
if e.code in (401, 403):
|
|
527
|
+
raise RegistryAuthError(f"HTTP {e.code}") from e
|
|
528
|
+
raise RegistryUnreachableError(f"template contract HTTP {e.code}: {url}") from e
|
|
529
|
+
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as e:
|
|
530
|
+
raise RegistryUnreachableError(f"template contract unreachable: {url} ({e})") from e
|
|
531
|
+
code = parsed.get("code") if isinstance(parsed, dict) else None
|
|
532
|
+
if str(code) in ("401", "403"):
|
|
533
|
+
raise RegistryAuthError(str(parsed.get("msg") or f"code={code}"))
|
|
534
|
+
if code not in (0, "0") or not isinstance(parsed.get("data"), dict):
|
|
535
|
+
|
|
536
|
+
raise ValueError(str(parsed.get("msg") if isinstance(parsed, dict) else parsed))
|
|
537
|
+
return parsed["data"]
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
_contract_cache: dict[tuple[str, Optional[str]], dict] = {}
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def load_contract(tpl: dict, source: Optional[str] = None) -> dict:
|
|
544
|
+
|
|
545
|
+
template_id = str(tpl.get("templateId"))
|
|
546
|
+
key = (template_id, source)
|
|
547
|
+
if key not in _contract_cache:
|
|
548
|
+
registry_url = os.environ.get("VIDEO_TEMPLATE_REGISTRY_URL", "")
|
|
549
|
+
local = registry_url.startswith("file:") and not os.environ.get("VIDEO_TEMPLATE_CONTRACT_URL")
|
|
550
|
+
_contract_cache[key] = _local_contract(tpl, source) if local else _fetch_contract(template_id, source)
|
|
551
|
+
return _contract_cache[key]
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def template_for_source(tpl: Optional[dict], source: Optional[str] = None) -> Optional[dict]:
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
if not declares_sources(tpl):
|
|
560
|
+
if source:
|
|
561
|
+
raise ValueError(f"template {(tpl or {}).get('templateId')} does not declare presenter sources")
|
|
562
|
+
return tpl
|
|
563
|
+
contract = load_contract(tpl, source)
|
|
564
|
+
view = dict(tpl)
|
|
565
|
+
view["capabilities"] = contract.get("capabilities") or {}
|
|
566
|
+
for key in ("llmHint", "customPayloadSchema"):
|
|
567
|
+
if contract.get(key) is not None:
|
|
568
|
+
view[key] = contract[key]
|
|
569
|
+
return view
|
|
570
|
+
|
|
571
|
+
|
|
474
572
|
def invalidate_cache() -> None:
|
|
475
573
|
|
|
476
574
|
|
|
@@ -478,3 +576,4 @@ def invalidate_cache() -> None:
|
|
|
478
576
|
|
|
479
577
|
|
|
480
578
|
_indexed_view.cache_clear()
|
|
579
|
+
_contract_cache.clear()
|
|
@@ -168,6 +168,32 @@ def step_alignment_field(tpl: Optional[dict]) -> Optional[str]:
|
|
|
168
168
|
return field if isinstance(field, str) and field else None
|
|
169
169
|
|
|
170
170
|
|
|
171
|
+
def _dsl_source(dsl: dict) -> Optional[str]:
|
|
172
|
+
presenter = (dsl.get("global") or {}).get("presenter")
|
|
173
|
+
source = presenter.get("source") if isinstance(presenter, dict) else None
|
|
174
|
+
return source if isinstance(source, str) and source else None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _template_for_dsl(dsl: dict) -> Optional[dict]:
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
tpl = _get_template_cfg(_pick_template_id(dsl))
|
|
184
|
+
source = _dsl_source(dsl)
|
|
185
|
+
if not tpl or not source:
|
|
186
|
+
return tpl
|
|
187
|
+
caps = tpl.get("capabilities")
|
|
188
|
+
if not isinstance(caps, dict) or source == caps.get("defaultSource"):
|
|
189
|
+
return tpl
|
|
190
|
+
try:
|
|
191
|
+
from registry_loader import template_for_source
|
|
192
|
+
return template_for_source(tpl, source)
|
|
193
|
+
except Exception:
|
|
194
|
+
return tpl
|
|
195
|
+
|
|
196
|
+
|
|
171
197
|
def _get_template_cfg(template_id: Optional[str]) -> Optional[dict]:
|
|
172
198
|
|
|
173
199
|
|
|
@@ -538,7 +564,7 @@ def _check_slide_id_chosen(dsl: dict) -> list[ValidationError]:
|
|
|
538
564
|
|
|
539
565
|
|
|
540
566
|
|
|
541
|
-
tpl =
|
|
567
|
+
tpl = _template_for_dsl(dsl)
|
|
542
568
|
if not tpl:
|
|
543
569
|
return []
|
|
544
570
|
schema = tpl.get("customPayloadSchema")
|
|
@@ -611,7 +637,7 @@ def _check_slide_cross_field(dsl: dict) -> list[ValidationError]:
|
|
|
611
637
|
|
|
612
638
|
|
|
613
639
|
|
|
614
|
-
tpl =
|
|
640
|
+
tpl = _template_for_dsl(dsl)
|
|
615
641
|
if not tpl:
|
|
616
642
|
return []
|
|
617
643
|
schemas = tpl.get("slideSchemas")
|
|
@@ -712,7 +738,7 @@ def _check_line_sync_narration(dsl: dict) -> list[ValidationError]:
|
|
|
712
738
|
|
|
713
739
|
|
|
714
740
|
|
|
715
|
-
tpl =
|
|
741
|
+
tpl = _template_for_dsl(dsl)
|
|
716
742
|
if not tpl:
|
|
717
743
|
return []
|
|
718
744
|
line_sync = line_sync_slides(tpl)
|
|
@@ -789,7 +815,7 @@ def _check_step_narration_alignment(dsl: dict) -> list[ValidationError]:
|
|
|
789
815
|
|
|
790
816
|
|
|
791
817
|
|
|
792
|
-
tpl =
|
|
818
|
+
tpl = _template_for_dsl(dsl)
|
|
793
819
|
if not tpl:
|
|
794
820
|
return []
|
|
795
821
|
field = step_alignment_field(tpl)
|
|
@@ -871,7 +897,7 @@ def _check_duration_estimate(dsl: dict) -> list[ValidationError]:
|
|
|
871
897
|
narration_cfg = ((dsl.get("global") or {}).get("narration") or {})
|
|
872
898
|
speed = narration_cfg.get("speed")
|
|
873
899
|
speed = float(speed) if isinstance(speed, (int, float)) and not isinstance(speed, bool) and speed > 0 else 1.0
|
|
874
|
-
tail = nb.template_tail_pad_sec(
|
|
900
|
+
tail = nb.template_tail_pad_sec(_template_for_dsl(dsl))
|
|
875
901
|
|
|
876
902
|
total = 0.0
|
|
877
903
|
narrated = 0
|
|
@@ -925,7 +951,7 @@ def _check_slide_payload_fields(dsl: dict) -> list[ValidationError]:
|
|
|
925
951
|
|
|
926
952
|
|
|
927
953
|
|
|
928
|
-
tpl =
|
|
954
|
+
tpl = _template_for_dsl(dsl)
|
|
929
955
|
if not tpl:
|
|
930
956
|
return []
|
|
931
957
|
schemas = tpl.get("slideSchemas")
|
|
@@ -988,7 +1014,7 @@ def _check_custom_payload_fields(dsl: dict) -> list[ValidationError]:
|
|
|
988
1014
|
|
|
989
1015
|
|
|
990
1016
|
|
|
991
|
-
tpl =
|
|
1017
|
+
tpl = _template_for_dsl(dsl)
|
|
992
1018
|
if not tpl:
|
|
993
1019
|
return []
|
|
994
1020
|
from .payload_contract import required_payload_errors
|
|
@@ -1240,7 +1266,7 @@ def _check_narration_language_strict(dsl: dict) -> list[ValidationError]:
|
|
|
1240
1266
|
|
|
1241
1267
|
|
|
1242
1268
|
|
|
1243
|
-
tpl =
|
|
1269
|
+
tpl = _template_for_dsl(dsl)
|
|
1244
1270
|
if not tpl:
|
|
1245
1271
|
return []
|
|
1246
1272
|
strict = (tpl.get("capabilities") or {}).get("narrationLanguageStrict")
|
|
@@ -1273,12 +1299,31 @@ def _check_narration_language_strict(dsl: dict) -> list[ValidationError]:
|
|
|
1273
1299
|
_TEXT_LAYER_SKELETON_PREFIXES = ("[骨架待填充]", "[skeleton placeholder]")
|
|
1274
1300
|
|
|
1275
1301
|
|
|
1302
|
+
def _check_presenter_source(dsl: dict) -> list[ValidationError]:
|
|
1303
|
+
|
|
1304
|
+
source = _dsl_source(dsl)
|
|
1305
|
+
if not source:
|
|
1306
|
+
return []
|
|
1307
|
+
tpl = _get_template_cfg(_pick_template_id(dsl))
|
|
1308
|
+
if tpl is None:
|
|
1309
|
+
return []
|
|
1310
|
+
caps = tpl.get("capabilities")
|
|
1311
|
+
sources = caps.get("sources") if isinstance(caps, dict) else None
|
|
1312
|
+
if not isinstance(sources, dict) or source not in sources:
|
|
1313
|
+
options = ", ".join(sorted(sources)) if isinstance(sources, dict) else "none"
|
|
1314
|
+
return [ValidationError(
|
|
1315
|
+
"global.presenter.source",
|
|
1316
|
+
f"unknown presenter source {source!r} for this template; options: {options}",
|
|
1317
|
+
)]
|
|
1318
|
+
return []
|
|
1319
|
+
|
|
1320
|
+
|
|
1276
1321
|
def _check_presenter_visibility(dsl: dict) -> list[ValidationError]:
|
|
1277
1322
|
|
|
1278
1323
|
|
|
1279
1324
|
|
|
1280
1325
|
|
|
1281
|
-
tpl =
|
|
1326
|
+
tpl = _template_for_dsl(dsl) or {}
|
|
1282
1327
|
caps = tpl.get("capabilities")
|
|
1283
1328
|
caps = caps if isinstance(caps, dict) else {}
|
|
1284
1329
|
spec = caps.get("digitalHuman") or {}
|
|
@@ -1325,7 +1370,7 @@ def _check_text_layer_rules(dsl: dict) -> list[ValidationError]:
|
|
|
1325
1370
|
|
|
1326
1371
|
|
|
1327
1372
|
|
|
1328
|
-
tpl =
|
|
1373
|
+
tpl = _template_for_dsl(dsl)
|
|
1329
1374
|
if not tpl:
|
|
1330
1375
|
return []
|
|
1331
1376
|
rules = (tpl.get("capabilities") or {}).get("textLayerRules")
|
|
@@ -1436,6 +1481,7 @@ def validate_integrity(dsl: dict) -> list[ValidationError]:
|
|
|
1436
1481
|
errors += _check_step_narration_alignment(dsl)
|
|
1437
1482
|
errors += _check_slide_cross_field(dsl)
|
|
1438
1483
|
errors += _check_text_layer_rules(dsl)
|
|
1484
|
+
errors += _check_presenter_source(dsl)
|
|
1439
1485
|
errors += _check_presenter_visibility(dsl)
|
|
1440
1486
|
|
|
1441
1487
|
errors += _check_duration_estimate(dsl)
|
|
@@ -1474,6 +1520,7 @@ def validate_dsl(dsl: dict) -> list[ValidationError]:
|
|
|
1474
1520
|
errors += _check_step_narration_alignment(dsl)
|
|
1475
1521
|
errors += _check_slide_cross_field(dsl)
|
|
1476
1522
|
errors += _check_text_layer_rules(dsl)
|
|
1523
|
+
errors += _check_presenter_source(dsl)
|
|
1477
1524
|
errors += _check_presenter_visibility(dsl)
|
|
1478
1525
|
|
|
1479
1526
|
errors += _check_duration_estimate(dsl)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
_SENTENCE_END = re.compile(r"(?<=[。!?!?;;])")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _spread_sentences(text: str, start_ms: int, end_ms: int) -> list[dict]:
|
|
16
|
+
|
|
17
|
+
pieces = [p.strip() for p in _SENTENCE_END.split(text) if p.strip()]
|
|
18
|
+
total = sum(len(p) for p in pieces) or 1
|
|
19
|
+
out = []
|
|
20
|
+
cursor = float(start_ms)
|
|
21
|
+
for piece in pieces:
|
|
22
|
+
span = (end_ms - start_ms) * len(piece) / total
|
|
23
|
+
out.append({"text": piece, "startMs": round(cursor), "endMs": round(cursor + span), "words": []})
|
|
24
|
+
cursor += span
|
|
25
|
+
return out
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _split_by_words(utterance: dict) -> list[dict]:
|
|
29
|
+
|
|
30
|
+
out: list[dict] = []
|
|
31
|
+
current: list[dict] = []
|
|
32
|
+
for word in utterance["words"]:
|
|
33
|
+
current.append({"text": word["text"], "startMs": word["startTime"], "endMs": word["endTime"]})
|
|
34
|
+
if _SENTENCE_END.search(word["text"] + " ") or word["text"][-1:] in "。!?!?;;":
|
|
35
|
+
out.append(current)
|
|
36
|
+
current = []
|
|
37
|
+
if current:
|
|
38
|
+
out.append(current)
|
|
39
|
+
return [
|
|
40
|
+
{"text": "".join(w["text"] for w in s), "startMs": s[0]["startMs"], "endMs": s[-1]["endMs"], "words": s}
|
|
41
|
+
for s in out
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def transcript_from_asr(asr: dict) -> list[dict]:
|
|
46
|
+
|
|
47
|
+
sentences: list[dict] = []
|
|
48
|
+
for utterance in asr.get("utterances") or []:
|
|
49
|
+
if utterance.get("words"):
|
|
50
|
+
sentences += _split_by_words(utterance)
|
|
51
|
+
elif utterance.get("text"):
|
|
52
|
+
sentences += _spread_sentences(utterance["text"], utterance.get("startTime", 0), utterance.get("endTime", 0))
|
|
53
|
+
return sentences
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def plan_speech_scenes(sentences: list[dict], video_sec: float, count: int, fps: int) -> list[dict]:
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
if not sentences:
|
|
62
|
+
return []
|
|
63
|
+
count = max(1, min(count, len(sentences)))
|
|
64
|
+
total = sum(len(s["text"]) for s in sentences)
|
|
65
|
+
groups: list[list[dict]] = [[]]
|
|
66
|
+
seen = 0
|
|
67
|
+
for i, sentence in enumerate(sentences):
|
|
68
|
+
remaining_sentences = len(sentences) - i
|
|
69
|
+
remaining_groups = count - len(groups)
|
|
70
|
+
boundary = total * len(groups) / count
|
|
71
|
+
if groups[-1] and remaining_groups > 0 and (seen >= boundary or remaining_sentences <= remaining_groups):
|
|
72
|
+
groups.append([])
|
|
73
|
+
groups[-1].append(sentence)
|
|
74
|
+
seen += len(sentence["text"])
|
|
75
|
+
total_frames = max(round(video_sec * fps), round(sentences[-1]["endMs"] / 1000 * fps))
|
|
76
|
+
scenes = []
|
|
77
|
+
for i, group in enumerate(groups):
|
|
78
|
+
start = 0 if i == 0 else round(group[0]["startMs"] / 1000 * fps)
|
|
79
|
+
end = total_frames if i == len(groups) - 1 else round(groups[i + 1][0]["startMs"] / 1000 * fps)
|
|
80
|
+
scenes.append({"startFrame": start, "endFrame": end, "text": "".join(s["text"] for s in group)})
|
|
81
|
+
return scenes
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def subtitles_between(sentences: list[dict], start_frame: int, end_frame: int, fps: int) -> list[dict]:
|
|
85
|
+
|
|
86
|
+
to_frame = lambda ms: round(ms / 1000 * fps)
|
|
87
|
+
segments = []
|
|
88
|
+
for index, sentence in enumerate(sentences):
|
|
89
|
+
start = to_frame(sentence["startMs"])
|
|
90
|
+
if not start_frame <= start < end_frame:
|
|
91
|
+
continue
|
|
92
|
+
segment = {
|
|
93
|
+
"text": sentence["text"],
|
|
94
|
+
"startFrame": start,
|
|
95
|
+
"endFrame": min(max(to_frame(sentence["endMs"]), start + 1), end_frame),
|
|
96
|
+
"sentenceIndex": index,
|
|
97
|
+
}
|
|
98
|
+
if sentence.get("words"):
|
|
99
|
+
segment["words"] = [
|
|
100
|
+
{"text": w["text"], "startFrame": to_frame(w["startMs"]), "endFrame": to_frame(w["endMs"])}
|
|
101
|
+
for w in sentence["words"]
|
|
102
|
+
]
|
|
103
|
+
segments.append(segment)
|
|
104
|
+
return segments
|
|
@@ -442,6 +442,7 @@ def compile_timeline(dsl: dict, binding: dict) -> dict:
|
|
|
442
442
|
"colorScheme": binding.get("globalOverrides", {}).get("colorScheme", []),
|
|
443
443
|
**({"themeConfig": binding["themeConfig"]} if binding.get("themeConfig") else {}),
|
|
444
444
|
**({"variantId": binding["variantId"]} if binding.get("variantId") else {}),
|
|
445
|
+
**({"templateConfig": binding["templateConfig"]} if binding.get("templateConfig") else {}),
|
|
445
446
|
},
|
|
446
447
|
},
|
|
447
448
|
"errors": [],
|