#!/usr/bin/env python3 """Native Windows hardware transcode worker; Python standard library only.""" import argparse import json import logging import math import os from pathlib import Path import subprocess import tempfile import time import uuid PRESETS = { "nvenc_h264": ["-c:v", "h264_nvenc", "-preset", "p4", "-rc", "vbr"], "qsv_h264": ["-c:v", "h264_qsv"], "nvenc_hevc": ["-c:v", "hevc_nvenc", "-preset", "p4", "-rc", "vbr"], } class WorkerLock: """Windows releases this byte-range lock even if the worker crashes.""" def __init__(self, inbox): self.path = inbox / ".worker.lock" self.file = None def __enter__(self): import msvcrt self.file = self.path.open("a+b") self.file.seek(0, os.SEEK_END) if self.file.tell() == 0: self.file.write(b"\0") self.file.flush() self.file.seek(0) try: msvcrt.locking(self.file.fileno(), msvcrt.LK_NBLCK, 1) except OSError: self.file.close() self.file = None raise return self def __exit__(self, *args): # Closing the handle releases the lock; keep the file to avoid races. self.file.close() def load_job(job_path): # Windows PowerShell 5.1's UTF8 output includes a BOM. with job_path.open(encoding="utf-8-sig") as source: job = json.load(source) if not isinstance(job, dict): raise ValueError("Job must be a JSON object") for key in ("input", "output", "preset"): if not isinstance(job.get(key), str) or not job[key].strip(): raise ValueError("Job requires a nonempty string: " + key) if job["preset"] not in PRESETS: raise ValueError("Unknown preset: " + job["preset"]) quality = job.get("crf", 23) if not isinstance(quality, int) or isinstance(quality, bool): raise ValueError("crf must be an integer") def job_file(value): path = Path(value).expanduser() return (job_path.parent / path).resolve() input_path = job_file(job["input"]) output_path = job_file(job["output"]) if not input_path.is_file(): raise ValueError("Input file does not exist: " + str(input_path)) if input_path == output_path: raise ValueError("Input and output must be different files") if output_path.exists(): raise ValueError("Output already exists: " + str(output_path)) if not output_path.suffix: raise ValueError("Output needs a container extension, such as .mp4") return input_path, output_path, job["preset"], quality def archive_job(job_path, directory, error=None): target = directory / job_path.name while target.exists() or target.with_suffix(target.suffix + ".err").exists(): target = directory / (job_path.stem + "-" + uuid.uuid4().hex + ".json") if error is not None: target.with_suffix(target.suffix + ".err").write_text(error, encoding="utf-8") job_path.rename(target) return target def process_job(job_path, done, failed, ffmpeg): temporary_output = None try: input_path, output_path, preset, quality = load_job(job_path) output_path.parent.mkdir(parents=True, exist_ok=True) # Keep the container suffix so ffmpeg can infer the output format. temporary_output = output_path.with_name( "." + output_path.stem + "-" + uuid.uuid4().hex + ".part" + output_path.suffix ) video_args = list(PRESETS[preset]) if preset == "qsv_h264": video_args += ["-global_quality", str(quality)] else: video_args += ["-cq", str(quality), "-b:v", "0"] command = [ ffmpeg, "-hide_banner", "-nostdin", "-n", "-i", str(input_path), "-map", "0:v:0", "-map", "0:a:0?", *video_args, "-pix_fmt", "nv12", "-c:a", "aac", "-b:a", "192k", str(temporary_output), ] logging.info("Job %s: %s", job_path.name, subprocess.list2cmdline(command)) with tempfile.TemporaryFile() as stderr: result = subprocess.run(command, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=stderr, check=False) if result.returncode != 0: # Bound diagnostic size even for a long-running failed encode. stderr.seek(0, os.SEEK_END) stderr.seek(max(0, stderr.tell() - 65536)) detail = stderr.read().decode("utf-8", errors="replace") raise RuntimeError( "ffmpeg exited with code {}\nCommand: {}\n\n{}".format( result.returncode, subprocess.list2cmdline(command), detail ) ) if not temporary_output.is_file() or temporary_output.stat().st_size == 0: raise RuntimeError("ffmpeg returned success without a nonempty output") # On Windows rename refuses to replace an existing destination. temporary_output.rename(output_path) archive_job(job_path, done) logging.info("Completed %s -> %s", job_path.name, output_path) return True except Exception as error: logging.error("Failed %s: %s", job_path.name, error) archive_job(job_path, failed, "{}: {}\n".format(type(error).__name__, error)) return False finally: if temporary_output is not None: temporary_output.unlink(missing_ok=True) def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--inbox", type=Path, default=Path(r"C:\transcode-jobs\inbox"), help="JSON inbox; done/failed are sibling directories") parser.add_argument("--ffmpeg", default="ffmpeg", help="ffmpeg executable (default: PATH)") parser.add_argument("--poll-interval", type=float, default=5, help="Poll interval in seconds") parser.add_argument("--once", action="store_true", help="Process the current inbox and exit") args = parser.parse_args() if os.name != "nt": parser.error("Run this worker with Windows Python directly, not inside WSL") if not math.isfinite(args.poll_interval) or args.poll_interval <= 0: parser.error("--poll-interval must be a finite positive number") logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") inbox = args.inbox.resolve() done, failed = inbox.parent / "done", inbox.parent / "failed" for directory in (inbox, done, failed): directory.mkdir(parents=True, exist_ok=True) lock = WorkerLock(inbox) try: lock.__enter__() except OSError as error: logging.error("Cannot acquire worker lock (another worker may be running): %s", error) return 1 try: logging.info("Polling %s", inbox) had_failure = False while True: for job_path in sorted(inbox.glob("*.json")): if job_path.is_file(): had_failure = not process_job(job_path, done, failed, args.ffmpeg) or had_failure if args.once: return int(had_failure) time.sleep(args.poll_interval) except KeyboardInterrupt: logging.info("Worker stopped") return 0 finally: lock.__exit__() if __name__ == "__main__": raise SystemExit(main())