#!/usr/bin/env python3
"""Refuse a master whose FIRST FRAME is blank.

LinkedIn, X and Slack all pull frame 0 as the video's poster image. An 85s film
shipped with an empty first frame — dotted-grid ground, progress dots, the small
corner mark, no type — because the title beat animates its headline IN rather
than holding a composed title. The film was fine; the only frame most of the
feed would ever see was an empty grey card.

Four rounds of checks passed it. Duration, dimensions, LUFS and true peak were
all green, and the design stage's own frame extraction starts at 35% into beat
one — so frame 0 was never in the checked set. A human caught it by eye. This
gate is that eye.

Thresholds are DERIVED, not guessed: measured over the 458 review frames behind
the 14 delivered films, plus the blank frame that actually shipped. Every blocking
signal sits between the two with real headroom, so a sparse-but-composed beat
clears it — the sparsest delivered frame is 5-13x clear of every bar:

                    the blank    fleet floor   blocks below
  peak tile density      4.3%         31.4%            15%
  composed tiles            0             4              2
  ink coverage          0.041%        0.527%          0.15%

Ink alone is not enough: the house ground carries a dot grid whose pixels are
spread evenly over the whole frame, so a global count scores an empty frame
higher than it deserves. The deciding signal is CONCENTRATION — the densest
tile — which is what "there is composed type here" actually looks like.

The three blocking signals are ratios of tiles and pixels to frame area, so they
mean the same thing at any size: verified identical verdicts at 1080p, 720p and
540p, and on all three aspects the engine renders (16x9, 1x1, 9x16).

One defect is ADVISORY, not blocking, and the reason is the house rule from
check_icons.py: only provable damage blocks. A frame 0 can also be caught
mid-animation — shapes already slid in, type not yet — which posts as a coloured
card with blobs on it. That is the same defect class, but the only signal that
sees it (detail density: how much of the ink is fine stroke rather than slab)
separates the one delivered example, 0.083%, from the sparsest legitimate frame,
0.143%, by just 1.7x. A composed frame carrying big mono cards (substack) sits
right beside a blob frame (block-buzz) on every cheap metric tried — ink, peak
tile, and erosion-survival all conflate them. 1.7x on 14 films is not enough to
refuse a delivery on, so it prints its number and the reviewer decides. Promote
it to a blocker when the fleet is big enough to show where the line really is.

That advisory is calibrated on 16x9, where all 458 fleet frames live. Its raw
value scales as 1/height, so it is normalised to a 1080p equivalent, which holds
it steady down to 720p. It does NOT carry across aspects — the same content in
1x1 or 9x16 reads higher — so on a square or vertical cut read its silence as
"no signal", not "clear". Only the advisory is affected.

Usage:  check_frame0.py <frame.png>
        check_frame0.py --from-video <master.mp4> [<frame-out.png>]
Exit:   0 the first frame is composed; 1 it is blank, with the fix.
"""
import os
import shutil
import subprocess
import sys
import tempfile

from PIL import Image, ImageChops, ImageFilter

INK_DELTA = 40      # per-channel deviation from the ground that counts as ink.
                    # The dot grid sits at ~10-16 and real type at ~200, so 40
                    # excludes the grid without touching anything drawn.
CELL_DIV = 18       # tile = height/18 (60px at 1080p) — resolution-independent
DENSE = 0.25        # a tile >=25% ink is "composed" (type or illustration)
MIN_PEAK = 0.15     # the densest tile must reach this (fleet floor 31.4%)
MIN_CELLS = 2       # at least two composed tiles (fleet floor 4)
MIN_INK = 0.0015    # 0.15% of the frame (fleet floor 0.527%)
LOW_DETAIL = 0.0012  # advisory only — see the docstring (fleet floor 0.143%)

FIX = (
    "Frame 0 is the feed thumbnail — LinkedIn, X and Slack all poster the video\n"
    "with it. Hold a composed title frame (headline + subhead + wordmark) BEFORE\n"
    "animating anything in, so the first frame is already the title card.\n"
    "\n"
    "Fix at: projects/<slug>/accents.py, the accent_title hook (+ two pack fields).\n"
    "NOT the engine. The engine's title_beat eases the mark, the word and the\n"
    "tagline in from zero, so all three are invisible at t=0 — that is a beat\n"
    "entrance doing its job, and every film shares it. Take the beat over in the\n"
    "hook: paint it COMPLETE at t=0, hold it still, then animate out of that. Set\n"
    "TITLE_WORD=\"\" and TAGLINE=\"\" in the pack so the engine's own draws stay\n"
    "suppressed (its existing guards) and carry the strings in your own POSTER_*\n"
    "fields, or the hook's static word ghosts under the engine's rising one.\n"
    "projects/mcp-stateless-explainer/accents.py is the worked example."
)


