@remixmate/cli 0.13.4 → 0.13.5

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.
@@ -1,19 +1,19 @@
1
- var w=Object.defineProperty;var l=(e,o)=>w(e,"name",{value:o,configurable:!0});import{mmPost as m,resolveHttpContext as v,SkillError as c}from"../http.js";import{getCapabilities as _}from"../capabilities.js";import{emitProgress as y}from"../progress.js";import{isTrue as u,toNumber as $}from"./shared.js";const b=[{id:"Chinese (Mandarin)_Male_Announcer",language:"zh",name:"Mandarin Male Announcer (legacy default)"},{id:"english-male-friendly-01",language:"en",name:"Friendly Male (English, TBD)"}];async function k(e){const t=(await m(e,"/voice/page",{current:1,pageSize:100},{timeoutMs:3e4})).list??[];if(t.length===0){process.stdout.write(`⚠️ No voices available
1
+ var w=Object.defineProperty;var d=(e,i)=>w(e,"name",{value:i,configurable:!0});import{mmPost as m,resolveHttpContext as v,SkillError as c}from"../http.js";import{getCapabilities as y}from"../capabilities.js";import{emitProgress as x}from"../progress.js";import{isTrue as u,toNumber as _}from"./shared.js";const b=[{id:"Chinese (Mandarin)_Male_Announcer",language:"zh",name:"Mandarin Male Announcer (legacy default)"},{id:"english-male-friendly-01",language:"en",name:"Friendly Male (English, TBD)"}];async function $(e){const o=(await m(e,"/voice/page",{current:1,pageSize:100},{timeoutMs:3e4})).list??[];if(o.length===0){process.stdout.write(`⚠️ No voices available
2
2
  `);return}process.stdout.write(`
3
3
  ${"Voice ID".padEnd(30)} Name
4
4
  `),process.stdout.write(`${"-".repeat(30)} ${"-".repeat(20)}
5
- `);for(const s of t)process.stdout.write(`${(s.uniqId??"").padEnd(30)} ${s.name??""}
5
+ `);for(const s of o)process.stdout.write(`${(s.uniqId??"").padEnd(30)} ${s.name??""}
6
6
  `);process.stdout.write(`
7
- ${t.length} voice(s) available
7
+ ${o.length} voice(s) available
8
8
  `),process.stdout.write(`💡 Use --voice-id <id> to select a voice for synthesis
9
- `)}l(k,"listVoicesRemote");function x(){process.stderr.write(`⚠️ --local prints a hardcoded 2-entry fallback list, NOT the available voices. Drop --local to query the live catalog.
9
+ `)}d($,"listVoicesRemote");function k(){process.stderr.write(`⚠️ --local prints a hardcoded 2-entry fallback list, NOT the available voices. Drop --local to query the live catalog.
10
10
  `);for(const e of b)process.stdout.write(`${e.id} ${e.language} ${e.name}
11
- `)}l(x,"listVoicesLocal");async function E(e,o){if(u(e.list_voices)){if(u(e.local))return x();const a=await v(o.skillName,{apiBaseUrl:e.api_base_url,privateToken:e.priv_token,preflightToken:o.auth?.token});return k(a)}const t=e.text;if(!t||typeof t!="string"||!t.trim())throw new c("❌ Please provide --text to specify the text to synthesize");const s=e.api_base_url,i=(await _(s)).voice,p=e.voice_id??i.defaultVoiceId,r=$(e.speed,"speed")??1;if(r<i.speedMin||r>i.speedMax)throw new c(`❌ speed must be in the range ${i.speedMin}~${i.speedMax}`);const g=e.language_boost?.trim()||void 0,h=await v(o.skillName,{apiBaseUrl:s,privateToken:e.priv_token,preflightToken:o.auth?.token});y({phase:"gen-voice:request",voiceId:p,speed:r});const n=await m(h,"/tool/tts",{provider:i.provider,text:t,voiceSetting:{voiceId:p,speed:r},outputFormat:"url",...g?{languageBoost:g}:{}},{timeoutMs:12e4}),d=n.audio;if(!d)throw new c(`❌ API response did not contain an audio URL: ${JSON.stringify(n)}`);if(u(e.json_output)){process.stdout.write(JSON.stringify({url:d,audio_length_ms:n.extraInfo?.audioLength??null,subtitles:(n.subtitles??[]).map(a=>({text:a.text??"",timeBegin:a.timeBegin??0,timeEnd:a.timeEnd??0}))})+`
12
- `);return}const f=n.extraInfo?.audioLength;typeof f=="number"&&process.stdout.write(`
11
+ `)}d(k,"listVoicesLocal");async function L(e,i){if(u(e.list_voices)){if(u(e.local))return k();const t=await v(i.skillName,{apiBaseUrl:e.api_base_url,privateToken:e.priv_token,preflightToken:i.auth?.token});return $(t)}const o=e.text;if(!o||typeof o!="string"||!o.trim())throw new c("❌ Please provide --text to specify the text to synthesize");const s=e.api_base_url,n=(await y(s)).voice,p=e.voice_id??n.defaultVoiceId,r=_(e.speed,"speed")??1;if(r<n.speedMin||r>n.speedMax)throw new c(`❌ speed must be in the range ${n.speedMin}~${n.speedMax}`);const g=e.language_boost?.trim()||void 0,h=await v(i.skillName,{apiBaseUrl:s,privateToken:e.priv_token,preflightToken:i.auth?.token});x({phase:"gen-voice:request",voiceId:p,speed:r});const a=await m(h,"/tool/tts",{provider:n.provider,text:o,voiceSetting:{voiceId:p,speed:r},outputFormat:"url",...g?{languageBoost:g}:{}},{timeoutMs:12e4}),l=a.audio;if(!l)throw new c(`❌ API response did not contain an audio URL: ${JSON.stringify(a)}`);if(u(e.json_output)){process.stdout.write(JSON.stringify({url:l,audio_length_ms:a.extraInfo?.audioLength??null,subtitles:(a.subtitles??[]).map(t=>({text:t.text??"",timeBegin:t.timeBegin??0,timeEnd:t.timeEnd??0,...typeof t.textBegin=="number"&&typeof t.textEnd=="number"?{textBegin:t.textBegin,textEnd:t.textEnd}:{}}))})+`
12
+ `);return}const f=a.extraInfo?.audioLength;typeof f=="number"&&process.stdout.write(`
13
13
  🕐 Audio length: ${(f/1e3).toFixed(1)} s
14
14
  `),process.stdout.write(`
15
15
  🔗 Audio URL (plays directly, no download needed):
16
- ${d}
16
+ ${l}
17
17
  `),process.stdout.write(`
18
18
  🎉 TTS synthesis done!
19
- `)}l(E,"genVoice");export{E as genVoice};
19
+ `)}d(L,"genVoice");export{L as genVoice};
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.13.4",
4
- "generatedAt": "2026-10-26T01:31:08.439Z",
3
+ "version": "0.13.5",
4
+ "generatedAt": "2026-10-28T14:51:31.746Z",
5
5
  "skills": [
6
6
  {
7
7
  "id": "doc-parse",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remixmate/cli",
3
- "version": "0.13.4",
3
+ "version": "0.13.5",
4
4
  "description": "AI media generation skills for Claude Code / Codex — 12 skills covering image, video, voice, digital human, web screenshot, web recording, script, template registry, rendering, Jianying export, and video deconstruction.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -0,0 +1,468 @@
1
+
2
+
3
+
4
+
5
+
6
+
7
+
8
+
9
+
10
+
11
+
12
+
13
+
14
+
15
+
16
+
17
+
18
+
19
+
20
+
21
+
22
+ from __future__ import annotations
23
+
24
+ import os
25
+ import re
26
+ import subprocess
27
+ import unicodedata
28
+ from typing import Optional
29
+
30
+ MODE_CONTINUOUS = "continuous"
31
+ MODE_PER_SCENE = "perScene"
32
+
33
+
34
+
35
+ DEFAULT_SCENE_PAUSE_SEC = 0.8
36
+
37
+
38
+
39
+ MAX_RUN_CHARS = 1500
40
+
41
+
42
+
43
+
44
+ ESTIMATE_WINDOW_SEC = 2.0
45
+ _ESTIMATE_WINDOW_RATIO = 0.2
46
+
47
+
48
+ _NATURAL_PAUSE_SEC = 0.45
49
+
50
+
51
+ _MIN_BOUNDARY_PAUSE_RATIO = 0.75
52
+
53
+ _SILENCE_NOISE_DB = -40
54
+ _SILENCE_MIN_SEC = 0.25
55
+
56
+ PAUSE_TAG_RE = re.compile(r"<#\s*(\d+(?:\.\d+)?)\s*#>")
57
+ _LEADING_TAG_RE = re.compile(r"^\s*<#\s*(\d+(?:\.\d+)?)\s*#>\s*")
58
+ _TRAILING_TAG_RE = re.compile(r"\s*<#\s*(\d+(?:\.\d+)?)\s*#>\s*$")
59
+
60
+
61
+
62
+
63
+ def _pause_value(raw) -> Optional[float]:
64
+ if isinstance(raw, bool) or not isinstance(raw, (int, float)):
65
+ return None
66
+
67
+ return min(max(float(raw), 0.01), 99.99) if raw > 0 else None
68
+
69
+
70
+ def resolve_config(template: dict, override: Optional[dict]) -> dict:
71
+
72
+
73
+
74
+
75
+ declared = ((template or {}).get("capabilities") or {}).get("narration") or {}
76
+ override = override or {}
77
+ mode = MODE_PER_SCENE
78
+ for source in (declared, override):
79
+ if source.get("mode") in (MODE_CONTINUOUS, MODE_PER_SCENE):
80
+ mode = source["mode"]
81
+ pause = DEFAULT_SCENE_PAUSE_SEC
82
+ for source in (declared, override):
83
+ value = _pause_value(source.get("scenePauseSec"))
84
+ if value is not None:
85
+ pause = value
86
+ return {"mode": mode, "scenePauseSec": pause}
87
+
88
+
89
+
90
+
91
+ def group_runs(candidates: list[Optional[dict]], max_chars: int = MAX_RUN_CHARS) -> list[list[dict]]:
92
+
93
+
94
+
95
+
96
+
97
+
98
+ runs: list[list[dict]] = []
99
+ current: list[dict] = []
100
+ chars = 0
101
+
102
+ def flush() -> None:
103
+ if len(current) >= 2:
104
+ runs.append(list(current))
105
+ current.clear()
106
+
107
+ for cand in candidates:
108
+ if cand is None:
109
+ flush()
110
+ chars = 0
111
+ continue
112
+ size = len(cand["text"])
113
+ if current and (
114
+ cand.get("break")
115
+ or cand["voiceKey"] != current[-1]["voiceKey"]
116
+ or chars + size > max_chars
117
+ ):
118
+ flush()
119
+ chars = 0
120
+ current.append(cand)
121
+ chars += size
122
+ flush()
123
+ return runs
124
+
125
+
126
+
127
+
128
+ def format_pause(sec: float) -> str:
129
+ return f"{sec:.2f}".rstrip("0").rstrip(".")
130
+
131
+
132
+ def compose_run_text(texts: list[str], pause_sec: float) -> tuple[str, list[tuple[int, int]]]:
133
+
134
+
135
+
136
+
137
+ parts: list[str] = []
138
+ for i, text in enumerate(texts):
139
+ text = text.strip()
140
+ if i > 0:
141
+ text = _LEADING_TAG_RE.sub("", text, count=1)
142
+ if i < len(texts) - 1:
143
+ text = _TRAILING_TAG_RE.sub("", text, count=1)
144
+ parts.append(text)
145
+
146
+ pauses: list[float] = []
147
+ for i in range(len(texts) - 1):
148
+ candidates = [pause_sec]
149
+ tail = _TRAILING_TAG_RE.search(texts[i].strip())
150
+ head = _LEADING_TAG_RE.search(texts[i + 1].strip())
151
+ for m in (tail, head):
152
+ if m:
153
+ candidates.append(_pause_value(float(m.group(1))) or 0)
154
+ pauses.append(max(candidates))
155
+
156
+ out = ""
157
+ ranges: list[tuple[int, int]] = []
158
+ for i, part in enumerate(parts):
159
+ if i > 0:
160
+ out += f"\n<#{format_pause(pauses[i - 1])}#>\n"
161
+ start = len(out)
162
+ out += part
163
+ ranges.append((start, len(out)))
164
+ return out, ranges
165
+
166
+
167
+ def normalize(text: str) -> str:
168
+
169
+ text = PAUSE_TAG_RE.sub("", text or "")
170
+ return "".join(
171
+ ch for ch in text
172
+ if not ch.isspace() and not unicodedata.category(ch).startswith(("P", "S"))
173
+ )
174
+
175
+
176
+ def estimate_durations(texts: list[str], total_ms: float, pause_sec: float) -> list[float]:
177
+
178
+
179
+
180
+
181
+ counts = [max(len(normalize(t)), 1) for t in texts]
182
+ boundaries = len(texts) - 1
183
+ gap_ms = (pause_sec + _NATURAL_PAUSE_SEC) * 1000
184
+ speech_ms = total_ms - boundaries * gap_ms
185
+ if speech_ms <= 0:
186
+ gap_ms, speech_ms = 0.0, total_ms
187
+ per_char = speech_ms / sum(counts)
188
+ durations: list[float] = []
189
+ for k, count in enumerate(counts):
190
+ edges = (k > 0) + (k < boundaries)
191
+ durations.append(count * per_char + edges * gap_ms / 2)
192
+ return durations
193
+
194
+
195
+
196
+
197
+ def _sub_mid(subs: list[dict], i: int) -> float:
198
+ return (float(subs[i]["timeEnd"]) + float(subs[i + 1]["timeBegin"])) / 2
199
+
200
+
201
+ def cuts_from_text_offsets(subs: list[dict], ranges: list[tuple[int, int]]) -> Optional[list[float]]:
202
+
203
+
204
+ if len(subs) < 2 or not all(
205
+ isinstance(s.get("textBegin"), int) and isinstance(s.get("textEnd"), int) for s in subs
206
+ ):
207
+ return None
208
+ cuts: list[float] = []
209
+ for k in range(1, len(ranges)):
210
+ prev_end, next_start = ranges[k - 1][1], ranges[k][0]
211
+ hit = next(
212
+ (
213
+ i for i in range(len(subs) - 1)
214
+ if subs[i]["textEnd"] <= next_start
215
+ and subs[i + 1]["textBegin"] >= prev_end
216
+ and subs[i + 1]["textBegin"] >= subs[i]["textEnd"]
217
+ ),
218
+ None,
219
+ )
220
+ if hit is None:
221
+ return None
222
+ cuts.append(_sub_mid(subs, hit))
223
+ return cuts if _monotonic(cuts) else None
224
+
225
+
226
+ def speakable_ranges(run_text: str, ranges: list[tuple[int, int]]) -> list[tuple[int, int]]:
227
+
228
+
229
+
230
+
231
+
232
+
233
+ keep = [ch not in "\r\n" for ch in run_text]
234
+ for m in PAUSE_TAG_RE.finditer(run_text):
235
+ for i in range(m.start(), m.end()):
236
+ keep[i] = False
237
+ prefix = [0]
238
+ for k in keep:
239
+ prefix.append(prefix[-1] + k)
240
+ return [(prefix[a], prefix[b]) for a, b in ranges]
241
+
242
+
243
+ def cuts_from_text_alignment(subs: list[dict], texts: list[str]) -> Optional[list[float]]:
244
+
245
+
246
+ if len(subs) < 2:
247
+ return None
248
+ sub_acc: list[int] = []
249
+ acc = 0
250
+ for s in subs:
251
+ acc += len(normalize(s.get("text") or ""))
252
+ sub_acc.append(acc)
253
+ if acc != sum(len(normalize(t)) for t in texts):
254
+ return None
255
+ cuts: list[float] = []
256
+ scene_acc = 0
257
+ for k in range(len(texts) - 1):
258
+ scene_acc += len(normalize(texts[k]))
259
+ try:
260
+ i = sub_acc.index(scene_acc)
261
+ except ValueError:
262
+ return None
263
+ if i >= len(subs) - 1:
264
+ return None
265
+ cuts.append(_sub_mid(subs, i))
266
+ return cuts if _monotonic(cuts) else None
267
+
268
+
269
+ def cuts_from_silences(
270
+ silences: list[tuple[float, float]],
271
+ texts: list[str],
272
+ total_ms: float,
273
+ pause_sec: float,
274
+ ) -> Optional[list[float]]:
275
+
276
+ need = len(texts) - 1
277
+ if need <= 0:
278
+ return []
279
+
280
+ inner = [s for s in silences if 0 < s[0] and s[1] < total_ms]
281
+ if len(inner) < need:
282
+ return None
283
+ longest = sorted(inner, key=lambda s: s[1] - s[0], reverse=True)[:need]
284
+ longest.sort(key=lambda s: s[0])
285
+ min_len = pause_sec * _MIN_BOUNDARY_PAUSE_RATIO * 1000
286
+ if any(end - start < min_len for start, end in longest):
287
+ return None
288
+ cuts = [(start + end) / 2 for start, end in longest]
289
+ if not _durations_plausible(cuts, texts, total_ms, pause_sec):
290
+ return None
291
+ return cuts if _monotonic(cuts) else None
292
+
293
+
294
+ def _monotonic(cuts: list[float]) -> bool:
295
+ return all(b > a for a, b in zip(cuts, cuts[1:])) and all(c > 0 for c in cuts)
296
+
297
+
298
+ def _durations_plausible(cuts: list[float], texts: list[str], total_ms: float, pause_sec: float) -> bool:
299
+
300
+ edges = [0.0, *cuts, float(total_ms)]
301
+ for k, predicted in enumerate(estimate_durations(texts, total_ms, pause_sec)):
302
+ actual = edges[k + 1] - edges[k]
303
+ if abs(actual - predicted) > max(ESTIMATE_WINDOW_SEC * 1000, predicted * _ESTIMATE_WINDOW_RATIO):
304
+ return False
305
+ return True
306
+
307
+
308
+ def _cuts_on_boundary_pauses(cuts: list[float], silences: list[tuple[float, float]], pause_sec: float) -> bool:
309
+
310
+
311
+
312
+ min_len = pause_sec * _MIN_BOUNDARY_PAUSE_RATIO * 1000
313
+ tol = 100.0
314
+ return all(
315
+ any(end - start >= min_len and start - tol <= c <= end + tol for start, end in silences)
316
+ for c in cuts
317
+ )
318
+
319
+
320
+ def find_cuts(
321
+ texts: list[str],
322
+ ranges: list[tuple[int, int]],
323
+ subs: list[dict],
324
+ silences: Optional[list[tuple[float, float]]],
325
+ total_ms: float,
326
+ pause_sec: float,
327
+ run_text: Optional[str] = None,
328
+ ) -> tuple[Optional[list[float]], str]:
329
+
330
+
331
+
332
+
333
+ def trusted(cuts: Optional[list[float]]) -> bool:
334
+ return (
335
+ cuts is not None
336
+ and _durations_plausible(cuts, texts, total_ms, pause_sec)
337
+ and (silences is None or _cuts_on_boundary_pauses(cuts, silences, pause_sec))
338
+ )
339
+
340
+ candidates = [speakable_ranges(run_text, ranges)] if run_text is not None else []
341
+ for rs in [*candidates, ranges]:
342
+ cuts = cuts_from_text_offsets(subs, rs)
343
+ if trusted(cuts):
344
+ return cuts, "subtitle-offsets"
345
+ cuts = cuts_from_text_alignment(subs, texts)
346
+ if trusted(cuts):
347
+ return cuts, "subtitle-text"
348
+ if silences is not None:
349
+ cuts = cuts_from_silences(silences, texts, total_ms, pause_sec)
350
+ if cuts is not None:
351
+ return cuts, "silence"
352
+ return None, "none"
353
+
354
+
355
+
356
+
357
+ def slice_bounds(cuts: list[float], total_ms: float) -> list[tuple[float, float]]:
358
+ edges = [0.0, *cuts, float(total_ms)]
359
+ return list(zip(edges, edges[1:]))
360
+
361
+
362
+ def speech_span(bounds: tuple[float, float], silences: list[tuple[float, float]]) -> tuple[int, int]:
363
+
364
+ start, end = bounds
365
+ lo, hi = start, end
366
+ for s_start, s_end in silences:
367
+ if s_start <= lo < s_end:
368
+ lo = min(s_end, end)
369
+ if s_start < hi <= s_end:
370
+ hi = max(s_start, lo)
371
+ return int(round(lo - start)), int(round(hi - start))
372
+
373
+
374
+ def split_subtitles(subs: list[dict], bounds: list[tuple[float, float]]) -> list[list[dict]]:
375
+
376
+ per_scene: list[list[dict]] = [[] for _ in bounds]
377
+ for s in subs:
378
+ mid = (float(s.get("timeBegin", 0)) + float(s.get("timeEnd", 0))) / 2
379
+ for k, (start, end) in enumerate(bounds):
380
+ if start <= mid < end or (k == len(bounds) - 1 and mid >= start):
381
+ length = end - start
382
+ per_scene[k].append({
383
+ "text": s.get("text", ""),
384
+ "timeBegin": int(round(min(max(float(s["timeBegin"]) - start, 0), length))),
385
+ "timeEnd": int(round(min(max(float(s["timeEnd"]) - start, 0), length))),
386
+ })
387
+ break
388
+ return per_scene
389
+
390
+
391
+
392
+
393
+ def silencedetect_command(src: str, ffmpeg: str = "ffmpeg") -> list[str]:
394
+ return [
395
+ ffmpeg, "-hide_banner", "-nostats", "-i", src,
396
+ "-af", f"silencedetect=noise={_SILENCE_NOISE_DB}dB:d={_SILENCE_MIN_SEC}",
397
+ "-f", "null", "-",
398
+ ]
399
+
400
+
401
+ _SILENCE_START_RE = re.compile(r"silence_start:\s*(-?\d+(?:\.\d+)?)")
402
+ _SILENCE_END_RE = re.compile(r"silence_end:\s*(-?\d+(?:\.\d+)?)")
403
+
404
+
405
+ def parse_silencedetect(stderr: str, total_ms: float) -> list[tuple[float, float]]:
406
+
407
+ silences: list[tuple[float, float]] = []
408
+ start: Optional[float] = None
409
+ for line in (stderr or "").splitlines():
410
+ m = _SILENCE_START_RE.search(line)
411
+ if m:
412
+ start = max(float(m.group(1)) * 1000, 0.0)
413
+ continue
414
+ m = _SILENCE_END_RE.search(line)
415
+ if m and start is not None:
416
+ silences.append((start, float(m.group(1)) * 1000))
417
+ start = None
418
+ if start is not None:
419
+ silences.append((start, float(total_ms)))
420
+ return silences
421
+
422
+
423
+ def detect_silences(src: str, total_ms: float, ffmpeg: str, timeout: int = 120) -> list[tuple[float, float]]:
424
+ try:
425
+ result = subprocess.run(
426
+ silencedetect_command(src, ffmpeg),
427
+ capture_output=True, text=True, timeout=timeout, stdin=subprocess.DEVNULL,
428
+ )
429
+ except subprocess.TimeoutExpired as exc:
430
+ raise RuntimeError(f"ffmpeg silencedetect timed out after {timeout}s") from exc
431
+ if result.returncode != 0:
432
+ raise RuntimeError(f"ffmpeg silencedetect failed: {(result.stderr or '').strip()[-500:]}")
433
+ return parse_silencedetect(result.stderr, total_ms)
434
+
435
+
436
+ def slice_command(src: str, start_ms: float, end_ms: Optional[float], out_path: str, ffmpeg: str = "ffmpeg") -> list[str]:
437
+
438
+ cmd = [ffmpeg, "-y", "-loglevel", "error", "-i", src, "-ss", f"{start_ms / 1000:.3f}"]
439
+ if end_ms is not None:
440
+ cmd += ["-to", f"{end_ms / 1000:.3f}"]
441
+ cmd += ["-c:a", "libmp3lame", "-b:a", "128k", out_path]
442
+ return cmd
443
+
444
+
445
+ def slice_audio(
446
+ src: str,
447
+ bounds: list[tuple[float, float]],
448
+ out_dir: str,
449
+ names: list[str],
450
+ ffmpeg: str,
451
+ timeout: int = 120,
452
+ ) -> list[str]:
453
+
454
+ os.makedirs(out_dir, exist_ok=True)
455
+ paths: list[str] = []
456
+ for k, ((start, end), name) in enumerate(zip(bounds, names)):
457
+ safe = "".join(ch if ch.isalnum() or ch in "-_." else "_" for ch in name)
458
+ out_path = os.path.join(out_dir, f"narration-{safe}.mp3")
459
+
460
+ cmd = slice_command(src, start, None if k == len(bounds) - 1 else end, out_path, ffmpeg)
461
+ try:
462
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, stdin=subprocess.DEVNULL)
463
+ except subprocess.TimeoutExpired as exc:
464
+ raise RuntimeError(f"ffmpeg slice timed out after {timeout}s") from exc
465
+ if result.returncode != 0 or not os.path.exists(out_path):
466
+ raise RuntimeError(f"ffmpeg slice failed: {(result.stderr or '').strip()[:500]}")
467
+ paths.append(out_path)
468
+ return paths
@@ -70,6 +70,8 @@ from video_dsl.runtime.timeline_compiler import (
70
70
 
71
71
  _TRAILING_PUNCT_RE = re.compile(r"[。!?;,,、::..…]+$")
72
72
 
73
+ _PAUSE_TAG_RE = re.compile(r"<#\s*\d+(?:\.\d+)?\s*#>")
74
+
73
75
  def _strip_subtitle_trailing_punct(text: str) -> str:
74
76
 
75
77
  return _TRAILING_PUNCT_RE.sub("", text)
@@ -1001,12 +1003,15 @@ def build_render_plan(dsl: dict, binding: dict) -> dict:
1001
1003
 
1002
1004
  if isinstance(scene.get("textLayers"), list) and scene["textLayers"]:
1003
1005
  entry["textLayers"] = scene["textLayers"]
1006
+
1007
+ if narration.get("break") is True:
1008
+ entry["narrationBreak"] = True
1004
1009
  timeline.append(entry)
1005
1010
  current_frame += duration_frames
1006
1011
 
1007
1012
  total_frames = current_frame
1008
1013
 
1009
- return {
1014
+ plan = {
1010
1015
  "version": "v1alpha1",
1011
1016
  "createdAt": now_iso(),
1012
1017
  "status": "planning",
@@ -1047,6 +1052,13 @@ def build_render_plan(dsl: dict, binding: dict) -> dict:
1047
1052
  ],
1048
1053
  }
1049
1054
 
1055
+ narration_override = {
1056
+ k: global_narration[k] for k in ("mode", "scenePauseSec") if k in global_narration
1057
+ }
1058
+ if narration_override:
1059
+ plan["narration"] = narration_override
1060
+ return plan
1061
+
1050
1062
 
1051
1063
  def resolve_asset_image(asset: dict, private_token: str, timeout: int) -> dict:
1052
1064
 
@@ -1099,24 +1111,32 @@ def resolve_asset_audio(asset: dict, private_token: str, timeout: int) -> dict:
1099
1111
 
1100
1112
 
1101
1113
  payload = asset.get("payload", {})
1114
+ tts_text = _tts_text_from_payload(payload)
1115
+ if not tts_text:
1116
+ return {"status": "failed", "error": "No text provided for TTS"}
1117
+ return _call_gen_voice(tts_text, payload, private_token, timeout)
1118
+
1119
+
1120
+ def _tts_text_from_payload(payload: dict) -> str:
1102
1121
 
1103
- try:
1104
- cmd = build_skill_command("gen-voice")
1105
- except RuntimeError as e:
1106
- return {"status": "failed", "error": str(e)}
1107
1122
 
1108
1123
  narration_items = payload.get("narrationItems")
1109
1124
  if isinstance(narration_items, list) and narration_items:
1110
1125
  cleaned = [str(s).strip() for s in narration_items if str(s).strip()]
1111
- tts_text = "\n".join(cleaned)
1112
- if not tts_text:
1113
- return {"status": "failed", "error": "No text provided for TTS"}
1114
- else:
1115
- text = payload.get("text", "")
1116
- if not text:
1117
- return {"status": "failed", "error": "No text provided for TTS"}
1118
- segments = segment_narration(text)
1119
- tts_text = "\n".join(s.strip() for s in segments) if segments else text
1126
+ return "\n".join(cleaned)
1127
+ text = payload.get("text", "")
1128
+ if not text:
1129
+ return ""
1130
+ segments = segment_narration(text)
1131
+ return "\n".join(s.strip() for s in segments) if segments else text
1132
+
1133
+
1134
+ def _call_gen_voice(tts_text: str, payload: dict, private_token: str, timeout: int) -> dict:
1135
+
1136
+ try:
1137
+ cmd = build_skill_command("gen-voice")
1138
+ except RuntimeError as e:
1139
+ return {"status": "failed", "error": str(e)}
1120
1140
 
1121
1141
  cmd.extend(["--text", tts_text, "--json-output"])
1122
1142
  if payload.get("voiceId"):
@@ -1441,6 +1461,178 @@ def apply_stub_urls(render_plan: dict, stub_image_url: str = "", stub_video_url:
1441
1461
  return stubbed
1442
1462
 
1443
1463
 
1464
+ def plan_narration_runs(render_plan: dict) -> tuple[list[list[dict]], dict]:
1465
+
1466
+
1467
+
1468
+
1469
+ import _continuous_narration as cn
1470
+
1471
+ cfg = cn.resolve_config(_timing_template(render_plan.get("templateId", "")), render_plan.get("narration"))
1472
+ if cfg["mode"] != cn.MODE_CONTINUOUS:
1473
+ return [], cfg
1474
+ assets = {a.get("assetId"): a for a in render_plan.get("resolvedAssets", [])}
1475
+ payloads = {
1476
+ a.get("assetId"): a.get("payload") or {}
1477
+ for a in render_plan.get("_dsl_assets", [])
1478
+ if a.get("assetId")
1479
+ }
1480
+ timeline = render_plan.get("timeline", [])
1481
+ refs: dict[str, int] = {}
1482
+ for entry in timeline:
1483
+ nar_id = (entry.get("props") or {}).get("narrationAssetId")
1484
+ if nar_id:
1485
+ refs[nar_id] = refs.get(nar_id, 0) + 1
1486
+
1487
+ candidates: list[Optional[dict]] = []
1488
+ for entry in timeline:
1489
+ nar_id = (entry.get("props") or {}).get("narrationAssetId")
1490
+ asset = assets.get(nar_id)
1491
+ payload = payloads.get(nar_id) or {}
1492
+ text = _tts_text_from_payload(payload) if asset else ""
1493
+ if (
1494
+ not asset
1495
+ or asset.get("source") != "gen-voice"
1496
+ or asset.get("status") != "pending"
1497
+ or refs.get(nar_id) != 1
1498
+ or not text
1499
+ ):
1500
+ candidates.append(None)
1501
+ continue
1502
+ candidates.append({
1503
+ "assetId": nar_id,
1504
+ "sceneId": entry.get("sceneId"),
1505
+ "text": text,
1506
+ "voiceKey": (_narration_voice(payload), _narration_speed(payload), _narration_language_boost(payload)),
1507
+ "break": bool(entry.get("narrationBreak")),
1508
+ })
1509
+ return cn.group_runs(candidates), cfg
1510
+
1511
+
1512
+ def _download_to(url: str, path: str, timeout: int) -> str:
1513
+ os.makedirs(os.path.dirname(path), exist_ok=True)
1514
+ with urllib.request.urlopen(url, timeout=timeout) as resp, open(path, "wb") as f:
1515
+ while True:
1516
+ chunk = resp.read(1 << 16)
1517
+ if not chunk:
1518
+ break
1519
+ f.write(chunk)
1520
+ return path
1521
+
1522
+
1523
+ def _upload_tts_clip(path: str, title: str) -> str:
1524
+
1525
+
1526
+
1527
+
1528
+
1529
+
1530
+ from upload_video import upload_media_file
1531
+ data = upload_media_file(path, title=title, role="tts_clip", retention="intermediate")
1532
+ url = data.get("signedUrl") or data["fileUrl"]
1533
+ if not data.get("signedUrl"):
1534
+ req = urllib.request.Request(url, headers={"Range": "bytes=0-0"})
1535
+ with urllib.request.urlopen(req, timeout=15):
1536
+ pass
1537
+ return url
1538
+
1539
+
1540
+ def resolve_narration_run(
1541
+ run: list[dict],
1542
+ cfg: dict,
1543
+ render_plan: dict,
1544
+ private_token: str,
1545
+ max_retries: int,
1546
+ timeout: int,
1547
+ work_dir: Optional[str] = None,
1548
+ ) -> bool:
1549
+
1550
+
1551
+ import _continuous_narration as cn
1552
+ from _digital_human_segments import find_ffmpeg
1553
+
1554
+ scene_ids = [c.get("sceneId") for c in run]
1555
+
1556
+ def log(message: str, level: str = "info") -> None:
1557
+ render_plan.setdefault("logs", []).append({
1558
+ "phase": "narration-run",
1559
+ "level": level,
1560
+ "message": message,
1561
+ "timestamp": now_iso(),
1562
+ })
1563
+ LogPrint(f" 🎙️ {message}", file=sys.stderr)
1564
+
1565
+ ffmpeg = find_ffmpeg()
1566
+ if not ffmpeg:
1567
+ log(f"ffmpeg not found; scenes {scene_ids} fall back to per-scene TTS", level="warn")
1568
+ return False
1569
+
1570
+ pause = cfg["scenePauseSec"]
1571
+ texts = [c["text"] for c in run]
1572
+ run_text, ranges = cn.compose_run_text(texts, pause)
1573
+ payloads = {
1574
+ a.get("assetId"): a.get("payload") or {}
1575
+ for a in render_plan.get("_dsl_assets", [])
1576
+ if a.get("assetId")
1577
+ }
1578
+
1579
+ voice_payload = payloads.get(run[0]["assetId"]) or {}
1580
+
1581
+ result: dict = {}
1582
+ for attempt in range(max_retries):
1583
+ LogPrint(f" 🔄 Generating continuous narration for {len(run)} scenes (attempt {attempt + 1}/{max_retries})...", file=sys.stderr)
1584
+ result = _call_gen_voice(run_text, voice_payload, private_token, timeout)
1585
+ if result.get("status") == "generated":
1586
+ break
1587
+ if attempt < max_retries - 1:
1588
+ time.sleep(2)
1589
+ if result.get("status") != "generated":
1590
+ log(f"continuous TTS failed for scenes {scene_ids}: {str(result.get('error'))[:300]}; falling back to per-scene TTS", level="warn")
1591
+ return False
1592
+
1593
+ try:
1594
+ total_ms = float(result.get("audio_length_ms") or 0)
1595
+ if total_ms <= 0:
1596
+ raise RuntimeError("TTS returned no audio length")
1597
+ out_dir = work_dir or os.path.join(OUTPUT_DIR, "narration")
1598
+ run_name = "".join(ch if ch.isalnum() or ch in "-_." else "_" for ch in run[0]["assetId"])
1599
+ local = _download_to(result["url"], os.path.join(out_dir, f"run-{run_name}.mp3"), timeout)
1600
+ subs = result.get("subtitles") or []
1601
+ silences = cn.detect_silences(local, total_ms, ffmpeg)
1602
+ cuts, method = cn.find_cuts(texts, ranges, subs, silences, total_ms, pause, run_text)
1603
+ if cuts is None:
1604
+ log(f"no reliable cut points for scenes {scene_ids}; falling back to per-scene TTS", level="warn")
1605
+ return False
1606
+ bounds = cn.slice_bounds(cuts, total_ms)
1607
+ paths = cn.slice_audio(local, bounds, out_dir, [c["assetId"] for c in run], ffmpeg)
1608
+ urls = [_upload_tts_clip(path, f"narration {c['assetId']}") for path, c in zip(paths, run)]
1609
+ except Exception as exc:
1610
+ log(f"continuous narration split failed for scenes {scene_ids}: {exc}; falling back to per-scene TTS", level="warn")
1611
+ return False
1612
+
1613
+ per_scene_subs = cn.split_subtitles(subs, bounds)
1614
+ assets = {a.get("assetId"): a for a in render_plan.get("resolvedAssets", [])}
1615
+ run_id = run[0]["assetId"]
1616
+ for k, c in enumerate(run):
1617
+ asset = assets[c["assetId"]]
1618
+ start, end = bounds[k]
1619
+ asset["status"] = "generated"
1620
+ asset["url"] = urls[k]
1621
+ asset["duration"] = int(round(end - start))
1622
+ asset["generatedBy"] = {"skill": "gen-voice", "mode": "continuous", "startedAt": now_iso(), "completedAt": now_iso()}
1623
+
1624
+ asset["narrationRun"] = {"id": run_id, "index": k, "count": len(run), "method": method}
1625
+ asset["speechSpanMs"] = list(cn.speech_span(bounds[k], silences))
1626
+ if per_scene_subs[k]:
1627
+ asset["ttsSubtitles"] = per_scene_subs[k]
1628
+ payload = payloads.get(c["assetId"]) or {}
1629
+ if isinstance(payload.get("narrationItems"), list):
1630
+ asset["narrationLineCount"] = len(payload["narrationItems"])
1631
+ asset["narrationIntroLines"] = int(payload.get("narrationIntroLines") or 0)
1632
+ log(f"continuous narration: {len(run)} scenes in one TTS call, {total_ms / 1000:.1f}s, cut by {method}")
1633
+ return True
1634
+
1635
+
1444
1636
  def resolve_assets(
1445
1637
  render_plan: dict,
1446
1638
  private_token: str,
@@ -1480,21 +1672,56 @@ def resolve_assets(
1480
1672
  failed = 0
1481
1673
 
1482
1674
 
1675
+ runs: list[list[dict]] = []
1676
+ narration_cfg: dict = {}
1677
+ if phase1:
1678
+ runs, narration_cfg = plan_narration_runs(render_plan)
1679
+ run_asset_ids = {c["assetId"] for run in runs for c in run}
1680
+ singles = [a for a in phase1 if a.get("assetId") not in run_asset_ids]
1681
+ assets_by_id = {a.get("assetId"): a for a in phase1}
1682
+
1683
+ def resolve_run(run: list[dict]) -> tuple[int, int]:
1684
+ if resolve_narration_run(run, narration_cfg, render_plan, private_token, max_retries, timeout):
1685
+ return len(run), 0
1686
+ ok_count = fail_count = 0
1687
+ for c in run:
1688
+ asset = assets_by_id[c["assetId"]]
1689
+ if _resolve_single_asset(asset, render_plan, private_token, max_retries, timeout):
1690
+ ok_count += 1
1691
+ elif asset["status"] == "failed":
1692
+ fail_count += 1
1693
+ return ok_count, fail_count
1694
+
1695
+
1483
1696
  if phase1:
1484
1697
  default_parallelism = 8
1485
1698
  max_workers = min(
1486
1699
  int(os.environ.get("REMOTION_ASSET_PARALLELISM", str(default_parallelism))),
1487
- len(phase1),
1700
+ len(singles) + len(runs),
1488
1701
  )
1489
1702
  LogPrint(f" ⚡ Phase 1: generating {len(phase1)} asset(s) in parallel (concurrency={max_workers})", file=sys.stderr)
1703
+ if runs:
1704
+ LogPrint(f" 🎙️ {len(runs)} continuous narration run(s) covering {len(run_asset_ids)} scene(s)", file=sys.stderr)
1490
1705
  with ThreadPoolExecutor(max_workers=max_workers) as executor:
1491
1706
  future_to_asset = {
1492
1707
  executor.submit(
1493
1708
  _resolve_single_asset, asset, render_plan, private_token, max_retries, timeout
1494
1709
  ): asset
1495
- for asset in phase1
1710
+ for asset in singles
1496
1711
  }
1497
- for future in as_completed(future_to_asset):
1712
+ future_to_run = {executor.submit(resolve_run, run): run for run in runs}
1713
+ for future in as_completed([*future_to_asset, *future_to_run]):
1714
+ if future in future_to_run:
1715
+ try:
1716
+ ok_count, fail_count = future.result()
1717
+ generated += ok_count
1718
+ failed += fail_count
1719
+ except Exception as exc:
1720
+ for c in future_to_run[future]:
1721
+ assets_by_id[c["assetId"]]["status"] = "failed"
1722
+ failed += 1
1723
+ LogPrint(f" ❌ continuous narration run exception: {exc}", file=sys.stderr)
1724
+ continue
1498
1725
  asset = future_to_asset[future]
1499
1726
  try:
1500
1727
  ok = future.result()
@@ -1801,10 +2028,17 @@ def adjust_timeline_to_audio(render_plan: dict) -> dict:
1801
2028
 
1802
2029
 
1803
2030
  tail_pad_override = entry.get("tailPadFrames")
2031
+
2032
+
2033
+ run = narration_asset.get("narrationRun") or {}
2034
+ scene_default_tail = (
2035
+ 0 if run and int(run.get("index", 0)) < int(run.get("count", 0)) - 1
2036
+ else default_tail_pad
2037
+ )
1804
2038
  tail_pad = (
1805
2039
  int(tail_pad_override)
1806
2040
  if isinstance(tail_pad_override, int) and tail_pad_override >= 0
1807
- else default_tail_pad
2041
+ else scene_default_tail
1808
2042
  )
1809
2043
  min_dur = int(fps * 2)
1810
2044
 
@@ -1828,12 +2062,13 @@ def adjust_timeline_to_audio(render_plan: dict) -> dict:
1828
2062
  )
1829
2063
  entry["subtitleSegments"] = [
1830
2064
  {
1831
- "text": _strip_subtitle_trailing_punct(s["text"].strip()),
2065
+ "text": _strip_subtitle_trailing_punct(_PAUSE_TAG_RE.sub("", s["text"]).strip()),
1832
2066
  "startFrame": current_frame + int(s["timeBegin"] / 1000.0 * fps),
1833
2067
  "endFrame": current_frame + int(s["timeEnd"] / 1000.0 * fps),
1834
2068
  "sentenceIndex": sentence_indices[i],
1835
2069
  }
1836
2070
  for i, s in enumerate(tts_subs)
2071
+ if _PAUSE_TAG_RE.sub("", s.get("text") or "").strip()
1837
2072
  ]
1838
2073
  _warn_step_sentence_mismatch(entry, sentence_indices, step_field)
1839
2074
  else:
@@ -1887,13 +2122,18 @@ def adjust_timeline_to_audio(render_plan: dict) -> dict:
1887
2122
  entry["subtitleSegments"] = new_subs
1888
2123
  elif old_subs:
1889
2124
  sub_span = audio_frames
2125
+ cursor = 0
2126
+
2127
+ span = narration_asset.get("speechSpanMs")
2128
+ if isinstance(span, list) and len(span) == 2 and 0 <= span[0] < span[1]:
2129
+ cursor = int(span[0] / 1000.0 * fps)
2130
+ sub_span = max(math.ceil(span[1] / 1000.0 * fps) - cursor, fps // 2)
1890
2131
 
1891
2132
 
1892
2133
  def _plain(t: str) -> int:
1893
2134
  return len(re.sub(r"\*+", "", t or ""))
1894
2135
  total_chars = max(sum(_plain(s.get("text", "")) for s in old_subs), 1)
1895
2136
  new_subs = []
1896
- cursor = 0
1897
2137
  for s in old_subs:
1898
2138
  text = s.get("text", "")
1899
2139
  ratio = _plain(text) / total_chars
@@ -199,6 +199,7 @@ Rules:
199
199
  - The legacy flat `narration.text` form still works but the author then has to keep `highlightMap` in sync with the subtitle segmentation (error-prone, not recommended).
200
200
  - `narration.languageBoost` (optional, also accepted at `global.narration`) pins the TTS language hint (`auto` / `Chinese` / `English` / …). Leave it unset unless auto-detection gets it wrong — the service applies its own default, and a scene-level value overrides the global one, exactly like `speed`.
201
201
  - Voice: to change the narration voice, set `global.narration.voiceId` (whole video) or `narration.voiceId` on a scene (that scene only). Precedence when synthesizing is scene > global > the audio asset's `payload.voiceId`, which is only a fallback — editing the asset payload alone is not how you change a voice.
202
+ - Continuous narration: templates that declare `capabilities.narration.mode: "continuous"` get adjacent scenes with the same voice / speed / languageBoost synthesized in one TTS call and cut back per scene, so pace and loudness stay even. Nothing to author for it. Set `narration.break: true` on a scene to start a new run there (a chapter change that wants a longer pause); `global.narration.mode` (`continuous` / `perScene`) overrides the template only when the user asks for it.
202
203
 
203
204
  ## Image sources
204
205
 
@@ -109,7 +109,9 @@
109
109
  "voiceId": { "type": "string", "description": "Minimax 音色 ID" },
110
110
  "voiceName": { "type": "string" },
111
111
  "speed": { "type": "number", "default": 1.0 },
112
- "languageBoost": { "type": "string", "description": "TTS 语种提示(auto / Chinese / English …)。中英混排的旁白不给提示时,英文词容易被按中文音系读出来;留空由服务端取默认值。" }
112
+ "languageBoost": { "type": "string", "description": "TTS 语种提示(auto / Chinese / English …)。中英混排的旁白不给提示时,英文词容易被按中文音系读出来;留空由服务端取默认值。" },
113
+ "mode": { "type": "string", "enum": ["continuous", "perScene"], "description": "旁白合成方式,覆盖模板 capabilities.narration.mode。continuous = 相邻、音色语速语种相同的场景整段合成一次再按场景切开(语速响度一致);perScene = 每个场景单独合成。留空跟随模板,模板没声明即 perScene。" },
114
+ "scenePauseSec": { "type": "number", "minimum": 0.01, "maximum": 99.99, "description": "continuous 模式下场景之间插入的停顿秒数(MiniMax <#x#>),覆盖模板声明;默认 0.8。" }
113
115
  }
114
116
  },
115
117
  "bgm": {
@@ -268,6 +270,7 @@
268
270
  "voiceId": { "type": "string", "description": "该场旁白音色,覆盖 global.narration.voiceId;留空跟随全局。合成时优先级:场景 > 全局 > 音频资产 payload.voiceId(仅作兜底)。" },
269
271
  "voiceName": { "type": "string", "description": "voiceId 的展示名(仅展示用,不参与合成)" },
270
272
  "speed": { "type": "number", "description": "该场旁白语速,覆盖 global.narration.speed;留空跟随全局。" },
273
+ "break": { "type": "boolean", "description": "continuous 旁白模式下从这一幕起另起一段整段合成(例如换章节、需要更长停顿)。perScene 模式下无效。" },
271
274
  "languageBoost": { "type": "string", "description": "TTS 语种提示(auto / Chinese / English …)。中英混排的旁白不给提示时,英文词容易被按中文音系读出来;覆盖 global.narration 的同名字段;留空则跟随全局/服务端默认。" }
272
275
  }
273
276
  },