2024-10-02 10:54:35 -04:00
|
|
|
import { Injectable } from '@nestjs/common';
|
2025-02-11 17:15:56 -05:00
|
|
|
import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
|
2025-04-16 20:08:49 +02:00
|
|
|
import { StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core';
|
|
|
|
|
import { Exif } from 'src/database';
|
2024-12-10 12:11:19 -05:00
|
|
|
import { OnEvent, OnJob } from 'src/decorators';
|
2024-09-27 10:28:42 -04:00
|
|
|
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
|
2024-03-20 19:32:04 +01:00
|
|
|
import {
|
2024-09-27 10:28:42 -04:00
|
|
|
AssetFileType,
|
|
|
|
|
AssetPathType,
|
|
|
|
|
AssetType,
|
2024-03-20 19:32:04 +01:00
|
|
|
AudioCodec,
|
|
|
|
|
Colorspace,
|
2025-02-11 17:15:56 -05:00
|
|
|
JobName,
|
|
|
|
|
JobStatus,
|
2024-09-27 18:10:39 -04:00
|
|
|
LogLevel,
|
2025-02-11 17:15:56 -05:00
|
|
|
QueueName,
|
2025-04-29 06:18:46 +08:00
|
|
|
RawExtractedFormat,
|
2024-09-27 10:28:42 -04:00
|
|
|
StorageFolder,
|
2024-03-20 19:32:04 +01:00
|
|
|
TranscodeHWAccel,
|
|
|
|
|
TranscodePolicy,
|
|
|
|
|
TranscodeTarget,
|
|
|
|
|
VideoCodec,
|
2024-07-21 17:14:23 -04:00
|
|
|
VideoContainer,
|
2024-09-27 10:28:42 -04:00
|
|
|
} from 'src/enum';
|
2025-04-29 00:03:20 +02:00
|
|
|
import { UpsertFileOptions } from 'src/repositories/asset.repository';
|
2024-09-30 17:31:21 -04:00
|
|
|
import { BaseService } from 'src/services/base.service';
|
2025-04-01 01:24:28 +08:00
|
|
|
import {
|
|
|
|
|
AudioStreamInfo,
|
|
|
|
|
DecodeToBufferOptions,
|
|
|
|
|
JobItem,
|
|
|
|
|
JobOf,
|
|
|
|
|
VideoFormat,
|
|
|
|
|
VideoInterfaces,
|
|
|
|
|
VideoStreamInfo,
|
|
|
|
|
} from 'src/types';
|
2024-08-19 20:03:33 -04:00
|
|
|
import { getAssetFiles } from 'src/utils/asset.util';
|
2024-05-27 15:20:07 -04:00
|
|
|
import { BaseConfig, ThumbnailConfig } from 'src/utils/media';
|
2024-04-19 11:50:13 -04:00
|
|
|
import { mimeTypes } from 'src/utils/mime-types';
|
2023-09-08 08:49:43 +02:00
|
|
|
|
2023-02-25 09:12:03 -05:00
|
|
|
@Injectable()
|
2024-09-30 17:31:21 -04:00
|
|
|
export class MediaService extends BaseService {
|
2024-12-10 12:11:19 -05:00
|
|
|
videoInterfaces: VideoInterfaces = { dri: [], mali: false };
|
|
|
|
|
|
|
|
|
|
@OnEvent({ name: 'app.bootstrap' })
|
|
|
|
|
async onBootstrap() {
|
|
|
|
|
const [dri, mali] = await Promise.all([this.getDevices(), this.hasMaliOpenCL()]);
|
|
|
|
|
this.videoInterfaces = { dri, mali };
|
|
|
|
|
}
|
2023-02-25 09:12:03 -05:00
|
|
|
|
2024-10-31 13:42:58 -04:00
|
|
|
@OnJob({ name: JobName.QUEUE_GENERATE_THUMBNAILS, queue: QueueName.THUMBNAIL_GENERATION })
|
|
|
|
|
async handleQueueGenerateThumbnails({ force }: JobOf<JobName.QUEUE_GENERATE_THUMBNAILS>): Promise<JobStatus> {
|
2025-04-30 10:52:51 -04:00
|
|
|
let jobs: JobItem[] = [];
|
|
|
|
|
|
|
|
|
|
const queueAll = async () => {
|
|
|
|
|
await this.jobRepository.queueAll(jobs);
|
|
|
|
|
jobs = [];
|
|
|
|
|
};
|
|
|
|
|
|
2025-04-15 19:53:28 +02:00
|
|
|
for await (const asset of this.assetJobRepository.streamForThumbnailJob(!!force)) {
|
|
|
|
|
const { previewFile, thumbnailFile } = getAssetFiles(asset.files);
|
2023-03-20 11:55:28 -04:00
|
|
|
|
2025-04-15 19:53:28 +02:00
|
|
|
if (!previewFile || !thumbnailFile || !asset.thumbhash || force) {
|
2025-04-30 10:52:51 -04:00
|
|
|
jobs.push({ name: JobName.GENERATE_THUMBNAILS, data: { id: asset.id } });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) {
|
|
|
|
|
await queueAll();
|
2023-03-20 11:55:28 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-04-30 10:52:51 -04:00
|
|
|
await queueAll();
|
2024-01-18 00:08:48 -05:00
|
|
|
|
2025-01-21 19:12:28 +01:00
|
|
|
const people = this.personRepository.getAll(force ? undefined : { thumbnailPath: '' });
|
2024-01-18 00:08:48 -05:00
|
|
|
|
2025-01-21 19:12:28 +01:00
|
|
|
for await (const person of people) {
|
|
|
|
|
if (!person.faceAssetId) {
|
|
|
|
|
const face = await this.personRepository.getRandomFace(person.id);
|
|
|
|
|
if (!face) {
|
|
|
|
|
continue;
|
2023-09-26 03:03:22 -04:00
|
|
|
}
|
|
|
|
|
|
2025-01-21 19:12:28 +01:00
|
|
|
await this.personRepository.update({ id: person.id, faceAssetId: face.id });
|
2023-09-08 08:49:43 +02:00
|
|
|
}
|
2025-01-21 19:12:28 +01:00
|
|
|
|
|
|
|
|
jobs.push({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: person.id } });
|
2025-04-30 10:52:51 -04:00
|
|
|
if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) {
|
|
|
|
|
await queueAll();
|
|
|
|
|
}
|
2023-09-08 08:49:43 +02:00
|
|
|
}
|
|
|
|
|
|
2025-04-30 10:52:51 -04:00
|
|
|
await queueAll();
|
2024-01-01 15:45:42 -05:00
|
|
|
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.SUCCESS;
|
2023-05-26 15:43:24 -04:00
|
|
|
}
|
2023-04-11 20:28:25 -05:00
|
|
|
|
2024-10-31 13:42:58 -04:00
|
|
|
@OnJob({ name: JobName.QUEUE_MIGRATION, queue: QueueName.MIGRATION })
|
2024-03-15 14:16:54 +01:00
|
|
|
async handleQueueMigration(): Promise<JobStatus> {
|
2023-09-25 17:07:21 +02:00
|
|
|
const { active, waiting } = await this.jobRepository.getJobCounts(QueueName.MIGRATION);
|
|
|
|
|
if (active === 1 && waiting === 0) {
|
|
|
|
|
await this.storageCore.removeEmptyDirs(StorageFolder.THUMBNAILS);
|
|
|
|
|
await this.storageCore.removeEmptyDirs(StorageFolder.ENCODED_VIDEO);
|
|
|
|
|
}
|
|
|
|
|
|
2025-04-30 12:23:13 -04:00
|
|
|
let jobs: JobItem[] = [];
|
|
|
|
|
const assets = this.assetJobRepository.streamForMigrationJob();
|
|
|
|
|
for await (const asset of assets) {
|
|
|
|
|
jobs.push({ name: JobName.MIGRATE_ASSET, data: { id: asset.id } });
|
|
|
|
|
if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) {
|
|
|
|
|
await this.jobRepository.queueAll(jobs);
|
|
|
|
|
jobs = [];
|
|
|
|
|
}
|
2023-09-25 17:07:21 +02:00
|
|
|
}
|
|
|
|
|
|
2025-04-30 12:23:13 -04:00
|
|
|
await this.jobRepository.queueAll(jobs);
|
|
|
|
|
jobs = [];
|
2023-09-25 17:07:21 +02:00
|
|
|
|
2025-01-21 19:12:28 +01:00
|
|
|
for await (const person of this.personRepository.getAll()) {
|
|
|
|
|
jobs.push({ name: JobName.MIGRATE_PERSON, data: { id: person.id } });
|
|
|
|
|
|
|
|
|
|
if (jobs.length === JOBS_ASSET_PAGINATION_SIZE) {
|
|
|
|
|
await this.jobRepository.queueAll(jobs);
|
|
|
|
|
jobs = [];
|
|
|
|
|
}
|
2024-01-18 00:08:48 -05:00
|
|
|
}
|
|
|
|
|
|
2025-01-21 19:12:28 +01:00
|
|
|
await this.jobRepository.queueAll(jobs);
|
|
|
|
|
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.SUCCESS;
|
2023-09-25 17:07:21 +02:00
|
|
|
}
|
|
|
|
|
|
2024-10-31 13:42:58 -04:00
|
|
|
@OnJob({ name: JobName.MIGRATE_ASSET, queue: QueueName.MIGRATION })
|
|
|
|
|
async handleAssetMigration({ id }: JobOf<JobName.MIGRATE_ASSET>): Promise<JobStatus> {
|
2024-09-30 17:31:21 -04:00
|
|
|
const { image } = await this.getConfig({ withCache: true });
|
2025-04-15 21:49:15 +02:00
|
|
|
const asset = await this.assetJobRepository.getForMigrationJob(id);
|
2023-09-25 17:07:21 +02:00
|
|
|
if (!asset) {
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.FAILED;
|
2023-09-25 17:07:21 +02:00
|
|
|
}
|
|
|
|
|
|
2025-04-01 18:11:46 -04:00
|
|
|
await this.storageCore.moveAssetImage(asset, AssetPathType.FULLSIZE, image.fullsize.format);
|
2024-09-28 02:01:04 -04:00
|
|
|
await this.storageCore.moveAssetImage(asset, AssetPathType.PREVIEW, image.preview.format);
|
|
|
|
|
await this.storageCore.moveAssetImage(asset, AssetPathType.THUMBNAIL, image.thumbnail.format);
|
2024-04-02 00:56:56 -04:00
|
|
|
await this.storageCore.moveAssetVideo(asset);
|
2023-09-25 17:07:21 +02:00
|
|
|
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.SUCCESS;
|
2023-09-25 17:07:21 +02:00
|
|
|
}
|
|
|
|
|
|
2024-10-31 13:42:58 -04:00
|
|
|
@OnJob({ name: JobName.GENERATE_THUMBNAILS, queue: QueueName.THUMBNAIL_GENERATION })
|
|
|
|
|
async handleGenerateThumbnails({ id }: JobOf<JobName.GENERATE_THUMBNAILS>): Promise<JobStatus> {
|
2025-04-16 20:08:49 +02:00
|
|
|
const asset = await this.assetJobRepository.getForGenerateThumbnailJob(id);
|
2023-04-11 20:28:25 -05:00
|
|
|
if (!asset) {
|
2024-09-28 13:47:24 -04:00
|
|
|
this.logger.warn(`Thumbnail generation failed for asset ${id}: not found`);
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.FAILED;
|
2023-04-11 20:28:25 -05:00
|
|
|
}
|
2023-02-25 09:12:03 -05:00
|
|
|
|
2024-04-18 21:37:55 -04:00
|
|
|
if (!asset.isVisible) {
|
2024-09-28 13:47:24 -04:00
|
|
|
this.logger.verbose(`Thumbnail generation skipped for asset ${id}: not visible`);
|
2024-04-18 21:37:55 -04:00
|
|
|
return JobStatus.SKIPPED;
|
|
|
|
|
}
|
|
|
|
|
|
2025-04-01 01:24:28 +08:00
|
|
|
let generated: {
|
|
|
|
|
previewPath: string;
|
|
|
|
|
thumbnailPath: string;
|
|
|
|
|
fullsizePath?: string;
|
|
|
|
|
thumbhash: Buffer;
|
|
|
|
|
};
|
2024-10-22 22:34:44 +05:30
|
|
|
if (asset.type === AssetType.VIDEO || asset.originalFileName.toLowerCase().endsWith('.gif')) {
|
2024-09-28 13:47:24 -04:00
|
|
|
generated = await this.generateVideoThumbnails(asset);
|
2024-10-22 22:34:44 +05:30
|
|
|
} else if (asset.type === AssetType.IMAGE) {
|
|
|
|
|
generated = await this.generateImageThumbnails(asset);
|
2024-09-28 13:47:24 -04:00
|
|
|
} else {
|
|
|
|
|
this.logger.warn(`Skipping thumbnail generation for asset ${id}: ${asset.type} is not an image or video`);
|
2024-08-19 13:50:00 -04:00
|
|
|
return JobStatus.SKIPPED;
|
|
|
|
|
}
|
|
|
|
|
|
2025-04-01 01:24:28 +08:00
|
|
|
const { previewFile, thumbnailFile, fullsizeFile } = getAssetFiles(asset.files);
|
2024-09-28 13:47:24 -04:00
|
|
|
const toUpsert: UpsertFileOptions[] = [];
|
|
|
|
|
if (previewFile?.path !== generated.previewPath) {
|
|
|
|
|
toUpsert.push({ assetId: asset.id, path: generated.previewPath, type: AssetFileType.PREVIEW });
|
2024-04-27 18:43:05 -04:00
|
|
|
}
|
2024-08-19 13:50:00 -04:00
|
|
|
|
2024-09-28 13:47:24 -04:00
|
|
|
if (thumbnailFile?.path !== generated.thumbnailPath) {
|
|
|
|
|
toUpsert.push({ assetId: asset.id, path: generated.thumbnailPath, type: AssetFileType.THUMBNAIL });
|
2023-05-26 15:43:24 -04:00
|
|
|
}
|
2024-08-30 00:16:12 +02:00
|
|
|
|
2025-04-01 01:24:28 +08:00
|
|
|
if (generated.fullsizePath && fullsizeFile?.path !== generated.fullsizePath) {
|
|
|
|
|
toUpsert.push({ assetId: asset.id, path: generated.fullsizePath, type: AssetFileType.FULLSIZE });
|
|
|
|
|
}
|
|
|
|
|
|
2024-09-28 13:47:24 -04:00
|
|
|
if (toUpsert.length > 0) {
|
|
|
|
|
await this.assetRepository.upsertFiles(toUpsert);
|
|
|
|
|
}
|
2023-02-25 09:12:03 -05:00
|
|
|
|
2025-04-01 01:24:28 +08:00
|
|
|
const pathsToDelete: string[] = [];
|
2024-09-28 13:47:24 -04:00
|
|
|
if (previewFile && previewFile.path !== generated.previewPath) {
|
|
|
|
|
this.logger.debug(`Deleting old preview for asset ${asset.id}`);
|
|
|
|
|
pathsToDelete.push(previewFile.path);
|
2023-02-25 09:12:03 -05:00
|
|
|
}
|
|
|
|
|
|
2024-09-28 13:47:24 -04:00
|
|
|
if (thumbnailFile && thumbnailFile.path !== generated.thumbnailPath) {
|
|
|
|
|
this.logger.debug(`Deleting old thumbnail for asset ${asset.id}`);
|
|
|
|
|
pathsToDelete.push(thumbnailFile.path);
|
2024-04-18 21:37:55 -04:00
|
|
|
}
|
|
|
|
|
|
2025-04-01 01:24:28 +08:00
|
|
|
if (fullsizeFile && fullsizeFile.path !== generated.fullsizePath) {
|
|
|
|
|
this.logger.debug(`Deleting old fullsize preview image for asset ${asset.id}`);
|
|
|
|
|
pathsToDelete.push(fullsizeFile.path);
|
|
|
|
|
if (!generated.fullsizePath) {
|
|
|
|
|
// did not generate a new fullsize image, delete the existing record
|
|
|
|
|
await this.assetRepository.deleteFiles([fullsizeFile]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-09-28 13:47:24 -04:00
|
|
|
if (pathsToDelete.length > 0) {
|
|
|
|
|
await Promise.all(pathsToDelete.map((path) => this.storageRepository.unlink(path)));
|
2024-08-19 13:50:00 -04:00
|
|
|
}
|
|
|
|
|
|
2025-01-27 17:20:18 -05:00
|
|
|
if (!asset.thumbhash || Buffer.compare(asset.thumbhash, generated.thumbhash) !== 0) {
|
2024-09-28 13:47:24 -04:00
|
|
|
await this.assetRepository.update({ id: asset.id, thumbhash: generated.thumbhash });
|
2024-04-27 18:43:05 -04:00
|
|
|
}
|
2024-08-19 13:50:00 -04:00
|
|
|
|
2024-09-28 13:47:24 -04:00
|
|
|
await this.assetRepository.upsertJobStatus({ assetId: asset.id, previewAt: new Date(), thumbnailAt: new Date() });
|
2024-08-19 13:50:00 -04:00
|
|
|
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.SUCCESS;
|
2023-02-25 09:12:03 -05:00
|
|
|
}
|
2023-04-04 10:48:02 -04:00
|
|
|
|
2025-04-29 06:18:46 +08:00
|
|
|
private async extractImage(originalPath: string, minSize: number) {
|
|
|
|
|
let extracted = await this.mediaRepository.extract(originalPath);
|
|
|
|
|
if (extracted && !(await this.shouldUseExtractedImage(extracted.buffer, minSize))) {
|
|
|
|
|
extracted = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return extracted;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async decodeImage(thumbSource: string | Buffer, exifInfo: Exif, targetSize?: number) {
|
|
|
|
|
const { image } = await this.getConfig({ withCache: true });
|
|
|
|
|
const colorspace = this.isSRGB(exifInfo) ? Colorspace.SRGB : image.colorspace;
|
|
|
|
|
const decodeOptions: DecodeToBufferOptions = {
|
|
|
|
|
colorspace,
|
|
|
|
|
processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true',
|
|
|
|
|
size: targetSize,
|
|
|
|
|
orientation: exifInfo.orientation ? Number(exifInfo.orientation) : undefined,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const { info, data } = await this.mediaRepository.decodeImage(thumbSource, decodeOptions);
|
|
|
|
|
return { info, data, colorspace };
|
|
|
|
|
}
|
|
|
|
|
|
2025-04-16 20:08:49 +02:00
|
|
|
private async generateImageThumbnails(asset: {
|
|
|
|
|
id: string;
|
|
|
|
|
ownerId: string;
|
|
|
|
|
originalFileName: string;
|
|
|
|
|
originalPath: string;
|
|
|
|
|
exifInfo: Exif;
|
|
|
|
|
}) {
|
2024-09-30 17:31:21 -04:00
|
|
|
const { image } = await this.getConfig({ withCache: true });
|
2024-09-28 13:47:24 -04:00
|
|
|
const previewPath = StorageCore.getImagePath(asset, AssetPathType.PREVIEW, image.preview.format);
|
|
|
|
|
const thumbnailPath = StorageCore.getImagePath(asset, AssetPathType.THUMBNAIL, image.thumbnail.format);
|
|
|
|
|
this.storageCore.ensureFolders(previewPath);
|
|
|
|
|
|
2025-04-29 06:18:46 +08:00
|
|
|
// Handle embedded preview extraction for RAW files
|
|
|
|
|
const extractEmbedded = image.extractEmbedded && mimeTypes.isRaw(asset.originalFileName);
|
|
|
|
|
const extracted = extractEmbedded ? await this.extractImage(asset.originalPath, image.preview.size) : null;
|
|
|
|
|
const generateFullsize = image.fullsize.enabled && !mimeTypes.isWebSupportedImage(asset.originalPath);
|
|
|
|
|
const convertFullsize = generateFullsize && (!extracted || !mimeTypes.isWebSupportedImage(` .${extracted.format}`));
|
2025-04-01 01:24:28 +08:00
|
|
|
|
2025-04-29 06:18:46 +08:00
|
|
|
const { info, data, colorspace } = await this.decodeImage(
|
|
|
|
|
extracted ? extracted.buffer : asset.originalPath,
|
|
|
|
|
asset.exifInfo,
|
|
|
|
|
convertFullsize ? undefined : image.preview.size,
|
|
|
|
|
);
|
2025-04-01 01:24:28 +08:00
|
|
|
|
2025-04-29 06:18:46 +08:00
|
|
|
// generate final images
|
|
|
|
|
const thumbnailOptions = { colorspace, processInvalidImages: false, raw: info };
|
2025-04-01 01:24:28 +08:00
|
|
|
const promises = [
|
|
|
|
|
this.mediaRepository.generateThumbhash(data, thumbnailOptions),
|
|
|
|
|
this.mediaRepository.generateThumbnail(data, { ...image.thumbnail, ...thumbnailOptions }, thumbnailPath),
|
|
|
|
|
this.mediaRepository.generateThumbnail(data, { ...image.preview, ...thumbnailOptions }, previewPath),
|
|
|
|
|
];
|
|
|
|
|
|
2025-04-29 06:18:46 +08:00
|
|
|
let fullsizePath: string | undefined;
|
|
|
|
|
|
|
|
|
|
if (convertFullsize) {
|
|
|
|
|
// convert a new fullsize image from the same source as the thumbnail
|
|
|
|
|
fullsizePath = StorageCore.getImagePath(asset, AssetPathType.FULLSIZE, image.fullsize.format);
|
|
|
|
|
const fullsizeOptions = { format: image.fullsize.format, quality: image.fullsize.quality, ...thumbnailOptions };
|
2025-04-01 01:24:28 +08:00
|
|
|
promises.push(this.mediaRepository.generateThumbnail(data, fullsizeOptions, fullsizePath));
|
2025-04-29 06:18:46 +08:00
|
|
|
} else if (generateFullsize && extracted && extracted.format === RawExtractedFormat.JPEG) {
|
|
|
|
|
fullsizePath = StorageCore.getImagePath(asset, AssetPathType.FULLSIZE, extracted.format);
|
|
|
|
|
this.storageCore.ensureFolders(fullsizePath);
|
|
|
|
|
|
|
|
|
|
// Write the buffer to disk with essential EXIF data
|
|
|
|
|
await this.storageRepository.createOrOverwriteFile(fullsizePath, extracted.buffer);
|
|
|
|
|
await this.mediaRepository.writeExif(
|
|
|
|
|
{
|
|
|
|
|
orientation: asset.exifInfo.orientation,
|
|
|
|
|
colorspace: asset.exifInfo.colorspace,
|
|
|
|
|
},
|
|
|
|
|
fullsizePath,
|
|
|
|
|
);
|
2025-04-01 01:24:28 +08:00
|
|
|
}
|
2025-04-29 06:18:46 +08:00
|
|
|
|
2025-04-01 01:24:28 +08:00
|
|
|
const outputs = await Promise.all(promises);
|
|
|
|
|
|
|
|
|
|
return { previewPath, thumbnailPath, fullsizePath, thumbhash: outputs[0] as Buffer };
|
2024-09-28 13:47:24 -04:00
|
|
|
}
|
2024-04-18 21:37:55 -04:00
|
|
|
|
2025-04-16 20:08:49 +02:00
|
|
|
private async generateVideoThumbnails(asset: ThumbnailPathEntity & { originalPath: string }) {
|
2024-09-30 17:31:21 -04:00
|
|
|
const { image, ffmpeg } = await this.getConfig({ withCache: true });
|
2024-09-28 13:47:24 -04:00
|
|
|
const previewPath = StorageCore.getImagePath(asset, AssetPathType.PREVIEW, image.preview.format);
|
|
|
|
|
const thumbnailPath = StorageCore.getImagePath(asset, AssetPathType.THUMBNAIL, image.thumbnail.format);
|
|
|
|
|
this.storageCore.ensureFolders(previewPath);
|
|
|
|
|
|
2024-11-14 02:07:04 -05:00
|
|
|
const { format, audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath);
|
2024-09-28 13:47:24 -04:00
|
|
|
const mainVideoStream = this.getMainStream(videoStreams);
|
|
|
|
|
if (!mainVideoStream) {
|
|
|
|
|
throw new Error(`No video streams found for asset ${asset.id}`);
|
2024-04-18 21:37:55 -04:00
|
|
|
}
|
2024-09-28 13:47:24 -04:00
|
|
|
const mainAudioStream = this.getMainStream(audioStreams);
|
2024-04-18 21:37:55 -04:00
|
|
|
|
2024-09-28 13:47:24 -04:00
|
|
|
const previewConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: image.preview.size.toString() });
|
|
|
|
|
const thumbnailConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: image.thumbnail.size.toString() });
|
2024-11-14 02:07:04 -05:00
|
|
|
const previewOptions = previewConfig.getCommand(TranscodeTarget.VIDEO, mainVideoStream, mainAudioStream, format);
|
|
|
|
|
const thumbnailOptions = thumbnailConfig.getCommand(
|
|
|
|
|
TranscodeTarget.VIDEO,
|
|
|
|
|
mainVideoStream,
|
|
|
|
|
mainAudioStream,
|
|
|
|
|
format,
|
|
|
|
|
);
|
2024-11-20 12:20:54 -05:00
|
|
|
|
2024-09-28 13:47:24 -04:00
|
|
|
await this.mediaRepository.transcode(asset.originalPath, previewPath, previewOptions);
|
|
|
|
|
await this.mediaRepository.transcode(asset.originalPath, thumbnailPath, thumbnailOptions);
|
2023-06-17 23:22:31 -04:00
|
|
|
|
2024-09-28 13:47:24 -04:00
|
|
|
const thumbhash = await this.mediaRepository.generateThumbhash(previewPath, {
|
|
|
|
|
colorspace: image.colorspace,
|
|
|
|
|
processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true',
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return { previewPath, thumbnailPath, thumbhash };
|
2023-06-17 23:22:31 -04:00
|
|
|
}
|
|
|
|
|
|
2024-10-31 13:42:58 -04:00
|
|
|
@OnJob({ name: JobName.QUEUE_VIDEO_CONVERSION, queue: QueueName.VIDEO_CONVERSION })
|
|
|
|
|
async handleQueueVideoConversion(job: JobOf<JobName.QUEUE_VIDEO_CONVERSION>): Promise<JobStatus> {
|
2023-04-04 10:48:02 -04:00
|
|
|
const { force } = job;
|
|
|
|
|
|
2025-04-29 00:03:20 +02:00
|
|
|
let queue: { name: JobName.VIDEO_CONVERSION; data: { id: string } }[] = [];
|
|
|
|
|
for await (const asset of this.assetJobRepository.streamForVideoConversion(force)) {
|
|
|
|
|
queue.push({ name: JobName.VIDEO_CONVERSION, data: { id: asset.id } });
|
2023-05-26 15:43:24 -04:00
|
|
|
|
2025-04-29 00:03:20 +02:00
|
|
|
if (queue.length >= JOBS_ASSET_PAGINATION_SIZE) {
|
|
|
|
|
await this.jobRepository.queueAll(queue);
|
|
|
|
|
queue = [];
|
|
|
|
|
}
|
2023-04-04 10:48:02 -04:00
|
|
|
}
|
|
|
|
|
|
2025-04-29 00:03:20 +02:00
|
|
|
await this.jobRepository.queueAll(queue);
|
|
|
|
|
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.SUCCESS;
|
2023-05-26 15:43:24 -04:00
|
|
|
}
|
2023-04-11 08:56:52 -05:00
|
|
|
|
2024-10-31 13:42:58 -04:00
|
|
|
@OnJob({ name: JobName.VIDEO_CONVERSION, queue: QueueName.VIDEO_CONVERSION })
|
|
|
|
|
async handleVideoConversion({ id }: JobOf<JobName.VIDEO_CONVERSION>): Promise<JobStatus> {
|
2025-04-29 00:03:20 +02:00
|
|
|
const asset = await this.assetJobRepository.getForVideoConversion(id);
|
|
|
|
|
if (!asset) {
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.FAILED;
|
2023-04-11 08:56:52 -05:00
|
|
|
}
|
2023-04-04 10:48:02 -04:00
|
|
|
|
2023-05-26 15:43:24 -04:00
|
|
|
const input = asset.originalPath;
|
2023-10-23 17:52:21 +02:00
|
|
|
const output = StorageCore.getEncodedVideoPath(asset);
|
2023-10-11 04:14:44 +02:00
|
|
|
this.storageCore.ensureFolders(output);
|
2023-05-26 15:43:24 -04:00
|
|
|
|
2024-09-27 18:10:39 -04:00
|
|
|
const { videoStreams, audioStreams, format } = await this.mediaRepository.probe(input, {
|
|
|
|
|
countFrames: this.logger.isLevelEnabled(LogLevel.DEBUG), // makes frame count more reliable for progress logs
|
|
|
|
|
});
|
2024-12-10 12:11:19 -05:00
|
|
|
const videoStream = this.getMainStream(videoStreams);
|
|
|
|
|
const audioStream = this.getMainStream(audioStreams);
|
|
|
|
|
if (!videoStream || !format.formatName) {
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.FAILED;
|
2023-05-26 15:43:24 -04:00
|
|
|
}
|
2023-04-04 10:48:02 -04:00
|
|
|
|
2024-12-10 12:11:19 -05:00
|
|
|
if (!videoStream.height || !videoStream.width) {
|
2023-12-28 00:34:00 -05:00
|
|
|
this.logger.warn(`Skipped transcoding for asset ${asset.id}: no video streams found`);
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.FAILED;
|
2023-12-28 00:34:00 -05:00
|
|
|
}
|
|
|
|
|
|
2024-12-10 12:11:19 -05:00
|
|
|
let { ffmpeg } = await this.getConfig({ withCache: true });
|
|
|
|
|
const target = this.getTranscodeTarget(ffmpeg, videoStream, audioStream);
|
2024-07-21 17:14:23 -04:00
|
|
|
if (target === TranscodeTarget.NONE && !this.isRemuxRequired(ffmpeg, format)) {
|
2023-12-28 00:34:00 -05:00
|
|
|
if (asset.encodedVideoPath) {
|
|
|
|
|
this.logger.log(`Transcoded video exists for asset ${asset.id}, but is no longer required. Deleting...`);
|
|
|
|
|
await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files: [asset.encodedVideoPath] } });
|
2024-03-19 22:42:10 -04:00
|
|
|
await this.assetRepository.update({ id: asset.id, encodedVideoPath: null });
|
2024-09-27 18:10:39 -04:00
|
|
|
} else {
|
|
|
|
|
this.logger.verbose(`Asset ${asset.id} does not require transcoding based on current policy, skipping`);
|
2023-12-28 00:34:00 -05:00
|
|
|
}
|
|
|
|
|
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.SKIPPED;
|
2023-05-26 15:43:24 -04:00
|
|
|
}
|
2023-04-04 10:48:02 -04:00
|
|
|
|
2024-12-10 12:11:19 -05:00
|
|
|
const command = BaseConfig.create(ffmpeg, this.videoInterfaces).getCommand(target, videoStream, audioStream);
|
2024-09-27 18:10:39 -04:00
|
|
|
if (ffmpeg.accel === TranscodeHWAccel.DISABLED) {
|
2024-11-22 16:08:49 +08:00
|
|
|
this.logger.log(`Transcoding video ${asset.id} without hardware acceleration`);
|
2024-09-27 18:10:39 -04:00
|
|
|
} else {
|
2024-11-22 16:08:49 +08:00
|
|
|
this.logger.log(
|
|
|
|
|
`Transcoding video ${asset.id} with ${ffmpeg.accel.toUpperCase()}-accelerated encoding and${ffmpeg.accelDecode ? '' : ' software'} decoding`,
|
|
|
|
|
);
|
2024-09-27 18:10:39 -04:00
|
|
|
}
|
|
|
|
|
|
2023-08-01 21:56:10 -04:00
|
|
|
try {
|
2024-05-27 15:20:07 -04:00
|
|
|
await this.mediaRepository.transcode(input, output, command);
|
2024-09-27 18:10:39 -04:00
|
|
|
} catch (error: any) {
|
|
|
|
|
this.logger.error(`Error occurred during transcoding: ${error.message}`);
|
|
|
|
|
if (ffmpeg.accel === TranscodeHWAccel.DISABLED) {
|
|
|
|
|
return JobStatus.FAILED;
|
2023-08-01 21:56:10 -04:00
|
|
|
}
|
2024-11-22 16:08:49 +08:00
|
|
|
|
|
|
|
|
let partialFallbackSuccess = false;
|
|
|
|
|
if (ffmpeg.accelDecode) {
|
|
|
|
|
try {
|
|
|
|
|
this.logger.error(`Retrying with ${ffmpeg.accel.toUpperCase()}-accelerated encoding and software decoding`);
|
2024-12-10 12:11:19 -05:00
|
|
|
ffmpeg = { ...ffmpeg, accelDecode: false };
|
|
|
|
|
const command = BaseConfig.create(ffmpeg, this.videoInterfaces).getCommand(target, videoStream, audioStream);
|
2024-11-22 16:08:49 +08:00
|
|
|
await this.mediaRepository.transcode(input, output, command);
|
|
|
|
|
partialFallbackSuccess = true;
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
this.logger.error(`Error occurred during transcoding: ${error.message}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!partialFallbackSuccess) {
|
|
|
|
|
this.logger.error(`Retrying with ${ffmpeg.accel.toUpperCase()} acceleration disabled`);
|
2024-12-10 12:11:19 -05:00
|
|
|
ffmpeg = { ...ffmpeg, accel: TranscodeHWAccel.DISABLED };
|
|
|
|
|
const command = BaseConfig.create(ffmpeg, this.videoInterfaces).getCommand(target, videoStream, audioStream);
|
2024-11-22 16:08:49 +08:00
|
|
|
await this.mediaRepository.transcode(input, output, command);
|
|
|
|
|
}
|
2023-08-01 21:56:10 -04:00
|
|
|
}
|
2023-04-04 10:48:02 -04:00
|
|
|
|
2024-02-14 11:24:39 -05:00
|
|
|
this.logger.log(`Successfully encoded ${asset.id}`);
|
2023-04-04 10:48:02 -04:00
|
|
|
|
2024-03-19 22:42:10 -04:00
|
|
|
await this.assetRepository.update({ id: asset.id, encodedVideoPath: output });
|
2023-05-26 15:43:24 -04:00
|
|
|
|
2024-03-15 14:16:54 +01:00
|
|
|
return JobStatus.SUCCESS;
|
2023-04-04 10:48:02 -04:00
|
|
|
}
|
|
|
|
|
|
2023-08-29 05:01:42 -04:00
|
|
|
private getMainStream<T extends VideoStreamInfo | AudioStreamInfo>(streams: T[]): T {
|
2024-10-19 00:50:08 -05:00
|
|
|
return streams
|
|
|
|
|
.filter((stream) => stream.codecName !== 'unknown')
|
|
|
|
|
.sort((stream1, stream2) => stream2.frameCount - stream1.frameCount)[0];
|
2023-04-06 04:32:59 +01:00
|
|
|
}
|
|
|
|
|
|
2024-02-14 11:24:39 -05:00
|
|
|
private getTranscodeTarget(
|
|
|
|
|
config: SystemConfigFFmpegDto,
|
2024-10-12 03:33:10 +02:00
|
|
|
videoStream: VideoStreamInfo,
|
2024-05-27 15:20:07 -04:00
|
|
|
audioStream?: AudioStreamInfo,
|
2024-02-14 11:24:39 -05:00
|
|
|
): TranscodeTarget {
|
|
|
|
|
const isAudioTranscodeRequired = this.isAudioTranscodeRequired(config, audioStream);
|
|
|
|
|
const isVideoTranscodeRequired = this.isVideoTranscodeRequired(config, videoStream);
|
|
|
|
|
|
|
|
|
|
if (isAudioTranscodeRequired && isVideoTranscodeRequired) {
|
|
|
|
|
return TranscodeTarget.ALL;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isAudioTranscodeRequired) {
|
|
|
|
|
return TranscodeTarget.AUDIO;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isVideoTranscodeRequired) {
|
|
|
|
|
return TranscodeTarget.VIDEO;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return TranscodeTarget.NONE;
|
|
|
|
|
}
|
|
|
|
|
|
2024-05-27 15:20:07 -04:00
|
|
|
private isAudioTranscodeRequired(ffmpegConfig: SystemConfigFFmpegDto, stream?: AudioStreamInfo): boolean {
|
|
|
|
|
if (!stream) {
|
2024-02-14 11:24:39 -05:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
switch (ffmpegConfig.transcode) {
|
|
|
|
|
case TranscodePolicy.DISABLED: {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
case TranscodePolicy.ALL: {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
case TranscodePolicy.REQUIRED:
|
|
|
|
|
case TranscodePolicy.OPTIMAL:
|
|
|
|
|
case TranscodePolicy.BITRATE: {
|
|
|
|
|
return !ffmpegConfig.acceptedAudioCodecs.includes(stream.codecName as AudioCodec);
|
|
|
|
|
}
|
|
|
|
|
default: {
|
|
|
|
|
throw new Error(`Unsupported transcode policy: ${ffmpegConfig.transcode}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-10-12 03:33:10 +02:00
|
|
|
private isVideoTranscodeRequired(ffmpegConfig: SystemConfigFFmpegDto, stream: VideoStreamInfo): boolean {
|
2023-06-10 00:15:12 -04:00
|
|
|
const scalingEnabled = ffmpegConfig.targetResolution !== 'original';
|
|
|
|
|
const targetRes = Number.parseInt(ffmpegConfig.targetResolution);
|
2024-02-14 11:24:39 -05:00
|
|
|
const isLargerThanTargetRes = scalingEnabled && Math.min(stream.height, stream.width) > targetRes;
|
|
|
|
|
const isLargerThanTargetBitrate = stream.bitrate > this.parseBitrateToBps(ffmpegConfig.maxBitrate);
|
|
|
|
|
|
|
|
|
|
const isTargetVideoCodec = ffmpegConfig.acceptedVideoCodecs.includes(stream.codecName as VideoCodec);
|
2024-10-31 20:48:23 -04:00
|
|
|
const isRequired = !isTargetVideoCodec || !stream.pixelFormat.endsWith('420p');
|
2023-04-04 10:48:02 -04:00
|
|
|
|
|
|
|
|
switch (ffmpegConfig.transcode) {
|
2024-02-02 04:18:00 +01:00
|
|
|
case TranscodePolicy.DISABLED: {
|
2023-04-06 04:32:59 +01:00
|
|
|
return false;
|
2024-02-02 04:18:00 +01:00
|
|
|
}
|
|
|
|
|
case TranscodePolicy.ALL: {
|
2023-04-04 10:48:02 -04:00
|
|
|
return true;
|
2024-02-02 04:18:00 +01:00
|
|
|
}
|
|
|
|
|
case TranscodePolicy.REQUIRED: {
|
2024-02-14 11:24:39 -05:00
|
|
|
return isRequired;
|
2024-02-02 04:18:00 +01:00
|
|
|
}
|
|
|
|
|
case TranscodePolicy.OPTIMAL: {
|
2024-02-14 11:24:39 -05:00
|
|
|
return isRequired || isLargerThanTargetRes;
|
2024-02-02 04:18:00 +01:00
|
|
|
}
|
|
|
|
|
case TranscodePolicy.BITRATE: {
|
2024-02-14 11:24:39 -05:00
|
|
|
return isRequired || isLargerThanTargetBitrate;
|
2024-02-02 04:18:00 +01:00
|
|
|
}
|
|
|
|
|
default: {
|
2024-02-14 11:24:39 -05:00
|
|
|
throw new Error(`Unsupported transcode policy: ${ffmpegConfig.transcode}`);
|
2024-02-02 04:18:00 +01:00
|
|
|
}
|
2023-04-04 10:48:02 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-07-21 17:14:23 -04:00
|
|
|
private isRemuxRequired(ffmpegConfig: SystemConfigFFmpegDto, { formatName, formatLongName }: VideoFormat): boolean {
|
|
|
|
|
if (ffmpegConfig.transcode === TranscodePolicy.DISABLED) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const name = formatLongName === 'QuickTime / MOV' ? VideoContainer.MOV : (formatName as VideoContainer);
|
|
|
|
|
return name !== VideoContainer.MP4 && !ffmpegConfig.acceptedContainers.includes(name);
|
|
|
|
|
}
|
|
|
|
|
|
2025-04-29 06:18:46 +08:00
|
|
|
isSRGB({ colorspace, profileDescription, bitsPerSample }: Exif): boolean {
|
2023-09-25 19:18:47 -04:00
|
|
|
if (colorspace || profileDescription) {
|
|
|
|
|
return [colorspace, profileDescription].some((s) => s?.toLowerCase().includes('srgb'));
|
|
|
|
|
} else if (bitsPerSample) {
|
|
|
|
|
// assume sRGB for 8-bit images with no color profile or colorspace metadata
|
|
|
|
|
return bitsPerSample === 8;
|
|
|
|
|
} else {
|
|
|
|
|
// assume sRGB for images with no relevant metadata
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
2024-01-31 02:25:07 +01:00
|
|
|
|
2024-04-19 11:50:13 -04:00
|
|
|
private parseBitrateToBps(bitrateString: string) {
|
2024-01-31 02:25:07 +01:00
|
|
|
const bitrateValue = Number.parseInt(bitrateString);
|
|
|
|
|
|
2024-02-02 04:18:00 +01:00
|
|
|
if (Number.isNaN(bitrateValue)) {
|
2024-01-31 02:25:07 +01:00
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (bitrateString.toLowerCase().endsWith('k')) {
|
|
|
|
|
return bitrateValue * 1000; // Kilobits per second to bits per second
|
|
|
|
|
} else if (bitrateString.toLowerCase().endsWith('m')) {
|
2024-02-02 04:18:00 +01:00
|
|
|
return bitrateValue * 1_000_000; // Megabits per second to bits per second
|
2024-01-31 02:25:07 +01:00
|
|
|
} else {
|
|
|
|
|
return bitrateValue;
|
|
|
|
|
}
|
|
|
|
|
}
|
2024-04-19 11:50:13 -04:00
|
|
|
|
2025-04-29 06:18:46 +08:00
|
|
|
private async shouldUseExtractedImage(extractedPathOrBuffer: string | Buffer, targetSize: number) {
|
|
|
|
|
const { width, height } = await this.mediaRepository.getImageDimensions(extractedPathOrBuffer);
|
2024-04-19 11:50:13 -04:00
|
|
|
const extractedSize = Math.min(width, height);
|
|
|
|
|
return extractedSize >= targetSize;
|
|
|
|
|
}
|
2024-05-10 15:03:47 -04:00
|
|
|
|
|
|
|
|
private async getDevices() {
|
2024-12-10 12:11:19 -05:00
|
|
|
try {
|
|
|
|
|
return await this.storageRepository.readdir('/dev/dri');
|
|
|
|
|
} catch {
|
|
|
|
|
this.logger.debug('No devices found in /dev/dri.');
|
|
|
|
|
return [];
|
2024-05-10 15:03:47 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-05-27 15:20:07 -04:00
|
|
|
private async hasMaliOpenCL() {
|
2024-12-10 12:11:19 -05:00
|
|
|
try {
|
|
|
|
|
const [maliIcdStat, maliDeviceStat] = await Promise.all([
|
|
|
|
|
this.storageRepository.stat('/etc/OpenCL/vendors/mali.icd'),
|
|
|
|
|
this.storageRepository.stat('/dev/mali0'),
|
|
|
|
|
]);
|
|
|
|
|
return maliIcdStat.isFile() && maliDeviceStat.isCharacterDevice();
|
|
|
|
|
} catch {
|
|
|
|
|
this.logger.debug('OpenCL not available for transcoding, so RKMPP acceleration will use CPU tonemapping');
|
|
|
|
|
return false;
|
2024-05-10 15:03:47 -04:00
|
|
|
}
|
|
|
|
|
}
|
2023-02-25 09:12:03 -05:00
|
|
|
}
|