diff --git a/server/src/repositories/media.repository.ts b/server/src/repositories/media.repository.ts index c1e8cfd234..577b8b2c33 100644 --- a/server/src/repositories/media.repository.ts +++ b/server/src/repositories/media.repository.ts @@ -241,6 +241,7 @@ export class MediaRepository { formatLongName: results.format.format_long_name, duration: this.parseFloat(results.format.duration), bitrate: this.parseInt(results.format.bit_rate), + tags: results.format.tags, }, videoStreams: results.streams .filter((stream) => stream.codec_type === 'video' && !stream.disposition?.attached_pic) diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index d64e0088eb..b51989f777 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -2099,6 +2099,58 @@ describe(MediaService.name, () => { expect(mocks.media.transcode).not.toHaveBeenCalled(); }); + it('should remux fragmented MP4 (fMP4) files based on probe metadata', async () => { + mocks.media.probe.mockResolvedValue(probeStub.fragmentedMp4); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Required } }); + await sut.handleVideoConversion({ id: 'video-id' }); + expect(mocks.media.transcode).toHaveBeenCalledWith( + '/original/path.ext', + expect.any(String), + expect.objectContaining({ + inputOptions: expect.any(Array), + outputOptions: expect.arrayContaining(['-c:v copy', '-c:a copy', '-movflags faststart']), + twoPass: false, + }), + ); + expect(mocks.asset.upsertFile).toHaveBeenCalledWith( + expect.objectContaining({ + type: AssetFileType.EncodedVideo, + isEdited: false, + }), + ); + }); + + it('should remux fragmented MP4 when only compatible_brands indicates fragmentation', async () => { + mocks.media.probe.mockResolvedValue(probeStub.fragmentedMp4CompatibleBrands); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Required } }); + await sut.handleVideoConversion({ id: 'video-id' }); + expect(mocks.media.transcode).toHaveBeenCalledWith( + '/original/path.ext', + expect.any(String), + expect.objectContaining({ + outputOptions: expect.arrayContaining(['-c:v copy', '-c:a copy', '-movflags faststart']), + twoPass: false, + }), + ); + }); + + it('should not include encoding options when remuxing', async () => { + mocks.media.probe.mockResolvedValue(probeStub.fragmentedMp4); + mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Required } }); + await sut.handleVideoConversion({ id: 'video-id' }); + expect(mocks.media.transcode).toHaveBeenCalledWith( + '/original/path.ext', + expect.any(String), + expect.objectContaining({ + outputOptions: expect.not.arrayContaining([ + expect.stringContaining('-preset'), + expect.stringContaining('-crf'), + expect.stringContaining('scale'), + ]), + }), + ); + }); + it('should not scale resolution if no target resolution', async () => { mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.videoStream2160p }); mocks.systemMetadata.get.mockResolvedValue({ diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 19b1688e88..feac7b0c0d 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -56,6 +56,7 @@ interface UpsertFileOptions { } type ThumbnailAsset = NonNullable>>; +const FRAGMENTED_MP4_BRANDS = ['iso5', 'iso6', 'dash', 'msdh', 'msix', 'cmfc']; @Injectable() export class MediaService extends BaseService { @@ -703,7 +704,7 @@ export class MediaService extends BaseService { } } - private isRemuxRequired(ffmpegConfig: SystemConfigFFmpegDto, { formatName, formatLongName }: VideoFormat): boolean { + private isRemuxRequired(ffmpegConfig: SystemConfigFFmpegDto, { formatName, formatLongName, tags }: VideoFormat): boolean { if (ffmpegConfig.transcode === TranscodePolicy.Disabled) { return false; } @@ -714,8 +715,25 @@ export class MediaService extends BaseService { }; const name = (formatLongName ? formatLongNameMapping[formatLongName] : undefined) ?? (formatName as VideoContainer); + if (name !== VideoContainer.Mp4 && !ffmpegConfig.acceptedContainers.includes(name)) { + return true; + } - return name !== VideoContainer.Mp4 && !ffmpegConfig.acceptedContainers.includes(name); + if (!formatName?.includes('mp4') && formatLongName !== 'QuickTime / MOV') { + return false; + } + + const majorBrand = tags?.major_brand; + if (majorBrand && FRAGMENTED_MP4_BRANDS.includes(majorBrand)) { + return true; + } + + const compatibleBrands = tags?.compatible_brands; + if (!compatibleBrands) { + return false; + } + + return FRAGMENTED_MP4_BRANDS.some((brand) => compatibleBrands.includes(brand)); } isSRGB({ diff --git a/server/src/types.ts b/server/src/types.ts index 27995e841f..8cf8b2d7e1 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -136,6 +136,7 @@ export interface VideoFormat { formatLongName?: string; duration: number; bitrate: number; + tags?: Record; } export interface ImageDimensions { diff --git a/server/src/utils/media.ts b/server/src/utils/media.ts index aea821d6d5..e45b430c5f 100644 --- a/server/src/utils/media.ts +++ b/server/src/utils/media.ts @@ -152,6 +152,13 @@ export class BaseConfig implements VideoCodecSWConfig { twoPass: this.eligibleForTwoPass(), progress: { frameCount: video.frameCount, percentInterval: 5 }, } as TranscodeCommand; + + // Skip two-pass and encoder-specific options when we're only remuxing streams. + if (target === TranscodeTarget.None) { + options.twoPass = false; + return options; + } + if ([TranscodeTarget.All, TranscodeTarget.Video].includes(target)) { const filters = this.getFilterOptions(video); if (filters.length > 0) { diff --git a/server/test/fixtures/media.stub.ts b/server/test/fixtures/media.stub.ts index 77534fd6cb..3d99dbee6a 100644 --- a/server/test/fixtures/media.stub.ts +++ b/server/test/fixtures/media.stub.ts @@ -380,6 +380,24 @@ export const videoInfoStub = { ...probeStubDefault, videoStreams: [{ ...probeStubDefaultVideoStream[0], codecName: 'h264' }], }), + fragmentedMp4: Object.freeze({ + ...probeStubDefault, + videoStreams: [{ ...probeStubDefaultVideoStream[0], codecName: 'h264' }], + format: { + ...probeStubDefaultFormat, + formatName: 'mov,mp4,m4a,3gp,3g2,mj2', + tags: { major_brand: 'iso6', compatible_brands: 'isomiso6dashmp41' }, + }, + }), + fragmentedMp4CompatibleBrands: Object.freeze({ + ...probeStubDefault, + videoStreams: [{ ...probeStubDefaultVideoStream[0], codecName: 'h264' }], + format: { + ...probeStubDefaultFormat, + formatName: 'mov,mp4,m4a,3gp,3g2,mj2', + tags: { major_brand: 'isom', compatible_brands: 'isomiso6dashmp41' }, + }, + }), videoStreamAvi: Object.freeze({ ...probeStubDefault, videoStreams: [{ ...probeStubDefaultVideoStream[0], codecName: 'h264' }],