Files
Doomsday_Survival_Manual/skills/video-use/helpers/grade.py

376 lines
13 KiB
Python

"""Apply a color grade to a video via ffmpeg filter chain.
Two modes:
1. Preset mode — pick a named preset (e.g. `warm_cinematic`, `neutral_punch`).
Simple fixed filter chain applied uniformly.
2. Auto mode (DEFAULT) — analyze the clip mathematically and emit a subtle
per-clip correction. Samples N frames via ffmpeg, computes mean brightness,
RMS contrast, saturation. Emits a bounded filter string that corrects
under-exposure, flatness, and mild desaturation without applying any
creative color shift. All adjustments capped at ±8% on any axis.
The goal is "make it look clean without looking graded". Never applies
creative LUTs, teal/orange splits, or filmic curves. For creative looks,
use `--preset warm_cinematic` explicitly.
Usage:
python helpers/grade.py <input> -o <output> # auto mode
python helpers/grade.py <input> -o <output> --preset warm_cinematic
python helpers/grade.py <input> -o <output> --filter 'eq=contrast=1.1'
python helpers/grade.py --print-preset warm_cinematic # print filter only
python helpers/grade.py --analyze <input> # print auto-grade analysis
Can also be imported by render.py: `get_preset(name)` and `auto_grade_for_clip(path, edl_range)`.
"""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
import tempfile
from pathlib import Path
PRESETS: dict[str, str] = {
# Subtle baseline — barely perceptible cleanup. No color shift.
# Use when auto-analysis isn't available or when you want a safe floor.
"subtle": "eq=contrast=1.03:saturation=0.98",
# Minimal corrective grade: light contrast + subtle S-curve, no color shifts.
"neutral_punch": (
"eq=contrast=1.06:brightness=0.0:saturation=1.0,"
"curves=master='0/0 0.25/0.23 0.75/0.77 1/1'"
),
# OPT-IN creative preset for retro/cinematic looks ONLY. Not a default.
# +12% contrast, crushed blacks, -12% sat, warm shadows + cool highs, filmic curve.
# Originally from HEURISTICS §6 — too aggressive for standard launch content.
"warm_cinematic": (
"eq=contrast=1.12:brightness=-0.02:saturation=0.88,"
"colorbalance="
"rs=0.02:gs=0.0:bs=-0.03:"
"rm=0.04:gm=0.01:bm=-0.02:"
"rh=0.08:gh=0.02:bh=-0.05,"
"curves=master='0/0 0.25/0.22 0.75/0.78 1/1'"
),
# Flat — no grade. Useful as a sentinel for "skip grading this source".
"none": "",
}
def get_preset(name: str) -> str:
"""Return the ffmpeg filter string for a preset name. Empty string for 'none'."""
if name not in PRESETS:
raise KeyError(
f"unknown preset '{name}'. Available: {', '.join(sorted(PRESETS))}"
)
return PRESETS[name]
# -------- Auto grade (data-driven, per-clip) --------------------------------
def _sample_frame_stats(
video: Path,
start: float,
duration: float,
n_samples: int = 10,
) -> dict[str, float]:
"""Sample N frames from a range and compute brightness/contrast/saturation stats.
Uses ffmpeg's `signalstats` filter which gives us YMIN, YMAX, YAVG, SATAVG
etc. in the metadata. We average across the sample range.
Returns:
{
"y_mean": mean Y (luma) in 0..1,
"y_std": approximate stddev of Y across samples (0..1),
"sat_mean": mean saturation in 0..1,
}
"""
# Use signalstats + metadata=print to get per-frame stats
# Sample fps = n_samples / duration, clamped so we don't over-sample short clips
fps = max(0.5, min(n_samples / max(duration, 0.1), 10.0))
with tempfile.NamedTemporaryFile(mode="w+", suffix=".txt", delete=False) as f:
metadata_path = f.name
try:
cmd = [
"ffmpeg", "-y", "-hide_banner", "-nostats",
"-ss", f"{start:.3f}",
"-i", str(video),
"-t", f"{duration:.3f}",
"-vf", f"fps={fps:.2f},signalstats,metadata=print:file={metadata_path}",
"-f", "null", "-",
]
subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
# Parse signalstats metadata. Signalstats reports values in the NATIVE
# bit depth of the decoded frame (8-bit → 0-255, 10-bit → 0-1023). We
# read YBITDEPTH and normalize by (2^depth - 1) so downstream math is
# in 0..1 regardless of source bit depth.
y_avgs: list[float] = []
y_mins: list[float] = []
y_maxs: list[float] = []
sat_avgs: list[float] = []
bit_depth: int = 8
def _parse_value(line: str) -> float | None:
try:
return float(line.rsplit("=", 1)[1])
except (ValueError, IndexError):
return None
with open(metadata_path) as f:
for line in f:
line = line.strip()
if "lavfi.signalstats.YBITDEPTH" in line:
v = _parse_value(line)
if v is not None:
bit_depth = int(v)
elif "lavfi.signalstats.YAVG" in line:
v = _parse_value(line)
if v is not None:
y_avgs.append(v)
elif "lavfi.signalstats.YMIN" in line:
v = _parse_value(line)
if v is not None:
y_mins.append(v)
elif "lavfi.signalstats.YMAX" in line:
v = _parse_value(line)
if v is not None:
y_maxs.append(v)
elif "lavfi.signalstats.SATAVG" in line:
v = _parse_value(line)
if v is not None:
sat_avgs.append(v)
if not y_avgs:
# Analysis failed — return neutral defaults (no correction)
return {"y_mean": 0.5, "y_std": 0.18, "sat_mean": 0.25}
# Normalize by native bit-depth max value
max_val = (2 ** bit_depth) - 1
y_mean = (sum(y_avgs) / len(y_avgs)) / max_val
y_range = (
((sum(y_maxs) / len(y_maxs)) - (sum(y_mins) / len(y_mins))) / max_val
if y_maxs and y_mins
else 0.7
)
sat_mean = ((sum(sat_avgs) / len(sat_avgs)) / max_val) if sat_avgs else 0.25
return {
"y_mean": y_mean,
"y_std": y_range / 4.0, # range ÷ 4 ≈ stddev for normal-ish distributions
"sat_mean": sat_mean,
}
finally:
Path(metadata_path).unlink(missing_ok=True)
def auto_grade_for_clip(
video: Path,
start: float = 0.0,
duration: float | None = None,
verbose: bool = False,
) -> tuple[str, dict[str, float]]:
"""Analyze a clip range and emit a subtle per-clip correction filter.
Returns (filter_string, stats_dict). The filter is bounded to ±8% on any axis
and applies NO color shift. It only addresses:
- Underexposure (lift gamma slightly if too dark)
- Flatness (tiny contrast boost if range is narrow)
- Desaturation (tiny sat boost if extremely flat)
If the clip is already well-balanced, returns the baseline `subtle` preset.
"""
if duration is None:
# Probe duration
probe_cmd = [
"ffprobe", "-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
str(video),
]
try:
duration = float(subprocess.check_output(probe_cmd).decode().strip())
except Exception:
duration = 10.0
stats = _sample_frame_stats(video, start, duration)
y_mean = stats["y_mean"]
y_range = stats["y_std"] * 4.0 # back to range
sat_mean = stats["sat_mean"]
# ------ Decision rules ---------------------------------------------------
# All caps bounded to ±8%. Target "clean, not graded".
# Contrast: target y_range ≈ 0.72. Boost gently if flat, never reduce.
contrast_adj = 1.0
if y_range < 0.65:
# Map [0.50, 0.65] → [1.08, 1.03]
t = max(0.0, min(1.0, (y_range - 0.50) / 0.15))
contrast_adj = 1.08 - 0.05 * t
else:
contrast_adj = 1.03 # subtle baseline
# Gamma: target y_mean ≈ 0.48. Lift gently if too dark.
gamma_adj = 1.0
if y_mean < 0.42:
# Map [0.30, 0.42] → [1.10, 1.02]
t = max(0.0, min(1.0, (y_mean - 0.30) / 0.12))
gamma_adj = 1.10 - 0.08 * t
elif y_mean > 0.60:
# Slightly overexposed — tiny pullback
gamma_adj = 0.97
# Saturation: target sat_mean ≈ 0.25. Never desaturate aggressively;
# modest boost if very flat. Default to 0.98 (tiny pullback — most digital
# video is slightly over-saturated on consumer displays).
sat_adj = 0.98
if sat_mean < 0.18:
# Very flat — tiny boost
sat_adj = 1.04
elif sat_mean > 0.38:
# Already punchy — hold
sat_adj = 0.96
# Clamp all adjustments hard
contrast_adj = max(0.94, min(1.08, contrast_adj))
gamma_adj = max(0.94, min(1.10, gamma_adj))
sat_adj = max(0.94, min(1.06, sat_adj))
# Build filter string
eq_parts = []
if abs(contrast_adj - 1.0) > 0.005:
eq_parts.append(f"contrast={contrast_adj:.3f}")
if abs(gamma_adj - 1.0) > 0.005:
eq_parts.append(f"gamma={gamma_adj:.3f}")
if abs(sat_adj - 1.0) > 0.005:
eq_parts.append(f"saturation={sat_adj:.3f}")
if not eq_parts:
filter_string = ""
else:
filter_string = "eq=" + ":".join(eq_parts)
if verbose:
print(f" auto-grade stats:")
print(f" y_mean={y_mean:.3f} y_range={y_range:.3f} sat_mean={sat_mean:.3f}")
print(f" → contrast={contrast_adj:.3f} gamma={gamma_adj:.3f} sat={sat_adj:.3f}")
print(f" → filter: {filter_string or '(empty)'}")
return filter_string, stats
def apply_grade(input_path: Path, output_path: Path, filter_string: str) -> None:
output_path.parent.mkdir(parents=True, exist_ok=True)
if not filter_string:
cmd = [
"ffmpeg", "-y", "-i", str(input_path),
"-c", "copy", str(output_path),
]
else:
cmd = [
"ffmpeg", "-y", "-i", str(input_path),
"-vf", filter_string,
"-c:v", "libx264", "-preset", "fast", "-crf", "18",
"-pix_fmt", "yuv420p",
"-c:a", "copy",
"-movflags", "+faststart",
str(output_path),
]
subprocess.run(cmd, check=True)
def main() -> None:
ap = argparse.ArgumentParser(description="Apply a color grade via ffmpeg filter chain")
ap.add_argument("input", type=Path, nargs="?", help="Input video")
ap.add_argument("-o", "--output", type=Path, help="Output video")
ap.add_argument(
"--preset",
type=str,
default=None,
choices=list(PRESETS.keys()),
help="Grade preset. Omit for auto mode (default).",
)
ap.add_argument(
"--filter",
type=str,
default=None,
help="Raw ffmpeg filter string. Overrides --preset.",
)
ap.add_argument(
"--analyze",
type=Path,
default=None,
help="Analyze a clip and print the auto-grade filter it would produce. No output written.",
)
ap.add_argument(
"--print-preset",
type=str,
default=None,
help="Print the filter string for a preset and exit. No input/output needed.",
)
ap.add_argument(
"--list-presets",
action="store_true",
help="List available presets and exit.",
)
args = ap.parse_args()
if args.list_presets:
for name, f in PRESETS.items():
print(f"{name}:")
print(f" {f}" if f else " (no filter)")
print()
return
if args.print_preset is not None:
print(get_preset(args.print_preset))
return
if args.analyze is not None:
if not args.analyze.exists():
sys.exit(f"input not found: {args.analyze}")
filter_string, stats = auto_grade_for_clip(args.analyze, verbose=True)
print(f"\nfilter: {filter_string or '(none)'}")
print(f"stats: {json.dumps(stats, indent=2)}")
return
if not args.input or not args.output:
ap.error("input and -o/--output are required unless using --analyze/--print-preset/--list-presets")
if not args.input.exists():
sys.exit(f"input not found: {args.input}")
# Decide filter string
if args.filter is not None:
filter_string = args.filter
elif args.preset is not None:
filter_string = get_preset(args.preset)
else:
# Auto mode (default)
filter_string, _ = auto_grade_for_clip(args.input, verbose=True)
print(f"grading {args.input.name}{args.output.name}")
if filter_string:
print(f" filter: {filter_string[:120]}{'...' if len(filter_string) > 120 else ''}")
else:
print(" filter: (none — copy)")
apply_grade(args.input, args.output, filter_string)
print(f"done: {args.output}")
if __name__ == "__main__":
main()