aivee 0.1.0-alpha.0 → 0.1.0-dev.7025438

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.
Files changed (73) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +118 -118
  3. package/docs/architecture.md +401 -401
  4. package/docs/aspect-ratio-presets.md +72 -72
  5. package/docs/releasing.md +19 -19
  6. package/docs/usage.md +1193 -1193
  7. package/package.json +52 -52
  8. package/requirements-otio.txt +1 -1
  9. package/src/capabilities.mjs +307 -307
  10. package/src/cli.mjs +646 -646
  11. package/src/doctor.mjs +133 -133
  12. package/src/edits/analysis-to-edl.mjs +43 -43
  13. package/src/edits/detect-silence.mjs +51 -51
  14. package/src/edits/find-mistake-candidates.mjs +42 -42
  15. package/src/edits/plan-shot-placements.mjs +105 -105
  16. package/src/edits/plan-silence-cuts.mjs +49 -49
  17. package/src/edits/retime-transcript.mjs +105 -105
  18. package/src/files.mjs +80 -80
  19. package/src/index.mjs +44 -44
  20. package/src/interchange/otio.mjs +204 -204
  21. package/src/interchange/otio_bridge.py +521 -521
  22. package/src/media/analysis-provider.mjs +75 -75
  23. package/src/media/compatibility.mjs +45 -45
  24. package/src/media/ffmpeg-capabilities.mjs +129 -129
  25. package/src/media/frame-rate.mjs +58 -58
  26. package/src/media/frame-time.mjs +22 -22
  27. package/src/media/preview-assets.mjs +350 -350
  28. package/src/media/probe.mjs +149 -149
  29. package/src/media/render-cuts.mjs +90 -90
  30. package/src/media/scene-detection.mjs +107 -107
  31. package/src/media/scene-review.mjs +205 -205
  32. package/src/project/assets.mjs +1194 -1194
  33. package/src/project/create.mjs +100 -100
  34. package/src/project/presets.mjs +44 -44
  35. package/src/render/checkpoints.mjs +192 -192
  36. package/src/render/formats.mjs +163 -163
  37. package/src/render/hyperframes.mjs +590 -590
  38. package/src/render/process.mjs +111 -111
  39. package/src/render/progress.mjs +46 -46
  40. package/src/render/timeline-control.mjs +53 -53
  41. package/src/timeline/assemble.mjs +119 -119
  42. package/src/timeline/audio-effects.mjs +75 -75
  43. package/src/timeline/audio-mastering.mjs +236 -236
  44. package/src/timeline/audio-mix.mjs +105 -105
  45. package/src/timeline/audio-track-effects.mjs +139 -139
  46. package/src/timeline/audio-volume.mjs +63 -63
  47. package/src/timeline/background.mjs +8 -8
  48. package/src/timeline/blend-mode.mjs +34 -34
  49. package/src/timeline/caption-layouts.mjs +61 -61
  50. package/src/timeline/captions.mjs +80 -80
  51. package/src/timeline/edit.mjs +965 -965
  52. package/src/timeline/fingerprint.mjs +6 -6
  53. package/src/timeline/from-edl.mjs +31 -31
  54. package/src/timeline/history-store.mjs +281 -281
  55. package/src/timeline/history.mjs +69 -69
  56. package/src/timeline/inspect.mjs +117 -117
  57. package/src/timeline/keyframes.mjs +158 -158
  58. package/src/timeline/layers.mjs +18 -18
  59. package/src/timeline/mask.mjs +53 -53
  60. package/src/timeline/project.mjs +52 -52
  61. package/src/timeline/render.mjs +759 -759
  62. package/src/timeline/sidechain-render.mjs +209 -209
  63. package/src/timeline/source-fit.mjs +15 -15
  64. package/src/timeline/speech-bubble.mjs +26 -26
  65. package/src/timeline/speed-ramp.mjs +188 -188
  66. package/src/timeline/track-state.mjs +22 -22
  67. package/src/timeline/transition.mjs +34 -34
  68. package/src/timeline/validate.mjs +322 -322
  69. package/src/transcript/corrections.mjs +21 -21
  70. package/src/transcript/normalize.mjs +17 -17
  71. package/src/transcript/provider.mjs +42 -42
  72. package/src/transcript/subtitles.mjs +57 -57
  73. package/src/transcript/validate.mjs +39 -39
