clipwright-noise 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,80 @@
1
+ Metadata-Version: 2.3
2
+ Name: clipwright-noise
3
+ Version: 0.1.1
4
+ Summary: MCP tool for noise detection and OTIO timeline annotation generation. Measures noise floor with ffmpeg astats and writes denoise instructions to timeline.otio.
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-noise
15
+
16
+ MCP tool for noise detection and OTIO timeline annotation generation.
17
+
18
+ ## Overview
19
+
20
+ Measures audio noise floor using ffmpeg `astats` filter,
21
+ writes denoise instructions (backend, parameters) to timeline-level `metadata["clipwright"]["denoise"]`.
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
+ **Initial render support**:
27
+ - `afftdn` backend: render application supported (`clipwright-render` injects afftdn filter).
28
+ - `deepfilternet` backend: annotation only. render application not yet supported (planned in future version).
29
+
30
+ ## Prerequisites
31
+
32
+ - Python 3.11 or later
33
+ - **ffmpeg / ffprobe must exist on PATH or full paths set in environment variables `CLIPWRIGHT_FFMPEG` / `CLIPWRIGHT_FFPROBE`.**
34
+
35
+ Add ffmpeg to PATH directly or specify via environment variables:
36
+
37
+ ```bash
38
+ export CLIPWRIGHT_FFMPEG=/path/to/ffmpeg
39
+ export CLIPWRIGHT_FFPROBE=/path/to/ffprobe
40
+ ```
41
+
42
+ ## MCP Tool
43
+
44
+ `clipwright_detect_noise`
45
+
46
+ ### Parameters
47
+
48
+ | Name | Type | Default | Description |
49
+ |------|------|---------|-------------|
50
+ | `media` | `string` | required | Input media file path (video + audio required) |
51
+ | `output` | `string` | required | Output OTIO timeline path (`.otio`, same directory as media) |
52
+ | `options.backend` | `"afftdn" \| "deepfilternet"` | `"afftdn"` | denoise backend |
53
+ | `options.strength` | `"light" \| "medium" \| "strong"` | `"medium"` | afftdn nr mapping (light=6/medium=12/strong=24 dB) |
54
+ | `timeline` | `string \| null` | `null` | Existing OTIO timeline path (if specified, append to it) |
55
+
56
+ ## Dependencies
57
+
58
+ | Package | Purpose |
59
+ |---------|---------|
60
+ | `clipwright` | Shared types, envelope, errors, process.run |
61
+ | `mcp[cli]` | MCP server |
62
+ | `pydantic` | Parameter validation |
63
+
64
+ ffmpeg / ffprobe are invoked as separate processes (via PATH or environment variables) for license independence.
65
+ DeepFilterNet binary is not bundled in initial version; render-side dependency planned.
66
+
67
+ ## Installation and Startup
68
+
69
+ Within a uv workspace:
70
+
71
+ ```bash
72
+ uv run --package clipwright-noise clipwright-noise
73
+ ```
74
+
75
+ Or install directly:
76
+
77
+ ```bash
78
+ uv add clipwright-noise
79
+ clipwright-noise
80
+ ```
@@ -0,0 +1,67 @@
1
+ # clipwright-noise
2
+
3
+ MCP tool for noise detection and OTIO timeline annotation generation.
4
+
5
+ ## Overview
6
+
7
+ Measures audio noise floor using ffmpeg `astats` filter,
8
+ writes denoise instructions (backend, parameters) to timeline-level `metadata["clipwright"]["denoise"]`.
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
+ **Initial render support**:
14
+ - `afftdn` backend: render application supported (`clipwright-render` injects afftdn filter).
15
+ - `deepfilternet` backend: annotation only. render application not yet supported (planned in future version).
16
+
17
+ ## Prerequisites
18
+
19
+ - Python 3.11 or later
20
+ - **ffmpeg / ffprobe must exist on PATH or full paths set in environment variables `CLIPWRIGHT_FFMPEG` / `CLIPWRIGHT_FFPROBE`.**
21
+
22
+ Add ffmpeg to PATH directly or specify via environment variables:
23
+
24
+ ```bash
25
+ export CLIPWRIGHT_FFMPEG=/path/to/ffmpeg
26
+ export CLIPWRIGHT_FFPROBE=/path/to/ffprobe
27
+ ```
28
+
29
+ ## MCP Tool
30
+
31
+ `clipwright_detect_noise`
32
+
33
+ ### Parameters
34
+
35
+ | Name | Type | Default | Description |
36
+ |------|------|---------|-------------|
37
+ | `media` | `string` | required | Input media file path (video + audio required) |
38
+ | `output` | `string` | required | Output OTIO timeline path (`.otio`, same directory as media) |
39
+ | `options.backend` | `"afftdn" \| "deepfilternet"` | `"afftdn"` | denoise backend |
40
+ | `options.strength` | `"light" \| "medium" \| "strong"` | `"medium"` | afftdn nr mapping (light=6/medium=12/strong=24 dB) |
41
+ | `timeline` | `string \| null` | `null` | Existing OTIO timeline path (if specified, append to it) |
42
+
43
+ ## Dependencies
44
+
45
+ | Package | Purpose |
46
+ |---------|---------|
47
+ | `clipwright` | Shared types, envelope, errors, process.run |
48
+ | `mcp[cli]` | MCP server |
49
+ | `pydantic` | Parameter validation |
50
+
51
+ ffmpeg / ffprobe are invoked as separate processes (via PATH or environment variables) for license independence.
52
+ DeepFilterNet binary is not bundled in initial version; render-side dependency planned.
53
+
54
+ ## Installation and Startup
55
+
56
+ Within a uv workspace:
57
+
58
+ ```bash
59
+ uv run --package clipwright-noise clipwright-noise
60
+ ```
61
+
62
+ Or install directly:
63
+
64
+ ```bash
65
+ uv add clipwright-noise
66
+ clipwright-noise
67
+ ```
@@ -0,0 +1,86 @@
1
+ [project]
2
+ name = "clipwright-noise"
3
+ version = "0.1.1"
4
+ description = "MCP tool for noise detection and OTIO timeline annotation generation. Measures noise floor with ffmpeg astats and writes denoise instructions to timeline.otio."
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-noise = "clipwright_noise.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
+ "clipwright-render",
27
+ "mypy>=2.1.0",
28
+ "pytest>=9.0.3",
29
+ "pytest-cov>=7.1.0",
30
+ "pytest-mock>=3.15.1",
31
+ "ruff>=0.15.16",
32
+ ]
33
+
34
+ # Resolve clipwright (core) and clipwright-render within workspace by path reference
35
+ [tool.uv.sources]
36
+ clipwright = { workspace = true }
37
+ clipwright-render = { workspace = true }
38
+
39
+ # --- Ruff ---
40
+ [tool.ruff]
41
+ target-version = "py311"
42
+ line-length = 88
43
+
44
+ [tool.ruff.lint]
45
+ select = ["E", "F", "W", "I", "UP", "B", "C4", "SIM"]
46
+ ignore = []
47
+
48
+ [tool.ruff.lint.per-file-ignores]
49
+ # Allow E501 for English docstrings/comments in test files
50
+ "tests/*.py" = ["E501"]
51
+
52
+ [tool.ruff.format]
53
+ # Default ruff formatter is OK
54
+
55
+ # --- mypy ---
56
+ [tool.mypy]
57
+ python_version = "3.11"
58
+ strict = true
59
+ warn_return_any = true
60
+ warn_unused_configs = true
61
+ disallow_untyped_defs = true
62
+ disallow_any_generics = true
63
+
64
+ # opentimelineio has no stubs, ignored with mypy strict
65
+ [[tool.mypy.overrides]]
66
+ module = "opentimelineio.*"
67
+ ignore_missing_imports = true
68
+
69
+ # --- pytest ---
70
+ [tool.pytest.ini_options]
71
+ testpaths = ["tests"]
72
+ addopts = "--strict-markers -q"
73
+ markers = [
74
+ "integration: integration test requiring actual ffmpeg/ffprobe binaries",
75
+ "slow: test with long execution time",
76
+ "e2e: e2e test using actual ffmpeg binary",
77
+ ]
78
+
79
+ # --- coverage ---
80
+ [tool.coverage.run]
81
+ source = ["clipwright_noise"]
82
+ omit = ["tests/*"]
83
+
84
+ [tool.coverage.report]
85
+ show_missing = true
86
+ skip_covered = false
@@ -0,0 +1,3 @@
1
+ """clipwright-noise: Noise detection → OTIO timeline annotation generation MCP tool."""
2
+
3
+ __version__ = "0.1.1"
@@ -0,0 +1,149 @@
1
+ """analyze.py — Noise floor measurement and parameter calculation via ffmpeg astats.
2
+
3
+ Design reference: §2.3.
4
+
5
+ Measures audio RMS/Noise_floor using the astats filter,
6
+ then calculates denoise parameters per backend.
7
+ Falls back to nf=-50.0 when measurement is unavailable,
8
+ returning a warning (design B-6).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from clipwright.process import resolve_tool, run
18
+
19
+ # strength → afftdn nr (dB) mapping (design §2.1 fixed values)
20
+ _STRENGTH_TO_NR: dict[str, float] = {
21
+ "light": 6.0,
22
+ "medium": 12.0,
23
+ "strong": 24.0,
24
+ }
25
+
26
+ # nf fallback value (when astats measurement is unavailable; design B-6)
27
+ _NF_FALLBACK: float = -50.0
28
+
29
+ # nf clamp range (aligned with AfftdnParams constraints)
30
+ _NF_MIN: float = -80.0
31
+ _NF_MAX: float = -20.0
32
+
33
+ # astats execution timeout (seconds)
34
+ _TIMEOUT_SECONDS: float = 60.0
35
+
36
+
37
+ def _parse_noise_floor(stderr: str) -> float | None:
38
+ """Extract the noise floor value (dB) from astats stderr.
39
+
40
+ Priority:
41
+ 1. `Noise floor dB:` field (actual ffmpeg astats output format)
42
+ 2. `RMS level dB:` field (fallback)
43
+
44
+ Returns None if extraction fails.
45
+ """
46
+ # Prefer Noise floor dB (actual ffmpeg astats output format: "Noise floor dB: -X.X")
47
+ m = re.search(r"Noise floor dB:\s*(-?\d+\.?\d*)", stderr)
48
+ if m:
49
+ try:
50
+ return float(m.group(1))
51
+ except ValueError:
52
+ pass
53
+
54
+ # Fall back to RMS level dB (actual ffmpeg astats output: "RMS level dB: -X.X")
55
+ m = re.search(r"RMS level dB:\s*(-?\d+\.?\d*)", stderr)
56
+ if m:
57
+ try:
58
+ return float(m.group(1))
59
+ except ValueError:
60
+ pass
61
+
62
+ return None
63
+
64
+
65
+ def _clamp(value: float, lo: float, hi: float) -> float:
66
+ """Clamp value to the range [lo, hi]."""
67
+ return max(lo, min(hi, value))
68
+
69
+
70
+ def measure_noise(
71
+ media_path: Path,
72
+ strength: str,
73
+ backend: str,
74
+ ) -> dict[str, Any]:
75
+ """Analyze media audio with astats and return denoise parameters per backend.
76
+
77
+ Args:
78
+ media_path: Path to the input media file (video + audio).
79
+ strength: DetectNoiseOptions.strength ("light"/"medium"/"strong").
80
+ backend: "afftdn" or "deepfilternet".
81
+
82
+ Returns:
83
+ {
84
+ "params": dict (AfftdnParams-equivalent or {}),
85
+ "measured_noise_floor_db": float | None,
86
+ "warnings": list[str],
87
+ }
88
+
89
+ Raises:
90
+ clipwright.errors.ClipwrightError: DEPENDENCY_MISSING / SUBPROCESS_FAILED /
91
+ SUBPROCESS_TIMEOUT.
92
+ """
93
+ # Resolve the ffmpeg binary (B-1: PATH-independent via resolve_tool)
94
+ ffmpeg_bin = resolve_tool("ffmpeg", "CLIPWRIGHT_FFMPEG")
95
+
96
+ # Measure noise floor for the entire duration with astats
97
+ # (metadata=1:reset=0 for global stats)
98
+ cmd = [
99
+ ffmpeg_bin,
100
+ "-i",
101
+ str(media_path),
102
+ "-af",
103
+ "astats=metadata=1:reset=0",
104
+ "-f",
105
+ "null",
106
+ "-",
107
+ ]
108
+
109
+ warnings: list[str] = []
110
+
111
+ # run raises ClipwrightError (SUBPROCESS_FAILED / SUBPROCESS_TIMEOUT, etc.),
112
+ # which propagates directly to the caller.
113
+ result = run(
114
+ cmd,
115
+ timeout=_TIMEOUT_SECONDS,
116
+ )
117
+
118
+ # astats statistics are written to stderr (run returns CompletedProcess)
119
+ stderr_text = result.stderr
120
+
121
+ measured = _parse_noise_floor(stderr_text)
122
+
123
+ if backend == "afftdn":
124
+ nr = _STRENGTH_TO_NR.get(strength, _STRENGTH_TO_NR["medium"])
125
+
126
+ if measured is not None:
127
+ nf = _clamp(measured, _NF_MIN, _NF_MAX)
128
+ else:
129
+ nf = _NF_FALLBACK
130
+ warnings.append(
131
+ f"Noise floor measurement failed; using default nf={_NF_FALLBACK}."
132
+ " The astats output did not contain"
133
+ " Noise floor dB / RMS level dB fields."
134
+ )
135
+
136
+ params: dict[str, Any] = {"nr": nr, "nf": nf, "nt": "w"}
137
+ else:
138
+ # deepfilternet: params fixed to {} (first release; design DC-AM-002)
139
+ if measured is None:
140
+ warnings.append(
141
+ "Noise floor measurement failed; measured_noise_floor_db will be None."
142
+ )
143
+ params = {}
144
+
145
+ return {
146
+ "params": params,
147
+ "measured_noise_floor_db": measured,
148
+ "warnings": warnings,
149
+ }
@@ -0,0 +1,364 @@
1
+ """noise.py — clipwright-noise orchestration layer (design §1.1).
2
+
3
+ Flow:
4
+ 1. Output validation (extension, parent dir, output==media,
5
+ output==timeline, same dir)
6
+ 2. inspect_media: video + audio required check (ADR-N8)
7
+ 3. Timeline resolution (None → new / path → load + validate)
8
+ 4. measure_noise: measure noise floor via astats → calculate params
9
+ 5. Partial update of denoise directive into timeline-level metadata
10
+ 6. save_timeline → return ok_result
11
+
12
+ Design decisions:
13
+ - FILE_NOT_FOUND / SUBPROCESS_FAILED messages use basename only (DC-GP-005).
14
+ - output must be in the same directory as media (MUST; DC-AS-002).
15
+ - source==media comparison is normalized with Path.resolve() (DC-AS-003 / B-4).
16
+ - Timeline validation: exactly one Video-kind track (B-5).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ import opentimelineio as otio
25
+ from clipwright.envelope import error_result, ok_result
26
+ from clipwright.errors import ClipwrightError, ErrorCode
27
+ from clipwright.media import inspect_media
28
+ from clipwright.otio_utils import (
29
+ get_clipwright_metadata,
30
+ load_timeline,
31
+ new_timeline,
32
+ save_timeline,
33
+ set_clipwright_metadata,
34
+ )
35
+ from clipwright.schemas import RationalTimeModel
36
+
37
+ import clipwright_noise
38
+ from clipwright_noise.analyze import measure_noise
39
+ from clipwright_noise.schemas import DenoiseDirective, DetectNoiseOptions
40
+
41
+
42
+ def detect_noise(
43
+ media: str,
44
+ output: str,
45
+ options: DetectNoiseOptions,
46
+ timeline: str | None,
47
+ ) -> dict[str, Any]:
48
+ """Public API for noise detection. Converts ClipwrightError to ok=False.
49
+
50
+ Args:
51
+ media: Input media file path (video + audio required).
52
+ output: Output OTIO timeline file path (.otio; same directory as media).
53
+ options: DetectNoiseOptions.
54
+ timeline: Existing timeline path (None = create new).
55
+
56
+ Returns:
57
+ ok_result or error_result envelope dict.
58
+ """
59
+ try:
60
+ return _detect_noise_inner(media, output, options, timeline)
61
+ except ClipwrightError as exc:
62
+ return error_result(exc.code, exc.message, exc.hint)
63
+
64
+
65
+ def _detect_noise_inner(
66
+ media: str,
67
+ output: str,
68
+ options: DetectNoiseOptions,
69
+ timeline: str | None,
70
+ ) -> dict[str, Any]:
71
+ """Internal implementation of detect_noise. Raises ClipwrightError directly."""
72
+ media_path = Path(media)
73
+ output_path = Path(output)
74
+
75
+ # --- 1. Output validation ---
76
+
77
+ if output_path.suffix.lower() != ".otio":
78
+ raise ClipwrightError(
79
+ code=ErrorCode.INVALID_INPUT,
80
+ message=f"Unsupported output extension: {output_path.suffix!r}",
81
+ hint="Set the output file extension to .otio.",
82
+ )
83
+
84
+ if not output_path.parent.exists():
85
+ raise ClipwrightError(
86
+ code=ErrorCode.INVALID_INPUT,
87
+ message="Output directory does not exist.",
88
+ hint="Create the output directory first, then re-run.",
89
+ )
90
+
91
+ # Prohibit output == media (non-destructive; M5)
92
+ if _same_path(output_path, media_path):
93
+ raise ClipwrightError(
94
+ code=ErrorCode.INVALID_INPUT,
95
+ message="Output path and input media path are identical.",
96
+ hint="Change the output file path to differ from the input media.",
97
+ )
98
+
99
+ # Prohibit output == timeline (non-destructive)
100
+ if timeline is not None and _same_path(output_path, Path(timeline)):
101
+ raise ClipwrightError(
102
+ code=ErrorCode.INVALID_INPUT,
103
+ message="Output path and input timeline path are identical.",
104
+ hint="Change the output file path to differ from the input timeline.",
105
+ )
106
+
107
+ # output must be in same directory as media (MUST; DC-AS-002)
108
+ try:
109
+ media_resolved_dir = media_path.resolve().parent
110
+ output_resolved_dir = output_path.resolve().parent
111
+ except OSError:
112
+ media_resolved_dir = media_path.absolute().parent
113
+ output_resolved_dir = output_path.absolute().parent
114
+
115
+ if media_resolved_dir != output_resolved_dir:
116
+ raise ClipwrightError(
117
+ code=ErrorCode.INVALID_INPUT,
118
+ message=(
119
+ "The output file must be in the same directory as the media file."
120
+ ),
121
+ hint="Change the output path to the same directory as the media file.",
122
+ )
123
+
124
+ # --- 2. inspect_media: video + audio required (ADR-N8 / DC-AS-003) ---
125
+
126
+ if not media_path.exists():
127
+ raise ClipwrightError(
128
+ code=ErrorCode.FILE_NOT_FOUND,
129
+ message=f"File not found: {media_path.name}",
130
+ hint="Check that the input media file path is correct.",
131
+ )
132
+
133
+ media_info = inspect_media(media)
134
+
135
+ has_video = any(s.codec_type == "video" for s in media_info.streams)
136
+ has_audio = any(s.codec_type == "audio" for s in media_info.streams)
137
+
138
+ if not has_video:
139
+ raise ClipwrightError(
140
+ code=ErrorCode.UNSUPPORTED_OPERATION,
141
+ message=f"No video stream found: {media_path.name}",
142
+ hint="Provide a media file that contains both video and audio.",
143
+ )
144
+
145
+ if not has_audio:
146
+ raise ClipwrightError(
147
+ code=ErrorCode.UNSUPPORTED_OPERATION,
148
+ message=f"No audio stream found: {media_path.name}",
149
+ hint="Provide a media file that contains both video and audio.",
150
+ )
151
+
152
+ # Obtain duration (total duration in seconds passed to _add_full_clip)
153
+ duration_sec: float = 0.0
154
+ if media_info.duration is not None:
155
+ duration_sec = media_info.duration.value / media_info.duration.rate
156
+
157
+ # --- 3. Timeline resolution ---
158
+
159
+ if timeline is None:
160
+ # New timeline: append one full-length keep clip to V1
161
+ tl = new_timeline(media_path.name)
162
+ _add_full_clip(tl, media_path, duration_sec, media_info.duration)
163
+ else:
164
+ tl = _load_and_validate_timeline(
165
+ timeline, media_path, duration_sec, media_info.duration
166
+ )
167
+
168
+ # --- 4. Noise analysis ---
169
+
170
+ analysis = measure_noise(
171
+ media_path=media_path,
172
+ strength=options.strength,
173
+ backend=options.backend,
174
+ )
175
+
176
+ params: dict[str, Any] = analysis["params"]
177
+ measured: float | None = analysis["measured_noise_floor_db"]
178
+ warnings: list[str] = list(analysis["warnings"])
179
+
180
+ # --- 5. Partial update of denoise directive into timeline-level metadata ---
181
+
182
+ directive = DenoiseDirective(
183
+ tool="clipwright-noise",
184
+ version=clipwright_noise.__version__,
185
+ kind="denoise",
186
+ backend=options.backend,
187
+ params=params,
188
+ measured_noise_floor_db=measured,
189
+ )
190
+
191
+ existing_meta = get_clipwright_metadata(tl)
192
+ existing_meta["denoise"] = directive.model_dump()
193
+ set_clipwright_metadata(tl, existing_meta)
194
+
195
+ # Add render-not-supported warning when deepfilternet is selected (DC-GP-003)
196
+ if options.backend == "deepfilternet":
197
+ warnings.append(
198
+ "backend=deepfilternet was selected."
199
+ " Render application is not supported (first release: afftdn only)."
200
+ " Re-detect with afftdn or wait for a future release."
201
+ )
202
+
203
+ # --- 6. save_timeline → ok_result ---
204
+
205
+ save_timeline(tl, str(output_path))
206
+
207
+ summary = (
208
+ f"Noise analysis of {media_path.name} completed."
209
+ f" backend={options.backend}, strength={options.strength}."
210
+ f" Denoise directive written to {output_path.name}."
211
+ + (
212
+ f" Measured noise floor: {measured:.1f} dB."
213
+ if measured is not None
214
+ else " Noise floor measurement failed; default value used."
215
+ )
216
+ )
217
+
218
+ return ok_result(
219
+ summary,
220
+ data={
221
+ "backend": options.backend,
222
+ "strength": options.strength,
223
+ "measured_noise_floor_db": measured,
224
+ "params": params,
225
+ },
226
+ artifacts=[
227
+ {"role": "timeline", "path": str(output_path), "format": "otio"},
228
+ ],
229
+ warnings=warnings,
230
+ )
231
+
232
+
233
+ def _add_full_clip(
234
+ tl: otio.schema.Timeline,
235
+ media_path: Path,
236
+ duration_sec: float,
237
+ duration_rt: RationalTimeModel | None,
238
+ ) -> None:
239
+ """Append one full-length keep clip to the V1/A1 tracks of the timeline.
240
+
241
+ Used for new timelines.
242
+ target_url is the absolute path of media_path.resolve() (DC-AS-002).
243
+ """
244
+ try:
245
+ target_url = str(media_path.resolve())
246
+ except OSError:
247
+ target_url = str(media_path.absolute())
248
+
249
+ # Determine rate: use the measured duration rate if available, otherwise 1000.0
250
+ rate = duration_rt.rate if duration_rt is not None else 1000.0
251
+
252
+ source_range = otio.opentime.TimeRange(
253
+ start_time=otio.opentime.RationalTime(0.0, rate),
254
+ duration=otio.opentime.RationalTime(duration_sec * rate, rate),
255
+ )
256
+ ref = otio.schema.ExternalReference(target_url=target_url)
257
+
258
+ # Append the same clip to V1 (index 0) and A1 (index 1)
259
+ for track in tl.tracks:
260
+ clip = otio.schema.Clip(
261
+ name=media_path.name,
262
+ media_reference=ref,
263
+ source_range=source_range,
264
+ )
265
+ track.append(clip)
266
+
267
+
268
+ def _load_and_validate_timeline(
269
+ timeline_path: str,
270
+ media_path: Path,
271
+ duration_sec: float,
272
+ duration_rt: RationalTimeModel | None,
273
+ ) -> otio.schema.Timeline:
274
+ """Load an existing timeline and validate its consistency.
275
+
276
+ Validation references: DC-AM-003 / DC-AM-004 / B-4 / B-5.
277
+
278
+ Validation:
279
+ - V1 clip target_url matches media_path (B-4: normalized path comparison)
280
+ - Single source (all clips share the same target_url)
281
+ - Exactly one Video-kind track (B-5)
282
+
283
+ If V1 is empty, a full-length keep clip is appended and processing continues
284
+ (to make the timeline renderable, equivalent to creating a new one;
285
+ prevents render from rejecting with INVALID_INPUT due to zero clips).
286
+
287
+ Raises:
288
+ ClipwrightError: INVALID_INPUT / OTIO_ERROR.
289
+ """
290
+ tl = load_timeline(timeline_path)
291
+
292
+ # --- Exactly one Video-kind track (B-5) ---
293
+ video_tracks = [t for t in tl.tracks if t.kind == otio.schema.TrackKind.Video]
294
+ if len(video_tracks) != 1:
295
+ raise ClipwrightError(
296
+ code=ErrorCode.INVALID_INPUT,
297
+ message=(
298
+ f"Invalid number of Video tracks in timeline: {len(video_tracks)}"
299
+ " (only 1 is supported)"
300
+ ),
301
+ hint="Specify a timeline with exactly one Video track.",
302
+ )
303
+
304
+ v1 = video_tracks[0]
305
+
306
+ # --- Collect all clip target_urls and validate single source (DC-AM-004) ---
307
+ clips = [item for item in v1 if isinstance(item, otio.schema.Clip)]
308
+
309
+ if not clips:
310
+ # V1 is empty: append a full-length keep clip and continue
311
+ # (equivalent to creating a new timeline).
312
+ # Prevents render's resolve_kept_ranges from rejecting due to zero clips.
313
+ _add_full_clip(tl, media_path, duration_sec, duration_rt)
314
+ return tl
315
+
316
+ urls: set[str] = set()
317
+ for clip in clips:
318
+ ref = clip.media_reference
319
+ if isinstance(ref, otio.schema.ExternalReference):
320
+ urls.add(ref.target_url)
321
+
322
+ if len(urls) > 1:
323
+ raise ClipwrightError(
324
+ code=ErrorCode.UNSUPPORTED_OPERATION,
325
+ message="The timeline contains clips from multiple sources.",
326
+ hint="Specify a timeline with a single source (same media file).",
327
+ )
328
+
329
+ # --- Validate target_url == media_path (B-4: resolve() normalized comparison) ---
330
+ if urls:
331
+ target_url = next(iter(urls))
332
+ try:
333
+ tl_source = Path(target_url).resolve()
334
+ media_resolved = media_path.resolve()
335
+ except OSError:
336
+ # On OSError, fall back to absolute() comparison (best-effort)
337
+ tl_source = Path(target_url).absolute()
338
+ media_resolved = media_path.absolute()
339
+
340
+ if tl_source != media_resolved:
341
+ raise ClipwrightError(
342
+ code=ErrorCode.INVALID_INPUT,
343
+ message=(
344
+ "Timeline source file does not match the input media."
345
+ f" timeline source: {Path(target_url).name}"
346
+ f" / media: {media_path.name}"
347
+ ),
348
+ hint=(
349
+ "Specify the same media file used when the timeline was generated."
350
+ ),
351
+ )
352
+
353
+ return tl
354
+
355
+
356
+ def _same_path(a: Path, b: Path) -> bool:
357
+ """Return True if two paths refer to the same entity.
358
+
359
+ Falls back to string comparison on OSError.
360
+ """
361
+ try:
362
+ return a.resolve() == b.resolve()
363
+ except OSError:
364
+ return str(a) == str(b)
File without changes
@@ -0,0 +1,96 @@
1
+ """schemas.py — clipwright-noise-specific Pydantic schemas.
2
+
3
+ Common types (MediaRef / Artifact / ToolResult, etc.) are defined centrally
4
+ in clipwright.schemas and are not redefined here.
5
+
6
+ DetectNoiseOptions: Input options for clipwright_detect_noise.
7
+ DenoiseDirective: Directive schema written to timeline-level
8
+ metadata["clipwright"]["denoise"].
9
+ AfftdnParams: afftdn filter parameters (body of DenoiseDirective.params).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import math
15
+ from typing import Annotated, Any, Literal
16
+
17
+ from pydantic import BaseModel, Field, field_validator
18
+
19
+
20
+ class DetectNoiseOptions(BaseModel):
21
+ """Options for clipwright_detect_noise (design §2.1).
22
+
23
+ The `track` field is deprecated (ADR-N7).
24
+ The first audio stream is processed unconditionally.
25
+ """
26
+
27
+ backend: Annotated[
28
+ Literal["afftdn", "deepfilternet"],
29
+ Field(
30
+ default="afftdn",
31
+ description=(
32
+ "Denoise backend. "
33
+ '"afftdn" (default) applies the ffmpeg afftdn filter at render time. '
34
+ '"deepfilternet" generates annotations only; '
35
+ "render application is not supported (first release)."
36
+ ),
37
+ ),
38
+ ] = "afftdn"
39
+
40
+ strength: Annotated[
41
+ Literal["light", "medium", "strong"],
42
+ Field(
43
+ default="medium",
44
+ description=(
45
+ "Strength mapped to afftdn nr (noise reduction dB). "
46
+ "light=6 / medium=12 / strong=24 (fixed values). "
47
+ "Not referenced when using the deepfilternet backend."
48
+ ),
49
+ ),
50
+ ] = "medium"
51
+
52
+
53
+ class AfftdnParams(BaseModel):
54
+ """afftdn filter parameters (design §2.2 / DC-AS-006).
55
+
56
+ Used when render re-validates DenoiseDirective.params as AfftdnParams.
57
+ Applied only when backend=="afftdn".
58
+ """
59
+
60
+ nr: Annotated[
61
+ float,
62
+ Field(ge=0.01, le=97, description="Noise reduction (dB). Range [0.01, 97]."),
63
+ ]
64
+ nf: Annotated[
65
+ float,
66
+ Field(ge=-80, le=-20, description="Noise floor (dB). Range [-80, -20]."),
67
+ ]
68
+ nt: Annotated[
69
+ Literal["w", "v"],
70
+ Field(default="w", description="Noise type. w=white noise (default), v=vinyl."),
71
+ ] = "w"
72
+
73
+
74
+ class DenoiseDirective(BaseModel):
75
+ """Denoise directive schema written to timeline-level metadata (design §2.2).
76
+
77
+ Generated by noise; loaded and validated by render.
78
+ When backend=="afftdn", params is a dict equivalent to AfftdnParams
79
+ (re-validated by render).
80
+ When backend=="deepfilternet", params is fixed to {} (first release).
81
+ """
82
+
83
+ tool: Annotated[str, Field(max_length=64)]
84
+ version: Annotated[str, Field(max_length=64)]
85
+ kind: Literal["denoise"]
86
+ backend: Literal["afftdn", "deepfilternet"]
87
+ params: dict[str, Any]
88
+ measured_noise_floor_db: Annotated[float, Field(ge=-200.0, le=0.0)] | None = None
89
+
90
+ @field_validator("measured_noise_floor_db", mode="before")
91
+ @classmethod
92
+ def _reject_non_finite(cls, v: object) -> object:
93
+ """Reject inf / nan (CWE-20: invalid numeric value elimination)."""
94
+ if isinstance(v, float) and not math.isfinite(v):
95
+ raise ValueError("inf / nan cannot be used for measured_noise_floor_db.")
96
+ return v
@@ -0,0 +1,105 @@
1
+ """server.py — clipwright-noise MCP server + CLI entry point.
2
+
3
+ A thin wrapper that delegates business logic to noise.py.
4
+ ClipwrightError conversion is handled in noise.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_noise.noise import detect_noise
18
+ from clipwright_noise.schemas import DetectNoiseOptions
19
+
20
+ # FastMCP instance (server name)
21
+ mcp = FastMCP("clipwright-noise")
22
+
23
+
24
+ # ===========================================================================
25
+ # clipwright_detect_noise 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_noise(
38
+ media: Annotated[
39
+ str,
40
+ Field(description="Input media file path (must contain 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
+ DetectNoiseOptions | None,
53
+ Field(
54
+ description=(
55
+ "Noise detection options (backend / strength). "
56
+ "Defaults to backend=afftdn / strength=medium when omitted."
57
+ )
58
+ ),
59
+ ] = None,
60
+ timeline: Annotated[
61
+ str | None,
62
+ Field(
63
+ description=(
64
+ "Existing OTIO timeline file path. "
65
+ "When specified, the denoise directive is appended to that timeline. "
66
+ "A new timeline is generated when omitted."
67
+ )
68
+ ),
69
+ ] = None,
70
+ ) -> dict[str, Any]:
71
+ """MCP tool: analyzes audio noise and generates a denoise-annotated OTIO timeline.
72
+
73
+ The input media file is never modified (non-destructive / readOnly).
74
+ Measures the noise floor via ffmpeg astats, calculates backend-specific parameters,
75
+ and writes them to timeline-level metadata["clipwright"]["denoise"].
76
+ Returns the path to the annotated timeline.otio in artifacts.
77
+
78
+ Business logic is delegated to noise.detect_noise.
79
+ When options is None, the default DetectNoiseOptions() is used.
80
+ """
81
+ resolved_options = options if options is not None else DetectNoiseOptions()
82
+ return detect_noise(
83
+ media=media,
84
+ output=output,
85
+ options=resolved_options,
86
+ timeline=timeline,
87
+ )
88
+
89
+
90
+ # ===========================================================================
91
+ # Entry point (MCP stdio launch)
92
+ # ===========================================================================
93
+
94
+
95
+ def main() -> None:
96
+ """CLI entry point. Starts the MCP server over stdio.
97
+
98
+ Registered in pyproject.toml [project.scripts] as:
99
+ clipwright-noise = "clipwright_noise.server:main"
100
+ """
101
+ mcp.run(transport="stdio")
102
+
103
+
104
+ if __name__ == "__main__": # pragma: no cover
105
+ main()