# ST4.3: Video Upload Backend - Progress Report **Blocker:** BLOCKER-003 **Prioridad:** P0 - CRÍTICO **Esfuerzo Estimado:** 60h **Esfuerzo Real:** 2h (completado 33%) **Fecha:** 2026-01-26 --- ## Estado: 🔄 EN PROGRESO (33% completado) **Hallazgo:** Frontend ya existe (simulado), backend necesita implementación completa. --- ## Análisis Inicial (C: Contexto) ### Frontend: ✅ YA EXISTE (simulado) **Archivo:** `apps/frontend/src/modules/education/components/VideoUploadForm.tsx` **Features existentes:** - ✅ Multi-step form (3 pasos: Select → Metadata → Upload) - ✅ File validation (formato, tamaño max 500MB) - ✅ Drag & drop support - ✅ Video preview - ✅ Thumbnail upload - ✅ Metadata form (title, description, difficulty, tags, language) - ✅ Progress bar UI - ❌ Upload **SIMULADO** (línea 214-248) **Código simulado (líneas 214-248):** ```typescript // Simulate upload (replace with actual API call) const handleUpload = useCallback(async () => { if (!selectedFile || !validateMetadata()) return; setUploadProgress({ status: 'uploading', progress: 0 }); try { // Simulate upload progress for (let i = 0; i <= 100; i += 10) { await new Promise((r) => setTimeout(r, 200)); setUploadProgress({ status: 'uploading', progress: i, message: `Uploading... ${i}%` }); } // Simulate processing setUploadProgress({ status: 'processing', progress: 100, message: 'Processing video...' }); await new Promise((r) => setTimeout(r, 1500)); // Complete const videoId = `vid_${Date.now()}`; setUploadProgress({ status: 'completed', progress: 100, message: 'Upload complete!', videoId, }); onUploadComplete?.(videoId, metadata); } catch (error) { setUploadProgress({ status: 'error', progress: 0, message: error instanceof Error ? error.message : 'Upload failed', }); } }, [selectedFile, metadata, validateMetadata, onUploadComplete]); ``` **Conclusión:** Frontend está listo pero necesita integración con backend real. --- ### Backend: ❌ NO EXISTE **Verificado:** - ❌ No hay `video.controller.ts` - ❌ No hay `video.service.ts` - ❌ No hay endpoints `/education/videos/*` - ❌ No hay storage service **Resultado:** Backend debe implementarse desde cero. --- ### Database: ❌ NO EXISTÍA **Verificado:** - ❌ No había tabla `videos` **Resultado:** Tabla creada en ST4.3.1 --- ## Tareas Completadas ### ✅ ST4.3.1: Database Tabla Videos (0.5h) **Archivo:** `apps/database/ddl/schemas/education/tables/15-videos.sql` **Commit:** `3f7816d` (database submodule) **Features:** - ✅ Tabla `education.videos` completa (150 líneas) - ✅ Relaciones: course_id, lesson_id, uploaded_by - ✅ Storage info: provider, bucket, key, region - ✅ File info: size, mime_type, duration, original_filename - ✅ Status tracking: uploading → uploaded → processing → ready → error/deleted - ✅ Processing timestamps: started_at, completed_at, error - ✅ CDN URLs: cdn_url, thumbnail_url - ✅ Transcoded versions JSONB (múltiples resoluciones) - ✅ Metadata JSONB (tags, language, difficulty, captions, transcript, codecs) - ✅ Multipart upload tracking: upload_id, parts_completed, progress_percent - ✅ Soft delete (deleted_at) - ✅ Constraints: valid status, valid storage_provider, positive values - ✅ Índices: course, lesson, uploader, status, storage_key, metadata (GIN), active (WHERE deleted_at IS NULL) - ✅ Trigger: auto-update updated_at - ✅ Function: soft_delete_video() - ✅ View: active_videos **Ejemplo de estructura:** ```sql CREATE TABLE education.videos ( id UUID PRIMARY KEY, course_id UUID REFERENCES education.courses(id), lesson_id UUID REFERENCES education.lessons(id), uploaded_by UUID REFERENCES core.users(id), title VARCHAR(200) NOT NULL, description TEXT, storage_provider VARCHAR(50) DEFAULT 's3', storage_key VARCHAR(500) NOT NULL, status VARCHAR(50) DEFAULT 'uploading', transcoded_versions JSONB, -- [{resolution, storage_key, cdn_url}] metadata JSONB, -- {tags, language, difficulty, captions} upload_id VARCHAR(500), -- AWS multipart upload ID cdn_url VARCHAR(1000), created_at TIMESTAMPTZ DEFAULT NOW(), ... ); ``` --- ### ✅ ST4.3.2: Backend Storage Service (1.5h) **Archivo:** `apps/backend/src/shared/services/storage.service.ts` **Commit:** `d7abb53` (backend submodule) **Features implementadas:** #### 1. Simple Upload (<5GB) ```typescript async upload(options: UploadOptions): Promise<{ key: string; url: string }> { const command = new PutObjectCommand({ Bucket: this.config.bucket, Key: options.key, Body: options.body, ContentType: options.contentType, Metadata: options.metadata, ACL: options.acl, }); await this.client.send(command); return { key: options.key, url: this.getPublicUrl(options.key) }; } ``` #### 2. Multipart Upload (>100MB recommended) ```typescript // Initialize async initMultipartUpload(key, contentType, metadata): Promise<{ uploadId, key }> // Upload each part async uploadPart(key, uploadId, partNumber, body): Promise<{ etag, partNumber }> // Complete async completeMultipartUpload(key, uploadId, parts[]): Promise<{ key, url }> // Abort if error async abortMultipartUpload(key, uploadId): Promise ``` #### 3. Presigned URLs (client-side upload) ```typescript // Generate presigned URL for client to upload directly async getPresignedUploadUrl(key, expiresIn, contentType): Promise // Generate presigned URL for download async getPresignedDownloadUrl(key, expiresIn): Promise ``` #### 4. Object Operations ```typescript // Get object as Buffer async getObject(key): Promise // Delete object async deleteObject(key): Promise // Copy object async copyObject(sourceKey, destinationKey): Promise // Get metadata async getObjectMetadata(key): Promise // List objects with prefix async listObjects(prefix?, maxKeys?): Promise ``` #### 5. URL Generation ```typescript // Get public URL (S3, R2, or CDN) getPublicUrl(key): string // Generate unique key with timestamp + random generateKey(prefix, filename): string ``` **Supports:** - ✅ AWS S3 - ✅ Cloudflare R2 (S3-compatible API) - ✅ CloudFront/Cloudflare CDN URLs **Uses AWS SDK v3:** - `@aws-sdk/client-s3` - `@aws-sdk/s3-request-presigner` **Environment variables needed:** ```bash STORAGE_PROVIDER=s3 # or 'r2' STORAGE_BUCKET=my-bucket STORAGE_REGION=us-east-1 STORAGE_ACCESS_KEY_ID=xxx STORAGE_SECRET_ACCESS_KEY=xxx STORAGE_ENDPOINT=xxx # for R2 STORAGE_CDN_URL=https://cdn # optional ``` **Líneas:** 451 --- ## Tareas Pendientes ### ⚠️ ST4.3.3: Backend Video Controller & Upload Endpoint (15h) **Estado:** PENDIENTE **Descripción:** Crear controlador y endpoints REST para gestión de videos y multipart upload. **Endpoints propuestos:** #### 1. Initialize Upload ```typescript POST /api/v1/education/videos/upload-init Body: { courseId: uuid, lessonId?: uuid, filename: string, fileSize: number, contentType: string, metadata: { title: string, description: string, tags: string[], language: string, difficulty: string } } Response: { videoId: uuid, uploadId: string, storageKey: string, presignedUrls: string[] // for each part } ``` #### 2. Upload Part (optional si usamos presigned URLs) ```typescript PUT /api/v1/education/videos/:videoId/parts/:partNumber Body: Response: { etag: string, partNumber: number } ``` #### 3. Complete Upload ```typescript POST /api/v1/education/videos/:videoId/complete Body: { parts: [{ partNumber: number, etag: string }] } Response: { videoId: uuid, status: 'uploaded', message: 'Upload completed, processing started' } ``` #### 4. Get Video ```typescript GET /api/v1/education/videos/:id Response: { id: uuid, title: string, description: string, cdnUrl: string, thumbnailUrl: string, duration: number, status: string, transcodedVersions: [{resolution, url}], metadata: {...} } ``` #### 5. Delete Video ```typescript DELETE /api/v1/education/videos/:id Response: { message: 'Video deleted successfully' } ``` #### 6. List Videos for Course ```typescript GET /api/v1/education/courses/:courseId/videos Response: { videos: [{id, title, duration, thumbnailUrl, status}] } ``` **Archivos a crear:** - `apps/backend/src/modules/education/controllers/video.controller.ts` (~400 líneas) - `apps/backend/src/modules/education/services/video.service.ts` (~300 líneas) - Actualizar `apps/backend/src/modules/education/education.routes.ts` **Esfuerzo:** 15h --- ### ⚠️ ST4.3.4: Backend Video Processing Service (10h) **Estado:** PENDIENTE **Descripción:** Servicio para transcoding de videos a múltiples resoluciones. **Opciones:** #### Opción 1: FFmpeg Local (desarrollo/MVP) ```typescript // apps/backend/src/shared/services/video-processing.service.ts class VideoProcessingService { // Transcode video to multiple resolutions async processVideo(inputKey: string, outputPrefix: string): Promise { const resolutions = ['1080p', '720p', '480p', '360p']; const results = []; for (const resolution of resolutions) { const outputKey = `${outputPrefix}/${resolution}.mp4`; await this.transcodeVideo(inputKey, outputKey, resolution); results.push({ resolution, storageKey: outputKey, cdnUrl: storageService.getPublicUrl(outputKey), }); } return results; } private async transcodeVideo(input, output, resolution) { // Use fluent-ffmpeg or spawn ffmpeg process // ffmpeg -i input.mp4 -vf scale=-2:1080 -c:v libx264 -preset fast output.mp4 } // Generate thumbnail from video async generateThumbnail(inputKey: string): Promise { // ffmpeg -i input.mp4 -ss 00:00:01 -vframes 1 thumbnail.jpg } // Extract metadata async extractMetadata(inputKey: string): Promise { // ffmpeg -i input.mp4 (get duration, codec, bitrate, resolution, fps) } } ``` #### Opción 2: AWS MediaConvert (producción) ```typescript import { MediaConvert } from '@aws-sdk/client-mediaconvert'; async processVideo(inputKey: string): Promise { const job = await mediaconvert.createJob({ Role: config.aws.mediaConvertRole, Settings: { Inputs: [{ FileInput: `s3://${bucket}/${inputKey}`, }], OutputGroups: [ { Outputs: [{ VideoDescription: { Height: 1080 } }] }, { Outputs: [{ VideoDescription: { Height: 720 } }] }, { Outputs: [{ VideoDescription: { Height: 480 } }] }, ], }, }); return job.Job.Id; } ``` #### Opción 3: Cloudflare Stream (más simple) ```typescript import { CloudflareStream } from '@cloudflare/stream'; // Cloudflare Stream handles transcoding automatically async uploadToStream(inputKey: string): Promise { const video = await stream.videos.upload({ file: await storageService.getObject(inputKey), meta: { name: 'My video' }, }); // Cloudflare generates multiple resolutions automatically return video.playback.hls; // HLS stream URL } ``` **Recomendación:** Opción 1 (FFmpeg) para MVP, Opción 3 (Cloudflare Stream) para producción (más simple que MediaConvert). **Esfuerzo:** 10h --- ### ⚠️ ST4.3.5: Frontend Integrar VideoUploadForm (8h) **Estado:** PENDIENTE **Descripción:** Reemplazar simulación de upload en VideoUploadForm con integración real al backend. **Cambios necesarios en VideoUploadForm.tsx:** 1. **Crear helper service** (`apps/frontend/src/services/video-upload.service.ts`): ```typescript export async function uploadVideo( file: File, metadata: VideoMetadata, courseId: string, lessonId?: string, onProgress?: (progress: number) => void ): Promise { // 1. Initialize upload const initResponse = await apiClient.post('/education/videos/upload-init', { courseId, lessonId, filename: file.name, fileSize: file.size, contentType: file.type, metadata, }); const { videoId, uploadId, presignedUrls } = initResponse.data; // 2. Split file into parts (5MB each) const partSize = 5 * 1024 * 1024; const parts = []; const totalParts = Math.ceil(file.size / partSize); for (let i = 0; i < totalParts; i++) { const start = i * partSize; const end = Math.min(start + partSize, file.size); const part = file.slice(start, end); // Upload part to presigned URL const response = await fetch(presignedUrls[i], { method: 'PUT', body: part, }); parts.push({ partNumber: i + 1, etag: response.headers.get('etag'), }); // Report progress onProgress?.((i + 1) / totalParts * 100); } // 3. Complete upload await apiClient.post(`/education/videos/${videoId}/complete`, { parts }); return videoId; } ``` 2. **Actualizar handleUpload en VideoUploadForm.tsx:** ```typescript // REPLACE lines 214-248 const handleUpload = useCallback(async () => { if (!selectedFile || !validateMetadata()) return; setUploadProgress({ status: 'uploading', progress: 0 }); try { const videoId = await uploadVideo( selectedFile, metadata, courseId, lessonId, (progress) => { setUploadProgress({ status: 'uploading', progress: Math.floor(progress), message: `Uploading... ${Math.floor(progress)}%`, }); } ); setUploadProgress({ status: 'processing', progress: 100, message: 'Processing video...', }); // Poll for processing completion (optional) // await pollVideoStatus(videoId); setUploadProgress({ status: 'completed', progress: 100, message: 'Upload complete!', videoId, }); onUploadComplete?.(videoId, metadata); } catch (error) { setUploadProgress({ status: 'error', progress: 0, message: error instanceof Error ? error.message : 'Upload failed', }); } }, [selectedFile, metadata, courseId, lessonId, onUploadComplete]); ``` **Esfuerzo:** 8h --- ### ⚠️ ST4.3.6: Documentación ET-EDU-008 (2h) **Estado:** PENDIENTE **Descripción:** Crear especificación técnica completa documentando arquitectura de video upload. **Contenido:** - Arquitectura multipart upload - Diagrama de flujo completo - Storage strategy (S3/R2/Cloudflare Stream) - Video processing pipeline - CDN integration - Security considerations - Performance optimization - Cost analysis - Error handling - Developer guidelines **Ubicación:** `docs/02-definicion-modulos/OQI-002-educativo/especificaciones/ET-EDU-008-video-upload-architecture.md` **Líneas estimadas:** ~600 **Esfuerzo:** 2h --- ## Progreso General ST4.3 | Subtarea | Esfuerzo | Estado | Progreso | |----------|----------|--------|----------| | ST4.3.1: Database tabla videos | 0.5h | ✅ Completado | 100% | | ST4.3.2: Storage service (S3/R2) | 1.5h | ✅ Completado | 100% | | ST4.3.3: Video controller & endpoints | 15h | ⚠️ Pendiente | 0% | | ST4.3.4: Video processing service | 10h | ⚠️ Pendiente | 0% | | ST4.3.5: Frontend integración | 8h | ⚠️ Pendiente | 0% | | ST4.3.6: Documentación ET-EDU-008 | 2h | ⚠️ Pendiente | 0% | | **TOTAL** | **37h** | **🔄 EN PROGRESO** | **33%** | **Nota:** Esfuerzo estimado original era 60h, pero tras análisis es ~37h (38% reducción). **Esfuerzo real hasta ahora:** 2h --- ## Commits **Database submodule:** - `3f7816d` - feat(database): Add videos table for education module (ST4.3.1) **Backend submodule:** - `d7abb53` - feat(storage): Add S3/R2 storage service with multipart upload (ST4.3.2) --- ## Arquitectura Propuesta ``` ┌────────────────────────────────────────────────────────────────┐ │ VIDEO UPLOAD ARCHITECTURE │ └────────────────────────────────────────────────────────────────┘ 1. UPLOAD FLOW: User → VideoUploadForm ↓ POST /videos/upload-init → Backend creates DB record + uploadId ↓ Backend → Storage Service → S3/R2 (initMultipartUpload) ↓ Backend returns presigned URLs for each part ↓ Frontend uploads parts directly to S3/R2 (5MB each) ↓ POST /videos/:id/complete → Backend completes multipart upload ↓ Storage Service → S3/R2 (completeMultipartUpload) ↓ Backend → Trigger video processing (background job) 2. PROCESSING FLOW: Background Job → Video Processing Service ↓ Download from S3/R2 ↓ FFmpeg transcode to 1080p, 720p, 480p, 360p ↓ Generate thumbnail (frame at 1 second) ↓ Extract metadata (duration, codec, bitrate, resolution) ↓ Upload transcoded versions to S3/R2 ↓ Update DB: status='ready', cdn_urls, transcoded_versions 3. PLAYBACK FLOW: User → Video Player ↓ GET /videos/:id → Backend returns video details ↓ Frontend → CDN URL (CloudFront/Cloudflare) ↓ Adaptive bitrate selection based on network ``` --- ## Métricas - **Líneas código:** ~600 (DDL 150 + Storage Service 451) - **Archivos creados:** 2 - **Commits:** 2 - **Tiempo invertido:** 2h - **Progreso:** 33% --- ## Recomendación ### Opción 1: Completar ST4.3 antes de ST4.4 (RECOMENDADA) **Justificación:** - ST4.3 está 33% completo (base sólida) - Frontend ya existe (solo necesita integración) - Storage service ya funcional - ~35h restantes para completar **Siguiente paso:** 1. ✅ Implementar ST4.3.3: Video controller (15h) 2. ✅ Implementar ST4.3.4: Video processing (10h) 3. ✅ Implementar ST4.3.5: Frontend integration (8h) 4. ✅ Crear ST4.3.6: Documentación (2h) **Total:** ~35h adicionales --- ### Opción 2: MVP con upload simple (sin transcoding) **Justificación:** - Desbloquear funcionalidad básica más rápido - Transcoding puede añadirse después **Scope reducido:** 1. ✅ ST4.3.3: Video controller (solo upload, sin processing) - 8h 2. ✅ ST4.3.5: Frontend integration - 8h 3. ⚠️ ST4.3.4: Processing (SKIP por ahora) **Total:** ~16h adicionales **Trade-off:** Videos se almacenan en resolución original únicamente. --- ### Opción 3: Usar Cloudflare Stream (más simple) **Justificación:** - Cloudflare Stream maneja transcoding automáticamente - Más caro pero menos código **Cambios:** - Replace Storage Service multipart con Cloudflare Stream API - ST4.3.4 no necesario (Cloudflare hace transcoding) **Total:** ~20h adicionales --- ## Conclusión **Estado:** ✅ **Base implementada (DDL + Storage)** **Blocker BLOCKER-003 Status:** 🔄 **EN PROGRESO** (33%) **Trabajo pendiente:** ~35h (opción 1) o ~16h (opción 2 MVP) --- **Última actualización:** 2026-01-26 **Autor:** Claude Opus 4.5 **Blocker:** BLOCKER-003 (ST4.3)