Compare commits
10
Commits
87975c76bf
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a30d7f7f8a
|
||
|
|
a02330a30c
|
||
|
|
3845e868c6
|
||
|
|
e298825aac
|
||
|
|
51cb34a163
|
||
|
|
8c6d053aaf
|
||
|
|
516c2be4f2
|
||
|
|
c09769f0cc
|
||
|
|
393a04a85d
|
||
|
|
09012abab9
|
No files matched your search
@@ -1,3 +1,42 @@
|
||||
# stop-motion
|
||||
|
||||
shell script to create stop-motion videos from images
|
||||
A small shell script to turn a folder of images into a stop-motion video, using ffmpeg.
|
||||
|
||||
Give it a directory of photos (e.g. shot with an iPhone in burst mode), and it builds a single H.264 MP4: each image becomes one frame, played at a fixed frame rate.
|
||||
|
||||
## Requirements
|
||||
|
||||
- [ffmpeg](https://ffmpeg.org/) available in your `PATH` (on macOS: `brew install ffmpeg`)
|
||||
- Bash
|
||||
|
||||
Supported image formats: `jpg`, `jpeg`, `png`, `heic` (case-insensitive). Only files in the top level of the input directory are used; they are ordered by filename, so make sure the names reflect the intended play order (e.g. `IMG_0001`, `IMG_0002`, ...).
|
||||
|
||||
## Usage
|
||||
|
||||
```sh
|
||||
./stopmotion.sh # ./images -> ./stop_motion.mp4, 12 fps, 1080 px
|
||||
./stopmotion.sh -i photos -o film.mp4 -f 15 -s 720 -y
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `-i DIR` | `./images` | Directory containing the images |
|
||||
| `-o FILE` | `./stop_motion.mp4` | Output video file |
|
||||
| `-f FPS` | `12` | Frame rate (frames per second, > 0) |
|
||||
| `-s SIZE` | `1080` | Maximum size of the longest side in pixels (aspect ratio preserved) |
|
||||
| `-y` | off | Overwrite the output file without asking |
|
||||
| `-h` | — | Show help |
|
||||
|
||||
## How it works
|
||||
|
||||
1. Validates options and checks that `ffmpeg` is installed.
|
||||
2. Lists the images in the input directory (top level only, sorted by name) and fails with a clear error if none are found.
|
||||
3. Generates a temporary [ffconcat](https://ffmpeg.org/ffmpeg-formats.html#concat) playlist giving each image a duration of `1/fps`.
|
||||
4. Encodes with ffmpeg: `libx264`, `yuv420p`, `+faststart` (web-friendly MP4), frames scaled down to fit `SIZE` while keeping the aspect ratio and even dimensions.
|
||||
5. Prints a summary: image count, fps, video duration and output file size.
|
||||
|
||||
If the output file already exists, the script asks for confirmation unless `-y` is given. Any error exits with a non-zero status and a failed encode leaves no partial file behind.
|
||||
|
||||
## License
|
||||
|
||||
GPL-3.0 — see [LICENSE](LICENSE).
|
||||
@@ -0,0 +1,294 @@
|
||||
# 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.
|
||||
|
||||
- [x] **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 <<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
|
||||
```
|
||||
|
||||
- [x] **Step 2 : Rendre le script exécutable**
|
||||
|
||||
```bash
|
||||
chmod +x stopmotion.sh
|
||||
```
|
||||
|
||||
- [x] **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`.
|
||||
|
||||
- [x] **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 : <fichier> (<N> images, <fps> fps, <durée> s, <taille>)`.
|
||||
|
||||
- [x] **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
|
||||
```
|
||||
|
||||
- [x] **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//\'/\'\\\'\'}
|
||||
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})"
|
||||
```
|
||||
|
||||
- [x] **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).
|
||||
|
||||
- [x] **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`.
|
||||
|
||||
- [x] **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`.
|
||||
|
||||
- [x] **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).
|
||||
|
||||
- [x] **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.
|
||||
@@ -0,0 +1,55 @@
|
||||
# Design — Script réutilisable de stop motion
|
||||
|
||||
Date : 2026-08-31
|
||||
Statut : validé par l'utilisateur
|
||||
|
||||
## Contexte
|
||||
|
||||
Le projet contient un dossier `images/` (42 photos HEIC 3056x3056, prises par iPhone, triées par nom = ordre chronologique) et une vidéo `stop_motion.mp4` générée manuellement avec ffmpeg (12 fps, 1080x1080, 3,5 s). Le build ffmpeg installé (8.1.2, Homebrew) décode le HEIC mais ne supporte pas `-pattern_type glob` ; la génération manuelle a utilisé le concat demuxer avec un fichier `ffconcat`.
|
||||
|
||||
Objectif : un script réutilisable pour regénérer la vidéo depuis n'importe quel dossier d'images, sans dépendance au-delà de ffmpeg.
|
||||
|
||||
## Décisions
|
||||
|
||||
- **Approche choisie** : script bash unique (`stopmotion.sh`) à la racine. Les alternatives (Python/argparse, Makefile) ont été écartées : bash suffit, ffmpeg fait tout le travail, aucune dépendance supplémentaire.
|
||||
- **Usage visé** : flexible — détection automatique des formats d'images, options CLI simples.
|
||||
|
||||
## Interface
|
||||
|
||||
```
|
||||
./stopmotion.sh # ./images → ./stop_motion.mp4, 12 fps, 1080px
|
||||
./stopmotion.sh -i photos -o film.mp4 -f 15 -s 720 -y
|
||||
```
|
||||
|
||||
| Option | Défaut | Rôle |
|
||||
|--------|--------|------|
|
||||
| `-i` | `./images` | dossier d'images |
|
||||
| `-o` | `./stop_motion.mp4` | fichier de sortie |
|
||||
| `-f` | `12` | fps (cadence stop motion) |
|
||||
| `-s` | `1080` | taille max du côté long, ratio préservé |
|
||||
| `-y` | off | écraser la sortie sans demander |
|
||||
| `-h` | — | aide |
|
||||
|
||||
## Comportement
|
||||
|
||||
1. Vérifie la présence de `ffmpeg` dans le PATH ; sinon message clair + exit 1.
|
||||
2. Liste les images du dossier d'entrée (extensions `jpg`, `jpeg`, `png`, `heic`, insensible à la casse, premier niveau uniquement), triées par nom (`LC_ALL=C sort`).
|
||||
3. Erreur explicite si le dossier n'existe pas ou ne contient aucune image (exit 1).
|
||||
4. Génère un fichier `ffconcat` temporaire (mktemp + trap de nettoyage) : en-tête `ffconcat version 1.0`, puis pour chaque image `file <chemin>` + `duration 1/fps`. Les chemins contenant des apostrophes sont échappés (`'` → `'\''`). (Amendement du 2026-08-31, arbitrage utilisateur : la « dernière image répétée » prévue initialement créait une 43e frame dupliquée sur ce build ffmpeg — timebase 1/25 des HEIC ; la cadence finale est garantie par `-r FPS` en sortie, qui couvre aussi la dernière frame.)
|
||||
5. Encode avec ffmpeg : `-f concat -safe 0`, `libx264`, `yuv420p`, `-movflags +faststart`, `-r FPS`, filtre `scale=S:S:force_original_aspect_ratio=decrease:force_divisible_by=2`.
|
||||
6. Affiche un récapitulatif : nombre d'images, fps, durée de la vidéo, taille du fichier de sortie.
|
||||
|
||||
Pour des fps élevés (ex. `-f 30`), le mapping CFR peut dupliquer une image (arrondi au créneau le plus proche face à la timebase 1/25 des HEIC) sans jamais en perdre ; les valeurs `-f` 12/15/20/24 ont été vérifiées exactes sur le corpus de référence.
|
||||
|
||||
## Gestion des erreurs
|
||||
|
||||
- Options inconnues ou valeur manquante → usage + exit 1.
|
||||
- Valeurs non numériques ou non strictement positives pour `-f` et `-s` → erreur + exit 1 (avant tout traitement).
|
||||
- Dossier d'entrée inexistant ou sans image → message en français + exit 1.
|
||||
- Échec d'encodage ffmpeg → le fichier de sortie est supprimé s'il est partiel, exit 1.
|
||||
- Sortie existante sans `-y` → demande confirmation (refus = exit 0 sans rien faire).
|
||||
- Codes de sortie non nuls pour toute erreur.
|
||||
|
||||
## Validation
|
||||
|
||||
Rejouer le scénario de référence : `./stopmotion.sh` sans option sur ce projet doit produire une vidéo équivalente à l'actuelle `stop_motion.mp4` (42 frames, 12 fps, 1080x1080, ~3,5 s). Vérifier aussi : `-h`, dossier inexistant, dossier sans image, sortie refusée sans `-y`.
|
||||
Executable
+113
@@ -0,0 +1,113 @@
|
||||
#!/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
|
||||
|
||||
OUTPUT=${OUTPUT%/}
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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})"
|
||||
Reference in new issue
Block a user