mirror of
https://github.com/immich-app/immich
synced 2026-08-15 13:03:57 +00:00
fix(server): recover thumbnail generation for corrupt media
This commit is contained in:
parent
af5ff4983a
commit
f146d2176a
3 changed files with 112 additions and 23 deletions
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue