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.
- clipwright_noise-0.1.1/PKG-INFO +80 -0
- clipwright_noise-0.1.1/README.md +67 -0
- clipwright_noise-0.1.1/pyproject.toml +86 -0
- clipwright_noise-0.1.1/src/clipwright_noise/__init__.py +3 -0
- clipwright_noise-0.1.1/src/clipwright_noise/analyze.py +149 -0
- clipwright_noise-0.1.1/src/clipwright_noise/noise.py +364 -0
- clipwright_noise-0.1.1/src/clipwright_noise/py.typed +0 -0
- clipwright_noise-0.1.1/src/clipwright_noise/schemas.py +96 -0
- clipwright_noise-0.1.1/src/clipwright_noise/server.py +105 -0
|
@@ -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,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()
|