muvid.footage.workspace¶
Per-user, STATEFUL project for the footage-aligned music_video genre.
Unlike the visualizer (a pure function of audio+cover), a music-video project accumulates
state across calls: one fixed song, several uploaded clips, a persisted alignment
manifest, and rendered outputs. This is deliberately NEW infrastructure (not the stateless
VisualizerWorkspace), reusing only the identity + fetch + tool-aggregation seams.
Layout (default root ~/.local/share/muvid; override via MUVID_DATA_HOME — shared
with the visualizer’s root, different subtree). Never inside the app/deploy tree:
{root}/music_video/projects/{email}/{project_id}/manifest.json— title, canvas, song.../song/song.<ext>— the one fixed clean song.../clips/{clip_id}.<ext>— an uploaded footage clip.../alignments.json— the persisted per-clip alignment.../renders/{render_id}/— an assembled music video.../edits/{edit_id}.json— a named, persisted edit (an EDL plus how it was made).../cover.jpg— the project’s cover frame (hosted projects only)
The catalog seam. MusicVideoFootageProject(..., media_catalog=None): when a host
places the project (muvid.Project), every song, clip, render and cover that
lands here is also registered in the host’s artifact catalog
(muvid.catalog.HostArtifactCatalog) and its artifact_id recorded beside it,
so the host can serve the bytes. None — the MCP connector’s per-caller workspace —
registers nothing and records nothing, so its on-disk records are unchanged.
Every JSON record here is replaced, never truncated in place (muvid#17 item 4).
manifest.json and alignments.json used to be bare write_text calls — a
truncate-then-write — while manifest() deliberately swallows OSError/ValueError
so an unreadable project reads as an EMPTY one. Put together, a process killed between
the truncate and the write left a project that presented as having no song and no clips:
not an error a caller could act on, but a plausible state a caller would act on wrongly
(re-uploading everything, or rendering nothing). The scoring layer three directories
away already wrote tmp + os.replace; atomic_write_text() is the ONE such dance
now, shared by every manifest/alignment/meta write here, scoring/grid.py (through
atomic_write_bytes(), its .npz primitive) and downloads.organise. A reader
sees the whole prior record or the whole new one. The on-disk FORMAT is untouched: that
would be a migration.
Invalidation runs BEFORE the write that would make the stale artifact look current.
set_song drops alignments.json and the score tracks first and replaces the manifest
LAST, so no crash window leaves a manifest naming the new song beside an alignment measured
against the old one — the offsets would then be read as if they belonged to it, which is
muvid#59’s silently-out-of-sync video by another route.
Invalidation is deliberate and lives here, beside the state it protects: set_song
and remove_clip both drop alignments.json and every persisted score track,
because the alignment is a measurement of exactly the song and the clip set that were
present when it was taken (muvid#22 — a removed clip’s offset must not outlive the
clip, and the score tensor is keyed on that alignment’s fingerprint).
Module Attributes
Re-exported from |
|
Named output canvases a project may choose at create (the genre Templates). |
|
What a caller-chosen id (a clip's, an edit's) may be — after surrounding whitespace is dropped. |
Functions
|
Replace |
|
Atomically replace |
The muvid data root: |
|
|
An exclusive advisory lock on |
|
A temp path beside |
|
A footage project rooted at |
|
|
|
Put a copy of |
Classes
|
A caller's private music-video area, addressed by |
|
One caller's stateful music-video project (song + clips + alignments + renders). |
- muvid.footage.workspace.CANVASES: dict[str, tuple[int, int]] = {'landscape': (1920, 1080), 'portrait': (1080, 1920), 'square': (1080, 1080)}¶
Named output canvases a project may choose at create (the genre Templates).
- muvid.footage.workspace.DATA_HOME_ENV_VAR = 'MUVID_DATA_HOME'¶
Re-exported from
muvid.paths, the SSOT for where muvid’s data lives. Kept as names here because both are public API of this module (muvid.mcpre-exportsdata_root, anddownloadsimportssafe_componentfrom here).
- class muvid.footage.workspace.FootageWorkspace(email, root)[source]¶
Bases:
objectA caller’s private music-video area, addressed by
email.
- muvid.footage.workspace.ID_PATTERN = re.compile('^[A-Za-z0-9_-]{1,64}$')¶
What a caller-chosen id (a clip’s, an edit’s) may be — after surrounding whitespace is dropped. Letters, digits,
_and-only: an id names a FILE (clips/<id>.mp4,edits/<id>.json), and a looser rule let*through to a glob that deleted every clip, and" c1"replacec1’s bytes under a different manifest key.
- class muvid.footage.workspace.MusicVideoFootageProject(email, project_id, root, media_catalog=None, defaults=<factory>)[source]¶
Bases:
objectOne caller’s stateful music-video project (song + clips + alignments + renders).
- add_clip(clip_id, src_path, *, ext, name='', kind='')[source]¶
Store a footage clip from a local file; returns its
clip_id.kind="still"stores a photo (no sound, no duration); the default is a video, and writes nokindkey, so a manifest written before photos existed reads the same.- Return type:
- defaults: dict¶
What
manifest()reads before anything is written — a hosted project’s title and canvas come from its genre envelope, so READING its footage creates nothing; the first write persists them.
- delete_edit(edit_id)[source]¶
Remove one edit record (and its undo history); whether it existed.
- Return type:
- edits_lock()[source]¶
Serialise a read-modify-write of this project’s edits (see
file_lock()).
- ensure_render_refs()[source]¶
Give every render a stable, speakable reference; return
{id: n}.A render id is a uuid4 slice (
b02fc05417ea) — fine for a URL, useless in a sentence. Nobody can ask for “a bit less of the wide shot in b02fc05417ea”. So each render also carries a small ordinal, rendered ascut 4bynw.delivery.format_ref()at the delivery boundary.This module stores the INTEGER only. The word “cut” belongs to
nw.deliveryand is spelled in exactly one place; core muvid does not depend on nw (it is in themcpextra, somuvid.visualizeand downstreams likeybstay lightweight), and a local second spelling is precisely the driftnw.deliveryexists to prevent.Two properties make it worth persisting rather than deriving:
Stable. Assigned once, at creation, and never renumbered. A position in a sorted list would shift under the user every time they rendered again, so the reference they wrote down would rot.
Chronological. Backfill runs OLDEST first, so
cut 1is the first thing they made, which is what someone means by “the first cut”.
Self-healing on read, in the same spirit as an open-time schema migration: renders made before refs existed acquire one the first time anything lists or resolves them, and the assignment is written back so it never moves again.
- Return type:
- invalidate_scores()[source]¶
Delete persisted score tracks — the primary invalidation on song/offset change.
- Return type:
- list_clips()[source]¶
[{clip_id, name}]— plusartifact_idwhen the host catalog holds it,kind: "still"for a photo, andhas_songwhen a person said (set_has_song).
- media_catalog: object = None¶
Where this project’s media is registered for a host to serve it — a
muvid.catalog.HostArtifactCatalog(anything with itsregister), orNonefor the MCP workspace, which registers nothing.
- next_render_ref()[source]¶
The ordinal the next render will carry (1-based, never reused).
- Return type:
- read_edit_history(edit_id)[source]¶
{"undo": [...], "redo": [...]}— earlier/later versions of one edit.- Return type:
- remove_clip(clip_id)[source]¶
Delete one clip — its file(s) and its manifest entry — and invalidate what was measured against it (muvid#22).
A removal changes the clip SET the alignment describes, so
alignments.jsonis dropped and, with it, every persisted score track — the same invalidationset_songperforms, and for the same reason: scores are keyed on the alignment fingerprint, which no longer matches. Whole-artifact, not surgical: pruning one record from the alignment would still move the fingerprint and strand the score manifest, and editing that manifest in place is a migration-surface change this method deliberately does not make.The steps run in an order where every crash-intermediate state is a valid project: alignment and scores go first (leaving “present, unaligned”), then the manifest (leaving at worst an orphan file
add_clipwould sweep), then the file — never a manifest entry pointing at a file that is gone.Returns what happened:
{clip_id, name, files, alignment_invalidated, scores_invalidated}. RaisesKeyErrorfor aclip_idthe manifest does not hold; the MCP tool turns that into a refusal naming the known ids.- Return type:
- property renders_dir: Path¶
Where this project’s renders live.
Named to match
VisualizerProject.renders_dirso anything that spans both muvid genres —muvid.downloads— sees one shape instead of branching on which drawer it is looking in.
- set_cover(image_path, *, taken_from)[source]¶
Store
image_pathascover.jpg, register it; return its artifact id.
- set_song(src_path, *, ext, name='', duration_s=None)[source]¶
Store (replacing) the project’s one clean song from a local file.
The order of operations is the contract (muvid#17 item 4), in three phases:
Everything that can fail runs first, against a staged copy — the copy itself, the duration probe, the content hash. A missing source or an un-probeable file raises here and the project is exactly as it was: the old song, its alignment and its scores all still stand.
What the old song vouched for is dropped —
alignments.jsonand the score tracks — BEFORE the manifest can name the new song. The song is the alignment reference, so an offset measured against the old one is meaningless against the new one; but nothing on disk ties an alignment record to a song, so a manifest that named the new song beside the old alignment would have those offsets read as its own and cut a video silently out of sync (the muvid#59 failure shape, by a different route). Dropping first means the worst a crash can leave is a project that demands a freshalign_footage.The song file lands, then the manifest is replaced LAST — it names the file, so the file must exist before any reader can be pointed at it.
nameis the display name (an uploaded file’s original name);duration_sis a duration the caller already probed, so a 100 MB song is not probed twice. With a host catalog the song is registered too, and its id recorded assong_artifact_id(it equalssong_hash: both are the SHA-256 of the bytes).- Return type:
- song_hash()[source]¶
The clean song’s content hash (cached in the manifest; computed if missing).
- Return type:
- muvid.footage.workspace.atomic_write_bytes(path, data)[source]¶
Replace
path’s content withdataso a reader never sees a torn file.The ONE tmp + fsync +
os.replacedance in the package (muvid#17 item 4); every JSON record in this module goes throughatomic_write_text(), and the scoring layer’s.npzarrays come here directly. The pieces, and why each is load-bearing:**The temp file is created in
path’s own directory** (tempfile.mkstemp, so a concurrent writer gets its own name rather than racing on a fixed.tmp).os.replaceis only a rename — and only atomic — within one filesystem; a temp under/tmpwould make it a copy with a torn window.The temp is fsync’d before the rename. Without it the rename can reach the journal before the data reaches the disk, and a power cut leaves the new name pointing at zeros — the failure mode a rename alone is wrongly believed to exclude.
The directory is fsync’d after the rename, best-effort. That is what makes the rename itself — and any
unlinka caller did in the same directory just before it, which is howMusicVideoFootageProject.set_song()orders its invalidation — durable. Directories cannot be opened for fsync on every platform (Windows), so anOSErrorthere is the one exception swallowed: the data is already safe on disk; only the metadata’s promptness is lost.On any failure the temp is removed and the error propagates.
write_textraised too; the difference is thatpathstill holds the previous complete record instead of a truncated one.
mkstempcreates the file0600, which is what these per-user records want and whatdownloads.organisealready did formeta.json; the mode of an existing file is not carried over, deliberately, so the outcome does not depend on history.- Return type:
- muvid.footage.workspace.atomic_write_text(path, text)[source]¶
Atomically replace
pathwithtext(UTF-8) — seeatomic_write_bytes().Every
manifest.json,alignments.jsonand rendermeta.jsonwrite goes through here. UTF-8 is a superset of the ASCIIjson.dumpsemits by default, so the bytes on disk are identical to whatwrite_textproduced and the locale-defaultread_texton the read side is untouched — the FORMAT does not change, only the way it gets there.- Return type:
- muvid.footage.workspace.data_root()[source]¶
The muvid data root:
$MUVID_DATA_HOMEor~/.local/share/muvid.A thin forwarder rather than
data_root = data_home: a plain alias keeps__module__ == "muvid.paths", which drops the name out of anything that filters a module’s members by where they were defined —automodule :members:(so this docstring would not render here) and this repo’s own drift-test idiom intests/test_mcp.py. Costs one call; keeps the module’s surface introspectable.- Return type:
- muvid.footage.workspace.file_lock(path)[source]¶
An exclusive advisory lock on
pathfor a read-modify-write (POSIXflock;msvcrt.lockingon Windows). Serialises concurrent edits of one project’s files; each write is also atomic, so a reader never needs the lock.
- muvid.footage.workspace.fresh_output(dest)[source]¶
A temp path beside
destfor a writer that cannot be told to be careful (ffmpeg); on success it is renamed ontodest— a new inode, as inreplace_file()— and on failure removed.
- muvid.footage.workspace.init_footage_project(root, *, project_id, title='', canvas='landscape', media_catalog=None, email='')[source]¶
A footage project rooted at
root, writing its manifest if there is none yet.The one place a footage manifest is born, for both the MCP workspace (below) and a host-placed
muvid.Project(whose footage lives at<project>/footage). Idempotent: an existing manifest is left exactly as it is.- Return type:
- muvid.footage.workspace.normalise_id(value, *, label)[source]¶
valuestripped, and refused (ValueError) unless it matchesID_PATTERN.- Return type:
>>> normalise_id(" c01 ", label="clip_id") 'c01' >>> normalise_id("*", label="clip_id") Traceback (most recent call last): ... ValueError: invalid clip_id '*': use 1-64 letters, digits, '_' or '-'
- muvid.footage.workspace.replace_file(src, dest)[source]¶
Put a copy of
srcatdestas a NEW file (temp sibling +os.replace).Never writes into an existing
dest: a project’s media is hardlinked into the host’s content-addressed catalog (blobs/<sha256>), so an in-place overwrite would silently change the bytes behind an id that names the old ones. A rename givesdesta new inode and leaves the blob exactly as it was.- Return type: