mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-18 12:46:43 +02:00
feat(reels): add video stabilization, quality improvements and processing monitoring
This commit is contained in:
1 parent
4e20a84836
commit
aac776777a
7 files changed
+289
-11
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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():
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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éé
|
||||
|
||||
Reference in new issue
Block a user