gabriel / muse public
Open #85 Enhancement phase/vid-1
filed by aaronrene human · 13 days ago

VID-1: Canonical model + snapshot + RationalTime + stable identity (interface freeze → build)

0 Anchors
Blast radius
Churn 30d
0 Proposals

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 branch task/video-timeline-vid1.

Cockpit doc — do NOT commit into the muse repo. Lives in MUSE_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.pycontent_hash(), blob_id(), split_id()
Schema types muse/muse/core/schema.pyDomainSchema, ElementSchema, DimensionSpec
Active clone MUSE_HUB/musemuse-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 items left→right; among the identical set, assign occurrence = 0, 1, 2, … in that order.
  • occurrence feeds clip_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_range changes, 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):

  1. Parse each tracked *.otio (plain JSON) via _otio_bridge.load_otio_json(path) → raw JsonObject.
  2. Build IR: map OTIO_SCHEMA families → §1 dataclasses (§5 bridge).
  3. Exactify time: every RationalTime.rateRationalRate.from_float (exact table, §2); every valueint (round-half-to-even only if the source stored an integral float like 24.0; a non-integral frame value raises — frames/samples are integers).
  4. 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 any target_url/path that is inside the repo root to a POSIX repo-relative path (external file:///http(s) URLs kept verbatim); sort by key.
  5. Sort collections deterministically:
    • Project.media_pool by content_id; Project.sequences by ordinal.
    • Sequence.video_tracks / audio_lanes / subtitle_tracks by ordinal.
    • Track.items / AudioLane.items: keep timeline order (ordered — order is meaning).
    • Clip.effects by (effect_type, name); Effect.keyframes by time; *.markers by (start_time, name); Track.captions by (marked_range.start_time, text).
  6. Assign identities top-down (§3), computing the §3.5 tiebreaker during the left→right items walk.
  7. Serialise + hash: each entity's canonical dict → content_hash. The per-.otio file's content id is content_hash(canonical_project_dict) — a hash of the IR, not the bytes.
  8. Emit SnapshotManifest: files = {relative_otio_path: file_content_id}, domain = "timeline", directories = [...]. This is the canonical flat manifest the core engine content-addresses.
  9. Emit sidecar TimelineManifestmanifest.py, mirrors MusicManifest): the rich per-entity breakdown (project → sequences → tracks → clips with their ids), written to .muse/timeline_manifests/<snapshot_id>.json, additive metadata only — never replacing files. 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_configresolve_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; no Any/object/cast/type: ignore/print.
  • Registered; muse domains --json lists timeline active. Media essence never in .muse/objects (VID_14).
  • Branch task/video-timeline-vid1 off dev (--intent, --resumable) → MP to devgabriel reviews & merges (no self-merge).
  • Cockpit sync: video-roadmap.md VID-1 row + video-overseer-handover.md §1 + change logs updated.

STOP — freeze only. No plugin code in this session.

Activity
aaronrene opened this issue 13 days ago
No activity yet. Use the CLI to comment.