Initial movmaker script
This commit is contained in:
commit
5b37d43665
3 changed files with 443 additions and 0 deletions
22
.gitignore
vendored
Normal file
22
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# Generated videos / outputs
|
||||||
|
output/
|
||||||
|
*.mp4
|
||||||
|
*.mov
|
||||||
|
*.mkv
|
||||||
|
*.avi
|
||||||
|
*.webm
|
||||||
|
|
||||||
|
# Local input media
|
||||||
|
input/
|
||||||
|
media/
|
||||||
|
|
||||||
|
# OS/editor
|
||||||
|
.DS_Store
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
115
README.md
Normal file
115
README.md
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
# movmaker
|
||||||
|
|
||||||
|
Simple Python + ffmpeg movie maker.
|
||||||
|
|
||||||
|
Drop pictures, videos, music, and optionally one simple `data.txt` file into a single flat input directory. Then run one command to create an MP4 video.
|
||||||
|
|
||||||
|
No YAML. No required subdirectories.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Python 3
|
||||||
|
- `ffmpeg`
|
||||||
|
- `ffprobe` usually included with ffmpeg
|
||||||
|
|
||||||
|
Install on Debian/Ubuntu/Raspberry Pi OS:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install ffmpeg
|
||||||
|
```
|
||||||
|
|
||||||
|
## Input folder
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
input/
|
||||||
|
IMG_001.jpg
|
||||||
|
IMG_002.png
|
||||||
|
holiday_clip.mp4
|
||||||
|
music.mp3
|
||||||
|
data.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported file types:
|
||||||
|
|
||||||
|
- Images: `.jpg`, `.jpeg`, `.png`, `.webp`, `.bmp`, `.tif`, `.tiff`
|
||||||
|
- Videos: `.mp4`, `.mov`, `.mkv`, `.avi`, `.webm`, `.m4v`
|
||||||
|
- Audio: `.mp3`, `.wav`, `.m4a`, `.aac`, `.flac`, `.ogg`
|
||||||
|
- Data: `data.txt`, `info.txt`, or `title.txt`
|
||||||
|
|
||||||
|
Files are ordered by filename, so names like this work well:
|
||||||
|
|
||||||
|
```text
|
||||||
|
001.jpg
|
||||||
|
002.jpg
|
||||||
|
003.mp4
|
||||||
|
004.jpg
|
||||||
|
```
|
||||||
|
|
||||||
|
## data.txt
|
||||||
|
|
||||||
|
`data.txt` is optional.
|
||||||
|
|
||||||
|
Simplest format:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Paris Trip
|
||||||
|
May 2024
|
||||||
|
Paris, France
|
||||||
|
```
|
||||||
|
|
||||||
|
Those three lines mean:
|
||||||
|
|
||||||
|
1. title
|
||||||
|
2. date
|
||||||
|
3. place
|
||||||
|
|
||||||
|
Key/value format also works:
|
||||||
|
|
||||||
|
```text
|
||||||
|
title: Paris Trip
|
||||||
|
date: May 2024
|
||||||
|
place: Paris, France
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional per-file captions may be supported, but they are not required:
|
||||||
|
|
||||||
|
```text
|
||||||
|
IMG_001.jpg: Eiffel Tower
|
||||||
|
IMG_002.jpg: Louvre
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Basic:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 movmaker.py input --output movie.mp4
|
||||||
|
```
|
||||||
|
|
||||||
|
With options:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 movmaker.py input \
|
||||||
|
--output output/movie.mp4 \
|
||||||
|
--image-duration 3 \
|
||||||
|
--fade 1 \
|
||||||
|
--resolution 1920x1080 \
|
||||||
|
--fps 30
|
||||||
|
```
|
||||||
|
|
||||||
|
## Current behavior
|
||||||
|
|
||||||
|
- scans one flat input directory
|
||||||
|
- auto-detects media/audio/data files by extension
|
||||||
|
- shows each image for 3 seconds by default
|
||||||
|
- includes video clips inline
|
||||||
|
- crossfades between media items
|
||||||
|
- loops/trims music to match the video
|
||||||
|
- overlays title/date/place at the beginning
|
||||||
|
- writes an MP4 file
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
This is an early version. The intended workflow is deliberately simple: dump files into one folder and run the script.
|
||||||
306
movmaker.py
Executable file
306
movmaker.py
Executable file
|
|
@ -0,0 +1,306 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Simple flat-folder photo/video movie maker using ffmpeg.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python movmaker.py input --output movie.mp4
|
||||||
|
|
||||||
|
Put images, videos, audio files, and optionally data.txt in one directory.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tif", ".tiff"}
|
||||||
|
VIDEO_EXTS = {".mp4", ".mov", ".mkv", ".avi", ".webm", ".m4v"}
|
||||||
|
AUDIO_EXTS = {".mp3", ".wav", ".m4a", ".aac", ".flac", ".ogg"}
|
||||||
|
DATA_NAMES = {"data.txt", "info.txt", "title.txt"}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MovieData:
|
||||||
|
title: str = ""
|
||||||
|
date: str = ""
|
||||||
|
place: str = ""
|
||||||
|
captions: dict[str, str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MediaItem:
|
||||||
|
path: Path
|
||||||
|
kind: str # image or video
|
||||||
|
duration: float
|
||||||
|
|
||||||
|
|
||||||
|
def run(cmd: list[str]) -> None:
|
||||||
|
print("+", shlex.join(cmd))
|
||||||
|
subprocess.run(cmd, check=True)
|
||||||
|
|
||||||
|
|
||||||
|
def capture(cmd: list[str]) -> str:
|
||||||
|
return subprocess.check_output(cmd, text=True).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def require_tool(name: str) -> None:
|
||||||
|
try:
|
||||||
|
subprocess.run([name, "-version"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
|
||||||
|
except Exception:
|
||||||
|
raise SystemExit(f"Error: required tool not found: {name}")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_resolution(value: str) -> tuple[int, int]:
|
||||||
|
try:
|
||||||
|
w, h = value.lower().split("x", 1)
|
||||||
|
return int(w), int(h)
|
||||||
|
except Exception:
|
||||||
|
raise argparse.ArgumentTypeError("resolution must look like 1920x1080")
|
||||||
|
|
||||||
|
|
||||||
|
def ffprobe_duration(path: Path) -> float:
|
||||||
|
try:
|
||||||
|
out = capture([
|
||||||
|
"ffprobe", "-v", "error",
|
||||||
|
"-show_entries", "format=duration",
|
||||||
|
"-of", "json",
|
||||||
|
str(path),
|
||||||
|
])
|
||||||
|
data = json.loads(out)
|
||||||
|
return max(0.1, float(data["format"]["duration"]))
|
||||||
|
except Exception:
|
||||||
|
return 3.0
|
||||||
|
|
||||||
|
|
||||||
|
def natural_key(path: Path) -> list[object]:
|
||||||
|
# Simple natural-ish sort: IMG_2 before IMG_10.
|
||||||
|
import re
|
||||||
|
parts = re.split(r"(\d+)", path.name.lower())
|
||||||
|
return [int(p) if p.isdigit() else p for p in parts]
|
||||||
|
|
||||||
|
|
||||||
|
def scan_input(input_dir: Path, image_duration: float) -> tuple[list[MediaItem], list[Path], MovieData]:
|
||||||
|
if not input_dir.is_dir():
|
||||||
|
raise SystemExit(f"Input directory does not exist: {input_dir}")
|
||||||
|
|
||||||
|
media: list[MediaItem] = []
|
||||||
|
audio: list[Path] = []
|
||||||
|
data_file: Path | None = None
|
||||||
|
|
||||||
|
for path in sorted(input_dir.iterdir(), key=natural_key):
|
||||||
|
if not path.is_file():
|
||||||
|
continue
|
||||||
|
ext = path.suffix.lower()
|
||||||
|
if path.name.lower() in DATA_NAMES:
|
||||||
|
data_file = path
|
||||||
|
elif ext in IMAGE_EXTS:
|
||||||
|
media.append(MediaItem(path=path, kind="image", duration=image_duration))
|
||||||
|
elif ext in VIDEO_EXTS:
|
||||||
|
media.append(MediaItem(path=path, kind="video", duration=ffprobe_duration(path)))
|
||||||
|
elif ext in AUDIO_EXTS:
|
||||||
|
audio.append(path)
|
||||||
|
|
||||||
|
if not media:
|
||||||
|
raise SystemExit(f"No images or videos found in {input_dir}")
|
||||||
|
|
||||||
|
return media, audio, parse_data_file(data_file)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_data_file(path: Path | None) -> MovieData:
|
||||||
|
if not path or not path.exists():
|
||||||
|
return MovieData(captions={})
|
||||||
|
|
||||||
|
lines = [line.strip() for line in path.read_text(encoding="utf-8", errors="replace").splitlines()]
|
||||||
|
lines = [line for line in lines if line and not line.startswith("#")]
|
||||||
|
|
||||||
|
data = MovieData(captions={})
|
||||||
|
plain: list[str] = []
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
if ":" in line:
|
||||||
|
key, value = line.split(":", 1)
|
||||||
|
key = key.strip()
|
||||||
|
value = value.strip()
|
||||||
|
low = key.lower()
|
||||||
|
if low in {"title", "name"}:
|
||||||
|
data.title = value
|
||||||
|
elif low in {"date", "dates", "when"}:
|
||||||
|
data.date = value
|
||||||
|
elif low in {"place", "location", "where"}:
|
||||||
|
data.place = value
|
||||||
|
elif Path(key).suffix.lower() in IMAGE_EXTS | VIDEO_EXTS:
|
||||||
|
data.captions[key] = value
|
||||||
|
else:
|
||||||
|
plain.append(line)
|
||||||
|
else:
|
||||||
|
plain.append(line)
|
||||||
|
|
||||||
|
# Simple mode: first three non-key lines are title/date/place.
|
||||||
|
if plain:
|
||||||
|
data.title = data.title or plain[0]
|
||||||
|
if len(plain) > 1:
|
||||||
|
data.date = data.date or plain[1]
|
||||||
|
if len(plain) > 2:
|
||||||
|
data.place = data.place or plain[2]
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def drawtext_escape(text: str) -> str:
|
||||||
|
# Escape for ffmpeg drawtext text='...'
|
||||||
|
return text.replace("\\", "\\\\").replace(":", "\\:").replace("'", "\\'").replace("%", "\\%")
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_clip(item: MediaItem, out: Path, width: int, height: int, fps: int) -> None:
|
||||||
|
vf = (
|
||||||
|
f"scale={width}:{height}:force_original_aspect_ratio=decrease,"
|
||||||
|
f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:black,"
|
||||||
|
f"fps={fps},format=yuv420p,setsar=1"
|
||||||
|
)
|
||||||
|
|
||||||
|
if item.kind == "image":
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg", "-y",
|
||||||
|
"-loop", "1", "-t", f"{item.duration:.3f}", "-i", str(item.path),
|
||||||
|
"-an", "-vf", vf,
|
||||||
|
"-c:v", "libx264", "-preset", "veryfast", "-crf", "20",
|
||||||
|
str(out),
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg", "-y",
|
||||||
|
"-i", str(item.path),
|
||||||
|
"-an", "-vf", vf,
|
||||||
|
"-c:v", "libx264", "-preset", "veryfast", "-crf", "20",
|
||||||
|
str(out),
|
||||||
|
]
|
||||||
|
run(cmd)
|
||||||
|
|
||||||
|
|
||||||
|
def concat_or_mix_audio(audio_files: list[Path], out: Path) -> Path | None:
|
||||||
|
if not audio_files:
|
||||||
|
return None
|
||||||
|
if len(audio_files) == 1:
|
||||||
|
return audio_files[0]
|
||||||
|
|
||||||
|
list_file = out.with_suffix(".txt")
|
||||||
|
def esc(p: Path) -> str:
|
||||||
|
return str(p.resolve()).replace("'", "'\\''")
|
||||||
|
list_file.write_text("".join(f"file '{esc(p)}'\n" for p in audio_files), encoding="utf-8")
|
||||||
|
run([
|
||||||
|
"ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(list_file),
|
||||||
|
"-vn", "-c:a", "aac", "-b:a", "192k", str(out),
|
||||||
|
])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def build_video(
|
||||||
|
clips: list[Path],
|
||||||
|
durations: list[float],
|
||||||
|
audio: Path | None,
|
||||||
|
output: Path,
|
||||||
|
data: MovieData,
|
||||||
|
fade: float,
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
fps: int,
|
||||||
|
) -> None:
|
||||||
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
cmd = ["ffmpeg", "-y"]
|
||||||
|
for clip in clips:
|
||||||
|
cmd += ["-i", str(clip)]
|
||||||
|
if audio:
|
||||||
|
cmd += ["-stream_loop", "-1", "-i", str(audio)]
|
||||||
|
|
||||||
|
filters: list[str] = []
|
||||||
|
n = len(clips)
|
||||||
|
|
||||||
|
if n == 1:
|
||||||
|
current = "0:v"
|
||||||
|
else:
|
||||||
|
current = "v01"
|
||||||
|
offset = max(0.0, durations[0] - fade)
|
||||||
|
filters.append(f"[0:v][1:v]xfade=transition=fade:duration={fade:.3f}:offset={offset:.3f}[{current}]")
|
||||||
|
combined_duration = durations[0] + durations[1] - fade
|
||||||
|
for i in range(2, n):
|
||||||
|
next_label = f"v{i:02d}"
|
||||||
|
offset = max(0.0, combined_duration - fade)
|
||||||
|
filters.append(f"[{current}][{i}:v]xfade=transition=fade:duration={fade:.3f}:offset={offset:.3f}[{next_label}]")
|
||||||
|
current = next_label
|
||||||
|
combined_duration += durations[i] - fade
|
||||||
|
|
||||||
|
title_lines = [x for x in [data.title, data.date, data.place] if x]
|
||||||
|
final_label = "vout"
|
||||||
|
if title_lines:
|
||||||
|
text = drawtext_escape("\\n".join(title_lines))
|
||||||
|
# Draw centered title for first 5 seconds with a translucent box.
|
||||||
|
filters.append(
|
||||||
|
f"[{current}]drawtext=text='{text}':x=(w-text_w)/2:y=(h-text_h)/2:"
|
||||||
|
f"fontsize={max(32, height // 18)}:fontcolor=white:box=1:boxcolor=black@0.45:boxborderw=24:"
|
||||||
|
f"enable='between(t,0,5)'[{final_label}]"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
filters.append(f"[{current}]copy[{final_label}]")
|
||||||
|
|
||||||
|
cmd += ["-filter_complex", ";".join(filters), "-map", f"[{final_label}]"]
|
||||||
|
|
||||||
|
if audio:
|
||||||
|
audio_index = len(clips)
|
||||||
|
cmd += ["-map", f"{audio_index}:a:0", "-c:a", "aac", "-b:a", "192k", "-shortest"]
|
||||||
|
else:
|
||||||
|
cmd += ["-an"]
|
||||||
|
|
||||||
|
cmd += ["-c:v", "libx264", "-preset", "veryfast", "-crf", "20", "-r", str(fps), "-pix_fmt", "yuv420p", str(output)]
|
||||||
|
run(cmd)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Make a video from a flat folder of images/videos/audio.")
|
||||||
|
parser.add_argument("input", type=Path, help="flat input directory")
|
||||||
|
parser.add_argument("--output", "-o", type=Path, default=Path("movie.mp4"), help="output mp4 path")
|
||||||
|
parser.add_argument("--image-duration", type=float, default=3.0, help="seconds each image is visible")
|
||||||
|
parser.add_argument("--fade", type=float, default=1.0, help="crossfade duration in seconds")
|
||||||
|
parser.add_argument("--resolution", type=parse_resolution, default=(1920, 1080), help="output resolution, e.g. 1920x1080")
|
||||||
|
parser.add_argument("--fps", type=int, default=30, help="output frames per second")
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
require_tool("ffmpeg")
|
||||||
|
require_tool("ffprobe")
|
||||||
|
|
||||||
|
width, height = args.resolution
|
||||||
|
media, audio_files, data = scan_input(args.input, args.image_duration)
|
||||||
|
fade = min(args.fade, max(0.0, args.image_duration - 0.1))
|
||||||
|
|
||||||
|
print(f"Found {len(media)} media file(s), {len(audio_files)} audio file(s).")
|
||||||
|
if data.title or data.date or data.place:
|
||||||
|
print("Title data:", " | ".join(x for x in [data.title, data.date, data.place] if x))
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory(prefix="movmaker-") as tmp_name:
|
||||||
|
tmp = Path(tmp_name)
|
||||||
|
clips: list[Path] = []
|
||||||
|
durations: list[float] = []
|
||||||
|
for idx, item in enumerate(media):
|
||||||
|
out = tmp / f"clip_{idx:04d}.mp4"
|
||||||
|
print(f"Preparing {item.kind}: {item.path.name}")
|
||||||
|
normalize_clip(item, out, width, height, args.fps)
|
||||||
|
clips.append(out)
|
||||||
|
# Use probed normalized duration for accuracy.
|
||||||
|
durations.append(ffprobe_duration(out))
|
||||||
|
|
||||||
|
audio = concat_or_mix_audio(audio_files, tmp / "music.m4a")
|
||||||
|
build_video(clips, durations, audio, args.output, data, fade, width, height, args.fps)
|
||||||
|
|
||||||
|
print(f"Done: {args.output}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Loading…
Add table
Add a link
Reference in a new issue