diff --git a/backend/src/services/__tests__/videoProcessor.test.js b/backend/src/services/__tests__/videoProcessor.test.js index 2b34eaa8..cf650a2b 100644 --- a/backend/src/services/__tests__/videoProcessor.test.js +++ b/backend/src/services/__tests__/videoProcessor.test.js @@ -3,9 +3,13 @@ jest.mock('fluent-ffmpeg'); jest.mock('../storage', () => ({ getStorage: jest.fn() })); +jest.mock('../imageProcessor', () => ({ + generateVideoPlaceholder: jest.fn() +})); const ffmpeg = require('fluent-ffmpeg'); const { getStorage } = require('../storage'); +const { generateVideoPlaceholder } = require('../imageProcessor'); const { extractVideoMetadata, processUploadedVideo @@ -46,6 +50,7 @@ describe('processUploadedVideo degrades gracefully instead of rejecting the whol beforeEach(() => { storage = { putFromFile: jest.fn().mockResolvedValue(undefined), exists: jest.fn().mockResolvedValue(true) }; getStorage.mockReturnValue(storage); + generateVideoPlaceholder.mockResolvedValue('thumbnails/thumb_placeholder.jpg'); }); afterEach(() => jest.clearAllMocks()); @@ -68,9 +73,11 @@ describe('processUploadedVideo degrades gracefully instead of rejecting the whol expect(result.success).toBe(true); expect(result.metadata).toBeNull(); expect(result.thumbnailKey).toBe('thumbnails/thumb_video.jpg'); + // A real thumbnail already succeeded — never touch the placeholder path. + expect(generateVideoPlaceholder).not.toHaveBeenCalled(); }); - it('keeps the metadata when only thumbnail generation fails', async () => { + it('falls back to the SVG placeholder when thumbnail generation fails, so the gallery never falls back to rendering the raw video as an (codex review)', async () => { ffmpeg.ffprobe = jest.fn((videoPath, cb) => { cb(null, { streams: [{ codec_type: 'video', width: 1080, height: 1920, codec_name: 'h264' }], @@ -85,15 +92,18 @@ describe('processUploadedVideo degrades gracefully instead of rejecting the whol } })); - const result = await processUploadedVideo('/tmp/video.mp4', 'thumbnails/thumb_video.jpg'); + const result = await processUploadedVideo('/tmp/video.mp4', 'thumbnails/thumb_wedding_001.jpg'); expect(result.success).toBe(true); expect(result.metadata).toEqual(expect.objectContaining({ duration: 5, videoCodec: 'h264' })); - expect(result.thumbnailKey).toBeNull(); + // thumbnailKey is always thumbnails/thumb_.jpg — strip the prefix + // back to a filename so generateVideoPlaceholder recomputes the same key. + expect(generateVideoPlaceholder).toHaveBeenCalledWith('wedding_001.jpg'); + expect(result.thumbnailKey).toBe('thumbnails/thumb_placeholder.jpg'); expect(storage.putFromFile).not.toHaveBeenCalled(); }); - it('still succeeds with both null when metadata AND thumbnail fail — never throws, never blocks the upload', async () => { + it('still resolves with a null thumbnail when metadata, thumbnail generation, AND the placeholder all fail — never throws, never blocks the upload', async () => { ffmpeg.ffprobe = jest.fn((videoPath, cb) => cb(new Error('Invalid data found when processing input'))); ffmpeg.mockImplementation(() => ({ screenshots() { return this; }, @@ -102,6 +112,7 @@ describe('processUploadedVideo degrades gracefully instead of rejecting the whol return this; } })); + generateVideoPlaceholder.mockRejectedValue(new Error('sharp render failed')); const result = await processUploadedVideo('/tmp/corrupt.mp4', 'thumbnails/thumb_corrupt.jpg'); diff --git a/backend/src/services/videoProcessor.js b/backend/src/services/videoProcessor.js index b94268d5..803e351f 100644 --- a/backend/src/services/videoProcessor.js +++ b/backend/src/services/videoProcessor.js @@ -177,6 +177,30 @@ async function processUploadedVideo(videoPath, thumbnailKey, options = {}) { }); } + // Never return "success" with no thumbnail at all: the gallery grid + // (GridGalleryLayout/JustifiedGalleryLayout) falls back to + // `photo.thumbnail_url || photo.url` when there's no thumbnail, which + // makes AuthenticatedImage download the full ORIGINAL VIDEO and try to + // render it as an — a broken tile and a multi-GB fetch just from + // opening the gallery (codex review, #1371/#1372). Fall back to the same + // ffmpeg-free SVG placeholder the callers already generate for a total + // processing failure, so a bare thumbnail-generation failure degrades to + // that placeholder too, not to "no thumbnail". thumbnailKey is always + // `thumbnails/thumb_.jpg` (see callers) — strip the prefix back to + // a filename so generateVideoPlaceholder recomputes this exact same key. + if (!generatedThumbnailKey) { + try { + const { generateVideoPlaceholder } = require('./imageProcessor'); + const placeholderFilename = path.basename(thumbnailKey).replace(/^thumb_/, ''); + const placeholderKey = await generateVideoPlaceholder(placeholderFilename); + if (placeholderKey) { + generatedThumbnailKey = placeholderKey; + } + } catch (error) { + logger.error('Video placeholder generation also failed', { error: error.message, videoPath }); + } + } + return { success: true, metadata,