Story review
diff --git a/server/lib/apiRouteCatalog.generated.json b/server/lib/apiRouteCatalog.generated.json
index db406dacdb..896f8e9459 100644
--- a/server/lib/apiRouteCatalog.generated.json
+++ b/server/lib/apiRouteCatalog.generated.json
@@ -8724,7 +8724,7 @@
"sources": [
{
"source": "server/routes/fableLoom.js",
- "line": 68
+ "line": 73
}
]
},
@@ -8735,7 +8735,7 @@
"sources": [
{
"source": "server/routes/fableLoom.js",
- "line": 75
+ "line": 80
}
]
},
@@ -8746,7 +8746,7 @@
"sources": [
{
"source": "server/routes/fableLoom.js",
- "line": 91
+ "line": 96
}
]
},
@@ -8757,7 +8757,7 @@
"sources": [
{
"source": "server/routes/fableLoom.js",
- "line": 80
+ "line": 85
}
]
},
@@ -8768,7 +8768,7 @@
"sources": [
{
"source": "server/routes/fableLoom.js",
- "line": 86
+ "line": 91
}
]
},
@@ -8779,7 +8779,7 @@
"sources": [
{
"source": "server/routes/fableLoom.js",
- "line": 115
+ "line": 120
}
]
},
@@ -8790,7 +8790,7 @@
"sources": [
{
"source": "server/routes/fableLoom.js",
- "line": 125
+ "line": 130
}
]
},
@@ -8801,7 +8801,7 @@
"sources": [
{
"source": "server/routes/fableLoom.js",
- "line": 120
+ "line": 125
}
]
},
@@ -8812,7 +8812,7 @@
"sources": [
{
"source": "server/routes/fableLoom.js",
- "line": 203
+ "line": 223
}
]
},
@@ -8823,7 +8823,7 @@
"sources": [
{
"source": "server/routes/fableLoom.js",
- "line": 142
+ "line": 162
}
]
},
@@ -8834,7 +8834,7 @@
"sources": [
{
"source": "server/routes/fableLoom.js",
- "line": 152
+ "line": 172
}
]
},
@@ -8845,7 +8845,7 @@
"sources": [
{
"source": "server/routes/fableLoom.js",
- "line": 147
+ "line": 167
}
]
},
@@ -8856,7 +8856,18 @@
"sources": [
{
"source": "server/routes/fableLoom.js",
- "line": 190
+ "line": 210
+ }
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/fableloom/:id/episodes/:episodeId/nodes/:nodeId/readiness",
+ "mountPath": "/api/fableloom",
+ "sources": [
+ {
+ "source": "server/routes/fableLoom.js",
+ "line": 151
}
]
},
@@ -8867,7 +8878,7 @@
"sources": [
{
"source": "server/routes/fableLoom.js",
- "line": 165
+ "line": 185
}
]
},
@@ -8878,7 +8889,7 @@
"sources": [
{
"source": "server/routes/fableLoom.js",
- "line": 177
+ "line": 197
}
]
},
@@ -8889,7 +8900,7 @@
"sources": [
{
"source": "server/routes/fableLoom.js",
- "line": 170
+ "line": 190
}
]
},
@@ -8900,7 +8911,7 @@
"sources": [
{
"source": "server/routes/fableLoom.js",
- "line": 208
+ "line": 228
}
]
},
@@ -8911,7 +8922,7 @@
"sources": [
{
"source": "server/routes/fableLoom.js",
- "line": 219
+ "line": 239
}
]
},
@@ -8922,7 +8933,7 @@
"sources": [
{
"source": "server/routes/fableLoom.js",
- "line": 195
+ "line": 215
}
]
},
@@ -8933,7 +8944,7 @@
"sources": [
{
"source": "server/routes/fableLoom.js",
- "line": 130
+ "line": 135
}
]
},
@@ -8944,7 +8955,7 @@
"sources": [
{
"source": "server/routes/fableLoom.js",
- "line": 185
+ "line": 205
}
]
},
@@ -8955,7 +8966,7 @@
"sources": [
{
"source": "server/routes/fableLoom.js",
- "line": 108
+ "line": 113
}
]
},
@@ -8966,7 +8977,7 @@
"sources": [
{
"source": "server/routes/fableLoom.js",
- "line": 98
+ "line": 103
}
]
},
@@ -8977,7 +8988,7 @@
"sources": [
{
"source": "server/routes/fableLoom.js",
- "line": 103
+ "line": 108
}
]
},
@@ -23147,8 +23158,8 @@
],
"stats": {
"mounts": 145,
- "operations": 2088,
- "declarations": 2091,
+ "operations": 2089,
+ "declarations": 2092,
"sourceFiles": 225
}
}
diff --git a/server/lib/fableLoomLimits.js b/server/lib/fableLoomLimits.js
index 3baa5416ec..ba3c59e791 100644
--- a/server/lib/fableLoomLimits.js
+++ b/server/lib/fableLoomLimits.js
@@ -32,4 +32,11 @@ export const LOOM_LIMITS = Object.freeze({
TRIGGER_MAX: 160,
TRIGGERS_MAX: 8,
TRANSITION_DESC_MAX: 500,
+ HOLD_LOOPS_MAX: 8,
+ AUDIO_INTERVALS_MAX: 50,
+ PROVENANCE_CHARACTERS_MAX: 12,
+ AMBIENT_DUCK_DB_MIN: -60,
+ AMBIENT_DUCK_DB_MAX: 0,
+ AMBIENT_DUCK_DB_DEFAULT: -8,
});
+
diff --git a/server/lib/fableLoomPlayback.js b/server/lib/fableLoomPlayback.js
index 495e2d22b0..75fdc45ad8 100644
--- a/server/lib/fableLoomPlayback.js
+++ b/server/lib/fableLoomPlayback.js
@@ -1,13 +1,563 @@
/**
* FableLoom node playback semantics shared by persistence, prompts, and UI.
- * Legacy nodes default to decision mode so upgrades never start auto-advancing
- * an authored choice graph without the author explicitly reweaving/editing it.
+ *
+ * Supports entry clips, deterministic hold loops, transition exit clips,
+ * live interaction windows, audio-occupancy manifests, and production
+ * readiness inspection. Legacy nodes default to decision mode and single
+ * videoHistoryId playback.
*/
+import { LOOM_LIMITS } from './fableLoomLimits.js';
+
export const FABLELOOM_PLAYBACK_MODES = Object.freeze(['cut', 'decision']);
export const FABLELOOM_PLAYBACK_MODE_DEFAULT = 'decision';
+export const FABLELOOM_PLAYBACK_PHASES = Object.freeze(['entry', 'hold', 'exit', 'ended']);
+
+export const FABLELOOM_PROTAGONIST_PRESENCE = Object.freeze(['offscreen', 'onscreen']);
+export const FABLELOOM_PROTAGONIST_PRESENCE_DEFAULT = 'offscreen';
+
+export const FABLELOOM_AUDIO_TARGETS = Object.freeze(['host', 'audience']);
+export const FABLELOOM_AUDIO_TARGET_DEFAULT = 'host';
+
+export const FABLELOOM_HOLD_ROTATION_MODES = Object.freeze(['deterministic', 'shuffle', 'sequential']);
+export const FABLELOOM_HOLD_ROTATION_MODE_DEFAULT = 'deterministic';
+
export const isFableLoomPlaybackMode = (value) => FABLELOOM_PLAYBACK_MODES.includes(value);
export const asFableLoomPlaybackMode = (value) => (
isFableLoomPlaybackMode(value) ? value : FABLELOOM_PLAYBACK_MODE_DEFAULT
);
+
+export const isSafeVideoHistoryId = (value) =>
+ typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(value);
+
+const isStr = (v) => typeof v === 'string' && v.trim().length > 0;
+const trimTo = (v, max) => (typeof v === 'string' ? v.trim().slice(0, max) : '');
+const clamp = (v, min, max) => Math.max(min, Math.min(max, v));
+
+/**
+ * Sanitize an audio interval (dialogue, music, or effect).
+ */
+export function sanitizeAudioInterval(raw) {
+ if (!raw || typeof raw !== 'object') return null;
+ const startMs = Number.isFinite(raw.startMs) && raw.startMs >= 0 ? Math.round(raw.startMs) : 0;
+ const endMs = Number.isFinite(raw.endMs) && raw.endMs >= startMs ? Math.round(raw.endMs) : startMs;
+ return {
+ startMs,
+ endMs,
+ ...(isStr(raw.characterId) ? { characterId: trimTo(raw.characterId, 80) } : {}),
+ ...(isStr(raw.speaker) ? { speaker: trimTo(raw.speaker, 100) } : {}),
+ ...(raw.blocking === true ? { blocking: true } : {}),
+ ...(isStr(raw.name) ? { name: trimTo(raw.name, 100) } : {}),
+ };
+}
+
+/**
+ * Sanitize and validate an audio occupancy manifest.
+ * `safeForLiveVoice` is strictly computed/enforced:
+ * - A hold asset containing character dialogue CANNOT open a live voice window.
+ * - An asset with author-marked blocking effects CANNOT open a live voice window.
+ */
+export function validateAudioOccupancy(raw) {
+ if (!raw || typeof raw !== 'object') {
+ return {
+ durationMs: 0,
+ characterDialogue: [],
+ music: [],
+ effects: [],
+ safeForLiveVoice: true,
+ };
+ }
+
+ const durationMs = Number.isFinite(raw.durationMs) && raw.durationMs >= 0
+ ? Math.round(raw.durationMs)
+ : 0;
+
+ const characterDialogue = (Array.isArray(raw.characterDialogue) ? raw.characterDialogue : [])
+ .map(sanitizeAudioInterval)
+ .filter(Boolean)
+ .slice(0, LOOM_LIMITS.AUDIO_INTERVALS_MAX || 50);
+
+ const music = (Array.isArray(raw.music) ? raw.music : [])
+ .map(sanitizeAudioInterval)
+ .filter(Boolean)
+ .slice(0, LOOM_LIMITS.AUDIO_INTERVALS_MAX || 50);
+
+ const effects = (Array.isArray(raw.effects) ? raw.effects : [])
+ .map(sanitizeAudioInterval)
+ .filter(Boolean)
+ .slice(0, LOOM_LIMITS.AUDIO_INTERVALS_MAX || 50);
+
+ const hasCharacterDialogue = characterDialogue.length > 0;
+ const hasBlockingEffects = effects.some((e) => e.blocking === true);
+
+ // safeForLiveVoice is false if any dialogue or blocking effects exist
+ const safeForLiveVoice = !hasCharacterDialogue && !hasBlockingEffects;
+
+ return {
+ durationMs,
+ characterDialogue,
+ music,
+ effects,
+ safeForLiveVoice,
+ };
+}
+
+export const sanitizeAudioOccupancy = validateAudioOccupancy;
+
+/**
+ * Check if a manifest or asset is safe for live voice.
+ */
+export function isAssetSafeForLiveVoice(manifest) {
+ if (!manifest) return true;
+ return validateAudioOccupancy(manifest).safeForLiveVoice === true;
+}
+
+/**
+ * Helper to build an audio occupancy manifest.
+ */
+export function createAudioOccupancyManifest({
+ durationMs = 0,
+ characterDialogue = [],
+ music = [],
+ effects = [],
+} = {}) {
+ return validateAudioOccupancy({ durationMs, characterDialogue, music, effects });
+}
+
+/**
+ * Mux audio tracks into a single unified occupancy manifest.
+ */
+export function muxAudioTracks({
+ durationMs = 0,
+ characterDialogue = [],
+ music = [],
+ effects = [],
+} = {}) {
+ const manifest = createAudioOccupancyManifest({ durationMs, characterDialogue, music, effects });
+ return {
+ manifest,
+ totalDialogueMs: manifest.characterDialogue.reduce((sum, d) => sum + Math.max(0, d.endMs - d.startMs), 0),
+ totalMusicMs: manifest.music.reduce((sum, m) => sum + Math.max(0, m.endMs - m.startMs), 0),
+ totalEffectsMs: manifest.effects.reduce((sum, e) => sum + Math.max(0, e.endMs - e.startMs), 0),
+ safeForLiveVoice: manifest.safeForLiveVoice,
+ };
+}
+
+/**
+ * Compute ducking and audio level adjustments when live voice or dialogue is active.
+ */
+export function computeAudioMix({
+ assetManifest = null,
+ liveVoiceActive = false,
+ baseDuckDb = LOOM_LIMITS.AMBIENT_DUCK_DB_DEFAULT || -8,
+ currentTimeMs = 0,
+} = {}) {
+ const manifest = validateAudioOccupancy(assetManifest);
+ const duckDb = clamp(
+ Number.isFinite(baseDuckDb) ? baseDuckDb : (LOOM_LIMITS.AMBIENT_DUCK_DB_DEFAULT || -8),
+ LOOM_LIMITS.AMBIENT_DUCK_DB_MIN || -60,
+ LOOM_LIMITS.AMBIENT_DUCK_DB_MAX || 0,
+ );
+
+ const duckFactor = liveVoiceActive ? 10 ** (duckDb / 20) : 1.0;
+
+ const dialogueActive = manifest.characterDialogue.some(
+ (d) => currentTimeMs >= d.startMs && currentTimeMs <= d.endMs,
+ );
+
+ const blockingEffectActive = manifest.effects.some(
+ (e) => e.blocking && currentTimeMs >= e.startMs && currentTimeMs <= e.endMs,
+ );
+
+ return {
+ duckDb: liveVoiceActive ? duckDb : 0,
+ duckFactor: Math.round(duckFactor * 1000) / 1000,
+ musicLevel: duckFactor,
+ effectsLevel: blockingEffectActive ? 1.0 : duckFactor,
+ dialogueActive,
+ blockingEffectActive,
+ safeForLiveVoice: manifest.safeForLiveVoice,
+ canOpenLiveVoice: manifest.safeForLiveVoice && !dialogueActive && !blockingEffectActive,
+ };
+}
+
+/**
+ * Sanitize asset provenance envelope.
+ */
+export function sanitizeProvenance(raw) {
+ if (!raw || typeof raw !== 'object') return null;
+ return {
+ version: Number.isFinite(raw.version) ? Math.max(1, Math.round(raw.version)) : 1,
+ loomId: isStr(raw.loomId) ? trimTo(raw.loomId, 80) : null,
+ episodeId: isStr(raw.episodeId) ? trimTo(raw.episodeId, 80) : null,
+ nodeId: isStr(raw.nodeId) ? trimTo(raw.nodeId, 80) : null,
+ universeId: isStr(raw.universeId) ? trimTo(raw.universeId, 80) : null,
+ characters: (Array.isArray(raw.characters) ? raw.characters : [])
+ .filter((c) => c && typeof c === 'object' && isStr(c.characterId))
+ .slice(0, LOOM_LIMITS.PROVENANCE_CHARACTERS_MAX || 12)
+ .map((c) => ({
+ characterId: trimTo(c.characterId, 80),
+ wardrobeId: isStr(c.wardrobeId) ? trimTo(c.wardrobeId, 80) : null,
+ identityAssets: Array.isArray(c.identityAssets) ? c.identityAssets.filter(isStr).slice(0, 5) : [],
+ lora: c.lora && typeof c.lora === 'object' ? {
+ filename: trimTo(c.lora.filename, 200),
+ sha256: trimTo(c.lora.sha256, 128),
+ scale: Number.isFinite(c.lora.scale) ? c.lora.scale : 1.0,
+ } : null,
+ voice: c.voice && typeof c.voice === 'object' ? {
+ profileId: trimTo(c.voice.profileId, 80),
+ profileVersion: Number.isFinite(c.voice.profileVersion) ? Math.round(c.voice.profileVersion) : 1,
+ engine: trimTo(c.voice.engine, 80),
+ modelRevision: trimTo(c.voice.modelRevision, 120),
+ } : null,
+ })),
+ visualConditioningVersion: Number.isFinite(raw.visualConditioningVersion) ? raw.visualConditioningVersion : 1,
+ promptCompilerVersion: Number.isFinite(raw.promptCompilerVersion) ? raw.promptCompilerVersion : 1,
+ audioMixVersion: Number.isFinite(raw.audioMixVersion) ? raw.audioMixVersion : 1,
+ omitted: Array.isArray(raw.omitted) ? raw.omitted.filter(isStr).slice(0, 20) : [],
+ warnings: Array.isArray(raw.warnings) ? raw.warnings.filter(isStr).slice(0, 20) : [],
+ };
+}
+
+/**
+ * Sanitize playbackAssets node field.
+ */
+export function sanitizePlaybackAssets(raw) {
+ if (!raw || typeof raw !== 'object') return null;
+
+ const entryVideoHistoryId = isSafeVideoHistoryId(raw.entryVideoHistoryId)
+ ? raw.entryVideoHistoryId
+ : null;
+
+ const holdLoopVideoHistoryIds = (Array.isArray(raw.holdLoopVideoHistoryIds) ? raw.holdLoopVideoHistoryIds : [])
+ .filter(isSafeVideoHistoryId)
+ .slice(0, LOOM_LIMITS.HOLD_LOOPS_MAX || 8);
+
+ const exitByTransition = {};
+ if (raw.exitByTransition && typeof raw.exitByTransition === 'object') {
+ for (const [trId, vid] of Object.entries(raw.exitByTransition)) {
+ if (isStr(trId) && isSafeVideoHistoryId(vid)) {
+ exitByTransition[trId.slice(0, 80)] = vid;
+ }
+ }
+ }
+
+ const audioOccupancy = {};
+ if (raw.audioOccupancy && typeof raw.audioOccupancy === 'object') {
+ for (const [assetId, occ] of Object.entries(raw.audioOccupancy)) {
+ if (isStr(assetId) && occ && typeof occ === 'object') {
+ audioOccupancy[assetId.slice(0, 200)] = validateAudioOccupancy(occ);
+ }
+ }
+ }
+
+ const provenance = sanitizeProvenance(raw.provenance);
+
+ // If completely empty, return null
+ if (!entryVideoHistoryId
+ && !holdLoopVideoHistoryIds.length
+ && !Object.keys(exitByTransition).length
+ && !Object.keys(audioOccupancy).length
+ && !provenance) {
+ return null;
+ }
+
+ return {
+ entryVideoHistoryId,
+ holdLoopVideoHistoryIds,
+ exitByTransition,
+ audioOccupancy,
+ ...(provenance ? { provenance } : {}),
+ };
+}
+
+/**
+ * Sanitize interactionWindow node field.
+ */
+export function sanitizeInteractionWindow(raw) {
+ if (!raw || typeof raw !== 'object') return null;
+
+ const enabled = raw.enabled === true;
+ const protagonistCharacterId = isStr(raw.protagonistCharacterId)
+ ? trimTo(raw.protagonistCharacterId, LOOM_LIMITS.REF_ID_MAX || 64)
+ : null;
+
+ const protagonistPresence = FABLELOOM_PROTAGONIST_PRESENCE.includes(raw.protagonistPresence)
+ ? raw.protagonistPresence
+ : FABLELOOM_PROTAGONIST_PRESENCE_DEFAULT;
+
+ const audioTarget = FABLELOOM_AUDIO_TARGETS.includes(raw.audioTarget)
+ ? raw.audioTarget
+ : FABLELOOM_AUDIO_TARGET_DEFAULT;
+
+ const ambientDuckDb = Number.isFinite(raw.ambientDuckDb)
+ ? clamp(
+ Math.round(raw.ambientDuckDb),
+ LOOM_LIMITS.AMBIENT_DUCK_DB_MIN || -60,
+ LOOM_LIMITS.AMBIENT_DUCK_DB_MAX || 0,
+ )
+ : (LOOM_LIMITS.AMBIENT_DUCK_DB_DEFAULT || -8);
+
+ const holdLoopRotation = FABLELOOM_HOLD_ROTATION_MODES.includes(raw.holdLoopRotation)
+ ? raw.holdLoopRotation
+ : FABLELOOM_HOLD_ROTATION_MODE_DEFAULT;
+
+ return {
+ enabled,
+ protagonistCharacterId,
+ protagonistPresence,
+ audioTarget,
+ ambientDuckDb,
+ holdLoopRotation,
+ };
+}
+
+/**
+ * Resolve which video asset should be active for a given playback phase and node.
+ * Backward compatible with legacy nodes having only videoHistoryId.
+ */
+export function resolvePlaybackPhaseAsset({
+ node,
+ phase = 'entry',
+ activeHoldIndex = 0,
+ transitionId = null,
+ seed = 0,
+ iteration = 0,
+} = {}) {
+ if (!node) {
+ return {
+ phase: 'ended',
+ videoHistoryId: null,
+ audioOccupancy: null,
+ safeForLiveVoice: true,
+ isEntry: false,
+ isHold: false,
+ isExit: false,
+ };
+ }
+
+ const assets = node.playbackAssets || null;
+ const legacyVideoId = node.videoHistoryId || null;
+
+ let selectedVideoId = null;
+ let effectivePhase = phase;
+
+ if (effectivePhase === 'entry') {
+ selectedVideoId = assets?.entryVideoHistoryId || legacyVideoId || null;
+ // If no entry video, try to fall back to hold loop or legacy video
+ if (!selectedVideoId && assets?.holdLoopVideoHistoryIds?.length) {
+ selectedVideoId = assets.holdLoopVideoHistoryIds[0];
+ effectivePhase = 'hold';
+ }
+ } else if (effectivePhase === 'hold') {
+ const holdIds = (assets?.holdLoopVideoHistoryIds?.length)
+ ? assets.holdLoopVideoHistoryIds
+ : (assets?.entryVideoHistoryId ? [assets.entryVideoHistoryId] : (legacyVideoId ? [legacyVideoId] : []));
+
+ if (holdIds.length > 0) {
+ const rotationMode = node.interactionWindow?.holdLoopRotation || 'deterministic';
+ let index = 0;
+ if (rotationMode === 'sequential') {
+ index = Math.abs(activeHoldIndex) % holdIds.length;
+ } else if (rotationMode === 'shuffle') {
+ // deterministic pseudo-random from seed and iteration
+ const hash = Math.abs((Number(seed) || 0) * 31 + (Number(iteration) || 0) * 17 + Number(activeHoldIndex));
+ index = hash % holdIds.length;
+ } else {
+ // deterministic
+ index = (Math.abs(Number(seed) || 0) + Number(iteration) + Number(activeHoldIndex)) % holdIds.length;
+ }
+ selectedVideoId = holdIds[index] || holdIds[0];
+ }
+ } else if (effectivePhase === 'exit') {
+ if (transitionId && assets?.exitByTransition?.[transitionId]) {
+ selectedVideoId = assets.exitByTransition[transitionId];
+ } else {
+ selectedVideoId = null;
+ }
+ } else if (effectivePhase === 'ended') {
+ selectedVideoId = null;
+ }
+
+ const audioOccupancy = (selectedVideoId && assets?.audioOccupancy?.[selectedVideoId])
+ ? validateAudioOccupancy(assets.audioOccupancy[selectedVideoId])
+ : null;
+
+ const safeForLiveVoice = audioOccupancy ? audioOccupancy.safeForLiveVoice : true;
+
+ return {
+ phase: effectivePhase,
+ videoHistoryId: selectedVideoId,
+ audioOccupancy,
+ safeForLiveVoice,
+ isEntry: effectivePhase === 'entry',
+ isHold: effectivePhase === 'hold',
+ isExit: effectivePhase === 'exit',
+ };
+}
+
+/**
+ * Inspect a scene node for production readiness and live voice safety.
+ * Pure deterministic preflight check — NO AI / provider calls!
+ */
+export function inspectNodeProductionReadiness(node, { universe = null, loom = null } = {}) {
+ const findings = [];
+ const push = (code, severity, message, remediation, extra = {}) => {
+ findings.push({ code, severity, message, remediation, ...extra });
+ };
+
+ if (!node) {
+ return { ready: false, findings: [{ code: 'NO_NODE', severity: 'error', message: 'No scene provided.', remediation: 'Select a scene.' }] };
+ }
+
+ const assets = node.playbackAssets;
+ const interaction = node.interactionWindow;
+
+ // Check audio occupancy safety on hold loops
+ if (assets?.holdLoopVideoHistoryIds?.length) {
+ for (const holdId of assets.holdLoopVideoHistoryIds) {
+ const occ = assets.audioOccupancy?.[holdId];
+ if (occ) {
+ const validated = validateAudioOccupancy(occ);
+ if (validated.characterDialogue.length > 0) {
+ push(
+ 'HOLD_ASSET_HAS_DIALOGUE',
+ 'error',
+ `Hold loop "${holdId}" contains rendered character dialogue and cannot open a live voice window.`,
+ 'Render dialogue separately or remove speech lane from hold loop.',
+ { assetId: holdId },
+ );
+ }
+ if (validated.effects.some((e) => e.blocking)) {
+ push(
+ 'HOLD_ASSET_HAS_BLOCKING_EFFECTS',
+ 'warning',
+ `Hold loop "${holdId}" contains author-marked voice-blocking sound effects.`,
+ 'Adjust effect intervals or unmark blocking flag.',
+ { assetId: holdId },
+ );
+ }
+ }
+ }
+ }
+
+ // Check interaction window prerequisites
+ if (interaction?.enabled) {
+ if (node.isEnding) {
+ push(
+ 'INTERACTION_ON_ENDING',
+ 'error',
+ 'Live interaction cannot be enabled on an ending scene.',
+ 'Disable live interaction on ending scene.',
+ );
+ }
+
+ if (node.playbackMode === 'cut') {
+ push(
+ 'INTERACTION_ON_CUT',
+ 'warning',
+ 'Scene is an automatic cut; live voice interaction will be bypassed.',
+ 'Change playback behavior to decision point or disable interaction.',
+ );
+ }
+
+ if (interaction.protagonistPresence === 'onscreen') {
+ push(
+ 'PROTAGONIST_ONSCREEN',
+ 'warning',
+ 'Live voice currently requires off-screen protagonist presence to avoid lip-sync mismatch.',
+ 'Set protagonist presence to off-screen.',
+ );
+ }
+
+ if (!interaction.protagonistCharacterId) {
+ push(
+ 'MISSING_PROTAGONIST_CHARACTER',
+ 'error',
+ 'Live interaction is enabled but no protagonist character is bound.',
+ 'Select a protagonist character in scene settings.',
+ );
+ } else if (universe && Array.isArray(universe.characters)) {
+ const found = universe.characters.some((c) => c.id === interaction.protagonistCharacterId);
+ if (!found) {
+ push(
+ 'PROTAGONIST_NOT_IN_UNIVERSE',
+ 'error',
+ `Bound protagonist character "${interaction.protagonistCharacterId}" does not exist in the linked Universe.`,
+ 'Re-bind a valid character from the Universe.',
+ );
+ }
+ }
+
+ if (loom?.participationMode === 'helper' && node.audienceConnection !== 'connected') {
+ push(
+ 'DISCONNECTED_INTERACTION',
+ 'error',
+ 'Live interaction is enabled while the audience communication channel is disconnected.',
+ 'Set audience connection to connected or disable interaction.',
+ );
+ }
+
+ const hasHoldAsset = (assets?.holdLoopVideoHistoryIds?.length > 0)
+ || !!assets?.entryVideoHistoryId
+ || !!node.videoHistoryId;
+
+ if (!hasHoldAsset) {
+ push(
+ 'NO_HOLD_ASSET',
+ 'warning',
+ 'No hold loop or video asset is rendered for this live conversation scene.',
+ 'Generate or attach a hold loop video asset.',
+ );
+ }
+ }
+
+ // Optional transition exit check
+ const transitions = node.transitions || [];
+ if (assets && transitions.length > 0) {
+ const missingExits = transitions.filter((t) => !assets.exitByTransition?.[t.id]);
+ if (missingExits.length > 0 && Object.keys(assets.exitByTransition || {}).length > 0) {
+ push(
+ 'PARTIAL_EXIT_CLIPS',
+ 'info',
+ `${missingExits.length} of ${transitions.length} transition(s) do not have exit clips and will cut directly to target scene.`,
+ 'Render and attach matching exit clips if continuous transition is desired.',
+ );
+ }
+ }
+
+ const errorCount = findings.filter((f) => f.severity === 'error').length;
+ const warningCount = findings.filter((f) => f.severity === 'warning').length;
+
+ return {
+ ready: errorCount === 0,
+ errorCount,
+ warningCount,
+ findings,
+ };
+}
+
+/**
+ * Inspect all scenes in an episode for production readiness.
+ */
+export function inspectEpisodeProductionReadiness(episode, { universe = null, loom = null } = {}) {
+ const nodes = Array.isArray(episode?.nodes) ? episode.nodes : [];
+ const nodeResults = {};
+ let totalErrors = 0;
+ let totalWarnings = 0;
+
+ for (const node of nodes) {
+ const res = inspectNodeProductionReadiness(node, { universe, loom });
+ nodeResults[node.id] = res;
+ totalErrors += res.errorCount;
+ totalWarnings += res.warningCount;
+ }
+
+ return {
+ ready: totalErrors === 0,
+ totalErrors,
+ totalWarnings,
+ nodeResults,
+ };
+}
diff --git a/server/lib/fableLoomPlayback.test.js b/server/lib/fableLoomPlayback.test.js
index f2bb4366db..042cf9f843 100644
--- a/server/lib/fableLoomPlayback.test.js
+++ b/server/lib/fableLoomPlayback.test.js
@@ -1,9 +1,27 @@
import { describe, expect, it } from 'vitest';
import {
- FABLELOOM_PLAYBACK_MODES, asFableLoomPlaybackMode, isFableLoomPlaybackMode,
+ FABLELOOM_AUDIO_TARGETS,
+ FABLELOOM_HOLD_ROTATION_MODES,
+ FABLELOOM_PLAYBACK_MODES,
+ FABLELOOM_PLAYBACK_PHASES,
+ FABLELOOM_PROTAGONIST_PRESENCE,
+ asFableLoomPlaybackMode,
+ computeAudioMix,
+ createAudioOccupancyManifest,
+ inspectEpisodeProductionReadiness,
+ inspectNodeProductionReadiness,
+ isAssetSafeForLiveVoice,
+ isFableLoomPlaybackMode,
+ muxAudioTracks,
+ resolvePlaybackPhaseAsset,
+ sanitizeAudioInterval,
+ sanitizeAudioOccupancy,
+ sanitizeInteractionWindow,
+ sanitizePlaybackAssets,
+ validateAudioOccupancy,
} from './fableLoomPlayback.js';
-describe('FableLoom playback modes', () => {
+describe('FableLoom playback modes and vocabulary', () => {
it('recognizes the persisted vocabulary and defaults old or invalid nodes to decisions', () => {
expect(FABLELOOM_PLAYBACK_MODES).toEqual(['cut', 'decision']);
expect(isFableLoomPlaybackMode('cut')).toBe(true);
@@ -12,4 +30,330 @@ describe('FableLoom playback modes', () => {
expect(asFableLoomPlaybackMode(undefined)).toBe('decision');
expect(asFableLoomPlaybackMode('loop')).toBe('decision');
});
+
+ it('exposes playback phases, protagonist presence, audio targets, and hold rotation modes', () => {
+ expect(FABLELOOM_PLAYBACK_PHASES).toEqual(['entry', 'hold', 'exit', 'ended']);
+ expect(FABLELOOM_PROTAGONIST_PRESENCE).toEqual(['offscreen', 'onscreen']);
+ expect(FABLELOOM_AUDIO_TARGETS).toEqual(['host', 'audience']);
+ expect(FABLELOOM_HOLD_ROTATION_MODES).toEqual(['deterministic', 'shuffle', 'sequential']);
+ });
+});
+
+describe('FableLoom audio occupancy and validation', () => {
+ it('sanitizes audio intervals', () => {
+ expect(sanitizeAudioInterval(null)).toBeNull();
+ expect(sanitizeAudioInterval({ startMs: 100, endMs: 500, characterId: 'char-1' })).toEqual({
+ startMs: 100,
+ endMs: 500,
+ characterId: 'char-1',
+ });
+ expect(sanitizeAudioInterval({ startMs: 200, endMs: 100, blocking: true, name: 'gunshot' })).toEqual({
+ startMs: 200,
+ endMs: 200,
+ blocking: true,
+ name: 'gunshot',
+ });
+ });
+
+ it('validates safeForLiveVoice strictly: dialogue prevents live voice', () => {
+ const manifestWithDialogue = validateAudioOccupancy({
+ durationMs: 8000,
+ characterDialogue: [{ startMs: 1000, endMs: 3000, characterId: 'char-1' }],
+ music: [{ startMs: 0, endMs: 8000 }],
+ safeForLiveVoice: true, // author attempted to mark true
+ });
+ expect(manifestWithDialogue.safeForLiveVoice).toBe(false);
+ expect(isAssetSafeForLiveVoice(manifestWithDialogue)).toBe(false);
+ });
+
+ it('validates safeForLiveVoice strictly: author-marked blocking effects prevent live voice', () => {
+ const manifestWithBlocking = validateAudioOccupancy({
+ durationMs: 6000,
+ effects: [{ startMs: 500, endMs: 1500, blocking: true, name: 'explosion' }],
+ music: [{ startMs: 0, endMs: 6000 }],
+ });
+ expect(manifestWithBlocking.safeForLiveVoice).toBe(false);
+ });
+
+ it('allows live voice when only music / non-blocking ambience is present', () => {
+ const safeManifest = validateAudioOccupancy({
+ durationMs: 8000,
+ music: [{ startMs: 0, endMs: 8000 }],
+ effects: [{ startMs: 200, endMs: 400, blocking: false, name: 'wind' }],
+ });
+ expect(safeManifest.safeForLiveVoice).toBe(true);
+ expect(isAssetSafeForLiveVoice(safeManifest)).toBe(true);
+ });
+
+ it('muxes audio tracks and calculates total lane durations', () => {
+ const muxed = muxAudioTracks({
+ durationMs: 10000,
+ characterDialogue: [{ startMs: 0, endMs: 3000 }],
+ music: [{ startMs: 0, endMs: 10000 }],
+ effects: [{ startMs: 1000, endMs: 2000 }],
+ });
+ expect(muxed.totalDialogueMs).toBe(3000);
+ expect(muxed.totalMusicMs).toBe(10000);
+ expect(muxed.totalEffectsMs).toBe(1000);
+ expect(muxed.safeForLiveVoice).toBe(false);
+ });
+
+ it('computes audio ducking levels when live voice is active', () => {
+ const manifest = createAudioOccupancyManifest({
+ durationMs: 8000,
+ music: [{ startMs: 0, endMs: 8000 }],
+ });
+
+ const inactiveMix = computeAudioMix({
+ assetManifest: manifest,
+ liveVoiceActive: false,
+ baseDuckDb: -8,
+ });
+ expect(inactiveMix.duckFactor).toBe(1.0);
+ expect(inactiveMix.musicLevel).toBe(1.0);
+ expect(inactiveMix.canOpenLiveVoice).toBe(true);
+
+ const activeMix = computeAudioMix({
+ assetManifest: manifest,
+ liveVoiceActive: true,
+ baseDuckDb: -8,
+ });
+ expect(activeMix.duckDb).toBe(-8);
+ expect(activeMix.duckFactor).toBeCloseTo(0.398, 2);
+ expect(activeMix.musicLevel).toBeCloseTo(0.398, 2);
+ expect(activeMix.canOpenLiveVoice).toBe(true);
+ });
+});
+
+describe('FableLoom playbackAssets and interactionWindow sanitizers', () => {
+ it('sanitizes playbackAssets with safe video IDs and occupancy', () => {
+ expect(sanitizePlaybackAssets(null)).toBeNull();
+ expect(sanitizePlaybackAssets({})).toBeNull();
+
+ const sanitized = sanitizePlaybackAssets({
+ entryVideoHistoryId: 'vid-entry-1',
+ holdLoopVideoHistoryIds: ['vid-hold-1', 'vid-hold-2', 'invalid / id!'],
+ exitByTransition: {
+ 'tr-1': 'vid-exit-1',
+ 'tr-2': 'invalid / id!',
+ },
+ audioOccupancy: {
+ 'vid-hold-1': {
+ durationMs: 5000,
+ music: [{ startMs: 0, endMs: 5000 }],
+ },
+ },
+ });
+
+ expect(sanitized.entryVideoHistoryId).toBe('vid-entry-1');
+ expect(sanitized.holdLoopVideoHistoryIds).toEqual(['vid-hold-1', 'vid-hold-2']);
+ expect(sanitized.exitByTransition).toEqual({ 'tr-1': 'vid-exit-1' });
+ expect(sanitized.audioOccupancy['vid-hold-1'].safeForLiveVoice).toBe(true);
+ });
+
+ it('sanitizes interactionWindow with defaults and clamped duck dB', () => {
+ expect(sanitizeInteractionWindow(null)).toBeNull();
+
+ const sanitized = sanitizeInteractionWindow({
+ enabled: true,
+ protagonistCharacterId: 'char-elena',
+ protagonistPresence: 'offscreen',
+ audioTarget: 'host',
+ ambientDuckDb: -12,
+ holdLoopRotation: 'shuffle',
+ });
+
+ expect(sanitized).toEqual({
+ enabled: true,
+ protagonistCharacterId: 'char-elena',
+ protagonistPresence: 'offscreen',
+ audioTarget: 'host',
+ ambientDuckDb: -12,
+ holdLoopRotation: 'shuffle',
+ });
+
+ const clamped = sanitizeInteractionWindow({
+ enabled: true,
+ ambientDuckDb: -100, // beyond min -60
+ protagonistPresence: 'invalid',
+ });
+ expect(clamped.ambientDuckDb).toBe(-60);
+ expect(clamped.protagonistPresence).toBe('offscreen');
+ });
});
+
+describe('resolvePlaybackPhaseAsset', () => {
+ it('resolves legacy node with videoHistoryId seamlessly across phases', () => {
+ const legacyNode = {
+ id: 'node-legacy',
+ videoHistoryId: 'vid-legacy-1',
+ };
+
+ const entry = resolvePlaybackPhaseAsset({ node: legacyNode, phase: 'entry' });
+ expect(entry.videoHistoryId).toBe('vid-legacy-1');
+ expect(entry.phase).toBe('entry');
+
+ const hold = resolvePlaybackPhaseAsset({ node: legacyNode, phase: 'hold' });
+ expect(hold.videoHistoryId).toBe('vid-legacy-1');
+ expect(hold.phase).toBe('hold');
+ expect(hold.safeForLiveVoice).toBe(true);
+ });
+
+ it('resolves production playbackAssets for entry, hold rotation, and exit clips', () => {
+ const prodNode = {
+ id: 'node-prod',
+ playbackAssets: {
+ entryVideoHistoryId: 'vid-entry',
+ holdLoopVideoHistoryIds: ['vid-hold-a', 'vid-hold-b'],
+ exitByTransition: {
+ 'tr-left': 'vid-exit-left',
+ 'tr-right': 'vid-exit-right',
+ },
+ audioOccupancy: {
+ 'vid-hold-a': {
+ durationMs: 6000,
+ music: [{ startMs: 0, endMs: 6000 }],
+ },
+ 'vid-hold-b': {
+ durationMs: 6000,
+ characterDialogue: [{ startMs: 1000, endMs: 2000 }],
+ },
+ },
+ },
+ interactionWindow: {
+ enabled: true,
+ holdLoopRotation: 'sequential',
+ },
+ };
+
+ // Phase 1: Entry
+ const entry = resolvePlaybackPhaseAsset({ node: prodNode, phase: 'entry' });
+ expect(entry.videoHistoryId).toBe('vid-entry');
+ expect(entry.isEntry).toBe(true);
+
+ // Phase 2: Hold rotation (index 0 -> hold-a)
+ const hold0 = resolvePlaybackPhaseAsset({ node: prodNode, phase: 'hold', activeHoldIndex: 0 });
+ expect(hold0.videoHistoryId).toBe('vid-hold-a');
+ expect(hold0.safeForLiveVoice).toBe(true);
+
+ // Phase 2: Hold rotation (index 1 -> hold-b)
+ const hold1 = resolvePlaybackPhaseAsset({ node: prodNode, phase: 'hold', activeHoldIndex: 1 });
+ expect(hold1.videoHistoryId).toBe('vid-hold-b');
+ expect(hold1.safeForLiveVoice).toBe(false); // contains dialogue
+
+ // Phase 3: Exit transition clip
+ const exitLeft = resolvePlaybackPhaseAsset({ node: prodNode, phase: 'exit', transitionId: 'tr-left' });
+ expect(exitLeft.videoHistoryId).toBe('vid-exit-left');
+ expect(exitLeft.isExit).toBe(true);
+
+ // Phase 4: Ended
+ const ended = resolvePlaybackPhaseAsset({ node: prodNode, phase: 'ended' });
+ expect(ended.videoHistoryId).toBeNull();
+ });
+});
+
+describe('inspectNodeProductionReadiness & inspectEpisodeProductionReadiness', () => {
+ const universe = {
+ characters: [{ id: 'char-maya', name: 'Maya' }],
+ };
+
+ it('detects unsafe hold asset containing character dialogue', () => {
+ const node = {
+ id: 'node-1',
+ playbackAssets: {
+ holdLoopVideoHistoryIds: ['vid-hold-bad'],
+ audioOccupancy: {
+ 'vid-hold-bad': {
+ characterDialogue: [{ startMs: 0, endMs: 1000, characterId: 'char-maya' }],
+ },
+ },
+ },
+ interactionWindow: {
+ enabled: true,
+ protagonistCharacterId: 'char-maya',
+ protagonistPresence: 'offscreen',
+ },
+ };
+
+ const res = inspectNodeProductionReadiness(node, { universe });
+ expect(res.ready).toBe(false);
+ expect(res.findings).toEqual(expect.arrayContaining([
+ expect.objectContaining({
+ code: 'HOLD_ASSET_HAS_DIALOGUE',
+ severity: 'error',
+ }),
+ ]));
+ });
+
+ it('detects missing protagonist character and interaction on ending', () => {
+ const node = {
+ id: 'node-ending',
+ isEnding: true,
+ interactionWindow: {
+ enabled: true,
+ protagonistCharacterId: null,
+ },
+ };
+
+ const res = inspectNodeProductionReadiness(node, { universe });
+ expect(res.ready).toBe(false);
+ expect(res.findings).toEqual(expect.arrayContaining([
+ expect.objectContaining({ code: 'INTERACTION_ON_ENDING', severity: 'error' }),
+ expect.objectContaining({ code: 'MISSING_PROTAGONIST_CHARACTER', severity: 'error' }),
+ ]));
+ });
+
+ it('validates sound production scene with ready: true', () => {
+ const readyNode = {
+ id: 'node-sound',
+ playbackAssets: {
+ entryVideoHistoryId: 'vid-entry',
+ holdLoopVideoHistoryIds: ['vid-hold'],
+ audioOccupancy: {
+ 'vid-hold': {
+ durationMs: 5000,
+ music: [{ startMs: 0, endMs: 5000 }],
+ },
+ },
+ },
+ interactionWindow: {
+ enabled: true,
+ protagonistCharacterId: 'char-maya',
+ protagonistPresence: 'offscreen',
+ },
+ };
+
+ const res = inspectNodeProductionReadiness(readyNode, { universe });
+ expect(res.ready).toBe(true);
+ expect(res.errorCount).toBe(0);
+ });
+
+ it('inspects full episode production readiness', () => {
+ const episode = {
+ id: 'ep-1',
+ nodes: [
+ {
+ id: 'node-1',
+ playbackAssets: {
+ holdLoopVideoHistoryIds: ['vid-hold'],
+ audioOccupancy: {
+ 'vid-hold': {
+ music: [{ startMs: 0, endMs: 5000 }],
+ },
+ },
+ },
+ interactionWindow: {
+ enabled: true,
+ protagonistCharacterId: 'char-maya',
+ },
+ },
+ ],
+ };
+
+ const epRes = inspectEpisodeProductionReadiness(episode, { universe });
+ expect(epRes.ready).toBe(true);
+ expect(epRes.totalErrors).toBe(0);
+ expect(epRes.nodeResults['node-1'].ready).toBe(true);
+ });
+});
+
diff --git a/server/lib/fableLoomValidation.js b/server/lib/fableLoomValidation.js
index 930ea5ac61..ed29b64eb1 100644
--- a/server/lib/fableLoomValidation.js
+++ b/server/lib/fableLoomValidation.js
@@ -8,7 +8,12 @@
import { z } from 'zod';
import { LOOM_LIMITS } from './fableLoomLimits.js';
import { LOOM_FORMATS } from './fableLoomFormats.js';
-import { FABLELOOM_PLAYBACK_MODES } from './fableLoomPlayback.js';
+import {
+ FABLELOOM_AUDIO_TARGETS,
+ FABLELOOM_HOLD_ROTATION_MODES,
+ FABLELOOM_PLAYBACK_MODES,
+ FABLELOOM_PROTAGONIST_PRESENCE,
+} from './fableLoomPlayback.js';
import {
FABLELOOM_AUDIENCE_CONNECTION_STATES,
FABLELOOM_PARTICIPATION_MODES,
@@ -129,6 +134,40 @@ export const transitionPatchSchema = z.object({
description: transitionFields.description,
});
+const audioIntervalSchema = z.object({
+ startMs: z.number().min(0),
+ endMs: z.number().min(0),
+ characterId: z.string().max(80).optional(),
+ speaker: z.string().max(100).optional(),
+ blocking: z.boolean().optional(),
+ name: z.string().max(100).optional(),
+});
+
+export const audioOccupancySchema = z.object({
+ durationMs: z.number().min(0).optional(),
+ characterDialogue: z.array(audioIntervalSchema).max(LOOM_LIMITS.AUDIO_INTERVALS_MAX).optional(),
+ music: z.array(audioIntervalSchema).max(LOOM_LIMITS.AUDIO_INTERVALS_MAX).optional(),
+ effects: z.array(audioIntervalSchema).max(LOOM_LIMITS.AUDIO_INTERVALS_MAX).optional(),
+ safeForLiveVoice: z.boolean().optional(),
+});
+
+export const playbackAssetsSchema = z.object({
+ entryVideoHistoryId: z.string().max(200).nullable().optional(),
+ holdLoopVideoHistoryIds: z.array(z.string().max(200)).max(LOOM_LIMITS.HOLD_LOOPS_MAX).optional(),
+ exitByTransition: z.record(z.string(), z.string().max(200)).optional(),
+ audioOccupancy: z.record(z.string(), audioOccupancySchema).optional(),
+ provenance: z.record(z.string(), z.any()).nullable().optional(),
+}).nullable();
+
+export const interactionWindowSchema = z.object({
+ enabled: z.boolean().optional(),
+ protagonistCharacterId: z.string().max(LOOM_LIMITS.REF_ID_MAX).nullable().optional(),
+ protagonistPresence: z.enum(FABLELOOM_PROTAGONIST_PRESENCE).optional(),
+ audioTarget: z.enum(FABLELOOM_AUDIO_TARGETS).optional(),
+ ambientDuckDb: z.number().min(LOOM_LIMITS.AMBIENT_DUCK_DB_MIN).max(LOOM_LIMITS.AMBIENT_DUCK_DB_MAX).optional(),
+ holdLoopRotation: z.enum(FABLELOOM_HOLD_ROTATION_MODES).optional(),
+}).nullable();
+
const nodeFields = {
title: z.string().max(LOOM_LIMITS.NODE_TITLE_MAX).optional(),
prose: z.string().max(LOOM_LIMITS.PROSE_MAX).optional(),
@@ -137,6 +176,9 @@ const nodeFields = {
cameraMovement: z.string().max(LOOM_LIMITS.CAMERA_MOVEMENT_MAX).optional(),
playbackMode: z.enum(FABLELOOM_PLAYBACK_MODES).optional(),
audienceConnection: z.enum(FABLELOOM_AUDIENCE_CONNECTION_STATES).optional(),
+ videoHistoryId: z.string().max(200).nullable().optional(),
+ playbackAssets: playbackAssetsSchema.optional(),
+ interactionWindow: interactionWindowSchema.optional(),
isEnding: z.boolean().optional(),
endingLabel: z.string().max(LOOM_LIMITS.ENDING_LABEL_MAX).optional(),
pos: z.object({ x: z.number(), y: z.number() }).nullable().optional(),
diff --git a/server/routes/fableLoom.js b/server/routes/fableLoom.js
index 435cd42965..0eac0e0eae 100644
--- a/server/routes/fableLoom.js
+++ b/server/routes/fableLoom.js
@@ -32,6 +32,11 @@ import {
weaveSchema,
} from '../lib/fableLoomValidation.js';
import { analyzeEpisodeGraph } from '../lib/fableLoomGraph.js';
+import {
+ inspectEpisodeProductionReadiness,
+ inspectNodeProductionReadiness,
+} from '../lib/fableLoomPlayback.js';
+import { getUniverse } from '../services/universeBuilder.js';
import {
addEpisode,
addNode,
@@ -126,15 +131,30 @@ router.delete('/:id/episodes/:episodeId', asyncHandler(async (req, res) => {
res.json(await deleteEpisode(req.params.id, req.params.episodeId));
}));
-// Deterministic graph validation — no LLM.
+// Deterministic graph validation and production readiness — no LLM.
router.get('/:id/episodes/:episodeId/validate', asyncHandler(async (req, res) => {
const loom = await getLoom(req.params.id);
const episode = loom?.episodes.find((e) => e.id === req.params.episodeId);
if (!episode) throw new ServerError('Episode not found', { status: 404, code: 'NOT_FOUND' });
- res.json(analyzeEpisodeGraph(episode, {
+ const universe = loom.universeId ? await getUniverse(loom.universeId).catch(() => null) : null;
+ const graphAnalysis = analyzeEpisodeGraph(episode, {
participationMode: loom.participationMode,
requireAudienceIntroduction: episode.id === loom.episodes[0]?.id,
- }));
+ });
+ const productionReadiness = inspectEpisodeProductionReadiness(episode, { universe, loom });
+ res.json({
+ ...graphAnalysis,
+ productionReadiness,
+ });
+}));
+
+router.get('/:id/episodes/:episodeId/nodes/:nodeId/readiness', asyncHandler(async (req, res) => {
+ const loom = await getLoom(req.params.id);
+ const episode = loom?.episodes.find((e) => e.id === req.params.episodeId);
+ const node = episode?.nodes.find((n) => n.id === req.params.nodeId);
+ if (!loom || !episode || !node) throw new ServerError('Scene not found', { status: 404, code: 'NOT_FOUND' });
+ const universe = loom.universeId ? await getUniverse(loom.universeId).catch(() => null) : null;
+ res.json(inspectNodeProductionReadiness(node, { universe, loom }));
}));
// --- Nodes ------------------------------------------------------------------
diff --git a/server/services/fableLoom/README.md b/server/services/fableLoom/README.md
index 0e75a20a24..a5eb00c8d2 100644
--- a/server/services/fableLoom/README.md
+++ b/server/services/fableLoom/README.md
@@ -7,7 +7,7 @@ intent to a transition and moves them through the graph until an ending.
| Module | Purpose |
|---|---|
-| `records.js` | Sanitizer + CRUD + peer LWW/tombstone merge for looms/episodes/nodes; transitions are addressable one at a time (`addNodeTransition` / `updateNodeTransition` / `deleteNodeTransition`) as well as replaceable as a whole array via the node patch; `attachNodeImage` and `attachNodeVideo` for media-job hooks. |
+| `records.js` | Sanitizer + CRUD + peer LWW/tombstone merge for looms/episodes/nodes; transitions are addressable one at a time (`addNodeTransition` / `updateNodeTransition` / `deleteNodeTransition`) as well as replaceable as a whole array via the node patch; `attachNodeImage`, `attachNodeVideo`, and `attachNodePlaybackAsset` for media-job hooks. |
| `weave.js` | AI ops via `runStagedLLM`: `generateSeriesPlan` (full arc / plot-point / side-quest scaffold), `weaveEpisode` (single-camera-cut graph with automatic cuts and looping decisions), `branchNode` (grow paths), `feedbackEpisode` (apply a conversational sparse patch to one episode), `reviewEpisode` (critique + deterministic analysis), `playTurn` (reader intent → transition; tapped/automatic paths resolve with NO LLM call), `reformatEpisodeScenes` (rewrite ONE episode's scenes into another format; the loom's format pin lands only once every episode is converted). |
| `formats.js` | Scene formats (`prose` / `teleplay`) and the prompt contracts each generative stage renders for them. |
| `store.js` | PostgreSQL/file backend facade (`fableloom_stories`; collectionStore escape hatch for tests). |
diff --git a/server/services/fableLoom/index.js b/server/services/fableLoom/index.js
index 7199daa5f0..01d8f5be46 100644
--- a/server/services/fableLoom/index.js
+++ b/server/services/fableLoom/index.js
@@ -4,6 +4,7 @@ export {
addNode,
addNodeTransition,
attachNodeImage,
+ attachNodePlaybackAsset,
attachNodeVideo,
createLoom,
deleteEpisode,
diff --git a/server/services/fableLoom/records.js b/server/services/fableLoom/records.js
index fb27de30a2..701beb7b18 100644
--- a/server/services/fableLoom/records.js
+++ b/server/services/fableLoom/records.js
@@ -42,7 +42,12 @@ import {
} from './store.js';
import { LOOM_LIMITS } from './limits.js';
import { asLoomFormat, isLoomFormat } from './formats.js';
-import { asFableLoomPlaybackMode } from '../../lib/fableLoomPlayback.js';
+import {
+ asFableLoomPlaybackMode,
+ isSafeVideoHistoryId,
+ sanitizeInteractionWindow,
+ sanitizePlaybackAssets,
+} from '../../lib/fableLoomPlayback.js';
import {
FABLELOOM_LEGACY_PARTICIPATION_MODE,
asFableLoomAudienceConnection,
@@ -54,8 +59,6 @@ export { LOOM_LIMITS };
const isSafeImageFilename = (value) =>
typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]*\.(png|jpg|jpeg|webp)$/i.test(value);
-const isSafeVideoHistoryId = (value) =>
- typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,199}$/.test(value);
const nullableRef = (value) => (isStr(value) && value.trim() ? value.trim().slice(0, LOOM_LIMITS.REF_ID_MAX) : null);
@@ -95,6 +98,8 @@ function sanitizeNode(raw) {
playbackMode: asFableLoomPlaybackMode(raw.playbackMode),
audienceConnection: asFableLoomAudienceConnection(raw.audienceConnection),
videoHistoryId: isSafeVideoHistoryId(raw.videoHistoryId) ? raw.videoHistoryId : null,
+ playbackAssets: sanitizePlaybackAssets(raw.playbackAssets),
+ interactionWindow: sanitizeInteractionWindow(raw.interactionWindow),
isEnding: raw.isEnding === true,
// The format this scene's text is actually WRITTEN in — server-set, not
// patchable. `null` means unknown (authored before the field existed, or
@@ -529,7 +534,8 @@ export function deleteEpisode(loomId, episodeId) {
const NODE_PATCH_FIELDS = [
'title', 'prose', 'imagePrompt', 'videoPrompt', 'cameraMovement', 'playbackMode',
- 'audienceConnection', 'isEnding', 'endingLabel', 'pos', 'transitions',
+ 'audienceConnection', 'videoHistoryId', 'playbackAssets', 'interactionWindow',
+ 'isEnding', 'endingLabel', 'pos', 'transitions',
];
export function addNode(loomId, episodeId, fields = {}) {
@@ -685,3 +691,62 @@ export async function attachNodeVideo(loomId, episodeId, nodeId, { videoHistoryI
}).catch(() => null);
return updated?.episodes.find((e) => e.id === episodeId)?.nodes.find((n) => n.id === nodeId) ?? null;
}
+
+/**
+ * Attach a typed playback asset (entry clip, hold loop, or transition exit) onto
+ * a node with optional audio occupancy manifest and provenance.
+ */
+export async function attachNodePlaybackAsset(loomId, episodeId, nodeId, {
+ role = 'entry',
+ videoHistoryId,
+ transitionId = null,
+ audioOccupancy = null,
+ provenance = null,
+} = {}) {
+ if (!isValidLoomId(loomId) || !isSafeVideoHistoryId(videoHistoryId)) return null;
+ const updated = await mutateLoom(loomId, (loom) => {
+ const episode = loom.episodes.find((e) => e.id === episodeId);
+ const node = episode?.nodes.find((n) => n.id === nodeId);
+ if (!node) return null;
+
+ const currentAssets = node.playbackAssets || {
+ entryVideoHistoryId: null,
+ holdLoopVideoHistoryIds: [],
+ exitByTransition: {},
+ audioOccupancy: {},
+ };
+
+ const nextAssets = {
+ ...currentAssets,
+ exitByTransition: { ...(currentAssets.exitByTransition || {}) },
+ audioOccupancy: { ...(currentAssets.audioOccupancy || {}) },
+ holdLoopVideoHistoryIds: [...(currentAssets.holdLoopVideoHistoryIds || [])],
+ };
+
+ if (role === 'entry') {
+ nextAssets.entryVideoHistoryId = videoHistoryId;
+ node.videoHistoryId = videoHistoryId;
+ } else if (role === 'hold') {
+ if (!nextAssets.holdLoopVideoHistoryIds.includes(videoHistoryId)) {
+ nextAssets.holdLoopVideoHistoryIds.push(videoHistoryId);
+ }
+ if (!node.videoHistoryId) node.videoHistoryId = videoHistoryId;
+ } else if (role === 'exit' && isStr(transitionId)) {
+ nextAssets.exitByTransition[transitionId] = videoHistoryId;
+ }
+
+ if (audioOccupancy) {
+ nextAssets.audioOccupancy[videoHistoryId] = audioOccupancy;
+ }
+ if (provenance) {
+ nextAssets.provenance = provenance;
+ }
+
+ node.playbackAssets = nextAssets;
+ episode.updatedAt = new Date().toISOString();
+ return loom;
+ }).catch(() => null);
+
+ return updated?.episodes.find((e) => e.id === episodeId)?.nodes.find((n) => n.id === nodeId) ?? null;
+}
+
diff --git a/server/services/fableLoom/records.test.js b/server/services/fableLoom/records.test.js
index 4d6324e2ea..0a5e576edb 100644
--- a/server/services/fableLoom/records.test.js
+++ b/server/services/fableLoom/records.test.js
@@ -24,7 +24,8 @@ const getSeriesMock = vi.hoisted(() => vi.fn(async (id) => ({ id })));
vi.mock('../pipeline/series.js', () => ({ getSeries: getSeriesMock }));
const {
- LOOM_LIMITS, addEpisode, addNode, addNodeTransition, attachNodeImage, attachNodeVideo, createLoom,
+ LOOM_LIMITS, addEpisode, addNode, addNodeTransition, attachNodeImage,
+ attachNodePlaybackAsset, attachNodeVideo, createLoom,
deleteEpisode, deleteLoom, deleteNode, deleteNodeTransition, getLoom,
listLooms, listLoomSummaries, mergeLoomsFromSync, pruneTombstonedLooms,
restoreLoom, sanitizeLoom, updateEpisode, updateLoom,
@@ -590,3 +591,89 @@ describe('attachNodeVideo', () => {
expect(await attachNodeVideo(loom.id, episodeId, 'node-gone', { videoHistoryId: 'video-1' })).toBeNull();
});
});
+
+describe('attachNodePlaybackAsset and node playback fields', () => {
+ it('attaches entry, hold loops, exit transitions, and audio occupancy', async () => {
+ const loom = await makeLoom();
+ let updated = await addEpisode(loom.id, {});
+ const episodeId = updated.episodes[0].id;
+ updated = await addNode(loom.id, episodeId, {
+ title: 'Courtyard',
+ interactionWindow: {
+ enabled: true,
+ protagonistCharacterId: 'char-1',
+ protagonistPresence: 'offscreen',
+ ambientDuckDb: -10,
+ },
+ });
+ const node = updated.episodes[0].nodes[0];
+
+ // Attach entry
+ let attached = await attachNodePlaybackAsset(loom.id, episodeId, node.id, {
+ role: 'entry',
+ videoHistoryId: 'video-entry-1',
+ });
+ expect(attached.playbackAssets.entryVideoHistoryId).toBe('video-entry-1');
+ expect(attached.videoHistoryId).toBe('video-entry-1'); // back-compat
+
+ // Attach hold loop with occupancy manifest
+ attached = await attachNodePlaybackAsset(loom.id, episodeId, node.id, {
+ role: 'hold',
+ videoHistoryId: 'video-hold-1',
+ audioOccupancy: {
+ durationMs: 5000,
+ music: [{ startMs: 0, endMs: 5000 }],
+ },
+ });
+ expect(attached.playbackAssets.holdLoopVideoHistoryIds).toEqual(['video-hold-1']);
+ expect(attached.playbackAssets.audioOccupancy['video-hold-1'].safeForLiveVoice).toBe(true);
+
+ // Attach exit transition
+ attached = await attachNodePlaybackAsset(loom.id, episodeId, node.id, {
+ role: 'exit',
+ transitionId: 'tr-escape',
+ videoHistoryId: 'video-exit-1',
+ });
+ expect(attached.playbackAssets.exitByTransition['tr-escape']).toBe('video-exit-1');
+
+ const reloaded = await getLoom(loom.id);
+ const reloadedNode = reloaded.episodes[0].nodes[0];
+ expect(reloadedNode.interactionWindow).toMatchObject({
+ enabled: true,
+ protagonistCharacterId: 'char-1',
+ protagonistPresence: 'offscreen',
+ ambientDuckDb: -10,
+ });
+ expect(reloadedNode.playbackAssets).toMatchObject({
+ entryVideoHistoryId: 'video-entry-1',
+ holdLoopVideoHistoryIds: ['video-hold-1'],
+ exitByTransition: { 'tr-escape': 'video-exit-1' },
+ });
+ });
+
+ it('updates interactionWindow and playbackAssets through updateNode', async () => {
+ const loom = await makeLoom();
+ let updated = await addEpisode(loom.id, {});
+ const episodeId = updated.episodes[0].id;
+ updated = await addNode(loom.id, episodeId, { title: 'Tavern' });
+ const nodeId = updated.episodes[0].nodes[0].id;
+
+ const patchedLoom = await updateNode(loom.id, episodeId, nodeId, {
+ interactionWindow: {
+ enabled: true,
+ protagonistCharacterId: 'char-2',
+ ambientDuckDb: -6,
+ },
+ playbackAssets: {
+ entryVideoHistoryId: 'vid-e',
+ holdLoopVideoHistoryIds: ['vid-h1', 'vid-h2'],
+ },
+ });
+
+ const targetNode = patchedLoom.episodes[0].nodes.find((n) => n.id === nodeId);
+ expect(targetNode.interactionWindow.enabled).toBe(true);
+ expect(targetNode.interactionWindow.ambientDuckDb).toBe(-6);
+ expect(targetNode.playbackAssets.holdLoopVideoHistoryIds).toEqual(['vid-h1', 'vid-h2']);
+ });
+});
+
diff --git a/server/services/fableLoom/weave.js b/server/services/fableLoom/weave.js
index 8b3ab53967..1ffb9b2c90 100644
--- a/server/services/fableLoom/weave.js
+++ b/server/services/fableLoom/weave.js
@@ -650,6 +650,8 @@ export const publicNode = (node) => ({
prose: node.prose,
image: node.image,
videoHistoryId: node.videoHistoryId,
+ playbackAssets: node.playbackAssets || null,
+ interactionWindow: node.interactionWindow || null,
playbackMode: node.playbackMode,
audienceConnection: asFableLoomAudienceConnection(node.audienceConnection),
isEnding: node.isEnding,