clipwright-bgm 0.1.1__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,61 @@
1
+ Metadata-Version: 2.3
2
+ Name: clipwright-bgm
3
+ Version: 0.1.1
4
+ Summary: MCP tool to write BGM placement annotations to OTIO timeline. Records BGM volume, fade, and ducking instructions as metadata on A2 Audio track clips, which clipwright-render realizes as a mix.
5
+ Author: satoh-y-0323
6
+ Author-email: satoh-y-0323 <shoma.papa.0323@gmail.com>
7
+ License: MIT
8
+ Requires-Dist: clipwright>=0.1.1
9
+ Requires-Dist: mcp[cli]>=1.27.2
10
+ Requires-Dist: pydantic>=2
11
+ Requires-Python: >=3.11
12
+ Description-Content-Type: text/markdown
13
+
14
+ # clipwright-bgm
15
+
16
+ MCP tool to write BGM placement annotations to an OTIO timeline. Records BGM volume, fade, and ducking instructions as metadata on A2 Audio track clips, which clipwright-render realizes as a mix.
17
+
18
+ ## Overview
19
+
20
+ - **Input**: Timeline OTIO file, BGM audio file, output path, optional parameters (volume, fade, ducking)
21
+ - **Process**: OTIO manipulation only (no ffmpeg/external OSS). Adds BGM clip to A2 Audio track and writes clipwright metadata
22
+ - **Output**: New OTIO file with BGM annotations (input timeline immutable, M5)
23
+
24
+ ## MCP Tool
25
+
26
+ `clipwright_add_bgm`
27
+
28
+ ### Parameters
29
+
30
+ | Name | Type | Default | Description |
31
+ |------|------|---------|-------------|
32
+ | `timeline` | `string` | required | Input timeline file path (existing .otio) |
33
+ | `bgm` | `string` | required | BGM audio file path (mp3/wav/m4a/aac/flac/ogg/opus/mp4/mkv/mov/webm) |
34
+ | `output` | `string` | required | Output OTIO file path (newly generated, different from input) |
35
+ | `options` | `object` | `null` | BgmOptions (volume_db / fade_in_sec / fade_out_sec / ducking) |
36
+
37
+ ## Dependencies
38
+
39
+ | Package | Purpose |
40
+ |---------|---------|
41
+ | `clipwright` | Shared types, envelope, errors, inspect_media |
42
+ | `mcp[cli]` | MCP server |
43
+ | `pydantic` | Parameter validation |
44
+
45
+ ## Installation and Startup
46
+
47
+ ```bash
48
+ uv add clipwright-bgm
49
+ clipwright-bgm
50
+ ```
51
+
52
+ Or within a uv workspace:
53
+
54
+ ```bash
55
+ uv run --package clipwright-bgm clipwright-bgm
56
+ ```
57
+
58
+ ## Prerequisites
59
+
60
+ - Python 3.11 or later
61
+ - ffprobe available on PATH or specified via `CLIPWRIGHT_FFPROBE` environment variable (used to determine BGM media length)
@@ -0,0 +1,48 @@
1
+ # clipwright-bgm
2
+
3
+ MCP tool to write BGM placement annotations to an OTIO timeline. Records BGM volume, fade, and ducking instructions as metadata on A2 Audio track clips, which clipwright-render realizes as a mix.
4
+
5
+ ## Overview
6
+
7
+ - **Input**: Timeline OTIO file, BGM audio file, output path, optional parameters (volume, fade, ducking)
8
+ - **Process**: OTIO manipulation only (no ffmpeg/external OSS). Adds BGM clip to A2 Audio track and writes clipwright metadata
9
+ - **Output**: New OTIO file with BGM annotations (input timeline immutable, M5)
10
+
11
+ ## MCP Tool
12
+
13
+ `clipwright_add_bgm`
14
+
15
+ ### Parameters
16
+
17
+ | Name | Type | Default | Description |
18
+ |------|------|---------|-------------|
19
+ | `timeline` | `string` | required | Input timeline file path (existing .otio) |
20
+ | `bgm` | `string` | required | BGM audio file path (mp3/wav/m4a/aac/flac/ogg/opus/mp4/mkv/mov/webm) |
21
+ | `output` | `string` | required | Output OTIO file path (newly generated, different from input) |
22
+ | `options` | `object` | `null` | BgmOptions (volume_db / fade_in_sec / fade_out_sec / ducking) |
23
+
24
+ ## Dependencies
25
+
26
+ | Package | Purpose |
27
+ |---------|---------|
28
+ | `clipwright` | Shared types, envelope, errors, inspect_media |
29
+ | `mcp[cli]` | MCP server |
30
+ | `pydantic` | Parameter validation |
31
+
32
+ ## Installation and Startup
33
+
34
+ ```bash
35
+ uv add clipwright-bgm
36
+ clipwright-bgm
37
+ ```
38
+
39
+ Or within a uv workspace:
40
+
41
+ ```bash
42
+ uv run --package clipwright-bgm clipwright-bgm
43
+ ```
44
+
45
+ ## Prerequisites
46
+
47
+ - Python 3.11 or later
48
+ - ffprobe available on PATH or specified via `CLIPWRIGHT_FFPROBE` environment variable (used to determine BGM media length)
@@ -0,0 +1,84 @@
1
+ [project]
2
+ name = "clipwright-bgm"
3
+ version = "0.1.1"
4
+ description = "MCP tool to write BGM placement annotations to OTIO timeline. Records BGM volume, fade, and ducking instructions as metadata on A2 Audio track clips, which clipwright-render realizes as a mix."
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.1.1",
13
+ "mcp[cli]>=1.27.2",
14
+ "pydantic>=2",
15
+ ]
16
+
17
+ [project.scripts]
18
+ clipwright-bgm = "clipwright_bgm.server:main"
19
+
20
+ [build-system]
21
+ requires = ["uv_build>=0.11.19,<0.12.0"]
22
+ build-backend = "uv_build"
23
+
24
+ [dependency-groups]
25
+ dev = [
26
+ "mypy>=2.1.0",
27
+ "pytest>=9.0.3",
28
+ "pytest-cov>=7.1.0",
29
+ "pytest-mock>=3.15.1",
30
+ "ruff>=0.15.16",
31
+ ]
32
+
33
+ # Resolve clipwright (core) within workspace by path reference
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
+ "tests/*.py" = ["E501"]
49
+
50
+ [tool.ruff.format]
51
+ # Default ruff formatter is OK
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
+ # opentimelineio has no stubs, ignored with mypy strict
63
+ [[tool.mypy.overrides]]
64
+ module = "opentimelineio.*"
65
+ ignore_missing_imports = true
66
+
67
+ # --- pytest ---
68
+ [tool.pytest.ini_options]
69
+ testpaths = ["tests"]
70
+ addopts = "--strict-markers -q"
71
+ markers = [
72
+ "integration: integration test requiring actual ffmpeg/ffprobe binaries",
73
+ "slow: test with long execution time",
74
+ "e2e: e2e test using actual ffmpeg binary",
75
+ ]
76
+
77
+ # --- coverage ---
78
+ [tool.coverage.run]
79
+ source = ["clipwright_bgm"]
80
+ omit = ["tests/*"]
81
+
82
+ [tool.coverage.report]
83
+ show_missing = true
84
+ skip_covered = false
@@ -0,0 +1 @@
1
+ __version__ = "0.1.1"
@@ -0,0 +1,348 @@
1
+ """bgm.py — clipwright-bgm orchestration layer (design ADR-B1/B2/B3/B8/B10).
2
+
3
+ Flow:
4
+ 1. Input validation (timeline exists, bgm exists, extension whitelist,
5
+ boundary check, output collision)
6
+ 2. Load timeline
7
+ 3. Re-invocation detection
8
+ (kind=='bgm' clip exists → INVALID_INPUT, ADR-B2-r3)
9
+ 4. Fetch BGM duration via core inspect_media
10
+ — direct ffprobe subprocess call is forbidden (ADR-B2-r2)
11
+ 5. Add A2 Audio track and place BGM clip
12
+ (BgmDirective co-locate, ADR-B3/B9-r2)
13
+ 6. save_timeline (new output file, input timeline unchanged, M5)
14
+ 7. Return ok_result
15
+
16
+ Design decisions:
17
+ - bgm.py does not call ffmpeg/ffprobe via subprocess (OTIO operations only).
18
+ - Error messages must not expose absolute paths — basename only (CWE-209, ADR-B10).
19
+ - Re-invocation detection is based on kind=='bgm' clip presence,
20
+ not track name "A2" (ADR-B2-r3).
21
+ - BGM extension whitelist rejects disallowed extensions (DC-AM-007, ADR-B2-r3).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
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.media import inspect_media
33
+ from clipwright.otio_utils import load_timeline, save_timeline
34
+
35
+ import clipwright_bgm
36
+ from clipwright_bgm.schemas import BgmDirective, BgmOptions, DuckingDirective
37
+
38
+ # Allowed BGM input extension whitelist (DC-AM-007, ADR-B2-r3).
39
+ # Primarily audio files; video containers are included because they
40
+ # may carry audio tracks.
41
+ _ALLOWED_BGM_EXTENSIONS: frozenset[str] = frozenset(
42
+ {"mp3", "wav", "m4a", "aac", "flac", "ogg", "opus", "mp4", "mkv", "mov", "webm"}
43
+ )
44
+
45
+
46
+ def add_bgm(
47
+ timeline: str,
48
+ bgm: str,
49
+ output: str,
50
+ options: BgmOptions | None = None,
51
+ ) -> dict[str, Any]:
52
+ """Public API to add a BGM clip to an OTIO timeline.
53
+
54
+ Converts ClipwrightError to an ok=False envelope.
55
+ BGM duration is fetched via core inspect_media;
56
+ direct ffprobe calls are forbidden (ADR-B2-r2).
57
+
58
+ Args:
59
+ timeline: Input OTIO timeline file path.
60
+ bgm: BGM file path (audio or video; see allowed extension whitelist).
61
+ output: Output OTIO timeline file path (must differ from timeline, M5).
62
+ options: BGM options. When None, BgmOptions(volume_db=-6.0) is used.
63
+
64
+ Returns:
65
+ Envelope dict from ok_result or error_result.
66
+ """
67
+ try:
68
+ return _add_bgm_inner(timeline, bgm, output, options)
69
+ except ClipwrightError as exc:
70
+ return error_result(exc.code, exc.message, exc.hint)
71
+
72
+
73
+ def _add_bgm_inner(
74
+ timeline: str,
75
+ bgm: str,
76
+ output: str,
77
+ options: BgmOptions | None,
78
+ ) -> dict[str, Any]:
79
+ """Internal implementation of add_bgm. Propagates ClipwrightError as-is."""
80
+ resolved_options = options if options is not None else BgmOptions(volume_db=-6.0)
81
+
82
+ timeline_path = Path(timeline)
83
+ bgm_path = Path(bgm)
84
+ output_path = Path(output)
85
+
86
+ # --- 1. Input validation ---
87
+
88
+ # Check timeline exists
89
+ if not timeline_path.exists():
90
+ raise ClipwrightError(
91
+ code=ErrorCode.FILE_NOT_FOUND,
92
+ message=f"Timeline file not found: {timeline_path.name}",
93
+ hint="Check that the input timeline file path is correct.",
94
+ )
95
+
96
+ # Check bgm exists (existence check must come before extension check)
97
+ if not bgm_path.exists():
98
+ raise ClipwrightError(
99
+ code=ErrorCode.FILE_NOT_FOUND,
100
+ message=f"BGM file not found: {bgm_path.name}",
101
+ hint="Check that the BGM file path is correct.",
102
+ )
103
+
104
+ # BGM extension whitelist validation (DC-AM-007, ADR-B2-r3)
105
+ bgm_ext = bgm_path.suffix.lstrip(".").lower()
106
+ if bgm_ext not in _ALLOWED_BGM_EXTENSIONS:
107
+ raise ClipwrightError(
108
+ code=ErrorCode.INVALID_INPUT,
109
+ message=f"Disallowed BGM file format: .{bgm_ext}",
110
+ hint=(
111
+ f"BGM file must have one of the following extensions: "
112
+ f"{', '.join(sorted(_ALLOWED_BGM_EXTENSIONS))}"
113
+ ),
114
+ )
115
+
116
+ # BGM path boundary check: bgm must be under the same directory as timeline (ADR-B8)
117
+ _check_bgm_within_timeline_dir(bgm_path, timeline_path)
118
+
119
+ # Output collision check: output == input timeline is forbidden
120
+ # (non-destructive, M5)
121
+ if _same_path(output_path, timeline_path):
122
+ raise ClipwrightError(
123
+ code=ErrorCode.INVALID_INPUT,
124
+ message="Output path and input timeline path are identical.",
125
+ hint=(
126
+ "Specify a different path for the output file from the input timeline."
127
+ ),
128
+ )
129
+
130
+ # Output boundary check: output must be under the same directory
131
+ # as timeline (SR L-3)
132
+ _check_output_within_timeline_dir(output_path, timeline_path)
133
+
134
+ # Output collision check: overwriting an existing file is forbidden
135
+ # (non-destructive)
136
+ if output_path.exists():
137
+ raise ClipwrightError(
138
+ code=ErrorCode.INVALID_INPUT,
139
+ message=f"Output file already exists: {output_path.name}",
140
+ hint=(
141
+ "Specify a different output file path that does not"
142
+ " conflict with an existing file."
143
+ ),
144
+ )
145
+
146
+ # --- 2. Load timeline ---
147
+
148
+ tl = load_timeline(str(timeline_path))
149
+
150
+ # --- 3. Re-invocation detection (DC-AS-002/AM-005, ADR-B2-r3) ---
151
+ # Raise INVALID_INPUT if a kind=='bgm' clip already exists.
152
+ # Detection is kind-based, not track-name-based ("A2").
153
+ existing_bgm_clips = _collect_bgm_clips(tl)
154
+ if existing_bgm_clips:
155
+ # Do not expand existing clip names in hint
156
+ # (prevents control-character injection from OTIO data, SR L-2, CWE-20)
157
+ raise ClipwrightError(
158
+ code=ErrorCode.INVALID_INPUT,
159
+ message="A BGM clip already exists in the timeline.",
160
+ hint=(
161
+ "An existing BGM clip was found. "
162
+ "Specify a timeline that does not already contain a BGM clip."
163
+ ),
164
+ )
165
+
166
+ # --- 4. Fetch BGM duration via core inspect_media (ADR-B2-r2) ---
167
+ # On inspect_media failure, catch ClipwrightError and reformat
168
+ # to hide the absolute path.
169
+
170
+ try:
171
+ media_info = inspect_media(str(bgm_path))
172
+ except ClipwrightError as exc:
173
+ # Replace message with basename-only to avoid exposing absolute paths
174
+ # (CWE-209, ADR-B10)
175
+ safe_message = f"Failed to retrieve BGM file info: {bgm_path.name}"
176
+ raise ClipwrightError(
177
+ code=exc.code,
178
+ message=safe_message,
179
+ hint=exc.hint,
180
+ ) from None
181
+
182
+ # Convert duration to seconds
183
+ if media_info.duration is None:
184
+ raise ClipwrightError(
185
+ code=ErrorCode.INVALID_INPUT,
186
+ message=f"Could not retrieve duration of BGM file: {bgm_path.name}",
187
+ hint="Specify a BGM file that has a valid audio stream.",
188
+ )
189
+
190
+ bgm_duration_sec = media_info.duration.value / media_info.duration.rate
191
+ bgm_rate = media_info.duration.rate
192
+
193
+ # --- 5. Add A2 Audio track and place BGM clip ---
194
+
195
+ # source_range is fixed to full BGM media length (0–bgm_duration)
196
+ # (DC-AS-003, ADR-B2-r2)
197
+ source_range = otio.opentime.TimeRange(
198
+ start_time=otio.opentime.RationalTime(0.0, bgm_rate),
199
+ duration=otio.opentime.RationalTime(bgm_duration_sec * bgm_rate, bgm_rate),
200
+ )
201
+
202
+ # Build BgmDirective and co-locate in BGM clip metadata (ADR-B3/B9-r2)
203
+ directive = BgmDirective(
204
+ tool="clipwright-bgm",
205
+ version=clipwright_bgm.__version__,
206
+ kind="bgm",
207
+ volume_db=resolved_options.volume_db,
208
+ fade_in_sec=resolved_options.fade_in_sec,
209
+ fade_out_sec=resolved_options.fade_out_sec,
210
+ ducking=DuckingDirective(
211
+ enabled=resolved_options.ducking.enabled,
212
+ threshold=resolved_options.ducking.threshold,
213
+ ratio=resolved_options.ducking.ratio,
214
+ ),
215
+ )
216
+
217
+ ref = otio.schema.ExternalReference(target_url=str(bgm_path))
218
+ bgm_clip = otio.schema.Clip(
219
+ name=bgm_path.name,
220
+ media_reference=ref,
221
+ source_range=source_range,
222
+ metadata={"clipwright": directive.model_dump()},
223
+ )
224
+
225
+ # Add A2 Audio track and append BGM clip
226
+ a2 = otio.schema.Track(name="A2", kind=otio.schema.TrackKind.Audio)
227
+ a2.append(bgm_clip)
228
+ tl.tracks.append(a2)
229
+
230
+ # --- 6. save_timeline (new output file, input timeline unchanged, M5) ---
231
+
232
+ save_timeline(tl, str(output_path))
233
+
234
+ # --- 7. Return ok_result ---
235
+
236
+ summary = (
237
+ f"BGM added."
238
+ f" bgm={bgm_path.name}"
239
+ f", volume_db={resolved_options.volume_db}"
240
+ f", fade_in={resolved_options.fade_in_sec}s"
241
+ f", fade_out={resolved_options.fade_out_sec}s"
242
+ f", ducking={'ON' if resolved_options.ducking.enabled else 'OFF'}"
243
+ f", bgm_duration={bgm_duration_sec:.2f}s."
244
+ f" Output timeline: {output_path.name}"
245
+ )
246
+
247
+ return ok_result(
248
+ summary,
249
+ data={
250
+ "bgm": bgm_path.name,
251
+ "volume_db": resolved_options.volume_db,
252
+ "fade_in_sec": resolved_options.fade_in_sec,
253
+ "fade_out_sec": resolved_options.fade_out_sec,
254
+ "ducking_enabled": resolved_options.ducking.enabled,
255
+ "bgm_duration_sec": bgm_duration_sec,
256
+ },
257
+ artifacts=[
258
+ {"role": "timeline", "path": str(output_path), "format": "otio"},
259
+ ],
260
+ warnings=[],
261
+ )
262
+
263
+
264
+ def _collect_bgm_clips(tl: otio.schema.Timeline) -> list[otio.schema.Clip]:
265
+ """Collect all Clips with kind=='bgm' from every Audio track in the timeline.
266
+
267
+ Uses kind-based detection to avoid dependency on track names,
268
+ supporting re-invocation detection (ADR-B2-r3).
269
+ """
270
+ bgm_clips: list[otio.schema.Clip] = []
271
+ for track in tl.tracks:
272
+ if track.kind == otio.schema.TrackKind.Audio:
273
+ for item in track:
274
+ if isinstance(item, otio.schema.Clip):
275
+ meta = item.metadata.get("clipwright", {})
276
+ if meta.get("kind") == "bgm":
277
+ bgm_clips.append(item)
278
+ return bgm_clips
279
+
280
+
281
+ def _check_bgm_within_timeline_dir(bgm_path: Path, timeline_path: Path) -> None:
282
+ """Verify that the BGM file is under the same directory as the timeline (ADR-B8).
283
+
284
+ Boundary check: raises PATH_NOT_ALLOWED if the BGM path is outside
285
+ the timeline directory.
286
+ Falls back to absolute() when resolve() fails (Windows compatibility).
287
+
288
+ Raises:
289
+ ClipwrightError: PATH_NOT_ALLOWED (out of boundary).
290
+ """
291
+ try:
292
+ bgm_resolved = bgm_path.resolve()
293
+ timeline_dir = timeline_path.resolve().parent
294
+ except OSError:
295
+ bgm_resolved = bgm_path.absolute()
296
+ timeline_dir = timeline_path.absolute().parent
297
+
298
+ # Check whether bgm is under timeline_dir
299
+ try:
300
+ bgm_resolved.relative_to(timeline_dir)
301
+ except ValueError:
302
+ raise ClipwrightError(
303
+ code=ErrorCode.PATH_NOT_ALLOWED,
304
+ message=(f"BGM file is outside the timeline directory: {bgm_path.name}"),
305
+ hint="Place the BGM file in the same directory as the timeline.",
306
+ ) from None
307
+
308
+
309
+ def _check_output_within_timeline_dir(output_path: Path, timeline_path: Path) -> None:
310
+ """Verify that the output file is under the same directory as the timeline (SR L-3).
311
+
312
+ Boundary check: raises PATH_NOT_ALLOWED if output is outside
313
+ the timeline directory.
314
+ Falls back to absolute() when resolve() fails (Windows compatibility).
315
+
316
+ Raises:
317
+ ClipwrightError: PATH_NOT_ALLOWED (out of boundary).
318
+ """
319
+ try:
320
+ output_resolved = output_path.resolve()
321
+ timeline_dir = timeline_path.resolve().parent
322
+ except OSError:
323
+ output_resolved = output_path.absolute()
324
+ timeline_dir = timeline_path.absolute().parent
325
+
326
+ # Check whether output's parent directory is under (or equal to) timeline_dir.
327
+ # The parent directory of output (not output itself) must be within timeline_dir.
328
+ try:
329
+ output_resolved.parent.relative_to(timeline_dir)
330
+ except ValueError:
331
+ raise ClipwrightError(
332
+ code=ErrorCode.PATH_NOT_ALLOWED,
333
+ message=(
334
+ f"Output path is outside the timeline directory: {output_path.name}"
335
+ ),
336
+ hint="Place the output file in the same directory as the timeline.",
337
+ ) from None
338
+
339
+
340
+ def _same_path(a: Path, b: Path) -> bool:
341
+ """Return True if both paths point to the same entity.
342
+
343
+ Falls back to string comparison when resolve fails.
344
+ """
345
+ try:
346
+ return a.resolve() == b.resolve()
347
+ except OSError:
348
+ return str(a) == str(b)
File without changes
@@ -0,0 +1,145 @@
1
+ """schemas.py — Pydantic schemas specific to clipwright-bgm.
2
+
3
+ Common types (MediaRef / Artifact / ToolResult, etc.) are defined centrally in
4
+ clipwright.schemas and are not redefined here.
5
+
6
+ DuckingOptions: User-facing ducking options.
7
+ DuckingDirective: Ducking directive written into BGM clip metadata.
8
+ BgmOptions: Input options for clipwright_add_bgm (user input layer).
9
+ BgmDirective: Directive schema written to BGM clip metadata["clipwright"]
10
+ (writer layer, B9-r2).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Annotated, Literal
16
+
17
+ from pydantic import BaseModel, Field
18
+
19
+
20
+ class DuckingOptions(BaseModel):
21
+ """User-facing ducking options (ADR-B9).
22
+
23
+ When enabled=True, sidechaincompress is applied at render time to automatically
24
+ attenuate the BGM. threshold and ratio map to sidechaincompress parameters.
25
+ """
26
+
27
+ model_config = {"allow_inf_nan": False}
28
+
29
+ enabled: bool = False
30
+ threshold: Annotated[
31
+ float,
32
+ Field(
33
+ default=0.05,
34
+ description="Sidechain trigger threshold (linear amplitude, range 0–1).",
35
+ ),
36
+ ] = 0.05
37
+ ratio: Annotated[
38
+ float,
39
+ Field(
40
+ default=4.0,
41
+ description="Compression ratio. Higher values produce stronger ducking.",
42
+ ),
43
+ ] = 4.0
44
+
45
+
46
+ class BgmOptions(BaseModel):
47
+ """Options for clipwright_add_bgm (user input layer, ADR-B9).
48
+
49
+ volume_db: BGM volume adjustment in dB. Range: -60 to 20.
50
+ fade_in_sec: Fade-in duration in seconds. 0 means no afade is injected (ADR-B9-r3).
51
+ fade_out_sec: Fade-out duration in seconds.
52
+ 0 means no afade is injected (ADR-B9-r3).
53
+ ducking: Ducking options (default OFF).
54
+ """
55
+
56
+ model_config = {"allow_inf_nan": False}
57
+
58
+ volume_db: Annotated[
59
+ float,
60
+ Field(
61
+ ge=-60.0,
62
+ le=20.0,
63
+ description="BGM volume adjustment in dB. Range: [-60, 20].",
64
+ ),
65
+ ]
66
+ fade_in_sec: Annotated[
67
+ float,
68
+ Field(
69
+ default=0.0,
70
+ ge=0.0,
71
+ description=(
72
+ "Fade-in duration in seconds (ge=0). 0 means no fade (ADR-B9-r3)."
73
+ ),
74
+ ),
75
+ ] = 0.0
76
+ fade_out_sec: Annotated[
77
+ float,
78
+ Field(
79
+ default=0.0,
80
+ ge=0.0,
81
+ description=(
82
+ "Fade-out duration in seconds (ge=0). 0 means no fade (ADR-B9-r3)."
83
+ ),
84
+ ),
85
+ ] = 0.0
86
+ ducking: DuckingOptions = Field(default_factory=DuckingOptions)
87
+
88
+
89
+ class DuckingDirective(BaseModel):
90
+ """Ducking directive written into BGM clip metadata (writer layer, ADR-B9-r2).
91
+
92
+ Co-located as BgmDirective.ducking.
93
+ The render reader side reads the same field structure.
94
+ allow_inf_nan=False is not propagated to child models automatically, so it is set
95
+ explicitly here (SR L-1, M-1).
96
+ Range constraints on threshold/ratio are based on the actual allowed range of
97
+ sidechaincompress (CR L-6), confirmed via `ffmpeg -h filter=sidechaincompress`.
98
+ """
99
+
100
+ model_config = {"allow_inf_nan": False}
101
+
102
+ enabled: bool = False
103
+ threshold: Annotated[
104
+ float,
105
+ Field(
106
+ default=0.05,
107
+ gt=0.0,
108
+ le=1.0,
109
+ description=(
110
+ "Sidechain trigger threshold (linear amplitude, range 0–1). "
111
+ "ffmpeg sidechaincompress threshold range: (0, 1]."
112
+ ),
113
+ ),
114
+ ] = 0.05
115
+ ratio: Annotated[
116
+ float,
117
+ Field(
118
+ default=4.0,
119
+ ge=1.0,
120
+ le=20.0,
121
+ description=(
122
+ "Compression ratio (ffmpeg sidechaincompress ratio range: [1, 20])."
123
+ ),
124
+ ),
125
+ ] = 4.0
126
+
127
+
128
+ class BgmDirective(BaseModel):
129
+ """Directive schema written to BGM clip metadata["clipwright"]
130
+ (writer layer, ADR-B9-r2).
131
+
132
+ Built by add_bgm and stored via .model_dump() into OTIO metadata.
133
+ The render reader side defines the same fields with max_length=64
134
+ (following NR-M-1).
135
+ """
136
+
137
+ model_config = {"allow_inf_nan": False}
138
+
139
+ tool: Annotated[str, Field(max_length=64)]
140
+ version: Annotated[str, Field(max_length=64)]
141
+ kind: Literal["bgm"]
142
+ volume_db: Annotated[float, Field(ge=-60.0, le=20.0, allow_inf_nan=False)]
143
+ fade_in_sec: Annotated[float, Field(ge=0.0)]
144
+ fade_out_sec: Annotated[float, Field(ge=0.0)]
145
+ ducking: DuckingDirective
@@ -0,0 +1,108 @@
1
+ """server.py — clipwright-bgm MCP server + CLI entry point.
2
+
3
+ Thin wrapper that delegates business logic to bgm.py.
4
+ ClipwrightError conversion is handled in bgm.py, so no double-conversion is done here.
5
+
6
+ Transport defaults to stdio (mcp.run(transport="stdio")).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Annotated, Any
12
+
13
+ from mcp.server.fastmcp import FastMCP
14
+ from mcp.types import ToolAnnotations
15
+ from pydantic import Field
16
+
17
+ from clipwright_bgm.bgm import add_bgm
18
+ from clipwright_bgm.schemas import BgmOptions
19
+
20
+ # FastMCP instance (server name)
21
+ mcp = FastMCP("clipwright-bgm")
22
+
23
+
24
+ # ===========================================================================
25
+ # clipwright_add_bgm MCP tool
26
+ # ===========================================================================
27
+
28
+
29
+ @mcp.tool(
30
+ annotations=ToolAnnotations(
31
+ readOnlyHint=False,
32
+ destructiveHint=False,
33
+ idempotentHint=True,
34
+ openWorldHint=False,
35
+ )
36
+ )
37
+ def clipwright_add_bgm(
38
+ timeline: Annotated[
39
+ str,
40
+ Field(description="Input OTIO timeline file path (.otio)."),
41
+ ],
42
+ bgm: Annotated[
43
+ str,
44
+ Field(
45
+ description=(
46
+ "BGM file path (audio or video). "
47
+ "Allowed extensions: mp3, wav, m4a, aac, flac, ogg, opus,"
48
+ " mp4, mkv, mov, webm. "
49
+ "Must be placed in the same directory as the timeline file."
50
+ )
51
+ ),
52
+ ],
53
+ output: Annotated[
54
+ str,
55
+ Field(
56
+ description=(
57
+ "Output OTIO timeline file path (.otio extension). "
58
+ "Must differ from the input timeline path (non-destructive, M5)."
59
+ )
60
+ ),
61
+ ],
62
+ options: Annotated[
63
+ BgmOptions | None,
64
+ Field(
65
+ description=(
66
+ "BGM options (volume_db / fade_in_sec / fade_out_sec / ducking). "
67
+ "When omitted, the default values inside add_bgm are used."
68
+ )
69
+ ),
70
+ ] = None,
71
+ ) -> dict[str, Any]:
72
+ """MCP tool to add a BGM clip to an OTIO timeline.
73
+
74
+ Not read-only because a new output OTIO file is created, but the input OTIO
75
+ and media files are unchanged (non-destructive).
76
+ Symmetric design with clipwright-render's readOnlyHint=False
77
+ (new file generation, CR M-4).
78
+ Fetches BGM duration via core inspect_media and adds a BGM clip
79
+ to the A2 Audio track.
80
+ Writes BgmDirective (volume_db/fade/ducking) into the BGM clip metadata.
81
+ The actual mix is performed by clipwright-render.
82
+
83
+ Delegates business logic to bgm.add_bgm.
84
+ """
85
+ return add_bgm(
86
+ timeline=timeline,
87
+ bgm=bgm,
88
+ output=output,
89
+ options=options,
90
+ )
91
+
92
+
93
+ # ===========================================================================
94
+ # Entry point (MCP stdio)
95
+ # ===========================================================================
96
+
97
+
98
+ def main() -> None:
99
+ """CLI entry point. Starts the MCP server over stdio.
100
+
101
+ Registered in pyproject.toml [project.scripts] as:
102
+ clipwright-bgm = "clipwright_bgm.server:main"
103
+ """
104
+ mcp.run(transport="stdio")
105
+
106
+
107
+ if __name__ == "__main__": # pragma: no cover
108
+ main()