---
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<VimeoVideo> {
    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<void> {
    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<VimeoAnalytics> {
    const response = await this.client.request(
      `/videos/${videoId}/analytics`
    );
    return response;
  }

  async deleteVideo(videoId: string): Promise<void> {
    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<VimeoPlayerProps> = ({
  videoId,
  onProgress,
  onComplete,
  initialTime = 0
}) => {
  const containerRef = useRef<HTMLDivElement>(null);
  const playerRef = useRef<Player | null>(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 (
    <div
      ref={containerRef}
      className="aspect-video bg-black rounded-lg overflow-hidden"
    />
  );
};
```

---

### 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<HLSPlayerProps> = ({
  src,
  poster,
  onProgress,
  onComplete,
  initialTime = 0
}) => {
  const videoRef = useRef<HTMLVideoElement>(null);
  const playerRef = useRef<any>(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 (
    <div data-vjs-player>
      <video
        ref={videoRef}
        className="video-js vjs-big-play-centered"
      />
    </div>
  );
};
```

---

### 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<void> {
    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<void> {
    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<void> {
    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<void> {
    // Implementation to upload all files to S3
    // ...
  }

  async generateThumbnails(
    videoPath: string,
    count: number = 10
  ): Promise<string[]> {
    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<number> {
    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<void> {
    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<void> {
    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<void> {
    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**
