aivee 0.1.0-alpha.0 → 0.1.0-dev.59872e1
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.
- package/CHANGELOG.md +34 -0
- package/LICENSE +21 -21
- package/README.md +120 -118
- package/docs/architecture.md +401 -401
- package/docs/aspect-ratio-presets.md +72 -72
- package/docs/releasing.md +19 -19
- package/docs/usage.md +1193 -1193
- package/package.json +52 -52
- package/requirements-otio.txt +1 -1
- package/src/capabilities.mjs +307 -307
- package/src/cli.mjs +646 -646
- package/src/doctor.mjs +133 -133
- package/src/edits/analysis-to-edl.mjs +43 -43
- package/src/edits/detect-silence.mjs +51 -51
- package/src/edits/find-mistake-candidates.mjs +42 -42
- package/src/edits/plan-shot-placements.mjs +105 -105
- package/src/edits/plan-silence-cuts.mjs +49 -49
- package/src/edits/retime-transcript.mjs +105 -105
- package/src/files.mjs +80 -80
- package/src/index.mjs +44 -44
- package/src/interchange/otio.mjs +204 -204
- package/src/interchange/otio_bridge.py +521 -521
- package/src/media/analysis-provider.mjs +75 -75
- package/src/media/compatibility.mjs +45 -45
- package/src/media/ffmpeg-capabilities.mjs +129 -129
- package/src/media/frame-rate.mjs +58 -58
- package/src/media/frame-time.mjs +22 -22
- package/src/media/preview-assets.mjs +350 -350
- package/src/media/probe.mjs +149 -149
- package/src/media/render-cuts.mjs +90 -90
- package/src/media/scene-detection.mjs +107 -107
- package/src/media/scene-review.mjs +205 -205
- package/src/project/assets.mjs +1194 -1194
- package/src/project/create.mjs +100 -100
- package/src/project/presets.mjs +44 -44
- package/src/render/checkpoints.mjs +192 -192
- package/src/render/formats.mjs +163 -163
- package/src/render/hyperframes.mjs +590 -590
- package/src/render/process.mjs +111 -111
- package/src/render/progress.mjs +46 -46
- package/src/render/timeline-control.mjs +53 -53
- package/src/timeline/assemble.mjs +119 -119
- package/src/timeline/audio-effects.mjs +75 -75
- package/src/timeline/audio-mastering.mjs +236 -236
- package/src/timeline/audio-mix.mjs +105 -105
- package/src/timeline/audio-track-effects.mjs +139 -139
- package/src/timeline/audio-volume.mjs +63 -63
- package/src/timeline/background.mjs +8 -8
- package/src/timeline/blend-mode.mjs +34 -34
- package/src/timeline/caption-layouts.mjs +62 -61
- package/src/timeline/captions.mjs +80 -80
- package/src/timeline/edit.mjs +965 -965
- package/src/timeline/fingerprint.mjs +6 -6
- package/src/timeline/from-edl.mjs +31 -31
- package/src/timeline/history-store.mjs +281 -281
- package/src/timeline/history.mjs +69 -69
- package/src/timeline/inspect.mjs +117 -117
- package/src/timeline/keyframes.mjs +158 -158
- package/src/timeline/layers.mjs +18 -18
- package/src/timeline/mask.mjs +53 -53
- package/src/timeline/project.mjs +52 -52
- package/src/timeline/render.mjs +759 -759
- package/src/timeline/sidechain-render.mjs +209 -209
- package/src/timeline/source-fit.mjs +15 -15
- package/src/timeline/speech-bubble.mjs +26 -26
- package/src/timeline/speed-ramp.mjs +188 -188
- package/src/timeline/track-state.mjs +22 -22
- package/src/timeline/transition.mjs +34 -34
- package/src/timeline/validate.mjs +322 -322
- package/src/transcript/corrections.mjs +21 -21
- package/src/transcript/normalize.mjs +17 -17
- package/src/transcript/provider.mjs +42 -42
- package/src/transcript/subtitles.mjs +57 -57
- 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)
|