fix(server): recover thumbnail generation for corrupt media

This commit is contained in:
mrwogu 2026-07-24 00:46:51 +02:00 committed by Wojciech Guziak
parent af5ff4983a
commit f146d2176a
3 changed files with 112 additions and 23 deletions

View file

@ -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({

View file

@ -225,15 +225,21 @@ export class MediaService extends BaseService {
}
let generated: Awaited<ReturnType<MediaService['generateImageThumbnails']>>;
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,

View file

@ -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 [];
}