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
|
*.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
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:
|
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
|
||||||
|
|
||||||
|
|
|
||||||
239
movmaker.py
239
movmaker.py
|
|
@ -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(
|
||||||
|
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(
|
filters.append(
|
||||||
f"[{current}]drawtext=text='{text}':x=(w-text_w)/2:y=(h-text_h)/2:"
|
f"[{title_label}]drawtext=text='{stamp_text}':x=24:y=h-text_h-18:"
|
||||||
f"fontsize={max(32, height // 18)}:fontcolor=white:box=1:boxcolor=black@0.45:boxborderw=24:"
|
f"fontsize={stamp_size}:fontcolor=white@0.75:borderw=2:bordercolor=black@0.75[{stamp_left_label}]"
|
||||||
f"enable='between(t,0,5)'[{final_label}]"
|
|
||||||
)
|
)
|
||||||
else:
|
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}]"]
|
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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue