test_timeline_plugin.py
python
sha256:61100cca63d948098d334e1b600d8fea514568a1c26ef357bf8b6380fbd8a217
chore(timeline): remove unused RationalRate import in entity.py
Human
minor
⚠ breaking
8 days ago
| 1 | """VID_01, VID_07–VID_14 — timeline plugin integration tests.""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | import json |
| 6 | import pathlib |
| 7 | import shutil |
| 8 | |
| 9 | import pytest |
| 10 | |
| 11 | from muse.core.schema import DomainSchema |
| 12 | from muse.core.types import JsonObject, content_hash |
| 13 | from muse.domain import MuseDomainPlugin, SnapshotManifest |
| 14 | from muse.plugins.registry import registered_domains, resolve_plugin_by_domain |
| 15 | from muse.plugins.timeline._otio_bridge import BridgeError, load_otio_json, parse_otio_to_project |
| 16 | from muse.plugins.timeline.entity import ( |
| 17 | Clip, |
| 18 | Gap, |
| 19 | assign_clip_occurrences, |
| 20 | assign_identities, |
| 21 | clip_identity, |
| 22 | project_content_id, |
| 23 | sequence_identity, |
| 24 | track_identity, |
| 25 | ) |
| 26 | from muse.plugins.timeline.manifest import build_timeline_manifest |
| 27 | from muse.plugins.timeline.plugin import TimelinePlugin, plugin |
| 28 | from muse.plugins.timeline.rational_time import RationalRate, RationalTime, TimeRange |
| 29 | |
| 30 | FIXTURES = pathlib.Path(__file__).parent / "fixtures" / "timeline" |
| 31 | GOLDEN = FIXTURES / "golden" |
| 32 | |
| 33 | |
| 34 | @pytest.fixture() |
| 35 | def timeline_plugin() -> TimelinePlugin: |
| 36 | return TimelinePlugin() |
| 37 | |
| 38 | |
| 39 | @pytest.fixture() |
| 40 | def fixture_repo(tmp_path: pathlib.Path) -> pathlib.Path: |
| 41 | repo = tmp_path / "repo" |
| 42 | repo.mkdir() |
| 43 | media = repo / "media" |
| 44 | media.mkdir() |
| 45 | for name in ("shot_a.mov", "shot_b.mov", "shot_c.mov", "same_shot.mov", "v1.mov", "v2.mov", "a1.wav", "ntsc.mov", "df.mov", "nested_ref.mov"): |
| 46 | (media / name).write_bytes(b"stub") |
| 47 | shutil.copy(FIXTURES / "single_track.otio", repo / "single_track.otio") |
| 48 | return repo |
| 49 | |
| 50 | |
| 51 | class TestVID01Protocol: |
| 52 | def test_plugin_satisfies_protocol(self) -> None: |
| 53 | assert isinstance(plugin, MuseDomainPlugin) |
| 54 | |
| 55 | def test_schema_valid(self, timeline_plugin: TimelinePlugin) -> None: |
| 56 | schema: DomainSchema = timeline_plugin.schema() |
| 57 | assert schema["domain"] == "timeline" |
| 58 | assert schema["merge_mode"] == "three_way" |
| 59 | assert len(schema["dimensions"]) > 0 |
| 60 | |
| 61 | def test_registered_active(self) -> None: |
| 62 | assert "timeline" in registered_domains() |
| 63 | p = resolve_plugin_by_domain("timeline") |
| 64 | assert isinstance(p, TimelinePlugin) |
| 65 | |
| 66 | |
| 67 | class TestVID07OtioBridge: |
| 68 | def test_single_track_parse(self, fixture_repo: pathlib.Path) -> None: |
| 69 | raw = load_otio_json(fixture_repo / "single_track.otio") |
| 70 | project = parse_otio_to_project(raw, fixture_repo / "single_track.otio", fixture_repo) |
| 71 | assert project.project_key == "single_track" |
| 72 | assert len(project.sequences) == 1 |
| 73 | track = project.sequences[0].video_tracks[0] |
| 74 | clips = [i for i in track.items if isinstance(i, Clip)] |
| 75 | gaps = [i for i in track.items if isinstance(i, Gap)] |
| 76 | assert len(clips) == 3 |
| 77 | assert len(gaps) == 1 |
| 78 | assert clips[0].source_range.start_time.rate == RationalRate(24, 1) |
| 79 | |
| 80 | def test_unknown_schema_version_raises(self) -> None: |
| 81 | bad = { |
| 82 | "OTIO_SCHEMA": "Timeline.999", |
| 83 | "name": "bad", |
| 84 | "tracks": {"OTIO_SCHEMA": "Stack.1", "children": []}, |
| 85 | } |
| 86 | with pytest.raises(BridgeError): |
| 87 | parse_otio_to_project(bad, pathlib.Path("bad.otio"), None) |
| 88 | |
| 89 | |
| 90 | class TestVID08Determinism: |
| 91 | def test_snapshot_twice_identical(self, fixture_repo: pathlib.Path) -> None: |
| 92 | a = plugin.snapshot(fixture_repo) |
| 93 | b = plugin.snapshot(fixture_repo) |
| 94 | assert content_hash(a) == content_hash(b) |
| 95 | |
| 96 | def test_reformatted_identical_hash(self, fixture_repo: pathlib.Path) -> None: |
| 97 | shutil.copy(FIXTURES / "single_track_reformatted.otio", fixture_repo / "reformatted.otio") |
| 98 | base = plugin.snapshot(fixture_repo) |
| 99 | only_reformatted = fixture_repo / "only_ref" |
| 100 | only_reformatted.mkdir() |
| 101 | (only_reformatted / "media").mkdir() |
| 102 | for name in ("shot_a.mov", "shot_b.mov", "shot_c.mov"): |
| 103 | (only_reformatted / "media" / name).write_bytes(b"stub") |
| 104 | shutil.copy(FIXTURES / "single_track.otio", only_reformatted / "single_track.otio") |
| 105 | snap_a = plugin.snapshot(only_reformatted) |
| 106 | shutil.copy(FIXTURES / "single_track_reformatted.otio", only_reformatted / "single_track.otio") |
| 107 | snap_b = plugin.snapshot(only_reformatted) |
| 108 | assert snap_a["files"]["single_track.otio"] == snap_b["files"]["single_track.otio"] |
| 109 | |
| 110 | |
| 111 | class TestVID09ClipIdentityExcludesTimelineStart: |
| 112 | def test_widen_gap_preserves_clip_ids(self, fixture_repo: pathlib.Path) -> None: |
| 113 | raw = load_otio_json(fixture_repo / "single_track.otio") |
| 114 | project = parse_otio_to_project(raw, fixture_repo / "single_track.otio", fixture_repo) |
| 115 | before = assign_identities(project) |
| 116 | clip_ids_before = [ic.clip_id for ic in before.sequences[0].tracks[0].clips] |
| 117 | |
| 118 | track = project.sequences[0].video_tracks[0] |
| 119 | items = list(track.items) |
| 120 | gap = items[1] |
| 121 | assert isinstance(gap, Gap) |
| 122 | wider = Gap( |
| 123 | duration=RationalTime(gap.duration.value + 48, gap.duration.rate), |
| 124 | metadata=gap.metadata, |
| 125 | ) |
| 126 | items[1] = wider |
| 127 | from dataclasses import replace |
| 128 | |
| 129 | new_track = replace(track, items=tuple(items)) |
| 130 | new_seq = replace( |
| 131 | project.sequences[0], |
| 132 | video_tracks=(new_track,), |
| 133 | ) |
| 134 | new_project = replace(project, sequences=(new_seq,)) |
| 135 | after = assign_identities(new_project) |
| 136 | clip_ids_after = [ic.clip_id for ic in after.sequences[0].tracks[0].clips] |
| 137 | assert clip_ids_before == clip_ids_after |
| 138 | |
| 139 | def test_trim_source_changes_clip_id(self, fixture_repo: pathlib.Path) -> None: |
| 140 | raw = load_otio_json(fixture_repo / "single_track.otio") |
| 141 | project = parse_otio_to_project(raw, fixture_repo / "single_track.otio", fixture_repo) |
| 142 | before = assign_identities(project) |
| 143 | cid_before = before.sequences[0].tracks[0].clips[0].clip_id |
| 144 | |
| 145 | from dataclasses import replace |
| 146 | |
| 147 | clip = project.sequences[0].video_tracks[0].items[0] |
| 148 | assert isinstance(clip, Clip) |
| 149 | new_sr = TimeRange( |
| 150 | clip.source_range.start_time, |
| 151 | RationalTime(clip.source_range.duration.value + 10, clip.source_range.duration.rate), |
| 152 | ) |
| 153 | new_clip = replace(clip, source_range=new_sr) |
| 154 | items = list(project.sequences[0].video_tracks[0].items) |
| 155 | items[0] = new_clip |
| 156 | new_track = replace(project.sequences[0].video_tracks[0], items=tuple(items)) |
| 157 | new_seq = replace(project.sequences[0], video_tracks=(new_track,)) |
| 158 | new_project = replace(project, sequences=(new_seq,)) |
| 159 | after = assign_identities(new_project) |
| 160 | assert after.sequences[0].tracks[0].clips[0].clip_id != cid_before |
| 161 | |
| 162 | |
| 163 | class TestVID10TrackIdentity: |
| 164 | def test_reorder_swaps_track_ids(self, fixture_repo: pathlib.Path) -> None: |
| 165 | raw = load_otio_json(FIXTURES / "multi_track.otio") |
| 166 | project = parse_otio_to_project(raw, FIXTURES / "multi_track.otio", fixture_repo) |
| 167 | seq = project.sequences[0] |
| 168 | seq_id = sequence_identity(project.project_key, seq.name, seq.ordinal) |
| 169 | t0_id = track_identity(seq_id, "video", 0) |
| 170 | t1_id = track_identity(seq_id, "video", 1) |
| 171 | |
| 172 | from dataclasses import replace |
| 173 | |
| 174 | vtracks = list(seq.video_tracks) |
| 175 | vtracks[0], vtracks[1] = vtracks[1], vtracks[0] |
| 176 | vtracks[0] = replace(vtracks[0], ordinal=0) |
| 177 | vtracks[1] = replace(vtracks[1], ordinal=1) |
| 178 | reordered = replace(seq, video_tracks=tuple(vtracks)) |
| 179 | assert track_identity(seq_id, "video", 0) == t0_id |
| 180 | assert track_identity(seq_id, "video", 1) == t1_id |
| 181 | assert vtracks[0].name == "V2" |
| 182 | assert vtracks[1].name == "V1" |
| 183 | id_v1_after = track_identity(seq_id, "video", vtracks[1].ordinal) |
| 184 | id_v2_after = track_identity(seq_id, "video", vtracks[0].ordinal) |
| 185 | assert t0_id == id_v2_after |
| 186 | assert t1_id == id_v1_after |
| 187 | |
| 188 | def test_edit_clip_preserves_track_id(self, fixture_repo: pathlib.Path) -> None: |
| 189 | raw = load_otio_json(FIXTURES / "multi_track.otio") |
| 190 | project = parse_otio_to_project(raw, FIXTURES / "multi_track.otio", fixture_repo) |
| 191 | seq = project.sequences[0] |
| 192 | tid = track_identity( |
| 193 | sequence_identity(project.project_key, seq.name, seq.ordinal), |
| 194 | "video", |
| 195 | 0, |
| 196 | ) |
| 197 | from dataclasses import replace |
| 198 | |
| 199 | clip = seq.video_tracks[0].items[0] |
| 200 | assert isinstance(clip, Clip) |
| 201 | new_clip = replace(clip, name="renamed") |
| 202 | items = list(seq.video_tracks[0].items) |
| 203 | items[0] = new_clip |
| 204 | new_track = replace(seq.video_tracks[0], items=tuple(items)) |
| 205 | new_seq = replace(seq, video_tracks=(new_track, seq.video_tracks[1])) |
| 206 | new_project = replace(project, sequences=(new_seq,)) |
| 207 | tid_after = track_identity( |
| 208 | sequence_identity(new_project.project_key, new_seq.name, new_seq.ordinal), |
| 209 | "video", |
| 210 | 0, |
| 211 | ) |
| 212 | assert tid == tid_after |
| 213 | |
| 214 | |
| 215 | class TestVID11PositionalTiebreaker: |
| 216 | def test_identical_shots_distinct_ids(self, fixture_repo: pathlib.Path) -> None: |
| 217 | shutil.copy(FIXTURES / "identical_shots.otio", fixture_repo / "identical_shots.otio") |
| 218 | raw = load_otio_json(fixture_repo / "identical_shots.otio") |
| 219 | project = parse_otio_to_project(raw, fixture_repo / "identical_shots.otio", fixture_repo) |
| 220 | identified = assign_identities(project) |
| 221 | clips = identified.sequences[0].tracks[0].clips |
| 222 | assert len(clips) == 2 |
| 223 | assert clips[0].clip_id != clips[1].clip_id |
| 224 | assert clips[0].occurrence == 0 |
| 225 | assert clips[1].occurrence == 1 |
| 226 | |
| 227 | def test_stable_across_resnapshot(self, fixture_repo: pathlib.Path) -> None: |
| 228 | shutil.copy(FIXTURES / "identical_shots.otio", fixture_repo / "identical_shots.otio") |
| 229 | a = plugin.snapshot(fixture_repo) |
| 230 | b = plugin.snapshot(fixture_repo) |
| 231 | assert a == b |
| 232 | |
| 233 | def test_trim_leaves_identical_set(self, fixture_repo: pathlib.Path) -> None: |
| 234 | raw = load_otio_json(FIXTURES / "identical_shots.otio") |
| 235 | project = parse_otio_to_project(raw, FIXTURES / "identical_shots.otio", fixture_repo) |
| 236 | track = project.sequences[0].video_tracks[0] |
| 237 | tid = track_identity( |
| 238 | sequence_identity(project.project_key, project.sequences[0].name, 0), |
| 239 | "video", |
| 240 | 0, |
| 241 | ) |
| 242 | clip = track.items[1] |
| 243 | assert isinstance(clip, Clip) |
| 244 | from dataclasses import replace |
| 245 | |
| 246 | new_sr = TimeRange( |
| 247 | RationalTime(10, clip.source_range.start_time.rate), |
| 248 | clip.source_range.duration, |
| 249 | ) |
| 250 | new_clip = replace(clip, source_range=new_sr) |
| 251 | items = list(track.items) |
| 252 | items[1] = new_clip |
| 253 | occ = assign_clip_occurrences(tid, tuple(items)) |
| 254 | assert occ[1][1] == 0 |
| 255 | |
| 256 | |
| 257 | def _build_manifest(repo: pathlib.Path, otio_name: str) -> JsonObject: |
| 258 | snap = plugin.snapshot(repo) |
| 259 | raw = load_otio_json(repo / otio_name) |
| 260 | project = parse_otio_to_project(raw, repo / otio_name, repo) |
| 261 | identified = assign_identities(project) |
| 262 | manifest = build_timeline_manifest( |
| 263 | snap["files"], |
| 264 | {otio_name: identified}, |
| 265 | {otio_name: project_content_id(project)}, |
| 266 | snapshot_id="golden", |
| 267 | ) |
| 268 | return {"files": snap["files"], "manifest": manifest} |
| 269 | |
| 270 | |
| 271 | class TestVID12GoldenSingleTrack: |
| 272 | def test_matches_golden(self, fixture_repo: pathlib.Path) -> None: |
| 273 | built = _build_manifest(fixture_repo, "single_track.otio") |
| 274 | golden_path = GOLDEN / "single_track.manifest.json" |
| 275 | if not golden_path.exists(): |
| 276 | golden_path.parent.mkdir(parents=True, exist_ok=True) |
| 277 | golden_path.write_text(json.dumps(built, indent=2, sort_keys=True) + "\n") |
| 278 | golden = json.loads(golden_path.read_text()) |
| 279 | assert built["files"] == golden["files"] |
| 280 | |
| 281 | |
| 282 | class TestVID13GoldenMultiTrack: |
| 283 | def test_matches_golden(self, fixture_repo: pathlib.Path) -> None: |
| 284 | shutil.copy(FIXTURES / "multi_track.otio", fixture_repo / "multi_track.otio") |
| 285 | built = _build_manifest(fixture_repo, "multi_track.otio") |
| 286 | golden_path = GOLDEN / "multi_track.manifest.json" |
| 287 | if not golden_path.exists(): |
| 288 | golden_path.parent.mkdir(parents=True, exist_ok=True) |
| 289 | golden_path.write_text(json.dumps(built, indent=2, sort_keys=True) + "\n") |
| 290 | golden = json.loads(golden_path.read_text()) |
| 291 | assert built["files"] == golden["files"] |
| 292 | |
| 293 | |
| 294 | class TestVID14Security: |
| 295 | def test_offline_media_deterministic(self, tmp_path: pathlib.Path) -> None: |
| 296 | repo = tmp_path / "offline" |
| 297 | repo.mkdir() |
| 298 | shutil.copy(FIXTURES / "offline_media.otio", repo / "offline_media.otio") |
| 299 | a = plugin.snapshot(repo) |
| 300 | b = plugin.snapshot(repo) |
| 301 | assert a == b |
| 302 | raw = load_otio_json(repo / "offline_media.otio") |
| 303 | project = parse_otio_to_project(raw, repo / "offline_media.otio", repo) |
| 304 | ref = project.media_pool[0].references[0][1] |
| 305 | assert ref.is_offline |
| 306 | |
| 307 | def test_path_traversal_rejected(self) -> None: |
| 308 | bad = { |
| 309 | "OTIO_SCHEMA": "Timeline.1", |
| 310 | "name": "bad", |
| 311 | "metadata": {"muse.project_key": "bad"}, |
| 312 | "tracks": { |
| 313 | "OTIO_SCHEMA": "Stack.1", |
| 314 | "children": [ |
| 315 | { |
| 316 | "OTIO_SCHEMA": "Track.1", |
| 317 | "kind": "Video", |
| 318 | "children": [ |
| 319 | { |
| 320 | "OTIO_SCHEMA": "Clip.2", |
| 321 | "name": "x", |
| 322 | "media_reference": { |
| 323 | "OTIO_SCHEMA": "ExternalReference.1", |
| 324 | "target_url": "../etc/passwd", |
| 325 | }, |
| 326 | "source_range": { |
| 327 | "OTIO_SCHEMA": "TimeRange.1", |
| 328 | "start_time": {"OTIO_SCHEMA": "RationalTime.1", "rate": 24, "value": 0}, |
| 329 | "duration": {"OTIO_SCHEMA": "RationalTime.1", "rate": 24, "value": 1}, |
| 330 | }, |
| 331 | } |
| 332 | ], |
| 333 | } |
| 334 | ], |
| 335 | }, |
| 336 | } |
| 337 | with pytest.raises(BridgeError): |
| 338 | parse_otio_to_project(bad, pathlib.Path("bad.otio"), None) |
| 339 | |
| 340 | def test_no_media_bytes_in_objects(self, fixture_repo: pathlib.Path, tmp_path: pathlib.Path) -> None: |
| 341 | muse_repo = tmp_path / "muse_repo" |
| 342 | muse_repo.mkdir() |
| 343 | shutil.copytree(fixture_repo / "media", muse_repo / "media") |
| 344 | shutil.copy(fixture_repo / "single_track.otio", muse_repo / "single_track.otio") |
| 345 | from muse.core.paths import objects_dir |
| 346 | |
| 347 | objects_dir(muse_repo).mkdir(parents=True, exist_ok=True) |
| 348 | snap = plugin.snapshot(muse_repo) |
| 349 | for digest in snap["files"].values(): |
| 350 | blob_path = objects_dir(muse_repo) / digest.split(":", 1)[1] |
| 351 | if blob_path.exists(): |
| 352 | data = blob_path.read_bytes() |
| 353 | assert b"stub" not in data |
File History
1 commit
sha256:61100cca63d948098d334e1b600d8fea514568a1c26ef357bf8b6380fbd8a217
chore(timeline): remove unused RationalRate import in entity.py
Human
minor
⚠
8 days ago