Merge branch 'main' into feature/readonly-sharing

# Conflicts:
#	server/src/services/album.service.ts
This commit is contained in:
mgabor
2024-04-12 17:20:36 +02:00
250 changed files with 9537 additions and 4911 deletions

View File

@@ -25,6 +25,7 @@ module.exports = {
'unicorn/prefer-top-level-await': 'off',
'unicorn/prefer-event-target': 'off',
'unicorn/no-thenable': 'off',
'unicorn/import-style': 'off',
'@typescript-eslint/await-thenable': 'error',
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-misused-promises': 'error',

View File

@@ -5,7 +5,7 @@ RUN apt-get install --no-install-recommends -yqq tini
WORKDIR /usr/src/app
COPY server/package.json server/package-lock.json ./
RUN npm ci && \
# sharp-linux-x64 and sharp-linux-arm64 are the only ones we need
# exiftool-vendored.pl, sharp-linux-x64 and sharp-linux-arm64 are the only ones we need
# they're marked as optional dependencies, so we need to copy them manually after pruning
rm -rf node_modules/@img/sharp-libvips* && \
rm -rf node_modules/@img/sharp-linuxmusl-x64
@@ -22,9 +22,10 @@ FROM dev AS prod
RUN npm run build
RUN npm prune --omit=dev --omit=optional
COPY --from=dev /usr/src/app/node_modules/@img ./node_modules/@img
COPY --from=dev /usr/src/app/node_modules/exiftool-vendored.pl ./node_modules/exiftool-vendored.pl
# web build
FROM node:iron-alpine3.18@sha256:fa5d3cf51725bd42d32e67917623038539dbe720dab082f590785c001eb4dfef as web
FROM node:iron-alpine3.18@sha256:3fb85a68652064ab109ed9730f45a3ede11f064afdd3ad9f96ef7e8a3c55f47e as web
WORKDIR /usr/src/open-api/typescript-sdk
COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./

1834
server/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -45,9 +45,9 @@
"@nestjs/swagger": "^7.1.8",
"@nestjs/typeorm": "^10.0.0",
"@nestjs/websockets": "^10.2.2",
"@opentelemetry/auto-instrumentations-node": "^0.43.0",
"@opentelemetry/exporter-prometheus": "^0.49.0",
"@opentelemetry/sdk-node": "^0.49.0",
"@opentelemetry/auto-instrumentations-node": "^0.44.0",
"@opentelemetry/exporter-prometheus": "^0.50.0",
"@opentelemetry/sdk-node": "^0.50.0",
"@socket.io/postgres-adapter": "^0.3.1",
"@types/picomatch": "^2.3.3",
"archiver": "^7.0.0",
@@ -59,7 +59,6 @@
"class-validator": "^0.14.0",
"cookie-parser": "^1.4.6",
"exiftool-vendored": "~24.6.0",
"exiftool-vendored.pl": "12.78",
"fast-glob": "^3.3.2",
"fluent-ffmpeg": "^2.1.2",
"geo-tz": "^8.0.0",
@@ -113,7 +112,7 @@
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^51.0.0",
"eslint-plugin-unicorn": "^52.0.0",
"jest": "^29.6.4",
"jest-when": "^3.6.0",
"mock-fs": "^5.2.0",
@@ -168,6 +167,6 @@
"globalSetup": "<rootDir>/test/global-setup.js"
},
"volta": {
"node": "20.12.0"
"node": "20.12.1"
}
}

View File

