@remixmate/cli 0.13.4 → 0.13.6
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-voice.js +8 -8
- package/dist/manifest.json +2 -2
- package/package.json +1 -1
- package/skills/gen-script/SKILL.md +2 -0
- package/skills/gen-script/scripts/gen_script.py +114 -4
- package/skills/gen-script/version.json +1 -1
- package/skills/prepare-video-assets/SKILL.md +2 -0
- package/skills/prepare-video-assets/version.json +1 -1
- package/skills/render-video/scripts/_continuous_narration.py +476 -0
- package/skills/render-video/scripts/_narration_alignment.py +88 -0
- package/skills/render-video/scripts/_scene_assets.py +39 -0
- package/skills/render-video/scripts/render_video.py +324 -28
- package/skills/render-video/version.json +1 -1
- package/skills/template-registry/SKILL.md +1 -0
- package/skills/template-registry/video_dsl/schema/video-dsl-v1alpha1.json +4 -1
|
@@ -0,0 +1,476 @@
|
|
|
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
|
+
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
_MIN_BOUNDARY_PAUSE_SEC = 0.6
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _min_boundary_ms(pause_sec: float) -> float:
|
|
60
|
+
return max(_MIN_BOUNDARY_PAUSE_SEC, pause_sec * _MIN_BOUNDARY_PAUSE_RATIO) * 1000
|
|
61
|
+
|
|
62
|
+
_SILENCE_NOISE_DB = -40
|
|
63
|
+
_SILENCE_MIN_SEC = 0.25
|
|
64
|
+
|
|
65
|
+
PAUSE_TAG_RE = re.compile(r"<#\s*(\d+(?:\.\d+)?)\s*#>")
|
|
66
|
+
_LEADING_TAG_RE = re.compile(r"^\s*<#\s*(\d+(?:\.\d+)?)\s*#>\s*")
|
|
67
|
+
_TRAILING_TAG_RE = re.compile(r"\s*<#\s*(\d+(?:\.\d+)?)\s*#>\s*$")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _pause_value(raw) -> Optional[float]:
|
|
73
|
+
if isinstance(raw, bool) or not isinstance(raw, (int, float)):
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
return min(max(float(raw), 0.01), 99.99) if raw > 0 else None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def resolve_config(template: dict, override: Optional[dict]) -> dict:
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
declared = ((template or {}).get("capabilities") or {}).get("narration") or {}
|
|
85
|
+
override = override or {}
|
|
86
|
+
mode = MODE_PER_SCENE
|
|
87
|
+
for source in (declared, override):
|
|
88
|
+
if source.get("mode") in (MODE_CONTINUOUS, MODE_PER_SCENE):
|
|
89
|
+
mode = source["mode"]
|
|
90
|
+
pause = DEFAULT_SCENE_PAUSE_SEC
|
|
91
|
+
for source in (declared, override):
|
|
92
|
+
value = _pause_value(source.get("scenePauseSec"))
|
|
93
|
+
if value is not None:
|
|
94
|
+
pause = value
|
|
95
|
+
return {"mode": mode, "scenePauseSec": pause}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def group_runs(candidates: list[Optional[dict]], max_chars: int = MAX_RUN_CHARS) -> list[list[dict]]:
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
runs: list[list[dict]] = []
|
|
108
|
+
current: list[dict] = []
|
|
109
|
+
chars = 0
|
|
110
|
+
|
|
111
|
+
def flush() -> None:
|
|
112
|
+
if len(current) >= 2:
|
|
113
|
+
runs.append(list(current))
|
|
114
|
+
current.clear()
|
|
115
|
+
|
|
116
|
+
for cand in candidates:
|
|
117
|
+
if cand is None:
|
|
118
|
+
flush()
|
|
119
|
+
chars = 0
|
|
120
|
+
continue
|
|
121
|
+
size = len(cand["text"])
|
|
122
|
+
if current and (
|
|
123
|
+
cand.get("break")
|
|
124
|
+
or cand["voiceKey"] != current[-1]["voiceKey"]
|
|
125
|
+
or chars + size > max_chars
|
|
126
|
+
):
|
|
127
|
+
flush()
|
|
128
|
+
chars = 0
|
|
129
|
+
current.append(cand)
|
|
130
|
+
chars += size
|
|
131
|
+
flush()
|
|
132
|
+
return runs
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def format_pause(sec: float) -> str:
|
|
138
|
+
return f"{sec:.2f}".rstrip("0").rstrip(".")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def compose_run_text(texts: list[str], pause_sec: float) -> tuple[str, list[tuple[int, int]]]:
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
parts: list[str] = []
|
|
147
|
+
for i, text in enumerate(texts):
|
|
148
|
+
text = text.strip()
|
|
149
|
+
if i > 0:
|
|
150
|
+
text = _LEADING_TAG_RE.sub("", text, count=1)
|
|
151
|
+
if i < len(texts) - 1:
|
|
152
|
+
text = _TRAILING_TAG_RE.sub("", text, count=1)
|
|
153
|
+
parts.append(text)
|
|
154
|
+
|
|
155
|
+
pauses: list[float] = []
|
|
156
|
+
for i in range(len(texts) - 1):
|
|
157
|
+
candidates = [pause_sec]
|
|
158
|
+
tail = _TRAILING_TAG_RE.search(texts[i].strip())
|
|
159
|
+
head = _LEADING_TAG_RE.search(texts[i + 1].strip())
|
|
160
|
+
for m in (tail, head):
|
|
161
|
+
if m:
|
|
162
|
+
candidates.append(_pause_value(float(m.group(1))) or 0)
|
|
163
|
+
pauses.append(max(candidates))
|
|
164
|
+
|
|
165
|
+
out = ""
|
|
166
|
+
ranges: list[tuple[int, int]] = []
|
|
167
|
+
for i, part in enumerate(parts):
|
|
168
|
+
if i > 0:
|
|
169
|
+
out += f"\n<#{format_pause(pauses[i - 1])}#>\n"
|
|
170
|
+
start = len(out)
|
|
171
|
+
out += part
|
|
172
|
+
ranges.append((start, len(out)))
|
|
173
|
+
return out, ranges
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def normalize(text: str) -> str:
|
|
177
|
+
|
|
178
|
+
text = PAUSE_TAG_RE.sub("", text or "")
|
|
179
|
+
return "".join(
|
|
180
|
+
ch for ch in text
|
|
181
|
+
if not ch.isspace() and not unicodedata.category(ch).startswith(("P", "S"))
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def estimate_durations(texts: list[str], total_ms: float, pause_sec: float) -> list[float]:
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
counts = [max(len(normalize(t)), 1) for t in texts]
|
|
191
|
+
boundaries = len(texts) - 1
|
|
192
|
+
gap_ms = (pause_sec + _NATURAL_PAUSE_SEC) * 1000
|
|
193
|
+
speech_ms = total_ms - boundaries * gap_ms
|
|
194
|
+
if speech_ms <= 0:
|
|
195
|
+
gap_ms, speech_ms = 0.0, total_ms
|
|
196
|
+
per_char = speech_ms / sum(counts)
|
|
197
|
+
durations: list[float] = []
|
|
198
|
+
for k, count in enumerate(counts):
|
|
199
|
+
edges = (k > 0) + (k < boundaries)
|
|
200
|
+
durations.append(count * per_char + edges * gap_ms / 2)
|
|
201
|
+
return durations
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _sub_mid(subs: list[dict], i: int) -> float:
|
|
207
|
+
return (float(subs[i]["timeEnd"]) + float(subs[i + 1]["timeBegin"])) / 2
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def cuts_from_text_offsets(subs: list[dict], ranges: list[tuple[int, int]]) -> Optional[list[float]]:
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
if len(subs) < 2 or not all(
|
|
214
|
+
isinstance(s.get("textBegin"), int) and isinstance(s.get("textEnd"), int) for s in subs
|
|
215
|
+
):
|
|
216
|
+
return None
|
|
217
|
+
cuts: list[float] = []
|
|
218
|
+
for k in range(1, len(ranges)):
|
|
219
|
+
prev_end, next_start = ranges[k - 1][1], ranges[k][0]
|
|
220
|
+
hit = next(
|
|
221
|
+
(
|
|
222
|
+
i for i in range(len(subs) - 1)
|
|
223
|
+
if subs[i]["textEnd"] <= next_start
|
|
224
|
+
and subs[i + 1]["textBegin"] >= prev_end
|
|
225
|
+
and subs[i + 1]["textBegin"] >= subs[i]["textEnd"]
|
|
226
|
+
),
|
|
227
|
+
None,
|
|
228
|
+
)
|
|
229
|
+
if hit is None:
|
|
230
|
+
return None
|
|
231
|
+
cuts.append(_sub_mid(subs, hit))
|
|
232
|
+
return cuts if _monotonic(cuts) else None
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def speakable_ranges(run_text: str, ranges: list[tuple[int, int]]) -> list[tuple[int, int]]:
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
keep = [ch not in "\r\n" for ch in run_text]
|
|
243
|
+
for m in PAUSE_TAG_RE.finditer(run_text):
|
|
244
|
+
for i in range(m.start(), m.end()):
|
|
245
|
+
keep[i] = False
|
|
246
|
+
prefix = [0]
|
|
247
|
+
for k in keep:
|
|
248
|
+
prefix.append(prefix[-1] + k)
|
|
249
|
+
return [(prefix[a], prefix[b]) for a, b in ranges]
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def cuts_from_text_alignment(subs: list[dict], texts: list[str]) -> Optional[list[float]]:
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
if len(subs) < 2:
|
|
256
|
+
return None
|
|
257
|
+
sub_acc: list[int] = []
|
|
258
|
+
acc = 0
|
|
259
|
+
for s in subs:
|
|
260
|
+
acc += len(normalize(s.get("text") or ""))
|
|
261
|
+
sub_acc.append(acc)
|
|
262
|
+
if acc != sum(len(normalize(t)) for t in texts):
|
|
263
|
+
return None
|
|
264
|
+
cuts: list[float] = []
|
|
265
|
+
scene_acc = 0
|
|
266
|
+
for k in range(len(texts) - 1):
|
|
267
|
+
scene_acc += len(normalize(texts[k]))
|
|
268
|
+
try:
|
|
269
|
+
i = sub_acc.index(scene_acc)
|
|
270
|
+
except ValueError:
|
|
271
|
+
return None
|
|
272
|
+
if i >= len(subs) - 1:
|
|
273
|
+
return None
|
|
274
|
+
cuts.append(_sub_mid(subs, i))
|
|
275
|
+
return cuts if _monotonic(cuts) else None
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def cuts_from_silences(
|
|
279
|
+
silences: list[tuple[float, float]],
|
|
280
|
+
texts: list[str],
|
|
281
|
+
total_ms: float,
|
|
282
|
+
pause_sec: float,
|
|
283
|
+
) -> Optional[list[float]]:
|
|
284
|
+
|
|
285
|
+
need = len(texts) - 1
|
|
286
|
+
if need <= 0:
|
|
287
|
+
return []
|
|
288
|
+
|
|
289
|
+
inner = [s for s in silences if 0 < s[0] and s[1] < total_ms]
|
|
290
|
+
if len(inner) < need:
|
|
291
|
+
return None
|
|
292
|
+
longest = sorted(inner, key=lambda s: s[1] - s[0], reverse=True)[:need]
|
|
293
|
+
longest.sort(key=lambda s: s[0])
|
|
294
|
+
min_len = _min_boundary_ms(pause_sec)
|
|
295
|
+
if any(end - start < min_len for start, end in longest):
|
|
296
|
+
return None
|
|
297
|
+
cuts = [(start + end) / 2 for start, end in longest]
|
|
298
|
+
if not _durations_plausible(cuts, texts, total_ms, pause_sec):
|
|
299
|
+
return None
|
|
300
|
+
return cuts if _monotonic(cuts) else None
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _monotonic(cuts: list[float]) -> bool:
|
|
304
|
+
return all(b > a for a, b in zip(cuts, cuts[1:])) and all(c > 0 for c in cuts)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _durations_plausible(cuts: list[float], texts: list[str], total_ms: float, pause_sec: float) -> bool:
|
|
308
|
+
|
|
309
|
+
edges = [0.0, *cuts, float(total_ms)]
|
|
310
|
+
for k, predicted in enumerate(estimate_durations(texts, total_ms, pause_sec)):
|
|
311
|
+
actual = edges[k + 1] - edges[k]
|
|
312
|
+
if abs(actual - predicted) > max(ESTIMATE_WINDOW_SEC * 1000, predicted * _ESTIMATE_WINDOW_RATIO):
|
|
313
|
+
return False
|
|
314
|
+
return True
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _cuts_on_boundary_pauses(cuts: list[float], silences: list[tuple[float, float]], pause_sec: float) -> bool:
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
min_len = _min_boundary_ms(pause_sec)
|
|
321
|
+
tol = 100.0
|
|
322
|
+
return all(
|
|
323
|
+
any(end - start >= min_len and start - tol <= c <= end + tol for start, end in silences)
|
|
324
|
+
for c in cuts
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def find_cuts(
|
|
329
|
+
texts: list[str],
|
|
330
|
+
ranges: list[tuple[int, int]],
|
|
331
|
+
subs: list[dict],
|
|
332
|
+
silences: Optional[list[tuple[float, float]]],
|
|
333
|
+
total_ms: float,
|
|
334
|
+
pause_sec: float,
|
|
335
|
+
run_text: Optional[str] = None,
|
|
336
|
+
) -> tuple[Optional[list[float]], str]:
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def trusted(cuts: Optional[list[float]]) -> bool:
|
|
342
|
+
return (
|
|
343
|
+
cuts is not None
|
|
344
|
+
and _durations_plausible(cuts, texts, total_ms, pause_sec)
|
|
345
|
+
and (silences is None or _cuts_on_boundary_pauses(cuts, silences, pause_sec))
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
candidates = [speakable_ranges(run_text, ranges)] if run_text is not None else []
|
|
349
|
+
for rs in [*candidates, ranges]:
|
|
350
|
+
cuts = cuts_from_text_offsets(subs, rs)
|
|
351
|
+
if trusted(cuts):
|
|
352
|
+
return cuts, "subtitle-offsets"
|
|
353
|
+
cuts = cuts_from_text_alignment(subs, texts)
|
|
354
|
+
if trusted(cuts):
|
|
355
|
+
return cuts, "subtitle-text"
|
|
356
|
+
if silences is not None:
|
|
357
|
+
cuts = cuts_from_silences(silences, texts, total_ms, pause_sec)
|
|
358
|
+
if cuts is not None:
|
|
359
|
+
return cuts, "silence"
|
|
360
|
+
return None, "none"
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def slice_bounds(cuts: list[float], total_ms: float) -> list[tuple[float, float]]:
|
|
366
|
+
edges = [0.0, *cuts, float(total_ms)]
|
|
367
|
+
return list(zip(edges, edges[1:]))
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def speech_span(bounds: tuple[float, float], silences: list[tuple[float, float]]) -> tuple[int, int]:
|
|
371
|
+
|
|
372
|
+
start, end = bounds
|
|
373
|
+
lo, hi = start, end
|
|
374
|
+
for s_start, s_end in silences:
|
|
375
|
+
if s_start <= lo < s_end:
|
|
376
|
+
lo = min(s_end, end)
|
|
377
|
+
if s_start < hi <= s_end:
|
|
378
|
+
hi = max(s_start, lo)
|
|
379
|
+
return int(round(lo - start)), int(round(hi - start))
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def split_subtitles(subs: list[dict], bounds: list[tuple[float, float]]) -> list[list[dict]]:
|
|
383
|
+
|
|
384
|
+
per_scene: list[list[dict]] = [[] for _ in bounds]
|
|
385
|
+
for s in subs:
|
|
386
|
+
mid = (float(s.get("timeBegin", 0)) + float(s.get("timeEnd", 0))) / 2
|
|
387
|
+
for k, (start, end) in enumerate(bounds):
|
|
388
|
+
if start <= mid < end or (k == len(bounds) - 1 and mid >= start):
|
|
389
|
+
length = end - start
|
|
390
|
+
per_scene[k].append({
|
|
391
|
+
"text": s.get("text", ""),
|
|
392
|
+
"timeBegin": int(round(min(max(float(s["timeBegin"]) - start, 0), length))),
|
|
393
|
+
"timeEnd": int(round(min(max(float(s["timeEnd"]) - start, 0), length))),
|
|
394
|
+
})
|
|
395
|
+
break
|
|
396
|
+
return per_scene
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def silencedetect_command(src: str, ffmpeg: str = "ffmpeg") -> list[str]:
|
|
402
|
+
return [
|
|
403
|
+
ffmpeg, "-hide_banner", "-nostats", "-i", src,
|
|
404
|
+
"-af", f"silencedetect=noise={_SILENCE_NOISE_DB}dB:d={_SILENCE_MIN_SEC}",
|
|
405
|
+
"-f", "null", "-",
|
|
406
|
+
]
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
_SILENCE_START_RE = re.compile(r"silence_start:\s*(-?\d+(?:\.\d+)?)")
|
|
410
|
+
_SILENCE_END_RE = re.compile(r"silence_end:\s*(-?\d+(?:\.\d+)?)")
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def parse_silencedetect(stderr: str, total_ms: float) -> list[tuple[float, float]]:
|
|
414
|
+
|
|
415
|
+
silences: list[tuple[float, float]] = []
|
|
416
|
+
start: Optional[float] = None
|
|
417
|
+
for line in (stderr or "").splitlines():
|
|
418
|
+
m = _SILENCE_START_RE.search(line)
|
|
419
|
+
if m:
|
|
420
|
+
start = max(float(m.group(1)) * 1000, 0.0)
|
|
421
|
+
continue
|
|
422
|
+
m = _SILENCE_END_RE.search(line)
|
|
423
|
+
if m and start is not None:
|
|
424
|
+
silences.append((start, float(m.group(1)) * 1000))
|
|
425
|
+
start = None
|
|
426
|
+
if start is not None:
|
|
427
|
+
silences.append((start, float(total_ms)))
|
|
428
|
+
return silences
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def detect_silences(src: str, total_ms: float, ffmpeg: str, timeout: int = 120) -> list[tuple[float, float]]:
|
|
432
|
+
try:
|
|
433
|
+
result = subprocess.run(
|
|
434
|
+
silencedetect_command(src, ffmpeg),
|
|
435
|
+
capture_output=True, text=True, timeout=timeout, stdin=subprocess.DEVNULL,
|
|
436
|
+
)
|
|
437
|
+
except subprocess.TimeoutExpired as exc:
|
|
438
|
+
raise RuntimeError(f"ffmpeg silencedetect timed out after {timeout}s") from exc
|
|
439
|
+
if result.returncode != 0:
|
|
440
|
+
raise RuntimeError(f"ffmpeg silencedetect failed: {(result.stderr or '').strip()[-500:]}")
|
|
441
|
+
return parse_silencedetect(result.stderr, total_ms)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def slice_command(src: str, start_ms: float, end_ms: Optional[float], out_path: str, ffmpeg: str = "ffmpeg") -> list[str]:
|
|
445
|
+
|
|
446
|
+
cmd = [ffmpeg, "-y", "-loglevel", "error", "-i", src, "-ss", f"{start_ms / 1000:.3f}"]
|
|
447
|
+
if end_ms is not None:
|
|
448
|
+
cmd += ["-to", f"{end_ms / 1000:.3f}"]
|
|
449
|
+
cmd += ["-c:a", "libmp3lame", "-b:a", "128k", out_path]
|
|
450
|
+
return cmd
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def slice_audio(
|
|
454
|
+
src: str,
|
|
455
|
+
bounds: list[tuple[float, float]],
|
|
456
|
+
out_dir: str,
|
|
457
|
+
names: list[str],
|
|
458
|
+
ffmpeg: str,
|
|
459
|
+
timeout: int = 120,
|
|
460
|
+
) -> list[str]:
|
|
461
|
+
|
|
462
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
463
|
+
paths: list[str] = []
|
|
464
|
+
for k, ((start, end), name) in enumerate(zip(bounds, names)):
|
|
465
|
+
safe = "".join(ch if ch.isalnum() or ch in "-_." else "_" for ch in name)
|
|
466
|
+
out_path = os.path.join(out_dir, f"narration-{safe}.mp3")
|
|
467
|
+
|
|
468
|
+
cmd = slice_command(src, start, None if k == len(bounds) - 1 else end, out_path, ffmpeg)
|
|
469
|
+
try:
|
|
470
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, stdin=subprocess.DEVNULL)
|
|
471
|
+
except subprocess.TimeoutExpired as exc:
|
|
472
|
+
raise RuntimeError(f"ffmpeg slice timed out after {timeout}s") from exc
|
|
473
|
+
if result.returncode != 0 or not os.path.exists(out_path):
|
|
474
|
+
raise RuntimeError(f"ffmpeg slice failed: {(result.stderr or '').strip()[:500]}")
|
|
475
|
+
paths.append(out_path)
|
|
476
|
+
return paths
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
import re
|
|
12
|
+
import unicodedata
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _normalized(text: str) -> str:
|
|
16
|
+
text = re.sub(r"<#\s*\d+(?:\.\d+)?\s*#>", "", text)
|
|
17
|
+
return "".join(c for c in unicodedata.normalize("NFKC", text).casefold() if c.isalnum())
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _number(value) -> bool:
|
|
21
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def align_narration_lines(
|
|
25
|
+
lines: list[str], subtitles: list[dict], duration_ms: float, speech_span_ms=None,
|
|
26
|
+
) -> tuple[list[dict], str]:
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
normalized = [_normalized(line) for line in lines]
|
|
34
|
+
weights = [max(len(text), 1) for text in normalized]
|
|
35
|
+
start_ms, end_ms = 0.0, float(duration_ms)
|
|
36
|
+
if (isinstance(speech_span_ms, list) and len(speech_span_ms) == 2
|
|
37
|
+
and all(_number(v) for v in speech_span_ms)
|
|
38
|
+
and 0 <= speech_span_ms[0] < speech_span_ms[1] <= duration_ms):
|
|
39
|
+
start_ms, end_ms = map(float, speech_span_ms)
|
|
40
|
+
|
|
41
|
+
anchors = []
|
|
42
|
+
offset = 0
|
|
43
|
+
previous_end = 0.0
|
|
44
|
+
for sub in subtitles:
|
|
45
|
+
if not isinstance(sub, dict) or not isinstance(sub.get("text"), str):
|
|
46
|
+
anchors = []
|
|
47
|
+
break
|
|
48
|
+
text = _normalized(sub["text"])
|
|
49
|
+
if not text:
|
|
50
|
+
continue
|
|
51
|
+
begin, end = sub.get("timeBegin"), sub.get("timeEnd")
|
|
52
|
+
if not (_number(begin) and _number(end) and previous_end <= begin < end <= duration_ms):
|
|
53
|
+
anchors = []
|
|
54
|
+
break
|
|
55
|
+
anchors.append((offset, offset + len(text), float(begin), float(end), text))
|
|
56
|
+
offset += len(text)
|
|
57
|
+
previous_end = end
|
|
58
|
+
|
|
59
|
+
matches = (anchors and all(normalized)
|
|
60
|
+
and "".join(normalized) == "".join(a[4] for a in anchors))
|
|
61
|
+
result = []
|
|
62
|
+
interpolated = False
|
|
63
|
+
if matches:
|
|
64
|
+
offset = 0
|
|
65
|
+
anchor_index = 0
|
|
66
|
+
for line, text in zip(lines, normalized):
|
|
67
|
+
line_end = offset + len(text)
|
|
68
|
+
while anchors[anchor_index][1] <= offset:
|
|
69
|
+
anchor_index += 1
|
|
70
|
+
first = anchors[anchor_index]
|
|
71
|
+
while anchors[anchor_index][1] < line_end:
|
|
72
|
+
anchor_index += 1
|
|
73
|
+
last = anchors[anchor_index]
|
|
74
|
+
begin = first[2] + (first[3] - first[2]) * (offset - first[0]) / (first[1] - first[0])
|
|
75
|
+
end = last[2] + (last[3] - last[2]) * (line_end - last[0]) / (last[1] - last[0])
|
|
76
|
+
interpolated |= offset != first[0] or line_end != last[1]
|
|
77
|
+
result.append({"text": line, "timeBegin": begin, "timeEnd": end})
|
|
78
|
+
offset = line_end
|
|
79
|
+
return result, "tts-interpolated" if interpolated else "tts-boundaries"
|
|
80
|
+
|
|
81
|
+
total = sum(weights)
|
|
82
|
+
offset = 0
|
|
83
|
+
for line, weight in zip(lines, weights):
|
|
84
|
+
begin = start_ms + (end_ms - start_ms) * offset / total
|
|
85
|
+
offset += weight
|
|
86
|
+
end = start_ms + (end_ms - start_ms) * offset / total
|
|
87
|
+
result.append({"text": line, "timeBegin": begin, "timeEnd": end})
|
|
88
|
+
return result, "estimated"
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def scene_assets(plan: dict, narration_texts: dict[str, str] | None = None) -> list[dict]:
|
|
6
|
+
assets = {a.get("assetId"): a for a in plan.get("resolvedAssets", [])}
|
|
7
|
+
fps = (plan.get("renderConfig") or {}).get("fps", 30)
|
|
8
|
+
out = []
|
|
9
|
+
|
|
10
|
+
def refs(value):
|
|
11
|
+
if isinstance(value, dict):
|
|
12
|
+
for key, child in value.items():
|
|
13
|
+
if isinstance(child, str) and key.lower().endswith(("assetref", "assetid")) and child in assets:
|
|
14
|
+
yield child
|
|
15
|
+
else:
|
|
16
|
+
yield from refs(child)
|
|
17
|
+
elif isinstance(value, list):
|
|
18
|
+
for child in value:
|
|
19
|
+
yield from refs(child)
|
|
20
|
+
|
|
21
|
+
for entry in plan.get("timeline", []):
|
|
22
|
+
narration = "".join(s.get("text", "") for s in entry.get("subtitleSegments", []))
|
|
23
|
+
narration = (narration_texts or {}).get(entry.get("sceneId"), narration)
|
|
24
|
+
for ref in dict.fromkeys(refs(entry.get("props") or {})):
|
|
25
|
+
asset = assets[ref]
|
|
26
|
+
if asset.get("type") not in ("image", "video", "audio") or asset.get("status") != "generated" or not asset.get("url"):
|
|
27
|
+
continue
|
|
28
|
+
item = {
|
|
29
|
+
"type": asset["type"], "url": asset["url"], "label": ref,
|
|
30
|
+
"sceneId": entry["sceneId"],
|
|
31
|
+
"sceneStartSec": entry.get("startFrame", 0) / fps,
|
|
32
|
+
"sceneEndSec": entry.get("endFrame", 0) / fps,
|
|
33
|
+
"narrationText": narration,
|
|
34
|
+
}
|
|
35
|
+
if asset.get("duration"):
|
|
36
|
+
|
|
37
|
+
item["durationSec"] = asset["duration"] / (1000 if asset["type"] == "audio" else 1)
|
|
38
|
+
out.append(item)
|
|
39
|
+
return out
|