From f146d2176adf32734f8d3be14f105a542e65d856 Mon Sep 17 00:00:00 2001 From: mrwogu Date: Fri, 24 Jul 2026 00:46:51 +0200 Subject: [PATCH] fix(server): recover thumbnail generation for corrupt media --- server/src/services/media.service.spec.ts | 42 +++++++++++++++++- server/src/services/media.service.ts | 39 +++++++++++----- server/src/utils/media.ts | 54 ++++++++++++++++++----- 3 files changed, 112 insertions(+), 23 deletions(-) diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 290bf947a1..1a0b6102ac 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -385,7 +385,7 @@ describe(MediaService.name, () => { ...getForGenerateThumbnail(asset), ...probeStub.noVideoStreams, }); - await expect(sut.handleGenerateThumbnails({ id: asset.id })).rejects.toThrowError(); + await expect(sut.handleGenerateThumbnails({ id: asset.id })).resolves.toBe(JobStatus.Failed); expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); expect(mocks.asset.update).not.toHaveBeenCalledWith(); }); @@ -543,6 +543,46 @@ describe(MediaService.name, () => { ]); }); + it('should retry video thumbnail generation with a fallback filter chain when ffmpeg fails', async () => { + const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' }).exif().build(); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({ + ...getForGenerateThumbnail(asset), + ...probeStub.videoStream2160p, + }); + mocks.media.transcode.mockRejectedValueOnce(new Error('ffmpeg was killed with signal SIGSEGV')); + + await expect(sut.handleGenerateThumbnails({ id: asset.id })).resolves.toBe(JobStatus.Success); + + // primary preview attempt (1) fails, then preview (2) and thumbnail (3) fallbacks run + expect(mocks.media.transcode).toHaveBeenCalledTimes(3); + expect(mocks.media.transcode).toHaveBeenCalledWith( + '/original/path.ext', + expect.any(String), + expect.objectContaining({ + inputOptions: ['-sws_flags', 'accurate_rnd+full_chroma_int'], + outputOptions: expect.arrayContaining([ + '-fps_mode', + 'passthrough', + '-vf', + String.raw`select=eq(n\,0),scale=-2:1440:flags=lanczos+accurate_rnd+full_chroma_int:out_range=pc`, + ]), + twoPass: false, + }), + ); + expect(mocks.asset.upsertFiles).toHaveBeenCalled(); + }); + + it('should fail gracefully without throwing when media decoding is unsupported', async () => { + const asset = AssetFactory.from().exif().build(); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset)); + mocks.media.decodeImage.mockRejectedValue(new Error('Input file contains unsupported image format')); + + await expect(sut.handleGenerateThumbnails({ id: asset.id })).resolves.toBe(JobStatus.Failed); + + expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); + expect(mocks.asset.update).not.toHaveBeenCalled(); + }); + it('should tonemap thumbnail for hdr video', async () => { const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' }).exif().build(); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({ diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 956636dd51..726974579e 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -225,15 +225,21 @@ export class MediaService extends BaseService { } let generated: Awaited>; - if (asset.type === AssetType.Video || asset.originalFileName.toLowerCase().endsWith('.gif')) { - this.logger.verbose(`Thumbnail generation for video ${id} ${asset.originalPath}`); - generated = await this.generateVideoThumbnails(asset, config); - } else if (asset.type === AssetType.Image) { - this.logger.verbose(`Thumbnail generation for image ${id} ${asset.originalPath}`); - generated = await this.generateImageThumbnails(asset, config); - } else { - this.logger.warn(`Skipping thumbnail generation for asset ${id}: ${asset.type} is not an image or video`); - return JobStatus.Skipped; + try { + if (asset.type === AssetType.Video || asset.originalFileName.toLowerCase().endsWith('.gif')) { + this.logger.verbose(`Thumbnail generation for video ${id} ${asset.originalPath}`); + generated = await this.generateVideoThumbnails(asset, config); + } else if (asset.type === AssetType.Image) { + this.logger.verbose(`Thumbnail generation for image ${id} ${asset.originalPath}`); + generated = await this.generateImageThumbnails(asset, config); + } else { + this.logger.warn(`Skipping thumbnail generation for asset ${id}: ${asset.type} is not an image or video`); + return JobStatus.Skipped; + } + } catch (error: any) { + // Corrupt/unsupported media: fail the job instead of throwing into indefinite retries. + this.logger.error(`Thumbnail generation failed for asset ${id} (${asset.originalPath}): ${error.message}`); + return JobStatus.Failed; } const editedGenerated = await this.generateEditedThumbnails(asset, config); @@ -532,8 +538,19 @@ export class MediaService extends BaseService { const previewOptions = previewConfig.getCommand(TranscodeTarget.Video, videoStream, undefined, format ?? undefined); const thumbnailOptions = thumbConfig.getCommand(TranscodeTarget.Video, videoStream, undefined, format ?? undefined); - await this.mediaRepository.transcode(asset.originalPath, previewFile.path, previewOptions); - await this.mediaRepository.transcode(asset.originalPath, thumbnailFile.path, thumbnailOptions); + try { + await this.mediaRepository.transcode(asset.originalPath, previewFile.path, previewOptions); + await this.mediaRepository.transcode(asset.originalPath, thumbnailFile.path, thumbnailOptions); + } catch (error: any) { + // Primary chain can SIGSEGV on very short videos; retry once with the fallback. + this.logger.warn( + `Thumbnail generation failed for asset ${asset.id} (${asset.originalPath}): ${error.message}. Retrying with fallback filter chain.`, + ); + const fallbackPreviewOptions = previewConfig.getFallbackCommand(TranscodeTarget.Video, videoStream); + const fallbackThumbnailOptions = thumbConfig.getFallbackCommand(TranscodeTarget.Video, videoStream); + await this.mediaRepository.transcode(asset.originalPath, previewFile.path, fallbackPreviewOptions); + await this.mediaRepository.transcode(asset.originalPath, thumbnailFile.path, fallbackThumbnailOptions); + } const thumbhash = await this.mediaRepository.generateThumbhash(previewFile.path, { colorspace: image.colorspace, diff --git a/server/src/utils/media.ts b/server/src/utils/media.ts index aea821d6d5..fda1995f76 100644 --- a/server/src/utils/media.ts +++ b/server/src/utils/media.ts @@ -471,17 +471,12 @@ export class BaseHWConfig extends BaseConfig { } export class ThumbnailConfig extends BaseConfig { - static create(config: SystemConfigFFmpegDto): VideoCodecSWConfig { + static create(config: SystemConfigFFmpegDto): ThumbnailConfig { return new ThumbnailConfig(config); } - getBaseInputOptions(videoStream: VideoStreamInfo, format?: VideoFormat): string[] { - // skip_frame nointra skips all frames for some MPEG-TS files. Look at ffmpeg tickets 7950 and 7895 for more details. - const options = - format?.formatName === 'mpegts' - ? ['-sws_flags', 'accurate_rnd+full_chroma_int'] - : ['-skip_frame', 'nointra', '-sws_flags', 'accurate_rnd+full_chroma_int']; - + // workaround for https://fftrac-bg.ffmpeg.org/ticket/11020 + private getColorMetadataOptions(videoStream: VideoStreamInfo): string[] { const metadataOverrides = []; if (videoStream.colorPrimaries === ColorPrimaries.Reserved) { metadataOverrides.push('colour_primaries=1'); @@ -495,11 +490,22 @@ export class ThumbnailConfig extends BaseConfig { metadataOverrides.push('transfer_characteristics=1'); } - if (metadataOverrides.length > 0) { - // workaround for https://fftrac-bg.ffmpeg.org/ticket/11020 - options.push(`-bsf:${videoStream.index}`, `${videoStream.codecName}_metadata=${metadataOverrides.join(':')}`); + if (metadataOverrides.length === 0) { + return []; } + return [`-bsf:${videoStream.index}`, `${videoStream.codecName}_metadata=${metadataOverrides.join(':')}`]; + } + + getBaseInputOptions(videoStream: VideoStreamInfo, format?: VideoFormat): string[] { + // skip_frame nointra skips all frames for some MPEG-TS files. Look at ffmpeg tickets 7950 and 7895 for more details. + const options = + format?.formatName === 'mpegts' + ? ['-sws_flags', 'accurate_rnd+full_chroma_int'] + : ['-skip_frame', 'nointra', '-sws_flags', 'accurate_rnd+full_chroma_int']; + + options.push(...this.getColorMetadataOptions(videoStream)); + return options; } @@ -518,6 +524,32 @@ export class ThumbnailConfig extends BaseConfig { ]; } + // No skip_frame nointra, which can drop every frame of a short video. + getFallbackBaseInputOptions(videoStream: VideoStreamInfo): string[] { + return ['-sws_flags', 'accurate_rnd+full_chroma_int', ...this.getColorMetadataOptions(videoStream)]; + } + + // First frame only; thumbnail=12 + reverse can SIGSEGV on short videos when the pipeline is empty. + getFallbackFilterOptions(videoStream: VideoStreamInfo): string[] { + return [String.raw`select=eq(n\,0)`, ...super.getFilterOptions(videoStream)]; + } + + getFallbackCommand(target: TranscodeTarget, video: VideoStreamInfo): TranscodeCommand { + const options: TranscodeCommand = { + inputOptions: this.getFallbackBaseInputOptions(video), + outputOptions: [...this.getBaseOutputOptions(), '-fps_mode', 'passthrough', '-v', 'verbose'], + twoPass: false, + progress: { frameCount: video.frameCount, percentInterval: 5 }, + }; + if ([TranscodeTarget.All, TranscodeTarget.Video].includes(target)) { + const filters = this.getFallbackFilterOptions(video); + if (filters.length > 0) { + options.outputOptions.push('-vf', filters.join(',')); + } + } + return options; + } + getPresetOptions() { return []; }