mirror of
https://github.com/KevinMidboe/immich.git
synced 2025-12-08 20:29:05 +00:00
refactor(server)*: tsconfigs (#2689)
* refactor(server): tsconfigs * chore: dummy commit * fix: start.sh * chore: restore original entry scripts
This commit is contained in:
3
server/src/domain/media/index.ts
Normal file
3
server/src/domain/media/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './media.constant';
|
||||
export * from './media.repository';
|
||||
export * from './media.service';
|
||||
3
server/src/domain/media/media.constant.ts
Normal file
3
server/src/domain/media/media.constant.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const JPEG_THUMBNAIL_SIZE = 1440;
|
||||
export const WEBP_THUMBNAIL_SIZE = 250;
|
||||
export const FACE_THUMBNAIL_SIZE = 250;
|
||||
56
server/src/domain/media/media.repository.ts
Normal file
56
server/src/domain/media/media.repository.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
export const IMediaRepository = 'IMediaRepository';
|
||||
|
||||
export interface ResizeOptions {
|
||||
size: number;
|
||||
format: 'webp' | 'jpeg';
|
||||
}
|
||||
|
||||
export interface VideoStreamInfo {
|
||||
height: number;
|
||||
width: number;
|
||||
rotation: number;
|
||||
codecName?: string;
|
||||
codecType?: string;
|
||||
frameCount: number;
|
||||
}
|
||||
|
||||
export interface AudioStreamInfo {
|
||||
codecName?: string;
|
||||
codecType?: string;
|
||||
}
|
||||
|
||||
export interface VideoFormat {
|
||||
formatName?: string;
|
||||
formatLongName?: string;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export interface VideoInfo {
|
||||
format: VideoFormat;
|
||||
videoStreams: VideoStreamInfo[];
|
||||
audioStreams: AudioStreamInfo[];
|
||||
}
|
||||
|
||||
export interface CropOptions {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface TranscodeOptions {
|
||||
outputOptions: string[];
|
||||
twoPass: boolean;
|
||||
}
|
||||
|
||||
export interface IMediaRepository {
|
||||
// image
|
||||
extractThumbnailFromExif(input: string, output: string): Promise<void>;
|
||||
resize(input: string | Buffer, output: string, options: ResizeOptions): Promise<void>;
|
||||
crop(input: string, options: CropOptions): Promise<Buffer>;
|
||||
|
||||
// video
|
||||
extractVideoThumbnail(input: string, output: string, size: number): Promise<void>;
|
||||
probe(input: string): Promise<VideoInfo>;
|
||||
transcode(input: string, output: string, options: TranscodeOptions): Promise<void>;
|
||||
}
|
||||
489
server/src/domain/media/media.service.spec.ts
Normal file
489
server/src/domain/media/media.service.spec.ts
Normal file
@@ -0,0 +1,489 @@
|
||||
import { AssetType, SystemConfigKey } from '@app/infra/entities';
|
||||
import {
|
||||
assetEntityStub,
|
||||
newAssetRepositoryMock,
|
||||
newJobRepositoryMock,
|
||||
newMediaRepositoryMock,
|
||||
newStorageRepositoryMock,
|
||||
newSystemConfigRepositoryMock,
|
||||
probeStub,
|
||||
} from '@test';
|
||||
import { IAssetRepository, WithoutProperty } from '../asset';
|
||||
import { IJobRepository, JobName } from '../job';
|
||||
import { IStorageRepository } from '../storage';
|
||||
import { ISystemConfigRepository } from '../system-config';
|
||||
import { IMediaRepository } from './media.repository';
|
||||
import { MediaService } from './media.service';
|
||||
|
||||
describe(MediaService.name, () => {
|
||||
let sut: MediaService;
|
||||
let assetMock: jest.Mocked<IAssetRepository>;
|
||||
let configMock: jest.Mocked<ISystemConfigRepository>;
|
||||
let jobMock: jest.Mocked<IJobRepository>;
|
||||
let mediaMock: jest.Mocked<IMediaRepository>;
|
||||
let storageMock: jest.Mocked<IStorageRepository>;
|
||||
|
||||
beforeEach(async () => {
|
||||
assetMock = newAssetRepositoryMock();
|
||||
configMock = newSystemConfigRepositoryMock();
|
||||
jobMock = newJobRepositoryMock();
|
||||
mediaMock = newMediaRepositoryMock();
|
||||
storageMock = newStorageRepositoryMock();
|
||||
|
||||
sut = new MediaService(assetMock, jobMock, mediaMock, storageMock, configMock);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(sut).toBeDefined();
|
||||
});
|
||||
|
||||
describe('handleQueueGenerateThumbnails', () => {
|
||||
it('should queue all assets', async () => {
|
||||
assetMock.getAll.mockResolvedValue({
|
||||
items: [assetEntityStub.image],
|
||||
hasNextPage: false,
|
||||
});
|
||||
|
||||
await sut.handleQueueGenerateThumbnails({ force: true });
|
||||
|
||||
expect(assetMock.getAll).toHaveBeenCalled();
|
||||
expect(assetMock.getWithout).not.toHaveBeenCalled();
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
name: JobName.GENERATE_JPEG_THUMBNAIL,
|
||||
data: { id: assetEntityStub.image.id },
|
||||
});
|
||||
});
|
||||
|
||||
it('should queue all assets with missing thumbnails', async () => {
|
||||
assetMock.getWithout.mockResolvedValue({
|
||||
items: [assetEntityStub.image],
|
||||
hasNextPage: false,
|
||||
});
|
||||
|
||||
await sut.handleQueueGenerateThumbnails({ force: false });
|
||||
|
||||
expect(assetMock.getAll).not.toHaveBeenCalled();
|
||||
expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL);
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
name: JobName.GENERATE_JPEG_THUMBNAIL,
|
||||
data: { id: assetEntityStub.image.id },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleGenerateJpegThumbnail', () => {
|
||||
it('should generate a thumbnail for an image', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
|
||||
await sut.handleGenerateJpegThumbnail({ id: assetEntityStub.image.id });
|
||||
|
||||
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id');
|
||||
expect(mediaMock.resize).toHaveBeenCalledWith('/original/path.ext', 'upload/thumbs/user-id/asset-id.jpeg', {
|
||||
size: 1440,
|
||||
format: 'jpeg',
|
||||
});
|
||||
expect(mediaMock.extractThumbnailFromExif).not.toHaveBeenCalled();
|
||||
expect(assetMock.save).toHaveBeenCalledWith({
|
||||
id: 'asset-id',
|
||||
resizePath: 'upload/thumbs/user-id/asset-id.jpeg',
|
||||
});
|
||||
});
|
||||
|
||||
it('should generate a thumbnail for an image from exif', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
|
||||
mediaMock.resize.mockRejectedValue(new Error('unsupported format'));
|
||||
|
||||
await sut.handleGenerateJpegThumbnail({ id: assetEntityStub.image.id });
|
||||
|
||||
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id');
|
||||
expect(mediaMock.resize).toHaveBeenCalledWith('/original/path.ext', 'upload/thumbs/user-id/asset-id.jpeg', {
|
||||
size: 1440,
|
||||
format: 'jpeg',
|
||||
});
|
||||
expect(mediaMock.extractThumbnailFromExif).toHaveBeenCalledWith(
|
||||
'/original/path.ext',
|
||||
'upload/thumbs/user-id/asset-id.jpeg',
|
||||
);
|
||||
expect(assetMock.save).toHaveBeenCalledWith({
|
||||
id: 'asset-id',
|
||||
resizePath: 'upload/thumbs/user-id/asset-id.jpeg',
|
||||
});
|
||||
});
|
||||
|
||||
it('should generate a thumbnail for a video', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
|
||||
await sut.handleGenerateJpegThumbnail({ id: assetEntityStub.video.id });
|
||||
|
||||
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id');
|
||||
expect(mediaMock.extractVideoThumbnail).toHaveBeenCalledWith(
|
||||
'/original/path.ext',
|
||||
'upload/thumbs/user-id/asset-id.jpeg',
|
||||
1440,
|
||||
);
|
||||
expect(assetMock.save).toHaveBeenCalledWith({
|
||||
id: 'asset-id',
|
||||
resizePath: 'upload/thumbs/user-id/asset-id.jpeg',
|
||||
});
|
||||
});
|
||||
|
||||
it('should run successfully', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
|
||||
await sut.handleGenerateJpegThumbnail({ id: assetEntityStub.image.id });
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleGenerateWebpThumbnail', () => {
|
||||
it('should skip thumbnail generate if resize path is missing', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.noResizePath]);
|
||||
await sut.handleGenerateWepbThumbnail({ id: assetEntityStub.noResizePath.id });
|
||||
expect(mediaMock.resize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should generate a thumbnail', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
|
||||
await sut.handleGenerateWepbThumbnail({ id: assetEntityStub.image.id });
|
||||
|
||||
expect(mediaMock.resize).toHaveBeenCalledWith(
|
||||
'/uploads/user-id/thumbs/path.ext',
|
||||
'/uploads/user-id/thumbs/path.ext',
|
||||
{ format: 'webp', size: 250 },
|
||||
);
|
||||
expect(assetMock.save).toHaveBeenCalledWith({ id: 'asset-id', webpPath: '/uploads/user-id/thumbs/path.ext' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleQueueVideoConversion', () => {
|
||||
it('should queue all video assets', async () => {
|
||||
assetMock.getAll.mockResolvedValue({
|
||||
items: [assetEntityStub.video],
|
||||
hasNextPage: false,
|
||||
});
|
||||
|
||||
await sut.handleQueueVideoConversion({ force: true });
|
||||
|
||||
expect(assetMock.getAll).toHaveBeenCalledWith({ skip: 0, take: 1000 }, { type: AssetType.VIDEO });
|
||||
expect(assetMock.getWithout).not.toHaveBeenCalled();
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
name: JobName.VIDEO_CONVERSION,
|
||||
data: { id: assetEntityStub.video.id },
|
||||
});
|
||||
});
|
||||
|
||||
it('should queue all video assets without encoded videos', async () => {
|
||||
assetMock.getWithout.mockResolvedValue({
|
||||
items: [assetEntityStub.video],
|
||||
hasNextPage: false,
|
||||
});
|
||||
|
||||
await sut.handleQueueVideoConversion({});
|
||||
|
||||
expect(assetMock.getAll).not.toHaveBeenCalled();
|
||||
expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.ENCODED_VIDEO);
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
name: JobName.VIDEO_CONVERSION,
|
||||
data: { id: assetEntityStub.video.id },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleVideoConversion', () => {
|
||||
beforeEach(() => {
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
|
||||
});
|
||||
|
||||
it('should transcode the longest stream', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
|
||||
mediaMock.probe.mockResolvedValue(probeStub.multipleVideoStreams);
|
||||
|
||||
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
|
||||
|
||||
expect(mediaMock.probe).toHaveBeenCalledWith('/original/path.ext');
|
||||
expect(configMock.load).toHaveBeenCalled();
|
||||
expect(storageMock.mkdirSync).toHaveBeenCalled();
|
||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||
'/original/path.ext',
|
||||
'upload/encoded-video/user-id/asset-id.mp4',
|
||||
{
|
||||
outputOptions: ['-vcodec h264', '-acodec aac', '-movflags faststart', '-preset ultrafast', '-crf 23'],
|
||||
twoPass: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should skip a video without any streams', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.noVideoStreams);
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
|
||||
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
|
||||
expect(mediaMock.transcode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip a video without any height', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.noHeight);
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
|
||||
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
|
||||
expect(mediaMock.transcode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should transcode when set to all', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.multipleVideoStreams);
|
||||
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'all' }]);
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
|
||||
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
|
||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||
'/original/path.ext',
|
||||
'upload/encoded-video/user-id/asset-id.mp4',
|
||||
{
|
||||
outputOptions: ['-vcodec h264', '-acodec aac', '-movflags faststart', '-preset ultrafast', '-crf 23'],
|
||||
twoPass: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should transcode when optimal and too big', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p);
|
||||
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'optimal' }]);
|
||||
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
|
||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||
'/original/path.ext',
|
||||
'upload/encoded-video/user-id/asset-id.mp4',
|
||||
{
|
||||
outputOptions: [
|
||||
'-vcodec h264',
|
||||
'-acodec aac',
|
||||
'-movflags faststart',
|
||||
'-vf scale=-2:720',
|
||||
'-preset ultrafast',
|
||||
'-crf 23',
|
||||
],
|
||||
twoPass: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should transcode with alternate scaling video is vertical', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.videoStreamVertical2160p);
|
||||
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'optimal' }]);
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
|
||||
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
|
||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||
'/original/path.ext',
|
||||
'upload/encoded-video/user-id/asset-id.mp4',
|
||||
{
|
||||
outputOptions: [
|
||||
'-vcodec h264',
|
||||
'-acodec aac',
|
||||
'-movflags faststart',
|
||||
'-vf scale=720:-2',
|
||||
'-preset ultrafast',
|
||||
'-crf 23',
|
||||
],
|
||||
twoPass: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should transcode when audio doesnt match target', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.audioStreamMp3);
|
||||
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'optimal' }]);
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
|
||||
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
|
||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||
'/original/path.ext',
|
||||
'upload/encoded-video/user-id/asset-id.mp4',
|
||||
{
|
||||
outputOptions: [
|
||||
'-vcodec h264',
|
||||
'-acodec aac',
|
||||
'-movflags faststart',
|
||||
'-vf scale=-2:720',
|
||||
'-preset ultrafast',
|
||||
'-crf 23',
|
||||
],
|
||||
twoPass: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should transcode when container doesnt match target', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
|
||||
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'optimal' }]);
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
|
||||
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
|
||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||
'/original/path.ext',
|
||||
'upload/encoded-video/user-id/asset-id.mp4',
|
||||
{
|
||||
outputOptions: [
|
||||
'-vcodec h264',
|
||||
'-acodec aac',
|
||||
'-movflags faststart',
|
||||
'-vf scale=-2:720',
|
||||
'-preset ultrafast',
|
||||
'-crf 23',
|
||||
],
|
||||
twoPass: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should not transcode an invalid transcode value', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p);
|
||||
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: 'invalid' }]);
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
|
||||
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
|
||||
expect(mediaMock.transcode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should set max bitrate if above 0', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
|
||||
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '4500k' }]);
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
|
||||
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
|
||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||
'/original/path.ext',
|
||||
'upload/encoded-video/user-id/asset-id.mp4',
|
||||
{
|
||||
outputOptions: [
|
||||
'-vcodec h264',
|
||||
'-acodec aac',
|
||||
'-movflags faststart',
|
||||
'-vf scale=-2:720',
|
||||
'-preset ultrafast',
|
||||
'-crf 23',
|
||||
'-maxrate 4500k',
|
||||
],
|
||||
twoPass: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should transcode in two passes for h264/h265 when enabled and max bitrate is above 0', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
|
||||
configMock.load.mockResolvedValue([
|
||||
{ key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '4500k' },
|
||||
{ key: SystemConfigKey.FFMPEG_TWO_PASS, value: true },
|
||||
]);
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
|
||||
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
|
||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||
'/original/path.ext',
|
||||
'upload/encoded-video/user-id/asset-id.mp4',
|
||||
{
|
||||
outputOptions: [
|
||||
'-vcodec h264',
|
||||
'-acodec aac',
|
||||
'-movflags faststart',
|
||||
'-vf scale=-2:720',
|
||||
'-preset ultrafast',
|
||||
'-b:v 3104k',
|
||||
'-minrate 1552k',
|
||||
'-maxrate 4500k',
|
||||
],
|
||||
twoPass: true,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should fallback to one pass for h264/h265 if two-pass is enabled but no max bitrate is set', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
|
||||
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TWO_PASS, value: true }]);
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
|
||||
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
|
||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||
'/original/path.ext',
|
||||
'upload/encoded-video/user-id/asset-id.mp4',
|
||||
{
|
||||
outputOptions: [
|
||||
'-vcodec h264',
|
||||
'-acodec aac',
|
||||
'-movflags faststart',
|
||||
'-vf scale=-2:720',
|
||||
'-preset ultrafast',
|
||||
'-crf 23',
|
||||
],
|
||||
twoPass: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should configure preset for vp9', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
|
||||
configMock.load.mockResolvedValue([
|
||||
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: 'vp9' },
|
||||
{ key: SystemConfigKey.FFMPEG_THREADS, value: 2 },
|
||||
]);
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
|
||||
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
|
||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||
'/original/path.ext',
|
||||
'upload/encoded-video/user-id/asset-id.mp4',
|
||||
{
|
||||
outputOptions: [
|
||||
'-vcodec vp9',
|
||||
'-acodec aac',
|
||||
'-movflags faststart',
|
||||
'-vf scale=-2:720',
|
||||
'-cpu-used 5',
|
||||
'-row-mt 1',
|
||||
'-threads 2',
|
||||
'-crf 23',
|
||||
'-b:v 0',
|
||||
],
|
||||
twoPass: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should configure threads if above 0', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
|
||||
configMock.load.mockResolvedValue([
|
||||
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: 'vp9' },
|
||||
{ key: SystemConfigKey.FFMPEG_THREADS, value: 2 },
|
||||
]);
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
|
||||
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
|
||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||
'/original/path.ext',
|
||||
'upload/encoded-video/user-id/asset-id.mp4',
|
||||
{
|
||||
outputOptions: [
|
||||
'-vcodec vp9',
|
||||
'-acodec aac',
|
||||
'-movflags faststart',
|
||||
'-vf scale=-2:720',
|
||||
'-cpu-used 5',
|
||||
'-row-mt 1',
|
||||
'-threads 2',
|
||||
'-crf 23',
|
||||
'-b:v 0',
|
||||
],
|
||||
twoPass: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should disable thread pooling for x264/x265 if thread limit is above 0', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
|
||||
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_THREADS, value: 2 }]);
|
||||
assetMock.getByIds.mockResolvedValue([assetEntityStub.video]);
|
||||
await sut.handleVideoConversion({ id: assetEntityStub.video.id });
|
||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||
'/original/path.ext',
|
||||
'upload/encoded-video/user-id/asset-id.mp4',
|
||||
{
|
||||
outputOptions: [
|
||||
'-vcodec h264',
|
||||
'-acodec aac',
|
||||
'-movflags faststart',
|
||||
'-vf scale=-2:720',
|
||||
'-preset ultrafast',
|
||||
'-threads 2',
|
||||
'-x264-params "pools=none"',
|
||||
'-x264-params "frame-threads=2"',
|
||||
'-crf 23',
|
||||
],
|
||||
twoPass: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
290
server/src/domain/media/media.service.ts
Normal file
290
server/src/domain/media/media.service.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
import { AssetEntity, AssetType, TranscodePreset } from '@app/infra/entities';
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { join } from 'path';
|
||||
import { IAssetRepository, WithoutProperty } from '../asset';
|
||||
import { usePagination } from '../domain.util';
|
||||
import { IBaseJob, IEntityJob, IJobRepository, JobName, JOBS_ASSET_PAGINATION_SIZE } from '../job';
|
||||
import { IStorageRepository, StorageCore, StorageFolder } from '../storage';
|
||||
import { ISystemConfigRepository, SystemConfigFFmpegDto } from '../system-config';
|
||||
import { SystemConfigCore } from '../system-config/system-config.core';
|
||||
import { JPEG_THUMBNAIL_SIZE, WEBP_THUMBNAIL_SIZE } from './media.constant';
|
||||
import { AudioStreamInfo, IMediaRepository, VideoStreamInfo } from './media.repository';
|
||||
|
||||
@Injectable()
|
||||
export class MediaService {
|
||||
private logger = new Logger(MediaService.name);
|
||||
private storageCore = new StorageCore();
|
||||
private configCore: SystemConfigCore;
|
||||
|
||||
constructor(
|
||||
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||
@Inject(IMediaRepository) private mediaRepository: IMediaRepository,
|
||||
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
||||
@Inject(ISystemConfigRepository) systemConfig: ISystemConfigRepository,
|
||||
) {
|
||||
this.configCore = new SystemConfigCore(systemConfig);
|
||||
}
|
||||
|
||||
async handleQueueGenerateThumbnails(job: IBaseJob) {
|
||||
const { force } = job;
|
||||
|
||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
||||
return force
|
||||
? this.assetRepository.getAll(pagination)
|
||||
: this.assetRepository.getWithout(pagination, WithoutProperty.THUMBNAIL);
|
||||
});
|
||||
|
||||
for await (const assets of assetPagination) {
|
||||
for (const asset of assets) {
|
||||
await this.jobRepository.queue({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { id: asset.id } });
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async handleGenerateJpegThumbnail({ id }: IEntityJob) {
|
||||
const [asset] = await this.assetRepository.getByIds([id]);
|
||||
if (!asset) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const resizePath = this.storageCore.getFolderLocation(StorageFolder.THUMBNAILS, asset.ownerId);
|
||||
this.storageRepository.mkdirSync(resizePath);
|
||||
const jpegThumbnailPath = join(resizePath, `${asset.id}.jpeg`);
|
||||
|
||||
if (asset.type == AssetType.IMAGE) {
|
||||
try {
|
||||
await this.mediaRepository.resize(asset.originalPath, jpegThumbnailPath, {
|
||||
size: JPEG_THUMBNAIL_SIZE,
|
||||
format: 'jpeg',
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Failed to generate jpeg thumbnail using sharp, trying with exiftool-vendored (asset=${asset.id})`,
|
||||
);
|
||||
await this.mediaRepository.extractThumbnailFromExif(asset.originalPath, jpegThumbnailPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (asset.type == AssetType.VIDEO) {
|
||||
this.logger.log('Start Generating Video Thumbnail');
|
||||
await this.mediaRepository.extractVideoThumbnail(asset.originalPath, jpegThumbnailPath, JPEG_THUMBNAIL_SIZE);
|
||||
this.logger.log(`Generating Video Thumbnail Success ${asset.id}`);
|
||||
}
|
||||
|
||||
await this.assetRepository.save({ id: asset.id, resizePath: jpegThumbnailPath });
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async handleGenerateWepbThumbnail({ id }: IEntityJob) {
|
||||
const [asset] = await this.assetRepository.getByIds([id]);
|
||||
if (!asset || !asset.resizePath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const webpPath = asset.resizePath.replace('jpeg', 'webp');
|
||||
|
||||
await this.mediaRepository.resize(asset.resizePath, webpPath, { size: WEBP_THUMBNAIL_SIZE, format: 'webp' });
|
||||
await this.assetRepository.save({ id: asset.id, webpPath: webpPath });
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async handleQueueVideoConversion(job: IBaseJob) {
|
||||
const { force } = job;
|
||||
|
||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
||||
return force
|
||||
? this.assetRepository.getAll(pagination, { type: AssetType.VIDEO })
|
||||
: this.assetRepository.getWithout(pagination, WithoutProperty.ENCODED_VIDEO);
|
||||
});
|
||||
|
||||
for await (const assets of assetPagination) {
|
||||
for (const asset of assets) {
|
||||
await this.jobRepository.queue({ name: JobName.VIDEO_CONVERSION, data: { id: asset.id } });
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async handleVideoConversion({ id }: IEntityJob) {
|
||||
const [asset] = await this.assetRepository.getByIds([id]);
|
||||
if (!asset) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const input = asset.originalPath;
|
||||
const outputFolder = this.storageCore.getFolderLocation(StorageFolder.ENCODED_VIDEO, asset.ownerId);
|
||||
const output = join(outputFolder, `${asset.id}.mp4`);
|
||||
this.storageRepository.mkdirSync(outputFolder);
|
||||
|
||||
const { videoStreams, audioStreams, format } = await this.mediaRepository.probe(input);
|
||||
const mainVideoStream = this.getMainVideoStream(videoStreams);
|
||||
const mainAudioStream = this.getMainAudioStream(audioStreams);
|
||||
const containerExtension = format.formatName;
|
||||
if (!mainVideoStream || !mainAudioStream || !containerExtension) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { ffmpeg: config } = await this.configCore.getConfig();
|
||||
|
||||
const required = this.isTranscodeRequired(asset, mainVideoStream, mainAudioStream, containerExtension, config);
|
||||
if (!required) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const outputOptions = this.getFfmpegOptions(mainVideoStream, config);
|
||||
const twoPass = this.eligibleForTwoPass(config);
|
||||
|
||||
this.logger.log(`Start encoding video ${asset.id} ${outputOptions}`);
|
||||
await this.mediaRepository.transcode(input, output, { outputOptions, twoPass });
|
||||
|
||||
this.logger.log(`Encoding success ${asset.id}`);
|
||||
|
||||
await this.assetRepository.save({ id: asset.id, encodedVideoPath: output });
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private getMainVideoStream(streams: VideoStreamInfo[]): VideoStreamInfo | null {
|
||||
return streams.sort((stream1, stream2) => stream2.frameCount - stream1.frameCount)[0];
|
||||
}
|
||||
|
||||
private getMainAudioStream(streams: AudioStreamInfo[]): AudioStreamInfo | null {
|
||||
return streams[0];
|
||||
}
|
||||
|
||||
private isTranscodeRequired(
|
||||
asset: AssetEntity,
|
||||
videoStream: VideoStreamInfo,
|
||||
audioStream: AudioStreamInfo,
|
||||
containerExtension: string,
|
||||
ffmpegConfig: SystemConfigFFmpegDto,
|
||||
): boolean {
|
||||
if (!videoStream.height || !videoStream.width) {
|
||||
this.logger.error('Skipping transcode, height or width undefined for video stream');
|
||||
return false;
|
||||
}
|
||||
|
||||
const isTargetVideoCodec = videoStream.codecName === ffmpegConfig.targetVideoCodec;
|
||||
const isTargetAudioCodec = audioStream.codecName === ffmpegConfig.targetAudioCodec;
|
||||
const isTargetContainer = ['mov,mp4,m4a,3gp,3g2,mj2', 'mp4', 'mov'].includes(containerExtension);
|
||||
|
||||
this.logger.verbose(
|
||||
`${asset.id}: AudioCodecName ${audioStream.codecName}, AudioStreamCodecType ${audioStream.codecType}, containerExtension ${containerExtension}`,
|
||||
);
|
||||
|
||||
const allTargetsMatching = isTargetVideoCodec && isTargetAudioCodec && isTargetContainer;
|
||||
|
||||
const targetResolution = Number.parseInt(ffmpegConfig.targetResolution);
|
||||
const isLargerThanTargetResolution = Math.min(videoStream.height, videoStream.width) > targetResolution;
|
||||
|
||||
switch (ffmpegConfig.transcode) {
|
||||
case TranscodePreset.DISABLED:
|
||||
return false;
|
||||
|
||||
case TranscodePreset.ALL:
|
||||
return true;
|
||||
|
||||
case TranscodePreset.REQUIRED:
|
||||
return !allTargetsMatching;
|
||||
|
||||
case TranscodePreset.OPTIMAL:
|
||||
return !allTargetsMatching || isLargerThanTargetResolution;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private getFfmpegOptions(stream: VideoStreamInfo, ffmpeg: SystemConfigFFmpegDto) {
|
||||
const options = [
|
||||
`-vcodec ${ffmpeg.targetVideoCodec}`,
|
||||
`-acodec ${ffmpeg.targetAudioCodec}`,
|
||||
// Makes a second pass moving the moov atom to the beginning of
|
||||
// the file for improved playback speed.
|
||||
`-movflags faststart`,
|
||||
];
|
||||
|
||||
// video dimensions
|
||||
const videoIsRotated = Math.abs(stream.rotation) === 90;
|
||||
const targetResolution = Number.parseInt(ffmpeg.targetResolution);
|
||||
const isVideoVertical = stream.height > stream.width || videoIsRotated;
|
||||
const scaling = isVideoVertical ? `${targetResolution}:-2` : `-2:${targetResolution}`;
|
||||
const shouldScale = Math.min(stream.height, stream.width) > targetResolution;
|
||||
|
||||
// video codec
|
||||
const isVP9 = ffmpeg.targetVideoCodec === 'vp9';
|
||||
const isH264 = ffmpeg.targetVideoCodec === 'h264';
|
||||
const isH265 = ffmpeg.targetVideoCodec === 'hevc';
|
||||
|
||||
// transcode efficiency
|
||||
const limitThreads = ffmpeg.threads > 0;
|
||||
const maxBitrateValue = Number.parseInt(ffmpeg.maxBitrate) || 0;
|
||||
const constrainMaximumBitrate = maxBitrateValue > 0;
|
||||
const bitrateUnit = ffmpeg.maxBitrate.trim().substring(maxBitrateValue.toString().length); // use inputted unit if provided
|
||||
|
||||
if (shouldScale) {
|
||||
options.push(`-vf scale=${scaling}`);
|
||||
}
|
||||
|
||||
if (isH264 || isH265) {
|
||||
options.push(`-preset ${ffmpeg.preset}`);
|
||||
}
|
||||
|
||||
if (isVP9) {
|
||||
// vp9 doesn't have presets, but does have a similar setting -cpu-used, from 0-5, 0 being the slowest
|
||||
const presets = ['veryslow', 'slower', 'slow', 'medium', 'fast', 'faster', 'veryfast', 'superfast', 'ultrafast'];
|
||||
const speed = Math.min(presets.indexOf(ffmpeg.preset), 5); // values over 5 require realtime mode, which is its own can of worms since it overrides -crf and -threads
|
||||
if (speed >= 0) {
|
||||
options.push(`-cpu-used ${speed}`);
|
||||
}
|
||||
options.push('-row-mt 1'); // better multithreading
|
||||
}
|
||||
|
||||
if (limitThreads) {
|
||||
options.push(`-threads ${ffmpeg.threads}`);
|
||||
|
||||
// x264 and x265 handle threads differently than one might expect
|
||||
// https://x265.readthedocs.io/en/latest/cli.html#cmdoption-pools
|
||||
if (isH264 || isH265) {
|
||||
options.push(`-${isH265 ? 'x265' : 'x264'}-params "pools=none"`);
|
||||
options.push(`-${isH265 ? 'x265' : 'x264'}-params "frame-threads=${ffmpeg.threads}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// two-pass mode for x264/x265 uses bitrate ranges, so it requires a max bitrate from which to derive a target and min bitrate
|
||||
if (constrainMaximumBitrate && ffmpeg.twoPass) {
|
||||
const targetBitrateValue = Math.ceil(maxBitrateValue / 1.45); // recommended by https://developers.google.com/media/vp9/settings/vod
|
||||
const minBitrateValue = targetBitrateValue / 2;
|
||||
|
||||
options.push(`-b:v ${targetBitrateValue}${bitrateUnit}`);
|
||||
options.push(`-minrate ${minBitrateValue}${bitrateUnit}`);
|
||||
options.push(`-maxrate ${maxBitrateValue}${bitrateUnit}`);
|
||||
} else if (constrainMaximumBitrate || isVP9) {
|
||||
// for vp9, these flags work for both one-pass and two-pass
|
||||
options.push(`-crf ${ffmpeg.crf}`);
|
||||
options.push(`${isVP9 ? '-b:v' : '-maxrate'} ${maxBitrateValue}${bitrateUnit}`);
|
||||
} else {
|
||||
options.push(`-crf ${ffmpeg.crf}`);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
private eligibleForTwoPass(ffmpeg: SystemConfigFFmpegDto) {
|
||||
if (!ffmpeg.twoPass) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isVP9 = ffmpeg.targetVideoCodec === 'vp9';
|
||||
const maxBitrateValue = Number.parseInt(ffmpeg.maxBitrate) || 0;
|
||||
const constrainMaximumBitrate = maxBitrateValue > 0;
|
||||
|
||||
return constrainMaximumBitrate || isVP9;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user