From a03dd91b2998ce2a0db64c1ff2755f3449a8ce1a Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Mon, 26 Jan 2026 20:34:01 -0600 Subject: [PATCH] feat(education): Add video processing service (ST4.3.4) Implement MVP video processing service for education module: - Video transcoding to multiple resolutions (1080p, 720p, 480p) - Thumbnail generation from video - Metadata extraction (duration, codec, dimensions) - Mock/placeholder implementation for MVP - Extensive TODO comments for production FFmpeg/MediaConvert integration Technical Details: - Storage integration with S3/R2 via storage.service - Returns mock metadata for MVP (can upgrade to real processing later) - Supports queueing for background processing - Clear upgrade path documented in code comments Files: - src/shared/services/video-processing.service.ts (NEW): Video processing service - src/modules/education/services/video.service.ts (UPDATED): Import processing service Status: BLOCKER-003 (ST4.3) - 67% complete Task: #9 ST4.3.4 - Backend video processing service Co-Authored-By: Claude Opus 4.5 --- .../education/services/video.service.ts | 1 + .../services/video-processing.service.ts | 319 ++++++++++++++++++ 2 files changed, 320 insertions(+) create mode 100644 src/shared/services/video-processing.service.ts diff --git a/src/modules/education/services/video.service.ts b/src/modules/education/services/video.service.ts index 785b178..50b62b8 100644 --- a/src/modules/education/services/video.service.ts +++ b/src/modules/education/services/video.service.ts @@ -7,6 +7,7 @@ import { db } from '../../../shared/database'; import { storageService } from '../../../shared/services/storage.service'; +import { videoProcessingService } from '../../../shared/services/video-processing.service'; import { logger } from '../../../shared/utils/logger'; import type { QueryResult } from 'pg'; diff --git a/src/shared/services/video-processing.service.ts b/src/shared/services/video-processing.service.ts new file mode 100644 index 0000000..e68c5c0 --- /dev/null +++ b/src/shared/services/video-processing.service.ts @@ -0,0 +1,319 @@ +// ============================================================================ +// Trading Platform - Video Processing Service +// ============================================================================ +// Video transcoding, thumbnail generation, and metadata extraction +// Blocker: BLOCKER-003 (ST4.3) +// ============================================================================ +// NOTE: This is an MVP implementation. Production should use: +// - FFmpeg for local processing +// - AWS MediaConvert for cloud processing +// - Cloudflare Stream for managed processing +// ============================================================================ + +import { storageService } from './storage.service'; +import { logger } from '../utils/logger'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface VideoMetadata { + durationSeconds: number; + width: number; + height: number; + codec: string; + bitrate: number; + fps: number; + audioCodec?: string; +} + +export interface TranscodedVersion { + resolution: string; + storageKey: string; + cdnUrl: string; + fileSizeBytes: number; + width: number; + height: number; +} + +export interface ProcessingResult { + metadata: VideoMetadata; + cdnUrl: string; + thumbnailUrl: string; + transcodedVersions: TranscodedVersion[]; +} + +export interface ProcessingOptions { + generateThumbnail?: boolean; + transcodeResolutions?: string[]; // e.g., ['1080p', '720p', '480p'] + extractMetadata?: boolean; +} + +// ============================================================================ +// Video Processing Service +// ============================================================================ + +export class VideoProcessingService { + /** + * Process video: extract metadata, generate thumbnail, transcode to multiple resolutions + * + * NOTE: This is a MOCK implementation for MVP. + * TODO: Integrate FFmpeg, AWS MediaConvert, or Cloudflare Stream + */ + async processVideo( + storageKey: string, + options: ProcessingOptions = {} + ): Promise { + const { + generateThumbnail = true, + transcodeResolutions = ['1080p', '720p', '480p'], + extractMetadata = true, + } = options; + + try { + logger.info('Starting video processing', { storageKey, options }); + + // Step 1: Extract metadata + const metadata = extractMetadata + ? await this.extractMetadata(storageKey) + : await this.mockMetadata(); + + // Step 2: Generate thumbnail + const thumbnailUrl = generateThumbnail + ? await this.generateThumbnail(storageKey) + : ''; + + // Step 3: Transcode to multiple resolutions + const transcodedVersions = await this.transcodeVideo(storageKey, transcodeResolutions); + + // Step 4: Get CDN URL for original video + const cdnUrl = storageService.getPublicUrl(storageKey); + + logger.info('Video processing completed', { + storageKey, + durationSeconds: metadata.durationSeconds, + transcodedVersions: transcodedVersions.length, + }); + + return { + metadata, + cdnUrl, + thumbnailUrl, + transcodedVersions, + }; + } catch (error) { + logger.error('Video processing failed', { error, storageKey }); + throw new Error( + `Video processing failed: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } + } + + /** + * Extract video metadata using FFmpeg (or mock for MVP) + * + * TODO: Implement with FFmpeg: + * ```bash + * ffprobe -v quiet -print_format json -show_format -show_streams input.mp4 + * ``` + */ + private async extractMetadata(storageKey: string): Promise { + logger.debug('Extracting video metadata', { storageKey }); + + // TODO: Download video from storage + // const videoBuffer = await storageService.getObject(storageKey); + + // TODO: Use FFmpeg to extract metadata + // const ffmpeg = require('fluent-ffmpeg'); + // const metadata = await new Promise((resolve, reject) => { + // ffmpeg.ffprobe(videoBuffer, (err, data) => { + // if (err) reject(err); + // else resolve(data); + // }); + // }); + + // MVP: Return mock metadata + return this.mockMetadata(); + } + + /** + * Generate thumbnail from video at 1 second mark + * + * TODO: Implement with FFmpeg: + * ```bash + * ffmpeg -i input.mp4 -ss 00:00:01 -vframes 1 -q:v 2 thumbnail.jpg + * ``` + */ + private async generateThumbnail(storageKey: string): Promise { + logger.debug('Generating video thumbnail', { storageKey }); + + // TODO: Download video from storage + // const videoBuffer = await storageService.getObject(storageKey); + + // TODO: Use FFmpeg to generate thumbnail + // const ffmpeg = require('fluent-ffmpeg'); + // const thumbnailBuffer = await new Promise((resolve, reject) => { + // ffmpeg(videoBuffer) + // .screenshots({ + // count: 1, + // timemarks: ['1'], + // size: '1280x720' + // }) + // .on('end', () => resolve(thumbnailBuffer)) + // .on('error', reject); + // }); + + // TODO: Upload thumbnail to storage + // const thumbnailKey = storageKey.replace(/\.\w+$/, '_thumb.jpg'); + // await storageService.upload({ + // key: thumbnailKey, + // body: thumbnailBuffer, + // contentType: 'image/jpeg', + // }); + + // MVP: Return placeholder thumbnail URL + const thumbnailKey = storageKey.replace(/\.\w+$/, '_thumb.jpg'); + return storageService.getPublicUrl(thumbnailKey); + } + + /** + * Transcode video to multiple resolutions + * + * TODO: Implement with FFmpeg: + * ```bash + * # 1080p + * ffmpeg -i input.mp4 -vf scale=-2:1080 -c:v libx264 -preset fast -crf 23 output_1080p.mp4 + * + * # 720p + * ffmpeg -i input.mp4 -vf scale=-2:720 -c:v libx264 -preset fast -crf 23 output_720p.mp4 + * + * # 480p + * ffmpeg -i input.mp4 -vf scale=-2:480 -c:v libx264 -preset fast -crf 23 output_480p.mp4 + * ``` + * + * TODO: Or use AWS MediaConvert: + * ```typescript + * const mediaconvert = new MediaConvert({ region: 'us-east-1' }); + * await mediaconvert.createJob({ + * Role: config.aws.mediaConvertRole, + * Settings: { + * Inputs: [{ FileInput: `s3://bucket/${storageKey}` }], + * OutputGroups: [ + * { Outputs: [{ VideoDescription: { Height: 1080 } }] }, + * { Outputs: [{ VideoDescription: { Height: 720 } }] }, + * { Outputs: [{ VideoDescription: { Height: 480 } }] }, + * ], + * }, + * }); + * ``` + * + * TODO: Or use Cloudflare Stream (simplest): + * ```typescript + * const stream = new CloudflareStream({ accountId, apiToken }); + * const video = await stream.videos.upload({ file: videoBuffer }); + * // Cloudflare automatically generates multiple resolutions + * ``` + */ + private async transcodeVideo( + storageKey: string, + resolutions: string[] + ): Promise { + logger.debug('Transcoding video', { storageKey, resolutions }); + + // TODO: Download video from storage + // const videoBuffer = await storageService.getObject(storageKey); + + // TODO: Transcode to each resolution using FFmpeg + // const transcodedVersions = []; + // for (const resolution of resolutions) { + // const height = parseInt(resolution); // e.g., '1080p' -> 1080 + // const outputKey = storageKey.replace(/\.\w+$/, `_${resolution}.mp4`); + // + // const transcodedBuffer = await this.transcodeWithFFmpeg(videoBuffer, height); + // + // await storageService.upload({ + // key: outputKey, + // body: transcodedBuffer, + // contentType: 'video/mp4', + // }); + // + // transcodedVersions.push({ + // resolution, + // storageKey: outputKey, + // cdnUrl: storageService.getPublicUrl(outputKey), + // fileSizeBytes: transcodedBuffer.length, + // width: Math.floor(height * (16/9)), // Assume 16:9 aspect ratio + // height, + // }); + // } + + // MVP: Return mock transcoded versions + return resolutions.map((resolution) => { + const height = parseInt(resolution); + const outputKey = storageKey.replace(/\.\w+$/, `_${resolution}.mp4`); + + return { + resolution, + storageKey: outputKey, + cdnUrl: storageService.getPublicUrl(outputKey), + fileSizeBytes: 10 * 1024 * 1024, // Mock: 10MB + width: Math.floor(height * (16 / 9)), + height, + }; + }); + } + + /** + * Mock metadata for MVP + */ + private mockMetadata(): VideoMetadata { + return { + durationSeconds: 120, // 2 minutes + width: 1920, + height: 1080, + codec: 'h264', + bitrate: 5000000, // 5 Mbps + fps: 30, + audioCodec: 'aac', + }; + } + + /** + * Process video in background (queue job) + * + * TODO: Integrate with job queue (Bull, BullMQ, or AWS SQS) + * ```typescript + * import { Queue } from 'bull'; + * const videoQueue = new Queue('video-processing', { + * redis: { host: 'localhost', port: 6379 }, + * }); + * + * videoQueue.add('process', { videoId, storageKey }, { + * attempts: 3, + * backoff: { type: 'exponential', delay: 60000 }, + * }); + * ``` + */ + async queueProcessing(videoId: string, storageKey: string): Promise { + logger.info('Queueing video for processing', { videoId, storageKey }); + + // TODO: Add to job queue + // await videoQueue.add('process', { videoId, storageKey }); + + // MVP: Process immediately (blocking) + // In production, this should be async via job queue + logger.warn('Processing video synchronously (should be async in production)', { + videoId, + storageKey, + }); + + // For MVP, we'll just mark the video as ready without actual processing + // The actual processing should be triggered by the video service after upload completion + } +} + +// ============================================================================ +// Default Instance +// ============================================================================ + +export const videoProcessingService = new VideoProcessingService();