gabriel / muse public
plugin.py python
251 lines 8.8 KB
Raw
sha256:08c083095bcaffb4c43ce947668fac93bf5d261e13d321776170c4019dd1d77d fix: migration must never update identity.toml on a failed … Sonnet 5 minor ⚠ breaking 19 hours ago
1 """Timeline domain plugin — VID-1 canonical model + snapshot."""
2
3 from __future__ import annotations
4
5 import os
6 import pathlib
7 import stat as _stat
8
9 from muse._version import __version__
10 from muse.core.diff_algorithms import snapshot_diff
11 from muse.core.schema import (
12 DimensionSpec,
13 DomainSchema,
14 MapSchema,
15 SequenceSchema,
16 SetSchema,
17 TreeSchema,
18 )
19 from muse.core.stat_cache import load_cache
20 from muse.core.types import Manifest
21 from muse.domain import (
22 DriftReport,
23 LiveState,
24 MergeResult,
25 MuseDomainPlugin,
26 SnapshotManifest,
27 StateDelta,
28 StateSnapshot,
29 StructuredDelta,
30 )
31
32 from muse.plugins.timeline._otio_bridge import BridgeError, load_otio_json, parse_otio_to_project
33 from muse.plugins.timeline.entity import IdentifiedProject, assign_identities, project_content_id
34 from muse.plugins.timeline.manifest import build_timeline_manifest
35
36 _DOMAIN_TAG = "timeline"
37 _OTIO_SUFFIX = ".otio"
38
39
40 class TimelinePlugin:
41 """Video timeline domain plugin (OTIO-compatible canonical IR)."""
42
43 def snapshot(self, live_state: LiveState) -> StateSnapshot:
44 if isinstance(live_state, pathlib.Path):
45 from muse.core.ignore import is_ignored, load_ignore_config, resolve_patterns
46
47 workdir = live_state
48 patterns = resolve_patterns(load_ignore_config(workdir), _DOMAIN_TAG)
49 cache = load_cache(workdir)
50 files: Manifest = {}
51 directories: list[str] = []
52 projects: dict[str, IdentifiedProject] = {}
53 project_hashes: dict[str, str] = {}
54 root_str = str(workdir)
55 prefix_len = len(root_str) + 1
56
57 for dirpath, dirnames, filenames in os.walk(root_str, followlinks=False):
58 dirnames[:] = sorted(d for d in dirnames if not d.startswith("."))
59 for fname in sorted(filenames):
60 if fname.startswith(".") or not fname.endswith(_OTIO_SUFFIX):
61 continue
62 abs_str = os.path.join(dirpath, fname)
63 try:
64 st = os.lstat(abs_str)
65 except OSError:
66 continue
67 if not _stat.S_ISREG(st.st_mode):
68 continue
69 rel = abs_str[prefix_len:]
70 if os.sep != "/":
71 rel = rel.replace(os.sep, "/")
72 if is_ignored(rel, patterns):
73 continue
74 otio_path = pathlib.Path(abs_str)
75 raw = load_otio_json(otio_path)
76 project = parse_otio_to_project(raw, otio_path, workdir)
77 cid = project_content_id(project)
78 files[rel] = cid
79 identified = assign_identities(project)
80 projects[rel] = identified
81 project_hashes[rel] = cid
82
83 cache.prune(set(files))
84 cache.save()
85
86 snap = SnapshotManifest(files=files, domain=_DOMAIN_TAG, directories=sorted(directories))
87 if files:
88 manifest = build_timeline_manifest(files, projects, project_hashes, snapshot_id="pending")
89 sidecar_dir = workdir / ".muse" / "timeline_manifests"
90 sidecar_dir.mkdir(parents=True, exist_ok=True)
91 sidecar_path = sidecar_dir / "_latest_build.json"
92 import json
93
94 sidecar_path.write_text(
95 json.dumps(manifest, indent=2, sort_keys=True) + "\n"
96 )
97 return snap
98
99 return live_state
100
101 def diff(
102 self,
103 base: StateSnapshot,
104 target: StateSnapshot,
105 *,
106 repo_root: pathlib.Path | None = None,
107 ) -> StateDelta:
108 return snapshot_diff(self.schema(), base, target)
109
110 def merge(
111 self,
112 base: StateSnapshot,
113 left: StateSnapshot,
114 right: StateSnapshot,
115 *,
116 repo_root: pathlib.Path | None = None,
117 ) -> MergeResult:
118 base_files = base["files"]
119 left_files = left["files"]
120 right_files = right["files"]
121 merged: Manifest = dict(base_files)
122 conflicts: list[str] = []
123 all_paths = set(base_files) | set(left_files) | set(right_files)
124 for path in sorted(all_paths):
125 b_val = base_files.get(path)
126 l_val = left_files.get(path)
127 r_val = right_files.get(path)
128 if l_val == r_val:
129 if l_val is None:
130 merged.pop(path, None)
131 else:
132 merged[path] = l_val
133 elif b_val == l_val:
134 if r_val is None:
135 merged.pop(path, None)
136 else:
137 merged[path] = r_val
138 elif b_val == r_val:
139 if l_val is None:
140 merged.pop(path, None)
141 else:
142 merged[path] = l_val
143 else:
144 conflicts.append(path)
145 merged[path] = l_val or r_val or b_val or ""
146 return MergeResult(
147 merged=SnapshotManifest(files=merged, domain=_DOMAIN_TAG, directories=[]),
148 conflicts=conflicts,
149 )
150
151 def drift(self, committed: StateSnapshot, live: LiveState) -> DriftReport:
152 current = self.snapshot(live)
153 delta = self.diff(committed, current)
154 has_drift = len(delta["ops"]) > 0
155 return DriftReport(
156 has_drift=has_drift,
157 summary=delta["summary"],
158 delta=delta,
159 )
160
161 def apply(self, delta: StateDelta, live_state: LiveState) -> LiveState:
162 return live_state
163
164 def schema(self) -> DomainSchema:
165 return DomainSchema(
166 domain=_DOMAIN_TAG,
167 description="Video editing timelines (OTIO-compatible canonical model).",
168 top_level=TreeSchema(
169 kind="tree",
170 node_type="timeline_element",
171 diff_algorithm="zhang_shasha",
172 ),
173 dimensions=[
174 DimensionSpec(
175 name="structure",
176 description="Project→sequence→track hierarchy",
177 schema=TreeSchema(
178 kind="tree",
179 node_type="track",
180 diff_algorithm="zhang_shasha",
181 ),
182 independent_merge=False,
183 ),
184 DimensionSpec(
185 name="clips",
186 description="Ordered clips/gaps/transitions per lane",
187 schema=SequenceSchema(
188 kind="sequence",
189 element_type="clip",
190 identity="by_id",
191 diff_algorithm="myers",
192 alphabet=None,
193 ),
194 independent_merge=True,
195 ),
196 DimensionSpec(
197 name="effects",
198 description="Clip/track effects + keyframes",
199 schema=SetSchema(
200 kind="set",
201 element_type="effect",
202 identity="by_id",
203 ),
204 independent_merge=True,
205 ),
206 DimensionSpec(
207 name="markers",
208 description="Markers (union-mergeable)",
209 schema=SetSchema(
210 kind="set",
211 element_type="marker",
212 identity="by_id",
213 ),
214 independent_merge=True,
215 ),
216 DimensionSpec(
217 name="captions",
218 description="Subtitle/caption events",
219 schema=SequenceSchema(
220 kind="sequence",
221 element_type="caption",
222 identity="by_id",
223 diff_algorithm="myers",
224 alphabet=None,
225 ),
226 independent_merge=True,
227 ),
228 DimensionSpec(
229 name="media_pool",
230 description="Deduplicated source media assets",
231 schema=MapSchema(
232 kind="map",
233 key_type="content_id",
234 value_schema=SetSchema(
235 kind="set",
236 element_type="external_reference",
237 identity="by_content",
238 ),
239 identity="by_key",
240 ),
241 independent_merge=True,
242 ),
243 ],
244 merge_mode="three_way",
245 schema_version=__version__,
246 )
247
248
249 plugin = TimelinePlugin()
250
251 __all__ = ["TimelinePlugin", "plugin", "BridgeError"]
File History 1 commit
sha256:08c083095bcaffb4c43ce947668fac93bf5d261e13d321776170c4019dd1d77d fix: migration must never update identity.toml on a failed … Sonnet 5 minor 19 hours ago