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

View file

@ -13,17 +13,26 @@ import argparse
import json
import math
import os
import re
import shlex
import subprocess
import sys
import tempfile
from dataclasses import dataclass
from datetime import datetime
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"}
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
@ -52,7 +61,8 @@ def capture(cmd: list[str]) -> str:
def require_tool(name: str) -> None:
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:
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:
try:
out = capture([
"ffprobe", "-v", "error",
tool_path(FFPROBE, "ffprobe"), "-v", "error",
"-show_entries", "format=duration",
"-of", "json",
str(path),
@ -79,6 +89,136 @@ def ffprobe_duration(path: Path) -> float:
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]:
# Simple natural-ish sort: IMG_2 before IMG_10.
import re
@ -167,7 +307,7 @@ def normalize_clip(item: MediaItem, out: Path, width: int, height: int, fps: int
if item.kind == "image":
cmd = [
"ffmpeg", "-y",
tool_path(FFMPEG, "ffmpeg"), "-y",
"-loop", "1", "-t", f"{item.duration:.3f}", "-i", str(item.path),
"-an", "-vf", vf,
"-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:
cmd = [
"ffmpeg", "-y",
tool_path(FFMPEG, "ffmpeg"), "-y",
"-i", str(item.path),
"-an", "-vf", vf,
"-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("'", "'\\''")
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),
tool_path(FFMPEG, "ffmpeg"), "-y", "-f", "concat", "-safe", "0", "-i", str(list_file),
"-vn", "-c:a", "aac", "-b:a", "192k", str(out),
])
return out
@ -208,13 +348,14 @@ def build_video(
output: Path,
data: MovieData,
fade: float,
audio_fade: float,
width: int,
height: int,
fps: int,
) -> None:
output.parent.mkdir(parents=True, exist_ok=True)
cmd = ["ffmpeg", "-y"]
cmd = [tool_path(FFMPEG, "ffmpeg"), "-y"]
for clip in clips:
cmd += ["-i", str(clip)]
if audio:
@ -223,38 +364,74 @@ def build_video(
filters: list[str] = []
n = len(clips)
total_duration = durations[0]
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
total_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)
offset = max(0.0, total_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
total_duration += durations[i] - fade
title_lines = [x for x in [data.title, data.date, data.place] if x]
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:
text = drawtext_escape("\\n".join(title_lines))
# Draw centered title for first 5 seconds with a translucent box.
title_font_size = max(44, height // 14)
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(
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={title_font_size}:fontcolor=white:borderw=3:bordercolor=black:"
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:
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"[{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}]"
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"[{current}]copy[{final_label}]")
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}]"]
if audio:
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:
cmd += ["-an"]
@ -265,9 +442,12 @@ def build_video(
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("--output", "-o", type=Path, help="output mp4 path; defaults to out/YYYYMMDD_title.mp4")
parser.add_argument("--out-dir", type=Path, default=Path("out"), help="directory for auto-named output videos")
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("--fps", type=int, default=30, help="output frames per second")
args = parser.parse_args(argv)
@ -278,8 +458,25 @@ def main(argv: list[str] | None = None) -> int:
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))
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"Output: {output}")
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))
@ -296,9 +493,9 @@ def main(argv: list[str] | None = None) -> int:
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)
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