diff --git a/client/src/components/fableloom/sceneMediaRequests.js b/client/src/components/fableloom/sceneMediaRequests.js index 39279438ee..4a40d42be3 100644 --- a/client/src/components/fableloom/sceneMediaRequests.js +++ b/client/src/components/fableloom/sceneMediaRequests.js @@ -5,6 +5,8 @@ * page owner. Keeping request composition here makes their image/video prompts * identical: the canonical universe/series style preset leads, the scene owns * the subject/action, and loom-local direction remains an explicit suffix. + * Image generation also conditions on a rendered direct predecessor when one + * exists, preserving visual continuity across adjacent graph shots. */ import { composeStyledPrompt } from '../../lib/composeStyledPrompt'; @@ -15,12 +17,40 @@ const withLoomStyle = (prompt, styleNotes) => { return notes ? `${prompt}\n\nStyle: ${notes}` : prompt; }; -export function buildFableLoomImageRequest({ loom, episodeId, node, stylePreset = null }) { +// Match Image Gen's established i2i default: strong enough to carry likeness +// and environment forward without pinning the next shot to the same pose. +export const FABLELOOM_CONTINUITY_STRENGTH = 0.4; + +/** + * Resolve the still from a direct incoming graph neighbor. Storage order is + * the deterministic tie-break at a convergence because a shared target node + * has no active reader path while it is being authored. Self-loops never seed + * themselves, and unrelated adjacent array entries are not "prior" shots. + */ +export function findFableLoomPriorImage(episode, nodeId) { + const nodes = Array.isArray(episode?.nodes) ? episode.nodes : []; + if (!nodeId || episode?.startNodeId === nodeId) return null; + const predecessor = nodes.find((candidate) => ( + candidate?.id !== nodeId + && typeof candidate?.image === 'string' + && candidate.image.trim() + && Array.isArray(candidate.transitions) + && candidate.transitions.some((transition) => transition?.targetNodeId === nodeId) + )); + return predecessor?.image.trim() || null; +} + +export function buildFableLoomImageRequest({ loom, episode, episodeId, node, stylePreset = null }) { const authoredPrompt = withLoomStyle((node?.imagePrompt || '').trim(), loom?.styleNotes); const styled = composeStyledPrompt(authoredPrompt, '', stylePreset); + const priorImage = findFableLoomPriorImage(episode, node?.id); return { prompt: styled.prompt, ...(styled.negativePrompt ? { negativePrompt: styled.negativePrompt } : {}), + ...(priorImage ? { + initImageFile: priorImage, + initImageStrength: FABLELOOM_CONTINUITY_STRENGTH, + } : {}), fableLoom: { loomId: loom.id, episodeId, nodeId: node.id }, }; } diff --git a/client/src/components/fableloom/sceneMediaRequests.test.js b/client/src/components/fableloom/sceneMediaRequests.test.js index 8a3c1ed85d..477e0b2625 100644 --- a/client/src/components/fableloom/sceneMediaRequests.test.js +++ b/client/src/components/fableloom/sceneMediaRequests.test.js @@ -17,6 +17,45 @@ describe('FableLoom scene media request composition', () => { }); }); + it('conditions an image on its rendered direct predecessor, not an unrelated adjacent scene', () => { + const target = { id: 'node-3', imagePrompt: 'the scout enters a crystal observatory' }; + const episode = { + nodes: [ + { id: 'node-unrelated', image: 'nearby.png', transitions: [] }, + { + id: 'node-1', + image: 'prior-shot.png', + transitions: [{ id: 'tr-1', targetNodeId: target.id }], + }, + target, + ], + }; + + expect(buildFableLoomImageRequest({ loom, episode, episodeId: 'ep-1', node: target })) + .toMatchObject({ + initImageFile: 'prior-shot.png', + initImageStrength: 0.4, + }); + }); + + it('keeps an opening scene text-to-image when a loop points back to it', () => { + const opening = { id: 'node-1', imagePrompt: 'the opening shot', transitions: [] }; + const episode = { + startNodeId: opening.id, + nodes: [ + opening, + { + id: 'node-ending', + image: 'finale.png', + transitions: [{ id: 'tr-loop', targetNodeId: opening.id }], + }, + ], + }; + + expect(buildFableLoomImageRequest({ loom, episode, episodeId: 'ep-1', node: opening })) + .not.toHaveProperty('initImageFile'); + }); + it('builds image-to-video direction from the shared camera vocabulary', () => { expect(buildFableLoomVideoRequest({ loom, diff --git a/client/src/pages/FableLoomStory.jsx b/client/src/pages/FableLoomStory.jsx index 0b76703d49..4ba7ba4f28 100644 --- a/client/src/pages/FableLoomStory.jsx +++ b/client/src/pages/FableLoomStory.jsx @@ -45,6 +45,11 @@ import { weaveLoomEpisode, } from '../services/api'; +const CONTINUITY_FALLBACK_CODES = new Set([ + 'IMAGE_EDIT_UNSUPPORTED_MODE', + 'INIT_IMAGE_NOT_FOUND', +]); + export default function FableLoomStory({ view = 'graph' }) { const { loomId, episodeId, nodeId } = useParams(); const navigate = useNavigate(); @@ -221,16 +226,33 @@ export default function FableLoomStory({ view = 'graph' }) { } setSceneMediaJob(targetNode.id, 'image', { jobId: null, status: 'submitting', progress: 0 }); - const queued = await generateImage(buildFableLoomImageRequest({ - loom, episodeId, node: targetNode, stylePreset: sceneStylePreset, - }), { silent: true }).catch((err) => { - setSceneMediaJob(targetNode.id, 'image', { - jobId: null, status: 'failed', progress: 0, error: err.message || 'Could not start the render', - }); - toast.error(`Could not start scene image: ${err.message || 'Render request failed'}`); - return null; + const imageRequest = (includeContinuity) => buildFableLoomImageRequest({ + loom, + episode: includeContinuity ? episode : null, + episodeId, + node: targetNode, + stylePreset: sceneStylePreset, }); + let continuityFallbackCode = null; + const queued = await generateImage(imageRequest(true), { silent: true }) + .catch((err) => { + if (!CONTINUITY_FALLBACK_CODES.has(err.code)) throw err; + continuityFallbackCode = err.code; + return generateImage(imageRequest(false), { silent: true }); + }) + .catch((err) => { + setSceneMediaJob(targetNode.id, 'image', { + jobId: null, status: 'failed', progress: 0, error: err.message || 'Could not start the render', + }); + toast.error(`Could not start scene image: ${err.message || 'Render request failed'}`); + return null; + }); if (!queued) return null; + if (continuityFallbackCode) { + toast.warning(continuityFallbackCode === 'INIT_IMAGE_NOT_FOUND' + ? 'The prior shot image is missing — rendering this scene without continuity conditioning' + : 'The current image backend cannot use the prior shot — rendering this scene without continuity conditioning'); + } // External SD-API renders synchronously: its generationId identifies the // completed request, not a media-job record. The server has already filed // the image onto the scene, so swap the preview immediately and do not @@ -259,7 +281,7 @@ export default function FableLoomStory({ view = 'graph' }) { }); toast.success('Scene image queued'); return queued; - }, [applySceneMedia, episodeId, generationDisabledReason, loom, sceneStylePreset, setSceneMediaJob, styleContextLoading, styleContextUnavailable]); + }, [applySceneMedia, episode, episodeId, generationDisabledReason, loom, sceneStylePreset, setSceneMediaJob, styleContextLoading, styleContextUnavailable]); const queueSceneVideo = useCallback(async (targetNode) => { const prompt = (targetNode?.videoPrompt || '').trim() || (targetNode?.prose || '').trim(); diff --git a/client/src/pages/FableLoomStory.test.jsx b/client/src/pages/FableLoomStory.test.jsx index 70c038318e..70e8df125a 100644 --- a/client/src/pages/FableLoomStory.test.jsx +++ b/client/src/pages/FableLoomStory.test.jsx @@ -3,7 +3,7 @@ import { render, screen, waitFor } from '@testing-library/react'; import { MemoryRouter, Route, Routes } from 'react-router'; import userEvent from '@testing-library/user-event'; -const toastMocks = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn() })); +const toastMocks = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn(), warning: vi.fn() })); vi.mock('../components/ui/Toast', () => ({ default: toastMocks })); vi.mock('../services/api', () => ({ @@ -33,6 +33,15 @@ vi.mock('../components/fableloom/LoomCanvas', () => ({ > Canvas generate image + {episode.nodes[1] && ( + + )} {mediaJobs[episode.nodes[0].id]?.image?.status || 'idle'} {episode.nodes[0].image || 'none'} @@ -254,4 +263,76 @@ describe('FableLoomStory scene media lifecycle', () => { expect(screen.getByTestId('canvas-image-status')).toHaveTextContent('idle'); expect(toastMocks.success).toHaveBeenCalledWith('Scene image ready'); }); + + it('passes the rendered incoming shot into the next scene image request', async () => { + const user = userEvent.setup(); + api.getLoom.mockResolvedValue(loom({ + episodes: [episode({ + nodes: [ + { + id: 'node-1', + title: 'Threshold', + imagePrompt: 'an ancient gate', + image: 'threshold.png', + transitions: [{ id: 'tr-1', targetNodeId: 'node-2', intent: 'Continue' }], + }, + { + id: 'node-2', + title: 'Beyond', + imagePrompt: 'the same scout crosses into the observatory', + transitions: [], + }, + ], + })], + })); + api.generateImage.mockResolvedValue({ jobId: 'image-job-2', status: 'queued' }); + renderEditor(); + + await user.click(await screen.findByRole('button', { name: 'Canvas generate second image' })); + + await waitFor(() => expect(api.generateImage).toHaveBeenCalledWith({ + prompt: 'the same scout crosses into the observatory', + initImageFile: 'threshold.png', + initImageStrength: 0.4, + fableLoom: { loomId: 'loom-1', episodeId: 'ep-1', nodeId: 'node-2' }, + }, { silent: true })); + }); + + it('retries without continuity when the configured backend cannot accept the prior shot', async () => { + const user = userEvent.setup(); + api.getLoom.mockResolvedValue(loom({ + episodes: [episode({ + nodes: [ + { + id: 'node-1', + image: 'threshold.png', + transitions: [{ id: 'tr-1', targetNodeId: 'node-2', intent: 'Continue' }], + }, + { + id: 'node-2', + imagePrompt: 'the scout enters the observatory', + transitions: [], + }, + ], + })], + })); + api.generateImage + .mockRejectedValueOnce(Object.assign(new Error('Text-to-image only'), { + code: 'IMAGE_EDIT_UNSUPPORTED_MODE', + })) + .mockResolvedValueOnce({ jobId: 'image-job-fallback', status: 'queued' }); + renderEditor(); + + await user.click(await screen.findByRole('button', { name: 'Canvas generate second image' })); + + await waitFor(() => expect(api.generateImage).toHaveBeenCalledTimes(2)); + expect(api.generateImage.mock.calls[0][0]).toMatchObject({ initImageFile: 'threshold.png' }); + expect(api.generateImage.mock.calls[1]).toEqual([{ + prompt: 'the scout enters the observatory', + fableLoom: { loomId: 'loom-1', episodeId: 'ep-1', nodeId: 'node-2' }, + }, { silent: true }]); + expect(toastMocks.warning).toHaveBeenCalledWith( + 'The current image backend cannot use the prior shot — rendering this scene without continuity conditioning', + ); + }); }); diff --git a/docs/features/fableloom.md b/docs/features/fableloom.md index a978f63968..262a07cb86 100644 --- a/docs/features/fableloom.md +++ b/docs/features/fableloom.md @@ -161,7 +161,19 @@ nodeId }` destination tag. The completion hook (`server/services/fableLoomSceneImageHook.js`) files the finished render onto the node durably — even if the editor unmounted mid-render — with newest-render-wins per node. The loom's `styleNotes` are appended to the -prompt for a consistent look. +prompt for a consistent look. When a direct incoming scene already has a +rendered still, its gallery filename is also sent as the next scene's init +image at `0.4` strength. Graph edges, not node-array adjacency, define which +shot is prior; at a convergence the first rendered incoming scene in stable +episode order is the deterministic authoring-time source because there is no +active reader path yet. Opening scenes and scenes without a rendered incoming +neighbor remain text-to-image. + +Continuity conditioning is best-effort for the current stopgap: if the active +backend is text-to-image-only, or the predecessor's gallery file has since been +removed, the editor warns and retries the scene without the init image rather +than blocking production. Canon-locked generation will replace that fallback +with an explicit capability gate in the planned typed-reference workflow. **Generate video** prefers the node's dedicated single-clip `videoPrompt`, adds the selected movement's production direction from the shared camera registry, @@ -175,6 +187,11 @@ otherwise the render is text-to-video. The completion hook Decision videos are authored as seamless loops; automatic-cut videos land on a final beat that hands cleanly to the next node. +The broader character/environment canon-reference design, including structured +scene bindings, provider input budgets, prompt compilation, provenance, and +branch convergence, is specified in +[`docs/plans/2026-08-29-fableloom-visual-continuity.md`](../plans/2026-08-29-fableloom-visual-continuity.md). + ## Storage `fableloom_stories` (db-primary; one row per loom, full record in `data` diff --git a/docs/plans/2026-08-29-fableloom-visual-continuity.md b/docs/plans/2026-08-29-fableloom-visual-continuity.md new file mode 100644 index 0000000000..000c787dc9 --- /dev/null +++ b/docs/plans/2026-08-29-fableloom-visual-continuity.md @@ -0,0 +1,304 @@ +# FableLoom visual continuity and canon references + +## Status and scope + +This is the decision-complete design for keeping characters, environments, +objects, and style visually consistent across FableLoom storyboard stills and +video clips. The first increment is implemented: when a rendered direct graph +predecessor exists, its still conditions the next image generation. That is a +useful temporal bridge, but it is not a substitute for durable visual canon. + +The design reuses the Universe's existing canon records and assets. It does not +create a second FableLoom-only character or location registry, train models, or +attempt automatic face recognition. + +## Why the prior-shot bridge is insufficient + +A previous shot can carry palette, lighting, costume, and likeness into the +next render, but it also carries accidental composition and only depicts the +entities visible in that one frame. It cannot establish a character who enters +later, recover an occluded costume detail, describe an environment after a +branch jump, or choose the correct source at a converging node. Repeated +image-to-image generations also accumulate drift. + +Visual continuity therefore has three distinct inputs: + +1. **World style** — the Universe's embrace/avoid influences, style notes, and + selected style references. +2. **Entity canon** — stable Character, Place, and Object records plus their + pinned reference assets. +3. **Temporal continuity** — the immediately preceding shot on the path being + rendered, used for state that canon does not encode: current pose, damage, + carried props, weather in progress, and lighting continuity. + +Prompts and attached images must identify these roles separately. Treating all +attachments as an unordered reference pile makes it impossible to explain a +provider result or degrade honestly when a backend has fewer image slots. + +## Existing sources of truth + +Universe already owns the right durable primitives: + +- `characters[]`, `places[]`, and `objects[]` are stable, id-addressed canon. +- Every canon entry has `imageRefs[]` and may pin one `primaryImageRef`. +- Characters may have variant reference sheets and structured wardrobes, + palette, silhouette, posture, traits, props, expressions, and gestures. +- Places carry description, palette, era, weather, INT/EXT, time of day, and + recurring details; their generated references may include clean plates. +- `styleReferences[]`, `styleImageRefs[]`, influences, and `styleNotes` define + the visual language independently of scene content. +- `richCanonDescriptorFragments` and the scene-prompt matchers already define + the shared field order and legacy name/alias matching behavior. + +FableLoom should reference those ids and filenames; it must not copy canon prose +or image bytes into a loom record. Updating Universe canon must affect the next +render without rewriting every linked scene. + +## Scene binding model + +Add an optional `visualCanon` object to each scene node: + +```json +{ + "visualCanon": { + "characterAppearances": [ + { + "characterId": "char-example", + "wardrobeId": "wardrobe-field", + "expression": "guarded", + "continuityNotes": "mud on the left sleeve" + } + ], + "placeId": "place-observatory", + "objectIds": ["object-brass-key"], + "continuitySourceNodeId": "node-prior", + "shotNotes": "the storm has intensified; preserve the broken east window" + } +} +``` + +All keys are optional and bounded. IDs are soft references because Universe +canon is independently editable; a missing id produces an explicit degraded +binding rather than invalidating the story graph. `continuitySourceNodeId` is +an author override for convergence, loops, and non-linear production order. If +it is absent, an unambiguous direct predecessor is used. Multiple predecessors +without an override are reported as ambiguous; the temporary first-in-episode +fallback remains only for pre-binding scenes. + +Generation stages should return these structured bindings alongside prose, +`imagePrompt`, and `videoPrompt`. For legacy or manually authored nodes with no +bindings, the compiler may infer candidates with the existing exact +name/alias/place/object matchers. Inferred matches are request-local and marked +as inferred; they are never silently persisted as author decisions. + +## Canon asset roles and selection + +The compiler resolves assets in this order within each role: + +| Role | Selection | +|---|---| +| Character identity | Pinned `primaryImageRef`; otherwise newest valid `imageRefs[]` entry | +| Character structure | Requested reference-sheet variant; otherwise the standard sheet when available | +| Character wardrobe | The bound wardrobe's future pinned reference, then identity reference plus wardrobe text | +| Environment | Pinned place `primaryImageRef`; otherwise newest valid place reference; prefer a clean plate once asset roles are persisted | +| Object | Pinned object `primaryImageRef`; otherwise newest valid object reference | +| Style | User-selected `styleReferences[]`, then a style probe when explicitly enabled for FableLoom | +| Temporal | Explicit `continuitySourceNodeId`, then the unambiguous direct predecessor still | + +Missing files are different from absent references. Resolution returns +`ready`, `missing`, `ambiguous`, or `unsupported` for every requested role so +the UI and job provenance never represent a failed lookup as an authoritative +empty reference set. + +Character sheets live in a managed reference root, while canon references and +today's clean plates are gallery images without a persisted role distinction. +Phase 2 therefore adds typed server-resolved asset references and stamps the +clean-plate role at generation time; it does not infer the role from a prompt or +filename. The image-generation route should accept those typed references +rather than exposing filesystem paths or forcing the browser to download and +re-upload them. Resolution remains server-side and constrained to approved +media roots. Federated renders reuse the existing conditioning-asset gate and +transfer only the allowlisted resolved inputs, never Universe records or +unrelated canon. + +## Prompt compiler + +Create one server-side `compileFableLoomVisualRequest` workflow used by both +image and video generation. The client submits loom/episode/node identity and +explicit per-render overrides; the server loads the current loom and Universe, +resolves bindings and assets, and returns the final prompt plus a conditioning +manifest. Centralizing compilation prevents a stale open browser from sending +old canon and gives API, voice, and future batch generation identical results. + +The positive prompt is assembled in this order: + +1. **Visual language** — Universe embrace influences and style notes. +2. **Environment lock** — place name, INT/EXT, time of day, then rich Place + descriptor fragments in their shared precedence order. +3. **Character locks** — one named block per bound appearance: physical and + visual identity, silhouette, posture, traits, palette, then wardrobe and + per-shot expression/state overlays. +4. **Object locks** — identity/significance only for objects visible in this + shot. +5. **Shot content** — the authored `imagePrompt` or `videoPrompt`: action, + staging, framing, lens, and composition. This remains the primary statement + of what is new in the shot. +6. **Temporal invariants** — concise changes and non-changes relative to the + predecessor: carry forward costume, damage, prop ownership, environment + state, and lighting unless the shot explicitly changes them. +7. **Camera direction** — shared FableLoom movement vocabulary for video. + +The negative prompt starts with the authored avoid list and Universe avoid +influences, then adds only relevant anti-drift clauses such as duplicate +characters, changed costume colors, altered signature props, or a populated +clean plate. It must not dump narrative secrets, motivations, relationships, +or unrevealed facts into a visual-provider prompt. Only visual canon fields and +the scene's already-visible continuity state are eligible. + +Prompt budgeting drops detail in reverse importance: optional style prose, +secondary objects, character detail beyond identity anchors, then environment +atmospherics. It never truncates ids, entity names, the shot content, or the +selected wardrobe/state overlay. The compiled request records which fragments +were omitted. + +## Provider capability and attachment budget + +Backends have different input-image limits and roles. Extend the image/video +capability contract to expose total input slots and support for `init`, +`reference`, and video-first-frame conditioning. Allocate slots +deterministically: + +1. Temporal predecessor when the shot explicitly continues it. +2. Every on-screen character's identity reference, in authored appearance + order. +3. The environment reference. +4. Signature objects. +5. Style references. + +An attachment is never silently dropped. If the full manifest does not fit, +the compiler retains the highest-priority assets, keeps the corresponding text +canon, and reports a degraded result naming omitted roles. The editor shows the +effective set before generation (for example, “Previous shot + 2 characters + +environment; 1 style reference omitted”). A backend that cannot accept the +required temporal/character inputs blocks a canon-locked render rather than +quietly producing an unconditioned image. Authors may explicitly choose a +text-only draft, which is labeled as such in the job and preview. + +For the temporary prior-shot path, the predecessor is an init image at strength +`0.4`, matching the existing Image Gen and Universe reference-render default. +Once typed multi-reference inputs exist, temporal continuity becomes a distinct +role and provider adapters decide whether it maps to init/edit or reference +conditioning without changing FableLoom semantics. + +## Branches, convergence, and production order + +A graph edge defines temporal adjacency. Array position never does. A node with +one incoming edge inherits that predecessor; an opening node inherits none. A +node with multiple incoming edges needs one of: + +- an explicit `continuitySourceNodeId` for a shared canonical render; +- path-specific render variants keyed by incoming transition; or +- an intentional continuity reset, which uses canon only. + +The first implementation supports the shared canonical render. Path-specific +variants are a later extension because they affect playback asset selection, +job destination tags, storage caps, and editing UI. Loops never use a node's own +still as its automatic predecessor, though an author may explicitly select a +different prior loop node. + +Batch generation follows graph topology and waits for each selected temporal +source to finish before queueing its dependent. Independent branches may render +in parallel. A failed predecessor blocks only descendants that require it; +canon-only branches can continue. + +## Provenance and observability + +Persist a versioned `visualConditioning` manifest in image sidecars and video +history: + +```json +{ + "version": 1, + "universeId": "universe-example", + "bindings": { + "characterIds": ["char-example"], + "placeId": "place-observatory", + "objectIds": [] + }, + "assets": [ + { "role": "character", "entryId": "char-example", "filename": "character.png" }, + { "role": "temporal", "nodeId": "node-prior", "filename": "prior-shot.png" } + ], + "omitted": [], + "promptCompilerVersion": 1 +} +``` + +The editor exposes this as a compact “Continuity” summary on each scene and a +details view containing resolved/missing/omitted references and the compiled +prompt. Regeneration reads current canon by default; “repeat exact inputs” uses +the recorded manifest and refuses if an asset has been removed. This keeps +“latest canon” and reproducibility explicit rather than conflated. + +## Delivery phases + +### Phase 0 — predecessor bridge + +- Condition a scene image on the first rendered direct predecessor. +- Use graph edges, exclude self-loops, and leave openings text-to-image. +- Warn and retry text-to-image when the backend cannot edit or the predecessor + file is stale, preserving the existing render path until typed capability + checks land. +- Cover request composition and the editor-to-API boundary. + +### Phase 1 — structured visual bindings and compiler + +- Add backward-compatible `visualCanon` sanitization, validation, and prompt- + stage schemas; preserve absent fields on old records. +- Add the server compiler using shared canon descriptor and matcher helpers. +- Update weave/branch/feedback prompt templates and preserve previous defaults + through the required prompt migration/version mechanism. +- Expose binding editors beside the scene's image/video prompts. + +### Phase 2 — typed assets and capability-aware generation + +- Add server-resolved typed asset references for gallery, reference-sheet, and + clean-plate roots. +- Extend backend capability payloads with role and slot support. +- Add effective/degraded conditioning preview and blocking rules. +- Persist the versioned conditioning manifest for images and videos. + +### Phase 3 — production workflows + +- Add topological “generate missing storyboards/videos” with dependency-aware + queueing. +- Add an explicit convergence-source/reset control. +- Add optional path-specific render variants only after playback and storage + contracts are specified and migrated. + +### Phase 4 — continuity review + +- Add a user-triggered visual review comparing generated shots with their + bound canon and predecessor; no boot-time or unrequested provider calls. +- Report suspected drift as review findings with source images and fields, not + automatic canon mutations. +- Use accepted corrections to update pinned Universe references or scene-local + state only through explicit author actions. + +## Acceptance criteria + +- Two adjacent rendered nodes send the predecessor still into the dependent + image request; unrelated nodes do not. +- Every generated shot can state which Universe entities, text fragments, and + image assets conditioned it, including omissions. +- Editing linked Universe visual canon changes the next render without copying + data into FableLoom. +- Missing canon or files are visibly degraded, never collapsed into an empty + success state. +- Provider limits never cause silent reference loss. +- Branch convergence is deterministic and author-overridable. +- Image and video generation use the same scene bindings and prompt compiler; + video additionally receives camera direction and the generated scene still + as its first frame. +- Existing looms with no `visualCanon` continue to load, edit, play, and render. +- No provider call occurs at boot or solely because a loom was opened.