diff --git a/.gitignore b/.gitignore index 345f333..354a15f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ __pycache__/ venv/ # Generated videos / outputs +movmaker.log out/ *.mp4 *.mov diff --git a/README.md b/README.md index e5a0dab..4bc70bf 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,7 @@ python3 movmaker.py input --preview - embeds MP4 metadata: title, date, location/place, QuickTime location name, artist, comment, and creation_time - writes an MP4 file named like `YYYYMMDD_title.mp4` in the current directory by default; use `--out-dir DIR` to choose another output directory - takes `YYYYMMDD` from the first picture/video metadata, falling back to file modification date +- keeps console output minimal during rendering and writes detailed ffmpeg output to `movmaker.log` in the input directory - uses local `ffmpeg/ffmpeg` and `ffmpeg/ffprobe` when present, otherwise system tools ## Notes diff --git a/movmaker.py b/movmaker.py index b5737f0..156d61e 100755 --- a/movmaker.py +++ b/movmaker.py @@ -35,6 +35,7 @@ BASE_DIR = Path(__file__).resolve().parent FFMPEG = BASE_DIR / "ffmpeg" / "ffmpeg" FFPROBE = BASE_DIR / "ffmpeg" / "ffprobe" PIXABAY_ENV = Path.home() / ".config" / "movmaker" / "pixabay.env" +LOG_PATH: Path | None = None def tool_path(local_path: Path, fallback: str) -> str: @@ -56,9 +57,75 @@ class MediaItem: duration: float -def run(cmd: list[str]) -> None: - print("+", shlex.join(cmd)) - subprocess.run(cmd, check=True) +def log_message(message: str) -> None: + if LOG_PATH: + with LOG_PATH.open("a", encoding="utf-8") as log: + log.write(message.rstrip() + "\n") + + +def ffmpeg_progress_cmd(cmd: list[str], progress: bool) -> list[str]: + if not cmd or Path(cmd[0]).name != "ffmpeg": + return cmd + out = [cmd[0], "-hide_banner", "-nostats"] + rest = cmd[1:] + if rest and rest[0] == "-y": + out.append("-y") + rest = rest[1:] + if progress: + out += ["-progress", "pipe:1"] + return out + rest + + +def format_bytes(value: int) -> str: + size = float(max(0, value)) + for unit in ["B", "KB", "MB", "GB"]: + if size < 1024 or unit == "GB": + return f"{size:.1f}{unit}" + size /= 1024 + return f"{size:.1f}GB" + + +def run(cmd: list[str], progress_duration: float | None = None) -> None: + progress = progress_duration is not None and progress_duration > 0 and Path(cmd[0]).name == "ffmpeg" + run_cmd = ffmpeg_progress_cmd(cmd, progress) + log_message("+ " + shlex.join(run_cmd)) + if not LOG_PATH: + subprocess.run(run_cmd, check=True) + return + + with LOG_PATH.open("a", encoding="utf-8") as log: + if not progress: + subprocess.run(run_cmd, stdout=log, stderr=log, check=True) + return + + process = subprocess.Popen(run_cmd, stdout=subprocess.PIPE, stderr=log, text=True, bufsize=1) + fields: dict[str, str] = {} + assert process.stdout is not None + for raw_line in process.stdout: + line = raw_line.strip() + log.write(line + "\n") + if "=" not in line: + continue + key, value = line.split("=", 1) + fields[key] = value + if key in {"out_time_ms", "total_size", "speed", "fps", "progress"}: + try: + seconds = int(fields.get("out_time_ms", "0")) / 1_000_000 + except ValueError: + seconds = 0.0 + try: + written = int(fields.get("total_size", "0")) + except ValueError: + written = 0 + percent = min(100.0, max(0.0, seconds / progress_duration * 100.0)) + fps = fields.get("fps", "?") + speed = fields.get("speed", "?") + sys.stdout.write(f"\rRendering {percent:5.1f}% fps={fps} speed={speed} written={format_bytes(written)}") + sys.stdout.flush() + rc = process.wait() + sys.stdout.write("\n") + if rc != 0: + raise subprocess.CalledProcessError(rc, run_cmd) def capture(cmd: list[str]) -> str: @@ -785,7 +852,7 @@ def build_video( cmd += ["-an"] cmd += ["-c:v", "libx264", "-preset", preset, "-crf", str(crf), "-r", str(fps), "-pix_fmt", "yuv420p", "-movflags", "+faststart+use_metadata_tags", str(output)] - run(cmd) + run(cmd, total_duration) def main(argv: list[str] | None = None) -> int: @@ -806,6 +873,10 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--music-genre", default="cinematic punk rock", help="Pixabay music search query used only when input has no audio files") args = parser.parse_args(argv) + global LOG_PATH + LOG_PATH = args.input / "movmaker.log" + LOG_PATH.write_text("movmaker log\n", encoding="utf-8") + require_tool("ffmpeg") require_tool("ffprobe") @@ -852,6 +923,7 @@ def main(argv: list[str] | None = None) -> int: print(f"Found {len(media)} media file(s), {len(audio_files)} audio file(s).") print(f"Output: {output}") + print(f"Log: {LOG_PATH}") 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)) @@ -860,15 +932,16 @@ def main(argv: list[str] | None = None) -> int: jobs = max(1, args.jobs) clips = [tmp / f"clip_{idx:04d}.mp4" for idx in range(len(media))] if jobs == 1: + print("Preparing clips...") for item, out in zip(media, clips): - print(f"Preparing {item.kind}: {item.path.name}") + log_message(f"Preparing {item.kind}: {item.path.name}") normalize_clip(item, out, width, height, fps, preset, crf) else: print(f"Preparing clips with {jobs} parallel jobs") with concurrent.futures.ThreadPoolExecutor(max_workers=jobs) as executor: futures = [] for item, out in zip(media, clips): - print(f"Preparing {item.kind}: {item.path.name}") + log_message(f"Preparing {item.kind}: {item.path.name}") futures.append(executor.submit(normalize_clip, item, out, width, height, fps, preset, crf)) for future in concurrent.futures.as_completed(futures): future.result()