From 9d2d5562002a937b8e34e24ffa994aebaadf11c5 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Sun, 21 Jul 2024 17:14:23 -0400 Subject: [PATCH] feat(server): accepted video containers (#11274) * add accepted container config * update api * mp4 option makes no sense * add to transcoding settings * wording * updated spec config * formatting --- mobile/openapi/README.md | Bin 30938 -> 30982 bytes mobile/openapi/lib/api.dart | Bin 10476 -> 10511 bytes mobile/openapi/lib/api_client.dart | Bin 27445 -> 27540 bytes mobile/openapi/lib/api_helper.dart | Bin 6272 -> 6378 bytes .../lib/model/system_config_f_fmpeg_dto.dart | Bin 8535 -> 8942 bytes mobile/openapi/lib/model/video_container.dart | Bin 0 -> 2832 bytes open-api/immich-openapi-specs.json | 16 +++++ open-api/typescript-sdk/src/fetch-client.ts | 7 ++ server/src/config.ts | 9 +++ server/src/dtos/system-config.dto.ts | 5 ++ server/src/services/media.service.spec.ts | 23 +++++++ server/src/services/media.service.ts | 17 +++-- .../services/system-config.service.spec.ts | 2 + server/test/fixtures/media.stub.ts | 10 +++ .../settings/ffmpeg/ffmpeg-settings.svelte | 65 +++++++++++------- web/src/lib/i18n/en.json | 2 + 16 files changed, 127 insertions(+), 29 deletions(-) create mode 100644 mobile/openapi/lib/model/video_container.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 12482b5597c05f5a1729e011d4cf1a1a6447e3a6..cf66cac279df0ac01ec0f56668c83fbff34bc54b 100644 GIT binary patch delta 54 xcmcchk+JO)68=&;O`)O_dsypqJsywsvtjg`0O{^gp$B#Y2ztAb(lNGbJ@2E-<-XR8|bmD^`!y)ZtQqfXN@(MK%|UuIEB>pP+&pBLKp>VuAnw delta 59 zcmaFodfjOQBhzM0rb4F8$5@J)Cu=fsZ|>vJVBUO-yOascYvQY7+H5Hp#tP$odJVk2((1mS|w5=sTf9)|Gwu) zNr@Gu4G^0$pL@T1PSfdVI;E@o<=ywc&Tr>`T`lL=bhG$4pV0Lk-QF$fkGt!|%|9oA zG4ewyjGO%w{rGmokNH+ z`CpwIh$~Uzvl0g1P8tj4#tw@oT3IKQi}Mr)^DLLfi`zkCMXscc3$3<*xt&n)+aKa& zCyX79V9t@wNLNycDv$90ZZsMtg|L?XhI;*rR!%^Jp)Vt%DdJxQ1Sz>Esp^vU zx!aN3Nve!~fMiOm2T{~=9YM}}+*26EW4Fs~p{tOC;IlN#lH}A87V6 z1apduQ@Brb46n{#fmi1PaJ}6Q!*MYH-^)!ojOQB`XtMb+x4yulD5%ob=9@x>@Ga#k zBz490Td$7G?aOu)k*|b>)t`@%?^m!zgfvXTp*jx-xE>Dd6Q9=Jtw!mwl1s{rE`1Tw z14aR94}HPqI)*L}m0TL3EGDp&W_%WL}n%0Hnm8?6i4 z8od{srx|6UuyXQv=9|CmHzRFbxS>QKKu$%T(U=tyI@TB9%^Tw9D7UxT&CALiT8+@D z4UI!1!!8$vp-=a%)i4MzI7*LukLl zlaa1As+Dq@gBrJKy66(-?LUqlnI@;y)d)j`Tm()5|2Rs7A~YQrZB01mo>3CXW8QB= za=Mw}iPtI=h$R$!O>L{B!U@`lvfSYa3&I`&EZ@9#w{6lK3`tW2I;M5 z0Bjega)=6F!#3;`QZ+sv1LB}PoADvAXgBuPej+URdDZ5~cf)$c;5Da7^ulC5McKk9 zEl>$?hTh+zch=m<&eY(0WS=>HSS#wPw4;rrP@t4-og~uQ3`!2n0NfXlSH2}#Yv?SQ zM)aHBOVIX&r>AGyPvO^XH@d&!L9or;cdxLX!@`#7+!$>_kUNZ#I!*;Iq@F|Y^FY|7 z>4Ekf4K!Z;xgac6ELmh7S$eh^!yF=5j2qS>=G{oJrA*t5FdnZE1_&duMskpKZF#3b zu`Zyjs-E^M|G=ZeULLo^Ws~=DTXQaU%W7RUL0nwRj?|c~7)URe!eXZ8MWhTJk7f50I$7wj} zlJq=WMmTN^x-#3E-|>9Ij8E^?XN7hNZzZA17_89y7xWz`HSF%z##gwNIhy?gd4@6h hg!&iKfjN7G)*}n_Z9|{j5QEFLCvo}2UhkN!{|9fjrTzc_ literal 0 HcmV?d00001 diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index e17410606..a6cd8913d 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 7f30ef7ba..84d959a8d 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 230c0f8ff..c7d16826b 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 457ad6a00..98acb495c 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 173af4bd3..7bb201f78 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 9dbf26900..9d5b4ed85 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 f62f9156f..a3b0011d0 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 323a5ac5c..9b4e15a95 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 3ca5e7d38..7ddb71cbd 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])} - /> -