Skip to content

Commit ab6909b

Browse files
authored
20 video conversion for web view (immich-app#200)
* Added job for video conversion every 1 minute * Handle get video as mp4 on the web * Auto play video on web on hovered * Added video player * Added animation and video duration to thumbnail player * Fixed issue with video not playing on hover * Added animation when loading thumbnail
1 parent 53c3c91 commit ab6909b

File tree

17 files changed

+372
-51
lines changed

17 files changed

+372
-51
lines changed

server/src/api-v1/asset/asset.module.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,4 @@ import { CommunicationModule } from '../communication/communication.module';
3838
providers: [AssetService, AssetOptimizeService, BackgroundTaskService],
3939
exports: [],
4040
})
41-
export class AssetModule {}
41+
export class AssetModule { }

server/src/api-v1/asset/asset.service.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { Response as Res } from 'express';
1111
import { promisify } from 'util';
1212
import { DeleteAssetDto } from './dto/delete-asset.dto';
1313
import { SearchAssetDto } from './dto/search-asset.dto';
14+
import ffmpeg from 'fluent-ffmpeg';
1415

1516
const fileInfo = promisify(stat);
1617

@@ -185,7 +186,15 @@ export class AssetService {
185186

186187
} else if (asset.type == AssetType.VIDEO) {
187188
// Handle Video
188-
const { size } = await fileInfo(asset.originalPath);
189+
let videoPath = asset.originalPath;
190+
let mimeType = asset.mimeType;
191+
192+
if (query.isWeb && asset.mimeType == 'video/quicktime') {
193+
videoPath = asset.encodedVideoPath == '' ? asset.originalPath : asset.encodedVideoPath;
194+
mimeType = asset.encodedVideoPath == '' ? asset.mimeType : 'video/mp4';
195+
}
196+
197+
const { size } = await fileInfo(videoPath);
189198
const range = headers.range;
190199

191200
if (range) {
@@ -220,20 +229,22 @@ export class AssetService {
220229
'Content-Range': `bytes ${start}-${end}/${size}`,
221230
'Accept-Ranges': 'bytes',
222231
'Content-Length': end - start + 1,
223-
'Content-Type': asset.mimeType,
232+
'Content-Type': mimeType,
224233
});
225234

226-
const videoStream = createReadStream(asset.originalPath, { start: start, end: end });
235+
236+
const videoStream = createReadStream(videoPath, { start: start, end: end });
227237

228238
return new StreamableFile(videoStream);
229239

230240

231241
} else {
242+
232243
res.set({
233-
'Content-Type': asset.mimeType,
244+
'Content-Type': mimeType,
234245
});
235246

236-
return new StreamableFile(createReadStream(asset.originalPath));
247+
return new StreamableFile(createReadStream(videoPath));
237248
}
238249
}
239250
}

server/src/api-v1/asset/entities/asset.entity.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ export class AssetEntity {
2929
@Column({ nullable: true })
3030
webpPath: string;
3131

32+
@Column({ nullable: true })
33+
encodedVideoPath: string;
34+
3235
@Column()
3336
createdAt: string;
3437

server/src/app.module.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ import { ScheduleTasksModule } from './modules/schedule-tasks/schedule-tasks.mod
6565
export class AppModule implements NestModule {
6666
configure(consumer: MiddlewareConsumer): void {
6767
if (process.env.NODE_ENV == 'development') {
68-
consumer.apply(AppLoggerMiddleware).forRoutes('*');
68+
// consumer.apply(AppLoggerMiddleware).forRoutes('*');
6969
}
7070
}
7171
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { MigrationInterface, QueryRunner } from "typeorm";
2+
3+
export class UpdateAssetTableWithEncodeVideoPath1654299904583 implements MigrationInterface {
4+
public async up(queryRunner: QueryRunner): Promise<void> {
5+
await queryRunner.query(`
6+
alter table assets
7+
add column if not exists "encodedVideoPath" varchar default '';
8+
`)
9+
}
10+
11+
public async down(queryRunner: QueryRunner): Promise<void> {
12+
await queryRunner.query(`
13+
alter table assets
14+
drop column if exists "encodedVideoPath";
15+
`);
16+
}
17+
}

server/src/modules/background-task/background-task.module.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,10 @@ import { BackgroundTaskService } from './background-task.service';
1717
removeOnFail: false,
1818
},
1919
}),
20+
2021
TypeOrmModule.forFeature([AssetEntity, ExifEntity, SmartInfoEntity]),
2122
],
2223
providers: [BackgroundTaskService, BackgroundTaskProcessor],
2324
exports: [BackgroundTaskService],
2425
})
25-
export class BackgroundTaskModule {}
26+
export class BackgroundTaskModule { }

server/src/modules/image-optimize/image-optimize.module.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,4 @@ import { AssetOptimizeService } from './image-optimize.service';
3333
providers: [AssetOptimizeService, ImageOptimizeProcessor, BackgroundTaskService],
3434
exports: [AssetOptimizeService],
3535
})
36-
export class ImageOptimizeModule {}
36+
export class ImageOptimizeModule { }
Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,30 @@
1+
import { BullModule } from '@nestjs/bull';
12
import { Module } from '@nestjs/common';
23
import { TypeOrmModule } from '@nestjs/typeorm';
4+
import { AssetModule } from '../../api-v1/asset/asset.module';
35
import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
46
import { ImageConversionService } from './image-conversion.service';
7+
import { VideoConversionProcessor } from './video-conversion.processor';
8+
import { VideoConversionService } from './video-conversion.service';
59

