Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 30 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,32 @@ Cross platform(ish) productivity commands written in python. Tools for doing med

[![Linting](https://github.com/zackees/zcmds/actions/workflows/lint.yml/badge.svg?branch=master)](https://github.com/zackees/zcmds/actions/workflows/lint.yml)

# Install

```bash
> pip install zcmds
> zcmds # shows all commands
> diskaudit # audits the disk usage from the current directory.
```
# Install

`pipx` is the recommended install path. This is especially important on
Debian/Ubuntu systems that enforce PEP 668 and block global `pip install`
inside the externally managed system Python.

```bash
pipx install zcmds
zcmds # shows all commands
diskaudit # audits the disk usage from the current directory.
```

If `pipx` is not installed:

```bash
sudo apt install pipx
pipx ensurepath
```

For a local virtual environment instead of `pipx`:

```bash
python -m venv .venv
source .venv/bin/activate
python -m pip install zcmds
```

# Commands

Expand Down Expand Up @@ -123,10 +142,10 @@ Cross platform(ish) productivity commands written in python. Tools for doing med

# Install (dev):

* `git clone https://github.com/zackees/zcmds`
* `cd zcmds`
* `python -pip install -e .`
* Test by typing in `zcmds`
* `git clone https://github.com/zackees/zcmds`
* `cd zcmds`
* `python -m pip install -e .`
* Test by typing in `zcmds`

# How to Add a New Command

Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,13 @@ dependencies = [
"json5",
"pipx",
"GitPython",
"transcribe-anything>=2.7.27",
"transcribe-anything>=3.0.11",
"send2trash",
"magic-wormhole",
"wormhole-tx",
"pillow-heif",
"advanced-aicode>=1.1.0",
"advanced-askai",
"advanced-askai>=1.0.3",
"pywinpty; platform_system=='Windows'",
"codeup>=1.0.1",
"psutil",
Expand Down
73 changes: 54 additions & 19 deletions src/zcmds/cmds/common/vid2mp4.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

import argparse
import os
import subprocess
import sys
from pathlib import Path
from typing import Sequence


VERSION = "0.2.0"
Expand Down Expand Up @@ -32,7 +34,34 @@
ALL_PRESETS = X264_PRESETS + NVENC_PRESETS


def main():
def build_ffmpeg_command(
filename: str,
out_path: Path,
*,
rencode: bool,
codec: str,
preset: str,
crf: int,
height: int | None,
) -> list[str]:
if not rencode:
return ["ffmpeg", "-i", filename, "-c", "copy", str(out_path)]

command = ["static_ffmpeg", "-hide_banner", "-i", filename]
if height:
command.extend(["-vf", f"scale=trunc(oh*a/2)*2:{height}"])

command.extend(["-vcodec", codec, "-preset", preset])
if codec == "h264_nvenc":
command.extend(["-rc", "constqp", "-qp", str(crf or 23)])
else:
command.extend(["-crf", str(crf or 23)])

command.extend(["-c:a", "copy", "-y", str(out_path)])
return command


def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Convert video to mp4")
parser.add_argument("filename", help="Path to video file", nargs="?")
parser.add_argument("--rencode", help="Rencode the video", action="store_true")
Expand All @@ -51,15 +80,17 @@ def main():
)
parser.add_argument("--height", help="Output video height.", type=int, default=None)
parser.add_argument("--version", help="Print version and exit", action="store_true")
args = parser.parse_args()
args = parser.parse_args(argv)
if args.version:
print(VERSION)
sys.exit(0)
args.rencode = args.rencode or args.nvenc or args.crf or args.height
return 0
if not args.filename:
parser.error("filename is required")
should_rencode = bool(args.rencode or args.nvenc or args.crf or args.height)
filename = args.filename
if not os.path.exists(filename):
print(f"{filename} does not exist")
sys.exit(1)
print(f"{filename} does not exist", file=sys.stderr)
return 1
out_path = Path(filename).with_suffix(".mp4")
if out_path.exists():
# Remove suffix from file name and add _converted.mp4
Expand All @@ -81,7 +112,7 @@ def main():
file=sys.stderr,
)
print(f"Valid NVENC presets: {', '.join(NVENC_PRESETS)}", file=sys.stderr)
sys.exit(1)
return 1
elif not args.nvenc and preset in NVENC_PRESETS and preset not in X264_PRESETS:
print(
f"Warning: Preset '{preset}' is NVENC-specific. Using with x264.",
Expand All @@ -92,19 +123,23 @@ def main():
else:
preset = "veryslow" # Default x264 preset

scale_cmd = f'-vf scale="trunc(oh*a/2)*2:{args.height}"' if args.height else ""

if args.rencode:
quality_stmt = f"-crf {args.crf}" if args.crf else "-b:v 0 -crf 23"
if codec == "h264_nvenc":
quality_stmt = f"-cq {args.crf}" if args.crf else "-cq 23"
cmd = f'static_ffmpeg -hide_banner -i "{filename}" {scale_cmd} -vcodec {codec} -preset {preset} {quality_stmt} -c:a copy -y "{out_path}"'
else:
cmd = f'ffmpeg -i "{filename}" -c copy "{out_path}"'
print(f"Running:\n {cmd}")
os.system(cmd)
cmd = build_ffmpeg_command(
filename,
out_path,
rencode=should_rencode,
codec=codec,
preset=preset,
crf=args.crf,
height=args.height,
)
print(f"Running:\n {subprocess.list2cmdline(cmd)}")
result = subprocess.run(cmd, check=False)
if result.returncode != 0:
print(f"ffmpeg failed with exit code {result.returncode}", file=sys.stderr)
return result.returncode
print(f"Generated {out_path}")
return 0


if __name__ == "__main__":
main()
sys.exit(main())
13 changes: 13 additions & 0 deletions tests/test_imgai.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import unittest


class ImgAiTester(unittest.TestCase):
def test_imgai_imports(self) -> None:
from zcmds.cmds.common import imgai

self.assertTrue(callable(imgai.main))
self.assertTrue(callable(imgai.read_console))


if __name__ == "__main__":
unittest.main()
66 changes: 66 additions & 0 deletions tests/test_vid2mp4.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import io
import subprocess
import tempfile
import unittest
from contextlib import redirect_stderr, redirect_stdout
from pathlib import Path
from unittest.mock import patch

from zcmds.cmds.common import vid2mp4


class Vid2Mp4Tester(unittest.TestCase):
def test_nvenc_uses_constqp_quality_args(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
input_path = Path(tmp_dir) / "input.mkv"
input_path.write_bytes(b"")

with patch("zcmds.cmds.common.vid2mp4.subprocess.run") as mock_run:
mock_run.return_value = subprocess.CompletedProcess([], 0)
with redirect_stdout(io.StringIO()):
result = vid2mp4.main([str(input_path), "--nvenc"])

self.assertEqual(0, result)
command = mock_run.call_args.args[0]
self.assertIn("-rc", command)
self.assertIn("constqp", command)
self.assertIn("-qp", command)
self.assertIn("23", command)
self.assertNotIn("-cq", command)

def test_x264_uses_crf_quality_args(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
input_path = Path(tmp_dir) / "input.mkv"
input_path.write_bytes(b"")

with patch("zcmds.cmds.common.vid2mp4.subprocess.run") as mock_run:
mock_run.return_value = subprocess.CompletedProcess([], 0)
with redirect_stdout(io.StringIO()):
result = vid2mp4.main([str(input_path)])

self.assertEqual(0, result)
command = mock_run.call_args.args[0]
self.assertIn("libx264", command)
self.assertIn("-crf", command)
self.assertIn("23", command)
self.assertNotIn("-rc", command)

def test_ffmpeg_failure_returned_without_generated_message(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
input_path = Path(tmp_dir) / "input.mkv"
input_path.write_bytes(b"")

stdout = io.StringIO()
stderr = io.StringIO()
with patch("zcmds.cmds.common.vid2mp4.subprocess.run") as mock_run:
mock_run.return_value = subprocess.CompletedProcess([], 9)
with redirect_stdout(stdout), redirect_stderr(stderr):
result = vid2mp4.main([str(input_path)])

self.assertEqual(9, result)
self.assertNotIn("Generated", stdout.getvalue())
self.assertIn("ffmpeg failed with exit code 9", stderr.getvalue())


if __name__ == "__main__":
unittest.main()
22 changes: 11 additions & 11 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading