Add docstrings for tooling and pipeline helpers
This commit is contained in:
parent
2ac00e0e67
commit
8ae46752f6
1 changed files with 35 additions and 0 deletions
35
movmaker.py
35
movmaker.py
|
|
@ -55,6 +55,15 @@ LOG_PATH: Path | None = None
|
||||||
|
|
||||||
|
|
||||||
def tool_path(local_path: Path, fallback: str) -> str:
|
def tool_path(local_path: Path, fallback: str) -> str:
|
||||||
|
"""Return the preferred executable path for ffmpeg/ffprobe style tools.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
local_path: Path to a bundled copy shipped with movmaker.
|
||||||
|
fallback: Name of the system executable to call when the bundled copy is missing.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The fully-qualified path to execute.
|
||||||
|
"""
|
||||||
return str(local_path) if local_path.exists() else fallback
|
return str(local_path) if local_path.exists() else fallback
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -76,12 +85,14 @@ class MediaItem:
|
||||||
|
|
||||||
|
|
||||||
def log_message(message: str) -> None:
|
def log_message(message: str) -> None:
|
||||||
|
"""Append a diagnostic line to the current render log if it is enabled."""
|
||||||
if LOG_PATH:
|
if LOG_PATH:
|
||||||
with LOG_PATH.open("a", encoding="utf-8") as log:
|
with LOG_PATH.open("a", encoding="utf-8") as log:
|
||||||
log.write(message.rstrip() + "\n")
|
log.write(message.rstrip() + "\n")
|
||||||
|
|
||||||
|
|
||||||
def ffmpeg_progress_cmd(cmd: list[str], progress: bool) -> list[str]:
|
def ffmpeg_progress_cmd(cmd: list[str], progress: bool) -> list[str]:
|
||||||
|
"""Inject ffmpeg-friendly progress flags when progress monitoring is requested."""
|
||||||
if not cmd or Path(cmd[0]).name != "ffmpeg":
|
if not cmd or Path(cmd[0]).name != "ffmpeg":
|
||||||
return cmd
|
return cmd
|
||||||
out = [cmd[0], "-hide_banner", "-nostats"]
|
out = [cmd[0], "-hide_banner", "-nostats"]
|
||||||
|
|
@ -104,6 +115,12 @@ def format_bytes(value: int) -> str:
|
||||||
|
|
||||||
|
|
||||||
def run(cmd: list[str], progress_duration: float | None = None) -> None:
|
def run(cmd: list[str], progress_duration: float | None = None) -> None:
|
||||||
|
"""Execute a subprocess while streaming progress and teeing output to the log.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cmd: argv array ready for subprocess.run.
|
||||||
|
progress_duration: Expected output duration in seconds used to estimate progress; omit for instant commands.
|
||||||
|
"""
|
||||||
progress = progress_duration is not None and progress_duration > 0 and Path(cmd[0]).name == "ffmpeg"
|
progress = progress_duration is not None and progress_duration > 0 and Path(cmd[0]).name == "ffmpeg"
|
||||||
run_cmd = ffmpeg_progress_cmd(cmd, progress)
|
run_cmd = ffmpeg_progress_cmd(cmd, progress)
|
||||||
log_message("+ " + shlex.join(run_cmd))
|
log_message("+ " + shlex.join(run_cmd))
|
||||||
|
|
@ -483,6 +500,7 @@ def media_date(path: Path) -> datetime:
|
||||||
|
|
||||||
|
|
||||||
def ascii_safe(value: str) -> str:
|
def ascii_safe(value: str) -> str:
|
||||||
|
"""Return a human-readable ASCII approximation of Scandinavian/Slavic letters."""
|
||||||
replacements = {
|
replacements = {
|
||||||
# Danish letters
|
# Danish letters
|
||||||
"æ": "ae", "Æ": "Ae",
|
"æ": "ae", "Æ": "Ae",
|
||||||
|
|
@ -505,6 +523,7 @@ def ascii_safe(value: str) -> str:
|
||||||
|
|
||||||
|
|
||||||
def slugify(value: str) -> str:
|
def slugify(value: str) -> str:
|
||||||
|
"""Convert an arbitrary string into a filesystem-safe slug."""
|
||||||
value = ascii_safe(value)
|
value = ascii_safe(value)
|
||||||
value = value.strip().lower()
|
value = value.strip().lower()
|
||||||
value = re.sub(r"[^a-z0-9]+", "_", value)
|
value = re.sub(r"[^a-z0-9]+", "_", value)
|
||||||
|
|
@ -525,6 +544,7 @@ def display_date(dt: datetime) -> str:
|
||||||
|
|
||||||
|
|
||||||
def prepare_clip_stamps(media: list[MediaItem], data: MovieData) -> list[str]:
|
def prepare_clip_stamps(media: list[MediaItem], data: MovieData) -> list[str]:
|
||||||
|
"""Build per-clip stamp strings containing title/date/place metadata."""
|
||||||
geocode_cache: dict[tuple[float, float], str | None] = {}
|
geocode_cache: dict[tuple[float, float], str | None] = {}
|
||||||
stamps: list[str] = []
|
stamps: list[str] = []
|
||||||
for item in media:
|
for item in media:
|
||||||
|
|
@ -566,6 +586,7 @@ def natural_key(path: Path) -> list[object]:
|
||||||
|
|
||||||
|
|
||||||
def scan_input(input_dir: Path, image_duration: float) -> tuple[list[MediaItem], list[Path], MovieData]:
|
def scan_input(input_dir: Path, image_duration: float) -> tuple[list[MediaItem], list[Path], MovieData]:
|
||||||
|
"""Detect media/audio/data files inside the input directory and build metadata."""
|
||||||
if not input_dir.is_dir():
|
if not input_dir.is_dir():
|
||||||
raise SystemExit(f"Input directory does not exist: {input_dir}")
|
raise SystemExit(f"Input directory does not exist: {input_dir}")
|
||||||
|
|
||||||
|
|
@ -678,6 +699,15 @@ def drawtext_escape(text: str) -> str:
|
||||||
|
|
||||||
|
|
||||||
def render_intro_logo_frame(logo: Path, out: Path, width: int, height: int, alpha: float) -> None:
|
def render_intro_logo_frame(logo: Path, out: Path, width: int, height: int, alpha: float) -> None:
|
||||||
|
"""Build a single black frame with the intro logo centered and scaled.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
logo: Source PNG to overlay.
|
||||||
|
out: Destination PNG path for the composed frame.
|
||||||
|
width: Target video width.
|
||||||
|
height: Target video height.
|
||||||
|
alpha: Desired opacity between 0 and 1.
|
||||||
|
"""
|
||||||
if width <= 0 or height <= 0:
|
if width <= 0 or height <= 0:
|
||||||
raise ValueError("intro logo frame must have positive width/height")
|
raise ValueError("intro logo frame must have positive width/height")
|
||||||
alpha = min(1.0, max(0.0, alpha))
|
alpha = min(1.0, max(0.0, alpha))
|
||||||
|
|
@ -703,6 +733,7 @@ def render_intro_logo_frame(logo: Path, out: Path, width: int, height: int, alph
|
||||||
|
|
||||||
|
|
||||||
def normalize_clip(item: MediaItem, out: Path, width: int, height: int, fps: int, preset: str, crf: int) -> None:
|
def normalize_clip(item: MediaItem, out: Path, width: int, height: int, fps: int, preset: str, crf: int) -> None:
|
||||||
|
"""Transcode one media item into a padded, FPS-normalized H.264 clip."""
|
||||||
vf = (
|
vf = (
|
||||||
f"scale={width}:{height}:force_original_aspect_ratio=decrease,"
|
f"scale={width}:{height}:force_original_aspect_ratio=decrease,"
|
||||||
f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:black,"
|
f"pad={width}:{height}:(ow-iw)/2:(oh-ih)/2:black,"
|
||||||
|
|
@ -773,6 +804,7 @@ def load_jamendo_client_id() -> str | None:
|
||||||
|
|
||||||
|
|
||||||
def download_jamendo_music(genre: str, out_dir: Path) -> Path | None:
|
def download_jamendo_music(genre: str, out_dir: Path) -> Path | None:
|
||||||
|
"""Download a random Jamendo track matching the requested genre."""
|
||||||
client_id = load_jamendo_client_id()
|
client_id = load_jamendo_client_id()
|
||||||
if not client_id:
|
if not client_id:
|
||||||
console("No audio files found and no Jamendo client ID configured; continuing without music.")
|
console("No audio files found and no Jamendo client ID configured; continuing without music.")
|
||||||
|
|
@ -843,6 +875,7 @@ def download_jamendo_music(genre: str, out_dir: Path) -> Path | None:
|
||||||
|
|
||||||
|
|
||||||
def concat_or_mix_audio(audio_files: list[Path], out: Path) -> Path | None:
|
def concat_or_mix_audio(audio_files: list[Path], out: Path) -> Path | None:
|
||||||
|
"""Concatenate or mix multiple audio tracks into one temp file."""
|
||||||
if not audio_files:
|
if not audio_files:
|
||||||
return None
|
return None
|
||||||
if len(audio_files) == 1:
|
if len(audio_files) == 1:
|
||||||
|
|
@ -885,6 +918,7 @@ def build_video(
|
||||||
intro_visible_lead: float = 0.0,
|
intro_visible_lead: float = 0.0,
|
||||||
skip_video_fade_out: bool = False,
|
skip_video_fade_out: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""Assemble normalized clips, overlays, and audio into the final MP4."""
|
||||||
output.parent.mkdir(parents=True, exist_ok=True)
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
text_file_count = 0
|
text_file_count = 0
|
||||||
|
|
@ -1144,6 +1178,7 @@ def build_video(
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
"""CLI entry point for movmaker."""
|
||||||
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("--out-dir", type=Path, default=Path("."), help="directory for auto-named output videos")
|
parser.add_argument("--out-dir", type=Path, default=Path("."), help="directory for auto-named output videos")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue