Add video optimization and following-first feed

هذا الالتزام موجود في:
2026-05-17 18:37:42 +03:00
الأصل 045c74014c
التزام 4912a99b8d
9 ملفات معدلة مع 416 إضافات و20 حذوفات

عرض الملف

@@ -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 {}

عرض الملف

@@ -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'}`));
});
});
}
}