Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion client/src/components/fableloom/sceneMediaRequests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 },
};
}
Expand Down
39 changes: 39 additions & 0 deletions client/src/components/fableloom/sceneMediaRequests.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
40 changes: 31 additions & 9 deletions client/src/pages/FableLoomStory.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down
83 changes: 82 additions & 1 deletion client/src/pages/FableLoomStory.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down Expand Up @@ -33,6 +33,15 @@ vi.mock('../components/fableloom/LoomCanvas', () => ({
>
Canvas generate image
</button>
{episode.nodes[1] && (
<button
type="button"
disabled={generationDisabled}
onClick={() => onGenerateImage(episode.nodes[1])}
>
Canvas generate second image
</button>
)}
<span data-testid="canvas-image-status">{mediaJobs[episode.nodes[0].id]?.image?.status || 'idle'}</span>
<span data-testid="canvas-image-filename">{episode.nodes[0].image || 'none'}</span>
</div>
Expand Down Expand Up @@ -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',
);
});
});
19 changes: 18 additions & 1 deletion docs/features/fableloom.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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`
Expand Down
Loading