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>
1088 lines
28 KiB
Markdown
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**
|