From 1f635496fdacb98aed3568f81ca83d8cdc8f4112 Mon Sep 17 00:00:00 2001 From: cskonopka Date: Sun, 9 Nov 2025 18:01:42 -0500 Subject: [PATCH] adding new features, docs in docs, examples in examples --- docs/docs-captburn.txt | 66 +++ docs/docs-convert_dims.txt | 41 ++ docs/docs-convert_mux.txt | 60 +++ docs/docs-gamma_fix.txt | 93 +++++ docs/docs-hash_fingerprint.txt | 161 ++++++++ docs/docs-meta_extraction.txt | 146 +++++++ docs/docs-thumbs.txt | 131 ++++++ docs/docs-tonemap_hdr_sdr.txt | 93 +++++ experimental/captburn_top20_commands.txt | 27 ++ media/bbb.mov.videobeaux.meta.json | 87 ++++ media/logo.png | Bin 0 -> 5690 bytes media/{nan.gif => media.gif} | Bin out/bbb_contact_scenes_5x5.jpg | Bin 0 -> 51166 bytes out/outbbb_hashes.json | 8 + out/rollup4_small.captburn.ass | 109 +++++ out/rollup4_small.captburn.json | 506 +++++++++++++++++++++++ out/spacing_wide.captburn.ass | 26 ++ out/spacing_wide.captburn.json | 91 ++++ testfile.json | 1 + videobeaux/programs/gamma_fix.py | 225 ++++++++++ videobeaux/programs/hash_fingerprint.py | 251 +++++++++++ videobeaux/programs/lut_apply.py | 122 ++++++ videobeaux/programs/subs_convert.py | 324 +++++++++++++++ videobeaux/programs/thumbs.py | 183 ++++++++ videobeaux/programs/tonemap_hdr_sdr.py | 120 ++++++ videobeaux/programs/watermark.py | 199 +++++++++ 26 files changed, 3070 insertions(+) create mode 100644 docs/docs-captburn.txt create mode 100644 docs/docs-convert_dims.txt create mode 100644 docs/docs-convert_mux.txt create mode 100644 docs/docs-gamma_fix.txt create mode 100644 docs/docs-hash_fingerprint.txt create mode 100644 docs/docs-meta_extraction.txt create mode 100644 docs/docs-thumbs.txt create mode 100644 docs/docs-tonemap_hdr_sdr.txt create mode 100644 media/bbb.mov.videobeaux.meta.json create mode 100644 media/logo.png rename media/{nan.gif => media.gif} (100%) create mode 100644 out/bbb_contact_scenes_5x5.jpg create mode 100644 out/outbbb_hashes.json create mode 100644 out/rollup4_small.captburn.ass create mode 100644 out/rollup4_small.captburn.json create mode 100644 out/spacing_wide.captburn.ass create mode 100644 out/spacing_wide.captburn.json create mode 100644 testfile.json create mode 100644 videobeaux/programs/gamma_fix.py create mode 100644 videobeaux/programs/hash_fingerprint.py create mode 100644 videobeaux/programs/lut_apply.py create mode 100644 videobeaux/programs/subs_convert.py create mode 100644 videobeaux/programs/thumbs.py create mode 100644 videobeaux/programs/tonemap_hdr_sdr.py create mode 100644 videobeaux/programs/watermark.py diff --git a/docs/docs-captburn.txt b/docs/docs-captburn.txt new file mode 100644 index 0000000..75db570 --- /dev/null +++ b/docs/docs-captburn.txt @@ -0,0 +1,66 @@ +# videobeaux: captburn + +## Description +`captburn` is a modular caption-generation and burn-in engine for videobeaux. +It ingests video and transcript data (JSON-based) and produces high-quality `.ass` subtitle files or directly burns captions into the video. +It supports multiple captioning modes — **pop-on**, **paint-on**, and **roll-up** — and preserves all ASS styling fields. + +## Features +- Generates ASS subtitles from JSON transcripts or caption files. +- Supports `popon`, `painton`, and `rollup` timing modes. +- Full styling options: font, size, color, outline, background, alignment, margins, rotation. +- Generates both `.captburn.json` (metadata) and `.captburn.ass` (subtitle style) sidecars. +- Can reburn captions directly using `--caption` JSON without reprocessing. +- Threaded pipeline compatible with FFmpeg rendering. + +## Parameters +| Flag | Type | Description | +|------|------|--------------| +| `-i, --input` | str | Input video file | +| `--json` | str | Path to transcript or caption JSON | +| `--caption` | str | (Optional) Caption JSON for reburn | +| `--rotate` | float | Rotation angle for text (ASS Angle tag) | +| `--font` | str | Font family (e.g. Arial, Helvetica) | +| `--font-size` | int | Text size | +| `--color` | str | Primary text color (hex or ASS color) | +| `--outline-color` | str | Outline color | +| `--back-color` | str | Background box color | +| `--back-opacity` | float | Background opacity (0.0–1.0) | +| `--align` | int | Alignment (ASS-style \\anN code) | +| `--margin-v` | int | Vertical margin in pixels | +| `--tracking` | float | Character spacing multiplier | +| `--rotate` | float | Rotation of the text block | +| `-F, --force` | flag | Overwrite existing files | + +## Modes +| Mode | Description | +|------|--------------| +| `popon` | Sentence-level captions (common for subtitles) | +| `painton` | Word-by-word reveal effect | +| `rollup` | Scrolling line-by-line broadcast style | + +## Example Usage +```bash +# Generate and burn captions using a transcript JSON +videobeaux -P captburn -i ./media/bbb.mov --json ./media/bbb.json -F + +# Burn captions from an existing caption JSON (reburn mode) +videobeaux -P captburn -i ./media/bbb.mov --caption ./media/bbb.captburn.json -F + +# Specify font, size, and color +videobeaux -P captburn -i ./media/bbb.mov --json ./media/bbb.json --font "Helvetica" --font-size 42 --color "#FFFFFF" --outline-color "#000000" --align 2 --margin-v 50 -F + +# Apply rotation to text (e.g., stylized tilt) +videobeaux -P captburn -i ./media/bbb.mov --json ./media/bbb.json --rotate 5 -F +``` + +## Outputs +- `.captburn.ass`: Styled subtitle file +- `.captburn.json`: Caption metadata file +- `.mp4`: (if burn enabled) Video with captions baked in + +## Notes +- Automatically detects video resolution for accurate ASS PlayResX/Y. +- Uses actual pixel-true coordinates and alignment. +- Fully compatible with reburn workflow using `--caption`. +- Built upon FFmpeg `subtitles` and `ass` filters for reliable rendering. diff --git a/docs/docs-convert_dims.txt b/docs/docs-convert_dims.txt new file mode 100644 index 0000000..a426bb9 --- /dev/null +++ b/docs/docs-convert_dims.txt @@ -0,0 +1,41 @@ +# videobeaux: convert_dims + +## Description +`convert_dims` resizes videos according to standardized or platform-specific aspect ratio presets. +It supports direct scaling, padding to maintain aspect ratio, and an optional “translate/stretch” mode to fill target dimensions exactly. + +## Features +- Dozens of predefined presets: HD, 4K, Instagram Reels, YouTube Shorts, TikTok, etc. +- Stretch (`--translate yes`) or maintain aspect ratio (`--translate no`). +- New "fill" mode allows uniform scaling with crop to fill the target frame. +- Automatically sets safe `setdar` and `setsar` filters. +- Optional forced overwrite with `-F`. + +## Parameters +| Flag | Type | Description | +|------|------|--------------| +| `-i, --input` | str | Input video file | +| `--output-format` | str | Output format (e.g. mp4, mov) | +| `--preset` | str | Target dimension preset (e.g. 1080p, instagram_reels) | +| `--translate` | yes/no | Stretch to fit or preserve aspect ratio with padding | +| `--fill` | yes/no | Crop to fill the target frame (centered) | +| `-F, --force` | flag | Overwrite output file | + +## Example Usage +```bash +# Resize to 1080p maintaining aspect ratio +videobeaux -P convert_dims -i ./media/bbb.mov --output-format mp4 --preset 1080p -F + +# Force exact stretch to TikTok format (9:16) +videobeaux -P convert_dims -i ./media/bbb.mov --output-format mp4 --preset tiktok_video --translate yes -F + +# Maintain ratio with letterboxing to square +videobeaux -P convert_dims -i ./media/bbb.mov --output-format mp4 --preset square1080 --translate no -F + +# Fill target aspect ratio (crop edges to fit) +videobeaux -P convert_dims -i ./media/bbb.mov --output-format mp4 --preset instagram_reels --fill yes -F +``` + +## Output +- Produces scaled MP4 or MOV with proper aspect ratio metadata. +- Compatible with social platforms and web delivery. diff --git a/docs/docs-convert_mux.txt b/docs/docs-convert_mux.txt new file mode 100644 index 0000000..c37a516 --- /dev/null +++ b/docs/docs-convert_mux.txt @@ -0,0 +1,60 @@ +# videobeaux: convert_mux + +## Description +`convert_mux` provides a unified wrapper for re-muxing or transcoding any input media file. +It’s a high-level black-box converter supporting FFmpeg profiles and custom codec settings. + +## Features +- Automatically infers codecs and container from extension. +- Supports dozens of curated profiles: mp4_h264, mp4_hevc, webm_vp9, prores_422, lossless_ffv1, etc. +- Stream-copy mode for instant rewraps. +- Handles audio-only formats (mp3, aac, flac, etc.) +- Full FFmpeg passthrough via `--` for raw arguments. + +## Parameters +| Flag | Type | Description | +|------|------|--------------| +| `-i, --input` | str | Input media file | +| `-o, --output` | str | Output file (container inferred by extension) | +| `--profile` | str | Use a curated preset (see list below) | +| `--vcodec` / `--acodec` | str | Manual override codecs | +| `--crf`, `--bitrate`, etc. | mixed | Control quality, rate, and buffer settings | +| `--copy` | flag | Stream copy without re-encoding | +| `--format` | str | Force container format | +| `--ffmpeg_args` | list | Raw FFmpeg passthrough args | +| `-F, --force` | flag | Overwrite output | + +## Common Profiles +| Profile | Container | Description | +|----------|------------|-------------| +| mp4_h264 | MP4 | Web-friendly H.264/AAC | +| mp4_hevc | MP4 | HEVC (H.265) Apple-ready | +| webm_vp9 | WebM | VP9 + Opus | +| prores_422 | MOV | ProRes mezzanine | +| prores_4444 | MOV | ProRes + Alpha | +| lossless_ffv1 | MKV | Archival | +| avi_mjpeg_fast | AVI | Fast MJPEG | +| avi_mpeg4_fast | AVI | Legacy fast MPEG-4 | + +## Example Usage +```bash +# Simple H.264 conversion +videobeaux -P convert_mux -i ./media/bbb.mov -o ./out/bbb_h264.mp4 --profile mp4_h264 -F + +# Convert to WebM VP9 + Opus +videobeaux -P convert_mux -i ./media/bbb.mov -o ./out/bbb_vp9.webm --profile webm_vp9 -F + +# Convert to ProRes for post-production +videobeaux -P convert_mux -i ./media/bbb.mov -o ./out/bbb_prores.mov --profile prores_422 -F + +# Fast rewrap (no re-encode) +videobeaux -P convert_mux -i ./media/bbb.mov -o ./out/bbb.mkv --copy -F + +# Raw FFmpeg flags +videobeaux -P convert_mux -i ./media/bbb.mov -o ./out/bbb_custom.mp4 --profile mp4_h264 -- --max_muxing_queue_size 9999 +``` + +## Output +- Encoded or rewrapped video/audio in the specified format. +- Preserves metadata and timestamps. +- Ideal for format conversion, web publishing, or broadcast deliverables. diff --git a/docs/docs-gamma_fix.txt b/docs/docs-gamma_fix.txt new file mode 100644 index 0000000..e5d2cfe --- /dev/null +++ b/docs/docs-gamma_fix.txt @@ -0,0 +1,93 @@ +videobeaux — gamma_fix +======================== + +Purpose +------- +Normalize overall exposure for web/broadcast delivery by sampling video luma +(YAVG) with a fast prepass, computing gentle contrast/brightness (and optional +gamma), then encoding the adjusted video. Optionally clamp output to +broadcast-legal (TV/limited) range. + +How It Works +------------ +1) Probe pass: ffmpeg + signalstats collects per-frame YAVG values (0..255). +2) Robust center: we take the median YAVG to avoid bright/dark spikes. +3) Mapping: + - Choose contrast ~= target/current (clamped to a friendly range). + - Solve brightness so the current median maps near the desired target. + - Optional gamma curve if you request it. +4) Optional “legalize” remaps to TV range and outputs yuv420p for broad + compatibility. + +Basic Invocation +---------------- +videobeaux -P gamma_fix -i INPUT -o OUTPUT [options] + +Where -P selects the program, and -i/-o/--force and similar are provided by +videobeaux’s CLI front-end. + +Inputs / Outputs +---------------- +- Input: Any ffmpeg-readable video file. +- Output: Encoded video with exposure normalization applied. +- Audio: Encoded using --acodec/--ab (defaults aac/160k). + +Key Options (program-specific) +------------------------------ +--target-yavg FLOAT Target average luma, 0..255 (default: 64.0) + ~64 is a balanced midpoint for web. Try 60–70 for + darker looks, 70–90 for brighter looks. + +--min-contrast FLOAT Lower clamp for the auto contrast (default: 0.80) +--max-contrast FLOAT Upper clamp for the auto contrast (default: 1.35) + +--gamma FLOAT Optional gamma adjustment (default: 1.00 = neutral). + Leave at 1.00 to rely purely on contrast/brightness. + +--sat FLOAT Saturation multiplier via hue filter (default: 1.00). + Example: 1.10 = +10% saturation. + +--legalize Clamp output to broadcast-legal (TV/limited) range, + then convert to yuv420p for delivery safety. + +--vcodec STR Video codec (default: libx264) +--crf STR Quality factor for x264/x265 (default: 18) +--preset STR Encoder preset (default: medium) +--acodec STR Audio codec (default: aac) +--ab STR Audio bitrate (default: 160k) + +Notes on Global Options (provided by videobeaux CLI) +---------------------------------------------------- +-i PATH Input file path +-o PATH Output file path +--force Overwrite output if it exists + +Quality / Performance Tips +-------------------------- +- Start with --target-yavg 64 (neutral) and tweak by ±5–10 for taste. +- Keep contrast clamps near defaults for natural results; widening them can + push highlights/shadows into clipping on contrasty scenes. +- --legalize is recommended for broadcast or when downstream platforms expect + limited (TV) range. For purely web, it’s optional but safe. +- A small --sat like 1.05–1.12 often livens washed-out sources without + overshooting. +- If your footage is very dark or very bright across the entire piece, adjust + --target-yavg rather than forcing aggressive contrast. + +Edge Cases +---------- +- Extremely low dynamic range (flat/cast) may require manual grading beyond + this auto-normalizer. +- Heavily stylized content (crushed blacks, hard clipping) can benefit from a + lower --max-contrast and modest --sat. +- If probing fails (rare), the module defaults to a neutral pass (contrast=1, + brightness=0). + +Examples +-------- +See the separate “gamma_fix_EXAMPLES.txt” file for ready-to-run commands. + +Versioning +---------- +Keep this program additive to your baseline. Do not remove other features in +videobeaux; this module is designed as a drop-in program callable via -P. diff --git a/docs/docs-hash_fingerprint.txt b/docs/docs-hash_fingerprint.txt new file mode 100644 index 0000000..a644c47 --- /dev/null +++ b/docs/docs-hash_fingerprint.txt @@ -0,0 +1,161 @@ +videobeaux — hash_fingerprint +================================= + +## Description + +`hash_fingerprint` is a fast, flexible hashing cataloger for media libraries within videobeaux. +It computes deterministic hashes and fingerprints to ensure data integrity, verify exports, detect duplicates, and measure perceptual similarity. + +### Features +- File-level hashes: `md5`, `sha1`, `sha256` (streamed, low RAM) +- Stream-level hash: FFmpeg-based hash of decoded content +- Frame-level checksum: `framemd5` per frame +- Perceptual hash: aHash over sampled frames (Pillow required) +- Works on single files or entire directories (recursive) +- Outputs to JSON or CSV + +--- + +## Why Use It + +### 1. Integrity & Provenance +Ensure the exact same content is delivered or archived — detect even one-bit changes. + +### 2. Duplicate & Version Control +Detect duplicates and content drift across export iterations. + +### 3. Codec-Level Comparison +FFmpeg’s stream hash reveals content changes even when metadata or bitrates differ. + +### 4. Frame-Accurate Verification +framemd5 provides true frame-level checksum comparison. + +### 5. Perceptual Matching +Find visually similar clips using aHash to detect re-encodes or near-duplicates. + +--- + +## Use Cases + +- Library audits for media integrity +- Delivery verification (QC workflows) +- Regression testing for re-exports +- Duplicate detection +- Visual similarity clustering (phash) + +--- + +## Inputs & Outputs + +**Inputs** +- `-i/--input`: file or directory +- `--recursive`: traverse directories +- `--exts`: filter by extensions + +**Outputs** +- `--catalog`: JSON or CSV catalog path + +### Example JSON Record +```json +{ + "path": "/abs/path/to/media/bbb.mov", + "size_bytes": 12345678, + "file_md5": "…", + "file_sha256": "…", + "stream_sha256": "…", + "framemd5": ["stream, pts, checksum…"], + "phash_algo": "aHash", + "phash_frames": 124, + "phash_list": ["f3a1…", "9b7c…"] +} +``` + +--- + +## Key Flags + +| Flag | Description | +|------|--------------| +| `--file-hashes` | md5, sha1, sha256 (default: md5 sha256) | +| `--stream-hash` | Compute stream hash using FFmpeg | +| `--framemd5` | Generate per-frame checksums | +| `--phash` | Enable perceptual hashing | +| `--phash-fps` | Sample frequency for phash | +| `--phash-size` | Hash matrix size (8 → 64-bit, 16 → 256-bit) | +| `--catalog` | Output catalog path (.json or .csv) | + +--- + +## Example Commands + +**Default file hash** +```bash +videobeaux -P hash_fingerprint -i ./media/bbb.mov --catalog ./out/outbbb_hashes.json -F +``` + +**Directory recursive hash** +```bash +videobeaux -P hash_fingerprint -i ./media --recursive --exts .mp4 .mov --catalog ./out/outdir_hashes.json -F +``` + +**Add stream hash** +```bash +videobeaux -P hash_fingerprint -i ./media/bbb.mov --stream-hash sha256 --stream-kind video --catalog ./out/outbbb_streamsha.json -F +``` + +**Frame checksum** +```bash +videobeaux -P hash_fingerprint -i ./media/bbb.mov --framemd5 --catalog ./out/outbbb_framemd5.json -F +``` + +**Perceptual hash** +```bash +videobeaux -P hash_fingerprint -i ./media/bbb.mov --phash --phash-fps 1.0 --phash-size 16 --catalog ./out/outbbb_phash.json -F +``` + +**Compare exports** +```bash +videobeaux -P hash_fingerprint -i ./out/v1 --recursive --file-hashes sha256 --catalog ./out/v1_hashes.json -F +videobeaux -P hash_fingerprint -i ./out/v2 --recursive --file-hashes sha256 --catalog ./out/v2_hashes.json -F +``` + +--- + +## Performance Notes + +- File hashes: Fastest, limited by I/O. +- Stream hash / framemd5: CPU-intensive (decoding). +- Perceptual hashing: Adjustable via fps and size. +- Always prefer local disk for large scans. + +--- + +## Best Practices + +- **Ingest Audit:** `--file-hashes sha256` on daily ingest. +- **QC Re-exports:** Add `--stream-hash sha256`. +- **Forensic Accuracy:** Use `--framemd5` for exact match. +- **Similarity:** Use `--phash --phash-fps 0.5 --phash-size 8` for clustering. + +--- + +## Troubleshooting + +- Ensure FFmpeg is installed and in PATH. +- Install Pillow for `--phash` (`pip install Pillow`). +- Create parent directories for output paths. + +--- + +## Security & Determinism + +- Hashes are deterministic and consistent across systems. +- md5 is fast for duplicates; sha256 is more secure. +- Stream and frame hashes depend on FFmpeg decoding path. + +--- + +## Future Enhancements + +- `--verify` mode to compare current files vs stored catalog. +- Duplicate-grouping report in JSON/CSV. diff --git a/docs/docs-meta_extraction.txt b/docs/docs-meta_extraction.txt new file mode 100644 index 0000000..4b71626 --- /dev/null +++ b/docs/docs-meta_extraction.txt @@ -0,0 +1,146 @@ +videobeaux — meta_extractionion +================================ + +## Description + +`meta_extraction` is a robust metadata extraction and analysis utility for video and audio files. +It leverages `ffprobe` and optional `ffmpeg` filters to capture **technical metadata**, **black-frame detection**, **audio loudness analysis**, and **sampled visual summaries**. + +This function is useful for Quality Control (QC), editorial preparation, archival metadata generation, and automated video insight pipelines. + +--- + +## Features + +- **Comprehensive ffprobe core metadata** + - Format, streams, codecs, durations, bitrates, resolution, color profile, and tags. +- **Sampled frame analysis** + - Extract visual snapshots every N seconds. + - Optionally compute histogram/mean color data. +- **Black frame detection** + - Detect fade-ins/fade-outs or black commercials gaps. +- **EBU R128 Loudness** + - Integrated Loudness (LUFS), LRA, True Peak. +- **Combined JSON catalog** + - Merges all collected data into a structured metadata report. + +--- + +## Why Use It + +### 1. Quality Control (QC) +Detects black segments, silence, or loudness inconsistencies automatically. + +### 2. Automated Asset Management +Generate sidecar JSON metadata for cataloging and search indexing. + +### 3. Archival Provenance +Document codec info, stream layout, aspect ratio, and other preservation details. + +### 4. Edit Prep +Helps identify fades and loudness ranges to pre-trim or normalize clips before editing. + +--- + +## Inputs & Outputs + +**Inputs** +- `-i / --input` : Input file (video or audio). +- Optional sampling & detection flags. + +**Outputs** +- `--outputfile`: Output JSON metadata file. + If omitted, defaults to `.videobeaux.meta.json` + +### Example JSON Structure +```json +{ + "source": "bbb.mov", + "format": { "duration": 8.41, "size": 45678123, "bitrate": 876000 }, + "streams": [ + { "type": "video", "codec": "h264", "width": 1920, "height": 1080 }, + { "type": "audio", "codec": "aac", "channels": 2, "samplerate": 48000 } + ], + "blackdetect": [ { "start": 0.00, "end": 0.20, "duration": 0.20 } ], + "loudness": { "integrated": -14.2, "lra": 3.5, "truepeak": -1.1 }, + "samples": [ "frame_0001.jpg", "frame_0002.jpg", "..." ] +} +``` + +--- + +## Key Flags + +| Flag | Description | +|------|--------------| +| `--sample-frames` | Enable frame sampling for visual snapshots | +| `--sample-stride` | Seconds between frame samples (default 0.5s) | +| `--sample-limit` | Max number of frames to sample | +| `--blackdetect` | Run black frame detection | +| `--black-pic-th` | Pixel threshold for black detection (default 0.06) | +| `--black-dur-min` | Minimum duration for black detection (default 0.06s) | +| `--loudness` | Run EBU R128 loudness analysis | +| `--outputfile` | Output metadata JSON | +| `-F` | Force overwrite existing output file | + +--- + +## Example Commands + +### 1. Basic Probe (format + streams + chapters) +```bash +videobeaux -P meta_extraction -i ./media/bbb.mov --outputfile ./out/outbbb_meta_basic.json -F +``` + +### 2. Frame Sampling +```bash +videobeaux -P meta_extraction -i ./media/bbb.mov --outputfile ./out/outbbb_meta_sampled.json --sample-frames --sample-stride 0.5 --sample-limit 200 -F +``` + +### 3. Black Detection (commercial fade detection) +```bash +videobeaux -P meta_extraction -i ./media/bbb.mov --outputfile ./out/outbbb_meta_black.json --blackdetect --black-pic-th 0.08 --black-dur-min 0.08 -F +``` + +### 4. Loudness Measurement +```bash +videobeaux -P meta_extraction -i ./media/bbb.mov --outputfile ./out/outbbb_meta_loud.json --loudness -F +``` + +### 5. All-in-One Comprehensive QC Report +```bash +videobeaux -P meta_extraction -i ./media/bbb.mov --outputfile ./out/outbbb_meta_full.json --sample-frames --sample-stride 0.5 --sample-limit 200 --blackdetect --black-pic-th 0.10 --black-dur-min 0.10 --loudness -F +``` + +--- + +## Performance Notes + +- Frame sampling and loudness analyses invoke full decoding → expect increased runtime. +- ffprobe-only mode is extremely fast (metadata only). +- Works with all FFmpeg-supported containers (MOV, MP4, MKV, WEBM, MXF, WAV, etc.). + +--- + +## Best Practices + +- Use **ffprobe-only** mode (`no --sample-frames/--blackdetect/--loudness`) for mass ingestion. +- Use **blackdetect** for commercial-cut or fade timing analysis. +- Use **loudness** when targeting broadcast spec (-23 LUFS EBU or -14 LUFS web). +- Combine **sample-frames + loudness** for advanced QC dashboards. + +--- + +## Troubleshooting + +- Ensure FFmpeg/ffprobe installed and in PATH. +- For noisy blackdetect, raise `--black-pic-th` slightly. +- Loudness requires `ebur128` filter; ensure FFmpeg built with it (`ffmpeg -filters | grep ebur128`). + +--- + +## Future Enhancements + +- Scene detection and color histogram summaries. +- Waveform extraction for audio visualization. +- Optional export to CSV for large-scale audits. diff --git a/docs/docs-thumbs.txt b/docs/docs-thumbs.txt new file mode 100644 index 0000000..1106e00 --- /dev/null +++ b/docs/docs-thumbs.txt @@ -0,0 +1,131 @@ +videobeaux — thumbs (Thumbnail / Contact Sheet) +=============================================== + +## Overview + +`thumbs` automatically extracts representative frames and (optionally) assembles them into a single **contact sheet** image. It’s ideal for catalog previews, editorial reference, QC, and web galleries. + +Two output modes: +1) **Contact Sheet** — a single tiled image with evenly spaced or scene-based thumbnails. +2) **Frame Sequence** — a directory of individual thumbnail images (e.g., for galleries or further processing). + +--- + +## Why This Matters + +- **Preview at a glance**: Summarize a clip’s content without scrubbing. +- **Editorial assistance**: Visual guide for cut points and pacing. +- **QC**: Spot exposure shifts, black frames, or artifacts quickly. +- **Web/social**: Ready-made storyboards or strips for sharing. + +--- + +## Flags & Behavior + +### Sampling +- `--fps `: Sample rate in frames per second. Example: `0.5` → one frame every 2 seconds. +- `--scene`: Enable scene-based selection (uses FFmpeg scene change detection). +- `--scene-threshold `: Sensitivity for `--scene`. Lower finds more cuts. Typical range: `0.3–0.6` (default: 0.4). + +### Layout & Appearance +- `--tile CxR`: Grid columns x rows for contact sheet (e.g., `6x4`). If omitted, the module can auto-fit based on sample count. +- `--scale WxH`: Scale each thumbnail. Use `-1` to preserve aspect for one dimension (e.g., `320:-1`). Use fixed values for square tiles (e.g., `320:320`). +- `--timestamps`: Overlay a timestamp on each tile (`hh:mm:ss`). +- `--label`: Add a footer label with filename and duration. +- `--bg '#RRGGBB'`: Sheet background color (default `#000000`). +- `--margin `: Margin around the full sheet (default `12`). +- `--padding `: Spacing between tiles (default `6`). +- `--fontfile `: Custom font path for drawtext (optional). If omitted, system default is used. + +### Outputs +- `--contactsheet `: Write a single image (PNG/JPG recommended). +- `--outdir `: Write a sequence of thumbnails (`frame_0001.jpg` etc.). +- If you provide **both**, both products are generated. +- If you only use global `-o`, it is treated as the contact sheet path. + +### Defaults +- If neither `--contactsheet` nor `--outdir` is provided, the module will **require** either global `-o` or one of those flags. + +--- + +## Examples + +**1) Contact sheet, 5x4 grid, timestamps** +```bash +videobeaux -P thumbs -i ./media/bbb.mov -o ./out/bbb_contact_5x4.jpg --fps 0.5 --tile 5x4 --scale 320:-1 --timestamps --label -F +``` + +**2) Scene-based sheet (5x5) with timestamps** +```bash +videobeaux -P thumbs -i ./media/bbb.mov -o ./out/bbb_contact_scenes_5x5.jpg --scene --scene-threshold 0.35 --tile 5x5 --scale 320:-1 --timestamps --label -F +``` + +**3) Frame sequence only (no contact sheet)** +```bash +videobeaux -P thumbs -i ./media/bbb.mov --fps 1.0 --scale 480:-1 --outdir ./out/bbb_frames -F +``` + +**4) Contact sheet + frame sequence, custom font** +```bash +videobeaux -P thumbs -i ./media/bbb.mov -o ./out/bbb_contact_font.jpg --fps 0.5 --tile 6x4 --scale 360:-1 --timestamps --fontfile ./fonts/Inter-SemiBold.ttf --outdir ./out/bbb_frames2 -F +``` + +**5) Minimal (uses defaults)** +```bash +videobeaux -P thumbs -i ./media/bbb.mov -o ./out/bbb_contact_min.jpg -F +``` + +--- + +## Under the Hood + +- **Frame sampling** uses `fps` or `select='gt(scene,THRESH)'` in FFmpeg filtergraphs. +- **Timestamp overlay** uses `drawtext=text='%{pts\:hms}'` positioned near the bottom of each tile. +- **Tiling** uses FFmpeg’s `tile=CxR` filter after scaling. Padding/margins can be done with `pad` and `tmpl` layer offsets. +- **Scene detection** leverages FFmpeg’s scene filter and selects frames with deltas above the threshold. + +**Conceptual FFmpeg filter chain (contact sheet):** +``` +-vf "fps=0.5,scale=320:-1,tile=5x4" +``` +or for scenes: +``` +-vf "select='gt(scene,0.4)',scale=320:-1,tile=5x4" +``` + +**Timestamp (drawtext) example:** +``` +drawtext=text='%{pts\:hms}':x=10:y=h-th-10:fontsize=20:fontcolor=white +``` + +--- + +## Performance Notes + +- Lower `--fps` or higher `--scene-threshold` → fewer frames → faster. +- Large grids and high `--scale` values increase memory and processing time. +- Prefer JPG for large sheets (smaller files), PNG for lossless or text-heavy overlays. + +--- + +## Best Practices + +- For long clips, start with `--fps 0.33` or `--scene --scene-threshold 0.4` to avoid massive sheets. +- Keep `--scale` widths around `320–480` for practical sheet sizes. +- Use `--label` when sending to clients—adds filename and duration context. + +--- + +## Troubleshooting + +- **Text rendering errors**: Provide a `--fontfile` to guarantee glyph availability. +- **Sheet too huge**: Reduce `--scale`, tighten `--fps`, or reduce `--tile` dimensions. +- **Colors look off**: Ensure correct quoting for hex colors (`'#101010'`). + +--- + +## Future Enhancements + +- Optional scene clustering and caption under tiles (shot length). +- Border styles (rounded corners, drop shadow). +- Multi-row storyboards per chapter or per detected scene. diff --git a/docs/docs-tonemap_hdr_sdr.txt b/docs/docs-tonemap_hdr_sdr.txt new file mode 100644 index 0000000..d3c5708 --- /dev/null +++ b/docs/docs-tonemap_hdr_sdr.txt @@ -0,0 +1,93 @@ +HDR → SDR Tone Map (videobeaux program) +====================================== + +Name +---- +tonemap_hdr_sdr — Convert HDR (PQ/HLG) video to SDR (BT.709) using +ffmpeg `zscale` + `tonemap` with Hable (default), Mobius, Reinhard, or Clip. + +What it does +------------ +• Linearizes HDR content with zscale using a specified nominal peak (nits). +• Applies an SDR-target tonemap operator (default: Hable) with optional highlight desaturation. +• Converts color primaries/transfer/matrix to BT.709 and tags the stream accordingly. +• Outputs in a user-specified pixel format (default yuv420p). +• Re-encodes video (libx264 by default); audio can be copied with --copy-audio. + +When to use +----------- +Use this when you have HDR10/HLG masters that need a faithful SDR deliverable +for web or broadcast players that don’t support HDR. + +Invocation pattern +------------------ +videobeaux -P tonemap_hdr_sdr -i --outfile [options] + +Arguments +--------- +--outfile + Output file path for the SDR result. Use this instead of the global -o. + +--algo {hable,mobius,reinhard,clip} + The tonemap curve/operator. Defaults to "hable". + • hable: Filmic curve with pleasing roll-off; great default. + • mobius: Preserves mid-tones; gentle shoulder. + • reinhard: Classic operator; can feel flatter. + • clip: Hard clip (avoid unless you need an absolute ceiling). + +--desat + Highlight desaturation applied during tonemapping. Default: 0.0. + Try 0.15–0.35 for very hot HDR sources to avoid neon colors in speculars. + +--peak + Nominal peak luminance (nits) passed to zscale:npl for linearization. + Default: 1000. Use 4000 for HDR masters graded to 4000 nits. + +--dither {none,ordered,random,error_diffusion} + Dithering strategy in zscale before format(). Default: error_diffusion. + +--pix-fmt + Output pixel format. Common: yuv420p (default), yuv422p10le, yuv420p10le. + +--x264-preset + libx264 preset if encoding H.264. Default: medium. Examples: slow, fast. + +--crf + CRF value for libx264 quality. Default: 18 (visually lossless-ish). + +--copy-audio + If set, copies audio bit-for-bit. Otherwise audio is encoded as AAC. + +Standard/global flags respected (from videobeaux) +------------------------------------------------ +--force Overwrite output if it exists (injects -y into ffmpeg). + +Color tagging +------------- +The program writes BT.709 tags on the output: + -colorspace bt709 -color_trc bt709 -color_primaries bt709 + +Practical guidance +------------------ +• Start with: --algo hable --peak 1000 --desat 0.2 +• If midtones feel too flat, try --algo mobius. +• If highlights look too neon, increase --desat to ~0.25–0.35. +• For mezzanine masters, use 10-bit: --pix-fmt yuv422p10le and lower CRF. + +Performance notes +----------------- +• zscale + tonemap is GPU-agnostic and runs on CPU; performance depends on your CPU. +• For speed, you can try a faster x264 preset (e.g., --x264-preset fast) or target ProRes. + +Troubleshooting +--------------- +• “Washed out” SDR: confirm your player isn’t forcing HDR or BT.2020. The output is + explicitly tagged BT.709. +• Crushed highlights: your source peak was higher than expected; try --peak 4000 or switch + operator to mobius. +• Banding: use 10-bit pix-fmt (e.g., yuv420p10le) and keep --dither error_diffusion. + +Changelog +--------- +v1.0.1 — Switched to program-scoped --outfile (no use of global -o). +v1.0.0 — Initial release (Hable default, Mobius/Reinhard/Clip options, desat, peak, dither, pix-fmt, CRF, copy-audio). diff --git a/experimental/captburn_top20_commands.txt b/experimental/captburn_top20_commands.txt index add4632..9ff7c89 100644 --- a/experimental/captburn_top20_commands.txt +++ b/experimental/captburn_top20_commands.txt @@ -59,3 +59,30 @@ python captburn.py -i testfile.mp4 -t testfile.json --style popon --align 2 --fo 20) Top-left locator/date (tiny & restrained) python captburn.py -i testfile.mp4 -t testfile.json --style popon --align 7 --font "IBM Plex Sans" --font-size 28 --outline "#000000" --outline-width 2 --margin-l 80 --margin-v 80 + + +videobeaux -P captburn --input testfile.mp4 --output out/default.mp4 +videobeaux -P captburn --input testfile.mp4 --output out/trans_explicit.mp4 -t media/testfile.json +videobeaux -P captburn --input testfile.mp4 --output out/reburn_from_capton.mp4 --caption media/testfile.captburn.json +videobeaux -P captburn --input testfile.mp4 --output out/doc_center.mp4 --style popon --font "Helvetica Neue" --font-size 38 --align 2 --primary "#FFFFFF" --outline "#000000" --outline-width 2 --shadow 2 +videobeaux -P captburn --input testfile.mp4 --output out/vintage_top_right.mp4 --style popon --font "Baskerville" --italic --font-size 48 --align 9 --margin-r 120 --margin-v 80 +videobeaux -P captburn --input testfile.mp4 --output out/mono_terminal.mp4 --font "Courier New" --font-size 34 --primary "#00FF00" --outline "#002200" +videobeaux -P captburn --input testfile.mp4 --output out/opaque_box.mp4 --border-style 3 --back "#000000" --back-opacity 0.6 +videobeaux -P captburn --input testfile.mp4 --output out/youtube_shadow.mp4 --font "Arial Black" --font-size 48 --shadow 4 --outline "#101010" --outline-width 3 +videobeaux -P captburn --input testfile.mp4 --output out/bottom_center_film.mp4 --align 2 --margin-v 60 --font-size 36 +videobeaux -P captburn --input testfile.mp4 --output out/bottom_left.mp4 --align 1 --margin-l 100 --margin-v 60 --font-size 36 +videobeaux -P captburn --input testfile.mp4 --output out/bottom_right.mp4 --align 3 --margin-r 100 --margin-v 60 --font-size 36 +videobeaux -P captburn --input testfile.mp4 --output out/top_left.mp4 --align 7 --margin-l 60 --margin-v 80 --font-size 34 +videobeaux -P captburn --input testfile.mp4 --output out/top_center.mp4 --align 8 --margin-v 100 --font-size 34 +videobeaux -P captburn --input testfile.mp4 --output out/top_right.mp4 --align 9 --margin-r 80 --margin-v 100 --font-size 34 +videobeaux -P captburn --input testfile.mp4 --output out/rotate_slight.mp4 --align 2 --font "Source Sans 3" --font-size 40 --rotate -2.0 --outline "#000000" --outline-width 2 --margin-v 80 +videobeaux -P captburn --input testfile.mp4 --output out/move_slide_in.mp4 --align 2 --move 100,620,100,540,0,1000 --font-size 36 +videobeaux -P captburn --input testfile.mp4 --output out/spin_quote.mp4 --align 8 --rotate 10 --font "Baskerville" --italic --font-size 42 +videobeaux -P captburn --input testfile.mp4 --output out/painton.mp4 --style painton --font "Futura" --font-size 40 +videobeaux -P captburn --input testfile.mp4 --output out/rollup2.mp4 --style rollup --rollup-lines 2 --words-per-line 7 --font-size 34 +videobeaux -P captburn --input testfile.mp4 --output out/rollup4_small.mp4 --style rollup --rollup-lines 4 --font-size 30 +videobeaux -P captburn --input testfile.mp4 --output out/scale_compress.mp4 --scale-x 85 --scale-y 100 +videobeaux -P captburn --input testfile.mp4 --output out/spacing_wide.mp4 --spacing 1.8 +videobeaux -P captburn --input testfile.mp4 --output out/small_wide_screen.mp4 --font-size 28 --margin-v 120 +videobeaux -P captburn --input testfile.mp4 --output out/fast_preview.mp4 --crf 28 --preset ultrafast +videobeaux -P captburn --input testfile.mp4 --output out/archival_quality.mp4 --crf 16 --preset slow --vcodec libx264 \ No newline at end of file diff --git a/media/bbb.mov.videobeaux.meta.json b/media/bbb.mov.videobeaux.meta.json new file mode 100644 index 0000000..68492f2 --- /dev/null +++ b/media/bbb.mov.videobeaux.meta.json @@ -0,0 +1,87 @@ +{ + "input_path": "/Users/tgm/Documents/SPLASH/videobeaux/media/bbb.mov", + "generated_at_utc": "2025-11-09T18:39:51Z", + "provenance": { + "version": "videobeaux meta_extract v1", + "ffprobe_cmd": "ffprobe -v error -print_format json -show_format -show_streams -show_chapters /Users/tgm/Documents/SPLASH/videobeaux/media/bbb.mov" + }, + "format": { + "filename": "/Users/tgm/Documents/SPLASH/videobeaux/media/bbb.mov", + "format_name": "mov,mp4,m4a,3gp,3g2,mj2", + "duration_sec": 8.4055, + "size_bytes": 3668431, + "bitrate_mbps": 3.491457, + "tags": { + "major_brand": "qt ", + "minor_version": "0", + "compatible_brands": "qt ", + "creation_time": "2025-09-09T01:31:13.000000Z", + "comment": "Creative Commons Attribution 3.0 - http://bbb3d.renderfarming.net", + "artist": "Blender Foundation 2008, Janus Bager Kristensen 2013", + "title": "Big Buck Bunny, Sunflower version", + "genre": "Animation", + "composer": "Sacha Goedegebure" + } + }, + "streams": { + "video": [ + { + "index": 1, + "codec_name": "h264", + "profile": "High", + "width": 1920, + "height": 1080, + "pix_fmt": "yuv420p", + "sar": "1:1", + "dar": "16:9", + "avg_fps": "3380000/56357", + "avg_fps_float": 59.974803484926454, + "time_base": "1/60000", + "color": { + "space": null, + "primaries": null, + "transfer": null + }, + "rotation": null, + "nb_frames": 507.0 + } + ], + "audio": [ + { + "index": 0, + "codec_name": "aac", + "sample_rate": 48000.0, + "channels": 6, + "channel_layout": "5.1", + "bit_rate": 262717.0 + } + ], + "subtitle": [] + }, + "chapters": [], + "derived": { + "has_video": true, + "has_audio": true, + "has_subtitles": false, + "video_codecs": [ + "h264" + ], + "audio_codecs": [ + "aac" + ], + "container": "mov", + "display_aspect_ratio": "16:9", + "duration_hms": "00:00:08.41" + }, + "sampling": { + "enabled": false + }, + "analysis": { + "loudness": { + "enabled": true, + "integrated_lufs": null, + "lra": null, + "true_peak_dbfs": null + } + } +} \ No newline at end of file diff --git a/media/logo.png b/media/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..de0f37bc7a6adf36d6ee464d787fb5f739b41909 GIT binary patch literal 5690 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 ZW)(}Fu8jE1d#SwvJ?&=?D>Pqy{2$#4m@5DP literal 0 HcmV?d00001 diff --git a/media/nan.gif b/media/media.gif similarity index 100% rename from media/nan.gif rename to media/media.gif diff --git a/out/bbb_contact_scenes_5x5.jpg b/out/bbb_contact_scenes_5x5.jpg new file mode 100644 index 0000000000000000000000000000000000000000..81af8c536e42da6c81eef34e94dd3170c6fd23f3 GIT binary patch literal 51166 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 list[float]: + """ + Run a fast ffmpeg prepass with signalstats to gather YAVG samples. + Returns a list of YAVG values in 0..255. + """ + # We downscale + fps limit during probe for speed, without altering stats trends much. + # (stats before scale is ideal, but signalstats after a mild, fast scale is fine for global mean.) + cmd = [ + "ffmpeg", + "-hide_banner", "-nostdin", + "-i", input_path, + # Keep it quick: sample at ~4 fps, tiny scale, metadata only. + "-vf", "signalstats,framestep=2,scale=iw*0.25:ih*0.25", + "-f", "null", "-" + ] + proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + text = proc.stderr + proc.stdout + + yvals = [float(m.group(1)) for m in _YAVG_RE.finditer(text)] + if not yvals: + # Fallback: try again without framestep/scale if a weird codec/stream blocks it + cmd = ["ffmpeg", "-hide_banner", "-nostdin", "-i", input_path, "-vf", "signalstats", "-f", "null", "-"] + proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + text = proc.stderr + proc.stdout + yvals = [float(m.group(1)) for m in _YAVG_RE.finditer(text)] + + if len(yvals) > max_samples: + # Uniformly downsample the list to max_samples for median stability + step = max(1, len(yvals) // max_samples) + yvals = yvals[::step] + + return yvals + +def _compute_eq_params(yavg_values: list[float], target_yavg: float, min_contrast: float, max_contrast: float): + """ + Compute eq filter brightness/contrast that maps the measured median YAVG close to target. + eq works in normalized [0..1] domain as: y' = (y - 0.5)*contrast + 0.5 + brightness + + We choose a contrast around (target/current), clamped; then derive brightness to hit target. + Returns (contrast, brightness). Brightness is in [-1, 1]; contrast is typically [0.5, 1.5]. + """ + if not yavg_values: + # If probing failed, do nothing (neutral) + return 1.0, 0.0 + + current = statistics.median(yavg_values) # robust against bright/dark spikes + # Convert 8-bit YAVG to normalized [0,1] + y_cur = max(0.0, min(1.0, current / 255.0)) + y_tgt = max(0.0, min(1.0, target_yavg / 255.0)) + + # Initial contrast guess: keep gentle moves, clamp to avoid harsh clipping + raw_gain = (y_tgt / y_cur) if y_cur > 1e-6 else 1.0 + contrast = max(min_contrast, min(max_contrast, raw_gain)) + + # Solve for brightness that maps the current median to target + # t = (y_cur - 0.5)*c + 0.5 + b => b = t - ((y_cur - 0.5)*c + 0.5) + brightness = y_tgt - ((y_cur - 0.5) * contrast + 0.5) + + # Clamp brightness to eq valid range [-1, 1] + brightness = max(-1.0, min(1.0, brightness)) + + return contrast, brightness + +def _build_filter_chain(contrast: float, brightness: float, gamma: float | None, legalize: bool, sat_boost: float) -> str: + """ + Build the ffmpeg -vf filter chain. + - eq for exposure normalization + - (optional) gamma tweak + - (optional) saturation boost + - (optional) legalize to broadcast-safe luma/chroma (TV range) + """ + chain = [] + + # exposure/contrast/brightness normalize + eq_parts = [f"contrast={contrast:.3f}", f"brightness={brightness:.3f}"] + if gamma is not None and abs(gamma - 1.0) > 1e-6: + eq_parts.append(f"gamma={gamma:.3f}") + chain.append("eq=" + ":".join(eq_parts)) + + # saturation tweak (via hsv / hue sat) + if abs(sat_boost - 1.0) > 1e-6: + # hue=s=multiplier; 1.10 = +10% + chain.append(f"hue=s={sat_boost:.3f}") + + # broadcast legalize: convert from full->TV range if needed + if legalize: + # Use zscale to remap to TV (limited) range safely + # rangein=auto tries to detect; range=tv enforces legal range outputs + chain.append("zscale=range=tv") + # yuv420p for web/broadcast delivery compat + chain.append("format=yuv420p") + + return ",".join(chain) + +# ----------------------------- +# Public API expected by cli.py +# ----------------------------- + +def register_arguments(parser): + parser.description = ( + "Gamma / Exposure Fix — auto-detect overall luminance and normalize for web/broadcast.\n" + "Prepass samples luma (YAVG) with signalstats, computes friendly contrast/brightness (and optional gamma),\n" + "and applies safe clamping if requested." + ) + # Core behavior flags (global --input/--output/--force are provided by cli.py) + parser.add_argument( + "--target-yavg", + type=float, + default=64.0, + help="Target average luma (0..255). ~64 is a balanced web midpoint. Try 60–70 for darker footage, 70–90 for bright." + ) + parser.add_argument( + "--min-contrast", + type=float, + default=0.80, + help="Lower clamp for auto contrast mapping. Default 0.80." + ) + parser.add_argument( + "--max-contrast", + type=float, + default=1.35, + help="Upper clamp for auto contrast mapping. Default 1.35." + ) + parser.add_argument( + "--gamma", + type=float, + default=1.00, + help="Optional gamma override (1.00 = neutral). Leave at 1.00 to rely on contrast/brightness mapping." + ) + parser.add_argument( + "--sat", + type=float, + default=1.00, + help="Optional saturation multiplier via hue filter (1.00 = unchanged). e.g., 1.10 = +10%% saturation." + ) + parser.add_argument( + "--legalize", + action="store_true", + help="Clamp output to broadcast-legal (TV) range using zscale and output yuv420p." + ) + parser.add_argument( + "--vcodec", + type=str, + default="libx264", + help="Video codec for output. Default libx264." + ) + parser.add_argument( + "--crf", + type=str, + default="18", + help="CRF for output quality (x264/x265). Default 18." + ) + parser.add_argument( + "--preset", + type=str, + default="medium", + help="Encoder preset (x264/x265). Default medium." + ) + parser.add_argument( + "--acodec", + type=str, + default="aac", + help="Audio codec. Default aac." + ) + parser.add_argument( + "--ab", + type=str, + default="160k", + help="Audio bitrate. Default 160k." + ) + +def run(args): + # 1) Probe luminance stats + yvals = _probe_yavg_values(args.input) + contrast, brightness = _compute_eq_params( + yavg_values=yvals, + target_yavg=args.target_yavg, + min_contrast=args.min_contrast, + max_contrast=args.max_contrast + ) + # If user forcibly set gamma != 1.0, honor it; otherwise pass None to omit param + gamma = args.gamma if args.gamma and abs(args.gamma - 1.0) > 1e-6 else None + + # 2) Build filter graph + vf = _build_filter_chain( + contrast=contrast, + brightness=brightness, + gamma=gamma, + legalize=args.legalize, + sat_boost=args.sat + ) + + # 3) Encode + command = [ + "ffmpeg", + "-i", args.input, + "-vf", vf, + "-c:v", args.vcodec, + "-crf", args.crf, + "-preset", args.preset, + "-c:a", args.acodec, + "-b:a", args.ab, + "-ac", "2", + args.output + ] + + # Use your standard progress runner that respects --force just like other programs + run_ffmpeg_with_progress( + (command[:1] + ["-y"] + command[1:]) if args.force else command, + args.input, + args.output + ) diff --git a/videobeaux/programs/hash_fingerprint.py b/videobeaux/programs/hash_fingerprint.py new file mode 100644 index 0000000..eb9cf35 --- /dev/null +++ b/videobeaux/programs/hash_fingerprint.py @@ -0,0 +1,251 @@ +# videobeaux/programs/hash_fingerprint.py +from __future__ import annotations +import argparse, csv, hashlib, json, os, shlex, subprocess, sys +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +# Optional deps for perceptual hashing (gracefully degrade if missing) +try: + from PIL import Image + PIL_OK = True +except Exception: + PIL_OK = False + +VERSION = "videobeaux hash_fingerprint v1" + +# Default file extensions we’ll scan in batch mode (can be overridden) +DEFAULT_EXTS = [ + ".mp4", ".mov", ".m4v", ".mkv", ".webm", ".avi", ".wmv", ".mxf", + ".mp3", ".m4a", ".aac", ".wav", ".flac", ".ogg", ".opus", + ".jpg", ".jpeg", ".png", ".bmp", ".tif", ".tiff", ".gif" +] + +def register_arguments(parser: argparse.ArgumentParser): + parser.description = ( + "Compute file hashes (md5/sha1/sha256), optional FFmpeg stream hash, per-frame checksums, " + "and optional perceptual hashes over sampled frames. Works on a single input or a directory." + ) + + # Discovery / batch + parser.add_argument("--recursive", action="store_true", + help="If input is a directory, scan recursively.") + parser.add_argument("--exts", nargs="+", default=DEFAULT_EXTS, + help="File extensions to include when scanning a directory (case-insensitive).") + + # Algorithms + parser.add_argument("--file-hashes", nargs="+", + choices=["md5", "sha1", "sha256"], default=["md5", "sha256"], + help="File-level hashes to compute (streamed, no load into RAM).") + parser.add_argument("--stream-hash", choices=["none", "md5", "sha256"], default="none", + help="Use FFmpeg -f hash to hash the primary video stream (fast, codec-level).") + parser.add_argument("--framemd5", action="store_true", + help="Emit per-frame checksums via FFmpeg -f framemd5 (verbose).") + + # Perceptual hashing (over sampled frames) + parser.add_argument("--phash", action="store_true", + help="Compute perceptual hashes (average hash) over sampled frames. Requires Pillow.") + parser.add_argument("--phash-fps", type=float, default=0.5, + help="Approx frames-per-second to sample for perceptual hashing (0.5 = one frame every 2s).") + parser.add_argument("--phash-size", type=int, default=8, + help="aHash size NxN (default 8 -> 64-bit hash).") + + # Output catalog + parser.add_argument("--catalog", required=False, + help="Output catalog path (.json or .csv). If not provided, writes .hashes.json") + + # Stream selection (advanced) + parser.add_argument("--stream-kind", choices=["video", "audio"], default="video", + help="Which primary stream to hash for --stream-hash/--framemd5.") + + # Force overwrite behavior is handled at top-level; we just respect existing files for CSV/JSON if desired. + +# ----------------- helpers ----------------- + +def _iter_files(entry: Path, exts: List[str], recursive: bool) -> List[Path]: + if entry.is_file(): + return [entry] + exts_lower = {e.lower() for e in exts} + files: List[Path] = [] + walker = entry.rglob("*") if recursive else entry.glob("*") + for p in walker: + if p.is_file() and p.suffix.lower() in exts_lower: + files.append(p) + return sorted(files) + +def _hash_file(path: Path, method: str) -> str: + h = hashlib.new(method) + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + +def _ffmpeg_stream_hash(path: Path, algo: str, kind: str) -> Optional[str]: + # Map selection + stream_map = "0:v:0" if kind == "video" else "0:a:0" + cmd = [ + "ffmpeg", "-v", "error", "-i", str(path), + "-map", stream_map, + "-f", "hash", "-hash", algo, "-" + ] + proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + if proc.returncode != 0: + return None + # Output line like "MD5=xxxxxxxx" or "SHA256=xxxxxxxx" + for line in (proc.stdout or "").splitlines(): + line = line.strip() + if "=" in line: + return line.split("=", 1)[1].strip() + return None + +def _ffmpeg_framemd5(path: Path, kind: str) -> List[str]: + stream_map = "0:v:0" if kind == "video" else "0:a:0" + cmd = [ + "ffmpeg", "-v", "error", "-i", str(path), + "-map", stream_map, + "-f", "framemd5", "-" + ] + proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + if proc.returncode != 0: + return [] + # Return raw lines (CSV/JSON writer can embed or omit) + return [ln.rstrip("\n") for ln in (proc.stdout or "").splitlines()] + +def _extract_sample_frames(path: Path, fps: float) -> List[Path]: + """ + Extract sampled frames to a temp folder next to file. Caller cleans up or keeps ephemeral. + For catalog reproducibility we won't delete by default (caller may choose). + """ + out_dir = path.parent / (path.stem + ".hashframes") + out_dir.mkdir(parents=True, exist_ok=True) + # Use -q:v 4 for decent JPEG; names frame_000001.jpg etc. + cmd = [ + "ffmpeg", "-v", "error", "-i", str(path), + "-vf", f"fps={fps}", + "-qscale:v", "4", + str(out_dir / "frame_%06d.jpg") + ] + subprocess.run(cmd, check=False) + return sorted(out_dir.glob("frame_*.jpg")) + +def _ahash_image(p: Path, size: int) -> Optional[str]: + if not PIL_OK: + return None + try: + img = Image.open(p).convert("L").resize((size, size)) + px = list(img.getdata()) + avg = sum(px) / float(len(px)) + bits = "".join("1" if val >= avg else "0" for val in px) + # pack into hex (4 bits per hex char) + width = size * size + hex_len = (width + 3) // 4 + return f"{int(bits, 2):0{hex_len}x}" + except Exception: + return None + +def _catalog_default_path(first_input: Path) -> Path: + return first_input.with_suffix(first_input.suffix + ".hashes.json") + +def _write_json(path: Path, rows: List[Dict]): + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8") as f: + json.dump(rows, f, indent=2, ensure_ascii=False) + +def _write_csv(path: Path, rows: List[Dict]): + path.parent.mkdir(parents=True, exist_ok=True) + # stable column order + field_order = [ + "path", "size_bytes", + "file_md5", "file_sha1", "file_sha256", + "stream_md5", "stream_sha256", + "phash_algo", "phash_size", "phash_frames", + ] + # Include framemd5? We'll omit from CSV (too verbose). JSON includes it if requested. + with path.open("w", newline="", encoding="utf-8") as f: + w = csv.DictWriter(f, fieldnames=field_order, extrasaction="ignore") + w.writeheader() + for r in rows: + w.writerow(r) + +# ----------------- main ----------------- + +def run(args: argparse.Namespace): + input_path = Path(args.input) # global CLI provides this + entries: List[Path] = [] + + if not input_path.exists(): + print(f"❌ Input not found: {input_path}") + sys.exit(1) + + if input_path.is_dir(): + entries = _iter_files(input_path, args.exts, args.recursive) + if not entries: + print(f"⚠️ No files found in {input_path} (recursive={args.recursive}, exts={args.exts})") + sys.exit(0) + else: + entries = [input_path] + + # Determine catalog path + if args.catalog: + catalog_path = Path(args.catalog) + else: + catalog_path = _catalog_default_path(entries[0]) + + results: List[Dict] = [] + + for p in entries: + rec: Dict[str, Optional[str] | int | List[str] | Dict] = {} + rec["path"] = str(p.resolve()) + try: + rec["size_bytes"] = p.stat().st_size + except Exception: + rec["size_bytes"] = None + + # File-level hashes + for h in args.file_hashes: + try: + rec[f"file_{h}"] = _hash_file(p, h) + except Exception as e: + rec[f"file_{h}"] = None + + # Stream hash via FFmpeg + if args.stream_hash != "none": + try: + sh = _ffmpeg_stream_hash(p, args.stream_hash, args.stream_kind) + rec[f"stream_{args.stream_hash}"] = sh + except Exception: + rec[f"stream_{args.stream_hash}"] = None + + # framemd5 (verbose) + if args.framemd5: + try: + rec["framemd5"] = _ffmpeg_framemd5(p, args.stream_kind) + except Exception: + rec["framemd5"] = [] + + # Perceptual hashing over sampled frames + if args.phash: + if not PIL_OK: + rec["phash_error"] = "Pillow not installed; install Pillow to enable perceptual hashing." + else: + frames = _extract_sample_frames(p, fps=max(0.01, float(args.phash_fps))) + hashes = [] + for fp in frames: + h = _ahash_image(fp, size=max(4, int(args.phash_size))) + if h: + hashes.append(h) + rec["phash_algo"] = "aHash" + rec["phash_size"] = int(args.phash_size) + rec["phash_frames"] = len(hashes) + rec["phash_list"] = hashes # keep full list in JSON; CSV will ignore + + results.append(rec) + + # Write catalog + suffix = catalog_path.suffix.lower() + if suffix == ".csv": + _write_csv(catalog_path, results) + else: + # default JSON + _write_json(catalog_path, results) + + print(f"📒 Wrote catalog → {catalog_path} ({len(results)} item(s))") diff --git a/videobeaux/programs/lut_apply.py b/videobeaux/programs/lut_apply.py new file mode 100644 index 0000000..5231e21 --- /dev/null +++ b/videobeaux/programs/lut_apply.py @@ -0,0 +1,122 @@ +# videobeaux/programs/lut_apply.py +# Color Correction / LUT Apply — requires --outfile (no -o/--output fallback) + +from videobeaux.utils.ffmpeg_operations import run_ffmpeg_with_progress + +def _is_hi_bit_pf(pix_fmt: str) -> bool: + if not pix_fmt: + return False + pf = pix_fmt.lower() + return "p10" in pf or "p12" in pf + +def register_arguments(parser): + parser.description = ( + "Color Correction / LUT Apply\n" + "• Apply a 3D LUT (.cube/.3dl) with adjustable intensity.\n" + "• Basic color tweaks: brightness, contrast, saturation, gamma.\n" + "• Uses only --outfile for output (no -o/--output)." + ) + + # Program-specific output ONLY (no short alias; avoids global -o) + parser.add_argument( + "--outfile", + required=True, + help="Output video file (required)" + ) + + # Optional explicit vcodec; if omitted we auto-pick based on pix_fmt + parser.add_argument( + "--vcodec", + choices=["libx264", "libx265", "prores_ks", "dnxhd"], + help="Force a specific video codec (else auto-select)." + ) + + # LUT controls + parser.add_argument("--lut", help="Path to a 3D LUT file (.cube, .3dl).") + parser.add_argument("--interp", choices=["tetrahedral", "trilinear", "nearest"], + default="tetrahedral", help="LUT interpolation. Default: tetrahedral") + parser.add_argument("--intensity", type=float, default=1.0, + help="Mix of LUT with original [0.0–1.0]. Default: 1.0") + + # EQ (basic color) + parser.add_argument("--brightness", type=float, default=0.0, help="Brightness offset [-1..1].") + parser.add_argument("--contrast", type=float, default=1.0, help="Contrast multiplier [0..2].") + parser.add_argument("--saturation", type=float, default=1.0, help="Saturation multiplier [0..3].") + parser.add_argument("--gamma", type=float, default=1.0, help="Gamma multiplier [0.1..10].") + + # Output / encode + parser.add_argument("--pix-fmt", default="yuv420p", + help="Output pixel format (e.g., yuv420p, yuv422p10le).") + parser.add_argument("--x264-preset", default="medium", help="Encoder preset (x264/x265). Default: medium") + parser.add_argument("--crf", type=float, default=18.0, + help="CRF (x264/x265). Lower = higher quality.") + parser.add_argument("--copy-audio", action="store_true", + help="Copy audio instead of re-encoding.") + + # NOTE: Do NOT declare --force here — it’s global. We’ll still read args.force if present. + +def run(args): + infile = getattr(args, "input", None) # provided by global CLI + outfile = args.outfile # required here + if not infile: + raise SystemExit("❌ Missing input. Provide -i/--input globally.") + + # EQ chain + eq = ( + f"eq=brightness={args.brightness}:" + f"contrast={args.contrast}:" + f"saturation={args.saturation}:" + f"gamma={args.gamma}" + ) + + fg_parts = [] + + # LUT branch w/ intensity blend + if args.lut: + intensity = max(0.0, min(1.0, float(args.intensity))) + if intensity >= 0.9999: + fg_parts.append(f"[0:v]lut3d=file='{args.lut}':interp={args.interp}[v_lut]") + src = "[v_lut]" + elif intensity <= 0.0001: + src = "[0:v]" + else: + fg_parts.append( + f"[0:v]split[v_o][v_b];" + f"[v_b]lut3d=file='{args.lut}':interp={args.interp}[v_lut];" + f"[v_o][v_lut]blend=all_mode=normal:all_opacity={intensity}[v_mix]" + ) + src = "[v_mix]" + else: + src = "[0:v]" + + fg_parts.append(f"{src},{eq}[v_eq]") + fg_parts.append(f"[v_eq]format={args.pix_fmt}[out_v]") + filtergraph = ";".join(fg_parts) + + # Decide codec (auto if not forced) + pix_fmt = args.pix_fmt + vcodec = args.vcodec or ("libx265" if _is_hi_bit_pf(pix_fmt) else "libx264") + + # Optional audio map so silent inputs don’t fail + audio_map = ["-map", "0:a?"] + audio_codec = ["-c:a", "copy" if getattr(args, "copy_audio", False) else "aac"] + + cmd = [ + "ffmpeg", + "-err_detect", "ignore_err", + "-fflags", "+genpts+discardcorrupt", + "-i", infile, + "-filter_complex", filtergraph, + "-map", "[out_v]", + *audio_map, + "-c:v", vcodec, + "-crf", f"{args.crf}", + "-preset", f"{args.x264_preset}", # accepted by x264/x265 + *audio_codec, + "-pix_fmt", f"{pix_fmt}", + outfile + ] + + # Respect global --force if present (we didn’t declare it locally) + final_cmd = (cmd[:1] + ["-y"] + cmd[1:]) if getattr(args, "force", False) else cmd + run_ffmpeg_with_progress(final_cmd, infile, outfile) diff --git a/videobeaux/programs/subs_convert.py b/videobeaux/programs/subs_convert.py new file mode 100644 index 0000000..3225a2b --- /dev/null +++ b/videobeaux/programs/subs_convert.py @@ -0,0 +1,324 @@ +#!/usr/bin/env python3 +# videobeaux/programs/subs_convert.py +# +# Subtitles Extract / Convert for videobeaux. +# +# Modes: +# A) VIDEO INPUT (-i video.{mp4,mov,mkv,...}) +# - --list : print subtitle streams and exit +# - extract/convert tracks to files: +# --indexes 0,2 : extract by stream index +# --langs eng,spa : extract by language code (ffprobe 'tags:language') +# --all : extract all subtitle streams +# --forced-only : only streams with disposition.forced == 1 +# --exclude-hi : exclude hearing_impaired disposition +# --format srt|vtt|ass: convert to target format (default: inferred) +# --outdir DIR : write multiple outputs +# --outputfile PATH : write exactly one output (only valid when extracting a single stream) +# --time-shift +/-S : shift subs by seconds (float; may be negative) +# +# B) SUBTITLE INPUT (-i subs.{srt,ass,vtt}) +# - Convert single file to target format: +# --format srt|vtt|ass (required) +# --outputfile PATH (required) +# --time-shift +/-S (optional) +# +# Notes: +# - We prefer --outputfile for single-output artifacts and --outdir for multi-output batches. +# - ffprobe + ffmpeg must be on PATH. + +from __future__ import annotations +import argparse, json, subprocess, sys +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +from videobeaux.utils.ffmpeg_operations import run_ffmpeg_with_progress + + +# ------------------------------ +# Helpers +# ------------------------------ +def _run_ffprobe_streams(input_path: Path) -> List[Dict[str, Any]]: + """Return list of subtitle streams from ffprobe (may be empty).""" + cmd = [ + "ffprobe", "-v", "error", + "-print_format", "json", + "-show_entries", "stream=index,codec_name,codec_type,disposition:stream_tags=language,title", + "-select_streams", "s", + str(input_path) + ] + try: + out = subprocess.check_output(cmd) + data = json.loads(out.decode("utf-8", errors="replace")) + return data.get("streams", []) or [] + except subprocess.CalledProcessError as e: + raise SystemExit(f"❌ ffprobe failed: {e}") + +def _is_subtitle_file(path: Path) -> bool: + return path.suffix.lower() in {".srt", ".ass", ".ssa", ".vtt", ".sub"} + +def _infer_target_ext(codec_name: Optional[str]) -> str: + # Sensible defaults when --format not provided for video-extract mode + if not codec_name: + return ".srt" + c = codec_name.lower() + if c in {"subrip"}: # ffprobe calls SRT 'subrip' + return ".srt" + if c in {"webvtt", "vtt"}: + return ".vtt" + if c in {"ass", "ssa"}: + return ".ass" + if c in {"mov_text"}: + return ".srt" # transcode mov_text to SRT by default + return ".srt" + +def _format_to_ext(fmt: str) -> str: + fmt = fmt.lower() + if fmt not in {"srt","vtt","ass"}: + raise SystemExit("❌ --format must be one of: srt, vtt, ass") + return f".{fmt}" + +def _parse_index_list(val: str) -> List[int]: + try: + return [int(x.strip()) for x in val.split(",") if x.strip() != ""] + except Exception: + raise SystemExit("❌ --indexes expects a comma-separated list of integers, e.g., 0,2,3") + +def _parse_langs(val: str) -> List[str]: + return [x.strip().lower() for x in val.split(",") if x.strip() != ""] + +def _disp_is_forced(disp: Dict[str, Any]) -> bool: + return bool(disp.get("forced", 0)) + +def _disp_is_hi(disp: Dict[str, Any]) -> bool: + # Some containers mark 'hearing_impaired'; if absent, assume False + return bool(disp.get("hearing_impaired", 0)) + +def _select_streams(streams: List[Dict[str, Any]], + indexes: Optional[List[int]], + langs: Optional[List[str]], + forced_only: bool, + exclude_hi: bool) -> List[Dict[str, Any]]: + sel = [] + for st in streams: + if st.get("codec_type") != "subtitle": + continue + idx_ok = True + lang_ok = True + forced_ok = True + hi_ok = True + + if indexes is not None: + idx_ok = (st.get("index") in indexes) + + if langs is not None: + lang_tag = (st.get("tags", {}) or {}).get("language", "") + lang_ok = (lang_tag.lower() in langs) + + if forced_only: + forced_ok = _disp_is_forced(st.get("disposition", {}) or {}) + + if exclude_hi: + hi_ok = not _disp_is_hi(st.get("disposition", {}) or {}) + + if idx_ok and lang_ok and forced_ok and hi_ok: + sel.append(st) + return sel + +def _shift_args(seconds: float) -> List[str]: + # Apply time shift using -itsoffset on the subtitle input branch. + # We’ll insert these flags just before the subtitle -i when needed. + return ["-itsoffset", str(seconds)] + +def _target_codec_for(fmt: str) -> str: + # ffmpeg subtitle encoders by container: + # srt: -c:s srt + # webvtt: -c:s webvtt + # ass: -c:s ass + m = {"srt":"srt", "vtt":"webvtt", "ass":"ass"} + return m[fmt] + +def _print_list(streams: List[Dict[str, Any]], src: Path) -> None: + if not streams: + print(f"(no subtitle streams) — {src}") + return + print(f"Subtitle streams in: {src}") + for st in streams: + i = st.get("index") + c = st.get("codec_name", "?") + tag = st.get("tags", {}) or {} + lang = tag.get("language", "") + title = tag.get("title", "") + disp = st.get("disposition", {}) or {} + forced = "forced" if disp.get("forced",0)==1 else "" + hi = "hearing_impaired" if disp.get("hearing_impaired",0)==1 else "" + flags = ", ".join(x for x in (forced, hi) if x) + flags = f" [{flags}]" if flags else "" + print(f" index={i:>2} codec={c:8} lang={lang or '-':3} title={title or '-'}{flags}") + +# ------------------------------ +# CLI +# ------------------------------ +def register_arguments(parser: argparse.ArgumentParser): + parser.description = ( + "List, extract, and convert subtitle tracks. " + "Works with container-embedded subtitles or standalone .srt/.ass/.vtt files." + ) + + # Selection (video mode) + parser.add_argument("--list", action="store_true", + help="List subtitle streams in the input video and exit.") + parser.add_argument("--indexes", type=str, + help="Comma-separated list of subtitle stream indexes to extract (e.g., '0,2').") + parser.add_argument("--langs", type=str, + help="Comma-separated list of language codes to extract (e.g., 'eng,spa').") + parser.add_argument("--all", action="store_true", + help="Extract all subtitle streams.") + parser.add_argument("--forced-only", action="store_true", + help="Only include streams with 'forced' disposition.") + parser.add_argument("--exclude-hi", action="store_true", + help="Exclude streams with 'hearing_impaired' disposition.") + + # Output control + parser.add_argument("--format", choices=["srt","vtt","ass"], + help="Target subtitle format for output. Required for standalone subtitle conversion; optional for video mode.") + parser.add_argument("--outdir", type=str, + help="Directory for multiple extracted subtitle files.") + parser.add_argument("--outputfile", type=str, + help="Single output file path (only valid when extracting a single stream or converting a single subtitle file).") + + # Timing + parser.add_argument("--time-shift", type=float, default=0.0, + help="Apply time shift in seconds (can be negative).") + + # Note: -i/--input and -F/--force are handled by top-level CLI. + +# ------------------------------ +# Main execution +# ------------------------------ +def run(args: argparse.Namespace): + in_path = Path(args.input) + if not in_path.exists(): + raise SystemExit(f"❌ Input not found: {in_path}") + + is_sub_file = _is_subtitle_file(in_path) + + # Standalone subtitle conversion mode + if is_sub_file: + if not args.format: + raise SystemExit("❌ --format is required when input is a subtitle file.") + if not args.outputfile: + raise SystemExit("❌ --outputfile is required when input is a subtitle file.") + + fmt = args.format.lower() + out_path = Path(args.outputfile) + if out_path.suffix.lower() != f".{fmt}": + out_path = out_path.with_suffix(f".{fmt}") + out_path.parent.mkdir(parents=True, exist_ok=True) + + # ffmpeg: sub file -> target format + # Use -itsoffset if time-shift != 0 + cmd = ["ffmpeg"] + if args.time_shift and args.time_shift != 0.0: + cmd += _shift_args(args.time_shift) + cmd += ["-i", str(in_path), + "-map", "0:s:0", + "-c:s", _target_codec_for(fmt), + str(out_path)] + if getattr(args, "force", False): + cmd = cmd[:1] + ["-y"] + cmd[1:] + run_ffmpeg_with_progress(cmd, args.input, out_path) + return + + # Video mode (container with possible subtitle streams) + streams = _run_ffprobe_streams(in_path) + + if args.list: + _print_list(streams, in_path) + return + + # Build selection + indexes = _parse_index_list(args.indexes) if args.indexes else None + langs = _parse_langs(args.langs) if args.langs else None + selected = _select_streams(streams, indexes, langs, args.forced_only, args.exclude_hi) + + if args.all: + selected = streams[:] # all subtitle streams (already filtered by ffprobe) + + if not selected: + raise SystemExit("❌ No subtitle streams matched your selection. Use --list to inspect indices and languages.") + + # Output policy + single_output = (len(selected) == 1) + if single_output and args.outputfile: + # Write exactly one file + st = selected[0] + fmt = (args.format or _infer_target_ext(st.get("codec_name"))[1:]).lower() + out_path = Path(args.outputfile) + if out_path.suffix.lower() != f".{fmt}": + out_path = out_path.with_suffix(f".{fmt}") + out_path.parent.mkdir(parents=True, exist_ok=True) + + # Build command + # If time-shift, we insert -itsoffset before the video input, then map the subtitle stream. + # We use -map 0:s: ? Careful: stream.index is global stream index, not 's' index. + # In ffmpeg, selecting a specific subtitle by absolute index can be done via -map 0:. + abs_index = st.get("index") + if abs_index is None: + raise SystemExit("❌ Unexpected: stream lacks 'index' field.") + + fmt_target = (args.format or _infer_target_ext(st.get("codec_name"))[1:]).lower() + cmd = ["ffmpeg"] + if args.time_shift and args.time_shift != 0.0: + cmd += _shift_args(args.time_shift) + cmd += [ + "-i", str(in_path), + "-map", f"0:{abs_index}", + "-c:s", _target_codec_for(fmt_target), + str(out_path) + ] + if getattr(args, "force", False): + cmd = cmd[:1] + ["-y"] + cmd[1:] + run_ffmpeg_with_progress(cmd, args.input, out_path) + return + + # Multiple outputs → --outdir required + if not args.outdir: + raise SystemExit("❌ Multiple streams selected. Provide --outdir to write batch outputs.") + + outdir = Path(args.outdir) + outdir.mkdir(parents=True, exist_ok=True) + + # Build and run per-stream extraction + for st in selected: + idx = st.get("index") + codec = st.get("codec_name", "") + tags = (st.get("tags", {}) or {}) + lang = (tags.get("language") or "und").lower() + title = tags.get("title") or "" + # Decide extension + ext = _format_to_ext(args.format) if args.format else _infer_target_ext(codec) + # out name: _sIDX_LANG[optional_title_sanitized].ext + base = in_path.stem + title_part = f"_{_sanitize_filename(title)}" if title else "" + out_path = outdir / f"{base}_s{idx}_{lang}{title_part}{ext}" + + # Build command + cmd = ["ffmpeg"] + if args.time_shift and args.time_shift != 0.0: + cmd += _shift_args(args.time_shift) + cmd += [ + "-i", str(in_path), + "-map", f"0:{idx}", + "-c:s", _target_codec_for((args.format or ext[1:]).lower()), + str(out_path) + ] + if getattr(args, "force", False): + cmd = cmd[:1] + ["-y"] + cmd[1:] + run_ffmpeg_with_progress(cmd, args.input, out_path) + + +def _sanitize_filename(s: str) -> str: + bad = '<>:"/\\|?*' + out = "".join("_" if ch in bad else ch for ch in s) + return out.strip() diff --git a/videobeaux/programs/thumbs.py b/videobeaux/programs/thumbs.py new file mode 100644 index 0000000..20fd1a5 --- /dev/null +++ b/videobeaux/programs/thumbs.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +# videobeaux/programs/thumbs.py +# Thumbnail / Contact Sheet generator for videobeaux. +# +# Supports: +# - Interval sampling (--fps) +# - Scene-based sampling (--scene) +# - Timestamp overlays (--timestamps) +# - Custom fonts, colors, margins, padding +# - Frame sequences (--outdir) +# - Contact sheets (--outputfile) +# +# Example: +# videobeaux -P thumbs -i ./media/bbb.mov --outputfile ./out/bbb_contact.jpg --fps 0.5 --tile 5x4 -F + +from __future__ import annotations +import argparse +from pathlib import Path +from typing import List + +from videobeaux.utils.ffmpeg_operations import run_ffmpeg_with_progress + +DEFAULT_EXT = "jpg" + +# ------------------------------ +# Helper functions +# ------------------------------ +def _parse_tile(tile: str | None) -> tuple[int, int]: + if not tile: + return (6, 4) + try: + parts = tile.lower().replace("x", " ").split() + c, r = int(parts[0]), int(parts[1]) + return (max(1, c), max(1, r)) + except Exception: + raise SystemExit("❌ Invalid --tile format. Use like 6x4 (columns x rows).") + +def _scale_expr(scale: str | None) -> str: + if not scale: + return "320:-1" + if ":" not in scale: + raise SystemExit("❌ Invalid --scale format. Use WIDTH:HEIGHT (e.g., 320:-1 or 360:360).") + return scale + +def _escape_text(s: str) -> str: + return s.replace(":", r"\:").replace("'", r"\'") + +def _sanitize_color(c: str) -> str: + """Accepts '#RRGGBB', '0xRRGGBB', or named colors like 'black'.""" + c = (c or "").strip() + if not c: + return "black" + if c.startswith("#"): + hx = c[1:] + if len(hx) == 3: + hx = "".join(ch * 2 for ch in hx) + return "0x" + hx.lower() + if c.lower().startswith("0x"): + return c.lower() + return c # Named colors + +def _drawtext_chain(timestamps: bool, fontfile: str | None) -> list[str]: + chain: list[str] = [] + if timestamps: + dt = "drawtext=text='%{pts\\:hms}':x=10:y=h-th-10:fontsize=20:fontcolor=white" + if fontfile: + dt += f":fontfile='{_escape_text(fontfile)}'" + chain.append(dt) + return chain + +def _ensure_parent(path: Path): + path.parent.mkdir(parents=True, exist_ok=True) + +# ------------------------------ +# Argument registration +# ------------------------------ +def register_arguments(parser: argparse.ArgumentParser): + parser.description = ( + "Generate thumbnails and/or a tiled contact sheet from a video. " + "Supports interval or scene-based selection, timestamps, custom fonts, and layout styling." + ) + + # Sampling + parser.add_argument("--fps", type=float, default=0.5, help="Frames per second to sample (e.g., 0.5 = one every 2s).") + parser.add_argument("--scene", action="store_true", help="Use scene-based selection instead of fixed intervals.") + parser.add_argument("--scene-threshold", type=float, default=0.4, help="Scene detection sensitivity (lower = more cuts).") + + # Appearance + parser.add_argument("--tile", type=str, default="6x4", help="Contact sheet grid 'COLUMNSxROWS' (e.g., 6x4).") + parser.add_argument("--scale", type=str, default="320:-1", help="Per-thumb scale (e.g., 320:-1).") + parser.add_argument("--timestamps", action="store_true", help="Overlay timestamps on thumbnails.") + parser.add_argument("--label", action="store_true", help="Add a footer label with filename.") + parser.add_argument("--fontfile", type=str, help="Custom font path for drawtext.") + parser.add_argument("--bg", type=str, default="#000000", help="Background color ('black', '#111111', or '0x111111').") + parser.add_argument("--margin", type=int, default=12, help="Outer margin (pixels).") + parser.add_argument("--padding", type=int, default=6, help="Padding between tiles (pixels).") + + # Outputs + parser.add_argument("--outdir", type=str, help="Directory to export frame sequence.") + parser.add_argument("--outputfile", type=str, help="Output contact sheet path (e.g., ./out/sheet.jpg).") + parser.add_argument("--image-format", choices=["jpg", "png"], default=None, help="Output format if --outputfile has no extension.") + parser.add_argument("--jpeg-quality", type=int, default=3, help="JPEG quality (2=high, 3=good, 5=ok).") + +# ------------------------------ +# Main execution +# ------------------------------ +def run(args: argparse.Namespace): + in_path = Path(args.input) + if not in_path.exists(): + raise SystemExit(f"❌ Input not found: {in_path}") + + contactsheet_path: Path | None = None + if getattr(args, "outputfile", None): + contactsheet_path = Path(args.outputfile) + if contactsheet_path and contactsheet_path.suffix == "": + ext = args.image_format or DEFAULT_EXT + contactsheet_path = contactsheet_path.with_suffix(f".{ext}") + + outdir_path: Path | None = None + if getattr(args, "outdir", None): + outdir_path = Path(args.outdir) + + if not contactsheet_path and not outdir_path: + raise SystemExit("❌ Provide at least one output: --outputfile or --outdir.") + + scale = _scale_expr(args.scale) + draw_chain = _drawtext_chain(args.timestamps, args.fontfile) + + # -------------- Frame Sequence -------------- + if outdir_path: + outdir_path.mkdir(parents=True, exist_ok=True) + seq_filters: list[str] = [] + if args.scene: + seq_filters.append(f"select='gt(scene,{args.scene_threshold})'") + else: + seq_filters.append(f"fps={max(0.001, float(args.fps))}") + seq_filters.append(f"scale={scale}") + seq_filters.extend(draw_chain) + seq_vf = ",".join(seq_filters) + pattern = str(outdir_path / "frame_%06d.jpg") + cmd = [ + "ffmpeg", + "-i", str(in_path), + "-vf", seq_vf, + "-q:v", str(max(1, min(31, int(args.jpeg_quality)))), + pattern + ] + if getattr(args, "force", False): + cmd = cmd[:1] + ["-y"] + cmd[1:] + run_ffmpeg_with_progress(cmd, args.input, outdir_path / "frame_%06d.jpg") + + # -------------- Contact Sheet -------------- + if contactsheet_path: + _ensure_parent(contactsheet_path) + cols, rows = _parse_tile(args.tile) + bg_color = _sanitize_color(args.bg) + + filters: list[str] = [] + if args.scene: + filters.append(f"select='gt(scene,{args.scene_threshold})'") + else: + filters.append(f"fps={max(0.001, float(args.fps))}") + filters.append(f"scale={scale}") + filters.extend(draw_chain) + filters.append(f"tile={cols}x{rows}:{int(args.margin)}:{int(args.padding)}:{bg_color}") + + if args.label: + label_txt = _escape_text(in_path.name) + label = f"drawtext=text='{label_txt}':x=10:y=h-th-10:fontsize=22:fontcolor=white" + if args.fontfile: + label += f":fontfile='{_escape_text(args.fontfile)}'" + filters.append(label) + + full_chain = ",".join(filters) + is_png = contactsheet_path.suffix.lower() == ".png" + + cmd = ["ffmpeg", "-i", str(in_path), "-vf", full_chain, "-frames:v", "1"] + if not is_png: + cmd += ["-q:v", str(max(1, min(31, int(args.jpeg_quality))))] + cmd += [str(contactsheet_path)] + if getattr(args, "force", False): + cmd = cmd[:1] + ["-y"] + cmd[1:] + run_ffmpeg_with_progress(cmd, args.input, contactsheet_path) diff --git a/videobeaux/programs/tonemap_hdr_sdr.py b/videobeaux/programs/tonemap_hdr_sdr.py new file mode 100644 index 0000000..4354e49 --- /dev/null +++ b/videobeaux/programs/tonemap_hdr_sdr.py @@ -0,0 +1,120 @@ +# videobeaux/programs/tonemap_hdr_sdr.py +# HDR → SDR tone mapping using zscale + tonemap (default: hable). +# Matches videobeaux program structure: register_arguments() + run(args). + +from videobeaux.utils.ffmpeg_operations import run_ffmpeg_with_progress + +def register_arguments(parser): + parser.description = ( + "HDR → SDR Tone Map\n" + "Convert HDR (PQ/HLG) video to SDR (BT.709) using zscale + tonemap.\n" + "Default mapping is Hable with mild desaturation and 1000-nit peak." + ) + # IO + parser.add_argument( + "--outfile", + required=True, + help="Output file path for the SDR result (use this instead of the global -o)." + ) + + # Tonemap controls + parser.add_argument( + "--algo", + choices=["hable", "mobius", "reinhard", "clip"], + default="hable", + help="Tonemap operator. Default: hable" + ) + parser.add_argument( + "--desat", + type=float, + default=0.0, + help="Desaturate highlights during tonemap [0.0–1.0]. Default: 0.0" + ) + parser.add_argument( + "--peak", + type=float, + default=1000.0, + help="Nominal HDR peak (nits) for linearization (zscale npl). Default: 1000" + ) + # Output color / dithering / pixfmt + parser.add_argument( + "--dither", + choices=["none", "ordered", "random", "error_diffusion"], + default="error_diffusion", + help="Dither mode applied in zscale prior to format(). Default: error_diffusion" + ) + parser.add_argument( + "--pix-fmt", + default="yuv420p", + help="Output pixel format. Common picks: yuv420p, yuv422p10le. Default: yuv420p" + ) + parser.add_argument( + "--x264-preset", + default="medium", + help="libx264 preset (if re-encoding). Default: medium" + ) + parser.add_argument( + "--crf", + type=float, + default=18.0, + help="CRF when encoding with libx264. Default: 18" + ) + parser.add_argument( + "--copy-audio", + action="store_true", + help="Copy audio stream instead of re-encoding." + ) + +def run(args): + """ + Pipeline: + 1) zscale=transfer=linear:npl=PEAK # Convert to linear using nominal peak + 2) tonemap=ALGO:desat=DESAT # Apply tonemap curve + 3) zscale=primaries=bt709:transfer=bt709:matrix=bt709:dither=DITHER + 4) format=PIX_FMT + Notes: + - We set explicit BT.709 flags on the stream to keep players honest. + - We re-encode video (libx264). Audio can be copied with --copy-audio. + """ + + outfile = args.outfile + + # Build filtergraph + filtergraph = ( + f"zscale=transfer=linear:npl={args.peak}," + f"tonemap={args.algo}:desat={args.desat}," + f"zscale=primaries=bt709:transfer=bt709:matrix=bt709:dither={args.dither}," + f"format={args.pix_fmt}" + ) + + # Core command + command = [ + "ffmpeg", + "-err_detect", "ignore_err", + "-fflags", "+genpts+discardcorrupt", + "-i", args.input, + + "-vf", filtergraph, + + # Color tags (make sure containers/players see BT.709 SDR) + "-colorspace", "bt709", + "-color_trc", "bt709", + "-color_primaries", "bt709", + + # Encode video + "-c:v", "libx264", + "-preset", f"{args.x264_preset}", + "-crf", f"{args.crf}", + + # Audio strategy + "-c:a", "copy" if getattr(args, "copy_audio", False) else "aac", + + # Output path from --outfile + outfile, + ] + + # Respect --force like other programs (inject -y right after 'ffmpeg') + final_cmd = (command[:1] + ["-y"] + command[1:]) if getattr(args, "force", False) else command + + # Progress helper consistent with other programs + run_ffmpeg_with_progress(final_cmd, args.input, outfile) diff --git a/videobeaux/programs/watermark.py b/videobeaux/programs/watermark.py new file mode 100644 index 0000000..582dea6 --- /dev/null +++ b/videobeaux/programs/watermark.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +# videobeaux/programs/watermark.py +# +# Overlay a static/animated watermark (PNG/JPG/GIF) onto a video. +# - Robust GIF handling (looping, -ignore_loop, optional -stream_loop) +# - Placement presets with margin +# - Scale factor relative to watermark's intrinsic width (iw*scale) +# - Opacity via colorchannelmixer (alpha) +# - Optional spin (continuous rotation over time) +# - Timed enable window (start/end seconds) +# - Safe stream mapping and mp4-friendly output +# +# Example: +# videobeaux -P watermark \ +# -i ./media/bbb.mov -o ./out/bbb_wm_windowed.mp4 \ +# --watermark ./media/badge.gif --placement bottom-right --margin 24 \ +# --scale 0.25 --opacity 0.7 --spin 12.0 --start 1.0 --end 7.0 \ +# --wm-loop -1 -F +# +# Notes: +# - spin is degrees per second (float). angle(t) = spin_deg_per_sec * t * pi/180 +# - wm-loop behaves like ffmpeg -stream_loop for the watermark input: +# -1 = infinite, 0 = no extra loops, N>0 loop N times after first play +# - We ALWAYS pass -ignore_loop 0 for GIF so decoder honors intrinsic timing. +# - For non-GIF stills, ffmpeg holds the frame; for sequences/GIF we add -stream_loop as requested. + +from __future__ import annotations +import argparse +from pathlib import Path +from typing import Tuple + +from videobeaux.utils.ffmpeg_operations import run_ffmpeg_with_progress + + +def _placement_xy(placement: str, margin: int) -> Tuple[str, str]: + pm = placement.lower().strip() + m = int(margin) + if pm == "top-left": + return (f"{m}", f"{m}") + if pm == "top-right": + return (f"W-w-{m}", f"{m}") + if pm == "bottom-left": + return (f"{m}", f"H-h-{m}") + if pm == "bottom-right": + return (f"W-w-{m}", f"H-h-{m}") + if pm == "center": + return (f"(W-w)/2", f"(H-h)/2") + # fallback + return (f"W-w-{m}", f"H-h-{m}") + + +def _sanitize_scale(scale: float) -> float: + try: + s = float(scale) + except Exception: + raise SystemExit("❌ --scale must be a number (e.g., 0.25).") + if s <= 0: + raise SystemExit("❌ --scale must be > 0.") + return s + + +def _sanitize_opacity(opacity: float) -> float: + try: + a = float(opacity) + except Exception: + raise SystemExit("❌ --opacity must be a number between 0.0 and 1.0.") + if not (0.0 <= a <= 1.0): + raise SystemExit("❌ --opacity must be between 0.0 and 1.0.") + return a + + +def _gif_input_flags(wm_path: Path, wm_loop: int, ignore_loop_flag: bool) -> list[str]: + """ + Build input flags for GIF/animated watermark. + We default to respecting GIF's intrinsic loop: -ignore_loop 0 + Then optionally add -stream_loop to extend looping. + """ + flags: list[str] = [] + if wm_path.suffix.lower() == ".gif": + # If user asked to ignore the gif's intrinsic loop, set -ignore_loop 1 + if ignore_loop_flag: + flags += ["-ignore_loop", "1"] + else: + flags += ["-ignore_loop", "0"] + + # -stream_loop : -1 infinite, 0 none, N>0 N extra loops after first play. + # Only add when user provides a value different from None and not 0. + # (If 0, we omit; if -1 or >0, we set it.) + if wm_loop is not None and wm_loop != 0: + flags += ["-stream_loop", str(int(wm_loop))] + return flags + + +def register_arguments(parser: argparse.ArgumentParser): + parser.description = ( + "Burn a watermark (PNG/JPG/GIF) into a video with placement, scale, opacity, " + "optional spin, and timed enable window." + ) + parser.add_argument("--watermark", required=True, help="Path to watermark image (PNG/JPG/GIF).") + parser.add_argument("--placement", default="bottom-right", + choices=["top-left", "top-right", "bottom-left", "bottom-right", "center"], + help="Watermark placement.") + parser.add_argument("--margin", type=int, default=24, help="Margin (px) from edges for placement.") + parser.add_argument("--scale", type=float, default=0.25, + help="Scale factor relative to watermark intrinsic width (iw*scale).") + parser.add_argument("--opacity", type=float, default=0.8, + help="Watermark opacity (0.0–1.0).") + parser.add_argument("--spin", type=float, default=0.0, + help="Watermark spin in degrees per second (0 = no rotation).") + parser.add_argument("--start", type=float, default=0.0, help="Enable overlay starting at t seconds.") + parser.add_argument("--end", type=float, default=0.0, + help="Disable overlay after t seconds (0 = until end).") + + # GIF/animated controls + parser.add_argument("--wm-loop", type=int, default=0, + help="Additional loops for watermark input (-1=infinite, 0=none, N>0 times).") + parser.add_argument("--ignore-loop", action="store_true", + help="For GIF watermark: ignore intrinsic loop (use frames once).") + + # Video encode controls (kept from your args) + parser.add_argument("--video-crf", type=int, default=18, help="CRF for libx264.") + parser.add_argument("--video-preset", type=str, default="fast", help="x264 preset.") + + # NOTE: -i/--input, -o/--output, -F/--force are provided by the top-level CLI. + + +def run(args: argparse.Namespace): + in_path = Path(args.input) + if not in_path.exists(): + raise SystemExit(f"❌ Input not found: {in_path}") + + out_path = Path(args.output) + out_path.parent.mkdir(parents=True, exist_ok=True) + + wm_path = Path(args.watermark) + if not wm_path.exists(): + raise SystemExit(f"❌ Watermark not found: {wm_path}") + + # Validate numerics + scale = _sanitize_scale(args.scale) + opacity = _sanitize_opacity(args.opacity) + + # Placement math + x_expr, y_expr = _placement_xy(args.placement, args.margin) + + # Enable expression + if args.end and args.end > 0: + enable_expr = f"between(t,{float(args.start)},{float(args.end)})" + else: + enable_expr = f"gte(t,{float(args.start)})" + + # Build the watermark processing chain + # 1) scale relative to its own width (iw*scale) + # 2) convert to RGBA, then apply alpha multiplier via colorchannelmixer + # 3) optional rotation with 'rotate' (angle in radians) + wm_chain_parts = [f"scale=iw*{scale}:-1", "format=rgba", f"colorchannelmixer=aa={opacity}"] + + spin = float(args.spin or 0.0) + if spin != 0.0: + # angle(t) = spin_deg_per_sec * t * pi/180 + # Use ffmpeg expr: (spin*pi/180)*t + wm_chain_parts.append(f"rotate={(spin)}*PI/180*t:fillcolor=0x00000000") + + wm_chain = ",".join(wm_chain_parts) + + # Overlay (+ enable) + overlay = f"overlay={x_expr}:{y_expr}:enable='{enable_expr}'" + + # Assemble filter_complex with named pads + # [1:v]wm_chain[wm];[0:v][wm]overlay=... (alpha premult handled by format=rgba) + filter_complex = f"[1:v]{wm_chain}[wm];[0:v][wm]{overlay}" + + # Inputs (include GIF flags when appropriate) + input_flags: list[str] = ["-i", str(in_path)] + gif_flags = _gif_input_flags(wm_path, int(args.wm_loop), bool(args.ignore_loop)) + input_flags += gif_flags + ["-i", str(wm_path)] + + # Safe mapping: map main video/audio from #0, output yuv420p mp4 with x264 + command = [ + "ffmpeg", + *input_flags, + "-filter_complex", filter_complex, + "-map", "0:v:0", + "-map", "0:a?:0", + "-c:v", "libx264", + "-crf", str(int(args.video_crf)), + "-preset", str(args.video_preset), + "-pix_fmt", "yuv420p", + "-c:a", "aac", + "-b:a", "192k", + "-shortest", + str(out_path), + ] + + if getattr(args, "force", False): + command = command[:1] + ["-y"] + command[1:] + + # Run + run_ffmpeg_with_progress(command, args.input, out_path)