Éditeur vidéo web optimisé pour le montage de vidéos "talking-head" (screencasts, podcasts, tutoriels). L'innovation principale est l'édition pilotée par la transcription : au lieu de naviguer manuellement dans la vidéo, on édite la transcription et la vidéo suit.
- Résolution : 2560×1440 (1440p)
- Framerate : 60 fps
- Durée : jusqu'à 2 heures
- Taille fichier : ~1 GB (bitrate ~1 Mbps depuis OBS)
- Format : conteneur MKV, vidéo H.264, audio AAC
| Composant | Choix |
|---|---|
| Frontend | React 19 + TypeScript + Vite (port ${5173}) |
| State management | Zustand |
| Backend | Node.js + Express + TypeScript (port ${3000}) |
| Base de données | leangraph (Cypher/SQLite embarqué) |
| Job Queue | Persistée dans leangraph |
| Transcription | WhisperX large-v3, venv Python, français |
| Traitement vidéo | FFmpeg (subprocess) |
| Temps réel | WebSocket (ws) |
| Tests | Vitest |
leangraph est publié sur npm (https://www.npmjs.com/package/leangraph). La première version publiée est 1.0.0 (il n'existe pas de version 0.x).
npm install leangraph # latest version (>=1.0.0)
npm install -D better-sqlite3@^12 # devDependency (module natif SQLite, ^12 requis pour Node 24+)import { LeanGraph, LeanGraphError } from 'leangraph';
// Mode local (défaut) - persiste dans ./data/nicefox.db
const db = await LeanGraph({
project: 'nicefox',
dataPath: './data'
});
// Mode test (pour tests unitaires) - DB in-memory, reset à chaque run
const testDb = await LeanGraph({
mode: 'test',
project: 'nicefox'
});// Batch insert avec UNWIND (pour les mots de transcription)
await db.execute(`
UNWIND $words AS w
MATCH (seg:Segment {id: $segmentId})
CREATE (seg)-[:HAS_WORD]->(word:Word {
id: w.id, word: w.word, start: w.start, end: w.end, confidence: w.confidence
})
`, { segmentId, words });
// Upsert avec MERGE (pour les jobs)
await db.execute(`
MERGE (j:Job {id: $id})
ON CREATE SET j.type = $type, j.status = 'pending', j.createdAt = datetime()
ON MATCH SET j.status = $status, j.progress = $progress
`, { id, type, status, progress });
// Gestion d'erreurs typée
try {
await db.query('...');
} catch (err) {
if (err instanceof LeanGraphError) {
console.error(`Query failed: ${err.message} at line ${err.line}, column ${err.column}`);
}
}
// IMPORTANT: Toujours fermer la connexion à l'arrêt du serveur
process.on('SIGTERM', () => db.close());
process.on('SIGINT', () => db.close());- Programmation fonctionnelle : Favoriser les fonctions pures, l'immutabilité, les compositions de fonctions,
map/filter/reduceplutôt que les boucles impératives. Éviter les classes quand possible, préférer les modules avec des fonctions exportées. - React 19 : Utiliser les nouvelles APIs quand pertinent :
refcomme prop directe (pas deforwardRef)<Context value={...}>au lieu de<Context.Provider value={...}>useOptimisticpour les mises à jour UI optimistes lors des éditions- Cleanup functions dans les callbacks
ref
- Dark mode uniquement : Pas de thème clair, design sombre par défaut avec des contrastes appropriés pour le travail prolongé sur vidéo.
┌─────────────────────────────────────────────────────────────────┐
│ Navigateur (React) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Composant Timeline │ │
│ │ - Piste vidéo (thumbnails) │ │
│ │ - Piste audio (visualisation waveform) │ │
│ │ - Overlay transcription (mots cliquables) │ │
│ │ - Contrôles zoom/pan │ │
│ └─────────────────────────────────────────────────────────┘ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌───────────────┐ │
│ │ Lecteur Vidéo │ │ Contrôles │ │ Panneau │ │
│ │ (HTML5 video) │ │ d'édition │ │ Export │ │
│ └─────────────────┘ └─────────────────┘ └───────────────┘ │
└─────────────────────────────────────────────────────────────────┘
│ HTTP/WebSocket
▼
┌─────────────────────────────────────────────────────────────────┐
│ Serveur Node.js │
│ - API REST pour projets, sources, éditions │
│ - Serveur de fichiers statiques (vidéo, thumbnails, waveforms) │
│ - WebSocket pour progression des jobs │
│ - Gestion de la queue de jobs │
│ - Base de données leangraph │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Workers de traitement │
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────┐ │
│ │ WhisperX │ │ FFmpeg │ │ FFmpeg │ │
│ │ (transcription) │ │ (thumbnails, │ │ (export) │ │
│ │ │ │ waveform) │ │ │ │
│ │ Python/venv │ │ │ │ │ │
│ └──────────────────┘ └──────────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Stockage Fichiers │
│ /data │
│ ├── nicefox.db # Base de données leangraph │
│ └── projects/ │
│ └── {project-id}/ │
│ ├── sources/ │
│ │ └── {source-id}/ │
│ │ ├── original.mkv # Vidéo originale │
│ │ ├── waveform.bin # Niveaux audio par frame │
│ │ └── thumbnails/ # Sprite sheets │
│ └── exports/ │
│ └── {export-id}.mp4 │
└─────────────────────────────────────────────────────────────────┘
// Projet principal
(:Project {id, name, createdAt, updatedAt})
// Source vidéo importée
(:Source {id, filename, path, duration, width, height, fps, status})
// status: 'importing' | 'processing' | 'ready' | 'error'
// Segment de transcription (phrase/paragraphe)
(:Segment {id, start, end, text})
// Mot individuel avec timestamps précis
(:Word {id, word, start, end, confidence})
// Clip dans la timeline (ce qu'on garde)
(:Clip {id, inPoint, outPoint, order})
// Job de traitement
(:Job {id, type, status, progress, error, createdAt, startedAt, completedAt})
// type: 'thumbnails' | 'waveform' | 'transcription' | 'export'
// status: 'pending' | 'running' | 'completed' | 'failed'(:Project)-[:HAS_SOURCE]->(:Source)
(:Source)-[:HAS_SEGMENT]->(:Segment)
(:Segment)-[:HAS_WORD]->(:Word)
(:Project)-[:HAS_CLIP]->(:Clip)
(:Clip)-[:FROM_SOURCE]->(:Source)
(:Source)-[:HAS_JOB]->(:Job)// Créer un projet
CREATE (p:Project {id: $id, name: $name, createdAt: datetime()})
// Récupérer un projet avec ses sources
MATCH (p:Project {id: $projectId})
OPTIONAL MATCH (p)-[:HAS_SOURCE]->(s:Source)
RETURN p, collect(s) as sources
// Récupérer la transcription complète d'une source
MATCH (s:Source {id: $sourceId})-[:HAS_SEGMENT]->(seg:Segment)-[:HAS_WORD]->(w:Word)
RETURN seg, collect(w) as words
ORDER BY seg.start
// Récupérer le prochain job en attente
MATCH (j:Job {status: 'pending'})
RETURN j
ORDER BY j.createdAt
LIMIT 1
// Mettre à jour la progression d'un job
MATCH (j:Job {id: $jobId})
SET j.progress = $progress, j.status = 'running'// Project
interface Project {
id: string;
name: string;
createdAt: string;
updatedAt: string;
}
// Source (vidéo importée)
interface Source {
id: string;
filename: string;
path: string;
duration: number; // secondes
width: number;
height: number;
fps: number;
status: 'importing' | 'processing' | 'ready' | 'error';
}
// Transcription
interface Segment {
id: string;
start: number; // secondes
end: number;
text: string;
words: Word[];
}
interface Word {
id: string;
word: string;
start: number; // secondes (précision WhisperX)
end: number;
confidence: number;
}
// Timeline
interface Clip {
id: string;
sourceId: string;
inPoint: number; // secondes dans la source
outPoint: number; // secondes dans la source
order: number; // position dans la timeline
}
// Job
interface Job {
id: string;
type: 'thumbnails' | 'waveform' | 'transcription' | 'export';
sourceId?: string;
projectId: string;
status: 'pending' | 'running' | 'completed' | 'failed';
progress: number; // 0-100
error?: string;
createdAt: string;
startedAt?: string;
completedAt?: string;
}Utilisateur dépose un fichier vidéo
│
▼
┌───────────────────┐
│ Upload streaming │
│ vers le serveur │
└───────────────────┘
│
▼
┌───────────────────┐ ┌───────────────────┐
│ Extraction │────▶│ Création des jobs │
│ métadonnées │ │ de traitement │
│ (ffprobe) │ │ dans leangraph │
└───────────────────┘ └───────────────────┘
│
┌──────────────┼──────────────┐
▼ ▼ ▼
┌───────────┐ ┌───────────┐ ┌───────────┐
│ Job │ │ Job │ │ Job │
│ thumbnails│ │ waveform │ │ WhisperX │
│ (ffmpeg) │ │ (ffmpeg) │ │ (python) │
└───────────┘ └───────────┘ └───────────┘
│ │ │
└──────────────┼──────────────┘
▼
Source prête pour édition
Utilisateur clique sur un mot ou une région de silence
│
▼
┌─────────────────────────┐
│ Identification de la │
│ plage temporelle │
│ (word.start → word.end) │
└─────────────────────────┘
│
▼
┌─────────────────────────┐
│ Mise à jour des clips: │
│ - Division du clip │
│ - Suppression de la │
│ plage concernée │
│ - Recalcul des ordres │
└─────────────────────────┘
│
▼
┌─────────────────────────┐
│ Re-rendu timeline │
│ (instantané, pas de │
│ traitement vidéo) │
└─────────────────────────┘
Utilisateur clique sur Exporter
│
▼
┌─────────────────────────┐
│ Génération de la │
│ commande FFmpeg │
│ depuis les clips │
└─────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────┐
│ FFmpeg complex filter: │
│ - Concat des segments │
│ - Application des crossfades audio │
│ - Ré-encodage vers le format cible │
└─────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────┐
│ Progression via │
│ WebSocket │
└─────────────────────────┘
│
▼
Téléchargement prêt
Génération de sprite sheets pour un chargement efficace :
# Extraction 1 thumbnail par seconde, création de sprite sheet
ffmpeg -i input.mkv -vf "fps=1,scale=160:-1,tile=10x10" \
-frames:v 1 thumbnails_%03d.jpgPour une vidéo de 2 heures : ~7200 thumbnails → 72 sprite sheets de 100 chacune.
Extraction des niveaux audio à la granularité du framerate :
# Extraction audio brut pour analyse dans Node
ffmpeg -i input.mkv -ac 1 -ar 48000 -f f32le -acodec pcm_f32le pipe:1Puis calcul du RMS pour chaque frame (48000/60 = 800 échantillons par frame).
import whisperx
# Chargement du modèle (CPU ou GPU)
model = whisperx.load_model("large-v3", device="cpu", compute_type="int8")
# Transcription avec timestamps mot par mot
audio = whisperx.load_audio(audio_path)
result = model.transcribe(audio, batch_size=16, language="fr")
# Alignement pour timestamps précis
model_a, metadata = whisperx.load_align_model(language_code="fr", device="cpu")
result = whisperx.align(result["segments"], model_a, metadata, audio, device="cpu")
# Résultat : timestamps mot par mot avec précision ~50ms# Pour chaque point de coupe, application du filtre acrossfade
ffmpeg -i input.mkv \
-filter_complex "
[0:a]atrim=0:10,asetpts=PTS-STARTPTS[a1];
[0:a]atrim=15:30,asetpts=PTS-STARTPTS[a2];
[a1][a2]acrossfade=d=0.1:c1=tri:c2=tri[aout]
" \
-map 0:v -map "[aout]" output.mp4La prévisualisation doit être fidèle au résultat final exporté, y compris les crossfades audio. Pas de compromis sur la qualité de prévisualisation.
Approche : Web Audio API + HTMLVideoElement
-
Vidéo : Utilisation de
requestVideoFrameCallbackpour synchroniser précisément la lecture avec les segments. Le navigateur lit le fichier original, mais on contrôle frame par frame les sauts entre segments. -
Audio avec crossfades :
- Extraction de l'audio via Web Audio API (
AudioContext) - Création de
AudioBufferSourceNodepour chaque segment - Application des crossfades en temps réel via
GainNode(fade in/out) - Scheduling précis avec
audioContext.currentTime
Gestion mémoire (vidéos longues) : Pour une vidéo de 2h, charger tout l'audio en
AudioBufferconsommerait ~1GB de RAM. Solution : chunking de l'audio (charger uniquement les segments proches du playhead, précharger le suivant). Garder en mémoire ~30 secondes avant/après la position courante. - Extraction de l'audio via Web Audio API (
// Exemple simplifié de crossfade audio
const playSegmentWithCrossfade = (
audioBuffer: AudioBuffer,
startTime: number,
endTime: number,
fadeInDuration: number,
fadeOutDuration: number
) => {
const source = audioContext.createBufferSource();
const gainNode = audioContext.createGain();
source.buffer = audioBuffer;
source.connect(gainNode);
gainNode.connect(audioContext.destination);
// Fade in
gainNode.gain.setValueAtTime(0, audioContext.currentTime);
gainNode.gain.linearRampToValueAtTime(1, audioContext.currentTime + fadeInDuration);
// Fade out
const duration = endTime - startTime;
gainNode.gain.setValueAtTime(1, audioContext.currentTime + duration - fadeOutDuration);
gainNode.gain.linearRampToValueAtTime(0, audioContext.currentTime + duration);
source.start(0, startTime, duration);
};- Synchronisation vidéo/audio : L'audio Web Audio API est la source de vérité pour le timing. La vidéo suit en ajustant son
currentTimepour rester synchronisée.
Cette approche garantit une prévisualisation identique à l'export final.
┌─────────────────────────────────────────────────────────────────────┐
│ [Zoom: ────●────] [00:00:00 / 01:45:32] [▶ Play] [⏹] │
├─────────────────────────────────────────────────────────────────────┤
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ VIDEO │▓▓▓▓▓│▓▓▓▓▓│▓▓▓▓▓│▓▓▓▓▓│ │▓▓▓▓▓│▓▓▓▓▓│▓▓▓▓▓│▓▓▓▓▓│ │ │
│ │ │thumb│thumb│thumb│thumb│ SUP │thumb│thumb│thumb│thumb│ │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ AUDIO │█▄▂▁▂▄█│█▄▂▁│ │▂▄█│ │█▄▂▁▂▄█│█▄▂▁▂▄█│█▄▂▁▂│ │ │
│ │ │───────│────│ SIL │───│ SUP │───────│───────│─────│ │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ MOTS │Bonjour│ je │ │suis│ │Conrad│ et │ je │ │ │
│ │ │───────│────│ │────│ │──────│─────│─────│ │ │
│ └─────────────────────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────────────────┤
│ ◀ ──────────────────────────●────────────────────────────────── ▶ │
└─────────────────────────────────────────────────────────────────────┘
| Raccourci | Action |
|---|---|
| Espace | Play/pause global |
| Suppr / Backspace | Supprimer la sélection |
| Ctrl+Z | Annuler (undo) |
| Ctrl+Shift+Z | Rétablir (redo) |
| I | Preview début du segment/sélection (~1 seconde) |
| O | Preview fin du segment/sélection (~1 seconde) |
| C ou S | Couper au niveau du playhead |
- Survol d'un mot : Mise en surbrillance du mot + région vidéo/audio correspondante
- Clic sur un mot : Sélection (bordure jaune)
- Shift+clic : Sélection multiple
- Clic sur région de silence : Sélection du silence
- Glisser les bords d'un mot : Ajustement des limites (fine-tune)
- Clic droit : Prévisualiser le segment (contextuel)
- Si curseur proche du début du segment (< 0.5s) → lecture depuis le début
- Sinon → lecture depuis la position du curseur
- Arrêt automatique à la fin du segment (scheduler via Web Audio API)
La transcription automatique n'est pas parfaite. L'éditeur doit permettre :
- Coupe manuelle : Clic sur la timeline (hors mots) + raccourci (ex:
CouS) pour créer un point de coupe à n'importe quel endroit - Sélection de plage libre : Drag sur la timeline pour sélectionner une région arbitraire (pas limitée aux mots)
- Ajustement des points de coupe : Glisser les bords d'un clip pour corriger une coupe imprécise
- Split d'un clip : Diviser un clip existant en deux au niveau du playhead
Ces fonctionnalités sont essentielles pour les cas où :
- WhisperX détecte mal les limites d'un mot
- On veut couper au milieu d'un mot (bégaiement, hésitation)
- On veut garder un silence partiel
- Régions de silence : Fond rouge/rose (détecté via gaps WhisperX)
- Mots : Fond blanc avec texte
- Sélectionné : Bordure jaune
- Points de crossfade : Petit indicateur de dégradé aux coupures
Les éléments supprimés disparaissent de la timeline (pas de mode "grisé"). La confiance vient du système d'historique.
- Affichage des 10 dernières actions dans un panneau discret (ex: coin inférieur)
- Chaque action montre : type (suppression, coupe, ajustement) + timestamp ou mot concerné
- Ctrl+Z pour annuler, Ctrl+Shift+Z pour rétablir
- Pattern Command pour l'implémentation (chaque action est réversible)
nicefox-video-editor/
├── client/ # Frontend React + Vite
│ ├── src/
│ │ ├── components/
│ │ │ ├── Timeline/
│ │ │ ├── VideoPlayer/
│ │ │ ├── TranscriptTrack/
│ │ │ └── WaveformTrack/
│ │ ├── stores/ # Zustand stores
│ │ ├── hooks/
│ │ ├── types/
│ │ └── App.tsx
│ ├── package.json
│ └── vite.config.ts
├── server/ # Backend Node.js + Express
│ ├── src/
│ │ ├── routes/
│ │ ├── services/
│ │ │ ├── ffmpeg.ts
│ │ │ ├── whisperx.ts
│ │ │ └── jobs.ts
│ │ ├── db.ts # Instance leangraph
│ │ └── index.ts
│ └── package.json
├── worker/ # Worker Python WhisperX
│ ├── venv/ # Environnement virtuel Python
│ ├── transcribe.py
│ └── requirements.txt
├── data/ # Données runtime (gitignored)
│ ├── nicefox.db # Base de données leangraph
│ └── projects/
│ └── {project-id}/
│ ├── sources/
│ │ └── {source-id}/
│ │ ├── original.mkv
│ │ ├── waveform.bin
│ │ └── thumbnails/
│ └── exports/
├── package.json # Racine workspace
└── README.md
- Setup monorepo (npm workspaces)
- Serveur Express + leangraph
- API REST basique (CRUD projets)
- Upload vidéo (streaming)
- Extraction métadonnées FFprobe
- Client React + Vite minimal
- UI upload fichier
- Setup venv Python + WhisperX
- Queue de jobs persistée (leangraph)
- Worker de traitement des jobs
- Job : génération thumbnails (FFmpeg sprites)
- Job : extraction waveform (FFmpeg → Node)
- Job : transcription (WhisperX large-v3, français)
- WebSocket pour mises à jour de progression
- Composant Timeline virtualisé
- Piste thumbnails (sprite sheets)
- Piste waveform (canvas)
- Piste transcription (mots cliquables)
- Contrôles zoom/pan
- Playhead + affichage temps
- Lecteur vidéo synchronisé (HTMLVideoElement)
- Moteur audio Web Audio API (pour crossfades en prévisualisation)
- Synchronisation précise vidéo/audio
- Sélection mots/silences (clic)
- Sélection multiple (shift+clic, drag)
- Suppression (Suppr/Backspace → mise à jour clips)
- Feedback visuel des coupures
- Système undo/redo (pattern command) + historique 10 dernières actions
- Prévisualisation fidèle avec crossfades audio temps réel
- Coupe manuelle à n'importe quel point (raccourci clavier)
- Sélection de plage libre (drag sur timeline)
- Ajustement des points de coupe existants (drag des bords)
- Split de clip au niveau du playhead
- Preview rapide début/fin de segment (raccourcis I/O)
- Suppression batch des silences > X secondes (killer feature)
- Clips → commande FFmpeg
- Crossfade audio (filtre acrossfade)
- Job d'export avec progression
- Téléchargement du fichier final
- Raccourcis clavier complets (au-delà des essentiels)
- UI d'ajustement des limites des mots
- Gestion d'erreurs robuste
- Polish UI/UX
- Virtualisation : Ne rendre que la portion visible de la timeline
- Sprites thumbnails : Charger des sprite sheets, pas des images individuelles
- LOD waveform : Plusieurs niveaux de résolution selon le zoom
- Rendu canvas : Dessiner le waveform sur canvas, pas en éléments DOM
- Ne pas charger la vidéo complète en mémoire : Stream depuis le disque
- Chargement lazy des thumbnails : Charger les sprites au scroll
- Chunking du waveform : Charger les données par morceaux
- UI optimiste : Mise à jour immédiate de l'UI, sync serveur en arrière-plan
- Sauvegardes debounced : Ne pas sauvegarder à chaque édition, grouper les updates
- Web Workers : Déporter le traitement du waveform vers un worker thread
| Couche | TDD ? | Pourquoi |
|---|---|---|
| Services backend (FFmpeg, jobs, WhisperX) | Oui | Logique bien définie, inputs/outputs clairs |
| Stores Zustand (édition, undo/redo, timeline) | Oui | Logique métier critique, état complexe |
| Routes API | Oui | Contrats clairs, facile à tester |
| Composants React | Non | Exploratoire, UI change souvent |
| Intégration audio/vidéo | Non | Dépendant du browser, validation manuelle |
Pour chaque fonctionnalité :
- Écrire le test qui décrit le comportement attendu
- Exécuter le test → vérifier qu'il échoue (et pour la bonne raison : fonction non définie, assertion failed — pas une erreur de syntaxe)
- Écrire le code minimal pour faire passer uniquement ce test
- Exécuter le test → vérifier qu'il passe
- Passer au test suivant
Règles strictes :
- Ne jamais écrire de code applicatif sans un test qui échoue d'abord
- Ne jamais écrire plusieurs tests d'un coup avant d'écrire le code
- Un test = un comportement précis
Les tests doivent valider le comportement métier, pas l'implémentation de la base de données.
| Bon test (comportement métier) | Mauvais test (implémentation DB) |
|---|---|
deleteWord(wordId) → les clips adjacents sont fusionnés |
db.execute('DELETE...') retourne le bon résultat |
createProject(name) → retourne un projet avec id et timestamps |
MERGE (:Project) fonctionne |
splitClipAtTime(clipId, time) → crée 2 clips avec les bons in/out points |
La query Cypher est syntaxiquement correcte |
Après undoLastAction(), l'état est restauré |
Les relations sont bien créées dans le graphe |
// ✅ BON : Teste le comportement métier via les services
describe('ClipService', () => {
it('should split clip into two parts at given timestamp', async () => {
// Arrange : setup via les services métier
const db = await LeanGraph({ mode: 'test', project: 'nicefox' });
const clipService = createClipService(db);
const originalClip = await clipService.create({
sourceId: 'src-1',
inPoint: 0,
outPoint: 10,
order: 0
});
// Act : appeler la méthode métier
const [clip1, clip2] = await clipService.splitAt(originalClip.id, 5);
// Assert : vérifier le comportement attendu
expect(clip1.inPoint).toBe(0);
expect(clip1.outPoint).toBe(5);
expect(clip2.inPoint).toBe(5);
expect(clip2.outPoint).toBe(10);
expect(clip2.order).toBe(clip1.order + 1);
});
});
// ❌ MAUVAIS : Teste la DB directement
describe('ClipService', () => {
it('should create clip in database', async () => {
const db = await LeanGraph({ mode: 'test', project: 'nicefox' });
// Mauvais : on écrit du Cypher au lieu d'appeler un service
await db.execute(`
CREATE (:Clip {id: 'c1', inPoint: 0, outPoint: 10})
`);
// Mauvais : on vérifie juste que la DB fonctionne
const result = await db.query(`MATCH (c:Clip {id: 'c1'}) RETURN c`);
expect(result).toHaveLength(1);
});
});- Test écrit avant le code applicatif
- Test exécuté et échoué (vérifier le message d'erreur)
- Code minimal écrit (juste assez pour ce test)
- Test exécuté et passé
- Le test appelle une méthode de service/store, pas
db.execute()oudb.query()directement
// Utiliser le mode test de leangraph pour les tests unitaires/intégration
import { LeanGraph } from 'leangraph';
const db = await LeanGraph({ mode: 'test', project: 'nicefox' });
// DB in-memory, isolée, reset automatique à la fin| Type | Outil | Cible |
|---|---|---|
| Unitaires backend | Vitest | Services, utilitaires, logique métier |
| Intégration API | Vitest + supertest | Routes Express, interactions DB |
| Unitaires frontend | Vitest | Stores Zustand, hooks custom, utilitaires |
Pas de tests de composants React (fragiles, ROI faible pour un projet solo). Pas de tests E2E (validation manuelle suffisante).
- Fichier vidéo de test : Voir
sample/screencast_sample.mkv- Un exemple réel de screencast talking-head pour les tests d'intégration - Fichiers vidéo de test courts (~10 secondes) pour les tests unitaires
- Transcriptions JSON mockées pour les tests de timeline
- Waveforms binaires mockés pour les tests de visualisation
L'éditeur est un succès si :
- Import : Vidéo de 2h traitée en < 30 minutes (avec WhisperX en CPU)
- Timeline : Scroll/zoom fluide à 60fps sur vidéo de 2h
- Édition : Suppression d'un mot en < 100ms (réponse UI)
- Export : Vidéo de 2h exportée en < 10 minutes
- Workflow : Temps d'édition réduit de 50%+ comparé à Kdenlive