--- id: "ET-EDU-004" title: "Video Streaming System" type: "Specification" status: "Done" rf_parent: "RF-EDU-002" epic: "OQI-002" version: "1.0" created_date: "2025-12-05" updated_date: "2026-01-04" --- # ET-EDU-004: Sistema de Streaming de Video **Versión:** 1.0.0 **Fecha:** 2025-12-05 **Épica:** OQI-002 - Módulo Educativo **Componente:** Backend/Infraestructura --- ## Descripción Define la arquitectura de streaming de video para el módulo educativo, incluyendo integración con CDN externo (Vimeo/AWS S3+CloudFront), configuración del player, tracking de progreso, adaptive bitrate streaming, subtítulos y controles de seguridad. --- ## Arquitectura ``` ┌─────────────────────────────────────────────────────────────────┐ │ Video Streaming Architecture │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ ┌──────────────┐ │ │ │ Frontend │ │ │ │ React Player │ │ │ └──────┬───────┘ │ │ │ │ │ │ 1. Request signed URL │ │ v │ │ ┌──────────────────────────────────────┐ │ │ │ Backend API │ │ │ │ - Verify enrollment │ │ │ │ - Generate signed URL │ │ │ │ - Track progress │ │ │ └──────┬──────────────┬────────────────┘ │ │ │ │ │ │ │ │ 2. Get video metadata │ │ │ v │ │ │ ┌──────────────┐ │ │ │ │ PostgreSQL │ │ │ │ │ (lessons) │ │ │ │ └──────────────┘ │ │ │ │ │ │ 3. Return signed URL │ │ v │ │ ┌──────────────┐ │ │ │ Frontend │ │ │ └──────┬───────┘ │ │ │ │ │ │ 4. Request video stream │ │ v │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ CDN Layer │ │ │ │ │ │ │ │ ┌──────────────┐ ┌─────────────────┐ │ │ │ │ │ Vimeo │ OR │ AWS CloudFront │ │ │ │ │ │ - HLS/DASH │ │ - HLS/DASH │ │ │ │ │ │ - Adaptive │ │ - Adaptive │ │ │ │ │ │ - DRM │ │ - Signed URLs │ │ │ │ │ └──────────────┘ └────────┬────────┘ │ │ │ │ │ │ │ │ │ v │ │ │ │ ┌──────────────┐ │ │ │ │ │ AWS S3 │ │ │ │ │ │ (Video files)│ │ │ │ │ └──────────────┘ │ │ │ └─────────────────────────────────────────────────────┘ │ │ │ │ │ │ 5. Stream video chunks │ │ v │ │ ┌──────────────┐ │ │ │ Frontend │ │ │ │ Video Player │ │ │ │ - Playback │ │ │ │ - Progress │ │ │ │ - Quality │ │ │ └──────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────┘ ``` --- ## Especificación Detallada ### 1. VIMEO INTEGRATION #### Configuración de Cuenta ```yaml Vimeo Plan: Pro o Business Features Required: - Video Privacy (Domain-level) - Embed Privacy Controls - API Access - Analytics - Custom Player Colors - No Vimeo branding (Business plan) ``` #### Upload de Videos ```typescript // services/vimeo/upload.service.ts import { Vimeo } from '@vimeo/vimeo'; export class VimeoUploadService { private client: Vimeo; constructor() { this.client = new Vimeo( process.env.VIMEO_CLIENT_ID!, process.env.VIMEO_CLIENT_SECRET!, process.env.VIMEO_ACCESS_TOKEN! ); } async uploadVideo( filePath: string, metadata: { name: string; description: string; privacy: { view: 'disable' | 'unlisted' | 'password'; embed: 'private' | 'public' | 'whitelist'; }; } ): Promise { return new Promise((resolve, reject) => { this.client.upload( filePath, { name: metadata.name, description: metadata.description, privacy: metadata.privacy }, (uri) => { // Get video details this.client.request(uri, (error, body) => { if (error) { reject(error); } else { resolve({ id: body.uri.split('/').pop(), uri: body.uri, link: body.link, duration: body.duration, embed: body.embed, player_embed_url: body.player_embed_url }); } }); }, (bytesUploaded, bytesTotal) => { const percentage = ((bytesUploaded / bytesTotal) * 100).toFixed(2); console.log(`Upload progress: ${percentage}%`); }, (error) => { reject(error); } ); }); } async updateVideoPrivacy( videoId: string, domains: string[] ): Promise { await this.client.request( `/videos/${videoId}`, { method: 'PATCH', query: { privacy: { embed: 'whitelist', view: 'disable' }, embed: { buttons: { like: false, watchlater: false, share: false, embed: false }, logos: { vimeo: false }, title: { name: 'hide', owner: 'hide', portrait: 'hide' } }, embed_domains: domains } } ); } async getVideoAnalytics(videoId: string): Promise { const response = await this.client.request( `/videos/${videoId}/analytics` ); return response; } async deleteVideo(videoId: string): Promise { await this.client.request(`/videos/${videoId}`, { method: 'DELETE' }); } } interface VimeoVideo { id: string; uri: string; link: string; duration: number; embed: any; player_embed_url: string; } interface VimeoAnalytics { total_plays: number; total_impressions: number; average_percent_watched: number; total_time_watched: number; } ``` #### Player Embed ```typescript // components/video/VimeoPlayer.tsx import React, { useRef, useEffect } from 'react'; import Player from '@vimeo/player'; interface VimeoPlayerProps { videoId: string; onProgress: (data: { seconds: number; percent: number }) => void; onComplete: () => void; initialTime?: number; } export const VimeoPlayer: React.FC = ({ videoId, onProgress, onComplete, initialTime = 0 }) => { const containerRef = useRef(null); const playerRef = useRef(null); useEffect(() => { if (!containerRef.current) return; const player = new Player(containerRef.current, { id: videoId, responsive: true, byline: false, portrait: false, title: false, speed: true, playsinline: true, controls: true, autopause: true, quality: 'auto' }); playerRef.current = player; // Set initial time if (initialTime > 0) { player.setCurrentTime(initialTime); } // Progress tracking player.on('timeupdate', (data) => { onProgress({ seconds: data.seconds, percent: data.percent * 100 }); }); // Completion tracking player.on('ended', () => { onComplete(); }); return () => { player.destroy(); }; }, [videoId]); return (
); }; ``` --- ### 2. AWS S3 + CLOUDFRONT INTEGRATION #### S3 Bucket Configuration ```yaml Bucket Configuration: Name: trading-videos-prod Region: us-east-1 Versioning: Enabled Encryption: AES-256 (SSE-S3) Block Public Access: All enabled CORS: Configured for CloudFront Lifecycle Rules: - Incomplete multipart uploads: Delete after 7 days - Transition to IA: After 90 days (opcional) Folder Structure: /courses/{course-id}/ /modules/{module-id}/ /lessons/{lesson-id}/ - video.m3u8 (HLS manifest) - video_1080p.m3u8 - video_720p.m3u8 - video_480p.m3u8 - video_360p.m3u8 - segments/ - segment_0.ts - segment_1.ts - ... - thumbnails/ - thumb_0.jpg - thumb_1.jpg - ... ``` #### CloudFront Distribution ```typescript // infrastructure/cloudfront-config.ts export const cloudFrontConfig = { distribution: { enabled: true, comment: 'Trading Platform Video CDN', origins: [ { id: 'S3-trading-videos', domainName: 'trading-videos-prod.s3.amazonaws.com', s3OriginConfig: { originAccessIdentity: 'origin-access-identity/cloudfront/XXXXX' } } ], defaultCacheBehavior: { targetOriginId: 'S3-trading-videos', viewerProtocolPolicy: 'redirect-to-https', allowedMethods: ['GET', 'HEAD', 'OPTIONS'], cachedMethods: ['GET', 'HEAD'], compress: true, forwardedValues: { queryString: true, cookies: { forward: 'none' }, headers: ['Origin', 'Access-Control-Request-Headers', 'Access-Control-Request-Method'] }, minTTL: 0, defaultTTL: 86400, // 24 hours maxTTL: 31536000, // 1 year trustedSigners: { enabled: true, items: [process.env.AWS_ACCOUNT_ID] } }, priceClass: 'PriceClass_100', // US, Canada, Europe restrictions: { geoRestriction: { restrictionType: 'none' } }, viewerCertificate: { acmCertificateArn: process.env.ACM_CERTIFICATE_ARN, sslSupportMethod: 'sni-only', minimumProtocolVersion: 'TLSv1.2_2021' } } }; ``` #### Signed URLs Generation ```typescript // services/video/cloudfront.service.ts import { getSignedUrl } from '@aws-sdk/cloudfront-signer'; export class CloudFrontService { private readonly distributionDomain: string; private readonly keyPairId: string; private readonly privateKey: string; constructor() { this.distributionDomain = process.env.CLOUDFRONT_DOMAIN!; this.keyPairId = process.env.CLOUDFRONT_KEY_PAIR_ID!; this.privateKey = process.env.CLOUDFRONT_PRIVATE_KEY!; } generateSignedUrl( videoPath: string, expiresIn: number = 3600 // 1 hour default ): string { const url = `https://${this.distributionDomain}/${videoPath}`; const expirationTime = Math.floor(Date.now() / 1000) + expiresIn; return getSignedUrl({ url, keyPairId: this.keyPairId, privateKey: this.privateKey, dateLessThan: new Date(expirationTime * 1000).toISOString() }); } generateSignedCookies( resourcePath: string, expiresIn: number = 3600 ): { 'CloudFront-Policy': string; 'CloudFront-Signature': string; 'CloudFront-Key-Pair-Id': string; } { const policy = this.createPolicy(resourcePath, expiresIn); const signature = this.signPolicy(policy); return { 'CloudFront-Policy': policy, 'CloudFront-Signature': signature, 'CloudFront-Key-Pair-Id': this.keyPairId }; } private createPolicy(resourcePath: string, expiresIn: number): string { const expirationTime = Math.floor(Date.now() / 1000) + expiresIn; const policy = { Statement: [ { Resource: `https://${this.distributionDomain}/${resourcePath}*`, Condition: { DateLessThan: { 'AWS:EpochTime': expirationTime } } } ] }; return Buffer.from(JSON.stringify(policy)).toString('base64'); } private signPolicy(policy: string): string { const crypto = require('crypto'); const sign = crypto.createSign('RSA-SHA1'); sign.update(policy); return sign.sign(this.privateKey, 'base64'); } } ``` #### HLS Player (Video.js) ```typescript // components/video/HLSPlayer.tsx import React, { useRef, useEffect } from 'react'; import videojs from 'video.js'; import 'video.js/dist/video-js.css'; import 'videojs-contrib-quality-levels'; import 'videojs-hls-quality-selector'; interface HLSPlayerProps { src: string; // Signed URL to .m3u8 poster?: string; onProgress: (data: { seconds: number; percent: number }) => void; onComplete: () => void; initialTime?: number; } export const HLSPlayer: React.FC = ({ src, poster, onProgress, onComplete, initialTime = 0 }) => { const videoRef = useRef(null); const playerRef = useRef(null); useEffect(() => { if (!videoRef.current) return; const player = videojs(videoRef.current, { controls: true, responsive: true, fluid: true, poster, playbackRates: [0.5, 0.75, 1, 1.25, 1.5, 2], controlBar: { children: [ 'playToggle', 'volumePanel', 'currentTimeDisplay', 'timeDivider', 'durationDisplay', 'progressControl', 'qualitySelector', 'playbackRateMenuButton', 'fullscreenToggle' ] }, html5: { vhs: { overrideNative: true, enableLowInitialPlaylist: true, smoothQualityChange: true } } }); playerRef.current = player; // Load source player.src({ src, type: 'application/x-mpegURL' }); // Set initial time if (initialTime > 0) { player.one('loadedmetadata', () => { player.currentTime(initialTime); }); } // Progress tracking (every second) let lastUpdate = 0; player.on('timeupdate', () => { const currentTime = player.currentTime(); const duration = player.duration(); if (currentTime - lastUpdate >= 1) { lastUpdate = currentTime; onProgress({ seconds: Math.floor(currentTime), percent: (currentTime / duration) * 100 }); } }); // Completion tracking player.on('ended', () => { onComplete(); }); return () => { if (playerRef.current) { playerRef.current.dispose(); } }; }, [src]); return (
); }; ``` --- ### 3. VIDEO TRANSCODING #### FFmpeg Transcoding Pipeline ```typescript // services/video/transcode.service.ts import ffmpeg from 'fluent-ffmpeg'; import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; import { createReadStream } from 'fs'; export class VideoTranscodeService { private s3Client: S3Client; constructor() { this.s3Client = new S3Client({ region: process.env.AWS_REGION!, credentials: { accessKeyId: process.env.AWS_ACCESS_KEY_ID!, secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY! } }); } async transcodeToHLS( inputPath: string, outputDir: string, lessonId: string ): Promise { const qualities = [ { name: '1080p', height: 1080, bitrate: '5000k', audioBitrate: '192k' }, { name: '720p', height: 720, bitrate: '2800k', audioBitrate: '128k' }, { name: '480p', height: 480, bitrate: '1400k', audioBitrate: '128k' }, { name: '360p', height: 360, bitrate: '800k', audioBitrate: '96k' } ]; // Create variant playlists const variantPromises = qualities.map((quality) => this.createVariant(inputPath, outputDir, quality) ); await Promise.all(variantPromises); // Create master playlist await this.createMasterPlaylist(outputDir, qualities); // Upload to S3 await this.uploadToS3(outputDir, lessonId); } private async createVariant( inputPath: string, outputDir: string, quality: any ): Promise { return new Promise((resolve, reject) => { ffmpeg(inputPath) .outputOptions([ '-c:v libx264', '-c:a aac', `-b:v ${quality.bitrate}`, `-b:a ${quality.audioBitrate}`, `-vf scale=-2:${quality.height}`, '-preset medium', '-profile:v main', '-level 4.0', '-start_number 0', '-hls_time 6', '-hls_list_size 0', '-hls_segment_filename', `${outputDir}/segments/${quality.name}_%03d.ts`, '-f hls' ]) .output(`${outputDir}/video_${quality.name}.m3u8`) .on('end', resolve) .on('error', reject) .run(); }); } private async createMasterPlaylist( outputDir: string, qualities: any[] ): Promise { let masterPlaylist = '#EXTM3U\n#EXT-X-VERSION:3\n'; qualities.forEach((quality) => { const bandwidth = parseInt(quality.bitrate) * 1000; masterPlaylist += `#EXT-X-STREAM-INF:BANDWIDTH=${bandwidth},RESOLUTION=1920x${quality.height}\n`; masterPlaylist += `video_${quality.name}.m3u8\n`; }); const fs = require('fs').promises; await fs.writeFile(`${outputDir}/video.m3u8`, masterPlaylist); } private async uploadToS3( localDir: string, lessonId: string ): Promise { // Implementation to upload all files to S3 // ... } async generateThumbnails( videoPath: string, count: number = 10 ): Promise { const thumbnails: string[] = []; const duration = await this.getVideoDuration(videoPath); const interval = duration / count; for (let i = 0; i < count; i++) { const timestamp = i * interval; const outputPath = `/tmp/thumb_${i}.jpg`; await this.extractFrame(videoPath, timestamp, outputPath); thumbnails.push(outputPath); } return thumbnails; } private async getVideoDuration(videoPath: string): Promise { return new Promise((resolve, reject) => { ffmpeg.ffprobe(videoPath, (err, metadata) => { if (err) reject(err); else resolve(metadata.format.duration || 0); }); }); } private async extractFrame( videoPath: string, timestamp: number, outputPath: string ): Promise { return new Promise((resolve, reject) => { ffmpeg(videoPath) .seekInput(timestamp) .frames(1) .output(outputPath) .on('end', resolve) .on('error', reject) .run(); }); } } ``` --- ### 4. PROGRESS TRACKING #### Backend Endpoint ```typescript // controllers/video-progress.controller.ts import { Request, Response } from 'express'; import { VideoProgressService } from '@/services/video-progress.service'; export class VideoProgressController { private progressService: VideoProgressService; constructor() { this.progressService = new VideoProgressService(); } async updateProgress(req: Request, res: Response): Promise { try { const userId = req.user.id; const { lesson_id, enrollment_id, last_position_seconds, watch_percentage } = req.body; // Validate enrollment const isEnrolled = await this.progressService.verifyEnrollment( userId, enrollment_id ); if (!isEnrolled) { res.status(403).json({ success: false, error: { code: 'NOT_ENROLLED', message: 'No estás inscrito en este curso' } }); return; } // Update progress const progress = await this.progressService.updateProgress({ user_id: userId, lesson_id, enrollment_id, last_position_seconds, watch_percentage }); res.json({ success: true, data: progress }); } catch (error) { console.error('Error updating video progress:', error); res.status(500).json({ success: false, error: { code: 'INTERNAL_ERROR', message: 'Error al actualizar progreso' } }); } } async getVideoUrl(req: Request, res: Response): Promise { try { const userId = req.user.id; const { lessonId } = req.params; // Verify access const hasAccess = await this.progressService.verifyLessonAccess( userId, lessonId ); if (!hasAccess) { res.status(403).json({ success: false, error: { code: 'ACCESS_DENIED', message: 'No tienes acceso a esta lección' } }); return; } // Get lesson video info const lesson = await this.progressService.getLesson(lessonId); // Generate signed URL const cloudFront = new CloudFrontService(); const signedUrl = cloudFront.generateSignedUrl( lesson.video_path, 3600 // 1 hour ); res.json({ success: true, data: { video_url: signedUrl, expires_in: 3600 } }); } catch (error) { console.error('Error getting video URL:', error); res.status(500).json({ success: false, error: { code: 'INTERNAL_ERROR', message: 'Error al obtener URL del video' } }); } } } ``` --- ### 5. SUBTITLES & CAPTIONS #### WebVTT Format ```vtt WEBVTT 00:00:00.000 --> 00:00:03.500 Bienvenidos al curso de trading avanzado. 00:00:03.500 --> 00:00:07.000 En esta lección aprenderemos sobre análisis técnico. 00:00:07.000 --> 00:00:11.500 El análisis técnico es fundamental para identificar oportunidades en el mercado. ``` #### Subtitles Storage ```typescript // S3 folder structure for subtitles /courses/{course-id}/modules/{module-id}/lessons/{lesson-id}/ /subtitles/ - es.vtt (Español) - en.vtt (English) - pt.vtt (Português) ``` #### Player with Subtitles ```typescript // Add to HLS Player player.src({ src: videoUrl, type: 'application/x-mpegURL' }); // Add text tracks const tracks = [ { src: 'subtitles/es.vtt', srclang: 'es', label: 'Español', kind: 'subtitles' }, { src: 'subtitles/en.vtt', srclang: 'en', label: 'English', kind: 'subtitles' } ]; tracks.forEach(track => { player.addRemoteTextTrack(track, false); }); ``` --- ## Interfaces/Tipos ```typescript export interface VideoMetadata { id: string; provider: 'vimeo' | 's3'; video_url: string; video_id?: string; duration_seconds: number; qualities: VideoQuality[]; thumbnails: string[]; subtitles: Subtitle[]; } export interface VideoQuality { name: string; height: number; bitrate: string; url: string; } export interface Subtitle { language: string; label: string; url: string; } export interface VideoProgress { lesson_id: string; user_id: string; last_position_seconds: number; watch_percentage: number; total_watch_time_seconds: number; is_completed: boolean; } ``` --- ## Configuración ### Variables de Entorno ```bash # Vimeo VIMEO_CLIENT_ID=xxxxx VIMEO_CLIENT_SECRET=xxxxx VIMEO_ACCESS_TOKEN=xxxxx # AWS S3 AWS_ACCESS_KEY_ID=xxxxx AWS_SECRET_ACCESS_KEY=xxxxx AWS_REGION=us-east-1 AWS_S3_BUCKET=trading-videos-prod # CloudFront CLOUDFRONT_DOMAIN=d1234abcd.cloudfront.net CLOUDFRONT_KEY_PAIR_ID=APKAXXXXXXXX CLOUDFRONT_PRIVATE_KEY=-----BEGIN RSA PRIVATE KEY-----... # Video Settings VIDEO_SIGNED_URL_EXPIRY=3600 VIDEO_MAX_QUALITY=1080p VIDEO_DEFAULT_QUALITY=720p ``` --- ## Dependencias ```json { "dependencies": { "@vimeo/vimeo": "^2.2.0", "@vimeo/player": "^2.20.1", "@aws-sdk/client-s3": "^3.490.0", "@aws-sdk/cloudfront-signer": "^3.490.0", "fluent-ffmpeg": "^2.1.2", "video.js": "^8.6.1", "videojs-contrib-quality-levels": "^3.0.0", "videojs-hls-quality-selector": "^1.1.1" }, "devDependencies": { "@types/fluent-ffmpeg": "^2.1.24" } } ``` --- ## Consideraciones de Seguridad ### 1. Signed URLs - Todas las URLs de video DEBEN ser firmadas con expiración corta (1 hora) - Verificar enrollment antes de generar signed URL - Nunca exponer URLs directas de S3/Vimeo ### 2. Domain Whitelisting ```typescript // Para Vimeo const allowedDomains = [ 'trading.ai', 'app.trading.ai', 'localhost:3000' // Solo dev ]; ``` ### 3. Rate Limiting ```typescript // Limitar requests de video URLs app.post( '/video-url', rateLimit({ windowMs: 60 * 1000, max: 30 // 30 requests por minuto por usuario }), videoController.getVideoUrl ); ``` ### 4. Watermarking (Opcional) ```typescript // FFmpeg watermark ffmpeg(inputPath) .input('logo.png') .complexFilter([ '[0:v][1:v]overlay=W-w-10:H-h-10:enable=\'between(t,0,10)\'' ]) .output(outputPath); ``` --- ## Testing ### Load Testing ```typescript // Test concurrent video streams import loadtest from 'loadtest'; const options = { url: 'https://api.trading.ai/v1/education/lessons/xxx/video-url', maxRequests: 1000, concurrency: 100, headers: { 'Authorization': 'Bearer test-token' } }; loadtest.loadTest(options, (error, results) => { console.log('Total requests:', results.totalRequests); console.log('Errors:', results.totalErrors); console.log('RPS:', results.rps); }); ``` ### Video Quality Tests - Verificar adaptive bitrate switching - Verificar calidad de transcoding - Verificar sincronización de subtítulos - Verificar reproducción en diferentes dispositivos/navegadores --- **Fin de Especificación ET-EDU-004**