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
195 changes: 194 additions & 1 deletion client/src/components/fableloom/LoomNodeEditor.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ const rowToPatch = ({ targetNodeId, intent, triggersText, description }) => ({
});

export default function LoomNodeEditor({
loom, episode, node, onLoomUpdate, onClearSelection, onMakeStart,
loom, episode, node, universe, onLoomUpdate, onClearSelection, onMakeStart,
mediaJobs = {}, onGenerateImage, onGenerateVideo,
generationDisabled = false, generationDisabledReason = '',
}) {
Expand All @@ -70,6 +70,11 @@ export default function LoomNodeEditor({
imagePrompt: node.imagePrompt || '',
videoPrompt: node.videoPrompt || '',
cameraMovement: node.cameraMovement || '',
visualCanon: node.visualCanon ? {
...node.visualCanon,
characterAppearances: [...(node.visualCanon.characterAppearances || [])],
objectIds: [...(node.visualCanon.objectIds || [])],
} : null,
playbackMode: node.playbackMode || 'decision',
audienceConnection: node.audienceConnection || 'disconnected',
playbackAssets: node.playbackAssets || null,
Expand Down Expand Up @@ -107,6 +112,13 @@ export default function LoomNodeEditor({
return updated;
};

const saveVisualCanon = (next) => {
setForm((current) => ({ ...current, visualCanon: next }));
patchNode({ visualCanon: next });
};

const updateVisualCanon = (patch) => saveVisualCanon({ ...form.visualCanon, ...patch });

// Blur-save helper: skip the round-trip when the value matches the record
// (tabbing through the panel shouldn't rewrite the loom).
const saveField = (key, value) => {
Expand Down Expand Up @@ -474,6 +486,187 @@ export default function LoomNodeEditor({
/>
</div>

<div className="rounded border border-port-border p-3 space-y-3">
<label className="flex items-center gap-2 text-sm" htmlFor="loom-node-visual-canon">
<input
id="loom-node-visual-canon"
aria-label="Bind this shot to Universe canon"
type="checkbox"
checked={Boolean(form.visualCanon)}
onChange={(event) => saveVisualCanon(event.target.checked ? {
mode: 'locked', characterAppearances: [], placeId: null, objectIds: [],
continuitySourceNodeId: null, shotNotes: '', storyboardImageApproved: false,
} : null)}
/>
Bind this shot to Universe canon
</label>
{form.visualCanon && (
<div className="space-y-3">
<label className="flex items-center gap-2 text-xs" htmlFor="loom-node-canon-draft">
<input
id="loom-node-canon-draft"
aria-label="Allow degraded canon draft"
type="checkbox"
checked={form.visualCanon.mode === 'draft'}
onChange={(event) => updateVisualCanon({ mode: event.target.checked ? 'draft' : 'locked' })}
/>
Allow an explicitly degraded draft when this backend cannot preserve canon
</label>

<fieldset className="space-y-2">
<legend className={labelClass}>Characters</legend>
{(universe?.characters || []).map((character) => {
const appearance = form.visualCanon.characterAppearances
.find((item) => item.characterId === character.id);
return (
<div key={character.id} className="rounded bg-port-bg-subtle p-2 space-y-2">
<label className="flex items-center gap-2 text-xs" htmlFor={`loom-canon-character-${character.id}`}>
<input
id={`loom-canon-character-${character.id}`}
aria-label={character.name}
type="checkbox"
checked={Boolean(appearance)}
onChange={(event) => updateVisualCanon({
characterAppearances: event.target.checked
? [...form.visualCanon.characterAppearances, { characterId: character.id, wardrobeId: null, expression: '', continuityNotes: '' }]
: form.visualCanon.characterAppearances.filter((item) => item.characterId !== character.id),
})}
/>
{character.name}
</label>
{appearance && (character.wardrobes || []).length > 0 && (
<select
className={fieldClass}
aria-label={`${character.name} wardrobe`}
value={appearance.wardrobeId || ''}
onChange={(event) => updateVisualCanon({
characterAppearances: form.visualCanon.characterAppearances.map((item) => (
item.characterId === character.id ? { ...item, wardrobeId: event.target.value || null } : item
)),
})}
>
<option value="">Default wardrobe</option>
{character.wardrobes.map((wardrobe) => (
<option key={wardrobe.id} value={wardrobe.id}>{wardrobe.name || wardrobe.label || 'Wardrobe'}</option>
))}
</select>
)}
{appearance && (
<div className="grid gap-2 sm:grid-cols-2">
<input
className={fieldClass}
aria-label={`${character.name} expression`}
placeholder="Expression"
value={appearance.expression || ''}
onChange={(event) => setForm((current) => ({
...current,
visualCanon: {
...current.visualCanon,
characterAppearances: current.visualCanon.characterAppearances.map((item) => (
item.characterId === character.id ? { ...item, expression: event.target.value } : item
)),
},
}))}
onBlur={() => patchNode({ visualCanon: form.visualCanon })}
/>
<input
className={fieldClass}
aria-label={`${character.name} continuity notes`}
placeholder="Continuity notes"
value={appearance.continuityNotes || ''}
onChange={(event) => setForm((current) => ({
...current,
visualCanon: {
...current.visualCanon,
characterAppearances: current.visualCanon.characterAppearances.map((item) => (
item.characterId === character.id ? { ...item, continuityNotes: event.target.value } : item
)),
},
}))}
onBlur={() => patchNode({ visualCanon: form.visualCanon })}
/>
</div>
)}
</div>
);
})}
</fieldset>

<FormField label="Location" labelClassName={labelClass}>
<select
className={fieldClass}
value={form.visualCanon.placeId || ''}
onChange={(event) => updateVisualCanon({ placeId: event.target.value || null })}
>
<option value="">No bound location</option>
{(universe?.places || []).map((place) => (
<option key={place.id} value={place.id}>{place.name || place.slugline}</option>
))}
</select>
</FormField>

{(universe?.objects || []).length > 0 && (
<fieldset className="space-y-1">
<legend className={labelClass}>Props and objects</legend>
{universe.objects.map((object) => (
<label key={object.id} className="flex items-center gap-2 text-xs" htmlFor={`loom-canon-object-${object.id}`}>
<input
id={`loom-canon-object-${object.id}`}
aria-label={object.name}
type="checkbox"
checked={form.visualCanon.objectIds.includes(object.id)}
onChange={(event) => updateVisualCanon({
objectIds: event.target.checked
? [...form.visualCanon.objectIds, object.id]
: form.visualCanon.objectIds.filter((id) => id !== object.id),
})}
/>
{object.name}
</label>
))}
</fieldset>
)}

<FormField label="Continuity source" labelClassName={labelClass}>
<select
className={fieldClass}
value={form.visualCanon.continuitySourceNodeId || ''}
onChange={(event) => updateVisualCanon({ continuitySourceNodeId: event.target.value || null })}
>
<option value="">Automatic (only for one incoming scene)</option>
{otherNodes.filter((candidate) => candidate.transitions?.some((transition) => transition.targetNodeId === node.id))
.map((candidate) => <option key={candidate.id} value={candidate.id}>{candidate.title || 'Untitled scene'}</option>)}
</select>
</FormField>

<FormField label="Shot continuity notes" labelClassName={labelClass}>
<textarea
rows={2}
className={fieldClass}
value={form.visualCanon.shotNotes || ''}
onChange={(event) => setForm((current) => ({
...current, visualCanon: { ...current.visualCanon, shotNotes: event.target.value },
}))}
onBlur={() => patchNode({ visualCanon: form.visualCanon })}
/>
</FormField>

{node.image && (
<label className="flex items-center gap-2 text-xs" htmlFor="loom-node-storyboard-approved">
<input
id="loom-node-storyboard-approved"
aria-label="Approve storyboard image for video"
type="checkbox"
checked={form.visualCanon.storyboardImageApproved === true}
onChange={(event) => updateVisualCanon({ storyboardImageApproved: event.target.checked })}
/>
Approve the current storyboard image as this shot's video first frame
</label>
)}
</div>
)}
</div>

<div>
<span className="mb-1 block text-xs font-medium text-port-text-muted">Scene image prompt</span>
<textarea
Expand Down
28 changes: 28 additions & 0 deletions client/src/components/fableloom/LoomNodeEditor.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ const renderEditor = (transitions = [existingPath]) => {
loom={loom}
episode={episode}
node={nodes[0]}
universe={{
characters: [{ id: 'char-1', name: 'Aria', wardrobes: [{ id: 'coat', name: 'Travel coat' }] }],
places: [{ id: 'place-1', name: 'Atrium' }],
objects: [{ id: 'object-1', name: 'Compass' }],
}}
onLoomUpdate={onLoomUpdate}
onClearSelection={() => {}}
onGenerateImage={onGenerateImage}
Expand Down Expand Up @@ -134,6 +139,29 @@ describe('LoomNodeEditor paths', () => {
});

describe('LoomNodeEditor scene media', () => {
it('persists structured canon bindings and explicit storyboard approval', async () => {
const user = userEvent.setup();
updateLoomNode.mockResolvedValue({ id: 'loom-1' });
renderEditor();

await user.click(screen.getByLabelText('Bind this shot to Universe canon'));
await user.click(screen.getByLabelText('Aria'));
await user.selectOptions(screen.getByLabelText('Aria wardrobe'), 'coat');
await user.selectOptions(screen.getByLabelText('Location'), 'place-1');
await user.click(screen.getByLabelText('Compass'));
await user.click(screen.getByLabelText("Approve the current storyboard image as this shot's video first frame"));

await waitFor(() => expect(updateLoomNode).toHaveBeenLastCalledWith(
'loom-1', 'ep-1', 'n1',
{ visualCanon: expect.objectContaining({
mode: 'locked',
characterAppearances: [expect.objectContaining({ characterId: 'char-1', wardrobeId: 'coat' })],
placeId: 'place-1', objectIds: ['object-1'], storyboardImageApproved: true,
}) },
{ silent: true },
));
});

it('queues a local video from the teleplay scene and rendered still', async () => {
const user = userEvent.setup();
const { onGenerateVideo } = renderEditor();
Expand Down
35 changes: 4 additions & 31 deletions client/src/components/fableloom/sceneMediaRequests.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
* 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.
* The server-side visual-canon compiler owns reference allocation and graph
* continuity. These builders send authored intent plus the destination tag;
* they never pre-flatten canon into an untyped browser reference list.
*/

import { composeStyledPrompt } from '../../lib/composeStyledPrompt';
Expand All @@ -17,40 +18,12 @@ const withLoomStyle = (prompt, styleNotes) => {
return notes ? `${prompt}\n\nStyle: ${notes}` : prompt;
};

// Keep enough reference influence to carry likeness and environment forward
// without asking the model to preserve the prior shot's composition.
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 }) {
export function buildFableLoomImageRequest({ loom, 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 ? {
referenceImageFiles: [priorImage],
referenceStrengths: [FABLELOOM_CONTINUITY_STRENGTH],
} : {}),
fableLoom: { loomId: loom.id, episodeId, nodeId: node.id },
};
}
Expand Down
10 changes: 4 additions & 6 deletions client/src/components/fableloom/sceneMediaRequests.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ describe('FableLoom scene media request composition', () => {
});
});

it('conditions an image on its rendered direct predecessor, not an unrelated adjacent scene', () => {
it('leaves typed continuity allocation to the server-side compiler', () => {
const target = { id: 'node-3', imagePrompt: 'the scout enters a crystal observatory' };
const episode = {
nodes: [
Expand All @@ -31,11 +31,9 @@ describe('FableLoom scene media request composition', () => {
],
};

expect(buildFableLoomImageRequest({ loom, episode, episodeId: 'ep-1', node: target }))
.toMatchObject({
referenceImageFiles: ['prior-shot.png'],
referenceStrengths: [0.4],
});
const request = buildFableLoomImageRequest({ loom, episode, episodeId: 'ep-1', node: target });
expect(request).not.toHaveProperty('referenceImageFiles');
expect(request.fableLoom).toEqual({ loomId: 'loom-1', episodeId: 'ep-1', nodeId: 'node-3' });
});

it('keeps an opening scene text-to-image when a loop points back to it', () => {
Expand Down
Loading