feat(server): wide gamut thumbnails (#3658)
This commit is contained in:
parent
4bd77d5899
commit
2069293cc1
27 changed files with 405 additions and 61 deletions
cli/src/api/open-api
mobile/openapi
server
web/src
api/open-api
lib/components/admin-page/settings
28
cli/src/api/open-api/api.ts
generated
28
cli/src/api/open-api/api.ts
generated
|
@ -1046,6 +1046,20 @@ export interface ClassificationConfig {
|
|||
}
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @enum {string}
|
||||
*/
|
||||
|
||||
export const Colorspace = {
|
||||
Srgb: 'srgb',
|
||||
P3: 'p3'
|
||||
} as const;
|
||||
|
||||
export type Colorspace = typeof Colorspace[keyof typeof Colorspace];
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
|
@ -3184,12 +3198,24 @@ export interface SystemConfigTemplateStorageOptionDto {
|
|||
* @interface SystemConfigThumbnailDto
|
||||
*/
|
||||
export interface SystemConfigThumbnailDto {
|
||||
/**
|
||||
*
|
||||
* @type {Colorspace}
|
||||
* @memberof SystemConfigThumbnailDto
|
||||
*/
|
||||
'colorspace': Colorspace;
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
* @memberof SystemConfigThumbnailDto
|
||||
*/
|
||||
'jpegSize': number;
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
* @memberof SystemConfigThumbnailDto
|
||||
*/
|
||||
'quality': number;
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
|
@ -3197,6 +3223,8 @@ export interface SystemConfigThumbnailDto {
|
|||
*/
|
||||
'webpSize': number;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
|
|
3
mobile/openapi/.openapi-generator/FILES
generated
3
mobile/openapi/.openapi-generator/FILES
generated
|
@ -44,6 +44,7 @@ doc/CheckDuplicateAssetResponseDto.md
|
|||
doc/CheckExistingAssetsDto.md
|
||||
doc/CheckExistingAssetsResponseDto.md
|
||||
doc/ClassificationConfig.md
|
||||
doc/Colorspace.md
|
||||
doc/CreateAlbumDto.md
|
||||
doc/CreateProfileImageResponseDto.md
|
||||
doc/CreateTagDto.md
|
||||
|
@ -199,6 +200,7 @@ lib/model/check_existing_assets_response_dto.dart
|
|||
lib/model/classification_config.dart
|
||||
lib/model/clip_config.dart
|
||||
lib/model/clip_mode.dart
|
||||
lib/model/colorspace.dart
|
||||
lib/model/cq_mode.dart
|
||||
lib/model/create_album_dto.dart
|
||||
lib/model/create_profile_image_response_dto.dart
|
||||
|
@ -326,6 +328,7 @@ test/check_existing_assets_response_dto_test.dart
|
|||
test/classification_config_test.dart
|
||||
test/clip_config_test.dart
|
||||
test/clip_mode_test.dart
|
||||
test/colorspace_test.dart
|
||||
test/cq_mode_test.dart
|
||||
test/create_album_dto_test.dart
|
||||
test/create_profile_image_response_dto_test.dart
|
||||
|
|
1
mobile/openapi/README.md
generated
1
mobile/openapi/README.md
generated
|
@ -218,6 +218,7 @@ Class | Method | HTTP request | Description
|
|||
- [CheckExistingAssetsDto](doc//CheckExistingAssetsDto.md)
|
||||
- [CheckExistingAssetsResponseDto](doc//CheckExistingAssetsResponseDto.md)
|
||||
- [ClassificationConfig](doc//ClassificationConfig.md)
|
||||
- [Colorspace](doc//Colorspace.md)
|
||||
- [CreateAlbumDto](doc//CreateAlbumDto.md)
|
||||
- [CreateProfileImageResponseDto](doc//CreateProfileImageResponseDto.md)
|
||||
- [CreateTagDto](doc//CreateTagDto.md)
|
||||
|
|
14
mobile/openapi/doc/Colorspace.md
generated
Normal file
14
mobile/openapi/doc/Colorspace.md
generated
Normal file
|
@ -0,0 +1,14 @@
|
|||
# openapi.model.Colorspace
|
||||
|
||||
## Load the model package
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
```
|
||||
|
||||
## Properties
|
||||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
|
||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||
|
||||
|
2
mobile/openapi/doc/SystemConfigThumbnailDto.md
generated
2
mobile/openapi/doc/SystemConfigThumbnailDto.md
generated
|
@ -8,7 +8,9 @@ import 'package:openapi/api.dart';
|
|||
## Properties
|
||||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
**colorspace** | [**Colorspace**](Colorspace.md) | |
|
||||
**jpegSize** | **int** | |
|
||||
**quality** | **int** | |
|
||||
**webpSize** | **int** | |
|
||||
|
||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||
|
|
1
mobile/openapi/lib/api.dart
generated
1
mobile/openapi/lib/api.dart
generated
|
@ -80,6 +80,7 @@ part 'model/check_duplicate_asset_response_dto.dart';
|
|||
part 'model/check_existing_assets_dto.dart';
|
||||
part 'model/check_existing_assets_response_dto.dart';
|
||||
part 'model/classification_config.dart';
|
||||
part 'model/colorspace.dart';
|
||||
part 'model/create_album_dto.dart';
|
||||
part 'model/create_profile_image_response_dto.dart';
|
||||
part 'model/create_tag_dto.dart';
|
||||
|
|
2
mobile/openapi/lib/api_client.dart
generated
2
mobile/openapi/lib/api_client.dart
generated
|
@ -253,6 +253,8 @@ class ApiClient {
|
|||
return CheckExistingAssetsResponseDto.fromJson(value);
|
||||
case 'ClassificationConfig':
|
||||
return ClassificationConfig.fromJson(value);
|
||||
case 'Colorspace':
|
||||
return ColorspaceTypeTransformer().decode(value);
|
||||
case 'CreateAlbumDto':
|
||||
return CreateAlbumDto.fromJson(value);
|
||||
case 'CreateProfileImageResponseDto':
|
||||
|
|
3
mobile/openapi/lib/api_helper.dart
generated
3
mobile/openapi/lib/api_helper.dart
generated
|
@ -70,6 +70,9 @@ String parameterToString(dynamic value) {
|
|||
if (value is CQMode) {
|
||||
return CQModeTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is Colorspace) {
|
||||
return ColorspaceTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is DeleteAssetStatus) {
|
||||
return DeleteAssetStatusTypeTransformer().encode(value).toString();
|
||||
}
|
||||
|
|
85
mobile/openapi/lib/model/colorspace.dart
generated
Normal file
85
mobile/openapi/lib/model/colorspace.dart
generated
Normal file
|
@ -0,0 +1,85 @@
|
|||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.12
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
|
||||
class Colorspace {
|
||||
/// Instantiate a new enum with the provided [value].
|
||||
const Colorspace._(this.value);
|
||||
|
||||
/// The underlying value of this enum member.
|
||||
final String value;
|
||||
|
||||
@override
|
||||
String toString() => value;
|
||||
|
||||
String toJson() => value;
|
||||
|
||||
static const srgb = Colorspace._(r'srgb');
|
||||
static const p3 = Colorspace._(r'p3');
|
||||
|
||||
/// List of all possible values in this [enum][Colorspace].
|
||||
static const values = <Colorspace>[
|
||||
srgb,
|
||||
p3,
|
||||
];
|
||||
|
||||
static Colorspace? fromJson(dynamic value) => ColorspaceTypeTransformer().decode(value);
|
||||
|
||||
static List<Colorspace>? listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <Colorspace>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = Colorspace.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
}
|
||||
|
||||
/// Transformation class that can [encode] an instance of [Colorspace] to String,
|
||||
/// and [decode] dynamic data back to [Colorspace].
|
||||
class ColorspaceTypeTransformer {
|
||||
factory ColorspaceTypeTransformer() => _instance ??= const ColorspaceTypeTransformer._();
|
||||
|
||||
const ColorspaceTypeTransformer._();
|
||||
|
||||
String encode(Colorspace data) => data.value;
|
||||
|
||||
/// Decodes a [dynamic value][data] to a Colorspace.
|
||||
///
|
||||
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
|
||||
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
|
||||
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
|
||||
///
|
||||
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
|
||||
/// and users are still using an old app with the old code.
|
||||
Colorspace? decode(dynamic data, {bool allowNull = true}) {
|
||||
if (data != null) {
|
||||
switch (data) {
|
||||
case r'srgb': return Colorspace.srgb;
|
||||
case r'p3': return Colorspace.p3;
|
||||
default:
|
||||
if (!allowNull) {
|
||||
throw ArgumentError('Unknown enum value to decode: $data');
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Singleton [ColorspaceTypeTransformer] instance.
|
||||
static ColorspaceTypeTransformer? _instance;
|
||||
}
|
||||
|
|
@ -13,31 +13,43 @@ part of openapi.api;
|
|||
class SystemConfigThumbnailDto {
|
||||
/// Returns a new [SystemConfigThumbnailDto] instance.
|
||||
SystemConfigThumbnailDto({
|
||||
required this.colorspace,
|
||||
required this.jpegSize,
|
||||
required this.quality,
|
||||
required this.webpSize,
|
||||
});
|
||||
|
||||
Colorspace colorspace;
|
||||
|
||||
int jpegSize;
|
||||
|
||||
int quality;
|
||||
|
||||
int webpSize;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is SystemConfigThumbnailDto &&
|
||||
other.colorspace == colorspace &&
|
||||
other.jpegSize == jpegSize &&
|
||||
other.quality == quality &&
|
||||
other.webpSize == webpSize;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(colorspace.hashCode) +
|
||||
(jpegSize.hashCode) +
|
||||
(quality.hashCode) +
|
||||
(webpSize.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SystemConfigThumbnailDto[jpegSize=$jpegSize, webpSize=$webpSize]';
|
||||
String toString() => 'SystemConfigThumbnailDto[colorspace=$colorspace, jpegSize=$jpegSize, quality=$quality, webpSize=$webpSize]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'colorspace'] = this.colorspace;
|
||||
json[r'jpegSize'] = this.jpegSize;
|
||||
json[r'quality'] = this.quality;
|
||||
json[r'webpSize'] = this.webpSize;
|
||||
return json;
|
||||
}
|
||||
|
@ -50,7 +62,9 @@ class SystemConfigThumbnailDto {
|
|||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return SystemConfigThumbnailDto(
|
||||
colorspace: Colorspace.fromJson(json[r'colorspace'])!,
|
||||
jpegSize: mapValueOfType<int>(json, r'jpegSize')!,
|
||||
quality: mapValueOfType<int>(json, r'quality')!,
|
||||
webpSize: mapValueOfType<int>(json, r'webpSize')!,
|
||||
);
|
||||
}
|
||||
|
@ -99,7 +113,9 @@ class SystemConfigThumbnailDto {
|
|||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'colorspace',
|
||||
'jpegSize',
|
||||
'quality',
|
||||
'webpSize',
|
||||
};
|
||||
}
|
||||
|
|
21
mobile/openapi/test/colorspace_test.dart
generated
Normal file
21
mobile/openapi/test/colorspace_test.dart
generated
Normal file
|
@ -0,0 +1,21 @@
|
|||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.12
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
// tests for Colorspace
|
||||
void main() {
|
||||
|
||||
group('test Colorspace', () {
|
||||
|
||||
});
|
||||
|
||||
}
|
|
@ -16,11 +16,21 @@ void main() {
|
|||
// final instance = SystemConfigThumbnailDto();
|
||||
|
||||
group('test SystemConfigThumbnailDto', () {
|
||||
// Colorspace colorspace
|
||||
test('to test the property `colorspace`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// int jpegSize
|
||||
test('to test the property `jpegSize`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// int quality
|
||||
test('to test the property `quality`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// int webpSize
|
||||
test('to test the property `webpSize`', () async {
|
||||
// TODO
|
||||
|
|
|
@ -5523,6 +5523,13 @@
|
|||
],
|
||||
"type": "object"
|
||||
},
|
||||
"Colorspace": {
|
||||
"enum": [
|
||||
"srgb",
|
||||
"p3"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"CreateAlbumDto": {
|
||||
"properties": {
|
||||
"albumName": {
|
||||
|
@ -7284,16 +7291,24 @@
|
|||
},
|
||||
"SystemConfigThumbnailDto": {
|
||||
"properties": {
|
||||
"colorspace": {
|
||||
"$ref": "#/components/schemas/Colorspace"
|
||||
},
|
||||
"jpegSize": {
|
||||
"type": "integer"
|
||||
},
|
||||
"quality": {
|
||||
"type": "integer"
|
||||
},
|
||||
"webpSize": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"webpSize",
|
||||
"jpegSize"
|
||||
"jpegSize",
|
||||
"quality",
|
||||
"colorspace"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { Colorspace } from '@app/infra/entities';
|
||||
import {
|
||||
assetStub,
|
||||
faceStub,
|
||||
|
@ -115,7 +116,6 @@ describe(FacialRecognitionService.name, () => {
|
|||
personMock = newPersonRepositoryMock();
|
||||
searchMock = newSearchRepositoryMock();
|
||||
storageMock = newStorageRepositoryMock();
|
||||
configMock = newSystemConfigRepositoryMock();
|
||||
|
||||
mediaMock.crop.mockResolvedValue(croppedFace);
|
||||
|
||||
|
@ -292,6 +292,8 @@ describe(FacialRecognitionService.name, () => {
|
|||
expect(mediaMock.resize).toHaveBeenCalledWith(croppedFace, 'upload/thumbs/user-id/person-1.jpeg', {
|
||||
format: 'jpeg',
|
||||
size: 250,
|
||||
quality: 80,
|
||||
colorspace: Colorspace.P3,
|
||||
});
|
||||
expect(personMock.update).toHaveBeenCalledWith({
|
||||
id: 'person-1',
|
||||
|
@ -313,6 +315,8 @@ describe(FacialRecognitionService.name, () => {
|
|||
expect(mediaMock.resize).toHaveBeenCalledWith(croppedFace, 'upload/thumbs/user-id/person-1.jpeg', {
|
||||
format: 'jpeg',
|
||||
size: 250,
|
||||
quality: 80,
|
||||
colorspace: Colorspace.P3,
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -330,6 +334,8 @@ describe(FacialRecognitionService.name, () => {
|
|||
expect(mediaMock.resize).toHaveBeenCalledWith(croppedFace, 'upload/thumbs/user-id/person-1.jpeg', {
|
||||
format: 'jpeg',
|
||||
size: 250,
|
||||
quality: 80,
|
||||
colorspace: Colorspace.P3,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -162,8 +162,15 @@ export class FacialRecognitionService {
|
|||
height: newHalfSize * 2,
|
||||
};
|
||||
|
||||
const { thumbnail } = await this.configCore.getConfig();
|
||||
const croppedOutput = await this.mediaRepository.crop(asset.resizePath, cropOptions);
|
||||
await this.mediaRepository.resize(croppedOutput, output, { size: FACE_THUMBNAIL_SIZE, format: 'jpeg' });
|
||||
const thumbnailOptions = {
|
||||
format: 'jpeg',
|
||||
size: FACE_THUMBNAIL_SIZE,
|
||||
colorspace: thumbnail.colorspace,
|
||||
quality: thumbnail.quality,
|
||||
} as const;
|
||||
await this.mediaRepository.resize(croppedOutput, output, thumbnailOptions);
|
||||
await this.personRepository.update({ id: personId, thumbnailPath: output });
|
||||
|
||||
return true;
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
import { VideoCodec } from '@app/infra/entities';
|
||||
import { Writable } from 'stream';
|
||||
|
||||
export const IMediaRepository = 'IMediaRepository';
|
||||
|
||||
export interface ResizeOptions {
|
||||
size: number;
|
||||
format: 'webp' | 'jpeg';
|
||||
colorspace: string;
|
||||
quality: number;
|
||||
}
|
||||
|
||||
export interface VideoStreamInfo {
|
||||
|
@ -73,5 +76,5 @@ export interface IMediaRepository {
|
|||
|
||||
// video
|
||||
probe(input: string): Promise<VideoInfo>;
|
||||
transcode(input: string, output: string, options: TranscodeOptions): Promise<void>;
|
||||
transcode(input: string, output: string | Writable, options: TranscodeOptions): Promise<void>;
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import {
|
||||
AssetType,
|
||||
Colorspace,
|
||||
SystemConfigKey,
|
||||
ToneMapping,
|
||||
TranscodeHWAccel,
|
||||
|
@ -134,6 +135,8 @@ describe(MediaService.name, () => {
|
|||
expect(mediaMock.resize).toHaveBeenCalledWith('/original/path.jpg', 'upload/thumbs/user-id/asset-id.jpeg', {
|
||||
size: 1440,
|
||||
format: 'jpeg',
|
||||
quality: 80,
|
||||
colorspace: Colorspace.P3,
|
||||
});
|
||||
expect(assetMock.save).toHaveBeenCalledWith({
|
||||
id: 'asset-id',
|
||||
|
@ -148,12 +151,11 @@ describe(MediaService.name, () => {
|
|||
|
||||
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id');
|
||||
expect(mediaMock.transcode).toHaveBeenCalledWith('/original/path.ext', 'upload/thumbs/user-id/asset-id.jpeg', {
|
||||
inputOptions: [],
|
||||
inputOptions: ['-ss 00:00:00', '-sws_flags accurate_rnd+bitexact+full_chroma_int'],
|
||||
outputOptions: [
|
||||
'-ss 00:00:00.000',
|
||||
'-frames:v 1',
|
||||
'-v verbose',
|
||||
'-vf scale=-2:1440:out_color_matrix=bt601:out_range=pc,format=yuv420p',
|
||||
'-vf scale=-2:1440:flags=lanczos+accurate_rnd+bitexact+full_chroma_int:out_color_matrix=601:out_range=pc,format=yuv420p',
|
||||
],
|
||||
twoPass: false,
|
||||
});
|
||||
|
@ -170,12 +172,11 @@ describe(MediaService.name, () => {
|
|||
|
||||
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id');
|
||||
expect(mediaMock.transcode).toHaveBeenCalledWith('/original/path.ext', 'upload/thumbs/user-id/asset-id.jpeg', {
|
||||
inputOptions: [],
|
||||
inputOptions: ['-ss 00:00:00', '-sws_flags accurate_rnd+bitexact+full_chroma_int'],
|
||||
outputOptions: [
|
||||
'-ss 00:00:00.000',
|
||||
'-frames:v 1',
|
||||
'-v verbose',
|
||||
'-vf zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt470bg:t=601:m=bt470bg:range=pc,format=yuv420p',
|
||||
'-vf zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=601:m=bt470bg:range=pc,format=yuv420p',
|
||||
],
|
||||
twoPass: false,
|
||||
});
|
||||
|
@ -209,12 +210,13 @@ describe(MediaService.name, () => {
|
|||
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
||||
await sut.handleGenerateWebpThumbnail({ id: assetStub.image.id });
|
||||
|
||||
expect(mediaMock.resize).toHaveBeenCalledWith(
|
||||
'/uploads/user-id/thumbs/path.jpg',
|
||||
'/uploads/user-id/thumbs/path.webp',
|
||||
{ format: 'webp', size: 250 },
|
||||
);
|
||||
expect(assetMock.save).toHaveBeenCalledWith({ id: 'asset-id', webpPath: '/uploads/user-id/thumbs/path.webp' });
|
||||
expect(mediaMock.resize).toHaveBeenCalledWith('/original/path.jpg', 'upload/thumbs/user-id/asset-id.webp', {
|
||||
format: 'webp',
|
||||
size: 250,
|
||||
quality: 80,
|
||||
colorspace: Colorspace.P3,
|
||||
});
|
||||
expect(assetMock.save).toHaveBeenCalledWith({ id: 'asset-id', webpPath: 'upload/thumbs/user-id/asset-id.webp' });
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -9,7 +9,6 @@ import { ISystemConfigRepository, SystemConfigFFmpegDto } from '../system-config
|
|||
import { SystemConfigCore } from '../system-config/system-config.core';
|
||||
import { AudioStreamInfo, IMediaRepository, VideoCodecHWConfig, VideoStreamInfo } from './media.repository';
|
||||
import { H264Config, HEVCConfig, NVENCConfig, QSVConfig, ThumbnailConfig, VAAPIConfig, VP9Config } from './media.util';
|
||||
|
||||
@Injectable()
|
||||
export class MediaService {
|
||||
private logger = new Logger(MediaService.name);
|
||||
|
@ -21,9 +20,9 @@ export class MediaService {
|
|||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||
@Inject(IMediaRepository) private mediaRepository: IMediaRepository,
|
||||
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
||||
@Inject(ISystemConfigRepository) systemConfig: ISystemConfigRepository,
|
||||
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
|
||||
) {
|
||||
this.configCore = new SystemConfigCore(systemConfig);
|
||||
this.configCore = new SystemConfigCore(configRepository);
|
||||
}
|
||||
|
||||
async handleQueueGenerateThumbnails(job: IBaseJob) {
|
||||
|
@ -59,38 +58,53 @@ export class MediaService {
|
|||
return false;
|
||||
}
|
||||
|
||||
const resizePath = this.storageCore.getFolderLocation(StorageFolder.THUMBNAILS, asset.ownerId);
|
||||
this.storageRepository.mkdirSync(resizePath);
|
||||
const jpegThumbnailPath = join(resizePath, `${asset.id}.jpeg`);
|
||||
const { thumbnail } = await this.configCore.getConfig();
|
||||
const resizePath = await this.generateThumbnail(asset, 'jpeg');
|
||||
await this.assetRepository.save({ id: asset.id, resizePath });
|
||||
return true;
|
||||
}
|
||||
|
||||
async generateThumbnail(asset: AssetEntity, format: 'jpeg' | 'webp') {
|
||||
let path;
|
||||
switch (asset.type) {
|
||||
case AssetType.IMAGE:
|
||||
await this.mediaRepository.resize(asset.originalPath, jpegThumbnailPath, {
|
||||
size: thumbnail.jpegSize,
|
||||
format: 'jpeg',
|
||||
});
|
||||
this.logger.log(`Successfully generated image thumbnail ${asset.id}`);
|
||||
path = await this.generateImageThumbnail(asset, format);
|
||||
break;
|
||||
case AssetType.VIDEO:
|
||||
const { audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath);
|
||||
const mainVideoStream = this.getMainStream(videoStreams);
|
||||
if (!mainVideoStream) {
|
||||
this.logger.error(`Could not extract thumbnail for asset ${asset.id}: no video streams found`);
|
||||
return false;
|
||||
}
|
||||
const mainAudioStream = this.getMainStream(audioStreams);
|
||||
const { ffmpeg } = await this.configCore.getConfig();
|
||||
const config = { ...ffmpeg, targetResolution: thumbnail.jpegSize.toString(), twoPass: false };
|
||||
const options = new ThumbnailConfig(config).getOptions(mainVideoStream, mainAudioStream);
|
||||
await this.mediaRepository.transcode(asset.originalPath, jpegThumbnailPath, options);
|
||||
this.logger.log(`Successfully generated video thumbnail ${asset.id}`);
|
||||
path = await this.generateVideoThumbnail(asset, format);
|
||||
break;
|
||||
default:
|
||||
throw new UnsupportedMediaTypeException(`Unsupported asset type for thumbnail generation: ${asset.type}`);
|
||||
}
|
||||
this.logger.log(
|
||||
`Successfully generated ${format.toUpperCase()} ${asset.type.toLowerCase()} thumbnail for asset ${asset.id}`,
|
||||
);
|
||||
return path;
|
||||
}
|
||||
|
||||
await this.assetRepository.save({ id: asset.id, resizePath: jpegThumbnailPath });
|
||||
async generateImageThumbnail(asset: AssetEntity, format: 'jpeg' | 'webp') {
|
||||
const { thumbnail } = await this.configCore.getConfig();
|
||||
const size = format === 'jpeg' ? thumbnail.jpegSize : thumbnail.webpSize;
|
||||
const thumbnailOptions = { format, size, colorspace: thumbnail.colorspace, quality: thumbnail.quality };
|
||||
const path = this.ensureThumbnailPath(asset, format);
|
||||
await this.mediaRepository.resize(asset.originalPath, path, thumbnailOptions);
|
||||
return path;
|
||||
}
|
||||
|
||||
return true;
|
||||
async generateVideoThumbnail(asset: AssetEntity, format: 'jpeg' | 'webp') {
|
||||
const { ffmpeg, thumbnail } = await this.configCore.getConfig();
|
||||
const size = format === 'jpeg' ? thumbnail.jpegSize : thumbnail.webpSize;
|
||||
const { audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath);
|
||||
const mainVideoStream = this.getMainStream(videoStreams);
|
||||
if (!mainVideoStream) {
|
||||
this.logger.warn(`Skipped thumbnail generation for asset ${asset.id}: no video streams found`);
|
||||
return;
|
||||
}
|
||||
const mainAudioStream = this.getMainStream(audioStreams);
|
||||
const path = this.ensureThumbnailPath(asset, format);
|
||||
const config = { ...ffmpeg, targetResolution: size.toString() };
|
||||
const options = new ThumbnailConfig(config).getOptions(mainVideoStream, mainAudioStream);
|
||||
await this.mediaRepository.transcode(asset.originalPath, path, options);
|
||||
return path;
|
||||
}
|
||||
|
||||
async handleGenerateWebpThumbnail({ id }: IEntityJob) {
|
||||
|
@ -99,12 +113,8 @@ export class MediaService {
|
|||
return false;
|
||||
}
|
||||
|
||||
const webpPath = asset.resizePath.replace('jpeg', 'webp').replace('jpg', 'webp');
|
||||
|
||||
const { thumbnail } = await this.configCore.getConfig();
|
||||
await this.mediaRepository.resize(asset.resizePath, webpPath, { size: thumbnail.webpSize, format: 'webp' });
|
||||
const webpPath = await this.generateThumbnail(asset, 'webp');
|
||||
await this.assetRepository.save({ id: asset.id, webpPath });
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
@ -289,4 +299,10 @@ export class MediaService {
|
|||
|
||||
return handler;
|
||||
}
|
||||
|
||||
ensureThumbnailPath(asset: AssetEntity, extension: string): string {
|
||||
const folderPath = this.storageCore.getFolderLocation(StorageFolder.THUMBNAILS, asset.ownerId);
|
||||
this.storageRepository.mkdirSync(folderPath);
|
||||
return join(folderPath, `${asset.id}.${extension}`);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -263,8 +263,11 @@ export class BaseHWConfig extends BaseConfig implements VideoCodecHWConfig {
|
|||
}
|
||||
|
||||
export class ThumbnailConfig extends BaseConfig {
|
||||
getBaseInputOptions(): string[] {
|
||||
return ['-ss 00:00:00', '-sws_flags accurate_rnd+bitexact+full_chroma_int'];
|
||||
}
|
||||
getBaseOutputOptions() {
|
||||
return ['-ss 00:00:00.000', '-frames:v 1'];
|
||||
return ['-frames:v 1'];
|
||||
}
|
||||
|
||||
getPresetOptions() {
|
||||
|
@ -277,16 +280,16 @@ export class ThumbnailConfig extends BaseConfig {
|
|||
|
||||
getScaling(videoStream: VideoStreamInfo) {
|
||||
let options = super.getScaling(videoStream);
|
||||
options += ':flags=lanczos+accurate_rnd+bitexact+full_chroma_int';
|
||||
if (!this.shouldToneMap(videoStream)) {
|
||||
options += ':out_color_matrix=bt601:out_range=pc';
|
||||
options += ':out_color_matrix=601:out_range=pc';
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
getColors() {
|
||||
return {
|
||||
// jpeg and webp only support bt.601, so we need to convert to that directly when tone-mapping to avoid color shifts
|
||||
primaries: 'bt470bg',
|
||||
primaries: 'bt709',
|
||||
transfer: '601',
|
||||
matrix: 'bt470bg',
|
||||
};
|
||||
|
|
|
@ -1,15 +1,29 @@
|
|||
import { Colorspace } from '@app/infra/entities';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt } from 'class-validator';
|
||||
import { IsEnum, IsInt, Max, Min } from 'class-validator';
|
||||
|
||||
export class SystemConfigThumbnailDto {
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'integer' })
|
||||
webpSize!: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'integer' })
|
||||
jpegSize!: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'integer' })
|
||||
quality!: number;
|
||||
|
||||
@IsEnum(Colorspace)
|
||||
@ApiProperty({ enumName: 'Colorspace', enum: Colorspace })
|
||||
colorspace!: Colorspace;
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import {
|
||||
AudioCodec,
|
||||
CQMode,
|
||||
Colorspace,
|
||||
SystemConfig,
|
||||
SystemConfigEntity,
|
||||
SystemConfigKey,
|
||||
|
@ -98,6 +99,8 @@ export const defaults = Object.freeze<SystemConfig>({
|
|||
thumbnail: {
|
||||
webpSize: 250,
|
||||
jpegSize: 1440,
|
||||
quality: 80,
|
||||
colorspace: Colorspace.P3,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import {
|
||||
AudioCodec,
|
||||
CQMode,
|
||||
Colorspace,
|
||||
SystemConfig,
|
||||
SystemConfigEntity,
|
||||
SystemConfigKey,
|
||||
|
@ -94,6 +95,8 @@ const updatedConfig = Object.freeze<SystemConfig>({
|
|||
thumbnail: {
|
||||
webpSize: 250,
|
||||
jpegSize: 1440,
|
||||
quality: 80,
|
||||
colorspace: Colorspace.P3,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -76,6 +76,8 @@ export enum SystemConfigKey {
|
|||
|
||||
THUMBNAIL_WEBP_SIZE = 'thumbnail.webpSize',
|
||||
THUMBNAIL_JPEG_SIZE = 'thumbnail.jpegSize',
|
||||
THUMBNAIL_QUALITY = 'thumbnail.quality',
|
||||
THUMBNAIL_COLORSPACE = 'thumbnail.colorspace',
|
||||
}
|
||||
|
||||
export enum TranscodePolicy {
|
||||
|
@ -117,6 +119,11 @@ export enum CQMode {
|
|||
ICQ = 'icq',
|
||||
}
|
||||
|
||||
export enum Colorspace {
|
||||
SRGB = 'srgb',
|
||||
P3 = 'p3',
|
||||
}
|
||||
|
||||
export interface SystemConfig {
|
||||
ffmpeg: {
|
||||
crf: number;
|
||||
|
@ -179,5 +186,7 @@ export interface SystemConfig {
|
|||
thumbnail: {
|
||||
webpSize: number;
|
||||
jpegSize: number;
|
||||
quality: number;
|
||||
colorspace: Colorspace;
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
import { CropOptions, IMediaRepository, ResizeOptions, TranscodeOptions, VideoInfo } from '@app/domain';
|
||||
import { Colorspace } from '@app/infra/entities';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import ffmpeg, { FfprobeData } from 'fluent-ffmpeg';
|
||||
import fs from 'fs/promises';
|
||||
import sharp from 'sharp';
|
||||
import { Writable } from 'stream';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const probe = promisify<string, FfprobeData>(ffmpeg.ffprobe);
|
||||
|
@ -11,7 +13,7 @@ sharp.concurrency(0);
|
|||
export class MediaRepository implements IMediaRepository {
|
||||
private logger = new Logger(MediaRepository.name);
|
||||
|
||||
crop(input: string, options: CropOptions): Promise<Buffer> {
|
||||
crop(input: string | Buffer, options: CropOptions): Promise<Buffer> {
|
||||
return sharp(input, { failOn: 'none' })
|
||||
.extract({
|
||||
left: options.left,
|
||||
|
@ -23,10 +25,25 @@ export class MediaRepository implements IMediaRepository {
|
|||
}
|
||||
|
||||
async resize(input: string | Buffer, output: string, options: ResizeOptions): Promise<void> {
|
||||
await sharp(input, { failOn: 'none' })
|
||||
let colorProfile = options.colorspace;
|
||||
if (options.colorspace !== Colorspace.SRGB) {
|
||||
try {
|
||||
const { space } = await sharp(input).metadata();
|
||||
// if the image is already in srgb, keep it that way
|
||||
if (space === 'srgb') {
|
||||
colorProfile = Colorspace.SRGB;
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`Could not determine colorspace of image, defaulting to ${colorProfile} profile`);
|
||||
}
|
||||
}
|
||||
const chromaSubsampling = options.quality >= 80 ? '4:4:4' : '4:2:0'; // this is default in libvips (except the threshold is 90), but we need to set it manually in sharp
|
||||
sharp(input, { failOn: 'none' })
|
||||
.pipelineColorspace('rgb16')
|
||||
.resize(options.size, options.size, { fit: 'outside', withoutEnlargement: true })
|
||||
.rotate()
|
||||
.toFormat(options.format)
|
||||
.withMetadata({ icc: colorProfile })
|
||||
.toFormat(options.format, { quality: options.quality, chromaSubsampling })
|
||||
.toFile(output);
|
||||
}
|
||||
|
||||
|
@ -61,7 +78,7 @@ export class MediaRepository implements IMediaRepository {
|
|||
};
|
||||
}
|
||||
|
||||
transcode(input: string, output: string, options: TranscodeOptions): Promise<void> {
|
||||
transcode(input: string, output: string | Writable, options: TranscodeOptions): Promise<void> {
|
||||
if (!options.twoPass) {
|
||||
return new Promise((resolve, reject) => {
|
||||
ffmpeg(input, { niceness: 10 })
|
||||
|
@ -77,6 +94,10 @@ export class MediaRepository implements IMediaRepository {
|
|||
});
|
||||
}
|
||||
|
||||
if (typeof output !== 'string') {
|
||||
throw new Error('Two-pass transcoding does not support writing to a stream');
|
||||
}
|
||||
|
||||
// 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) => {
|
||||
|
|
28
web/src/api/open-api/api.ts
generated
28
web/src/api/open-api/api.ts
generated
|
@ -1046,6 +1046,20 @@ export interface ClassificationConfig {
|
|||
}
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @enum {string}
|
||||
*/
|
||||
|
||||
export const Colorspace = {
|
||||
Srgb: 'srgb',
|
||||
P3: 'p3'
|
||||
} as const;
|
||||
|
||||
export type Colorspace = typeof Colorspace[keyof typeof Colorspace];
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
|
@ -3184,12 +3198,24 @@ export interface SystemConfigTemplateStorageOptionDto {
|
|||
* @interface SystemConfigThumbnailDto
|
||||
*/
|
||||
export interface SystemConfigThumbnailDto {
|
||||
/**
|
||||
*
|
||||
* @type {Colorspace}
|
||||
* @memberof SystemConfigThumbnailDto
|
||||
*/
|
||||
'colorspace': Colorspace;
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
* @memberof SystemConfigThumbnailDto
|
||||
*/
|
||||
'jpegSize': number;
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
* @memberof SystemConfigThumbnailDto
|
||||
*/
|
||||
'quality': number;
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
|
@ -3197,6 +3223,8 @@ export interface SystemConfigThumbnailDto {
|
|||
*/
|
||||
'webpSize': number;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
|
|
|
@ -1,12 +1,16 @@
|
|||
<script lang="ts">
|
||||
import { quintOut } from 'svelte/easing';
|
||||
import { fly } from 'svelte/transition';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let title: string;
|
||||
export let subtitle = '';
|
||||
export let checked = false;
|
||||
export let disabled = false;
|
||||
export let isEdited = false;
|
||||
|
||||
const dispatch = createEventDispatcher<{ toggle: boolean }>();
|
||||
const onToggle = (event: Event) => dispatch('toggle', (event.target as HTMLInputElement).checked);
|
||||
</script>
|
||||
|
||||
<div class="flex place-items-center justify-between">
|
||||
|
@ -29,7 +33,13 @@
|
|||
</div>
|
||||
|
||||
<label class="relative inline-block h-[10px] w-[36px] flex-none">
|
||||
<input class="disabled::cursor-not-allowed h-0 w-0 opacity-0" type="checkbox" bind:checked on:click {disabled} />
|
||||
<input
|
||||
class="disabled::cursor-not-allowed h-0 w-0 opacity-0"
|
||||
type="checkbox"
|
||||
bind:checked
|
||||
on:click={onToggle}
|
||||
{disabled}
|
||||
/>
|
||||
|
||||
{#if disabled}
|
||||
<span class="slider-disable cursor-not-allowed" />
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<script lang="ts">
|
||||
import SettingSelect from '$lib/components/admin-page/settings/setting-select.svelte';
|
||||
import { api, SystemConfigThumbnailDto } from '@api';
|
||||
import { api, Colorspace, SystemConfigThumbnailDto } from '@api';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { isEqual } from 'lodash-es';
|
||||
import SettingButtonsRow from '$lib/components/admin-page/settings/setting-buttons-row.svelte';
|
||||
|
@ -8,6 +8,8 @@
|
|||
notificationController,
|
||||
NotificationType,
|
||||
} from '$lib/components/shared-components/notification/notification';
|
||||
import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte';
|
||||
import SettingSwitch from '../setting-switch.svelte';
|
||||
|
||||
export let thumbnailConfig: SystemConfigThumbnailDto; // this is the config that is being edited
|
||||
export let disabled = false;
|
||||
|
@ -91,7 +93,7 @@
|
|||
{ value: 250, text: '250p' },
|
||||
]}
|
||||
name="resolution"
|
||||
isEdited={!(thumbnailConfig.webpSize === savedConfig.webpSize)}
|
||||
isEdited={thumbnailConfig.webpSize !== savedConfig.webpSize}
|
||||
{disabled}
|
||||
/>
|
||||
|
||||
|
@ -105,9 +107,25 @@
|
|||
{ value: 1440, text: '1440p' },
|
||||
]}
|
||||
name="resolution"
|
||||
isEdited={!(thumbnailConfig.jpegSize === savedConfig.jpegSize)}
|
||||
isEdited={thumbnailConfig.jpegSize !== savedConfig.jpegSize}
|
||||
{disabled}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
label="QUALITY"
|
||||
desc="Thumbnail quality from 1-100. Higher is better for quality but produces larger files."
|
||||
bind:value={thumbnailConfig.quality}
|
||||
isEdited={thumbnailConfig.quality !== savedConfig.quality}
|
||||
/>
|
||||
|
||||
<SettingSwitch
|
||||
title="PREFER WIDE GAMUT"
|
||||
subtitle="Use Display P3 for thumbnails. This better preserves the vibrance of images with wide colorspaces, but images may appear differently on old devices with an old browser version. sRGB images are kept as sRGB to avoid color shifts."
|
||||
checked={thumbnailConfig.colorspace === Colorspace.P3}
|
||||
on:toggle={(e) => (thumbnailConfig.colorspace = e.detail ? Colorspace.P3 : Colorspace.Srgb)}
|
||||
isEdited={thumbnailConfig.colorspace !== savedConfig.colorspace}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="ml-4">
|
||||
|
|
Loading…
Add table
Reference in a new issue