clipwright-sequence 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.
@@ -0,0 +1,3 @@
1
+ """clipwright-sequence — multi-source OTIO sequence builder MCP tool."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,141 @@
1
+ """plan.py — Pure clip-resolution logic for clipwright-sequence.
2
+
3
+ No I/O, no subprocess, no OTIO. All operations work on float-second values.
4
+
5
+ Design decisions (ADR-SEQ-3):
6
+ - Empty clips list -> INVALID_INPUT (defensive guard).
7
+ - start_sec=None defaults to 0.0; end_sec=None defaults to probe.duration_sec.
8
+ - end_sec within one frame of duration is accepted and clipped to duration
9
+ (DC-AS-003 tolerance: absorbs probe-measurement error, no warning emitted).
10
+ - start_sec >= end_sec - _EPSILON -> INVALID_INPUT (inversion check).
11
+ - Clips are returned in enumeration order with zero-based index (DC-GP-003).
12
+ - Probe keys are already resolved absolute paths (DC-AM-002 / §V2.6):
13
+ plan.py does NOT call Path().resolve().
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass
19
+
20
+ from clipwright.errors import ClipwrightError, ErrorCode
21
+
22
+ from clipwright_sequence.schemas import SequenceClip
23
+
24
+ # Floating-point epsilon for near-zero comparisons (same convention as trim/silence).
25
+ _EPSILON = 1e-9
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class SourceProbe:
30
+ """Probe result for a single source media file.
31
+
32
+ abs_path is the resolved absolute path used as the probes dict key.
33
+ rate is frames-per-second (video). Audio-only sources never reach plan.py —
34
+ they are rejected by sequence.py before resolve_clip_specs is called.
35
+ The sentinel check (rate >= 1000.0) is performed by sequence.py.
36
+ """
37
+
38
+ abs_path: str
39
+ duration_sec: float
40
+ rate: float
41
+ has_video: bool
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class ResolvedClip:
46
+ """A fully resolved clip ready for OTIO construction.
47
+
48
+ start_sec and end_sec are concrete float seconds (no None values).
49
+ index is the zero-based position in the input clips list.
50
+ """
51
+
52
+ source: str
53
+ start_sec: float
54
+ end_sec: float
55
+ rate: float
56
+ index: int
57
+
58
+
59
+ def resolve_clip_specs(
60
+ probes: dict[str, SourceProbe],
61
+ clips: list[SequenceClip],
62
+ ) -> tuple[list[ResolvedClip], list[str]]:
63
+ """Resolve SequenceClip specs against probed source metadata.
64
+
65
+ Args:
66
+ probes: Mapping from resolved absolute path to SourceProbe.
67
+ Keys must already be resolved paths (DC-AM-002 / §V2.6).
68
+ clips: Ordered list of SequenceClip specs from the MCP caller.
69
+
70
+ Returns:
71
+ A 2-tuple of (resolved_clips, warnings).
72
+ resolved_clips is in the same enumeration order as the input clips.
73
+ warnings is an empty list in v0.1.0 (no clamping produces warnings).
74
+
75
+ Raises:
76
+ ClipwrightError(INVALID_INPUT) for:
77
+ - Empty clips list (defensive guard, ADR-SEQ-3 §1).
78
+ - end_sec exceeds duration beyond one-frame tolerance (DC-AS-003).
79
+ - start_sec >= end_sec after defaulting (inversion, ADR-SEQ-3).
80
+ """
81
+ if not clips:
82
+ raise ClipwrightError(
83
+ code=ErrorCode.INVALID_INPUT,
84
+ message="No clips were provided.",
85
+ hint="Provide at least one SequenceClip in the clips list.",
86
+ )
87
+
88
+ resolved: list[ResolvedClip] = []
89
+ warnings: list[str] = []
90
+
91
+ for index, clip in enumerate(clips):
92
+ probe = probes[clip.media]
93
+ duration = probe.duration_sec
94
+ rate = probe.rate
95
+
96
+ # Default None values to full-length range.
97
+ start = clip.start_sec if clip.start_sec is not None else 0.0
98
+ end = clip.end_sec if clip.end_sec is not None else duration
99
+
100
+ # DC-AS-003 tolerance: end within one frame beyond duration is accepted
101
+ # and silently clipped to duration (no warning — this absorbs probe error).
102
+ tolerance = max(_EPSILON, 1.0 / rate)
103
+ if end > duration and end <= duration + tolerance:
104
+ end = duration
105
+
106
+ # Out-of-range check: end still exceeds duration + tolerance.
107
+ if end > duration + tolerance:
108
+ raise ClipwrightError(
109
+ code=ErrorCode.INVALID_INPUT,
110
+ message="A clip's end_sec exceeds the source duration.",
111
+ hint=(
112
+ f"Clip at index {index}: end_sec={end:.3f}s exceeds "
113
+ f"source duration={duration:.3f}s (tolerance={tolerance:.6f}s). "
114
+ "Set end_sec within the source duration, or omit end_sec."
115
+ ),
116
+ )
117
+
118
+ # Inversion check: start must be strictly less than end (gap > _EPSILON).
119
+ if start >= end - _EPSILON:
120
+ raise ClipwrightError(
121
+ code=ErrorCode.INVALID_INPUT,
122
+ message="A clip's start_sec is greater than or equal to its end_sec.",
123
+ hint=(
124
+ f"Clip at index {index}: start_sec={start:.3f}s >= "
125
+ f"end_sec={end:.3f}s — zero-length or inverted range. "
126
+ "Ensure start_sec < end_sec for every clip. "
127
+ "Omit end_sec to use the full source duration."
128
+ ),
129
+ )
130
+
131
+ resolved.append(
132
+ ResolvedClip(
133
+ source=probe.abs_path,
134
+ start_sec=start,
135
+ end_sec=end,
136
+ rate=rate,
137
+ index=index,
138
+ )
139
+ )
140
+
141
+ return resolved, warnings
File without changes
@@ -0,0 +1,56 @@
1
+ """schemas.py — Pydantic types for clipwright-sequence tool.
2
+
3
+ SequenceClip: a single clip specification within a multi-source sequence.
4
+
5
+ SequenceOptions is NOT defined in v0.1.0 (ADR-SEQ-1): the tool has no
6
+ adjustable parameters yet. Introduce SequenceOptions only when concrete
7
+ options (e.g. transition hints) are added.
8
+
9
+ Common types (MediaRef, Artifact, ToolResult) are imported from clipwright.schemas;
10
+ do NOT redefine them here.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from pydantic import BaseModel, ConfigDict, Field
16
+
17
+
18
+ class SequenceClip(BaseModel):
19
+ """A single clip specification within a multi-source sequence.
20
+
21
+ Each SequenceClip refers to a source media file and an optional
22
+ sub-range [start_sec, end_sec). Omitting start_sec defaults to 0.0;
23
+ omitting end_sec defaults to the source's full duration.
24
+
25
+ Range validity (start_sec < end_sec, end_sec <= duration) is deferred
26
+ to plan.resolve_clip_specs because duration is unknown at schema-validation
27
+ time (ADR-SEQ-1).
28
+ """
29
+
30
+ model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
31
+
32
+ media: str = Field(
33
+ max_length=4096, # OS path length upper bound
34
+ description=(
35
+ "Absolute or relative path to the source media file. "
36
+ "The path is resolved by the orchestration layer before probe."
37
+ ),
38
+ )
39
+ start_sec: float | None = Field(
40
+ default=None,
41
+ ge=0,
42
+ description=(
43
+ "Start of the clip in seconds from the beginning of the source media. "
44
+ "Must be non-negative (ge=0) when provided. "
45
+ "Defaults to 0.0 (beginning of the source) when omitted."
46
+ ),
47
+ )
48
+ end_sec: float | None = Field(
49
+ default=None,
50
+ gt=0,
51
+ description=(
52
+ "End of the clip in seconds from the beginning of the source media. "
53
+ "Must be strictly positive (gt=0) when provided. "
54
+ "Defaults to the source's full duration when omitted."
55
+ ),
56
+ )
@@ -0,0 +1,426 @@
1
+ """sequence.py — orchestration layer for clipwright-sequence.
2
+
3
+ Assembles an ordered list of SequenceClip specs into a single multi-source
4
+ OTIO timeline (V1 video track only).
5
+
6
+ Design decisions:
7
+ - build_sequence is the sole ClipwrightError -> error_result boundary (ADR-SEQ-4).
8
+ No error conversion in server.py; no I/O in plan.py.
9
+ - Fast-fail order before spawning ffprobe: empty clips, clips>1000, .otio
10
+ extension, parent dir existence (§V2.12 / trim.py L66-99 pattern).
11
+ - Unique sources are deduplicated by resolved absolute path in first-occurrence
12
+ order (§V2.6 DC-AM-002). probe is called exactly once per unique source.
13
+ - Per-source validation order: probe -> duration None -> rate sentinel ->
14
+ has_video -> co-location -> output==source (§V2.2 DC-AS-002).
15
+ - _resolve_and_check_colocation resolves the source path exactly once and
16
+ returns the resolved absolute path string, which is reused as SourceProbe.abs_path
17
+ and OTIO target_url (§V2.1 DC-AS-001; no double resolve).
18
+ - All ClipwrightError from inspect_media (including DEPENDENCY_MISSING /
19
+ SUBPROCESS_*) propagate transparently through the 2-layer boundary
20
+ (§V2.10 DC-AM-004/005).
21
+ - Error messages do not expose full paths (CWE-209).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from pathlib import Path
27
+
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 add_clip, new_timeline, save_timeline
32
+ from clipwright.schemas import MediaRef, RationalTimeModel, TimeRangeModel, ToolResult
33
+
34
+ import clipwright_sequence
35
+ from clipwright_sequence.plan import SourceProbe, resolve_clip_specs
36
+ from clipwright_sequence.schemas import SequenceClip
37
+
38
+ # Sentinel frame rate produced by media.py when avg_frame_rate is 0/0 or N/A.
39
+ # Keep in sync with clipwright.media (1000.0).
40
+ _SENTINEL_RATE = 1000.0
41
+
42
+ # Maximum number of clips accepted per call (§V2.12 DC-GP-003).
43
+ _MAX_CLIPS = 1000
44
+
45
+
46
+ def build_sequence(clips: list[SequenceClip], output: str) -> ToolResult:
47
+ """Public entry point. Sole ClipwrightError -> error_result boundary.
48
+
49
+ Assembles the ordered clips list into a single OTIO timeline written to
50
+ the output path. Non-destructive: input media files are never modified.
51
+
52
+ Args:
53
+ clips: Ordered list of SequenceClip specs to assemble.
54
+ output: Output OTIO file path (.otio extension required).
55
+
56
+ Returns:
57
+ ok_result on success; error_result on any failure.
58
+ total_duration_sec in data is the sum of input clip ranges (an estimate);
59
+ the rendered output duration may differ by a few frames after normalization.
60
+ """
61
+ try:
62
+ return _build_sequence_inner(clips, output)
63
+ except ClipwrightError as exc:
64
+ return error_result(exc.code, exc.message, exc.hint)
65
+
66
+
67
+ def _build_sequence_inner(clips: list[SequenceClip], output: str) -> ToolResult:
68
+ """Internal implementation. Raises ClipwrightError directly on any failure.
69
+
70
+ Flow:
71
+ 1. Fast-fail checks (before ffprobe): empty clips, clips>1000, .otio
72
+ extension, output parent dir existence.
73
+ 2. Per unique source (first-occurrence order, resolved-path dedup):
74
+ inspect_media -> duration None -> rate sentinel -> has_video ->
75
+ co-location -> output==source.
76
+ 3. resolve_clip_specs (pure range arithmetic / defaulting / tolerance).
77
+ 4. OTIO build: new_timeline -> V1 clips with add_clip.
78
+ 5. save_timeline (atomic write).
79
+ 6. Return ok_result envelope.
80
+ """
81
+ output_path = Path(output)
82
+
83
+ # ------------------------------------------------------------------
84
+ # 1. Fast-fail checks (before spawning ffprobe)
85
+ # ------------------------------------------------------------------
86
+
87
+ # Empty clips list
88
+ if not clips:
89
+ raise ClipwrightError(
90
+ code=ErrorCode.INVALID_INPUT,
91
+ message="No clips were provided.",
92
+ hint="Provide at least one SequenceClip in the clips list.",
93
+ )
94
+
95
+ # Clips length upper bound (§V2.12 DC-GP-003)
96
+ if len(clips) > _MAX_CLIPS:
97
+ raise ClipwrightError(
98
+ code=ErrorCode.INVALID_INPUT,
99
+ message="Too many clips were provided.",
100
+ hint=(
101
+ f"Received {len(clips)} clips; reduce to at most {_MAX_CLIPS} clips, "
102
+ "or split into multiple sequences."
103
+ ),
104
+ )
105
+
106
+ # Output extension must be .otio
107
+ if output_path.suffix.lower() != ".otio":
108
+ raise ClipwrightError(
109
+ code=ErrorCode.INVALID_INPUT,
110
+ message="Invalid output file extension. Only .otio is allowed.",
111
+ hint="Change the output file path extension to .otio.",
112
+ )
113
+
114
+ # Output parent directory must exist
115
+ if not output_path.parent.exists():
116
+ raise ClipwrightError(
117
+ code=ErrorCode.INVALID_INPUT,
118
+ message="The output directory does not exist.",
119
+ hint="Create the output directory first, then re-run.",
120
+ )
121
+
122
+ # ------------------------------------------------------------------
123
+ # 2. Per-unique-source validation and probe (first-occurrence order)
124
+ # ------------------------------------------------------------------
125
+
126
+ # Determine unique sources by resolved absolute path in first-occurrence order
127
+ # (§V2.6 DC-AM-002). All clip.media strings are pre-resolved to a temporary
128
+ # key for dedup; the canonical abs_path used as target_url comes from
129
+ # _resolve_and_check_colocation in the probe loop (DC-AS-001: single resolve).
130
+ #
131
+ # TOCTOU note: this pre-resolve and the second resolve in
132
+ # _resolve_and_check_colocation create a two-resolve window per source path.
133
+ # The window is closed by the inspect_media call (step 1 in the probe loop)
134
+ # which runs between the two resolves: inspect_media -> _validate_existing_file
135
+ # rejects symlinks and non-existent paths with FILE_NOT_FOUND, so any path
136
+ # manipulation between the two resolves is caught before the canonical abs_path
137
+ # is used as target_url (SR L-1 / DC-AS-005).
138
+ #
139
+ # pre_resolved_map: maps every clip.media string -> its temporary resolved key.
140
+ # This is used both for dedup and for mapping back after probing.
141
+ pre_resolved_map: dict[str, str] = {} # clip.media -> resolved/absolute key
142
+ seen_keys: set[str] = set()
143
+ ordered_unique_media: list[
144
+ str
145
+ ] = [] # first-occurrence clip.media for each unique source
146
+
147
+ for clip in clips:
148
+ if clip.media in pre_resolved_map:
149
+ continue # already processed this exact string
150
+ try:
151
+ key = str(Path(clip.media).resolve())
152
+ except OSError:
153
+ key = str(Path(clip.media).absolute())
154
+ pre_resolved_map[clip.media] = key
155
+ if key not in seen_keys:
156
+ seen_keys.add(key)
157
+ ordered_unique_media.append(clip.media)
158
+
159
+ # canonical_map: pre-resolved key -> canonical abs_path (set during probe loop).
160
+ # All clip.media spellings sharing the same pre-resolved key share one entry.
161
+ canonical_map: dict[str, str] = {} # pre-resolved key -> canonical abs_path
162
+
163
+ probes: dict[str, SourceProbe] = {} # keyed by canonical abs_path
164
+
165
+ for media in ordered_unique_media:
166
+ # (1) inspect_media (existence check, FILE_NOT_FOUND, DEPENDENCY_MISSING, etc.)
167
+ # FILE_NOT_FOUND is caught here to replace the core message (which may
168
+ # contain a full path from _validate_existing_file) with a basename-only
169
+ # fixed message (CWE-209 / DC-AM-004/005). All other ClipwrightError codes
170
+ # (DEPENDENCY_MISSING / SUBPROCESS_*) propagate transparently (§V2.10).
171
+ try:
172
+ info = inspect_media(media)
173
+ except ClipwrightError as exc:
174
+ if exc.code == ErrorCode.FILE_NOT_FOUND:
175
+ raise ClipwrightError(
176
+ code=ErrorCode.FILE_NOT_FOUND,
177
+ message=f"File not found: {Path(media).name}",
178
+ hint="Check that the path is correct and the file exists.",
179
+ ) from None
180
+ raise # DEPENDENCY_MISSING / SUBPROCESS_* stay transparent
181
+
182
+ # (2) duration None -> PROBE_FAILED
183
+ if info.duration is None:
184
+ raise ClipwrightError(
185
+ code=ErrorCode.PROBE_FAILED,
186
+ message=f"Could not retrieve media duration: {Path(media).name}",
187
+ hint=(
188
+ "Check that the media file is not corrupted. "
189
+ "You can also verify manually with ffprobe."
190
+ ),
191
+ )
192
+
193
+ duration_sec = info.duration.value / info.duration.rate
194
+ rate = info.duration.rate
195
+
196
+ # (3) Rate sentinel: video stream reported avg_frame_rate=0/0 or N/A
197
+ # (§V2.4 DC-AS-004). Sentinel rate means fps is undetermined.
198
+ if rate >= _SENTINEL_RATE:
199
+ raise ClipwrightError(
200
+ code=ErrorCode.INVALID_INPUT,
201
+ message=(
202
+ f"Could not determine a valid frame rate for source: "
203
+ f"{Path(media).name}"
204
+ ),
205
+ hint=(
206
+ "This source reports no usable frame rate (e.g. a still image "
207
+ "stream or an unusual capture). Provide a video file with a "
208
+ "normal frame rate."
209
+ ),
210
+ )
211
+
212
+ # (4) has_video check
213
+ has_video = any(s.codec_type == "video" for s in info.streams)
214
+ if not has_video:
215
+ raise ClipwrightError(
216
+ code=ErrorCode.INVALID_INPUT,
217
+ message=f"Source has no video stream: {Path(media).name}",
218
+ hint=(
219
+ "Provide a source file that contains a video stream. "
220
+ "Audio-only files are not supported."
221
+ ),
222
+ )
223
+
224
+ # (5) co-location check: resolve once, reuse as abs_path and target_url
225
+ # (§V2.1 DC-AS-001 / §V2.2 step 5 / ADR-SEQ-6).
226
+ abs_path = _resolve_and_check_colocation(media, output_path)
227
+
228
+ # (6) output == source -> PATH_NOT_ALLOWED (§V2.8 DC-AM-001)
229
+ _check_output_not_source(output_path, abs_path)
230
+
231
+ probes[abs_path] = SourceProbe(
232
+ abs_path=abs_path,
233
+ duration_sec=duration_sec,
234
+ rate=rate,
235
+ has_video=has_video,
236
+ )
237
+
238
+ # Record the canonical abs_path for every clip.media string that shares
239
+ # the same pre-resolved key (handles "./a.mp4" vs "a.mp4" dedup).
240
+ media_key = pre_resolved_map[media]
241
+ canonical_map[media_key] = abs_path
242
+
243
+ # Build resolved-key clips for plan.resolve_clip_specs (§V2.6):
244
+ # replace each clip.media with its canonical abs_path so that plan.py
245
+ # can look up probes[clip.media] correctly.
246
+ resolved_key_clips = [
247
+ SequenceClip(
248
+ media=canonical_map[pre_resolved_map[clip.media]],
249
+ start_sec=clip.start_sec,
250
+ end_sec=clip.end_sec,
251
+ )
252
+ for clip in clips
253
+ ]
254
+
255
+ # ------------------------------------------------------------------
256
+ # 3. Resolve clip specs (pure arithmetic; raises INVALID_INPUT on range errors)
257
+ # ------------------------------------------------------------------
258
+
259
+ resolved_clips, warnings = resolve_clip_specs(probes, resolved_key_clips)
260
+
261
+ # ------------------------------------------------------------------
262
+ # 4. Build OTIO timeline (ADR-SEQ-5)
263
+ # ------------------------------------------------------------------
264
+
265
+ timeline = new_timeline(output_path.stem)
266
+ v1 = timeline.tracks[0] # V1 (Video) track; index 0 per new_timeline
267
+
268
+ for rc in resolved_clips:
269
+ source_range = TimeRangeModel(
270
+ start_time=RationalTimeModel(value=rc.start_sec * rc.rate, rate=rc.rate),
271
+ duration=RationalTimeModel(
272
+ value=(rc.end_sec - rc.start_sec) * rc.rate, rate=rc.rate
273
+ ),
274
+ )
275
+ add_clip(
276
+ v1,
277
+ MediaRef(target_url=rc.source),
278
+ source_range,
279
+ name="sequence_clip",
280
+ metadata={
281
+ "tool": "clipwright_build_sequence",
282
+ "version": clipwright_sequence.__version__,
283
+ "kind": "sequence_clip",
284
+ "index": rc.index,
285
+ },
286
+ )
287
+
288
+ # ------------------------------------------------------------------
289
+ # 5. Save timeline (atomic write)
290
+ # ------------------------------------------------------------------
291
+
292
+ save_timeline(timeline, output)
293
+
294
+ # ------------------------------------------------------------------
295
+ # 6. Build and return ok_result envelope (ADR-SEQ-7)
296
+ # ------------------------------------------------------------------
297
+
298
+ clip_count = len(resolved_clips)
299
+ total_duration_sec = sum(rc.end_sec - rc.start_sec for rc in resolved_clips)
300
+ unique_source_count = len(probes)
301
+
302
+ summary = (
303
+ f"Assembled a {clip_count}-clip sequence "
304
+ f"(approx total {total_duration_sec:.1f}s) "
305
+ f"from {unique_source_count} source(s). "
306
+ f"Generated {output_path.name}. "
307
+ f"Pass it to clipwright-render to concatenate into a single video."
308
+ )
309
+
310
+ return ok_result(
311
+ summary,
312
+ data={
313
+ "clip_count": clip_count,
314
+ "total_duration_sec": total_duration_sec,
315
+ "unique_source_count": unique_source_count,
316
+ },
317
+ artifacts=[{"role": "timeline", "path": str(output), "format": "otio"}],
318
+ warnings=warnings,
319
+ )
320
+
321
+
322
+ def _resolve_and_check_colocation(media: str, output_path: Path) -> str:
323
+ """Resolve the source path once and verify co-location against output directory.
324
+
325
+ Resolves the source path exactly once and returns the resolved absolute path
326
+ string. The return value is reused as SourceProbe.abs_path and the OTIO
327
+ target_url (DC-AS-001: no double resolve).
328
+
329
+ Mirrors render._check_within_timeline_dir (keep in sync).
330
+ Allows recursive subdirectories; raises PATH_NOT_ALLOWED only when the
331
+ source points outside the output parent directory tree (ADR-SEQ-6).
332
+ Falls back to absolute()-based comparison when resolve() raises OSError
333
+ (§V2.11 DC-GP-002 / SR L-1).
334
+
335
+ Error message uses fixed wording without exposing the full path (CWE-209).
336
+
337
+ Args:
338
+ media: Original source media path string.
339
+ output_path: Output OTIO file path (its parent is the project boundary).
340
+
341
+ Returns:
342
+ Resolved (or absolute, as fallback) absolute path string.
343
+
344
+ Raises:
345
+ ClipwrightError: PATH_NOT_ALLOWED when source is outside the project tree.
346
+ """
347
+ try:
348
+ base = output_path.parent.resolve()
349
+ target = Path(media).resolve()
350
+ base_str = str(base)
351
+ target_str = str(target)
352
+ if not (
353
+ target_str == base_str
354
+ or target_str.startswith(base_str + "/")
355
+ or target_str.startswith(base_str + "\\")
356
+ ):
357
+ raise ClipwrightError(
358
+ code=ErrorCode.PATH_NOT_ALLOWED,
359
+ message="Source file points outside the project boundary.",
360
+ hint=(
361
+ "Use a source file located under the same directory"
362
+ " as the OTIO timeline."
363
+ ),
364
+ )
365
+ return target_str
366
+ except ClipwrightError:
367
+ raise
368
+ except OSError:
369
+ # resolve() failure (network paths, extremely long paths, symlink loops):
370
+ # fall back to absolute()-based best-effort comparison (§V2.11 DC-GP-002).
371
+ try:
372
+ base_abs = str(output_path.parent.absolute())
373
+ target_abs = str(Path(media).absolute())
374
+ if not (
375
+ target_abs == base_abs
376
+ or target_abs.startswith(base_abs + "/")
377
+ or target_abs.startswith(base_abs + "\\")
378
+ ):
379
+ raise ClipwrightError(
380
+ code=ErrorCode.PATH_NOT_ALLOWED,
381
+ message="Source file points outside the project boundary.",
382
+ hint=(
383
+ "Use a source file located under the same directory"
384
+ " as the OTIO timeline."
385
+ ),
386
+ )
387
+ return target_abs
388
+ except ClipwrightError:
389
+ raise
390
+ except OSError:
391
+ # Truly unresolvable (e.g. extremely long path, network path, or symlink
392
+ # loop where both resolve() and absolute() fail): accept the raw absolute
393
+ # path and skip boundary comparison. This is an intentional best-effort
394
+ # boundary-skip, consistent with the render/color/loudness precedent
395
+ # (SR L-2 / §V2.11 DC-GP-002). Only triggers under extreme path conditions
396
+ # not present in normal operation; real existence is verified upstream by
397
+ # inspect_media.
398
+ return str(Path(media).absolute())
399
+
400
+
401
+ def _check_output_not_source(output_path: Path, abs_source: str) -> None:
402
+ """Verify that output and source do not resolve to the same path (§V2.8 DC-AM-001).
403
+
404
+ abs_source is already a resolved (or absolute-fallback) path from
405
+ _resolve_and_check_colocation, so only the output needs resolving.
406
+
407
+ Raises:
408
+ ClipwrightError: PATH_NOT_ALLOWED when paths are equal.
409
+ """
410
+ try:
411
+ out_resolved = str(output_path.resolve())
412
+ except OSError:
413
+ try:
414
+ out_resolved = str(output_path.absolute())
415
+ except OSError:
416
+ out_resolved = str(output_path)
417
+
418
+ if out_resolved == abs_source:
419
+ raise ClipwrightError(
420
+ code=ErrorCode.PATH_NOT_ALLOWED,
421
+ message="Output path and input source path are the same.",
422
+ hint=(
423
+ "Change the output file path to be different from the"
424
+ " input source file."
425
+ ),
426
+ )
@@ -0,0 +1,90 @@
1
+ """server.py — clipwright-sequence MCP server entry point.
2
+
3
+ Thin wrapper that delegates all business logic to sequence.build_sequence.
4
+ No error conversion is performed here; build_sequence is the sole boundary.
5
+
6
+ Transport: 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_sequence.schemas import SequenceClip
19
+ from clipwright_sequence.sequence import build_sequence
20
+
21
+ # FastMCP instance (server name matches package name)
22
+ mcp = FastMCP("clipwright-sequence")
23
+
24
+
25
+ # ===========================================================================
26
+ # clipwright_build_sequence MCP tool
27
+ # ===========================================================================
28
+
29
+
30
+ @mcp.tool(
31
+ annotations=ToolAnnotations(
32
+ # readOnlyHint=True: OTIO-only output; input media unchanged.
33
+ # Convention for non-render tools (render uses readOnlyHint=False).
34
+ readOnlyHint=True,
35
+ destructiveHint=False,
36
+ idempotentHint=True,
37
+ openWorldHint=False,
38
+ )
39
+ )
40
+ def clipwright_build_sequence(
41
+ clips: Annotated[
42
+ list[SequenceClip],
43
+ Field(
44
+ description=(
45
+ "Ordered list of clip specifications to assemble into a sequence. "
46
+ "Each entry identifies a source media file and an optional sub-range. "
47
+ "Maximum 1000 clips per call (DC-GP-003)."
48
+ ),
49
+ max_length=1000,
50
+ ),
51
+ ],
52
+ output: Annotated[
53
+ str,
54
+ Field(
55
+ description=(
56
+ "Output OTIO timeline file path (.otio extension required). "
57
+ "Must be in the same directory as the source media files. "
58
+ "The file is created or overwritten atomically."
59
+ )
60
+ ),
61
+ ],
62
+ ) -> ToolResult:
63
+ """MCP tool: assemble an ordered list of clips into a single-track OTIO timeline.
64
+
65
+ Non-destructive: does not modify the input media files.
66
+ Produces a single V1 video track OTIO timeline consumed by clipwright-render.
67
+ total_duration_sec in the result data is an approx estimate based on input
68
+ clip ranges; the rendered output duration may differ after normalization.
69
+ Symlink sources are unsupported; resolve symlinks before passing to this tool.
70
+ Delegates all logic to sequence.build_sequence.
71
+ """
72
+ return build_sequence(clips=clips, output=output)
73
+
74
+
75
+ # ===========================================================================
76
+ # Entry point (MCP stdio launch)
77
+ # ===========================================================================
78
+
79
+
80
+ def main() -> None:
81
+ """CLI entry point. Launches the MCP server over stdio.
82
+
83
+ Registered in pyproject.toml [project.scripts] as:
84
+ clipwright-sequence = "clipwright_sequence.server:main"
85
+ """
86
+ mcp.run(transport="stdio")
87
+
88
+
89
+ if __name__ == "__main__": # pragma: no cover
90
+ main()
@@ -0,0 +1,232 @@
1
+ Metadata-Version: 2.3
2
+ Name: clipwright-sequence
3
+ Version: 0.1.0
4
+ Summary: MCP tool for assembling multiple source media files into a single OTIO timeline (multi-source sequence builder).
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: opentimelineio>=0.18
11
+ Requires-Dist: pydantic>=2
12
+ Requires-Python: >=3.11
13
+ Description-Content-Type: text/markdown
14
+
15
+ # clipwright-sequence
16
+
17
+ MCP tool that assembles multiple source media files into a single multi-source OTIO
18
+ timeline for concatenation by `clipwright-render`.
19
+
20
+ ## Overview
21
+
22
+ `clipwright-sequence` accepts an ordered list of clip specifications and emits a single
23
+ OTIO timeline file (one V1 video track; A1 audio track left empty for BGM or mixing
24
+ by the calling agent). The timeline is consumed unchanged by `clipwright-render`, which
25
+ concatenates the clips into a single output video.
26
+
27
+ This fills the gap between single-source tools and multi-clip programs: every other
28
+ clipwright tool starts from one `media` file. `clipwright-sequence` is the authoring
29
+ primitive that builds a multi-source program OTIO before the final render pass.
30
+
31
+ **Design principle** (M3 — separation of annotation and realisation):
32
+ `clipwright-sequence` writes only the OTIO; it does not transcode or touch media files.
33
+ All media processing is deferred to `clipwright-render`.
34
+
35
+ ## Prerequisites
36
+
37
+ - Python 3.11 or later
38
+ - `clipwright` core package (shared types, envelope, OTIO utils)
39
+ - `CLIPWRIGHT_FFPROBE` environment variable (or `ffprobe` on `PATH`) — used to probe
40
+ each source's duration and confirm video stream presence before building the timeline
41
+
42
+ No FFmpeg is needed at sequence-build time; FFmpeg is only invoked by `clipwright-render`
43
+ at realisation time.
44
+
45
+ ## MCP Tool: `clipwright_build_sequence`
46
+
47
+ ### Parameters
48
+
49
+ | Name | Type | Default | Description |
50
+ |------|------|---------|-------------|
51
+ | `clips` | `list[SequenceClip]` | required | Ordered list of clip specifications to assemble. Maximum 1000 entries per call (DC-GP-003). |
52
+ | `clips[].media` | `string` | required | Path to the source media file. Must be co-located under the output directory (see Co-location Constraint). |
53
+ | `clips[].start_sec` | `float \| null` | `null` → `0.0` | Start of the clip in seconds from the beginning of the source. Must be ≥ 0. |
54
+ | `clips[].end_sec` | `float \| null` | `null` → full duration | End of the clip in seconds from the beginning of the source. Must be > 0 and ≤ source duration. |
55
+ | `output` | `string` | required | Output OTIO timeline file path (`.otio` extension required). The parent directory must exist. |
56
+
57
+ ### Return value
58
+
59
+ ```json
60
+ {
61
+ "ok": true,
62
+ "summary": "Assembled a 3-clip sequence (approx total 45.2s) from 2 source(s). Generated sequence.otio. Pass it to clipwright-render to concatenate into a single video.",
63
+ "data": {
64
+ "clip_count": 3,
65
+ "total_duration_sec": 45.2,
66
+ "unique_source_count": 2
67
+ },
68
+ "artifacts": [{"role": "timeline", "path": "sequence.otio", "format": "otio"}],
69
+ "warnings": []
70
+ }
71
+ ```
72
+
73
+ > **Note on `total_duration_sec`**: This value is an approximate estimate computed as
74
+ > the sum of the input clip ranges (DC-AM-003). The rendered output duration may differ
75
+ > slightly after per-frame normalisation in `clipwright-render`.
76
+
77
+ ### Error codes
78
+
79
+ | Code | Cause |
80
+ |------|-------|
81
+ | `INVALID_INPUT` | `clips` is empty, exceeds 1000 entries, contains an invalid range (`start_sec >= end_sec`, `end_sec > source duration`), or `output` has a non-`.otio` extension |
82
+ | `INVALID_INPUT` | `output` parent directory does not exist |
83
+ | `INVALID_INPUT` | A source file has no video stream (audio-only file) |
84
+ | `INVALID_INPUT` | A source file's frame rate is undetermined (e.g. still-image stream or unusual capture device) |
85
+ | `FILE_NOT_FOUND` | A source media path does not exist |
86
+ | `PATH_NOT_ALLOWED` | A source file is located outside the output directory tree (co-location violation) |
87
+ | `PATH_NOT_ALLOWED` | `output` path and a source media path resolve to the same file |
88
+ | `PROBE_FAILED` | ffprobe could not determine a source's duration (corrupted file) |
89
+ | `DEPENDENCY_MISSING` | ffprobe binary not found (`CLIPWRIGHT_FFPROBE` unset and not on `PATH`) |
90
+
91
+ ## Co-location Constraint
92
+
93
+ All source media files **must be located under the same directory as the output `.otio`
94
+ file** (or in a recursive subdirectory of it).
95
+
96
+ **Why this mirrors `clipwright-render`'s rule — not a relaxation of it:**
97
+ `clipwright-render` enforces a `PATH_NOT_ALLOWED` boundary: every source referenced in
98
+ an OTIO timeline must be co-located with the timeline file. If `clipwright-sequence`
99
+ were to accept sources from outside that boundary, the produced `.otio` would be valid
100
+ at build time but would fail immediately when passed to `clipwright-render`.
101
+
102
+ By enforcing the same co-location rule at sequence-build time, `clipwright-sequence`
103
+ guarantees that any `.otio` it produces will round-trip through `clipwright-render`
104
+ without a `PATH_NOT_ALLOWED` error. The constraint is a forward-compatibility guarantee,
105
+ not an arbitrary restriction.
106
+
107
+ Recursive subdirectories are permitted: sources may live anywhere inside the tree rooted
108
+ at the output's parent directory.
109
+
110
+ ```
111
+ project/
112
+ intro.mp4 ← allowed (same directory)
113
+ footage/
114
+ main.mp4 ← allowed (subdirectory)
115
+ broll.mp4 ← allowed (subdirectory)
116
+ sequence.otio ← output
117
+ ```
118
+
119
+ ```
120
+ /other/path/clip.mp4 ← PATH_NOT_ALLOWED
121
+ ```
122
+
123
+ ## Symlink Sources
124
+
125
+ Symlink sources are **not supported** (DC-AS-005). Resolve symlinks to their real paths
126
+ before passing them to this tool.
127
+
128
+ ## Two-Phase Workflow
129
+
130
+ ```
131
+ clipwright_build_sequence(clips, output) # Phase 1 — build OTIO
132
+
133
+ ▼ OTIO timeline with ordered V1 clips
134
+ clipwright_render(timeline, output_media) # Phase 2 — concatenate and encode
135
+
136
+ ▼ single output video (intro + main + outro, or any sequence)
137
+ ```
138
+
139
+ `clipwright-sequence` can be combined with other directive tools before the final render:
140
+
141
+ ```
142
+ clipwright_build_sequence → clipwright_detect_color → clipwright_reduce_noise → clipwright_render
143
+ ```
144
+
145
+ ## MCP Client Registration
146
+
147
+ Register `clipwright-sequence` as a standalone MCP server in your client configuration
148
+ (`.mcp.json` / `claude_desktop_config.json`). `CLIPWRIGHT_FFPROBE` is required.
149
+
150
+ ```json
151
+ {
152
+ "mcpServers": {
153
+ "clipwright-sequence": {
154
+ "command": "clipwright-sequence",
155
+ "env": {
156
+ "CLIPWRIGHT_FFPROBE": "/path/to/ffprobe"
157
+ }
158
+ }
159
+ }
160
+ }
161
+ ```
162
+
163
+ `clipwright-render` (which materialises the OTIO into a video) still requires
164
+ `CLIPWRIGHT_FFMPEG`.
165
+
166
+ ## Usage Examples
167
+
168
+ ### Assemble intro + main + outro
169
+
170
+ ```python
171
+ # Via MCP call_tool
172
+ result = await session.call_tool("clipwright_build_sequence", {
173
+ "clips": [
174
+ {"media": "/project/intro.mp4"},
175
+ {"media": "/project/main.mp4", "start_sec": 10.0, "end_sec": 130.0},
176
+ {"media": "/project/outro.mp4"}
177
+ ],
178
+ "output": "/project/sequence.otio"
179
+ })
180
+ # Then render
181
+ render_result = await session.call_tool("clipwright_render", {
182
+ "timeline": "/project/sequence.otio",
183
+ "output": "/project/final.mp4"
184
+ })
185
+ ```
186
+
187
+ ### Splice two gameplay segments
188
+
189
+ ```python
190
+ result = await session.call_tool("clipwright_build_sequence", {
191
+ "clips": [
192
+ {"media": "/project/gameplay.mp4", "start_sec": 60.0, "end_sec": 180.0},
193
+ {"media": "/project/gameplay.mp4", "start_sec": 300.0, "end_sec": 420.0}
194
+ ],
195
+ "output": "/project/highlights.otio"
196
+ })
197
+ ```
198
+
199
+ The same source file may appear multiple times (with different ranges). Each unique
200
+ source is probed exactly once.
201
+
202
+ ### B-roll interleave
203
+
204
+ ```python
205
+ result = await session.call_tool("clipwright_build_sequence", {
206
+ "clips": [
207
+ {"media": "/project/interview.mp4", "start_sec": 0.0, "end_sec": 30.0},
208
+ {"media": "/project/broll/cityscape.mp4"},
209
+ {"media": "/project/interview.mp4", "start_sec": 30.0, "end_sec": 60.0}
210
+ ],
211
+ "output": "/project/edited.otio"
212
+ })
213
+ ```
214
+
215
+ ## Installation
216
+
217
+ Within a uv workspace:
218
+
219
+ ```bash
220
+ uv run --package clipwright-sequence clipwright-sequence
221
+ ```
222
+
223
+ Or install from PyPI:
224
+
225
+ ```bash
226
+ pip install clipwright-sequence
227
+ clipwright-sequence
228
+ ```
229
+
230
+ ## License
231
+
232
+ MIT — See [LICENSE](../LICENSE) for details.
@@ -0,0 +1,10 @@
1
+ clipwright_sequence/__init__.py,sha256=4VmSHb1BRFPj3m9jDnCbaOBkksmLHW8XLNZ6R-2QFZw,98
2
+ clipwright_sequence/plan.py,sha256=AfTtosOj_yLB7KoVi15SozAAPmGto6NnkEKkENA6xiw,5123
3
+ clipwright_sequence/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ clipwright_sequence/schemas.py,sha256=8fviz4jhZbWyeA5dz2GHPZN7raA0Yj7bRPMjzsGYr60,1992
5
+ clipwright_sequence/sequence.py,sha256=m3it7mEdanrhWwY9EG1OvtVzfMsLcoKjhBUyN_ys7r8,17839
6
+ clipwright_sequence/server.py,sha256=qnLmswe25m0mHBV7aS9rg_Ctnw6VnZoTBNjUdSEmciM,3022
7
+ clipwright_sequence-0.1.0.dist-info/WHEEL,sha256=oBsDExVIEya4llboy9Ce1l6on8xt3GrtT29y6pYVypw,81
8
+ clipwright_sequence-0.1.0.dist-info/entry_points.txt,sha256=pHRnAFvRp94jhACERCFVanRhKpfhqse69fbvRw6m4xc,73
9
+ clipwright_sequence-0.1.0.dist-info/METADATA,sha256=1lUQEdLZ2y8CMg62uAURDkKBfOPE8Nkj4mi1upJIipU,8299
10
+ clipwright_sequence-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.23
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ clipwright-sequence = clipwright_sequence.server:main
3
+