From 960d3e365b80911171b989449c3cbdbbee60c67e Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Sat, 15 Aug 2026 05:20:54 +0000 Subject: [PATCH] fix: sync directive plan renders to peers (#4159) --- .changelog/next/fixed-issue-4159.md | 1 + server/services/sharing/peerSyncAssets.js | 36 +++++++++++++---- .../services/sharing/peerSyncAssets.test.js | 39 +++++++++++++++++++ 3 files changed, 69 insertions(+), 7 deletions(-) create mode 100644 .changelog/next/fixed-issue-4159.md diff --git a/.changelog/next/fixed-issue-4159.md b/.changelog/next/fixed-issue-4159.md new file mode 100644 index 0000000000..ffa34200a8 --- /dev/null +++ b/.changelog/next/fixed-issue-4159.md @@ -0,0 +1 @@ +- Creative Director plan render previews now sync to selected peers. diff --git a/server/services/sharing/peerSyncAssets.js b/server/services/sharing/peerSyncAssets.js index 2647ddc5fb..1c6d05792f 100644 --- a/server/services/sharing/peerSyncAssets.js +++ b/server/services/sharing/peerSyncAssets.js @@ -362,12 +362,12 @@ export async function buildTrackAssetManifest(track) { * VIDEO renders are NOT hashed here: they live in the project's linked media * collection, which federates as its own record and ships its bytes via that * collection's manifest — duplicating them here would double the transfer. - * The music bed has no such alternate channel (unlike scene video, it isn't - * filed into the linked collection), so without bundling it here a subscribed - * peer would receive a dangling `musicBed.filename` reference — mirrors how - * `buildTrackAssetManifest` / `buildMusicVideoAssetManifest` bundle their - * `PATHS.music` audio. Each direct asset is missing-local-file skipped - * silently (mirrors buildAuthorAssetManifest). + * Directive-plan render steps do not enter that collection, so their settled + * image/video job ids are bundled directly. The music bed has no alternate + * channel either, so without bundling it a subscribed peer would receive a + * dangling `musicBed.filename` reference — mirrors how `buildTrackAssetManifest` + * / `buildMusicVideoAssetManifest` bundle their `PATHS.music` audio. Each direct + * asset is missing-local-file skipped silently (mirrors buildAuthorAssetManifest). */ export async function buildProjectAssetManifest(project) { const entries = []; @@ -381,6 +381,28 @@ export async function buildProjectAssetManifest(project) { const musicEntry = await hashSimpleAsset(musicBedFilename, 'music', PATHS.music); if (musicEntry) entries.push(musicEntry); } + const planSteps = Array.isArray(project?.plan?.steps) ? project.plan.steps : []; + const renderEntries = await Promise.all(planSteps.map((step) => { + const jobId = step?.status === 'done' && isStr(step?.result?.jobId) && step.result.jobId.trim() + ? step.result.jobId.trim() + : null; + if (!jobId) return null; + if (step.toolName === 'media_enqueueImageJob') { + return hashImageForManifest(`${jobId}.png`); + } + if (step.toolName === 'media_enqueueVideoJob') { + return hashSimpleAsset(`${jobId}.mp4`, 'video', PATHS.videos); + } + return null; + })); + const seen = new Set(entries.map((entry) => `${entry.kind}:${entry.filename}`)); + for (const entry of renderEntries) { + const key = entry && `${entry.kind}:${entry.filename}`; + if (entry && !seen.has(key)) { + seen.add(key); + entries.push(entry); + } + } return entries; } @@ -909,4 +931,4 @@ async function doPullOneAsset(peer, base, entry, urlPrefix, localDir, safeName) const videoPath = join(localDir, safeName); await generateThumbnail(videoPath, jobId).catch(() => null); } -} \ No newline at end of file +} diff --git a/server/services/sharing/peerSyncAssets.test.js b/server/services/sharing/peerSyncAssets.test.js index 9d117c7662..6713be30e8 100644 --- a/server/services/sharing/peerSyncAssets.test.js +++ b/server/services/sharing/peerSyncAssets.test.js @@ -162,6 +162,45 @@ describe('buildProjectAssetManifest — first-pass music bed (#1928)', () => { expect(manifest).toContainEqual(expect.objectContaining({ filename: 'music-gen-def.wav', kind: 'music', sha256: sha(musicBytes) })); expect(manifest).toHaveLength(2); }); + + it('bundles completed directive-plan image and video renders that have no collection channel (#4159)', async () => { + const imageBytes = Buffer.from('directive-image'); + const videoBytes = Buffer.from('directive-video'); + writeImage('plan-image.png', imageBytes); + writeVideo('plan-video.mp4', videoBytes); + const manifest = await buildProjectAssetManifest({ + plan: { + steps: [ + { toolName: 'media_enqueueImageJob', status: 'done', result: { jobId: 'plan-image' } }, + { toolName: 'media_enqueueVideoJob', status: 'done', result: { jobId: 'plan-video' } }, + ], + }, + }); + expect(manifest).toContainEqual(expect.objectContaining({ + filename: 'plan-image.png', kind: 'image', sha256: sha(imageBytes), + })); + expect(manifest).toContainEqual({ filename: 'plan-video.mp4', kind: 'video', sha256: sha(videoBytes) }); + }); + + it('ignores unfinished, non-render, missing, and duplicate directive-plan results', async () => { + const imageBytes = Buffer.from('one-render'); + writeImage('done-image.png', imageBytes); + const manifest = await buildProjectAssetManifest({ + plan: { + steps: [ + { toolName: 'media_enqueueImageJob', status: 'done', result: { jobId: 'done-image' } }, + { toolName: 'media_enqueueImageJob', status: 'done', result: { jobId: 'done-image' } }, + { toolName: 'media_enqueueImageJob', status: 'done', result: { jobId: ' ' } }, + { toolName: 'media_enqueueImageJob', status: 'running', result: { jobId: 'still-running' } }, + { toolName: 'pipeline_createSeries', status: 'done', result: { jobId: 'not-media' } }, + { toolName: 'media_enqueueVideoJob', status: 'done', result: { jobId: 'missing-video' } }, + ], + }, + }); + expect(manifest).toEqual([expect.objectContaining({ + filename: 'done-image.png', kind: 'image', sha256: sha(imageBytes), + })]); + }); }); describe('buildBoardAssetManifest — video items (#4188)', () => {