Tune slideshow timing and title overlays

This commit is contained in:
hbrain 2026-05-24 19:56:30 +00:00
parent 51146f75b9
commit 54a474f278
4 changed files with 246 additions and 31 deletions

5
.gitignore vendored
View file

@ -12,9 +12,10 @@ out/
*.avi *.avi
*.webm *.webm
# Local input media # Local input media and bundled local tools
input/ input*/
media/ media/
ffmpeg/
# OS/editor # OS/editor
.DS_Store .DS_Store

10
AGENTS.md Normal file
View file

@ -0,0 +1,10 @@
# Project context for movmaker
## Notes
- This project will be a simple Python + ffmpeg movie maker.
- Desired workflow: user dumps all files into one flat input directory and runs a script; no manual YAML and no required subdirectories.
- Script should auto-detect images, videos, audio files, and an optional simple text data file for titles/dates/places. Default/simple data file content applies generally to the whole movie; optional per-file entries may be supported but must not be required.
## Guidelines
- Do not store sensitive secrets, passwords, API keys, or private credentials here.
- Never push changes to git unless explicitly asked.

View file

@ -85,16 +85,18 @@ IMG_002.jpg: Louvre
Basic: Basic:
```bash ```bash
python3 movmaker.py input --output movie.mp4 python3 movmaker.py input
``` ```
With options: With options:
```bash ```bash
python3 movmaker.py input \ python3 movmaker.py input \
--output out/movie.mp4 \ --out-dir out \
--image-duration 3 \ --image-duration 6 \
--fade 1 \ --fade 3 \
--audio-fade 10 \
--first-transition-at 12 \
--resolution 1920x1080 \ --resolution 1920x1080 \
--fps 30 --fps 30
``` ```
@ -103,12 +105,17 @@ python3 movmaker.py input \
- scans one flat input directory - scans one flat input directory
- auto-detects media/audio/data files by extension - auto-detects media/audio/data files by extension
- shows each image for 3 seconds by default - shows each image fully visible for 6 seconds by default, not counting crossfades
- keeps the first image longer so the first transition starts at 12 seconds by default
- includes video clips inline - includes video clips inline
- crossfades between media items - crossfades between media items over 3 seconds by default
- loops/trims music to match the video - loops/trims music to match the video
- overlays title/date/place at the beginning - fades music in at the beginning and out at the end over 10 seconds by default
- writes an MP4 file - overlays the opening title for the first 6 seconds: large bold title, then smaller non-bold date/location on one line
- adds persistent bottom-left title/date/place and bottom-right `marijo@novosel.dk` stamps
- writes an MP4 file named like `YYYYMMDD_title.mp4` by default
- takes `YYYYMMDD` from the first picture/video metadata, falling back to file modification date
- uses local `ffmpeg/ffmpeg` and `ffmpeg/ffprobe` when present, otherwise system tools
## Notes ## Notes

View file