@@ -1,521 +1,521 @@
1
- #!/usr/bin/env python3
2
- """OpenTimelineIO JSON bridge for the Node.js editing engine."""
3
-
4
- import json
5
- import math
6
- import os
7
- import sys
8
- from collections.abc import Mapping
9
- from pathlib import Path
10
- from urllib.parse import unquote, urlparse
11
-
12
- import opentimelineio as otio
13
-
14
-
15
- ENGINE_KEY = "ai-video-editing-engine"
16
- ENGINE_CLIP_SETTINGS = (
17
- "audioEffects", "volumeKeyframes", "reverse", "speedRamp",
18
- "transitionIn", "transitionOut", "fadeInSeconds", "fadeOutSeconds",
19
- "keyframes", "fit", "x", "y", "width", "height",
20
- "objectPositionX", "objectPositionY", "zoom", "mask", "blendMode",
21
- "brightness", "contrast", "saturation", "blurPixels", "hueDegrees",
22
- "audioStreamIndex", "videoStreamIndex", "opacity", "volume",
23
- )
24
- ENGINE_TRACK_SETTINGS = ("audioEffects", "volumeKeyframes", "volume")
25
- MARKER_COLOR_HEX = {
26
- "BLACK": "#000000",
27
- "BLUE": "#0000ff",
28
- "CYAN": "#00ffff",
29
- "GREEN": "#00ff00",
30
- "MAGENTA": "#ff00ff",
31
- "ORANGE": "#ff8000",
32
- "PINK": "#ff69b4",
33
- "PURPLE": "#800080",
34
- "RED": "#ff0000",
35
- "WHITE": "#ffffff",
36
- "YELLOW": "#ffff00",
37
- }
38
-
39
-
40
- def plain_json(value):
41
- if isinstance(value, Mapping):
42
- return {key: plain_json(item) for key, item in value.items()}
43
- if isinstance(value, (list, tuple)) or (not isinstance(value, (str, bytes, int, float, bool, type(None))) and hasattr(value, "__iter__")):
44
- return [plain_json(item) for item in value]
45
- return value
46
-
47
-
48
- def time_range(start_seconds, duration_seconds):
49
- return otio.opentime.TimeRange(
50
- otio.opentime.RationalTime(start_seconds, 1),
51
- otio.opentime.RationalTime(duration_seconds, 1),
52
- )
53
-
54
-
55
- def marker_color(color_hex):
56
- if not color_hex:
57
- return otio.schema.Marker.Color.ORANGE
58
- target = tuple(int(color_hex[index:index + 2], 16) for index in (1, 3, 5))
59
- return min(
60
- MARKER_COLOR_HEX,
61
- key=lambda color: sum(
62
- (target[channel] - int(MARKER_COLOR_HEX[color][index:index + 2], 16)) ** 2
63
- for channel, index in enumerate((1, 3, 5))
64
- ),
65
- )
66
-
67
-
68
- def export_timeline(request):
69
- engine_timeline = request["timeline"]
70
- project_root = Path(request["projectRoot"]).resolve()
71
- timeline = otio.schema.Timeline(name=engine_timeline["project"]["id"])
72
- timeline.metadata[ENGINE_KEY] = {"timeline": engine_timeline}
73
- tracks = timeline.tracks
74
- for marker in engine_timeline.get("markers", []):
75
- exported_marker = otio.schema.Marker(
76
- name=marker["label"],
77
- marked_range=time_range(marker["timeSeconds"], 0),
78
- color=marker_color(marker.get("color")),
79
- )
80
- exported_marker.metadata[ENGINE_KEY] = {
81
- "markerId": marker["id"],
82
- **({"colorHex": marker["color"]} if marker.get("color") else {}),
83
- }
84
- tracks.markers.append(exported_marker)
85
-
86
- for source_track in sorted(engine_timeline["tracks"], key=lambda item: item["order"]):
87
- if source_track["kind"] == "overlay":
88
- continue
89
- kind = otio.schema.TrackKind.Video if source_track["kind"] == "video" else otio.schema.TrackKind.Audio
90
- track = otio.schema.Track(name=source_track["id"], kind=kind)
91
- soloed_audio = any(
92
- candidate["kind"] == "audio" and candidate.get("solo") is True
93
- for candidate in engine_timeline["tracks"]
94
- )
95
- track.enabled = not (source_track.get("muted", False) if source_track["kind"] == "audio" else not source_track.get("visible", True))
96
- if source_track["kind"] == "audio" and soloed_audio:
97
- track.enabled = track.enabled and source_track.get("solo") is True
98
- track.metadata[ENGINE_KEY] = {
99
- "trackId": source_track["id"],
100
- "trackSettings": {key: source_track[key] for key in ENGINE_TRACK_SETTINGS if key in source_track},
101
- **({"locked": source_track["locked"]} if source_track.get("locked") is not None else {}),
102
- **({"solo": source_track.get("solo", False), "muted": source_track.get("muted", False)} if source_track["kind"] == "audio" else {}),
103
- }
104
- cursor = 0.0
105
- clips = sorted(source_track["clips"], key=lambda item: item["timelineStart"])
106
- index = 0
107
- while index < len(clips):
108
- source_clip = clips[index]
109
- if source_clip["timelineStart"] < cursor - 1e-6:
110
- raise ValueError(f"Track {source_track['id']} contains overlapping clips, which OTIO interchange does not support.")
111
- gap = source_clip["timelineStart"] - cursor
112
- if gap > 1e-6:
113
- track.append(otio.schema.Gap(source_range=time_range(0, gap)))
114
- next_clip = clips[index + 1] if index + 1 < len(clips) else None
115
- overlap = 0.0 if next_clip is None else (
116
- source_clip["timelineStart"] + source_clip["durationSeconds"] - next_clip["timelineStart"]
117
- )
118
- if overlap > 1e-6:
119
- validate_crossfade_engine_settings(source_clip, next_clip)
120
- if source_clip.get("transitionIn") or source_clip.get("transitionOut"):
121
- raise ValueError(f"Clip {source_clip['id']} combines an Engine transition with an OTIO crossfade, which is not supported by the interchange profile.")
122
- if abs(source_clip.get("playbackRate", 1) - 1) > 1e-9:
123
- raise ValueError(f"Clip {source_clip['id']} uses retiming in a crossfade, which is not supported by the OTIO interchange profile.")
124
- validate_crossfade_pair(source_track, source_clip, next_clip, overlap)
125
- if next_clip.get("reverse"):
126
- raise ValueError(f"Clip {next_clip['id']} uses reverse playback, which is not supported by the OTIO interchange profile.")
127
- if next_clip.get("audioEffects"):
128
- raise ValueError(f"Clip {next_clip['id']} uses clip audio effects, which are not supported by the OTIO interchange profile.")
129
- if next_clip.get("volumeKeyframes"):
130
- raise ValueError(f"Clip {next_clip['id']} uses volume automation, which is not supported by the OTIO interchange profile.")
131
- if next_clip.get("speedRamp") or abs(next_clip.get("playbackRate", 1) - 1) > 1e-9:
132
- raise ValueError(f"Clip {next_clip['id']} uses retiming, which is not supported by the OTIO interchange profile.")
133
- if next_clip.get("transitionIn") or next_clip.get("transitionOut"):
134
- raise ValueError(f"Clip {next_clip['id']} uses transitions, which are not supported by the OTIO interchange profile.")
135
- if index + 2 < len(clips) and next_clip["timelineStart"] + next_clip["durationSeconds"] > clips[index + 2]["timelineStart"] + 1e-6:
136
- raise ValueError("Three-way or chained clip overlaps are not supported by the OTIO interchange profile.")
137
- in_offset = overlap / 2
138
- out_offset = overlap - in_offset
139
- track.append(make_otio_clip(
140
- source_clip,
141
- project_root,
142
- engine_timeline["assets"],
143
- source_clip["sourceIn"],
144
- source_clip["sourceOut"] - out_offset,
145
- f"{source_clip['id']}-crossfade-out",
146
- ))
147
- track.append(otio.schema.Transition(
148
- name=f"{source_clip['id']}-{next_clip['id']}-crossfade",
149
- transition_type="SMPTE_Dissolve",
150
- in_offset=otio.opentime.RationalTime(in_offset, 1),
151
- out_offset=otio.opentime.RationalTime(out_offset, 1),
152
- ))
153
- track.append(make_otio_clip(
154
- next_clip,
155
- project_root,
156
- engine_timeline["assets"],
157
- next_clip["sourceIn"] + in_offset,
158
- next_clip["sourceOut"],
159
- f"{next_clip['id']}-crossfade-in",
160
- ))
161
- cursor = max(
162
- source_clip["timelineStart"] + source_clip["durationSeconds"],
163
- next_clip["timelineStart"] + next_clip["durationSeconds"],
164
- )
165
- index += 2
166
- continue
167
-
168
- track.append(make_otio_clip(
169
- source_clip,
170
- project_root,
171
- engine_timeline["assets"],
172
- source_clip["sourceIn"],
173
- source_clip["sourceOut"],
174
- source_clip["id"],
175
- ))
176
- cursor = source_clip["timelineStart"] + source_clip["durationSeconds"]
177
- index += 1
178
- trailing_gap = engine_timeline["project"]["durationSeconds"] - cursor
179
- if trailing_gap > 1e-6:
180
- track.append(otio.schema.Gap(source_range=time_range(0, trailing_gap)))
181
- tracks.append(track)
182
-
183
- return otio.adapters.write_to_string(timeline, adapter_name="otio_json")
184
-
185
-
186
- def validate_crossfade_pair(track, outgoing, incoming, overlap):
187
- fade_in = incoming.get("fadeInSeconds", 0)
188
- fade_out = outgoing.get("fadeOutSeconds", 0)
189
- if abs(fade_in - overlap) > 1e-6:
190
- raise ValueError(
191
- f"Track {track['id']} contains overlapping clips {outgoing['id']} and {incoming['id']} without a matching incoming fade; OTIO export requires an explicit crossfade."
192
- )
193
- if track["kind"] == "audio" and abs(fade_out - overlap) > 1e-6:
194
- raise ValueError(
195
- f"Audio crossfade {outgoing['id']} to {incoming['id']} must use matching outgoing and incoming fades."
196
- )
197
- if track["kind"] == "video" and fade_out > 0 and abs(fade_out - overlap) > 1e-6:
198
- raise ValueError(
199
- f"Video crossfade {outgoing['id']} to {incoming['id']} has a non-matching outgoing fade."
200
- )
201
- if outgoing.get("fadeInSeconds", 0) > 0 or incoming.get("fadeOutSeconds", 0) > 0:
202
- raise ValueError("Crossfades with additional standalone clip fades are not supported by the OTIO interchange profile.")
203
-
204
-
205
- def validate_crossfade_engine_settings(*clips):
206
- mapped_fades = {"fadeInSeconds", "fadeOutSeconds"}
207
- for clip in clips:
208
- unsupported = [
209
- key for key in ENGINE_CLIP_SETTINGS
210
- if key not in mapped_fades and key in clip and clip[key] is not None
211
- ]
212
- if unsupported:
213
- raise ValueError(
214
- f"Clip {clip['id']} uses Engine settings ({', '.join(unsupported)}) that cannot be split across an OTIO crossfade."
215
- )
216
-
217
-
218
- def make_otio_clip(source_clip, project_root, assets, source_in, source_out, name):
219
- asset = next(item for item in assets if item["id"] == source_clip["assetId"])
220
- media_path = (project_root / asset["path"]).resolve()
221
- if not media_path.is_file():
222
- raise ValueError(f"Asset {asset['id']} is missing: {media_path}")
223
- clip = otio.schema.Clip(
224
- name=name,
225
- media_reference=otio.schema.ExternalReference(
226
- target_url=media_path.as_uri(),
227
- available_range=time_range(0, asset.get("durationSeconds", source_clip["sourceOut"])),
228
- ),
229
- source_range=time_range(source_in, source_out - source_in),
230
- )
231
- clip.metadata[ENGINE_KEY] = {
232
- "clipId": source_clip["id"],
233
- "assetId": asset["id"],
234
- "clipSettings": {key: source_clip[key] for key in ENGINE_CLIP_SETTINGS if key in source_clip},
235
- }
236
- clip.enabled = source_clip.get("enabled", True)
237
- playback_rate = source_clip.get("playbackRate", 1)
238
- if abs(playback_rate - 1) > 1e-9:
239
- clip.effects.append(otio.schema.LinearTimeWarp(name="Engine Playback Rate", time_scalar=playback_rate))
240
- return clip
241
-
242
-
243
- def local_reference(target_url, otio_path):
244
- parsed = urlparse(target_url)
245
- if parsed.scheme == "file":
246
- path_text = unquote(parsed.path)
247
- if os.name == "nt" and len(path_text) >= 3 and path_text[0] == "/" and path_text[2] == ":":
248
- path_text = path_text[1:]
249
- path = Path(path_text)
250
- if parsed.netloc and parsed.netloc not in ("", "localhost"):
251
- path = Path(f"//{parsed.netloc}{path_text}")
252
- return path
253
- if parsed.scheme:
254
- raise ValueError(f"Remote media reference is not supported: {target_url}")
255
- return (otio_path.parent / unquote(target_url)).resolve()
256
-
257
-
258
- def import_timeline(request):
259
- otio_path = Path(request["otioPath"]).resolve()
260
- timeline = otio.adapters.read_from_string(request["otioText"], adapter_name="otio_json")
261
- payload = {
262
- "name": timeline.name,
263
- "durationSeconds": 0.0,
264
- "engineTimeline": plain_json(timeline.metadata.get(ENGINE_KEY, {}).get("timeline")),
265
- "markers": [],
266
- "tracks": [],
267
- }
268
- marker_ids = set()
269
- for marker in timeline.tracks.markers:
270
- metadata = marker.metadata.get(ENGINE_KEY, {})
271
- marker_id = metadata.get("markerId")
272
- if not isinstance(marker_id, str) or not marker_id:
273
- marker_id = safe_id(marker.name, f"marker-{len(payload['markers']) + 1}")
274
- marker_id = unique_id(marker_id, marker_ids)
275
- marker_record = {
276
- "id": marker_id,
277
- "timeSeconds": marker.marked_range.start_time.to_seconds(),
278
- "label": marker.name or marker.comment or marker_id,
279
- }
280
- color = metadata.get("colorHex") or MARKER_COLOR_HEX.get(str(marker.color).upper())
281
- if color:
282
- marker_record["color"] = color
283
- payload["markers"].append(marker_record)
284
-
285
- def import_track(track, group_path, parent_enabled, parent_range=None, timeline_offset=0.0):
286
- if track.kind not in (otio.schema.TrackKind.Audio, otio.schema.TrackKind.Video):
287
- raise ValueError(f"Track {track.name!r} has unsupported kind {track.kind!r}.")
288
- if track.effects:
289
- effects = ", ".join(effect.effect_name or type(effect).__name__ for effect in track.effects)
290
- raise ValueError(f"Track {track.name!r} has effects ({effects}) that the OTIO interchange profile does not support.")
291
- track_range = track.source_range
292
- if track_range is not None and parent_range is not None:
293
- raise ValueError(f"Track {track.name!r} combines track and parent stack trims; combined trim ranges are not supported.")
294
- if track_range is not None:
295
- range_start = track_range.start_time.to_seconds()
296
- range_end = track_range.end_time_exclusive().to_seconds()
297
- elif parent_range is not None:
298
- range_start, range_end = parent_range
299
- else:
300
- range_start, range_end = 0.0, math.inf
301
- bounded_range = track_range is not None or parent_range is not None
302
- if (
303
- not math.isfinite(range_start)
304
- or (bounded_range and not math.isfinite(range_end))
305
- or range_start < 0
306
- or range_end <= range_start
307
- ):
308
- raise ValueError(f"Track {track.name!r} has an invalid or negative trimmed source range.")
309
- kind = "audio" if track.kind == otio.schema.TrackKind.Audio else "video"
310
- name = " / ".join([*group_path, track.name])
311
- imported = {
312
- "name": name,
313
- "kind": kind,
314
- "enabled": bool(track.enabled and parent_enabled),
315
- "engineTrackId": track.metadata.get(ENGINE_KEY, {}).get("trackId"),
316
- "engineSettings": plain_json(track.metadata.get(ENGINE_KEY, {}).get("trackSettings", {})),
317
- **({"locked": track.metadata[ENGINE_KEY]["locked"]} if isinstance(track.metadata.get(ENGINE_KEY), Mapping) and isinstance(track.metadata[ENGINE_KEY].get("locked"), bool) else {}),
318
- **({"solo": track.metadata[ENGINE_KEY]["solo"]} if isinstance(track.metadata.get(ENGINE_KEY), Mapping) and isinstance(track.metadata[ENGINE_KEY].get("solo"), bool) else {}),
319
- **({"muted": track.metadata[ENGINE_KEY]["muted"]} if isinstance(track.metadata.get(ENGINE_KEY), Mapping) and isinstance(track.metadata[ENGINE_KEY].get("muted"), bool) else {}),
320
- "clips": [],
321
- }
322
- cursor = 0.0
323
- expanded_items = otio.algorithms.track_with_expanded_transitions(track)
324
-
325
- def clip_record(item, timeline_start, crossfade=None, source_window=None):
326
- if not isinstance(item, otio.schema.Clip):
327
- raise ValueError("Only transitions between two clips are supported by the OTIO interchange profile.")
328
- unsupported_effects = [effect for effect in item.effects if not isinstance(effect, otio.schema.LinearTimeWarp)]
329
- if unsupported_effects:
330
- effects = ", ".join(effect.effect_name or type(effect).__name__ for effect in unsupported_effects)
331
- raise ValueError(f"Clip {item.name!r} has effects ({effects}) that the OTIO interchange profile does not support.")
332
- time_warps = [effect for effect in item.effects if isinstance(effect, otio.schema.LinearTimeWarp) and effect.enabled]
333
- if len(time_warps) > 1:
334
- raise ValueError(f"Clip {item.name!r} uses multiple LinearTimeWarp effects, which the OTIO interchange profile does not support.")
335
- playback_rate = time_warps[0].time_scalar if time_warps else 1.0
336
- if (
337
- isinstance(playback_rate, bool)
338
- or not isinstance(playback_rate, (int, float))
339
- or not math.isfinite(playback_rate)
340
- or not 0.1 <= playback_rate <= 5
341
- ):
342
- raise ValueError(f"Clip {item.name!r} uses a LinearTimeWarp rate outside the Engine's supported range (0.1 to 5).")
343
- if crossfade and abs(playback_rate - 1) > 1e-9:
344
- raise ValueError(f"Transition clip {item.name!r} uses retiming, which the OTIO interchange profile does not support.")
345
- media_reference = item.media_reference
346
- if not isinstance(media_reference, otio.schema.ExternalReference):
347
- raise ValueError(f"Clip {item.name!r} does not use an external media reference.")
348
- source_range = item.source_range or media_reference.available_range
349
- if source_range is None:
350
- raise ValueError(f"Clip {item.name!r} has no source range or available media range.")
351
- source_offset, timeline_duration = (0.0, None) if source_window is None else source_window
352
- source_start = source_range.start_time.to_seconds() + source_offset * playback_rate
353
- source_duration = source_range.duration.to_seconds() if timeline_duration is None else timeline_duration * playback_rate
354
- duration = source_duration / playback_rate
355
- metadata = item.metadata.get(ENGINE_KEY, {})
356
- return {
357
- "name": item.name,
358
- "path": str(local_reference(media_reference.target_url, otio_path)),
359
- "timelineStart": timeline_start,
360
- "sourceIn": source_start,
361
- "sourceOut": source_start + source_duration,
362
- "durationSeconds": duration,
363
- "enabled": bool(item.enabled),
364
- **({"playbackRate": playback_rate} if abs(playback_rate - 1) > 1e-9 else {}),
365
- "engineClipId": metadata.get("clipId") if crossfade is None else None,
366
- "engineAssetId": metadata.get("assetId"),
367
- "engineSettings": plain_json(metadata.get("clipSettings", {})) if crossfade is None else {},
368
- **({"crossfade": crossfade} if crossfade else {}),
369
- }
370
-
371
- for item in expanded_items:
372
- if isinstance(item, tuple):
373
- outgoing, transition, incoming = item
374
- duration = (transition.in_offset + transition.out_offset).to_seconds()
375
- if duration <= 0:
376
- raise ValueError(f"Transition {transition.name!r} must have a positive duration.")
377
- selected_start = max(cursor, range_start)
378
- selected_end = min(cursor + duration, range_end)
379
- if selected_end > selected_start:
380
- if selected_start > cursor + 1e-6 or selected_end < cursor + duration - 1e-6:
381
- raise ValueError(f"Track {track.name!r} trims through transition {transition.name!r}; transition-boundary trims are not supported.")
382
- if transition.transition_type != "SMPTE_Dissolve":
383
- if selected_end > selected_start:
384
- raise ValueError(
385
- f"Transition {transition.name!r} uses unsupported type {transition.transition_type!r}; only SMPTE_Dissolve is supported."
386
- )
387
- if selected_end > selected_start:
388
- pair_id = f"otio-transition-{len(imported['clips']) + 1:04d}"
389
- timeline_start = timeline_offset + selected_start - range_start
390
- for item_clip, role, suffix in ((outgoing, "outgoing", "a-out"), (incoming, "incoming", "b-in")):
391
- crossfade = {"role": role, "durationSeconds": duration}
392
- record = clip_record(item_clip, timeline_start, crossfade)
393
- record["name"] = f"{pair_id}-{suffix}"
394
- if abs(record["durationSeconds"] - duration) > 1e-6:
395
- raise ValueError(f"Transition {transition.name!r} does not expand to equal-duration clip segments.")
396
- imported["clips"].append(record)
397
- cursor += duration
398
- continue
399
- if isinstance(item, otio.schema.Gap):
400
- if item.effects:
401
- effects = ", ".join(effect.effect_name or type(effect).__name__ for effect in item.effects)
402
- raise ValueError(f"Gap {item.name!r} has effects ({effects}) that the OTIO interchange profile does not support.")
403
- cursor += item.duration().to_seconds()
404
- continue
405
- if isinstance(item, (otio.schema.Track, otio.schema.Stack)):
406
- item_duration = item.duration().to_seconds()
407
- if not math.isfinite(item_duration) or item_duration < 0:
408
- raise ValueError(f"Nested composition {item.name!r} has an invalid duration.")
409
- selected_start = max(cursor, range_start)
410
- selected_end = min(cursor + item_duration, range_end)
411
- if selected_end > selected_start:
412
- relative_start = selected_start - cursor
413
- relative_end = selected_end - cursor
414
- nested_range = None
415
- if (track_range is not None or parent_range is not None) and (
416
- relative_start > 1e-6 or relative_end < item_duration - 1e-6
417
- ):
418
- item_source_range = item.source_range
419
- if item_source_range is not None:
420
- raise ValueError(
421
- f"Nested composition {item.name!r} combines its own source range with a parent track trim; combined nested trims are not supported."
422
- )
423
- nested_range = (relative_start, relative_end)
424
- nested_offset = timeline_offset + selected_start - range_start
425
- nested_group = (*group_path, item.name) if isinstance(item, otio.schema.Stack) and item.name else group_path
426
- nested_enabled = bool(item.enabled and parent_enabled)
427
- if isinstance(item, otio.schema.Track):
428
- import_track(item, nested_group, nested_enabled, nested_range, nested_offset)
429
- else:
430
- import_stack(
431
- item,
432
- nested_group,
433
- include_self=False,
434
- parent_enabled=nested_enabled,
435
- parent_range=nested_range,
436
- timeline_offset=nested_offset,
437
- )
438
- cursor += item_duration
439
- continue
440
- if not isinstance(item, otio.schema.Clip):
441
- raise ValueError(
442
- f"OTIO item {item.name!r} is not a clip, gap, track, or stack supported by the interchange profile."
443
- )
444
- full_record = clip_record(item, cursor)
445
- item_duration = full_record["durationSeconds"]
446
- selected_start = max(cursor, range_start)
447
- selected_end = min(cursor + item_duration, range_end)
448
- if selected_end > selected_start:
449
- if track_range is None and parent_range is None:
450
- full_record["timelineStart"] += timeline_offset
451
- imported["clips"].append(full_record)
452
- else:
453
- relative_start = selected_start - cursor
454
- relative_end = selected_end - cursor
455
- record = clip_record(
456
- item,
457
- timeline_offset + selected_start - range_start,
458
- source_window=(relative_start, relative_end - relative_start),
459
- )
460
- imported["clips"].append(record)
461
- cursor += item_duration
462
- visible_duration = cursor if track_range is None and parent_range is None else range_end - range_start
463
- payload["durationSeconds"] = max(payload["durationSeconds"], timeline_offset + visible_duration)
464
- imported["order"] = len(payload["tracks"])
465
- payload["tracks"].append(imported)
466
-
467
- def import_stack(stack, group_path=(), include_self=True, parent_enabled=True, parent_range=None, timeline_offset=0.0):
468
- stack_range = stack.source_range
469
- if stack_range is not None and parent_range is not None:
470
- raise ValueError(f"Stack {stack.name!r} combines nested stack trims; combined trim ranges are not supported.")
471
- if stack_range is not None:
472
- range_start = stack_range.start_time.to_seconds()
473
- range_end = stack_range.end_time_exclusive().to_seconds()
474
- if (
475
- not math.isfinite(range_start)
476
- or not math.isfinite(range_end)
477
- or range_start < 0
478
- or range_end <= range_start
479
- ):
480
- raise ValueError(f"Stack {stack.name!r} has an invalid or negative trimmed source range.")
481
- if any(isinstance(item, otio.schema.Stack) for item in stack):
482
- raise ValueError(f"Stack {stack.name!r} trims nested stacks; nested stack trims are not supported.")
483
- if any(isinstance(item, otio.schema.Track) and item.source_range is not None for item in stack):
484
- raise ValueError(f"Stack {stack.name!r} combines stack and track trims; combined trim ranges are not supported.")
485
- child_range = (range_start, range_end)
486
- else:
487
- child_range = parent_range
488
- if stack.effects:
489
- effects = ", ".join(effect.effect_name or type(effect).__name__ for effect in stack.effects)
490
- raise ValueError(f"Stack {stack.name!r} has effects ({effects}) that the OTIO interchange profile does not support.")
491
- stack_enabled = bool(stack.enabled and parent_enabled)
492
- next_group = (*group_path, stack.name) if include_self and stack.name else group_path
493
- for item in stack:
494
- if isinstance(item, otio.schema.Track):
495
- import_track(item, next_group, stack_enabled, child_range, timeline_offset)
496
- elif isinstance(item, otio.schema.Stack):
497
- import_stack(item, next_group, parent_enabled=stack_enabled, parent_range=child_range, timeline_offset=timeline_offset)
498
- else:
499
- raise ValueError(f"OTIO stack item {item.name!r} is not a track or nested stack.")
500
-
501
- import_stack(timeline.tracks, include_self=False)
502
- return payload
503
-
504
-
505
- def main():
506
- request = json.load(sys.stdin)
507
- if request.get("operation") == "export":
508
- result = export_timeline(request)
509
- elif request.get("operation") == "import":
510
- result = import_timeline(request)
511
- else:
512
- raise ValueError("operation must be export or import")
513
- json.dump({"result": result}, sys.stdout, ensure_ascii=False)
514
-
515
-
516
- if __name__ == "__main__":
517
- try:
518
- main()
519
- except Exception as error: # Keep a concise, actionable bridge error for Node.
520
- print(f"OTIO bridge: {error}", file=sys.stderr)
521
- sys.exit(1)
1
+ #!/usr/bin/env python3
2
+ """OpenTimelineIO JSON bridge for the Node.js editing engine."""
3
+
4
+ import json
5
+ import math
6
+ import os
7
+ import sys
8
+ from collections.abc import Mapping
9
+ from pathlib import Path
10
+ from urllib.parse import unquote, urlparse
11
+
12
+ import opentimelineio as otio
13
+
14
+
15
+ ENGINE_KEY = "ai-video-editing-engine"
16
+ ENGINE_CLIP_SETTINGS = (
17
+ "audioEffects", "volumeKeyframes", "reverse", "speedRamp",
18
+ "transitionIn", "transitionOut", "fadeInSeconds", "fadeOutSeconds",
19
+ "keyframes", "fit", "x", "y", "width", "height",
20
+ "objectPositionX", "objectPositionY", "zoom", "mask", "blendMode",
21
+ "brightness", "contrast", "saturation", "blurPixels", "hueDegrees",
22
+ "audioStreamIndex", "videoStreamIndex", "opacity", "volume",
23
+ )
24
+ ENGINE_TRACK_SETTINGS = ("audioEffects", "volumeKeyframes", "volume")
25
+ MARKER_COLOR_HEX = {
26
+ "BLACK": "#000000",
27
+ "BLUE": "#0000ff",
28
+ "CYAN": "#00ffff",
29
+ "GREEN": "#00ff00",
30
+ "MAGENTA": "#ff00ff",
31
+ "ORANGE": "#ff8000",
32
+ "PINK": "#ff69b4",
33
+ "PURPLE": "#800080",
34
+ "RED": "#ff0000",
35
+ "WHITE": "#ffffff",
36
+ "YELLOW": "#ffff00",
37
+ }
38
+
39
+
40
+ def plain_json(value):
41
+ if isinstance(value, Mapping):
42
+ return {key: plain_json(item) for key, item in value.items()}
43
+ if isinstance(value, (list, tuple)) or (not isinstance(value, (str, bytes, int, float, bool, type(None))) and hasattr(value, "__iter__")):
44
+ return [plain_json(item) for item in value]
45
+ return value
46
+
47
+
48
+ def time_range(start_seconds, duration_seconds):
49
+ return otio.opentime.TimeRange(
50
+ otio.opentime.RationalTime(start_seconds, 1),
51
+ otio.opentime.RationalTime(duration_seconds, 1),
52
+ )
53
+
54
+
55
+ def marker_color(color_hex):
56
+ if not color_hex:
57
+ return otio.schema.Marker.Color.ORANGE
58
+ target = tuple(int(color_hex[index:index + 2], 16) for index in (1, 3, 5))
59
+ return min(
60
+ MARKER_COLOR_HEX,
61
+ key=lambda color: sum(
62
+ (target[channel] - int(MARKER_COLOR_HEX[color][index:index + 2], 16)) ** 2
63
+ for channel, index in enumerate((1, 3, 5))
64
+ ),
65
+ )
66
+
67
+
68
+ def export_timeline(request):
69
+ engine_timeline = request["timeline"]
70
+ project_root = Path(request["projectRoot"]).resolve()
71
+ timeline = otio.schema.Timeline(name=engine_timeline["project"]["id"])
72
+ timeline.metadata[ENGINE_KEY] = {"timeline": engine_timeline}
73
+ tracks = timeline.tracks
74
+ for marker in engine_timeline.get("markers", []):
75
+ exported_marker = otio.schema.Marker(
76
+ name=marker["label"],
77
+ marked_range=time_range(marker["timeSeconds"], 0),
78
+ color=marker_color(marker.get("color")),
79
+ )
80
+ exported_marker.metadata[ENGINE_KEY] = {
81
+ "markerId": marker["id"],
82
+ **({"colorHex": marker["color"]} if marker.get("color") else {}),
83
+ }
84
+ tracks.markers.append(exported_marker)
85
+
86
+ for source_track in sorted(engine_timeline["tracks"], key=lambda item: item["order"]):
87
+ if source_track["kind"] == "overlay":
88
+ continue
89
+ kind = otio.schema.TrackKind.Video if source_track["kind"] == "video" else otio.schema.TrackKind.Audio
90
+ track = otio.schema.Track(name=source_track["id"], kind=kind)
91
+ soloed_audio = any(
92
+ candidate["kind"] == "audio" and candidate.get("solo") is True
93
+ for candidate in engine_timeline["tracks"]
94
+ )
95
+ track.enabled = not (source_track.get("muted", False) if source_track["kind"] == "audio" else not source_track.get("visible", True))
96
+ if source_track["kind"] == "audio" and soloed_audio:
97
+ track.enabled = track.enabled and source_track.get("solo") is True
98
+ track.metadata[ENGINE_KEY] = {
99
+ "trackId": source_track["id"],
100
+ "trackSettings": {key: source_track[key] for key in ENGINE_TRACK_SETTINGS if key in source_track},
101
+ **({"locked": source_track["locked"]} if source_track.get("locked") is not None else {}),
102
+ **({"solo": source_track.get("solo", False), "muted": source_track.get("muted", False)} if source_track["kind"] == "audio" else {}),
103
+ }
104
+ cursor = 0.0
105
+ clips = sorted(source_track["clips"], key=lambda item: item["timelineStart"])
106
+ index = 0
107
+ while index < len(clips):
108
+ source_clip = clips[index]
109
+ if source_clip["timelineStart"] < cursor - 1e-6:
110
+ raise ValueError(f"Track {source_track['id']} contains overlapping clips, which OTIO interchange does not support.")
111
+ gap = source_clip["timelineStart"] - cursor
112
+ if gap > 1e-6:
113
+ track.append(otio.schema.Gap(source_range=time_range(0, gap)))
114
+ next_clip = clips[index + 1] if index + 1 < len(clips) else None
115
+ overlap = 0.0 if next_clip is None else (
116
+ source_clip["timelineStart"] + source_clip["durationSeconds"] - next_clip["timelineStart"]
117
+ )
118
+ if overlap > 1e-6:
119
+ validate_crossfade_engine_settings(source_clip, next_clip)
120
+ if source_clip.get("transitionIn") or source_clip.get("transitionOut"):
121
+ raise ValueError(f"Clip {source_clip['id']} combines an Engine transition with an OTIO crossfade, which is not supported by the interchange profile.")
122
+ if abs(source_clip.get("playbackRate", 1) - 1) > 1e-9:
123
+ raise ValueError(f"Clip {source_clip['id']} uses retiming in a crossfade, which is not supported by the OTIO interchange profile.")
124
+ validate_crossfade_pair(source_track, source_clip, next_clip, overlap)
125
+ if next_clip.get("reverse"):
126
+ raise ValueError(f"Clip {next_clip['id']} uses reverse playback, which is not supported by the OTIO interchange profile.")
127
+ if next_clip.get("audioEffects"):
128
+ raise ValueError(f"Clip {next_clip['id']} uses clip audio effects, which are not supported by the OTIO interchange profile.")
129
+ if next_clip.get("volumeKeyframes"):
130
+ raise ValueError(f"Clip {next_clip['id']} uses volume automation, which is not supported by the OTIO interchange profile.")
131
+ if next_clip.get("speedRamp") or abs(next_clip.get("playbackRate", 1) - 1) > 1e-9:
132
+ raise ValueError(f"Clip {next_clip['id']} uses retiming, which is not supported by the OTIO interchange profile.")
133
+ if next_clip.get("transitionIn") or next_clip.get("transitionOut"):
134
+ raise ValueError(f"Clip {next_clip['id']} uses transitions, which are not supported by the OTIO interchange profile.")
135
+ if index + 2 < len(clips) and next_clip["timelineStart"] + next_clip["durationSeconds"] > clips[index + 2]["timelineStart"] + 1e-6:
136
+ raise ValueError("Three-way or chained clip overlaps are not supported by the OTIO interchange profile.")
137
+ in_offset = overlap / 2
138
+ out_offset = overlap - in_offset
139
+ track.append(make_otio_clip(
140
+ source_clip,
141
+ project_root,
142
+ engine_timeline["assets"],
143
+ source_clip["sourceIn"],
144
+ source_clip["sourceOut"] - out_offset,
145
+ f"{source_clip['id']}-crossfade-out",
146
+ ))
147
+ track.append(otio.schema.Transition(
148
+ name=f"{source_clip['id']}-{next_clip['id']}-crossfade",
149
+ transition_type="SMPTE_Dissolve",
150
+ in_offset=otio.opentime.RationalTime(in_offset, 1),
151
+ out_offset=otio.opentime.RationalTime(out_offset, 1),
152
+ ))
153
+ track.append(make_otio_clip(
154
+ next_clip,
155
+ project_root,
156
+ engine_timeline["assets"],
157
+ next_clip["sourceIn"] + in_offset,
158
+ next_clip["sourceOut"],
159
+ f"{next_clip['id']}-crossfade-in",
160
+ ))
161
+ cursor = max(
162
+ source_clip["timelineStart"] + source_clip["durationSeconds"],
163
+ next_clip["timelineStart"] + next_clip["durationSeconds"],
164
+ )
165
+ index += 2
166
+ continue
167
+
168
+ track.append(make_otio_clip(
169
+ source_clip,
170
+ project_root,
171
+ engine_timeline["assets"],
172
+ source_clip["sourceIn"],
173
+ source_clip["sourceOut"],
174
+ source_clip["id"],
175
+ ))
176
+ cursor = source_clip["timelineStart"] + source_clip["durationSeconds"]
177
+ index += 1
178
+ trailing_gap = engine_timeline["project"]["durationSeconds"] - cursor
179
+ if trailing_gap > 1e-6:
180
+ track.append(otio.schema.Gap(source_range=time_range(0, trailing_gap)))
181
+ tracks.append(track)
182
+
183
+ return otio.adapters.write_to_string(timeline, adapter_name="otio_json")
184
+
185
+
186
+ def validate_crossfade_pair(track, outgoing, incoming, overlap):
187
+ fade_in = incoming.get("fadeInSeconds", 0)
188
+ fade_out = outgoing.get("fadeOutSeconds", 0)
189
+ if abs(fade_in - overlap) > 1e-6:
190
+ raise ValueError(
191
+ f"Track {track['id']} contains overlapping clips {outgoing['id']} and {incoming['id']} without a matching incoming fade; OTIO export requires an explicit crossfade."
192
+ )
193
+ if track["kind"] == "audio" and abs(fade_out - overlap) > 1e-6:
194
+ raise ValueError(
195
+ f"Audio crossfade {outgoing['id']} to {incoming['id']} must use matching outgoing and incoming fades."
196
+ )
197
+ if track["kind"] == "video" and fade_out > 0 and abs(fade_out - overlap) > 1e-6:
198
+ raise ValueError(
199
+ f"Video crossfade {outgoing['id']} to {incoming['id']} has a non-matching outgoing fade."
200
+ )
201
+ if outgoing.get("fadeInSeconds", 0) > 0 or incoming.get("fadeOutSeconds", 0) > 0:
202
+ raise ValueError("Crossfades with additional standalone clip fades are not supported by the OTIO interchange profile.")
203
+
204
+
205
+ def validate_crossfade_engine_settings(*clips):
206
+ mapped_fades = {"fadeInSeconds", "fadeOutSeconds"}
207
+ for clip in clips:
208
+ unsupported = [
209
+ key for key in ENGINE_CLIP_SETTINGS
210
+ if key not in mapped_fades and key in clip and clip[key] is not None
211
+ ]
212
+ if unsupported:
213
+ raise ValueError(
214
+ f"Clip {clip['id']} uses Engine settings ({', '.join(unsupported)}) that cannot be split across an OTIO crossfade."
215
+ )
216
+
217
+
218
+ def make_otio_clip(source_clip, project_root, assets, source_in, source_out, name):
219
+ asset = next(item for item in assets if item["id"] == source_clip["assetId"])
220
+ media_path = (project_root / asset["path"]).resolve()
221
+ if not media_path.is_file():
222
+ raise ValueError(f"Asset {asset['id']} is missing: {media_path}")
223
+ clip = otio.schema.Clip(
224
+ name=name,
225
+ media_reference=otio.schema.ExternalReference(
226
+ target_url=media_path.as_uri(),
227
+ available_range=time_range(0, asset.get("durationSeconds", source_clip["sourceOut"])),
228
+ ),
229
+ source_range=time_range(source_in, source_out - source_in),
230
+ )
231
+ clip.metadata[ENGINE_KEY] = {
232
+ "clipId": source_clip["id"],
233
+ "assetId": asset["id"],
234
+ "clipSettings": {key: source_clip[key] for key in ENGINE_CLIP_SETTINGS if key in source_clip},
235
+ }
236
+ clip.enabled = source_clip.get("enabled", True)
237
+ playback_rate = source_clip.get("playbackRate", 1)
238
+ if abs(playback_rate - 1) > 1e-9:
239
+ clip.effects.append(otio.schema.LinearTimeWarp(name="Engine Playback Rate", time_scalar=playback_rate))
240
+ return clip
241
+
242
+
243
+ def local_reference(target_url, otio_path):
244
+ parsed = urlparse(target_url)
245
+ if parsed.scheme == "file":
246
+ path_text = unquote(parsed.path)
247
+ if os.name == "nt" and len(path_text) >= 3 and path_text[0] == "/" and path_text[2] == ":":
248
+ path_text = path_text[1:]
249
+ path = Path(path_text)
250
+ if parsed.netloc and parsed.netloc not in ("", "localhost"):
251
+ path = Path(f"//{parsed.netloc}{path_text}")
252
+ return path
253
+ if parsed.scheme:
254
+ raise ValueError(f"Remote media reference is not supported: {target_url}")
255
+ return (otio_path.parent / unquote(target_url)).resolve()
256
+
257
+
258
+ def import_timeline(request):
259
+ otio_path = Path(request["otioPath"]).resolve()
260
+ timeline = otio.adapters.read_from_string(request["otioText"], adapter_name="otio_json")
261
+ payload = {
262
+ "name": timeline.name,
263
+ "durationSeconds": 0.0,
264
+ "engineTimeline": plain_json(timeline.metadata.get(ENGINE_KEY, {}).get("timeline")),
265
+ "markers": [],
266
+ "tracks": [],
267
+ }
268
+ marker_ids = set()
269
+ for marker in timeline.tracks.markers:
270
+ metadata = marker.metadata.get(ENGINE_KEY, {})
271
+ marker_id = metadata.get("markerId")
272
+ if not isinstance(marker_id, str) or not marker_id:
273
+ marker_id = safe_id(marker.name, f"marker-{len(payload['markers']) + 1}")
274
+ marker_id = unique_id(marker_id, marker_ids)
275
+ marker_record = {
276
+ "id": marker_id,
277
+ "timeSeconds": marker.marked_range.start_time.to_seconds(),
278
+ "label": marker.name or marker.comment or marker_id,
279
+ }
280
+ color = metadata.get("colorHex") or MARKER_COLOR_HEX.get(str(marker.color).upper())
281
+ if color:
282
+ marker_record["color"] = color
283
+ payload["markers"].append(marker_record)
284
+
285
+ def import_track(track, group_path, parent_enabled, parent_range=None, timeline_offset=0.0):
286
+ if track.kind not in (otio.schema.TrackKind.Audio, otio.schema.TrackKind.Video):
287
+ raise ValueError(f"Track {track.name!r} has unsupported kind {track.kind!r}.")
288
+ if track.effects:
289
+ effects = ", ".join(effect.effect_name or type(effect).__name__ for effect in track.effects)
290
+ raise ValueError(f"Track {track.name!r} has effects ({effects}) that the OTIO interchange profile does not support.")
291
+ track_range = track.source_range
292
+ if track_range is not None and parent_range is not None:
293
+ raise ValueError(f"Track {track.name!r} combines track and parent stack trims; combined trim ranges are not supported.")
294
+ if track_range is not None:
295
+ range_start = track_range.start_time.to_seconds()
296
+ range_end = track_range.end_time_exclusive().to_seconds()
297
+ elif parent_range is not None:
298
+ range_start, range_end = parent_range
299
+ else:
300
+ range_start, range_end = 0.0, math.inf
301
+ bounded_range = track_range is not None or parent_range is not None
302
+ if (
303
+ not math.isfinite(range_start)
304
+ or (bounded_range and not math.isfinite(range_end))
305
+ or range_start < 0
306
+ or range_end <= range_start
307
+ ):
308
+ raise ValueError(f"Track {track.name!r} has an invalid or negative trimmed source range.")
309
+ kind = "audio" if track.kind == otio.schema.TrackKind.Audio else "video"
310
+ name = " / ".join([*group_path, track.name])
311
+ imported = {
312
+ "name": name,
313
+ "kind": kind,
314
+ "enabled": bool(track.enabled and parent_enabled),
315
+ "engineTrackId": track.metadata.get(ENGINE_KEY, {}).get("trackId"),
316
+ "engineSettings": plain_json(track.metadata.get(ENGINE_KEY, {}).get("trackSettings", {})),
317
+ **({"locked": track.metadata[ENGINE_KEY]["locked"]} if isinstance(track.metadata.get(ENGINE_KEY), Mapping) and isinstance(track.metadata[ENGINE_KEY].get("locked"), bool) else {}),
318
+ **({"solo": track.metadata[ENGINE_KEY]["solo"]} if isinstance(track.metadata.get(ENGINE_KEY), Mapping) and isinstance(track.metadata[ENGINE_KEY].get("solo"), bool) else {}),
319
+ **({"muted": track.metadata[ENGINE_KEY]["muted"]} if isinstance(track.metadata.get(ENGINE_KEY), Mapping) and isinstance(track.metadata[ENGINE_KEY].get("muted"), bool) else {}),
320
+ "clips": [],
321
+ }
322
+ cursor = 0.0
323
+ expanded_items = otio.algorithms.track_with_expanded_transitions(track)
324
+
325
+ def clip_record(item, timeline_start, crossfade=None, source_window=None):
326
+ if not isinstance(item, otio.schema.Clip):
327
+ raise ValueError("Only transitions between two clips are supported by the OTIO interchange profile.")
328
+ unsupported_effects = [effect for effect in item.effects if not isinstance(effect, otio.schema.LinearTimeWarp)]
329
+ if unsupported_effects:
330
+ effects = ", ".join(effect.effect_name or type(effect).__name__ for effect in unsupported_effects)
331
+ raise ValueError(f"Clip {item.name!r} has effects ({effects}) that the OTIO interchange profile does not support.")
332
+ time_warps = [effect for effect in item.effects if isinstance(effect, otio.schema.LinearTimeWarp) and effect.enabled]
333
+ if len(time_warps) > 1:
334
+ raise ValueError(f"Clip {item.name!r} uses multiple LinearTimeWarp effects, which the OTIO interchange profile does not support.")
335
+ playback_rate = time_warps[0].time_scalar if time_warps else 1.0
336
+ if (
337
+ isinstance(playback_rate, bool)
338
+ or not isinstance(playback_rate, (int, float))
339
+ or not math.isfinite(playback_rate)
340
+ or not 0.1 <= playback_rate <= 5
341
+ ):
342
+ raise ValueError(f"Clip {item.name!r} uses a LinearTimeWarp rate outside the Engine's supported range (0.1 to 5).")
343
+ if crossfade and abs(playback_rate - 1) > 1e-9:
344
+ raise ValueError(f"Transition clip {item.name!r} uses retiming, which the OTIO interchange profile does not support.")
345
+ media_reference = item.media_reference
346
+ if not isinstance(media_reference, otio.schema.ExternalReference):
347
+ raise ValueError(f"Clip {item.name!r} does not use an external media reference.")
348
+ source_range = item.source_range or media_reference.available_range
349
+ if source_range is None:
350
+ raise ValueError(f"Clip {item.name!r} has no source range or available media range.")
351
+ source_offset, timeline_duration = (0.0, None) if source_window is None else source_window
352
+ source_start = source_range.start_time.to_seconds() + source_offset * playback_rate
353
+ source_duration = source_range.duration.to_seconds() if timeline_duration is None else timeline_duration * playback_rate
354
+ duration = source_duration / playback_rate
355
+ metadata = item.metadata.get(ENGINE_KEY, {})
356
+ return {
357
+ "name": item.name,
358
+ "path": str(local_reference(media_reference.target_url, otio_path)),
359
+ "timelineStart": timeline_start,
360
+ "sourceIn": source_start,
361
+ "sourceOut": source_start + source_duration,
362
+ "durationSeconds": duration,
363
+ "enabled": bool(item.enabled),
364
+ **({"playbackRate": playback_rate} if abs(playback_rate - 1) > 1e-9 else {}),
365
+ "engineClipId": metadata.get("clipId") if crossfade is None else None,
366
+ "engineAssetId": metadata.get("assetId"),
367
+ "engineSettings": plain_json(metadata.get("clipSettings", {})) if crossfade is None else {},
368
+ **({"crossfade": crossfade} if crossfade else {}),
369
+ }
370
+
371
+ for item in expanded_items:
372
+ if isinstance(item, tuple):
373
+ outgoing, transition, incoming = item
374
+ duration = (transition.in_offset + transition.out_offset).to_seconds()
375
+ if duration <= 0:
376
+ raise ValueError(f"Transition {transition.name!r} must have a positive duration.")
377
+ selected_start = max(cursor, range_start)
378
+ selected_end = min(cursor + duration, range_end)
379
+ if selected_end > selected_start:
380
+ if selected_start > cursor + 1e-6 or selected_end < cursor + duration - 1e-6:
381
+ raise ValueError(f"Track {track.name!r} trims through transition {transition.name!r}; transition-boundary trims are not supported.")
382
+ if transition.transition_type != "SMPTE_Dissolve":
383
+ if selected_end > selected_start:
384
+ raise ValueError(
385
+ f"Transition {transition.name!r} uses unsupported type {transition.transition_type!r}; only SMPTE_Dissolve is supported."
386
+ )
387
+ if selected_end > selected_start:
388
+ pair_id = f"otio-transition-{len(imported['clips']) + 1:04d}"
389
+ timeline_start = timeline_offset + selected_start - range_start
390
+ for item_clip, role, suffix in ((outgoing, "outgoing", "a-out"), (incoming, "incoming", "b-in")):
391
+ crossfade = {"role": role, "durationSeconds": duration}
392
+ record = clip_record(item_clip, timeline_start, crossfade)
393
+ record["name"] = f"{pair_id}-{suffix}"
394
+ if abs(record["durationSeconds"] - duration) > 1e-6:
395
+ raise ValueError(f"Transition {transition.name!r} does not expand to equal-duration clip segments.")
396
+ imported["clips"].append(record)
397
+ cursor += duration
398
+ continue
399
+ if isinstance(item, otio.schema.Gap):
400
+ if item.effects:
401
+ effects = ", ".join(effect.effect_name or type(effect).__name__ for effect in item.effects)
402
+ raise ValueError(f"Gap {item.name!r} has effects ({effects}) that the OTIO interchange profile does not support.")
403
+ cursor += item.duration().to_seconds()
404
+ continue
405
+ if isinstance(item, (otio.schema.Track, otio.schema.Stack)):
406
+ item_duration = item.duration().to_seconds()
407
+ if not math.isfinite(item_duration) or item_duration < 0:
408
+ raise ValueError(f"Nested composition {item.name!r} has an invalid duration.")
409
+ selected_start = max(cursor, range_start)
410
+ selected_end = min(cursor + item_duration, range_end)
411
+ if selected_end > selected_start:
412
+ relative_start = selected_start - cursor
413
+ relative_end = selected_end - cursor
414
+ nested_range = None
415
+ if (track_range is not None or parent_range is not None) and (
416
+ relative_start > 1e-6 or relative_end < item_duration - 1e-6
417
+ ):
418
+ item_source_range = item.source_range
419
+ if item_source_range is not None:
420
+ raise ValueError(
421
+ f"Nested composition {item.name!r} combines its own source range with a parent track trim; combined nested trims are not supported."
422
+ )
423
+ nested_range = (relative_start, relative_end)
424
+ nested_offset = timeline_offset + selected_start - range_start
425
+ nested_group = (*group_path, item.name) if isinstance(item, otio.schema.Stack) and item.name else group_path
426
+ nested_enabled = bool(item.enabled and parent_enabled)
427
+ if isinstance(item, otio.schema.Track):
428
+ import_track(item, nested_group, nested_enabled, nested_range, nested_offset)
429
+ else:
430
+ import_stack(
431
+ item,
432
+ nested_group,
433
+ include_self=False,
434
+ parent_enabled=nested_enabled,
435
+ parent_range=nested_range,
436
+ timeline_offset=nested_offset,
437
+ )
438
+ cursor += item_duration
439
+ continue
440
+ if not isinstance(item, otio.schema.Clip):
441
+ raise ValueError(
442
+ f"OTIO item {item.name!r} is not a clip, gap, track, or stack supported by the interchange profile."
443
+ )
444
+ full_record = clip_record(item, cursor)
445
+ item_duration = full_record["durationSeconds"]
446
+ selected_start = max(cursor, range_start)
447
+ selected_end = min(cursor + item_duration, range_end)
448
+ if selected_end > selected_start:
449
+ if track_range is None and parent_range is None:
450
+ full_record["timelineStart"] += timeline_offset
451
+ imported["clips"].append(full_record)
452
+ else:
453
+ relative_start = selected_start - cursor
454
+ relative_end = selected_end - cursor
455
+ record = clip_record(
456
+ item,
457
+ timeline_offset + selected_start - range_start,
458
+ source_window=(relative_start, relative_end - relative_start),
459
+ )
460
+ imported["clips"].append(record)
461
+ cursor += item_duration
462
+ visible_duration = cursor if track_range is None and parent_range is None else range_end - range_start
463
+ payload["durationSeconds"] = max(payload["durationSeconds"], timeline_offset + visible_duration)
464
+ imported["order"] = len(payload["tracks"])
465
+ payload["tracks"].append(imported)
466
+
467
+ def import_stack(stack, group_path=(), include_self=True, parent_enabled=True, parent_range=None, timeline_offset=0.0):
468
+ stack_range = stack.source_range
469
+ if stack_range is not None and parent_range is not None:
470
+ raise ValueError(f"Stack {stack.name!r} combines nested stack trims; combined trim ranges are not supported.")
471
+ if stack_range is not None:
472
+ range_start = stack_range.start_time.to_seconds()
473
+ range_end = stack_range.end_time_exclusive().to_seconds()
474
+ if (
475
+ not math.isfinite(range_start)
476
+ or not math.isfinite(range_end)
477
+ or range_start < 0
478
+ or range_end <= range_start
479
+ ):
480
+ raise ValueError(f"Stack {stack.name!r} has an invalid or negative trimmed source range.")
481
+ if any(isinstance(item, otio.schema.Stack) for item in stack):
482
+ raise ValueError(f"Stack {stack.name!r} trims nested stacks; nested stack trims are not supported.")
483
+ if any(isinstance(item, otio.schema.Track) and item.source_range is not None for item in stack):
484
+ raise ValueError(f"Stack {stack.name!r} combines stack and track trims; combined trim ranges are not supported.")
485
+ child_range = (range_start, range_end)
486
+ else:
487
+ child_range = parent_range
488
+ if stack.effects:
489
+ effects = ", ".join(effect.effect_name or type(effect).__name__ for effect in stack.effects)
490
+ raise ValueError(f"Stack {stack.name!r} has effects ({effects}) that the OTIO interchange profile does not support.")
491
+ stack_enabled = bool(stack.enabled and parent_enabled)
492
+ next_group = (*group_path, stack.name) if include_self and stack.name else group_path
493
+ for item in stack:
494
+ if isinstance(item, otio.schema.Track):
495
+ import_track(item, next_group, stack_enabled, child_range, timeline_offset)
496
+ elif isinstance(item, otio.schema.Stack):
497
+ import_stack(item, next_group, parent_enabled=stack_enabled, parent_range=child_range, timeline_offset=timeline_offset)
498
+ else:
499
+ raise ValueError(f"OTIO stack item {item.name!r} is not a track or nested stack.")
500
+
501
+ import_stack(timeline.tracks, include_self=False)
502
+ return payload
503
+
504
+
505
+ def main():
506
+ request = json.load(sys.stdin)
507
+ if request.get("operation") == "export":
508
+ result = export_timeline(request)
509
+ elif request.get("operation") == "import":
510
+ result = import_timeline(request)
511
+ else:
512
+ raise ValueError("operation must be export or import")
513
+ json.dump({"result": result}, sys.stdout, ensure_ascii=False)
514
+
515
+
516
+ if __name__ == "__main__":
517
+ try:
518
+ main()
519
+ except Exception as error: # Keep a concise, actionable bridge error for Node.
520
+ print(f"OTIO bridge: {error}", file=sys.stderr)
521
+ sys.exit(1)