diff --git a/client/src/components/fableloom/LoomNodeEditor.jsx b/client/src/components/fableloom/LoomNodeEditor.jsx index 8009baa945..37fdd2d293 100644 --- a/client/src/components/fableloom/LoomNodeEditor.jsx +++ b/client/src/components/fableloom/LoomNodeEditor.jsx @@ -27,7 +27,12 @@ import { fieldClass, labelClass, sceneFieldClass } from './fieldStyles'; import { isTeleplayFormat } from './loomFormats'; import LoomSceneMedia from './LoomSceneMedia'; import { FABLELOOM_CAMERA_MOVEMENTS } from '../../../../server/lib/fableLoomCameraMovements.js'; -import { FABLELOOM_PLAYBACK_MODES } from '../../../../server/lib/fableLoomPlayback.js'; +import { + FABLELOOM_HOLD_ROTATION_MODES, + FABLELOOM_PLAYBACK_MODES, + FABLELOOM_PROTAGONIST_PRESENCE, + inspectNodeProductionReadiness, +} from '../../../../server/lib/fableLoomPlayback.js'; import { FABLELOOM_AUDIENCE_CONNECTION_STATES } from '../../../../server/lib/fableLoomParticipation.js'; const toRow = (t) => ({ ...t, triggersText: (t.triggers || []).join('; ') }); @@ -67,6 +72,15 @@ export default function LoomNodeEditor({ cameraMovement: node.cameraMovement || '', playbackMode: node.playbackMode || 'decision', audienceConnection: node.audienceConnection || 'disconnected', + playbackAssets: node.playbackAssets || null, + interactionWindow: node.interactionWindow || { + enabled: false, + protagonistCharacterId: null, + protagonistPresence: 'offscreen', + audioTarget: 'host', + ambientDuckDb: -8, + holdLoopRotation: 'deterministic', + }, isEnding: !!node.isEnding, endingLabel: node.endingLabel || '', transitions: (node.transitions || []).map(toRow), @@ -333,6 +347,121 @@ export default function LoomNodeEditor({ )} +
+ Live interaction & voice +
+ + + {form.interactionWindow?.enabled && ( +
+ + { + const next = { ...form.interactionWindow, protagonistCharacterId: e.target.value }; + setForm((p) => ({ ...p, interactionWindow: next })); + }} + onBlur={() => patchNode({ interactionWindow: form.interactionWindow })} + /> + + +
+ + + + + + + +
+ + + { + const next = { ...form.interactionWindow, ambientDuckDb: parseInt(e.target.value, 10) }; + setForm((p) => ({ ...p, interactionWindow: next })); + }} + onMouseUp={() => patchNode({ interactionWindow: form.interactionWindow })} + onTouchEnd={() => patchNode({ interactionWindow: form.interactionWindow })} + /> + +
+ )} + + {(() => { + const readiness = inspectNodeProductionReadiness(node, { loom }); + if (!readiness.findings.length) return null; + return ( +
+ + Readiness checks ({readiness.errorCount} error{readiness.errorCount === 1 ? '' : 's'}) + + {readiness.findings.map((f, i) => ( +
+

{f.message}

+ {f.remediation &&

Tip: {f.remediation}

} +
+ ))} +
+ ); + })()} +
+
+
Scene media { await waitFor(() => expect(screen.getByLabelText('Playback behavior')).toHaveValue('decision')); }); + + it('toggles live interaction window and patches node', async () => { + const user = userEvent.setup(); + updateLoomNode.mockResolvedValue({ id: 'loom-1' }); + renderEditor(); + + const checkbox = screen.getByLabelText('Live conversation window (off-screen voice)'); + expect(checkbox).not.toBeChecked(); + + await user.click(checkbox); + + await waitFor(() => expect(updateLoomNode).toHaveBeenCalledWith( + 'loom-1', 'ep-1', 'n1', + { + interactionWindow: expect.objectContaining({ + enabled: true, + }), + }, + { silent: true }, + )); + + expect(screen.getByLabelText('Protagonist Character ID')).toBeInTheDocument(); + expect(screen.getByLabelText('Protagonist presence')).toBeInTheDocument(); + }); + + it('displays production readiness findings for unsafe hold loop dialogue', () => { + const nodes = makeNodes([existingPath]); + nodes[0].interactionWindow = { enabled: true, protagonistCharacterId: 'char-1' }; + nodes[0].playbackAssets = { + holdLoopVideoHistoryIds: ['vid-hold-1'], + audioOccupancy: { + 'vid-hold-1': { + characterDialogue: [{ startMs: 0, endMs: 2000 }], + }, + }, + }; + + render( + {}} + />, + ); + + expect(screen.getByText(/contains rendered character dialogue/)).toBeInTheDocument(); + expect(screen.getByText(/Tip: Render dialogue separately/)).toBeInTheDocument(); + }); }); + diff --git a/client/src/components/fableloom/LoomPlayPanel.jsx b/client/src/components/fableloom/LoomPlayPanel.jsx index 514676c9e7..d6d2642b63 100644 --- a/client/src/components/fableloom/LoomPlayPanel.jsx +++ b/client/src/components/fableloom/LoomPlayPanel.jsx @@ -14,12 +14,13 @@ */ import { useEffect, useMemo, useRef, useState } from 'react'; -import { Loader2, RotateCcw, Send, Flag } from 'lucide-react'; +import { Loader2, RotateCcw, Send, Flag, Volume2, Mic, CheckCircle2, AlertCircle } from 'lucide-react'; import MediaImage from '../MediaImage'; import { useAsyncAction } from '../../hooks/useAsyncAction'; import { playLoomTurn } from '../../services/api'; import { sceneProseClass } from './fieldStyles'; import { audienceCanParticipate } from '../../../../server/lib/fableLoomParticipation.js'; +import { resolvePlaybackPhaseAsset } from '../../../../server/lib/fableLoomPlayback.js'; const findNode = (episode, id) => episode?.nodes.find((n) => n.id === id) || null; const hasPlayableStart = (episode) => !!findNode(episode, episode?.startNodeId); @@ -33,6 +34,8 @@ const asPublic = (node) => (node ? { prose: node.prose, image: node.image, videoHistoryId: node.videoHistoryId, + playbackAssets: node.playbackAssets || null, + interactionWindow: node.interactionWindow || null, playbackMode: node.playbackMode || 'decision', audienceConnection: node.audienceConnection || 'disconnected', isEnding: !!node.isEnding, @@ -40,6 +43,17 @@ const asPublic = (node) => (node ? { choices: (node.transitions || []).map((t) => ({ id: t.id, intent: t.intent })), } : null); +const initialPhaseForNode = (node) => { + if (!node) return 'ended'; + if (node.isEnding) return 'ended'; + if (node.playbackAssets?.entryVideoHistoryId) return 'entry'; + if (node.playbackAssets?.holdLoopVideoHistoryIds?.length) return 'hold'; + if (node.videoHistoryId) { + return node.playbackMode === 'cut' ? 'entry' : 'hold'; + } + return 'hold'; +}; + export default function LoomPlayPanel({ loom, episode: initialEpisode }) { const [playEpisodeId, setPlayEpisodeId] = useState(initialEpisode.id); const episode = loom.episodes?.find((item) => item.id === playEpisodeId) || initialEpisode; @@ -57,10 +71,14 @@ export default function LoomPlayPanel({ loom, episode: initialEpisode }) { [episode.id, episode.startNodeId], ); const [scene, setScene] = useState(start); + const [playbackPhase, setPlaybackPhase] = useState(() => initialPhaseForNode(start)); + const [activeHoldIndex, setActiveHoldIndex] = useState(0); + const [pendingTransition, setPendingTransition] = useState(null); const [transcript, setTranscript] = useState(() => (start ? [{ role: 'scene', node: start }] : [])); const [message, setMessage] = useState(''); const [previewMode, setPreviewMode] = useState('text'); const [failedVideoId, setFailedVideoId] = useState(null); + const [showInspector, setShowInspector] = useState(false); const scrollRef = useRef(null); // Mirrors the server's terminal rule: an ending, or a dead-end scene with // no paths out, ends the read-through. @@ -70,8 +88,19 @@ export default function LoomPlayPanel({ loom, episode: initialEpisode }) { && scene.choices?.length > 0 && (!audienceConnected || (scene.playbackMode === 'cut' && scene.choices.length === 1)); + // Resolve current active video asset and occupancy + const currentAsset = useMemo(() => resolvePlaybackPhaseAsset({ + node: scene, + phase: playbackPhase, + activeHoldIndex, + transitionId: pendingTransition?.id || null, + }), [scene, playbackPhase, activeHoldIndex, pendingTransition]); + const restart = () => { setScene(start); + setPlaybackPhase(initialPhaseForNode(start)); + setActiveHoldIndex(0); + setPendingTransition(null); setTranscript(start ? [{ role: 'scene', node: start }] : []); setMessage(''); setFailedVideoId(null); @@ -106,6 +135,9 @@ export default function LoomPlayPanel({ loom, episode: initialEpisode }) { if (result.narration) additions.push({ role: 'narrator', text: result.narration }); if (result.action === 'move' && result.node) { setScene(result.node); + setPlaybackPhase(initialPhaseForNode(result.node)); + setActiveHoldIndex(0); + setPendingTransition(null); additions.push({ role: 'scene', node: result.node }); } // A turn that moves nowhere and says nothing would read as the app @@ -118,13 +150,21 @@ export default function LoomPlayPanel({ loom, episode: initialEpisode }) { }, { errorMessage: 'The narrator lost the thread — try again' }); // Tapping a path: the reader already named the transition, so the turn - // carries its id and the server moves them without an intent-matching call. + // carries its id. If a transition-specific exit clip exists, rehearse the exit + // clip before committing the move. const takePath = (choice) => { if (sending || !scene) return; setMessage(''); const history = [...transcript, { role: 'reader', text: choice.intent }]; setTranscript(history); - runTurn({ transitionId: choice.id }, history); + + const hasExitClip = Boolean(scene.playbackAssets?.exitByTransition?.[choice.id]); + if (hasExitClip && previewMode === 'video') { + setPendingTransition(choice); + setPlaybackPhase('exit'); + } else { + runTurn({ transitionId: choice.id }, history); + } }; const advanceCut = () => { @@ -132,6 +172,28 @@ export default function LoomPlayPanel({ loom, episode: initialEpisode }) { runTurn({ transitionId: scene.choices[0].id }, transcript); }; + const handleVideoEnded = () => { + if (playbackPhase === 'entry') { + if (automaticCut) { + advanceCut(); + } else { + setPlaybackPhase('hold'); + setActiveHoldIndex(0); + } + } else if (playbackPhase === 'hold') { + const holdLoops = scene?.playbackAssets?.holdLoopVideoHistoryIds || []; + if (holdLoops.length > 1) { + setActiveHoldIndex((prev) => (prev + 1) % holdLoops.length); + } + } else if (playbackPhase === 'exit') { + if (pendingTransition) { + const choice = pendingTransition; + setPendingTransition(null); + runTurn({ transitionId: choice.id }, transcript); + } + } + }; + const send = () => { const text = message.trim(); if (!text || sending || !scene) return; @@ -154,12 +216,25 @@ export default function LoomPlayPanel({ loom, episode: initialEpisode }) { ); } + const liveVoiceActive = Boolean( + scene?.interactionWindow?.enabled && audienceConnected && currentAsset.safeForLiveVoice && playbackPhase === 'hold', + ); + return (

Episode {episode.number || episodeIndex + 1 || 1}: {episode.title || 'Untitled'}

- +
+ + +
+ + {showInspector && ( +
+
+ Phase: + + {playbackPhase} + + {currentAsset.videoHistoryId && ( + + Asset: {currentAsset.videoHistoryId} + + )} + {scene.interactionWindow?.enabled && ( + + {currentAsset.safeForLiveVoice ? ( + + Safe for live voice + + ) : ( + + Unsafe (dialogue/blocking) + + )} + + )} +
+ {scene.interactionWindow?.enabled && ( +
+ Duck level: {scene.interactionWindow.ambientDuckDb ?? -8} dB + Presence: {scene.interactionWindow.protagonistPresence || 'offscreen'} +
+ )} +
+ )} + + {liveVoiceActive && ( +
+ + Off-screen voice window open + + + Ambience ducked {scene?.interactionWindow?.ambientDuckDb ?? -8} dB + +
+ )} +
{transcript.map((turn, i) => { if (turn.role === 'scene') { @@ -210,11 +337,13 @@ export default function LoomPlayPanel({ loom, episode: initialEpisode }) { isOpening={scene?.id === start.id} format={loom.format} previewMode={previewMode} - onCutEnded={advanceCut} + onCutEnded={handleVideoEnded} + playbackPhase={playbackPhase} + activeAsset={currentAsset} automaticCut={automaticCut} helperMode={loom.participationMode === 'helper'} - videoFailed={!!scene.videoHistoryId && failedVideoId === scene.videoHistoryId} - onVideoError={() => setFailedVideoId(scene.videoHistoryId)} + videoFailed={Boolean(currentAsset.videoHistoryId && failedVideoId === currentAsset.videoHistoryId)} + onVideoError={() => setFailedVideoId(currentAsset.videoHistoryId)} /> {ended && (
@@ -244,12 +373,12 @@ export default function LoomPlayPanel({ loom, episode: initialEpisode }) { @@ -306,24 +435,31 @@ export default function LoomPlayPanel({ loom, episode: initialEpisode }) { function SceneCard({ node, isOpening = false, format, previewMode, onCutEnded, automaticCut, + playbackPhase = 'hold', activeAsset = null, helperMode = false, videoFailed = false, onVideoError, }) { if (!node) return null; - const showVideo = previewMode === 'video' && node.videoHistoryId && !videoFailed; + const videoId = activeAsset?.videoHistoryId || node.videoHistoryId || null; + const showVideo = previewMode === 'video' && Boolean(videoId) && !videoFailed; const showImage = previewMode === 'image' && node.image; + + // Decision nodes with single hold loop loop natively; otherwise ended event rotates or advances phase + const holdLoopCount = node.playbackAssets?.holdLoopVideoHistoryIds?.length || 0; + const shouldLoopNatively = !automaticCut && !node.isEnding && playbackPhase === 'hold' && holdLoopCount <= 1; + return (
{showVideo && (
{previewMode === 'text' &&

{node.prose}

} {previewMode === 'image' && !node.image &&

No storyboard image rendered for this cut yet.

} - {previewMode === 'video' && (!node.videoHistoryId || videoFailed) && ( + {previewMode === 'video' && (!videoId || videoFailed) && (

{videoFailed ? 'The rendered video is unavailable; advance manually or retry after rendering.' : 'No video rendered for this cut yet.'}

@@ -357,3 +493,4 @@ function SceneCard({
); } + diff --git a/client/src/components/fableloom/LoomPlayPanel.test.jsx b/client/src/components/fableloom/LoomPlayPanel.test.jsx index 7838d65ff8..aeccdd1b34 100644 --- a/client/src/components/fableloom/LoomPlayPanel.test.jsx +++ b/client/src/components/fableloom/LoomPlayPanel.test.jsx @@ -273,4 +273,114 @@ describe('LoomPlayPanel', () => { expect(screen.getByRole('button', { name: 'Play again' })).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Next: Episode 2' })).not.toBeInTheDocument(); }); + + it('rehearses entry clip, transitions to hold loop on ended, and displays live voice status', async () => { + const user = userEvent.setup(); + const productionEpisode = { + id: 'ep-prod', number: 1, title: 'Production Pilot', startNodeId: 'node-prod', + nodes: [{ + id: 'node-prod', + title: 'Courtyard', + prose: 'You arrive at the courtyard.', + playbackAssets: { + entryVideoHistoryId: 'vid-entry-1', + holdLoopVideoHistoryIds: ['vid-hold-1', 'vid-hold-2'], + exitByTransition: { 'tr-gate': 'vid-exit-gate' }, + audioOccupancy: { + 'vid-hold-1': { durationMs: 6000, music: [{ startMs: 0, endMs: 6000 }] }, + 'vid-hold-2': { durationMs: 6000, music: [{ startMs: 0, endMs: 6000 }] }, + }, + }, + interactionWindow: { + enabled: true, + protagonistCharacterId: 'char-maya', + protagonistPresence: 'offscreen', + ambientDuckDb: -10, + }, + transitions: [{ id: 'tr-gate', targetNodeId: 'node-inside', intent: 'open the gate' }], + }, { + id: 'node-inside', + title: 'Inside Sanctum', + prose: 'Inside the quiet hall.', + isEnding: true, + transitions: [], + }], + }; + + render(); + await user.selectOptions(screen.getByLabelText('Preview stage'), 'video'); + + // Initially plays entry clip + const video = screen.getByLabelText('Courtyard'); + expect(video.getAttribute('src')).toContain('vid-entry-1'); + + // When entry video ends, advances to hold loop + fireEvent.ended(video); + + // Now playing hold loop vid-hold-1 and live voice status is displayed + await waitFor(() => { + const updatedVideo = screen.getByLabelText('Courtyard'); + expect(updatedVideo.getAttribute('src')).toContain('vid-hold-1'); + }); + + expect(screen.getByText('Off-screen voice window open')).toBeInTheDocument(); + expect(screen.getByText(/Ambience ducked -10 dB/)).toBeInTheDocument(); + + // Loop ended again -> rotates to vid-hold-2 + fireEvent.ended(screen.getByLabelText('Courtyard')); + await waitFor(() => { + const rotatedVideo = screen.getByLabelText('Courtyard'); + expect(rotatedVideo.getAttribute('src')).toContain('vid-hold-2'); + }); + + // Tap path 'open the gate' -> starts exit clip vid-exit-gate + playLoomTurn.mockResolvedValue({ + action: 'move', narration: '', ended: true, + node: { id: 'node-inside', title: 'Inside Sanctum', prose: 'Inside the quiet hall.', isEnding: true, choices: [] }, + }); + + await user.click(screen.getByRole('button', { name: 'Take path: open the gate' })); + + // Rehearses exit clip before sending turn + await waitFor(() => { + const exitVideo = screen.getByLabelText('Courtyard'); + expect(exitVideo.getAttribute('src')).toContain('vid-exit-gate'); + }); + + // Exit video ends -> finishes turn and enters next node + fireEvent.ended(screen.getByLabelText('Courtyard')); + + await waitFor(() => expect(playLoomTurn).toHaveBeenCalledWith( + 'loom-1', 'ep-prod', expect.objectContaining({ transitionId: 'tr-gate' }), { silent: true }, + )); + await waitFor(() => expect(screen.getByText('Ending')).toBeInTheDocument()); + }); + + it('shows rehearsal details with inspector drawer', async () => { + const user = userEvent.setup(); + const episodeWithOccupancy = { + id: 'ep-occ', number: 1, title: 'Occupancy', startNodeId: 'node-1', + nodes: [{ + id: 'node-1', + title: 'Hall', + prose: 'A quiet hall.', + playbackAssets: { + entryVideoHistoryId: 'vid-entry-hall', + audioOccupancy: { + 'vid-entry-hall': { durationMs: 4000, safeForLiveVoice: true }, + }, + }, + interactionWindow: { enabled: true, ambientDuckDb: -8 }, + transitions: [], + }], + }; + + render(); + await user.click(screen.getByRole('button', { name: 'Rehearsal details' })); + + expect(screen.getByRole('region', { name: 'Playback rehearsal' })).toBeInTheDocument(); + expect(screen.getByText(/Duck level: -8 dB/)).toBeInTheDocument(); + expect(screen.getByText(/Asset: vid-entry-hall/)).toBeInTheDocument(); + }); }); + diff --git a/client/src/components/fableloom/LoomValidationPanel.jsx b/client/src/components/fableloom/LoomValidationPanel.jsx index e1563ab796..2d644edea6 100644 --- a/client/src/components/fableloom/LoomValidationPanel.jsx +++ b/client/src/components/fableloom/LoomValidationPanel.jsx @@ -85,6 +85,24 @@ export default function LoomValidationPanel({ loom, episode, onSelectNode }) { )}
+ {structural?.productionReadiness && ( +
+
+

Production readiness

+ + {structural.productionReadiness.ready ? 'Ready for live voice' : `${structural.productionReadiness.totalErrors} blocking error(s)`} + +
+ {structural.productionReadiness.findings?.length ? ( +
+ {structural.productionReadiness.findings.map((f, i) => findingRow(f, `pr-${i}`, f.nodeId))} +
+ ) : ( +

All scenes meet audio occupancy and off-screen voice standards.

+ )} +
+ )} +

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,