mirror of
https://github.com/immich-app/immich.git
synced 2024-12-25 10:43:13 +02:00
feat(server): transcode bitrate and thread settings (#2488)
* support for two-pass transcoding * added max bitrate and thread to transcode api * admin page setting desc+bitrate and thread options * Update web/src/lib/components/admin-page/settings/setting-input-field.svelte Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> * Update web/src/lib/components/admin-page/settings/setting-input-field.svelte Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> * two-pass slider, `crf` and `threads` as numbers * updated and added transcode tests * refactored `getFfmpegOptions` * default `threads`, `maxBitrate` now 0, more tests * vp9 constant quality mode * fixed nullable `crf` and `threads` * fixed two-pass slider, added apiproperty * optional `desc` for `SettingSelect` * disable two-pass if settings are incompatible * fixed test * transcode interface --------- Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com>
This commit is contained in:
parent
f1384fea58
commit
e9722710ac
BIN
mobile/openapi/doc/SystemConfigFFmpegDto.md
generated
BIN
mobile/openapi/doc/SystemConfigFFmpegDto.md
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/system_config_f_fmpeg_dto.dart
generated
BIN
mobile/openapi/lib/model/system_config_f_fmpeg_dto.dart
generated
Binary file not shown.
BIN
mobile/openapi/test/system_config_f_fmpeg_dto_test.dart
generated
BIN
mobile/openapi/test/system_config_f_fmpeg_dto_test.dart
generated
Binary file not shown.
@ -5349,7 +5349,10 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"crf": {
|
"crf": {
|
||||||
"type": "string"
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"threads": {
|
||||||
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"preset": {
|
"preset": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@ -5363,6 +5366,12 @@
|
|||||||
"targetResolution": {
|
"targetResolution": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"maxBitrate": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"twoPass": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"transcode": {
|
"transcode": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
@ -5375,10 +5384,13 @@
|
|||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"crf",
|
"crf",
|
||||||
|
"threads",
|
||||||
"preset",
|
"preset",
|
||||||
"targetVideoCodec",
|
"targetVideoCodec",
|
||||||
"targetAudioCodec",
|
"targetAudioCodec",
|
||||||
"targetResolution",
|
"targetResolution",
|
||||||
|
"maxBitrate",
|
||||||
|
"twoPass",
|
||||||
"transcode"
|
"transcode"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
@ -38,6 +38,11 @@ export interface CropOptions {
|
|||||||
height: number;
|
height: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TranscodeOptions {
|
||||||
|
outputOptions: string[];
|
||||||
|
twoPass: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IMediaRepository {
|
export interface IMediaRepository {
|
||||||
// image
|
// image
|
||||||
extractThumbnailFromExif(input: string, output: string): Promise<void>;
|
extractThumbnailFromExif(input: string, output: string): Promise<void>;
|
||||||
@ -47,5 +52,5 @@ export interface IMediaRepository {
|
|||||||
// video
|
// video
|
||||||
extractVideoThumbnail(input: string, output: string, size: number): Promise<void>;
|
extractVideoThumbnail(input: string, output: string, size: number): Promise<void>;
|
||||||
probe(input: string): Promise<VideoInfo>;
|
probe(input: string): Promise<VideoInfo>;
|
||||||
transcode(input: string, output: string, options: any): Promise<void>;
|
transcode(input: string, output: string, options: TranscodeOptions): Promise<void>;
|
||||||
}
|
}
|
||||||
|
@ -253,7 +253,10 @@ describe(MediaService.name, () => {
|
|||||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||||
'/original/path.ext',
|
'/original/path.ext',
|
||||||
'upload/encoded-video/user-id/asset-id.mp4',
|
'upload/encoded-video/user-id/asset-id.mp4',
|
||||||
['-crf 23', '-preset ultrafast', '-vcodec h264', '-acodec aac', '-movflags faststart'],
|
{
|
||||||
|
outputOptions: ['-vcodec h264', '-acodec aac', '-movflags faststart', '-preset ultrafast', '-crf 23'],
|
||||||
|
twoPass: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -276,7 +279,10 @@ describe(MediaService.name, () => {
|
|||||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||||
'/original/path.ext',
|
'/original/path.ext',
|
||||||
'upload/encoded-video/user-id/asset-id.mp4',
|
'upload/encoded-video/user-id/asset-id.mp4',
|
||||||
['-crf 23', '-preset ultrafast', '-vcodec h264', '-acodec aac', '-movflags faststart'],
|
{
|
||||||
|
outputOptions: ['-vcodec h264', '-acodec aac', '-movflags faststart', '-preset ultrafast', '-crf 23'],
|
||||||
|
twoPass: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -287,7 +293,17 @@ describe(MediaService.name, () => {
|
|||||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||||
'/original/path.ext',
|
'/original/path.ext',
|
||||||
'upload/encoded-video/user-id/asset-id.mp4',
|
'upload/encoded-video/user-id/asset-id.mp4',
|
||||||
['-crf 23', '-preset ultrafast', '-vcodec h264', '-acodec aac', '-movflags faststart', '-vf scale=-2:720'],
|
{
|
||||||
|
outputOptions: [
|
||||||
|
'-vcodec h264',
|
||||||
|
'-acodec aac',
|
||||||
|
'-movflags faststart',
|
||||||
|
'-vf scale=-2:720',
|
||||||
|
'-preset ultrafast',
|
||||||
|
'-crf 23',
|
||||||
|
],
|
||||||
|
twoPass: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -298,7 +314,17 @@ describe(MediaService.name, () => {
|
|||||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||||
'/original/path.ext',
|
'/original/path.ext',
|
||||||
'upload/encoded-video/user-id/asset-id.mp4',
|
'upload/encoded-video/user-id/asset-id.mp4',
|
||||||
['-crf 23', '-preset ultrafast', '-vcodec h264', '-acodec aac', '-movflags faststart', '-vf scale=720:-2'],
|
{
|
||||||
|
outputOptions: [
|
||||||
|
'-vcodec h264',
|
||||||
|
'-acodec aac',
|
||||||
|
'-movflags faststart',
|
||||||
|
'-vf scale=720:-2',
|
||||||
|
'-preset ultrafast',
|
||||||
|
'-crf 23',
|
||||||
|
],
|
||||||
|
twoPass: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -309,7 +335,17 @@ describe(MediaService.name, () => {
|
|||||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||||
'/original/path.ext',
|
'/original/path.ext',
|
||||||
'upload/encoded-video/user-id/asset-id.mp4',
|
'upload/encoded-video/user-id/asset-id.mp4',
|
||||||
['-crf 23', '-preset ultrafast', '-vcodec h264', '-acodec aac', '-movflags faststart', '-vf scale=-2:720'],
|
{
|
||||||
|
outputOptions: [
|
||||||
|
'-vcodec h264',
|
||||||
|
'-acodec aac',
|
||||||
|
'-movflags faststart',
|
||||||
|
'-vf scale=-2:720',
|
||||||
|
'-preset ultrafast',
|
||||||
|
'-crf 23',
|
||||||
|
],
|
||||||
|
twoPass: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -320,7 +356,17 @@ describe(MediaService.name, () => {
|
|||||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||||
'/original/path.ext',
|
'/original/path.ext',
|
||||||
'upload/encoded-video/user-id/asset-id.mp4',
|
'upload/encoded-video/user-id/asset-id.mp4',
|
||||||
['-crf 23', '-preset ultrafast', '-vcodec h264', '-acodec aac', '-movflags faststart', '-vf scale=-2:720'],
|
{
|
||||||
|
outputOptions: [
|
||||||
|
'-vcodec h264',
|
||||||
|
'-acodec aac',
|
||||||
|
'-movflags faststart',
|
||||||
|
'-vf scale=-2:720',
|
||||||
|
'-preset ultrafast',
|
||||||
|
'-crf 23',
|
||||||
|
],
|
||||||
|
twoPass: false,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -330,5 +376,152 @@ describe(MediaService.name, () => {
|
|||||||
await sut.handleVideoConversion({ asset: assetEntityStub.video });
|
await sut.handleVideoConversion({ asset: assetEntityStub.video });
|
||||||
expect(mediaMock.transcode).not.toHaveBeenCalled();
|
expect(mediaMock.transcode).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should set max bitrate if above 0', async () => {
|
||||||
|
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
|
||||||
|
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '4500k' }]);
|
||||||
|
await sut.handleVideoConversion({ asset: assetEntityStub.video });
|
||||||
|
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||||
|
'/original/path.ext',
|
||||||
|
'upload/encoded-video/user-id/asset-id.mp4',
|
||||||
|
{
|
||||||
|
outputOptions: [
|
||||||
|
'-vcodec h264',
|
||||||
|
'-acodec aac',
|
||||||
|
'-movflags faststart',
|
||||||
|
'-vf scale=-2:720',
|
||||||
|
'-preset ultrafast',
|
||||||
|
'-crf 23',
|
||||||
|
'-maxrate 4500k',
|
||||||
|
],
|
||||||
|
twoPass: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should transcode in two passes for h264/h265 when enabled and max bitrate is above 0', async () => {
|
||||||
|
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
|
||||||
|
configMock.load.mockResolvedValue([
|
||||||
|
{ key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '4500k' },
|
||||||
|
{ key: SystemConfigKey.FFMPEG_TWO_PASS, value: true },
|
||||||
|
]);
|
||||||
|
await sut.handleVideoConversion({ asset: assetEntityStub.video });
|
||||||
|
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||||
|
'/original/path.ext',
|
||||||
|
'upload/encoded-video/user-id/asset-id.mp4',
|
||||||
|
{
|
||||||
|
outputOptions: [
|
||||||
|
'-vcodec h264',
|
||||||
|
'-acodec aac',
|
||||||
|
'-movflags faststart',
|
||||||
|
'-vf scale=-2:720',
|
||||||
|
'-preset ultrafast',
|
||||||
|
'-b:v 3104k',
|
||||||
|
'-minrate 1552k',
|
||||||
|
'-maxrate 4500k',
|
||||||
|
],
|
||||||
|
twoPass: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fallback to one pass for h264/h265 if two-pass is enabled but no max bitrate is set', async () => {
|
||||||
|
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
|
||||||
|
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TWO_PASS, value: true }]);
|
||||||
|
await sut.handleVideoConversion({ asset: assetEntityStub.video });
|
||||||
|
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||||
|
'/original/path.ext',
|
||||||
|
'upload/encoded-video/user-id/asset-id.mp4',
|
||||||
|
{
|
||||||
|
outputOptions: [
|
||||||
|
'-vcodec h264',
|
||||||
|
'-acodec aac',
|
||||||
|
'-movflags faststart',
|
||||||
|
'-vf scale=-2:720',
|
||||||
|
'-preset ultrafast',
|
||||||
|
'-crf 23',
|
||||||
|
],
|
||||||
|
twoPass: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should configure preset for vp9', async () => {
|
||||||
|
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
|
||||||
|
configMock.load.mockResolvedValue([
|
||||||
|
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: 'vp9' },
|
||||||
|
{ key: SystemConfigKey.FFMPEG_THREADS, value: 2 },
|
||||||
|
]);
|
||||||
|
await sut.handleVideoConversion({ asset: assetEntityStub.video });
|
||||||
|
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||||
|
'/original/path.ext',
|
||||||
|
'upload/encoded-video/user-id/asset-id.mp4',
|
||||||
|
{
|
||||||
|
outputOptions: [
|
||||||
|
'-vcodec vp9',
|
||||||
|
'-acodec aac',
|
||||||
|
'-movflags faststart',
|
||||||
|
'-vf scale=-2:720',
|
||||||
|
'-cpu-used 5',
|
||||||
|
'-row-mt 1',
|
||||||
|
'-threads 2',
|
||||||
|
'-crf 23',
|
||||||
|
'-b:v 0',
|
||||||
|
],
|
||||||
|
twoPass: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should configure threads if above 0', async () => {
|
||||||
|
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
|
||||||
|
configMock.load.mockResolvedValue([
|
||||||
|
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: 'vp9' },
|
||||||
|
{ key: SystemConfigKey.FFMPEG_THREADS, value: 2 },
|
||||||
|
]);
|
||||||
|
await sut.handleVideoConversion({ asset: assetEntityStub.video });
|
||||||
|
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||||
|
'/original/path.ext',
|
||||||
|
'upload/encoded-video/user-id/asset-id.mp4',
|
||||||
|
{
|
||||||
|
outputOptions: [
|
||||||
|
'-vcodec vp9',
|
||||||
|
'-acodec aac',
|
||||||
|
'-movflags faststart',
|
||||||
|
'-vf scale=-2:720',
|
||||||
|
'-cpu-used 5',
|
||||||
|
'-row-mt 1',
|
||||||
|
'-threads 2',
|
||||||
|
'-crf 23',
|
||||||
|
'-b:v 0',
|
||||||
|
],
|
||||||
|
twoPass: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should disable thread pooling for x264/x265 if thread limit is above 0', async () => {
|
||||||
|
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
|
||||||
|
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_THREADS, value: 2 }]);
|
||||||
|
await sut.handleVideoConversion({ asset: assetEntityStub.video });
|
||||||
|
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||||
|
'/original/path.ext',
|
||||||
|
'upload/encoded-video/user-id/asset-id.mp4',
|
||||||
|
{
|
||||||
|
outputOptions: [
|
||||||
|
'-vcodec h264',
|
||||||
|
'-acodec aac',
|
||||||
|
'-movflags faststart',
|
||||||
|
'-vf scale=-2:720',
|
||||||
|
'-preset ultrafast',
|
||||||
|
'-threads 2',
|
||||||
|
'-x264-params "pools=none"',
|
||||||
|
'-x264-params "frame-threads=2"',
|
||||||
|
'-crf 23',
|
||||||
|
],
|
||||||
|
twoPass: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -165,10 +165,11 @@ export class MediaService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const options = this.getFfmpegOptions(mainVideoStream, config);
|
const outputOptions = this.getFfmpegOptions(mainVideoStream, config);
|
||||||
|
const twoPass = this.eligibleForTwoPass(config);
|
||||||
|
|
||||||
this.logger.log(`Start encoding video ${asset.id} ${options}`);
|
this.logger.log(`Start encoding video ${asset.id} ${outputOptions}`);
|
||||||
await this.mediaRepository.transcode(input, output, options);
|
await this.mediaRepository.transcode(input, output, { outputOptions, twoPass });
|
||||||
|
|
||||||
this.logger.log(`Encoding success ${asset.id}`);
|
this.logger.log(`Encoding success ${asset.id}`);
|
||||||
|
|
||||||
@ -231,8 +232,6 @@ export class MediaService {
|
|||||||
|
|
||||||
private getFfmpegOptions(stream: VideoStreamInfo, ffmpeg: SystemConfigFFmpegDto) {
|
private getFfmpegOptions(stream: VideoStreamInfo, ffmpeg: SystemConfigFFmpegDto) {
|
||||||
const options = [
|
const options = [
|
||||||
`-crf ${ffmpeg.crf}`,
|
|
||||||
`-preset ${ffmpeg.preset}`,
|
|
||||||
`-vcodec ${ffmpeg.targetVideoCodec}`,
|
`-vcodec ${ffmpeg.targetVideoCodec}`,
|
||||||
`-acodec ${ffmpeg.targetAudioCodec}`,
|
`-acodec ${ffmpeg.targetAudioCodec}`,
|
||||||
// Makes a second pass moving the moov atom to the beginning of
|
// Makes a second pass moving the moov atom to the beginning of
|
||||||
@ -240,17 +239,81 @@ export class MediaService {
|
|||||||
`-movflags faststart`,
|
`-movflags faststart`,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// video dimensions
|
||||||
const videoIsRotated = Math.abs(stream.rotation) === 90;
|
const videoIsRotated = Math.abs(stream.rotation) === 90;
|
||||||
const targetResolution = Number.parseInt(ffmpeg.targetResolution);
|
const targetResolution = Number.parseInt(ffmpeg.targetResolution);
|
||||||
|
|
||||||
const isVideoVertical = stream.height > stream.width || videoIsRotated;
|
const isVideoVertical = stream.height > stream.width || videoIsRotated;
|
||||||
const scaling = isVideoVertical ? `${targetResolution}:-2` : `-2:${targetResolution}`;
|
const scaling = isVideoVertical ? `${targetResolution}:-2` : `-2:${targetResolution}`;
|
||||||
|
|
||||||
const shouldScale = Math.min(stream.height, stream.width) > targetResolution;
|
const shouldScale = Math.min(stream.height, stream.width) > targetResolution;
|
||||||
|
|
||||||
|
// video codec
|
||||||
|
const isVP9 = ffmpeg.targetVideoCodec === 'vp9';
|
||||||
|
const isH264 = ffmpeg.targetVideoCodec === 'h264';
|
||||||
|
const isH265 = ffmpeg.targetVideoCodec === 'hevc';
|
||||||
|
|
||||||
|
// transcode efficiency
|
||||||
|
const limitThreads = ffmpeg.threads > 0;
|
||||||
|
const maxBitrateValue = Number.parseInt(ffmpeg.maxBitrate) || 0;
|
||||||
|
const constrainMaximumBitrate = maxBitrateValue > 0;
|
||||||
|
const bitrateUnit = ffmpeg.maxBitrate.trim().substring(maxBitrateValue.toString().length); // use inputted unit if provided
|
||||||
|
|
||||||
if (shouldScale) {
|
if (shouldScale) {
|
||||||
options.push(`-vf scale=${scaling}`);
|
options.push(`-vf scale=${scaling}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isH264 || isH265) {
|
||||||
|
options.push(`-preset ${ffmpeg.preset}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isVP9) {
|
||||||
|
// vp9 doesn't have presets, but does have a similar setting -cpu-used, from 0-5, 0 being the slowest
|
||||||
|
const presets = ['veryslow', 'slower', 'slow', 'medium', 'fast', 'faster', 'veryfast', 'superfast', 'ultrafast'];
|
||||||
|
const speed = Math.min(presets.indexOf(ffmpeg.preset), 5); // values over 5 require realtime mode, which is its own can of worms since it overrides -crf and -threads
|
||||||
|
if (speed >= 0) {
|
||||||
|
options.push(`-cpu-used ${speed}`);
|
||||||
|
}
|
||||||
|
options.push('-row-mt 1'); // better multithreading
|
||||||
|
}
|
||||||
|
|
||||||
|
if (limitThreads) {
|
||||||
|
options.push(`-threads ${ffmpeg.threads}`);
|
||||||
|
|
||||||
|
// x264 and x265 handle threads differently than one might expect
|
||||||
|
// https://x265.readthedocs.io/en/latest/cli.html#cmdoption-pools
|
||||||
|
if (isH264 || isH265) {
|
||||||
|
options.push(`-${isH265 ? 'x265' : 'x264'}-params "pools=none"`);
|
||||||
|
options.push(`-${isH265 ? 'x265' : 'x264'}-params "frame-threads=${ffmpeg.threads}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// two-pass mode for x264/x265 uses bitrate ranges, so it requires a max bitrate from which to derive a target and min bitrate
|
||||||
|
if (constrainMaximumBitrate && ffmpeg.twoPass) {
|
||||||
|
const targetBitrateValue = Math.ceil(maxBitrateValue / 1.45); // recommended by https://developers.google.com/media/vp9/settings/vod
|
||||||
|
const minBitrateValue = targetBitrateValue / 2;
|
||||||
|
|
||||||
|
options.push(`-b:v ${targetBitrateValue}${bitrateUnit}`);
|
||||||
|
options.push(`-minrate ${minBitrateValue}${bitrateUnit}`);
|
||||||
|
options.push(`-maxrate ${maxBitrateValue}${bitrateUnit}`);
|
||||||
|
} else if (constrainMaximumBitrate || isVP9) {
|
||||||
|
// for vp9, these flags work for both one-pass and two-pass
|
||||||
|
options.push(`-crf ${ffmpeg.crf}`);
|
||||||
|
options.push(`${isVP9 ? '-b:v' : '-maxrate'} ${maxBitrateValue}${bitrateUnit}`);
|
||||||
|
} else {
|
||||||
|
options.push(`-crf ${ffmpeg.crf}`);
|
||||||
|
}
|
||||||
|
|
||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private eligibleForTwoPass(ffmpeg: SystemConfigFFmpegDto) {
|
||||||
|
if (!ffmpeg.twoPass) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isVP9 = ffmpeg.targetVideoCodec === 'vp9';
|
||||||
|
const maxBitrateValue = Number.parseInt(ffmpeg.maxBitrate) || 0;
|
||||||
|
const constrainMaximumBitrate = maxBitrateValue > 0;
|
||||||
|
|
||||||
|
return constrainMaximumBitrate || isVP9;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,21 @@
|
|||||||
import { IsEnum, IsString } from 'class-validator';
|
import { IsEnum, IsString, IsInt, IsBoolean, Min, Max } from 'class-validator';
|
||||||
import { TranscodePreset } from '@app/infra/entities';
|
import { TranscodePreset } from '@app/infra/entities';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
export class SystemConfigFFmpegDto {
|
export class SystemConfigFFmpegDto {
|
||||||
@IsString()
|
@IsInt()
|
||||||
crf!: string;
|
@Min(0)
|
||||||
|
@Max(51)
|
||||||
|
@Type(() => Number)
|
||||||
|
@ApiProperty({ type: 'integer' })
|
||||||
|
crf!: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
@Type(() => Number)
|
||||||
|
@ApiProperty({ type: 'integer' })
|
||||||
|
threads!: number;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
preset!: string;
|
preset!: string;
|
||||||
@ -17,6 +29,12 @@ export class SystemConfigFFmpegDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
targetResolution!: string;
|
targetResolution!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
maxBitrate!: string;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
twoPass!: boolean;
|
||||||
|
|
||||||
@IsEnum(TranscodePreset)
|
@IsEnum(TranscodePreset)
|
||||||
transcode!: TranscodePreset;
|
transcode!: TranscodePreset;
|
||||||
}
|
}
|
||||||
|
@ -9,11 +9,14 @@ export type SystemConfigValidator = (config: SystemConfig) => void | Promise<voi
|
|||||||
|
|
||||||
const defaults: SystemConfig = Object.freeze({
|
const defaults: SystemConfig = Object.freeze({
|
||||||
ffmpeg: {
|
ffmpeg: {
|
||||||
crf: '23',
|
crf: 23,
|
||||||
|
threads: 0,
|
||||||
preset: 'ultrafast',
|
preset: 'ultrafast',
|
||||||
targetVideoCodec: 'h264',
|
targetVideoCodec: 'h264',
|
||||||
targetAudioCodec: 'aac',
|
targetAudioCodec: 'aac',
|
||||||
targetResolution: '720',
|
targetResolution: '720',
|
||||||
|
maxBitrate: '0',
|
||||||
|
twoPass: false,
|
||||||
transcode: TranscodePreset.REQUIRED,
|
transcode: TranscodePreset.REQUIRED,
|
||||||
},
|
},
|
||||||
oauth: {
|
oauth: {
|
||||||
|
@ -7,17 +7,20 @@ import { ISystemConfigRepository } from './system-config.repository';
|
|||||||
import { SystemConfigService } from './system-config.service';
|
import { SystemConfigService } from './system-config.service';
|
||||||
|
|
||||||
const updates: SystemConfigEntity[] = [
|
const updates: SystemConfigEntity[] = [
|
||||||
{ key: SystemConfigKey.FFMPEG_CRF, value: 'a new value' },
|
{ key: SystemConfigKey.FFMPEG_CRF, value: 30 },
|
||||||
{ key: SystemConfigKey.OAUTH_AUTO_LAUNCH, value: true },
|
{ key: SystemConfigKey.OAUTH_AUTO_LAUNCH, value: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
const updatedConfig = Object.freeze({
|
const updatedConfig = Object.freeze({
|
||||||
ffmpeg: {
|
ffmpeg: {
|
||||||
crf: 'a new value',
|
crf: 30,
|
||||||
|
threads: 0,
|
||||||
preset: 'ultrafast',
|
preset: 'ultrafast',
|
||||||
targetAudioCodec: 'aac',
|
targetAudioCodec: 'aac',
|
||||||
targetResolution: '720',
|
targetResolution: '720',
|
||||||
targetVideoCodec: 'h264',
|
targetVideoCodec: 'h264',
|
||||||
|
maxBitrate: '0',
|
||||||
|
twoPass: false,
|
||||||
transcode: TranscodePreset.REQUIRED,
|
transcode: TranscodePreset.REQUIRED,
|
||||||
},
|
},
|
||||||
oauth: {
|
oauth: {
|
||||||
@ -85,7 +88,7 @@ describe(SystemConfigService.name, () => {
|
|||||||
|
|
||||||
it('should merge the overrides', async () => {
|
it('should merge the overrides', async () => {
|
||||||
configMock.load.mockResolvedValue([
|
configMock.load.mockResolvedValue([
|
||||||
{ key: SystemConfigKey.FFMPEG_CRF, value: 'a new value' },
|
{ key: SystemConfigKey.FFMPEG_CRF, value: 30 },
|
||||||
{ key: SystemConfigKey.OAUTH_AUTO_LAUNCH, value: true },
|
{ key: SystemConfigKey.OAUTH_AUTO_LAUNCH, value: true },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
@ -479,11 +479,14 @@ export const keyStub = {
|
|||||||
export const systemConfigStub = {
|
export const systemConfigStub = {
|
||||||
defaults: Object.freeze({
|
defaults: Object.freeze({
|
||||||
ffmpeg: {
|
ffmpeg: {
|
||||||
crf: '23',
|
crf: 23,
|
||||||
|
threads: 0,
|
||||||
preset: 'ultrafast',
|
preset: 'ultrafast',
|
||||||
targetAudioCodec: 'aac',
|
targetAudioCodec: 'aac',
|
||||||
targetResolution: '720',
|
targetResolution: '720',
|
||||||
targetVideoCodec: 'h264',
|
targetVideoCodec: 'h264',
|
||||||
|
maxBitrate: '0',
|
||||||
|
twoPass: false,
|
||||||
transcode: TranscodePreset.REQUIRED,
|
transcode: TranscodePreset.REQUIRED,
|
||||||
},
|
},
|
||||||
oauth: {
|
oauth: {
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { Column, Entity, PrimaryColumn } from 'typeorm';
|
import { Column, Entity, PrimaryColumn } from 'typeorm';
|
||||||
|
|
||||||
@Entity('system_config')
|
@Entity('system_config')
|
||||||
export class SystemConfigEntity<T = string | boolean> {
|
export class SystemConfigEntity<T = string | boolean | number> {
|
||||||
@PrimaryColumn()
|
@PrimaryColumn()
|
||||||
key!: SystemConfigKey;
|
key!: SystemConfigKey;
|
||||||
|
|
||||||
@ -14,10 +14,13 @@ export type SystemConfigValue = any;
|
|||||||
// dot notation matches path in `SystemConfig`
|
// dot notation matches path in `SystemConfig`
|
||||||
export enum SystemConfigKey {
|
export enum SystemConfigKey {
|
||||||
FFMPEG_CRF = 'ffmpeg.crf',
|
FFMPEG_CRF = 'ffmpeg.crf',
|
||||||
|
FFMPEG_THREADS = 'ffmpeg.threads',
|
||||||
FFMPEG_PRESET = 'ffmpeg.preset',
|
FFMPEG_PRESET = 'ffmpeg.preset',
|
||||||
FFMPEG_TARGET_VIDEO_CODEC = 'ffmpeg.targetVideoCodec',
|
FFMPEG_TARGET_VIDEO_CODEC = 'ffmpeg.targetVideoCodec',
|
||||||
FFMPEG_TARGET_AUDIO_CODEC = 'ffmpeg.targetAudioCodec',
|
FFMPEG_TARGET_AUDIO_CODEC = 'ffmpeg.targetAudioCodec',
|
||||||
FFMPEG_TARGET_RESOLUTION = 'ffmpeg.targetResolution',
|
FFMPEG_TARGET_RESOLUTION = 'ffmpeg.targetResolution',
|
||||||
|
FFMPEG_MAX_BITRATE = 'ffmpeg.maxBitrate',
|
||||||
|
FFMPEG_TWO_PASS = 'ffmpeg.twoPass',
|
||||||
FFMPEG_TRANSCODE = 'ffmpeg.transcode',
|
FFMPEG_TRANSCODE = 'ffmpeg.transcode',
|
||||||
OAUTH_ENABLED = 'oauth.enabled',
|
OAUTH_ENABLED = 'oauth.enabled',
|
||||||
OAUTH_ISSUER_URL = 'oauth.issuerUrl',
|
OAUTH_ISSUER_URL = 'oauth.issuerUrl',
|
||||||
@ -42,11 +45,14 @@ export enum TranscodePreset {
|
|||||||
|
|
||||||
export interface SystemConfig {
|
export interface SystemConfig {
|
||||||
ffmpeg: {
|
ffmpeg: {
|
||||||
crf: string;
|
crf: number;
|
||||||
|
threads: number;
|
||||||
preset: string;
|
preset: string;
|
||||||
targetVideoCodec: string;
|
targetVideoCodec: string;
|
||||||
targetAudioCodec: string;
|
targetAudioCodec: string;
|
||||||
targetResolution: string;
|
targetResolution: string;
|
||||||
|
maxBitrate: string;
|
||||||
|
twoPass: boolean;
|
||||||
transcode: TranscodePreset;
|
transcode: TranscodePreset;
|
||||||
};
|
};
|
||||||
oauth: {
|
oauth: {
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
import { CropOptions, IMediaRepository, ResizeOptions, VideoInfo } from '@app/domain';
|
import { CropOptions, IMediaRepository, ResizeOptions, TranscodeOptions, VideoInfo } from '@app/domain';
|
||||||
import { exiftool } from 'exiftool-vendored';
|
import { exiftool } from 'exiftool-vendored';
|
||||||
import ffmpeg, { FfprobeData } from 'fluent-ffmpeg';
|
import ffmpeg, { FfprobeData } from 'fluent-ffmpeg';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
|
||||||
const probe = promisify<string, FfprobeData>(ffmpeg.ffprobe);
|
const probe = promisify<string, FfprobeData>(ffmpeg.ffprobe);
|
||||||
|
|
||||||
@ -85,14 +86,40 @@ export class MediaRepository implements IMediaRepository {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
transcode(input: string, output: string, options: string[]): Promise<void> {
|
transcode(input: string, output: string, options: TranscodeOptions): Promise<void> {
|
||||||
|
if (!options.twoPass) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
ffmpeg(input, { niceness: 10 })
|
||||||
|
.outputOptions(options.outputOptions)
|
||||||
|
.output(output)
|
||||||
|
.on('error', reject)
|
||||||
|
.on('end', resolve)
|
||||||
|
.run();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// two-pass allows for precise control of bitrate at the cost of running twice
|
||||||
|
// recommended for vp9 for better quality and compression
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
ffmpeg(input, { niceness: 10 })
|
ffmpeg(input, { niceness: 10 })
|
||||||
//
|
.outputOptions(options.outputOptions)
|
||||||
.outputOptions(options)
|
.addOptions('-pass', '1')
|
||||||
.output(output)
|
.addOptions('-passlogfile', output)
|
||||||
|
.addOptions('-f null')
|
||||||
|
.output('/dev/null') // first pass output is not saved as only the .log file is needed
|
||||||
.on('error', reject)
|
.on('error', reject)
|
||||||
.on('end', resolve)
|
.on('end', () => {
|
||||||
|
// second pass
|
||||||
|
ffmpeg(input, { niceness: 10 })
|
||||||
|
.outputOptions(options.outputOptions)
|
||||||
|
.addOptions('-pass', '2')
|
||||||
|
.addOptions('-passlogfile', output)
|
||||||
|
.output(output)
|
||||||
|
.on('error', reject)
|
||||||
|
.on('end', () => fs.unlink(`${output}-0.log`))
|
||||||
|
.on('end', resolve)
|
||||||
|
.run();
|
||||||
|
})
|
||||||
.run();
|
.run();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -9,7 +9,7 @@ export class SystemConfigRepository implements ISystemConfigRepository {
|
|||||||
private repository: Repository<SystemConfigEntity>,
|
private repository: Repository<SystemConfigEntity>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
load(): Promise<SystemConfigEntity<string | boolean>[]> {
|
load(): Promise<SystemConfigEntity<string | boolean | number>[]> {
|
||||||
return this.repository.find();
|
return this.repository.find();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
22
web/src/api/open-api/api.ts
generated
22
web/src/api/open-api/api.ts
generated
@ -2112,10 +2112,16 @@ export interface SystemConfigDto {
|
|||||||
export interface SystemConfigFFmpegDto {
|
export interface SystemConfigFFmpegDto {
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @type {string}
|
* @type {number}
|
||||||
* @memberof SystemConfigFFmpegDto
|
* @memberof SystemConfigFFmpegDto
|
||||||
*/
|
*/
|
||||||
'crf': string;
|
'crf': number;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {number}
|
||||||
|
* @memberof SystemConfigFFmpegDto
|
||||||
|
*/
|
||||||
|
'threads': number;
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @type {string}
|
* @type {string}
|
||||||
@ -2140,6 +2146,18 @@ export interface SystemConfigFFmpegDto {
|
|||||||
* @memberof SystemConfigFFmpegDto
|
* @memberof SystemConfigFFmpegDto
|
||||||
*/
|
*/
|
||||||
'targetResolution': string;
|
'targetResolution': string;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
* @memberof SystemConfigFFmpegDto
|
||||||
|
*/
|
||||||
|
'maxBitrate': string;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {boolean}
|
||||||
|
* @memberof SystemConfigFFmpegDto
|
||||||
|
*/
|
||||||
|
'twoPass': boolean;
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @type {string}
|
* @type {string}
|
||||||
|
@ -7,6 +7,7 @@
|
|||||||
import SettingButtonsRow from '../setting-buttons-row.svelte';
|
import SettingButtonsRow from '../setting-buttons-row.svelte';
|
||||||
import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte';
|
import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte';
|
||||||
import SettingSelect from '../setting-select.svelte';
|
import SettingSelect from '../setting-select.svelte';
|
||||||
|
import SettingSwitch from '../setting-switch.svelte';
|
||||||
import { isEqual } from 'lodash-es';
|
import { isEqual } from 'lodash-es';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
|
|
||||||
@ -80,21 +81,34 @@
|
|||||||
<SettingInputField
|
<SettingInputField
|
||||||
inputType={SettingInputFieldType.NUMBER}
|
inputType={SettingInputFieldType.NUMBER}
|
||||||
label="CONSTANT RATE FACTOR (-crf)"
|
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."
|
||||||
bind:value={ffmpegConfig.crf}
|
bind:value={ffmpegConfig.crf}
|
||||||
required={true}
|
required={true}
|
||||||
isEdited={!(ffmpegConfig.crf == savedConfig.crf)}
|
isEdited={!(ffmpegConfig.crf == savedConfig.crf)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SettingInputField
|
<SettingSelect
|
||||||
inputType={SettingInputFieldType.TEXT}
|
|
||||||
label="PRESET (-preset)"
|
label="PRESET (-preset)"
|
||||||
|
desc="Compression speed. Slower presets produce smaller files, and increase quality when targeting a certain bitrate. VP9 ignores speeds above `faster`."
|
||||||
bind:value={ffmpegConfig.preset}
|
bind:value={ffmpegConfig.preset}
|
||||||
required={true}
|
name="preset"
|
||||||
|
options={[
|
||||||
|
{ value: 'ultrafast', text: 'ultrafast' },
|
||||||
|
{ value: 'superfast', text: 'superfast' },
|
||||||
|
{ value: 'veryfast', text: 'veryfast' },
|
||||||
|
{ value: 'faster', text: 'faster' },
|
||||||
|
{ value: 'fast', text: 'fast' },
|
||||||
|
{ value: 'medium', text: 'medium' },
|
||||||
|
{ value: 'slow', text: 'slow' },
|
||||||
|
{ value: 'slower', text: 'slower' },
|
||||||
|
{ value: 'veryslow', text: 'veryslow' }
|
||||||
|
]}
|
||||||
isEdited={!(ffmpegConfig.preset == savedConfig.preset)}
|
isEdited={!(ffmpegConfig.preset == savedConfig.preset)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SettingSelect
|
<SettingSelect
|
||||||
label="AUDIO CODEC"
|
label="AUDIO CODEC"
|
||||||
|
desc="Opus is the highest quality option, but has lower compatibility with old devices or software."
|
||||||
bind:value={ffmpegConfig.targetAudioCodec}
|
bind:value={ffmpegConfig.targetAudioCodec}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'aac', text: 'aac' },
|
{ value: 'aac', text: 'aac' },
|
||||||
@ -107,6 +121,7 @@
|
|||||||
|
|
||||||
<SettingSelect
|
<SettingSelect
|
||||||
label="VIDEO CODEC"
|
label="VIDEO CODEC"
|
||||||
|
desc="VP9 has high efficiency and web compatibility, but takes longer to transcode. HEVC performs similarly, but has lower web compatibility. H.264 is widely compatible and quick to transcode, but produces much larger files."
|
||||||
bind:value={ffmpegConfig.targetVideoCodec}
|
bind:value={ffmpegConfig.targetVideoCodec}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'h264', text: 'h264' },
|
{ value: 'h264', text: 'h264' },
|
||||||
@ -119,6 +134,7 @@
|
|||||||
|
|
||||||
<SettingSelect
|
<SettingSelect
|
||||||
label="TARGET RESOLUTION"
|
label="TARGET RESOLUTION"
|
||||||
|
desc="Higher resolutions can preserve more detail but take longer to encode, have larger file sizes, and can reduce app responsiveness."
|
||||||
bind:value={ffmpegConfig.targetResolution}
|
bind:value={ffmpegConfig.targetResolution}
|
||||||
options={[
|
options={[
|
||||||
{ value: '2160', text: '4k' },
|
{ value: '2160', text: '4k' },
|
||||||
@ -131,8 +147,25 @@
|
|||||||
isEdited={!(ffmpegConfig.targetResolution == savedConfig.targetResolution)}
|
isEdited={!(ffmpegConfig.targetResolution == savedConfig.targetResolution)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<SettingInputField
|
||||||
|
inputType={SettingInputFieldType.TEXT}
|
||||||
|
label="MAX BITRATE"
|
||||||
|
desc="Setting a max bitrate can make file sizes more predictable at a minor cost to quality. At 720p, typical values are 2600k for VP9 or HEVC, or 4500k for H.264. Disabled if set to 0."
|
||||||
|
bind:value={ffmpegConfig.maxBitrate}
|
||||||
|
isEdited={!(ffmpegConfig.maxBitrate == savedConfig.maxBitrate)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SettingInputField
|
||||||
|
inputType={SettingInputFieldType.NUMBER}
|
||||||
|
label="THREADS"
|
||||||
|
desc="Higher values lead to faster encoding, but leave less room for the server to process other tasks while active. This value should not be more than the number of CPU cores. Maximizes utilization if set to 0."
|
||||||
|
bind:value={ffmpegConfig.threads}
|
||||||
|
isEdited={!(ffmpegConfig.threads == savedConfig.threads)}
|
||||||
|
/>
|
||||||
|
|
||||||
<SettingSelect
|
<SettingSelect
|
||||||
label="TRANSCODE"
|
label="TRANSCODE"
|
||||||
|
desc="Policy for when a video should be transcoded."
|
||||||
bind:value={ffmpegConfig.transcode}
|
bind:value={ffmpegConfig.transcode}
|
||||||
name="transcode"
|
name="transcode"
|
||||||
options={[
|
options={[
|
||||||
@ -152,6 +185,13 @@
|
|||||||
]}
|
]}
|
||||||
isEdited={!(ffmpegConfig.transcode == savedConfig.transcode)}
|
isEdited={!(ffmpegConfig.transcode == savedConfig.transcode)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<SettingSwitch
|
||||||
|
title="TWO-PASS ENCODING"
|
||||||
|
subtitle="Transcode in two passes to produce better encoded videos. When max bitrate is enabled (required for it to work with H.264 and HEVC), this mode uses a bitrate range based on the max bitrate and ignores CRF. For VP9, CRF can be used if max bitrate is disabled."
|
||||||
|
bind:checked={ffmpegConfig.twoPass}
|
||||||
|
isEdited={!(ffmpegConfig.twoPass === savedConfig.twoPass)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ml-4">
|
<div class="ml-4">
|
||||||
|
@ -12,8 +12,9 @@
|
|||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition';
|
||||||
|
|
||||||
export let inputType: SettingInputFieldType;
|
export let inputType: SettingInputFieldType;
|
||||||
export let value: string;
|
export let value: string | number;
|
||||||
export let label = '';
|
export let label = '';
|
||||||
|
export let desc = '';
|
||||||
export let required = false;
|
export let required = false;
|
||||||
export let disabled = false;
|
export let disabled = false;
|
||||||
export let isEdited = false;
|
export let isEdited = false;
|
||||||
@ -39,8 +40,17 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if desc}
|
||||||
|
<p class="immich-form-label text-xs pb-2" id="{label}-desc">
|
||||||
|
{desc}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<input
|
<input
|
||||||
class="immich-form-input w-full"
|
class="immich-form-input pb-2 w-full"
|
||||||
|
aria-describedby={desc ? `${label}-desc` : undefined}
|
||||||
|
aria-labelledby="{label}-label"
|
||||||
id={label}
|
id={label}
|
||||||
name={label}
|
name={label}
|
||||||
type={inputType}
|
type={inputType}
|
||||||
|
@ -5,6 +5,7 @@
|
|||||||
export let value: string;
|
export let value: string;
|
||||||
export let options: { value: string; text: string }[];
|
export let options: { value: string; text: string }[];
|
||||||
export let label = '';
|
export let label = '';
|
||||||
|
export let desc = '';
|
||||||
export let name = '';
|
export let name = '';
|
||||||
export let isEdited = false;
|
export let isEdited = false;
|
||||||
|
|
||||||
@ -26,8 +27,16 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if desc}
|
||||||
|
<p class="immich-form-label text-xs pb-2" id="{name}-desc">
|
||||||
|
{desc}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<select
|
<select
|
||||||
class="immich-form-input w-full"
|
class="immich-form-input pb-2 w-full"
|
||||||
|
aria-describedby={desc ? `${name}-desc` : undefined}
|
||||||
{name}
|
{name}
|
||||||
id="{name}-select"
|
id="{name}-select"
|
||||||
bind:value
|
bind:value
|
||||||
|
@ -28,7 +28,7 @@
|
|||||||
<p class="text-sm dark:text-immich-dark-fg">{subtitle}</p>
|
<p class="text-sm dark:text-immich-dark-fg">{subtitle}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label class="relative inline-block w-[36px] h-[10px]">
|
<label class="relative inline-block flex-none w-[36px] h-[10px]">
|
||||||
<input
|
<input
|
||||||
class="opacity-0 w-0 h-0 disabled::cursor-not-allowed"
|
class="opacity-0 w-0 h-0 disabled::cursor-not-allowed"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
Loading…
Reference in New Issue
Block a user