diff --git a/server/src/api-v1/asset/asset.module.ts b/server/src/api-v1/asset/asset.module.ts index dda0958fb4..20fb5d1613 100644 --- a/server/src/api-v1/asset/asset.module.ts +++ b/server/src/api-v1/asset/asset.module.ts @@ -38,4 +38,4 @@ import { CommunicationModule } from '../communication/communication.module'; providers: [AssetService, AssetOptimizeService, BackgroundTaskService], exports: [], }) -export class AssetModule {} +export class AssetModule { } diff --git a/server/src/api-v1/asset/asset.service.ts b/server/src/api-v1/asset/asset.service.ts index 59a0d11ed3..4c598d6acf 100644 --- a/server/src/api-v1/asset/asset.service.ts +++ b/server/src/api-v1/asset/asset.service.ts @@ -11,6 +11,7 @@ import { Response as Res } from 'express'; import { promisify } from 'util'; import { DeleteAssetDto } from './dto/delete-asset.dto'; import { SearchAssetDto } from './dto/search-asset.dto'; +import ffmpeg from 'fluent-ffmpeg'; const fileInfo = promisify(stat); @@ -185,7 +186,15 @@ export class AssetService { } else if (asset.type == AssetType.VIDEO) { // Handle Video - const { size } = await fileInfo(asset.originalPath); + let videoPath = asset.originalPath; + let mimeType = asset.mimeType; + + if (query.isWeb && asset.mimeType == 'video/quicktime') { + videoPath = asset.encodedVideoPath == '' ? asset.originalPath : asset.encodedVideoPath; + mimeType = asset.encodedVideoPath == '' ? asset.mimeType : 'video/mp4'; + } + + const { size } = await fileInfo(videoPath); const range = headers.range; if (range) { @@ -220,20 +229,22 @@ export class AssetService { 'Content-Range': `bytes ${start}-${end}/${size}`, 'Accept-Ranges': 'bytes', 'Content-Length': end - start + 1, - 'Content-Type': asset.mimeType, + 'Content-Type': mimeType, }); - const videoStream = createReadStream(asset.originalPath, { start: start, end: end }); + + const videoStream = createReadStream(videoPath, { start: start, end: end }); return new StreamableFile(videoStream); } else { + res.set({ - 'Content-Type': asset.mimeType, + 'Content-Type': mimeType, }); - return new StreamableFile(createReadStream(asset.originalPath)); + return new StreamableFile(createReadStream(videoPath)); } } } diff --git a/server/src/api-v1/asset/entities/asset.entity.ts b/server/src/api-v1/asset/entities/asset.entity.ts index bce9fd5283..557187e58d 100644 --- a/server/src/api-v1/asset/entities/asset.entity.ts +++ b/server/src/api-v1/asset/entities/asset.entity.ts @@ -29,6 +29,9 @@ export class AssetEntity { @Column({ nullable: true }) webpPath: string; + @Column({ nullable: true }) + encodedVideoPath: string; + @Column() createdAt: string; diff --git a/server/src/app.module.ts b/server/src/app.module.ts index b9a0fff80c..7aedb33c80 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -65,7 +65,7 @@ import { ScheduleTasksModule } from './modules/schedule-tasks/schedule-tasks.mod export class AppModule implements NestModule { configure(consumer: MiddlewareConsumer): void { if (process.env.NODE_ENV == 'development') { - consumer.apply(AppLoggerMiddleware).forRoutes('*'); + // consumer.apply(AppLoggerMiddleware).forRoutes('*'); } } } diff --git a/server/src/migration/1654299904583-UpdateAssetTableWithEncodeVideoPath.ts b/server/src/migration/1654299904583-UpdateAssetTableWithEncodeVideoPath.ts new file mode 100644 index 0000000000..0c05b0b9bb --- /dev/null +++ b/server/src/migration/1654299904583-UpdateAssetTableWithEncodeVideoPath.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class UpdateAssetTableWithEncodeVideoPath1654299904583 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + alter table assets + add column if not exists "encodedVideoPath" varchar default ''; + `) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + alter table assets + drop column if exists "encodedVideoPath"; + `); + } +} diff --git a/server/src/modules/background-task/background-task.module.ts b/server/src/modules/background-task/background-task.module.ts index b610ebf9e3..ba912f5b2f 100644 --- a/server/src/modules/background-task/background-task.module.ts +++ b/server/src/modules/background-task/background-task.module.ts @@ -17,9 +17,10 @@ import { BackgroundTaskService } from './background-task.service'; removeOnFail: false, }, }), + TypeOrmModule.forFeature([AssetEntity, ExifEntity, SmartInfoEntity]), ], providers: [BackgroundTaskService, BackgroundTaskProcessor], exports: [BackgroundTaskService], }) -export class BackgroundTaskModule {} +export class BackgroundTaskModule { } diff --git a/server/src/modules/image-optimize/image-optimize.module.ts b/server/src/modules/image-optimize/image-optimize.module.ts index 89e9cc739d..3d58ad8d0c 100644 --- a/server/src/modules/image-optimize/image-optimize.module.ts +++ b/server/src/modules/image-optimize/image-optimize.module.ts @@ -33,4 +33,4 @@ import { AssetOptimizeService } from './image-optimize.service'; providers: [AssetOptimizeService, ImageOptimizeProcessor, BackgroundTaskService], exports: [AssetOptimizeService], }) -export class ImageOptimizeModule {} +export class ImageOptimizeModule { } diff --git a/server/src/modules/schedule-tasks/schedule-tasks.module.ts b/server/src/modules/schedule-tasks/schedule-tasks.module.ts index 6248762c0a..a37054df94 100644 --- a/server/src/modules/schedule-tasks/schedule-tasks.module.ts +++ b/server/src/modules/schedule-tasks/schedule-tasks.module.ts @@ -1,12 +1,30 @@ +import { BullModule } from '@nestjs/bull'; import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { AssetModule } from '../../api-v1/asset/asset.module'; import { AssetEntity } from '../../api-v1/asset/entities/asset.entity'; import { ImageConversionService } from './image-conversion.service'; +import { VideoConversionProcessor } from './video-conversion.processor'; +import { VideoConversionService } from './video-conversion.service'; @Module({ imports: [ TypeOrmModule.forFeature([AssetEntity]), + + BullModule.registerQueue({ + settings: {}, + name: 'video-conversion', + limiter: { + max: 1, + duration: 60000 + }, + defaultJobOptions: { + attempts: 3, + removeOnComplete: true, + removeOnFail: false, + }, + }), ], - providers: [ImageConversionService], + providers: [ImageConversionService, VideoConversionService, VideoConversionProcessor,], }) export class ScheduleTasksModule { } diff --git a/server/src/modules/schedule-tasks/video-conversion.processor.ts b/server/src/modules/schedule-tasks/video-conversion.processor.ts new file mode 100644 index 0000000000..da64d08acc --- /dev/null +++ b/server/src/modules/schedule-tasks/video-conversion.processor.ts @@ -0,0 +1,56 @@ +import { Process, Processor } from '@nestjs/bull'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Job } from 'bull'; +import { Repository } from 'typeorm'; +import { AssetEntity } from '../../api-v1/asset/entities/asset.entity'; +import { existsSync, mkdirSync } from 'fs'; +import { APP_UPLOAD_LOCATION } from '../../constants/upload_location.constant'; +import ffmpeg from 'fluent-ffmpeg'; +import { Logger } from '@nestjs/common'; + +@Processor('video-conversion') +export class VideoConversionProcessor { + + constructor( + @InjectRepository(AssetEntity) + private assetRepository: Repository, + ) { } + + @Process('to-mp4') + async convertToMp4(job: Job) { + const { asset }: { asset: AssetEntity } = job.data; + + const basePath = APP_UPLOAD_LOCATION; + const encodedVideoPath = `${basePath}/${asset.userId}/encoded-video`; + + if (!existsSync(encodedVideoPath)) { + mkdirSync(encodedVideoPath, { recursive: true }); + } + + const latestAssetInfo = await this.assetRepository.findOne({ id: asset.id }); + const savedEncodedPath = encodedVideoPath + "/" + latestAssetInfo.id + '.mp4' + + if (latestAssetInfo.encodedVideoPath == '') { + ffmpeg(latestAssetInfo.originalPath) + .outputOptions([ + '-crf 23', + '-preset ultrafast', + '-vcodec libx264', + '-acodec mp3', + '-vf scale=1280:-2' + ]) + .output(savedEncodedPath) + .on('start', () => Logger.log("Start Converting", 'VideoConversionMOV2MP4')) + .on('error', (a, b, c) => { + Logger.error('Cannot Convert Video', 'VideoConversionMOV2MP4') + console.log(a, b, c) + }) + .on('end', async () => { + Logger.log(`Converting Success ${latestAssetInfo.id}`, 'VideoConversionMOV2MP4') + await this.assetRepository.update({ id: latestAssetInfo.id }, { encodedVideoPath: savedEncodedPath }); + }).run(); + } + + return {} + } +} diff --git a/server/src/modules/schedule-tasks/video-conversion.service.ts b/server/src/modules/schedule-tasks/video-conversion.service.ts new file mode 100644 index 0000000000..e714476098 --- /dev/null +++ b/server/src/modules/schedule-tasks/video-conversion.service.ts @@ -0,0 +1,50 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AssetEntity } from '../../api-v1/asset/entities/asset.entity'; +import sharp from 'sharp'; +import ffmpeg from 'fluent-ffmpeg'; +import { APP_UPLOAD_LOCATION } from '../../constants/upload_location.constant'; +import { existsSync, mkdirSync } from 'fs'; +import { InjectQueue } from '@nestjs/bull/dist/decorators'; +import { Queue } from 'bull'; +import { randomUUID } from 'crypto'; + +@Injectable() +export class VideoConversionService { + + + constructor( + @InjectRepository(AssetEntity) + private assetRepository: Repository, + + @InjectQueue('video-conversion') + private videoEncodingQueue: Queue + ) { } + + + // time ffmpeg -i 15065f4a-47ff-4aed-8c3e-c9fcf1840531.mov -crf 35 -preset ultrafast -vcodec libx264 -acodec mp3 -vf "scale=1280:-1" 15065f4a-47ff-4aed-8c3e-c9fcf1840531.mp4 + @Cron(CronExpression.EVERY_MINUTE + , { + name: 'video-encoding' + }) + async mp4Conversion() { + const assets = await this.assetRepository.find({ + where: { + type: 'VIDEO', + mimeType: 'video/quicktime', + encodedVideoPath: '' + }, + order: { + createdAt: 'DESC' + }, + take: 1 + }); + + if (assets.length > 0) { + const asset = assets[0]; + await this.videoEncodingQueue.add('to-mp4', { asset }, { jobId: asset.id },) + } + } +} diff --git a/web/package-lock.json b/web/package-lock.json index 3d2be5a20d..dfee43d84b 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -23,6 +23,7 @@ "@types/axios": "^0.14.0", "@types/bcrypt": "^5.0.0", "@types/cookie": "^0.4.1", + "@types/fluent-ffmpeg": "^2.1.20", "@types/leaflet": "^1.7.10", "@types/lodash": "^4.14.182", "@types/lodash-es": "^4.17.6", @@ -260,6 +261,15 @@ "integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==", "dev": true }, + "node_modules/@types/fluent-ffmpeg": { + "version": "2.1.20", + "resolved": "https://registry.npmjs.org/@types/fluent-ffmpeg/-/fluent-ffmpeg-2.1.20.tgz", + "integrity": "sha512-B+OvhCdJ3LgEq2PhvWNOiB/EfwnXLElfMCgc4Z1K5zXgSfo9I6uGKwR/lqmNPFQuebNnes7re3gqkV77SyypLg==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/geojson": { "version": "7946.0.8", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.8.tgz", @@ -3418,6 +3428,15 @@ "integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==", "dev": true }, + "@types/fluent-ffmpeg": { + "version": "2.1.20", + "resolved": "https://registry.npmjs.org/@types/fluent-ffmpeg/-/fluent-ffmpeg-2.1.20.tgz", + "integrity": "sha512-B+OvhCdJ3LgEq2PhvWNOiB/EfwnXLElfMCgc4Z1K5zXgSfo9I6uGKwR/lqmNPFQuebNnes7re3gqkV77SyypLg==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/geojson": { "version": "7946.0.8", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.8.tgz", diff --git a/web/package.json b/web/package.json index 6189345244..15030595c8 100644 --- a/web/package.json +++ b/web/package.json @@ -19,6 +19,7 @@ "@types/axios": "^0.14.0", "@types/bcrypt": "^5.0.0", "@types/cookie": "^0.4.1", + "@types/fluent-ffmpeg": "^2.1.20", "@types/leaflet": "^1.7.10", "@types/lodash": "^4.14.182", "@types/lodash-es": "^4.17.6", diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index c19df36132..f6382413eb 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -12,10 +12,12 @@ import { serverEndpoint } from '../../constants'; import axios from 'axios'; import { downloadAssets } from '$lib/stores/download'; + import VideoViewer from './video-viewer.svelte'; const dispatch = createEventDispatcher(); export let selectedAsset: ImmichAsset; + export let selectedIndex: number; let viewDeviceId: string; @@ -157,7 +159,9 @@
{ halfLeftHover = true; halfRightHover = false; @@ -168,7 +172,7 @@ on:click={navigateAssetBackward} >
{ halfLeftHover = false; @@ -205,7 +206,7 @@ }} >