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.
- clipwright_text-0.1.0/PKG-INFO +142 -0
- clipwright_text-0.1.0/README.md +128 -0
- clipwright_text-0.1.0/pyproject.toml +83 -0
- clipwright_text-0.1.0/src/clipwright_text/__init__.py +3 -0
- clipwright_text-0.1.0/src/clipwright_text/py.typed +0 -0
- clipwright_text-0.1.0/src/clipwright_text/schemas.py +91 -0
- clipwright_text-0.1.0/src/clipwright_text/server.py +79 -0
- clipwright_text-0.1.0/src/clipwright_text/text.py +614 -0
|
@@ -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
|
|
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)
|