clipwright-color 0.1.0__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,114 @@
1
+ Metadata-Version: 2.3
2
+ Name: clipwright-color
3
+ Version: 0.1.0
4
+ Summary: MCP tool for video brightness detection and OTIO timeline color annotation generation. Measures average luma with ffmpeg signalstats and writes an eq color-correction directive 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.2.0
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-color
15
+
16
+ MCP tool for video brightness detection and OTIO timeline color annotation generation.
17
+
18
+ ## Overview
19
+
20
+ Measures average luma using the ffmpeg `signalstats` filter,
21
+ writes a color correction directive (brightness offset, eq parameters) to timeline-level
22
+ `metadata["clipwright"]["color"]`.
23
+
24
+ Performs detection only (OTIO annotation); realization (eq filter application) is done once
25
+ by `clipwright-render` (design M3: separation of detection and application).
26
+
27
+ **Brightness derivation**:
28
+
29
+ - Samples frames at a configurable interval using `fps=1/<interval>` and `signalstats=stat=brng`.
30
+ - Computes mean luma (YAVG) across sampled frames.
31
+ - Derives brightness offset as `clamp((target_luma - YAVG) / 255.0, -1.0, 1.0)`.
32
+ - Contrast, saturation, and gamma are left at neutral defaults (1.0 / 1.0 / 1.0);
33
+ only brightness is auto-derived from the measured luma.
34
+
35
+ ## Prerequisites
36
+
37
+ - Python 3.11 or later
38
+ - **ffmpeg must exist on PATH or full path set in environment variable `CLIPWRIGHT_FFMPEG`.**
39
+
40
+ ```bash
41
+ export CLIPWRIGHT_FFMPEG=/path/to/ffmpeg
42
+ export CLIPWRIGHT_FFPROBE=/path/to/ffprobe
43
+ ```
44
+
45
+ ## MCP Tool
46
+
47
+ `clipwright_detect_color`
48
+
49
+ ### Parameters
50
+
51
+ | Name | Type | Default | Description |
52
+ |------|------|---------|-------------|
53
+ | `media` | `string` | required | Input video file path (video stream required) |
54
+ | `output` | `string` | required | Output OTIO timeline path (`.otio`, same directory as media) |
55
+ | `options.target_luma` | `float` | `128.0` | Target average luma on the 0-255 scale (default: mid-grey) |
56
+ | `options.sample_interval_sec` | `float` | `1.0` | Frame sampling interval in seconds (ffmpeg fps=1/interval, must be > 0) |
57
+ | `timeline` | `string \| null` | `null` | Existing OTIO timeline path (if specified, append color directive to it) |
58
+
59
+ ### Return value
60
+
61
+ The tool returns a ToolResult envelope:
62
+
63
+ ```json
64
+ {
65
+ "ok": true,
66
+ "summary": "Color analysis complete. measured_luma=96.4 ...",
67
+ "data": {
68
+ "measured_luma": 96.4,
69
+ "brightness": 0.123,
70
+ "contrast": 1.0,
71
+ "saturation": 1.0,
72
+ "gamma": 1.0,
73
+ "target_luma": 128.0,
74
+ "sampled_frames": 12
75
+ },
76
+ "artifacts": [{"role": "timeline", "path": "out.otio", "format": "otio"}],
77
+ "warnings": []
78
+ }
79
+ ```
80
+
81
+ When measurement is not possible (`measured=None`, U-1), the color directive is not written
82
+ but the timeline is still saved and a warning is returned.
83
+
84
+ ## Dependencies
85
+
86
+ | Package | Purpose |
87
+ |---------|---------|
88
+ | `clipwright` | Shared types, envelope, errors, process.run |
89
+ | `mcp[cli]` | MCP server |
90
+ | `pydantic` | Parameter validation |
91
+
92
+ ffmpeg is invoked as a separate process (via PATH or environment variable) for license independence.
93
+
94
+ ## Detection and Render Two-Phase Flow
95
+
96
+ 1. **detect (this tool)**: `ffmpeg -i <media> -vf "fps=1/1,signalstats=stat=brng,metadata=print" -f null -`
97
+ extracts per-frame YAVG and saves the derived eq directive to OTIO annotation.
98
+ 2. **render (clipwright-render)**: reads `metadata["clipwright"]["color"]["eq"]` and applies
99
+ `eq=brightness=...:contrast=...:saturation=...:gamma=...` in the ffmpeg filter graph.
100
+
101
+ ## Installation and Startup
102
+
103
+ Within a uv workspace:
104
+
105
+ ```bash
106
+ uv run --package clipwright-color clipwright-color
107
+ ```
108
+
109
+ Or install directly:
110
+
111
+ ```bash
112
+ uv add clipwright-color
113
+ clipwright-color
114
+ ```
@@ -0,0 +1,101 @@
1
+ # clipwright-color
2
+
3
+ MCP tool for video brightness detection and OTIO timeline color annotation generation.
4
+
5
+ ## Overview
6
+
7
+ Measures average luma using the ffmpeg `signalstats` filter,
8
+ writes a color correction directive (brightness offset, eq parameters) to timeline-level
9
+ `metadata["clipwright"]["color"]`.
10
+
11
+ Performs detection only (OTIO annotation); realization (eq filter application) is done once
12
+ by `clipwright-render` (design M3: separation of detection and application).
13
+
14
+ **Brightness derivation**:
15
+
16
+ - Samples frames at a configurable interval using `fps=1/<interval>` and `signalstats=stat=brng`.
17
+ - Computes mean luma (YAVG) across sampled frames.
18
+ - Derives brightness offset as `clamp((target_luma - YAVG) / 255.0, -1.0, 1.0)`.
19
+ - Contrast, saturation, and gamma are left at neutral defaults (1.0 / 1.0 / 1.0);
20
+ only brightness is auto-derived from the measured luma.
21
+
22
+ ## Prerequisites
23
+
24
+ - Python 3.11 or later
25
+ - **ffmpeg must exist on PATH or full path set in environment variable `CLIPWRIGHT_FFMPEG`.**
26
+
27
+ ```bash
28
+ export CLIPWRIGHT_FFMPEG=/path/to/ffmpeg
29
+ export CLIPWRIGHT_FFPROBE=/path/to/ffprobe
30
+ ```
31
+
32
+ ## MCP Tool
33
+
34
+ `clipwright_detect_color`
35
+
36
+ ### Parameters
37
+
38
+ | Name | Type | Default | Description |
39
+ |------|------|---------|-------------|
40
+ | `media` | `string` | required | Input video file path (video stream required) |
41
+ | `output` | `string` | required | Output OTIO timeline path (`.otio`, same directory as media) |
42
+ | `options.target_luma` | `float` | `128.0` | Target average luma on the 0-255 scale (default: mid-grey) |
43
+ | `options.sample_interval_sec` | `float` | `1.0` | Frame sampling interval in seconds (ffmpeg fps=1/interval, must be > 0) |
44
+ | `timeline` | `string \| null` | `null` | Existing OTIO timeline path (if specified, append color directive to it) |
45
+
46
+ ### Return value
47
+
48
+ The tool returns a ToolResult envelope:
49
+
50
+ ```json
51
+ {
52
+ "ok": true,
53
+ "summary": "Color analysis complete. measured_luma=96.4 ...",
54
+ "data": {
55
+ "measured_luma": 96.4,
56
+ "brightness": 0.123,
57
+ "contrast": 1.0,
58
+ "saturation": 1.0,
59
+ "gamma": 1.0,
60
+ "target_luma": 128.0,
61
+ "sampled_frames": 12
62
+ },
63
+ "artifacts": [{"role": "timeline", "path": "out.otio", "format": "otio"}],
64
+ "warnings": []
65
+ }
66
+ ```
67
+
68
+ When measurement is not possible (`measured=None`, U-1), the color directive is not written
69
+ but the timeline is still saved and a warning is returned.
70
+
71
+ ## Dependencies
72
+
73
+ | Package | Purpose |
74
+ |---------|---------|
75
+ | `clipwright` | Shared types, envelope, errors, process.run |
76
+ | `mcp[cli]` | MCP server |
77
+ | `pydantic` | Parameter validation |
78
+
79
+ ffmpeg is invoked as a separate process (via PATH or environment variable) for license independence.
80
+
81
+ ## Detection and Render Two-Phase Flow
82
+
83
+ 1. **detect (this tool)**: `ffmpeg -i <media> -vf "fps=1/1,signalstats=stat=brng,metadata=print" -f null -`
84
+ extracts per-frame YAVG and saves the derived eq directive to OTIO annotation.
85
+ 2. **render (clipwright-render)**: reads `metadata["clipwright"]["color"]["eq"]` and applies
86
+ `eq=brightness=...:contrast=...:saturation=...:gamma=...` in the ffmpeg filter graph.
87
+
88
+ ## Installation and Startup
89
+
90
+ Within a uv workspace:
91
+
92
+ ```bash
93
+ uv run --package clipwright-color clipwright-color
94
+ ```
95
+
96
+ Or install directly:
97
+
98
+ ```bash
99
+ uv add clipwright-color
100
+ clipwright-color
101
+ ```
@@ -0,0 +1,76 @@
1
+ [project]
2
+ name = "clipwright-color"
3
+ version = "0.1.0"
4
+ description = "MCP tool for video brightness detection and OTIO timeline color annotation generation. Measures average luma with ffmpeg signalstats and writes an eq color-correction directive 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.2.0",
13
+ "mcp[cli]>=1.27.2",
14
+ "pydantic>=2",
15
+ ]
16
+
17
+ [project.scripts]
18
+ clipwright-color = "clipwright_color.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
+ [tool.uv.sources]
34
+ clipwright = { workspace = true }
35
+
36
+ [tool.ruff]
37
+ target-version = "py311"
38
+ line-length = 88
39
+
40
+ [tool.ruff.lint]
41
+ select = ["E", "F", "W", "I", "UP", "B", "C4", "SIM"]
42
+ ignore = []
43
+
44
+ [tool.ruff.lint.per-file-ignores]
45
+ "tests/*.py" = ["E501"]
46
+
47
+ [tool.ruff.format]
48
+
49
+ [tool.mypy]
50
+ python_version = "3.11"
51
+ strict = true
52
+ warn_return_any = true
53
+ warn_unused_configs = true
54
+ disallow_untyped_defs = true
55
+ disallow_any_generics = true
56
+
57
+ [[tool.mypy.overrides]]
58
+ module = "opentimelineio.*"
59
+ ignore_missing_imports = true
60
+
61
+ [tool.pytest.ini_options]
62
+ testpaths = ["tests"]
63
+ addopts = "--strict-markers -q"
64
+ markers = [
65
+ "integration: integration test requiring actual ffmpeg/ffprobe binaries",
66
+ "slow: test with long execution time",
67
+ "e2e: e2e test using actual ffmpeg binary",
68
+ ]
69
+
70
+ [tool.coverage.run]
71
+ source = ["clipwright_color"]
72
+ omit = ["tests/*"]
73
+
74
+ [tool.coverage.report]
75
+ show_missing = true
76
+ skip_covered = false
@@ -0,0 +1,5 @@
1
+ """clipwright-color — Video brightness detection and OTIO color annotation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.0"
@@ -0,0 +1,140 @@
1
+ """analyze.py — ffmpeg signalstats measurement for clipwright-color.
2
+
3
+ Actual ffmpeg 8.1.1 Windows output format (verified in e2e env):
4
+ metadata=print emits per sampled frame on stderr with
5
+ [Parsed_metadata_N @ addr] prefix:
6
+ [Parsed_metadata_2 @ 000002139b615380] lavfi.signalstats.YMIN=9
7
+ [Parsed_metadata_2 @ 000002139b615380] lavfi.signalstats.YAVG=125.951
8
+ [Parsed_metadata_2 @ 000002139b615380] lavfi.signalstats.YMAX=242
9
+
10
+ The parser (_parse_signalstats) accepts a single text blob so the source stream
11
+ (stderr vs stdout) is a one-line switch (ADR-CO-6). Default: result.stderr.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from clipwright.errors import ClipwrightError
21
+ from clipwright.process import resolve_tool, run
22
+ from pydantic import ValidationError
23
+
24
+ from clipwright_color.schemas import BrightnessMeasured, DetectColorOptions
25
+
26
+ # ffmpeg execution timeout (seconds).
27
+ # Measurement-only pass; same budget as loudness.
28
+ _TIMEOUT_SECONDS: float = 300.0
29
+
30
+ # Regex patterns for signalstats per-frame output lines (ADR-CO-6 / §4.2).
31
+ # The [Parsed_metadata_N @ addr] prefix is ignored; we match the key=value portion.
32
+ _YAVG_RE = re.compile(r"lavfi\.signalstats\.YAVG=(-?\d+\.?\d*)")
33
+ _YMIN_RE = re.compile(r"lavfi\.signalstats\.YMIN=(-?\d+\.?\d*)")
34
+ _YMAX_RE = re.compile(r"lavfi\.signalstats\.YMAX=(-?\d+\.?\d*)")
35
+
36
+
37
+ def _parse_signalstats(text: str) -> dict[str, Any] | None:
38
+ """Parse signalstats metadata output and return aggregated measurements.
39
+
40
+ Extracts YAVG (mean across frames), YMIN (min across frames), YMAX (max across
41
+ frames), and sampled_frames count. Returns None when no YAVG lines are found
42
+ or when BrightnessMeasured validation fails (inf/nan/out-of-range -> None, U-1).
43
+
44
+ Args:
45
+ text: Single text blob from ffmpeg signalstats output (stderr or stdout).
46
+
47
+ Returns:
48
+ Dict with yavg/ymin/ymax/sampled_frames, or None on failure.
49
+ """
50
+ yavg_vals = [float(m) for m in _YAVG_RE.findall(text)]
51
+ if not yavg_vals:
52
+ return None
53
+
54
+ yavg = sum(yavg_vals) / len(yavg_vals)
55
+ ymin_vals = [float(m) for m in _YMIN_RE.findall(text)]
56
+ ymax_vals = [float(m) for m in _YMAX_RE.findall(text)]
57
+
58
+ raw: dict[str, Any] = {
59
+ "yavg": yavg,
60
+ "ymin": min(ymin_vals) if ymin_vals else None,
61
+ "ymax": max(ymax_vals) if ymax_vals else None,
62
+ "sampled_frames": len(yavg_vals),
63
+ }
64
+
65
+ try:
66
+ validated = BrightnessMeasured(**raw)
67
+ except ValidationError:
68
+ # inf/nan or out-of-range -> degrade to None (parity with U-1)
69
+ return None
70
+
71
+ return validated.model_dump()
72
+
73
+
74
+ def measure_brightness(
75
+ media: Path,
76
+ options: DetectColorOptions,
77
+ ) -> dict[str, Any]:
78
+ """Measure video brightness using ffmpeg signalstats (architecture-report §4).
79
+
80
+ Runs ffmpeg with signalstats=stat=brng,metadata=print to extract per-frame
81
+ luma statistics. Parses YAVG (mean), YMIN (min), YMAX (max) from stderr.
82
+
83
+ Args:
84
+ media: Path to the input media file (video stream required).
85
+ options: DetectColorOptions with sample_interval_sec and target_luma.
86
+
87
+ Returns:
88
+ {
89
+ "measured": dict | None, # BrightnessMeasured fields, or None (U-1).
90
+ "warnings": list[str],
91
+ }
92
+
93
+ Raises:
94
+ clipwright.errors.ClipwrightError:
95
+ DEPENDENCY_MISSING / SUBPROCESS_FAILED / SUBPROCESS_TIMEOUT.
96
+ """
97
+ ffmpeg_bin = resolve_tool("ffmpeg", "CLIPWRIGHT_FFMPEG")
98
+
99
+ interval = options.sample_interval_sec
100
+ # -vf is a single argv element (CWE-78 / NFR-2 / ADR-CO-6).
101
+ # interval is a validated Pydantic float (gt=0) so {interval:g} cannot inject
102
+ # filtergraph syntax.
103
+ vf = f"fps=1/{interval:g},signalstats=stat=brng,metadata=print"
104
+
105
+ cmd: list[str] = [
106
+ ffmpeg_bin,
107
+ "-hide_banner",
108
+ "-i",
109
+ str(media),
110
+ "-vf",
111
+ vf,
112
+ "-f",
113
+ "null",
114
+ "-",
115
+ ]
116
+
117
+ warnings: list[str] = []
118
+
119
+ try:
120
+ result = run(cmd, timeout=_TIMEOUT_SECONDS)
121
+ except ClipwrightError as exc:
122
+ # Prevent absolute paths / raw stderr from leaking into the error message.
123
+ # Re-raise with fixed wording + from None to avoid chaining __cause__ (CWE-209).
124
+ raise ClipwrightError(
125
+ code=exc.code,
126
+ message="ffmpeg signalstats command failed.",
127
+ hint="Check the ffmpeg version and arguments.",
128
+ ) from None
129
+
130
+ # Default: read from result.stderr (ADR-CO-6; parser accepts single text blob).
131
+ measured = _parse_signalstats(result.stderr)
132
+
133
+ if measured is None:
134
+ warnings.append(
135
+ "Could not retrieve signalstats YAVG values."
136
+ " color directive will not be written (U-1)."
137
+ " ffmpeg stderr did not contain lavfi.signalstats.YAVG fields."
138
+ )
139
+
140
+ return {"measured": measured, "warnings": warnings}
@@ -0,0 +1,447 @@
1
+ """color.py — clipwright-color orchestration layer (architecture-report §5).
2
+
3
+ Flow (7 steps):
4
+ 1. Output validation (extension, parent dir, output==media,
5
+ output==timeline, same directory as media)
6
+ 2. inspect_media: require video stream (audio NOT required; FR-2 Constraint)
7
+ 3. Timeline resolution (None -> create new / path -> load + validate)
8
+ 4. measure_brightness
9
+ 5. Derive brightness offset and annotate ColorDirective
10
+ (measured=None -> skip directive + warning, U-1)
11
+ 6. save_timeline (atomic)
12
+ 7. ok_result with summary / data / artifacts
13
+
14
+ Design decisions:
15
+ - Audio NOT required (Constraint, FR-2); color requires video stream only.
16
+ - output must be in the same directory as media (DC-AS-002).
17
+ - measured=None: skip directive, still save timeline + return warning (U-1 parity).
18
+ - brightness = clamp((target_luma - yavg) / 255.0, -1.0, 1.0).
19
+ - Mirrors loudness.py helper structure (_same_path / _add_full_clip /
20
+ _load_and_validate_timeline / _check_source_within_timeline_dir).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ import opentimelineio as otio
29
+ from clipwright.envelope import error_result, ok_result
30
+ from clipwright.errors import ClipwrightError, ErrorCode
31
+ from clipwright.media import inspect_media
32
+ from clipwright.otio_utils import (
33
+ get_clipwright_metadata,
34
+ load_timeline,
35
+ new_timeline,
36
+ save_timeline,
37
+ set_clipwright_metadata,
38
+ )
39
+ from clipwright.schemas import RationalTimeModel, ToolResult
40
+ from pydantic import ValidationError
41
+
42
+ import clipwright_color
43
+ from clipwright_color.analyze import measure_brightness
44
+ from clipwright_color.schemas import (
45
+ BrightnessMeasured,
46
+ ColorDirective,
47
+ DetectColorOptions,
48
+ EqParams,
49
+ )
50
+
51
+
52
+ def detect_color(
53
+ media: str,
54
+ output: str,
55
+ options: DetectColorOptions,
56
+ timeline: str | None,
57
+ ) -> ToolResult:
58
+ """Public API for color detection. Converts ClipwrightError to ok=False envelope.
59
+
60
+ Args:
61
+ media: Input video file path (video stream required).
62
+ output: Output OTIO timeline file path (.otio, same directory as media).
63
+ options: DetectColorOptions.
64
+ timeline: Existing timeline path (None = create new).
65
+
66
+ Returns:
67
+ ok_result or error_result ToolResult.
68
+ """
69
+ try:
70
+ return _detect_color_inner(media, output, options, timeline)
71
+ except ClipwrightError as exc:
72
+ return error_result(exc.code, exc.message, exc.hint)
73
+
74
+
75
+ def _detect_color_inner(
76
+ media: str,
77
+ output: str,
78
+ options: DetectColorOptions,
79
+ timeline: str | None,
80
+ ) -> ToolResult:
81
+ """Internal implementation of detect_color. Raises ClipwrightError directly."""
82
+ media_path = Path(media)
83
+ output_path = Path(output)
84
+
85
+ # --- 1. Output validation ---
86
+
87
+ if output_path.suffix.lower() != ".otio":
88
+ raise ClipwrightError(
89
+ code=ErrorCode.INVALID_INPUT,
90
+ message="Output file must use the .otio extension.",
91
+ hint="Set the output file extension to .otio.",
92
+ )
93
+
94
+ if not output_path.parent.exists():
95
+ raise ClipwrightError(
96
+ code=ErrorCode.INVALID_INPUT,
97
+ message="output directory does not exist.",
98
+ hint="Create the output directory first, then re-run.",
99
+ )
100
+
101
+ # Prohibit output == media (non-destructive)
102
+ if _same_path(output_path, media_path):
103
+ raise ClipwrightError(
104
+ code=ErrorCode.INVALID_INPUT,
105
+ message="Output path and input media path are the same.",
106
+ hint="Change the output file path to differ from the input media.",
107
+ )
108
+
109
+ # Prohibit output == timeline (non-destructive)
110
+ if timeline is not None and _same_path(output_path, Path(timeline)):
111
+ raise ClipwrightError(
112
+ code=ErrorCode.INVALID_INPUT,
113
+ message="Output path and input timeline path are the same.",
114
+ hint="Change the output file path to differ from the input timeline.",
115
+ )
116
+
117
+ # output must be in the same directory as media (DC-AS-002)
118
+ try:
119
+ media_resolved_dir = media_path.resolve().parent
120
+ output_resolved_dir = output_path.resolve().parent
121
+ except OSError:
122
+ media_resolved_dir = media_path.absolute().parent
123
+ output_resolved_dir = output_path.absolute().parent
124
+
125
+ if media_resolved_dir != output_resolved_dir:
126
+ raise ClipwrightError(
127
+ code=ErrorCode.INVALID_INPUT,
128
+ message=(
129
+ "Output file must be placed in the same directory as the media file."
130
+ ),
131
+ hint="Change the output path to the same directory as the media file.",
132
+ )
133
+
134
+ # --- 2. inspect_media: video required, audio NOT required ---
135
+
136
+ if not media_path.exists():
137
+ raise ClipwrightError(
138
+ code=ErrorCode.FILE_NOT_FOUND,
139
+ message=f"File not found: {media_path.name}",
140
+ hint="Check that the input media file path is correct.",
141
+ )
142
+
143
+ media_info = inspect_media(media)
144
+
145
+ has_video = any(s.codec_type == "video" for s in media_info.streams)
146
+ # NOTE: no has_audio check — color does not require audio (Constraint, FR-2).
147
+
148
+ if not has_video:
149
+ raise ClipwrightError(
150
+ code=ErrorCode.UNSUPPORTED_OPERATION,
151
+ message=f"No video stream found: {media_path.name}",
152
+ hint="Provide a media file that contains a video stream.",
153
+ )
154
+
155
+ # Retrieve duration (total seconds for the full-length clip in _add_full_clip)
156
+ duration_sec: float = 0.0
157
+ if media_info.duration is not None:
158
+ duration_sec = media_info.duration.value / media_info.duration.rate
159
+
160
+ # --- 3. Timeline resolution ---
161
+
162
+ if timeline is None:
163
+ tl = new_timeline(media_path.name)
164
+ _add_full_clip(tl, media_path, duration_sec, media_info.duration)
165
+ else:
166
+ tl = _load_and_validate_timeline(
167
+ timeline, media_path, duration_sec, media_info.duration
168
+ )
169
+
170
+ # --- 4. measure_brightness ---
171
+
172
+ analysis = measure_brightness(media_path, options)
173
+ measured_raw: dict[str, Any] | None = analysis["measured"]
174
+ warnings: list[str] = list(analysis["warnings"])
175
+
176
+ # --- 5. Derive brightness offset and annotate ColorDirective ---
177
+ # (U-1: skip when measured is None)
178
+
179
+ final_luma: float | None = None
180
+ final_brightness: float | None = None
181
+ final_frames: int = 0
182
+ summary: str
183
+
184
+ if measured_raw is None:
185
+ # U-1: measurement not possible — skip directive, still save timeline
186
+ warnings.append(
187
+ "Could not retrieve brightness measurement."
188
+ " color directive will not be written (U-1)."
189
+ )
190
+ summary = (
191
+ f"Color analysis of {media_path.name} attempted but no YAVG could be"
192
+ f" measured. color directive was not written (U-1)."
193
+ )
194
+ else:
195
+ try:
196
+ measured_obj = BrightnessMeasured(**measured_raw)
197
+ except ValidationError:
198
+ # CWE-209: do not expose ValidationError details externally
199
+ raise ClipwrightError(
200
+ code=ErrorCode.INVALID_INPUT,
201
+ message="Validation of brightness measured values failed.",
202
+ hint="Check the return value of measure_brightness.",
203
+ ) from None
204
+
205
+ brightness: float = _clamp(
206
+ (options.target_luma - measured_obj.yavg) / 255.0, -1.0, 1.0
207
+ )
208
+
209
+ directive = ColorDirective(
210
+ tool="clipwright-color",
211
+ version=clipwright_color.__version__,
212
+ kind="color",
213
+ target_luma=options.target_luma,
214
+ measured=measured_obj,
215
+ eq=EqParams(brightness=brightness), # contrast/saturation/gamma neutral
216
+ )
217
+
218
+ existing_meta = get_clipwright_metadata(tl)
219
+ existing_meta["color"] = directive.model_dump()
220
+ set_clipwright_metadata(tl, existing_meta)
221
+
222
+ final_luma = measured_obj.yavg
223
+ final_brightness = brightness
224
+ final_frames = measured_obj.sampled_frames
225
+ summary = (
226
+ f"Color analysis of {media_path.name} complete."
227
+ f" measured_luma={final_luma:.1f}"
228
+ f" (over {final_frames} frame(s)),"
229
+ f" target_luma={options.target_luma:.1f},"
230
+ f" computed brightness offset={brightness:+.3f}."
231
+ f" color directive written to {output_path.name}."
232
+ )
233
+
234
+ # --- 6. save_timeline (atomic) ---
235
+
236
+ save_timeline(tl, str(output_path))
237
+
238
+ return ok_result(
239
+ summary,
240
+ data={
241
+ "measured_luma": final_luma,
242
+ "brightness": final_brightness,
243
+ "contrast": 1.0,
244
+ "saturation": 1.0,
245
+ "gamma": 1.0,
246
+ "target_luma": options.target_luma,
247
+ "sampled_frames": final_frames,
248
+ },
249
+ artifacts=[{"role": "timeline", "path": str(output_path), "format": "otio"}],
250
+ warnings=warnings,
251
+ )
252
+
253
+
254
+ def _clamp(value: float, lo: float, hi: float) -> float:
255
+ """Clamp value to [lo, hi] range."""
256
+ return max(lo, min(hi, value))
257
+
258
+
259
+ def _same_path(a: Path, b: Path) -> bool:
260
+ """Return True if both paths refer to the same entity (DC-GP-005 / B-4).
261
+
262
+ Falls back to string comparison on OSError.
263
+ """
264
+ try:
265
+ return a.resolve() == b.resolve()
266
+ except OSError:
267
+ return str(a) == str(b)
268
+
269
+
270
+ def _add_full_clip(
271
+ tl: otio.schema.Timeline,
272
+ media_path: Path,
273
+ duration_sec: float,
274
+ duration_rt: RationalTimeModel | None,
275
+ ) -> None:
276
+ """Add one full-length keep clip to V1/A1 tracks of the timeline (new creation).
277
+
278
+ target_url is set to the absolute path of media_path.resolve() (DC-AS-002).
279
+
280
+ Args:
281
+ duration_rt: Pydantic model RationalTimeModel (not OTIO RationalTime).
282
+ Used to obtain the rate. Falls back to rate=1000.0 when None.
283
+ """
284
+ try:
285
+ target_url = str(media_path.resolve())
286
+ except OSError:
287
+ target_url = str(media_path.absolute())
288
+
289
+ rate = duration_rt.rate if duration_rt is not None else 1000.0
290
+
291
+ source_range = otio.opentime.TimeRange(
292
+ start_time=otio.opentime.RationalTime(0.0, rate),
293
+ duration=otio.opentime.RationalTime(duration_sec * rate, rate),
294
+ )
295
+ ref = otio.schema.ExternalReference(target_url=target_url)
296
+
297
+ for track in tl.tracks:
298
+ clip = otio.schema.Clip(
299
+ name=media_path.name,
300
+ media_reference=ref,
301
+ source_range=source_range,
302
+ )
303
+ track.append(clip)
304
+
305
+
306
+ def _load_and_validate_timeline(
307
+ timeline_path: str,
308
+ media_path: Path,
309
+ duration_sec: float,
310
+ duration_rt: RationalTimeModel | None,
311
+ ) -> otio.schema.Timeline:
312
+ """Load an existing timeline and validate its consistency (B-4 / B-5).
313
+
314
+ Validates:
315
+ - The target_url of V1 clips matches media_path
316
+ - Single source (all clips share the same target_url)
317
+ - Exactly one Video-kind track (B-5)
318
+
319
+ If V1 is empty, adds a full-length keep clip and continues.
320
+
321
+ Raises:
322
+ ClipwrightError: INVALID_INPUT / OTIO_ERROR.
323
+ """
324
+ tl = load_timeline(timeline_path)
325
+
326
+ # Exactly one Video-kind track (B-5)
327
+ video_tracks = [t for t in tl.tracks if t.kind == otio.schema.TrackKind.Video]
328
+ if len(video_tracks) != 1:
329
+ raise ClipwrightError(
330
+ code=ErrorCode.INVALID_INPUT,
331
+ message=(
332
+ f"Invalid number of Video tracks in timeline: {len(video_tracks)}"
333
+ " (only 1 is supported)"
334
+ ),
335
+ hint="Specify a timeline with exactly one Video track.",
336
+ )
337
+
338
+ v1 = video_tracks[0]
339
+
340
+ clips = [item for item in v1 if isinstance(item, otio.schema.Clip)]
341
+
342
+ if not clips:
343
+ _add_full_clip(tl, media_path, duration_sec, duration_rt)
344
+ return tl
345
+
346
+ urls: set[str] = set()
347
+ for clip in clips:
348
+ ref = clip.media_reference
349
+ if isinstance(ref, otio.schema.ExternalReference):
350
+ urls.add(ref.target_url)
351
+
352
+ # Boundary check: target_url must be within timeline parent dir (SR L-2)
353
+ tl_path = Path(timeline_path)
354
+ for url in urls:
355
+ _check_source_within_timeline_dir(tl_path, url)
356
+
357
+ if len(urls) > 1:
358
+ raise ClipwrightError(
359
+ code=ErrorCode.UNSUPPORTED_OPERATION,
360
+ message="Timeline contains clips from multiple sources.",
361
+ hint="Specify a timeline with a single source (same media file).",
362
+ )
363
+
364
+ # Validate target_url == media_path (B-4: resolve() normalization)
365
+ if urls:
366
+ target_url = next(iter(urls))
367
+ try:
368
+ tl_source = Path(target_url).resolve()
369
+ media_resolved = media_path.resolve()
370
+ except OSError:
371
+ tl_source = Path(target_url).absolute()
372
+ media_resolved = media_path.absolute()
373
+
374
+ if tl_source != media_resolved:
375
+ raise ClipwrightError(
376
+ code=ErrorCode.INVALID_INPUT,
377
+ message=(
378
+ f"Timeline source file does not match input media."
379
+ f" timeline source: {Path(target_url).name}"
380
+ f" / media: {media_path.name}"
381
+ ),
382
+ hint=(
383
+ "Specify the same media file used when the timeline was created."
384
+ ),
385
+ )
386
+
387
+ return tl
388
+
389
+
390
+ def _check_source_within_timeline_dir(timeline_path: Path, source: str) -> None:
391
+ """Validate that the source path is within the timeline parent directory (SR L-2).
392
+
393
+ Guards against malicious OTIO embedding arbitrary paths in target_url.
394
+
395
+ Args:
396
+ timeline_path: Path to the OTIO timeline file.
397
+ source: Media source path obtained from OTIO target_url.
398
+
399
+ Raises:
400
+ ClipwrightError: PATH_NOT_ALLOWED (source outside timeline parent dir).
401
+ """
402
+ try:
403
+ allowed_base = timeline_path.parent.resolve()
404
+ source_resolved = Path(source).resolve()
405
+ source_str = str(source_resolved)
406
+ base_str = str(allowed_base)
407
+ if not (
408
+ source_str == base_str
409
+ or source_str.startswith(base_str + "/")
410
+ or source_str.startswith(base_str + "\\")
411
+ ):
412
+ raise ClipwrightError(
413
+ code=ErrorCode.PATH_NOT_ALLOWED,
414
+ message="Source file points outside the timeline directory boundary.",
415
+ hint=(
416
+ "Use a source file located within the same directory"
417
+ " as the OTIO timeline."
418
+ ),
419
+ )
420
+ except ClipwrightError:
421
+ raise
422
+ except OSError:
423
+ # Fallback to absolute() comparison when resolve() fails (e.g. broken symlink).
424
+ # Prevents silently skipping boundary validation, reducing CWE-22 risk.
425
+ try:
426
+ allowed_base_abs = str(timeline_path.parent.absolute())
427
+ source_abs = str(Path(source).absolute())
428
+ if not (
429
+ source_abs == allowed_base_abs
430
+ or source_abs.startswith(allowed_base_abs + "/")
431
+ or source_abs.startswith(allowed_base_abs + "\\")
432
+ ):
433
+ raise ClipwrightError(
434
+ code=ErrorCode.PATH_NOT_ALLOWED,
435
+ message=(
436
+ "Source file points outside the timeline directory boundary."
437
+ ),
438
+ hint=(
439
+ "Use a source file located within the same directory"
440
+ " as the OTIO timeline."
441
+ ),
442
+ )
443
+ except ClipwrightError:
444
+ raise
445
+ except OSError:
446
+ # absolute() also failed (truly unresolvable path) — best-effort skip
447
+ pass
File without changes
@@ -0,0 +1,97 @@
1
+ """schemas.py — Pydantic models for clipwright-color.
2
+
3
+ Common types (MediaRef / Artifact / ToolResult) are imported from core (clipwright)
4
+ and are never redefined here.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Annotated, Literal
10
+
11
+ from pydantic import BaseModel, Field
12
+
13
+
14
+ class DetectColorOptions(BaseModel):
15
+ """Options for clipwright_detect_color.
16
+
17
+ target_luma: target average luma (0-255 scale). Default 128 (mid-grey).
18
+ sample_interval_sec: ffmpeg fps=1/interval downsampling step (seconds, >0).
19
+ """
20
+
21
+ model_config = {"extra": "forbid", "allow_inf_nan": False}
22
+
23
+ target_luma: Annotated[
24
+ float,
25
+ Field(
26
+ default=128.0,
27
+ ge=0.0,
28
+ le=255.0,
29
+ allow_inf_nan=False,
30
+ description="Target average luma on the 0-255 scale. Default 128.",
31
+ ),
32
+ ] = 128.0
33
+
34
+ sample_interval_sec: Annotated[
35
+ float,
36
+ Field(
37
+ default=1.0,
38
+ gt=0.0,
39
+ le=3600.0,
40
+ allow_inf_nan=False,
41
+ description=(
42
+ "Frame sampling interval in seconds (ffmpeg fps=1/interval)."
43
+ " Must be > 0. Default 1.0."
44
+ ),
45
+ ),
46
+ ] = 1.0
47
+
48
+
49
+ class BrightnessMeasured(BaseModel):
50
+ """Raw signalstats measurement (writer side).
51
+
52
+ yavg is the mean of per-frame lavfi.signalstats.YAVG values.
53
+ ymin/ymax are optional aggregates (min of YMIN, max of YMAX) for diagnostics.
54
+ sampled_frames is the number of frames that produced a YAVG value.
55
+ inf/nan are rejected; the caller degrades to measured=None (parity with
56
+ loudness U-1).
57
+ """
58
+
59
+ model_config = {"extra": "forbid", "allow_inf_nan": False}
60
+
61
+ yavg: Annotated[float, Field(ge=0.0, le=255.0)]
62
+ ymin: Annotated[float, Field(ge=0.0, le=255.0)] | None = None
63
+ ymax: Annotated[float, Field(ge=0.0, le=255.0)] | None = None
64
+ sampled_frames: Annotated[int, Field(ge=0)]
65
+
66
+
67
+ class EqParams(BaseModel):
68
+ """eq filter parameters consumed by clipwright-render (writer side).
69
+
70
+ Defaults are neutral so the full eq string can always be applied
71
+ unconditionally on the render side. Ranges match ffmpeg eq filter limits.
72
+ """
73
+
74
+ model_config = {"extra": "forbid", "allow_inf_nan": False}
75
+
76
+ brightness: Annotated[float, Field(ge=-1.0, le=1.0)] = 0.0
77
+ contrast: Annotated[float, Field(ge=0.0, le=2.0)] = 1.0
78
+ saturation: Annotated[float, Field(ge=0.0, le=2.0)] = 1.0
79
+ gamma: Annotated[float, Field(ge=0.1, le=10.0)] = 1.0
80
+
81
+
82
+ class ColorDirective(BaseModel):
83
+ """Directive written to timeline metadata["clipwright"]["color"].
84
+
85
+ Generated by clipwright-color and read/validated by clipwright-render.
86
+ scope is timeline-level only (per_clip deferred). measured is optional
87
+ (None when measurement failed; parity with loudness U-1).
88
+ """
89
+
90
+ model_config = {"extra": "forbid", "allow_inf_nan": False}
91
+
92
+ tool: Annotated[str, Field(max_length=64)] = "clipwright-color"
93
+ version: Annotated[str, Field(max_length=64)]
94
+ kind: Literal["color"]
95
+ target_luma: Annotated[float, Field(ge=0.0, le=255.0, allow_inf_nan=False)]
96
+ measured: BrightnessMeasured | None = None
97
+ eq: EqParams
@@ -0,0 +1,106 @@
1
+ """server.py — clipwright-color MCP server + CLI entry point.
2
+
3
+ Thin wrapper that delegates business logic to color.py.
4
+ ClipwrightError conversion is handled in color.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
12
+
13
+ from clipwright.schemas import ToolResult
14
+ from mcp.server.fastmcp import FastMCP
15
+ from mcp.types import ToolAnnotations
16
+ from pydantic import Field
17
+
18
+ from clipwright_color.color import detect_color
19
+ from clipwright_color.schemas import DetectColorOptions
20
+
21
+ # FastMCP instance (server name)
22
+ mcp = FastMCP("clipwright-color")
23
+
24
+
25
+ # ===========================================================================
26
+ # clipwright_detect_color MCP tool
27
+ # ===========================================================================
28
+
29
+
30
+ @mcp.tool(
31
+ annotations=ToolAnnotations(
32
+ readOnlyHint=True,
33
+ destructiveHint=False,
34
+ idempotentHint=True,
35
+ openWorldHint=False,
36
+ )
37
+ )
38
+ def clipwright_detect_color(
39
+ media: Annotated[
40
+ str,
41
+ Field(description="Input video file path (video stream required)."),
42
+ ],
43
+ output: Annotated[
44
+ str,
45
+ Field(
46
+ description=(
47
+ "Output OTIO timeline file path (.otio extension)."
48
+ " Must be placed in the same directory as the media file."
49
+ )
50
+ ),
51
+ ],
52
+ options: Annotated[
53
+ DetectColorOptions | None,
54
+ Field(
55
+ description=(
56
+ "Color detection options (target_luma / sample_interval_sec)."
57
+ " Defaults to target_luma=128 / sample_interval_sec=1.0 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 color directive is appended to that timeline."
67
+ " A new timeline is created when omitted."
68
+ )
69
+ ),
70
+ ] = None,
71
+ ) -> ToolResult:
72
+ """Analyze video brightness and generate an OTIO timeline with a color directive.
73
+
74
+ The input media file is never modified (non-destructive, readOnly).
75
+ Measures average luma with ffmpeg signalstats and writes a color directive
76
+ to timeline-level metadata["clipwright"]["color"].
77
+ Returns the path of the resulting timeline.otio in artifacts.
78
+
79
+ Delegates business logic to color.detect_color.
80
+ Uses default DetectColorOptions() when options is None.
81
+ """
82
+ resolved_options = options if options is not None else DetectColorOptions()
83
+ return detect_color(
84
+ media=media,
85
+ output=output,
86
+ options=resolved_options,
87
+ timeline=timeline,
88
+ )
89
+
90
+
91
+ # ===========================================================================
92
+ # Entry point (MCP stdio launch)
93
+ # ===========================================================================
94
+
95
+
96
+ def main() -> None:
97
+ """CLI entry point. Launches the MCP server over stdio.
98
+
99
+ Registered in pyproject.toml [project.scripts] as:
100
+ clipwright-color = "clipwright_color.server:main"
101
+ """
102
+ mcp.run(transport="stdio")
103
+
104
+
105
+ if __name__ == "__main__": # pragma: no cover
106
+ main()