clipwright-loudness 0.1.1__tar.gz

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,94 @@
1
+ Metadata-Version: 2.3
2
+ Name: clipwright-loudness
3
+ Version: 0.1.1
4
+ Summary: MCP tool for audio loudness normalization detection and OTIO timeline annotation generation. Measures loudness with ffmpeg loudnorm/volumedetect and writes loudness instructions to timeline-level metadata.
5
+ Author: satoh-y-0323
6
+ Author-email: satoh-y-0323 <shoma.papa.0323@gmail.com>
7
+ License: MIT
8
+ Requires-Dist: clipwright>=0.1.1
9
+ Requires-Dist: mcp[cli]>=1.27.2
10
+ Requires-Dist: pydantic>=2
11
+ Requires-Python: >=3.11
12
+ Description-Content-Type: text/markdown
13
+
14
+ # clipwright-loudness
15
+
16
+ MCP tool for audio loudness normalization detection and OTIO timeline annotation generation.
17
+
18
+ ## Overview
19
+
20
+ Measures audio loudness and peak level using ffmpeg `loudnorm` / `volumedetect` filters,
21
+ writes loudness instructions (mode, target, measured) to timeline-level `metadata["clipwright"]["loudness"]`.
22
+
23
+ Performs detection only (OTIO annotation); realization (ffmpeg filter application) is done once by `clipwright-render`
24
+ (design M3: separation of detection and application).
25
+
26
+ **Normalization modes**:
27
+ - `loudnorm` (EBU R128 LUFS): Two-stage linear method. detect measures with `loudnorm print_format=json` and saves
28
+ `measured_*` parameters to OTIO annotation; render applies exact one-pass with `loudnorm:linear=true`.
29
+ - `peak` (max dB match): Measures max_volume with `volumedetect` and applies gain difference in render.
30
+
31
+ **Initial render support**:
32
+ - `track` scope only (single loudness normalization applied to entire timeline).
33
+ - `per_clip` scope (individual clip application) deferred until after compositing.
34
+
35
+ ## Prerequisites
36
+
37
+ - Python 3.11 or later
38
+ - **ffmpeg / ffprobe must exist on PATH or full paths set in environment variables `CLIPWRIGHT_FFMPEG` / `CLIPWRIGHT_FFPROBE`.**
39
+
40
+ Add ffmpeg to PATH directly or specify via environment variables:
41
+
42
+ ```bash
43
+ export CLIPWRIGHT_FFMPEG=/path/to/ffmpeg
44
+ export CLIPWRIGHT_FFPROBE=/path/to/ffprobe
45
+ ```
46
+
47
+ ## MCP Tool
48
+
49
+ `clipwright_detect_loudness`
50
+
51
+ ### Parameters
52
+
53
+ | Name | Type | Default | Description |
54
+ |------|------|---------|-------------|
55
+ | `media` | `string` | required | Input media file path (audio required) |
56
+ | `output` | `string` | required | Output OTIO timeline path (`.otio`, same directory as media) |
57
+ | `options.mode` | `"loudnorm" \| "peak"` | `"loudnorm"` | Normalization mode |
58
+ | `options.target_i` | `float` | `-14.0` | loudnorm mode: target integrated loudness (LUFS, -70 to -5) |
59
+ | `options.target_tp` | `float` | `-1.0` | loudnorm mode: target true peak (dBTP, -9 to 0) |
60
+ | `options.target_lra` | `float` | `11.0` | loudnorm mode: target loudness range (LU, 1 to 50) |
61
+ | `options.target_peak_db` | `float` | `-1.0` | peak mode: target peak level (dB, -60 to 0) |
62
+ | `timeline` | `string \| null` | `null` | Existing OTIO timeline path (if specified, append to it) |
63
+
64
+ ## Dependencies
65
+
66
+ | Package | Purpose |
67
+ |---------|---------|
68
+ | `clipwright` | Shared types, envelope, errors, process.run |
69
+ | `mcp[cli]` | MCP server |
70
+ | `pydantic` | Parameter validation |
71
+
72
+ ffmpeg / ffprobe are invoked as separate processes (via PATH or environment variables) for license independence.
73
+
74
+ ## loudnorm Linear Two-Stage Method
75
+
76
+ 1. **detect (this tool)**: `ffmpeg -i <media> -af loudnorm=I=-14:TP=-1:LRA=11:print_format=json -f null -`
77
+ gets measured_* parameters and saves them to OTIO annotation.
78
+ 2. **render (clipwright-render)**: `loudnorm=I=-14:TP=-1:LRA=11:measured_I=..:...:linear=true`
79
+ executes only one-pass linear application with detected parameters (improved accuracy).
80
+
81
+ ## Installation and Startup
82
+
83
+ Within a uv workspace:
84
+
85
+ ```bash
86
+ uv run --package clipwright-loudness clipwright-loudness
87
+ ```
88
+
89
+ Or install directly:
90
+
91
+ ```bash
92
+ uv add clipwright-loudness
93
+ clipwright-loudness
94
+ ```
@@ -0,0 +1,81 @@
1
+ # clipwright-loudness
2
+
3
+ MCP tool for audio loudness normalization detection and OTIO timeline annotation generation.
4
+
5
+ ## Overview
6
+
7
+ Measures audio loudness and peak level using ffmpeg `loudnorm` / `volumedetect` filters,
8
+ writes loudness instructions (mode, target, measured) to timeline-level `metadata["clipwright"]["loudness"]`.
9
+
10
+ Performs detection only (OTIO annotation); realization (ffmpeg filter application) is done once by `clipwright-render`
11
+ (design M3: separation of detection and application).
12
+
13
+ **Normalization modes**:
14
+ - `loudnorm` (EBU R128 LUFS): Two-stage linear method. detect measures with `loudnorm print_format=json` and saves
15
+ `measured_*` parameters to OTIO annotation; render applies exact one-pass with `loudnorm:linear=true`.
16
+ - `peak` (max dB match): Measures max_volume with `volumedetect` and applies gain difference in render.
17
+
18
+ **Initial render support**:
19
+ - `track` scope only (single loudness normalization applied to entire timeline).
20
+ - `per_clip` scope (individual clip application) deferred until after compositing.
21
+
22
+ ## Prerequisites
23
+
24
+ - Python 3.11 or later
25
+ - **ffmpeg / ffprobe must exist on PATH or full paths set in environment variables `CLIPWRIGHT_FFMPEG` / `CLIPWRIGHT_FFPROBE`.**
26
+
27
+ Add ffmpeg to PATH directly or specify via environment variables:
28
+
29
+ ```bash
30
+ export CLIPWRIGHT_FFMPEG=/path/to/ffmpeg
31
+ export CLIPWRIGHT_FFPROBE=/path/to/ffprobe
32
+ ```
33
+
34
+ ## MCP Tool
35
+
36
+ `clipwright_detect_loudness`
37
+
38
+ ### Parameters
39
+
40
+ | Name | Type | Default | Description |
41
+ |------|------|---------|-------------|
42
+ | `media` | `string` | required | Input media file path (audio required) |
43
+ | `output` | `string` | required | Output OTIO timeline path (`.otio`, same directory as media) |
44
+ | `options.mode` | `"loudnorm" \| "peak"` | `"loudnorm"` | Normalization mode |
45
+ | `options.target_i` | `float` | `-14.0` | loudnorm mode: target integrated loudness (LUFS, -70 to -5) |
46
+ | `options.target_tp` | `float` | `-1.0` | loudnorm mode: target true peak (dBTP, -9 to 0) |
47
+ | `options.target_lra` | `float` | `11.0` | loudnorm mode: target loudness range (LU, 1 to 50) |
48
+ | `options.target_peak_db` | `float` | `-1.0` | peak mode: target peak level (dB, -60 to 0) |
49
+ | `timeline` | `string \| null` | `null` | Existing OTIO timeline path (if specified, append to it) |
50
+
51
+ ## Dependencies
52
+
53
+ | Package | Purpose |
54
+ |---------|---------|
55
+ | `clipwright` | Shared types, envelope, errors, process.run |
56
+ | `mcp[cli]` | MCP server |
57
+ | `pydantic` | Parameter validation |
58
+
59
+ ffmpeg / ffprobe are invoked as separate processes (via PATH or environment variables) for license independence.
60
+
61
+ ## loudnorm Linear Two-Stage Method
62
+
63
+ 1. **detect (this tool)**: `ffmpeg -i <media> -af loudnorm=I=-14:TP=-1:LRA=11:print_format=json -f null -`
64
+ gets measured_* parameters and saves them to OTIO annotation.
65
+ 2. **render (clipwright-render)**: `loudnorm=I=-14:TP=-1:LRA=11:measured_I=..:...:linear=true`
66
+ executes only one-pass linear application with detected parameters (improved accuracy).
67
+
68
+ ## Installation and Startup
69
+
70
+ Within a uv workspace:
71
+
72
+ ```bash
73
+ uv run --package clipwright-loudness clipwright-loudness
74
+ ```
75
+
76
+ Or install directly:
77
+
78
+ ```bash
79
+ uv add clipwright-loudness
80
+ clipwright-loudness
81
+ ```
@@ -0,0 +1,84 @@
1
+ [project]
2
+ name = "clipwright-loudness"
3
+ version = "0.1.1"
4
+ description = "MCP tool for audio loudness normalization detection and OTIO timeline annotation generation. Measures loudness with ffmpeg loudnorm/volumedetect and writes loudness instructions to timeline-level metadata."
5
+ readme = "README.md"
6
+ license = { text = "MIT" }
7
+ authors = [
8
+ { name = "satoh-y-0323", email = "shoma.papa.0323@gmail.com" }
9
+ ]
10
+ requires-python = ">=3.11"
11
+ dependencies = [
12
+ "clipwright>=0.1.1",
13
+ "mcp[cli]>=1.27.2",
14
+ "pydantic>=2",
15
+ ]
16
+
17
+ [project.scripts]
18
+ clipwright-loudness = "clipwright_loudness.server:main"
19
+
20
+ [build-system]
21
+ requires = ["uv_build>=0.11.19,<0.12.0"]
22
+ build-backend = "uv_build"
23
+
24
+ [dependency-groups]
25
+ dev = [
26
+ "mypy>=2.1.0",
27
+ "pytest>=9.0.3",
28
+ "pytest-cov>=7.1.0",
29
+ "pytest-mock>=3.15.1",
30
+ "ruff>=0.15.16",
31
+ ]
32
+
33
+ # Resolve clipwright (core) within workspace by path reference
34
+ [tool.uv.sources]
35
+ clipwright = { workspace = true }
36
+
37
+ # --- Ruff ---
38
+ [tool.ruff]
39
+ target-version = "py311"
40
+ line-length = 88
41
+
42
+ [tool.ruff.lint]
43
+ select = ["E", "F", "W", "I", "UP", "B", "C4", "SIM"]
44
+ ignore = []
45
+
46
+ [tool.ruff.lint.per-file-ignores]
47
+ # Allow E501 for English docstrings/comments in test files
48
+ "tests/*.py" = ["E501"]
49
+
50
+ [tool.ruff.format]
51
+ # Default ruff formatter is OK
52
+
53
+ # --- mypy ---
54
+ [tool.mypy]
55
+ python_version = "3.11"
56
+ strict = true
57
+ warn_return_any = true
58
+ warn_unused_configs = true
59
+ disallow_untyped_defs = true
60
+ disallow_any_generics = true
61
+
62
+ # opentimelineio has no stubs, ignored with mypy strict
63
+ [[tool.mypy.overrides]]
64
+ module = "opentimelineio.*"
65
+ ignore_missing_imports = true
66
+
67
+ # --- pytest ---
68
+ [tool.pytest.ini_options]
69
+ testpaths = ["tests"]
70
+ addopts = "--strict-markers -q"
71
+ markers = [
72
+ "integration: integration test requiring actual ffmpeg/ffprobe binaries",
73
+ "slow: test with long execution time",
74
+ "e2e: e2e test using actual ffmpeg binary",
75
+ ]
76
+
77
+ # --- coverage ---
78
+ [tool.coverage.run]
79
+ source = ["clipwright_loudness"]
80
+ omit = ["tests/*"]
81
+
82
+ [tool.coverage.report]
83
+ show_missing = true
84
+ skip_covered = false
@@ -0,0 +1,3 @@
1
+ """clipwright-loudness: Loudness detection -> OTIO timeline directive MCP tool."""
2
+
3
+ __version__ = "0.1.1"
@@ -0,0 +1,240 @@
1
+ """analyze.py — Loudness measurement using ffmpeg loudnorm/volumedetect.
2
+
3
+ Design §3.1, ADR-L1/L2.
4
+
5
+ Actual ffmpeg 8.1.1 Windows output format (ADR-L3, verified on real hardware):
6
+
7
+ loudnorm print_format=json:
8
+ [Parsed_loudnorm_0 @ 0x...] <- empty line
9
+ {
10
+ \t"input_i" : "-21.75",
11
+ \t"input_tp" : "-18.06",
12
+ \t"input_lra" : "0.00",
13
+ \t"input_thresh" : "-31.75",
14
+ \t"output_i" : "-14.03",
15
+ ...
16
+ \t"target_offset" : "0.03"
17
+ }
18
+ Note: values are output as quoted strings. "-inf" may appear (silent input).
19
+
20
+ volumedetect:
21
+ [Parsed_volumedetect_0 @ 0x...] max_volume: -18.1 dB
22
+ Note: "max_volume: <VALUE> dB" format.
23
+
24
+ When measurement is not possible, returns measured=None + warning
25
+ (U-1 confirmed policy, DC-AM-003).
26
+ ClipwrightError failures are propagated as-is.
27
+ Absolute paths are never included in messages (fixed wording).
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ import re
34
+ from pathlib import Path
35
+ from typing import Any
36
+
37
+ from clipwright.errors import ClipwrightError
38
+ from clipwright.process import resolve_tool, run
39
+ from pydantic import ValidationError
40
+
41
+ from clipwright_loudness.schemas import LoudnormMeasured, PeakMeasured
42
+
43
+ # ffmpeg execution timeout (seconds).
44
+ # Measurement-only passes (loudnorm 1-pass / volumedetect) complete much faster
45
+ # than re-encoding, so 300 seconds is sufficient even for long-duration media.
46
+ # Dynamic calculation is not needed.
47
+ _TIMEOUT_SECONDS: float = 300.0
48
+
49
+
50
+ def _parse_loudnorm_measured(stderr: str) -> dict[str, Any] | None:
51
+ """Extract measured values from the loudnorm print_format=json stderr JSON block.
52
+
53
+ ffmpeg writes a JSON block to stderr. Values are quoted strings.
54
+ If "-inf" / "inf" is present, LoudnormMeasured's allow_inf_nan=False causes
55
+ a ValidationError and None is returned (U-1).
56
+
57
+ Returns:
58
+ Dict of {input_i, input_tp, input_lra, input_thresh, target_offset},
59
+ or None if extraction fails.
60
+ """
61
+ # Collect all JSON block candidates ({ ... }) from stderr.
62
+ # re.search only matches the first occurrence, so if an earlier {} appears
63
+ # before the loudnorm JSON block it may be missed (H-1).
64
+ # Use re.findall to collect all candidates, then search in reverse for the
65
+ # last block containing all required_keys.
66
+ candidates = re.findall(r"\{[^{}]+\}", stderr, re.DOTALL)
67
+ if not candidates:
68
+ return None
69
+
70
+ required_keys = [
71
+ "input_i",
72
+ "input_tp",
73
+ "input_lra",
74
+ "input_thresh",
75
+ "target_offset",
76
+ ]
77
+
78
+ raw: dict[str, Any] | None = None
79
+ for block in reversed(candidates):
80
+ try:
81
+ parsed: dict[str, Any] = json.loads(block)
82
+ except json.JSONDecodeError:
83
+ continue
84
+ if all(k in parsed for k in required_keys):
85
+ raw = parsed
86
+ break
87
+
88
+ if raw is None:
89
+ return None
90
+
91
+ # Convert required fields from string to float
92
+ extracted: dict[str, Any] = {}
93
+ for key in required_keys:
94
+ val = raw[key]
95
+ try:
96
+ extracted[key] = float(val)
97
+ except (ValueError, TypeError):
98
+ return None
99
+
100
+ # Validate with LoudnormMeasured (inf/nan -> ValidationError -> degrade to None)
101
+ try:
102
+ validated = LoudnormMeasured(**extracted)
103
+ except ValidationError:
104
+ return None
105
+
106
+ return validated.model_dump()
107
+
108
+
109
+ def _parse_volumedetect_measured(stderr: str) -> dict[str, Any] | None:
110
+ """Extract max_volume from volumedetect stderr.
111
+
112
+ Format: "[Parsed_volumedetect_0 @ 0x...] max_volume: -X.X dB"
113
+
114
+ Returns:
115
+ Dict of {max_volume_db: float}, or None if extraction fails.
116
+ """
117
+ m = re.search(r"max_volume:\s*(-?\d+\.?\d*)\s*dB", stderr)
118
+ if not m:
119
+ return None
120
+
121
+ try:
122
+ val = float(m.group(1))
123
+ except ValueError:
124
+ return None
125
+
126
+ # Validate with PeakMeasured (out-of-range -> ValidationError -> degrade to None)
127
+ try:
128
+ validated = PeakMeasured(max_volume_db=val)
129
+ except ValidationError:
130
+ return None
131
+
132
+ return validated.model_dump()
133
+
134
+
135
+ def measure_loudness(
136
+ media: Path,
137
+ *,
138
+ mode: str,
139
+ target_i: float = -14.0,
140
+ target_tp: float = -1.0,
141
+ target_lra: float = 11.0,
142
+ target_peak_db: float = -1.0,
143
+ ) -> dict[str, Any]:
144
+ """Measure audio loudness of media and return the measured values (ADR-L1/L2/L7).
145
+
146
+ Args:
147
+ media: Path to the input media file (video + audio).
148
+ mode: "loudnorm" or "peak".
149
+ target_i: loudnorm integrated loudness target (LUFS).
150
+ target_tp: loudnorm true peak target (dBTP).
151
+ target_lra: loudnorm LRA target (LU).
152
+ target_peak_db: peak mode peak target (dB).
153
+
154
+ Returns:
155
+ {
156
+ "measured": dict | None, # Mode-specific measured values.
157
+ # None means U-1 (not measurable).
158
+ "warnings": list[str],
159
+ }
160
+
161
+ Raises:
162
+ clipwright.errors.ClipwrightError:
163
+ DEPENDENCY_MISSING / SUBPROCESS_FAILED / SUBPROCESS_TIMEOUT.
164
+ """
165
+ # Resolve ffmpeg binary (PATH-independent)
166
+ ffmpeg_bin = resolve_tool("ffmpeg", "CLIPWRIGHT_FFMPEG")
167
+
168
+ warnings: list[str] = []
169
+
170
+ if mode == "loudnorm":
171
+ # Single-pass measurement with
172
+ # loudnorm=I=<I>:TP=<TP>:LRA=<LRA>:print_format=json (ADR-L1)
173
+ af_filter = (
174
+ f"loudnorm=I={target_i}:TP={target_tp}:LRA={target_lra}:print_format=json"
175
+ )
176
+ cmd: list[str] = [
177
+ ffmpeg_bin,
178
+ "-i",
179
+ str(media),
180
+ "-af",
181
+ af_filter,
182
+ "-f",
183
+ "null",
184
+ "-",
185
+ ]
186
+
187
+ try:
188
+ result = run(cmd, timeout=_TIMEOUT_SECONDS)
189
+ except ClipwrightError as exc:
190
+ # Prevent absolute paths from leaking into the error message from run().
191
+ # Preserve the ErrorCode and re-raise with fixed wording (CWE-209).
192
+ # Use "from None" to avoid chaining __cause__ (SR L-1).
193
+ raise ClipwrightError(
194
+ code=exc.code,
195
+ message="ffmpeg loudnorm command failed.",
196
+ hint="Check the ffmpeg version and arguments.",
197
+ ) from None
198
+ measured = _parse_loudnorm_measured(result.stderr)
199
+
200
+ if measured is None:
201
+ warnings.append(
202
+ "Could not retrieve loudnorm measured values."
203
+ " loudness directive will not be written (U-1, DC-AM-003)."
204
+ " ffmpeg stderr did not contain a valid loudnorm JSON block."
205
+ )
206
+
207
+ return {"measured": measured, "warnings": warnings}
208
+
209
+ else:
210
+ # mode == "peak": measure max_volume with volumedetect (ADR-L2)
211
+ cmd = [
212
+ ffmpeg_bin,
213
+ "-i",
214
+ str(media),
215
+ "-af",
216
+ "volumedetect",
217
+ "-f",
218
+ "null",
219
+ "-",
220
+ ]
221
+
222
+ try:
223
+ result = run(cmd, timeout=_TIMEOUT_SECONDS)
224
+ except ClipwrightError as exc:
225
+ # Use "from None" to avoid chaining __cause__ (SR L-1).
226
+ raise ClipwrightError(
227
+ code=exc.code,
228
+ message="ffmpeg volumedetect command failed.",
229
+ hint="Check the ffmpeg version and arguments.",
230
+ ) from None
231
+ measured = _parse_volumedetect_measured(result.stderr)
232
+
233
+ if measured is None:
234
+ warnings.append(
235
+ "Could not retrieve volumedetect measured values."
236
+ " loudness directive will not be written (U-1, DC-AM-003)."
237
+ " ffmpeg stderr did not contain a max_volume field."
238
+ )
239
+
240
+ return {"measured": measured, "warnings": warnings}
@@ -0,0 +1,471 @@
1
+ """loudness.py — clipwright-loudness orchestration layer (design §3.2, ADR-L4/L7).
2
+
3
+ Flow:
4
+ 1. Output validation (extension, parent dir, output==media,
5
+ output==timeline, same dir)
6
+ 2. inspect_media: require both video and audio streams
7
+ 3. Timeline resolution (None -> create new / path -> load + validate)
8
+ 4. measure_loudness: measure loudness
9
+ 5. If measured is None, skip loudness directive and emit warning
10
+ (U-1, DC-AM-003).
11
+ If measured is present, partial-update timeline-level metadata
12
+ with loudness directive.
13
+ 6. save_timeline -> return ok_result
14
+
15
+ Design decisions:
16
+ - FILE_NOT_FOUND / message uses basename only (DC-GP-005).
17
+ - output must be in the same directory as media (MUST, DC-AS-002).
18
+ - output==media comparison uses Path.resolve() for normalization (B-4).
19
+ - timeline validation: exactly one Video-kind track (B-5).
20
+ - measured=None: skip loudness directive and return warning (U-1, DC-AM-003).
21
+ - Mirrors _same_path / _add_full_clip / _load_and_validate_timeline
22
+ structure from noise.py.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from pathlib import Path
28
+ from typing import Any
29
+
30
+ import opentimelineio as otio
31
+ from clipwright.envelope import error_result, ok_result
32
+ from clipwright.errors import ClipwrightError, ErrorCode
33
+ from clipwright.media import inspect_media
34
+ from clipwright.otio_utils import (
35
+ get_clipwright_metadata,
36
+ load_timeline,
37
+ new_timeline,
38
+ save_timeline,
39
+ set_clipwright_metadata,
40
+ )
41
+ from clipwright.schemas import RationalTimeModel
42
+ from pydantic import ValidationError
43
+
44
+ import clipwright_loudness
45
+ from clipwright_loudness.analyze import measure_loudness
46
+ from clipwright_loudness.schemas import (
47
+ DetectLoudnessOptions,
48
+ LoudnessDirective,
49
+ LoudnormMeasured,
50
+ LoudnormTarget,
51
+ PeakMeasured,
52
+ PeakTarget,
53
+ )
54
+
55
+
56
+ def detect_loudness(
57
+ media: str,
58
+ output: str,
59
+ options: DetectLoudnessOptions,
60
+ timeline: str | None,
61
+ ) -> dict[str, Any]:
62
+ """Public API for loudness detection. Converts ClipwrightError to ok=False envelope.
63
+
64
+ Args:
65
+ media: Input media file path (video + audio required).
66
+ output: Output OTIO timeline file path (.otio, same directory as media).
67
+ options: DetectLoudnessOptions.
68
+ timeline: Existing timeline path (None = create new).
69
+
70
+ Returns:
71
+ ok_result or error_result envelope dict.
72
+ """
73
+ try:
74
+ return _detect_loudness_inner(media, output, options, timeline)
75
+ except ClipwrightError as exc:
76
+ return error_result(exc.code, exc.message, exc.hint)
77
+
78
+
79
+ def _detect_loudness_inner(
80
+ media: str,
81
+ output: str,
82
+ options: DetectLoudnessOptions,
83
+ timeline: str | None,
84
+ ) -> dict[str, Any]:
85
+ """Internal implementation of detect_loudness. Raises ClipwrightError directly."""
86
+ media_path = Path(media)
87
+ output_path = Path(output)
88
+
89
+ # --- 1. Output validation ---
90
+
91
+ if output_path.suffix.lower() != ".otio":
92
+ raise ClipwrightError(
93
+ code=ErrorCode.INVALID_INPUT,
94
+ message=f"Unsupported output extension: {output_path.suffix!r}",
95
+ hint="Set the output file extension to .otio.",
96
+ )
97
+
98
+ if not output_path.parent.exists():
99
+ raise ClipwrightError(
100
+ code=ErrorCode.INVALID_INPUT,
101
+ message="output directory does not exist.",
102
+ hint="Create the output directory first, then re-run.",
103
+ )
104
+
105
+ # Prohibit output == media (non-destructive, M5)
106
+ if _same_path(output_path, media_path):
107
+ raise ClipwrightError(
108
+ code=ErrorCode.INVALID_INPUT,
109
+ message="Output path and input media path are the same.",
110
+ hint="Change the output file path to differ from the input media.",
111
+ )
112
+
113
+ # Prohibit output == timeline (non-destructive)
114
+ if timeline is not None and _same_path(output_path, Path(timeline)):
115
+ raise ClipwrightError(
116
+ code=ErrorCode.INVALID_INPUT,
117
+ message="Output path and input timeline path are the same.",
118
+ hint="Change the output file path to differ from the input timeline.",
119
+ )
120
+
121
+ # output must be in the same directory as media (MUST, DC-AS-002)
122
+ try:
123
+ media_resolved_dir = media_path.resolve().parent
124
+ output_resolved_dir = output_path.resolve().parent
125
+ except OSError:
126
+ media_resolved_dir = media_path.absolute().parent
127
+ output_resolved_dir = output_path.absolute().parent
128
+
129
+ if media_resolved_dir != output_resolved_dir:
130
+ raise ClipwrightError(
131
+ code=ErrorCode.INVALID_INPUT,
132
+ message=(
133
+ "Output file must be placed in the same directory as the media file."
134
+ ),
135
+ hint="Change the output path to the same directory as the media file.",
136
+ )
137
+
138
+ # --- 2. inspect_media: require both video and audio ---
139
+
140
+ if not media_path.exists():
141
+ raise ClipwrightError(
142
+ code=ErrorCode.FILE_NOT_FOUND,
143
+ message=f"File not found: {media_path.name}",
144
+ hint="Check that the input media file path is correct.",
145
+ )
146
+
147
+ media_info = inspect_media(media)
148
+
149
+ has_video = any(s.codec_type == "video" for s in media_info.streams)
150
+ has_audio = any(s.codec_type == "audio" for s in media_info.streams)
151
+
152
+ if not has_video:
153
+ raise ClipwrightError(
154
+ code=ErrorCode.UNSUPPORTED_OPERATION,
155
+ message=f"No video stream found: {media_path.name}",
156
+ hint="Provide a media file that contains both video and audio.",
157
+ )
158
+
159
+ if not has_audio:
160
+ raise ClipwrightError(
161
+ code=ErrorCode.UNSUPPORTED_OPERATION,
162
+ message=f"No audio stream found: {media_path.name}",
163
+ hint="Provide a media file that contains both video and audio.",
164
+ )
165
+
166
+ # Retrieve duration (total seconds for the full-length clip in _add_full_clip)
167
+ duration_sec: float = 0.0
168
+ if media_info.duration is not None:
169
+ duration_sec = media_info.duration.value / media_info.duration.rate
170
+
171
+ # --- 3. Timeline resolution ---
172
+
173
+ if timeline is None:
174
+ # Create new: add one full-length keep clip to V1
175
+ tl = new_timeline(media_path.name)
176
+ _add_full_clip(tl, media_path, duration_sec, media_info.duration)
177
+ else:
178
+ tl = _load_and_validate_timeline(
179
+ timeline, media_path, duration_sec, media_info.duration
180
+ )
181
+
182
+ # --- 4. Loudness measurement ---
183
+
184
+ kwargs: dict[str, Any] = {}
185
+ if options.mode == "loudnorm":
186
+ kwargs = {
187
+ "target_i": options.target_i,
188
+ "target_tp": options.target_tp,
189
+ "target_lra": options.target_lra,
190
+ }
191
+ else:
192
+ kwargs = {"target_peak_db": options.target_peak_db}
193
+
194
+ analysis = measure_loudness(media_path, mode=options.mode, **kwargs)
195
+
196
+ measured_raw: dict[str, Any] | None = analysis["measured"]
197
+ warnings: list[str] = list(analysis["warnings"])
198
+
199
+ # --- 5. Partial-update timeline-level metadata with loudness directive ---
200
+ # (U-1: skip when measured is None)
201
+
202
+ if measured_raw is None:
203
+ # U-1: measurement not possible — skip directive and emit warning (DC-AM-003)
204
+ # analyze already added a warning, but loudness adds one as well
205
+ warnings.append(
206
+ "Could not retrieve loudness measured values."
207
+ " loudness directive will not be written (U-1)."
208
+ )
209
+ else:
210
+ # measured present — write loudness directive
211
+ if options.mode == "loudnorm":
212
+ target: LoudnormTarget | PeakTarget = LoudnormTarget(
213
+ i=options.target_i,
214
+ tp=options.target_tp,
215
+ lra=options.target_lra,
216
+ )
217
+ try:
218
+ measured_obj: LoudnormMeasured | PeakMeasured | None = LoudnormMeasured(
219
+ **measured_raw
220
+ )
221
+ except ValidationError:
222
+ # CWE-209: do not expose ValidationError details externally
223
+ raise ClipwrightError(
224
+ code=ErrorCode.INVALID_INPUT,
225
+ message=(
226
+ "Validation of loudnorm measured values failed."
227
+ " Check field types."
228
+ ),
229
+ hint="Check the return value of measure_loudness.",
230
+ ) from None
231
+ else:
232
+ target = PeakTarget(peak_db=options.target_peak_db)
233
+ try:
234
+ measured_obj = PeakMeasured(**measured_raw)
235
+ except ValidationError:
236
+ # CWE-209: do not expose ValidationError details externally
237
+ raise ClipwrightError(
238
+ code=ErrorCode.INVALID_INPUT,
239
+ message=(
240
+ "Validation of peak measured values failed. Check field types."
241
+ ),
242
+ hint="Check the return value of measure_loudness.",
243
+ ) from None
244
+
245
+ directive = LoudnessDirective(
246
+ tool="clipwright-loudness",
247
+ version=clipwright_loudness.__version__,
248
+ kind="loudness",
249
+ mode=options.mode,
250
+ scope="track",
251
+ target=target,
252
+ measured=measured_obj,
253
+ )
254
+
255
+ existing_meta = get_clipwright_metadata(tl)
256
+ existing_meta["loudness"] = directive.model_dump()
257
+ set_clipwright_metadata(tl, existing_meta)
258
+
259
+ # --- 6. save_timeline -> ok_result ---
260
+
261
+ save_timeline(tl, str(output_path))
262
+
263
+ if measured_raw is not None:
264
+ summary = (
265
+ f"Loudness analysis of {media_path.name} complete."
266
+ f" mode={options.mode}, scope={options.scope}."
267
+ f" loudness directive written to {output_path.name}."
268
+ )
269
+ else:
270
+ summary = (
271
+ f"Loudness analysis of {media_path.name} attempted"
272
+ " but measured values could not be retrieved."
273
+ f" mode={options.mode}, scope={options.scope}."
274
+ f" loudness directive was not written (U-1)."
275
+ )
276
+
277
+ return ok_result(
278
+ summary,
279
+ data={
280
+ "mode": options.mode,
281
+ "scope": options.scope,
282
+ "measured": measured_raw,
283
+ },
284
+ artifacts=[
285
+ {"role": "timeline", "path": str(output_path), "format": "otio"},
286
+ ],
287
+ warnings=warnings,
288
+ )
289
+
290
+
291
+ def _add_full_clip(
292
+ tl: otio.schema.Timeline,
293
+ media_path: Path,
294
+ duration_sec: float,
295
+ duration_rt: RationalTimeModel | None,
296
+ ) -> None:
297
+ """Add one full-length keep clip to V1/A1 tracks of the timeline (new creation).
298
+
299
+ target_url is set to the absolute path of media_path.resolve() (DC-AS-002).
300
+
301
+ Args:
302
+ duration_rt: Pydantic model RationalTimeModel (not OTIO RationalTime).
303
+ Used to obtain the rate. Falls back to rate=1000.0 when None.
304
+ """
305
+ try:
306
+ target_url = str(media_path.resolve())
307
+ except OSError:
308
+ target_url = str(media_path.absolute())
309
+
310
+ # Determine rate: use duration if available, otherwise 1000.0.
311
+ # RationalTimeModel.rate is guaranteed to be float by the Pydantic schema,
312
+ # but gt=0 is not constrained, so zero-division is theoretically possible.
313
+ # However, OTIO RationalTime(duration_sec * rate, rate) initialization does
314
+ # not divide internally, so no crash occurs in practice.
315
+ rate = duration_rt.rate if duration_rt is not None else 1000.0
316
+
317
+ source_range = otio.opentime.TimeRange(
318
+ start_time=otio.opentime.RationalTime(0.0, rate),
319
+ duration=otio.opentime.RationalTime(duration_sec * rate, rate),
320
+ )
321
+ ref = otio.schema.ExternalReference(target_url=target_url)
322
+
323
+ # Add the same clip to V1 (index 0) and A1 (index 1)
324
+ for track in tl.tracks:
325
+ clip = otio.schema.Clip(
326
+ name=media_path.name,
327
+ media_reference=ref,
328
+ source_range=source_range,
329
+ )
330
+ track.append(clip)
331
+
332
+
333
+ def _load_and_validate_timeline(
334
+ timeline_path: str,
335
+ media_path: Path,
336
+ duration_sec: float,
337
+ duration_rt: RationalTimeModel | None,
338
+ ) -> otio.schema.Timeline:
339
+ """Load an existing timeline and validate its consistency (B-4 / B-5).
340
+
341
+ Validates:
342
+ - The target_url of V1 clips matches media_path
343
+ (B-4: path normalization comparison)
344
+ - Single source (all clips share the same target_url)
345
+ - Exactly one Video-kind track (B-5)
346
+
347
+ If V1 is empty, adds a full-length keep clip and continues
348
+ (equivalent to new creation).
349
+
350
+ Raises:
351
+ ClipwrightError: INVALID_INPUT / OTIO_ERROR.
352
+ """
353
+ tl = load_timeline(timeline_path)
354
+
355
+ # --- Exactly one Video-kind track (B-5) ---
356
+ video_tracks = [t for t in tl.tracks if t.kind == otio.schema.TrackKind.Video]
357
+ if len(video_tracks) != 1:
358
+ raise ClipwrightError(
359
+ code=ErrorCode.INVALID_INPUT,
360
+ message=(
361
+ f"Invalid number of Video tracks in timeline: {len(video_tracks)}"
362
+ " (only 1 is supported)"
363
+ ),
364
+ hint="Specify a timeline with exactly one Video track.",
365
+ )
366
+
367
+ v1 = video_tracks[0]
368
+
369
+ # --- Collect all clip target_urls and validate single source ---
370
+ clips = [item for item in v1 if isinstance(item, otio.schema.Clip)]
371
+
372
+ if not clips:
373
+ # V1 is empty — add full-length keep clip and continue
374
+ _add_full_clip(tl, media_path, duration_sec, duration_rt)
375
+ return tl
376
+
377
+ urls: set[str] = set()
378
+ for clip in clips:
379
+ ref = clip.media_reference
380
+ if isinstance(ref, otio.schema.ExternalReference):
381
+ urls.add(ref.target_url)
382
+
383
+ # --- Boundary check: target_url must be within timeline parent dir (SR L-2) ---
384
+ # Guard against malicious OTIO embedding arbitrary paths in target_url.
385
+ tl_path = Path(timeline_path)
386
+ for url in urls:
387
+ _check_source_within_timeline_dir(tl_path, url)
388
+
389
+ if len(urls) > 1:
390
+ raise ClipwrightError(
391
+ code=ErrorCode.UNSUPPORTED_OPERATION,
392
+ message="Timeline contains clips from multiple sources.",
393
+ hint="Specify a timeline with a single source (same media file).",
394
+ )
395
+
396
+ # --- Validate target_url == media_path (B-4: resolve() normalization) ---
397
+ if urls:
398
+ target_url = next(iter(urls))
399
+ try:
400
+ tl_source = Path(target_url).resolve()
401
+ media_resolved = media_path.resolve()
402
+ except OSError:
403
+ tl_source = Path(target_url).absolute()
404
+ media_resolved = media_path.absolute()
405
+
406
+ if tl_source != media_resolved:
407
+ raise ClipwrightError(
408
+ code=ErrorCode.INVALID_INPUT,
409
+ message=(
410
+ f"Timeline source file does not match input media."
411
+ f" timeline source: {Path(target_url).name}"
412
+ f" / media: {media_path.name}"
413
+ ),
414
+ hint=(
415
+ "Specify the same media file used when the timeline was created."
416
+ ),
417
+ )
418
+
419
+ return tl
420
+
421
+
422
+ def _check_source_within_timeline_dir(timeline_path: Path, source: str) -> None:
423
+ """Validate that the source path is within the timeline parent directory (SR L-2).
424
+
425
+ Guards against malicious OTIO embedding arbitrary paths in target_url.
426
+ Equivalent boundary check to _check_source_within_timeline_dir in render.py.
427
+
428
+ Args:
429
+ timeline_path: Path to the OTIO timeline file.
430
+ source: Media source path obtained from OTIO target_url.
431
+
432
+ Raises:
433
+ ClipwrightError: INVALID_INPUT (source outside timeline parent dir).
434
+ """
435
+ try:
436
+ allowed_base = timeline_path.parent.resolve()
437
+ source_resolved = Path(source).resolve()
438
+ source_str = str(source_resolved)
439
+ base_str = str(allowed_base)
440
+ if not (
441
+ source_str == base_str
442
+ or source_str.startswith(base_str + "/")
443
+ or source_str.startswith(base_str + "\\")
444
+ ):
445
+ raise ClipwrightError(
446
+ # Use the same PATH_NOT_ALLOWED as render.py (SR-r2 L-1)
447
+ code=ErrorCode.PATH_NOT_ALLOWED,
448
+ message=("Source file points outside the timeline directory boundary."),
449
+ hint=(
450
+ "Use a source file located within the same directory"
451
+ " as the OTIO timeline."
452
+ ),
453
+ )
454
+ except ClipwrightError:
455
+ raise
456
+ except OSError:
457
+ # Skip on resolve() failure as a best-effort fallback.
458
+ # An invalid path will surface as INVALID_INPUT in the subsequent
459
+ # source==media comparison.
460
+ pass
461
+
462
+
463
+ def _same_path(a: Path, b: Path) -> bool:
464
+ """Return True if both paths refer to the same entity.
465
+
466
+ Falls back to string comparison on OSError.
467
+ """
468
+ try:
469
+ return a.resolve() == b.resolve()
470
+ except OSError:
471
+ return str(a) == str(b)
@@ -0,0 +1,251 @@
1
+ """schemas.py — clipwright-loudness Pydantic schemas.
2
+
3
+ Common types (MediaRef / Artifact / ToolResult, etc.) are defined centrally
4
+ in clipwright.schemas and are not redefined here.
5
+
6
+ DetectLoudnessOptions: Input options for clipwright_detect_loudness.
7
+ LoudnessDirective: Directive schema written to
8
+ timeline-level metadata["clipwright"]["loudness"].
9
+ LoudnormTarget / PeakTarget: Normalization target values per mode.
10
+ LoudnormMeasured / PeakMeasured: Measured values per mode.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Annotated, Literal
16
+
17
+ from pydantic import BaseModel, Field, model_validator
18
+
19
+
20
+ class DetectLoudnessOptions(BaseModel):
21
+ """Options for clipwright_detect_loudness (design §3, ADR-L1/L2).
22
+
23
+ mode: loudnorm (EBU R128 LUFS normalization) or peak (peak dB normalization).
24
+ scope: track only (per_clip deferred per DC-AS-003).
25
+ target_i/target_tp/target_lra: loudnorm target values (overridable).
26
+ target_peak_db: peak target value (overridable).
27
+ """
28
+
29
+ mode: Annotated[
30
+ Literal["loudnorm", "peak"],
31
+ Field(
32
+ default="loudnorm",
33
+ description=(
34
+ "Loudness normalization mode. "
35
+ '"loudnorm" (default) applies EBU R128 LUFS normalization'
36
+ " (ffmpeg loudnorm). "
37
+ '"peak" applies peak dB normalization (ffmpeg volumedetect).'
38
+ ),
39
+ ),
40
+ ] = "loudnorm"
41
+
42
+ scope: Annotated[
43
+ Literal["track"],
44
+ Field(
45
+ default="track",
46
+ description=(
47
+ "Processing scope. "
48
+ '"track" (default) measures the entire timeline in a single pass. '
49
+ "per_clip is deferred per DC-AS-003."
50
+ ),
51
+ ),
52
+ ] = "track"
53
+
54
+ # loudnorm target parameters (design: I=-14/TP=-1/LRA=11)
55
+ target_i: Annotated[
56
+ float,
57
+ Field(
58
+ default=-14.0,
59
+ ge=-70.0,
60
+ le=-5.0,
61
+ description=(
62
+ "loudnorm integrated loudness target (LUFS)."
63
+ " Range [-70, -5]. Default -14."
64
+ ),
65
+ ),
66
+ ] = -14.0
67
+
68
+ target_tp: Annotated[
69
+ float,
70
+ Field(
71
+ default=-1.0,
72
+ ge=-9.0,
73
+ le=0.0,
74
+ description=(
75
+ "loudnorm true peak target (dBTP). Range [-9, 0]. Default -1."
76
+ ),
77
+ ),
78
+ ] = -1.0
79
+
80
+ target_lra: Annotated[
81
+ float,
82
+ Field(
83
+ default=11.0,
84
+ ge=1.0,
85
+ le=50.0,
86
+ description="loudnorm LRA target (LU). Range [1, 50]. Default 11.",
87
+ ),
88
+ ] = 11.0
89
+
90
+ # peak target parameters
91
+ target_peak_db: Annotated[
92
+ float,
93
+ Field(
94
+ default=-1.0,
95
+ ge=-60.0,
96
+ le=0.0,
97
+ description="Peak mode peak target (dB). Range [-60, 0]. Default -1.",
98
+ ),
99
+ ] = -1.0
100
+
101
+
102
+ class LoudnormTarget(BaseModel):
103
+ """Normalization target values for loudnorm mode (ADR-L4).
104
+
105
+ Also independently redefined on the render side in plan.py
106
+ (NR-M-1 lesson: no dependency on clipwright_loudness).
107
+ """
108
+
109
+ i: Annotated[
110
+ float,
111
+ Field(
112
+ default=-14.0,
113
+ ge=-70.0,
114
+ le=-5.0,
115
+ description="Integrated loudness target (LUFS). Range [-70, -5].",
116
+ ),
117
+ ] = -14.0
118
+
119
+ tp: Annotated[
120
+ float,
121
+ Field(
122
+ default=-1.0,
123
+ ge=-9.0,
124
+ le=0.0,
125
+ description="True peak target (dBTP). Range [-9, 0].",
126
+ ),
127
+ ] = -1.0
128
+
129
+ lra: Annotated[
130
+ float,
131
+ Field(
132
+ default=11.0,
133
+ ge=1.0,
134
+ le=50.0,
135
+ description="LRA target (LU). Range [1, 50].",
136
+ ),
137
+ ] = 11.0
138
+
139
+
140
+ class PeakTarget(BaseModel):
141
+ """Normalization target values for peak mode (ADR-L4)."""
142
+
143
+ peak_db: Annotated[
144
+ float,
145
+ Field(
146
+ default=-1.0,
147
+ ge=-60.0,
148
+ le=0.0,
149
+ allow_inf_nan=False,
150
+ description="Peak target (dB). Range [-60, 0]. inf/nan not allowed.",
151
+ ),
152
+ ] = -1.0
153
+
154
+
155
+ class LoudnormMeasured(BaseModel):
156
+ """Measured values output by the loudnorm filter (ADR-L1).
157
+
158
+ Five values extracted from the JSON block at the end of ffmpeg
159
+ loudnorm=print_format=json stderr.
160
+ When the input is silent, "-inf" may be returned; in that case
161
+ allow_inf_nan=False causes a ValidationError and the caller treats
162
+ measured=None (U-1).
163
+ """
164
+
165
+ input_i: Annotated[
166
+ float,
167
+ Field(
168
+ allow_inf_nan=False,
169
+ description="Input integrated loudness (LUFS). inf/nan not allowed.",
170
+ ),
171
+ ]
172
+
173
+ input_tp: Annotated[
174
+ float,
175
+ Field(
176
+ allow_inf_nan=False,
177
+ description="Input true peak (dBTP). inf/nan not allowed.",
178
+ ),
179
+ ]
180
+
181
+ input_lra: Annotated[
182
+ float,
183
+ Field(
184
+ allow_inf_nan=False,
185
+ description="Input LRA (LU). inf/nan not allowed.",
186
+ ),
187
+ ]
188
+
189
+ input_thresh: Annotated[
190
+ float,
191
+ Field(
192
+ allow_inf_nan=False,
193
+ description="Input threshold (LUFS). inf/nan not allowed.",
194
+ ),
195
+ ]
196
+
197
+ target_offset: Annotated[
198
+ float,
199
+ Field(
200
+ allow_inf_nan=False,
201
+ description="Target offset (LU). inf/nan not allowed.",
202
+ ),
203
+ ]
204
+
205
+
206
+ class PeakMeasured(BaseModel):
207
+ """Measured values output by the volumedetect filter (ADR-L2).
208
+
209
+ Value extracted from "max_volume: -X.X dB" in ffmpeg volumedetect stderr.
210
+ """
211
+
212
+ max_volume_db: Annotated[
213
+ float,
214
+ Field(
215
+ ge=-200.0,
216
+ le=0.0,
217
+ allow_inf_nan=False,
218
+ description="Maximum volume (dB). Range [-200, 0]. inf/nan not allowed.",
219
+ ),
220
+ ]
221
+
222
+
223
+ class LoudnessDirective(BaseModel):
224
+ """Loudness directive schema written to timeline-level metadata
225
+ (design §3.2, ADR-L4).
226
+
227
+ Generated by loudness and read/validated by render.
228
+ scope is track only (per_clip deferred per DC-AS-003).
229
+ target is discriminated by mode (LoudnormTarget or PeakTarget).
230
+ measured holds mode-specific measured values or None (U-1: when measurement fails).
231
+ """
232
+
233
+ tool: Annotated[str, Field(max_length=64)]
234
+ version: Annotated[str, Field(max_length=64)]
235
+ kind: Literal["loudness"]
236
+ mode: Literal["loudnorm", "peak"]
237
+ scope: Literal["track"]
238
+ target: LoudnormTarget | PeakTarget
239
+ measured: LoudnormMeasured | PeakMeasured | None = None
240
+
241
+ @model_validator(mode="after")
242
+ def _validate_target_mode_consistency(self) -> LoudnessDirective:
243
+ """Validate that the target type is consistent with mode.
244
+
245
+ mode=loudnorm requires LoudnormTarget; mode=peak requires PeakTarget.
246
+ """
247
+ if self.mode == "loudnorm" and not isinstance(self.target, LoudnormTarget):
248
+ raise ValueError("When mode=loudnorm, target must be LoudnormTarget.")
249
+ if self.mode == "peak" and not isinstance(self.target, PeakTarget):
250
+ raise ValueError("When mode=peak, target must be PeakTarget.")
251
+ return self
@@ -0,0 +1,108 @@
1
+ """server.py — clipwright-loudness MCP server + CLI entry point.
2
+
3
+ Thin wrapper that delegates business logic to loudness.py.
4
+ ClipwrightError conversion is handled in loudness.py; no double conversion here.
5
+
6
+ Transport defaults to stdio (mcp.run(transport="stdio")).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Annotated, Any
12
+
13
+ from mcp.server.fastmcp import FastMCP
14
+ from mcp.types import ToolAnnotations
15
+ from pydantic import Field
16
+
17
+ from clipwright_loudness.loudness import detect_loudness
18
+ from clipwright_loudness.schemas import DetectLoudnessOptions
19
+
20
+ # FastMCP instance (server name)
21
+ mcp = FastMCP("clipwright-loudness")
22
+
23
+
24
+ # ===========================================================================
25
+ # clipwright_detect_loudness MCP tool
26
+ # ===========================================================================
27
+
28
+
29
+ @mcp.tool(
30
+ annotations=ToolAnnotations(
31
+ readOnlyHint=True,
32
+ destructiveHint=False,
33
+ idempotentHint=True,
34
+ openWorldHint=False,
35
+ )
36
+ )
37
+ def clipwright_detect_loudness(
38
+ media: Annotated[
39
+ str,
40
+ Field(description="Input media file path (must contain both video and audio)."),
41
+ ],
42
+ output: Annotated[
43
+ str,
44
+ Field(
45
+ description=(
46
+ "Output OTIO timeline file path (.otio extension). "
47
+ "Must be placed in the same directory as the media file."
48
+ )
49
+ ),
50
+ ],
51
+ options: Annotated[
52
+ DetectLoudnessOptions | None,
53
+ Field(
54
+ description=(
55
+ "Loudness detection options (mode / scope / target, etc.). "
56
+ "Defaults to mode=loudnorm / scope=track /"
57
+ " I=-14 / TP=-1 / LRA=11 when omitted."
58
+ )
59
+ ),
60
+ ] = None,
61
+ timeline: Annotated[
62
+ str | None,
63
+ Field(
64
+ description=(
65
+ "Existing OTIO timeline file path. "
66
+ "When specified, the loudness directive is appended to that timeline. "
67
+ "A new timeline is created when omitted."
68
+ )
69
+ ),
70
+ ] = None,
71
+ ) -> dict[str, Any]:
72
+ """Analyze audio loudness and generate an OTIO timeline with a loudness directive.
73
+
74
+ The input media file is never modified (non-destructive, readOnly).
75
+ readOnlyHint=True means "the input media is not changed"; the .otio file
76
+ specified in output is newly created (outside the readOnly scope).
77
+ Measures loudness with ffmpeg loudnorm/volumedetect and writes the loudness
78
+ directive to timeline-level metadata["clipwright"]["loudness"].
79
+ Returns the path of the resulting timeline.otio in artifacts.
80
+
81
+ Delegates business logic to loudness.detect_loudness.
82
+ Uses default DetectLoudnessOptions() when options is None.
83
+ """
84
+ resolved_options = options if options is not None else DetectLoudnessOptions()
85
+ return detect_loudness(
86
+ media=media,
87
+ output=output,
88
+ options=resolved_options,
89
+ timeline=timeline,
90
+ )
91
+
92
+
93
+ # ===========================================================================
94
+ # Entry point (MCP stdio launch)
95
+ # ===========================================================================
96
+
97
+
98
+ def main() -> None:
99
+ """CLI entry point. Launches the MCP server over stdio.
100
+
101
+ Registered in pyproject.toml [project.scripts] as:
102
+ clipwright-loudness = "clipwright_loudness.server:main"
103
+ """
104
+ mcp.run(transport="stdio")
105
+
106
+
107
+ if __name__ == "__main__": # pragma: no cover
108
+ main()