* fix(video): try metadata extraction and thumbnail generation independently processUploadedVideo() gated everything behind isValidVideo(), which rejects the whole video if ffprobe can't read even one of duration/width/height -- common on some iPhone/Lightroom-exported MP4s (issue 1370). Callers (photoProcessor.js's processPhoto and processUploadedPhotos) already catch that throw and fall back to a static placeholder thumbnail plus a metadata-only retry (codex review of #845), but that fallback never got a REAL thumbnail even when generateVideoThumbnail() would have succeeded on its own -- thumbnailing doesn't need valid duration/width/height, it just seeks and grabs a frame. processUploadedVideo now tries metadata extraction and thumbnail generation independently, keeping whichever succeeds instead of discarding both on a single failed field. The callers' existing throw handling stays as a backstop. Also: extractVideoMetadata stored duration as 0 (not null) whenever ffprobe had no duration field, masking "unknown" as a fake real zero-second clip and defeating downstream `duration != null` checks meant to skip an untrustworthy value. Relates to issue 1370 * fix(video): fall back to the SVG placeholder when thumbnail generation fails processUploadedVideo could return success with thumbnailKey: null when only thumbnail generation failed. The gallery grid (GridGalleryLayout/JustifiedGalleryLayout) falls back to `photo.thumbnail_url || photo.url` when there's no thumbnail, so AuthenticatedImage downloaded the full original video and tried to render it as an <img> -- a broken tile and a potentially huge fetch just from opening the gallery. Falls 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, never to "no thumbnail at all". Found by codex review. * fix(video): avoid a SQLite connection deadlock in the placeholder fallback generateVideoPlaceholder() unconditionally called getThumbnailSettings(), which queries the database directly (not through any active transaction). videoProcessor.js's new placeholder fallback can run from inside processUploadedPhotos' open per-file SQLite transaction (chunked video upload) -- knex's default SQLite pool has exactly one connection, so that second, un-transacted query deadlocks against the transaction holding it, timing out after acquireConnectionTimeout (60s). Reproduced directly against an isolated SQLite db. generateVideoPlaceholder now skips the settings lookup entirely when the caller supplies explicit width/height, and the video fallback passes the same DEFAULT_THUMBNAIL_WIDTH/HEIGHT the settings lookup would have fallen back to anyway (now exported for reuse). Found by codex review. * fix(video): throw when neither a real thumbnail nor the placeholder can be produced processUploadedVideo returned success with thumbnailKey: null when both the real thumbnail AND the SVG placeholder failed -- a total, systemic failure (storage backend down, disk full), not a quirk of one file. On stable, which doesn't have the #845 call-site fallback, this silently completed the video with no thumbnail at all instead of the retryable 'failed' status a throw here produces. On main, the pre-existing #845 fallback already absorbed this exact case (no behavior change there) -- verified against codex's own git-blame check of the pre-PR stable code before applying this. Now throws in that case, restoring the pre-existing "let the caller mark it failed and retryable" behavior for a genuinely unrecoverable video, while keeping every partial-failure case (the vast majority) resolving with whatever succeeded. Found by codex review. --------- Co-authored-by: Paul Nothaft <[email protected]>
126 lines
4.9 KiB
JavaScript
126 lines
4.9 KiB
JavaScript
jest.mock('../../utils/logger');
|
|
jest.mock('fluent-ffmpeg');
|
|
jest.mock('../storage', () => ({
|
|
getStorage: jest.fn()
|
|
}));
|
|
jest.mock('../imageProcessor', () => ({
|
|
generateVideoPlaceholder: jest.fn(),
|
|
DEFAULT_THUMBNAIL_WIDTH: 300,
|
|
DEFAULT_THUMBNAIL_HEIGHT: 300
|
|
}));
|
|
|
|
const ffmpeg = require('fluent-ffmpeg');
|
|
const { getStorage } = require('../storage');
|
|
const { generateVideoPlaceholder } = require('../imageProcessor');
|
|
const {
|
|
extractVideoMetadata,
|
|
processUploadedVideo
|
|
} = require('../videoProcessor');
|
|
|
|
describe('extractVideoMetadata (#1370)', () => {
|
|
afterEach(() => jest.clearAllMocks());
|
|
|
|
it('returns null duration rather than 0 when ffprobe has none, so "unknown" and "a real 0s clip" stay distinguishable', async () => {
|
|
ffmpeg.ffprobe = jest.fn((videoPath, cb) => {
|
|
cb(null, {
|
|
streams: [{ codec_type: 'video', width: 1920, height: 1080, codec_name: 'hevc' }],
|
|
format: {} // no duration field at all
|
|
});
|
|
});
|
|
|
|
const metadata = await extractVideoMetadata('/tmp/video.mp4');
|
|
|
|
expect(metadata.duration).toBeNull();
|
|
expect(metadata.width).toBe(1920);
|
|
expect(metadata.videoCodec).toBe('hevc');
|
|
});
|
|
|
|
it('floors a real duration', async () => {
|
|
ffmpeg.ffprobe = jest.fn((videoPath, cb) => {
|
|
cb(null, { streams: [], format: { duration: 12.9 } });
|
|
});
|
|
|
|
const metadata = await extractVideoMetadata('/tmp/video.mp4');
|
|
|
|
expect(metadata.duration).toBe(12);
|
|
});
|
|
});
|
|
|
|
describe('processUploadedVideo degrades gracefully instead of rejecting the whole video (#1370)', () => {
|
|
let storage;
|
|
|
|
beforeEach(() => {
|
|
storage = { putFromFile: jest.fn().mockResolvedValue(undefined), exists: jest.fn().mockResolvedValue(true) };
|
|
getStorage.mockReturnValue(storage);
|
|
generateVideoPlaceholder.mockResolvedValue('thumbnails/thumb_placeholder.jpg');
|
|
});
|
|
|
|
afterEach(() => jest.clearAllMocks());
|
|
|
|
it('keeps the thumbnail when only metadata extraction fails', async () => {
|
|
ffmpeg.ffprobe = jest.fn((videoPath, cb) => cb(new Error('moov atom not found')));
|
|
ffmpeg.mockImplementation(() => ({
|
|
screenshots: jest.fn(function screenshots({ filename, folder }) {
|
|
require('fs').writeFileSync(require('path').join(folder, filename), 'jpeg-bytes');
|
|
return this;
|
|
}),
|
|
on(event, handler) {
|
|
if (event === 'end') setImmediate(handler);
|
|
return this;
|
|
}
|
|
}));
|
|
|
|
const result = await processUploadedVideo('/tmp/video.mp4', 'thumbnails/thumb_video.jpg');
|
|
|
|
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('falls back to the SVG placeholder when thumbnail generation fails, so the gallery never falls back to rendering the raw video as an <img> (codex review)', async () => {
|
|
ffmpeg.ffprobe = jest.fn((videoPath, cb) => {
|
|
cb(null, {
|
|
streams: [{ codec_type: 'video', width: 1080, height: 1920, codec_name: 'h264' }],
|
|
format: { duration: 5.4 }
|
|
});
|
|
});
|
|
ffmpeg.mockImplementation(() => ({
|
|
screenshots() { return this; },
|
|
on(event, handler) {
|
|
if (event === 'error') setImmediate(() => handler(new Error('ffmpeg seek failed')));
|
|
return this;
|
|
}
|
|
}));
|
|
|
|
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' }));
|
|
// thumbnailKey is always thumbnails/thumb_<name>.jpg — strip the prefix
|
|
// back to a filename so generateVideoPlaceholder recomputes the same key.
|
|
// Explicit width/height so generateVideoPlaceholder skips its DB-backed
|
|
// settings lookup — this can run inside an open per-file SQLite
|
|
// transaction (chunked video upload), where that lookup deadlocks.
|
|
expect(generateVideoPlaceholder).toHaveBeenCalledWith('wedding_001.jpg', { width: 300, height: 300 });
|
|
expect(result.thumbnailKey).toBe('thumbnails/thumb_placeholder.jpg');
|
|
expect(storage.putFromFile).not.toHaveBeenCalled();
|
|
});
|
|
|
|
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; },
|
|
on(event, handler) {
|
|
if (event === 'error') setImmediate(() => handler(new Error('ffmpeg seek failed')));
|
|
return this;
|
|
}
|
|
}));
|
|
generateVideoPlaceholder.mockRejectedValue(new Error('sharp render failed'));
|
|
|
|
await expect(processUploadedVideo('/tmp/corrupt.mp4', 'thumbnails/thumb_corrupt.jpg'))
|
|
.rejects.toThrow('Unable to generate any thumbnail');
|
|
});
|
|
});
|