Graph Resources — Technical Design
Status: proposed. Companion: tasks.md. Precedent: script-storyboard-link/design.md for the “pure functions in a shared package, consumed by editor, agent tools and nodes alike” pattern this design continues.
0. The problem in one paragraph
Storyboards, scripts, timelines and entities are the documents a director
approves. Workflow graphs are the thing that re-runs. Today the two do not
meet: a graph can read a script or a timeline (ScriptRef, TimelineRef,
nodetool.script.*, nodetool.timeline.*) but there is no StoryboardRef,
no entity value type, no nodetool.storyboard.* and no nodetool.entity.*.
The creative nodes (Director, ScreenplayShots, ApplyEntities) pass
screenplays and entities as untyped dicts and never touch a persisted board or
the entity library. So every variant of an approved piece is re-directed by
chat, which is exactly the work an ecommerce, marketing or game customer wants
to stop paying for. The design makes the four documents typed graph values,
adds the nodes that copy and fill them, and keeps the template the human
approved read-only.
One sentence: approve once in the editor, re-run many times in a graph.
1. Principles
- P1. A document enters a graph by reference, leaves as a new row. Refs
are
{type, id, data?}likeScriptRef. Nodes that derive (recast, fill, retarget) create a new row and stamp lineage; they never write the source. A batch cannot overwrite the board a person approved. - P2. Pure first, IO second. Every mapping (recast a board, fill a script, retarget a sequence, prompt for a slot) is a pure function in a shared package with a fixture test. The node, the agent capability and the editor call the same function.
- P3. Re-runs are cheap by construction. Identity is by name, not by
row:
CreateEntityupserts on (project, kind, name),RecastStoryboardreuses its previous copy for the same substitution, and render nodes skip a shot whoserender_inputs.prompt_hashis unchanged. A monthly re-run pays only for what changed. - P4. The spend gate survives. Stills and clips are separate nodes, clips
refuse a shot without a selected keyframe by default, and both take
max_shots. A graph can run to the stills and stop, as the storyboard loop does. - P5. No new agent tools. The capabilities already cover headless editing. What is new is the node surface and the shared functions under it.
2. Types
2.1 Protocol (packages/protocol/src/api-types.ts, creative.ts)
/** Reference to a persisted storyboard, editable in the board editor and
* passable between workflow nodes. Mirrors ScriptRef / TimelineRef. */
export interface StoryboardRef {
type: "storyboard";
id?: string | null;
/** Optional inline StoryboardDocument, for tests and the debug harness. */
data?: unknown;
/**
* Set only by a node that created or derived the row in this run. A ref
* from the picker or from LoadStoryboard never carries it (§4.2, write
* contract).
*/
writable?: boolean;
}
Entity already exists (creative.ts, type: "entity", id = asset id).
It becomes a node value type named entity with no new interface. The
one addition is a lineage marker on entities a graph creates:
EntityMarker
└─ source?: { workflow_id?: string; key?: string }
// `key` is the upsert identity a batch used (e.g. a SKU); lets a re-run
// find its own entity even after a rename.
Storyboard document lineage (packages/models/src/storyboard.ts,
StoryboardDocument, mirrored in api-schemas/storyboards.ts):
StoryboardDocument
├─ templateId?: string | null // board this one was recast from
└─ recastKey?: string | null // the canonical substitution mapping
// (§3.1), so a re-run finds its copy
Script document lineage (packages/models/src/script.ts):
ScriptDocument
└─ templateId?: string | null // script this one was filled from
Timeline sequence lineage (packages/timeline/src/types.ts):
TimelineSequence
└─ templateId?: string | null // sequence this one was retargeted from
All optional, all passthrough: old rows load unchanged.
2.2 Node value types
| Type name | Wire shape | Default (ref-defaults.ts) |
Picker |
|---|---|---|---|
storyboard |
StoryboardRef |
{type:"storyboard", id:null, data:null} |
StoryboardProperty over DocumentPickerProperty (useStoryboards) |
entity |
Entity |
{type:"entity", id:"", kind:"prop", name:"", descriptor:""} |
EntityProperty reusing EntityAssetPickerDialog |
list[entity] |
Entity[] |
[] |
same picker, multi |
game_slot |
GameSlotSpec (protocol game-assets.ts) |
— (output only) | none |
slot_fill |
SlotFill |
— (output only) | none |
The entities property on nodetool.image.TextToImage,
nodetool.video.ImageToVideo, nodetool.video.TextToVideo and
nodetool.creative.ApplyEntities changes from list[dict] to
list[entity]. The runtime value is the same object, so saved graphs keep
working; the editor gains a picker and a typed handle.
Entity resolution rule. An entity value’s id is its identity; every
other field is a cache. A consumer calls resolveEntities(values, context)
(packages/runtime/src/entities.ts): when descriptor is empty and the
context has getEntity, the library copy wins. A picked entity therefore
follows later edits in the library, and an inline entity (tests, DSL) still
works with no database.
Registration points, one line each: web/src/config/data_types.ts (colour,
icon), web/src/components/node/PropertyInput.resolver.tsx,
packages/core-nodes/src/nodes/constant.ts (nodetool.constant.Storyboard,
nodetool.constant.Entity), packages/dsl/src/types.ts (StoryboardRef,
Entity), scripts/verify-backend-bundle.mjs type list.
2.3 ProcessingContext model interfaces (packages/runtime/src/context.ts)
Same shape as the script trio, installed in
packages/websocket/src/session/model-interfaces.ts and the CLI’s context:
getStoryboard: ({userId, id}) => StoryboardResponse | null
createStoryboard: ({userId, name, projectId?, document}) => StoryboardResponse
updateStoryboard: ({userId, id, document, timelineId?, baseUpdatedAt?}) => StoryboardResponse | null
listEntities: ({userId, projectId?, kind?, tags?, nameContains?, limit?}) => Entity[]
getEntity: ({userId, id}) => Entity | null
upsertEntity: ({userId, projectId?, kind, name, descriptor, imageAssetId,
description?, tags?, voiceId?, source?}) => Entity
// finds (project, kind, name) or `source.key`; updates the
// marker in place, else tags the image asset
listGameTemplates: () => { id, manifest }[]
entityFromAsset moves from packages/agents/src/capabilities/entities.ts
to packages/models/src/entity.ts so the websocket host and the agent
capability share one reader. The capability keeps its behaviour and imports
it.
3. Shared functions (pure)
3.1 packages/storyboard (new, depends on protocol + timeline)
Why a package: packages/timeline already holds buildStoryboardTimeline,
and protocol holds shot-prompt.ts and script-link.ts. Recast and the
render plan are storyboard-only and need both, and both video-nodes and
agents must import them. The package mirrors packages/timeline in role.
// recast.ts
export interface RecastInput {
document: StoryboardDocument; // the template, as it is now
/** Entities on the source board, resolved. */
boardEntities: Entity[];
/** Incoming cast. Each replaces the board entity it targets. */
cast: Array<{ entity: Entity; replaces?: string /* board entity id or name */ }>;
/** The copy a previous run made for this mapping, when reusing (below). */
existing?: StoryboardDocument;
}
export interface RecastResult {
document: StoryboardDocument; // new or re-derived copy, lineage stamped
substitutions: Array<{ from: Entity; to: Entity }>;
/** Shots whose rendered prompt changed and lost their takes. */
invalidatedShotIds: string[];
/** Shots whose prompt is unchanged and kept keyframe/clip versions. */
keptShotIds: string[];
/** Shots the template no longer has; their takes on `existing` are gone. */
droppedShotIds: string[];
recastKey: string;
}
export function recastStoryboard(input: RecastInput): RecastResult;
Rules, each pinned by a test:
- A cast entry with
replacestargets that board entity. Without it, the entry replaces the single board entity of the samekind; if there are several or none, the entry is appended and nothing is renamed. - Substitution rewrites the old name to the new name, whole word, case
insensitive, in
action,motion,dialogue,narration,slug, and in each shot’s explicitentity_ids, and onentityIds/screenplay.entity_ids. - Invalidation is by prompt, not by touch: for each shot the function
recomputes
keyframePromptandclipPromptwith the new cast throughinjectEntities, hashes them the wayRenderInputs.prompt_hashis written today, and clearskeyframe*/clip*/statusonly when the hash moved. A product-only frame on a board whose model changed keeps its takes. templateId= source board id.recastKeyis the mapping, not the result: one token per resolved substitution,<sourceEntityId>><destEntityId>, one per append,+<destEntityId>, sorted and joined with,. Input order does not matter; role assignment does. On a board with characters A and B, A→X, B→Y and A→Y, B→X are different keys and different copies.- Copies keep the template’s shot ids, so a later re-derive merges by id.
timeline_idis not copied; the approved cut is carried byAssembleTimeline(§4.2).
Reuse re-derives, it does not return. With existing set, the function
still derives from the current template and the current cast, every
time. There is no shortcut keyed on a fingerprint: such a key would have to
cover every derivation input (template text, board entity descriptors, the
incoming entities’ names and descriptors, the mapping), and the derivation
is one pass over one document. It then merges: for every shot id in both
documents the existing copy’s keyframe*, clip*, status and
render_inputs are carried over, and the hash rule above decides whether
they survive. Shots new on the template appear; shots the template dropped
disappear and are named in droppedShotIds. So a template edit to one
shot’s action invalidates that shot on every reused copy and no other; a
renamed destination entity (same id, a CreateEntity upsert under the same
key) has its new name rewritten into the shots and invalidates exactly the
shots that name it; an edited descriptor invalidates the shots it seasons,
because the descriptor is in the injected prompt. The next only_stale
render sees each new prompt against the old render_inputs.
// render-plan.ts
export interface ShotRenderPlan {
shotId: string;
kind: "keyframe" | "clip";
prompt: string;
referenceAssetIds: string[];
model: { provider: string; model: string };
aspectRatio: string;
/** True when render_inputs match this plan: nothing to do. */
fresh: boolean;
}
export function planShotRenders(
doc: StoryboardDocument, entities: Entity[], kind: "keyframe" | "clip",
targets?: string[]
): ShotRenderPlan[];
This is the logic render_storyboard_stills and filterStale run inline
today, lifted so the node and the capability plan identically. The IO half,
renderShots(context, ref, plans), lives in the same package under
io/: it calls context.runGeneration with persist, appends the version
with render_inputs, and writes the board through updateStoryboard with a
reload-and-retry on StoryboardConflictError (the same posture patchShot
has in the capability). The capability’s render tools switch to it in the
same PR so there is one render path.
3.2 packages/timeline additions
// fill-text.ts
export function fillTimelineText(
seq: TimelineSequence, values: Record<string, string>
): { sequence: TimelineSequence; filled: string[]; unresolved: string[] };
// `` in ClipTextStyle.text and caption text. Unresolved keys are
// reported, never blanked.
// clone.ts
export function cloneTimelineForBoard(
seq: TimelineSequence,
from: { boardId: string },
to: { boardId: string }
): TimelineSequence;
// A new sequence (fresh id, `templateId` = the source sequence id). Every
// clip owned by `from.boardId` (`storyboardBoardId` matches) is re-stamped
// to `to.boardId` with its media cleared; every other clip and every track
// is copied verbatim. The next re-assemble fills the owned clips from the
// copy's renders and, through `foreignTimelineParts` (`reassemble.ts`),
// leaves the text overlay, the music bed and any other edit alone. Hand
// edits on the shot clips themselves (a trim, a moved cut) are rebuilt from
// the shots, exactly as today's re-assemble does.
// retarget.ts
export function retargetSequence(
seq: TimelineSequence,
aspectRatio: string,
fit: "cover" | "contain"
): { sequence: TimelineSequence; croppedClipIds: string[] };
// frameSizeForAspect for the new canvas; clip transforms rescaled about the
// clip centre; `cover` scales to fill and reports every clip that now crops,
// `contain` letterboxes. Text clips rescale font size with the short edge.
3.3 packages/protocol additions
// script-fill.ts
export function fillScript(
doc: ScriptDocument, values: Record<string, string>
): { document: ScriptDocument; filled: string[]; unresolved: string[] };
// `` in ScriptLine.text. Takes are kept: `needsVoicing` already marks
// a line stale when its text moved, so a later VoiceScript pays only for
// changed lines.
// game-slot-prompt.ts
export function slotPrompt(
slot: GameSlotSpec, style: Entity | null, cast: Entity[]
): { prompt: string; width: number; height: number; checker: Record<string, unknown> };
// spritesheet: width = cell.w × max frames, height = cell.h × animations;
// tileset: a near-square grid of `count` cells; image: `size`; sfx/music:
// no size. `checker` is the exact prop bag the matching nodetool.game.* node
// wants, so a graph can Switch on slot.kind and wire one node per kind.
4. Nodes
All nodes are tagAsServer (they need the model interfaces). Handle names
are the ones an agent will see through search_nodes; keep them.
4.1 nodetool.entity.* (packages/core-nodes/src/nodes/entity.ts)
| Node | Inputs | Outputs |
|---|---|---|
LoadEntity |
entity: entity (picker) or name: str + kind? |
entity, descriptor: str, name: str, kind: str, reference_image: image, voice_id: str |
ListEntities |
kind?, tags?: list[str], name_contains?: str, project?: str |
entities: list[entity]; streams entity per item |
CreateEntity |
image: image, kind, name: str, descriptor: str, description?, tags?, voice_id?, key?: str (upsert identity, e.g. a SKU) |
entity, created: bool |
CreateEntity upserts (§2.3). A batch that runs twice yields the same
entity ids twice.
4.2 nodetool.storyboard.* (packages/video-nodes/src/nodes/storyboard.ts)
| Node | Inputs | Outputs |
|---|---|---|
LoadStoryboard |
storyboard |
storyboard (same ref), shots: list[dict], entities: list[entity], style: str, aspect_ratio: str, name: str, image_model, video_model, shot_count: int |
StoryboardShots |
storyboard |
streams shot: dict, index: int, slug: str, keyframe: image, clip: video; output: list[dict] at the end (the ScreenplayShots contract, over a persisted board) |
RecastStoryboard |
storyboard, cast: list[entity], replaces?: list[str], name?: str, reuse_existing: bool = true |
storyboard (the copy), invalidated: list[str], kept: list[str] |
RenderStills |
storyboard, targets?: list[str], max_shots: int = 24, concurrency: int = 2, only_stale: bool = true |
storyboard, keyframes: list[image], rendered: list[str], skipped: list[str], failed: list[str] |
RenderClips |
storyboard, targets?, max_shots: int = 8, require_keyframe: bool = true, concurrency: int = 1, only_stale: bool = true |
storyboard, clips: list[video], rendered, skipped, failed |
AssembleTimeline |
storyboard, name?: str |
timeline: timeline, skipped_shots: list[str], retimed: list[dict] |
RecastStoryboard with reuse_existing looks up a board in the same project
with the same templateId and recastKey and passes it to recastStoryboard
as existing: the copy is re-derived from the current template (§3.1), so a
template edit reaches every reused copy on the next run and nothing else is
re-rendered. The output ref carries writable: true.
Write contract. A StoryboardRef is read-only unless it carries
writable: true, and only a node that created or derived the row in this run
sets it: RecastStoryboard, and the render nodes on the ref they pass on.
The picker (nodetool.constant.Storyboard), LoadStoryboard and
StoryboardShots never set it. RenderStills, RenderClips and
AssembleTimeline refuse a ref without the flag with an error naming
allow_writes, the per-node override for a graph whose whole purpose is to
render the board a person picked. Permission does not depend on whether any
derived row exists yet, so a first-run miswire (picker → RenderStills)
fails before any spend, and a derived board picked from the library is as
protected as a template, whatever its lineage.
AssembleTimeline writes timeline_id on the board. When the board has no
cut yet and its template has one (templateId → that board’s timeline_id),
it first clones the template’s sequence for the copy (cloneTimelineForBoard,
§3.2), then re-assembles in place: only the shot clips are regenerated from
the copy’s renders, and the text overlay, the music bed and every other
track survive. A copy therefore inherits the approved cut, not a bare
assembly. When the board is script-linked it calls buildLinkedTimeline, as
the capability does.
Models come from the board (imageModel/videoModel), with an optional
image_model/video_model input to override. Unset is an error naming
find_model, never a default.
4.3 nodetool.script.* additions (packages/video-nodes/src/nodes/script.ts)
| Node | Inputs | Outputs |
|---|---|---|
WriteScript |
model: language_model, brief: str, format: str, cast: list[entity], language?: str, pace?, name?: str |
script (new row), line_count: int |
FillScript |
script, values: dict, name?: str |
script (new row), filled: list[str], unresolved: list[str] |
WriteScript runs the ScriptWriterInput prompt from
script-authoring.ts and maps cast to speakers with entityId and
voice.voice from voice_id. This is the script-first path the link
design’s §4 deviation asked for.
4.4 nodetool.timeline.* additions (packages/video-nodes/src/nodes/timeline.ts)
| Node | Inputs | Outputs |
|---|---|---|
FillTimelineText |
timeline, values: dict, name? |
timeline (new row), filled, unresolved |
RetargetTimeline |
timeline, aspect_ratio: str, fit: "cover" \| "contain" = "cover", name? |
timeline (new row), cropped: list[str] |
Both create a new sequence with templateId; RenderTimeline is unchanged.
4.5 nodetool.game.* additions
packages/image-nodes keeps the checkers. The template and export nodes go
in a new packages/game-nodes (depends on @nodetool-ai/godot,
godot-templates, runtime):
| Node | Inputs | Outputs |
|---|---|---|
LoadGameTemplate |
template: str (select from listGameTemplates) |
manifest: dict, slots: list[game_slot]; streams slot: game_slot |
SlotPrompt |
slot: game_slot, style?: entity, cast: list[entity] |
prompt: str, width: int, height: int, kind: str, checker: dict, seconds: float |
ExportGodotProject |
template: str, name: str, fills: list[slot_fill], directory: str, verify: bool = true |
directory: str, files: list[str], verified: bool, errors: list[str] |
The existing checkers gain a slot: game_slot input that fills
cell_width/cell_height/animations/count/slot_id from the slot, so
a SlotPrompt → generator → checker chain wires without hand-copied
numbers. ExportGodotProject calls writeGodotProject and, when
GODOT_BIN is set and the workspace is local, the same headless verify the
capability runs.
5. Examples, end to end
Each example ships as a workflow JSON under
packages/base-nodes/nodetool/examples/nodetool-base/ and as a
nodetool.fake.*-backed fixture the harness runs without spend (§7).
E1. Per-SKU product ads (ecommerce)
Setup, once, on the surfaces: a director builds the board “Hero 9:16” with a
prop entity named Product, a character entity, a style entity, both
models set, stills approved, clips rendered, cut assembled, a text clip
reading ` — `. The board and the cut are the templates.
Graph (Per-SKU Ad Factory):
LoadCSVAssets(folder: products/) → dataframe
ForEachRow(dataframe) → row {sku, name, image, descriptor, price}
LoadImageFile(row.image) → image
CreateEntity(image, kind: prop, name: row.name,
descriptor: row.descriptor, key: row.sku) → entity
Constant.Storyboard("Hero 9:16") → template
RecastStoryboard(template, cast: [entity], replaces: ["Product"])
→ board' (invalidated: shots naming Product)
RenderStills(board') → board' (only stale shots render)
RenderClips(board', require_keyframe: true) → board'
AssembleTimeline(board') → timeline' (clones the template cut, re-assembles
the shot clips, keeps the overlay and music)
FillTimelineText(timeline', values: {name: row.name, price: row.price})
→ timeline''
RenderTimeline(timeline'') → video
Collect(video) → Output "ads"
What it costs: per SKU, one still and one clip for each shot whose prompt
names Product; the style frame and any product-free shot are kept from the
template. Second run with two new rows: CreateEntity returns the same
entity for the eight old SKUs, RecastStoryboard returns the eight old
copies, only_stale renders nothing on them, and only the two new SKUs are
paid for. A WebhookTrigger on a product-created event in place of the CSV
turns it into a listener.
What it does not do: publish. The output is a list of videos. A Shopify or ad-platform node is a separate integration (§9).
E2. Localized explainer (marketing)
Setup: a script written on the script surface with a character entity
Mara as the narrator (voice_id set) and lines carrying and
; a board of six b-roll shots, stills approved, no clips.
Graph (Localized Explainer):
Constant.Script("Explainer template") → script
Constant.Entity(Mara) → narrator
Constant.Storyboard("B-roll") → board
Cross(rows: offers.csv, languages: ["en","de","fr"]) → (row, lang)
WriteScript(model, brief: row.brief, cast: [narrator], language: lang,
format: "narrator") → script' // or FillScript when only values change
VoiceScript(script') → script' // pays per changed line
ScriptToTimeline(script') → timeline'
StoryboardShots(board) → keyframes
AddClips(timeline', clips: keyframes, image_duration_ms: 4000) → timeline'
ScriptToSubtitles(script', format: srt) → subtitles
RenderTimeline(timeline') → video
The board is read only; nothing renders on it. For a value-only variant
(FillScript instead of WriteScript), unchanged lines keep their takes
and VoiceScript voices only the lines whose text moved.
E3. Game re-skin (gamedev)
Setup: a style entity Cave Pixel and a character entity Pip
created from an approved sprite, both in the library.
Graph (Platformer Asset Pack):
LoadGameTemplate("platformer") → streams slot
SlotPrompt(slot, style: Cave Pixel, cast: [Pip]) → prompt, width, height, kind, checker
Switch(kind)
spritesheet → TextToImage(prompt, width, height) → game.SpriteSheet(image, slot) → fill
tileset → TextToImage(...) → game.Tileset(image, slot) → fill
image → TextToImage(...) → game.SeamlessImage(image, slot)→ fill
sfx → TextToAudio(prompt, seconds) → game.SoundEffect(audio, slot) → fill
music → TextToMusic(prompt, seconds) → game.MusicLoop(audio, slot) → fill
TryCatch(around each branch) → on error, one retry with the checker's message appended to the prompt
Collect(fill) → fills
ExportGodotProject("platformer", name, fills, directory: "game/", verify: true)
→ directory, verified, errors
A re-skin is swapping the style entity on the SlotPrompt node and running
again. Cave Pixel seasons every slot the same way the storyboard render
path seasons a shot, so the pack is consistent by construction.
E4. One cut, three ratios
Constant.Timeline("Launch film 16:9 cut") → timeline (the approved cut, read-only)
RetargetTimeline(timeline, "9:16", fit: cover) → t1 (new row; cropped: [...])
RetargetTimeline(timeline, "1:1", fit: cover) → t2 (new row)
RenderTimeline(t1), RenderTimeline(t2), RenderTimeline(timeline)
The graph starts from the cut, not the board. A picker ref is read-only
under the write contract, and RetargetTimeline derives a new row from it
without writing the source, so the approved trims and placements carry over
unchanged. Re-assembling from the board would rebuild the shot clips (§3.2).
Honest limit: a 16:9 clip cropped to 9:16 loses the sides. cropped names
the clips so the director can decide which shots need a real 9:16 board
(the launch-kit rule of a board per ratio still holds for hero shots).
6. Web
StoryboardPropertyandEntityProperty(web/src/components/properties/), registered inPropertyInput.resolver.tsx;data_types.tsentries.- Constant nodes
nodetool.constant.Storyboard/Entityin the node menu under the existing Timeline / Script entries. - Output handles of type
storyboard/timeline/scripton a finished run open the document (theTimelinePropertyopen-in-editor affordance, reused). - A board created by
RecastStoryboardshows a “recast from " chip in the board header, linking back. Nothing else in the editors changes.
7. Testing and harnesses
- Vitest, pure:
recastStoryboard(targeting, whole-word rename, hash-based invalidation, kept takes, lineage),planShotRenders(fresh detection matchesfilterStaleon the capability’s fixtures),fillScript,fillTimelineText,retargetSequence(crop report, text scaling),slotPrompt(sizes per kind, checker bag matches each checker’s props). Every suite has a case that must fail: a rename that would hit a substring inside another word, a placeholder with no value, a board with two entities of the target kind and noreplaces. Recast fixtures also pin: the swapped assignment (A→X, B→Y versus A→Y, B→X) yields two keys; a re-run after editing one shot’s action on the template, cast ids unchanged, invalidates that shot on the reused copy and keeps the rest; a re-run after renaming a destination entity under the same id, template unchanged, rewrites the name and invalidates only the shots naming it; a cloned cut keeps its foreign clips and, afterfillTimelineText, carries the filled overlay text. - Vitest, nodes: each node against a
ProcessingContextwith in-memory model interfaces (testing.tsalready builds one), including the write contract (picker →RenderStillsrefused on a board with no children; a derived board picked from the library refused the same way;allow_writesadmits both), the upsert returning the same id twice, and the E1 fake-mode run asserting the filled ` — ` overlay is present on the exported sequence. - Harness registry (
packages/cli/src/harness/registry.ts): agraph-resourcesentry whose selfcheck runs the suites above plusnodetool debugon the four example workflows in fake mode (nodetool.fake.GenerateImagein place of the generators; render nodes take aprovideroverride the fixture sets tofake).harness gatepicks it up on diffs underpackages/storyboard/, the new node files, and the examples. validate_workflowon each example must pass with models stamped.npm run check:agents-docsfor the new package’sAGENTS.md.
8. What deliberately does not change
- The storyboard, script and timeline editors’ data model beyond the three optional lineage fields.
buildStoryboardTimeline,buildLinkedTimeline,buildScriptTimeline.- The agent capabilities’ names and arguments.
render_storyboard_stillsandrender_storyboard_clipsswitch torenderShotsinternally; their outputs are unchanged and their eval cases prove it. Director,ScreenplayShots,ShotBatch,ShotChain: still the path for a graph that directs from a brief with no board.
9. Out of scope, named
- Publish nodes (Shopify media, YouTube/TikTok upload, Meta ad creative, SMTP). They are the step that makes E1 automation rather than file generation, and they are integration work with no dependency on this design. Separate design.
- Cost estimation for render nodes.
Shot.cost_estimateis unpopulated today;max_shotsis the cap this design offers. - A
LinkedAssemblenode for a board plus script pair:AssembleTimelinehandles the linked case by readingscript_id, which covers it.
10. Risks
- R1. A graph draws over the approved board. Mitigated by P1 (derive nodes create rows), the write contract in §4.2 (a ref is read-only unless the run derived it), and the lineage chip.
- R2. Two branches write one board. A
ForEachRowthat fans out and each branch renders on the same recast copy conflicts onrevision.renderShotsreloads and retries on conflict, andRecastStoryboardgives each substitution its own row, so parallel SKUs never share a board. - R3. Rename collides. “Nova” inside “Novak”. Whole-word, case-insensitive
matching with a fixture;
RecastStoryboardreportssubstitutionsso the run log shows what was renamed. - R4. Hash drift between capability and node. If the prompt hash is
computed in two places they diverge and
only_stalere-renders everything. One function,planShotRenders, used by both; a test asserts the capability’sfilterStaleoutput equals the plan’sfreshset on the same fixture. - R5.
retargetSequenceon transforms with keyframes. Keyframed transforms must rescale every keyframe, not just the base. Covered by a fixture with an animated clip and the validator run on the output.