clipwright-stabilize 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,122 @@
1
+ Metadata-Version: 2.3
2
+ Name: clipwright-stabilize
3
+ Version: 0.1.0
4
+ Summary: MCP tool for video shake detection using ffmpeg vidstabdetect (requires ffmpeg with libvidstab). Generates a .trf analysis file and writes a stabilize directive to OTIO timeline metadata for use with clipwright-render.
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-stabilize
15
+
16
+ MCP tool for video shake detection and OTIO timeline stabilize annotation generation.
17
+
18
+ ## Overview
19
+
20
+ Runs ffmpeg `vidstabdetect` to generate a `.trf` transform file,
21
+ estimates shake severity from the binary TRF1 data (best-effort heuristic),
22
+ and writes a stabilize directive to timeline-level
23
+ `metadata["clipwright"]["stabilize"]`.
24
+
25
+ Performs detection only (OTIO annotation); realization (vidstabtransform application)
26
+ is done once by `clipwright-render` (design M3: separation of detection and application).
27
+
28
+ **Severity estimation**:
29
+
30
+ - Reads the binary TRF1 file produced by vidstabdetect.
31
+ - Scans all IEEE-754 little-endian doubles, computes mean absolute value.
32
+ - Normalises by a pinned heuristic constant (`_NORM_PX = 30.0 px`) to derive a
33
+ severity in `[0.0, 1.0]`.
34
+ - Returns `severity=null` when the file cannot be parsed (non-fatal; render does not
35
+ use severity).
36
+
37
+ ## Prerequisites
38
+
39
+ - Python 3.11 or later
40
+ - **ffmpeg compiled with `--enable-libvidstab` must exist on PATH or full path set**
41
+ **in environment variable `CLIPWRIGHT_FFMPEG`.**
42
+ Standard distribution builds (apt, brew, choco) may NOT include libvidstab.
43
+ Use a build that explicitly enables the vidstab filter.
44
+
45
+ ```bash
46
+ export CLIPWRIGHT_FFMPEG=/path/to/ffmpeg-with-libvidstab
47
+ export CLIPWRIGHT_FFPROBE=/path/to/ffprobe
48
+ ```
49
+
50
+ ## MCP Tool
51
+
52
+ `clipwright_detect_shake`
53
+
54
+ ### Parameters
55
+
56
+ | Name | Type | Default | Description |
57
+ |------|------|---------|-------------|
58
+ | `media` | `string` | required | Input video file path (video stream required) |
59
+ | `output` | `string` | required | Output OTIO timeline path (`.otio`, same directory as media) |
60
+ | `options.shakiness` | `int` | `5` | vidstabdetect shakiness 1-10 (higher = assume more shake) |
61
+ | `options.accuracy` | `int` | `15` | vidstabdetect accuracy 1-15 (higher = more accurate / slower) |
62
+ | `options.smoothing` | `int` | `30` | vidstabtransform smoothing window in frames 0-1000 |
63
+ | `timeline` | `string \| null` | `null` | Existing OTIO timeline path (if specified, append stabilize directive) |
64
+
65
+ ### Return value
66
+
67
+ The tool returns a ToolResult envelope:
68
+
69
+ ```json
70
+ {
71
+ "ok": true,
72
+ "summary": "Shake analysis of video.mp4 complete. severity=0.312, shakiness=5, smoothing=30. Stabilize directive and video.stabilize.trf written; apply with clipwright-render.",
73
+ "data": {
74
+ "severity": 0.312,
75
+ "shakiness": 5,
76
+ "accuracy": 15,
77
+ "smoothing": 30,
78
+ "trf_basename": "video.stabilize.trf"
79
+ },
80
+ "artifacts": [
81
+ {"role": "timeline", "path": "out.otio", "format": "otio"},
82
+ {"role": "analysis", "path": "video.stabilize.trf", "format": "trf"}
83
+ ],
84
+ "warnings": []
85
+ }
86
+ ```
87
+
88
+ When libvidstab is not compiled into the ffmpeg build, the tool returns
89
+ `UNSUPPORTED_OPERATION` with installation guidance (no path or raw stderr exposed).
90
+
91
+ ## Dependencies
92
+
93
+ | Package | Purpose |
94
+ |---------|---------|
95
+ | `clipwright` | Shared types, envelope, errors, process.run |
96
+ | `mcp[cli]` | MCP server |
97
+ | `pydantic` | Parameter validation |
98
+
99
+ ffmpeg is invoked as a separate process (via PATH or environment variable) for license independence.
100
+
101
+ ## Detection and Render Two-Phase Flow
102
+
103
+ 1. **detect (this tool)**: `ffmpeg -i <media> -vf "vidstabdetect=result=<stem>.stabilize.trf:shakiness=<n>:accuracy=<n>" -f null -`
104
+ generates `.trf` and saves the stabilize directive to OTIO annotation.
105
+ 2. **render (clipwright-render)**: reads `metadata["clipwright"]["stabilize"]` and applies
106
+ `vidstabtransform=input=<basename>:smoothing=<n>` in the ffmpeg filter graph
107
+ using `cwd=<trf parent directory>` for Windows-safe relative path resolution.
108
+
109
+ ## Installation and Startup
110
+
111
+ Within a uv workspace:
112
+
113
+ ```bash
114
+ uv run --package clipwright-stabilize clipwright-stabilize
115
+ ```
116
+
117
+ Or install directly:
118
+
119
+ ```bash
120
+ uv add clipwright-stabilize
121
+ clipwright-stabilize
122
+ ```
@@ -0,0 +1,109 @@
1
+ # clipwright-stabilize
2
+
3
+ MCP tool for video shake detection and OTIO timeline stabilize annotation generation.
4
+
5
+ ## Overview
6
+
7
+ Runs ffmpeg `vidstabdetect` to generate a `.trf` transform file,
8
+ estimates shake severity from the binary TRF1 data (best-effort heuristic),
9
+ and writes a stabilize directive to timeline-level
10
+ `metadata["clipwright"]["stabilize"]`.
11
+
12
+ Performs detection only (OTIO annotation); realization (vidstabtransform application)
13
+ is done once by `clipwright-render` (design M3: separation of detection and application).
14
+
15
+ **Severity estimation**:
16
+
17
+ - Reads the binary TRF1 file produced by vidstabdetect.
18
+ - Scans all IEEE-754 little-endian doubles, computes mean absolute value.
19
+ - Normalises by a pinned heuristic constant (`_NORM_PX = 30.0 px`) to derive a
20
+ severity in `[0.0, 1.0]`.
21
+ - Returns `severity=null` when the file cannot be parsed (non-fatal; render does not
22
+ use severity).
23
+
24
+ ## Prerequisites
25
+
26
+ - Python 3.11 or later
27
+ - **ffmpeg compiled with `--enable-libvidstab` must exist on PATH or full path set**
28
+ **in environment variable `CLIPWRIGHT_FFMPEG`.**
29
+ Standard distribution builds (apt, brew, choco) may NOT include libvidstab.
30
+ Use a build that explicitly enables the vidstab filter.
31
+
32
+ ```bash
33
+ export CLIPWRIGHT_FFMPEG=/path/to/ffmpeg-with-libvidstab
34
+ export CLIPWRIGHT_FFPROBE=/path/to/ffprobe
35
+ ```
36
+
37
+ ## MCP Tool
38
+
39
+ `clipwright_detect_shake`
40
+
41
+ ### Parameters
42
+
43
+ | Name | Type | Default | Description |
44
+ |------|------|---------|-------------|
45
+ | `media` | `string` | required | Input video file path (video stream required) |
46
+ | `output` | `string` | required | Output OTIO timeline path (`.otio`, same directory as media) |
47
+ | `options.shakiness` | `int` | `5` | vidstabdetect shakiness 1-10 (higher = assume more shake) |
48
+ | `options.accuracy` | `int` | `15` | vidstabdetect accuracy 1-15 (higher = more accurate / slower) |
49
+ | `options.smoothing` | `int` | `30` | vidstabtransform smoothing window in frames 0-1000 |
50
+ | `timeline` | `string \| null` | `null` | Existing OTIO timeline path (if specified, append stabilize directive) |
51
+
52
+ ### Return value
53
+
54
+ The tool returns a ToolResult envelope:
55
+
56
+ ```json
57
+ {
58
+ "ok": true,
59
+ "summary": "Shake analysis of video.mp4 complete. severity=0.312, shakiness=5, smoothing=30. Stabilize directive and video.stabilize.trf written; apply with clipwright-render.",
60
+ "data": {
61
+ "severity": 0.312,
62
+ "shakiness": 5,
63
+ "accuracy": 15,
64
+ "smoothing": 30,
65
+ "trf_basename": "video.stabilize.trf"
66
+ },
67
+ "artifacts": [
68
+ {"role": "timeline", "path": "out.otio", "format": "otio"},
69
+ {"role": "analysis", "path": "video.stabilize.trf", "format": "trf"}
70
+ ],
71
+ "warnings": []
72
+ }
73
+ ```
74
+
75
+ When libvidstab is not compiled into the ffmpeg build, the tool returns
76
+ `UNSUPPORTED_OPERATION` with installation guidance (no path or raw stderr exposed).
77
+
78
+ ## Dependencies
79
+
80
+ | Package | Purpose |
81
+ |---------|---------|
82
+ | `clipwright` | Shared types, envelope, errors, process.run |
83
+ | `mcp[cli]` | MCP server |
84
+ | `pydantic` | Parameter validation |
85
+
86
+ ffmpeg is invoked as a separate process (via PATH or environment variable) for license independence.
87
+
88
+ ## Detection and Render Two-Phase Flow
89
+
90
+ 1. **detect (this tool)**: `ffmpeg -i <media> -vf "vidstabdetect=result=<stem>.stabilize.trf:shakiness=<n>:accuracy=<n>" -f null -`
91
+ generates `.trf` and saves the stabilize directive to OTIO annotation.
92
+ 2. **render (clipwright-render)**: reads `metadata["clipwright"]["stabilize"]` and applies
93
+ `vidstabtransform=input=<basename>:smoothing=<n>` in the ffmpeg filter graph
94
+ using `cwd=<trf parent directory>` for Windows-safe relative path resolution.
95
+
96
+ ## Installation and Startup
97
+
98
+ Within a uv workspace:
99
+
100
+ ```bash
101
+ uv run --package clipwright-stabilize clipwright-stabilize
102
+ ```
103
+
104
+ Or install directly:
105
+
106
+ ```bash
107
+ uv add clipwright-stabilize
108
+ clipwright-stabilize
109
+ ```
@@ -0,0 +1,76 @@
1
+ [project]
2
+ name = "clipwright-stabilize"
3
+ version = "0.1.0"
4
+ description = "MCP tool for video shake detection using ffmpeg vidstabdetect (requires ffmpeg with libvidstab). Generates a .trf analysis file and writes a stabilize directive to OTIO timeline metadata for use with clipwright-render."
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-stabilize = "clipwright_stabilize.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", "I001"]
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/libvidstab",
66
+ "slow: test with long execution time",
67
+ "e2e: e2e test using actual ffmpeg binary with libvidstab",
68
+ ]
69
+
70
+ [tool.coverage.run]
71
+ source = ["clipwright_stabilize"]
72
+ omit = ["tests/*"]
73
+
74
+ [tool.coverage.report]
75
+ show_missing = true
76
+ skip_covered = false
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,207 @@
1
+ """analyze.py — ffmpeg vidstabdetect execution for clipwright-stabilize.
2
+
3
+ Runs ffmpeg with the vidstabdetect filter to generate a .trf transform file,
4
+ then estimates shake severity from the binary TRF1 file contents.
5
+
6
+ Key design decisions:
7
+ - cwd + relative basename is the only Windows-safe approach for vid.stab
8
+ result= / input= paths (P-2/P-3: Windows absolute paths are not escaped by
9
+ the filtergraph parser used by libvidstab).
10
+ - _TIMEOUT_SECONDS is pinned at 300.0 for the initial release (F-5).
11
+ - _estimate_severity is heuristic/best-effort; parse failure returns None (F-2/F-3).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import math
17
+ import re
18
+ import struct
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from clipwright.errors import ClipwrightError, ErrorCode
23
+ from clipwright.process import resolve_tool, run
24
+
25
+ from clipwright_stabilize.schemas import DetectShakeOptions
26
+
27
+ # ffmpeg execution timeout (seconds). vidstabdetect scans every frame so
28
+ # it takes longer than a measurement-only pass. Pinned for initial release (F-5).
29
+ _TIMEOUT_SECONDS: float = 300.0
30
+
31
+ # TRF1 binary file magic header.
32
+ _TRF_MAGIC = b"TRF1"
33
+
34
+ # Normalisation constant for severity estimation (heuristic / pinned — F-2/F-3).
35
+ # Mean absolute pixel displacement at or above this value maps to severity=1.0.
36
+ # Chosen based on typical camera shake magnitudes; adjust after e2e calibration.
37
+ _NORM_PX: float = 30.0
38
+
39
+ # Regex for detecting libvidstab filter absence in error messages (P-4 / §4-B).
40
+ _UNSUPPORTED_RE = re.compile(r"Unknown filter|No such filter", re.IGNORECASE)
41
+
42
+ # Maximum .trf file size accepted by _estimate_severity (OOM DoS guard — SR-MEM-001).
43
+ # Files larger than this are treated as unparseable (best-effort, return None).
44
+ _TRF_MAX_BYTES: int = 100 * 1024 * 1024 # 100 MB
45
+
46
+ # Sanitise filtergraph-unsafe characters from a trf stem (SR-INJ-002).
47
+ # vid.stab result=/input= cannot be safely escaped with backslash sequences;
48
+ # instead we replace any character that is not alphanumeric, '-', or '_' with '_'.
49
+ # An empty result falls back to "media" to guarantee a non-empty basename.
50
+ _TRF_STEM_SANITIZE_RE = re.compile(r"[^A-Za-z0-9\-_]")
51
+
52
+
53
+ def _estimate_severity(trf_path: Path) -> float | None:
54
+ """Best-effort severity in 0.0-1.0 from a binary TRF1 file.
55
+
56
+ The .trf body contains a TRF1 magic followed by IEEE-754 doubles describing
57
+ per-frame transforms (translation x/y, rotation, zoom). We scan all
58
+ little-endian doubles, take the mean absolute value of finite doubles,
59
+ and normalise by _NORM_PX (heuristic / pinned constant — F-2/F-3).
60
+ Any parse anomaly (magic mismatch, empty body, all non-finite, struct error,
61
+ OSError) returns None (render does not use severity).
62
+
63
+ Args:
64
+ trf_path: Path to the .trf binary file.
65
+
66
+ Returns:
67
+ Severity in [0.0, 1.0], or None when the file cannot be parsed.
68
+ """
69
+ try:
70
+ blob = trf_path.read_bytes()
71
+ except OSError:
72
+ return None
73
+
74
+ if len(blob) > _TRF_MAX_BYTES:
75
+ return None # best-effort; oversized .trf treated as unparseable
76
+
77
+ if not blob.startswith(_TRF_MAGIC):
78
+ return None
79
+
80
+ body = blob[len(_TRF_MAGIC) :]
81
+ n = len(body) // 8
82
+ if n == 0:
83
+ return None
84
+
85
+ try:
86
+ unpacked = struct.unpack_from(f"<{n}d", body, 0)
87
+ except struct.error:
88
+ return None
89
+
90
+ finite = [abs(v) for v in unpacked if math.isfinite(v)]
91
+ if not finite:
92
+ return None
93
+
94
+ mean_abs: float = sum(finite) / len(finite)
95
+ # Normalise mean pixel displacement to 0..1 (_NORM_PX is a pinned heuristic
96
+ # constant; see ADR-ST-3). Values above _NORM_PX clamp to 1.0.
97
+ severity: float = mean_abs / _NORM_PX
98
+ if not math.isfinite(severity):
99
+ return None
100
+
101
+ return max(0.0, min(1.0, severity))
102
+
103
+
104
+ def run_vidstabdetect(
105
+ media_path: Path,
106
+ output_path: Path,
107
+ options: DetectShakeOptions,
108
+ ) -> dict[str, Any]:
109
+ """Run ffmpeg vidstabdetect to generate a .trf file and estimate severity.
110
+
111
+ Uses cwd + relative trf basename (P-2/P-3): Windows absolute paths cannot be
112
+ safely used in vidstab filtergraph result= / input= parameters.
113
+
114
+ Args:
115
+ media_path: Input video file (absolute path).
116
+ output_path: Output .otio path; trf is written to output_path.parent.
117
+ options: DetectShakeOptions with shakiness / accuracy / smoothing.
118
+
119
+ Returns:
120
+ {
121
+ "trf_path": str, # absolute path of the generated .trf file
122
+ "severity": float | None, # 0.0-1.0 best-effort, None when unparseable
123
+ "warnings": list[str],
124
+ }
125
+
126
+ Raises:
127
+ ClipwrightError: DEPENDENCY_MISSING / UNSUPPORTED_OPERATION /
128
+ SUBPROCESS_FAILED / SUBPROCESS_TIMEOUT (sanitised messages, CWE-209).
129
+ """
130
+ ffmpeg_bin = resolve_tool("ffmpeg", "CLIPWRIGHT_FFMPEG")
131
+
132
+ trf_dir = output_path.parent
133
+ # Sanitise stem: replace filtergraph-unsafe chars with '_' (SR-INJ-002).
134
+ # cwd+relative basename approach (ADR-ST-1/P-2/P-3) is preserved.
135
+ sanitized_stem = _TRF_STEM_SANITIZE_RE.sub("_", media_path.stem) or "media"
136
+ trf_name = f"{sanitized_stem}.stabilize.trf" # relative basename (cwd-based)
137
+ trf_abs = trf_dir / trf_name
138
+
139
+ # -vf is a single argv element (CWE-78).
140
+ # shakiness / accuracy are validated int values from Pydantic — no injection risk.
141
+ vf = (
142
+ f"vidstabdetect=result={trf_name}"
143
+ f":shakiness={options.shakiness}"
144
+ f":accuracy={options.accuracy}"
145
+ )
146
+ cmd: list[str] = [
147
+ ffmpeg_bin,
148
+ "-hide_banner",
149
+ "-i",
150
+ str(media_path.resolve()), # absolute input path (cwd-independent)
151
+ "-vf",
152
+ vf,
153
+ "-f",
154
+ "null",
155
+ "-",
156
+ ]
157
+
158
+ try:
159
+ run(cmd, timeout=_TIMEOUT_SECONDS, cwd=str(trf_dir))
160
+ except ClipwrightError as exc:
161
+ # libvidstab not compiled into this ffmpeg build — stderr contains
162
+ # "Unknown filter" or "No such filter" (P-4 / §4-B).
163
+ if exc.code == ErrorCode.SUBPROCESS_FAILED and _UNSUPPORTED_RE.search(
164
+ exc.message
165
+ ):
166
+ raise ClipwrightError(
167
+ code=ErrorCode.UNSUPPORTED_OPERATION,
168
+ message=(
169
+ "This ffmpeg build does not support the vidstabdetect filter."
170
+ ),
171
+ hint=(
172
+ "Install an ffmpeg build compiled with libvidstab "
173
+ "(--enable-libvidstab), then retry."
174
+ ),
175
+ ) from None # CWE-209: cut __cause__ to avoid leaking abs paths / stderr
176
+
177
+ # All other failures (SUBPROCESS_FAILED without filter keyword,
178
+ # SUBPROCESS_TIMEOUT, DEPENDENCY_MISSING) — sanitise and re-raise.
179
+ raise ClipwrightError(
180
+ code=exc.code,
181
+ message="ffmpeg vidstabdetect command failed.",
182
+ hint="Check the ffmpeg version and that libvidstab is enabled.",
183
+ ) from None # CWE-209: cut __cause__
184
+
185
+ # Defensive check: rc=0 but .trf was not generated (frames parity — §4-D).
186
+ if not trf_abs.exists():
187
+ raise ClipwrightError(
188
+ code=ErrorCode.SUBPROCESS_FAILED,
189
+ message="ffmpeg succeeded but the .trf output file was not generated.",
190
+ hint=(
191
+ "Check that libvidstab is enabled and the output directory is writable."
192
+ ),
193
+ )
194
+
195
+ warnings: list[str] = []
196
+ severity = _estimate_severity(trf_abs)
197
+ if severity is None:
198
+ warnings.append(
199
+ "Could not estimate shake severity from the .trf file;"
200
+ " severity recorded as null."
201
+ )
202
+
203
+ return {
204
+ "trf_path": str(trf_abs.resolve()),
205
+ "severity": severity,
206
+ "warnings": warnings,
207
+ }
@@ -0,0 +1,47 @@
1
+ """schemas.py — Pydantic models for clipwright-stabilize.
2
+
3
+ Common types (MediaRef / Artifact / ToolResult) are imported from core
4
+ (clipwright) 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 DetectShakeOptions(BaseModel):
15
+ """Options for clipwright_detect_shake.
16
+
17
+ shakiness: vidstabdetect shakiness (1-10). Higher = assume more shake.
18
+ accuracy: vidstabdetect accuracy (1-15). Higher = more accurate / slower.
19
+ smoothing: vidstabtransform smoothing window (frames, 0-1000). Consumed by
20
+ clipwright-render at apply time; recorded here for round-trip.
21
+ """
22
+
23
+ model_config = {"extra": "forbid", "allow_inf_nan": False}
24
+
25
+ shakiness: Annotated[int, Field(ge=1, le=10)] = 5
26
+ accuracy: Annotated[int, Field(ge=1, le=15)] = 15
27
+ smoothing: Annotated[int, Field(ge=0, le=1000)] = 30
28
+
29
+
30
+ class StabilizeDirective(BaseModel):
31
+ """Directive written to timeline metadata["clipwright"]["stabilize"].
32
+
33
+ Generated by clipwright-stabilize and read/validated by clipwright-render.
34
+ scope is timeline-level / single-source only (per-source deferred). severity
35
+ is best-effort (None when the binary .trf could not be parsed).
36
+ """
37
+
38
+ model_config = {"extra": "forbid", "allow_inf_nan": False}
39
+
40
+ tool: Annotated[str, Field(max_length=64)] = "clipwright-stabilize"
41
+ version: Annotated[str, Field(max_length=64)]
42
+ kind: Literal["stabilize"]
43
+ trf_path: str # absolute path (record only; render splits to dir/basename)
44
+ severity: float | None = None # 0.0-1.0 best-effort, None when unparseable
45
+ shakiness: Annotated[int, Field(ge=1, le=10)]
46
+ accuracy: Annotated[int, Field(ge=1, le=15)]
47
+ smoothing: Annotated[int, Field(ge=0, le=1000)]
@@ -0,0 +1,108 @@
1
+ """server.py — clipwright-stabilize MCP server + CLI entry point.
2
+
3
+ Thin wrapper that delegates business logic to stabilize.py.
4
+ ClipwrightError conversion is handled in stabilize.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_stabilize.schemas import DetectShakeOptions
19
+ from clipwright_stabilize.stabilize import detect_shake
20
+
21
+ # FastMCP instance (server name)
22
+ mcp = FastMCP("clipwright-stabilize")
23
+
24
+
25
+ # ===========================================================================
26
+ # clipwright_detect_shake MCP tool
27
+ # ===========================================================================
28
+
29
+
30
+ @mcp.tool(
31
+ annotations=ToolAnnotations(
32
+ readOnlyHint=False, # .trf binary + .otio are generated as side-products
33
+ destructiveHint=False,
34
+ idempotentHint=True,
35
+ openWorldHint=False,
36
+ )
37
+ )
38
+ def clipwright_detect_shake(
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
+ DetectShakeOptions | None,
54
+ Field(
55
+ description=(
56
+ "Shake detection options (shakiness / accuracy / smoothing)."
57
+ " Defaults to shakiness=5 / accuracy=15 / smoothing=30 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 stabilize directive is appended to that timeline."
67
+ " A new timeline is created when omitted."
68
+ )
69
+ ),
70
+ ] = None,
71
+ ) -> ToolResult:
72
+ """Analyze video shake and generate an OTIO timeline with a stabilize directive.
73
+
74
+ The input media file is never modified (non-destructive, readOnly).
75
+ Requires an ffmpeg build compiled with --enable-libvidstab.
76
+ Runs ffmpeg vidstabdetect to generate a .trf transform file and writes a
77
+ stabilize directive to timeline-level metadata["clipwright"]["stabilize"].
78
+ Returns paths of the resulting timeline.otio and analysis.trf in artifacts.
79
+ The .trf is consumed by clipwright-render (vidstabtransform) to apply stabilization.
80
+
81
+ Delegates business logic to stabilize.detect_shake.
82
+ Uses default DetectShakeOptions() when options is None.
83
+ """
84
+ resolved_options = options if options is not None else DetectShakeOptions()
85
+ return detect_shake(
86
+ media=media,
87
+ output=output,
88
+ options=resolved_options,
89
+ timeline=timeline,
90
+ )
91
+
92
+
93
+ # ===========================================================================
94
+ # Entry point (MCP stdio launch)
95
+ # ===========================================================================
96
+
97
+
98
+ def main() -> None:
99
+ """CLI entry point. Launches the MCP server over stdio.
100
+
101
+ Registered in pyproject.toml [project.scripts] as:
102
+ clipwright-stabilize = "clipwright_stabilize.server:main"
103
+ """
104
+ mcp.run(transport="stdio")
105
+
106
+
107
+ if __name__ == "__main__": # pragma: no cover
108
+ main()
@@ -0,0 +1,428 @@
1
+ """stabilize.py — detect_shake 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)
7
+ 3. Timeline resolution (None -> create new / path -> load + validate)
8
+ 4. run_vidstabdetect (generate .trf + estimate severity)
9
+ 5. Build StabilizeDirective and annotate timeline metadata
10
+ (severity=None -> still write directive, differs from color measured=None skip)
11
+ 6. save_timeline (atomic)
12
+ 7. ok_result with summary / data / artifacts (both .otio and .trf paths verified)
13
+
14
+ Design decisions:
15
+ - Audio NOT required; shake detection requires video stream only.
16
+ - severity=None does NOT skip directive (trf was generated and can still be applied).
17
+ - output must be in the same directory as media (DC-AS-002).
18
+ - Mirrors color.py helper structure (_same_path / _add_full_clip /
19
+ _load_and_validate_timeline / _check_source_within_timeline_dir).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ import opentimelineio as otio
28
+ from clipwright.envelope import error_result, ok_result
29
+ from clipwright.errors import ClipwrightError, ErrorCode
30
+ from clipwright.media import inspect_media
31
+ from clipwright.otio_utils import (
32
+ get_clipwright_metadata,
33
+ load_timeline,
34
+ new_timeline,
35
+ save_timeline,
36
+ set_clipwright_metadata,
37
+ )
38
+ from clipwright.schemas import RationalTimeModel, ToolResult
39
+
40
+ import clipwright_stabilize
41
+ from clipwright_stabilize.analyze import run_vidstabdetect
42
+ from clipwright_stabilize.schemas import DetectShakeOptions, StabilizeDirective
43
+
44
+
45
+ def detect_shake(
46
+ media: str,
47
+ output: str,
48
+ options: DetectShakeOptions,
49
+ timeline: str | None,
50
+ ) -> ToolResult:
51
+ """Public API for shake detection. Converts ClipwrightError to ok=False envelope.
52
+
53
+ Args:
54
+ media: Input video file path (video stream required).
55
+ output: Output OTIO timeline file path (.otio, same directory as media).
56
+ options: DetectShakeOptions with shakiness / accuracy / smoothing.
57
+ timeline: Existing timeline path (None = create new).
58
+
59
+ Returns:
60
+ ok_result or error_result ToolResult.
61
+ """
62
+ try:
63
+ return _detect_shake_inner(media, output, options, timeline)
64
+ except ClipwrightError as exc:
65
+ return error_result(exc.code, exc.message, exc.hint)
66
+
67
+
68
+ def _detect_shake_inner(
69
+ media: str,
70
+ output: str,
71
+ options: DetectShakeOptions,
72
+ timeline: str | None,
73
+ ) -> ToolResult:
74
+ """Internal implementation of detect_shake. Raises ClipwrightError directly."""
75
+ media_path = Path(media)
76
+ output_path = Path(output)
77
+
78
+ # --- 1. Output validation ---
79
+
80
+ if output_path.suffix.lower() != ".otio":
81
+ raise ClipwrightError(
82
+ code=ErrorCode.INVALID_INPUT,
83
+ message="Output file must use the .otio extension.",
84
+ hint="Set the output file extension to .otio.",
85
+ )
86
+
87
+ if not output_path.parent.exists():
88
+ raise ClipwrightError(
89
+ code=ErrorCode.INVALID_INPUT,
90
+ message="output directory does not exist.",
91
+ hint="Create the output directory first, then re-run.",
92
+ )
93
+
94
+ # Prohibit output == media (non-destructive)
95
+ if _same_path(output_path, media_path):
96
+ raise ClipwrightError(
97
+ code=ErrorCode.INVALID_INPUT,
98
+ message="Output path and input media path are the same.",
99
+ hint="Change the output file path to differ from the input media.",
100
+ )
101
+
102
+ # Prohibit output == timeline (non-destructive)
103
+ if timeline is not None and _same_path(output_path, Path(timeline)):
104
+ raise ClipwrightError(
105
+ code=ErrorCode.INVALID_INPUT,
106
+ message="Output path and input timeline path are the same.",
107
+ hint="Change the output file path to differ from the input timeline.",
108
+ )
109
+
110
+ # output must be in the same directory as media (DC-AS-002)
111
+ try:
112
+ media_resolved_dir = media_path.resolve().parent
113
+ output_resolved_dir = output_path.resolve().parent
114
+ except OSError:
115
+ media_resolved_dir = media_path.absolute().parent
116
+ output_resolved_dir = output_path.absolute().parent
117
+
118
+ if media_resolved_dir != output_resolved_dir:
119
+ raise ClipwrightError(
120
+ code=ErrorCode.INVALID_INPUT,
121
+ message=(
122
+ "Output file must be placed in the same directory as the media file."
123
+ ),
124
+ hint="Change the output path to the same directory as the media file.",
125
+ )
126
+
127
+ # --- 2. inspect_media: video required, audio NOT required ---
128
+ # inspect_media raises FILE_NOT_FOUND internally (color.py parity — no pre-check).
129
+
130
+ media_info = inspect_media(media)
131
+
132
+ has_video = any(s.codec_type == "video" for s in media_info.streams)
133
+ # NOTE: no has_audio check — stabilize does not require audio.
134
+
135
+ if not has_video:
136
+ raise ClipwrightError(
137
+ code=ErrorCode.UNSUPPORTED_OPERATION,
138
+ message=f"No video stream found: {media_path.name}",
139
+ hint="Provide a media file that contains a video stream.",
140
+ )
141
+
142
+ # Retrieve duration for _add_full_clip
143
+ duration_sec: float = 0.0
144
+ if media_info.duration is not None:
145
+ duration_sec = media_info.duration.value / media_info.duration.rate
146
+
147
+ # --- 3. Timeline resolution ---
148
+
149
+ if timeline is None:
150
+ tl = new_timeline(media_path.name)
151
+ _add_full_clip(tl, media_path, duration_sec, media_info.duration)
152
+ else:
153
+ tl = _load_and_validate_timeline(
154
+ timeline, media_path, duration_sec, media_info.duration
155
+ )
156
+
157
+ # --- 4. run_vidstabdetect (generates .trf + estimates severity) ---
158
+
159
+ analysis: dict[str, Any] = run_vidstabdetect(media_path, output_path, options)
160
+ warnings: list[str] = list(analysis["warnings"])
161
+
162
+ # --- 5. Build StabilizeDirective and annotate timeline metadata ---
163
+ # severity=None is allowed — directive is always written when trf is generated.
164
+
165
+ directive = StabilizeDirective(
166
+ tool="clipwright-stabilize",
167
+ version=clipwright_stabilize.__version__,
168
+ kind="stabilize",
169
+ trf_path=str(Path(analysis["trf_path"]).resolve()),
170
+ severity=analysis["severity"],
171
+ shakiness=options.shakiness,
172
+ accuracy=options.accuracy,
173
+ smoothing=options.smoothing,
174
+ )
175
+ existing_meta = get_clipwright_metadata(tl)
176
+ existing_meta["stabilize"] = directive.model_dump()
177
+ set_clipwright_metadata(tl, existing_meta)
178
+
179
+ # --- 6. save_timeline (atomic) ---
180
+
181
+ save_timeline(tl, str(output_path))
182
+
183
+ # --- 7. ok_result: verify both paths exist, build summary and artifacts ---
184
+
185
+ trf_abs = Path(analysis["trf_path"]).resolve()
186
+ otio_abs = output_path.resolve()
187
+
188
+ # Post-save sanity check (frames parity — both artifacts must be on disk).
189
+ if not otio_abs.exists():
190
+ raise ClipwrightError(
191
+ code=ErrorCode.INTERNAL,
192
+ message="Timeline file was not created after save_timeline.",
193
+ hint="Check that the output directory is writable.",
194
+ )
195
+ if not trf_abs.exists():
196
+ raise ClipwrightError(
197
+ code=ErrorCode.SUBPROCESS_FAILED,
198
+ message="The .trf analysis file is no longer present after vidstabdetect.",
199
+ hint="Check that the output directory was not modified during analysis.",
200
+ )
201
+
202
+ trf_basename = trf_abs.name
203
+ sev = analysis["severity"]
204
+ sev_str = f"{sev:.3f}" if sev is not None else "unavailable"
205
+
206
+ summary = (
207
+ f"Shake analysis of {media_path.name} complete."
208
+ f" severity={sev_str},"
209
+ f" shakiness={options.shakiness},"
210
+ f" smoothing={options.smoothing}."
211
+ f" Stabilize directive and {trf_basename} written;"
212
+ f" apply with clipwright-render."
213
+ )
214
+
215
+ return ok_result(
216
+ summary,
217
+ data={
218
+ "severity": analysis["severity"],
219
+ "shakiness": options.shakiness,
220
+ "accuracy": options.accuracy,
221
+ "smoothing": options.smoothing,
222
+ "trf_basename": trf_basename,
223
+ },
224
+ artifacts=[
225
+ {
226
+ "role": "timeline",
227
+ "path": str(otio_abs),
228
+ "format": "otio",
229
+ },
230
+ {
231
+ "role": "analysis",
232
+ "path": str(trf_abs),
233
+ "format": "trf",
234
+ },
235
+ ],
236
+ warnings=warnings if warnings else None,
237
+ )
238
+
239
+
240
+ def _same_path(a: Path, b: Path) -> bool:
241
+ """Return True if both paths refer to the same entity (DC-GP-005 / B-4).
242
+
243
+ Falls back to string comparison on OSError.
244
+ """
245
+ try:
246
+ return a.resolve() == b.resolve()
247
+ except OSError:
248
+ return str(a) == str(b)
249
+
250
+
251
+ def _add_full_clip(
252
+ tl: otio.schema.Timeline,
253
+ media_path: Path,
254
+ duration_sec: float,
255
+ duration_rt: RationalTimeModel | None,
256
+ ) -> None:
257
+ """Add one full-length keep clip to V1/A1 tracks of the timeline (new creation).
258
+
259
+ target_url is set to the absolute path of media_path.resolve() (DC-AS-002).
260
+
261
+ Args:
262
+ duration_rt: Pydantic model RationalTimeModel (not OTIO RationalTime).
263
+ Used to obtain the rate. Falls back to rate=1000.0 when None.
264
+ """
265
+ try:
266
+ target_url = str(media_path.resolve())
267
+ except OSError:
268
+ target_url = str(media_path.absolute())
269
+
270
+ rate = duration_rt.rate if duration_rt is not None else 1000.0
271
+
272
+ source_range = otio.opentime.TimeRange(
273
+ start_time=otio.opentime.RationalTime(0.0, rate),
274
+ duration=otio.opentime.RationalTime(duration_sec * rate, rate),
275
+ )
276
+ ref = otio.schema.ExternalReference(target_url=target_url)
277
+
278
+ for track in tl.tracks:
279
+ clip = otio.schema.Clip(
280
+ name=media_path.name,
281
+ media_reference=ref,
282
+ source_range=source_range,
283
+ )
284
+ track.append(clip)
285
+
286
+
287
+ def _load_and_validate_timeline(
288
+ timeline_path: str,
289
+ media_path: Path,
290
+ duration_sec: float,
291
+ duration_rt: RationalTimeModel | None,
292
+ ) -> otio.schema.Timeline:
293
+ """Load an existing timeline and validate its consistency (B-4 / B-5).
294
+
295
+ Validates:
296
+ - The target_url of V1 clips matches media_path
297
+ - Single source (all clips share the same target_url)
298
+ - Exactly one Video-kind track (B-5)
299
+
300
+ If V1 is empty, adds a full-length keep clip and continues.
301
+
302
+ Raises:
303
+ ClipwrightError: INVALID_INPUT / OTIO_ERROR.
304
+ """
305
+ tl = load_timeline(timeline_path)
306
+
307
+ # Exactly one Video-kind track (B-5)
308
+ video_tracks = [t for t in tl.tracks if t.kind == otio.schema.TrackKind.Video]
309
+ if len(video_tracks) != 1:
310
+ raise ClipwrightError(
311
+ code=ErrorCode.INVALID_INPUT,
312
+ message=(
313
+ f"Invalid number of Video tracks in timeline: {len(video_tracks)}"
314
+ " (only 1 is supported)"
315
+ ),
316
+ hint="Specify a timeline with exactly one Video track.",
317
+ )
318
+
319
+ v1 = video_tracks[0]
320
+
321
+ clips = [item for item in v1 if isinstance(item, otio.schema.Clip)]
322
+
323
+ if not clips:
324
+ _add_full_clip(tl, media_path, duration_sec, duration_rt)
325
+ return tl
326
+
327
+ urls: set[str] = set()
328
+ for clip in clips:
329
+ ref = clip.media_reference
330
+ if isinstance(ref, otio.schema.ExternalReference):
331
+ urls.add(ref.target_url)
332
+
333
+ # Reject multi-source timelines first (UNSUPPORTED_OPERATION — color.py order).
334
+ if len(urls) > 1:
335
+ raise ClipwrightError(
336
+ code=ErrorCode.UNSUPPORTED_OPERATION,
337
+ message="Timeline contains clips from multiple sources.",
338
+ hint="Specify a timeline with a single source (same media file).",
339
+ )
340
+
341
+ # Boundary check: target_url must be within timeline parent dir (SR L-2)
342
+ tl_path = Path(timeline_path)
343
+ for url in urls:
344
+ _check_source_within_timeline_dir(tl_path, url)
345
+
346
+ # Validate target_url == media_path (B-4: resolve() normalization)
347
+ if urls:
348
+ target_url = next(iter(urls))
349
+ try:
350
+ tl_source = Path(target_url).resolve()
351
+ media_resolved = media_path.resolve()
352
+ except OSError:
353
+ tl_source = Path(target_url).absolute()
354
+ media_resolved = media_path.absolute()
355
+
356
+ if tl_source != media_resolved:
357
+ raise ClipwrightError(
358
+ code=ErrorCode.INVALID_INPUT,
359
+ message=(
360
+ f"Timeline source file does not match input media."
361
+ f" timeline source: {Path(target_url).name}"
362
+ f" / media: {media_path.name}"
363
+ ),
364
+ hint=(
365
+ "Specify the same media file used when the timeline was created."
366
+ ),
367
+ )
368
+
369
+ return tl
370
+
371
+
372
+ def _check_source_within_timeline_dir(timeline_path: Path, source: str) -> None:
373
+ """Validate that the source path is within the timeline parent directory (SR L-2).
374
+
375
+ Guards against malicious OTIO embedding arbitrary paths in target_url.
376
+
377
+ Args:
378
+ timeline_path: Path to the OTIO timeline file.
379
+ source: Media source path obtained from OTIO target_url.
380
+
381
+ Raises:
382
+ ClipwrightError: PATH_NOT_ALLOWED (source outside timeline parent dir).
383
+ """
384
+ try:
385
+ allowed_base = timeline_path.parent.resolve()
386
+ source_resolved = Path(source).resolve()
387
+ source_str = str(source_resolved)
388
+ base_str = str(allowed_base)
389
+ if not (
390
+ source_str == base_str
391
+ or source_str.startswith(base_str + "/")
392
+ or source_str.startswith(base_str + "\\")
393
+ ):
394
+ raise ClipwrightError(
395
+ code=ErrorCode.PATH_NOT_ALLOWED,
396
+ message="Source file points outside the timeline directory boundary.",
397
+ hint=(
398
+ "Use a source file located within the same directory"
399
+ " as the OTIO timeline."
400
+ ),
401
+ )
402
+ except ClipwrightError:
403
+ raise
404
+ except OSError:
405
+ # Fallback to absolute() comparison when resolve() fails (e.g. broken symlink).
406
+ try:
407
+ allowed_base_abs = str(timeline_path.parent.absolute())
408
+ source_abs = str(Path(source).absolute())
409
+ if not (
410
+ source_abs == allowed_base_abs
411
+ or source_abs.startswith(allowed_base_abs + "/")
412
+ or source_abs.startswith(allowed_base_abs + "\\")
413
+ ):
414
+ raise ClipwrightError(
415
+ code=ErrorCode.PATH_NOT_ALLOWED,
416
+ message=(
417
+ "Source file points outside the timeline directory boundary."
418
+ ),
419
+ hint=(
420
+ "Use a source file located within the same directory"
421
+ " as the OTIO timeline."
422
+ ),
423
+ )
424
+ except ClipwrightError:
425
+ raise
426
+ except OSError:
427
+ # absolute() also failed (truly unresolvable path) — best-effort skip
428
+ pass