Initial commit

This commit is contained in:
2026-04-05 17:19:06 +02:00
commit b622786943
6 changed files with 337 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv
# OS
.DS_Store
Thumbs.db
# Video output
*.mp4
*.mkv
*.mov
*.avi
*.webm
+1
View File
@@ -0,0 +1 @@
3.14
+55
View File
@@ -0,0 +1,55 @@
# recscripts
FFmpeg encode helpers for FPV recordings (HDZero, Analog, Walksnail).
## Install
Requires [ffmpeg](https://ffmpeg.org/) (includes `ffprobe`) on your PATH.
```sh
uv pip install -e .
```
## Commands
### `hdzero`
Encode HDZero recordings with denoise, scale, and sharpen.
```sh
hdzero <input> <output> [options]
```
**Options**
| Flag | Default | Description |
| ---------------------------- | ------- | ------------------------------------ |
| `-ss <time>` | — | Start time (e.g. `00:01:30` or `90`) |
| `-to <time>` | — | End time (e.g. `00:02:00` or `120`) |
| `--crop` | off | Crop to 4:3 |
| `--no-audio` | off | Strip audio |
| `--height <px>` | `1080` | Output height |
| `--crf <n>` | `23` | Quality (lower = better) |
| `--preset <name>` | `fast` | Encoder preset |
| `--gpu [nvidia\|amd\|intel]` | off | Use GPU encoder |
| `--threads <n>` | all | Limit CPU threads |
| `-n`, `--dry-run` | off | Print ffmpeg command without running |
**Examples**
```bash
# Basic encode
hdzero VID_0013.TS output.mp4
# Crop to 4:3, strip audio
hdzero VID_0013.TS output.mp4 --crop --no-audio
# Trim a clip
hdzero VID_0013.TS clip.mp4 -ss 00:01:00 -to 00:02:30
# GPU encode (NVIDIA)
hdzero VID_0013.TS output.mp4 --gpu
# Limit CPU usage
hdzero VID_0013.TS output.mp4 --threads 4
```
+186
View File
@@ -0,0 +1,186 @@
import argparse
import queue
import subprocess
import sys
import threading
from pathlib import Path
from rich.console import Console
from rich.progress import (
BarColumn,
Progress,
TaskProgressColumn,
TextColumn,
TimeElapsedColumn,
TimeRemainingColumn,
)
from rich.text import Text
console = Console(stderr=True)
def probe_duration(input_file: str) -> float | None:
try:
result = subprocess.run(
[
"ffprobe", "-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
input_file,
],
capture_output=True,
text=True,
)
return float(result.stdout.strip())
except (ValueError, FileNotFoundError):
return None
def build_ffmpeg_cmd(args) -> list[str]:
cmd = ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y"]
if args.ss:
cmd += ["-ss", args.ss]
if args.to:
cmd += ["-to", args.to]
cmd += ["-i", args.input]
filters = ["hqdn3d=4:3:6:4", f"scale=-1:{args.height}", "unsharp"]
if args.crop:
filters.append("crop=ih/3*4:ih")
cmd += ["-vf", ",".join(filters)]
gpu = args.gpu
if gpu == "nvidia":
cmd += ["-c:v", "h264_nvenc", "-cq", str(args.crf), "-preset", "p4"]
elif gpu == "amd":
cmd += ["-c:v", "h264_amf", "-rc", "cqp", "-qp_i", str(args.crf), "-qp_p", str(args.crf)]
elif gpu == "intel":
cmd += ["-c:v", "h264_qsv", "-global_quality", str(args.crf), "-preset", args.preset]
else:
cmd += ["-crf", str(args.crf), "-preset", args.preset]
if args.threads > 0:
cmd += ["-threads", str(args.threads)]
if args.no_audio:
cmd += ["-an"]
else:
cmd += ["-c:a", "copy"]
cmd += ["-progress", "pipe:1", "-stats_period", "0.5"]
cmd.append(args.output)
return cmd
def _pipe_reader(stream, q: queue.Queue):
for line in iter(stream.readline, b""):
q.put(line.decode(errors="replace").strip())
q.put(None)
def run(cmd: list[str], duration: float | None) -> int:
label = Text(Path(cmd[-1]).name, style="bold cyan")
total = duration if duration else 100.0
with Progress(
TextColumn("{task.description}"),
BarColumn(),
TaskProgressColumn(),
TimeElapsedColumn(),
TimeRemainingColumn(),
console=console,
) as progress:
task = progress.add_task(str(label), total=total)
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
stdout_q: queue.Queue = queue.Queue()
threading.Thread(target=_pipe_reader, args=(proc.stdout, stdout_q), daemon=True).start()
threading.Thread(target=_pipe_reader, args=(proc.stderr, queue.Queue()), daemon=True).start()
current: dict[str, str] = {}
try:
while True:
line = stdout_q.get()
if line is None:
break
key, _, value = line.partition("=")
current[key] = value
if key == "out_time" and duration:
try:
h, m, s = value.split(":")
elapsed_s = int(h) * 3600 + int(m) * 60 + float(s)
progress.update(task, completed=min(elapsed_s, duration))
except ValueError:
pass
elif key == "speed" and not duration:
progress.update(task, advance=0.5)
elif key == "progress" and value == "end":
progress.update(task, completed=total)
except KeyboardInterrupt:
proc.terminate()
proc.wait()
console.print("\n[yellow]cancelled[/yellow]")
return 130
proc.wait()
if proc.returncode != 0:
console.print(f"[red]ffmpeg failed (exit {proc.returncode})[/red]")
else:
fps = current.get("fps", "?")
speed = current.get("speed", "?").strip()
size_bytes = int(current.get("total_size", 0))
for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
if size_bytes < 1024 or unit == "TiB":
break
size_bytes /= 1024
size_str = f"{size_bytes:.1f} {unit}"
console.print(f"[green]done[/green] {size_str} {fps} fps {speed}")
return proc.returncode
def main():
parser = argparse.ArgumentParser(description="HDZero encode helper")
parser.add_argument("input", help="Input file")
parser.add_argument("output", help="Output file")
parser.add_argument("-ss", help="Start time (e.g. 00:01:30 or 90)")
parser.add_argument("-to", help="End time (e.g. 00:02:00 or 120)")
parser.add_argument("--crop", action="store_true", help="Crop to 4:3 (crop=ih/3*4:ih)")
parser.add_argument("--no-audio", action="store_true", help="Strip audio (-an)")
parser.add_argument("--height", default=1080, type=int, help="Scale height (default: 1080)")
parser.add_argument("--crf", default=23, type=int, help="CRF value (default: 23)")
parser.add_argument("--preset", default="fast", help="Encoder preset (default: fast)")
parser.add_argument("--gpu", nargs="?", const="nvidia", choices=["nvidia", "amd", "intel"],
help="Use GPU encoder (default: nvidia). Choices: nvidia, amd, intel")
parser.add_argument("--threads", type=int, default=0, help="Limit CPU threads (default: all)")
parser.add_argument("-n", "--dry-run", action="store_true", help="Print command without running")
args = parser.parse_args()
cmd = build_ffmpeg_cmd(args)
if args.dry_run:
t = Text()
for i, token in enumerate(cmd):
if i > 0:
t.append(" ")
if i == 0:
t.append(token, style="bold cyan")
elif token.startswith("-"):
t.append(token, style="bold yellow")
else:
t.append(token, style="white")
Console().print(t)
return
duration = probe_duration(args.input)
sys.exit(run(cmd, duration))
if __name__ == "__main__":
main()
+17
View File
@@ -0,0 +1,17 @@
[project]
name = "recscripts"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.14"
dependencies = ["rich"]
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
[tool.setuptools]
py-modules = ["hdzero"]
[project.scripts]
hdzero = "hdzero:main"
Generated
+57
View File
@@ -0,0 +1,57 @@
version = 1
revision = 3
requires-python = ">=3.14"
[[package]]
name = "markdown-it-py"
version = "4.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "recscripts"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "rich" },
]
[package.metadata]
requires-dist = [{ name = "rich" }]
[[package]]
name = "rich"
version = "14.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" },
]