clipwright 0.1.1__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/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """clipwright: Core library for the FFmpeg/OTIO MCP server suite."""
2
+
3
+ __version__ = "0.1.1"
clipwright/cli_io.py ADDED
@@ -0,0 +1,25 @@
1
+ """cli_io.py — Shared UTF-8 I/O helper for separate-process CLIs.
2
+
3
+ DRY home for the per-CLI UTF-8 pinning helper (CR L-2 / SR I-1). wrap_cli and
4
+ vad_cli import force_utf8_io() from here instead of carrying a local copy, so the
5
+ behaviour is defined once for every separate-process CLI that depends on core.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+
12
+
13
+ def force_utf8_io() -> None:
14
+ """Pin stdin/stdout to UTF-8 so this CLI is correct regardless of host
15
+ locale or inherited PYTHONIOENCODING (cp932 on JP Windows otherwise).
16
+
17
+ MUST be called before the first read from stdin: TextIOWrapper.reconfigure
18
+ raises once buffered reading has begun. Calling it at the very top of main()
19
+ (before sys.stdin.read()) satisfies this. Safe to call even on a CLI that
20
+ never reads stdin (reconfigure before any read is a no-op there).
21
+ """
22
+ for stream in (sys.stdin, sys.stdout):
23
+ reconfigure = getattr(stream, "reconfigure", None)
24
+ if reconfigure is not None:
25
+ reconfigure(encoding="utf-8")
clipwright/envelope.py ADDED
@@ -0,0 +1,64 @@
1
+ """envelope.py — Return value envelope construction helpers.
2
+
3
+ Thin helpers that normalise all tool return values to the standard format (§6.3 / §6.4).
4
+ Returns dict representations of ToolResult / ToolErrorResult, which are directly
5
+ compatible with FastMCP JSON serialisation.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+
13
+ def ok_result(
14
+ summary: str,
15
+ *,
16
+ data: dict[str, Any] | None = None,
17
+ artifacts: list[Any] | None = None,
18
+ warnings: list[str] | None = None,
19
+ ) -> dict[str, Any]:
20
+ """Build a success envelope dict (§6.3 ToolResult form).
21
+
22
+ summary must include key points an AI needs to decide the next action
23
+ (counts, durations, maxima, etc.). Do not make it minimal.
24
+
25
+ Args:
26
+ summary: Key points of the result (required).
27
+ data: Supplementary information (optional).
28
+ artifacts: List of references to output files (optional).
29
+ warnings: List of warning messages (optional).
30
+
31
+ Returns:
32
+ A dict of the form { ok: True, summary, data, artifacts, warnings }.
33
+ """
34
+ return {
35
+ "ok": True,
36
+ "summary": summary,
37
+ "data": data if data is not None else {},
38
+ "artifacts": artifacts if artifacts is not None else [],
39
+ "warnings": warnings if warnings is not None else [],
40
+ }
41
+
42
+
43
+ def error_result(code: str, message: str, hint: str) -> dict[str, Any]:
44
+ """Build a failure envelope dict (§6.4 ToolErrorResult form).
45
+
46
+ message describes what happened; hint describes the concrete next step.
47
+ An empty hint violates the error contract (§6).
48
+
49
+ Args:
50
+ code: String representation of an ErrorCode value.
51
+ message: What happened.
52
+ hint: Concrete, actionable next step.
53
+
54
+ Returns:
55
+ A dict of the form { ok: False, error: { code, message, hint } }.
56
+ """
57
+ return {
58
+ "ok": False,
59
+ "error": {
60
+ "code": code,
61
+ "message": message,
62
+ "hint": hint,
63
+ },
64
+ }
clipwright/errors.py ADDED
@@ -0,0 +1,62 @@
1
+ """errors.py — Error code taxonomy and ClipwrightError exception.
2
+
3
+ The library layer raises ClipwrightError on failure;
4
+ server.py converts it to error_result at the MCP boundary.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from enum import StrEnum
10
+
11
+
12
+ class ErrorCode(StrEnum):
13
+ """Error codes shared across all Clipwright tools (§4 + §13.1 DC-AM-002/DC-AS-003).
14
+
15
+ Inherits str so values are JSON-serializable at API boundaries.
16
+ Each value equals its name string.
17
+ """
18
+
19
+ DEPENDENCY_MISSING = "DEPENDENCY_MISSING"
20
+ """External tool (ffmpeg/ffprobe, etc.) not found."""
21
+ INVALID_INPUT = "INVALID_INPUT"
22
+ """Argument validation failed."""
23
+ FILE_NOT_FOUND = "FILE_NOT_FOUND"
24
+ """No file exists at the given input path."""
25
+ PATH_NOT_ALLOWED = "PATH_NOT_ALLOWED"
26
+ """Path validation failed (e.g., path traversal attempt)."""
27
+ SUBPROCESS_FAILED = "SUBPROCESS_FAILED"
28
+ """External process exited with a non-zero return code."""
29
+ SUBPROCESS_TIMEOUT = "SUBPROCESS_TIMEOUT"
30
+ """External process timed out."""
31
+ PROBE_FAILED = "PROBE_FAILED"
32
+ """Failed to parse ffprobe output."""
33
+ OTIO_ERROR = "OTIO_ERROR"
34
+ """Failed to read, write, or parse an OTIO file."""
35
+ PROJECT_NOT_FOUND = "PROJECT_NOT_FOUND"
36
+ """clipwright.json not found."""
37
+ PROJECT_EXISTS = "PROJECT_EXISTS"
38
+ """An existing project already exists at the target init location."""
39
+ UNSUPPORTED_OPERATION = "UNSUPPORTED_OPERATION"
40
+ """Unknown or unsupported operation type."""
41
+ INTERNAL = "INTERNAL"
42
+ """Unexpected internal error (§13.1 DC-AM-002).
43
+
44
+ Use a generic message; expose stack traces only in hints/logs.
45
+ The hint must include a prompt to report with reproduction steps.
46
+ """
47
+ TRACK_NOT_FOUND = "TRACK_NOT_FOUND"
48
+ """The track index in operations exceeds the total track count (§13.1 DC-AS-003)."""
49
+
50
+
51
+ class ClipwrightError(Exception):
52
+ """Exception raised by the Clipwright library layer.
53
+
54
+ Always carries the three-part set: code / message / hint (§6.4 error contract).
55
+ hint must describe the concrete next action for the user or AI agent.
56
+ """
57
+
58
+ def __init__(self, code: ErrorCode, message: str, hint: str) -> None:
59
+ super().__init__(message)
60
+ self.code = code
61
+ self.message = message
62
+ self.hint = hint
clipwright/media.py ADDED
@@ -0,0 +1,223 @@
1
+ """media.py — ffprobe wrapper.
2
+
3
+ Probes a media file with ffprobe and returns a structured MediaInfo.
4
+ Delegates ffprobe invocation to process.run, following subprocess discipline (§6.5).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from pathlib import Path
11
+
12
+ import clipwright.process as _process_module
13
+ from clipwright.errors import ClipwrightError, ErrorCode
14
+ from clipwright.schemas import MediaInfo, RationalTimeModel, StreamInfo
15
+
16
+
17
+ def inspect_media(path: str) -> MediaInfo:
18
+ """Probe a media file with ffprobe and return a MediaInfo.
19
+
20
+ Execution order: validate input → resolve ffprobe → run subprocess → parse JSON.
21
+ ffprobe is located via the CLIPWRIGHT_FFPROBE env var, then shutil.which (ADR-3).
22
+
23
+ Args:
24
+ path: Path to the media file to probe.
25
+
26
+ Returns:
27
+ Parsed MediaInfo instance.
28
+
29
+ Raises:
30
+ ClipwrightError: File not found (FILE_NOT_FOUND), ffprobe not found
31
+ (DEPENDENCY_MISSING), JSON parse failure (PROBE_FAILED),
32
+ or subprocess failure (SUBPROCESS_FAILED / SUBPROCESS_TIMEOUT).
33
+ """
34
+ _validate_existing_file(path)
35
+ ffprobe = _process_module.resolve_tool("ffprobe", "CLIPWRIGHT_FFPROBE")
36
+
37
+ cmd = [
38
+ ffprobe,
39
+ "-v",
40
+ "quiet",
41
+ "-print_format",
42
+ "json",
43
+ "-show_format",
44
+ "-show_streams",
45
+ path,
46
+ ]
47
+ result = _process_module.run(cmd, timeout=30.0)
48
+ return _parse_ffprobe_json(path, result.stdout)
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # Internal helpers
53
+ # ---------------------------------------------------------------------------
54
+
55
+
56
+ def _to_optional_int(val: object) -> int | None:
57
+ """Convert an arbitrary value to int, returning None if conversion is not possible.
58
+
59
+ Helper for safely converting field values after JSON parsing (int / float /
60
+ numeric string / None, etc.) to int (L-2: CR-Q-002 / SR-V-001).
61
+ Float strings such as "1.5" are treated as non-convertible and return None.
62
+ bool is a subclass of int, so True→1 / False→0 (existing behaviour).
63
+
64
+ Args:
65
+ val: Value to convert.
66
+
67
+ Returns:
68
+ Converted int, or None if conversion is not possible.
69
+ """
70
+ if val is None:
71
+ return None
72
+ if isinstance(val, (int, float)):
73
+ try:
74
+ return int(val)
75
+ except (ValueError, OverflowError):
76
+ return None
77
+ if isinstance(val, str):
78
+ try:
79
+ return int(val)
80
+ except ValueError:
81
+ return None
82
+ return None
83
+
84
+
85
+ def _validate_existing_file(path: str) -> None:
86
+ """Verify that a file exists at the given path.
87
+
88
+ Symbolic links are rejected (F-04: SR-V-002).
89
+ Raises FILE_NOT_FOUND if the file does not exist.
90
+ """
91
+ p = Path(path)
92
+ if p.is_symlink():
93
+ raise ClipwrightError(
94
+ code=ErrorCode.FILE_NOT_FOUND,
95
+ message=f"Symbolic links are not accepted: {path}",
96
+ hint="Specify the path to a real file, not a symbolic link.",
97
+ )
98
+ if not p.is_file():
99
+ raise ClipwrightError(
100
+ code=ErrorCode.FILE_NOT_FOUND,
101
+ message=f"File not found: {path}",
102
+ hint="Check that the path is correct and the file exists.",
103
+ )
104
+
105
+
106
+ def _parse_avg_frame_rate(avg_frame_rate: str) -> float:
107
+ """Convert an ffprobe avg_frame_rate string (e.g. "30/1", "24000/1001") to float.
108
+
109
+ Returns 0.0 for malformed input so the caller does not treat it as a video stream.
110
+ """
111
+ if "/" in avg_frame_rate:
112
+ parts = avg_frame_rate.split("/", 1)
113
+ try:
114
+ num = float(parts[0])
115
+ den = float(parts[1])
116
+ if den == 0.0:
117
+ return 0.0
118
+ return num / den
119
+ except ValueError:
120
+ return 0.0
121
+ try:
122
+ return float(avg_frame_rate)
123
+ except ValueError:
124
+ return 0.0
125
+
126
+
127
+ def _parse_ffprobe_json(path: str, stdout: str) -> MediaInfo:
128
+ """Parse ffprobe JSON output into a structured MediaInfo.
129
+
130
+ Raises PROBE_FAILED on JSON parse errors or missing required fields.
131
+ Rate determination rules (§13.3 DC-AS-006):
132
+ - If a video stream exists, use avg_frame_rate of the first video stream as rate.
133
+ - Audio-only sources use rate = 1000.0.
134
+ duration.value holds the frame count computed as seconds × rate.
135
+
136
+ Args:
137
+ path: Original input file path (stored as MediaInfo.path).
138
+ stdout: JSON string output by ffprobe.
139
+
140
+ Returns:
141
+ Parsed MediaInfo instance.
142
+
143
+ Raises:
144
+ ClipwrightError: JSON parse failure or missing required fields (PROBE_FAILED).
145
+ """
146
+ if not stdout:
147
+ raise ClipwrightError(
148
+ code=ErrorCode.PROBE_FAILED,
149
+ message="ffprobe returned empty output",
150
+ hint="Check that the input file is a valid media file.",
151
+ )
152
+
153
+ try:
154
+ data = json.loads(stdout)
155
+ except json.JSONDecodeError as exc:
156
+ # Do not expose parser-internal error strings; use a generic message (R3-L-02).
157
+ raise ClipwrightError(
158
+ code=ErrorCode.PROBE_FAILED,
159
+ message="ffprobe output is not valid JSON.",
160
+ hint="Check that the input file is a valid media file.",
161
+ ) from exc
162
+
163
+ # Verify required fields
164
+ if "streams" not in data or "format" not in data:
165
+ raise ClipwrightError(
166
+ code=ErrorCode.PROBE_FAILED,
167
+ message="ffprobe JSON is missing required fields (streams / format)",
168
+ hint="Check that the input file is a valid media file.",
169
+ )
170
+
171
+ raw_streams: list[dict[str, object]] = data["streams"]
172
+ raw_format: dict[str, object] = data["format"]
173
+
174
+ # Populate stream info
175
+ streams: list[StreamInfo] = []
176
+ for s in raw_streams:
177
+ codec_name_raw = s.get("codec_name")
178
+ index_raw = s.get("index", 0)
179
+
180
+ streams.append(
181
+ StreamInfo(
182
+ index=_to_optional_int(index_raw) or 0,
183
+ codec_type=str(s.get("codec_type", "")),
184
+ codec_name=str(codec_name_raw) if codec_name_raw is not None else None,
185
+ width=_to_optional_int(s.get("width")),
186
+ height=_to_optional_int(s.get("height")),
187
+ sample_rate=_to_optional_int(s.get("sample_rate")),
188
+ channels=_to_optional_int(s.get("channels")),
189
+ )
190
+ )
191
+
192
+ # Rate determination (§13.3 DC-AS-006): use avg_frame_rate of first video stream;
193
+ # audio-only defaults to 1000.0.
194
+ rate = 1000.0
195
+ for s in raw_streams:
196
+ if str(s.get("codec_type", "")) == "video":
197
+ avg_frame_rate_raw = s.get("avg_frame_rate", "")
198
+ if avg_frame_rate_raw:
199
+ parsed_rate = _parse_avg_frame_rate(str(avg_frame_rate_raw))
200
+ if parsed_rate > 0.0:
201
+ rate = parsed_rate
202
+ break
203
+
204
+ # Represent duration as RationalTimeModel
205
+ duration: RationalTimeModel | None = None
206
+ duration_raw = raw_format.get("duration")
207
+ if duration_raw is not None:
208
+ try:
209
+ duration_sec = float(str(duration_raw))
210
+ # value = seconds × rate (frame count equivalent)
211
+ duration = RationalTimeModel(value=duration_sec * rate, rate=rate)
212
+ except (ValueError, TypeError):
213
+ pass
214
+
215
+ container = str(raw_format.get("format_name", "")) or None
216
+
217
+ return MediaInfo(
218
+ path=path,
219
+ container=container,
220
+ duration=duration,
221
+ streams=streams,
222
+ bit_rate=_to_optional_int(raw_format.get("bit_rate")),
223
+ )
@@ -0,0 +1,165 @@
1
+ """operations.py — Declarative edit operation types and apply logic.
2
+
3
+ Operations are typed via a Pydantic discriminated union;
4
+ apply_operations applies them to the timeline in an all-or-nothing transaction.
5
+
6
+ This vocabulary forms the common interface for detect-family tools.
7
+ (Spec §4.2 dogfooding premise)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Annotated, Any, Literal
13
+
14
+ import opentimelineio as otio
15
+ from pydantic import BaseModel, Field
16
+
17
+ from clipwright.errors import ErrorCode
18
+ from clipwright.otio_utils import add_clip, add_gap, add_marker
19
+ from clipwright.schemas import (
20
+ MediaRef,
21
+ OperationError,
22
+ RationalTimeModel,
23
+ TimeRangeModel,
24
+ ValidationReport,
25
+ )
26
+
27
+ # ===========================================================================
28
+ # Operation types (discriminated union members)
29
+ # ===========================================================================
30
+
31
+
32
+ class AddClipOp(BaseModel):
33
+ """Operation to append a clip to a track.
34
+
35
+ track is a flat index (0=V1, 1=A1).
36
+ metadata uses Any because OTIO metadata is an arbitrary key-value dict.
37
+ Size and nesting limits are left for a future task.
38
+ """
39
+
40
+ op: Literal["add_clip"]
41
+ track: int = 0
42
+ media: MediaRef
43
+ source_range: TimeRangeModel
44
+ name: str | None = None
45
+ metadata: dict[str, Any] | None = None
46
+
47
+
48
+ class AddGapOp(BaseModel):
49
+ """Operation to append a gap to a track.
50
+
51
+ track is a flat index (0=V1, 1=A1).
52
+ """
53
+
54
+ op: Literal["add_gap"]
55
+ track: int = 0
56
+ duration: RationalTimeModel
57
+
58
+
59
+ class AddMarkerOp(BaseModel):
60
+ """Operation to attach a marker to the track itself (§13.5 DC-GP-001 re).
61
+
62
+ track is a flat index (0=V1, 1=A1).
63
+ No clip needs to exist; an empty track is valid.
64
+ metadata uses Any because OTIO metadata is an arbitrary key-value dict.
65
+ Size and nesting limits are left for a future task.
66
+ """
67
+
68
+ op: Literal["add_marker"]
69
+ track: int = 0
70
+ marked_range: TimeRangeModel
71
+ name: str
72
+ color: str | None = None
73
+ metadata: dict[str, Any] | None = None
74
+
75
+
76
+ # Discriminated union (discriminator="op")
77
+ Operation = Annotated[
78
+ AddClipOp | AddGapOp | AddMarkerOp,
79
+ Field(discriminator="op"),
80
+ ]
81
+
82
+ # ===========================================================================
83
+ # apply_operations — all-or-nothing (§13.1 DC-AM-004)
84
+ # ===========================================================================
85
+
86
+
87
+ def apply_operations(
88
+ timeline: otio.schema.Timeline,
89
+ ops: list[AddClipOp | AddGapOp | AddMarkerOp],
90
+ *,
91
+ validate_only: bool,
92
+ ) -> ValidationReport:
93
+ """Apply ops to timeline (all-or-nothing).
94
+
95
+ Validates all operations first. If any are invalid, nothing is applied and
96
+ ValidationReport(valid=False, applied_count=0, errors=[...]) is returned.
97
+ All operations are applied only when every one is valid; applied_count=len(ops).
98
+
99
+ validate_only=True: validates only; does not apply or save (applied_count=0).
100
+ track is resolved by flat index (0-based). Out-of-range raises TRACK_NOT_FOUND.
101
+ """
102
+ operation_count = len(ops)
103
+ errors: list[OperationError] = []
104
+ track_count = len(timeline.tracks)
105
+
106
+ # --- Validation phase ---
107
+ for i, op in enumerate(ops):
108
+ if op.track < 0 or op.track >= track_count:
109
+ errors.append(
110
+ OperationError(
111
+ index=i,
112
+ code=ErrorCode.TRACK_NOT_FOUND,
113
+ message=(
114
+ f"track {op.track} does not exist."
115
+ f" The timeline has {track_count} track(s)."
116
+ f" Specify track in the range 0..{track_count - 1}"
117
+ ),
118
+ )
119
+ )
120
+
121
+ if errors:
122
+ return ValidationReport(
123
+ valid=False,
124
+ operation_count=operation_count,
125
+ applied_count=0,
126
+ errors=errors,
127
+ )
128
+
129
+ # Return early when validate_only is set
130
+ if validate_only:
131
+ return ValidationReport(
132
+ valid=True,
133
+ operation_count=operation_count,
134
+ applied_count=0,
135
+ errors=[],
136
+ )
137
+
138
+ # --- Apply phase ---
139
+ for op in ops:
140
+ track = timeline.tracks[op.track]
141
+ if isinstance(op, AddClipOp):
142
+ add_clip(
143
+ track,
144
+ op.media,
145
+ op.source_range,
146
+ name=op.name,
147
+ metadata=op.metadata,
148
+ )
149
+ elif isinstance(op, AddGapOp):
150
+ add_gap(track, op.duration)
151
+ elif isinstance(op, AddMarkerOp):
152
+ add_marker(
153
+ track,
154
+ op.marked_range,
155
+ op.name,
156
+ color=op.color,
157
+ metadata=op.metadata,
158
+ )
159
+
160
+ return ValidationReport(
161
+ valid=True,
162
+ operation_count=operation_count,
163
+ applied_count=operation_count,
164
+ errors=[],
165
+ )