mirror of
https://github.com/immich-app/immich.git
synced 2025-01-07 00:50:23 -05:00
feat(server, web): Added TranscodePolicy "Bitrate higher than max bitrate or not in accepted format" (#6479)
* chore: rebase * chore: open api * Add Database-Migration for setting targetCodec as acceptedCodec if it was set by admin * Add TranscodePolicy setting, to only transcode files with a bitrate higher than set max bitrate * Rename enum value of TranscodePolicy * calculate max_bitrate according to "k" and "m" suffix for comparison * remove migration * minor changes * UnitTest for Bitrate Policy * Fix UnitTest * Add missing output options --------- Co-authored-by: Jason Rasmussen <jrasm91@gmail.com> Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
This commit is contained in:
parent
149bc71eba
commit
87c38d1832
10 changed files with 81 additions and 1 deletions
BIN
mobile/openapi/lib/model/transcode_policy.dart
generated
BIN
mobile/openapi/lib/model/transcode_policy.dart
generated
Binary file not shown.
|
@ -9923,6 +9923,7 @@
|
|||
"enum": [
|
||||
"all",
|
||||
"optimal",
|
||||
"bitrate",
|
||||
"required",
|
||||
"disabled"
|
||||
],
|
||||
|
|
1
open-api/typescript-sdk/client/api.ts
generated
1
open-api/typescript-sdk/client/api.ts
generated
|
@ -4370,6 +4370,7 @@ export type TranscodeHWAccel = typeof TranscodeHWAccel[keyof typeof TranscodeHWA
|
|||
export const TranscodePolicy = {
|
||||
All: 'all',
|
||||
Optimal: 'optimal',
|
||||
Bitrate: 'bitrate',
|
||||
Required: 'required',
|
||||
Disabled: 'disabled'
|
||||
} as const;
|
||||
|
|
|
@ -557,6 +557,37 @@ describe(MediaService.name, () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('should transcode when policy Bitrate and bitrate higher than max bitrate', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.videoStream40Mbps);
|
||||
configMock.load.mockResolvedValue([
|
||||
{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.BITRATE },
|
||||
{ key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '30M' },
|
||||
]);
|
||||
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 h264',
|
||||
'-c:a aac',
|
||||
'-movflags faststart',
|
||||
'-fps_mode passthrough',
|
||||
'-map 0:0',
|
||||
'-map 0:1',
|
||||
'-v verbose',
|
||||
'-vf scale=-2:720,format=yuv420p',
|
||||
'-preset ultrafast',
|
||||
'-crf 23',
|
||||
'-maxrate 30M',
|
||||
'-bufsize 60M',
|
||||
],
|
||||
twoPass: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should not scale resolution if no target resolution', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p);
|
||||
configMock.load.mockResolvedValue([
|
||||
|
|
|
@ -264,6 +264,7 @@ export class MediaService {
|
|||
const mainVideoStream = this.getMainStream(videoStreams);
|
||||
const mainAudioStream = this.getMainStream(audioStreams);
|
||||
const containerExtension = format.formatName;
|
||||
const bitrate = format.bitrate;
|
||||
if (!mainVideoStream || !containerExtension) {
|
||||
return false;
|
||||
}
|
||||
|
@ -275,7 +276,14 @@ export class MediaService {
|
|||
|
||||
const { ffmpeg: config } = await this.configCore.getConfig();
|
||||
|
||||
const required = this.isTranscodeRequired(asset, mainVideoStream, mainAudioStream, containerExtension, config);
|
||||
const required = this.isTranscodeRequired(
|
||||
asset,
|
||||
mainVideoStream,
|
||||
mainAudioStream,
|
||||
containerExtension,
|
||||
config,
|
||||
bitrate,
|
||||
);
|
||||
if (!required) {
|
||||
if (asset.encodedVideoPath) {
|
||||
this.logger.log(`Transcoded video exists for asset ${asset.id}, but is no longer required. Deleting...`);
|
||||
|
@ -326,6 +334,7 @@ export class MediaService {
|
|||
audioStream: AudioStreamInfo | null,
|
||||
containerExtension: string,
|
||||
ffmpegConfig: SystemConfigFFmpegDto,
|
||||
bitrate: number,
|
||||
): boolean {
|
||||
const isTargetVideoCodec = ffmpegConfig.acceptedVideoCodecs.includes(videoStream.codecName as VideoCodec);
|
||||
const isTargetContainer = ['mov,mp4,m4a,3gp,3g2,mj2', 'mp4', 'mov'].includes(containerExtension);
|
||||
|
@ -342,6 +351,7 @@ export class MediaService {
|
|||
const scalingEnabled = ffmpegConfig.targetResolution !== 'original';
|
||||
const targetRes = Number.parseInt(ffmpegConfig.targetResolution);
|
||||
const isLargerThanTargetRes = scalingEnabled && Math.min(videoStream.height, videoStream.width) > targetRes;
|
||||
const isLargerThanTargetBitrate = bitrate > this.parseBitrateToBps(ffmpegConfig.maxBitrate);
|
||||
|
||||
switch (ffmpegConfig.transcode) {
|
||||
case TranscodePolicy.DISABLED:
|
||||
|
@ -356,6 +366,9 @@ export class MediaService {
|
|||
case TranscodePolicy.OPTIMAL:
|
||||
return !allTargetsMatching || isLargerThanTargetRes || videoStream.isHDR;
|
||||
|
||||
case TranscodePolicy.BITRATE:
|
||||
return !allTargetsMatching || isLargerThanTargetBitrate || videoStream.isHDR;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
@ -424,4 +437,20 @@ export class MediaService {
|
|||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
parseBitrateToBps(bitrateString: string) {
|
||||
const bitrateValue = Number.parseInt(bitrateString);
|
||||
|
||||
if (isNaN(bitrateValue)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (bitrateString.toLowerCase().endsWith('k')) {
|
||||
return bitrateValue * 1000; // Kilobits per second to bits per second
|
||||
} else if (bitrateString.toLowerCase().endsWith('m')) {
|
||||
return bitrateValue * 1000000; // Megabits per second to bits per second
|
||||
} else {
|
||||
return bitrateValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -32,6 +32,7 @@ export interface VideoFormat {
|
|||
formatName?: string;
|
||||
formatLongName?: string;
|
||||
duration: number;
|
||||
bitrate: number;
|
||||
}
|
||||
|
||||
export interface VideoInfo {
|
||||
|
|
|
@ -108,6 +108,7 @@ export enum SystemConfigKey {
|
|||
export enum TranscodePolicy {
|
||||
ALL = 'all',
|
||||
OPTIMAL = 'optimal',
|
||||
BITRATE = 'bitrate',
|
||||
REQUIRED = 'required',
|
||||
DISABLED = 'disabled',
|
||||
}
|
||||
|
|
|
@ -47,6 +47,7 @@ export class MediaRepository implements IMediaRepository {
|
|||
formatName: results.format.format_name,
|
||||
formatLongName: results.format.format_long_name,
|
||||
duration: results.format.duration || 0,
|
||||
bitrate: results.format.bit_rate ?? 0,
|
||||
},
|
||||
videoStreams: results.streams
|
||||
.filter((stream) => stream.codec_type === 'video')
|
||||
|
|
11
server/test/fixtures/media.stub.ts
vendored
11
server/test/fixtures/media.stub.ts
vendored
|
@ -4,6 +4,7 @@ const probeStubDefaultFormat: VideoFormat = {
|
|||
formatName: 'mov,mp4,m4a,3gp,3g2,mj2',
|
||||
formatLongName: 'QuickTime / MOV',
|
||||
duration: 0,
|
||||
bitrate: 0,
|
||||
};
|
||||
|
||||
const probeStubDefaultVideoStream: VideoStreamInfo[] = [
|
||||
|
@ -87,6 +88,15 @@ export const probeStub = {
|
|||
},
|
||||
],
|
||||
}),
|
||||
videoStream40Mbps: Object.freeze<VideoInfo>({
|
||||
...probeStubDefault,
|
||||
format: {
|
||||
formatName: 'mov,mp4,m4a,3gp,3g2,mj2',
|
||||
formatLongName: 'QuickTime / MOV',
|
||||
duration: 0,
|
||||
bitrate: 40000000,
|
||||
},
|
||||
}),
|
||||
videoStreamHDR: Object.freeze<VideoInfo>({
|
||||
...probeStubDefault,
|
||||
videoStreams: [
|
||||
|
@ -157,6 +167,7 @@ export const probeStub = {
|
|||
formatName: 'matroska,webm',
|
||||
formatLongName: 'Matroska / WebM',
|
||||
duration: 0,
|
||||
bitrate: 0,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
|
|
@ -183,6 +183,10 @@
|
|||
value: TranscodePolicy.Optimal,
|
||||
text: 'Videos higher than target resolution or not in an accepted format',
|
||||
},
|
||||
{
|
||||
value: TranscodePolicy.Bitrate,
|
||||
text: 'Videos higher than max bitrate or not in an accepted format',
|
||||
},
|
||||
{
|
||||
value: TranscodePolicy.Required,
|
||||
text: 'Only videos not in an accepted format',
|
||||
|
|
Loading…
Reference in a new issue