clipwright-text 0.1.0__tar.gz

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,142 @@
1
+ Metadata-Version: 2.3
2
+ Name: clipwright-text
3
+ Version: 0.1.0
4
+ Summary: MCP tool for adding text 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-text
16
+
17
+ MCP tool for annotating an OTIO timeline with text overlay markers.
18
+ Text is not rendered here — `clipwright-render` reads the markers and applies
19
+ `drawtext` filters when producing the output video.
20
+
21
+ ## Overview
22
+
23
+ `clipwright-text` is part of the [clipwright](https://github.com/satoh-y-0323/clipwright)
24
+ suite. It is designed for **AI agents**, not humans — there is no GUI or
25
+ interactive CLI. All interaction is via the MCP (Model Context Protocol) `stdio`
26
+ transport.
27
+
28
+ ## Available Tools
29
+
30
+ | Tool | Description |
31
+ |------|-------------|
32
+ | `clipwright_add_text` | Append a `text_overlay` marker to an OTIO timeline for later rendering. |
33
+
34
+ ## How It Works
35
+
36
+ 1. AI calls `clipwright_add_text(timeline, output, options)` once per text overlay.
37
+ 2. The tool appends a `text_overlay` marker (name `text_0`, `text_1`, …) to the
38
+ first video track of the timeline and writes a new `.otio` file to `output`.
39
+ 3. The input timeline is never modified (non-destructive).
40
+ 4. Repeated calls with identical options are idempotent — the second call returns
41
+ `applied=0` with a warning instead of duplicating the marker.
42
+ 5. After annotating, pass the output OTIO to `clipwright-render` which converts
43
+ the markers into `drawtext` ffmpeg filters and bakes the text into the video.
44
+
45
+ ## MCP Client Registration
46
+
47
+ ### Claude Desktop (`claude_desktop_config.json`)
48
+
49
+ ```json
50
+ {
51
+ "mcpServers": {
52
+ "clipwright-text": {
53
+ "command": "clipwright-text",
54
+ "args": []
55
+ }
56
+ }
57
+ }
58
+ ```
59
+
60
+ If `clipwright-text` is not on `PATH`, use the full path to the script or the
61
+ Python interpreter:
62
+
63
+ ```json
64
+ {
65
+ "mcpServers": {
66
+ "clipwright-text": {
67
+ "command": "/path/to/.venv/bin/clipwright-text",
68
+ "args": []
69
+ }
70
+ }
71
+ }
72
+ ```
73
+
74
+ ## `clipwright_add_text` Reference
75
+
76
+ ### Parameters
77
+
78
+ | Parameter | Type | Required | Description |
79
+ |-----------|------|----------|-------------|
80
+ | `timeline` | `str` | Yes | Path to the input `.otio` timeline file. |
81
+ | `output` | `str` | Yes | Path for the new `.otio` output (must end in `.otio`, must differ from `timeline`). |
82
+ | `options` | `AddTextOptions` | Yes | Text overlay options (see below). |
83
+
84
+ ### `AddTextOptions` Fields
85
+
86
+ | Field | Type | Default | Description |
87
+ |-------|------|---------|-------------|
88
+ | `text` | `str` | — | Text to display. Single-line; no newlines or control characters. |
89
+ | `start_sec` | `float` | — | Start time in seconds (>= 0). |
90
+ | `duration_sec` | `float` | — | Duration in seconds (> 0). |
91
+ | `x` | `str` | `"(w-tw)/2"` | Horizontal position (ffmpeg drawtext expression). |
92
+ | `y` | `str` | `"h-th-40"` | Vertical position (ffmpeg drawtext expression). |
93
+ | `font_size` | `int` | `48` | Font size in points (> 0). |
94
+ | `font_color` | `str` | `"white"` | Font color: named color, `#RRGGBB`, or `name@alpha`. |
95
+ | `box` | `bool` | `False` | Draw a background box behind the text. |
96
+ | `box_color` | `str` | `"black@0.5"` | Background box color. |
97
+ | `fade_in_sec` | `float` | `0.3` | Fade-in duration (>= 0; `fade_in + fade_out <= duration`). |
98
+ | `fade_out_sec` | `float` | `0.3` | Fade-out duration (>= 0). |
99
+ | `font_path` | `str \| None` | `None` | Absolute path to a `.ttf`/`.otf` font file. `None` lets `clipwright-render` resolve a platform default. |
100
+
101
+ ### Return Value
102
+
103
+ ```json
104
+ {
105
+ "ok": true,
106
+ "summary": "Added text overlay \"Hello\" at 1.0s for 3.0s. Timeline now has 1 text overlay(s). Output: out.otio.",
107
+ "data": {
108
+ "applied": 1,
109
+ "overlay_count": 1,
110
+ "start_sec": 1.0,
111
+ "duration_sec": 3.0
112
+ },
113
+ "artifacts": [
114
+ { "role": "timeline", "path": "/abs/path/out.otio", "format": "otio" }
115
+ ],
116
+ "warnings": []
117
+ }
118
+ ```
119
+
120
+ On error:
121
+
122
+ ```json
123
+ {
124
+ "ok": false,
125
+ "error": {
126
+ "code": "INVALID_INPUT",
127
+ "message": "...",
128
+ "hint": "..."
129
+ }
130
+ }
131
+ ```
132
+
133
+ ## Requirements
134
+
135
+ - Python >= 3.11
136
+ - [opentimelineio](https://github.com/AcademySoftwareFoundation/OpenTimelineIO) >= 0.18
137
+ - [mcp](https://github.com/modelcontextprotocol/python-sdk) >= 1.27.2
138
+ - [clipwright](https://pypi.org/project/clipwright/) >= 0.3.0 (core library)
139
+
140
+ ## License
141
+
142
+ MIT
@@ -0,0 +1,128 @@
1
+ # clipwright-text
2
+
3
+ MCP tool for annotating an OTIO timeline with text overlay markers.
4
+ Text is not rendered here — `clipwright-render` reads the markers and applies
5
+ `drawtext` filters when producing the output video.
6
+
7
+ ## Overview
8
+
9
+ `clipwright-text` is part of the [clipwright](https://github.com/satoh-y-0323/clipwright)
10
+ suite. It is designed for **AI agents**, not humans — there is no GUI or
11
+ interactive CLI. All interaction is via the MCP (Model Context Protocol) `stdio`
12
+ transport.
13
+
14
+ ## Available Tools
15
+
16
+ | Tool | Description |
17
+ |------|-------------|
18
+ | `clipwright_add_text` | Append a `text_overlay` marker to an OTIO timeline for later rendering. |
19
+
20
+ ## How It Works
21
+
22
+ 1. AI calls `clipwright_add_text(timeline, output, options)` once per text overlay.
23
+ 2. The tool appends a `text_overlay` marker (name `text_0`, `text_1`, …) to the
24
+ first video track of the timeline and writes a new `.otio` file to `output`.
25
+ 3. The input timeline is never modified (non-destructive).
26
+ 4. Repeated calls with identical options are idempotent — the second call returns
27
+ `applied=0` with a warning instead of duplicating the marker.
28
+ 5. After annotating, pass the output OTIO to `clipwright-render` which converts
29
+ the markers into `drawtext` ffmpeg filters and bakes the text into the video.
30
+
31
+ ## MCP Client Registration
32
+
33
+ ### Claude Desktop (`claude_desktop_config.json`)
34
+
35
+ ```json
36
+ {
37
+ "mcpServers": {
38
+ "clipwright-text": {
39
+ "command": "clipwright-text",
40
+ "args": []
41
+ }
42
+ }
43
+ }
44
+ ```
45
+
46
+ If `clipwright-text` is not on `PATH`, use the full path to the script or the
47
+ Python interpreter:
48
+
49
+ ```json
50
+ {
51
+ "mcpServers": {
52
+ "clipwright-text": {
53
+ "command": "/path/to/.venv/bin/clipwright-text",
54
+ "args": []
55
+ }
56
+ }
57
+ }
58
+ ```
59
+
60
+ ## `clipwright_add_text` Reference
61
+
62
+ ### Parameters
63
+
64
+ | Parameter | Type | Required | Description |
65
+ |-----------|------|----------|-------------|
66
+ | `timeline` | `str` | Yes | Path to the input `.otio` timeline file. |
67
+ | `output` | `str` | Yes | Path for the new `.otio` output (must end in `.otio`, must differ from `timeline`). |
68
+ | `options` | `AddTextOptions` | Yes | Text overlay options (see below). |
69
+
70
+ ### `AddTextOptions` Fields
71
+
72
+ | Field | Type | Default | Description |
73
+ |-------|------|---------|-------------|
74
+ | `text` | `str` | — | Text to display. Single-line; no newlines or control characters. |
75
+ | `start_sec` | `float` | — | Start time in seconds (>= 0). |
76
+ | `duration_sec` | `float` | — | Duration in seconds (> 0). |
77
+ | `x` | `str` | `"(w-tw)/2"` | Horizontal position (ffmpeg drawtext expression). |
78
+ | `y` | `str` | `"h-th-40"` | Vertical position (ffmpeg drawtext expression). |
79
+ | `font_size` | `int` | `48` | Font size in points (> 0). |
80
+ | `font_color` | `str` | `"white"` | Font color: named color, `#RRGGBB`, or `name@alpha`. |
81
+ | `box` | `bool` | `False` | Draw a background box behind the text. |
82
+ | `box_color` | `str` | `"black@0.5"` | Background box color. |
83
+ | `fade_in_sec` | `float` | `0.3` | Fade-in duration (>= 0; `fade_in + fade_out <= duration`). |
84
+ | `fade_out_sec` | `float` | `0.3` | Fade-out duration (>= 0). |
85
+ | `font_path` | `str \| None` | `None` | Absolute path to a `.ttf`/`.otf` font file. `None` lets `clipwright-render` resolve a platform default. |
86
+
87
+ ### Return Value
88
+
89
+ ```json
90
+ {
91
+ "ok": true,
92
+ "summary": "Added text overlay \"Hello\" at 1.0s for 3.0s. Timeline now has 1 text overlay(s). Output: out.otio.",
93
+ "data": {
94
+ "applied": 1,
95
+ "overlay_count": 1,
96
+ "start_sec": 1.0,
97
+ "duration_sec": 3.0
98
+ },
99
+ "artifacts": [
100
+ { "role": "timeline", "path": "/abs/path/out.otio", "format": "otio" }
101
+ ],
102
+ "warnings": []
103
+ }
104
+ ```
105
+
106
+ On error:
107
+
108
+ ```json
109
+ {
110
+ "ok": false,
111
+ "error": {
112
+ "code": "INVALID_INPUT",
113
+ "message": "...",
114
+ "hint": "..."
115
+ }
116
+ }
117
+ ```
118
+
119
+ ## Requirements
120
+
121
+ - Python >= 3.11
122
+ - [opentimelineio](https://github.com/AcademySoftwareFoundation/OpenTimelineIO) >= 0.18
123
+ - [mcp](https://github.com/modelcontextprotocol/python-sdk) >= 1.27.2
124
+ - [clipwright](https://pypi.org/project/clipwright/) >= 0.3.0 (core library)
125
+
126
+ ## License
127
+
128
+ MIT
@@ -0,0 +1,83 @@
1
+ [project]
2
+ name = "clipwright-text"
3
+ version = "0.1.0"
4
+ description = "MCP tool for adding text overlays to an OTIO timeline."
5
+ readme = "README.md"
6
+ license = { text = "MIT" }
7
+ authors = [
8
+ { name = "satoh-y-0323", email = "shoma.papa.0323@gmail.com" }
9
+ ]
10
+ requires-python = ">=3.11"
11
+ dependencies = [
12
+ "clipwright>=0.3.0",
13
+ "mcp[cli]>=1.27.2",
14
+ "opentimelineio>=0.18",
15
+ "pydantic>=2",
16
+ ]
17
+
18
+ [project.scripts]
19
+ clipwright-text = "clipwright_text.server:main"
20
+
21
+ [build-system]
22
+ requires = ["uv_build>=0.11.19,<0.12.0"]
23
+ build-backend = "uv_build"
24
+
25
+ [dependency-groups]
26
+ dev = [
27
+ "mypy>=2.1.0",
28
+ "pytest>=9.0.3",
29
+ "pytest-cov>=7.1.0",
30
+ "pytest-mock>=3.15.1",
31
+ "ruff>=0.15.16",
32
+ ]
33
+
34
+ [tool.uv.sources]
35
+ clipwright = { workspace = true }
36
+
37
+ # --- Ruff ---
38
+ [tool.ruff]
39
+ target-version = "py311"
40
+ line-length = 88
41
+
42
+ [tool.ruff.lint]
43
+ select = ["E", "F", "W", "I", "UP", "B", "C4", "SIM"]
44
+ ignore = []
45
+
46
+ [tool.ruff.lint.per-file-ignores]
47
+ # Allow E501 for English docstrings/comments in test files.
48
+ # Allow I001 for test files authored in Red phase (import order is not our change).
49
+ "tests/*.py" = ["E501", "I001"]
50
+
51
+ [tool.ruff.format]
52
+
53
+ # --- mypy ---
54
+ [tool.mypy]
55
+ python_version = "3.11"
56
+ strict = true
57
+ warn_return_any = true
58
+ warn_unused_configs = true
59
+ disallow_untyped_defs = true
60
+ disallow_any_generics = true
61
+
62
+ [[tool.mypy.overrides]]
63
+ module = "opentimelineio.*"
64
+ ignore_missing_imports = true
65
+
66
+ # --- pytest ---
67
+ [tool.pytest.ini_options]
68
+ testpaths = ["tests"]
69
+ addopts = "--strict-markers -q"
70
+ asyncio_mode = "strict"
71
+ markers = [
72
+ "integration: integration test requiring actual ffmpeg/ffprobe binaries",
73
+ "slow: test with long execution time",
74
+ ]
75
+
76
+ # --- coverage ---
77
+ [tool.coverage.run]
78
+ source = ["clipwright_text"]
79
+ omit = ["tests/*"]
80
+
81
+ [tool.coverage.report]
82
+ show_missing = true
83
+ skip_covered = false
@@ -0,0 +1,3 @@
1
+ """clipwright-text: MCP tool for adding text overlays to an OTIO timeline."""
2
+
3
+ __version__ = "0.1.0"
File without changes
@@ -0,0 +1,91 @@
1
+ """schemas.py — Pydantic schema for AddTextOptions.
2
+
3
+ Defines the options model for text 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, etc.) is intentionally
8
+ NOT enforced here via Pydantic constraints. It is validated manually inside
9
+ _add_text_inner so that the error envelope carries a precise hint (decision OQ-1).
10
+ AddTextOptions uses extra="forbid" so unknown keys are rejected at the schema
11
+ boundary before business logic runs.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Annotated
17
+
18
+ from pydantic import BaseModel, ConfigDict, Field
19
+
20
+
21
+ class AddTextOptions(BaseModel):
22
+ """Options for adding a text overlay marker to an OTIO timeline.
23
+
24
+ text, start_sec, and duration_sec are required. All other fields are
25
+ optional with sensible defaults for a lower-third subtitle-style overlay.
26
+
27
+ Value-range validation (start_sec>=0, duration_sec>0, font_size>0, etc.)
28
+ is performed manually in _add_text_inner to produce precise error hints —
29
+ not via Pydantic constraints (decision OQ-1).
30
+
31
+ Color fields (font_color, box_color) accept named colors, #RRGGBB, or
32
+ name@alpha format. Validation against the allowlist is done in
33
+ _add_text_inner.
34
+ """
35
+
36
+ model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
37
+
38
+ text: str
39
+ """Text to display. Must be non-empty single-line (no newlines or control chars)."""
40
+
41
+ start_sec: float
42
+ """Start time in seconds from the beginning of the timeline. Must be >= 0."""
43
+
44
+ duration_sec: float
45
+ """Duration of the text overlay in seconds. Must be > 0."""
46
+
47
+ x: str = "(w-tw)/2"
48
+ """Horizontal position as an ffmpeg drawtext expression.
49
+
50
+ Default: horizontally centered.
51
+ """
52
+
53
+ y: str = "h-th-40"
54
+ """Vertical position as an ffmpeg drawtext expression. Default: lower-third."""
55
+
56
+ font_size: int = 48
57
+ """Font size in points. Must be > 0."""
58
+
59
+ font_color: str = "white"
60
+ """Font color. Named color, #RRGGBB, or name@alpha (e.g. white, #FFCC00, black@0.5).
61
+
62
+ Validated against the allowlist ^[A-Za-z0-9#@._-]+$ in _add_text_inner.
63
+ """
64
+
65
+ box: bool = False
66
+ """Whether to draw a background box behind the text."""
67
+
68
+ box_color: str = "black@0.5"
69
+ """Background box color. Named color, #RRGGBB, or name@alpha."""
70
+
71
+ fade_in_sec: float = 0.3
72
+ """Fade-in duration in seconds. Must be >= 0.
73
+
74
+ Sum of fade_in_sec + fade_out_sec must not exceed duration_sec.
75
+ """
76
+
77
+ fade_out_sec: float = 0.3
78
+ """Fade-out duration in seconds. Must be >= 0.
79
+
80
+ Sum of fade_in_sec + fade_out_sec must not exceed duration_sec.
81
+ """
82
+
83
+ font_path: Annotated[str | None, Field(max_length=4096)] = None
84
+ """Absolute path to a .ttf/.otf font file.
85
+
86
+ When None, clipwright-render resolves a platform default font.
87
+ Max length 4096 characters (OS path limit upper bound). Character-level
88
+ validation (single-quotes, newlines, control chars) is performed in
89
+ _validate_text_overlay_fields — keep in sync with render-side
90
+ _marker_to_text_overlay font_path validator.
91
+ """
@@ -0,0 +1,79 @@
1
+ """server.py — MCP server for clipwright-text text overlay annotation.
2
+
3
+ Exposes a single MCP tool: clipwright_add_text.
4
+ Delegates all business logic to text.add_text; 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_text.schemas import AddTextOptions
18
+ from clipwright_text.text import add_text
19
+
20
+ mcp = FastMCP("clipwright-text")
21
+
22
+
23
+ @mcp.tool(
24
+ annotations=ToolAnnotations(
25
+ readOnlyHint=False,
26
+ destructiveHint=False,
27
+ idempotentHint=True,
28
+ openWorldHint=False,
29
+ )
30
+ )
31
+ def clipwright_add_text(
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
+ AddTextOptions | None,
44
+ Field(
45
+ description=(
46
+ "Text overlay options. text, start_sec, and duration_sec are "
47
+ "required; all other fields have sensible defaults."
48
+ )
49
+ ),
50
+ ] = None,
51
+ ) -> ToolResult:
52
+ """Add a text_overlay marker to an OTIO timeline.
53
+
54
+ Later rendering by clipwright-render converts the marker to a drawtext filter.
55
+ Writes a new OTIO timeline to output; the input timeline is never modified.
56
+ Idempotent: calling with identical options on an already-annotated timeline
57
+ produces applied=0 with a warning rather than duplicating the marker.
58
+ Multiple distinct calls accumulate markers (text_0, text_1, ...) which
59
+ clipwright-render reads to apply drawtext filters.
60
+ """
61
+ if options is None:
62
+ return error_result(
63
+ "INVALID_INPUT",
64
+ "options is required but was not provided.",
65
+ (
66
+ "Pass options with at least text, start_sec, and duration_sec "
67
+ '(e.g., {"text": "Hello", "start_sec": 1.0, "duration_sec": 3.0}).'
68
+ ),
69
+ )
70
+ return add_text(timeline=timeline, output=output, options=options)
71
+
72
+
73
+ def main() -> None:
74
+ """Entry point for the clipwright-text MCP server (stdio transport)."""
75
+ mcp.run(transport="stdio")
76
+
77
+
78
+ if __name__ == "__main__": # pragma: no cover
79
+ main()
@@ -0,0 +1,614 @@
1
+ """text.py — clipwright-text orchestration layer.
2
+
3
+ Handles the full flow: input validation -> load timeline -> validate options
4
+ -> idempotency check -> add text_overlay marker -> save timeline -> return envelope.
5
+
6
+ Design decisions:
7
+ - _add_text_inner() is the raising implementation; add_text() 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
+ - Color allowlist ^[A-Za-z0-9#@._-]+$ prevents filtergraph injection (FR-5/ADR-T7).
11
+ - Idempotency: exact duplicate (all metadata fields match) -> no-op with warning.
12
+ - Non-destructive: input OTIO bytes are never modified; output is always new.
13
+ - Rate determination (OQ-2): first clip source_range -> existing text_overlay
14
+ marker rate -> fallback 1000.0 with warning.
15
+ - Boundary check _check_output_within_timeline_dir is a local copy of the
16
+ clipwright-speed implementation to avoid cross-package imports.
17
+ When changing the logic here, ensure behaviour remains in sync with
18
+ clipwright-speed's _check_output_within_timeline_dir; the two functions must
19
+ enforce the same boundary contract.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import collections.abc
25
+ import re
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ import opentimelineio as otio
30
+ from clipwright.envelope import error_result, ok_result
31
+ from clipwright.errors import ClipwrightError, ErrorCode
32
+ from clipwright.otio_utils import add_marker, get_markers, load_timeline, save_timeline
33
+ from clipwright.schemas import RationalTimeModel, TimeRangeModel, ToolResult
34
+
35
+ from clipwright_text import __version__
36
+ from clipwright_text.schemas import AddTextOptions
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Color allowlist (shared with clipwright-render; keep in sync — ADR-T4/ADR-T7)
40
+ # Permits: named colors, #RRGGBB, #RRGGBBAA, name@alpha.
41
+ # Excludes: spaces, single-quotes, colon, comma (filtergraph separators).
42
+ # clipwright-render counterpart: _COLOR_ALLOWLIST_RE in plan.py
43
+ # ---------------------------------------------------------------------------
44
+ _COLOR_PATTERN = re.compile(r"^[A-Za-z0-9#@._-]+$")
45
+
46
+ # Control-character pattern for text / position expressions / font_path.
47
+ # Includes: NUL-US (\x00-\x1f), DEL (\x7f), and line terminators (\n, \r).
48
+ _CONTROL_CHAR_PATTERN = re.compile(r"[\x00-\x1f\x7f]")
49
+
50
+ # Tolerance for float comparison in idempotency checks (rate-invariant).
51
+ # Keep in sync with _is_duplicate_overlay float comparison logic.
52
+ _IDEMPOTENCY_EPS: float = 1e-6
53
+
54
+
55
+ # ===========================================================================
56
+ # Path boundary helper (local copy; keep in sync with clipwright-speed)
57
+ # ===========================================================================
58
+
59
+
60
+ def _check_output_within_timeline_dir(timeline: Path, output: Path) -> None:
61
+ """Verify that output is within the timeline's parent directory tree.
62
+
63
+ Mirrors clipwright-speed _check_output_within_timeline_dir boundary contract.
64
+ Allows recursive subdirectories; raises PATH_NOT_ALLOWED only when the
65
+ resolved output is outside the timeline directory tree.
66
+
67
+ Intentionally re-implemented locally to avoid cross-package import of
68
+ clipwright-speed (NR-L-4: cross-package imports between satellite tools
69
+ create tight coupling and break independent packaging/deployment).
70
+ When changing the logic here, ensure the behaviour remains in sync with
71
+ clipwright-speed's _check_output_within_timeline_dir; the two functions
72
+ must enforce the same boundary contract.
73
+
74
+ Args:
75
+ timeline: Path to the input OTIO timeline file.
76
+ output: Output path to validate against the boundary.
77
+
78
+ Raises:
79
+ ClipwrightError: PATH_NOT_ALLOWED when output is outside the
80
+ timeline's parent directory tree.
81
+ """
82
+ try:
83
+ allowed_base = timeline.parent.resolve()
84
+ target_resolved = output.resolve()
85
+ target_str = str(target_resolved)
86
+ base_str = str(allowed_base)
87
+ if not (
88
+ target_str == base_str
89
+ or target_str.startswith(base_str + "/")
90
+ or target_str.startswith(base_str + "\\")
91
+ ):
92
+ raise ClipwrightError(
93
+ code=ErrorCode.PATH_NOT_ALLOWED,
94
+ message="Output path points outside the project boundary.",
95
+ hint=(
96
+ "Place the output file within the same directory as the "
97
+ "OTIO timeline, or in a subdirectory of it."
98
+ ),
99
+ )
100
+ except ClipwrightError:
101
+ raise
102
+ except OSError:
103
+ # resolve() failed (network path, symlink loop, etc.): fall back to
104
+ # absolute()-based best-effort comparison.
105
+ try:
106
+ allowed_base_abs = str(timeline.parent.absolute())
107
+ target_abs = str(output.absolute())
108
+ if not (
109
+ target_abs == allowed_base_abs
110
+ or target_abs.startswith(allowed_base_abs + "/")
111
+ or target_abs.startswith(allowed_base_abs + "\\")
112
+ ):
113
+ raise ClipwrightError(
114
+ code=ErrorCode.PATH_NOT_ALLOWED,
115
+ message="Output path points outside the project boundary.",
116
+ hint=(
117
+ "Place the output file within the same directory as the "
118
+ "OTIO timeline, or in a subdirectory of it."
119
+ ),
120
+ )
121
+ except ClipwrightError:
122
+ raise
123
+ except OSError:
124
+ # Skip only when absolute() also fails (truly unresolvable path).
125
+ pass
126
+
127
+
128
+ # ===========================================================================
129
+ # Validation helpers
130
+ # ===========================================================================
131
+
132
+
133
+ def _validate_text_overlay_fields(options: AddTextOptions) -> None:
134
+ """Validate value-range, text content, color, and position expression fields.
135
+
136
+ Validation order (fixed to keep error messages deterministic — ADR-T4):
137
+ 1. Value ranges (start_sec, duration_sec, font_size, fade_in_sec, fade_out_sec,
138
+ fade sum)
139
+ 2. text content (empty, control characters)
140
+ 3. Color allowlist (font_color, box_color)
141
+ 4. Position expression control characters (x, y)
142
+
143
+ All violations raise ClipwrightError(INVALID_INPUT).
144
+
145
+ Args:
146
+ options: AddTextOptions to validate.
147
+
148
+ Raises:
149
+ ClipwrightError: INVALID_INPUT on the first validation failure.
150
+ """
151
+ # --- 1. Value ranges ---
152
+ if options.start_sec < 0:
153
+ raise ClipwrightError(
154
+ code=ErrorCode.INVALID_INPUT,
155
+ message="Text start time must be 0 or greater.",
156
+ hint="Set start_sec to a non-negative value.",
157
+ )
158
+ if options.duration_sec <= 0:
159
+ raise ClipwrightError(
160
+ code=ErrorCode.INVALID_INPUT,
161
+ message="Text duration must be greater than 0.",
162
+ hint="Set duration_sec to a positive number of seconds.",
163
+ )
164
+ if options.font_size <= 0:
165
+ raise ClipwrightError(
166
+ code=ErrorCode.INVALID_INPUT,
167
+ message="Font size must be a positive integer.",
168
+ hint="Set font_size to a value greater than 0 (e.g. 48).",
169
+ )
170
+ if options.fade_in_sec < 0:
171
+ raise ClipwrightError(
172
+ code=ErrorCode.INVALID_INPUT,
173
+ message="Fade-in duration must be 0 or greater.",
174
+ hint="Set fade_in_sec to a non-negative value.",
175
+ )
176
+ if options.fade_out_sec < 0:
177
+ raise ClipwrightError(
178
+ code=ErrorCode.INVALID_INPUT,
179
+ message="Fade-out duration must be 0 or greater.",
180
+ hint="Set fade_out_sec to a non-negative value.",
181
+ )
182
+ if options.fade_in_sec + options.fade_out_sec > options.duration_sec:
183
+ raise ClipwrightError(
184
+ code=ErrorCode.INVALID_INPUT,
185
+ message="Fade-in plus fade-out exceeds the text duration.",
186
+ hint=(
187
+ "Reduce fade durations or increase duration_sec so fades fit within it."
188
+ ),
189
+ )
190
+
191
+ # --- 2. text content ---
192
+ if not options.text.strip():
193
+ raise ClipwrightError(
194
+ code=ErrorCode.INVALID_INPUT,
195
+ message="Text must not be empty or whitespace-only.",
196
+ hint="Provide a non-empty text string to display.",
197
+ )
198
+ if _CONTROL_CHAR_PATTERN.search(options.text):
199
+ raise ClipwrightError(
200
+ code=ErrorCode.INVALID_INPUT,
201
+ message="Text must not contain newlines or control characters.",
202
+ hint=(
203
+ "Remove line breaks and control characters; this version "
204
+ "supports single-line overlays only."
205
+ ),
206
+ )
207
+
208
+ # --- 3. Color allowlist (font_color, box_color) ---
209
+ if not _COLOR_PATTERN.match(options.font_color):
210
+ raise ClipwrightError(
211
+ code=ErrorCode.INVALID_INPUT,
212
+ message="Color value is not in the allowed format.",
213
+ hint=(
214
+ "Use a named color, #RRGGBB, or name@alpha "
215
+ "(e.g. white, #FFCC00, black@0.5)."
216
+ ),
217
+ )
218
+ if not _COLOR_PATTERN.match(options.box_color):
219
+ raise ClipwrightError(
220
+ code=ErrorCode.INVALID_INPUT,
221
+ message="Color value is not in the allowed format.",
222
+ hint=(
223
+ "Use a named color, #RRGGBB, or name@alpha "
224
+ "(e.g. white, #FFCC00, black@0.5)."
225
+ ),
226
+ )
227
+
228
+ # --- 4. Position expression control characters (x, y) ---
229
+ _pos_msg = "Position expression must not contain newlines or control characters."
230
+ _pos_hint = "Provide a single-line ffmpeg drawtext expression for x/y."
231
+ if _CONTROL_CHAR_PATTERN.search(options.x):
232
+ raise ClipwrightError(
233
+ code=ErrorCode.INVALID_INPUT,
234
+ message=_pos_msg,
235
+ hint=_pos_hint,
236
+ )
237
+ if _CONTROL_CHAR_PATTERN.search(options.y):
238
+ raise ClipwrightError(
239
+ code=ErrorCode.INVALID_INPUT,
240
+ message=_pos_msg,
241
+ hint=_pos_hint,
242
+ )
243
+
244
+ # --- 5. font_path: single-quote, newline, control characters ---
245
+ # Keep in sync with render-side _marker_to_text_overlay font_path validator
246
+ # (same rules: _CONTROL_CHAR_PATTERN + explicit single-quote check).
247
+ if options.font_path is not None:
248
+ if "'" in options.font_path:
249
+ raise ClipwrightError(
250
+ code=ErrorCode.INVALID_INPUT,
251
+ message="Font path must not contain single-quote characters.",
252
+ hint=(
253
+ "Remove single-quotes from the font file path "
254
+ "(they would corrupt filtergraph fontfile='...' quoting)."
255
+ ),
256
+ )
257
+ if _CONTROL_CHAR_PATTERN.search(options.font_path):
258
+ raise ClipwrightError(
259
+ code=ErrorCode.INVALID_INPUT,
260
+ message="Font path must not contain newlines or control characters.",
261
+ hint="Remove newlines and control characters from the font file path.",
262
+ )
263
+
264
+
265
+ # ===========================================================================
266
+ # Idempotency helpers
267
+ # ===========================================================================
268
+
269
+
270
+ def _overlay_metadata_dict(options: AddTextOptions) -> dict[str, Any]:
271
+ """Build the clipwright metadata dict for a text_overlay marker.
272
+
273
+ Stores tool/version/kind plus all AddTextOptions fields so that
274
+ clipwright-render can reconstruct the overlay from the marker alone.
275
+
276
+ Args:
277
+ options: Validated AddTextOptions.
278
+
279
+ Returns:
280
+ Dict to store under marker.metadata["clipwright"].
281
+ """
282
+ return {
283
+ "tool": "clipwright-text",
284
+ "version": __version__,
285
+ "kind": "text_overlay",
286
+ "text": options.text,
287
+ "start_sec": options.start_sec,
288
+ "duration_sec": options.duration_sec,
289
+ "x": options.x,
290
+ "y": options.y,
291
+ "font_size": options.font_size,
292
+ "font_color": options.font_color,
293
+ "box": options.box,
294
+ "box_color": options.box_color,
295
+ "fade_in_sec": options.fade_in_sec,
296
+ "fade_out_sec": options.fade_out_sec,
297
+ "font_path": options.font_path,
298
+ }
299
+
300
+
301
+ def _is_duplicate_overlay(marker: otio.schema.Marker, options: AddTextOptions) -> bool:
302
+ """Return True if marker is an exact duplicate of the given options.
303
+
304
+ Compares all AddTextOptions fields stored in marker.metadata["clipwright"]
305
+ against the current options. Uses approximate float comparison for seconds
306
+ fields to be rate-invariant (ADR-T1).
307
+
308
+ Args:
309
+ marker: An existing text_overlay marker to compare.
310
+ options: Current AddTextOptions to check for duplication.
311
+
312
+ Returns:
313
+ True if all fields match (complete duplicate -> no-op).
314
+ """
315
+ cw = marker.metadata.get("clipwright", {})
316
+ if not isinstance(cw, collections.abc.Mapping):
317
+ return False
318
+ if cw.get("kind") != "text_overlay":
319
+ return False
320
+
321
+ # String / bool / int fields: exact match
322
+ if cw.get("text") != options.text:
323
+ return False
324
+ if cw.get("x") != options.x:
325
+ return False
326
+ if cw.get("y") != options.y:
327
+ return False
328
+ if cw.get("font_size") != options.font_size:
329
+ return False
330
+ if cw.get("font_color") != options.font_color:
331
+ return False
332
+ if cw.get("box") != options.box:
333
+ return False
334
+ if cw.get("box_color") != options.box_color:
335
+ return False
336
+ if cw.get("font_path") != options.font_path:
337
+ return False
338
+
339
+ # Float fields: approximate comparison (rate-invariant tolerance)
340
+ def _approx_eq(a: object, b: float) -> bool:
341
+ if not isinstance(a, (int, float)):
342
+ return False
343
+ return abs(float(a) - b) <= _IDEMPOTENCY_EPS
344
+
345
+ if not _approx_eq(cw.get("start_sec"), options.start_sec):
346
+ return False
347
+ if not _approx_eq(cw.get("duration_sec"), options.duration_sec):
348
+ return False
349
+ if not _approx_eq(cw.get("fade_in_sec"), options.fade_in_sec):
350
+ return False
351
+ return _approx_eq(cw.get("fade_out_sec"), options.fade_out_sec)
352
+
353
+
354
+ # ===========================================================================
355
+ # Rate resolution (OQ-2)
356
+ # ===========================================================================
357
+
358
+
359
+ def _resolve_rate(
360
+ video_track: otio.schema.Track,
361
+ ) -> tuple[float, list[str]]:
362
+ """Determine the rate for RationalTime construction (OQ-2 priority order).
363
+
364
+ Priority:
365
+ 1. source_range.rate of the first Clip in the V1 track.
366
+ 2. marked_range.start_time.rate of the first existing text_overlay marker.
367
+ 3. Fallback: 1000.0 with a warning.
368
+
369
+ Args:
370
+ video_track: The first Video track from the loaded timeline.
371
+
372
+ Returns:
373
+ Tuple of (rate: float, warnings: list[str]). warnings is non-empty only
374
+ when the fallback rate is used.
375
+ """
376
+ # Priority 1: first clip's source_range rate
377
+ for item in video_track:
378
+ if isinstance(item, otio.schema.Clip) and item.source_range is not None:
379
+ return float(item.source_range.start_time.rate), []
380
+
381
+ # Priority 2: existing text_overlay marker rate
382
+ for marker in video_track.markers:
383
+ cw = marker.metadata.get("clipwright", {})
384
+ if isinstance(cw, collections.abc.Mapping) and cw.get("kind") == "text_overlay":
385
+ return float(marker.marked_range.start_time.rate), []
386
+
387
+ # Priority 3: fallback
388
+ return 1000.0, [
389
+ "Could not determine timeline rate from clips or existing markers; "
390
+ "using fallback rate 1000.0. Consider providing a timeline with clips."
391
+ ]
392
+
393
+
394
+ # ===========================================================================
395
+ # Core implementation
396
+ # ===========================================================================
397
+
398
+
399
+ def _add_text_inner(
400
+ timeline: str,
401
+ output: str,
402
+ options: AddTextOptions,
403
+ ) -> ToolResult:
404
+ """Internal implementation of add_text. Raises ClipwrightError on failure.
405
+
406
+ Validation order:
407
+ 1. output suffix == .otio
408
+ 2. output parent directory exists
409
+ 3. output boundary check (PATH_NOT_ALLOWED when outside timeline dir)
410
+ 4. output != timeline
411
+ 5. field validation (_validate_text_overlay_fields)
412
+ 6. load timeline (FILE_NOT_FOUND / OTIO_ERROR propagate)
413
+ 7. first TrackKind.Video track exists
414
+ 8. rate determination (OQ-2)
415
+ 9. idempotency check (exact duplicate -> no-op)
416
+ 10. add marker (text_{n}, all metadata fields)
417
+ 11. save timeline atomically
418
+ 12. return ok_result
419
+
420
+ Args:
421
+ timeline: Input OTIO timeline file path.
422
+ output: Output OTIO file path.
423
+ options: Validated AddTextOptions.
424
+
425
+ Returns:
426
+ ToolResult from ok_result.
427
+
428
+ Raises:
429
+ ClipwrightError: On any validation or I/O failure.
430
+ """
431
+ out = Path(output)
432
+ inp = Path(timeline)
433
+
434
+ # --- Step 1: output suffix validation ---
435
+ if out.suffix.lower() != ".otio":
436
+ raise ClipwrightError(
437
+ code=ErrorCode.INVALID_INPUT,
438
+ message="Output path must have a .otio extension.",
439
+ hint="Change the output file extension to .otio (e.g., 'result.otio').",
440
+ )
441
+
442
+ # --- Step 2: output parent directory exists ---
443
+ if not out.parent.exists():
444
+ raise ClipwrightError(
445
+ code=ErrorCode.FILE_NOT_FOUND,
446
+ message="Output directory does not exist.",
447
+ hint="Create the output directory before calling clipwright_add_text.",
448
+ )
449
+
450
+ # --- Step 3: output boundary check ---
451
+ _check_output_within_timeline_dir(inp, out)
452
+
453
+ # --- Step 4: output != timeline ---
454
+ if out.resolve() == inp.resolve():
455
+ raise ClipwrightError(
456
+ code=ErrorCode.INVALID_INPUT,
457
+ message="Output path must differ from the input timeline path.",
458
+ hint=(
459
+ "Provide a distinct output path (e.g., append '_text' before .otio)."
460
+ ),
461
+ )
462
+
463
+ # --- Step 5: field validation ---
464
+ _validate_text_overlay_fields(options)
465
+
466
+ # --- Step 6: load timeline ---
467
+ if not inp.exists():
468
+ raise ClipwrightError(
469
+ code=ErrorCode.FILE_NOT_FOUND,
470
+ message=f"Timeline file not found: {inp.name}",
471
+ hint="Verify the timeline path and ensure the file exists.",
472
+ )
473
+ timeline_obj = load_timeline(timeline)
474
+
475
+ # --- Step 7: find first Video track ---
476
+ video_track: otio.schema.Track | None = None
477
+ for track in timeline_obj.tracks:
478
+ if track.kind == otio.schema.TrackKind.Video:
479
+ video_track = track
480
+ break
481
+
482
+ if video_track is None:
483
+ raise ClipwrightError(
484
+ code=ErrorCode.UNSUPPORTED_OPERATION,
485
+ message="No video track found in the timeline.",
486
+ hint=(
487
+ "clipwright_add_text requires a timeline with at least one "
488
+ "video track to attach text overlay markers."
489
+ ),
490
+ )
491
+
492
+ # --- Step 8: rate determination (OQ-2) ---
493
+ rate, rate_warnings = _resolve_rate(video_track)
494
+
495
+ # --- Step 9: idempotency check ---
496
+ existing_markers = get_markers(timeline_obj, kind="text_overlay")
497
+ for existing in existing_markers:
498
+ if _is_duplicate_overlay(existing, options):
499
+ # Exact duplicate: save a copy of the timeline and return no-op result
500
+ save_timeline(timeline_obj, output)
501
+ overlay_count = len(existing_markers)
502
+ return ok_result(
503
+ summary=(
504
+ f'Text overlay "{options.text}" at {options.start_sec}s for '
505
+ f"{options.duration_sec}s already exists; no marker added. "
506
+ f"Timeline has {overlay_count} text overlay(s). "
507
+ f"Output: {out.name}."
508
+ ),
509
+ data={
510
+ "applied": 0,
511
+ "overlay_count": overlay_count,
512
+ "start_sec": options.start_sec,
513
+ "duration_sec": options.duration_sec,
514
+ },
515
+ artifacts=[
516
+ {
517
+ "role": "timeline",
518
+ "path": str(out.resolve()),
519
+ "format": "otio",
520
+ }
521
+ ],
522
+ warnings=["Identical text overlay already exists; no marker added."],
523
+ )
524
+
525
+ # --- Step 10: add marker ---
526
+ # Count existing text_overlay markers to determine name index
527
+ n = len(existing_markers)
528
+ marker_name = f"text_{n}"
529
+
530
+ # Build marked_range using the resolved rate
531
+ marked_range = TimeRangeModel(
532
+ start_time=RationalTimeModel(
533
+ value=options.start_sec * rate,
534
+ rate=rate,
535
+ ),
536
+ duration=RationalTimeModel(
537
+ value=options.duration_sec * rate,
538
+ rate=rate,
539
+ ),
540
+ )
541
+
542
+ add_marker(
543
+ video_track,
544
+ marked_range=marked_range,
545
+ name=marker_name,
546
+ color=None,
547
+ metadata=_overlay_metadata_dict(options),
548
+ )
549
+
550
+ # --- Step 11: save timeline ---
551
+ save_timeline(timeline_obj, output)
552
+
553
+ # --- Step 12: build result ---
554
+ overlay_count = n + 1
555
+ out_resolved = out.resolve()
556
+ text_preview = options.text[:40] + "..." if len(options.text) > 40 else options.text
557
+ summary = (
558
+ f'Added text overlay "{text_preview}" at {options.start_sec}s for '
559
+ f"{options.duration_sec}s. "
560
+ f"Timeline now has {overlay_count} text overlay(s). "
561
+ f"Output: {out.name}."
562
+ )
563
+
564
+ all_warnings = list(rate_warnings)
565
+
566
+ return ok_result(
567
+ summary=summary,
568
+ data={
569
+ "applied": 1,
570
+ "overlay_count": overlay_count,
571
+ "start_sec": options.start_sec,
572
+ "duration_sec": options.duration_sec,
573
+ },
574
+ artifacts=[
575
+ {
576
+ "role": "timeline",
577
+ "path": str(out_resolved),
578
+ "format": "otio",
579
+ }
580
+ ],
581
+ warnings=all_warnings if all_warnings else None,
582
+ )
583
+
584
+
585
+ def add_text(
586
+ timeline: str,
587
+ output: str,
588
+ options: AddTextOptions | None,
589
+ ) -> ToolResult:
590
+ """Add a text_overlay marker to an OTIO timeline.
591
+
592
+ Non-destructive: does not modify the input timeline file.
593
+ Idempotent: calling with the same options on an already-annotated timeline
594
+ produces applied=0 and a warning rather than duplicating the marker.
595
+
596
+ Args:
597
+ timeline: Input OTIO timeline file path.
598
+ output: Output OTIO file path (must end in .otio, must differ from timeline).
599
+ options: AddTextOptions with required text/start_sec/duration_sec and
600
+ optional style fields. None returns INVALID_INPUT.
601
+
602
+ Returns:
603
+ ToolResult from ok_result or error_result.
604
+ """
605
+ if options is None:
606
+ return error_result(
607
+ "INVALID_INPUT",
608
+ "options is required but was not provided.",
609
+ "Pass an AddTextOptions with at least text, start_sec, and duration_sec.",
610
+ )
611
+ try:
612
+ return _add_text_inner(timeline, output, options)
613
+ except ClipwrightError as exc:
614
+ return error_result(exc.code, exc.message, exc.hint)