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 globnon supporté → concat demuxer obligatoire). - Encodage :
-c:v libx264 -pix_fmt yuv420p -movflags +faststart,-r "$FPS"en sortie, filtrescale=S:S:force_original_aspect_ratio=decrease:force_divisible_by=2. - Validation stricte avant tout traitement :
-fet-snumé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 pargetoptset validées), fonctionusage. 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_YESet fonctionusagedé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.