@ -13,17 +13,26 @@ import argparse
import json import json
import math import math
import os import os
import re
import shlex import shlex
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime
from pathlib import Path from pathlib import Path
IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tif", ".tiff"} IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tif", ".tiff"}
VIDEO_EXTS = {".mp4", ".mov", ".mkv", ".avi", ".webm", ".m4v"} VIDEO_EXTS = {".mp4", ".mov", ".mkv", ".avi", ".webm", ".m4v"}
AUDIO_EXTS = {".mp3", ".wav", ".m4a", ".aac", ".flac", ".ogg"} AUDIO_EXTS = {".mp3", ".wav", ".m4a", ".aac", ".flac", ".ogg"}
DATA_NAMES = {"data.txt", "info.txt", "title.txt"} DATA_NAMES = {"data.txt", "info.txt", "title.txt"}
BASE_DIR = Path(__file__).resolve().parent
FFMPEG = BASE_DIR / "ffmpeg" / "ffmpeg"
FFPROBE = BASE_DIR / "ffmpeg" / "ffprobe"
def tool_path(local_path: Path, fallback: str) -> str:
return str(local_path) if local_path.exists() else fallback
@dataclass @dataclass
@ -52,7 +61,8 @@ def capture(cmd: list[str]) -> str:
def require_tool(name: str) -> None: def require_tool(name: str) -> None:
try: try:
subprocess.run([name, "-version"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True) executable = tool_path(FFMPEG, "ffmpeg") if name == "ffmpeg" else tool_path(FFPROBE, "ffprobe") if name == "ffprobe" else name
subprocess.run([executable, "-version"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
except Exception: except Exception:
raise SystemExit(f"Error: required tool not found: {name}") raise SystemExit(f"Error: required tool not found: {name}")
@ -68,7 +78,7 @@ def parse_resolution(value: str) -> tuple[int, int]:
def ffprobe_duration(path: Path) -> float: def ffprobe_duration(path: Path) -> float:
try: try:
out = capture([ out = capture([
"ffprobe", "-v", "error", tool_path(FFPROBE, "ffprobe"), "-v", "error",
"-show_entries", "format=duration", "-show_entries", "format=duration",
"-of", "json", "-of", "json",
str(path), str(path),
@ -79,6 +89,136 @@ def ffprobe_duration(path: Path) -> float:
return 3.0 return 3.0
def ffprobe_creation_date(path: Path) -> datetime | None:
try:
out = capture([
tool_path(FFPROBE, "ffprobe"), "-v", "error",
"-show_entries", "format_tags=creation_time:stream_tags=creation_time",
"-of", "json",
str(path),
])
data = json.loads(out)
candidates: list[str] = []
if isinstance(data.get("format"), dict):
candidates.append(data["format"].get("tags", {}).get("creation_time", ""))
for stream in data.get("streams", []):
candidates.append(stream.get("tags", {}).get("creation_time", ""))
for value in candidates:
if not value:
continue
try:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
pass
except Exception:
pass
return None
def exif_image_date(path: Path) -> datetime | None:
"""Read common JPEG/TIFF EXIF date tags using only the stdlib."""
data = path.read_bytes()[:1024 * 1024]
if data.startswith(b"\xff\xd8"):
pos = 2
tiff: bytes | None = None
while pos + 4 <= len(data):
if data[pos] != 0xFF:
break
marker = data[pos + 1]
pos += 2
if marker in {0xD8, 0xD9}:
continue
if pos + 2 > len(data):
break
size = int.from_bytes(data[pos:pos + 2], "big")
segment = data[pos + 2:pos + size]
pos += size
if marker == 0xE1 and segment.startswith(b"Exif\x00\x00"):
tiff = segment[6:]
break
if tiff is None:
return None
else:
tiff = data
if tiff[:2] == b"II":
endian = "little"
elif tiff[:2] == b"MM":
endian = "big"
else:
return None
def u16(off: int) -> int:
return int.from_bytes(tiff[off:off + 2], endian)
def u32(off: int) -> int:
return int.from_bytes(tiff[off:off + 4], endian)
def read_ascii(offset: int, count: int) -> str:
return tiff[offset:offset + count].split(b"\x00", 1)[0].decode("ascii", "ignore").strip()
def parse_ifd(ifd_off: int) -> tuple[str | None, int | None]:
if ifd_off <= 0 or ifd_off + 2 > len(tiff):
return None, None
entries = u16(ifd_off)
found_date = None
exif_ifd = None
for i in range(entries):
off = ifd_off + 2 + i * 12
if off + 12 > len(tiff):
break
tag = u16(off)
typ = u16(off + 2)
count = u32(off + 4)
value = u32(off + 8)
if tag in {0x0132, 0x9003, 0x9004} and typ == 2:
found_date = read_ascii(value, count) if count > 4 else tiff[off + 8:off + 8 + count].split(b"\x00", 1)[0].decode("ascii", "ignore")
elif tag == 0x8769:
exif_ifd = value
return found_date, exif_ifd
try:
if u16(2) != 42:
return None
date_text, exif_ifd = parse_ifd(u32(4))
if exif_ifd:
exif_date, _ = parse_ifd(exif_ifd)
date_text = exif_date or date_text
if date_text:
return datetime.strptime(date_text[:19], "%Y:%m:%d %H:%M:%S")
except Exception:
return None
return None
def media_date(path: Path) -> datetime:
ext = path.suffix.lower()
dt = None
if ext in IMAGE_EXTS:
try:
dt = exif_image_date(path)
except Exception:
dt = None
if dt is None:
dt = ffprobe_creation_date(path)
if dt is None:
dt = datetime.fromtimestamp(path.stat().st_mtime)
return dt
def slugify(value: str) -> str:
value = value.strip().lower()
value = re.sub(r"[^a-z0-9]+", "_", value)
value = value.strip("_")
return value or "movie"
def default_output_path(first_media: Path, data: MovieData, out_dir: Path) -> Path:
date = media_date(first_media).strftime("%Y%m%d")
title = slugify(data.title or first_media.parent.name or "movie")
return out_dir / f"{date}_{title}.mp4"
def natural_key(path: Path) -> list[object]: def natural_key(path: Path) -> list[object]:
# Simple natural-ish sort: IMG_2 before IMG_10. # Simple natural-ish sort: IMG_2 before IMG_10.
import re import re
@ -167,7 +307,7 @@ def normalize_clip(item: MediaItem, out: Path, width: int, height: int, fps: int
if item.kind == "image": if item.kind == "image":
cmd = [ cmd = [
"ffmpeg", "-y", tool_path(FFMPEG, "ffmpeg"), "-y",
"-loop", "1", "-t", f"{item.duration:.3f}", "-i", str(item.path), "-loop", "1", "-t", f"{item.duration:.3f}", "-i", str(item.path),
"-an", "-vf", vf, "-an", "-vf", vf,
"-c:v", "libx264", "-preset", "veryfast", "-crf", "20", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20",
@ -175,7 +315,7 @@ def normalize_clip(item: MediaItem, out: Path, width: int, height: int, fps: int
] ]
else: else:
cmd = [ cmd = [
"ffmpeg", "-y", tool_path(FFMPEG, "ffmpeg"), "-y",
"-i", str(item.path), "-i", str(item.path),
"-an", "-vf", vf, "-an", "-vf", vf,
"-c:v", "libx264", "-preset", "veryfast", "-crf", "20", "-c:v", "libx264", "-preset", "veryfast", "-crf", "20",
@ -195,7 +335,7 @@ def concat_or_mix_audio(audio_files: list[Path], out: Path) -> Path | None:
return str(p.resolve()).replace("'", "'\\''") return str(p.resolve()).replace("'", "'\\''")
list_file.write_text("".join(f"file '{esc(p)}'\n" for p in audio_files), encoding="utf-8") list_file.write_text("".join(f"file '{esc(p)}'\n" for p in audio_files), encoding="utf-8")
run([ run([
"ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(list_file), tool_path(FFMPEG, "ffmpeg"), "-y", "-f", "concat", "-safe", "0", "-i", str(list_file),
"-vn", "-c:a", "aac", "-b:a", "192k", str(out), "-vn", "-c:a", "aac", "-b:a", "192k", str(out),
]) ])
return out return out
@ -208,13 +348,14 @@ def build_video(
output: Path, output: Path,
data: MovieData, data: MovieData,
fade: float, fade: float,
audio_fade: float,
width: int, width: int,
height: int, height: int,
fps: int, fps: int,
) -> None: ) -> None:
output.parent.mkdir(parents=True, exist_ok=True) output.parent.mkdir(parents=True, exist_ok=True)
cmd = ["ffmpeg", "-y"] cmd = [tool_path(FFMPEG, "ffmpeg"), "-y"]
for clip in clips: for clip in clips:
cmd += ["-i", str(clip)] cmd += ["-i", str(clip)]
if audio: if audio:
@ -223,38 +364,74 @@ def build_video(
filters: list[str] = [] filters: list[str] = []
n = len(clips) n = len(clips)
total_duration = durations[0]
if n == 1: if n == 1:
current = "0:v" current = "0:v"
else: else:
current = "v01" current = "v01"
offset = max(0.0, durations[0] - fade) offset = max(0.0, durations[0] - fade)
filters.append(f"[0:v][1:v]xfade=transition=fade:duration={fade:.3f}:offset={offset:.3f}[{current}]") filters.append(f"[0:v][1:v]xfade=transition=fade:duration={fade:.3f}:offset={offset:.3f}[{current}]")
combined_duration = durations[0] + durations[1] - fade total_duration = durations[0] + durations[1] - fade
for i in range(2, n): for i in range(2, n):
next_label = f"v{i:02d}" next_label = f"v{i:02d}"
offset = max(0.0, combined_duration - fade) offset = max(0.0, total_duration - fade)
filters.append(f"[{current}][{i}:v]xfade=transition=fade:duration={fade:.3f}:offset={offset:.3f}[{next_label}]") filters.append(f"[{current}][{i}:v]xfade=transition=fade:duration={fade:.3f}:offset={offset:.3f}[{next_label}]")
current = next_label current = next_label
combined_duration += durations[i] - fade total_duration += durations[i] - fade
title_lines = [x for x in [data.title, data.date, data.place] if x] title_lines = [x for x in [data.title, data.date, data.place] if x]
final_label = "vout" final_label = "vout"
title_label = "titled"
bold_font = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
normal_font = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"
if title_lines: if title_lines:
text = drawtext_escape("\\n".join(title_lines)) title_font_size = max(44, height // 14)
# Draw centered title for first 5 seconds with a translucent box. subtitle_font_size = max(28, height // 24)
main_title = data.title or title_lines[0]
subtitle = " | ".join(x for x in [data.date, data.place] if x)
if subtitle:
filters.append( filters.append(
f"[{current}]drawtext=text='{text}':x=(w-text_w)/2:y=(h-text_h)/2:" f"[{current}]drawtext=fontfile='{bold_font}':text='{drawtext_escape(main_title)}':x=(w-text_w)/2:y=(h-text_h)/2-{title_font_size // 2}:"
f"fontsize={max(32, height // 18)}:fontcolor=white:box=1:boxcolor=black@0.45:boxborderw=24:" f"fontsize={title_font_size}:fontcolor=white:borderw=3:bordercolor=black:"
f"enable='between(t,0,5)'[{final_label}]" f"enable='between(t,0,6)'[title0]"
)
filters.append(
f"[title0]drawtext=fontfile='{normal_font}':text='{drawtext_escape(subtitle)}':x=(w-text_w)/2:y=(h-text_h)/2+{subtitle_font_size}:"
f"fontsize={subtitle_font_size}:fontcolor=white:borderw=3:bordercolor=black:"
f"enable='between(t,0,6)'[{title_label}]"
) )
else: else:
filters.append(f"[{current}]copy[{final_label}]") filters.append(
f"[{current}]drawtext=fontfile='{bold_font}':text='{drawtext_escape(main_title)}':x=(w-text_w)/2:y=(h-text_h)/2:"
f"fontsize={title_font_size}:fontcolor=white:borderw=3:bordercolor=black:"
f"enable='between(t,0,6)'[{title_label}]"
)
else:
filters.append(f"[{current}]copy[{title_label}]")
stamp_size = max(14, height // 60)
stamp_lines = [x for x in [data.title, data.date, data.place] if x]
stamp_text = drawtext_escape(" | ".join(stamp_lines))
stamp_left_label = "stamp_left"
if stamp_text:
filters.append(
f"[{title_label}]drawtext=text='{stamp_text}':x=24:y=h-text_h-18:"
f"fontsize={stamp_size}:fontcolor=white@0.75:borderw=2:bordercolor=black@0.75[{stamp_left_label}]"
)
else:
filters.append(f"[{title_label}]copy[{stamp_left_label}]")
filters.append(
f"[{stamp_left_label}]drawtext=text='marijo@novosel.dk':x=w-text_w-24:y=h-text_h-18:"
f"fontsize={stamp_size}:fontcolor=white@0.75:borderw=2:bordercolor=black@0.75[{final_label}]"
)
cmd += ["-filter_complex", ";".join(filters), "-map", f"[{final_label}]"] cmd += ["-filter_complex", ";".join(filters), "-map", f"[{final_label}]"]
if audio: if audio:
audio_index = len(clips) audio_index = len(clips)
cmd += ["-map", f"{audio_index}:a:0", "-c:a", "aac", "-b:a", "192k", "-shortest"] audio_fade_out_start = max(0.0, total_duration - audio_fade)
audio_filter = f"afade=t=in:st=0:d={audio_fade:.3f},afade=t=out:st={audio_fade_out_start:.3f}:d={audio_fade:.3f}"
cmd += ["-map", f"{audio_index}:a:0", "-af", audio_filter, "-c:a", "aac", "-b:a", "192k", "-shortest"]
else: else:
cmd += ["-an"] cmd += ["-an"]
@ -265,9 +442,12 @@ def build_video(
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Make a video from a flat folder of images/videos/audio.") 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("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("--output", "-o", type=Path, help="output mp4 path; defaults to out/YYYYMMDD_title.mp4")
parser.add_argument("--image-duration", type=float, default=3.0, help="seconds each image is visible") parser.add_argument("--out-dir", type=Path, default=Path("out"), help="directory for auto-named output videos")
parser.add_argument("--fade", type=float, default=1.0, help="crossfade duration in seconds") parser.add_argument("--image-duration", type=float, default=6.0, help="seconds each image is visible")
parser.add_argument("--fade", type=float, default=3.0, help="crossfade duration in seconds")
parser.add_argument("--audio-fade", type=float, default=10.0, help="music fade-in/fade-out duration in seconds")
parser.add_argument("--first-transition-at", type=float, default=12.0, help="seconds before first transition starts")
parser.add_argument("--resolution", type=parse_resolution, default=(1920, 1080), help="output resolution, e.g. 1920x1080") 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") parser.add_argument("--fps", type=int, default=30, help="output frames per second")
args = parser.parse_args(argv) args = parser.parse_args(argv)
@ -278,8 +458,25 @@ def main(argv: list[str] | None = None) -> int:
width, height = args.resolution width, height = args.resolution
media, audio_files, data = scan_input(args.input, args.image_duration) media, audio_files, data = scan_input(args.input, args.image_duration)
fade = min(args.fade, max(0.0, args.image_duration - 0.1)) fade = min(args.fade, max(0.0, args.image_duration - 0.1))
audio_fade = max(0.0, args.audio_fade)
output = args.output or default_output_path(media[0].path, data, args.out_dir)
# Image duration means fully visible time, not including crossfades.
# Middle images need both a fade-in and fade-out added to their clip length.
if len(media) > 1:
for idx, item in enumerate(media):
if item.kind != "image":
continue
if idx == 0:
item.duration = max(item.duration, args.first_transition_at + fade)
elif idx == len(media) - 1:
item.duration = item.duration + fade
else:
item.duration = item.duration + (2 * fade)
print(f"Found {len(media)} media file(s), {len(audio_files)} audio file(s).") print(f"Found {len(media)} media file(s), {len(audio_files)} audio file(s).")
print(f"Output: {output}")
if data.title or data.date or data.place: 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)) print("Title data:", " | ".join(x for x in [data.title, data.date, data.place] if x))
@ -296,9 +493,9 @@ def main(argv: list[str] | None = None) -> int:
durations.append(ffprobe_duration(out)) durations.append(ffprobe_duration(out))
audio = concat_or_mix_audio(audio_files, tmp / "music.m4a") audio = concat_or_mix_audio(audio_files, tmp / "music.m4a")
build_video(clips, durations, audio, args.output, data, fade, width, height, args.fps) build_video(clips, durations, audio, output, data, fade, audio_fade, width, height, args.fps)
print(f"Done: {args.output}") print(f"Done: {output}")
return 0 return 0