Add video optimization and following-first feed
هذا الالتزام موجود في:
@@ -1,9 +1,10 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { ManagedStorageService } from './managed-storage.service';
|
||||
import { VideoProcessingService } from './video-processing.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [ManagedStorageService],
|
||||
exports: [ManagedStorageService],
|
||||
providers: [ManagedStorageService, VideoProcessingService],
|
||||
exports: [ManagedStorageService, VideoProcessingService],
|
||||
})
|
||||
export class StorageModule {}
|
||||
|
||||
265
src/infrastructure/storage/video-processing.service.ts
Normal file
265
src/infrastructure/storage/video-processing.service.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { spawn } from 'child_process';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'fs/promises';
|
||||
import { tmpdir } from 'os';
|
||||
import { extname, join } from 'path';
|
||||
|
||||
export type UploadedVideoFile = {
|
||||
mimetype?: string;
|
||||
size: number;
|
||||
buffer: Buffer;
|
||||
originalname?: string;
|
||||
};
|
||||
|
||||
export type OptimizedVideoResult = {
|
||||
file: UploadedVideoFile;
|
||||
generatedThumbnail?: {
|
||||
buffer: Buffer;
|
||||
extension: '.jpg';
|
||||
contentType: 'image/jpeg';
|
||||
};
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class VideoProcessingService {
|
||||
private readonly logger = new Logger(VideoProcessingService.name);
|
||||
private ffmpegAvailabilityResolved = false;
|
||||
private ffmpegAvailable = false;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
async optimizeForPlayback(file: UploadedVideoFile): Promise<OptimizedVideoResult> {
|
||||
if (!this.isEnabled()) {
|
||||
return { file };
|
||||
}
|
||||
|
||||
const ffmpegReady = await this.ensureFfmpegAvailable();
|
||||
if (!ffmpegReady) {
|
||||
return { file };
|
||||
}
|
||||
|
||||
const workingDir = await mkdtemp(join(tmpdir(), 'oudelaa-video-'));
|
||||
const inputPath = join(workingDir, `input-${randomUUID()}${this.resolveInputExtension(file)}`);
|
||||
const outputPath = join(workingDir, `optimized-${randomUUID()}.mp4`);
|
||||
const thumbnailPath = join(workingDir, `thumbnail-${randomUUID()}.jpg`);
|
||||
|
||||
try {
|
||||
await writeFile(inputPath, file.buffer);
|
||||
await this.runFfmpeg([
|
||||
'-y',
|
||||
'-i',
|
||||
inputPath,
|
||||
'-map',
|
||||
'0:v:0',
|
||||
'-map',
|
||||
'0:a:0?',
|
||||
'-c:v',
|
||||
'libx264',
|
||||
'-preset',
|
||||
this.getPreset(),
|
||||
'-crf',
|
||||
String(this.getCrf()),
|
||||
'-pix_fmt',
|
||||
'yuv420p',
|
||||
'-profile:v',
|
||||
'main',
|
||||
'-level',
|
||||
'4.0',
|
||||
'-vf',
|
||||
this.buildVideoFilter(),
|
||||
'-movflags',
|
||||
'+faststart',
|
||||
'-c:a',
|
||||
'aac',
|
||||
'-b:a',
|
||||
`${this.getAudioBitrateKbps()}k`,
|
||||
'-ac',
|
||||
'2',
|
||||
'-ar',
|
||||
'44100',
|
||||
outputPath,
|
||||
]);
|
||||
|
||||
const optimizedBuffer = await readFile(outputPath);
|
||||
const optimizedFile: UploadedVideoFile = {
|
||||
buffer: optimizedBuffer,
|
||||
size: optimizedBuffer.length,
|
||||
mimetype: 'video/mp4',
|
||||
originalname: this.buildOptimizedFileName(file.originalname),
|
||||
};
|
||||
|
||||
let generatedThumbnail: OptimizedVideoResult['generatedThumbnail'];
|
||||
if (this.shouldGenerateThumbnails()) {
|
||||
try {
|
||||
await this.runFfmpeg([
|
||||
'-y',
|
||||
'-ss',
|
||||
'00:00:00.100',
|
||||
'-i',
|
||||
outputPath,
|
||||
'-frames:v',
|
||||
'1',
|
||||
'-vf',
|
||||
this.buildThumbnailFilter(),
|
||||
'-q:v',
|
||||
'3',
|
||||
thumbnailPath,
|
||||
]);
|
||||
|
||||
const thumbnailBuffer = await readFile(thumbnailPath);
|
||||
generatedThumbnail = {
|
||||
buffer: thumbnailBuffer,
|
||||
extension: '.jpg',
|
||||
contentType: 'image/jpeg',
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Thumbnail generation failed: ${error instanceof Error ? error.message : 'unknown error'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
file: optimizedFile,
|
||||
generatedThumbnail,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Video optimization failed for "${file.originalname ?? 'upload'}": ${
|
||||
error instanceof Error ? error.message : 'unknown error'
|
||||
}`,
|
||||
);
|
||||
throw new BadRequestException(
|
||||
'Video optimization failed. Upload an MP4/WebM video or disable server-side video processing.',
|
||||
);
|
||||
} finally {
|
||||
await rm(workingDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
private isEnabled(): boolean {
|
||||
return this.configService.get<boolean>('videoProcessing.enabled', { infer: true }) ?? false;
|
||||
}
|
||||
|
||||
private shouldGenerateThumbnails(): boolean {
|
||||
return (
|
||||
this.configService.get<boolean>('videoProcessing.generateThumbnails', { infer: true }) ?? true
|
||||
);
|
||||
}
|
||||
|
||||
private getFfmpegPath(): string {
|
||||
return (
|
||||
this.configService.get<string>('videoProcessing.ffmpegPath', { infer: true }) ?? 'ffmpeg'
|
||||
).trim();
|
||||
}
|
||||
|
||||
private getMaxWidth(): number {
|
||||
return this.configService.get<number>('videoProcessing.maxWidth', { infer: true }) ?? 1280;
|
||||
}
|
||||
|
||||
private getThumbnailWidth(): number {
|
||||
return this.configService.get<number>('videoProcessing.thumbnailWidth', { infer: true }) ?? 720;
|
||||
}
|
||||
|
||||
private getMaxFps(): number {
|
||||
return this.configService.get<number>('videoProcessing.maxFps', { infer: true }) ?? 30;
|
||||
}
|
||||
|
||||
private getCrf(): number {
|
||||
return this.configService.get<number>('videoProcessing.crf', { infer: true }) ?? 28;
|
||||
}
|
||||
|
||||
private getPreset(): string {
|
||||
return (
|
||||
this.configService.get<string>('videoProcessing.preset', { infer: true }) ?? 'veryfast'
|
||||
).trim();
|
||||
}
|
||||
|
||||
private getAudioBitrateKbps(): number {
|
||||
return (
|
||||
this.configService.get<number>('videoProcessing.audioBitrateKbps', { infer: true }) ?? 128
|
||||
);
|
||||
}
|
||||
|
||||
private buildVideoFilter(): string {
|
||||
return `scale='min(${this.getMaxWidth()},iw)':-2:force_original_aspect_ratio=decrease,fps=${this.getMaxFps()},format=yuv420p`;
|
||||
}
|
||||
|
||||
private buildThumbnailFilter(): string {
|
||||
return `scale='min(${this.getThumbnailWidth()},iw)':-2:force_original_aspect_ratio=decrease`;
|
||||
}
|
||||
|
||||
private buildOptimizedFileName(originalname?: string): string {
|
||||
const baseName = (originalname ?? 'video').replace(/\.[^.]+$/, '');
|
||||
return `${baseName}-optimized.mp4`;
|
||||
}
|
||||
|
||||
private resolveInputExtension(file: UploadedVideoFile): string {
|
||||
const extension = extname(file.originalname ?? '').toLowerCase();
|
||||
if (extension) {
|
||||
return extension;
|
||||
}
|
||||
|
||||
switch (file.mimetype) {
|
||||
case 'video/mp4':
|
||||
return '.mp4';
|
||||
case 'video/quicktime':
|
||||
return '.mov';
|
||||
case 'video/webm':
|
||||
return '.webm';
|
||||
case 'video/x-matroska':
|
||||
return '.mkv';
|
||||
case 'video/x-msvideo':
|
||||
return '.avi';
|
||||
default:
|
||||
return '.mp4';
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureFfmpegAvailable(): Promise<boolean> {
|
||||
if (this.ffmpegAvailabilityResolved) {
|
||||
return this.ffmpegAvailable;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.runFfmpeg(['-version']);
|
||||
this.ffmpegAvailable = true;
|
||||
} catch (error) {
|
||||
this.ffmpegAvailable = false;
|
||||
this.logger.warn(
|
||||
`VIDEO_PROCESSING_ENABLED is on, but ffmpeg is unavailable at "${this.getFfmpegPath()}": ${
|
||||
error instanceof Error ? error.message : 'unknown error'
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
this.ffmpegAvailabilityResolved = true;
|
||||
return this.ffmpegAvailable;
|
||||
}
|
||||
|
||||
private async runFfmpeg(args: string[]): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(this.getFfmpegPath(), args, { windowsHide: true });
|
||||
let stderr = '';
|
||||
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
reject(error);
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error(stderr.trim() || `ffmpeg exited with code ${code ?? 'unknown'}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
المرجع في مشكلة جديدة
حظر مستخدم