clipwright-stabilize 0.1.0__py3-none-any.whl
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_stabilize/__init__.py +1 -0
- clipwright_stabilize/analyze.py +207 -0
- clipwright_stabilize/schemas.py +47 -0
- clipwright_stabilize/server.py +108 -0
- clipwright_stabilize/stabilize.py +428 -0
- clipwright_stabilize-0.1.0.dist-info/METADATA +122 -0
- clipwright_stabilize-0.1.0.dist-info/RECORD +9 -0
- clipwright_stabilize-0.1.0.dist-info/WHEEL +4 -0
- clipwright_stabilize-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -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
|
|
@@ -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,9 @@
|
|
|
1
|
+
clipwright_stabilize/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
clipwright_stabilize/analyze.py,sha256=BLJlpk0E88LN1_2BeG0y4wQlC06A2JFxOM3B0rOJbZ4,7673
|
|
3
|
+
clipwright_stabilize/schemas.py,sha256=2weRIj4EQrZn3HA-vmSpc8g4lYJMv5JfR2aAZHOW3zg,1798
|
|
4
|
+
clipwright_stabilize/server.py,sha256=dJvDqoYN5j7XnFSCrflrr60tnELX5gD5EDtgb3fmzPo,3517
|
|
5
|
+
clipwright_stabilize/stabilize.py,sha256=rlUUR0At3avmUBBRQMojFpX15pEMueS8cKbHjDjXfoc,15091
|
|
6
|
+
clipwright_stabilize-0.1.0.dist-info/WHEEL,sha256=8ZlpUMJ7mlDirmlHRhDirEx_nPnARrwDjeE92mlk68E,81
|
|
7
|
+
clipwright_stabilize-0.1.0.dist-info/entry_points.txt,sha256=-4gId7RvaAwIEwxL4BJqtSJ5sKc-GbRTjvprsQeYBnE,75
|
|
8
|
+
clipwright_stabilize-0.1.0.dist-info/METADATA,sha256=Kh4StrvyfU_JWz6FxnIqnBUqzxnbDKASEjNfV5icNbs,4335
|
|
9
|
+
clipwright_stabilize-0.1.0.dist-info/RECORD,,
|