def measure(path):
    """Ink coverage, peak tile density, composed-tile count and detail, C-speed."""
    im = Image.open(path).convert("RGB")
    w, h = im.size
    # The ground is whatever colour dominates the frame — read it off the image
    # rather than the pack, so an encoder shift (#F0EEE6 lands as 238,238,238)
    # or a full-bleed panel does not turn the whole frame into "ink".
    small = im.resize((max(w // 4, 1), max(h // 4, 1)), Image.NEAREST)
    ground = max(small.getcolors(small.width * small.height), key=lambda c: c[0])[1]

    diff = ImageChops.difference(im, Image.new("RGB", im.size, ground))
    r, g, b = diff.split()
    peak_chan = ImageChops.lighter(ImageChops.lighter(r, g), b)
    mask = peak_chan.point(lambda v: 255 if v > INK_DELTA else 0)

    n = float(w * h)
    ink = mask.histogram()[255] / n
    # The morphological gradient (ink minus eroded ink) outlines every shape, so
    # its area is the frame's perimeter budget: type is nearly all edge, a slab is
    # nearly all interior. That rind is ~1px, so it grows with the frame's LINEAR
    # size while n grows with its AREA — the raw ratio scales as 1/h, and a 720p
    # render of the same frame reads ~1.6x higher. Normalise to a 1080p equivalent
    # so the advisory means the same thing at every resolution. Advisory only.
    rind = ImageChops.difference(mask, mask.filter(ImageFilter.MinFilter(3))).histogram()[255]
    detail = (rind / n) * (h / 1080.0)
    cell = max(h // CELL_DIV, 1)
    cols, rows = max(w // cell, 1), max(h // cell, 1)
    # A BOX resize of a 0/255 mask makes each output pixel the tile's mean,
    # i.e. exactly that tile's ink density.
    dens = [v / 255.0 for v in mask.resize((cols, rows), Image.BOX).tobytes()]
    return w, h, ground, ink, max(dens), sum(1 for d in dens if d >= DENSE), detail


def extract_frame0(video, out):
    """Same call shape the design stage uses for its review frames."""
    subprocess.run(
        ["ffmpeg", "-v", "error", "-ss", "0", "-i", video, "-frames:v", "1", "-y", out],
        check=True,
    )
    return out


def main():
    args = sys.argv[1:]
    tmp = None
    if args and args[0] == "--from-video":
        if len(args) < 2:
            sys.exit("usage: check_frame0.py --from-video <master.mp4> [<frame-out.png>]")
        video = args[1]
        if not os.path.exists(video):
            sys.exit(f"check_frame0: no master at {video}")
        if len(args) > 2:
            frame = args[2]
            os.makedirs(os.path.dirname(os.path.abspath(frame)), exist_ok=True)
        else:
            tmp = tempfile.mkdtemp(prefix="frame0-")
            frame = os.path.join(tmp, "frame0.png")
        try:
            extract_frame0(video, frame)
        except (subprocess.CalledProcessError, FileNotFoundError) as exc:
            sys.exit(f"check_frame0: could not extract frame 0 from {video}: {exc}")
    elif args and not args[0].startswith("-"):
        frame = args[0]
    else:
        sys.exit("usage: check_frame0.py <frame.png> | --from-video <master.mp4>")

    try:
        report(frame)
    finally:
        # the blank path exits non-zero, so this cannot live after the success print
        if tmp:
            shutil.rmtree(tmp, ignore_errors=True)


def report(frame):
    if not os.path.exists(frame):
        sys.exit(f"check_frame0: no frame at {frame}")
    w, h, ground, ink, peak, cells, detail = measure(frame)

    print(f"  frame 0: {w}x{h} ground=#{'%02X%02X%02X' % ground} ink={100*ink:.3f}% "
          f"peak tile={100*peak:.1f}% composed tiles={cells} detail={100*detail:.3f}%")

    reasons = []
    if peak < MIN_PEAK:
        reasons.append(
            f"the densest tile of the whole frame is {100*peak:.1f}% ink "
            f"(need {100*MIN_PEAK:.0f}%; the sparsest delivered frame is 31.4%) — "
            f"there is no composed region anywhere on it"
        )
    if cells < MIN_CELLS:
        reasons.append(
            f"{cells} composed tile(s) (need {MIN_CELLS}; the sparsest delivered "
            f"frame has 4) — nothing on this frame reads as type or illustration"
        )
    if ink < MIN_INK:
        reasons.append(
            f"{100*ink:.3f}% of the frame is ink (need {100*MIN_INK:.2f}%; the "
            f"sparsest delivered frame is 0.527%) — the frame is bare ground"
        )

    if not reasons and detail < LOW_DETAIL:
        print(
            f"advisory: only {100*detail:.3f}% of the frame is stroke detail (the sparsest "
            f"delivered frame reads 0.143%). The ink here is slab-like rather than type-like, "
            f"which is what frame 0 looks like when it catches the title MID-ANIMATION — "
            f"shapes already slid in, type not yet. Open it and confirm the headline is on it."
        )

    if reasons:
        print()
        for r in reasons:
            print(f"ERROR: {r}")
        sys.exit(f"\nframe 0 is blank — {os.path.basename(frame)}\n\n{FIX}")
    print(f"frame 0 OK: composed ({cells} tiles, peak {100*peak:.1f}%, ink {100*ink:.2f}%)")


main()
