video-edit-cli 0.1.0__py3-none-any.whl

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,86 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "video-edit-cli/project-profile.schema.json",
4
+ "title": "External project profile (YAML, validated as JSON)",
5
+ "type": "object",
6
+ "required": ["schema_version", "profiles"],
7
+ "properties": {
8
+ "schema_version": {"type": "string"},
9
+ "profiles": {
10
+ "type": "object",
11
+ "minProperties": 1,
12
+ "additionalProperties": {
13
+ "type": "object",
14
+ "required": ["canvas"],
15
+ "properties": {
16
+ "canvas": {
17
+ "type": "object",
18
+ "required": ["width", "height"],
19
+ "properties": {
20
+ "width": {"type": "integer", "minimum": 16},
21
+ "height": {"type": "integer", "minimum": 16},
22
+ "frame_rate": {"type": ["number", "null"]}
23
+ }
24
+ },
25
+ "video_codec": {
26
+ "type": "object",
27
+ "properties": {
28
+ "name": {"type": "string"},
29
+ "crf": {"type": ["integer", "null"]},
30
+ "preset": {"type": ["string", "null"]}
31
+ }
32
+ },
33
+ "audio_codec": {
34
+ "type": "object",
35
+ "properties": {
36
+ "name": {"type": "string"},
37
+ "bitrate": {"type": ["string", "null"]}
38
+ }
39
+ },
40
+ "loudness": {
41
+ "type": "object",
42
+ "properties": {
43
+ "integrated_lufs": {"type": "number"},
44
+ "true_peak_dbtp": {"type": "number"},
45
+ "lra": {"type": "number"}
46
+ }
47
+ },
48
+ "subtitles": {
49
+ "type": "object",
50
+ "properties": {
51
+ "mode": {"enum": ["mux", "burn", "none"]}
52
+ }
53
+ }
54
+ }
55
+ }
56
+ },
57
+ "music": {
58
+ "type": ["object", "null"],
59
+ "required": ["path"],
60
+ "properties": {
61
+ "path": {"type": "string"},
62
+ "gain_db": {"type": "number"},
63
+ "ducking": {
64
+ "type": "object",
65
+ "properties": {
66
+ "threshold": {"type": "number"},
67
+ "ratio": {"type": "number"}
68
+ }
69
+ }
70
+ }
71
+ },
72
+ "assets": {
73
+ "type": ["object", "null"],
74
+ "additionalProperties": {"type": "string"}
75
+ },
76
+ "fonts": {
77
+ "type": ["object", "null"],
78
+ "additionalProperties": {"type": "string"}
79
+ },
80
+ "camera_aliases": {
81
+ "type": ["object", "null"],
82
+ "additionalProperties": {"type": "string"}
83
+ },
84
+ "subtitle_style": {"type": ["object", "null"]}
85
+ }
86
+ }
@@ -0,0 +1,42 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "video-edit-cli/result.schema.json",
4
+ "title": "Command result envelope",
5
+ "type": "object",
6
+ "required": ["ok", "command", "schema_version"],
7
+ "properties": {
8
+ "ok": {"type": "boolean"},
9
+ "command": {"type": "string"},
10
+ "schema_version": {"type": "string"},
11
+ "artifacts": {
12
+ "type": "array",
13
+ "items": {
14
+ "type": "object",
15
+ "required": ["path", "kind"],
16
+ "properties": {
17
+ "path": {"type": "string"},
18
+ "kind": {"type": "string"}
19
+ }
20
+ }
21
+ },
22
+ "data": {"type": "object"},
23
+ "error": {
24
+ "type": "object",
25
+ "required": ["code", "message"],
26
+ "properties": {
27
+ "code": {"type": "string"},
28
+ "message": {"type": "string"}
29
+ }
30
+ }
31
+ },
32
+ "allOf": [
33
+ {
34
+ "if": {"properties": {"ok": {"const": true}}},
35
+ "then": {"required": ["artifacts", "data"]}
36
+ },
37
+ {
38
+ "if": {"properties": {"ok": {"const": false}}},
39
+ "then": {"required": ["error"]}
40
+ }
41
+ ]
42
+ }
@@ -0,0 +1,49 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "video-edit-cli/transcript.schema.json",
4
+ "title": "Authoritative word-level transcript",
5
+ "type": "object",
6
+ "required": ["schema_version", "source", "language", "backend", "model", "segments"],
7
+ "properties": {
8
+ "schema_version": {"type": "string"},
9
+ "source": {
10
+ "type": "object",
11
+ "required": ["path", "sha256"],
12
+ "properties": {
13
+ "path": {"type": "string"},
14
+ "sha256": {"type": "string"},
15
+ "source_id": {"type": ["string", "null"]}
16
+ }
17
+ },
18
+ "language": {"type": ["string", "null"]},
19
+ "backend": {"type": "string"},
20
+ "model": {"type": ["string", "null"]},
21
+ "created_at": {"type": "string"},
22
+ "segments": {
23
+ "type": "array",
24
+ "items": {
25
+ "type": "object",
26
+ "required": ["start", "end", "text", "words"],
27
+ "properties": {
28
+ "start": {"type": "number", "minimum": 0},
29
+ "end": {"type": "number", "minimum": 0},
30
+ "text": {"type": "string"},
31
+ "speaker": {"type": ["string", "null"]},
32
+ "words": {
33
+ "type": "array",
34
+ "items": {
35
+ "type": "object",
36
+ "required": ["text", "start", "end"],
37
+ "properties": {
38
+ "text": {"type": "string"},
39
+ "start": {"type": "number", "minimum": 0},
40
+ "end": {"type": "number", "minimum": 0},
41
+ "confidence": {"type": ["number", "null"]}
42
+ }
43
+ }
44
+ }
45
+ }
46
+ }
47
+ }
48
+ }
49
+ }
@@ -0,0 +1,39 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "video-edit-cli/workspace.schema.json",
4
+ "title": "Editing workspace manifest",
5
+ "type": "object",
6
+ "required": ["schema_version", "workspace_id", "created_at", "sources", "artifacts"],
7
+ "properties": {
8
+ "schema_version": {"type": "string"},
9
+ "workspace_id": {"type": "string"},
10
+ "created_at": {"type": "string"},
11
+ "sources": {
12
+ "type": "array",
13
+ "items": {
14
+ "type": "object",
15
+ "required": ["id", "path", "sha256", "registered_at"],
16
+ "properties": {
17
+ "id": {"type": "string"},
18
+ "path": {"type": "string"},
19
+ "sha256": {"type": "string"},
20
+ "role": {"type": ["string", "null"]},
21
+ "registered_at": {"type": "string"}
22
+ }
23
+ }
24
+ },
25
+ "artifacts": {
26
+ "type": "array",
27
+ "items": {
28
+ "type": "object",
29
+ "required": ["path", "kind", "command", "created_at"],
30
+ "properties": {
31
+ "path": {"type": "string"},
32
+ "kind": {"type": "string"},
33
+ "command": {"type": "string"},
34
+ "created_at": {"type": "string"}
35
+ }
36
+ }
37
+ }
38
+ }
39
+ }
@@ -0,0 +1,137 @@
1
+ """Subtitles derived from transcripts mapped through an edited timeline."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from video_editor import ffmpeg
9
+ from video_editor.errors import InvalidInputError, ToolFailureError
10
+
11
+
12
+ def _fmt_srt(seconds: float) -> str:
13
+ ms = round(seconds * 1000)
14
+ h, rem = divmod(ms, 3_600_000)
15
+ m, rem = divmod(rem, 60_000)
16
+ s, ms = divmod(rem, 1000)
17
+ return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
18
+
19
+
20
+ def _fmt_vtt(seconds: float) -> str:
21
+ return _fmt_srt(seconds).replace(",", ".")
22
+
23
+
24
+ def map_cues(
25
+ transcripts: dict[str, dict[str, Any]], segments: list[dict[str, Any]]
26
+ ) -> list[dict[str, Any]]:
27
+ """Map transcript segments through kept ranges into output-time cues.
28
+
29
+ `transcripts` is keyed by plan source id. Words outside kept ranges are
30
+ dropped; partially kept transcript segments are clamped.
31
+ """
32
+ cues: list[dict[str, Any]] = []
33
+ for segment in segments:
34
+ transcript = transcripts.get(segment["source"])
35
+ if transcript is None:
36
+ continue
37
+ kept_in, kept_out = segment["in"], segment["out"]
38
+ offset = segment["output_start"] - kept_in
39
+ for ts in transcript["segments"]:
40
+ words = [
41
+ w for w in ts["words"] if kept_in <= w["start"] and w["end"] <= kept_out
42
+ ]
43
+ if not words:
44
+ continue
45
+ cues.append(
46
+ {
47
+ "start": round(words[0]["start"] + offset, 3),
48
+ "end": round(words[-1]["end"] + offset, 3),
49
+ "text": " ".join(w["text"] for w in words).strip(),
50
+ }
51
+ )
52
+ cues.sort(key=lambda cue: cue["start"])
53
+ return cues
54
+
55
+
56
+ def write_srt(cues: list[dict[str, Any]], output: Path) -> None:
57
+ lines = []
58
+ for i, cue in enumerate(cues, start=1):
59
+ lines.append(str(i))
60
+ lines.append(f"{_fmt_srt(cue['start'])} --> {_fmt_srt(cue['end'])}")
61
+ lines.append(cue["text"])
62
+ lines.append("")
63
+ output.parent.mkdir(parents=True, exist_ok=True)
64
+ output.write_text("\n".join(lines))
65
+
66
+
67
+ def write_vtt(cues: list[dict[str, Any]], output: Path) -> None:
68
+ lines = ["WEBVTT", ""]
69
+ for cue in cues:
70
+ lines.append(f"{_fmt_vtt(cue['start'])} --> {_fmt_vtt(cue['end'])}")
71
+ lines.append(cue["text"])
72
+ lines.append("")
73
+ output.parent.mkdir(parents=True, exist_ok=True)
74
+ output.write_text("\n".join(lines))
75
+
76
+
77
+ def parse_srt_last_end(path: Path) -> float:
78
+ """Last cue end time in seconds, for output validation."""
79
+ import re
80
+
81
+ text = path.read_text()
82
+ times = re.findall(r"(\d\d):(\d\d):(\d\d)[,.](\d\d\d)", text)
83
+ if not times:
84
+ raise InvalidInputError(f"no cue timestamps found in {path}")
85
+ h, m, s, ms = times[-1]
86
+ return int(h) * 3600 + int(m) * 60 + int(s) + int(ms) / 1000
87
+
88
+
89
+ def render_subtitles(
90
+ video: Path, subtitle_file: Path, output: Path, mode: str
91
+ ) -> list[str]:
92
+ """Attach subtitles to a video: mux a subtitle track, or burn if supported."""
93
+ if not video.is_file():
94
+ raise InvalidInputError(f"video not found: {video}")
95
+ if not subtitle_file.is_file():
96
+ raise InvalidInputError(f"subtitle file not found: {subtitle_file}")
97
+ output.parent.mkdir(parents=True, exist_ok=True)
98
+ if mode == "mux":
99
+ codec = (
100
+ "mov_text" if output.suffix.lower() in (".mp4", ".m4v", ".mov") else "srt"
101
+ )
102
+ args = [
103
+ "-i",
104
+ str(video),
105
+ "-i",
106
+ str(subtitle_file),
107
+ "-map",
108
+ "0",
109
+ "-map",
110
+ "1:0",
111
+ "-c",
112
+ "copy",
113
+ "-c:s",
114
+ codec,
115
+ str(output),
116
+ ]
117
+ ffmpeg.run_ffmpeg(args)
118
+ return args
119
+ if mode == "burn":
120
+ filters = ffmpeg.run("ffmpeg", ["-hide_banner", "-filters"]).stdout
121
+ if " subtitles " not in filters:
122
+ raise ToolFailureError(
123
+ "this ffmpeg build has no 'subtitles' filter (libass); "
124
+ "use --mode mux or install a full ffmpeg build"
125
+ )
126
+ args = [
127
+ "-i",
128
+ str(video),
129
+ "-vf",
130
+ f"subtitles={subtitle_file}",
131
+ "-c:a",
132
+ "copy",
133
+ str(output),
134
+ ]
135
+ ffmpeg.run_ffmpeg(args)
136
+ return args
137
+ raise InvalidInputError(f"unknown subtitles render mode '{mode}' (mux|burn)")
video_editor/sync.py ADDED
@@ -0,0 +1,149 @@
1
+ """Audio-based synchronization between separately recorded sources.
2
+
3
+ `analyze` estimates the offset by cross-correlating low-rate loudness
4
+ envelopes — pure evidence, it never rewrites sources. `apply` creates an
5
+ aligned derivative or a mapping file from an explicitly approved offset.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import array
11
+ import json
12
+ import math
13
+ import subprocess
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from video_editor import ffmpeg
18
+ from video_editor.errors import InvalidInputError, ToolFailureError
19
+ from video_editor.provenance import utc_now
20
+
21
+ ENVELOPE_HZ = 100
22
+ SAMPLE_RATE = 8000
23
+
24
+
25
+ def _envelope(path: Path) -> list[float]:
26
+ """RMS envelope at ENVELOPE_HZ from mono 8 kHz PCM."""
27
+ if not path.is_file():
28
+ raise InvalidInputError(f"input file not found: {path}")
29
+ binary = ffmpeg.binary_path("ffmpeg")
30
+ proc = subprocess.run(
31
+ [
32
+ binary,
33
+ "-hide_banner",
34
+ "-nostdin",
35
+ "-i",
36
+ str(path),
37
+ "-map",
38
+ "0:a:0",
39
+ "-ac",
40
+ "1",
41
+ "-ar",
42
+ str(SAMPLE_RATE),
43
+ "-f",
44
+ "s16le",
45
+ "-",
46
+ ],
47
+ capture_output=True,
48
+ check=False,
49
+ )
50
+ if proc.returncode != 0:
51
+ raise ToolFailureError(
52
+ f"ffmpeg failed extracting audio from {path}: "
53
+ + proc.stderr.decode(errors="replace").strip().splitlines()[-1]
54
+ )
55
+ samples = array.array("h")
56
+ samples.frombytes(proc.stdout[: len(proc.stdout) // 2 * 2])
57
+ window = SAMPLE_RATE // ENVELOPE_HZ
58
+ envelope = []
59
+ for i in range(0, len(samples) - window, window):
60
+ acc = 0.0
61
+ for j in range(i, i + window):
62
+ value = samples[j] / 32768.0
63
+ acc += value * value
64
+ envelope.append(math.sqrt(acc / window))
65
+ return envelope
66
+
67
+
68
+ def _normalize(env: list[float]) -> list[float]:
69
+ mean = sum(env) / len(env)
70
+ centered = [v - mean for v in env]
71
+ norm = math.sqrt(sum(v * v for v in centered)) or 1.0
72
+ return [v / norm for v in centered]
73
+
74
+
75
+ def analyze(reference: Path, other: Path, max_offset: float = 30.0) -> dict[str, Any]:
76
+ """Estimated offset in seconds such that `other` content = reference + offset."""
77
+ env_ref = _normalize(_envelope(reference))
78
+ env_other = _normalize(_envelope(other))
79
+ if len(env_ref) < ENVELOPE_HZ or len(env_other) < ENVELOPE_HZ:
80
+ raise InvalidInputError("sources are too short to synchronize (<1s of audio)")
81
+
82
+ max_lag = int(max_offset * ENVELOPE_HZ)
83
+ scores: list[tuple[float, int]] = []
84
+ for lag in range(-max_lag, max_lag + 1):
85
+ acc = 0.0
86
+ for i, value in enumerate(env_ref):
87
+ j = i + lag
88
+ if 0 <= j < len(env_other):
89
+ acc += value * env_other[j]
90
+ scores.append((acc, lag))
91
+ scores.sort(reverse=True)
92
+ best_score, best_lag = scores[0]
93
+ runner_up = next(
94
+ (score for score, lag in scores[1:] if abs(lag - best_lag) > ENVELOPE_HZ // 5),
95
+ 0.0,
96
+ )
97
+ confidence = best_score / runner_up if runner_up > 0 else float("inf")
98
+ return {
99
+ "reference": str(reference),
100
+ "other": str(other),
101
+ "offset_seconds": round(best_lag / ENVELOPE_HZ, 3),
102
+ "peak_correlation": round(best_score, 4),
103
+ "confidence_ratio": round(confidence, 3) if math.isfinite(confidence) else None,
104
+ "candidates": [
105
+ {"offset_seconds": lag / ENVELOPE_HZ, "correlation": round(score, 4)}
106
+ for score, lag in scores[:5]
107
+ ],
108
+ "note": "offset is where reference content appears in the other source; "
109
+ "trim that many seconds from the other source to align",
110
+ }
111
+
112
+
113
+ def apply(source: Path, offset: float, output: Path) -> dict[str, Any]:
114
+ """Create an aligned derivative (or a .json mapping) from an approved offset."""
115
+ if not source.is_file():
116
+ raise InvalidInputError(f"input file not found: {source}")
117
+ output.parent.mkdir(parents=True, exist_ok=True)
118
+ if output.suffix.lower() == ".json":
119
+ mapping = {
120
+ "kind": "sync-mapping",
121
+ "created_at": utc_now(),
122
+ "source": str(source),
123
+ "offset_seconds": offset,
124
+ "aligned_time": "source_time - offset",
125
+ }
126
+ output.write_text(json.dumps(mapping, indent=2) + "\n")
127
+ return {"mode": "metadata", "output": str(output), "offset_seconds": offset}
128
+ if offset < 0:
129
+ raise InvalidInputError(
130
+ "negative offset: swap arguments so the trimmed source is the later one, "
131
+ "or write a .json mapping instead"
132
+ )
133
+ args = [
134
+ "-ss",
135
+ f"{offset:.6f}",
136
+ "-i",
137
+ str(source),
138
+ "-c:v",
139
+ "libx264",
140
+ "-preset",
141
+ "veryfast",
142
+ "-crf",
143
+ "18",
144
+ "-c:a",
145
+ "pcm_s24le" if output.suffix.lower() == ".mov" else "aac",
146
+ str(output),
147
+ ]
148
+ ffmpeg.run_ffmpeg(args)
149
+ return {"mode": "trim", "output": str(output), "offset_seconds": offset}
@@ -0,0 +1 @@
1
+ """Local transcription backends behind a replaceable interface."""
@@ -0,0 +1,73 @@
1
+ """Transcription backend interface.
2
+
3
+ A backend turns one media file into raw segments; `transcribe_to_document`
4
+ wraps the result in the authoritative transcript JSON document. Backends are
5
+ isolated behind this interface so the initial mlx-whisper backend can be
6
+ replaced without touching callers.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+ from typing import Any, Protocol
13
+
14
+ from video_editor import SCHEMA_VERSION, schemas
15
+ from video_editor.errors import InvalidInputError
16
+ from video_editor.provenance import sha256_file, utc_now
17
+
18
+
19
+ class TranscriptionBackend(Protocol):
20
+ name: str
21
+
22
+ def transcribe(
23
+ self, media: Path, model: str | None, language: str | None
24
+ ) -> dict[str, Any]:
25
+ """Return {"language": str|None, "model": str|None, "segments": [...]}.
26
+
27
+ Each segment: {"start", "end", "text", "words": [{"text", "start",
28
+ "end", "confidence"?}], "speaker"?}.
29
+ """
30
+ ...
31
+
32
+
33
+ def get_backend(name: str, fixture: Path | None = None) -> TranscriptionBackend:
34
+ if name == "mlx-whisper":
35
+ from video_editor.transcription.mlx_whisper import MlxWhisperBackend
36
+
37
+ return MlxWhisperBackend()
38
+ if name == "fixture":
39
+ from video_editor.transcription.fixture import FixtureBackend
40
+
41
+ if fixture is None:
42
+ raise InvalidInputError(
43
+ "--fixture <raw.json> is required with --backend fixture"
44
+ )
45
+ return FixtureBackend(fixture)
46
+ raise InvalidInputError(f"unknown transcription backend '{name}'")
47
+
48
+
49
+ def transcribe_to_document(
50
+ backend: TranscriptionBackend,
51
+ media: Path,
52
+ model: str | None,
53
+ language: str | None,
54
+ source_id: str | None = None,
55
+ ) -> dict[str, Any]:
56
+ if not media.is_file():
57
+ raise InvalidInputError(f"input file not found: {media}")
58
+ raw = backend.transcribe(media, model, language)
59
+ document: dict[str, Any] = {
60
+ "schema_version": SCHEMA_VERSION,
61
+ "source": {
62
+ "path": str(media.resolve()),
63
+ "sha256": sha256_file(media),
64
+ "source_id": source_id,
65
+ },
66
+ "language": raw.get("language"),
67
+ "backend": backend.name,
68
+ "model": raw.get("model"),
69
+ "created_at": utc_now(),
70
+ "segments": raw["segments"],
71
+ }
72
+ schemas.validate(document, "transcript.schema.json")
73
+ return document
@@ -0,0 +1,33 @@
1
+ """Deterministic backend that replays a prepared raw-transcription JSON file.
2
+
3
+ Used by automated tests and available for development so no command depends on
4
+ model weights. The fixture file holds exactly what a real backend would return:
5
+ {"language": ..., "model": ..., "segments": [...]}.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from video_editor.errors import InvalidInputError
15
+
16
+
17
+ class FixtureBackend:
18
+ name = "fixture"
19
+
20
+ def __init__(self, fixture: Path) -> None:
21
+ self._fixture = fixture
22
+
23
+ def transcribe(
24
+ self, media: Path, model: str | None, language: str | None
25
+ ) -> dict[str, Any]:
26
+ if not self._fixture.is_file():
27
+ raise InvalidInputError(f"fixture transcript not found: {self._fixture}")
28
+ raw: dict[str, Any] = json.loads(self._fixture.read_text())
29
+ if "segments" not in raw:
30
+ raise InvalidInputError(
31
+ f"fixture transcript {self._fixture} has no 'segments' key"
32
+ )
33
+ return raw
@@ -0,0 +1,68 @@
1
+ """mlx-whisper backend: local word-level transcription on Apple Silicon.
2
+
3
+ The `mlx-whisper` dependency is an optional extra (`video-edit-cli[mlx]`); its
4
+ absence fails cleanly without affecting other commands. Integration tests that
5
+ run real inference are opt-in via the `integration` pytest marker.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from video_editor.errors import VideoEditorError
14
+
15
+ DEFAULT_MODEL = "mlx-community/whisper-large-v3-turbo"
16
+
17
+
18
+ class MlxWhisperBackend:
19
+ name = "mlx-whisper"
20
+
21
+ def transcribe(
22
+ self, media: Path, model: str | None, language: str | None
23
+ ) -> dict[str, Any]:
24
+ import importlib
25
+
26
+ try:
27
+ mlx_whisper = importlib.import_module("mlx_whisper")
28
+ except ImportError as exc:
29
+ raise VideoEditorError(
30
+ "missing-dependency",
31
+ "the mlx-whisper backend requires the optional 'mlx' extra; "
32
+ "install with `uv sync --extra mlx` (Apple Silicon only)",
33
+ exit_code=5,
34
+ ) from exc
35
+
36
+ model_name = model or DEFAULT_MODEL
37
+ raw = mlx_whisper.transcribe(
38
+ str(media),
39
+ path_or_hf_repo=model_name,
40
+ word_timestamps=True,
41
+ language=language,
42
+ )
43
+ segments = []
44
+ for segment in raw.get("segments", []):
45
+ words = [
46
+ {
47
+ "text": str(word["word"]).strip(),
48
+ "start": float(word["start"]),
49
+ "end": float(word["end"]),
50
+ "confidence": float(word["probability"])
51
+ if word.get("probability") is not None
52
+ else None,
53
+ }
54
+ for word in segment.get("words", [])
55
+ ]
56
+ segments.append(
57
+ {
58
+ "start": float(segment["start"]),
59
+ "end": float(segment["end"]),
60
+ "text": str(segment["text"]).strip(),
61
+ "words": words,
62
+ }
63
+ )
64
+ return {
65
+ "language": raw.get("language"),
66
+ "model": model_name,
67
+ "segments": segments,
68
+ }