Video Studio
En une phrase : vidéo = scénario + durée réelle de l'audio + fonction de rendu image par image déterministe + encodage ffmpeg + QA automatique. Les images et le son sont entièrement générés par du code : zéro素材, reproductible, reprise possible, validation scriptable.
0. Règles d'airain (enfreindre une seule entraîne des problèmes garantis)
- L'audio d'abord, l'image ensuite. La timeline doit être dérivée de la durée réelle mesurée du TTS, jamais estimée par nombre de caractères. Une timeline basée sur le nombre de caractères dérivera visiblement à partir de la 20e phrase.
- Chaque image est une fonction pure :
draw(t, frame) -> Image. Interdiction d'utiliserrandom/time(), utilisezfilmkit.hash2/hash3/vnoisepour l'aléatoire. Le même numéro d'image doit toujours produire la même image. - Les sous-titres et les gros textes doivent rester dans la zone de sécurité : corps de texte
y ∈ [58, 974], sous-titresy ∈ [872, 1030], jamais au-delà de 1030. - Les images originales sont encodées directement : images PIL → pipe
rawvideo→libx264. N'utilisez pascv2.VideoWritercomme fichier intermédiaire (2x plus lent et perte de qualité). - Le mixage utilise numpy pour la superposition échantillon par échantillon, n'utilisez pas une seule commande ffmpeg avec des centaines d'entrées (cela provoque des silences non déterministes sur Windows).
- Avant la livraison, exécutez le script de QA + regardez 3 images fixes (avec
read_image). "Ça produit une image" ne veut pas dire "il n'y a pas de problème". - Le son suit l'image : les points de repère sonores sont enregistrés dans le code de la scène (
K.CueBus/ hookcues()descenes.py), pas ajustés manuellement après coup. Si l'image change (durée d'affichage modifiée, rythme ajusté), les points de repère suivent automatiquement. - Ajoutez une pause si ce n'est pas clair, ne ralentissez pas tout le film : utilisez
holddansfilm.json(global) oulines[].hold(par phrase) pour ajouter une pause après la voix off ; alignez la musique sur les mesures avecbpm.
0.5 【Règle d'airain】 Proposer d'abord, produire ensuite (que l'utilisateur le demande ou non)
Si l'utilisateur ne le demande pas, vous devez quand même proposer. C'est la règle produit la plus importante de cette compétence, pas une suggestion. Raison : si l'apparence est choisie par le programme, l'utilisateur ne participe jamais à la décision, le résultat sera "toutes les vidéos se ressemblent". Donc le flux est fixe : générer plusieurs propositions sur place → l'utilisateur en choisit une → geler le design → produire la vidéo.
Action fixe (la première chose à faire pour toute nouvelle tâche de production de vidéo) :
python propose.py --brief "<sujet/public/durée/ton>" --script film.json --n 3
Cela produit N propositions candidates (chacune = 1 design + 4 images fixes + coût + distance de design avec les autres candidates),
sauvegardées dans work/proposal.json, et imprime "Confirmez-en une / si aucune ne convient, utilisez --revise". Entièrement hors ligne, en quelques secondes.
| Réaction de l'utilisateur | Ce que vous faites |
|---|---|
| "Celle-ci" / choisit une | python propose.py --confirm --candidate B, puis autoriser le rendu |
| "Trop froid/trop voyant/change de voix/rythme plus rapide" | python propose.py --revise "<citation exacte>" — nouvelle rév, l'ancienne est conservée pour diff |
| "Aucune ne va, recommencez" | Idem, précisez la demande (le texte de --revise est stocké dans proposal.feedback) |
| Dit explicitement "Décidez vous-même" | Proposez quand même, choisissez-en une que vous recommandez et expliquez pourquoi, faites approuver par l'utilisateur |
Contraintes strictes :
- Avant
propose.py --confirm,render.pyavecframes/allsera bloqué par la barrière de proposition (avertissement par défaut,--require-proposaléchoue). - Les candidates doivent différer sur ≥2 dimensions de design (palette/motif/transition/easing/numéro/musique/voix). Changer seulement la couleur ne compte pas comme plusieurs propositions,
diversity.vs[].n < 2sera signalé. - Chaque candidate doit avoir une intention en chinois (
pitch), expliquant "ce que ce design veut accomplir" — pas "Proposition A/Proposition B". - Il n'y a pas de modèles intégrés : tous les champs de
design.jsonsont ouverts (35 champs de thème + 5 signatures de langage visuel + son/musique/rythme), la palette est un point quelconque dans un espace de teinte continue, pas un choix parmi quelques designs prêts à l'emploi. Chaque proposition est générée sur place — deux prises consécutives du même brief donneront des designs différents (pour reproduire, passez--seed). - Registre anti-répétition entre vidéos (activé par défaut) :
--confirmenregistre le design gelé dans~/.dsh/video-studio/design-ledger.json(max 500 entrées). La prochaine proposition évitera activement les designs récemment livrés — si une candidate a une distance de design < 2 dimensions avec une vidéo du registre, elle est automatiquement remplacée par un nouveau nonce avec avertissement. C'est le mécanisme qui fait passer "différentes vidéos, designs différents" de "aléatoire" à "avec mémoire".--show-ledgerpour consulter le registre,--no-ledgerpour ignorer cette fois, variable d'environnementVIDEO_STUDIO_LEDGERpour changer le chemin ou mettreoffpour désactiver. Les échecs de lecture/écriture du registre sont traités comme une table vide, ne bloquent jamais la production.
Un seul cas permet de sauter la proposition : pure réparation / pure re-rendu (pas de changement de contenu, pas de changement de design, seulement correction de désynchronisation AV ou nouveau rendu).
Dans ce cas, exécutez sans --require-proposal et expliquez dans la réponse "Proposition non effectuée cette fois parce que…".
0.5.1 D'où vient l'apparence : seulement deux chemins
Il n'y a pas de "style intégré" possible. Toute valeur style écrite dans film.json (auto / legacy / un id) sera
clairement rejetée avec deux instructions : le scénario ne détermine pas l'apparence, l'apparence est déterminée par work/design.json.
De même, il n'y a pas de table THEMES/STYLES/VISUAL_LANGUAGES dans la compétence, ni de dossier presets/ ;
la musique est générée sur place par musicgen.py selon les paramètres, et scenes.draw_frame n'a pas de branche legacy.
Pour une apparence spécifique, seulement deux chemins :
# ① Exiger en langage naturel (reconnaît les mots-clés dans must_have/avoid)
python propose.py --brief "Conférence tech, bleu foncé, format cinémascope" --script film.json
# ② Écrire directement design.json (tous les champs ouverts, palette RGB arbitraire)
python render.py --script film.json --stage all # Prérequis : work/design.json existe déjà
Contrat détaillé dans references/proposal.md et schemas/design.schema.json.
1. Démarrage en cinq étapes (zéro copie : pas de copie de script, pas de copie de modèle)
Ce chemin n'implique aucune "copie". Les scripts de la compétence restent dans le répertoire de la compétence et sont exécutés directement ; votre répertoire de projet n'a besoin que d'un
film.json(plus les dossierswork/etout/générés).
<SKILL_DIR>désigne leresourceBase.pathretourné par l'outilskilllors du chargement de cette compétence. Une fois installé comme plugin, c'est le dossierskills/video-studio/dans le package (lenode_modules/@dsh-gis/dsh-video-studio-skilldu profil est un lien). Si introuvable, localisez par nom :find ~ -path '*/skills/video-studio/scripts/filmkit.py'.
<REPO>désigne la racine du package (racine du dépôt, ounode_modules/@dsh-gis/dsh-video-studio-skill/dans le profil après installation), soit<SKILL_DIR>remonté de deux niveaux.tools/etschemas/sont sous<REPO>, pas sous<SKILL_DIR>.
# 1) Écrire seulement un film.json (le seul fichier que vous devez écrire à la main dans le répertoire du projet)
mkdir -p myfilm && cd myfilm
cat > film.json <<'JSON'
{
"title": "Mon film",
"lines": [
{"id": "l01", "kind": "hook", "chapter": "01 · Ouverture",
"text": "Voici une image et un son générés entièrement par code.", "sub": "Zéro素材, tout par code", "big": "Zéro素材, tout par code"},
{"id": "l02", "kind": "body", "chapter": "02 · Corps", "kicker": "PIPELINE",
"text": "Toute la pipeline ne comporte que cinq étapes : écrire le scénario, générer la voix off, construire la timeline, rendre image par image, mixer et encoder.",
"sub": "Cinq étapes, chaque étape sauvegarde", "big": "Cinq étapes",
"items": [{"text": "Scénario film.json"}, {"text": "Voix off"}, {"text": "Timeline"}, {"text": "Rendu image par image"}, {"text": "Mixage"}]},
{"id": "l03", "kind": "outro", "chapter": "99 · Conclusion",
"text": "D'abord le script, ensuite le son, enfin l'image.", "sub": "L'ordre est plus important que l'effort", "big": "L'ordre est plus important que l'effort"}
]
}
JSON
# lines[].text = texte parlé (langage naturel) ; sub = sous-titre (court) ; kind = hook/body/quote/outro/emphasis/calm
# Le style visuel/la musique/la voix/le rythme sont tous déterminés par le design, il n'y a **aucun** champ `style` à écrire dans film.json.
# Validation du contrat (optionnel, quelques secondes) :
python <SKILL_DIR>/scripts/filmcheck.py film.json
# 2) Auto-vérification de l'environnement (optionnel ; 69 garde-fous de régression, environ 10-20 secondes, hors ligne ; les modules optionnels manquants sont marqués SKIP)
python <SKILL_DIR>/scripts/selfcheck.py
# 3) 【Règle d'airain】 Proposer d'abord, produire ensuite — le même brief donne des résultats différents à chaque fois, donc l'utilisateur doit voir
python <SKILL_DIR>/scripts/propose.py \
--brief "Trois ans de grands modèles AI, 30 secondes, vulgarisation, bleu foncé" \
--script film.json --work work
# Produit 3 candidates : chacune avec un design + un planche contact d'images fixes (work/proposals/rev1/_compare.png) + distances de design,
# résultat en 2 secondes, entièrement hors ligne. Regardez les images côte à côte, choisissez-en une.
# 4) Confirmer la candidate, geler le design (work/design.json) — le rendu n'utilisera que ce design
python <SKILL_DIR>/scripts/propose.py --confirm --candidate A --work work
# Si insatisfait, changez-en une : --revise "Encore plus froid, moins de décorations, voix féminine" (nouvelle rév, l'ancienne reste pour diff)
# Pour reproduire une fois particulière : ajoutez --seed 42 à la première proposition (pas de seed = nouveau nonce à chaque fois, c'est la source de "chaque vidéo est différente")
# 5) Voir les images fixes d'abord, puis la vidéo complète ; sans --scenes, le rendu utilise le rendu intégré de la compétence
python <SKILL_DIR>/scripts/render.py --script film.json --work work --stage stills \
--stills "l01:3,l02:5,l03:2.5"
# → work/stills/_sheet.png : regardez avec read_image, ne sautez pas
python <SKILL_DIR>/scripts/render.py --script film.json --work work \
--out out/film.mp4 --stage all
# TTS→Typographie→Musique→Effets sonores→Mixage→Rendu image→Encodage→QA en une seule commande ; tous les intermédiaires dans work/, reprise possible.
À propos de --scenes (la clé du zéro copie) : Sans --scenes, render.py cherche dans l'ordre
① scenes.py dans le même répertoire que le script → ② assets/scenes.py intégré à la compétence. Donc par défaut, vous n'avez rien à écrire,
rien à copier, le rendu intégré produit directement la vidéo ; seulement si vous voulez modifier l'image, placez votre propre scenes.py
(dans le même répertoire que le script) implémentant draw_frame et éventuellement cues(), il sera automatiquement trouvé
— vous pouvez aussi utiliser explicitement --scenes my_scenes.py.
Le code source du rendu intégré est le meilleur exemple : <SKILL_DIR>/assets/scenes.py.
# Signature réelle (six paramètres, il en manque un et ça plante) : `scenes.draw_frame`
def draw_frame(scene, t_local, dur, t_global, script, frame):
# scene : dictionnaire de ce segment dans la timeline (id / kind / scene_type / sub / big / items / stats ...)
# t_local : temps local dans le segment (secondes, 0..dur) — pour les animations d'entrée/de maintien
# dur : durée totale de ce segment (secondes)
# t_global : temps global dans la vidéo (secondes) — pour les animations continues (lent zoom, respiration)
# script : dictionnaire complet du scénario/plan (plan, couleur du thème, words, etc. ; mis en cache, ne modifiez pas)
# frame : numéro d'image absolu (entier) — pour le bruit déterministe/phase, n'utilisez pas time()
return img # PIL.Image, doit être 1920x1080 RGB (utilisez K.new_frame() pour commencer, c'est le plus sûr)
Pour écrire un
scenes.pypersonnalisé, commencez par copier un renderer de<SKILL_DIR>/assets/scenes.py(comme_draw_hero), qui a déjà contourné les pièges de la fonction pure, la mise en page en cache, le pré-rendu des fonds, etc.
Pourquoi proposer d'abord : Il n'y a pas de "style intégré", l'apparence est déterminée par design.json.
Deux prises consécutives du même brief donneront des designs différents (palette/motif/transition/musique/voix peuvent toutes changer),
donc le flux doit être "générer des propositions → validation humaine → geler → produire" ; sans validation, le rendu via render.py émet un avertissement
(avec --require-proposal, il échoue). Contrat de design dans references/proposal.md.
Exécution par étapes (chaque étape fait seulement son travail, sauvegarde, peut être relancée individuellement) :
python <SKILL_DIR>/scripts/render.py --script film.json --work work --stage style # Analyser le design confirmé, imprimer le thème/langage visuel/alternance de mise en page/style de sous-titre
python <SKILL_DIR>/scripts/render.py --script film.json --work work --stage tts # Synthétiser seulement la voix off + durée réelle (cache avec validation d'entrée)
python <SKILL_DIR>/scripts/render.py --script film.json --work work --stage layout # Construire seulement la timeline + sélectionner les compositions, génère timeline.json / sub.srt / storyboard.json
python <SKILL_DIR>/scripts/render.py --script film.json --work work --stage cues # Générer seulement les points de repère -> work/cues.json
python <SKILL_DIR>/scripts/render.py --script film.json --work work --stage score # Générer seulement la musique -> work/music.wav (musicgen génère sur place)
python <SKILL_DIR>/scripts/render.py --script film.json --work work --stage sfx # Générer seulement la piste d'effets sonores (sfxkit, selon cues.json)
python <SKILL_DIR>/scripts/render.py --script film.json --work work --stage audio # Mixer seulement (voix+musique+effets, priorité à la voix)
python <SKILL_DIR>/scripts/render.py --script film.json --work work --stage frames # Rendre seulement les images (--engine pil|web, --jobs N parallélisation par blocs)
python <SKILL_DIR>/scripts/render.py --script film.json --work work --stage mux # Assembler seulement (flux vidéo copié, double passe loudnorm)
python <SKILL_DIR>/scripts/render.py --script film.json --work work --stage qa # QA seulement (lecture seule, écrit dans work/qa/)
python <SKILL_DIR>/scripts/render.py --script film.json --work work --stage stills # Rendre seulement les images fixes de la maquette + planche contact
python <SKILL_DIR>/scripts/render.py --script film.json --work work --stage accept --gate stills # Validation manuelle d'une barrière (écrit work/signoff.json)
python <SKILL_DIR>/scripts/render.py --script film.json --work work --stage explain # Rapporter seulement "quelles étapes sont obsolètes, pourquoi, quoi exécuter", n'exécute aucune étape
Ordre complet de --stage all : style → tts → layout → cues → score → sfx → audio → frames → mux → qa.
Si une étape échoue, elle s'arrête à cette étape, ne continue pas avec une mauvaise entrée.
Calculer le coût avant de lancer le rendu (optionnel ; estimate.py est un outil du dépôt, appelé selon <REPO>) :
python <REPO>/tools/estimate.py --script film.json --jobs 4
Il donne le nombre d'images/le temps par étape/le disque/la suggestion de parallélisation (--json lisible par machine), c'est une borne supérieure pas une promesse ;
si work/timeline.json existe, il utilise la durée réelle, sinon estimation grossière par nombre de caractères. Pour économiser le VMAF complet, écrivez dans film.json
"qa": {"vmaf": {"mode": "off"}} (estimate et render lisent ce champ, G11 noté skipped).
Voir les preuves de livraison avant validation :
cat out/QC-REPORT.md # gate matrix divisé en pass / fail / skipped ; skipped = pas exécuté cette fois, ne veut pas dire réussi
python <SKILL_DIR>/scripts/render.py --script film.json --work work --stage accept --gate release
La validation est liée à l'empreinte du contenu : si le scénario / scenes.py / le design change, l'ancienne validation est automatiquement invalidée, il faut revoir et resigner.
Pour forcer avant livraison (skipped compte aussi comme échec) : ajoutez --strict-gates.
Rendu de longs métrages (parallélisation par blocs + reprise) :
python <SKILL_DIR>/scripts/render.py --script film.json --work work --stage frames --jobs 4
python <SKILL_DIR>/scripts/render.py --script film.json --work work --stage frames --jobs 4 --resume
Les blocs sont sauvegardés dans
work/segments/, nommésseg_<image début>_<image fin>.mp4; avant l'assemblage, les trous sont explicitement détectés, un bloc manquant provoque une erreur, il n'est pas permis de produire une vidéo avec un segment manquant. Par défaut, la taille des blocs esttotal_frames / jobs / 4arrondi à la seconde entière (jobs=4, 900 images donne environ 8 secondes par bloc), peut être écrasée avecchunk_framesdansfilm.json.Après avoir modifié les répliques / modifié scenes.py / modifié le design / modifié les paramètres d'encodage,
--resumerefusera de réutiliser les anciens blocs (le plan fingerprint est stocké danswork/segments/_plan.json). Une erreur vous invitera àrm -rf work/segments— c'est intentionnel : réutiliser d'anciens blocs avec une nouvelle timeline produirait une vidéo "où l'image et la voix ne correspondent pas", et la barrière de durée ne le détecterait pas.
2. Huit étapes standard (P0–P7)
| Étape | Production | Action clé | Erreur courante |
|---|---|---|---|
| P0.5 Définir le design | work/design.json + work/style.json | propose.py génère des propositions → --confirm gèle le design ; --stage style analyse seulement le design confirmé et imprime le thème/langage visuel/alternance de mise en page/sous-titres | Sauter → render.py erreur (pas de design = pas d'apparence) ; rendu sans confirmation = avertissement de proposition |
| P0 Établir les bases | CONVENTIONS.md | Calculer une fois pour toutes la résolution/fps/durée/BPM→images, tout le projet se réfère à la même source | Chaque fichier écrit des nombres magiques comme 150 → les limites des scènes ne s'alignent pas |
| P1 Écrire le scénario | film.json | Chaque phrase sépare text (parlé) et sub (sous-titre) ; chaque segment un kind ; optionnel lines[].type pour verrouiller la composition | Un champ utilisé pour deux choses → sous-titre trop long ou lecture bizarre ; n'écrivez pas style (n'influence plus l'image, l'apparence est décidée par work/design.json) |
| P2 Vérification des faits | FACTS.md | Chaque chiffre/année à l'écran a une référence ; créer une liste noire "ne pas afficher" | Sauter cette étape → les chiffres à l'écran et la voix se contredisent, le premier commentaire vous donnera tort |
| P3 TTS | work/tts/*.mp3 + tts.json | Synthèse phrase par phrase edge-tts, mesure réelle de la durée avec ffmpeg -f f32le, cache disque | Estimer la durée par nombre de caractères → les sous-titres dérivent |
| P4 Construire la timeline | work/timeline.json + sub.srt | dur = lead + durée vocale réelle + tail, la ponctuation doit être dans ce segment | Segment trop court → la voix off est coupée par le segment suivant |
| P5 Mixage | work/audio.wav | Bus numpy : voix + musique + effets sonores superposés échantillon par échantillon | Mixage ffmpeg multi-entrées → silences non déterministes |
| P6 Maquette | 3 images fixes | Rendre d'abord 3 images pour vérifier la composition/palette/poids de la police, une fois décidé, rendre tout le film | Rendre directement tout le film → 36000 images plus tard, la police est moche |
| P7 Film final | out/film.mp4 | rawvideo→libx264 CRF18 + double passe loudnorm à -16 LUFS | Débit trop bas / son à -25 LUFS → "comme une ébauche" |
Remarque :
filmkit/render.pyutilise par défautcrf=18;assets/film.jsonn'est qu'un outil de test (pour la régression de la chaîne complète et des images de référence/barrières, pas un modèle à copier), il utilise 17. Quand vous écrivez votre propre scénario, si vous n'écrivez pascrf, c'est 18 ; pour des textes plus nets, vous pouvez écrire 17 (taille environ +30%).
3. Comment choisir le moteur de rendu
| Scénario | Choix | Vitesse réelle | Adapté à |
|---|---|---|---|
| Texte et images / données / vulgarisation / style UI | PIL (numpy+cv2 en aide) — par défaut filmkit | 40–80 fps @1080p30 (exemple 75) | La plupart des vidéos de vulgarisation chinoises, rapports transformés en vidéo |
| Nécessite des vecteurs/particules/3D/texture de papier fluides | Canvas2D/WebGL sans tête + capture CDP → image2pipe (webengine.py) | 80–130 ms/image (iGPU, capture PNG) | Longs métrages de niveau documentaire, nécessitant un langage visuel complexe |
| Animation pure de données | manim (si installé) | Selon la scène | Mathématiques / dérivation de formules |
Les vitesses ci-dessus sont mesurées sur une seule machine à vide (changement de machine, images plus lourdes modifient les résultats ; voir §12). Ne prenez pas les intervalles comme des garanties.
Le design ne couvre que le moteur PIL (
engine: pil, par défaut) : les couleurs/polices/mise en page/motifs dework/design.jsonsont lus parassets/scenes.py. La page du--engine webest écrite par vous (assets/web/n'est qu'un exemple fonctionnel, pas un modèle de production), l'image ne suit pas le design ; mais la musique et la voix suiventdesign.json(elles ne dépendent pas du moteur de rendu).
Par défaut, PIL : peu de dépendances (numpy+Pillow suffit), démarrage rapide, forte déterminisme, faible coût de débogage. Utilisez la solution navigateur uniquement pour "transformation fluide de particules / texture en temps réel avec shader" :
cp -r <SKILL_DIR>/assets/web ./web # Modèle de page (Canvas2D natif, zéro bibliothèque tierce)
pip install playwright && python -m playwright install chromium # Nécessaire seulement avec le moteur web
python render.py ... --engine web --stage cues # Exporter les points de repère depuis la page
python render.py ... --engine web --stage frames --jobs 3 --resume
python webengine.py textqa ./web/index.html # Texte qui se chevauche / sort du cadre / disparaît / n'apparaît jamais
Compromis entre les deux voies (réfléchissez d'abord, changer de moteur nécessite de refaire l'image) :
PIL (engine: pil) | Navigateur (engine: web) | |
|---|---|---|
| Dépendances | numpy + Pillow (+cv2 pour comparaison de netteté) | + playwright + Chromium/Edge |
| Vitesse | 40–80 images/s | 8–12 images/s (capture d'écran image par image + encodage, un ordre de grandeur plus lent) |
| Langage visuel | Composition bitmap, dégradés, géométrie, texte | Canvas2D/WebGL : particules, transformations, shaders, animations vectorielles |
| Déterminisme | Naturel (fonction pure pour dessiner) | Garanti en cherchant précisément la timeline à f/FPS (window.renderFrame) |
| Débogage | Modifier une image et l'exécuter | Dans le navigateur, index.html?dev=1&t=secondes pour voir directement, les bugs sont faciles à trouver |
| Adapté à | Texte et images / données / vulgarisation / vidéos promotionnelles | Longs métrages de niveau documentaire, nécessitant un langage visuel fluide |
Trois contraintes strictes pour le moteur navigateur (déjà implémentées dans webengine.py, à respecter aussi si vous écrivez votre propre page) :
- Toutes les interpolations sont placées sur une timeline principale en pause ; rendre l'image f = la chercher à
f / FPSsecondes ; interdiction de dépendre de la lecture. - Les contenus programmatiques comme les boucles/particules/vagues utilisent un "calcul direct selon le temps" (
update(fn)), interdiction d'utilisersetIntervalpour accumuler. - La page doit exposer
window.ready / DURATION / renderFrame(f) / getCues(); pas de CDN, pas de requête réseau.
Pour une reproductibilité au niveau octet entre sessions navigateur (crash et redémarrage, rendu multiple pour diff, QA compare deux images), activez le mode déterministe :
--deterministic ou variable d'environnement WEBENGINE_DETERMINISTIC=1 (désactive le GPU raster, testé 8/8 sessions cohérentes,
coût environ +22%/image). Désactivé par défaut : au sein d'une même session, c'est déjà cohérent ; entre sessions, seulement ≤4 pixels, ±1 niveau de différence d'anticrénelage.
4. Liste de contrôle qualité (ces éléments déterminent si "ça ressemble à une vidéo professionnelle")
- Luminosité et profondeur : fond sombre (
#0a1420) + accent lumineux en haut ; évitez le noir/blanc pur, l'écran de l'utilisateur mangera les dégradés. - Poids de la police et taille : gros texte 84–96px, sous-titres 34–38px, sous-titres 46px ; chinois utilisez
msyhbd.ttc(Microsoft YaHei Bold). - Sous-titres : ≤ 2 lignes, environ 24 caractères chinois par ligne, avec fond semi-transparent,
y ≥ 872. Le retour à la ligne réel est basé sur la largeur en pixels (fit_sub_linesutilisemax_w=1560 pxpour le retour automatique), 24 caractères est une "limite approximative" pas une validation stricte ——la constanteSUB_MAX_CHARS=24n'est actuellement référencée par aucun code, ne comptez pas sur elle pour vous arrêter. - Rythme : un changement visuel toutes les 3-6 secondes (nouvelle carte / déplacement / défilement de chiffres) ; statique plus de 6 secondes, le spectateur part.
- Caméra : seulement un lent zoom de 3-15%, pas de grands mouvements avant/arrière.
- Étalonnage : une couleur principale + une couleur d'accent pour tout le film, ne dépassez pas 4 teintes.
- Audio : master -16 LUFS / crête vraie ≤ -1 dBTP ; voix off 12 dB au-dessus du silence ; marge de 1,4 dB avant AAC.
5. Son : musique + effets sonores + points de repère
L'idée de cette section vient d'un retour d'expérience sur une vraie vidéo (
ai-history-film) : un mauvais son n'est pas "pas assez fort", mais "le son ne connaît pas l'image". L'ordre de résolution est toujours : d'abord faire savoir au son ce qui se passe à l'image (points de repère), ensuite parler du timbre, enfin du master.
5.1 Trois couches sonores
| Couche | Qui fait | Sauvegarde | Description |
|---|---|---|---|
| Voix off | filmkit.tts (edge-tts) | work/tts/*.mp3 | Seule autorité de durée : la timeline est dérivée de celle-ci |
| Musique | musicgen.py (orchestration paramétrique) | work/music.wav | Générée sur place selon les paramètres bpm/key/scale/mood/seed du design + paramètres d'orchestration ; pas de style musical prêt à l'emploi |
| Effets sonores | sfxkit.py (modélisation physique) | work/sfx.wav | Synthétisés un par un selon les points de repère enregistrés dans cues.json |
Les trois bus sont mixés dans --stage audio : quand un effet sonore se déclenche, la musique s'efface automatiquement (duck_db par défaut 4 dB,
cinema/epic 5.5 dB — avec une atténuation de 3 dB, la musique est presque au même niveau que l'effet sonore pendant la transition, l'audition devient confuse),
puis confié à ffmpeg double passe loudnorm à -16 LUFS / crête vraie ≤ -1 dBTP. Ne normalisez pas vous-même le niveau final dans numpy —
loudnorm est bien plus précis que la normalisation manuelle, et la crête vraie est la barrière.
Séparation intra-film : la musique a déjà des transitions aux limites des segments (swell / riser / sub_boom écrits par l'orchestrateur), la piste sfx ne répète pas whoosh/riser à la même limite —
--stage sfxapplique automatiquement -3 dB (sfx.duck_transitions=True) aux cues de transition qui tombent dans [limite - 2.2 s, limite].