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,68 @@
1
+ Metadata-Version: 2.3
2
+ Name: video-edit-cli
3
+ Version: 0.1.0
4
+ Summary: Headless, project-agnostic video-editing CLI with atomic subcommands for AI agents.
5
+ Keywords: video,editing,ffmpeg,agents,cli
6
+ Classifier: Development Status :: 3 - Alpha
7
+ Classifier: Environment :: Console
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Programming Language :: Python :: 3.14
13
+ Classifier: Topic :: Multimedia :: Video :: Conversion
14
+ Requires-Dist: jsonschema>=4.21
15
+ Requires-Dist: pyyaml>=6
16
+ Requires-Dist: deepfilternet>=0.5 ; extra == 'df'
17
+ Requires-Dist: mlx-whisper>=0.4 ; extra == 'mlx'
18
+ Requires-Python: >=3.11
19
+ Project-URL: Issues, https://github.com/computerlovetech/video-edit-cli/issues
20
+ Project-URL: Repository, https://github.com/computerlovetech/video-edit-cli
21
+ Provides-Extra: df
22
+ Provides-Extra: mlx
23
+ Description-Content-Type: text/markdown
24
+
25
+ # video-edit-cli
26
+
27
+ Headless, project-agnostic video-editing workbench for AI agents. One `video-edit-cli`
28
+ CLI exposes atomic, non-interactive subcommands that inspect and transform media
29
+ deterministically; the agent plans the edit and composes the primitives.
30
+
31
+ Contract:
32
+
33
+ - Machine-readable JSON on stdout (one result envelope per command), diagnostics on
34
+ stderr, meaningful non-zero exit codes on failure.
35
+ - Source media is immutable. Every derived file is new and gets a
36
+ `*.provenance.json` sidecar recording inputs, hashes, tool versions, parameters,
37
+ and the exact tool commands.
38
+ - Requires `ffmpeg` and `ffprobe` on PATH; commands fail with the stable error code
39
+ `missing-binary` when absent.
40
+
41
+ Install the base CLI with `uv tool install video-edit-cli`. For local Apple Silicon
42
+ transcription, install `uv tool install 'video-edit-cli[mlx]'`. FFmpeg and FFprobe must
43
+ be installed separately and available on `PATH`.
44
+
45
+ Usage discovery: `video-edit-cli --help` and `--help` on every subcommand. Inside this
46
+ repository, prefix commands with `uv run`.
47
+
48
+ Example:
49
+
50
+ ```sh
51
+ uv run video-edit-cli workspace init --root /tmp/ep1 --source recording.mp4
52
+ uv run video-edit-cli probe --input recording.mp4
53
+ uv run video-edit-cli filmstrip create --input recording.mp4 \
54
+ --start 60 --end 90 --output /tmp/ep1/analysis/strip.png --workspace /tmp/ep1
55
+ ```
56
+
57
+ The companion skill in `skills/video-editor/` teaches agents the editing method;
58
+ this package owns the mechanics. Tests generate small deterministic fixtures at
59
+ runtime (`tests/generate_fixtures.py`); no media is committed.
60
+
61
+ Quality gates (run from this directory):
62
+
63
+ ```sh
64
+ uv run ruff check .
65
+ uv run ruff format --check .
66
+ uv run ty check
67
+ uv run pytest
68
+ ```
@@ -0,0 +1,37 @@
1
+ video_editor/__init__.py,sha256=dmoIOd3jKxFn9QCPJ0SlTR1xmT7izGb-FCfgSdNC6Lw,122
2
+ video_editor/assets.py,sha256=1gb9h9Hng2snqdYYWLBuozHKn0sU9SkcjsMn-sUmGJU,4776
3
+ video_editor/audio/__init__.py,sha256=NENY7PjW2ZiasL-2N68b7el36mqjBhYsavVtj39VrxU,73
4
+ video_editor/audio/analysis.py,sha256=3j-2hNzcKnyRf3-DrwuclQkYe8ong71YT5ddaLfLGXI,4253
5
+ video_editor/audio/comparison.py,sha256=hbzTS8RIdeqlWpuq1-bn5z-eJNApvkQpKLvfBUGI77E,2352
6
+ video_editor/audio/denoise.py,sha256=ux3LGvqlgGd5GWotrgHxT0cxI40Fb5d9MeBsG2aSapA,2332
7
+ video_editor/audio/mastering.py,sha256=bRSTCQMRfH5K78MKcg8P_u45aW_qD1sInCGVvyUZgEU,2397
8
+ video_editor/cli.py,sha256=J1ncrmDCXVDg5FPnZqsveW_sFUDSoVCvajOs5DRFJe4,37134
9
+ video_editor/errors.py,sha256=I6yiB3FPZZuvjUu84OPQ57cbEjXs6UJMIH8gYkYWZQE,1056
10
+ video_editor/ffmpeg.py,sha256=068UlLvGgPQ_XLwge_v5HN4xFhF0HCSlCYa_S7DERts,1494
11
+ video_editor/inspection.py,sha256=f68kcmGJsEHFvuXW-o83AGyjwe3A2g54e2gtkEwRWB4,4775
12
+ video_editor/media.py,sha256=tczuDXPGFvpVZkFWcQXPtfUCWu9MJ6eKkS2H4FxWQqM,3088
13
+ video_editor/plans.py,sha256=uOjoXwBIRL4z0C6q1x3i4YeR4ruQDp7QqjXMj7tRmX0,3332
14
+ video_editor/profiles.py,sha256=bY-qES_uIJU3HEnyGLnDk0E95hraQmE2pdgR7EK1YNc,2011
15
+ video_editor/provenance.py,sha256=R6OaBycwpvqQtalZGNpEqo4vq-dzSyU_OuS3joAnmmk,1478
16
+ video_editor/reframe.py,sha256=HyR_QLtgZJBEas2wJfBR-yE_PnpLJz-kTdpq6xR3ib0,3582
17
+ video_editor/rendering.py,sha256=Z_o9mDMBiQDcYo7uJl4VcE2HRqpW9Za8iCRW12LoE1M,10655
18
+ video_editor/result.py,sha256=s0QTDO-IYfr6CgugnqA34TyYmbNHwc3jV4GcSyK-zIg,1061
19
+ video_editor/review.py,sha256=oWoJ-T3ng3H-x3VUm0i8_TPTPBEbOZmB82DccymRT1c,5947
20
+ video_editor/schemas/__init__.py,sha256=JWuofAIou2z3z2sPB1fMCwc2njG6XaPC9fzCsV5bhfc,831
21
+ video_editor/schemas/edit-plan.schema.json,sha256=qvtbrAOW5uOguNAK0HqXHHA0PKn2OJd-54igkTSKC2g,2287
22
+ video_editor/schemas/project-profile.schema.json,sha256=j_9pxd2VuiUqPHK2EvYLi29e7FVIcWKFv2bukJ-pGxc,2483
23
+ video_editor/schemas/result.schema.json,sha256=FY6K_6pPHQ5PtTpVU8fVueVY5CDLbe-b4nXWNyOqVPs,1055
24
+ video_editor/schemas/transcript.schema.json,sha256=XPUP9I_MIh4FtIHf0NUmtPflJV0MtsRjkT5IWE5nwlc,1566
25
+ video_editor/schemas/workspace.schema.json,sha256=MXiK3wu6oJOnmb1JUI44BUT6M7U2IIcMDdji-GcDrSg,1164
26
+ video_editor/subtitles.py,sha256=HpHywKgCEofZc-WiqhskKO7wmPjOWytf6RtaWjKqITM,4449
27
+ video_editor/sync.py,sha256=ftv-emys2G0NVzu3FmjemUvNUt-TDajxnUHoT4Zq8sE,5028
28
+ video_editor/transcription/__init__.py,sha256=2pqIxnlvPA-WPRzXosKtZ4DKNjQjhXLOrQ8nAiJlHMM,67
29
+ video_editor/transcription/base.py,sha256=nCa1v18_RAI0k5yJ5UNDopm1aS-BB07ilWSPkrnuXUw,2352
30
+ video_editor/transcription/fixture.py,sha256=D8j4QHqPBxa4RYc9q8hLhkefgd2kmWBfZUBXl56AOCs,1054
31
+ video_editor/transcription/mlx_whisper.py,sha256=EAAWJ47uOri7mlS53760XQ31RPEGGj8U13UJafbtqos,2214
32
+ video_editor/transcription/views.py,sha256=fmeyk9_6qUFmx1vy1Y99IDF1qh_fNCZiDC5x6pfLPXU,2986
33
+ video_editor/workspace.py,sha256=qec5J2ER_Q-DH_72M7g_7QuOrCjGJNjp2scdv5cUNmU,3270
34
+ video_edit_cli-0.1.0.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
35
+ video_edit_cli-0.1.0.dist-info/entry_points.txt,sha256=3JDERJzuUKd-Z_ksGpt4M1DdEXFs0R8IhGEFD39_3a8,58
36
+ video_edit_cli-0.1.0.dist-info/METADATA,sha256=z40XDWsUdQNG-vLbKfupeScS39Y5htkxhkK6WPl6QqI,2698
37
+ video_edit_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.8.24
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ video-edit-cli = video_editor.cli:main
3
+
@@ -0,0 +1,5 @@
1
+ """Headless video-editing workbench: atomic CLI primitives for AI agents."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ SCHEMA_VERSION = "1"
video_editor/assets.py ADDED
@@ -0,0 +1,133 @@
1
+ """External asset inspection and final-output validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from video_editor import media, subtitles
9
+ from video_editor.audio.analysis import measure_loudness
10
+ from video_editor.errors import InvalidInputError
11
+
12
+ MEDIA_SUFFIXES = {
13
+ ".mp4",
14
+ ".mov",
15
+ ".mkv",
16
+ ".m4v",
17
+ ".webm",
18
+ ".wav",
19
+ ".mp3",
20
+ ".m4a",
21
+ ".aac",
22
+ ".flac",
23
+ ".ogg",
24
+ }
25
+ IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".bmp"}
26
+ FONT_SUFFIXES = {".ttf", ".otf", ".ttc", ".woff", ".woff2"}
27
+
28
+
29
+ def inspect_asset(path: Path) -> dict[str, Any]:
30
+ """Classify and validate one external asset (media, image, font, subtitle)."""
31
+ if not path.is_file():
32
+ raise InvalidInputError(f"asset not found: {path}")
33
+ suffix = path.suffix.lower()
34
+ if suffix in FONT_SUFFIXES:
35
+ return {"path": str(path), "kind": "font", "size_bytes": path.stat().st_size}
36
+ if suffix in (".srt", ".vtt"):
37
+ last_end = subtitles.parse_srt_last_end(path)
38
+ return {"path": str(path), "kind": "subtitles", "last_cue_end": last_end}
39
+ if suffix in MEDIA_SUFFIXES or suffix in IMAGE_SUFFIXES:
40
+ info = media.probe(path)
41
+ types = {s["type"] for s in info["streams"]}
42
+ if suffix in IMAGE_SUFFIXES:
43
+ kind = "image"
44
+ elif "video" in types:
45
+ kind = "video"
46
+ else:
47
+ kind = "audio"
48
+ return {"kind": kind, **info}
49
+ raise InvalidInputError(
50
+ f"unrecognized asset type '{suffix}' for {path}; "
51
+ "expected media, image, font, or subtitle file"
52
+ )
53
+
54
+
55
+ def validate_output(
56
+ path: Path,
57
+ profile: dict[str, Any] | None = None,
58
+ expect_duration: float | None = None,
59
+ duration_tolerance: float = 0.5,
60
+ loudness_tolerance: float = 1.5,
61
+ expect_subtitles: bool = False,
62
+ subtitle_file: Path | None = None,
63
+ ) -> dict[str, Any]:
64
+ """Check a final render against expected streams, canvas, duration, loudness."""
65
+ info = media.probe(path)
66
+ issues: list[str] = []
67
+ types = [s["type"] for s in info["streams"]]
68
+ if "video" not in types:
69
+ issues.append("no video stream")
70
+ if "audio" not in types:
71
+ issues.append("no audio stream")
72
+
73
+ video = next((s for s in info["streams"] if s["type"] == "video"), None)
74
+ checks: dict[str, Any] = {"streams": types}
75
+
76
+ if profile and video:
77
+ canvas = profile["canvas"]
78
+ if (video["width"], video["height"]) != (canvas["width"], canvas["height"]):
79
+ issues.append(
80
+ f"canvas {video['width']}x{video['height']} != "
81
+ f"expected {canvas['width']}x{canvas['height']}"
82
+ )
83
+ expected_fps = canvas.get("frame_rate")
84
+ if (
85
+ expected_fps
86
+ and video["frame_rate"]
87
+ and abs(video["frame_rate"] - expected_fps) > 0.1
88
+ ):
89
+ issues.append(
90
+ f"frame rate {video['frame_rate']} != expected {expected_fps}"
91
+ )
92
+ checks["canvas"] = f"{video['width']}x{video['height']}@{video['frame_rate']}"
93
+
94
+ duration = info["duration_seconds"] or 0.0
95
+ checks["duration_seconds"] = duration
96
+ if (
97
+ expect_duration is not None
98
+ and abs(duration - expect_duration) > duration_tolerance
99
+ ):
100
+ issues.append(
101
+ f"duration {duration:.3f}s differs from expected "
102
+ f"{expect_duration:.3f}s by more than {duration_tolerance}s"
103
+ )
104
+
105
+ if profile and profile.get("loudness") and "audio" in types:
106
+ loudness = measure_loudness(path)
107
+ checks["loudness"] = {k: v for k, v in loudness.items() if k != "raw"}
108
+ target = profile["loudness"]
109
+ if (
110
+ abs(loudness["integrated_lufs"] - target["integrated_lufs"])
111
+ > loudness_tolerance
112
+ ):
113
+ issues.append(
114
+ f"integrated loudness {loudness['integrated_lufs']} LUFS outside "
115
+ f"±{loudness_tolerance} of target {target['integrated_lufs']}"
116
+ )
117
+ if loudness["true_peak_dbtp"] > target["true_peak_dbtp"] + 0.3:
118
+ issues.append(
119
+ f"true peak {loudness['true_peak_dbtp']} dBTP above "
120
+ f"target {target['true_peak_dbtp']}"
121
+ )
122
+
123
+ if expect_subtitles and "subtitle" not in types:
124
+ issues.append("no subtitle stream in output")
125
+ if subtitle_file is not None:
126
+ last_end = subtitles.parse_srt_last_end(subtitle_file)
127
+ checks["subtitle_last_cue_end"] = last_end
128
+ if last_end > duration + 0.5:
129
+ issues.append(
130
+ f"subtitle timing runs to {last_end:.3f}s beyond output duration {duration:.3f}s"
131
+ )
132
+
133
+ return {"path": str(path), "passed": not issues, "issues": issues, "checks": checks}
@@ -0,0 +1 @@
1
+ """Audio analysis, restoration, mastering, and comparison primitives."""
@@ -0,0 +1,147 @@
1
+ """Deterministic audio quality measurement (BS.1770 loudness, peaks, silence)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from video_editor import ffmpeg
11
+ from video_editor.errors import InvalidInputError, ToolFailureError
12
+
13
+
14
+ def measure_loudness(
15
+ path: Path,
16
+ target_i: float = -16.0,
17
+ target_tp: float = -1.5,
18
+ target_lra: float = 11.0,
19
+ ) -> dict[str, Any]:
20
+ """First-pass loudnorm measurement: integrated LUFS, true peak, LRA, threshold."""
21
+ if not path.is_file():
22
+ raise InvalidInputError(f"input file not found: {path}")
23
+ proc = ffmpeg.run(
24
+ "ffmpeg",
25
+ [
26
+ "-hide_banner",
27
+ "-nostdin",
28
+ "-i",
29
+ str(path),
30
+ "-af",
31
+ f"loudnorm=I={target_i}:TP={target_tp}:LRA={target_lra}:print_format=json",
32
+ "-f",
33
+ "null",
34
+ "-",
35
+ ],
36
+ )
37
+ match = re.search(r"\{[^{}]*\"input_i\"[^{}]*\}", proc.stderr, re.DOTALL)
38
+ if not match:
39
+ raise ToolFailureError("loudnorm did not report measurement JSON")
40
+ raw = json.loads(match.group(0))
41
+ return {
42
+ "integrated_lufs": float(raw["input_i"]),
43
+ "true_peak_dbtp": float(raw["input_tp"]),
44
+ "loudness_range_lu": float(raw["input_lra"]),
45
+ "threshold_lufs": float(raw["input_thresh"]),
46
+ "raw": raw,
47
+ }
48
+
49
+
50
+ def analyze(path: Path) -> dict[str, Any]:
51
+ """Loudness, true peak, clipping indicators, silence spans, bandwidth signal."""
52
+ loudness = measure_loudness(path)
53
+
54
+ proc = ffmpeg.run(
55
+ "ffmpeg",
56
+ [
57
+ "-hide_banner",
58
+ "-nostdin",
59
+ "-i",
60
+ str(path),
61
+ "-af",
62
+ "astats=measure_perchannel=none:measure_overall="
63
+ "Peak_level+Flat_factor+Peak_count+RMS_level,"
64
+ "silencedetect=n=-40dB:d=0.5",
65
+ "-f",
66
+ "null",
67
+ "-",
68
+ ],
69
+ )
70
+ log = proc.stderr
71
+
72
+ def stat(name: str) -> float | None:
73
+ found = re.search(rf"{name}: (-?[\d.]+|-inf)", log)
74
+ if not found or found.group(1) == "-inf":
75
+ return None
76
+ return float(found.group(1))
77
+
78
+ silence_starts = [float(t) for t in re.findall(r"silence_start: (-?[\d.]+)", log)]
79
+ silence_durations = [
80
+ float(t) for t in re.findall(r"silence_duration: ([\d.]+)", log)
81
+ ]
82
+
83
+ # Crude bandwidth signal: energy remaining above 8 kHz relative to full band.
84
+ hf = ffmpeg.run(
85
+ "ffmpeg",
86
+ [
87
+ "-hide_banner",
88
+ "-nostdin",
89
+ "-i",
90
+ str(path),
91
+ "-af",
92
+ "highpass=f=8000,volumedetect",
93
+ "-f",
94
+ "null",
95
+ "-",
96
+ ],
97
+ )
98
+ hf_mean = re.search(r"mean_volume: (-?[\d.]+)", hf.stderr)
99
+ full = ffmpeg.run(
100
+ "ffmpeg",
101
+ [
102
+ "-hide_banner",
103
+ "-nostdin",
104
+ "-i",
105
+ str(path),
106
+ "-af",
107
+ "volumedetect",
108
+ "-f",
109
+ "null",
110
+ "-",
111
+ ],
112
+ )
113
+ full_mean = re.search(r"mean_volume: (-?[\d.]+)", full.stderr)
114
+ hf_drop = (
115
+ round(float(full_mean.group(1)) - float(hf_mean.group(1)), 2)
116
+ if hf_mean and full_mean
117
+ else None
118
+ )
119
+
120
+ flat_factor = stat("Flat factor")
121
+ peak_level = stat("Peak level dB")
122
+ return {
123
+ "loudness": {k: v for k, v in loudness.items() if k != "raw"},
124
+ "peak_level_dbfs": peak_level,
125
+ "rms_level_dbfs": stat("RMS level dB"),
126
+ "clipping": {
127
+ "flat_factor": flat_factor,
128
+ "peak_count": stat("Peak count"),
129
+ "likely_clipped": bool(
130
+ flat_factor
131
+ and flat_factor > 0.1
132
+ and peak_level is not None
133
+ and peak_level > -0.2
134
+ ),
135
+ },
136
+ "silence": {
137
+ "spans": [
138
+ {"start": s, "duration": d}
139
+ for s, d in zip(silence_starts, silence_durations)
140
+ ],
141
+ "count": len(silence_starts),
142
+ },
143
+ "bandwidth": {
144
+ "high_frequency_dropoff_db": hf_drop,
145
+ "note": "full-band mean volume minus >8kHz mean volume; larger = duller",
146
+ },
147
+ }
@@ -0,0 +1,71 @@
1
+ """Loudness-matched A/B comparison of candidate audio treatments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from video_editor import ffmpeg
10
+ from video_editor.audio.analysis import measure_loudness
11
+ from video_editor.errors import InvalidInputError
12
+ from video_editor.provenance import utc_now
13
+
14
+
15
+ def compare(
16
+ candidates: list[Path],
17
+ output_dir: Path,
18
+ match_lufs: float = -20.0,
19
+ sample_start: float = 0.0,
20
+ sample_duration: float = 12.0,
21
+ ) -> dict[str, Any]:
22
+ """Measure candidates and write loudness-matched review excerpts + report."""
23
+ if len(candidates) < 2:
24
+ raise InvalidInputError("audio compare needs at least two --input candidates")
25
+ for path in candidates:
26
+ if not path.is_file():
27
+ raise InvalidInputError(f"candidate not found: {path}")
28
+ output_dir.mkdir(parents=True, exist_ok=True)
29
+
30
+ entries = []
31
+ for index, path in enumerate(candidates):
32
+ loudness = measure_loudness(path)
33
+ gain = round(match_lufs - loudness["integrated_lufs"], 3)
34
+ sample = output_dir / f"candidate-{index}-{path.stem}-ab.wav"
35
+ ffmpeg.run_ffmpeg(
36
+ [
37
+ "-ss",
38
+ f"{sample_start:.3f}",
39
+ "-t",
40
+ f"{sample_duration:.3f}",
41
+ "-i",
42
+ str(path),
43
+ "-af",
44
+ f"volume={gain}dB",
45
+ "-c:a",
46
+ "pcm_s24le",
47
+ str(sample),
48
+ ]
49
+ )
50
+ entries.append(
51
+ {
52
+ "index": index,
53
+ "path": str(path),
54
+ "metrics": {k: v for k, v in loudness.items() if k != "raw"},
55
+ "match_gain_db": gain,
56
+ "ab_sample": str(sample),
57
+ }
58
+ )
59
+
60
+ report = {
61
+ "created_at": utc_now(),
62
+ "match_lufs": match_lufs,
63
+ "sample": {"start": sample_start, "duration": sample_duration},
64
+ "candidates": entries,
65
+ "note": "A/B samples are loudness-matched; metrics rank candidates but "
66
+ "do not prove speech remains natural — listen before choosing.",
67
+ }
68
+ report_path = output_dir / "audio-compare-report.json"
69
+ report_path.write_text(json.dumps(report, indent=2) + "\n")
70
+ report["report_path"] = str(report_path)
71
+ return report
@@ -0,0 +1,63 @@
1
+ """Conservative neural speech denoising behind an explicit backend interface.
2
+
3
+ DeepFilterNet is the first backend; it is optional (`video-edit-cli[df]` extra)
4
+ and never applied implicitly — the agent chooses to denoise. Stronger or
5
+ generative restoration would be added as further explicit backends.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib
11
+ from pathlib import Path
12
+ from typing import Any, Protocol
13
+
14
+ from video_editor.errors import InvalidInputError, VideoEditorError
15
+
16
+
17
+ class DenoiseBackend(Protocol):
18
+ name: str
19
+
20
+ def denoise(self, source: Path, output: Path) -> dict[str, Any]:
21
+ """Write denoised audio to `output`; return provenance details."""
22
+ ...
23
+
24
+
25
+ class DeepFilterNetBackend:
26
+ name = "deepfilternet"
27
+
28
+ def denoise(self, source: Path, output: Path) -> dict[str, Any]:
29
+ try:
30
+ enhance_mod = importlib.import_module("df.enhance")
31
+ except ImportError as exc:
32
+ raise VideoEditorError(
33
+ "missing-dependency",
34
+ "the deepfilternet backend requires the optional 'df' extra; "
35
+ "install with `uv sync --extra df`",
36
+ exit_code=5,
37
+ ) from exc
38
+ model, df_state, _ = enhance_mod.init_df()
39
+ audio, _ = enhance_mod.load_audio(str(source), sr=df_state.sr())
40
+ enhanced = enhance_mod.enhance(model, df_state, audio)
41
+ enhance_mod.save_audio(str(output), enhanced, df_state.sr())
42
+ return {"model": "DeepFilterNet3", "sample_rate": df_state.sr()}
43
+
44
+
45
+ def get_backend(name: str) -> DenoiseBackend:
46
+ if name == "deepfilternet":
47
+ return DeepFilterNetBackend()
48
+ raise InvalidInputError(f"unknown denoise backend '{name}'")
49
+
50
+
51
+ def denoise(source: Path, output: Path, backend_name: str) -> dict[str, Any]:
52
+ if not source.is_file():
53
+ raise InvalidInputError(f"input file not found: {source}")
54
+ if output.suffix.lower() != ".wav":
55
+ raise InvalidInputError("audio denoise output must be a .wav path (lossless)")
56
+ backend = get_backend(backend_name)
57
+ output.parent.mkdir(parents=True, exist_ok=True)
58
+ details = backend.denoise(source, output)
59
+ if not output.is_file():
60
+ raise VideoEditorError(
61
+ "tool-failure", f"backend produced no output at {output}"
62
+ )
63
+ return {"backend": backend.name, **details}
@@ -0,0 +1,77 @@
1
+ """Deterministic mastering: filtering, dynamics, and two-pass loudness normalization.
2
+
3
+ This is the mastering-only baseline: no neural processing, fully reproducible
4
+ from explicit parameters. Output is lossless WAV.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from video_editor import ffmpeg
13
+ from video_editor.audio.analysis import measure_loudness
14
+ from video_editor.errors import InvalidInputError
15
+
16
+
17
+ def master(
18
+ source: Path,
19
+ output: Path,
20
+ target_i: float = -16.0,
21
+ target_tp: float = -1.5,
22
+ target_lra: float = 11.0,
23
+ highpass_hz: int = 70,
24
+ compress: bool = True,
25
+ ) -> tuple[list[str], dict[str, Any]]:
26
+ """Two-pass loudnorm mastering chain; returns (ffmpeg args, result metrics)."""
27
+ if output.suffix.lower() != ".wav":
28
+ raise InvalidInputError("audio master output must be a .wav path (lossless)")
29
+ measured = measure_loudness(source, target_i, target_tp, target_lra)["raw"]
30
+ output.parent.mkdir(parents=True, exist_ok=True)
31
+
32
+ chain = [f"highpass=f={highpass_hz}"]
33
+ if compress:
34
+ chain.append(
35
+ "acompressor=threshold=-18dB:ratio=2:attack=15:release=200:makeup=2"
36
+ )
37
+ chain.append(
38
+ "loudnorm=I={i}:TP={tp}:LRA={lra}:measured_I={mi}:measured_TP={mtp}:"
39
+ "measured_LRA={mlra}:measured_thresh={mth}:offset={off}:linear=true".format(
40
+ i=target_i,
41
+ tp=target_tp,
42
+ lra=target_lra,
43
+ mi=measured["input_i"],
44
+ mtp=measured["input_tp"],
45
+ mlra=measured["input_lra"],
46
+ mth=measured["input_thresh"],
47
+ off=measured["target_offset"],
48
+ )
49
+ )
50
+ args = [
51
+ "-i",
52
+ str(source),
53
+ "-af",
54
+ ",".join(chain),
55
+ "-ar",
56
+ "48000",
57
+ "-c:a",
58
+ "pcm_s24le",
59
+ str(output),
60
+ ]
61
+ ffmpeg.run_ffmpeg(args)
62
+
63
+ result_loudness = measure_loudness(output, target_i, target_tp, target_lra)
64
+ metrics = {
65
+ "target": {
66
+ "integrated_lufs": target_i,
67
+ "true_peak_dbtp": target_tp,
68
+ "lra": target_lra,
69
+ },
70
+ "input": {
71
+ "integrated_lufs": float(measured["input_i"]),
72
+ "true_peak_dbtp": float(measured["input_tp"]),
73
+ },
74
+ "output": {k: v for k, v in result_loudness.items() if k != "raw"},
75
+ "chain": chain,
76
+ }
77
+ return args, metrics