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.
@@ -0,0 +1,3 @@
1
+ """clipwright-overlay: MCP tool for adding image overlays to an OTIO timeline."""
2
+
3
+ __version__ = "0.1.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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.23
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ clipwright-overlay = clipwright_overlay.server:main
3
+