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.