@@ -32,9 +32,6 @@ export class CreateLibraryDto {
@ArrayUnique()
@ArrayMaxSize(128)
exclusionPatterns?: string[];
@ValidateBoolean({ optional: true })
isWatched?: boolean;
}
export class UpdateLibraryDto {

View File

@@ -1,115 +1,137 @@
import { ConcurrentQueueName } from 'src/interfaces/job.interface';
import { Column, Entity, PrimaryColumn } from 'typeorm';
@Entity('system_config')
export class SystemConfigEntity<T = SystemConfigValue> {
@PrimaryColumn()
key!: SystemConfigKey;
export type SystemConfigValue = string | string[] | number | boolean;
@Column({ type: 'varchar', nullable: true, transformer: { to: JSON.stringify, from: JSON.parse } })
value!: T | T[];
}
// https://stackoverflow.com/a/47058976
// https://stackoverflow.com/a/70692231
type PathsToStringProps<T> = T extends SystemConfigValue
? []
: {
[K in keyof T]: [K, ...PathsToStringProps<T[K]>];
}[keyof T];
export type SystemConfigValue = string | number | boolean;
type Join<T extends string[], D extends string> = T extends []
? never
: T extends [infer F]
? F
: T extends [infer F, ...infer R]
? F extends string
? `${F}${D}${Join<Extract<R, string[]>, D>}`
: never
: string;
// dot notation matches path in `SystemConfig`
export enum SystemConfigKey {
FFMPEG_CRF = 'ffmpeg.crf',
FFMPEG_THREADS = 'ffmpeg.threads',
FFMPEG_PRESET = 'ffmpeg.preset',
FFMPEG_TARGET_VIDEO_CODEC = 'ffmpeg.targetVideoCodec',
FFMPEG_ACCEPTED_VIDEO_CODECS = 'ffmpeg.acceptedVideoCodecs',
FFMPEG_TARGET_AUDIO_CODEC = 'ffmpeg.targetAudioCodec',
FFMPEG_ACCEPTED_AUDIO_CODECS = 'ffmpeg.acceptedAudioCodecs',
FFMPEG_TARGET_RESOLUTION = 'ffmpeg.targetResolution',
FFMPEG_MAX_BITRATE = 'ffmpeg.maxBitrate',
FFMPEG_BFRAMES = 'ffmpeg.bframes',
FFMPEG_REFS = 'ffmpeg.refs',
FFMPEG_GOP_SIZE = 'ffmpeg.gopSize',
FFMPEG_NPL = 'ffmpeg.npl',
FFMPEG_TEMPORAL_AQ = 'ffmpeg.temporalAQ',
FFMPEG_CQ_MODE = 'ffmpeg.cqMode',
FFMPEG_TWO_PASS = 'ffmpeg.twoPass',
FFMPEG_PREFERRED_HW_DEVICE = 'ffmpeg.preferredHwDevice',
FFMPEG_TRANSCODE = 'ffmpeg.transcode',
FFMPEG_ACCEL = 'ffmpeg.accel',
FFMPEG_TONEMAP = 'ffmpeg.tonemap',
// TODO: migrate to key value per section
export const SystemConfigKey = {
FFMPEG_CRF: 'ffmpeg.crf',
FFMPEG_THREADS: 'ffmpeg.threads',
FFMPEG_PRESET: 'ffmpeg.preset',
FFMPEG_TARGET_VIDEO_CODEC: 'ffmpeg.targetVideoCodec',
FFMPEG_ACCEPTED_VIDEO_CODECS: 'ffmpeg.acceptedVideoCodecs',
FFMPEG_TARGET_AUDIO_CODEC: 'ffmpeg.targetAudioCodec',
FFMPEG_ACCEPTED_AUDIO_CODECS: 'ffmpeg.acceptedAudioCodecs',
FFMPEG_TARGET_RESOLUTION: 'ffmpeg.targetResolution',
FFMPEG_MAX_BITRATE: 'ffmpeg.maxBitrate',
FFMPEG_BFRAMES: 'ffmpeg.bframes',
FFMPEG_REFS: 'ffmpeg.refs',
FFMPEG_GOP_SIZE: 'ffmpeg.gopSize',
FFMPEG_NPL: 'ffmpeg.npl',
FFMPEG_TEMPORAL_AQ: 'ffmpeg.temporalAQ',
FFMPEG_CQ_MODE: 'ffmpeg.cqMode',
FFMPEG_TWO_PASS: 'ffmpeg.twoPass',
FFMPEG_PREFERRED_HW_DEVICE: 'ffmpeg.preferredHwDevice',
FFMPEG_TRANSCODE: 'ffmpeg.transcode',
FFMPEG_ACCEL: 'ffmpeg.accel',
FFMPEG_TONEMAP: 'ffmpeg.tonemap',
JOB_THUMBNAIL_GENERATION_CONCURRENCY = 'job.thumbnailGeneration.concurrency',
JOB_METADATA_EXTRACTION_CONCURRENCY = 'job.metadataExtraction.concurrency',
JOB_VIDEO_CONVERSION_CONCURRENCY = 'job.videoConversion.concurrency',
JOB_FACE_DETECTION_CONCURRENCY = 'job.faceDetection.concurrency',
JOB_CLIP_ENCODING_CONCURRENCY = 'job.smartSearch.concurrency',
JOB_BACKGROUND_TASK_CONCURRENCY = 'job.backgroundTask.concurrency',
JOB_STORAGE_TEMPLATE_MIGRATION_CONCURRENCY = 'job.storageTemplateMigration.concurrency',
JOB_SEARCH_CONCURRENCY = 'job.search.concurrency',
JOB_SIDECAR_CONCURRENCY = 'job.sidecar.concurrency',
JOB_LIBRARY_CONCURRENCY = 'job.library.concurrency',
JOB_MIGRATION_CONCURRENCY = 'job.migration.concurrency',
JOB_THUMBNAIL_GENERATION_CONCURRENCY: 'job.thumbnailGeneration.concurrency',
JOB_METADATA_EXTRACTION_CONCURRENCY: 'job.metadataExtraction.concurrency',
JOB_VIDEO_CONVERSION_CONCURRENCY: 'job.videoConversion.concurrency',
JOB_FACE_DETECTION_CONCURRENCY: 'job.faceDetection.concurrency',
JOB_CLIP_ENCODING_CONCURRENCY: 'job.smartSearch.concurrency',
JOB_BACKGROUND_TASK_CONCURRENCY: 'job.backgroundTask.concurrency',
JOB_SEARCH_CONCURRENCY: 'job.search.concurrency',
JOB_SIDECAR_CONCURRENCY: 'job.sidecar.concurrency',
JOB_LIBRARY_CONCURRENCY: 'job.library.concurrency',
JOB_MIGRATION_CONCURRENCY: 'job.migration.concurrency',
LIBRARY_SCAN_ENABLED = 'library.scan.enabled',
LIBRARY_SCAN_CRON_EXPRESSION = 'library.scan.cronExpression',
LIBRARY_SCAN_ENABLED: 'library.scan.enabled',
LIBRARY_SCAN_CRON_EXPRESSION: 'library.scan.cronExpression',
LIBRARY_WATCH_ENABLED = 'library.watch.enabled',
LIBRARY_WATCH_ENABLED: 'library.watch.enabled',
LOGGING_ENABLED = 'logging.enabled',
LOGGING_LEVEL = 'logging.level',
LOGGING_ENABLED: 'logging.enabled',
LOGGING_LEVEL: 'logging.level',
MACHINE_LEARNING_ENABLED = 'machineLearning.enabled',
MACHINE_LEARNING_URL = 'machineLearning.url',
MACHINE_LEARNING_ENABLED: 'machineLearning.enabled',
MACHINE_LEARNING_URL: 'machineLearning.url',
MACHINE_LEARNING_CLIP_ENABLED = 'machineLearning.clip.enabled',
MACHINE_LEARNING_CLIP_MODEL_NAME = 'machineLearning.clip.modelName',
MACHINE_LEARNING_CLIP_ENABLED: 'machineLearning.clip.enabled',
MACHINE_LEARNING_CLIP_MODEL_NAME: 'machineLearning.clip.modelName',
MACHINE_LEARNING_FACIAL_RECOGNITION_ENABLED = 'machineLearning.facialRecognition.enabled',
MACHINE_LEARNING_FACIAL_RECOGNITION_MODEL_NAME = 'machineLearning.facialRecognition.modelName',
MACHINE_LEARNING_FACIAL_RECOGNITION_MIN_SCORE = 'machineLearning.facialRecognition.minScore',
MACHINE_LEARNING_FACIAL_RECOGNITION_MAX_DISTANCE = 'machineLearning.facialRecognition.maxDistance',
MACHINE_LEARNING_FACIAL_RECOGNITION_MIN_FACES = 'machineLearning.facialRecognition.minFaces',
MACHINE_LEARNING_FACIAL_RECOGNITION_ENABLED: 'machineLearning.facialRecognition.enabled',
MACHINE_LEARNING_FACIAL_RECOGNITION_MODEL_NAME: 'machineLearning.facialRecognition.modelName',
MACHINE_LEARNING_FACIAL_RECOGNITION_MIN_SCORE: 'machineLearning.facialRecognition.minScore',
MACHINE_LEARNING_FACIAL_RECOGNITION_MAX_DISTANCE: 'machineLearning.facialRecognition.maxDistance',
MACHINE_LEARNING_FACIAL_RECOGNITION_MIN_FACES: 'machineLearning.facialRecognition.minFaces',
MAP_ENABLED = 'map.enabled',
MAP_LIGHT_STYLE = 'map.lightStyle',
MAP_DARK_STYLE = 'map.darkStyle',
MAP_ENABLED: 'map.enabled',
MAP_LIGHT_STYLE: 'map.lightStyle',
MAP_DARK_STYLE: 'map.darkStyle',
REVERSE_GEOCODING_ENABLED = 'reverseGeocoding.enabled',
REVERSE_GEOCODING_ENABLED: 'reverseGeocoding.enabled',
NEW_VERSION_CHECK_ENABLED = 'newVersionCheck.enabled',
NEW_VERSION_CHECK_ENABLED: 'newVersionCheck.enabled',
OAUTH_AUTO_LAUNCH = 'oauth.autoLaunch',
OAUTH_AUTO_REGISTER = 'oauth.autoRegister',
OAUTH_BUTTON_TEXT = 'oauth.buttonText',
OAUTH_CLIENT_ID = 'oauth.clientId',
OAUTH_CLIENT_SECRET = 'oauth.clientSecret',
OAUTH_DEFAULT_STORAGE_QUOTA = 'oauth.defaultStorageQuota',
OAUTH_ENABLED = 'oauth.enabled',
OAUTH_ISSUER_URL = 'oauth.issuerUrl',
OAUTH_MOBILE_OVERRIDE_ENABLED = 'oauth.mobileOverrideEnabled',
OAUTH_MOBILE_REDIRECT_URI = 'oauth.mobileRedirectUri',
OAUTH_SCOPE = 'oauth.scope',
OAUTH_SIGNING_ALGORITHM = 'oauth.signingAlgorithm',
OAUTH_STORAGE_LABEL_CLAIM = 'oauth.storageLabelClaim',
OAUTH_STORAGE_QUOTA_CLAIM = 'oauth.storageQuotaClaim',
OAUTH_AUTO_LAUNCH: 'oauth.autoLaunch',
OAUTH_AUTO_REGISTER: 'oauth.autoRegister',
OAUTH_BUTTON_TEXT: 'oauth.buttonText',
OAUTH_CLIENT_ID: 'oauth.clientId',
OAUTH_CLIENT_SECRET: 'oauth.clientSecret',
OAUTH_DEFAULT_STORAGE_QUOTA: 'oauth.defaultStorageQuota',
OAUTH_ENABLED: 'oauth.enabled',
OAUTH_ISSUER_URL: 'oauth.issuerUrl',
OAUTH_MOBILE_OVERRIDE_ENABLED: 'oauth.mobileOverrideEnabled',
OAUTH_MOBILE_REDIRECT_URI: 'oauth.mobileRedirectUri',
OAUTH_SCOPE: 'oauth.scope',
OAUTH_SIGNING_ALGORITHM: 'oauth.signingAlgorithm',
OAUTH_STORAGE_LABEL_CLAIM: 'oauth.storageLabelClaim',
OAUTH_STORAGE_QUOTA_CLAIM: 'oauth.storageQuotaClaim',
PASSWORD_LOGIN_ENABLED = 'passwordLogin.enabled',
PASSWORD_LOGIN_ENABLED: 'passwordLogin.enabled',
SERVER_EXTERNAL_DOMAIN = 'server.externalDomain',
SERVER_LOGIN_PAGE_MESSAGE = 'server.loginPageMessage',
SERVER_EXTERNAL_DOMAIN: 'server.externalDomain',
SERVER_LOGIN_PAGE_MESSAGE: 'server.loginPageMessage',
STORAGE_TEMPLATE_ENABLED = 'storageTemplate.enabled',
STORAGE_TEMPLATE_HASH_VERIFICATION_ENABLED = 'storageTemplate.hashVerificationEnabled',
STORAGE_TEMPLATE = 'storageTemplate.template',
STORAGE_TEMPLATE_ENABLED: 'storageTemplate.enabled',
STORAGE_TEMPLATE_HASH_VERIFICATION_ENABLED: 'storageTemplate.hashVerificationEnabled',
STORAGE_TEMPLATE: 'storageTemplate.template',
THUMBNAIL_WEBP_SIZE = 'thumbnail.webpSize',
THUMBNAIL_JPEG_SIZE = 'thumbnail.jpegSize',
THUMBNAIL_QUALITY = 'thumbnail.quality',
THUMBNAIL_COLORSPACE = 'thumbnail.colorspace',
IMAGE_THUMBNAIL_FORMAT: 'image.thumbnailFormat',
IMAGE_THUMBNAIL_SIZE: 'image.thumbnailSize',
IMAGE_PREVIEW_FORMAT: 'image.previewFormat',
IMAGE_PREVIEW_SIZE: 'image.previewSize',
IMAGE_QUALITY: 'image.quality',
IMAGE_COLORSPACE: 'image.colorspace',
TRASH_ENABLED = 'trash.enabled',
TRASH_DAYS = 'trash.days',
TRASH_ENABLED: 'trash.enabled',
TRASH_DAYS: 'trash.days',
THEME_CUSTOM_CSS = 'theme.customCss',
THEME_CUSTOM_CSS: 'theme.customCss',
USER_DELETE_DELAY = 'user.deleteDelay',
USER_DELETE_DELAY: 'user.deleteDelay',
} as const satisfies Record<string, Join<PathsToStringProps<SystemConfig>, '.'>>;
export type SystemConfigKeyPaths = (typeof SystemConfigKey)[keyof typeof SystemConfigKey];
@Entity('system_config')
export class SystemConfigEntity<T = SystemConfigValue> {
@PrimaryColumn({ type: 'varchar' })
key!: SystemConfigKeyPaths;
@Column({ type: 'varchar', nullable: true, transformer: { to: JSON.stringify, from: JSON.parse } })
value!: T;
}
export enum TranscodePolicy {
@@ -131,6 +153,7 @@ export enum VideoCodec {
H264 = 'h264',
HEVC = 'hevc',
VP9 = 'vp9',
AV1 = 'av1',
}
export enum AudioCodec {

View File

@@ -146,7 +146,7 @@ export interface IAssetRepository {
): Promise<AssetEntity[]>;
getByIdsWithAllRelations(ids: string[]): Promise<AssetEntity[]>;
getByDayOfYear(ownerIds: string[], monthDay: MonthDay): Promise<AssetEntity[]>;
getByChecksum(userId: string, checksum: Buffer): Promise<AssetEntity | null>;
getByChecksum(libraryId: string, checksum: Buffer): Promise<AssetEntity | null>;
getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated<AssetEntity>;
getByUserId(pagination: PaginationOptions, userId: string, options?: AssetSearchOptions): Paginated<AssetEntity>;
getById(id: string, relations?: FindOptionsRelations<AssetEntity>): Promise<AssetEntity | null>;

View File

@@ -464,7 +464,7 @@ FROM
WHERE
(
(
("AssetEntity"."ownerId" = $1)
("AssetEntity"."libraryId" = $1)
AND ("AssetEntity"."checksum" = $2)
)
)

View File

@@ -265,8 +265,8 @@ export class AssetRepository implements IAssetRepository {
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.BUFFER] })
getByChecksum(userId: string, checksum: Buffer): Promise<AssetEntity | null> {
return this.repository.findOne({ where: { ownerId: userId, checksum } });
getByChecksum(libraryId: string, checksum: Buffer): Promise<AssetEntity | null> {
return this.repository.findOne({ where: { libraryId, checksum } });
}
findLivePhotoMatch(options: LivePhotoSearchOptions): Promise<AssetEntity | null> {

View File

@@ -175,6 +175,7 @@ describe(AlbumService.name, () => {
it('creates album', async () => {
albumMock.create.mockResolvedValue(albumStub.empty);
userMock.get.mockResolvedValue(userStub.user1);
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['123']));
await sut.create(authStub.admin, {
albumName: 'Empty album',
@@ -193,6 +194,7 @@ describe(AlbumService.name, () => {
});
expect(userMock.get).toHaveBeenCalledWith('user-id', {});
expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['123']));
});
it('should require valid userIds', async () => {
@@ -206,6 +208,31 @@ describe(AlbumService.name, () => {
expect(userMock.get).toHaveBeenCalledWith('user-3', {});
expect(albumMock.create).not.toHaveBeenCalled();
});
it('should only add assets the user is allowed to access', async () => {
userMock.get.mockResolvedValue(userStub.user1);
albumMock.create.mockResolvedValue(albumStub.oneAsset);
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
await sut.create(authStub.admin, {
albumName: 'Test album',
description: '',
assetIds: ['asset-1', 'asset-2'],
});
expect(albumMock.create).toHaveBeenCalledWith({
ownerId: authStub.admin.user.id,
albumName: 'Test album',
description: '',
sharedUsers: [],
assets: [{ id: 'asset-1' }],
albumThumbnailAssetId: 'asset-1',
});
expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(
authStub.admin.user.id,
new Set(['asset-1', 'asset-2']),
);
});
});
describe('update', () => {

View File

@@ -121,14 +121,17 @@ export class AlbumService {
}
}
const allowedAssetIdsSet = await this.access.checkAccess(auth, Permission.ASSET_SHARE, new Set(dto.assetIds));
const assets = [...allowedAssetIdsSet].map((id) => ({ id }) as AssetEntity);
const album = await this.albumRepository.create({
ownerId: auth.user.id,
albumName: dto.albumName,
description: dto.description,
albumPermissions:
dto.sharedWithUserIds?.map((userId) => ({ users: { id: userId } }) as AlbumPermissionEntity) ?? [],
assets: (dto.assetIds || []).map((id) => ({ id }) as AssetEntity),
albumThumbnailAssetId: dto.assetIds?.[0] || null,
assets,
albumThumbnailAssetId: assets[0]?.id || null,
});
return mapAlbumWithAssets(album);

View File

@@ -695,7 +695,7 @@ describe(AssetService.name, () => {
});
});
it('should not schedule delete-files job for readonly assets', async () => {
it('should only delete generated files for readonly assets', async () => {
when(assetMock.getById)
.calledWith(assetStub.readOnly.id, {
faces: {
@@ -709,7 +709,20 @@ describe(AssetService.name, () => {
await sut.handleAssetDeletion({ id: assetStub.readOnly.id });
expect(jobMock.queue.mock.calls).toEqual([]);
expect(jobMock.queue.mock.calls).toEqual([
[
{
name: JobName.DELETE_FILES,
data: {
files: [
assetStub.readOnly.thumbnailPath,
assetStub.readOnly.previewPath,
assetStub.readOnly.encodedVideoPath,
],
},
},
],
]);
expect(assetMock.remove).toHaveBeenCalledWith(assetStub.readOnly);
});
@@ -748,7 +761,6 @@ describe(AssetService.name, () => {
assetStub.external.thumbnailPath,
assetStub.external.previewPath,
assetStub.external.encodedVideoPath,
assetStub.external.sidecarPath,
],
},
},

View File

@@ -399,14 +399,12 @@ export class AssetService {
await this.jobRepository.queue({ name: JobName.ASSET_DELETION, data: { id: asset.livePhotoVideoId } });
}
const files = [asset.thumbnailPath, asset.previewPath, asset.encodedVideoPath, asset.sidecarPath];
if (!fromExternal) {
files.push(asset.originalPath);
const files = [asset.thumbnailPath, asset.previewPath, asset.encodedVideoPath];
if (!(asset.isExternal || asset.isReadOnly)) {
files.push(asset.sidecarPath, asset.originalPath);
}
if (!asset.isReadOnly) {
await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files } });
}
await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files } });
return JobStatus.SUCCESS;
}

View File

@@ -1,6 +1,6 @@
import { BadRequestException } from '@nestjs/common';
import { FeatureFlag, SystemConfigCore } from 'src/cores/system-config.core';
import { SystemConfig, SystemConfigKey } from 'src/entities/system-config.entity';
import { SystemConfig, SystemConfigKey, SystemConfigKeyPaths } from 'src/entities/system-config.entity';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import {
@@ -360,7 +360,7 @@ describe(JobService.name, () => {
});
}
const featureTests: Array<{ queue: QueueName; feature: FeatureFlag; configKey: SystemConfigKey }> = [
const featureTests: Array<{ queue: QueueName; feature: FeatureFlag; configKey: SystemConfigKeyPaths }> = [
{
queue: QueueName.SMART_SEARCH,
feature: FeatureFlag.SMART_SEARCH,

View File

@@ -1058,14 +1058,6 @@ describe(LibraryService.name, () => {
expect(libraryMock.create).not.toHaveBeenCalled();
});
it('should not create watched', async () => {
await expect(
sut.create({ ownerId: authStub.admin.user.id, type: LibraryType.UPLOAD, isWatched: true }),
).rejects.toBeInstanceOf(BadRequestException);
expect(storageMock.watch).not.toHaveBeenCalled();
});
});
});

View File

@@ -266,9 +266,6 @@ export class LibraryService extends EventEmitter {
if (dto.exclusionPatterns && dto.exclusionPatterns.length > 0) {
throw new BadRequestException('Upload libraries cannot have exclusion patterns');
}
if (dto.isWatched) {
throw new BadRequestException('Upload libraries cannot be watched');
}
break;
}
}

View File

@@ -210,25 +210,21 @@ describe(MediaService.name, () => {
expect(assetMock.update).not.toHaveBeenCalledWith();
});
it('should generate a thumbnail for an image', async () => {
it.each(Object.values(ImageFormat))('should generate a %s preview for an image when specified', async (format) => {
configMock.load.mockResolvedValue([{ key: SystemConfigKey.IMAGE_PREVIEW_FORMAT, value: format }]);
assetMock.getByIds.mockResolvedValue([assetStub.image]);
const previewPath = `upload/thumbs/user-id/as/se/asset-id-preview.${format}`;
await sut.handleGeneratePreview({ id: assetStub.image.id });
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se');
expect(mediaMock.resize).toHaveBeenCalledWith(
'/original/path.jpg',
'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
{
size: 1440,
format: ImageFormat.JPEG,
quality: 80,
colorspace: Colorspace.SRGB,
},
);
expect(assetMock.update).toHaveBeenCalledWith({
id: 'asset-id',
previewPath: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
expect(mediaMock.resize).toHaveBeenCalledWith('/original/path.jpg', previewPath, {
size: 1440,
format,
quality: 80,
colorspace: Colorspace.SRGB,
});
expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-id', previewPath });
});
it('should generate a P3 thumbnail for a wide gamut image', async () => {
@@ -342,25 +338,25 @@ describe(MediaService.name, () => {
expect(assetMock.update).not.toHaveBeenCalledWith();
});
it('should generate a thumbnail', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.image]);
await sut.handleGenerateThumbnail({ id: assetStub.image.id });
it.each(Object.values(ImageFormat))(
'should generate a %s thumbnail for an image when specified',
async (format) => {
configMock.load.mockResolvedValue([{ key: SystemConfigKey.IMAGE_THUMBNAIL_FORMAT, value: format }]);
assetMock.getByIds.mockResolvedValue([assetStub.image]);
const thumbnailPath = `upload/thumbs/user-id/as/se/asset-id-thumbnail.${format}`;
expect(mediaMock.resize).toHaveBeenCalledWith(
'/original/path.jpg',
'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp',
{
format: ImageFormat.WEBP,
await sut.handleGenerateThumbnail({ id: assetStub.image.id });
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se');
expect(mediaMock.resize).toHaveBeenCalledWith('/original/path.jpg', thumbnailPath, {
size: 250,
format,
quality: 80,
colorspace: Colorspace.SRGB,
},
);
expect(assetMock.update).toHaveBeenCalledWith({
id: 'asset-id',
thumbnailPath: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp',
});
});
});
expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-id', thumbnailPath });
},
);
});
it('should generate a P3 thumbnail for a wide gamut image', async () => {
@@ -747,6 +743,67 @@ describe(MediaService.name, () => {
);
});
it('should not include hevc tag when target is hevc and video stream is copied from a different codec', async () => {
mediaMock.probe.mockResolvedValue(probeStub.videoStreamH264);
configMock.load.mockResolvedValue([
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.HEVC },
{ key: SystemConfigKey.FFMPEG_ACCEPTED_VIDEO_CODECS, value: [VideoCodec.H264, VideoCodec.HEVC] },
{ key: SystemConfigKey.FFMPEG_ACCEPTED_AUDIO_CODECS, value: [AudioCodec.AAC] },
]);
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 copy',
'-c:a aac',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
'-map 0:1',
'-v verbose',
'-preset ultrafast',
'-crf 23',
],
twoPass: false,
},
);
});
it('should include hevc tag when target is hevc and copying hevc video stream', async () => {
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.HEVC },
{ key: SystemConfigKey.FFMPEG_ACCEPTED_VIDEO_CODECS, value: [VideoCodec.H264, VideoCodec.HEVC] },
{ key: SystemConfigKey.FFMPEG_ACCEPTED_AUDIO_CODECS, value: [AudioCodec.AAC] },
]);
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 copy',
'-c:a aac',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
'-map 0:1',
'-tag:v hvc1',
'-v verbose',
'-preset ultrafast',
'-crf 23',
],
twoPass: false,
},
);
});
it('should copy audio stream when audio matches target', async () => {
mediaMock.probe.mockResolvedValue(probeStub.audioStreamAac);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.OPTIMAL }]);
@@ -1091,9 +1148,9 @@ describe(MediaService.name, () => {
);
});
it('should disable thread pooling for h264 if thread limit is above 0', async () => {
it('should disable thread pooling for h264 if thread limit is 1', async () => {
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_THREADS, value: 2 }]);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_THREADS, value: 1 }]);
assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith(
@@ -1111,9 +1168,8 @@ describe(MediaService.name, () => {
'-v verbose',
'-vf scale=-2:720,format=yuv420p',
'-preset ultrafast',
'-threads 2',
'-x264-params "pools=none"',
'-x264-params "frame-threads=2"',
'-threads 1',
'-x264-params frame-threads=1:pools=none',
'-crf 23',
],
twoPass: false,
@@ -1148,10 +1204,10 @@ describe(MediaService.name, () => {
);
});
it('should disable thread pooling for hevc if thread limit is above 0', async () => {
it('should disable thread pooling for hevc if thread limit is 1', async () => {
mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9);
configMock.load.mockResolvedValue([
{ key: SystemConfigKey.FFMPEG_THREADS, value: 2 },
{ key: SystemConfigKey.FFMPEG_THREADS, value: 1 },
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.HEVC },
]);
assetMock.getByIds.mockResolvedValue([assetStub.video]);
@@ -1172,9 +1228,8 @@ describe(MediaService.name, () => {
'-v verbose',
'-vf scale=-2:720,format=yuv420p',
'-preset ultrafast',
'-threads 2',
'-x265-params "pools=none"',
'-x265-params "frame-threads=2"',
'-threads 1',
'-x265-params frame-threads=1:pools=none',
'-crf 23',
],
twoPass: false,
@@ -1213,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([

View File

@@ -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,
@@ -167,12 +168,15 @@ export class MediaService {
}
async handleGeneratePreview({ id }: IEntityJob): Promise<JobStatus> {
const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true });
const [{ image }, [asset]] = await Promise.all([
this.configCore.getConfig(),
this.assetRepository.getByIds([id], { exifInfo: true }),
]);
if (!asset) {
return JobStatus.FAILED;
}
const previewPath = await this.generateThumbnail(asset, AssetPathType.PREVIEW, ImageFormat.JPEG);
const previewPath = await this.generateThumbnail(asset, AssetPathType.PREVIEW, image.previewFormat);
await this.assetRepository.update({ id: asset.id, previewPath });
return JobStatus.SUCCESS;
}
@@ -210,18 +214,21 @@ export class MediaService {
}
}
this.logger.log(
`Successfully generated ${format.toUpperCase()} ${asset.type.toLowerCase()} thumbnail for asset ${asset.id}`,
`Successfully generated ${format.toUpperCase()} ${asset.type.toLowerCase()} ${type} for asset ${asset.id}`,
);
return path;
}
async handleGenerateThumbnail({ id }: IEntityJob): Promise<JobStatus> {
const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true });
const [{ image }, [asset]] = await Promise.all([
this.configCore.getConfig(),
this.assetRepository.getByIds([id], { exifInfo: true }),
]);
if (!asset) {
return JobStatus.FAILED;
}
const thumbnailPath = await this.generateThumbnail(asset, AssetPathType.THUMBNAIL, ImageFormat.WEBP);
const thumbnailPath = await this.generateThumbnail(asset, AssetPathType.THUMBNAIL, image.thumbnailFormat);
await this.assetRepository.update({ id: asset.id, thumbnailPath });
return JobStatus.SUCCESS;
}
@@ -433,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`);
}

View File

@@ -439,7 +439,7 @@ describe(MetadataService.name, () => {
});
cryptoRepository.hashSha1.mockReturnValue(randomBytes(512));
assetMock.getByChecksum.mockResolvedValue(null);
assetMock.create.mockResolvedValue(assetStub.livePhotoMotionAsset);
assetMock.create.mockImplementation((asset) => Promise.resolve({ ...assetStub.livePhotoMotionAsset, ...asset }));
await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id });
expect(jobMock.queue).toHaveBeenNthCalledWith(2, {
@@ -467,6 +467,30 @@ describe(MetadataService.name, () => {
expect(jobMock.queue).toHaveBeenCalledTimes(0);
});
it('should link and hide motion video asset to still asset if the hash of the extracted video matches an existing asset', async () => {
assetMock.getByIds.mockResolvedValue([{ ...assetStub.livePhotoStillAsset, livePhotoVideoId: null }]);
metadataMock.readTags.mockResolvedValue({
Directory: 'foo/bar/',
MotionPhoto: 1,
MicroVideo: 1,
MicroVideoOffset: 1,
});
cryptoRepository.hashSha1.mockReturnValue(randomBytes(512));
assetMock.getByChecksum.mockResolvedValue({ ...assetStub.livePhotoMotionAsset, isVisible: true });
const video = randomBytes(512);
storageMock.readFile.mockResolvedValue(video);
await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id });
expect(assetMock.update).toHaveBeenNthCalledWith(1, {
id: assetStub.livePhotoMotionAsset.id,
isVisible: false,
});
expect(assetMock.update).toHaveBeenNthCalledWith(2, {
id: assetStub.livePhotoStillAsset.id,
livePhotoVideoId: assetStub.livePhotoMotionAsset.id,
});
});
it('should save all metadata', async () => {
const tags: ImmichTags = {
BitsPerSample: 1,

View File

@@ -405,13 +405,19 @@ export class MetadataService {
}
const checksum = this.cryptoRepository.hashSha1(video);
let motionAsset = await this.assetRepository.getByChecksum(asset.ownerId, checksum);
let motionAsset = await this.assetRepository.getByChecksum(asset.libraryId, checksum);
if (motionAsset) {
this.logger.debug(
`Asset ${asset.id}'s motion photo video with checksum ${checksum.toString(
'base64',
)} already exists in the repository`,
);
// Hide the motion photo video asset if it's not already hidden to prepare for linking
if (motionAsset.isVisible) {
await this.assetRepository.update({ id: motionAsset.id, isVisible: false });
this.logger.log(`Hid unlinked motion photo video asset (${motionAsset.id})`);
}
} else {
// We create a UUID in advance so that each extracted video can have a unique filename
// (allowing us to delete old ones if necessary)
@@ -438,11 +444,14 @@ export class MetadataService {
this.storageCore.ensureFolders(motionPath);
await this.storageRepository.writeFile(motionAsset.originalPath, video);
await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: motionAsset.id } });
await this.assetRepository.update({ id: asset.id, livePhotoVideoId: motionAsset.id });
}
if (asset.livePhotoVideoId !== motionAsset.id) {
await this.assetRepository.update({ id: asset.id, livePhotoVideoId: motionAsset.id });
// If the asset already had an associated livePhotoVideo, delete it, because
// its checksum doesn't match the checksum of the motionAsset we just extracted
// (if it did, getByChecksum() would've returned non-null)
// (if it did, getByChecksum() would've returned a motionAsset with the same ID as livePhotoVideoId)
// note asset.livePhotoVideoId is not motionAsset.id yet
if (asset.livePhotoVideoId) {
await this.jobRepository.queue({ name: JobName.ASSET_DELETION, data: { id: asset.livePhotoVideoId } });
this.logger.log(`Removed old motion photo video asset (${asset.livePhotoVideoId})`);

View File

@@ -346,19 +346,6 @@ describe(SystemConfigService.name, () => {
});
});
describe('refreshConfig', () => {
it('should notify the subscribers', async () => {
const changeMock = jest.fn();
const subscription = sut.config$.subscribe(changeMock);
await sut.refreshConfig();
expect(changeMock).toHaveBeenCalledWith(defaults);
subscription.unsubscribe();
});
});
describe('getCustomCss', () => {
it('should return the default theme', async () => {
await expect(sut.getCustomCss()).resolves.toEqual(defaults.theme.customCss);

View File

@@ -90,13 +90,6 @@ export class SystemConfigService {
return mapConfig(newConfig);
}
// this is only used by the cli on config change, and it's not actually needed anymore
async refreshConfig() {
this.eventRepository.serverSend(ServerEvent.CONFIG_UPDATE, null);
await this.core.refreshConfig();
return true;
}
getStorageTemplateOptions(): SystemConfigTemplateStorageOptionDto {
const options = new SystemConfigTemplateStorageOptionDto();

Binary file not shown.

View File

@@ -37,9 +37,12 @@ class BaseConfig implements VideoCodecSWConfig {
}
getBaseOutputOptions(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo) {
const videoCodec = [TranscodeTarget.ALL, TranscodeTarget.VIDEO].includes(target) ? this.getVideoCodec() : 'copy';
const audioCodec = [TranscodeTarget.ALL, TranscodeTarget.AUDIO].includes(target) ? this.getAudioCodec() : 'copy';
const options = [
`-c:v ${[TranscodeTarget.ALL, TranscodeTarget.VIDEO].includes(target) ? this.getVideoCodec() : 'copy'}`,
`-c:a ${[TranscodeTarget.ALL, TranscodeTarget.AUDIO].includes(target) ? this.getAudioCodec() : 'copy'}`,
`-c:v ${videoCodec}`,
`-c:a ${audioCodec}`,
// Makes a second pass moving the moov atom to the
// beginning of the file for improved playback speed.
'-movflags faststart',
@@ -61,7 +64,10 @@ class BaseConfig implements VideoCodecSWConfig {
options.push(`-g ${this.getGopSize()}`);
}
if (this.config.targetVideoCodec === VideoCodec.HEVC) {
if (
this.config.targetVideoCodec === VideoCodec.HEVC &&
(videoCodec !== 'copy' || videoStream.codecName === 'hevc')
) {
options.push('-tag:v hvc1');
}
@@ -118,7 +124,7 @@ class BaseConfig implements VideoCodecSWConfig {
return false;
}
return this.isBitrateConstrained() || this.config.targetVideoCodec === VideoCodec.VP9;
return this.isBitrateConstrained();
}
getBitrateDistribution() {
@@ -259,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[]) {
@@ -343,27 +349,23 @@ export class ThumbnailConfig extends BaseConfig {
export class H264Config extends BaseConfig {
getThreadOptions() {
if (this.config.threads <= 0) {
return [];
const options = super.getThreadOptions();
if (this.config.threads === 1) {
options.push('-x264-params frame-threads=1:pools=none');
}
return [
...super.getThreadOptions(),
'-x264-params "pools=none"',
`-x264-params "frame-threads=${this.config.threads}"`,
];
return options;
}
}
export class HEVCConfig extends BaseConfig {
getThreadOptions() {
if (this.config.threads <= 0) {
return [];
const options = super.getThreadOptions();
if (this.config.threads === 1) {
options.push('-x265-params frame-threads=1:pools=none');
}
return [
...super.getThreadOptions(),
'-x265-params "pools=none"',
`-x265-params "frame-threads=${this.config.threads}"`,
];
return options;
}
}
@@ -392,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 {
@@ -525,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) {
@@ -603,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;
}

View File

@@ -173,4 +173,8 @@ export const probeStub = {
bitrate: 0,
},
}),
videoStreamH264: Object.freeze<VideoInfo>({
...probeStubDefault,
videoStreams: [{ ...probeStubDefaultVideoStream[0], codecName: 'h264' }],
}),
};