Files
Hermes Agent 26e972af85 Add MIT license
A public repository with no license is "all rights reserved" by default: nobody may
legally use, modify or redistribute it. MIT grants exactly the permissions a public
utility repo implies, while the warranty disclaimer keeps liability with the user.

Copyright (c) 2026 Lee Teong Hoe (Hoelee Enterprise). Linked from the README.
2026-10-04 14:56:20 +08:00

129 lines
5.3 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# universal-video-transcode
**Any video or audio file in, one MP4 out that plays on both an iPad and an Android
device — at the best quality the target codec allows.**
Give it a path. A file converts; a folder converts everything in it.
```sh
python scripts/transcode.py "D:/video/clip.mkv"
python scripts/transcode.py "D:/video" --recursive --dry-run # plan only, no encoding
python scripts/transcode.py "D:/video" --out D:/converted
```
## What it does
It reads the **source file** (never the filename) and picks one of three recipes:
| recipe | when | video | audio |
|---|---|---|---|
| `copy` | H.264/HEVC with a usable timeline | `-c copy` — **bit-identical** | copy if AAC |
| `fix-audio` | same, but the audio is not AAC | `-c copy` | AAC 256k |
| `encode` | VP9/AV1, or a timeline that cannot be copied | HEVC (or x264) | copy if AAC, else AAC 256k |
A copy is used whenever a copy is possible, because a copy is lossless *by definition* —
the strongest possible answer to "keep the quality the same".
Then it **verifies** the output and refuses to accept it otherwise: duration, resolution,
codec, `hvc1` tag, 8-bit pixel format, uniform frame cadence, no flat composition offsets,
and a full decode pass.
## Why it exists
A file can drop **~23% of its frames** in a browser while `r_frame_rate == avg_frame_rate`
reads **exactly** equal — so every fps-based check calls it healthy. The cause is
deterministic and header-readable: the MP4's composition-offset table is unusable, either a
single constant `ctts` entry (`PTS == DTS + const`) or **no `ctts` box at all** (`PTS == DTS`).
The timestamps are then in *decode* order, and the decoder's reordered output frames carry
the wrong times.
This repo gates on that, structurally, without playing anything.
```sh
python scripts/selftest.py # builds both defect shapes and asserts the detector
```
```
fixture expect got verdict
src.mp4 False False ok healthy MP4, proper ctts
src.mkv False False ok Matroska - no ctts by design
flat_noctts.mp4 True True ok MP4 with NO ctts box
flat_ctts1.mp4 True True ok MP4 with a single constant ctts entry
```
**Measured on the file that prompted this:** 23.0% of frames dropped before, **0.0% after**.
The full benefit case — the ten real failures this pipeline hit and the guard each one
produced — is in [`docs/WHY.md`](docs/WHY.md).
## Compatibility
| | iPad / iOS | Android |
|---|---|---|
| Container | MP4/MOV only — **no Matroska** | MP4 and MKV both fine |
| Video | H.264 (all), HEVC `hvc1` (A9+) | H.264 Baseline mandatory; HEVC 5.0+ |
| Audio | AAC — **never Opus** | AAC and Opus both fine |
Apple is the constraint, not Android: iOS has no Matroska demuxer and cannot play Opus, so
MP4 + AAC is what forces the conversion. Two traps: HEVC must be tagged **`hvc1`** (ffmpeg
defaults to `hev1`, which Apple refuses), and Android only *guarantees* HEVC Main profile
**Level 3** — use `--codec x264` when cheap/old Android hardware is in scope.
Details and citations: [`references/compatibility-matrix.md`](references/compatibility-matrix.md).
## Quality policy
- **Never upscale** — the source resolution is preserved; there is no `scale` in the pipeline.
- **HEVC** at `-cq 27`: measured **SSIM 0.9937** (visually transparent) at **~source size**.
- **H.264** (when chosen for reach) at `-crf 20`.
- **Audio** copied when already AAC; otherwise AAC 256k, where the residual error is the
source's own lossy error — 320k buys nothing measurable.
- `-fps_mode cfr` on every re-encode: without it the encoder silently drops 0.5–1.3% of
frames, leaving doubled intervals that play as judder.
## Running it unattended
Safe to schedule — it is idempotent and self-locking:
- a **lock file** refuses a second instance (two concurrent NVENC jobs starve one encoder and
*drop frames*, the exact defect this tool removes)
- an already-playable file is skipped, so a re-run costs one probe
- a failed item leaves the input untouched, so the next run retries it
- aborts below 5 GB free rather than filling the work drive
```sh
python scripts/transcode.py "Z:/media/incoming" --recursive \
--out "Z:/media/converted" >> /var/log/transcode.log 2>&1
```
## Install as a Hermes skill
This repo is the canonical source. Copy it into the skill directory rather than editing the
skill copy in place:
```sh
cp -r . "$LOCALAPPDATA/hermes/skills/media/universal-video-transcode"
```
See [`AGENTS.md`](AGENTS.md) for the non-negotiables.
## Options
| flag | meaning |
|---|---|
| `--dry-run` | print the plan, encode nothing |
| `--codec auto\|hevc\|x264` | force the video codec (`x264` for maximum reach) |
| `--cq N` / `--crf N` | quality (HEVC default 27 / H.264 default 20) |
| `--audio 256k`, `--channels 0` | audio bitrate; `0` keeps the source layout |
| `--cpu` | force libx265/libx264 (no NVENC) |
| `--keep` | keep the input file |
| `--overwrite` | replace an existing output |
| `--no-decode-check` | skip the full decode pass (faster, less safe) |
| `--lock PATH` | lock file location |
`ffmpeg`/`ffprobe` are taken from `PATH`, or from `TRANSCODE_FFMPEG` / `TRANSCODE_FFPROBE`.
## License
[MIT](LICENSE) © 2026 Lee Teong Hoe (Hoelee Enterprise)