Initial commit
This commit is contained in:
+21
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3.14
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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()
|
||||||
@@ -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"
|
||||||
@@ -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" },
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user