Tune slideshow timing and title overlays
This commit is contained in:
parent
51146f75b9
commit
54a474f278
4 changed files with 246 additions and 31 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -12,9 +12,10 @@ out/
|
|||
*.avi
|
||||
*.webm
|
||||
|
||||
# Local input media
|
||||
input/
|
||||
# Local input media and bundled local tools
|
||||
input*/
|
||||
media/
|
||||
ffmpeg/
|
||||
|
||||
# OS/editor
|
||||
.DS_Store
|
||||
|
|
|
|||
10
AGENTS.md
Normal file
10
AGENTS.md
Normal 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.
|
||||
23
README.md
23
README.md
|
|
@ -85,16 +85,18 @@ IMG_002.jpg: Louvre
|
|||
Basic:
|
||||
|
||||
```bash
|
||||
python3 movmaker.py input --output movie.mp4
|
||||
python3 movmaker.py input
|
||||
```
|
||||
|
||||
With options:
|
||||
|
||||
```bash
|
||||
python3 movmaker.py input \
|
||||
--output out/movie.mp4 \
|
||||
--image-duration 3 \
|
||||
--fade 1 \
|
||||
--out-dir out \
|
||||
--image-duration 6 \
|
||||
--fade 3 \
|
||||
--audio-fade 10 \
|
||||
--first-transition-at 12 \
|
||||
--resolution 1920x1080 \
|
||||
--fps 30
|
||||
```
|
||||
|
|
@ -103,12 +105,17 @@ python3 movmaker.py input \
|
|||
|
||||
- scans one flat input directory
|
||||
- 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
|
||||
- crossfades between media items
|
||||
- crossfades between media items over 3 seconds by default
|
||||
- loops/trims music to match the video
|
||||
- overlays title/date/place at the beginning
|
||||
- writes an MP4 file
|
||||
- fades music in at the beginning and out at the end over 10 seconds by default
|
||||
- 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
|
||||
|
||||
|
|
|
|||
239
movmaker.py
239
movmaker.py
|
|
@ -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=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"[{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}]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}]"]
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue