trading-platform/docs/02-definicion-modulos/OQI-002-education/especificaciones/ET-EDU-004-video.md
rckrdmrd c1b5081208 feat(ml): Complete FASE 11 - BTCUSD update and comprehensive documentation alignment
ML Engine Updates:
- Updated BTCUSD with Polygon API data (2024-2025): 215,699 new records
- Re-trained all ML models: Attention (R²: 0.223), Base, Metamodel (87.3% confidence)
- Backtest results: +176.71R profit with aggressive_filter strategy

Documentation Consolidation:
- Created docs/99-analisis/_MAP.md index with 13 new analysis documents
- Consolidated inventories: removed duplicates from orchestration/inventarios/
- Updated ML_INVENTORY.yml with BTCUSD metrics and training results
- Added execution reports: FASE11-BTCUSD, correction issues, alignment validation

Architecture & Integration:
- Updated all module documentation with NEXUS v3.4 frontmatter
- Fixed _MAP.md indexes across all folders
- Updated orchestration plans and traces

Files: 229 changed, 5064 insertions(+), 1872 deletions(-)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 09:31:29 -06:00

1088 lines
28 KiB
Markdown

---
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**