From b387d9be0790746a884db8b2e8bae486260e6184 Mon Sep 17 00:00:00 2001 From: Bryandero98 Date: Thu, 3 Sep 2026 14:37:31 -0500 Subject: [PATCH] fix: fableLoom asset manifest omits playbackAssets video clips (#6006) buildFableLoomAssetManifest only collected video ids from the legacy node.videoHistoryId field, so a scene render's entry clip, hold-loop clips, and per-transition exit clips (all under node.playbackAssets) never made it into the peer-sync manifest and were silently dropped from federation. Collects ids from entryVideoHistoryId, holdLoopVideoHistoryIds[], and Object.values(exitByTransition) as well, deduping against the legacy field. Co-Authored-By: Claude Sonnet 5 --- server/services/sharing/peerSyncAssets.js | 16 ++++++- .../services/sharing/peerSyncAssets.test.js | 45 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/server/services/sharing/peerSyncAssets.js b/server/services/sharing/peerSyncAssets.js index 7bfdaafab3..2ded1ae8d2 100644 --- a/server/services/sharing/peerSyncAssets.js +++ b/server/services/sharing/peerSyncAssets.js @@ -565,8 +565,22 @@ export async function buildFableLoomAssetManifest(loom) { for (const entry of await Promise.all(imageNames.map((name) => hashImageForManifest(name)))) { if (entry) dedup.set(`${entry.kind}:${entry.filename}`, entry); } + // Typed playback assets (hold loops, transition exit clips, and an explicit + // entry clip) live under node.playbackAssets alongside the legacy single + // node.videoHistoryId — all reference on-disk .mp4 files under + // PATHS.videos and must all be advertised, or a receiving peer never + // requests them and playback breaks post-sync (#6006). const videoIds = [...new Set( - nodes.map((node) => (isStr(node?.videoHistoryId) ? node.videoHistoryId : null)).filter(Boolean), + nodes.flatMap((node) => { + const ids = [node?.videoHistoryId, node?.playbackAssets?.entryVideoHistoryId]; + if (Array.isArray(node?.playbackAssets?.holdLoopVideoHistoryIds)) { + ids.push(...node.playbackAssets.holdLoopVideoHistoryIds); + } + if (node?.playbackAssets?.exitByTransition && typeof node.playbackAssets.exitByTransition === 'object') { + ids.push(...Object.values(node.playbackAssets.exitByTransition)); + } + return ids.filter(isStr); + }), )]; if (videoIds.length > 0) { const entries = await Promise.all(videoIds.map((id) => diff --git a/server/services/sharing/peerSyncAssets.test.js b/server/services/sharing/peerSyncAssets.test.js index 6c0041f984..a2f56b1677 100644 --- a/server/services/sharing/peerSyncAssets.test.js +++ b/server/services/sharing/peerSyncAssets.test.js @@ -332,6 +332,51 @@ describe('buildFableLoomAssetManifest — scene renders', () => { }); expect(manifest).toHaveLength(2); }); + + it('includes typed playback assets — entry, hold loops, and transition exits (#6006)', async () => { + const entryBytes = Buffer.from('entry-clip'); + const holdBytes = Buffer.from('hold-clip'); + const exitBytes = Buffer.from('exit-clip'); + writeVideo('entry-1.mp4', entryBytes); + writeVideo('hold-1.mp4', holdBytes); + writeVideo('exit-1.mp4', exitBytes); + + const manifest = await buildFableLoomAssetManifest({ + episodes: [{ + nodes: [{ + id: 'node-1', + // No legacy node.videoHistoryId — only typed playbackAssets. + playbackAssets: { + entryVideoHistoryId: 'entry-1', + holdLoopVideoHistoryIds: ['hold-1'], + exitByTransition: { 'trans-1': 'exit-1' }, + }, + }], + }], + }); + + expect(manifest).toContainEqual({ filename: 'entry-1.mp4', kind: 'video', sha256: sha(entryBytes) }); + expect(manifest).toContainEqual({ filename: 'hold-1.mp4', kind: 'video', sha256: sha(holdBytes) }); + expect(manifest).toContainEqual({ filename: 'exit-1.mp4', kind: 'video', sha256: sha(exitBytes) }); + expect(manifest).toHaveLength(3); + }); + + it('deduplicates a video id referenced by both node.videoHistoryId and playbackAssets', async () => { + const videoBytes = Buffer.from('shared-clip'); + writeVideo('shared-1.mp4', videoBytes); + + const manifest = await buildFableLoomAssetManifest({ + episodes: [{ + nodes: [{ + id: 'node-1', + videoHistoryId: 'shared-1', + playbackAssets: { entryVideoHistoryId: 'shared-1', holdLoopVideoHistoryIds: ['shared-1'] }, + }], + }], + }); + + expect(manifest).toEqual([{ filename: 'shared-1.mp4', kind: 'video', sha256: sha(videoBytes) }]); + }); }); describe('pullMissingAssetsFromPeer — unsafe and incomplete downloads (#5230)', () => {