fix(uploads): keep videos when thumbnail generation fails (#845)
* fix(uploads): keep videos when thumbnail generation fails processUploadedVideo() (ffmpeg probe + thumbnail) was unguarded in both pipeline paths, while the image branch next to each already survives its thumbnail failures: - processUploadedPhotos (sync): the throw failed the whole upload — the video was lost. - processPhoto (async worker, the path real uploads take): the throw marked the row 'failed', and the guest gallery only lists 'complete' — the video became permanently invisible despite being fully uploaded. Both call sites now fall back to extractVideoMetadata() alone and keep the video without a preview; if even the probe fails, the video is kept with no metadata. Idea from the munin92 fork (2026-07-02), reimplemented for both paths + regression test. * fix(uploads): placeholder thumbnail for rescued videos (codex review of #845) A completed video with a NULL thumbnail made the gallery grid fetch the ORIGINAL video file as an <img> blob (thumbnail_url || url) — a potentially multi-GB download for a broken tile. Both fallback paths now generate the existing sharp-rendered play-button placeholder (generateVideoPlaceholder — ffmpeg-free), so rescued videos get a real tile. Test asserts the placeholder key lands in thumbnail_path.
This commit is contained in:
@@ -71,6 +71,7 @@ jest.mock('../../src/services/imageProcessor', () => {
|
||||
const mockExtractCaptureDate = jest.fn();
|
||||
return {
|
||||
generateThumbnail: mockGenerateThumbnail,
|
||||
generateVideoPlaceholder: jest.fn(async (filename) => `thumbnails/thumb_${filename.replace(/\.[^.]+$/, '')}.jpg`),
|
||||
extractCaptureDate: mockExtractCaptureDate,
|
||||
withLocalCopy: jest.fn(async (key, fn) =>
|
||||
fn(`/tmp/local-copy-${require('path').basename(key)}`)
|
||||
@@ -87,6 +88,7 @@ jest.mock('../../src/services/imageProcessor', () => {
|
||||
|
||||
jest.mock('../../src/services/videoProcessor', () => ({
|
||||
processUploadedVideo: jest.fn(),
|
||||
extractVideoMetadata: jest.fn(),
|
||||
isVideoMimeType: (mime) => typeof mime === 'string' && mime.startsWith('video/'),
|
||||
}));
|
||||
|
||||
@@ -212,6 +214,44 @@ describe('photoProcessor.processPhoto', () => {
|
||||
expect(watermarkService.generateForPhoto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps a video complete with a placeholder thumbnail when ffmpeg fails', async () => {
|
||||
dbModule.__setPhoto({
|
||||
id: 203,
|
||||
event_id: 9,
|
||||
filename: 'drone-clip.mp4',
|
||||
original_filename: 'drone.mp4',
|
||||
mime_type: 'video/mp4',
|
||||
media_type: 'video',
|
||||
size_bytes: 12345,
|
||||
captured_at: null,
|
||||
});
|
||||
dbModule.__setEvent({ id: 9, slug: 'wedding', event_name: 'Wedding' });
|
||||
|
||||
// ffmpeg thumbnail pipeline throws (e.g. unsupported pixel format)…
|
||||
videoProcessor.processUploadedVideo.mockRejectedValueOnce(new Error('ffmpeg exited with code 1'));
|
||||
// …but a plain probe still works.
|
||||
videoProcessor.extractVideoMetadata.mockResolvedValueOnce({
|
||||
duration: 42,
|
||||
videoCodec: 'hevc',
|
||||
audioCodec: 'aac',
|
||||
width: 3840,
|
||||
height: 2160,
|
||||
});
|
||||
|
||||
const { processPhoto } = require('../../src/services/photoProcessor');
|
||||
await processPhoto(203);
|
||||
|
||||
const finalUpdate = dbModule.__recorded().updateCalls.pop();
|
||||
// The row must complete — 'failed' rows are invisible to guests.
|
||||
expect(finalUpdate.data.processing_status).toBe('complete');
|
||||
// Placeholder instead of NULL: a completed video without thumbnail would
|
||||
// make the grid fetch the original video file for the tile (#845 review).
|
||||
expect(finalUpdate.data.thumbnail_path).toBe('thumbnails/thumb_drone-clip.jpg');
|
||||
expect(imageProcessor.generateVideoPlaceholder).toHaveBeenCalledWith('drone-clip.mp4');
|
||||
expect(finalUpdate.data.duration).toBe(42);
|
||||
expect(finalUpdate.data.video_codec).toBe('hevc');
|
||||
});
|
||||
|
||||
it('throws when the photo row no longer exists', async () => {
|
||||
dbModule.__setPhoto(null);
|
||||
dbModule.__setEvent({ id: 1 });
|
||||
|
||||
Reference in New Issue
Block a user