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.
- video_edit_cli-0.1.0.dist-info/METADATA +68 -0
- video_edit_cli-0.1.0.dist-info/RECORD +37 -0
- video_edit_cli-0.1.0.dist-info/WHEEL +4 -0
- video_edit_cli-0.1.0.dist-info/entry_points.txt +3 -0
- video_editor/__init__.py +5 -0
- video_editor/assets.py +133 -0
- video_editor/audio/__init__.py +1 -0
- video_editor/audio/analysis.py +147 -0
- video_editor/audio/comparison.py +71 -0
- video_editor/audio/denoise.py +63 -0
- video_editor/audio/mastering.py +77 -0
- video_editor/cli.py +1021 -0
- video_editor/errors.py +33 -0
- video_editor/ffmpeg.py +48 -0
- video_editor/inspection.py +168 -0
- video_editor/media.py +92 -0
- video_editor/plans.py +90 -0
- video_editor/profiles.py +58 -0
- video_editor/provenance.py +49 -0
- video_editor/reframe.py +119 -0
- video_editor/rendering.py +328 -0
- video_editor/result.py +42 -0
- video_editor/review.py +169 -0
- video_editor/schemas/__init__.py +29 -0
- video_editor/schemas/edit-plan.schema.json +69 -0
- video_editor/schemas/project-profile.schema.json +86 -0
- video_editor/schemas/result.schema.json +42 -0
- video_editor/schemas/transcript.schema.json +49 -0
- video_editor/schemas/workspace.schema.json +39 -0
- video_editor/subtitles.py +137 -0
- video_editor/sync.py +149 -0
- video_editor/transcription/__init__.py +1 -0
- video_editor/transcription/base.py +73 -0
- video_editor/transcription/fixture.py +33 -0
- video_editor/transcription/mlx_whisper.py +68 -0
- video_editor/transcription/views.py +85 -0
- video_editor/workspace.py +105 -0
|
@@ -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
|
+
}
|