Files
stop-motion/docs/superpowers/plans/2026-08-31-stopmotion-script.md

11 KiB

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) → encodage ffmpeg (libx264, yuv420p, +faststart, -r FPS en sortie pour une cadence CFR qui garantit la durée complète de la dernière frame). 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). (Amendement 2026-08-31, arbitrage utilisateur : la répétition de la dernière image prévue initialement créait une 43e frame dupliquée avec la timebase 1/25 des HEIC — variante e4 validée par framehash.)

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, -r "$FPS" en sortie, 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 :

#!/usr/bin/env bash
set -euo pipefail

usage() {
  cat <<EOF
Usage : $(basename "$0") [options]

Crée une vidéo stop motion à partir d'un dossier d'images.

Options :
  -i DIR    dossier d'images (défaut : ./images)
  -o FILE   fichier de sortie (défaut : ./stop_motion.mp4)
  -f FPS    cadence en images/seconde, > 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
chmod +x stopmotion.sh
  • Step 3 : Tester l'aide et les erreurs
./stopmotion.sh -h

Attendu : l'aide s'affiche, exit 0.

./stopmotion.sh -z; echo "exit=$?"

Attendu : Erreur : option inconnue -z, usage sur stderr, exit=1.

./stopmotion.sh -f; echo "exit=$?"

Attendu : Erreur : valeur manquante pour -f, exit=1.

./stopmotion.sh -f 0; echo "exit=$?"

Attendu : Erreur : -f doit être un nombre strictement positif (reçu : 0), exit=1.

./stopmotion.sh -f abc; echo "exit=$?"

Attendu : Erreur : -f doit être un nombre strictement positif (reçu : abc), exit=1.

./stopmotion.sh -s -5; echo "exit=$?"

Attendu : Erreur : -s doit être un entier strictement positif (reçu : -5), exit=1.

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).

./stopmotion.sh -i dossier_inexistant; echo "exit=$?"

Attendu : Erreur : le dossier d'images 'dossier_inexistant' n'existe pas., exit=1.

  • Step 4 : Commit
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 : <fichier> (<N> images, <fps> fps, <durée> s, <taille>).

  • 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 :

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 :

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//\'/\'\\\'\'}
  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"
} > "$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" \
    -r "$FPS" \
    -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
./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 :

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 :

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
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
./stopmotion.sh -y -o dossier_inexistant/out.mp4; echo "exit=$?"

Attendu : Erreur : le dossier de sortie 'dossier_inexistant' n'existe pas., exit=1.

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
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
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.