# OpenTranscode

**Open-source batch video transcoder for Linux.** Encodes folders of video
files to AV1 / VP9 / HEVC with configurable audio codecs, resolution
scaling, and source-file management. Built on ffmpeg (default) with an
optional av1an chunk-parallel path for users with a working VapourSynth
setup.

- **Default encoder**: ffmpeg + libsvtav1 (reliable across distros)
- **Optional encoder**: av1an chunk-parallel (opt-in via UI toggle)
- **Codecs**: AV1 (SVT-AV1), VP9, x265 (HEVC) video; Opus, Vorbis, FLAC, IAMF audio
- **Containers**: MKV, WebM, MP4
- **Resolution**: Original or scaled (16:9, 21:9, 32:9 presets from 480p to 4K)
- **Skip-existing**: Probes output with ffprobe; skips files whose codec matches
- **Audio normalization**: Per-file loudness analysis with volume gain
- **Subtitle mux**: Optional English subtitle passthrough
- **Source management**: Optional verified-source deletion after encode

## Requirements

- Linux (POSIX)
- Python ≥ 3.12
- ffmpeg (with libsvtav1, libvpx, libx265, libopus, libvorbis, flac)
- ffprobe
- PySide6 (for the GUI)
- Optional: av1an + VapourSynth (only if using the av1an toggle)

## Quick start

### Install as a package (recommended)

```bash
cd /path/to/opentranscode
pip install -e .

opentranscode                        # launch the GUI
python -m opentranscode --version    # → opentranscode 4.5.0
python -m opentranscode --help
```

### Run the launcher script (backwards compat)

```bash
python open-transcode.py             # launches the GUI
```

### Verify environment without encoding

```bash
opentranscode --dry-run              # probe + smoke test, no encode
opentranscode --verify-only FILE.mkv # re-verify an existing output
```

## Usage

### Default (ffmpeg-only, recommended)

```bash
opentranscode
```

- Encodes with `ffmpeg -c:v libsvtav1` (or libvpx-vp9 / libx265 based on selection)
- Single-pass per file
- Reliable across distros; no VapourSynth dependency

### Skip-existing (default ON)

Files whose output already exists with a matching video+audio codec are
skipped. Detection uses ffprobe — verifies `codec_name` for both video
and audio streams, plus resolution when scaling is requested.

```bash
opentranscode                       # skip-existing ON (default)
opentranscode --force-reencode      # re-encode everything
```

### Verbose logging

Default log output is minimal — two lines per file (start + finish):

```
Found 180 file(s) to process.
[1/180] filename.mkv
[1/180] filename.mkv — OK: 1.6MB -> 1.3MB (81%)
[2/180] already_done.mkv — SKIP (already av1/opus)
[3/180] next.mkv
[3/180] next.mkv — OK: 2.4MB -> 1.8MB (75%)
QUEUE COMPLETE. Success: 178, Failed: 0, Skipped: 2.
```

For diagnostics (CMD lines, live tail of ffmpeg/av1an stderr, disk-space
warnings, heartbeats):

```bash
opentranscode --verbose
```

### Massive-file support

For 30GB+ BluRay rips:

- **24-hour per-file timeout** (configurable via `--timeout SECONDS`)
- **1KB absolute integrity minimum** (no false "output too small" failures
  on high-bitrate sources — duration check is the real gate)
- **Disk-space warnings** (verbose only) for output and temp partitions

```bash
opentranscode --timeout 36000       # 10h per-file timeout
```

### av1an chunk-parallel (opt-in)

For users with a working VapourSynth + source plugin (lsmash, ffms2,
bestsource) setup who want scene-detection-based chunk-parallel encoding:

- **UI**: Check the "av1an (chunk-parallel)" checkbox
- **CLI**: `opentranscode --use-av1an`

When av1an fails per-file (concat failures, scene-detection panics),
the code automatically falls back to ffmpeg for that file. When av1an
fails systematically (VSScript API mismatch, missing encoder), the queue
aborts with an actionable diagnostic.

## CLI reference

```
opentranscode [--version] [--dry-run] [--verify-only PATH] [--force]
              [--chunk-method METHOD] [--max-workers N] [--threads-per-worker N]
              [--use-av1an] [--verbose] [--skip-existing | --force-reencode]
              [--timeout SECONDS]
```

| Flag | Default | Description |
|------|---------|-------------|
| `--version` | — | Print version and exit |
| `--dry-run` | — | Probe environment + smoke test, no encode |
| `--verify-only PATH` | — | Re-verify an existing output file |
| `--force` | off | Skip ffprobe pre-validation |
| `--chunk-method METHOD` | auto | Force av1an chunk method (select, hybrid, ffms2, lsmash, bestsource, dgdecnv) |
| `--max-workers N` | auto | Cap chunk-parallel worker count |
| `--threads-per-worker N` | auto | Per-encoder thread cap |
| `--use-av1an` | off | Use av1an chunk-parallel (UI toggle also available) |
| `--verbose` | off | Full tech-detail log output |
| `--skip-existing` | on | Skip files whose output has matching codec |
| `--force-reencode` | off | Re-encode everything |
| `--timeout SECONDS` | 86400 | Per-file encode timeout (24h default) |

