VID-1: Canonical model + snapshot + RationalTime + stable identity (interface freeze → build)
VID-1 — Canonical model + snapshot + RationalTime + stable identity + OTIO(JSON) import (interface freeze)
This file is the body for staging issue ~#69 (child of the accepted #68 plan). It is a Thinking-tier interface freeze only — no production code is written in this phase. Step B (Auto,
gpt-5.3-codex) implements this spec test-first on branchtask/video-timeline-vid1.Cockpit doc — do NOT commit into the
muserepo. Lives inMUSE_HUB/docs/video-timeline-plugin/. The Muse-native artifact is the #69 issue text; paste this body there and self-assign.
0. Context (frozen inputs)
| Input | Source |
|---|---|
| Whole-domain architecture (canonical model §2, snapshot/identity §3, diff §4, merge §5, query §6, drift §7, TDD §8, backend §9, breakdown §10) | staging #68 (accepted design — authored by gabriel) |
| Plugin protocol (six methods + optional sub-protocols) | muse/muse/domain.py |
| Reference implementations | muse/muse/plugins/midi/ (plugin.py, manifest.py, entity.py) · muse/muse/plugins/code/ (AddressedMergePlugin) |
| Content-addressing primitives | muse/muse/core/types.py → content_hash(), blob_id(), split_id() |
| Schema types | muse/muse/core/schema.py → DomainSchema, ElementSchema, DimensionSpec |
| Active clone | MUSE_HUB/muse → muse-fresh (domain code, requires-python >=3.14) |
OTIO caveat (confirmed): OpenTimelineIO 0.18.1 ships no cp314 wheel; Muse pins >=3.14. VID-1 uses pre-exported .otio JSON fixtures and a pure-JSON bridge — no compiled OTIO lib. The compiled bridge + FCPXML/AAF/EDL adapters are VID-2. opentimelineio is added as an optional extra muse[timeline] that degrades gracefully (mirrors the tree-sitter-* grammar extras in pyproject.toml).
Load-bearing: nothing else in the domain (VID-2…VID-10) starts until this lands and gabriel merges the MP to dev.
Scope guard: this issue touches only muse/plugins/timeline/ + one registry line + one pyproject extra + .museignore [domain.timeline]. No core edits, no other plugin. If a muse/core/* change is needed → STOP, file a separate MP.
Cross-check flag for gabriel: the schema() dimensions in §6 and the identity tiebreaker in §3 are my interface interpretation of #68 §3/§9. If #68 §9's dimension list differs, the §6 dimensions are the single field to reconcile before Step B.
1. Canonical-model dataclasses (entity.py)
Representation decision (frozen): the canonical in-memory model is immutable, slotted, ordered dataclasses — @dataclass(frozen=True, slots=True). Immutability guarantees hashability and prevents post-parse mutation; slots keep them lightweight. All collections are tuple[...] (never list) so a parsed model is deeply immutable and order is explicit. This IR is the only thing that is ever hashed (§4) — never OTIO bytes.
Zero-Any metadata decision (frozen): metadata is not an open dict[str, Any]. It is a canonical, sorted, string-serialised pair-tuple:
type MetadataValue = str # every value is canonically stringified at bridge time
type Metadata = tuple[tuple[str, MetadataValue], ...] # sorted by key, unique keys
Rationale: satisfies typing_audit --max-any 0, is hashable, and is byte-identical-deterministic. Non-string OTIO metadata values (ints, floats, nested objects) are stringified through the same canonical JSON rule as content_hash (sort_keys=True, separators=(",",":"), ensure_ascii=True) at bridge time, and volatile keys are dropped (see §4 denylist). A typed-accessor helper (meta_get(md, key) -> str | None) is provided for readers; no raw Any escapes.
1.1 Time value types (defined in rational_time.py, re-exported from entity.py)
@dataclass(frozen=True, slots=True)
class RationalRate:
"""An exact frame/sample rate as a reduced positive rational num/den.
Invariants (enforced in __post_init__, raising ValueError on violation):
- den > 0
- num > 0
- gcd(num, den) == 1 (always stored reduced)
Integer rates -> (n, 1). NTSC rates are exact: 23.976 -> (24000, 1001),
29.97 -> (30000, 1001), 59.94 -> (60000, 1001). NEVER a float.
"""
num: int
den: int
@dataclass(frozen=True, slots=True)
class RationalTime:
"""An exact instant/duration: `value` integer ticks at exact `rate`.
`value` is an INTEGER count (frame index or audio sample index). No float
is ever stored — this is what makes re-snapshot byte-identical (§4).
"""
value: int
rate: RationalRate
@dataclass(frozen=True, slots=True)
class TimeRange:
"""Half-open range [start_time, start_time + duration)."""
start_time: RationalTime
duration: RationalTime # duration.value >= 0
1.2 Media / reference types
@dataclass(frozen=True, slots=True)
class ExternalReference:
"""Pointer to external media essence. Essence is NEVER stored in
.muse/objects (#68 §3) — only this locator + optional precomputed hash."""
target_url: str # relativised POSIX (§4); external URLs kept verbatim
available_range: TimeRange | None # media's own available in/out, if known
essence_hash: str | None # `sha256:<hex>` of the media bytes IF supplied
# externally (e.g. by an ingest tool); None = unknown/offline.
# Muse does NOT read media to compute this.
is_offline: bool # True when target cannot be resolved at snapshot time
metadata: Metadata
@dataclass(frozen=True, slots=True)
class ColorMetadata:
"""Colour-pipeline description. LUTs are external refs (no essence)."""
color_space: str # e.g. "ACEScg", "Rec.709", "" if unknown
transfer_function: str # e.g. "sRGB", "ST2084", ""
primaries: str # e.g. "Rec.709", "P3-D65", ""
color_range: Literal["full", "legal", "unknown"]
lut_ref: ExternalReference | None
metadata: Metadata
@dataclass(frozen=True, slots=True)
class SourceMedia:
"""A deduplicated media asset in the project media pool. Identified by a
stable content-id (§3). May carry several representations (proxy/high-res)."""
content_id: str # `sha256:<hex>` — the media identity key (§3.1)
name: str
references: tuple[tuple[str, ExternalReference], ...] # (repr_key, ref), sorted by repr_key
active_reference_key: str # which representation is "default"
available_range: TimeRange | None
color: ColorMetadata | None
metadata: Metadata
@dataclass(frozen=True, slots=True)
class RenderCacheMetadata:
"""Pointer to a rendered/proxy cache segment. Output is an external ref
(no baked essence in the object store)."""
source_range: TimeRange # which timeline region this cache covers
output_ref: ExternalReference | None
cache_key: str # deterministic key of the render inputs
is_valid: bool
metadata: Metadata
1.3 Animation / annotation types
@dataclass(frozen=True, slots=True)
class Keyframe:
"""One animation control point, time relative to its owning clip's
source_range.start_time."""
time: RationalTime
value: str # canonically stringified scalar (§ metadata rule)
interpolation: Literal["hold", "linear", "bezier", "constant"]
metadata: Metadata
@dataclass(frozen=True, slots=True)
class Effect:
"""A clip/track effect. Parameters are sorted string pairs; animated
parameters carry keyframes."""
name: str
effect_type: str # e.g. "LinearTimeWarp", "FreezeFrame", "Effect" (generic)
parameters: tuple[tuple[str, str], ...] # sorted by key
keyframes: tuple[Keyframe, ...] # sorted by time (§4)
metadata: Metadata
@dataclass(frozen=True, slots=True)
class Marker:
name: str
marked_range: TimeRange
color: str # OTIO marker colour token, e.g. "RED"
comment: str
metadata: Metadata
@dataclass(frozen=True, slots=True)
class Caption:
"""A subtitle/caption event on a subtitle track."""
text: str
marked_range: TimeRange # timeline-relative display window
style: str # style token / region id, "" if none
metadata: Metadata
@dataclass(frozen=True, slots=True)
class Transition:
"""A transition that sits between two neighbouring items on a track."""
transition_type: str # e.g. "SMPTE_Dissolve"
in_offset: RationalTime
out_offset: RationalTime
metadata: Metadata
1.4 Timeline structure types
@dataclass(frozen=True, slots=True)
class Gap:
"""Blank space on a track. Structural only — no independent identity (§3.4)."""
duration: RationalTime
metadata: Metadata
@dataclass(frozen=True, slots=True)
class Clip:
"""A media use on a track. Its IDENTITY (§3) deliberately EXCLUDES its
timeline placement, so a move is a MoveOp — not delete+insert."""
name: str
source_media_id: str # content_id of the SourceMedia this clip uses
source_range: TimeRange # in/out INTO the media (identity-relevant, §3)
enabled: bool
effects: tuple[Effect, ...] # sorted by (effect_type, name) (§4)
markers: tuple[Marker, ...] # sorted by (start_time, name) (§4)
color: ColorMetadata | None
nested_sequence_id: str | None # set when this clip references a nested Sequence
# (recursive timelines; used by VID-4 recursive merge)
metadata: Metadata
# A track carries an ORDERED list of items; Transition entries sit between
# neighbouring Clip/Gap items. Order is timeline order and is significant.
type TrackItem = Clip | Gap | Transition
@dataclass(frozen=True, slots=True)
class Track:
"""A single video or subtitle lane."""
name: str
kind: Literal["video", "subtitle"]
ordinal: int # 0-based position among tracks of the SAME kind
# in the owning sequence (identity input, §3)
enabled: bool
items: tuple[TrackItem, ...] # ordered; Transitions interleaved
captions: tuple[Caption, ...] # subtitle events (empty for video tracks), sorted (§4)
metadata: Metadata
@dataclass(frozen=True, slots=True)
class AudioLane:
"""An audio lane. Distinct type because audio is sample-accurate: its
RationalTimes use the sample rate, and it carries channel layout."""
name: str
ordinal: int # 0-based position among audio lanes in the sequence
enabled: bool
sample_rate: int # Hz — the rate of this lane's RationalTimes
channel_count: int
channel_layout: str # e.g. "stereo", "5.1", "" if unknown
items: tuple[TrackItem, ...] # ordered
metadata: Metadata
@dataclass(frozen=True, slots=True)
class Sequence:
"""One timeline (OTIO Timeline / Stack). Owns video tracks, audio lanes,
and subtitle tracks. Nested sequences are referenced by Clip.nested_sequence_id."""
name: str
ordinal: int # 0-based position among sequences in the project
global_start_time: RationalTime | None
video_tracks: tuple[Track, ...] # kind == "video", sorted by ordinal (§4)
audio_lanes: tuple[AudioLane, ...] # sorted by ordinal (§4)
subtitle_tracks: tuple[Track, ...] # kind == "subtitle", sorted by ordinal (§4)
markers: tuple[Marker, ...] # sequence-level markers, sorted (§4)
metadata: Metadata
@dataclass(frozen=True, slots=True)
class Project:
"""Top-level unit tracked by a Muse timeline repo. One `.otio` file maps to
either a Project (SerializableCollection) or a single Sequence promoted to a
one-sequence Project. Identity is a STABLE declared key, not a content hash (§3.1)."""
project_key: str # stable slug (from metadata or filename stem); seeds identity
name: str
sequences: tuple[Sequence, ...] # sorted by ordinal (§4)
media_pool: tuple[SourceMedia, ...] # deduplicated; sorted by content_id (§4)
render_cache: tuple[RenderCacheMetadata, ...] # sorted by (source_range.start, cache_key)
metadata: Metadata
Literal and type aliases come from typing. No Optional[...] (use X | None), no Any, no object, no cast.
2. RationalTime API (rational_time.py)
All signatures below are frozen. Semantics: exact rational, zero floating point. "Equal instant, different rate" compares equal (__eq__), matching OTIO's rescaling semantics, while an exact representation check is available via is_identical.
class RationalRate:
def __post_init__(self) -> None: ... # enforces reduced/positive invariants
@classmethod
def from_float(cls, fps: float) -> "RationalRate": ...
# Maps a float rate to an EXACT rational via a frozen table:
# 23.976, 23.98 -> (24000, 1001)
# 29.97 -> (30000, 1001)
# 59.94 -> (60000, 1001)
# 47.952 -> (48000, 1001)
# integer-valued f -> (int(f), 1)
# otherwise -> Fraction(f).limit_denominator(1001) then reduce,
# BUT raise ValueError if the residual error > 1e-6
# (unknown fractional rate must be declared, not guessed).
def __str__(self) -> str: ... # "num/den" or "num" when den == 1
class RationalTime:
# --- construction ---
@classmethod
def from_frames(cls, frames: int, rate: RationalRate) -> "RationalTime": ...
@classmethod
def from_timecode(cls, tc: str, rate: RationalRate, *, drop_frame: bool | None = None) -> "RationalTime": ...
# --- exact arithmetic / comparison (integer cross-multiplication, no float) ---
def to_fraction_seconds(self) -> Fraction: ... # value*den / num, reduced
def rescaled_to(self, rate: RationalRate) -> "RationalTime": ...
# Exact when value*rate.num*self.rate.den is divisible by self.rate.num*rate.den;
# raises ValueError otherwise (no silent rounding).
def __eq__(self, other: object) -> bool: ... # exact-instant equality:
# a == b iff a.value*a.rate.den*b.rate.num == b.value*b.rate.den*a.rate.num
def __lt__(self, other: "RationalTime") -> bool: ... # exact-instant ordering
def __hash__(self) -> int: ... # hash of reduced (num_secs, den_secs)
def is_identical(self, other: "RationalTime") -> bool: ... # value AND rate both equal
# --- formatting ---
def to_frames(self) -> int: ... # == value (identity check helper)
def to_timecode(self, *, drop_frame: bool | None = None) -> str: ...
# HH:MM:SS:FF (non-drop) or HH:MM:SS;FF (drop-frame; note ';' separator).
# drop_frame=None -> auto: True only when rate in {(30000,1001),(60000,1001)}.
# Drop-frame algorithm (frozen): NTSC 29.97 DF drops frame numbers 00 and 01
# at the start of every minute EXCEPT minutes divisible by 10 (2 frames/min
# * 9 of every 10 min). 59.94 DF drops 00..03. Implemented on the exact
# integer nominal frame rate (30 / 60), never on a float.
Canonical hash form of a RationalTime (§4): its reduced seconds fraction "<num>/<den>" — rate-agnostic, so 24@24fps and 48@48fps hash identically (same instant). Rate is retained in the model for timecode display but does not fork identity. RationalRate and TimeRange serialise through this same rule.
3. Stable identity scheme (#68 §3)
All IDs are sha256:<hex> strings from muse.core.types.content_hash(obj) (sorted-keys / compact / ascii JSON). Identity flows top-down: project → sequence → track → clip. A parent's id is an input to its children's ids, so structural context is baked in without depending on volatile timeline positions.
3.1 Media identity (SourceMedia.content_id)
content_id = content_hash({
"essence_hash": ref.essence_hash or "", # exact media bytes hash IF externally supplied
"target_url": canonical_active_target_url, # else fall back to the relativised locator
"available_range": canonical(available_range) or "",
})
Media is deduplicated by this key. When essence_hash is known it dominates (two paths to the same bytes = one asset); when offline/unknown, the relativised URL + available range key it. No media bytes are read or stored.
3.2 Sequence identity
sequence_identity = content_hash({
"project": project_key, # STABLE declared slug — NOT a content hash (edits don't churn it)
"name": sequence_name,
"ordinal": sequence_ordinal,
})
3.3 Track identity (#68 §3)
track_identity = content_hash({
"sequence": sequence_identity,
"kind": kind, # "video" | "audio" | "subtitle"
"ordinal": ordinal_position, # position among tracks of the SAME kind
})
Consequence (frozen, and asserted by VID_10): reordering tracks changes their ordinal, hence their ids — a track reorder is modelled as identity change of the moved lanes, which VID-3/VID-4 read as a track-level move/reorder. Editing content within a lane at a fixed ordinal preserves the track id.
3.4 Clip identity (#68 §3 — timeline start EXCLUDED)
clip_id = content_hash({
"track": track_identity,
"media": source_media_id, # SourceMedia.content_id
"src_in": canonical(source_range.start_time), # reduced seconds fraction
"src_dur": canonical(source_range.duration), # reduced seconds fraction
"occurrence": tiebreak_index, # §3.5
})
Clip has no timeline_start field at all — a clip's placement on the track is derived from the ordered items tuple (sum of preceding item durations). Therefore trimming the front gap or moving the clip left/right does not change clip_id; the semantic diff (VID-3) reports a move, and three-way merge (VID-4) treats trim vs. move as independent fields. This is the single most important identity property in the domain.
Gap has no identity — it is purely structural (position + duration). Two gaps only differ by position/duration, captured by the ordered items tuple.
3.5 Positional tiebreaker (identical shot on the same track)
When two or more clips on the same track share an identical (track_identity, source_media_id, source_range) triple, the base hash collides. Resolution (frozen):
- Walk the track's
itemsleft→right; among the identical set, assignoccurrence= 0, 1, 2, … in that order. occurrencefeedsclip_id, so identical shots get distinct, stable ids, and re-snapshotting the same fixture assigns the same indices (deterministic left-to-right) → byte-identical (VID_11).- If one instance is later trimmed differently, its
source_rangechanges, it leaves the identical set, and it acquires its natural (non-tiebroken,occurrence=0) id. - Documented limitation: swapping two byte-identical uses of the same shot cannot preserve per-instance lineage (there is nothing to distinguish them but position). This is acceptable and matches the 3D-Scenes plan's positional-tiebreak scheme referenced in #68 §3 / video-roadmap open questions.
3.6 Project identity
project_identity == project_key (the stable declared slug). It is derived once at bridge time from metadata["muse.project_key"] if present, else the .otio filename stem, and is frozen for the life of the project so downstream ids never churn on content edits.
4. snapshot() normalisation order + deterministic serialisation rule
Golden rule (frozen): never hash OTIO bytes. Always parse .otio JSON → canonical IR (§1) → canonical JSON via content_hash. Two .otio files that differ only in whitespace, key order, generator banner, or absolute paths must produce identical snapshots.
Normalisation pipeline (exact order — VID_08 asserts idempotent, byte-identical re-run):
- Parse each tracked
*.otio(plain JSON) via_otio_bridge.load_otio_json(path)→ rawJsonObject. - Build IR: map OTIO_SCHEMA families → §1 dataclasses (§5 bridge).
- Exactify time: every
RationalTime.rate→RationalRate.from_float(exact table, §2); everyvalue→int(round-half-to-even only if the source stored an integral float like24.0; a non-integral frame value raises — frames/samples are integers). - Canonicalise metadata: stringify all values (canonical JSON rule); drop the volatile-key denylist —
{"generator", "otio_schema_version", "created", "modified", "timestamp", any key ending in "_abspath"}; relativise anytarget_url/path that is inside the repo root to a POSIX repo-relative path (externalfile:///http(s)URLs kept verbatim); sort by key. - Sort collections deterministically:
Project.media_poolbycontent_id;Project.sequencesbyordinal.Sequence.video_tracks/audio_lanes/subtitle_tracksbyordinal.Track.items/AudioLane.items: keep timeline order (ordered — order is meaning).Clip.effectsby(effect_type, name);Effect.keyframesbytime;*.markersby(start_time, name);Track.captionsby(marked_range.start_time, text).
- Assign identities top-down (§3), computing the §3.5 tiebreaker during the left→right
itemswalk. - Serialise + hash: each entity's canonical dict →
content_hash. The per-.otiofile's content id iscontent_hash(canonical_project_dict)— a hash of the IR, not the bytes. - Emit
SnapshotManifest:files = {relative_otio_path: file_content_id},domain = "timeline",directories = [...]. This is the canonical flat manifest the core engine content-addresses. - Emit sidecar
TimelineManifest(§manifest.py, mirrorsMusicManifest): the rich per-entity breakdown (project → sequences → tracks → clips with their ids), written to.muse/timeline_manifests/<snapshot_id>.json, additive metadata only — never replacingfiles. Added to.museignore [domain.timeline].
Deterministic serialisation rule (frozen): the only serialiser is muse.core.types.content_hash (json.dumps(sort_keys=True, separators=(",",":"), ensure_ascii=True)). Byte-identical determinism is guaranteed by: (a) no floats anywhere in the IR, (b) sorted keys + explicitly sorted collections, (c) volatile-key denylist + path relativisation, (d) integer-only frame/sample values.
.museignore honouring: when live_state is a pathlib.Path, snapshot() loads the ignore config and filters paths per the protocol contract (muse.core.ignore.load_ignore_config → resolve_patterns("timeline") → is_ignored), exactly as the MIDI plugin does.
5. Test matrix VID_01–VID_14 (tests/test_timeline_*.py)
Tiers per Aaron Rule 0: unit + golden-snapshot are load-bearing for VID-1; integration/e2e/stress/perf/security touchpoints are noted where relevant. Tests are written RED first (Step B), then implemented to green. Golden manifests are committed JSON checked byte-for-byte.
| ID | Tier | Asserts |
|---|---|---|
| VID_01 | unit | isinstance(TimelinePlugin(), MuseDomainPlugin); schema() returns a valid DomainSchema (merge_mode == "three_way", non-empty dimensions, domain == "timeline"). |
| VID_02 | unit | RationalRate invariants: reduction ((48,2)->(24,1)), rejects den<=0/num<=0, gcd==1; from_float exact NTSC table (23.976->(24000,1001), 29.97->(30000,1001)), and raises on undeclared fractional rate. |
| VID_03 | unit / data-integrity | Exact-instant equality: RationalTime(24,(24,1)) == RationalTime(48,(48,1)); ordering; is_identical distinguishes representation; hash equal for equal instants; no float in any code path. |
| VID_04 | unit | Non-drop timecode round-trip to_timecode/from_timecode at 24 & 25 fps (HH:MM:SS:FF). |
| VID_05 | unit / data-integrity | NTSC drop-frame: 29.97 DF drops 00/01 each minute except every 10th (and 59.94 DF drops 00..03); ; separator; round-trip stable; spot-checked against known TC↔frame anchors (e.g. 01:00:00;00). |
| VID_06 | unit | TimeRange: half-open semantics, end_time_exclusive/inclusive, contains, overlaps including exact-boundary cases. |
| VID_07 | unit / integration | _otio_bridge parses the single-track .otio JSON fixture → IR with correct clip/gap/external-ref/rate mapping; unknown OTIO_SCHEMA version raises a typed bridge error. |
| VID_08 | data-integrity | Determinism: snapshot(fixture) run twice is byte-identical; snapshot of a whitespace-/key-order-/generator-banner-perturbed copy of the same fixture is identical (proves IR-not-bytes hashing). |
| VID_09 | data-integrity | Clip identity excludes timeline start: widening a leading Gap (moving a clip later) leaves every clip_id unchanged; changing a clip's source_range does change its clip_id. |
| VID_10 | unit / data-integrity | Track identity = (sequence, kind, ordinal): reordering two video tracks swaps their ids as specified; editing a clip inside a track at fixed ordinal preserves the track id. |
| VID_11 | data-integrity | Positional tiebreaker: identical shot placed twice on one track → two distinct clip_ids with occurrence 0/1; stable across re-snapshot; trimming one makes it leave the identical set. |
| VID_12 | golden-snapshot | Single-track fixture snapshot == committed golden/single_track.manifest.json (flat files + sidecar TimelineManifest). |
| VID_13 | golden-snapshot | Multi-track fixture (2 video + 1 audio lane + subtitle track + transitions + effects w/ keyframes + markers + captions + nested sequence) == committed golden/multi_track.manifest.json. |
| VID_14 | security / data-integrity | Media essence never stored: after muse commit, .muse/objects contains no media bytes — only manifest/IR blobs; every ExternalReference.target_url is a relativised path or external URL; an offline media fixture (unresolvable target) snapshots with is_offline=True and still hashes deterministically; bridge rejects path-traversal (../) in target_url (no injection). |
Fixture list (tests/fixtures/timeline/, all pre-exported .otio JSON — no compiled lib):
| Fixture | Purpose | Used by |
|---|---|---|
single_track.otio |
3 clips + 1 gap, external refs, 24 fps | VID_07, VID_08, VID_09, VID_12 |
single_track_reformatted.otio |
byte-different (whitespace/key-order/generator) copy of above | VID_08 |
multi_track.otio |
2 video tracks, 1 audio lane, 1 subtitle track, transitions, keyframed effect, markers, captions, nested sequence | VID_10, VID_13 |
identical_shots.otio |
same shot twice on one track | VID_11 |
ntsc_2997_df.otio |
29.97 drop-frame timeline | VID_05 |
ntsc_23976.otio |
23.976 timeline | VID_02, VID_03 |
offline_media.otio |
unresolvable target_url |
VID_14 |
golden/*.manifest.json |
committed golden snapshots | VID_12, VID_13 |
Quality gates (DoD): pytest tests/test_timeline_*.py -v green · mypy --strict = 0 · python tools/typing_audit.py --dirs muse/ tests/ --max-any 0 clean · muse domains --json lists timeline active.
6. Module layout under muse/plugins/timeline/
muse/plugins/timeline/
├── __init__.py # exports TimelinePlugin
├── plugin.py # class TimelinePlugin(MuseDomainPlugin):
│ # snapshot() -> full canonical-IR snapshot (§4) — the load-bearing method
│ # schema() -> DomainSchema (below)
│ # diff() -> VID-1: structural file-level delta only (rich semantic diff = VID-3)
│ # merge() -> VID-1: file-level three-way fallback (semantic merge = VID-4)
│ # drift() -> snapshot(live) vs committed (added/removed/modified files)
│ # apply() -> return live_state unchanged (files restored by engine)
├── entity.py # §1 frozen dataclasses (Project…Metadata) + §3 identity functions:
│ # project_identity / sequence_identity / track_identity / clip_id /
│ # media_content_id + the §3.5 tiebreaker walker
├── manifest.py # TimelineManifest TypedDict sidecar + build/read/write
│ # (mirrors muse/plugins/midi/manifest.py; stored .muse/timeline_manifests/)
├── rational_time.py # RationalRate, RationalTime, TimeRange + timecode / NTSC drop-frame (§2)
└── _otio_bridge.py # load_otio_json(path)->IR : pure-JSON reader of .otio, OTIO_SCHEMA family
# dispatch, NO compiled opentimelineio import (VID-1). Raises typed
# BridgeError on unknown schema version / path-traversal target_url.
schema() (frozen for VID-1 — cross-check vs #68 §9):
DomainSchema(
domain="timeline",
description="Video editing timelines (OTIO-compatible canonical model).",
top_level=TreeSchema(kind="tree", node_type="timeline_element",
diff_algorithm="zhang_shasha"),
dimensions=[
DimensionSpec("structure", "Project→sequence→track hierarchy",
TreeSchema(kind="tree", node_type="track", diff_algorithm="zhang_shasha"),
independent_merge=False),
DimensionSpec("clips", "Ordered clips/gaps/transitions per lane",
SequenceSchema(kind="sequence", element_type="clip",
identity="by_id", diff_algorithm="myers", alphabet=None),
independent_merge=True),
DimensionSpec("effects", "Clip/track effects + keyframes",
SetSchema(kind="set", element_type="effect", identity="by_id"),
independent_merge=True),
DimensionSpec("markers", "Markers (union-mergeable)",
SetSchema(kind="set", element_type="marker", identity="by_id"),
independent_merge=True),
DimensionSpec("captions", "Subtitle/caption events",
SequenceSchema(kind="sequence", element_type="caption",
identity="by_id", diff_algorithm="myers", alphabet=None),
independent_merge=True),
DimensionSpec("media_pool", "Deduplicated source media assets",
MapSchema(kind="map", key_type="content_id",
value_schema=SetSchema(kind="set", element_type="external_reference",
identity="by_content"),
identity="by_key"),
independent_merge=True),
],
merge_mode="three_way",
schema_version=__version__, # from muse._version
)
TimelinePlugin will implement AddressedMergePlugin in VID-4 (address-keyed op merge on clip_id/track_identity); VID-1 ships the base six methods only.
Wiring notes for Step B (not code — reminders): add "timeline": TimelinePlugin() to muse/plugins/registry.py; add timeline = ["opentimelineio>=0.18.1"] to [project.optional-dependencies] (graceful-degrade extra, used only by the VID-2 compiled bridge); add a [domain.timeline] section to .museignore for timeline_manifests/.
7. Definition of Done for VID-1 (Step B, Auto)
muse/plugins/timeline/implements the frozen §1–§6 spec; six protocol methods present;snapshot()fully implements §4.- VID_01–VID_14 green;
mypy --strict= 0;typing_audit --max-any 0; noAny/object/cast/type: ignore/print. - Registered;
muse domains --jsonliststimelineactive. Media essence never in.muse/objects(VID_14). - Branch
task/video-timeline-vid1offdev(--intent,--resumable) → MP todev→ gabriel reviews & merges (no self-merge). - Cockpit sync:
video-roadmap.mdVID-1 row +video-overseer-handover.md§1 + change logs updated.
STOP — freeze only. No plugin code in this session.