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.
- clipwright_sequence/__init__.py +3 -0
- clipwright_sequence/plan.py +141 -0
- clipwright_sequence/py.typed +0 -0
- clipwright_sequence/schemas.py +56 -0
- clipwright_sequence/sequence.py +426 -0
- clipwright_sequence/server.py +90 -0
- clipwright_sequence-0.1.0.dist-info/METADATA +232 -0
- clipwright_sequence-0.1.0.dist-info/RECORD +10 -0
- clipwright_sequence-0.1.0.dist-info/WHEEL +4 -0
- clipwright_sequence-0.1.0.dist-info/entry_points.txt +3 -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,,
|