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.
- clipwright_color-0.1.0/PKG-INFO +114 -0
- clipwright_color-0.1.0/README.md +101 -0
- clipwright_color-0.1.0/pyproject.toml +76 -0
- clipwright_color-0.1.0/src/clipwright_color/__init__.py +5 -0
- clipwright_color-0.1.0/src/clipwright_color/analyze.py +140 -0
- clipwright_color-0.1.0/src/clipwright_color/color.py +447 -0
- clipwright_color-0.1.0/src/clipwright_color/py.typed +0 -0
- clipwright_color-0.1.0/src/clipwright_color/schemas.py +97 -0
- clipwright_color-0.1.0/src/clipwright_color/server.py +106 -0
|
@@ -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,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()
|