@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.
@@ -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