clipwright-overlay 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_overlay/__init__.py +3 -0
- clipwright_overlay/overlay.py +781 -0
- clipwright_overlay/py.typed +0 -0
- clipwright_overlay/schemas.py +85 -0
- clipwright_overlay/server.py +87 -0
- clipwright_overlay-0.1.0.dist-info/METADATA +300 -0
- clipwright_overlay-0.1.0.dist-info/RECORD +9 -0
- clipwright_overlay-0.1.0.dist-info/WHEEL +4 -0
- clipwright_overlay-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,781 @@
|
|
|
1
|
+
"""overlay.py — clipwright-overlay orchestration layer.
|
|
2
|
+
|
|
3
|
+
Handles the full flow: input validation -> load timeline -> validate options
|
|
4
|
+
-> idempotency check -> add image_overlay marker -> save timeline -> return envelope.
|
|
5
|
+
|
|
6
|
+
Design decisions:
|
|
7
|
+
- _add_overlay_inner() is the raising implementation; add_overlay() is the public
|
|
8
|
+
boundary that catches ClipwrightError and converts to error_result.
|
|
9
|
+
- Value-range validation is performed manually (OQ-1) for precise error hints.
|
|
10
|
+
- image_path is stored as a RELATIVE posix path under the output timeline parent
|
|
11
|
+
dir (V2-3) to ensure round-trip stability across project moves.
|
|
12
|
+
- x/y allowlist ^[A-Za-z0-9_()+\\-*/. ]+$ prevents filtergraph injection (V2-5).
|
|
13
|
+
- Idempotency: exact duplicate (all metadata fields match) -> no-op with warning.
|
|
14
|
+
Comparison uses the stored relative image_path string (V2-3).
|
|
15
|
+
- Non-destructive: input OTIO bytes are never modified; output is always new.
|
|
16
|
+
- Rate determination: first clip source_range -> existing image_overlay marker
|
|
17
|
+
rate -> fallback 1000.0 with warning.
|
|
18
|
+
- Boundary check _check_output_within_timeline_dir is a local copy of the
|
|
19
|
+
clipwright-text implementation to avoid cross-package imports.
|
|
20
|
+
When changing the logic here, ensure behaviour remains in sync with
|
|
21
|
+
clipwright-text's _check_output_within_timeline_dir; the two functions must
|
|
22
|
+
enforce the same boundary contract.
|
|
23
|
+
- This module is subprocess-free (annotation layer; no ffmpeg/ffprobe calls).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import collections.abc
|
|
29
|
+
import os
|
|
30
|
+
import re
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
import opentimelineio as otio
|
|
35
|
+
from clipwright.envelope import error_result, ok_result
|
|
36
|
+
from clipwright.errors import ClipwrightError, ErrorCode
|
|
37
|
+
from clipwright.otio_utils import add_marker, get_markers, load_timeline, save_timeline
|
|
38
|
+
from clipwright.schemas import RationalTimeModel, TimeRangeModel, ToolResult
|
|
39
|
+
|
|
40
|
+
from clipwright_overlay import __version__
|
|
41
|
+
from clipwright_overlay.schemas import AddOverlayOptions
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------------------
|
|
44
|
+
# Module constants
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
# Control-character pattern for image_path and position expressions.
|
|
48
|
+
# Includes: NUL-US (\x00-\x1f), DEL (\x7f).
|
|
49
|
+
_CONTROL_CHAR_PATTERN = re.compile(r"[\x00-\x1f\x7f]")
|
|
50
|
+
|
|
51
|
+
# x/y allowlist: permits alphanumeric, underscore, parentheses, arithmetic
|
|
52
|
+
# operators, dot, and space. Rejects : ; [ ] , ' and control characters (V2-5).
|
|
53
|
+
# This covers ffmpeg overlay expressions such as (W-w)/2, (H-h)/2,
|
|
54
|
+
# main_w-overlay_w-10, and simple numeric positions like 0 or 100.
|
|
55
|
+
_XY_ALLOWLIST = re.compile(r"^[A-Za-z0-9_()+\-*/. ]+$")
|
|
56
|
+
|
|
57
|
+
# Allowed image file extensions for overlay (case-insensitive check via .lower()).
|
|
58
|
+
_ALLOWED_IMAGE_EXTENSIONS: frozenset[str] = frozenset(
|
|
59
|
+
{".png", ".jpg", ".jpeg", ".webp"}
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Tolerance for float comparison in idempotency checks (rate-invariant).
|
|
63
|
+
_IDEMPOTENCY_EPS: float = 1e-6
|
|
64
|
+
|
|
65
|
+
# Maximum number of image_overlay markers allowed per timeline (V2-9 / DoS guard).
|
|
66
|
+
_MAX_IMAGE_OVERLAYS: int = 64
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ===========================================================================
|
|
70
|
+
# Path boundary helpers (local copies; keep in sync with clipwright-text)
|
|
71
|
+
# ===========================================================================
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _check_output_within_timeline_dir(timeline: Path, output: Path) -> None:
|
|
75
|
+
"""Verify that output is within the timeline's parent directory tree.
|
|
76
|
+
|
|
77
|
+
Mirrors clipwright-text _check_output_within_timeline_dir boundary contract.
|
|
78
|
+
Allows recursive subdirectories; raises PATH_NOT_ALLOWED only when the
|
|
79
|
+
resolved output is outside the timeline directory tree.
|
|
80
|
+
|
|
81
|
+
Intentionally re-implemented locally to avoid cross-package import of
|
|
82
|
+
clipwright-text (NR-L-4: cross-package imports between satellite tools
|
|
83
|
+
create tight coupling and break independent packaging/deployment).
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
timeline: Path to the input OTIO timeline file.
|
|
87
|
+
output: Output path to validate against the boundary.
|
|
88
|
+
|
|
89
|
+
Raises:
|
|
90
|
+
ClipwrightError: PATH_NOT_ALLOWED when output is outside the
|
|
91
|
+
timeline's parent directory tree.
|
|
92
|
+
"""
|
|
93
|
+
try:
|
|
94
|
+
allowed_base = timeline.parent.resolve()
|
|
95
|
+
target_resolved = output.resolve()
|
|
96
|
+
target_str = str(target_resolved)
|
|
97
|
+
base_str = str(allowed_base)
|
|
98
|
+
if not (
|
|
99
|
+
target_str == base_str
|
|
100
|
+
or target_str.startswith(base_str + "/")
|
|
101
|
+
or target_str.startswith(base_str + "\\")
|
|
102
|
+
):
|
|
103
|
+
raise ClipwrightError(
|
|
104
|
+
code=ErrorCode.PATH_NOT_ALLOWED,
|
|
105
|
+
message="Output path points outside the project boundary.",
|
|
106
|
+
hint=(
|
|
107
|
+
"Place the output file within the same directory as the "
|
|
108
|
+
"OTIO timeline, or in a subdirectory of it."
|
|
109
|
+
),
|
|
110
|
+
)
|
|
111
|
+
except ClipwrightError:
|
|
112
|
+
raise
|
|
113
|
+
except OSError:
|
|
114
|
+
# resolve() failed (network path, symlink loop, etc.): fall back to
|
|
115
|
+
# absolute()-based best-effort comparison.
|
|
116
|
+
try:
|
|
117
|
+
allowed_base_abs = str(timeline.parent.absolute())
|
|
118
|
+
target_abs = str(output.absolute())
|
|
119
|
+
if not (
|
|
120
|
+
target_abs == allowed_base_abs
|
|
121
|
+
or target_abs.startswith(allowed_base_abs + "/")
|
|
122
|
+
or target_abs.startswith(allowed_base_abs + "\\")
|
|
123
|
+
):
|
|
124
|
+
raise ClipwrightError(
|
|
125
|
+
code=ErrorCode.PATH_NOT_ALLOWED,
|
|
126
|
+
message="Output path points outside the project boundary.",
|
|
127
|
+
hint=(
|
|
128
|
+
"Place the output file within the same directory as the "
|
|
129
|
+
"OTIO timeline, or in a subdirectory of it."
|
|
130
|
+
),
|
|
131
|
+
)
|
|
132
|
+
except ClipwrightError:
|
|
133
|
+
raise
|
|
134
|
+
except OSError:
|
|
135
|
+
# Skip only when absolute() also fails (truly unresolvable path).
|
|
136
|
+
pass
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _check_within_image_overlay_dir(output_otio_path: Path, resolved: Path) -> None:
|
|
140
|
+
"""Verify the resolved image path is within the output timeline's parent dir tree.
|
|
141
|
+
|
|
142
|
+
Uses the same boundary contract as _check_output_within_timeline_dir but
|
|
143
|
+
applied to the image file: the allowed base is the output OTIO's parent dir
|
|
144
|
+
(V2-3 / ADR-OV-3). This ensures that render-side re-validation (which uses
|
|
145
|
+
the timeline parent as the base) will also pass, since the output timeline
|
|
146
|
+
is the input to render.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
output_otio_path: Path to the output OTIO file (determines allowed base).
|
|
150
|
+
resolved: Resolved absolute path to the image file to validate.
|
|
151
|
+
|
|
152
|
+
Raises:
|
|
153
|
+
ClipwrightError: PATH_NOT_ALLOWED when the image is outside the
|
|
154
|
+
output timeline's parent directory tree.
|
|
155
|
+
"""
|
|
156
|
+
try:
|
|
157
|
+
allowed_base = output_otio_path.resolve().parent
|
|
158
|
+
target_str = str(resolved)
|
|
159
|
+
base_str = str(allowed_base)
|
|
160
|
+
if not (
|
|
161
|
+
target_str == base_str
|
|
162
|
+
or target_str.startswith(base_str + "/")
|
|
163
|
+
or target_str.startswith(base_str + "\\")
|
|
164
|
+
):
|
|
165
|
+
raise ClipwrightError(
|
|
166
|
+
code=ErrorCode.PATH_NOT_ALLOWED,
|
|
167
|
+
message="Image path points outside the project boundary.",
|
|
168
|
+
hint=(
|
|
169
|
+
"Place the image file within the same directory as the "
|
|
170
|
+
"output OTIO timeline, or in a subdirectory of it."
|
|
171
|
+
),
|
|
172
|
+
)
|
|
173
|
+
except ClipwrightError:
|
|
174
|
+
raise
|
|
175
|
+
except OSError:
|
|
176
|
+
try:
|
|
177
|
+
allowed_base_abs = str(output_otio_path.absolute().parent)
|
|
178
|
+
target_abs = str(resolved)
|
|
179
|
+
if not (
|
|
180
|
+
target_abs == allowed_base_abs
|
|
181
|
+
or target_abs.startswith(allowed_base_abs + "/")
|
|
182
|
+
or target_abs.startswith(allowed_base_abs + "\\")
|
|
183
|
+
):
|
|
184
|
+
raise ClipwrightError(
|
|
185
|
+
code=ErrorCode.PATH_NOT_ALLOWED,
|
|
186
|
+
message="Image path points outside the project boundary.",
|
|
187
|
+
hint=(
|
|
188
|
+
"Place the image file within the same directory as the "
|
|
189
|
+
"output OTIO timeline, or in a subdirectory of it."
|
|
190
|
+
),
|
|
191
|
+
)
|
|
192
|
+
except ClipwrightError:
|
|
193
|
+
raise
|
|
194
|
+
except OSError:
|
|
195
|
+
pass
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# ===========================================================================
|
|
199
|
+
# Validation helpers
|
|
200
|
+
# ===========================================================================
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _validate_overlay_fields(options: AddOverlayOptions, output: str) -> None:
|
|
204
|
+
"""Validate value-range, image_path (4-stage), and position expression fields.
|
|
205
|
+
|
|
206
|
+
Validation order (fixed to keep error messages deterministic — ADR-OV-2):
|
|
207
|
+
1. Value ranges (start_sec, duration_sec, scale, opacity, fade_in_sec,
|
|
208
|
+
fade_out_sec, fade sum)
|
|
209
|
+
2. image_path 4-stage:
|
|
210
|
+
a. path safety: single-quote or control char (INVALID_INPUT)
|
|
211
|
+
— checked before resolve() to prevent ValueError from control chars
|
|
212
|
+
and to ensure safety always precedes existence/extension checks
|
|
213
|
+
b. co-location (PATH_NOT_ALLOWED)
|
|
214
|
+
c. existence (FILE_NOT_FOUND, basename only)
|
|
215
|
+
d. extension allowlist (INVALID_INPUT)
|
|
216
|
+
3. x/y allowlist (INVALID_INPUT) (V2-5)
|
|
217
|
+
|
|
218
|
+
Path safety is placed before co-location because:
|
|
219
|
+
- control chars in the path cause Path.resolve() to raise ValueError on Windows
|
|
220
|
+
- single-quotes in the path must be rejected before the file's existence is
|
|
221
|
+
checked (the file is typically absent with a bad path)
|
|
222
|
+
All violations raise ClipwrightError on the first failure.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
options: AddOverlayOptions to validate.
|
|
226
|
+
output: Output OTIO file path (used for co-location boundary).
|
|
227
|
+
|
|
228
|
+
Raises:
|
|
229
|
+
ClipwrightError: On the first validation failure.
|
|
230
|
+
"""
|
|
231
|
+
out = Path(output)
|
|
232
|
+
|
|
233
|
+
# --- 1. Value ranges ---
|
|
234
|
+
if options.start_sec < 0:
|
|
235
|
+
raise ClipwrightError(
|
|
236
|
+
code=ErrorCode.INVALID_INPUT,
|
|
237
|
+
message="Overlay start time must be 0 or greater.",
|
|
238
|
+
hint="Set start_sec to a non-negative value.",
|
|
239
|
+
)
|
|
240
|
+
if options.duration_sec <= 0:
|
|
241
|
+
raise ClipwrightError(
|
|
242
|
+
code=ErrorCode.INVALID_INPUT,
|
|
243
|
+
message="Overlay duration must be greater than 0.",
|
|
244
|
+
hint="Set duration_sec to a positive number of seconds.",
|
|
245
|
+
)
|
|
246
|
+
# Manual recheck for scale (V2-9): schema is first line of defence; manual
|
|
247
|
+
# recheck provides a precise hint for values that slip through or are set
|
|
248
|
+
# programmatically after construction.
|
|
249
|
+
if options.scale <= 0 or options.scale > 8.0:
|
|
250
|
+
raise ClipwrightError(
|
|
251
|
+
code=ErrorCode.INVALID_INPUT,
|
|
252
|
+
message="Scale must be in the range (0, 8.0].",
|
|
253
|
+
hint=(
|
|
254
|
+
"Set scale to a positive value no greater than 8.0 "
|
|
255
|
+
"(e.g. 1.0 for original size)."
|
|
256
|
+
),
|
|
257
|
+
)
|
|
258
|
+
if options.opacity < 0 or options.opacity > 1.0:
|
|
259
|
+
raise ClipwrightError(
|
|
260
|
+
code=ErrorCode.INVALID_INPUT,
|
|
261
|
+
message="Opacity must be between 0.0 and 1.0.",
|
|
262
|
+
hint="Set opacity to a value in the range [0.0, 1.0].",
|
|
263
|
+
)
|
|
264
|
+
if options.fade_in_sec < 0:
|
|
265
|
+
raise ClipwrightError(
|
|
266
|
+
code=ErrorCode.INVALID_INPUT,
|
|
267
|
+
message="Fade-in duration must be 0 or greater.",
|
|
268
|
+
hint="Set fade_in_sec to a non-negative value.",
|
|
269
|
+
)
|
|
270
|
+
if options.fade_out_sec < 0:
|
|
271
|
+
raise ClipwrightError(
|
|
272
|
+
code=ErrorCode.INVALID_INPUT,
|
|
273
|
+
message="Fade-out duration must be 0 or greater.",
|
|
274
|
+
hint="Set fade_out_sec to a non-negative value.",
|
|
275
|
+
)
|
|
276
|
+
if options.fade_in_sec + options.fade_out_sec > options.duration_sec:
|
|
277
|
+
raise ClipwrightError(
|
|
278
|
+
code=ErrorCode.INVALID_INPUT,
|
|
279
|
+
message="Fade-in plus fade-out exceeds the overlay duration.",
|
|
280
|
+
hint=(
|
|
281
|
+
"Reduce fade durations or increase duration_sec so fades fit within it."
|
|
282
|
+
),
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
# --- 2. image_path 4-stage validation ---
|
|
286
|
+
|
|
287
|
+
# 2a. path safety: single-quote or control char — checked BEFORE resolve() to:
|
|
288
|
+
# (1) avoid ValueError from control chars embedded in the path on Windows,
|
|
289
|
+
# (2) ensure safety violations surface before existence/extension checks.
|
|
290
|
+
if "'" in options.image_path:
|
|
291
|
+
raise ClipwrightError(
|
|
292
|
+
code=ErrorCode.INVALID_INPUT,
|
|
293
|
+
message="Image path must not contain single-quote characters.",
|
|
294
|
+
hint=(
|
|
295
|
+
"Remove single-quotes from the image file path "
|
|
296
|
+
"(they would corrupt filtergraph quoting)."
|
|
297
|
+
),
|
|
298
|
+
)
|
|
299
|
+
if _CONTROL_CHAR_PATTERN.search(options.image_path):
|
|
300
|
+
raise ClipwrightError(
|
|
301
|
+
code=ErrorCode.INVALID_INPUT,
|
|
302
|
+
message="Image path must not contain control characters.",
|
|
303
|
+
hint="Remove control characters from the image file path.",
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
resolved = Path(options.image_path).resolve()
|
|
307
|
+
|
|
308
|
+
# 2b. co-location: image must be within the output timeline's parent dir tree (V2-3)
|
|
309
|
+
_check_within_image_overlay_dir(out, resolved)
|
|
310
|
+
|
|
311
|
+
# 2c. existence
|
|
312
|
+
if not resolved.exists():
|
|
313
|
+
raise ClipwrightError(
|
|
314
|
+
code=ErrorCode.FILE_NOT_FOUND,
|
|
315
|
+
message=f"Image file not found: {Path(options.image_path).name}",
|
|
316
|
+
hint="Verify the image path and ensure the file exists.",
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
# 2d. extension allowlist
|
|
320
|
+
if resolved.suffix.lower() not in _ALLOWED_IMAGE_EXTENSIONS:
|
|
321
|
+
raise ClipwrightError(
|
|
322
|
+
code=ErrorCode.INVALID_INPUT,
|
|
323
|
+
message=(
|
|
324
|
+
f"Image extension '{resolved.suffix}' is not supported. "
|
|
325
|
+
f"Allowed: {', '.join(sorted(_ALLOWED_IMAGE_EXTENSIONS))}."
|
|
326
|
+
),
|
|
327
|
+
hint="Use a supported image format: .png, .jpg, .jpeg, or .webp.",
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
# --- 3. x/y allowlist (V2-5) ---
|
|
331
|
+
if not _XY_ALLOWLIST.fullmatch(options.x):
|
|
332
|
+
raise ClipwrightError(
|
|
333
|
+
code=ErrorCode.INVALID_INPUT,
|
|
334
|
+
message="x expression contains forbidden characters.",
|
|
335
|
+
hint=(
|
|
336
|
+
"Use only alphanumeric characters, underscores, parentheses, "
|
|
337
|
+
"arithmetic operators (+, -, *, /), dot, and space. "
|
|
338
|
+
"Forbidden: : ; [ ] , ' and control characters."
|
|
339
|
+
),
|
|
340
|
+
)
|
|
341
|
+
if not _XY_ALLOWLIST.fullmatch(options.y):
|
|
342
|
+
raise ClipwrightError(
|
|
343
|
+
code=ErrorCode.INVALID_INPUT,
|
|
344
|
+
message="y expression contains forbidden characters.",
|
|
345
|
+
hint=(
|
|
346
|
+
"Use only alphanumeric characters, underscores, parentheses, "
|
|
347
|
+
"arithmetic operators (+, -, *, /), dot, and space. "
|
|
348
|
+
"Forbidden: : ; [ ] , ' and control characters."
|
|
349
|
+
),
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
# ===========================================================================
|
|
354
|
+
# Metadata and idempotency helpers
|
|
355
|
+
# ===========================================================================
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _overlay_metadata_dict(
|
|
359
|
+
options: AddOverlayOptions,
|
|
360
|
+
output: str,
|
|
361
|
+
version: str,
|
|
362
|
+
) -> dict[str, Any]:
|
|
363
|
+
"""Build the clipwright metadata dict for an image_overlay marker.
|
|
364
|
+
|
|
365
|
+
Stores the image_path as a RELATIVE posix path from the output timeline
|
|
366
|
+
parent dir (V2-3): this ensures round-trip stability when the project is
|
|
367
|
+
moved, as long as the relative layout between the timeline and image is
|
|
368
|
+
preserved. render reconstructs the absolute path as:
|
|
369
|
+
(Path(timeline_path).resolve().parent / rel).resolve()
|
|
370
|
+
|
|
371
|
+
Args:
|
|
372
|
+
options: Validated AddOverlayOptions.
|
|
373
|
+
output: Output OTIO file path (determines the relative base).
|
|
374
|
+
version: Package version string to embed in metadata.
|
|
375
|
+
|
|
376
|
+
Returns:
|
|
377
|
+
Dict to store under marker.metadata["clipwright"].
|
|
378
|
+
|
|
379
|
+
Raises:
|
|
380
|
+
ClipwrightError: PATH_NOT_ALLOWED if relpath contains '..', which
|
|
381
|
+
indicates the image is outside the output parent tree (defense-in-depth).
|
|
382
|
+
"""
|
|
383
|
+
resolved = Path(options.image_path).resolve()
|
|
384
|
+
timeline_parent = Path(output).resolve().parent
|
|
385
|
+
try:
|
|
386
|
+
rel_str = os.path.relpath(resolved, timeline_parent)
|
|
387
|
+
except ValueError:
|
|
388
|
+
# Different drive on Windows: relpath raises ValueError -> co-location violation
|
|
389
|
+
raise ClipwrightError(
|
|
390
|
+
code=ErrorCode.PATH_NOT_ALLOWED,
|
|
391
|
+
message="Image path points outside the project boundary.",
|
|
392
|
+
hint=(
|
|
393
|
+
"Place the image file within the same directory as the "
|
|
394
|
+
"output OTIO timeline, or in a subdirectory of it."
|
|
395
|
+
),
|
|
396
|
+
) from None
|
|
397
|
+
rel = Path(rel_str).as_posix()
|
|
398
|
+
# Defense-in-depth (V2-3): if co-location check passed but relpath still yields
|
|
399
|
+
# a '..' prefix, treat as PATH_NOT_ALLOWED.
|
|
400
|
+
if rel.startswith(".."):
|
|
401
|
+
raise ClipwrightError(
|
|
402
|
+
code=ErrorCode.PATH_NOT_ALLOWED,
|
|
403
|
+
message="Image path points outside the project boundary.",
|
|
404
|
+
hint=(
|
|
405
|
+
"Place the image file within the same directory as the "
|
|
406
|
+
"output OTIO timeline, or in a subdirectory of it."
|
|
407
|
+
),
|
|
408
|
+
)
|
|
409
|
+
return {
|
|
410
|
+
"tool": "clipwright-overlay",
|
|
411
|
+
"version": version,
|
|
412
|
+
"kind": "image_overlay",
|
|
413
|
+
"image_path": rel,
|
|
414
|
+
"start_sec": options.start_sec,
|
|
415
|
+
"duration_sec": options.duration_sec,
|
|
416
|
+
"x": options.x,
|
|
417
|
+
"y": options.y,
|
|
418
|
+
"scale": options.scale,
|
|
419
|
+
"opacity": options.opacity,
|
|
420
|
+
"fade_in_sec": options.fade_in_sec,
|
|
421
|
+
"fade_out_sec": options.fade_out_sec,
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _is_duplicate_overlay(
|
|
426
|
+
marker: otio.schema.Marker, options: AddOverlayOptions, output: str
|
|
427
|
+
) -> bool:
|
|
428
|
+
"""Return True if marker is an exact duplicate of the given options.
|
|
429
|
+
|
|
430
|
+
Compares all AddOverlayOptions fields stored in marker.metadata["clipwright"]
|
|
431
|
+
against the current options. Uses approximate float comparison for numeric
|
|
432
|
+
fields to be rate-invariant. image_path comparison is done on the relative
|
|
433
|
+
posix string (V2-3): both the stored value and the computed relative path
|
|
434
|
+
of the new options must match exactly.
|
|
435
|
+
|
|
436
|
+
Args:
|
|
437
|
+
marker: An existing image_overlay marker to compare.
|
|
438
|
+
options: Current AddOverlayOptions to check for duplication.
|
|
439
|
+
output: Output OTIO file path (for computing relative image_path).
|
|
440
|
+
|
|
441
|
+
Returns:
|
|
442
|
+
True if all fields match (complete duplicate -> no-op).
|
|
443
|
+
"""
|
|
444
|
+
cw = marker.metadata.get("clipwright", {})
|
|
445
|
+
if not isinstance(cw, collections.abc.Mapping):
|
|
446
|
+
return False
|
|
447
|
+
if cw.get("kind") != "image_overlay":
|
|
448
|
+
return False
|
|
449
|
+
|
|
450
|
+
# Compute the relative posix path for the new options
|
|
451
|
+
try:
|
|
452
|
+
resolved = Path(options.image_path).resolve()
|
|
453
|
+
timeline_parent = Path(output).resolve().parent
|
|
454
|
+
try:
|
|
455
|
+
rel_str = os.path.relpath(resolved, timeline_parent)
|
|
456
|
+
except ValueError:
|
|
457
|
+
return False
|
|
458
|
+
new_rel = Path(rel_str).as_posix()
|
|
459
|
+
if new_rel.startswith(".."):
|
|
460
|
+
return False
|
|
461
|
+
except Exception:
|
|
462
|
+
return False
|
|
463
|
+
|
|
464
|
+
# String fields: exact match (image_path as relative, x, y)
|
|
465
|
+
if cw.get("image_path") != new_rel:
|
|
466
|
+
return False
|
|
467
|
+
if cw.get("x") != options.x:
|
|
468
|
+
return False
|
|
469
|
+
if cw.get("y") != options.y:
|
|
470
|
+
return False
|
|
471
|
+
|
|
472
|
+
# Float fields: approximate comparison (rate-invariant tolerance)
|
|
473
|
+
def _approx_eq(a: object, b: float) -> bool:
|
|
474
|
+
if not isinstance(a, (int, float)):
|
|
475
|
+
return False
|
|
476
|
+
return abs(float(a) - b) <= _IDEMPOTENCY_EPS
|
|
477
|
+
|
|
478
|
+
if not _approx_eq(cw.get("start_sec"), options.start_sec):
|
|
479
|
+
return False
|
|
480
|
+
if not _approx_eq(cw.get("duration_sec"), options.duration_sec):
|
|
481
|
+
return False
|
|
482
|
+
if not _approx_eq(cw.get("scale"), options.scale):
|
|
483
|
+
return False
|
|
484
|
+
if not _approx_eq(cw.get("opacity"), options.opacity):
|
|
485
|
+
return False
|
|
486
|
+
if not _approx_eq(cw.get("fade_in_sec"), options.fade_in_sec):
|
|
487
|
+
return False
|
|
488
|
+
return _approx_eq(cw.get("fade_out_sec"), options.fade_out_sec)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
# ===========================================================================
|
|
492
|
+
# Rate resolution
|
|
493
|
+
# ===========================================================================
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _resolve_rate(
|
|
497
|
+
video_track: otio.schema.Track,
|
|
498
|
+
) -> tuple[float, list[str]]:
|
|
499
|
+
"""Determine the rate for RationalTime construction.
|
|
500
|
+
|
|
501
|
+
Priority:
|
|
502
|
+
1. source_range.rate of the first Clip in the V1 track.
|
|
503
|
+
2. marked_range.start_time.rate of the first existing image_overlay marker.
|
|
504
|
+
3. Fallback: 1000.0 with a warning.
|
|
505
|
+
|
|
506
|
+
Args:
|
|
507
|
+
video_track: The first Video track from the loaded timeline.
|
|
508
|
+
|
|
509
|
+
Returns:
|
|
510
|
+
Tuple of (rate: float, warnings: list[str]). warnings is non-empty only
|
|
511
|
+
when the fallback rate is used.
|
|
512
|
+
"""
|
|
513
|
+
# Priority 1: first clip's source_range rate
|
|
514
|
+
for item in video_track:
|
|
515
|
+
if isinstance(item, otio.schema.Clip) and item.source_range is not None:
|
|
516
|
+
return float(item.source_range.start_time.rate), []
|
|
517
|
+
|
|
518
|
+
# Priority 2: existing image_overlay marker rate
|
|
519
|
+
for marker in video_track.markers:
|
|
520
|
+
cw = marker.metadata.get("clipwright", {})
|
|
521
|
+
if (
|
|
522
|
+
isinstance(cw, collections.abc.Mapping)
|
|
523
|
+
and cw.get("kind") == "image_overlay"
|
|
524
|
+
):
|
|
525
|
+
return float(marker.marked_range.start_time.rate), []
|
|
526
|
+
|
|
527
|
+
# Priority 3: fallback
|
|
528
|
+
return 1000.0, [
|
|
529
|
+
"Could not determine timeline rate from clips or existing markers; "
|
|
530
|
+
"using fallback rate 1000.0. Consider providing a timeline with clips."
|
|
531
|
+
]
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
# ===========================================================================
|
|
535
|
+
# Core implementation
|
|
536
|
+
# ===========================================================================
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _add_overlay_inner(
|
|
540
|
+
timeline: str,
|
|
541
|
+
output: str,
|
|
542
|
+
options: AddOverlayOptions,
|
|
543
|
+
) -> ToolResult:
|
|
544
|
+
"""Internal implementation of add_overlay. Raises ClipwrightError on failure.
|
|
545
|
+
|
|
546
|
+
Validation order:
|
|
547
|
+
1. output suffix == .otio
|
|
548
|
+
2. output parent directory exists
|
|
549
|
+
3. output boundary check (PATH_NOT_ALLOWED when outside timeline dir)
|
|
550
|
+
4. output != timeline
|
|
551
|
+
5. field validation (_validate_overlay_fields, including image_path 4-stage)
|
|
552
|
+
6. load timeline (FILE_NOT_FOUND / OTIO_ERROR propagate)
|
|
553
|
+
7. first TrackKind.Video track exists
|
|
554
|
+
8. rate determination
|
|
555
|
+
9. _MAX_IMAGE_OVERLAYS cap check (V2-9)
|
|
556
|
+
10. idempotency check (exact duplicate -> no-op)
|
|
557
|
+
11. add marker (image_{n}, all metadata fields, relative image_path)
|
|
558
|
+
12. save timeline atomically
|
|
559
|
+
13. return ok_result
|
|
560
|
+
|
|
561
|
+
Args:
|
|
562
|
+
timeline: Input OTIO timeline file path.
|
|
563
|
+
output: Output OTIO file path.
|
|
564
|
+
options: Validated AddOverlayOptions.
|
|
565
|
+
|
|
566
|
+
Returns:
|
|
567
|
+
ToolResult from ok_result.
|
|
568
|
+
|
|
569
|
+
Raises:
|
|
570
|
+
ClipwrightError: On any validation or I/O failure.
|
|
571
|
+
"""
|
|
572
|
+
out = Path(output)
|
|
573
|
+
inp = Path(timeline)
|
|
574
|
+
|
|
575
|
+
# --- Step 1: output suffix validation ---
|
|
576
|
+
if out.suffix.lower() != ".otio":
|
|
577
|
+
raise ClipwrightError(
|
|
578
|
+
code=ErrorCode.INVALID_INPUT,
|
|
579
|
+
message="Output path must have a .otio extension.",
|
|
580
|
+
hint="Change the output file extension to .otio (e.g., 'result.otio').",
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
# --- Step 2: output parent directory exists ---
|
|
584
|
+
if not out.parent.exists():
|
|
585
|
+
raise ClipwrightError(
|
|
586
|
+
code=ErrorCode.FILE_NOT_FOUND,
|
|
587
|
+
message="Output directory does not exist.",
|
|
588
|
+
hint="Create the output directory before calling clipwright_add_overlay.",
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
# --- Step 3: output boundary check ---
|
|
592
|
+
_check_output_within_timeline_dir(inp, out)
|
|
593
|
+
|
|
594
|
+
# --- Step 4: output != timeline ---
|
|
595
|
+
if out.resolve() == inp.resolve():
|
|
596
|
+
raise ClipwrightError(
|
|
597
|
+
code=ErrorCode.INVALID_INPUT,
|
|
598
|
+
message="Output path must differ from the input timeline path.",
|
|
599
|
+
hint=(
|
|
600
|
+
"Provide a distinct output path (e.g., append '_overlay' before .otio)."
|
|
601
|
+
),
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
# --- Step 5: field validation (value ranges + image_path 4-stage + x/y) ---
|
|
605
|
+
_validate_overlay_fields(options, output)
|
|
606
|
+
|
|
607
|
+
# --- Step 6: load timeline ---
|
|
608
|
+
if not inp.exists():
|
|
609
|
+
raise ClipwrightError(
|
|
610
|
+
code=ErrorCode.FILE_NOT_FOUND,
|
|
611
|
+
message=f"Timeline file not found: {inp.name}",
|
|
612
|
+
hint="Verify the timeline path and ensure the file exists.",
|
|
613
|
+
)
|
|
614
|
+
timeline_obj = load_timeline(timeline)
|
|
615
|
+
|
|
616
|
+
# --- Step 7: find first Video track ---
|
|
617
|
+
video_track: otio.schema.Track | None = None
|
|
618
|
+
for track in timeline_obj.tracks:
|
|
619
|
+
if track.kind == otio.schema.TrackKind.Video:
|
|
620
|
+
video_track = track
|
|
621
|
+
break
|
|
622
|
+
|
|
623
|
+
if video_track is None:
|
|
624
|
+
raise ClipwrightError(
|
|
625
|
+
code=ErrorCode.UNSUPPORTED_OPERATION,
|
|
626
|
+
message="No video track found in the timeline.",
|
|
627
|
+
hint=(
|
|
628
|
+
"clipwright_add_overlay requires a timeline with at least one "
|
|
629
|
+
"video track to attach image overlay markers."
|
|
630
|
+
),
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
# --- Step 8: rate determination ---
|
|
634
|
+
rate, rate_warnings = _resolve_rate(video_track)
|
|
635
|
+
|
|
636
|
+
# --- Step 9: _MAX_IMAGE_OVERLAYS cap (V2-9) ---
|
|
637
|
+
existing_markers = get_markers(timeline_obj, kind="image_overlay")
|
|
638
|
+
if len(existing_markers) >= _MAX_IMAGE_OVERLAYS:
|
|
639
|
+
raise ClipwrightError(
|
|
640
|
+
code=ErrorCode.INVALID_INPUT,
|
|
641
|
+
message="Too many image overlays on this timeline.",
|
|
642
|
+
hint=(
|
|
643
|
+
f"The timeline already has {len(existing_markers)} image overlays "
|
|
644
|
+
f"and the maximum is {_MAX_IMAGE_OVERLAYS}. "
|
|
645
|
+
f"Remove some image_overlay markers before adding more."
|
|
646
|
+
),
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
# --- Step 10: idempotency check ---
|
|
650
|
+
for existing in existing_markers:
|
|
651
|
+
if _is_duplicate_overlay(existing, options, output):
|
|
652
|
+
# Exact duplicate: save a copy of the timeline and return no-op result
|
|
653
|
+
save_timeline(timeline_obj, output)
|
|
654
|
+
overlay_count = len(existing_markers)
|
|
655
|
+
basename = Path(options.image_path).name
|
|
656
|
+
return ok_result(
|
|
657
|
+
summary=(
|
|
658
|
+
f"Image overlay '{basename}' at {options.start_sec}s for "
|
|
659
|
+
f"{options.duration_sec}s already exists; no marker added. "
|
|
660
|
+
f"Timeline has {overlay_count} image overlay(s). "
|
|
661
|
+
f"Output: {out.name}."
|
|
662
|
+
),
|
|
663
|
+
data={
|
|
664
|
+
"applied": 0,
|
|
665
|
+
"overlay_count": overlay_count,
|
|
666
|
+
"start_sec": options.start_sec,
|
|
667
|
+
"duration_sec": options.duration_sec,
|
|
668
|
+
},
|
|
669
|
+
artifacts=[
|
|
670
|
+
{
|
|
671
|
+
"role": "timeline",
|
|
672
|
+
"path": str(out.resolve()),
|
|
673
|
+
"format": "otio",
|
|
674
|
+
}
|
|
675
|
+
],
|
|
676
|
+
warnings=["Identical image overlay already exists; no marker added."],
|
|
677
|
+
)
|
|
678
|
+
|
|
679
|
+
# --- Step 11: add marker ---
|
|
680
|
+
# Count existing image_overlay markers to determine name index
|
|
681
|
+
n = len(existing_markers)
|
|
682
|
+
marker_name = f"image_{n}"
|
|
683
|
+
|
|
684
|
+
# Build marker metadata with relative image_path (V2-3)
|
|
685
|
+
metadata = _overlay_metadata_dict(options, output, __version__)
|
|
686
|
+
|
|
687
|
+
# Build marked_range using the resolved rate
|
|
688
|
+
marked_range = TimeRangeModel(
|
|
689
|
+
start_time=RationalTimeModel(
|
|
690
|
+
value=options.start_sec * rate,
|
|
691
|
+
rate=rate,
|
|
692
|
+
),
|
|
693
|
+
duration=RationalTimeModel(
|
|
694
|
+
value=options.duration_sec * rate,
|
|
695
|
+
rate=rate,
|
|
696
|
+
),
|
|
697
|
+
)
|
|
698
|
+
|
|
699
|
+
add_marker(
|
|
700
|
+
video_track,
|
|
701
|
+
marked_range=marked_range,
|
|
702
|
+
name=marker_name,
|
|
703
|
+
color=None,
|
|
704
|
+
metadata=metadata,
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
# --- Step 12: save timeline ---
|
|
708
|
+
save_timeline(timeline_obj, output)
|
|
709
|
+
|
|
710
|
+
# --- Step 13: build result ---
|
|
711
|
+
overlay_count = n + 1
|
|
712
|
+
out_resolved = out.resolve()
|
|
713
|
+
basename = Path(options.image_path).name
|
|
714
|
+
summary = (
|
|
715
|
+
f"Added image overlay '{basename}' at {options.start_sec}s for "
|
|
716
|
+
f"{options.duration_sec}s. "
|
|
717
|
+
f"Timeline now has {overlay_count} image overlay(s). "
|
|
718
|
+
f"Output: {out.name}."
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
all_warnings = list(rate_warnings)
|
|
722
|
+
|
|
723
|
+
return ok_result(
|
|
724
|
+
summary=summary,
|
|
725
|
+
data={
|
|
726
|
+
"applied": 1,
|
|
727
|
+
"overlay_count": overlay_count,
|
|
728
|
+
"start_sec": options.start_sec,
|
|
729
|
+
"duration_sec": options.duration_sec,
|
|
730
|
+
},
|
|
731
|
+
artifacts=[
|
|
732
|
+
{
|
|
733
|
+
"role": "timeline",
|
|
734
|
+
"path": str(out_resolved),
|
|
735
|
+
"format": "otio",
|
|
736
|
+
}
|
|
737
|
+
],
|
|
738
|
+
warnings=all_warnings if all_warnings else None,
|
|
739
|
+
)
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def add_overlay(
|
|
743
|
+
timeline: str,
|
|
744
|
+
output: str,
|
|
745
|
+
options: AddOverlayOptions | None,
|
|
746
|
+
) -> dict[str, Any]:
|
|
747
|
+
"""Add an image_overlay marker to an OTIO timeline.
|
|
748
|
+
|
|
749
|
+
Non-destructive: does not modify the input timeline file.
|
|
750
|
+
Idempotent: calling with the same options on an already-annotated timeline
|
|
751
|
+
produces applied=0 and a warning rather than duplicating the marker.
|
|
752
|
+
|
|
753
|
+
The image_path is stored as a relative posix path in the marker metadata
|
|
754
|
+
(V2-3) to ensure round-trip stability across project directory moves.
|
|
755
|
+
|
|
756
|
+
Returns a plain dict (ToolResult.model_dump()) so callers can use both
|
|
757
|
+
dict-style access (``result["ok"]``, ``result.get(...)``) and
|
|
758
|
+
``isinstance(result, dict)`` checks. server.py wraps this in ToolResult
|
|
759
|
+
for FastMCP's typed outputSchema.
|
|
760
|
+
|
|
761
|
+
Args:
|
|
762
|
+
timeline: Input OTIO timeline file path.
|
|
763
|
+
output: Output OTIO file path (must end in .otio, must differ from timeline).
|
|
764
|
+
options: AddOverlayOptions with required image_path/start_sec/duration_sec
|
|
765
|
+
and optional style fields. None returns INVALID_INPUT.
|
|
766
|
+
|
|
767
|
+
Returns:
|
|
768
|
+
dict with the ToolResult envelope keys: ok, summary, data, artifacts, warnings,
|
|
769
|
+
error.
|
|
770
|
+
"""
|
|
771
|
+
if options is None:
|
|
772
|
+
return error_result(
|
|
773
|
+
"INVALID_INPUT",
|
|
774
|
+
"options is required but was not provided.",
|
|
775
|
+
"Pass an AddOverlayOptions with at least image_path, start_sec, "
|
|
776
|
+
"and duration_sec.",
|
|
777
|
+
).model_dump()
|
|
778
|
+
try:
|
|
779
|
+
return _add_overlay_inner(timeline, output, options).model_dump()
|
|
780
|
+
except ClipwrightError as exc:
|
|
781
|
+
return error_result(exc.code, exc.message, exc.hint).model_dump()
|
|
File without changes
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""schemas.py — Pydantic schema for AddOverlayOptions.
|
|
2
|
+
|
|
3
|
+
Defines the options model for image overlay annotation. Core shared types
|
|
4
|
+
(MediaRef, Artifact, ToolResult) are imported from clipwright.schemas and must
|
|
5
|
+
not be redefined here (§6 convention contract).
|
|
6
|
+
|
|
7
|
+
Value-range validation (start_sec>=0, duration_sec>0, opacity 0..1, etc.) is
|
|
8
|
+
intentionally NOT enforced here via Pydantic constraints, except for scale which
|
|
9
|
+
uses Field(gt=0, le=8.0) per V2-9. All other range checks are validated manually
|
|
10
|
+
inside overlay.py so that the error envelope carries a precise hint (decision OQ-1).
|
|
11
|
+
AddOverlayOptions uses extra="forbid" so unknown keys are rejected at the schema
|
|
12
|
+
boundary before business logic runs.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AddOverlayOptions(BaseModel):
|
|
21
|
+
"""Options for adding an image overlay marker to an OTIO timeline.
|
|
22
|
+
|
|
23
|
+
image_path, start_sec, and duration_sec are required. All other fields are
|
|
24
|
+
optional with sensible defaults for a centered watermark-style overlay.
|
|
25
|
+
|
|
26
|
+
Value-range validation (start_sec>=0, duration_sec>0, opacity 0..1, etc.)
|
|
27
|
+
is performed manually in _add_overlay_inner to produce precise error hints —
|
|
28
|
+
not via Pydantic constraints (decision OQ-1), except scale which has
|
|
29
|
+
Field(gt=0, le=8.0) per V2-9 (schema is the first line of defence for scale).
|
|
30
|
+
|
|
31
|
+
x/y expressions are validated against the allowlist ^[A-Za-z0-9_()+\\-*/. ]+$
|
|
32
|
+
in overlay.py (V2-5): this rejects `:;[],'` and control characters.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
|
|
36
|
+
|
|
37
|
+
image_path: str
|
|
38
|
+
"""Path to the image file to overlay.
|
|
39
|
+
|
|
40
|
+
Must be co-located in the output parent dir tree.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
start_sec: float
|
|
44
|
+
"""Start time in seconds from the beginning of the timeline. Must be >= 0."""
|
|
45
|
+
|
|
46
|
+
duration_sec: float
|
|
47
|
+
"""Duration of the image overlay in seconds. Must be > 0."""
|
|
48
|
+
|
|
49
|
+
x: str = "(W-w)/2"
|
|
50
|
+
"""Horizontal position as an ffmpeg overlay expression (uses CAPITAL W/w).
|
|
51
|
+
|
|
52
|
+
Default: horizontally centered. Validated against allowlist in overlay.py (V2-5).
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
y: str = "(H-h)/2"
|
|
56
|
+
"""Vertical position as an ffmpeg overlay expression (uses CAPITAL H/h).
|
|
57
|
+
|
|
58
|
+
Default: vertically centered. Validated against allowlist in overlay.py (V2-5).
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
scale: float = Field(default=1.0, gt=0, le=8.0)
|
|
62
|
+
"""Scale factor for the overlay image. Must be in range (0, 8.0] (V2-9).
|
|
63
|
+
|
|
64
|
+
1.0 = original size. Values > 1.0 enlarge; < 1.0 shrink.
|
|
65
|
+
Schema enforces gt=0 and le=8.0 as the first line of defence (V2-9).
|
|
66
|
+
overlay.py also validates this range manually to emit a precise hint (OQ-1).
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
opacity: float = 1.0
|
|
70
|
+
"""Opacity of the overlay image.
|
|
71
|
+
|
|
72
|
+
Range [0.0, 1.0] validated manually in overlay.py.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
fade_in_sec: float = 0.3
|
|
76
|
+
"""Fade-in duration in seconds. Must be >= 0.
|
|
77
|
+
|
|
78
|
+
Sum of fade_in_sec + fade_out_sec must not exceed duration_sec.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
fade_out_sec: float = 0.3
|
|
82
|
+
"""Fade-out duration in seconds. Must be >= 0.
|
|
83
|
+
|
|
84
|
+
Sum of fade_in_sec + fade_out_sec must not exceed duration_sec.
|
|
85
|
+
"""
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""server.py — MCP server for clipwright-overlay image overlay annotation.
|
|
2
|
+
|
|
3
|
+
Exposes a single MCP tool: clipwright_add_overlay.
|
|
4
|
+
Delegates all business logic to overlay.add_overlay; no logic here.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Annotated
|
|
10
|
+
|
|
11
|
+
from clipwright.envelope import error_result
|
|
12
|
+
from clipwright.schemas import ToolResult
|
|
13
|
+
from mcp.server.fastmcp import FastMCP
|
|
14
|
+
from mcp.types import ToolAnnotations
|
|
15
|
+
from pydantic import Field
|
|
16
|
+
|
|
17
|
+
from clipwright_overlay.overlay import add_overlay
|
|
18
|
+
from clipwright_overlay.schemas import AddOverlayOptions
|
|
19
|
+
|
|
20
|
+
mcp = FastMCP("clipwright-overlay")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@mcp.tool(
|
|
24
|
+
annotations=ToolAnnotations(
|
|
25
|
+
readOnlyHint=True,
|
|
26
|
+
destructiveHint=False,
|
|
27
|
+
idempotentHint=True,
|
|
28
|
+
openWorldHint=False,
|
|
29
|
+
)
|
|
30
|
+
)
|
|
31
|
+
def clipwright_add_overlay(
|
|
32
|
+
timeline: Annotated[str, Field(description="Input OTIO timeline file path.")],
|
|
33
|
+
output: Annotated[
|
|
34
|
+
str,
|
|
35
|
+
Field(
|
|
36
|
+
description=(
|
|
37
|
+
"Output OTIO file path where the annotated timeline is written. "
|
|
38
|
+
"Must end in .otio and differ from the input timeline path."
|
|
39
|
+
)
|
|
40
|
+
),
|
|
41
|
+
],
|
|
42
|
+
options: Annotated[
|
|
43
|
+
AddOverlayOptions | None,
|
|
44
|
+
Field(
|
|
45
|
+
description=(
|
|
46
|
+
"Image overlay options. image_path, start_sec, and duration_sec are "
|
|
47
|
+
"required; all other fields have sensible defaults."
|
|
48
|
+
)
|
|
49
|
+
),
|
|
50
|
+
] = None,
|
|
51
|
+
) -> ToolResult:
|
|
52
|
+
"""Add an image_overlay marker to an OTIO timeline.
|
|
53
|
+
|
|
54
|
+
readOnlyHint=True: this tool writes only a new .otio output file; the input
|
|
55
|
+
media and the input timeline are never modified. The new-file write is outside
|
|
56
|
+
the readOnly scope per the MCP annotation contract — readOnly refers to
|
|
57
|
+
existing resources, and the output is a freshly created file.
|
|
58
|
+
|
|
59
|
+
Later rendering by clipwright-render materializes image_overlay markers as
|
|
60
|
+
ffmpeg overlay filters. Idempotent: calling with identical options on an
|
|
61
|
+
already-annotated timeline produces applied=0 with a warning rather than
|
|
62
|
+
duplicating the marker. Multiple distinct calls accumulate image_0/image_1/...
|
|
63
|
+
markers which clipwright-render reads to apply overlay filters.
|
|
64
|
+
"""
|
|
65
|
+
if options is None:
|
|
66
|
+
return error_result(
|
|
67
|
+
"INVALID_INPUT",
|
|
68
|
+
"options is required but was not provided.",
|
|
69
|
+
(
|
|
70
|
+
"Pass options with at least image_path, start_sec, and duration_sec "
|
|
71
|
+
'(e.g., {"image_path": "/path/to/logo.png", "start_sec": 1.0, '
|
|
72
|
+
'"duration_sec": 3.0}).'
|
|
73
|
+
),
|
|
74
|
+
)
|
|
75
|
+
result = add_overlay(timeline=timeline, output=output, options=options)
|
|
76
|
+
if isinstance(result, ToolResult):
|
|
77
|
+
return result
|
|
78
|
+
return ToolResult.model_validate(result)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def main() -> None:
|
|
82
|
+
"""Entry point for the clipwright-overlay MCP server (stdio transport)."""
|
|
83
|
+
mcp.run(transport="stdio")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
if __name__ == "__main__": # pragma: no cover
|
|
87
|
+
main()
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: clipwright-overlay
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP tool for adding image overlays to an OTIO timeline.
|
|
5
|
+
Author: satoh-y-0323
|
|
6
|
+
Author-email: satoh-y-0323 <shoma.papa.0323@gmail.com>
|
|
7
|
+
License: MIT
|
|
8
|
+
Requires-Dist: clipwright>=0.3.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-overlay
|
|
16
|
+
|
|
17
|
+
MCP tool that annotates an OTIO timeline with a static image overlay (logo,
|
|
18
|
+
watermark, lower-third graphic, end card) for materialisation by `clipwright-render`.
|
|
19
|
+
|
|
20
|
+
## Overview
|
|
21
|
+
|
|
22
|
+
`clipwright-overlay` writes an `image_overlay` marker to the first video track (V1)
|
|
23
|
+
of an OTIO timeline. The marker stores the image path, position, scale, opacity, and
|
|
24
|
+
timing. `clipwright-render` reads the marker and inserts the image as an extra `-i`
|
|
25
|
+
input, building an FFmpeg filter chain that composites the image onto the video during
|
|
26
|
+
the single render pass.
|
|
27
|
+
|
|
28
|
+
**Design principle** (separation of annotation and realisation):
|
|
29
|
+
`clipwright-overlay` writes only the OTIO; it does not invoke FFmpeg or touch media
|
|
30
|
+
files. All image compositing is deferred to `clipwright-render`.
|
|
31
|
+
|
|
32
|
+
## Prerequisites
|
|
33
|
+
|
|
34
|
+
- Python 3.11 or later
|
|
35
|
+
- `clipwright` core package (shared types, envelope, OTIO utils)
|
|
36
|
+
- No FFmpeg is needed at annotation time. FFmpeg is only invoked by `clipwright-render`
|
|
37
|
+
at realisation time.
|
|
38
|
+
|
|
39
|
+
> **Note — clipwright-render >= 0.10.0 required for materialisation.**
|
|
40
|
+
> `clipwright-overlay` only *annotates* the OTIO timeline; it never invokes FFmpeg.
|
|
41
|
+
> The actual image compositing is performed by `clipwright-render`. Image overlay support
|
|
42
|
+
> was added in `clipwright-render` **0.10.0** — earlier versions will ignore the
|
|
43
|
+
> `image_overlay` markers and produce output without the overlay.
|
|
44
|
+
> Install or upgrade before calling `clipwright_render` on an annotated timeline:
|
|
45
|
+
>
|
|
46
|
+
> ```bash
|
|
47
|
+
> pip install "clipwright-render>=0.10.0"
|
|
48
|
+
> ```
|
|
49
|
+
|
|
50
|
+
## MCP Tool: `clipwright_add_overlay`
|
|
51
|
+
|
|
52
|
+
### Parameters
|
|
53
|
+
|
|
54
|
+
| Name | Type | Default | Description |
|
|
55
|
+
|------|------|---------|-------------|
|
|
56
|
+
| `timeline` | `string` | required | Input OTIO timeline file path (`.otio`). The parent directory is the co-location root for the image file. |
|
|
57
|
+
| `output` | `string` | required | Output OTIO timeline file path (`.otio`). Must differ from `timeline`. |
|
|
58
|
+
| `image_path` | `string` | required | Path to the overlay image. Must be co-located under the output timeline's parent directory (see Co-location Constraint). Supported formats: `.png`, `.jpg`, `.jpeg`, `.webp`. |
|
|
59
|
+
| `start_sec` | `float` | required | Start time in seconds (≥ 0) when the overlay becomes visible. |
|
|
60
|
+
| `duration_sec` | `float` | required | Duration in seconds (> 0) that the overlay remains visible. |
|
|
61
|
+
| `x` | `string` | `"(W-w)/2"` | Horizontal position expression (FFmpeg overlay filter syntax). `W` = base video width, `w` = overlay width. Default centres the overlay horizontally. |
|
|
62
|
+
| `y` | `string` | `"(H-h)/2"` | Vertical position expression. `H` = base video height, `h` = overlay height. Default centres the overlay vertically. |
|
|
63
|
+
| `scale` | `float` | `1.0` | Overlay scale factor relative to the original image size. Range: `(0, 8]`. `1.0` = original size. |
|
|
64
|
+
| `opacity` | `float` | `1.0` | Opacity of the overlay. Range: `[0.0, 1.0]`. `1.0` = fully opaque. |
|
|
65
|
+
| `fade_in_sec` | `float` | `0.3` | Fade-in duration in seconds (≥ 0). `0.0` = no fade-in (overlay appears immediately). |
|
|
66
|
+
| `fade_out_sec` | `float` | `0.3` | Fade-out duration in seconds (≥ 0). `0.0` = no fade-out. `fade_in_sec + fade_out_sec` must not exceed `duration_sec`. |
|
|
67
|
+
|
|
68
|
+
### Return value
|
|
69
|
+
|
|
70
|
+
```json
|
|
71
|
+
{
|
|
72
|
+
"ok": true,
|
|
73
|
+
"summary": "Added image overlay 'logo.png' at 5.0s for 10.0s. Timeline now has 1 image overlay(s). Output: output.otio.",
|
|
74
|
+
"data": {
|
|
75
|
+
"applied": 1,
|
|
76
|
+
"overlay_count": 1,
|
|
77
|
+
"start_sec": 5.0,
|
|
78
|
+
"duration_sec": 10.0
|
|
79
|
+
},
|
|
80
|
+
"artifacts": [{"role": "timeline", "path": "/absolute/path/to/output.otio", "format": "otio"}],
|
|
81
|
+
"warnings": []
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
When an identical overlay is submitted again (same parameters), `applied` returns `0`
|
|
86
|
+
and a warning is added; no duplicate marker is written (idempotency).
|
|
87
|
+
|
|
88
|
+
### Error codes (annotation time)
|
|
89
|
+
|
|
90
|
+
| Code | Cause |
|
|
91
|
+
|------|-------|
|
|
92
|
+
| `INVALID_INPUT` | `start_sec < 0`, `duration_sec ≤ 0`, `scale ≤ 0` or `scale > 8`, `opacity` outside `[0, 1]`, `fade_in_sec < 0`, `fade_out_sec < 0`, or `fade_in_sec + fade_out_sec > duration_sec` |
|
|
93
|
+
| `INVALID_INPUT` | `image_path` extension is not `.png`, `.jpg`, `.jpeg`, or `.webp` |
|
|
94
|
+
| `INVALID_INPUT` | `image_path` or `x` / `y` expression contains a control character or a prohibited character (`: ; [ ] , '`) |
|
|
95
|
+
| `INVALID_INPUT` | `output` path is identical to `timeline` path |
|
|
96
|
+
| `INVALID_INPUT` | Timeline already has 64 `image_overlay` markers (per-timeline limit) |
|
|
97
|
+
| `FILE_NOT_FOUND` | `image_path` does not exist |
|
|
98
|
+
| `PATH_NOT_ALLOWED` | `image_path` is outside the output timeline's parent directory tree |
|
|
99
|
+
| `UNSUPPORTED_OPERATION` | The input timeline has no V1 video track |
|
|
100
|
+
|
|
101
|
+
### Error codes (render time, raised by `clipwright-render`)
|
|
102
|
+
|
|
103
|
+
| Code | Cause |
|
|
104
|
+
|------|-------|
|
|
105
|
+
| `SUBPROCESS_FAILED` | The overlay image could not be decoded by FFmpeg (corrupt file or unsupported encoding). Message shows the image basename only (CWE-209). Hint: `"The overlay image may be corrupt or an unsupported format; provide a valid .png/.jpg/.jpeg/.webp."` |
|
|
106
|
+
|
|
107
|
+
## Co-location Constraint
|
|
108
|
+
|
|
109
|
+
The `image_path` **must be located under the same directory as the output `.otio` file**
|
|
110
|
+
(or in a recursive subdirectory of it).
|
|
111
|
+
|
|
112
|
+
**Why this rule exists:**
|
|
113
|
+
`clipwright-render` enforces the same co-location boundary for all resources referenced
|
|
114
|
+
in an OTIO timeline (sources, subtitles, image overlays). If the image were outside that
|
|
115
|
+
boundary, the annotation would succeed but render would immediately fail with
|
|
116
|
+
`PATH_NOT_ALLOWED`.
|
|
117
|
+
|
|
118
|
+
By enforcing co-location at annotation time, `clipwright-overlay` guarantees that any
|
|
119
|
+
`.otio` it produces will pass through `clipwright-render` without a `PATH_NOT_ALLOWED`
|
|
120
|
+
error.
|
|
121
|
+
|
|
122
|
+
**Relative-path storage (V2-3 round-trip portability):**
|
|
123
|
+
The image path is stored in the OTIO marker as a POSIX relative path from the output
|
|
124
|
+
timeline's parent directory (e.g. `images/logo.png`). When `clipwright-render` reads the
|
|
125
|
+
marker, it reconstructs the absolute path using the timeline file's parent directory as the
|
|
126
|
+
base. This means projects remain portable when the entire directory tree is moved or copied
|
|
127
|
+
to another location, as long as the relative positions of the timeline and image files are
|
|
128
|
+
preserved.
|
|
129
|
+
|
|
130
|
+
```
|
|
131
|
+
project/
|
|
132
|
+
logo.png ← allowed (same directory, stored as "logo.png")
|
|
133
|
+
assets/
|
|
134
|
+
watermark.png ← allowed (subdirectory, stored as "assets/watermark.png")
|
|
135
|
+
output.otio ← output timeline
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
```
|
|
139
|
+
/other/path/logo.png ← PATH_NOT_ALLOWED
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Position Expressions
|
|
143
|
+
|
|
144
|
+
`x` and `y` accept FFmpeg overlay filter expressions. The following variables are
|
|
145
|
+
available at render time:
|
|
146
|
+
|
|
147
|
+
| Variable | Meaning |
|
|
148
|
+
|----------|---------|
|
|
149
|
+
| `W` | Base video width in pixels |
|
|
150
|
+
| `H` | Base video height in pixels |
|
|
151
|
+
| `w` | Overlay image width (after scaling) |
|
|
152
|
+
| `h` | Overlay image height (after scaling) |
|
|
153
|
+
| `main_w` | Alias for `W` |
|
|
154
|
+
| `main_h` | Alias for `H` |
|
|
155
|
+
| `overlay_w` | Alias for `w` |
|
|
156
|
+
| `overlay_h` | Alias for `h` |
|
|
157
|
+
|
|
158
|
+
### Common position examples
|
|
159
|
+
|
|
160
|
+
| Position | `x` | `y` |
|
|
161
|
+
|----------|-----|-----|
|
|
162
|
+
| Centre | `(W-w)/2` (default) | `(H-h)/2` (default) |
|
|
163
|
+
| Top-left (10 px margin) | `10` | `10` |
|
|
164
|
+
| Top-right (10 px margin) | `W-w-10` | `10` |
|
|
165
|
+
| Bottom-left (10 px margin) | `10` | `H-h-10` |
|
|
166
|
+
| Bottom-right (10 px margin) | `W-w-10` | `H-h-10` |
|
|
167
|
+
| Bottom-centre | `(W-w)/2` | `H-h-10` |
|
|
168
|
+
|
|
169
|
+
**Allowed characters in `x` / `y`:** letters, digits, `_`, `(`, `)`, `+`, `-`, `*`,
|
|
170
|
+
`/`, `.`, and space. Characters `: ; [ ] , '` and control characters are prohibited to
|
|
171
|
+
prevent FFmpeg filtergraph injection.
|
|
172
|
+
|
|
173
|
+
## `readOnlyHint` Rationale
|
|
174
|
+
|
|
175
|
+
`clipwright_add_overlay` carries `readOnlyHint=true` in its MCP annotations.
|
|
176
|
+
|
|
177
|
+
`clipwright-overlay` writes only a new `.otio` file; the input media, the input
|
|
178
|
+
timeline, and the image file are never modified. The new-file write is outside the
|
|
179
|
+
readOnly scope (consistent with `clipwright-sequence`, `clipwright-trim`,
|
|
180
|
+
`clipwright-silence`, and other annotation tools). This signals to AI orchestrators
|
|
181
|
+
that the tool is safe for speculative execution and automatic retry without side effects.
|
|
182
|
+
|
|
183
|
+
## Fade Chain
|
|
184
|
+
|
|
185
|
+
The opacity and fade effect are implemented in a single filter chain per overlay:
|
|
186
|
+
|
|
187
|
+
```
|
|
188
|
+
[{N}:v]scale=iw*{scale}:-2,format=rgba,colorchannelmixer=aa={opacity},
|
|
189
|
+
fade=t=in:st={start}:d={fade_in}:alpha=1,fade=t=out:st={end-fade_out}:d={fade_out}:alpha=1[ov{i}];
|
|
190
|
+
{base}[ov{i}]overlay=x='{x}':y='{y}':enable='between(t,{start},{end})'[outvimg{i}]
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
- `scale=iw*{scale}:-2` — scale the image width by `scale`; height is computed
|
|
194
|
+
automatically with even rounding (`-2`) for yuv420p compatibility.
|
|
195
|
+
- `format=rgba` — add an alpha channel to the image.
|
|
196
|
+
- `colorchannelmixer=aa={opacity}` — set constant opacity (`aa` accepts a constant
|
|
197
|
+
double only; time-varying expressions are not supported by FFmpeg).
|
|
198
|
+
- `fade=t=in:...:alpha=1` — ramp the alpha from 0 to 1 over `fade_in_sec`. When
|
|
199
|
+
`fade_in_sec == 0` this segment is omitted entirely (no degenerate `d=0` filter).
|
|
200
|
+
- `fade=t=out:...:alpha=1` — ramp the alpha from 1 to 0 over `fade_out_sec`. Omitted
|
|
201
|
+
when `fade_out_sec == 0`.
|
|
202
|
+
- The `fade:alpha=1` flag multiplies the existing alpha channel, so the effective
|
|
203
|
+
alpha ramps from `0 → opacity → 0` across the fade windows.
|
|
204
|
+
- `overlay=x='{x}':y='{y}':enable='between(t,{start},{end})'` — composite the
|
|
205
|
+
prepared image onto the base video within the time window. `x` / `y` are
|
|
206
|
+
single-quoted (consistent with `enable` and `drawtext`).
|
|
207
|
+
|
|
208
|
+
This chain is inserted after the `drawtext` filter, so image overlays appear on top
|
|
209
|
+
of text overlays.
|
|
210
|
+
|
|
211
|
+
## Two-Phase Workflow
|
|
212
|
+
|
|
213
|
+
```
|
|
214
|
+
clipwright_add_overlay(timeline, output, image_path, ...) # Phase 1 — annotate OTIO
|
|
215
|
+
│
|
|
216
|
+
▼ OTIO timeline with image_overlay marker
|
|
217
|
+
clipwright_render(timeline, output_media) # Phase 2 — composite and encode
|
|
218
|
+
│
|
|
219
|
+
▼ video with image/logo composited
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
### Stacking multiple overlays
|
|
223
|
+
|
|
224
|
+
Multiple calls accumulate overlays on the same timeline:
|
|
225
|
+
|
|
226
|
+
```python
|
|
227
|
+
# Add a channel logo (top-right, always visible)
|
|
228
|
+
r1 = await session.call_tool("clipwright_add_overlay", {
|
|
229
|
+
"timeline": "/project/edit.otio",
|
|
230
|
+
"output": "/project/with_logo.otio",
|
|
231
|
+
"image_path": "/project/assets/logo.png",
|
|
232
|
+
"start_sec": 0.0,
|
|
233
|
+
"duration_sec": 120.0,
|
|
234
|
+
"x": "W-w-20",
|
|
235
|
+
"y": "20",
|
|
236
|
+
"scale": 0.15,
|
|
237
|
+
"opacity": 0.8,
|
|
238
|
+
"fade_in_sec": 0.0,
|
|
239
|
+
"fade_out_sec": 0.0
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
# Add a lower-third graphic at a specific moment
|
|
243
|
+
r2 = await session.call_tool("clipwright_add_overlay", {
|
|
244
|
+
"timeline": "/project/with_logo.otio",
|
|
245
|
+
"output": "/project/with_logo_lowerthird.otio",
|
|
246
|
+
"image_path": "/project/assets/lower_third.png",
|
|
247
|
+
"start_sec": 15.0,
|
|
248
|
+
"duration_sec": 5.0,
|
|
249
|
+
"x": "(W-w)/2",
|
|
250
|
+
"y": "H-h-80",
|
|
251
|
+
"scale": 0.6,
|
|
252
|
+
"opacity": 1.0,
|
|
253
|
+
"fade_in_sec": 0.3,
|
|
254
|
+
"fade_out_sec": 0.3
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
# Render
|
|
258
|
+
render_result = await session.call_tool("clipwright_render", {
|
|
259
|
+
"timeline": "/project/with_logo_lowerthird.otio",
|
|
260
|
+
"output": "/project/final.mp4"
|
|
261
|
+
})
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
## MCP Client Registration
|
|
265
|
+
|
|
266
|
+
`clipwright-overlay` does not require FFmpeg at annotation time, so no environment
|
|
267
|
+
variables are needed in the MCP server entry. Register it in your MCP client
|
|
268
|
+
configuration (`.mcp.json` / `claude_desktop_config.json`):
|
|
269
|
+
|
|
270
|
+
```json
|
|
271
|
+
{
|
|
272
|
+
"mcpServers": {
|
|
273
|
+
"clipwright-overlay": {
|
|
274
|
+
"command": "clipwright-overlay"
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
`clipwright-render` (which materialises the OTIO into video) still requires
|
|
281
|
+
`CLIPWRIGHT_FFMPEG`.
|
|
282
|
+
|
|
283
|
+
## Installation
|
|
284
|
+
|
|
285
|
+
Within a uv workspace:
|
|
286
|
+
|
|
287
|
+
```bash
|
|
288
|
+
uv run --package clipwright-overlay clipwright-overlay
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
Or install from PyPI:
|
|
292
|
+
|
|
293
|
+
```bash
|
|
294
|
+
pip install clipwright-overlay
|
|
295
|
+
clipwright-overlay
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
## License
|
|
299
|
+
|
|
300
|
+
MIT — See [LICENSE](../LICENSE) for details.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
clipwright_overlay/__init__.py,sha256=YoC3SnGwU0geJPwPQdJUt5yE9XvOlLX0UXqf9dVNwY4,105
|
|
2
|
+
clipwright_overlay/overlay.py,sha256=-CSo1qMEPLw7PTxEQsB3hCKb2kdrg-JcisYeHwq4VgY,30432
|
|
3
|
+
clipwright_overlay/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
clipwright_overlay/schemas.py,sha256=-e6-twohoMJ7EGH68ejivCQ-XEWcMIpjMQiPbrALKLE,3116
|
|
5
|
+
clipwright_overlay/server.py,sha256=csS4W7tPsGOxzv4ZJZkfsJ8Hlx82NzNbFERMFZkuYtU,2980
|
|
6
|
+
clipwright_overlay-0.1.0.dist-info/WHEEL,sha256=oBsDExVIEya4llboy9Ce1l6on8xt3GrtT29y6pYVypw,81
|
|
7
|
+
clipwright_overlay-0.1.0.dist-info/entry_points.txt,sha256=FvA4M4qt0YofBsjdgAcPvEFgopWXon0UrJYxVz7pvko,71
|
|
8
|
+
clipwright_overlay-0.1.0.dist-info/METADATA,sha256=9ScbvDlradLvx8nsuoN-1xQptaG9nW1Z752G7Msd1oY,12140
|
|
9
|
+
clipwright_overlay-0.1.0.dist-info/RECORD,,
|