The Media Graph
"Every project is a graph, not a gallery. Every generation is a node that knows its dependencies."
Implementation: src/server/services/graph.ts, table GraphEdge.
Why an explicit edge table
The alternative — deriving dependencies from foreign keys at query time — fails for the operation that defines the product. When a creator changes a wardrobe colour, we need to answer "what does this invalidate?" across heterogeneous node types (wardrobe → shot, style → shot, state event → shot, generation → asset) with different relation semantics and different regeneration costs. Foreign keys encode containment; they do not encode invalidation.
GraphEdge is a typed, directed, weighted edge:
fromType String // 20 node types — see GRAPH_NODE_TYPES
fromId String
toType String
toId String
relation String // depends_on | derived_from | references
// supersedes | contains | styled_by | cast_as
weight Float // higher = a change here is more likely to invalidate
stale Boolean // upstream changed, downstream not yet regenerated
Uniqueness is (projectId, fromType, fromId, toType, toId, relation), so the same pair can carry several relations without collision.
Traversal
resolveImpact(projectId, origin) is a breadth-first walk downstream, one query per level rather than per node — a 200-shot feature resolves in a handful of queries rather than hundreds. Depth is capped at 8 and visited nodes are tracked, so a malformed edge cannot hang the planner.
Each impacted node carries:
label— human-readable ("MARA — Relay coveralls")path— the full dependency chain, shown verbatim in the confirmation UIreason— derived from the relation typeestimatedCredits— what redoing this actually costsdepth
Showing the path is not decoration. It is how a creator confirms the system understood them before spending money.
Cost model
Only nodes that cost something to redo are billed:
| Node | Cost |
|---|---|
shot | creditsPerSec(qualityMode) × durationSec |
asset | 4 |
dialogue | 1 |
music_cue | 6 |
| Bible objects | 0 — metadata is free; its dependents are not |
The confirmation gate
Nothing regenerates without approval. planCascade writes a CascadePlan row (status pending) holding the impacted set, the price, and the mutation to apply. The UI renders it; approveCascade executes it.
On approval:
- Downstream edges from the origin are marked
stale. - Affected shots drop to status
approvedso the queue picks them up. - The existing rendered asset stays attached.
Point 3 is load-bearing and was a real bug during development. Detaching the old render on approval means the timeline clip loses its picture the instant you approve, and the creator has no watchable cut until every regeneration finishes. The whole promise of a graph-aware NLE is per-shot re-render without losing the cut — so the previous output survives until the new one swaps in (assembleFromShots' re-point pass).
Equally load-bearing: camera, lens, movement, framing, lighting and storyboard approval are untouched. That is the difference between "regenerate" and "start over".
Keeping edges honest
reindexShotEdges(projectId, shotId) deletes and rebuilds a shot's inbound edges from current truth. Call it whenever a shot's cast, props, location or style changes. Cheap, idempotent, and it makes stale edges impossible rather than merely unlikely.
High-level operations
All in graph.ts, all returning a priced plan rather than mutating:
| Function | Spec reference |
|---|---|
changeWardrobeAttribute | The canonical "black jacket → red" demo |
changeActor | Global recasting; creates a new canon version, earlier scenes stay pinned |
addStateEvent | Character State Machine; wires edges only to shots at/after the event scene |
applyStyle | Project / scene / shot scope; project scope sets Style DNA on the Universe |
Adding a node type
- Add it to
GRAPH_NODE_TYPESinsrc/lib/enums.ts. - Add a
labelForcase so the confirmation UI reads well. - Add an
estimateRegenCostcase if regenerating it costs credits. - Wire its edges wherever it is created.
No schema migration — GraphEdge is type-agnostic by design.