diff --git a/.gitignore b/.gitignore index 915062d..7f6aa94 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..403d1eb --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/README.md b/README.md index c8edfc1..99e5968 100644 --- a/README.md +++ b/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 diff --git a/movmaker.py b/movmaker.py index be6851d..5e10c8b 100755 --- a/movmaker.py +++ b/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=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