diff --git a/docs/superpowers/plans/2026-08-31-stopmotion-script.md b/docs/superpowers/plans/2026-08-31-stopmotion-script.md new file mode 100644 index 0000000..549594b --- /dev/null +++ b/docs/superpowers/plans/2026-08-31-stopmotion-script.md @@ -0,0 +1,296 @@ +# Plan d'implémentation — Script réutilisable stop motion + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal :** un script bash `stopmotion.sh` qui génère une vidéo stop motion MP4 (H.264) à partir d'un dossier d'images (jpg/jpeg/png/heic), avec options CLI. + +**Architecture :** un seul fichier `stopmotion.sh` à la racine. Découverte des images via `find` trié → fichier `ffconcat` temporaire (une entrée `duration` par image, dernière image répétée — contournement du concat demuxer qui ignore sinon la dernière frame) → encodage ffmpeg (libx264, yuv420p, +faststart). Le fichier ffconcat est créé dans le dossier du fichier de sortie (les chemins d'images y sont absolus, le concat demuxer résout les chemins relativement à l'emplacement de la liste). + +**Tech Stack :** bash 3.2 (bash système de macOS — pas de tableaux associatifs, pas de `${var,,}`), ffmpeg 8.x (Homebrew, décode le HEIC), `find` BSD. + +**Spec :** `docs/superpowers/specs/2026-08-31-stopmotion-script-design.md` + +## Global Constraints + +- Messages et aide en français ; codes de sortie non nuls pour toute erreur. +- Aucun commentaire dans le code. +- Compatible bash 3.2 (macOS) : pas de fonctionnalités bash 4+. +- Fonctionnement vérifié avec le build ffmpeg 8.1.2 Homebrew (`-pattern_type glob` non supporté → concat demuxer obligatoire). +- Encodage : `-c:v libx264 -pix_fmt yuv420p -movflags +faststart`, filtre `scale=S:S:force_original_aspect_ratio=decrease:force_divisible_by=2`. +- Validation stricte avant tout traitement : `-f` et `-s` numériques strictement positifs. +- Scénario de référence (validation finale) : `./images` (42 HEIC 3056x3056) sans option → 42 frames, 12 fps, 1080x1080, ~3,5 s. + +--- + +### Task 1 : Squelette du script — interface, validation, garde-fous + +**Files:** +- Create: `stopmotion.sh` + +**Interfaces:** +- Consumes: rien (première tâche). +- Produces: script exécutable avec variables globales `INPUT`, `OUTPUT`, `FPS`, `SIZE`, `ASSUME_YES` (définies par `getopts` et validées), fonction `usage`. Task 2 appende la logique après les garde-fous. + +- [ ] **Step 1 : Écrire le squelette du script** + +Créer `stopmotion.sh` avec exactement ce contenu : + +```bash +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat < 0 (défaut : 12) + -s SIZE taille max du côté long en pixels, > 0 (défaut : 1080) + -y écraser le fichier de sortie sans demander + -h afficher cette aide +EOF +} + +INPUT="images" +OUTPUT="stop_motion.mp4" +FPS="12" +SIZE="1080" +ASSUME_YES=0 + +while getopts ":i:o:f:s:yh" opt; do + case $opt in + i) INPUT=$OPTARG ;; + o) OUTPUT=$OPTARG ;; + f) FPS=$OPTARG ;; + s) SIZE=$OPTARG ;; + y) ASSUME_YES=1 ;; + h) usage ; exit 0 ;; + \?) echo "Erreur : option inconnue -$OPTARG" >&2 ; usage >&2 ; exit 1 ;; + :) echo "Erreur : valeur manquante pour -$OPTARG" >&2 ; usage >&2 ; exit 1 ;; + esac +done + +if ! [[ $FPS =~ ^[0-9]+([.][0-9]+)?$ ]] || ! awk -v f="$FPS" 'BEGIN{exit !(f>0)}'; then + echo "Erreur : -f doit être un nombre strictement positif (reçu : $FPS)" >&2 + exit 1 +fi +if ! [[ $SIZE =~ ^[0-9]+$ ]] || ! awk -v s="$SIZE" 'BEGIN{exit !(s>0)}'; then + echo "Erreur : -s doit être un entier strictement positif (reçu : $SIZE)" >&2 + exit 1 +fi + +if ! command -v ffmpeg >/dev/null 2>&1; then + echo "Erreur : ffmpeg est introuvable. Installation : brew install ffmpeg" >&2 + exit 1 +fi +if [ ! -d "$INPUT" ]; then + echo "Erreur : le dossier d'images '$INPUT' n'existe pas." >&2 + exit 1 +fi +``` + +- [ ] **Step 2 : Rendre le script exécutable** + +```bash +chmod +x stopmotion.sh +``` + +- [ ] **Step 3 : Tester l'aide et les erreurs** + +```bash +./stopmotion.sh -h +``` +Attendu : l'aide s'affiche, exit 0. + +```bash +./stopmotion.sh -z; echo "exit=$?" +``` +Attendu : `Erreur : option inconnue -z`, usage sur stderr, `exit=1`. + +```bash +./stopmotion.sh -f; echo "exit=$?" +``` +Attendu : `Erreur : valeur manquante pour -f`, `exit=1`. + +```bash +./stopmotion.sh -f 0; echo "exit=$?" +``` +Attendu : `Erreur : -f doit être un nombre strictement positif (reçu : 0)`, `exit=1`. + +```bash +./stopmotion.sh -f abc; echo "exit=$?" +``` +Attendu : `Erreur : -f doit être un nombre strictement positif (reçu : abc)`, `exit=1`. + +```bash +./stopmotion.sh -s -5; echo "exit=$?" +``` +Attendu : `Erreur : -s doit être un entier strictement positif (reçu : -5)`, `exit=1`. + +```bash +PATH=/usr/bin:/bin ./stopmotion.sh -i images; echo "exit=$?" +``` +Attendu : `Erreur : ffmpeg est introuvable. Installation : brew install ffmpeg`, `exit=1` (le dossier `images` existe, c'est bien la vérification ffmpeg qui déclenche). + +```bash +./stopmotion.sh -i dossier_inexistant; echo "exit=$?" +``` +Attendu : `Erreur : le dossier d'images 'dossier_inexistant' n'existe pas.`, `exit=1`. + +- [ ] **Step 4 : Commit** + +```bash +git add stopmotion.sh +git commit -m "feat: squelette stopmotion.sh (options, validation, garde-fous)" +``` + +--- + +### Task 2 : Pipeline — découverte des images, ffconcat, encodage, récapitulatif + +**Files:** +- Modify: `stopmotion.sh` (appende après le bloc de garde-fous de Task 1) + +**Interfaces:** +- Consumes: variables `INPUT`, `OUTPUT`, `FPS`, `SIZE`, `ASSUME_YES` et fonction `usage` définies en Task 1 (noms exacts). +- Produces: script complet conforme à la spec ; sortie vidéo MP4 encodée + message récapitulatif `Vidéo créée : ( images, fps, s, )`. + +- [ ] **Step 1 : Ajouter la découverte des images et la gestion d'écrasement** + +Après la ligne `fi` du garde-fou sur `$INPUT` (fin de Task 1), ajouter : + +```bash +IMAGES_ABS_DIR=$(cd "$INPUT" && pwd) +IMAGES=$(LC_ALL=C find "$IMAGES_ABS_DIR" -maxdepth 1 -type f \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.heic' \) | LC_ALL=C sort) +if [ -z "$IMAGES" ]; then + echo "Erreur : aucune image (jpg, jpeg, png, heic) trouvée dans '$INPUT'." >&2 + exit 1 +fi +COUNT=$(printf '%s\n' "$IMAGES" | wc -l | tr -d ' ') + +if [ ! -d "$(dirname "$OUTPUT")" ]; then + echo "Erreur : le dossier de sortie '$(dirname "$OUTPUT")' n'existe pas." >&2 + exit 1 +fi + +if [ -e "$OUTPUT" ] && [ "$ASSUME_YES" -ne 1 ]; then + read -r -p "'$OUTPUT' existe déjà. L'écraser ? [o/N] " reponse + case $reponse in + o|O|y|Y|oui|Oui) ;; + *) echo "Abandon." ; exit 0 ;; + esac +fi +``` + +- [ ] **Step 2 : Ajouter la génération ffconcat et l'encodage** + +À la suite, ajouter : + +```bash +OUT_DIR=$(cd "$(dirname "$OUTPUT")" && pwd) +OUT_NAME=$(basename "$OUTPUT") +LIST_FILE=$(mktemp "$OUT_DIR/.stopmotion.XXXXXX") +trap 'rm -f "$LIST_FILE"' EXIT + +quote_ffconcat() { + local s=$1 + s=${s//\\/\\\\} + s=${s//\'/\'\\\'\'} + printf "file '%s'\n" "$s" +} + +DURATION=$(awk -v f="$FPS" 'BEGIN{printf "%.6f", 1/f}') +{ + echo "ffconcat version 1.0" + while IFS= read -r img; do + quote_ffconcat "$img" + printf 'duration %s\n' "$DURATION" + done <<< "$IMAGES" + last=$(printf '%s\n' "$IMAGES" | tail -n 1) + quote_ffconcat "$last" +} > "$LIST_FILE" + +if ! ffmpeg -y -hide_banner -loglevel error -stats \ + -f concat -safe 0 -i "$LIST_FILE" \ + -vf "scale=${SIZE}:${SIZE}:force_original_aspect_ratio=decrease:force_divisible_by=2" \ + -c:v libx264 -pix_fmt yuv420p -movflags +faststart \ + "$OUT_DIR/$OUT_NAME"; then + rm -f "$OUT_DIR/$OUT_NAME" + echo "Erreur : l'encodage ffmpeg a échoué." >&2 + exit 1 +fi + +DURATION_VIDEO=$(awk -v c="$COUNT" -v f="$FPS" 'BEGIN{printf "%.2f", c/f}') +SIZE_HUMAN=$(du -h "$OUT_DIR/$OUT_NAME" | cut -f1 | tr -d ' ') +echo "Vidéo créée : $OUT_NAME ($COUNT images, ${FPS} fps, ${DURATION_VIDEO} s, ${SIZE_HUMAN})" +``` + +- [ ] **Step 3 : Tester le scénario de référence** + +```bash +./stopmotion.sh -y -o test_output.mp4; echo "exit=$?" +``` +Attendu : progression ffmpeg puis `Vidéo créée : test_output.mp4 (42 images, 12 fps, 3.50 s, ...)` et `exit=0`. + +Vérifier les métadonnées : + +```bash +ffprobe -v error -select_streams v:0 -show_entries stream=width,height,r_frame_rate,nb_frames -show_entries format=duration -of default=noprint_wrappers=1 test_output.mp4 +``` +Attendu : `width=1080`, `height=1080`, `r_frame_rate=12/1`, `nb_frames=42`, `duration≈3.5`. + +Comparer au résultat de référence généré manuellement : + +```bash +ffprobe -v error -select_streams v:0 -show_entries stream=nb_frames -of default=noprint_wrappers=1:nokey=1 stop_motion.mp4 +``` +Attendu : `42` (identique au test_output.mp4). + +- [ ] **Step 4 : Tester le refus d'écrasement** + +```bash +before=$(stat -f %m test_output.mp4) +printf 'n\n' | ./stopmotion.sh -o test_output.mp4; echo "exit=$?" +after=$(stat -f %m test_output.mp4) +[ "$before" = "$after" ] && echo "fichier intact" +``` +Attendu : prompt `'test_output.mp4' existe déjà. L'écraser ? [o/N]`, puis `Abandon.`, `exit=0`, `fichier intact`. + +- [ ] **Step 5 : Tester les erreurs de dossier de sortie et dossier vide** + +```bash +./stopmotion.sh -y -o dossier_inexistant/out.mp4; echo "exit=$?" +``` +Attendu : `Erreur : le dossier de sortie 'dossier_inexistant' n'existe pas.`, `exit=1`. + +```bash +mkdir -p /tmp/stopmotion_vide && ./stopmotion.sh -y -i /tmp/stopmotion_vide -o test_output.mp4; echo "exit=$?"; rm -rf /tmp/stopmotion_vide +``` +Attendu : `Erreur : aucune image (jpg, jpeg, png, heic) trouvée dans '/tmp/stopmotion_vide'.`, `exit=1`. + +- [ ] **Step 6 : Vérifier le nettoyage du fichier temporaire** + +```bash +ls -a | grep -c '^\.stopmotion\.' || echo "aucun fichier temporaire résiduel" +``` +Attendu : `aucun fichier temporaire résiduel` (le trap EXIT a supprimé la liste ffconcat). + +- [ ] **Step 7 : Nettoyer et commit** + +```bash +rm -f test_output.mp4 +git add stopmotion.sh +git commit -m "feat: pipeline stop motion (découverte, ffconcat, encodage, récapitulatif)" +``` + +--- + +## Auto-revue du plan + +- **Couverture spec** : interface (Task 1) ; comportement 1-2 (Task 1 + Task 2 Step 1), 3 (Task 1 + Task 2 Step 1), 4 (Task 2 Steps 1-2, trap EXIT), 5 (Task 2 Step 2), 6 (Task 2 Step 2) ; gestion des erreurs (Tasks 1-2, tous les cas testés) ; validation scénario de référence (Task 2 Step 3). Aucun manque. +- **Placeholders** : aucun — code complet fourni dans chaque étape. +- **Cohérence** : noms de variables (`INPUT`, `OUTPUT`, `FPS`, `SIZE`, `ASSUME_YES`, `IMAGES`, `COUNT`, `LIST_FILE`) identiques entre Task 1 et Task 2.