## Architecture

```
opentranscode/
├── __init__.py          # Package metadata + lazy launch_gui wrapper
├── __main__.py          # python -m opentranscode entry point
├── cli.py               # argparse + dry-run + verify-only
├── codec_profiles.py    # VideoCodecProfile, AudioProfile, ContainerProfile tables
├── encoder_worker.py    # QThread-based per-file encode pipeline
├── env_probe.py         # Distro + binary + library + av1an detection
├── ffprobe_utils.py     # ffprobe_validate, ffprobe_duration, file-type ID
├── temp_manager.py      # Per-worker temp directory isolation
├── cpu_topology.py      # Physical core / logical thread detection
├── distro_probe.py      # Distro family + package manager detection
├── keepawake.py         # systemd-inhibit + optional mouse nudge
├── source_builder.py    # From-git rebuild for VapourSynth/av1an ABI mismatches
├── license_registry.py  # Third-party license attribution
├── ui_window.py         # PySide6 main window + launch_gui
├── ui_theme.py          # Retro-futuristic QSS theme
└── widgets/             # Custom Qt widgets (radio_knob, etc.)

open-transcode.py        # Launcher script (mirrors package, test target)
pyproject.toml           # PEP 621 build config
tests/                   # 149 tests across 14 files
```

### Encode pipeline

1. **Environment probe** — detects distro, ffmpeg/ffprobe/av1an paths,
   encoder library availability, VapourSynth + source plugins, CPU topology
2. **Pre-flight validation** — ffprobe scans all input files; reports valid
   vs invalid counts before encoding starts
3. **Per-file pipeline**:
   - `_validate_file` — ffprobe pre-check (skip if invalid)
   - `_check_disk_space` — warn (verbose) if free space < source size
   - `_output_already_encoded` — skip if output exists with matching codec
   - `_prepare_input` — pre-scale (if requested) or symlink to temp
   - `_encode_one` — dispatch to ffmpeg (default) or av1an (opt-in)
   - `_run_with_stop_check` — subprocess with STOP-button interrupt support
   - `_verify_and_finalize` — duration check (≥95%), subtitle mux, source deletion
4. **Final cleanup** — sweep per-worker temp dir, delete verified sources

### Thread safety

- Each `EncoderWorker` runs in its own `QThread`
- Per-worker temp subdirectory (`~/.cache/OpenTranscode/tmp/worker-<pid>/`)
  created with mode 0700 (SEI CERT FIO09-C)
- Process-group signaling (`start_new_session=True` + `os.killpg`) reaches
  av1an's child encoders (SvtAv1EncApp / vpxenc / x265)
- Drainer threads read stdout/stderr continuously to prevent pipe-buffer
  deadlock (same pattern as `subprocess.run._communicate`)

### Coding standards

The codebase follows:
- **PEP 868** — parameterized type hints (`dict[str, object]`, not `Dict[str, object]`)
- **SEI CERT** — MSC04-C (single source of truth for diagnostics), FIO09-C
  (secure temp directory), ERR01-C (narrow exception scope), STR09-C (no
  substring matches for encoder names)
- **POSIX** — `start_new_session=True` for process-group signaling,
  `signal.SIGTERM` → `SIGKILL` escalation, `os.killpg` for child cleanup
- **MISRA** (where applicable to Python) — single exit point per function
  where practical, no early returns from `try` blocks without cleanup

## Testing

```bash
python -m pytest tests/ -q          # 149 tests, ~10s
python -m pytest tests/ -v          # verbose
python -m pytest tests/ -k "skip_existing"  # subset
```

Test categories:
- **Smoke tests** — av1an VSScript compatibility probe
- **Encoder pipeline** — `_validate_file`, `_prepare_input`, `_verify_and_finalize`
- **Real-encode e2e** — generates real test videos with ffmpeg, runs the full pipeline
- **Chunk-method retry** — y4m pipe break recovery
- **Stop button** — SIGTERM/SIGKILL on process group
- **Concurrent workers** — per-PID temp directory isolation
- **Skip-existing** — codec matching, ffprobe failure, resolution mismatch
- **Massive files** — timeout flag, 1KB integrity threshold, disk-space checks
- **Package structure** — public API surface, submodule imports, CLI parser

## License

AGPL-3.0-or-later. See [LICENSE](LICENSE).

Third-party tools invoked (not bundled): ffmpeg, ffprobe, av1an,
VapourSynth, SvtAv1EncApp, vpxenc, x265, mkvmerge. Licenses flow
through from upstream.

## Project

- **Repository**: https://git.dcos.net/dcosnet/OpenTranscode
- **Issues**: https://git.dcos.net/dcosnet/OpenTranscode/issues
- **Changelog**: [CHANGELOG.md](CHANGELOG.md)
