diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 12482b5597..cf66cac279 100644 Binary files a/mobile/openapi/README.md and b/mobile/openapi/README.md differ diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index e6a3907a24..a870267f1a 100644 Binary files a/mobile/openapi/lib/api.dart and b/mobile/openapi/lib/api.dart differ diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 2645a32813..0191f00059 100644 Binary files a/mobile/openapi/lib/api_client.dart and b/mobile/openapi/lib/api_client.dart differ diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index db8af0bfc2..04fcaa3463 100644 Binary files a/mobile/openapi/lib/api_helper.dart and b/mobile/openapi/lib/api_helper.dart differ diff --git a/mobile/openapi/lib/model/system_config_f_fmpeg_dto.dart b/mobile/openapi/lib/model/system_config_f_fmpeg_dto.dart index 3c856bcdbe..a75a77c669 100644 Binary files a/mobile/openapi/lib/model/system_config_f_fmpeg_dto.dart and b/mobile/openapi/lib/model/system_config_f_fmpeg_dto.dart differ diff --git a/mobile/openapi/lib/model/video_container.dart b/mobile/openapi/lib/model/video_container.dart new file mode 100644 index 0000000000..b8efc94adc Binary files /dev/null and b/mobile/openapi/lib/model/video_container.dart differ diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index e17410606c..a6cd8913d2 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -10691,6 +10691,12 @@ }, "type": "array" }, + "acceptedContainers": { + "items": { + "$ref": "#/components/schemas/VideoContainer" + }, + "type": "array" + }, "acceptedVideoCodecs": { "items": { "$ref": "#/components/schemas/VideoCodec" @@ -10762,6 +10768,7 @@ "accel", "accelDecode", "acceptedAudioCodecs", + "acceptedContainers", "acceptedVideoCodecs", "bframes", "cqMode", @@ -11847,6 +11854,15 @@ "av1" ], "type": "string" + }, + "VideoContainer": { + "enum": [ + "mov", + "mp4", + "ogg", + "webm" + ], + "type": "string" } } } diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 7f30ef7ba4..84d959a8da 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -960,6 +960,7 @@ export type SystemConfigFFmpegDto = { accel: TranscodeHWAccel; accelDecode: boolean; acceptedAudioCodecs: AudioCodec[]; + acceptedContainers: VideoContainer[]; acceptedVideoCodecs: VideoCodec[]; bframes: number; cqMode: CQMode; @@ -3178,6 +3179,12 @@ export enum AudioCodec { Aac = "aac", Libopus = "libopus" } +export enum VideoContainer { + Mov = "mov", + Mp4 = "mp4", + Ogg = "ogg", + Webm = "webm" +} export enum VideoCodec { H264 = "h264", Hevc = "hevc", diff --git a/server/src/config.ts b/server/src/config.ts index 230c0f8ff3..c7d16826bf 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -37,6 +37,13 @@ export enum AudioCodec { LIBOPUS = 'libopus', } +export enum VideoContainer { + MOV = 'mov', + MP4 = 'mp4', + OGG = 'ogg', + WEBM = 'webm', +} + export enum TranscodeHWAccel { NVENC = 'nvenc', QSV = 'qsv', @@ -86,6 +93,7 @@ export interface SystemConfig { acceptedVideoCodecs: VideoCodec[]; targetAudioCodec: AudioCodec; acceptedAudioCodecs: AudioCodec[]; + acceptedContainers: VideoContainer[]; targetResolution: string; maxBitrate: string; bframes: number; @@ -218,6 +226,7 @@ export const defaults = Object.freeze({ acceptedVideoCodecs: [VideoCodec.H264], targetAudioCodec: AudioCodec.AAC, acceptedAudioCodecs: [AudioCodec.AAC, AudioCodec.MP3, AudioCodec.LIBOPUS], + acceptedContainers: [VideoContainer.MOV, VideoContainer.OGG, VideoContainer.WEBM], targetResolution: '720', maxBitrate: '0', bframes: -1, diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts index 457ad6a004..98acb495ce 100644 --- a/server/src/dtos/system-config.dto.ts +++ b/server/src/dtos/system-config.dto.ts @@ -29,6 +29,7 @@ import { TranscodeHWAccel, TranscodePolicy, VideoCodec, + VideoContainer, } from 'src/config'; import { CLIPConfig, DuplicateDetectionConfig, FacialRecognitionConfig } from 'src/dtos/model-config.dto'; import { ConcurrentQueueName, QueueName } from 'src/interfaces/job.interface'; @@ -79,6 +80,10 @@ export class SystemConfigFFmpegDto { @ApiProperty({ enumName: 'AudioCodec', enum: AudioCodec, isArray: true }) acceptedAudioCodecs!: AudioCodec[]; + @IsEnum(VideoContainer, { each: true }) + @ApiProperty({ enumName: 'VideoContainer', enum: VideoContainer, isArray: true }) + acceptedContainers!: VideoContainer[]; + @IsString() targetResolution!: string; diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 173af4bd3e..7bb201f78f 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -957,6 +957,21 @@ describe(MediaService.name, () => { ); }); + it('should remux when input is not an accepted container', async () => { + mediaMock.probe.mockResolvedValue(probeStub.videoStreamAvi); + assetMock.getByIds.mockResolvedValue([assetStub.video]); + await sut.handleVideoConversion({ id: assetStub.video.id }); + expect(mediaMock.transcode).toHaveBeenCalledWith( + '/original/path.ext', + 'upload/encoded-video/user-id/as/se/asset-id.mp4', + { + inputOptions: expect.any(Array), + outputOptions: expect.arrayContaining(['-c:v copy', '-c:a copy']), + twoPass: false, + }, + ); + }); + it('should throw an exception if transcode value is invalid', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); systemMock.get.mockResolvedValue({ ffmpeg: { transcode: 'invalid' as any } }); @@ -973,6 +988,14 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).not.toHaveBeenCalled(); }); + it('should not remux when input is not an accepted container and transcoding is disabled', async () => { + mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); + systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.DISABLED } }); + assetMock.getByIds.mockResolvedValue([assetStub.video]); + await sut.handleVideoConversion({ id: assetStub.video.id }); + expect(mediaMock.transcode).not.toHaveBeenCalled(); + }); + it('should not transcode if target codec is invalid', async () => { mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); systemMock.get.mockResolvedValue({ ffmpeg: { targetVideoCodec: 'invalid' as any } }); diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 9dbf269007..9d5b4ed858 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -8,6 +8,7 @@ import { TranscodePolicy, TranscodeTarget, VideoCodec, + VideoContainer, } from 'src/config'; import { GeneratedImageType, StorageCore, StorageFolder } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; @@ -27,7 +28,7 @@ import { QueueName, } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { AudioStreamInfo, IMediaRepository, VideoStreamInfo } from 'src/interfaces/media.interface'; +import { AudioStreamInfo, IMediaRepository, VideoFormat, VideoStreamInfo } from 'src/interfaces/media.interface'; import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; @@ -314,8 +315,7 @@ export class MediaService { const { videoStreams, audioStreams, format } = await this.mediaRepository.probe(input); const mainVideoStream = this.getMainStream(videoStreams); const mainAudioStream = this.getMainStream(audioStreams); - const containerExtension = format.formatName; - if (!mainVideoStream || !containerExtension) { + if (!mainVideoStream || !format.formatName) { return JobStatus.FAILED; } @@ -326,7 +326,7 @@ export class MediaService { const { ffmpeg } = await this.configCore.getConfig({ withCache: true }); const target = this.getTranscodeTarget(ffmpeg, mainVideoStream, mainAudioStream); - if (target === TranscodeTarget.NONE) { + if (target === TranscodeTarget.NONE && !this.isRemuxRequired(ffmpeg, format)) { if (asset.encodedVideoPath) { this.logger.log(`Transcoded video exists for asset ${asset.id}, but is no longer required. Deleting...`); await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files: [asset.encodedVideoPath] } }); @@ -456,6 +456,15 @@ export class MediaService { } } + private isRemuxRequired(ffmpegConfig: SystemConfigFFmpegDto, { formatName, formatLongName }: VideoFormat): boolean { + if (ffmpegConfig.transcode === TranscodePolicy.DISABLED) { + return false; + } + + const name = formatLongName === 'QuickTime / MOV' ? VideoContainer.MOV : (formatName as VideoContainer); + return name !== VideoContainer.MP4 && !ffmpegConfig.acceptedContainers.includes(name); + } + isSRGB(asset: AssetEntity): boolean { const { colorspace, profileDescription, bitsPerSample } = asset.exifInfo ?? {}; if (colorspace || profileDescription) { diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index f62f9156fb..a3b0011d0c 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -10,6 +10,7 @@ import { TranscodeHWAccel, TranscodePolicy, VideoCodec, + VideoContainer, defaults, } from 'src/config'; import { SystemMetadataKey } from 'src/entities/system-metadata.entity'; @@ -54,6 +55,7 @@ const updatedConfig = Object.freeze({ targetResolution: '720', targetVideoCodec: VideoCodec.H264, acceptedVideoCodecs: [VideoCodec.H264], + acceptedContainers: [VideoContainer.MOV, VideoContainer.OGG, VideoContainer.WEBM], maxBitrate: '0', bframes: -1, refs: 0, diff --git a/server/test/fixtures/media.stub.ts b/server/test/fixtures/media.stub.ts index 323a5ac5cf..9b4e15a95d 100644 --- a/server/test/fixtures/media.stub.ts +++ b/server/test/fixtures/media.stub.ts @@ -177,4 +177,14 @@ export const probeStub = { ...probeStubDefault, videoStreams: [{ ...probeStubDefaultVideoStream[0], codecName: 'h264' }], }), + videoStreamAvi: Object.freeze({ + ...probeStubDefault, + videoStreams: [{ ...probeStubDefaultVideoStream[0], codecName: 'h264' }], + format: { + formatName: 'avi', + formatLongName: 'AVI (Audio Video Interleaved)', + duration: 0, + bitrate: 0, + }, + }), }; diff --git a/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte b/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte index 3ca5e7d388..7ddb71cbde 100644 --- a/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte +++ b/web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte @@ -7,6 +7,7 @@ TranscodeHWAccel, TranscodePolicy, VideoCodec, + VideoContainer, type SystemConfigDto, } from '@immich/sdk'; import { mdiHelpCircleOutline } from '@mdi/js'; @@ -85,6 +86,22 @@ isEdited={config.ffmpeg.preset !== savedConfig.ffmpeg.preset} /> + (config.ffmpeg.acceptedVideoCodecs = [config.ffmpeg.targetVideoCodec])} + /> + + + - (config.ffmpeg.acceptedVideoCodecs = [config.ffmpeg.targetVideoCodec])} - /> -