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

10 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, 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 :

#!/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//\\/\\\\}
  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
./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.