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.
- clipwright_loudness-0.1.1/PKG-INFO +94 -0
- clipwright_loudness-0.1.1/README.md +81 -0
- clipwright_loudness-0.1.1/pyproject.toml +84 -0
- clipwright_loudness-0.1.1/src/clipwright_loudness/__init__.py +3 -0
- clipwright_loudness-0.1.1/src/clipwright_loudness/analyze.py +240 -0
- clipwright_loudness-0.1.1/src/clipwright_loudness/loudness.py +471 -0
- clipwright_loudness-0.1.1/src/clipwright_loudness/py.typed +0 -0
- clipwright_loudness-0.1.1/src/clipwright_loudness/schemas.py +251 -0
- clipwright_loudness-0.1.1/src/clipwright_loudness/server.py +108 -0
|
@@ -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,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)
|
|
File without changes
|
|
@@ -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()
|