From a383b64270f3eabe94d608e672564c5072fb3d28 Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Sat, 29 Aug 2026 17:26:07 +0000 Subject: [PATCH] feat(fableloom): bind scenes to Universe canon and compile capability-budgeted render requests (#5379) Scene nodes gain structured `visualCanon` bindings for characters, wardrobes, locations, objects, temporal sources, and storyboard approval. Image and video requests compile against explicit per-backend capability manifests with deterministic input budgets, compatible local character adapters, and path-free durable provenance, so a render can be reproduced from the record alone. Canon-locked output is blocked when required conditioning cannot be preserved; draft/degraded rendering stays available and records what was omitted. Bumps `SCHEMA_VERSIONS.fableLoom` to 2 and retains the new node fields when an older v1 peer wins whole-record LWW, so a pre-upgrade peer cannot strip them. Closes #5379 --- .../components/fableloom/LoomNodeEditor.jsx | 195 ++++++++- .../fableloom/LoomNodeEditor.test.jsx | 28 ++ .../fableloom/sceneMediaRequests.js | 35 +- .../fableloom/sceneMediaRequests.test.js | 10 +- client/src/pages/FableLoomStory.jsx | 31 +- client/src/pages/FableLoomStory.test.jsx | 23 +- docs/features/fableloom.md | 40 +- server/lib/apiRouteCatalog.generated.json | 110 +++--- server/lib/fableLoomLimits.js | 4 + server/lib/fableLoomValidation.js | 16 + server/lib/schemaVersions.js | 5 +- server/lib/socketEventCatalog.generated.json | 6 +- server/routes/imageGen.js | 42 +- server/routes/imageGen.test.js | 22 +- server/routes/videoGen.js | 29 ++ server/routes/videoGen.test.js | 52 ++- server/services/characterLoraResolver.js | 23 +- server/services/characterLoraResolver.test.js | 13 + server/services/fableLoom/README.md | 1 + server/services/fableLoom/index.js | 6 + server/services/fableLoom/records.js | 152 +++++++- server/services/fableLoom/records.test.js | 57 +++ .../services/fableLoom/visualConditioning.js | 369 ++++++++++++++++++ .../fableLoom/visualConditioning.test.js | 210 ++++++++++ server/services/fableLoom/weave.js | 1 + server/services/fableLoomSceneImageHook.js | 6 +- server/services/fableLoomSceneVideoHook.js | 7 +- server/services/imageGen/agy.js | 2 + server/services/imageGen/codex.js | 2 + server/services/imageGen/grok.js | 2 + server/services/imageGen/local.js | 6 +- server/services/imageGen/local.test.js | 8 + server/services/sharing/peerSync.test.js | 2 +- server/services/sharing/peerSyncReceive.js | 2 +- server/services/videoGen/generateVideo.js | 3 +- server/services/videoGen/prepareParams.js | 23 +- .../services/videoGen/prepareParams.test.js | 10 +- 37 files changed, 1368 insertions(+), 185 deletions(-) create mode 100644 server/services/fableLoom/visualConditioning.js create mode 100644 server/services/fableLoom/visualConditioning.test.js diff --git a/client/src/components/fableloom/LoomNodeEditor.jsx b/client/src/components/fableloom/LoomNodeEditor.jsx index 37fdd2d293..9cc406a58a 100644 --- a/client/src/components/fableloom/LoomNodeEditor.jsx +++ b/client/src/components/fableloom/LoomNodeEditor.jsx @@ -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 = '', }) { @@ -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, @@ -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) => { @@ -474,6 +486,187 @@ export default function LoomNodeEditor({ /> +
+ + {form.visualCanon && ( +
+ + +
+ Characters + {(universe?.characters || []).map((character) => { + const appearance = form.visualCanon.characterAppearances + .find((item) => item.characterId === character.id); + return ( +
+ + {appearance && (character.wardrobes || []).length > 0 && ( + + )} + {appearance && ( +
+ 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 })} + /> + 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 })} + /> +
+ )} +
+ ); + })} +
+ + + + + + {(universe?.objects || []).length > 0 && ( +
+ Props and objects + {universe.objects.map((object) => ( + + ))} +
+ )} + + + + + + +