diff --git a/backend/src/services/__tests__/videoProcessor.test.js b/backend/src/services/__tests__/videoProcessor.test.js
index ec88190c..a6638b1d 100644
--- a/backend/src/services/__tests__/videoProcessor.test.js
+++ b/backend/src/services/__tests__/videoProcessor.test.js
@@ -108,7 +108,7 @@ describe('processUploadedVideo degrades gracefully instead of rejecting the whol
expect(storage.putFromFile).not.toHaveBeenCalled();
});
- it('still resolves with a null thumbnail when metadata, thumbnail generation, AND the placeholder all fail — never throws, never blocks the upload', async () => {
+ it('throws when metadata, thumbnail generation, AND the placeholder all fail, so the caller surfaces a retryable failure instead of completing with nothing to show (codex review)', async () => {
ffmpeg.ffprobe = jest.fn((videoPath, cb) => cb(new Error('Invalid data found when processing input')));
ffmpeg.mockImplementation(() => ({
screenshots() { return this; },
@@ -119,8 +119,7 @@ describe('processUploadedVideo degrades gracefully instead of rejecting the whol
}));
generateVideoPlaceholder.mockRejectedValue(new Error('sharp render failed'));
- const result = await processUploadedVideo('/tmp/corrupt.mp4', 'thumbnails/thumb_corrupt.jpg');
-
- expect(result).toEqual({ success: true, metadata: null, thumbnailKey: null });
+ await expect(processUploadedVideo('/tmp/corrupt.mp4', 'thumbnails/thumb_corrupt.jpg'))
+ .rejects.toThrow('Unable to generate any thumbnail');
});
});
diff --git a/backend/src/services/videoProcessor.js b/backend/src/services/videoProcessor.js
index 21182532..eedad31d 100644
--- a/backend/src/services/videoProcessor.js
+++ b/backend/src/services/videoProcessor.js
@@ -145,12 +145,18 @@ async function getVideoDuration(videoPath) {
* generateVideoThumbnail() would have succeeded on its own — thumbnailing
* doesn't need valid duration/width/height, it just seeks and grabs a frame.
* Trying both steps independently means a real thumbnail (and whatever
- * metadata ffprobe *can* read) survives far more often; the callers' throw
- * handling stays as a backstop for anything still unexpected.
+ * metadata ffprobe *can* read) survives far more often. metadata is still
+ * allowed to come back null (ffprobe failed) — a video with no thumbnail
+ * would fall back to rendering the raw video as an
in the gallery
+ * grid (`photo.thumbnail_url || photo.url`), so this only resolves when a
+ * real thumbnail or the SVG placeholder produced *something*; if both fail
+ * (storage backend down, disk full — not a quirk of one file) it throws
+ * instead, so the caller surfaces a retryable failure rather than silently
+ * completing with nothing to show.
*
* @param {string} videoPath - Local path to the source video (ffmpeg requires fs).
* @param {string} thumbnailKey - Relative storage key for the thumbnail.
- * @returns {Promise<{success: boolean, metadata: Object|null, thumbnailKey: string|null}>}
+ * @returns {Promise<{success: boolean, metadata: Object|null, thumbnailKey: string}>}
*/
async function processUploadedVideo(videoPath, thumbnailKey, options = {}) {
let metadata = null;
@@ -212,6 +218,18 @@ async function processUploadedVideo(videoPath, thumbnailKey, options = {}) {
}
}
+ // A real thumbnail AND the ffmpeg-free SVG placeholder both failing points
+ // at something systemic (storage backend down, disk full) rather than a
+ // quirk of this one file — that's worth surfacing as a retryable failure
+ // rather than silently completing with no thumbnail at all, which would
+ // make the gallery fall back to rendering the raw video as an
+ // (codex review, #1371/#1372). Metadata (if any was extracted) is lost
+ // here, same trade-off the callers' own pre-existing total-failure
+ // handling already makes.
+ if (!generatedThumbnailKey) {
+ throw new Error('Unable to generate any thumbnail (real or placeholder) for this video');
+ }
+
return {
success: true,
metadata,