Metadata-Version: 2.4
Name: proofpgs
Version: 1.7.5
Summary: PGS subtitle decoder with HDR and SDR support
License-Expression: GPL-3.0-or-later
Project-URL: Homepage, https://github.com/matthane/ProofPGS
Project-URL: Repository, https://github.com/matthane/ProofPGS
Keywords: pgs,subtitles,blu-ray,hdr,sdr
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE.txt
License-File: LICENSES/OFL.txt
Requires-Dist: numpy
Requires-Dist: pillow>=10
Dynamic: license-file

<img src="https://raw.githubusercontent.com/matthane/ProofPGS/refs/heads/main/proofpgs/assets/proofpgs-icon-light.png" width="64" height="64" alt="ProofPGS icon">

# ProofPGS

A tool for inspecting and exporting PGS (Presentation Graphic Stream) subtitles. Validates PGS tracks with per-track SDR/HDR detection and can export each subtitle as a PNG using the correct colour pipeline, either **HDR** (UHD Blu-ray, BT.2020 + PQ) or **SDR** (standard Blu-ray, BT.709).

Accepts `.sup` files directly, or video containers (MKV, MK3D, M2TS) from which PGS subtitle tracks are automatically discovered and extracted via [libpgs](https://github.com/matthane/libpgs).

## Installation

### pip (recommended)

```
pip install proofpgs
```

This installs ProofPGS with all dependencies and a bundled [libpgs](https://github.com/matthane/libpgs) binary. The `proofpgs` command is available system-wide after installation.

Available for Windows x64, Linux x64, macOS x64, and macOS ARM64.

### Release archive

Alternatively, download a pre-built archive for your platform from the [Releases](https://github.com/matthane/ProofPGS/releases) page. Each archive includes ProofPGS and a pre-built libpgs binary. Install the Python dependencies manually:

```
pip install numpy pillow
```

### Running from source

If you prefer to run from a git clone, you'll need to provide the [libpgs](https://github.com/matthane/libpgs) binary yourself. Place `libpgs` (or `libpgs.exe` on Windows) in `proofpgs/bin/`, or add it to your system PATH. Then install dependencies with `pip install numpy pillow`.

### Optional dependency

- [FFmpeg](https://ffmpeg.org/), needed only for the video stream dynamic range mismatch badge. If ffprobe is not on PATH, this feature is silently skipped.

## Quick Start

Point ProofPGS at any video file or `.sup` file:

```bash
proofpgs movie.mkv
```

That's it. ProofPGS will:

1. Detect all PGS subtitle tracks in the file
2. Auto-detect whether each track is SDR or HDR (per-track color space detection), flagging any mismatch with the video stream's dynamic range
3. Prompt you to pick which tracks to process (with an option to validate sparse tracks)
4. Prompt you for how many subtitles to decode (defaults to up to 10 cached analysis samples for instant output)
5. Decode using the correct color pipeline and save PNGs to a `<filename>_pgs_output/` folder next to the input file

Works the same way with `.sup` and `.m2ts` files.

## Advanced Usage

```
proofpgs <input_file> [options]
```

### Skip the prompts (non-interactive)

```bash
# Specific tracks, first 20 subtitles:
proofpgs movie.mkv --tracks 1,3 --first 20

# All tracks, all subtitles:
proofpgs movie.mkv --tracks all

# Custom output directory:
proofpgs movie.mkv --out ./my_output
```

### Extract a specific time range

Use `--start` and `--end` to extract subtitles from a specific portion of the file. libpgs seeks directly to the target offset, so data before the start point is not read.

```bash
# Subtitles from 5 minutes onward:
proofpgs movie.mkv --start 0:05:00

# Subtitles between 1:30:00 and 1:35:00:
proofpgs movie.mkv --start 1:30:00 --end 1:35:00

# First 10 subtitles within a time window:
proofpgs movie.mkv --start 0:05:00 --end 0:10:00 --first 10
```

Timestamps accept `HH:MM:SS.ms`, `MM:SS.ms`, `SS.ms`, or plain seconds (e.g. `300`). `--start` and `--end` can be used independently or together, and compose with `--first`.

### Output modes

ProofPGS has six output modes:

- `auto` (default): automatically detects whether each subtitle track was mastered for SDR or HDR by analyzing palette data, then decodes each track with the correct pipeline independently. A container with mixed SDR and HDR tracks will process each track using its own detected color space. Falls back to `compare` for any individual track where detection is inconclusive.
- `compare`: for delivery proofing. Produces an annotated PNG with a dark background showing the SDR and HDR decodes side by side, labelled for easy comparison. These are opaque RGB images meant for visual review.
- `hdr`: direct export. Outputs the HDR (BT.2020+PQ) decode as a transparent PNG, cropped to content. Useful when you need the subtitle graphic itself.
- `sdr`: direct export. Outputs the SDR (BT.709) decode as a transparent PNG, cropped to content.
- `validate`: analyzes all tracks without a time limit (with scan progress) and displays track information and SDR/HDR detection results without producing any output. Useful for thoroughly checking what PGS tracks a file contains and whether they are mastered for SDR or HDR, including sparse tracks that may be skipped during normal interactive analysis.
- `validate-fast`: runs the same analysis as `validate` but under the normal 10-second wallclock budget. Sparse tracks that can't be analyzed in time are flagged, and you're prompted to re-analyze them without a time limit if desired. Useful for a quick check when a full unbounded scan isn't needed.

```bash
# Auto-detect color space and decode accordingly (default):
proofpgs input.sup

# Force side-by-side comparison:
proofpgs input.sup --mode compare

# Direct export, transparent HDR-decoded PNGs:
proofpgs input.sup --mode hdr

# Direct export, transparent SDR-decoded PNGs:
proofpgs input.sup --mode sdr

# Show track info and detection only (no output):
proofpgs movie.mkv --mode validate

# Quick validation under 10s budget (prompts to re-analyze sparse tracks):
proofpgs movie.mkv --mode validate-fast
```

## Options

| Option | Values | Default | Description |
|---|---|---|---|
| `--mode` | `auto`, `compare`, `hdr`, `sdr`, `validate`, `validate-fast` | `auto` | `auto` detects color space per-track and decodes each track independently with the correct pipeline. `compare` produces annotated side-by-side proofing images. `hdr` and `sdr` produce direct transparent PNG exports. `validate` shows track info and detection only (no output). `validate-fast` same as validate but under the 10s analysis budget with option to re-analyze sparse tracks. |
| `--tonemap` | `clip`, `reinhard` | `clip` | HDR-to-SDR tonemapping strategy. `clip` limits the peak RGB channel at 203 nits reference white. `reinhard` applies a soft roll-off to the peak. Both scale all three channels together to preserve linear RGB ratios. |
| `--out` | path | `<filename>_pgs_output/` next to input file | Output directory. |
| `--first` | integer | all | Decode only the first N subtitle display sets. |
| `--start` | timestamp | beginning | Start timestamp for extraction (e.g. `0:05:00`, `5:00`, `300`). Seeks directly to the target offset. |
| `--end` | timestamp | end of file | End timestamp for extraction (e.g. `0:10:00`, `10:00`, `600`). |
| `--tracks` | e.g. `1,3,4` or `all` | interactive | Which PGS tracks to process (1-based, container input only). |
| `--nocrop` | flag | off | Output full video-frame-sized PNGs instead of cropping to subtitle content. |
| `--threads` | integer | auto (up to 8) | Number of parallel rendering threads. |
| `--install` | flag | n/a | Register file manager context menu entries for all supported file types. |
| `--uninstall` | flag | n/a | Remove file manager context menu entries. |

## File Manager Integration

ProofPGS can add a right-click context menu for all supported file types (`.sup`, `.mkv`, `.mk3d`, `.m2ts`). The menu shows entries for each output mode, filtered by file type.

```bash
# Register context menu entries:
proofpgs --install

# Remove context menu entries:
proofpgs --uninstall
```

When installed via pip, the context menu invokes the `proofpgs` command directly. When running from source or a release archive, the install command records the paths to both the Python interpreter and the project directory. If you move the project or switch Python environments, run `--install` again to update the paths.

| Platform | Mechanism | Notes |
|---|---|---|
| **Windows** | Explorer context menu via registry (`HKCU`) | On Windows 11, right-click and choose **Show more options** to see the submenu. |
| **Linux** | Freedesktop `.desktop` files in `~/.local/share/applications/` | Entries appear in the **Open With** menu. A custom MIME type is registered for `.sup` files. |
| **macOS** | Finder Quick Actions via Automator `.workflow` bundles in `~/Library/Services/` | You may need to enable the actions in **System Settings > Privacy & Security > Extensions > Finder**. |

## Output

Each subtitle is saved as a PNG file named with its display set index, timestamp, and decoded color space:

```
movie_pgs_output/
  track_1_eng/
    ds_0000_12500ms_sdr.png
    ds_0001_15200ms_sdr.png
    ...
  track_2_ger_forced/
    ds_0000_8300ms_hdr.png
    ...
```

The output folder is named after the input file (e.g. `movie.mkv` becomes `movie_pgs_output/`). The range suffix (`_sdr`, `_hdr`, or `_compare`) indicates which color pipeline was used to decode the subtitle.

For `.sup` input (single track), images are written directly to the output directory without a track subfolder.

## How SDR/HDR Detection Works

ProofPGS classifies each track by analysing the **subtitle's own palette colours**, not the video stream's metadata. Subtitle tracks can come from different sources than the video, so the video's range is not a reliable signal. The core check is a **PQ plausibility test**. Every visible palette entry is decoded under the HDR (BT.2020 + PQ) interpretation, where the 0-1 colour range maps onto 0-10,000 nits. Real HDR subtitles are authored near the 203-nit reference white, so if the brightest entry implies more than about 1000 nits under PQ, the HDR interpretation is implausible and the track is SDR. This is especially decisive for coloured text, where SDR gold at Y=178 decodes to a value that would mean about 4800 nits under PQ, obviously not real HDR.

When the PQ test is ambiguous, ProofPGS falls back to Y-value thresholds (SDR white sits at Y=235, HDR reference white at Y=143) and prefers achromatic entries when available, since white/gray decodes the same under either colour matrix. Two filters keep the detector honest: invisible entries (alpha below roughly 12.5%) are skipped to ignore fade-in palettes, and "ghost" palette entries that no pixel references are excluded so a single unrendered entry can't flip the verdict. Detection runs **per track**, so a container with mixed SDR and HDR subtitles decodes each one with the correct pipeline. Only genuinely inconclusive tracks fall back to `compare` mode.

Dark, opaque graphics need extra care: 3D subtitles can fade through black by changing their palette colours without reducing alpha. The brightest channel must reach roughly 150 nits under the PQ interpretation (code value 0.55), either in source BT.2020 or after linear conversion to BT.709. Checking both prevents saturated colours from being mistaken for dark graphics; this check precedes tone mapping and alpha blending. If a track opens below this intensity, HDR detection waits for a highlight above that floor, with a source PQ peak no higher than 0.65, to settle for at least 500 ms of subtitle timestamps. Its source PQ peak may vary by at most 0.015. Strong SDR evidence still ends analysis immediately, and normally lit openings retain single-sample detection. This is a heuristic: permanently dim SDR can resemble HDR. Tracks without enough evidence remain unknown and use comparison output in auto mode; the existing analysis limits still apply.

Detection and colour regression tests can be run with `python -m unittest discover -s tests`.

## Colour Pipeline

### HDR (UHD Blu-ray)

The palette is encoded in BT.2020 primaries with ST 2084 (PQ) transfer function, per the UHD BD specification. ProofPGS applies the full inverse pipeline:

```
BT.2020 YCbCr (limited range)  ->  BT.2020 matrix  ->  PQ EOTF (linearise)
  ->  BT.2020 to BT.709 gamut mapping  ->  Tonemap to SDR
  ->  sRGB gamma  ->  PNG
```

HDR colours are fitted into the SDR range by scaling all three linear RGB channels together. In `clip` mode, colours already within range stay unchanged; brighter colours are scaled until their peak channel reaches 203 nits reference white. This avoids shifting bright orange toward yellow by clipping red independently. `reinhard` uses a shared soft roll-off instead. Neutral whites follow the same tone curves as before, and alpha is preserved.

This trades highlight brightness for preservation of linear RGB ratios. It does not reproduce the full HDR appearance on an SDR display: colours outside the BT.709 primaries still have negative channels clamped to zero and can change chromaticity.

### SDR (standard Blu-ray)

```
BT.709 YCbCr (limited range)  ->  BT.709 matrix  ->  BT.1886 linearise (gamma 2.4)
  ->  sRGB gamma  ->  PNG
```

## Performance

Track detection is incremental: each new display set is inspected once, while colour statistics and brightness-settling state are retained. Extraction restarts reuse that progress and skip replayed samples before bitmap decoding. The discovery stream is reused for initial analysis, including indexed containers. These optimizations retain the same detection thresholds and earliest conclusive sample.

All file I/O is handled by [libpgs](https://github.com/matthane/libpgs), a Rust tool built for PGS segment extraction. libpgs streams decoded display sets over a subprocess pipe, with no temp files and no intermediate formats. For MKV files with a Cues index, libpgs seeks directly to subtitle data, reading only a few MB out of tens of GB for large UHD remuxes.

PNG rendering uses multiple threads by default (up to 8, override with `--threads`).

Palette indices are expanded with Pillow's native converter. Crop analysis scans only the occupied image region, retaining the same padding and thin-artifact filtering. Comparison exports reuse pre-rendered labels and branding across images; timestamps are drawn separately. PNG compression settings and rendered pixels are unchanged by these optimizations.

## Project Structure

```
proofpgs/
  assets/             # Bundled resources (fonts, icons)
    Sora-Medium.ttf
    Sora-Regular.ttf
  bin/                # Bundled libpgs binary (platform-specific, gitignored)
  __init__.py         # Public API exports
  __main__.py         # python -m proofpgs entry point
  cli.py              # Argument parsing and main()
  constants.py        # PQ constants, segment types, file extensions
  detect.py           # SDR/HDR auto-detection via PQ plausibility analysis
  parser.py           # Display set content check (ds_has_content)
  color.py            # Colour-space math and palette decoding (HDR & SDR)
  renderer.py         # Display set rendering and PNG output
  libpgs.py           # libpgs CLI adapter (subprocess streaming)
  ffmpeg.py           # ffprobe video range detection
  interactive.py      # Interactive track and count selection
  pipeline.py         # High-level orchestration
  shellmenu.py        # File manager context menu integration (Windows, Linux, macOS)
  style.py            # Terminal styling and color output
LICENSES/
  OFL.txt             # SIL Open Font License 1.1 (Sora font)
```

## Build Provenance

Before publishing, let the **Tests** workflow pass and manually run the **Release** workflow from GitHub Actions. A manual run builds and validates all four platform wheels without publishing to PyPI or uploading release assets. Publishing a GitHub Release triggers the full release flow; its tag must match the package version (for example, `v1.7.4`). Each platform runs the regression tests and validates wheel metadata before publication.

Release archives are built entirely in GitHub Actions from auditable source code, so no locally-built binaries are uploaded. The libpgs binary included in each release is compiled from the [libpgs source](https://github.com/matthane/libpgs) at its latest tagged release using `cargo build --release` on each platform's native CI runner.

Every release archive includes a `BUILD_INFO.txt` with the exact libpgs tag, commit hash, build target, and a link to the workflow run log. Release artifacts are signed with [Sigstore](https://www.sigstore.dev/) artifact attestations, cryptographically linking each archive to the GitHub Actions workflow and source commit that produced it.

To verify a downloaded release:

```bash
gh attestation verify ProofPGS-<version>-<platform>.zip --repo matthane/ProofPGS
```
