diff --git a/mobile/openapi/lib/model/video_codec.dart b/mobile/openapi/lib/model/video_codec.dart index 2cc18d1ae0..36e1c681a6 100644 Binary files a/mobile/openapi/lib/model/video_codec.dart and b/mobile/openapi/lib/model/video_codec.dart differ diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index a61a60eb84..fbedfd359d 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -11231,7 +11231,8 @@ "enum": [ "h264", "hevc", - "vp9" + "vp9", + "av1" ], "type": "string" } diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 4d2cfa0415..43f766dfe2 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -2984,7 +2984,8 @@ export enum AudioCodec { export enum VideoCodec { H264 = "h264", Hevc = "hevc", - Vp9 = "vp9" + Vp9 = "vp9", + Av1 = "av1" } export enum CQMode { Auto = "auto", diff --git a/server/src/entities/system-config.entity.ts b/server/src/entities/system-config.entity.ts index 1ddd6baff3..a8a550fd6d 100644 --- a/server/src/entities/system-config.entity.ts +++ b/server/src/entities/system-config.entity.ts @@ -153,6 +153,7 @@ export enum VideoCodec { H264 = 'h264', HEVC = 'hevc', VP9 = 'vp9', + AV1 = 'av1', } export enum AudioCodec { diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 0e55f4e2f8..e281047a50 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -1268,6 +1268,157 @@ describe(MediaService.name, () => { ); }); + it('should use av1 if specified', async () => { + mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); + configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.AV1 }]); + 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: [], + outputOptions: [ + '-c:v av1', + '-c:a copy', + '-movflags faststart', + '-fps_mode passthrough', + '-map 0:0', + '-map 0:1', + '-v verbose', + '-vf scale=-2:720,format=yuv420p', + '-preset 12', + '-crf 23', + ], + twoPass: false, + }, + ); + }); + + it('should map `veryslow` preset to 4 for av1', async () => { + mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); + configMock.load.mockResolvedValue([ + { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.AV1 }, + { key: SystemConfigKey.FFMPEG_PRESET, value: 'veryslow' }, + ]); + 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: [], + outputOptions: [ + '-c:v av1', + '-c:a copy', + '-movflags faststart', + '-fps_mode passthrough', + '-map 0:0', + '-map 0:1', + '-v verbose', + '-vf scale=-2:720,format=yuv420p', + '-preset 4', + '-crf 23', + ], + twoPass: false, + }, + ); + }); + + it('should set max bitrate for av1 if specified', async () => { + mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); + configMock.load.mockResolvedValue([ + { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.AV1 }, + { key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '2M' }, + ]); + 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: [], + outputOptions: [ + '-c:v av1', + '-c:a copy', + '-movflags faststart', + '-fps_mode passthrough', + '-map 0:0', + '-map 0:1', + '-v verbose', + '-vf scale=-2:720,format=yuv420p', + '-preset 12', + '-crf 23', + '-svtav1-params mbr=2M', + ], + twoPass: false, + }, + ); + }); + + it('should set threads for av1 if specified', async () => { + mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); + configMock.load.mockResolvedValue([ + { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.AV1 }, + { key: SystemConfigKey.FFMPEG_THREADS, value: 4 }, + ]); + 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: [], + outputOptions: [ + '-c:v av1', + '-c:a copy', + '-movflags faststart', + '-fps_mode passthrough', + '-map 0:0', + '-map 0:1', + '-v verbose', + '-vf scale=-2:720,format=yuv420p', + '-preset 12', + '-crf 23', + '-svtav1-params lp=4', + ], + twoPass: false, + }, + ); + }); + + it('should set both bitrate and threads for av1 if specified', async () => { + mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9); + configMock.load.mockResolvedValue([ + { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.AV1 }, + { key: SystemConfigKey.FFMPEG_THREADS, value: 4 }, + { key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '2M' }, + ]); + 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: [], + outputOptions: [ + '-c:v av1', + '-c:a copy', + '-movflags faststart', + '-fps_mode passthrough', + '-map 0:0', + '-map 0:1', + '-v verbose', + '-vf scale=-2:720,format=yuv420p', + '-preset 12', + '-crf 23', + '-svtav1-params lp=4:mbr=2M', + ], + twoPass: false, + }, + ); + }); + it('should skip transcoding for audioless videos with optimal policy if video codec is correct', async () => { mediaMock.probe.mockResolvedValue(probeStub.noAudioStreams); configMock.load.mockResolvedValue([ diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 8ca2486941..3c86c72bd7 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -32,6 +32,7 @@ import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'; import { ImmichLogger } from 'src/utils/logger'; import { + AV1Config, H264Config, HEVCConfig, NVENCConfig, @@ -439,6 +440,9 @@ export class MediaService { case VideoCodec.VP9: { return new VP9Config(config); } + case VideoCodec.AV1: { + return new AV1Config(config); + } default: { throw new UnsupportedMediaTypeException(`Codec '${config.targetVideoCodec}' is unsupported`); } diff --git a/server/src/utils/.media.ts.kate-swp b/server/src/utils/.media.ts.kate-swp new file mode 100644 index 0000000000..126d6519e0 Binary files /dev/null and b/server/src/utils/.media.ts.kate-swp differ diff --git a/server/src/utils/media.ts b/server/src/utils/media.ts index 261977028a..d0a6d4d740 100644 --- a/server/src/utils/media.ts +++ b/server/src/utils/media.ts @@ -124,7 +124,7 @@ class BaseConfig implements VideoCodecSWConfig { return false; } - return this.isBitrateConstrained() || this.config.targetVideoCodec === VideoCodec.VP9; + return this.isBitrateConstrained(); } getBitrateDistribution() { @@ -265,7 +265,7 @@ export class BaseHWConfig extends BaseConfig implements VideoCodecHWConfig { } getSupportedCodecs() { - return [VideoCodec.H264, VideoCodec.HEVC, VideoCodec.VP9]; + return [VideoCodec.H264, VideoCodec.HEVC]; } validateDevices(devices: string[]) { @@ -394,6 +394,44 @@ export class VP9Config extends BaseConfig { getThreadOptions() { return ['-row-mt 1', ...super.getThreadOptions()]; } + + eligibleForTwoPass() { + return this.config.twoPass; + } +} + +export class AV1Config extends BaseConfig { + getPresetOptions() { + const speed = this.getPresetIndex() + 4; // Use 4 as slowest, giving us an effective range of 4-12 which is far more useful than 0-8 + if (speed >= 0) { + return [`-preset ${speed}`]; + } + return []; + } + + getBitrateOptions() { + const options = [`-crf ${this.config.crf}`]; + const bitrates = this.getBitrateDistribution(); + const svtparams = []; + if (this.config.threads > 0) { + svtparams.push(`lp=${this.config.threads}`); + } + if (bitrates.max > 0) { + svtparams.push(`mbr=${bitrates.max}${bitrates.unit}`); + } + if (svtparams.length > 0) { + options.push(`-svtav1-params ${svtparams.join(':')}`); + } + return options; + } + + getThreadOptions() { + return []; // Already set above with svtav1-params + } + + eligibleForTwoPass() { + return this.config.twoPass; + } } export class NVENCConfig extends BaseHWConfig { @@ -527,6 +565,10 @@ export class QSVConfig extends BaseHWConfig { return options; } + getSupportedCodecs() { + return [VideoCodec.H264, VideoCodec.HEVC, VideoCodec.VP9]; + } + // recommended from https://github.com/intel/media-delivery/blob/master/doc/benchmarks/intel-iris-xe-max-graphics/intel-iris-xe-max-graphics.md getBFrames() { if (this.config.bframes < 0) { @@ -605,6 +647,10 @@ export class VAAPIConfig extends BaseHWConfig { return options; } + getSupportedCodecs() { + return [VideoCodec.H264, VideoCodec.HEVC, VideoCodec.VP9]; + } + useCQP() { return this.config.cqMode !== CQMode.ICQ || this.config.targetVideoCodec === VideoCodec.VP9; } 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 fba4c2bafc..bc91c2c993 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 @@ -54,7 +54,7 @@ inputType={SettingInputFieldType.NUMBER} {disabled} label="CONSTANT RATE FACTOR (-crf)" - desc="Video quality level. Typical values are 23 for H.264, 28 for HEVC, and 31 for VP9. Lower is better, but takes longer to encode and produces larger files." + desc="Video quality level. Typical values are 23 for H.264, 28 for HEVC, 31 for VP9, and 35 for AV1. Lower is better, but produces larger files." bind:value={config.ffmpeg.crf} required={true} isEdited={config.ffmpeg.crf !== savedConfig.ffmpeg.crf} @@ -115,12 +115,13 @@