feat(reels): add video stabilization, quality improvements and processing monitoring

This commit is contained in:
Michael committed 2026-01-23 13:42:42 +01:00
1 parent 4e20a84836
commit aac776777a
7 files changed
+289 -11

No files matched your search

+25 -6
View File
@@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button';
import { useToast } from '@/hooks/use-toast';
interface CameraRecorderProps {
onCapture: (file: File) => void;
onCapture: (file: File, options?: { stabilize?: boolean }) => void;
onCancel: () => void;
}
@@ -17,12 +17,13 @@ interface QualityConfig {
label: string;
width: number;
height: number;
bitrate: number; // in bps
}
const QUALITY_CONFIGS: Record<VideoQuality, QualityConfig> = {
"4k": { label: "4K Ultra HD", width: 3840, height: 2160 },
"1080p": { label: "Full HD", width: 1920, height: 1080 },
"720p": { label: "HD 720p", width: 1280, height: 720 },
"4k": { label: "4K Ultra HD", width: 3840, height: 2160, bitrate: 50000000 },
"1080p": { label: "Full HD", width: 1920, height: 1080, bitrate: 25000000 },
"720p": { label: "HD 720p", width: 1280, height: 720, bitrate: 12000000 },
};
export function CameraRecorder({ onCapture, onCancel }: CameraRecorderProps) {
@@ -43,6 +44,7 @@ export function CameraRecorder({ onCapture, onCancel }: CameraRecorderProps) {
const [quality, setQuality] = useState<VideoQuality>('1080p');
const [showSettings, setShowSettings] = useState(false);
const [cameraError, setCameraError] = useState<string | null>(null);
const [stabilize, setStabilize] = useState(true); // Stabilisation serveur activée par défaut
// Fetch devices
const handleDevices = useCallback((mediaDevices: MediaDeviceInfo[]) => {
@@ -86,11 +88,12 @@ export function CameraRecorder({ onCapture, onCancel }: CameraRecorderProps) {
const mimeTypes = ['video/mp4', 'video/webm;codecs=vp9,opus', 'video/webm'];
const mimeType = mimeTypes.find(type => MediaRecorder.isTypeSupported(type)) || '';
const currentConfig = QUALITY_CONFIGS[quality];
try {
const mediaRecorder = new MediaRecorder(stream, {
mimeType,
videoBitsPerSecond: 8000000
videoBitsPerSecond: currentConfig.bitrate // Bitrate dynamique selon qualité
});
mediaRecorder.ondataavailable = (e) => {
@@ -121,7 +124,10 @@ export function CameraRecorder({ onCapture, onCancel }: CameraRecorderProps) {
const handleConfirm = () => {
if (recordedBlob) {
onCapture(new File([recordedBlob], `capture-${Date.now()}.mp4`, { type: recordedBlob.type }));
onCapture(
new File([recordedBlob], `capture-${Date.now()}.mp4`, { type: recordedBlob.type }),
{ stabilize }
);
}
};
@@ -157,6 +163,7 @@ export function CameraRecorder({ onCapture, onCancel }: CameraRecorderProps) {
const videoConstraints = {
width: { ideal: config.width },
height: { ideal: config.height },
frameRate: { ideal: 30 }, // 30fps pour une capture fluide
deviceId: selectedDeviceId ? { exact: selectedDeviceId } : undefined,
facingMode: selectedDeviceId ? undefined : facingMode
};
@@ -246,6 +253,18 @@ export function CameraRecorder({ onCapture, onCancel }: CameraRecorderProps) {
))}
</div>
</div>
<div>
<label className="text-sm font-medium text-white/70 flex items-center justify-between">
<span>Stabilisation serveur</span>
<button
onClick={() => setStabilize(!stabilize)}
className={`relative w-12 h-6 rounded-full transition-colors ${stabilize ? 'bg-green-500' : 'bg-gray-600'}`}
>
<span className={`absolute top-1 w-4 h-4 rounded-full bg-white transition-transform ${stabilize ? 'left-7' : 'left-1'}`} />
</button>
</label>
<p className="text-xs text-white/50 mt-1">Corrige les tremblements après capture (traitement serveur)</p>
</div>
</div>
<Button className="w-full mt-6 bg-white text-black hover:bg-gray-200" onClick={() => setShowSettings(false)}>OK</Button>
</div>
+122 -1
View File
@@ -68,6 +68,7 @@ export default function NewReel() {
const [ttsEnabled, setTtsEnabled] = useState(true);
const [ttsVoice, setTtsVoice] = useState("fr-FR-VivienneMultilingualNeural");
const [drawText, setDrawText] = useState(true);
const [shouldStabilize, setShouldStabilize] = useState(false);
// État audio preview
const [isPlaying, setIsPlaying] = useState<string | null>(null);
@@ -316,6 +317,7 @@ export default function NewReel() {
ttsEnabled,
ttsVoice,
drawText,
stabilize: shouldStabilize,
});
};
@@ -335,7 +337,16 @@ export default function NewReel() {
{/* Camera Recorder Modal */}
{showCamera && (
<CameraRecorder
onCapture={(file) => {
onCapture={(file, options) => {
if (options?.stabilize) {
setShouldStabilize(true);
toast({
title: "Stabilisation activée",
description: "La vidéo sera stabilisée lors de la création du Reel (ceci peut prendre plus de temps)",
});
} else {
setShouldStabilize(false);
}
uploadMutation.mutate(file);
setShowCamera(false);
}}
@@ -989,5 +1000,115 @@ export default function NewReel() {
</div>
</main>
</div>
{/* Overlay de progression */ }
<ProcessingOverlay
isVisible={createReelMutation.isPending}
stabilize={shouldStabilize}
ttsEnabled={ttsEnabled}
/>
</div >
);
}
function ProcessingOverlay({ isVisible, stabilize, ttsEnabled }: { isVisible: boolean; stabilize: boolean; ttsEnabled: boolean }) {
const [progress, setProgress] = useState(0);
const [status, setStatus] = useState("Initialisation...");
useEffect(() => {
if (!isVisible) {
setProgress(0);
return;
}
setProgress(0);
setStatus("Préparation des fichiers...");
const timeouts: NodeJS.Timeout[] = [];
let interval: NodeJS.Timeout;
// Sequence de simulation
// 1. 2s: Téléchargement
timeouts.push(setTimeout(() => {
setStatus("Téléchargement des médias...");
setProgress(10);
}, 1500));
// 2. 4s: Audio/TTS
timeouts.push(setTimeout(() => {
setStatus(ttsEnabled ? "Génération de la voix IA..." : "Mixage audio...");
setProgress(25);
}, 3500));
// 3. 7s: Stabilisation ou Encodage
timeouts.push(setTimeout(() => {
if (stabilize) {
setStatus("Stabilisation vidéo (Traitement long)...");
setProgress(35);
// Progression lente : +1% toutes les 800ms
// Ça permet de couvrir ~45 secondes avant d'arriver à 90%
let p = 35;
interval = setInterval(() => {
if (p < 90) {
p++;
setProgress(p);
}
}, 800);
} else {
setStatus("Encodage optimisé...");
setProgress(40);
// Progression pour encodage standard (~10-15s)
let p = 40;
interval = setInterval(() => {
if (p < 90) {
p += 2;
setProgress(p);
}
}, 500);
}
}, 6500));
// Nettoyage
return () => {
timeouts.forEach(clearTimeout);
if (interval) clearInterval(interval);
};
}, [isVisible, stabilize, ttsEnabled]);
if (!isVisible) return null;
return (
<div className="fixed inset-0 z-[60] bg-black/90 flex flex-col items-center justify-center p-4 backdrop-blur-md animate-in fade-in duration-300">
<div className="w-full max-w-md space-y-8 text-center">
<div className="relative w-24 h-24 mx-auto mb-8">
<Loader2 className="w-full h-full text-primary animate-spin" />
<div className="absolute inset-0 flex items-center justify-center font-bold text-xl">
{progress}%
</div>
</div>
<div className="space-y-4">
<h3 className="text-2xl font-bold text-white tracking-tight">Création de votre Reel</h3>
<p className="text-lg text-muted-foreground animate-pulse">
{status}
</p>
</div>
<div className="w-full h-2 bg-secondary/30 rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300 ease-out"
style={{ width: `${progress}%` }}
/>
</div>
{stabilize && (
<div className="flex items-center justify-center gap-2 text-sm text-yellow-500/80 bg-yellow-500/10 py-2 px-4 rounded-full mx-auto w-fit">
<Sparkles className="w-4 h-4" />
<span>Stabilisation activée : traitement prolongé</span>
</div>
)}
</div>
</div>
);
}
+11 -2
View File
@@ -1,11 +1,20 @@
FROM python:3.11-slim
# Install system dependencies including FFmpeg and fonts
# Install system dependencies including FFmpeg with vidstab support and fonts
# We need to build/install libvidstab and compile ffmpeg with it, OR use static build
RUN apt-get update && apt-get install -y \
ffmpeg \
wget \
xz-utils \
fonts-dejavu \
&& rm -rf /var/lib/apt/lists/*
# Download static FFmpeg build with all filters including vidstab
RUN wget -q https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz \
&& tar xf ffmpeg-release-amd64-static.tar.xz \
&& mv ffmpeg-*-amd64-static/ffmpeg /usr/local/bin/ \
&& mv ffmpeg-*-amd64-static/ffprobe /usr/local/bin/ \
&& rm -rf ffmpeg-*
WORKDIR /app
# Copy requirements first for better caching
+100 -2
View File
@@ -12,6 +12,7 @@ from pathlib import Path
import edge_tts
import re
import emoji
import time
app = FastAPI()
@@ -35,6 +36,7 @@ class ReelRequest(BaseModel):
tts_enabled: bool = False
tts_voice: str = "fr-FR-VivienneMultilingualNeural"
draw_text: bool = True
stabilize: bool = False # Stabilisation vidéo via vidstab
def clean_text_for_tts(text: str) -> str:
@@ -160,6 +162,7 @@ class ReelResponse(BaseModel):
output_base64: Optional[str] = None
duration: Optional[float] = None
detail: Optional[str] = None
processing_stats: Optional[dict] = None
@app.get("/health")
@@ -174,6 +177,15 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)):
if x_api_key != API_KEY:
raise HTTPException(status_code=401, detail="Invalid API Key")
start_total = time.time()
stats = {
"download_duration": 0,
"tts_duration": 0,
"stabilize_duration": 0,
"encoding_duration": 0,
"total_duration": 0,
}
try:
job_id = str(uuid.uuid4())
job_dir = TEMP_DIR / job_id
@@ -185,6 +197,7 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)):
tts_vtt_path = job_dir / "tts.vtt"
output_video_path = job_dir / "output.mp4"
start_step = time.time()
# 1. Save Input Video
if request.video_base64:
with open(input_video_path, "wb") as f:
@@ -212,6 +225,9 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)):
print(f"Failed to download music: {e}")
# We continue without music if it fails
stats["download_duration"] = time.time() - start_step
start_step = time.time()
# 3. Generate TTS (if enabled)
has_tts = False
tts_clean_text = ""
@@ -257,6 +273,78 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)):
print(f"❌ Failed to generate TTS: {e}")
traceback.print_exc()
stats["tts_duration"] = time.time() - start_step
start_step = time.time()
# 3.5 Stabilize video if requested (vidstab two-pass)
if request.stabilize:
print("📐 Starting video stabilization (vidstab)...")
transforms_path = job_dir / "transforms.trf"
stabilized_path = job_dir / "stabilized.mp4"
try:
# Pass 1: Detect motion/shakiness
print("📐 Pass 1: Detecting motion...")
detect_cmd = [
"ffmpeg",
"-y",
"-i",
str(input_video_path),
"-vf",
f"vidstabdetect=stepsize=6:shakiness=8:accuracy=9:result={transforms_path}",
"-f",
"null",
"-",
]
detect_proc = subprocess.run(
detect_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
if detect_proc.returncode != 0:
print(
f"⚠️ Stabilization pass 1 failed: {detect_proc.stderr.decode()[:500]}"
)
elif transforms_path.exists():
# Pass 2: Apply stabilization transform
print("📐 Pass 2: Applying stabilization...")
transform_cmd = [
"ffmpeg",
"-y",
"-i",
str(input_video_path),
"-vf",
f"vidstabtransform=input={transforms_path}:smoothing=10:crop=black:zoom=1,unsharp=5:5:0.8:3:3:0.4",
"-c:v",
"libx264",
"-preset",
"medium",
"-crf",
"18",
"-c:a",
"copy",
str(stabilized_path),
]
transform_proc = subprocess.run(
transform_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
if transform_proc.returncode == 0 and stabilized_path.exists():
# Use stabilized video for further processing
input_video_path = stabilized_path
print("✅ Video stabilization complete!")
else:
print(
f"⚠️ Stabilization pass 2 failed: {transform_proc.stderr.decode()[:500]}"
)
else:
print("⚠️ Transforms file not created, skipping stabilization")
except Exception as e:
print(f"⚠️ Stabilization error (continuing without): {e}")
stats["stabilize_duration"] = time.time() - start_step
start_step = time.time()
# 4. Build FFmpeg Command
cmd = ["ffmpeg", "-y", "-i", str(input_video_path)]
@@ -383,7 +471,7 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)):
"-preset",
"medium",
"-crf",
"23", # Good balance for quality/size
"18", # High quality output (lower = better, 18 is visually lossless)
"-c:a",
"aac",
"-b:a",
@@ -400,6 +488,9 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)):
# execute
process = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
stats["encoding_duration"] = time.time() - start_step
stats["total_duration"] = time.time() - start_total
if process.returncode != 0:
print(f"FFmpeg failed: {process.stderr.decode()}")
raise Exception(f"FFmpeg encoding failed: {process.stderr.decode()}")
@@ -426,7 +517,14 @@ async def process_reel(request: ReelRequest, x_api_key: str = Header(None)):
# Cleanup
shutil.rmtree(job_dir)
return {"success": True, "output_base64": out_b64, "duration": duration}
print(f"📊 Processing Stats: {stats}")
return {
"success": True,
"output_base64": out_b64,
"duration": duration,
"processing_stats": stats,
}
except Exception as e:
if "job_dir" in locals():
+2
View File
@@ -188,6 +188,7 @@ reelsRouter.post('/reels/preview', async (req: Request, res: Response) => {
fontSize = 16,
musicVolume = 0.25,
drawText = true,
stabilize = false,
} = req.body;
// Récupérer le média vidéo
@@ -321,6 +322,7 @@ reelsRouter.post('/reels', async (req: Request, res: Response) => {
ttsEnabled: ttsEnabled,
ttsVoice: ttsVoice,
drawText: drawText,
stabilize: stabilize,
pageCount: pageIds.length,
});
+5
View File
@@ -17,6 +17,7 @@ interface FFmpegReelRequest {
font_size?: number; // Taille police (default: 24)
music_volume?: number; // Volume musique (default: 0.25)
draw_text?: boolean; // Dessiner le texte sur la vidéo (default: true)
stabilize?: boolean; // Stabilisation vidéo via vidstab (default: false)
}
interface FFmpegReelResponse {
@@ -71,6 +72,7 @@ export class FFmpegService {
fontSize?: number;
musicVolume?: number;
drawText?: boolean;
stabilize?: boolean;
} = {}
): Promise<{ success: boolean; videoBase64?: string; duration?: number; error?: string }> {
const config = this.ensureConfigured();
@@ -86,6 +88,7 @@ export class FFmpegService {
font_size: options.fontSize ?? 16,
music_volume: options.musicVolume ?? 0.25,
draw_text: options.drawText ?? true,
stabilize: options.stabilize ?? false,
};
// Remove undefined values
@@ -164,6 +167,7 @@ export class FFmpegService {
fontSize?: number;
musicVolume?: number;
drawText?: boolean;
stabilize?: boolean;
} = {}
): Promise<{ success: boolean; videoBase64?: string; duration?: number; error?: string }> {
const config = this.ensureConfigured();
@@ -179,6 +183,7 @@ export class FFmpegService {
font_size: options.fontSize ?? 16,
music_volume: options.musicVolume ?? 0.25,
draw_text: options.drawText ?? true,
stabilize: options.stabilize ?? false,
};
// Remove undefined values
+24
View File
@@ -73,6 +73,30 @@ Ajout d'une fonctionnalité complète de création de Reels Facebook permettant
- [~] Run `/bmad-bmgd-workflows-workflow-init` (Annulé: Application, pas un Jeu)
### Phase 7: Amélioration Qualité Vidéo & Stabilisation ✅
- [x] **Amélioration Capture Caméra** (`camera-recorder.tsx`)
- [x] Augmenter bitrate selon résolution (720p: 12Mbps, 1080p: 25Mbps, 4K: 50Mbps)
- [x] Ajouter contrainte framerate 30fps
- [x] Ajouter toggle "Stabilisation serveur" dans réglages
- [x] **Stabilisation FFmpeg vidstab** (`ffmpeg-service/main.py`)
- [x] Installer vidstab dans le Dockerfile (via static build)
- [x] Ajouter paramètre `stabilize` au modèle ReelRequest
- [x] Implémenter stabilisation 2 passes (detect + transform)
- [x] Améliorer qualité sortie (CRF 18)
- [x] **Backend API** (`ffmpeg.ts`)
- [x] Passer le paramètre `stabilize` à l'API FFmpeg
### Phase 8: Monitoring & UX ✅
- [x] **Monitoring Performance** (`ffmpeg-service/main.py`)
- [x] Mesurer et logger les temps de traitement par étape (Download, Stabilize, TTS, Encode)
- [x] Retourner les métriques dans la réponse API
- [x] **Feedback UI** (`new-reel.tsx`)
- [x] Ajouter un composant de progression "fake" intelligent pendant la création
- [x] Afficher les étapes (Upload, Audio, Stabilisation, Encodage)
- [x] Estimer le temps restant selon les options activées (Stabilisation = +30s)
## Progress Log
- **22 Jan 2026** - Analyse complète et PRD créé