610
@Module({
711
imports: [
812
TypeOrmModule.forFeature([AssetEntity]),
13+
14+
BullModule.registerQueue({
15+
settings: {},
16+
name: 'video-conversion',
17+
limiter: {
18+
max: 1,
19+
duration: 60000
20+
},
21+
defaultJobOptions: {
22+
attempts: 3,
23+
removeOnComplete: true,
24+
removeOnFail: false,
25+
},
26+
}),
927
],
10-
providers: [ImageConversionService],
28+
providers: [ImageConversionService, VideoConversionService, VideoConversionProcessor,],
1129
})
1230
export class ScheduleTasksModule { }
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { Process, Processor } from '@nestjs/bull';
2+
import { InjectRepository } from '@nestjs/typeorm';
3+
import { Job } from 'bull';
4+
import { Repository } from 'typeorm';
5+
import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
6+
import { existsSync, mkdirSync } from 'fs';
7+
import { APP_UPLOAD_LOCATION } from '../../constants/upload_location.constant';
8+
import ffmpeg from 'fluent-ffmpeg';
9+
import { Logger } from '@nestjs/common';
10+
11+
@Processor('video-conversion')
12+
export class VideoConversionProcessor {
13+
14+
constructor(
15+
@InjectRepository(AssetEntity)
16+
private assetRepository: Repository<AssetEntity>,
17+
) { }
18+
19+
@Process('to-mp4')
20+
async convertToMp4(job: Job) {
21+
const { asset }: { asset: AssetEntity } = job.data;
22+
23+
const basePath = APP_UPLOAD_LOCATION;
24+
const encodedVideoPath = `${basePath}/${asset.userId}/encoded-video`;
25+
26+
if (!existsSync(encodedVideoPath)) {
27+
mkdirSync(encodedVideoPath, { recursive: true });
28+
}
29+
30+
const latestAssetInfo = await this.assetRepository.findOne({ id: asset.id });
31+
const savedEncodedPath = encodedVideoPath + "/" + latestAssetInfo.id + '.mp4'
32+
33+
if (latestAssetInfo.encodedVideoPath == '') {
34+
ffmpeg(latestAssetInfo.originalPath)
35+
.outputOptions([
36+
'-crf 23',
37+
'-preset ultrafast',
38+
'-vcodec libx264',
39+
'-acodec mp3',
40+
'-vf scale=1280:-2'
41+
])
42+
.output(savedEncodedPath)
43+
.on('start', () => Logger.log("Start Converting", 'VideoConversionMOV2MP4'))
44+
.on('error', (a, b, c) => {
45+
Logger.error('Cannot Convert Video', 'VideoConversionMOV2MP4')
46+
console.log(a, b, c)
47+
})
48+
.on('end', async () => {
49+
Logger.log(`Converting Success ${latestAssetInfo.id}`, 'VideoConversionMOV2MP4')
50+
await this.assetRepository.update({ id: latestAssetInfo.id }, { encodedVideoPath: savedEncodedPath });
51+
}).run();
52+
}
53+
54+
return {}
55+
}
56+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { Injectable, Logger } from '@nestjs/common';
2+
import { Cron, CronExpression } from '@nestjs/schedule';
3+
import { InjectRepository } from '@nestjs/typeorm';
4+
import { Repository } from 'typeorm';
5+
import { AssetEntity } from '../../api-v1/asset/entities/asset.entity';
6+
import sharp from 'sharp';
7+
import ffmpeg from 'fluent-ffmpeg';
8+
import { APP_UPLOAD_LOCATION } from '../../constants/upload_location.constant';
9+
import { existsSync, mkdirSync } from 'fs';
10+
import { InjectQueue } from '@nestjs/bull/dist/decorators';
11+
import { Queue } from 'bull';
12+
import { randomUUID } from 'crypto';
13+
14+
@Injectable()
15+
export class VideoConversionService {
16+
17+
18+
constructor(
19+
@InjectRepository(AssetEntity)
20+
private assetRepository: Repository<AssetEntity>,
21+
22+
@InjectQueue('video-conversion')
23+
private videoEncodingQueue: Queue
24+
) { }
25+
26+
27+
// 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
28+
@Cron(CronExpression.EVERY_MINUTE
29+
, {
30+
name: 'video-encoding'
31+
})
32+
async mp4Conversion() {
33+
const assets = await this.assetRepository.find({
34+
where: {
35+
type: 'VIDEO',
36+
mimeType: 'video/quicktime',
37+
encodedVideoPath: ''
38+
},
39+
order: {
40+
createdAt: 'DESC'
41+
},
42+
take: 1
43+
});
44+
45+
if (assets.length > 0) {
46+
const asset = assets[0];
47+
await this.videoEncodingQueue.add('to-mp4', { asset }, { jobId: asset.id },)
48+
}
49+
}
50+
}

0 commit comments

Comments
 (0)