/* global React */

// v07zz499 — Pick the shot's DEFAULT hero / active version. ONLY published, non-archived
// frames may become the hero (Hugo: "I generated and didn't push, but the latest gen appears
// as the hero" — un-pushed candidates AND old archived frames must never drive it; the FRAMES
// "Pushed" strip is the source of truth). A stale `active_frame_version` pointer aimed at an
// archived/un-published version is IGNORED. Falls back to the newest candidate ONLY when NOTHING
// is pushed yet, so a brand-new shot mid-generation still previews something instead of blank.
function _pickDefaultActiveVersion(versions, ptr) {
  const stripF = (lab) => String(lab || "").replace(/_f\d+$/i, "");
  const byNum = (a, b) => String(a).localeCompare(String(b), undefined, { numeric: true });
  const list = Array.isArray(versions) ? versions : [];
  const live = list.filter(v => v && v.published !== 0 && !v.archived);
  if (ptr) {
    const p = stripF(ptr);
    if (live.some(v => stripF(v.version_label) === p)) return ptr;   // honor pointer only when it resolves to a LIVE version
  }
  const heroRow = live.find(v => v.kind === "hero");
  if (heroRow) return stripF(heroRow.version_label);
  const pubFrames = [...new Set(live.filter(v => v.kind === "frame").map(v => stripF(v.version_label)).filter(Boolean))].sort(byNum);
  if (pubFrames.length) return pubFrames[pubFrames.length - 1];   // newest PUSHED frame
  // Nothing pushed yet — preview the newest non-archived candidate so the modal isn't blank.
  const anyFrames = [...new Set(list.filter(v => v && (v.kind === "frame" || v.kind === "hero") && !v.archived).map(v => stripF(v.version_label)).filter(Boolean))].sort(byNum);
  if (anyFrames.length) return anyFrames[anyFrames.length - 1];
  return "v003";
}

// v07zz533 — Hugo: a shot WITH a video must OPEN on (and autoplay) the latest video,
// regardless of stage. This is the SINGLE source for the default active version — used
// for the initial state AND the post-fetch pick — so an async frame default can never
// clobber the video the modal is meant to open on (the previous bug: the fetch resolved
// after the once-per-shot video effect and snapped the hero back to a frame). Prefer the
// newest non-archived video; otherwise fall back to the pushed-frame picker above.
function _pickDefaultActiveVersionVideoFirst(versions, ptr) {
  const list = Array.isArray(versions) ? versions : [];
  const vids = list
    .filter(v => v && _isVideoFile(v) && (v.kind === "video" || v.kind === "upscale") && !v.archived && v.published !== 0)
    .map(v => v.version_label)
    .sort((a, b) => String(a).localeCompare(String(b), undefined, { numeric: true }));
  if (vids.length) return "video-" + vids[vids.length - 1];
  return _pickDefaultActiveVersion(versions, ptr);
}

// v07perf — Shared row picker used by BOTH matchingFrame (big hero
// image) and repFor (strip thumbnail). Defining it once at module
// scope eliminates any possibility of the two views diverging on
// which slice to render — they always pick the same row given the
// same baseVer.
//
// v07zz43 — Upscales are no longer auto-upgraded into the parent
// version. Hugo wants v001 (frame) and v001_4k (upscale) to be two
// distinct, separately-clickable filmstrip tiles.
//
// v07zz46/47 — Image-only picker. Tiles in the FRAMES strip render
// via <img>, so they must resolve to a frame / hero / upscale row
// — never a grid (raw 2x2 composite) or video (MP4 can't render in
// an <img>). The v001 video row has version_label="v001" exactly,
// so the previous "exclude grid only" filter still matched it and
// the v001 tile broke completely (gradient placeholder showing
// because the <img> failed to load the .mp4). New rule: step 1
// matches ONLY image-renderable kinds.
const _IMG_KINDS = new Set(["frame", "hero", "upscale"]);
//
// Priority for a given baseVer:
//   1. Exact label match — frame/hero/upscale only (covers
//      upscale tiles like "v001_4k" + single-image gens with bare
//      labels). Grid and video rows with the same label are skipped.
//   2. F1 row with kind="hero" (canonical default)
//   3. ANY F#1-4 row with kind="hero" (user picked a slice via grid)
//   4. F1 row with kind="frame" or "hero" (default first slice)
//   5. Bare-label row with kind="hero" (single-image gens, no slices)
//   6. Null (caller falls back to its own default)
function pickPreferredFrameRow(versions, baseVer) {
  if (!baseVer || !Array.isArray(versions)) return null;
  return versions.find(v =>
      v.version_label === baseVer && _IMG_KINDS.has(v.kind))
    || versions.find(v =>
      v.kind === "hero" && v.version_label === baseVer + "_f1")
    || versions.find(v =>
      v.kind === "hero"
      && /^v\d+_f[1-4]$/i.test(String(v.version_label || ""))
      && String(v.version_label).split("_")[0] === baseVer)
    || versions.find(v =>
      v.version_label === baseVer + "_f1"
      && (v.kind === "frame" || v.kind === "hero"))
    || versions.find(v =>
      v.kind === "hero" && v.version_label === baseVer)
    || null;
}
if (typeof window !== "undefined") window.pickPreferredFrameRow = pickPreferredFrameRow;

// v06m — useDragScroll v4. The previous useRef + useEffect version
// captured `ref.current` once when the modal first mounted. But the
// shot panel's filmstrips are rendered INSIDE an IIFE that returns
// different JSX depending on whether realVersions has loaded — so on
// first mount the scroller often hadn't rendered yet, the effect's
// `el = ref.current` was null, the listener never attached, and the
// user got a static strip for the rest of the modal's lifetime.
//
// Switched to a CALLBACK ref. React calls our function with the
// element when it mounts AND with null when it unmounts. So even
// when the scroller appears later (data loads, conditional render
// flips, etc.) the listeners attach the moment the DOM node exists.
// Returns the callback directly — assign it to `ref={…}` like any
// regular ref.
function useDragScroll(externalRef) {
  // Accepts an optional external ref object so callers (arrow buttons,
  // etc.) can also reach the live element. The hook's primary output
  // is a callback ref — attach to `ref={…}`.
  //
  // v06m also toggles `.has-overflow` on the strip when content
  // exceeds the visible width (and updates on resize + when content
  // mutates). The arrow CSS uses that class to hide itself when the
  // strip fits — no more dead arrows on short rows.
  const cleanupRef = React.useRef(null);
  return React.useCallback((el) => {
    if (cleanupRef.current) { cleanupRef.current(); cleanupRef.current = null; }
    if (externalRef) externalRef.current = el;
    if (!el) return;

    const updateOverflow = () => {
      const overflows = el.scrollWidth > el.clientWidth + 1;
      el.classList.toggle("has-overflow", overflows);
    };
    updateOverflow();
    const ro = (typeof ResizeObserver !== "undefined") ? new ResizeObserver(updateOverflow) : null;
    if (ro) ro.observe(el);
    const mo = new MutationObserver(updateOverflow);
    mo.observe(el, { childList: true, subtree: true });
    window.addEventListener("resize", updateOverflow);

    let active = false;
    let moved = false;
    let startX = 0;
    let startScroll = 0;
    const THRESHOLD = 5;

    const onMove = (e) => {
      if (!active) return;
      const delta = e.clientX - startX;
      if (!moved && Math.abs(delta) > THRESHOLD) {
        moved = true;
        el.classList.add("is-dragging");
      }
      if (moved) {
        el.scrollLeft = startScroll - delta;
        e.preventDefault();
      }
    };
    const onUp = () => {
      if (!active) return;
      active = false;
      el.classList.remove("is-dragging");
      document.removeEventListener("mousemove", onMove);
      document.removeEventListener("mouseup", onUp);
      setTimeout(() => { moved = false; }, 0);
    };
    const onDown = (e) => {
      if (e.button !== 0) return;
      // v07e — Hugo's "grid appears on hold" bug. The drag-scroll's
      // mousedown listener is attached via addEventListener (native),
      // so any React stopPropagation on a child component CAN'T stop
      // it. Bail out here when the click started on a hero star
      // button — let its own onClick handle it without engaging the
      // drag-scroll machinery.
      if (e.target && typeof e.target.closest === "function") {
        if (e.target.closest(".vt-hero-btn")) return;
        if (e.target.closest(".vt-current-btn")) return;
        if (e.target.closest(".vt-grid-btn")) return;
        if (e.target.closest(".vt-discard-btn")) return;
      }
      active = true;
      moved = false;
      startX = e.clientX;
      startScroll = el.scrollLeft;
      document.addEventListener("mousemove", onMove);
      document.addEventListener("mouseup", onUp);
    };
    const onClickCapture = (e) => {
      if (moved) {
        e.preventDefault();
        e.stopPropagation();
      }
    };
    // v07zz304 — Hugo: the scroll wheel should run through the filmstrip's bottom
    // row. Translate a predominantly-vertical wheel into horizontal scroll when the
    // strip overflows; native horizontal wheel / trackpad gestures pass through.
    const onWheel = (e) => {
      if (el.scrollWidth <= el.clientWidth + 1) return;     // nothing to scroll
      if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
        el.scrollLeft += (e.deltaY + e.deltaX);
        e.preventDefault();
      }
    };

    el.addEventListener("mousedown", onDown);
    el.addEventListener("click", onClickCapture, true);
    el.addEventListener("wheel", onWheel, { passive: false });

    cleanupRef.current = () => {
      el.removeEventListener("mousedown", onDown);
      el.removeEventListener("click", onClickCapture, true);
      el.removeEventListener("wheel", onWheel);
      document.removeEventListener("mousemove", onMove);
      document.removeEventListener("mouseup", onUp);
      if (ro) ro.disconnect();
      mo.disconnect();
      window.removeEventListener("resize", updateOverflow);
    };
  }, []);
}
if (typeof window !== "undefined") window.useDragScroll = useDragScroll;

// v07zz287 — touch-swipe between detail modals on mobile. Reuses the SAME
// prev/next handlers the (mobile-hidden) arrow buttons + keyboard already call.
// Returns a callback ref to attach to the modal card. Ignores swipes that begin
// on inner horizontal scrollers (version strips, galleries, the stepper) or in
// text fields so they don't fight modal navigation. Both cards already carry
// touch-action:pan-y, so we never preventDefault (vertical scroll stays native).
function useSwipeNav({ onPrev, onNext, enabled = true } = {}) {
  const cleanupRef = React.useRef(null);
  return React.useCallback((el) => {
    if (cleanupRef.current) { cleanupRef.current(); cleanupRef.current = null; }
    if (!el || !enabled) return;
    const IGNORE = '.version-scroller, .version-section, .char-gallery-strip, .char-gallery-wrap, .pipeline-stepper, .pdv-slides-scroll, input, textarea, select, [contenteditable="true"]';
    let s = null;
    const onStart = (e) => {
      if (!e.touches || e.touches.length !== 1) { s = null; return; }
      const t = e.touches[0];
      const bad = e.target && e.target.closest && e.target.closest(IGNORE);
      s = bad ? null : { x: t.clientX, y: t.clientY, t: Date.now() };
    };
    const onEnd = (e) => {
      const start = s; s = null;
      if (!start) return;
      const ct = (e.changedTouches && e.changedTouches[0]) || null;
      if (!ct) return;
      const dx = ct.clientX - start.x, dy = ct.clientY - start.y, dt = Date.now() - start.t;
      if (dt > 600 || Math.abs(dx) < 50 || Math.abs(dx) < Math.abs(dy) * 1.5) return;
      if (dx < 0) { if (onNext) onNext(); } else { if (onPrev) onPrev(); }
    };
    el.addEventListener("touchstart", onStart, { passive: true });
    el.addEventListener("touchend", onEnd, { passive: true });
    cleanupRef.current = () => { el.removeEventListener("touchstart", onStart); el.removeEventListener("touchend", onEnd); };
  }, [onPrev, onNext, enabled]);
}
if (typeof window !== "undefined") window.useSwipeNav = useSwipeNav;

const MIcon = {
  check: <svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round"><path d="m4 12 5 5 11-12"/></svg>,
  copy: <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V5a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3"/></svg>,
  arrow: <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M13 6l6 6-6 6"/></svg>,
};

// v03a follow-up — workflow stepper labels match the new pipeline
// terminology (Frame WIP / Frame Hero / Video WIP / Video Hero / 4K Upscale).
const STAGES = [
  { key: "prompt",       label: "Prompt" },
  { key: "first_pass",   label: "First Pass" },
  { key: "refinement",   label: "Frame WIP" },
  { key: "hero",         label: "Frame Hero" },
  { key: "video_prompt", label: "Video WIP" },
  { key: "video",        label: "Video Hero" },
  { key: "upscale",      label: "4K Upscale" },
];

// v06p — Hugo: stepper circles are now clickable. Each circle dispatches
// updateShotStatus to set the shot's stage to (or revert back to) that
// stage. Maps stepper stage keys → status keys used by App.updateShotStatus.
const STAGE_KEY_TO_STATUS = {
  prompt: "PROMPT",
  first_pass: "FIRST-PASS",
  refinement: "CONCEPT-WIP",
  hero: "CONCEPT-APPROVED",
  video_prompt: "VIDEO-WIP",
  video: "VIDEO-APPROVED",
  upscale: "UPSCALED",
};

function PipelineStepper({ shot }) {
  const st = shot.stage_status || {};
  let foundCurrent = false;
  // v06p — Stage stepper is read-only for users without
  // `change_shot_status` permission. Buttons render as plain spans
  // (no click handler, no clickable cursor) so reviewers still see
  // where the shot is in the pipeline but can't move it.
  const canChange = !window.hasPerm || window.hasPerm("change_shot_status");
  const change = (stageKey) => {
    if (!canChange) return;
    const statusKey = STAGE_KEY_TO_STATUS[stageKey];
    if (!statusKey) return;
    // v07zz56 — Click the CURRENT stage circle to step BACK one stage.
    // Hugo: "we should be able to unclick the Prompt circle and status
    // goes to Pending (0%)". Prompt is the first real stage; clicking
    // it when it's already current drops to PENDING. Same logic for
    // First Pass → Prompt, Frame WIP → First Pass, etc.
    const st = shot && shot.stage_status || {};
    const isCurrent = st[stageKey] === "done"
      && STAGES.every((s, i) => {
        const j = STAGES.findIndex(x => x.key === stageKey);
        return i <= j ? st[s.key] === "done" : st[s.key] !== "done";
      });
    let target = statusKey;
    if (isCurrent) {
      const STEP_BACK = {
        "PROMPT":           "PENDING",
        "FIRST-PASS":       "PROMPT",
        "CONCEPT-WIP":      "FIRST-PASS",
        "CONCEPT-APPROVED": "CONCEPT-WIP",
        "VIDEO-WIP":        "CONCEPT-APPROVED",
        "VIDEO-APPROVED":   "VIDEO-WIP",
        "UPSCALED":         "VIDEO-APPROVED",
      };
      target = STEP_BACK[statusKey] || "PENDING";
    }
    if (typeof window.__updateShotStatus === "function") {
      window.__updateShotStatus(shot.id, target);
    }
  };
  return (
    <div className={"pipeline-stepper" + (canChange ? "" : " is-readonly")}>
      {STAGES.map((s, i) => {
        let state;
        if (st[s.key] === "done") state = "done";
        else if (!foundCurrent) { state = "current"; foundCurrent = true; }
        else state = "future";
        return (
          <React.Fragment key={s.key}>
            <button
              type="button"
              className={"stepper-step " + (canChange ? "is-clickable " : "is-readonly ") + state}
              onClick={canChange ? () => change(s.key) : undefined}
              disabled={!canChange}
              title={canChange ? `Set stage to ${s.label}` : `${s.label} — read-only`}
              aria-label={canChange ? `Set stage to ${s.label}` : s.label}
            >
              <div className="step-circle">{state === "done" ? MIcon.check : null}</div>
              <div className="step-label">{s.label}</div>
            </button>
            {i < STAGES.length - 1 && <div className={"stepper-line " + (state === "done" ? "done" : "")}/>}
          </React.Fragment>
        );
      })}
    </div>
  );
}

// v07zw — File-extension classifier. kind='upscale' can be either an
// image (SH0150_frame_v###_4k.png) or a video (SH0150_video_v###_4k.mp4)
// upscale. The old filmstrip logic treated ALL upscales as videos —
// which dumped image upscales into the VIDEO row. Tell them apart by
// extension so the image upscale lands in the FRAMES row where it
// belongs.
// v07zz594 — ask the server to convert a frame/video version into a DEPTH MAP
// (Depth Anything V2 Small, runs locally in pipeline/.depthvenv). The result lands
// in the shot's WIP funnel as the next version; the queue drawer shows progress.
function _requestDepthMap(assetVersionId) {
  const f = window.authFetch || fetch;
  return f(`/api/depth/${assetVersionId}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" })
    .then(r => r.json().then(j => { if (!r.ok) console.warn("[depth]", j.error || r.status); return j; }))
    .then(j => { try { window.__refreshQueue && window.__refreshQueue(); } catch (_) {} return j; })
    .catch(e => console.warn("[depth]", e.message));
}
window.__requestDepthMap = _requestDepthMap;   // v07zz595 — GeneratePage drawers reuse it
// v777 — extract a video version's FIRST FRAME as a new WIP frame (Hugo: "auto
// extract first frame of a video tool… that i can click on the video hero").
// Sub-second ffmpeg on the server; the file lands in frames/_wip and the watcher
// ingests it, so we nudge the modal's filmstrip refetch ~1.4s later.
function _requestFirstFrame(assetVersionId, shotId) {
  const f = window.authFetch || fetch;
  return f(`/api/firstframe/${assetVersionId}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" })
    .then(r => r.json().then(j => { if (!r.ok) console.warn("[firstframe]", j.error || r.status); return j; }))
    .then(j => {
      if (j && j.ok && shotId) {
        setTimeout(() => { try { window.dispatchEvent(new CustomEvent("paradise-shot-frames-changed", { detail: { shot_id: shotId } })); } catch (_) {} }, 1400);
      }
      return j;
    })
    .catch(e => console.warn("[firstframe]", e.message));
}
window.__requestFirstFrame = _requestFirstFrame;
// v976 — same, for the video's LAST frame (Hugo: "another button to save the LAST
// frame of a video as a new frame in the shot, next to the save FIRST frame").
function _requestLastFrame(assetVersionId, shotId) {
  const f = window.authFetch || fetch;
  return f(`/api/lastframe/${assetVersionId}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" })
    .then(r => r.json().then(j => { if (!r.ok) console.warn("[lastframe]", j.error || r.status); return j; }))
    .then(j => {
      if (j && j.ok && shotId) {
        setTimeout(() => { try { window.dispatchEvent(new CustomEvent("paradise-shot-frames-changed", { detail: { shot_id: shotId } })); } catch (_) {} }, 1400);
      }
      return j;
    })
    .catch(e => console.warn("[lastframe]", e.message));
}
window.__requestLastFrame = _requestLastFrame;
// v07zz597 — module-level cache for /api/asset-versions/:id/dims results so
// flipping between versions (or reopening the modal) never refetches.
const _dimsCache = new Map();
function _isVideoFile(v) {
  if (!v) return false;
  if (v.kind === "video") return true;
  const fp = String(v.file_path || v.cloud_url || "").toLowerCase().split("?")[0];
  return /\.(mp4|mov|webm|avi|m4v)$/i.test(fp);
}
// v774 — depth-map outputs are ordinary video/frame rows on disk, but their
// provenance lives in prompt_text: POST /api/depth writes a "Depth map of …"
// sidecar that the watcher ingests verbatim. The VIDEO strip's DEPTH tab keys
// off this so gray depth clips stop cluttering Hero/WIP (Hugo's ask).
function _isDepthRow(v) {
  return !!v && /^depth map of /i.test(String(v.prompt_text || ""));
}

// v07zz289 — "Add from library" picker. Browse archived shot frames + the Green Echo
// trailer right inside the shot modal and add a chosen image as a NEW frame version
// on the shot (non-destructive copy via POST /api/shots/:id/add-version). Dispatches
// paradise-shot-frames-changed so the modal's filmstrip refetches.
// v695 — `assetTarget` retargets this whole picker at an ASSET instead of a shot
// (Hugo: "add images from anywhere (shots, props) in any asset modal … an image I made
// in the Santa Maria asset put into the location asset"). Shape: {cat, slug, name}.
// Everything above — tabs, search, locations drill-down, multi-select — is identical;
// only the commit target changes, which is why this is a prop and not a second picker.
function AddFromLibraryPicker({ shotId, onClose, mode = "frame", assetTarget = null }) {
  const VIDEO_MODE = mode === "video";   // v07zz321 — video library variant
  const ASSET_MODE = !!(assetTarget && assetTarget.cat && assetTarget.slug);
  const _noun = ASSET_MODE ? "image" : (VIDEO_MODE ? "video" : "frame");
  const _target = ASSET_MODE ? (assetTarget.name || assetTarget.slug) : shotId;
  const [tab, setTab] = React.useState(VIDEO_MODE ? "videos" : "archived");
  // 24 Sep 2026 (G3) — Paradise Found (the default project) keeps its Green Echo + storyboard folders
  const _pfLib = !window.__isDefaultProject || window.__isDefaultProject();
  const [archived, setArchived] = React.useState(null);   // null = not loaded yet
  const [favorites, setFavorites] = React.useState(null); // v07zz360 — Favourites tab (★-bookmarked frames)
  const [trailer, setTrailer] = React.useState(null);
  const [storyboard, setStoryboard] = React.useState(null);   // v07zz320 — Storyboard tab
  const [locations, setLocations] = React.useState(null);     // v07zz574 — Locations tab: [{slug,name,cover,count,images:[...]}]
  const [locOpen, setLocOpen] = React.useState(null);         // v07zz575 — drilled-into location slug (null = the location cards grid)
  // v07zz575 — shared hover-to-enlarge floating preview (the SAME box the Generate refs
  // picker + cross-shot chooser use), for the images inside an opened location.
  // 17 Sep 2026 — eager global (src/useHoverPreview.jsx): always called, so the hook order never changes.
  const _hoverApi = useHoverPreview();
  const hoverBind = _hoverApi.hoverBind;
  const [videos, setVideos] = React.useState(null);       // v07zz321 — video library
  const [geVideos, setGeVideos] = React.useState(null);   // v07zz323 — Green Echo trailer videos
  const [busy, setBusy] = React.useState(false);
  const [q, setQ] = React.useState("");
  // v07zz355 — multi-select: a Map of key → add-body for every tile the user has clicked.
  const [selected, setSelected] = React.useState(() => new Map());
  // v07zz319 — paginate the grid instead of a hard 300 cap that hid the rest. "Show more"
  // reveals the next page; reset to the first page whenever the tab or search changes.
  const [renderLimit, setRenderLimit] = React.useState(300);
  // v760 — Hugo: "need to be able to sort between Hero/Wip there too".
  // v761 — the buckets are the SAME three the shot modal's FRAMES strip uses, because they
  // mirror the folders: published/pushed (shot-folder root) = HERO, un-pushed candidate
  // (_wip) = WIP, discarded (_archived) = ARCHIVED. v760 briefly read "hero" as the single
  // active_frame_version, which showed 1 frame for SH0190 where its modal showed 3.
  // v924 — Hugo: "it should default on Hero images and have a tab for good as well".
  const [bucket, setBucket] = React.useState("hero");
  const BUCKETED_TABS = ["archived", "favorites", "videos"];
  const showBuckets = BUCKETED_TABS.includes(tab);
  const _bk = (it) => it.bucket || (it.archived ? "archived" : (it.published ? "hero" : "wip"));
  const passBucket = (it) => bucket === "all" || _bk(it) === bucket;
  React.useEffect(() => { setRenderLimit(300); }, [tab, q, bucket]);
  React.useEffect(() => { setLocOpen(null); }, [tab]);   // v07zz575 — leaving/re-entering a tab resets the location drill-down
  React.useEffect(() => {
    const f = window.authFetch || fetch;
    if (tab === "archived" && archived === null) {
      // v07zz313 — ?all=1 returns EVERY frame in Media (live + archived) so search covers them all.
      f("/api/asset-versions/archived?all=1").then(r => r.ok ? r.json() : null).then(d => setArchived((d && d.items) || [])).catch(() => setArchived([]));
    }
    if (tab === "favorites" && favorites === null) {
      // v07zz360 — only the ★-favourited frames, same portable-URL shape as the All-frames tab.
      f("/api/asset-versions/archived?favorites=1").then(r => r.ok ? r.json() : null).then(d => setFavorites((d && d.items) || [])).catch(() => setFavorites([]));
    }
    if (tab === "greenecho" && trailer === null) {
      f("/api/media/trailer").then(r => r.ok ? r.json() : null).then(d => {
        const items = [];
        for (const fo of ((d && d.folders) || [])) for (const im of (fo.images || [])) items.push({ url: im.url, label: `${fo.name} · ${im.filename}` });
        setTrailer(items);
      }).catch(() => setTrailer([]));
    }
    if (tab === "storyboard" && storyboard === null && !_pfLib) {
      // 24 Sep 2026 (G3) — another project's Storyboard tab = its OWN storyboard sheets (the
      // kind='storyboard' rows in its shots' storyboards/ folders), not Paradise Found's folder.
      f("/api/asset-versions/archived?all=1").then(r => r.ok ? r.json() : null)
        .then(d => setStoryboard(((d && d.items) || []).filter(it => it.kind === "storyboard" && !it.archived).map(it => ({
          id: it.id, url: it.url, file_path: it.file_path, cloud_url: it.cloud_url, asset_id: it.asset_id,
          label: `${String(it.asset_id || "").replace("__", " · ")} · ${it.version_label}`,
        }))))
        .catch(() => setStoryboard([]));
    } else if (tab === "storyboard" && storyboard === null) {
      f("/api/media/storyboard").then(r => r.ok ? r.json() : null)
        .then(d => setStoryboard(((d && d.images) || []).map(im => ({ url: im.url, label: im.filename }))))
        .catch(() => setStoryboard([]));
    }
    // v07zz574/575 — Locations tab: GROUP by location. Level 1 = one card per location
    // (cover + name + image count); click a card → level 2 = that location's promoted
    // reference images (the same renders on the Assets page), each hover-to-enlarge and
    // selectable. Adding copies onto the shot via add-version; the library stays untouched.
    if (tab === "locations" && locations === null) {
      f("/api/assets").then(r => r.ok ? r.json() : null).then(d => {
        const groups = [];
        for (const loc of ((d && d.locations) || [])) {
          const nm = loc.name || loc.title || loc.id || loc.slug || "Location";
          const slug = loc.id || loc.slug || nm;
          const imgs = (loc.references || [])
            .filter(ref => ref && ref.url)
            .map(ref => ({ url: ref.url, cloud_url: ref.cloud_url || null, slug, label: (ref.filename || nm) }));
          if (!imgs.length) continue;
          groups.push({ slug, name: nm, cover: loc.cover_url || loc.image || imgs[0].url, count: imgs.length, images: imgs });
        }
        setLocations(groups);
      }).catch(() => setLocations([]));
    }
    if (tab === "videos" && videos === null) {
      f("/api/asset-versions/archived?videos=1").then(r => r.ok ? r.json() : null)
        .then(d => setVideos((d && d.items) || []))
        .catch(() => setVideos([]));
    }
    // v07zz323 — Green Echo trailer videos (live off W:\…\GreenEcho\trailer_01\videos, outside WATCH).
    if (tab === "gevideos" && geVideos === null) {
      f("/api/media/trailer-videos").then(r => r.ok ? r.json() : null)
        .then(d => setGeVideos((d && d.videos) || []))
        .catch(() => setGeVideos([]));
    }
  }, [tab]); // eslint-disable-line
  // v07zz355 — clicking a tile toggles it into `selected`; the footer "Add N" button copies them
  // ALL onto the shot in one go. POSTs run SEQUENTIALLY so add-version assigns distinct vNNN per
  // copy (parallel would race for the same next-version number).
  const keyOf = (it) => String((it && (it.id || it.file_path || it.cloud_url || it.url || it.name)) || "");
  const bodyOf = (it) => {
    if (VIDEO_MODE) {
      return tab === "gevideos"
        ? { file_path: it.file_path, source: "greenecho-video" }
        : { file_path: it.file_path, cloud_url: it.cloud_url, url: it.url, source: "library-video" };
    }
    if (tab === "archived" || tab === "favorites")
      return { file_path: it.file_path, cloud_url: it.cloud_url, source: (tab === "favorites" ? "favorite:" : "archived:") + it.asset_id };
    // v07zz574 — Locations: send ONLY the image url — locally a "/local/..." url (strip the
    // "?v=" cache-buster so the server resolves it to a real file to COPY), on Railway an
    // http cloud_url. Don't also send cloud_url: the endpoint prefers it and would skip the
    // fast local-file copy. add-version's url branch handles both /local and http.
    if (tab === "locations")
      return { url: (it.url && it.url.startsWith("/local/")) ? it.url.split("?")[0] : it.url, source: "location:" + (it.slug || "") };
    // 24 Sep 2026 (G3) — another project's storyboard sheet is one of its own rows: send its file
    if (tab === "storyboard" && !_pfLib)
      return { file_path: it.file_path, cloud_url: it.cloud_url, source: "storyboard:" + (it.asset_id || "") };
    return { url: it.url, source: tab === "storyboard" ? "storyboard" : "greenecho" };
  };
  // 24 Sep 2026 (G3) — the server's refusal (shot in no album, file outside the project, cloud-only
  // source…) shown in the footer instead of the picker silently closing.
  const [addErr, setAddErr] = React.useState("");
  const toggleSel = (k, body) => {
    if (!k || busy) return;
    setAddErr("");
    setSelected(prev => { const n = new Map(prev); n.has(k) ? n.delete(k) : n.set(k, body); return n; });
  };
  const addSelected = async () => {
    if (busy || selected.size === 0) return;
    setBusy(true);
    setAddErr("");
    const _failed = new Map(), _errs = [];
    const _refused = async (r, k, body) => {
      if (!r || r.ok) return;
      let msg = "";
      try { const j = await r.json(); msg = (j && j.error) || ""; } catch (_) {}
      _failed.set(k, body);
      _errs.push(msg || ("The server refused it (" + r.status + ")."));
    };
    for (const [k, body] of selected.entries()) {
      try {
        if (ASSET_MODE) {
          // v695 — /api/assets/:cat/:slug/add-image takes ONE `src` and resolves it
          // itself (/local URL, absolute W:\ path, grid-slice URL or cloud URL), so
          // collapse whichever shape bodyOf() produced for this tab. url first: it's
          // the /local path, which the server can copy straight off disk.
          const src = body.url || body.file_path || body.cloud_url;
          if (!src) continue;
          await _refused(await (window.authFetch || fetch)(
            `/api/assets/${encodeURIComponent(assetTarget.cat)}/${encodeURIComponent(assetTarget.slug)}/add-image`,
            { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ src }) }), k, body);
        } else {
          await _refused(await (window.authFetch || fetch)(`/api/shots/${encodeURIComponent(shotId)}/add-version`, {
            method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
          }), k, body);
        }
      } catch (e) { _failed.set(k, body); _errs.push((e && e.message) || "Network error."); }
    }
    try {
      window.dispatchEvent(ASSET_MODE
        // The Assets page + asset modals already refresh on this one (it's what
        // promote/delete-reference fire), so a copied-in image shows up immediately.
        ? new CustomEvent("paradise-asset-refs-changed", { detail: { category: assetTarget.cat, slug: assetTarget.slug } })
        : new CustomEvent("paradise-shot-frames-changed", { detail: { shot_id: shotId } }));
    } catch (_) {}
    if (window.reloadAppData) window.reloadAppData();
    if (_failed.size) {
      // Keep the picker open with only the refused items still selected, so nothing is added twice.
      const uniq = [...new Set(_errs)];
      setSelected(_failed);
      setAddErr(`Couldn't add ${_failed.size} of ${selected.size}: ${uniq.join(" · ")}`);
      setBusy(false);
      return;
    }
    setBusy(false); onClose && onClose();
  };
  const ql = q.trim().toLowerCase();
  // v975 — the library rows only carry asset_id + version_label, so typing a word
  // like "Twain" used to return nothing (Hugo: "i need to be able to search for
  // other words like Twain and have all the twain shots come up"). Run the query
  // through the SAME scored engine the shots page uses, over window.__appData.shots,
  // and treat every frame belonging to a matching shot as a hit.
  const shotHits = React.useMemo(() => {
    if (!ql || typeof window.applyShotSearch !== "function") return null;
    const shots = (window.__appData && window.__appData.shots) || [];
    if (!shots.length) return null;
    // Same derivation ShotsPanel uses, so sequence names are searchable here too
    // AND the engine's per-shot field cache stays warm instead of thrashing.
    const seqs = (window.__appData && window.__appData.sequences) || [];
    const seqOf = (s) => { const g = seqs.find(x => x.number === s.seq); return g ? (g.slug || g.name || "") : ""; };
    const hits = window.applyShotSearch(shots, q, seqOf) || [];
    // Every shot matching means "no useful narrowing" — fall back to id/label only.
    if (!hits.length || hits.length === shots.length) return null;
    const set = new Set();
    for (let i = 0; i < hits.length; i++) set.add(String(hits[i].id).toLowerCase());
    return set;
  }, [ql, q]);
  // A frame/video row matches when its own id or version says so, OR its shot does.
  const rowHit = (it) => !ql
    || (it.asset_id || "").toLowerCase().includes(ql)
    || (it.version_label || "").toLowerCase().includes(ql)
    || (shotHits ? shotHits.has(String(it.asset_id || "").toLowerCase()) : false);
  const loading = tab === "videos" ? videos === null : tab === "gevideos" ? geVideos === null : tab === "archived" ? archived === null : tab === "favorites" ? favorites === null : tab === "locations" ? locations === null : tab === "greenecho" ? trailer === null : storyboard === null;
  const allItems = tab === "videos"
    ? (videos || []).filter(passBucket).filter(rowHit)
    : tab === "gevideos"
    ? (geVideos || []).filter(it => !ql || (it.name || "").toLowerCase().includes(ql))
    : tab === "archived"
    // v07zz320 — grids never belong in the Add-a-Frame picker (Hugo). Only frames/heroes/upscales.
    ? (archived || []).filter(it => it.kind !== "grid").filter(passBucket).filter(rowHit)
    : tab === "favorites"
    ? (favorites || []).filter(it => it.kind !== "grid").filter(passBucket).filter(rowHit)
    : tab === "locations"
    ? (locOpen
        // v07zz575 — level 2: the opened location's images (hover-preview + selectable).
        ? (((locations || []).find(g => g.slug === locOpen) || {}).images || []).filter(it => !ql || (it.label || "").toLowerCase().includes(ql))
        // v07zz575 — level 1: one CARD per location.
        : (locations || []).filter(g => !ql || (g.name || "").toLowerCase().includes(ql)).map(g => ({ _card: true, slug: g.slug, name: g.name, url: g.cover, count: g.count, label: g.name })))
    : (tab === "greenecho" ? (trailer || []) : (storyboard || [])).filter(it => !ql || (it.label || "").toLowerCase().includes(ql));
  // v07zz319 — render up to renderLimit; the "Show more" button pages through the rest
  // (was a hard 300 cap that just told you to search — Hugo couldn't reach the older frames).
  const items = allItems.slice(0, renderLimit);
  const moreCount = allItems.length - items.length;
  const tU = (u, w) => (window.thumbUrl ? window.thumbUrl(u, w) : u);
  return ReactDOM.createPortal(
    <div className="addlib-overlay" onMouseDown={() => onClose && onClose()}>
      <div className="addlib-modal" onMouseDown={e => e.stopPropagation()}>
        <div className="addlib-head">
          <div className="addlib-title">{ASSET_MODE
            ? <>Add an image to <b>{assetTarget.name || assetTarget.slug}</b> from the library</>
            : <>Add a {VIDEO_MODE ? "video" : "frame"} to <b>{shotId}</b> from the library</>}</div>
          <button className="addlib-x" onClick={() => onClose && onClose()} aria-label="Close">
            <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
          </button>
        </div>
        <div className="addlib-tabs">
          {VIDEO_MODE ? (
            <>
              <button className={tab === "videos" ? "is-on" : ""} onClick={() => setTab("videos")}>All videos</button>
              {/* 15 Sep 2026 — the Green Echo trailer + storyboard folders are Paradise Found's; only the default project offers them */}
              {(!window.__isDefaultProject || window.__isDefaultProject()) && <button className={tab === "gevideos" ? "is-on" : ""} onClick={() => setTab("gevideos")}>Green Echo</button>}
            </>
          ) : (
            <>
              <button className={tab === "archived" ? "is-on" : ""} onClick={() => setTab("archived")}>All frames</button>
              <button className={tab === "favorites" ? "is-on" : ""} onClick={() => setTab("favorites")}>★ Favourites</button>
              <button className={tab === "locations" ? "is-on" : ""} onClick={() => setTab("locations")}>Locations</button>
              {/* 24 Sep 2026 (G3) — every project has a Storyboard tab (its own sheets); Green Echo stays Paradise Found's */}
              <button className={tab === "storyboard" ? "is-on" : ""} onClick={() => setTab("storyboard")}>Storyboard</button>
              {(!window.__isDefaultProject || window.__isDefaultProject()) && <button className={tab === "greenecho" ? "is-on" : ""} onClick={() => setTab("greenecho")}>Green Echo trailer</button>}
            </>
          )}
          {/* v760 — Hero | WIP funnel filter. Reuses the EXACT pill classes the Generate
              refs funnel uses, and sits INLINE in this row (v07zz563's trick) rather than
              adding a second row — so switching tabs can never move the grid down. */}
          {showBuckets && (
            <div className="gen-refs-shotfilter gen-refs-shotfilter--inline" role="group" aria-label="Filter by version state">
              {/* v925 — Depth + Storyboard are KIND buckets (own asset_versions kinds;
                  depth VIDEOS spotted by their _depth path) so the picker can reach
                  them at all — the frame query excluded those kinds before. */}
              {[["all", "All"], ["hero", "★ Hero"], ["good", "Good"], ["wip", "WIP"], ["depth", "Depth"], ["storyboard", "Storyboard"]].map(([v, label]) => (
                <button key={v} type="button" className={"gen-refs-sf-btn" + (bucket === v ? " is-on" : "")}
                  title={v === "hero" ? "Only each shot's current/hero version"
                       : v === "wip" ? "Only un-pushed candidates (the _wip folder)"
                       : "Everything in the library"}
                  onClick={() => setBucket(v)}>{label}</button>
              ))}
            </div>
          )}
          {/* 15 Sep 2026 — "Twain" is Paradise Found's example; other projects get a neutral hint */}
          <input className="addlib-search" placeholder={VIDEO_MODE ? ((!window.__isDefaultProject || window.__isDefaultProject()) ? "Search videos — SH0040, v002, or words like Twain…" : "Search videos — SH0040, v002, or a word from the filename…") : (tab === "archived" || tab === "favorites") ? ((!window.__isDefaultProject || window.__isDefaultProject()) ? "Search frames — SH0040, v006, or words like Twain…" : "Search frames — SH0040, v006, or a word from the filename…") : tab === "locations" ? (locOpen ? "Search images…" : "Search locations…") : "Search by filename…"} value={q} onChange={e => setQ(e.target.value)} />
        </div>
        {/* v07zz575 — Locations drill-down breadcrumb: ← back to the location cards. */}
        {tab === "locations" && locOpen && (
          <div className="addlib-crumb">
            <button type="button" className="addlib-crumb-back" onClick={() => { setLocOpen(null); setQ(""); }}>
              <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M15 18l-6-6 6-6"/></svg>
              All locations
            </button>
            <span className="addlib-crumb-here">{(((locations || []).find(g => g.slug === locOpen)) || {}).name || "Location"}</span>
          </div>
        )}
        <div className="addlib-grid">
          {loading && <div className="addlib-empty">Loading…</div>}
          {/* v760 — an empty grid caused by the Hero/WIP filter says so, instead of the
              misleading "Nothing here yet" (there IS plenty here — just not in that bucket). */}
          {!loading && !items.length && <div className="addlib-empty">{
            showBuckets && bucket !== "all"
              ? `No ${bucket === "hero" ? "hero" : "WIP"} ${VIDEO_MODE ? "videos" : "frames"}${ql ? " match your search" : ""} — try All.`
              : ql ? `No ${tab === "locations" && !locOpen ? "locations" : VIDEO_MODE ? "videos" : "frames"} match your search.`
              : (tab === "favorites" ? "No favourites yet — in the Media tab, hover any image and click the ★ to add it here." : "Nothing here yet.")
          }</div>}
          {!loading && items.map((it, i) => {
            const cap = tab === "gevideos" ? it.name
              : (VIDEO_MODE || tab === "archived" || tab === "favorites") ? `${it.asset_id} · ${it.version_label}`
              : it.label;
            if (VIDEO_MODE) {
              // Green Echo videos live outside WATCH → pass only the absolute file_path; the
              // add-version video branch copies any existing absolute path into the shot's
              // video/ folder as a new take. Project videos pass their portable urls.
              const k = keyOf(it);
              const sel = selected.has(k);
              // v07zz356 — hover-to-play preview. "All videos" carry a servable url/cloud_url;
              // Green Echo videos stream via /api/media/trailer-video?rel= (they live outside WATCH).
              const playUrl = tab === "gevideos"
                ? (it.rel ? "/api/media/trailer-video?rel=" + encodeURIComponent(it.rel) : null)
                : (it.url || it.cloud_url || null);
              return (
                <button key={(it.id || it.file_path || it.url) + "_" + i} className={"addlib-cell addlib-cell--video" + (sel ? " is-selected" : "")} title={cap}
                  style={{ position: "relative" }}
                  onClick={() => toggleSel(k, bodyOf(it))}
                  onMouseEnter={(e) => { const v = e.currentTarget.querySelector("video"); if (v) { try { v.currentTime = 0; const p = v.play(); if (p && p.catch) p.catch(() => {}); } catch (_) {} } }}
                  onMouseLeave={(e) => { const v = e.currentTarget.querySelector("video"); if (v) { try { v.pause(); v.currentTime = 0; } catch (_) {} } }}>
                  {/* v07zz471 — GOLD selection ring on the THUMB (hugs the image shape, not the
                      whole cell) + gold checkmark. Replaces the green cell outline. */}
                  <div className="addlib-thumb addlib-thumb--video"
                    style={{
                      ...(it.poster ? { backgroundColor: "var(--shade-56)", backgroundImage: `url(${it.poster})`, backgroundSize: "cover", backgroundPosition: "center" } : {}),
                      ...(sel ? { boxShadow: "0 0 0 3px var(--gold-17), 0 0 0 4px color-mix(in srgb, var(--gold-20) 50%, transparent)" } : {}),
                    }}>
                    <span className="addlib-play" aria-hidden="true">▶</span>
                    {playUrl && (
                      <video src={playUrl} poster={it.poster || undefined} muted loop playsInline preload="none"
                        style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover", borderRadius: "inherit", opacity: 0, transition: "opacity var(--dur-1) ease", pointerEvents: "none" }}
                        onPlay={(e) => { e.currentTarget.style.opacity = 1; }}
                        onPause={(e) => { e.currentTarget.style.opacity = 0; }} />
                    )}
                  </div>
                  <span className="addlib-cap">{cap}</span>
                  {sel && <span className="addlib-check" aria-hidden="true" style={{ position: "absolute", top: 6, right: 6, width: 22, height: 22, borderRadius: "50%", background: "var(--gold-17)", color: "var(--ink-on-gold-2)", display: "grid", placeItems: "center", fontSize: "var(--fs-13)", fontWeight: "var(--fw-heavy)", boxShadow: "0 1px 4px rgba(0,0,0,0.4)" }}>✓</span>}
                </button>
              );
            }
            // v07zz575 — Locations LEVEL 1: a location CARD (cover + name + image count).
            // Clicking drills into that location's images (level 2); it never selects.
            if (it._card) {
              return (
                <button key={"loccard_" + it.slug + "_" + i} type="button" className="addlib-cell addlib-cell--loc" title={it.name + " · " + it.count + " image" + (it.count === 1 ? "" : "s")}
                  style={{ position: "relative" }} onClick={() => { setLocOpen(it.slug); setQ(""); }}>
                  <div className="addlib-thumb" style={{ backgroundImage: it.url ? `url(${tU(it.url, 320)})` : "none" }}>
                    <span className="addlib-loc-count">{it.count} image{it.count === 1 ? "" : "s"}</span>
                  </div>
                  <span className="addlib-cap">{it.name}</span>
                </button>
              );
            }
            const k = keyOf(it);
            const sel = selected.has(k);
            // v07zz575 — hover-to-enlarge on the picker images (the same floating preview
            // the Generate refs picker uses).
            // v760 — Hugo: "need hover on images here". This was gated to the Locations tab,
            // so All frames / Favourites / Storyboard / trailer stills gave you a 96px
            // thumbnail and a filename and nothing else — you couldn't tell two takes of the
            // same setup apart before adding one. Every image tile binds it now; the video
            // tiles keep their own hover (they play inline instead) and the location CARDS
            // stay un-bound because they drill in rather than being pickable images.
            const hb = hoverBind(it.url);
            return (
              <button key={(it.id || it.url) + "_" + i} className={"addlib-cell" + (sel ? " is-selected" : "")} title={cap}
                style={{ position: "relative" }}
                onClick={() => toggleSel(k, bodyOf(it))} {...hb}>
                {/* v07zz471 — GOLD selection ring on the THUMB (hugs the image shape) + gold check. */}
                <div className="addlib-thumb" style={{ backgroundImage: `url(${tU(it.url, 320)})`, ...(sel ? { boxShadow: "0 0 0 3px var(--gold-17), 0 0 0 4px color-mix(in srgb, var(--gold-20) 50%, transparent)" } : {}) }} />
                <span className="addlib-cap">{cap}</span>
                {sel && <span className="addlib-check" aria-hidden="true" style={{ position: "absolute", top: 6, right: 6, width: 22, height: 22, borderRadius: "50%", background: "var(--gold-17)", color: "var(--ink-on-gold-2)", display: "grid", placeItems: "center", fontSize: "var(--fs-13)", fontWeight: "var(--fw-heavy)", boxShadow: "0 1px 4px rgba(0,0,0,0.4)" }}>✓</span>}
              </button>
            );
          })}
          {!loading && moreCount > 0 && (
            <button type="button" className="addlib-more" disabled={busy}
              onClick={() => setRenderLimit(l => l + 300)}>
              Show {Math.min(300, moreCount)} more &nbsp;·&nbsp; {items.length} of {allItems.length} shown
            </button>
          )}
        </div>
        {/* v07zz355 — multi-select footer: click tiles to select, then add them all at once. The
            Add button is always rendered (disabled at 0 selected) so the footer never changes height. */}
        <div className="addlib-foot" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12 }}>
          <span role={addErr ? "alert" : undefined}
            style={addErr ? { color: "var(--danger)", display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical", overflow: "hidden" } : undefined}>{addErr ? addErr : selected.size > 0
            ? `${selected.size} selected — adds ${selected.size > 1 ? "copies" : "a copy"} onto ${_target}; the library stays untouched.`
            : `Click ${_noun}s to select, then add them ${ASSET_MODE ? "to" : "as new " + _noun + " versions on"} ${_target} — the source library stays untouched.`}</span>
          <button type="button" className="addlib-add-btn" disabled={busy || selected.size === 0} onClick={addSelected}
            style={{ flex: "0 0 auto", padding: "8px 18px", borderRadius: "var(--r-sm)", border: "none", fontSize: "var(--fs-13)", fontWeight: "var(--fw-bold)",
                     cursor: (busy || selected.size === 0) ? "default" : "pointer",
                     background: selected.size > 0 ? "var(--accent-strong)" : "color-mix(in srgb, var(--black) 10%, transparent)",
                     color: selected.size > 0 ? "var(--white)" : "var(--ink-muted, var(--ink-9))",
                     opacity: busy ? 0.6 : 1 }}>
            {busy ? "Adding…" : `Add${selected.size ? " " + selected.size : ""} ${_noun}${selected.size > 1 ? "s" : ""}`}
          </button>
        </div>
      </div>
      {/* v07zz575 — the floating hover-to-enlarge preview (body-portaled inside the hook). */}
      {_hoverApi.portal}
    </div>,
    document.getElementById("modal-root") || document.body
  );
}
window.AddFromLibraryPicker = AddFromLibraryPicker;

function ShotDetailModal({ openShotId, pendingSeq = 0, shots = [], sequences = [], onClose, onNavigate }) {
  // v07zz48 — Move `shot` lookup to the TOP of the function so hooks
  // below can reference it in their deps arrays without hitting the
  // TDZ. Hugo's Railway crash: the production-minified bundle threw
  // "ReferenceError: Cannot access 'shot' before initialization" at
  // the stage-class useEffect's deps array (`[shot && shot.stage_status,
  // openShotId, realVersions, shot]`) because `const shot =
  // shots.find(...)` was declared LATER in source order (line 757)
  // while the useEffect at line 730 evaluated its deps immediately.
  // Pulling the lookup up makes `shot` available everywhere. Null-
  // safe — propagates null when openShotId is unset OR when the shot
  // isn't in the array. The original `if (!shot) return null` guard
  // further down still works.
  const shot = (openShotId && Array.isArray(shots))
    ? (shots.find(s => s.id === openShotId) || null)
    : null;
  // v07zz209 — reference lock: pin a shot's references so Generate always
  // pre-fills + locks them. Toggles shots.locked_reference_assets.
  const [lockingRefs, setLockingRefs] = React.useState(false);
  const refsLocked = !!(shot && Array.isArray(shot.locked_reference_assets) && shot.locked_reference_assets.length);
  // v07zz278 — UI-hiding mirrors the server permission gates so low-perm
  // users never see a control that would 403. edit_shots → field edits +
  // ref-lock + set-current-version; approve_shots → hero/promote (also
  // gates /api/shots/:id/hero on the server).
  const canEditShots = !window.hasPerm || window.hasPerm("edit_shots");
  const canApprove   = !window.hasPerm || window.hasPerm("approve_shots");
  // v07zz289 — "Cut from edit" (omit) in the modal header, mirroring the shot-row
  // toggle Hugo already has. PATCH /api/shots/:id/omit is producer+
  // (REQ_PRODUCER_PLUS) so hide it for lower roles. Optimistic + __updateShotField
  // so the shotlist row greys out / restores in lock-step with the modal.
  const canCutShots = !window.__effectiveRole || ["admin", "producer"].includes(window.__effectiveRole);
  const [omittedLocal, setOmittedLocal] = React.useState(!!(shot && shot.omitted));
  React.useEffect(() => { setOmittedLocal(!!(shot && shot.omitted)); }, [openShotId, shot && shot.omitted]);
  const toggleOmit = React.useCallback(() => {
    if (!shot || !canCutShots) return;
    const next = !omittedLocal;
    setOmittedLocal(next);
    if (typeof window.__updateShotField === "function") window.__updateShotField(shot.id, { omitted: next });
    try {
      (window.authFetch || fetch)(`/api/shots/${encodeURIComponent(shot.id)}/omit`, {
        method: "PATCH", headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ omitted: next }),
      }).catch(() => {});
    } catch (_) {}
  }, [shot, canCutShots, omittedLocal]);
  // v07zz289 — link (continuation) control in the modal header, mirroring the row.
  // PATCH /api/shots/:id/link is edit_shots. Opens the shared row picker; the badge
  // shows the linked partner (master or continuation) and jumps to it on click.
  const canLink = !window.hasPerm || window.hasPerm("edit_shots");
  const linkPartner = shot
    ? (shot.linked_to || (((window.__appData && window.__appData.shots) || []).find(s => s.linked_to === shot.id) || {}).id || null)
    : null;
  // v07zz289 — "Add from library" (archived shot frames + Green Echo trailer) → adds
  // the chosen image as a new frame version. Adding a version = upload_assets.
  const canAddLib = !window.hasPerm || window.hasPerm("upload_assets");
  const [addLibOpen, setAddLibOpen] = React.useState(false);
  const [addLibVideoOpen, setAddLibVideoOpen] = React.useState(false);   // v07zz321 — video library
  React.useEffect(() => { setAddLibOpen(false); setAddLibVideoOpen(false); }, [openShotId]);
  // v07zz526 — Hugo: replace the "Add from library" header PILL with an empty version-tile
  // that carries a big + in the middle (cleaner, sits inline with the version thumbnails).
  // Same action. Rendered at the END of each strip (frames + video).
  const addFrameTile = canAddLib ? (
    <button type="button" className="version-tile version-tile--add" onClick={() => setAddLibOpen(true)} draggable={false}
      aria-label={(!window.__isDefaultProject || window.__isDefaultProject()) ? "Add a frame from the library — archived shot frames or the Green Echo trailer" : "Add a frame from the library — archived shot frames"}>
      <svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12h14"/></svg>
      <span className="vt-add-label">From library</span>
    </button>
  ) : null;
  const addVideoTile = canAddLib ? (
    <button type="button" className="version-tile version-tile--video version-tile--add" onClick={() => setAddLibVideoOpen(true)} draggable={false}
      aria-label="Add a video from the library (any project video)">
      <svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12h14"/></svg>
      <span className="vt-add-label">From library</span>
    </button>
  ) : null;
  const toggleLockRefs = () => {
    if (!shot || lockingRefs) return;
    if (!canEditShots) return;
    // v07zz293 — lock the references ACTUALLY USED in the current version's
    // generation (realRefs = the resolved /local URLs derived from this version's
    // reference_paths / prompt log), NOT shot.reference_assets — those are the
    // shotlist CASTING slugs (e.g. "mark-twain","missouri-1847"), which is why
    // locking used to pin the old Mark Twain ref instead of the images Hugo
    // actually fed the generator. The Generate page re-attaches these exact
    // images. (realRefs is declared later in render; this closure runs on click,
    // after render completes, so it reads the current value.)
    const used = Array.isArray(realRefs) ? realRefs.filter(Boolean) : [];
    if (!refsLocked && !used.length) return;
    const next = refsLocked ? [] : used;
    setLockingRefs(true);
    const fetcher = window.authFetch || fetch;
    fetcher(`/api/shots/${encodeURIComponent(shot.id)}/lock-references`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ locked_reference_assets: next }) })
      .then(r => { if (r.ok && typeof window.reloadAppData === "function") window.reloadAppData(); })
      .catch(() => {})
      .finally(() => setLockingRefs(false));
  };
  // v07zg — Snappy re-open: compute initial cache state up-front so
  // the modal opens already-ready when we've seen this shot before.
  // Avoids the "fade-in like everything is loading again" feeling
  // Hugo flagged. Without this, opening the same shot a second time
  // ran through the same reset→fetch→stripReady→fade cycle as the
  // first open even though every byte was already in browser cache.
  const _initialCachedVersions = React.useMemo(() => {
    if (!openShotId) return [];
    // Module-level Map: instant within a single page session.
    const memCached = window.__assetVersionCache && window.__assetVersionCache.get(openShotId);
    // v07zz466 — the cache holds the RAW list (candidates + pushed). The published-only
    // view is derived below (realVersions memo) so the FRAMES strip can toggle
    // Pushed ↔ All without a refetch. Videos + grids default published=1.
    if (Array.isArray(memCached) && memCached.length) return memCached;
    // sessionStorage fallback: survives page reloads (a hard Ctrl-Shift-R
    // clears it; the next /api/asset-versions call refills it). On
    // Railway this is the difference between "first open after refresh
    // pays the full fade cycle" and "first open is already warm".
    try {
      const raw = sessionStorage.getItem("asset-versions:" + openShotId);
      if (raw) {
        const parsed = JSON.parse(raw);
        if (Array.isArray(parsed) && parsed.length) {
          // Re-hydrate the in-memory map so subsequent reads hit it
          // synchronously without the JSON parse.
          window.__assetVersionCache = window.__assetVersionCache || new Map();
          window.__assetVersionCache.set(openShotId, parsed);
          return parsed;
        }
      }
    } catch (_) {}
    return [];
  }, [openShotId]);
  const _hasInitialCache = _initialCachedVersions.length > 0;
  const _initialActiveVersion = React.useMemo(() => {
    // v07zz549 — Review "Updates" → "Open shot" opens the modal AT a version. Reading the pending
    // marker HERE (the useState init AND the reset effect both use this memo) makes that version the
    // modal's default from the first paint — so nothing, not even StrictMode's double-invoked reset,
    // can flip it to the video-first default. The marker is dropped when you navigate to another shot.
    const pv = (typeof window !== "undefined") ? window.__pendingShotVersion : null;
    if (pv && pv.id === openShotId && pv.version) {
      // v07zz557 — family-aware: a video event's version needs the "video-" slot prefix.
      const _c = String(pv.version).replace(/^video-/, "");
      return pv.family === "video" ? "video-" + _c : _c;
    }
    if (!_hasInitialCache) return (shot && shot.active_frame_version) || "v003";
    return _pickDefaultActiveVersionVideoFirst(_initialCachedVersions, shot && shot.active_frame_version);
  }, [_initialCachedVersions, _hasInitialCache, shot && shot.active_frame_version, openShotId]);

  const [activeVersion, setActiveVersion] = React.useState(_initialActiveVersion);
  // v07zz314 — inline rename of the shot's title (frame_title). Editable when edit_shots.
  const [editingTitle, setEditingTitle] = React.useState(false);
  const [titleDraft, setTitleDraft] = React.useState("");
  React.useEffect(() => { setEditingTitle(false); }, [shot && shot.id]);
  const commitTitle = () => {
    const next = (titleDraft || "").trim();
    setEditingTitle(false);
    if (!next || !shot || next === (shot.frame_title || "")) return;
    if (window.__updateShotField) window.__updateShotField(shot.id, { frame_title: next });   // optimistic
    try {
      (window.authFetch || fetch)(`/api/shots/${encodeURIComponent(shot.id)}`, {
        method: "PATCH", headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ frame_title: next }),
      }).catch(() => {});
    } catch (_) {}
  };

  // v07zz320 — inline-edit the modal's visible entries (action/description + VO) the SAME
  // way as the title: click → textarea → blur/Cmd-Enter saves, Esc cancels. Optimistic via
  // __updateShotField (so it reflects immediately) + PATCH /api/shots/:id.
  const commitField = (field, value) => {
    const next = value == null ? "" : String(value);
    if (!shot || next === (shot[field] == null ? "" : String(shot[field]))) return;
    if (window.__updateShotField) window.__updateShotField(shot.id, { [field]: next });   // optimistic
    try {
      (window.authFetch || fetch)(`/api/shots/${encodeURIComponent(shot.id)}`, {
        method: "PATCH", headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ [field]: next }),
      }).catch(() => {});
    } catch (_) {}
  };
  const [editingAction, setEditingAction] = React.useState(false);
  const [actionDraft, setActionDraft]     = React.useState("");
  const [editingVO, setEditingVO]         = React.useState(false);
  const [voDraft, setVoDraft]             = React.useState("");
  React.useEffect(() => { setEditingAction(false); setEditingVO(false); }, [shot && shot.id]);
  // v07zz413 — Shot type + Location rows are ALWAYS shown + inline-editable (even when the
  // field is empty), so no shot is missing them. Click the value → input → blur/Enter saves.
  const [editingMeta, setEditingMeta] = React.useState(null); // "shot_type" | "landscape" | null
  const [metaDraft, setMetaDraft]     = React.useState("");
  React.useEffect(() => { setEditingMeta(null); }, [shot && shot.id]);

  // v07zz318 — "Edit shot" form: every editable field as a text box (Hugo asked to
  // fill VO / description / etc. on new shots he creates). Saves the changed subset via
  // PATCH /api/shots/:id (the same endpoint the inline title rename uses). Optimistic
  // with revert-on-failure. Rendered as a portal overlay so the shot modal never shifts.
  const SHOT_EDIT_FIELDS = [
    { k: "frame_title",   label: "Title",                type: "text" },
    { k: "seq",           label: "Sequence #",           type: "number" },
    { k: "shot_type",     label: "Shot type",            type: "text" },
    { k: "action",        label: "Action / description", type: "area", rows: 3 },
    { k: "narration",     label: "Narration / VO",       type: "area", rows: 2 },
    { k: "species",       label: "Subject / species",    type: "text" },
    { k: "landscape",     label: "Location / landscape", type: "text" },
    { k: "weather_light", label: "Weather / light",      type: "text" },
    { k: "behaviour",     label: "Behaviour / notes",    type: "area", rows: 2 },
  ];
  const [editingShot, setEditingShot] = React.useState(false);
  const [shotDraft, setShotDraft]     = React.useState({});
  const [savingShot, setSavingShot]   = React.useState(false);
  const [shotEditErr, setShotEditErr] = React.useState("");
  React.useEffect(() => { setEditingShot(false); }, [shot && shot.id]);
  // Esc closes ONLY this overlay (capture-phase + stopPropagation so the shot modal
  // underneath doesn't also close).
  React.useEffect(() => {
    if (!editingShot) return;
    const onKey = (e) => { if (e.key === "Escape" && !savingShot) { e.preventDefault(); e.stopPropagation(); setEditingShot(false); } };
    window.addEventListener("keydown", onKey, true);
    return () => window.removeEventListener("keydown", onKey, true);
  }, [editingShot, savingShot]);
  const openShotEdit = () => {
    if (!shot) return;
    const d = {};
    for (const f of SHOT_EDIT_FIELDS) d[f.k] = shot[f.k] == null ? "" : String(shot[f.k]);
    setShotDraft(d); setShotEditErr(""); setEditingShot(true);
  };
  const commitShotEdit = async () => {
    if (!shot || savingShot) return;
    const patch = {};
    for (const f of SHOT_EDIT_FIELDS) {
      if (f.k === "seq") continue;
      const v = (shotDraft[f.k] != null ? String(shotDraft[f.k]) : "");
      if (v !== (shot[f.k] == null ? "" : String(shot[f.k]))) patch[f.k] = v;
    }
    // seq is numeric — only send a valid integer change.
    const seqRaw = String(shotDraft.seq == null ? "" : shotDraft.seq).trim();
    const seqNum = parseInt(seqRaw, 10);
    if (seqRaw !== "" && Number.isFinite(seqNum) && seqNum !== shot.seq) patch.seq = seqNum;
    if (Object.keys(patch).length === 0) { setEditingShot(false); return; }
    const before = {};
    for (const k of Object.keys(patch)) before[k] = shot[k];
    setSavingShot(true); setShotEditErr("");
    if (window.__updateShotField) window.__updateShotField(shot.id, patch);   // optimistic
    try {
      const r = await (window.authFetch || fetch)(`/api/shots/${encodeURIComponent(shot.id)}`, {
        method: "PATCH", headers: { "Content-Type": "application/json" },
        body: JSON.stringify(patch),
      });
      if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error((e && e.error) || ("Save failed (" + r.status + ")")); }
      setEditingShot(false);
    } catch (e) {
      if (window.__updateShotField) window.__updateShotField(shot.id, before);   // revert optimistic
      setShotEditErr((e && e.message) || "Save failed — try again.");
    } finally { setSavingShot(false); }
  };

  // v07zz319 — Duplicate shot: clone ALL the metadata (title, shot type, action, VO, etc.)
  // into a brand-new shot with a number Hugo picks — but NO images (asset_versions are not
  // copied; the new shot starts empty). Uses POST /api/shots (which now accepts the full
  // descriptive field set). On success, opens the new shot.
  const [dupOpen, setDupOpen]   = React.useState(false);
  const [dupNum, setDupNum]     = React.useState("");
  const [dupBusy, setDupBusy]   = React.useState(false);
  const [dupErr, setDupErr]     = React.useState("");
  React.useEffect(() => { setDupOpen(false); }, [shot && shot.id]);
  const openShotDup = () => {
    if (!shot) return;
    // Suggest the first free number just after this shot (curr+1..curr+10), else max+10.
    const shots = (window.__appData && window.__appData.shots) || [];
    const nums = new Set(shots.map(s => parseInt(String(s.id).replace(/^SH/i, ""), 10)).filter(Number.isFinite));
    const curr = parseInt(String(shot.id).replace(/^SH/i, ""), 10) || 0;
    let suggest = null;
    for (let n = curr + 1; n <= curr + 10; n++) { if (!nums.has(n)) { suggest = n; break; } }
    if (suggest == null) { let mx = 0; nums.forEach(n => { if (n > mx) mx = n; }); suggest = mx + 10; }
    setDupNum("SH" + String(suggest).padStart(4, "0"));
    setDupErr(""); setDupOpen(true);
  };
  const commitShotDup = async () => {
    if (!shot || dupBusy) return;
    const raw = String(dupNum || "").trim().toUpperCase().replace(/^SH/, "");
    if (!/^\d{1,5}$/.test(raw)) { setDupErr("Shot number must be digits — e.g. 0095 or SH0095."); return; }
    setDupBusy(true); setDupErr("");
    const payload = {
      id: raw,
      frame_title:   shot.frame_title || "",
      action:        shot.action || "",
      narration:     shot.narration || "",
      shot_type:     shot.shot_type || "",
      species:       shot.species || "",
      landscape:     shot.landscape || "",
      weather_light: shot.weather_light || "",
      behaviour:     shot.behaviour || "",
      seq:           shot.seq,
    };
    try {
      const r = await (window.authFetch || fetch)("/api/shots", {
        method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload),
      });
      const j = await r.json().catch(() => ({}));
      if (!r.ok) throw new Error((j && j.error) || ("Create failed (" + r.status + ")"));
      setDupOpen(false);
      if (window.reloadAppData) window.reloadAppData();
      const newId = (j && j.id) || ("SH" + raw.padStart(4, "0"));
      setTimeout(() => { if (window.__nav && window.__nav.openShot) window.__nav.openShot(newId); }, 200);
    } catch (e) {
      setDupErr((e && e.message) || "Couldn't duplicate — try a different number.");
    } finally { setDupBusy(false); }
  };
  const [isPlaying, setIsPlaying] = React.useState(false);
  // v855 — Hugo: "when I press PLAY on a video myself, the sound needs to automatically
  // unmute. only mute when the video auto plays."
  // Kept as STATE, not an imperative `el.muted = false`: `muted` is a React-controlled prop
  // here, so writing the DOM property directly can be undone the next time the component
  // reconciles (and onPlay re-renders immediately via setIsPlaying). State is the only
  // version that survives. Starts muted every time — browsers block an unmuted autoplay,
  // so the opening autoplay would simply not run otherwise.
  const [heroMuted, setHeroMuted] = React.useState(true);
  // t14 — full-screen asset review modal trigger (open via click on the
  // hero image, the small expand button, or the version filmstrip).
  const [reviewOpen, setReviewOpen] = React.useState(false);
  // v06m — drag-scroll per filmstrip. Each pair: an `el` ref the arrow
  // buttons use to call scrollBy, and a callback ref attached via
  // `ref={…}` that wires up the drag listeners. useDragScroll's
  // callback-ref form reattaches the listeners every time the
  // scroller element mounts/unmounts (fixes the "drag doesn't work"
  // bug where the strip was rendered conditionally and missed by
  // the original effect-once-on-mount hook).
  const framesEl = React.useRef(null);
  const heroEl   = React.useRef(null);
  const videoEl  = React.useRef(null);
  const sbEl     = React.useRef(null);   // v783 — STORYBOARDS strip scroller
  const d3El     = React.useRef(null);   // v807 — 3D strip scroller
  const dpEl     = React.useRef(null);   // v825 — DEPTH strip scroller
  const framesScrollerRef = useDragScroll(framesEl);
  const heroScrollerRef   = useDragScroll(heroEl);
  const videoScrollerRef  = useDragScroll(videoEl);
  const sbScrollerRef     = useDragScroll(sbEl);
  const d3ScrollerRef     = useDragScroll(d3El);
  const dpScrollerRef     = useDragScroll(dpEl);
  // v783 — STORYBOARDS strip filter: colour sheets vs their depth twins.
  const [sbFilter, setSbFilter] = React.useState("colour");
  React.useEffect(() => { setSbFilter("colour"); }, [openShotId]);
  // t19 — real asset_versions data fetched per-shot. Used to override
  // the mock prompts + populate the reference thumbnails when real
  // generation metadata exists. Plus a lightbox state for clicked refs.
  // v07y — Module-level cache so closing + re-opening a shot doesn't
  // re-fetch /api/asset-versions. Initialise from the cache on mount;
  // a background refetch still runs to pick up changes from a peer or
  // a webhook between opens. Result: every re-open is INSTANT.
  // v07zz466 — allShotVersions = the RAW list (pushed + candidates). realVersions
  // (published-only) is derived from it, so every existing derivation — hero pick,
  // FRAMES/VIDEO strips, pickers — keeps its pushed-only behaviour, while the FRAMES
  // strip can flip to "All" (include candidates) without a refetch. The setter keeps
  // its name so every optimistic update below works unchanged (they patch by id).
  const [allShotVersions, setRealVersions] = React.useState(_initialCachedVersions);
  const realVersions = React.useMemo(
    () => (allShotVersions || []).filter(v => v && v.published !== 0),
    [allShotVersions]
  );
  // v07zz534 — FRAMES strip funnel: "hero" (default — chosen frames at the shot-folder
  // root, published) | "wip" (candidates in frames/_wip, published=0) | "archived"
  // (discarded, in this shot's frames/_archived). Each tab exposes move buttons that
  // reuse the publish/archive endpoints to shuttle a version between the three states.
  const [framesFilter, setFramesFilter] = React.useState("hero");
  React.useEffect(() => { setFramesFilter("hero"); }, [openShotId]);   // reset per shot
  // v777 — busy flag for the big-preview "extract first frame" button (base label or null).
  const [ffBusy, setFfBusy] = React.useState(null);
  const [lfBusy, setLfBusy] = React.useState(null);   // v976 — its own busy key, so the
                                                     // FIRST-frame button never greys out
                                                     // while LAST-frame is extracting.
  React.useEffect(() => { setFfBusy(null); }, [openShotId]);
  // v792 — 🔄 refresh-images tick. Clicking the hero-bar refresh bumps the global thumb
  // cache-bust token (App.jsx window.__bumpThumbCacheBust) and this tick forces a
  // re-render so every thumbUrl() in the modal recomputes with the new token.
  const [, _bumpImgTick] = React.useState(0);
  // v07zz553 — the VIDEO strip gets the exact same Hero | WIP | Archived funnel.
  const [videosFilter, setVideosFilter] = React.useState("hero");
  React.useEffect(() => { setVideosFilter("hero"); }, [openShotId]);   // reset per shot
  // v912 — a CONTINUATION shot can DISPLAY its MASTER's hero videos (Hugo: "i should
  // be able to see the master shot videos in the linked modal. not a move or a copy of
  // the file, just a display"). Fetched once per open when the shot is linked; the
  // "master" filter tab renders them with NO funnel buttons (every action gate below
  // checks the other filter values, and the ✓/★ gates exclude "master" explicitly).
  const _linkMasterId = (shot && shot.linked_to) || null;
  const [masterVersions, setMasterVersions] = React.useState(null);   // null = not fetched
  React.useEffect(() => { setMasterVersions(null); }, [openShotId]);
  React.useEffect(() => {
    if (!_linkMasterId || masterVersions !== null) return;
    const cached = window.__assetVersionCache && window.__assetVersionCache.get && window.__assetVersionCache.get(_linkMasterId);
    if (cached) { setMasterVersions(cached); return; }
    const fetcher = window.authFetch || fetch;
    fetcher(`/api/asset-versions?asset_id=${encodeURIComponent(_linkMasterId)}`)
      .then(r => r.ok ? r.json() : null)
      .then(d => setMasterVersions((d && d.versions) || []))
      .catch(() => setMasterVersions([]));
  }, [_linkMasterId, masterVersions, openShotId]);
  // v07zz468 — archived versions are EXCLUDED from the main live fetch (by design), so the
  // Archived view lazy-fetches them on first click: ?include_archived=1, then keeps only
  // rows that are archived AND belong to THIS shot's own folder (…/shots/<id>/…) — which
  // excludes the old-shotlist archive (assets/archivedFrames) and other shots' files.
  const [archivedVersions, setArchivedVersions] = React.useState(null);   // null = not fetched yet
  React.useEffect(() => { setArchivedVersions(null); }, [openShotId]);
  React.useEffect(() => {
    // v07zz553 — the VIDEO strip's Archived tab shares the same lazy fetch.
    if ((framesFilter !== "archived" && videosFilter !== "archived") || archivedVersions !== null || !openShotId) return;
    const fetcher = window.authFetch || fetch;
    fetcher(`/api/asset-versions?asset_id=${encodeURIComponent(openShotId)}&include_archived=1`)
      .then(r => r.ok ? r.json() : null)
      .then(d => {
        const own = new RegExp("/shots/" + openShotId + "/", "i");
        const rows = ((d && d.versions) || []).filter(v =>
          v && v.archived && own.test(String(v.file_path || v.cloud_url || "")));
        setArchivedVersions(rows);
      })
      .catch(() => setArchivedVersions([]));
  }, [framesFilter, videosFilter, archivedVersions, openShotId]);
  // v07h — hero-image load gate. We had a "previous shot's image
  // visible for ~1s" bug on Railway despite key={openShotId} on the
  // <img> and the whole modal. Browsers will sometimes paint the
  // last-decoded bitmap of an evicted DOM node for a few hundred ms
  // before garbage-collecting it, especially during back-to-back
  // shot opens. Solution: render the new <img> at opacity 0 until
  // its onLoad fires for the current src, then fade in. The
  // modal-hero-image's gradient bg shows during the gap, so the user
  // sees the new shot's colour palette but never another shot's
  // pixels. Reset on every openShotId change.
  // v07zg — Start TRUE when we've cached the asset list for this
  // shot. The hero image URL is browser-cached too (immutable
  // Cache-Control on /api/r2-thumb + /local/*?w=NNN) so it paints
  // synchronously from disk — no flash of empty hero.
  const [heroLoaded, setHeroLoaded] = React.useState(_hasInitialCache);
  const [lightbox, setLightbox] = React.useState(null);    // { src, caption } | null
  // v07zz17 — Grid overlay state. When the user clicks the "open
  // grid" button on a tile, this holds that asset_version row so
  // GridDetailModal can render on top of the shot modal.
  const [gridOverlay, setGridOverlay] = React.useState(null);
  // v07z3 — Strip-ready gate. Hugo on Railway: first-time-open shows
  // tiles "flickering" as each thumbnail loads at different moments
  // (proxy latency varies per image). Track how many imgs have
  // loaded; only show the version strip once ALL have loaded, with
  // the empty placeholder visible until then. Subsequent opens
  // (cache hit) flip almost instantly so the user just sees the
  // populated strip appear at once.
  // v07zg — Strip starts ready when cache exists: thumbnails for
  // every version are already in browser cache from the previous
  // open. Skips the per-tile onLoad counter wait + the 280 ms
  // fade-in. First-ever open still waits as before.
  const [stripReady, setStripReady] = React.useState(_hasInitialCache);
  const _stripLoadedRef = React.useRef(0);
  const _stripTotalRef = React.useRef(0);
  const onTileImgLoad = React.useCallback((e) => {
    // v07zz41 — record the loaded URL (relative, as the <img> attribute) so the
    // NEXT open renders this tile instantly, and fade in only if it wasn't
    // already warm — so the strip no longer fades EVERY open, only cold tiles.
    if (e && e.currentTarget) {
      try { const u = e.currentTarget.getAttribute("src"); if (u) (window.__warmedThumbs = window.__warmedThumbs || new Set()).add(u); } catch (_) {}
      if (!e.currentTarget.classList.contains("vt-img-warm")) e.currentTarget.classList.add("vt-img-loaded");
    }
    _stripLoadedRef.current++;
    if (_stripTotalRef.current > 0 && _stripLoadedRef.current >= _stripTotalRef.current) {
      setStripReady(true);
    }
  }, []);
  // v07zw — Hugo: when the strip becomes ready, scroll it all the
  // way to the right so the LATEST version is visible (was always
  // showing v001 on the left first). One-shot per modal open via
  // an openShotId-keyed ref.
  // v794 — Hugo: "the video and storyboard film strips don't show the last
  // version first the same way Frames do." Two fixes in one:
  //  1. ALL THREE strips scroll to their far end now (frames + video +
  //     storyboards — the lists are oldest→newest left-to-right).
  //  2. The trigger no longer waits for stripReady (= every tile IMAGE
  //     loaded): tiles use loading="lazy", so on a LONG strip the
  //     off-screen images never load and stripReady never fired — which is
  //     why big shots (SH0200, ~40 frame tiles) opened stuck on v001 while
  //     small shots looked fine. Tile WIDTH is fixed CSS, so scrollWidth is
  //     correct as soon as the tiles RENDER — gate on that instead. Re-runs
  //     on every versions change until tiles exist, then one-shots per shot.
  const _scrolledToLatestRef = React.useRef(null);
  React.useEffect(() => {
    if (_scrolledToLatestRef.current === openShotId) return;
    // setTimeout, NOT requestAnimationFrame: rAF is paused entirely in hidden/
    // backgrounded tabs (the old rAF version silently never ran there), while a
    // timeout always fires. 50ms is past React's commit + layout in a visible tab.
    const t = setTimeout(() => {
      try {
        if (_scrolledToLatestRef.current === openShotId) return;
        const anyTiles = [heroEl, videoEl, sbEl, d3El, dpEl].some(r => r.current && r.current.querySelector(".version-tile"));
        if (!anyTiles) return;   // data not in yet — the next allShotVersions change re-runs this
        _scrolledToLatestRef.current = openShotId;
        for (const ref of [heroEl, videoEl, sbEl, d3El, dpEl]) {
          if (ref.current) ref.current.scrollLeft = ref.current.scrollWidth;
        }
      } catch (_) {}
    }, 50);
    return () => clearTimeout(t);
  }, [stripReady, openShotId, allShotVersions]);
  // v07x — `dataReady` retained as a local flag for any future
  // sub-element gating; the v07w whole-modal hide was reverted
  // (it caused a worse "disappear → reappear" flash). Set to true
  // when /api/asset-versions resolves so any down-stream component
  // can opt in.
  // v07zg — Same: dataReady starts TRUE on cache hit. Background
  // refetch still runs; if it lands new versions they replace the
  // cached ones without flipping the loading flag.
  const [dataReady, setDataReady] = React.useState(_hasInitialCache);
  // v07c — Split into two effects:
  //   1. Immediate fetch when openShotId changes (open the modal → load).
  //   2. DEBOUNCED refetch when stage_status changes. The 700ms delay
  //      lets the backend finish processing the /status + /hero
  //      PATCHes that fired alongside the local stage change BEFORE
  //      we refetch, so we don't get stale data that overwrites the
  //      optimistic kind="frame" demotion. Was: instant refetch on
  //      stage change → race → server returns kind="hero" still set →
  //      star/pill snap back. Fixed by waiting for the dust to settle.
  // v07zz64 — Earlier attempt to drop the parent `key={openShotId}`
  // remount + reset state via useEffect blanked the entire UI on
  // load. Reverted: key={openShotId} is back in App.jsx and resets
  // happen naturally on remount, no explicit useEffect needed.

  // v07zz63 — Prefetch prev/next shot data + hero so arrow nav paints
  // instantly with no "flash of empty bg" between shots.
  // Hugo: "Still get flashes of the background when i go to the next
  // modal." Root cause: the parent forces a full unmount+remount on
  // openShotId change (key={openShotId} in App.jsx). When the new
  // modal mounts, asset_versions fetch + hero <img> haven't resolved
  // yet, so the gradient bg is visible for ~150 ms. Solution:
  // warm the cache for prev + next shot the moment THIS modal opens
  // — fetch their asset_versions into window.__assetVersionCache +
  // decode their hero image at width 1200. By the time the user
  // clicks an arrow, both are ready and the new modal paints from
  // memory with no fetch latency or image load gap.
  React.useEffect(() => {
    if (!openShotId || !Array.isArray(shots) || shots.length < 2) return;
    const idx = shots.findIndex(s => s.id === openShotId);
    if (idx < 0) return;
    const neighbors = [shots[idx - 1], shots[idx + 1]].filter(Boolean);
    const fetcher = window.authFetch || fetch;
    window.__assetVersionCache = window.__assetVersionCache || new Map();
    const cache = window.__assetVersionCache;
    for (const nb of neighbors) {
      if (cache.has(nb.id)) {
        // v07zz71 — Cache already has neighbor data; skip the image
        // pre-decode (was bleeding bitmaps into the modal swap).
        continue;
      }
      // v07zz71 — Hugo: "i see the ghosts of previous images still
      // when opening shots, looks like it's prefetching old data
      // then wiping it." Removed the eager Image() decode for the
      // neighbor's hero — that pre-painted decoded bitmaps that
      // could briefly bleed through during the modal swap. Now we
      // ONLY pre-fetch the asset-versions data into the cache.
      // The image still loads quickly because the data tells the
      // new modal exactly which URL to request, but the bitmap
      // arrives fresh per-shot.
      fetcher(`/api/asset-versions?asset_id=${encodeURIComponent(nb.id)}`)
        .then(r => r.ok ? r.json() : null)
        .then(d => {
          const versions = (d && d.versions) || [];
          cache.set(nb.id, versions);
          try { sessionStorage.setItem("asset-versions:" + nb.id, JSON.stringify(versions)); } catch (_) {}
        })
        .catch(() => {});
    }
  }, [openShotId, shots]);

  React.useEffect(() => {
    if (!openShotId) return;
    const fetcher = window.authFetch || fetch;
    fetcher(`/api/asset-versions?asset_id=${encodeURIComponent(openShotId)}`)
      .then(r => r.ok ? r.json() : null)
      .then(d => {
        const versions = (d && d.versions) || [];   // v07zz466 — RAW list; published-only view derived in realVersions memo
        setRealVersions(versions);
        // v07y — store in module cache so next open of this shot
        // reads the result immediately without waiting on the fetch.
        // v07zg — also persist to sessionStorage so a tab reload
        // doesn't lose every shot's warm-cache state.
        try {
          window.__assetVersionCache = window.__assetVersionCache || new Map();
          window.__assetVersionCache.set(openShotId, versions);
          try { sessionStorage.setItem("asset-versions:" + openShotId, JSON.stringify(versions)); } catch (_) {}
        } catch (_) {}
        // v07w — Flip dataReady so the modal body becomes visible.
        // Set it regardless of whether versions are empty — an
        // unfilled shot is still "ready to render".
        setDataReady(true);
        // v07zz533 — video-first default: a shot WITH a video opens on the latest video
        // (autoplay); else the PUBLISHED, non-archived frame (pointer honored only when live).
        // This async fetch is the LAST writer of activeVersion, so it MUST prefer the video
        // too — otherwise it clobbers the video pick and the modal snaps back to a frame.
        // v07zz557 — …unless an Updates-page "Open shot" pick is pending: being the last
        // writer, this fetch was ALSO clobbering that pick (the mount effect applied it,
        // then this snapped back to the video ~200ms later). Honor the marker, land on the
        // right family ("video-" slot prefix), and flip the strip filter so a WIP target's
        // tile is actually visible (the digest's rows are mostly _wip events).
        const _pv = window.__pendingShotVersion;
        if (_pv && _pv.id === openShotId && _pv.version) {
          const _core = String(_pv.version).replace(/^video-/, "");
          const _rows = versions.filter(v => String(v.version_label) === _core);
          const _row = (_pv.family === "video")
            ? (_rows.find(v => _isVideoFile(v)) || _rows[0])
            : (_rows.find(v => !_isVideoFile(v)) || _rows[0]);
          const _isVid = _row ? _isVideoFile(_row) : (_pv.family === "video");
          setActiveVersion(_isVid ? ("video-" + _core) : _core);
          if (_row && !_row.archived && _row.published === 0) {
            // v774 — depth videos live in their own DEPTH tab, not WIP.
            if (_isVid) setVideosFilter(_isDepthRow(_row) ? "depth" : "wip"); else setFramesFilter("wip");
          }
        } else {
          setActiveVersion(_pickDefaultActiveVersionVideoFirst(versions, shot && shot.active_frame_version));
        }
      })
      .catch(() => { setRealVersions([]); setDataReady(true); });
  }, [openShotId]);

  const _shotStageKey = (() => {
    const s = shots.find(x => x.id === openShotId);
    return s && s.stage_status ? JSON.stringify(s.stage_status) : "";
  })();
  const _firstStageRender = React.useRef(true);
  React.useEffect(() => {
    if (!openShotId) return;
    if (_firstStageRender.current) {
      _firstStageRender.current = false;
      return;
    }
    const t = setTimeout(() => {
      const fetcher = window.authFetch || fetch;
      fetcher(`/api/asset-versions?asset_id=${encodeURIComponent(openShotId)}`)
        .then(r => r.ok ? r.json() : null)
        .then(d => setRealVersions((d && d.versions) || []))   // v07zz466 — raw list; published-only derived in realVersions memo
        .catch(() => {});
    }, 700);
    return () => clearTimeout(t);
  }, [_shotStageKey, openShotId]);
  // v07zz288 — Keep the asset-version cache (module Map + sessionStorage) in
  // lock-step with realVersions. THE "un-hero doesn't stick" FIX: promoteHero
  // optimistically updated React state (star clears) but NOT the cache, so
  // navigating away and back re-seeded the modal from the STALE cache — the
  // hero ★ "came back". (Server + DB + sync all persist the un-hero correctly;
  // verified end-to-end. The bug was purely this client cache never being
  // invalidated on a hero mutation. Most visible on Railway, where the
  // corrective main-fetch has network lag, so the old hero shows for ~1 s on
  // every reopen and reads as "stuck".) Now every realVersions change — optimistic
  // toggle, promote, debounced reconcile, or fetch — writes through, so a reopen
  // paints the correct hero state instantly. Guard on length>0 so a transient
  // fetch error (which sets []) can't wipe a good cache entry.
  React.useEffect(() => {
    // v07zz466 — mirror the RAW list (pushed + candidates) so the seeded reopen can
    // still flip the FRAMES strip to "All" without a refetch.
    if (!openShotId || !Array.isArray(allShotVersions) || allShotVersions.length === 0) return;
    try {
      window.__assetVersionCache = window.__assetVersionCache || new Map();
      window.__assetVersionCache.set(openShotId, allShotVersions);
      try { sessionStorage.setItem("asset-versions:" + openShotId, JSON.stringify(allShotVersions)); } catch (_) {}
    } catch (_) {}
  }, [allShotVersions, openShotId]);
  // v07perf — Refetch asset_versions when the grid modal flips a slice
  // to hero (or any other path that mutates this shot's asset_versions
  // without changing shot stage). GridDetailModal dispatches
  // "paradise-shot-frames-changed" with detail.shot_id; we listen and
  // re-pull /api/asset-versions to pick up the new kind="hero" rows.
  React.useEffect(() => {
    if (!openShotId) return;
    const handler = (e) => {
      const shotId = e && e.detail && e.detail.shot_id;
      if (shotId && shotId !== openShotId) return;
      const fetcher = window.authFetch || fetch;
      fetcher(`/api/asset-versions?asset_id=${encodeURIComponent(openShotId)}`)
        .then(r => r.ok ? r.json() : null)
        .then(d => setRealVersions((d && d.versions) || []))   // v07zz466 — raw list; published-only derived in realVersions memo
        .catch(() => {});
    };
    window.addEventListener("paradise-shot-frames-changed", handler);
    return () => window.removeEventListener("paradise-shot-frames-changed", handler);
  }, [openShotId]);
  const videoRef = React.useRef(null);

  React.useEffect(() => {
    if (!openShotId) return;
    const onKey = (e) => {
      if (e.key === "Escape") onClose && onClose();
      // v07zz51 — Left/right arrows jump to the previous / next shot
      // in the visible list. Skip when focus is in a text input
      // (typing in a comment field etc.) to avoid hijacking typing.
      if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
        const t = e.target;
        if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
        if (!Array.isArray(shots) || shots.length < 2) return;
        const i = shots.findIndex(s => s.id === openShotId);
        if (i < 0) return;
        const target = e.key === "ArrowLeft"
          ? (i > 0 ? shots[i - 1] : null)
          : (i < shots.length - 1 ? shots[i + 1] : null);
        if (!target) return;
        e.preventDefault();
        if (typeof onNavigate === "function") onNavigate(target.id);
        else if (window.__nav && window.__nav.openShot) window.__nav.openShot(target.id);
      }
    };
    window.addEventListener("keydown", onKey);
    document.body.style.overflow = "hidden";
    return () => {
      window.removeEventListener("keydown", onKey);
      document.body.style.overflow = "";
    };
  }, [openShotId, onClose, onNavigate, shots]);

  // Reset active version when opening a new shot. Default: v003 (the
  // demo locked version). After realVersions load, the second effect
  // below promotes the latest video to the active slot when one
  // exists, so the hero plays the video by default.
  // v05l — Also clear the _autoVidApplied ref. Without this, closing
  // a shot and re-opening the SAME shot in the same session would
  // skip the video promotion (because the ref still matched the shot
  // id from the first open) and the modal would freeze on the v003
  // still — that's the "hero reverts to image version" bug.
  React.useEffect(() => {
    if (!openShotId) return;
    // v07zg — When we have cached versions for this shot, DON'T
    // reset the ready flags — useState inits already set them to
    // their final values, and resetting here would force the
    // modal back through the fade cycle even though every byte
    // is in browser cache. Only the cold path (no cache) hits
    // the reset branch.
    const cached = window.__assetVersionCache && window.__assetVersionCache.get(openShotId);
    const hasCache = Array.isArray(cached) && cached.length > 0;
    if (!hasCache) {
      // v07zz342 — restore the SHOT'S OWN locked/active version (not a hardcoded "v003"), so the
      // hero never transiently resolves to a wrong version before the post-fetch picker runs.
      setActiveVersion(_initialActiveVersion);
      setHeroLoaded(false);
      setDataReady(false);
      setStripReady(false);
    }
    setIsPlaying(false);
    _stripLoadedRef.current = 0;
    _stripTotalRef.current = 0;
    if (videoRef.current) { try { videoRef.current.pause(); videoRef.current.currentTime = 0; } catch (e) {} }
    _autoVidApplied.current = null;
    // v07zz549 — drop a STALE pending version (marker left for a DIFFERENT shot) so it can't leak;
    // keep the current shot's marker so its version stays the default (memo + apply-once above).
    try {
      const _pv = window.__pendingShotVersion;
      if (_pv && _pv.id !== openShotId) window.__pendingShotVersion = null;
      if (_pendingAppliedFor.current && _pendingAppliedFor.current !== openShotId) _pendingAppliedFor.current = null;
    } catch (_) {}
  }, [openShotId]);

  // v07z9 — Safety net. If onLoad / onError never fire for some
  // tile (broken URL, network stall, browser bug), the strip
  // would stay invisible forever AND the prompt gated on it
  // wouldn't fade in either — Hugo saw "no versions populate at
  // all" and "hero doesn't show sometimes". After 2.5 s force
  // stripReady + heroLoaded so content always becomes visible.
  React.useEffect(() => {
    if (!openShotId) return;
    const t = setTimeout(() => {
      setStripReady(true);
      setHeroLoaded(true);
    }, 2500);
    return () => clearTimeout(t);
  }, [openShotId]);
  // v06p — Backdrop close needs a ref to track whether the mousedown
  // started on the backdrop. MUST be declared before any conditional
  // early-return below, or React's Rules-of-Hooks check tears down
  // the whole tree (and the user sees a blank page with just the bg).
  const _backdropDown = React.useRef(false);

  // v06r/v06y — shared "promote to hero" helper. Called by:
  //   • the star buttons on every version tile in the strip
  //   • the HERO THIS FRAME/VIDEO pill in the cream popup header
  //   • the same pill inside AssetReviewModal
  // Accepts an `isToggleOff` flag — when true, the backend reverts the
  // hero pin to the previous one (or clears it) and the frontend
  // optimistically downgrades stage + clears hero_video_label so the
  // pill UI flips back to its "outline" state immediately.
  const promoteHero = React.useCallback((kind, versionLabel, isToggleOff = false) => {
    if (!openShotId || !versionLabel) return;
    if (window.hasPerm && !window.hasPerm("approve_shots")) return;
    const baseVer = String(versionLabel).split("_")[0];

    // Optimistic local state — instant UI feedback. The backend
    // confirms (and reconciles via the asset_versions refetch).
    if (!isToggleOff) {
      // v07zz582 — Hugo: DE-LINK the video star from the shot's stage_status. Starring a
      // video now ONLY marks it as the PROPOSED hero video (hero_video_label — still drives
      // which video plays in the hero + the shotlist thumb); it no longer auto-flips the
      // shot to "Video Hero". Hugo reviews, approves, then sets that status manually. Frames
      // (CONCEPT-APPROVED) and image upscales (UPSCALED) keep their auto-status behaviour.
      if (kind !== "video") {
        const statusKey = (kind === "upscale") ? "UPSCALED" : "CONCEPT-APPROVED";
        if (typeof window.__updateShotStatus === "function") {
          window.__updateShotStatus(openShotId, statusKey);
        }
      }
      if (kind === "video" || kind === "upscale") {
        if (typeof window.__updateShotField === "function") {
          window.__updateShotField(openShotId, { hero_video_label: baseVer });
        }
      } else if (kind === "frame") {
        // v07zz47 — Upscale-aware optimistic promote. When the
        // versionLabel passed in is itself an upscale label
        // ("v001_4k"), promote THAT row directly — don't strip _4k
        // off and look for a sliced frame. Hugo's bug: the star on
        // a 4K tile didn't stick because the optimistic update
        // flipped v001_f1 (wrong row) while the server flipped the
        // upscale row. The two were out of sync until the refetch.
        const isUpscaleLabel = /^v\d+_4k$/i.test(String(versionLabel));
        // v07zz316 — per-quadrant tiles pass a specific slice label (e.g. "v007_f2"). Hero
        // THAT exact row optimistically (the backend /hero already heroes the exact slice +
        // records slice_prefs when given a full _fN label).
        const isSliceLabel = /^v\d+_f[1-4]$/i.test(String(versionLabel));
        if (isUpscaleLabel || isSliceLabel) {
          setRealVersions(prev => {
            const demoted = prev.map(v => v.kind === "hero" ? { ...v, kind: "frame" } : v);
            const idx = demoted.findIndex(v =>
              v.version_label === versionLabel
              && (v.kind === "frame" || v.kind === "upscale" || v.kind === "hero"));
            if (idx >= 0) {
              const out = demoted.slice();
              out[idx] = { ...out[idx], kind: "hero" };
              return out;
            }
            return demoted;
          });
        } else {
          // v07perf — Optimistic flip targets exactly ONE row (the saved
          // preferred slice for this version, or f1 if no pref). Previous
          // version flipped ALL f1-f4 rows to kind="hero", which is what
          // caused "all the slices are heroed by default" in the grid
          // modal — and after a server round-trip the boot migration
          // would collapse it back to one anyway. Matching server logic
          // exactly here means no flash-of-multi-hero on click.
          const prefMap = (shot && shot.slice_prefs) || {};
          const savedF = prefMap[baseVer];
          const targetSliceN = Number.isFinite(savedF) ? savedF : 1;
          const targetSliceLabel = `${baseVer}_f${targetSliceN}`;
          setRealVersions(prev => {
            // First demote every hero in the shot (single hero invariant).
            const demoted = prev.map(v => v.kind === "hero" ? { ...v, kind: "frame" } : v);
            // Then promote: prefer the saved-pref slice; fall back to f1;
            // fall back to bare label (for single-image gens).
            const wantsList = [targetSliceLabel, `${baseVer}_f1`, baseVer];
            for (const want of wantsList) {
              const idx = demoted.findIndex(v =>
                v.version_label === want
                && (v.kind === "frame" || v.kind === "hero"));
              if (idx >= 0) {
                const out = demoted.slice();
                out[idx] = { ...out[idx], kind: "hero" };
                return out;
              }
            }
            return demoted;
          });
        }
      }
    } else {
      // Toggle-off: revert stage one notch + clear the hero pin
      // locally so the UI flips immediately. Backend's refetch will
      // reconcile (it just clears with no fallback now, per v06z).
      if (kind === "video" || kind === "upscale") {
        // v07zz582 — withdrawing a video proposal clears the hero_video_label marker but no
        // longer touches stage_status (de-linked — Hugo owns "Video Hero" manually). Image
        // upscales keep their VIDEO-WIP downgrade.
        if (kind === "upscale" && typeof window.__updateShotStatus === "function") {
          window.__updateShotStatus(openShotId, "VIDEO-WIP");
        }
        if (typeof window.__updateShotField === "function") {
          window.__updateShotField(openShotId, { hero_video_label: null });
        }
      } else if (kind === "frame") {
        if (typeof window.__updateShotStatus === "function") {
          window.__updateShotStatus(openShotId, "CONCEPT-WIP");
        }
        // v07b — optimistically demote ALL kind="hero" rows so the
        // strip ★ disappears + the pill flips back to "HERO THIS FRAME"
        // without waiting for the backend round trip.
        setRealVersions(prev => prev.map(v =>
          v.kind === "hero" ? { ...v, kind: "frame" } : v
        ));
      }
    }

    try {
      const fetcher = window.authFetch || fetch;
      // Fire-and-forget. The debounced stage-status refetch effect
      // above reconciles realVersions 700ms after the local stage
      // flip, which is plenty of time for both PATCHes to land. Doing
      // a refetch here too would race with the optimistic mutation
      // (server could return stale data before the /hero call lands).
      fetcher(`/api/shots/${encodeURIComponent(openShotId)}/hero`, {
        method: "PATCH",
        headers: { "Content-Type": "application/json" },
        // v07zz316 — for a specific quadrant tile (v007_f2) send the FULL label so the
        // backend heroes that exact slice + records the slice pref; bare/upscale unchanged.
        body: JSON.stringify({ kind, version_label: (kind === "frame" && /^v\d+_f[1-4]$/i.test(String(versionLabel))) ? versionLabel : baseVer, toggle: !!isToggleOff }),
      }).then(() => {
        // Force the parent App to refetch /api/data so the shotlist
        // thumbnail picks up the new hero. Without this, rowToShot's
        // asset_versions fallback returns the new hero URL on the
        // server but the frontend keeps showing the cached old URL.
        if (typeof window.reloadAppData === "function") {
          window.reloadAppData();
        }
      }).catch(() => {});
    } catch (e) { /* ignore */ }
  }, [openShotId]);

  // v07zz291 — discard a frame version (e.g. one just added from the library, or any
  // unwanted version). Soft-archive EVERY asset_versions row in the slot so it leaves
  // the strip; it's reversible from Media → Archived Frames, so no confirm needed.
  const discardVersion = React.useCallback((slot) => {
    if (!slot || (window.hasPerm && !window.hasPerm("edit_shots"))) return;
    const ids = [slot.hero, slot.upscale, slot.grid, ...(slot.items || [])]
      .filter(Boolean).map(r => r.id).filter(Boolean);
    if (!ids.length) return;
    setRealVersions(prev => prev.filter(v => !ids.includes(v.id)));   // optimistic
    const f = window.authFetch || fetch;
    Promise.all(ids.map(id => f(`/api/asset-versions/${id}/archive`, {
      method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ archived: true }),
    }).catch(() => {}))).then(() => { if (window.reloadAppData) window.reloadAppData(); });
  }, []);

  // v07zz534 — FRAMES funnel: move one version between HERO (root, published) /
  // WIP (frames/_wip, published=0) / ARCHIVED (this shot's frames/_archived). Reuses
  // existing endpoints — publish true/false shuttles _wip↔root + flips published;
  // archive true/false shuttles ↔_archived + flips archived. A restore-then-place is
  // sequenced so an archived tile can go straight to WIP or HERO. Optimistically drops
  // the tile from the current tab, then refetches the live + archived lists + the card.
  const moveFrame = React.useCallback(async (slot, dest, fromArchived) => {
    if (!slot || !openShotId) return;
    // v07zz553 — VIDEO slots carry TWO rows (the video + its _4k upscale twin); frame
    // slots carry one. Publish pairs the family server-side, so one call covers both;
    // archive/restore is per-row, so loop every id in the slot.
    const ids = (slot.items || []).filter(Boolean).map(r => r.id).filter(Boolean);
    const id = ids[0];
    if (!id) return;
    const f = window.authFetch || fetch;
    // optimistic: remove from whichever list is on screen
    if (fromArchived) setArchivedVersions(prev => Array.isArray(prev) ? prev.filter(v => !ids.includes(v.id)) : prev);
    else setRealVersions(prev => prev.filter(v => !ids.includes(v.id)));
    const publish = (v) => f(`/api/asset-versions/${id}/publish`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ published: v }) });
    const archive = async (v) => { for (const i of ids) { await f(`/api/asset-versions/${i}/archive`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ archived: v }) }).catch(() => {}); } };
    try {
      // v856 — VIDEO tiers route through /video-tier, which moves the clip's whole family
      // into that tier's folder and caches the tier. Frames keep the publish/archive pair:
      // they have no Good tier, and rewiring them would be scope creep on a working funnel.
      const _isVid = (slot.items || []).some(r => /\.(mp4|mov|webm|m4v)$/i.test(String((r && (r.file_path || r.cloud_url)) || "").split("?")[0]));
      const setTier = (t) => f(`/api/asset-versions/${id}/video-tier`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ tier: t }) });
      if (_isVid && ["hero", "good", "wip", "archive"].includes(dest)) {
        // Restore first so an archived clip can go straight to Good or Hero — the tier move
        // needs the file back out of _archived before it can place it.
        if (fromArchived && dest !== "archive") await archive(false);
        await setTier(dest === "archive" ? "archived" : dest);
        // Keep the legacy pair honest for anything still reading it (shotlist hero map,
        // Media tabs, the review scanner) until they are migrated to video_tier.
        if (dest === "archive") await archive(true);
        else await publish(dest === "hero");
      }
      else if (dest === "archive") { await archive(true); }
      else if (dest === "hero") { if (fromArchived) await archive(false); await publish(true); }
      // v900 — FRAMES have a Good tier now: /video-tier moves the file into frames/_good,
      // demotes published and stamps video_tier=good. Un-star = the plain WIP demote below
      // (the server moves _good → _wip and clears the tier).
      else if (dest === "good") { if (fromArchived) await archive(false); await setTier("good"); }
      else if (dest === "wip") { if (fromArchived) await archive(false); await publish(false); }
    } catch (_) {}
    // reconcile both lists + the shot-card thumbnail
    try {
      const rr = await f(`/api/asset-versions?asset_id=${encodeURIComponent(openShotId)}`).then(r => r.ok ? r.json() : null);
      if (rr && rr.versions) setRealVersions(rr.versions);
    } catch (_) {}
    try {
      const ar = await f(`/api/asset-versions?asset_id=${encodeURIComponent(openShotId)}&include_archived=1`).then(r => r.ok ? r.json() : null);
      if (ar && ar.versions) { const own = new RegExp("/shots/" + openShotId + "/", "i"); setArchivedVersions(ar.versions.filter(v => v && v.archived && own.test(String(v.file_path || v.cloud_url || "")))); }
    } catch (_) {}
    if (window.reloadAppData) window.reloadAppData();
  }, [openShotId]);

  // v816 — act on ONE version from the review lightbox, by row id. moveFrame() next door
  // works on a filmstrip SLOT (which carries a whole version family); the review modal
  // only knows the single row it's showing, so it gets its own thin wrapper over the same
  // two endpoints. action: "push" (publish → moves the file to the frames root),
  // "wip" (un-publish → back to frames/_wip), "archive" (discard).
  // Deliberately a PLAIN function, not useCallback: it's only ever called from a click
  // handler, so a fresh identity per render costs nothing — and one more hook in this
  // component is one more thing that has to line up on every render path.
  const reviewAct = async (avId, action) => {
    if (!avId || !openShotId) return;
    const f = window.authFetch || fetch;
    try {
      if (action === "archive") {
        await f(`/api/asset-versions/${avId}/archive`, {
          method: "PATCH", headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ archived: true }),
        });
      } else {
        await f(`/api/asset-versions/${avId}/publish`, {
          method: "POST", headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ published: action === "push" }),
        });
      }
    } catch (_) { return; }
    // Re-read both lists so the strip re-badges (and an archived row leaves it).
    try {
      const rr = await f(`/api/asset-versions?asset_id=${encodeURIComponent(openShotId)}`).then(r => r.ok ? r.json() : null);
      if (rr && rr.versions) setRealVersions(rr.versions);
    } catch (_) {}
    try {
      const ar = await f(`/api/asset-versions?asset_id=${encodeURIComponent(openShotId)}&include_archived=1`).then(r => r.ok ? r.json() : null);
      if (ar && ar.versions) { const own = new RegExp("/shots/" + openShotId + "/", "i"); setArchivedVersions(ar.versions.filter(v => v && v.archived && own.test(String(v.file_path || v.cloud_url || "")))); }
    } catch (_) {}
    try { if (window.__assetVersionCache) window.__assetVersionCache.delete(openShotId); } catch (_) {}
    if (window.reloadAppData) window.reloadAppData();
  };

  // v807 — MOVE AN IMAGE BETWEEN SECTIONS. Hugo: "Ive dropped some images 3d renders and
  // storyboards and i need to move them to the correct folders. add a little button on each
  // image to move to another folder." The 📁 button on a tile opens this picker; picking a
  // section calls the server, which physically moves + renames the file and rewrites the row.
  // No native prompt/confirm anywhere (invariant #22) — the picker is a styled portal.
  const SECTIONS = [
    { key: "frames",      label: "Frames",      hint: "the shot's real frames" },
    { key: "storyboards", label: "Storyboards", hint: "sheets and beat boards" },
    { key: "3d",          label: "3D",          hint: "renders and previz" },
    { key: "depth",       label: "Depth",       hint: "depth passes" },
  ];
  const [movePick, setMovePick] = React.useState(null);   // { id, label, current } | null
  const [moveBusy, setMoveBusy] = React.useState(false);
  const [moveErr, setMoveErr]  = React.useState("");
  React.useEffect(() => { setMovePick(null); setMoveErr(""); }, [openShotId]);
  const doMoveSection = React.useCallback(async (id, section) => {
    if (!id || !section || !openShotId) return;
    setMoveBusy(true); setMoveErr("");
    const f = window.authFetch || fetch;
    try {
      const r = await f(`/api/asset-versions/${id}/move-section`, {
        method: "POST", headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ section }),
      });
      const j = await r.json().catch(() => ({}));
      if (!r.ok) { setMoveErr(j.error || "Couldn't move that image."); setMoveBusy(false); return; }
      // Re-read both lists so the image leaves its old strip and appears in the new one.
      try {
        const rr = await f(`/api/asset-versions?asset_id=${encodeURIComponent(openShotId)}`).then(x => x.ok ? x.json() : null);
        if (rr && rr.versions) setRealVersions(rr.versions);
      } catch (_) {}
      try {
        const ar = await f(`/api/asset-versions?asset_id=${encodeURIComponent(openShotId)}&include_archived=1`).then(x => x.ok ? x.json() : null);
        if (ar && ar.versions) { const own = new RegExp("/shots/" + openShotId + "/", "i"); setArchivedVersions(ar.versions.filter(v => v && v.archived && own.test(String(v.file_path || v.cloud_url || "")))); }
      } catch (_) {}
      try { if (window.__assetVersionCache) window.__assetVersionCache.delete(openShotId); } catch (_) {}
      setMovePick(null);
      if (window.reloadAppData) window.reloadAppData();
    } catch (e) {
      setMoveErr("Couldn't move that image.");
    }
    setMoveBusy(false);
  }, [openShotId]);

  // v07zz — "Current WIP version" pointer. Hugo: pick which frame version
  // drives the shot-card thumbnail WITHOUT heroing it. Exposed as a small
  // LOCK toggle on each filmstrip tile + on the big hero image (next to
  // the HERO star), not as a header pill. setCurrentVersion pins the
  // version as shots.active_frame_version (toggle: click the current one
  // to clear).
  const [settingActive, setSettingActive] = React.useState(false);
  const activeFrameVer = (shot && shot.active_frame_version) || null;
  const setCurrentVersion = React.useCallback((versionLabel) => {
    if (!openShotId || settingActive || !versionLabel) return;
    if (window.hasPerm && !window.hasPerm("edit_shots")) return;
    const next = (activeFrameVer === versionLabel) ? null : versionLabel;
    setSettingActive(true);
    if (next) setActiveVersion(next);
    const fetcher = window.authFetch || fetch;
    fetcher(`/api/shots/${encodeURIComponent(openShotId)}/active-version`, {
      method: "PATCH", headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ version_label: next }),
    }).then(() => { if (typeof window.reloadAppData === "function") window.reloadAppData(); })
      .catch(() => {})
      .finally(() => setSettingActive(false));
  }, [openShotId, activeFrameVer, settingActive]);

  // Video takes precedence — once realVersions arrives, switch the
  // active slot to "video-vNNN" of the latest video version. Only
  // overrides the default v003; if the user manually picks a frame
  // afterwards, we don't yank them back.
  // v06q — Hugo: hero thumbnail should follow the shot's stage. When
  // the shot is in FRAME-WIP / FRAME-HERO we want the latest frame on
  // screen; when in VIDEO-WIP / VIDEO-HERO / 4K we want the latest
  // video. Previously the modal always jumped to the latest video as
  // soon as one existed, regardless of stage.
  const _autoVidApplied = React.useRef(null);
  const _pendingAppliedFor = React.useRef(null);   // which shot's pending version we've applied
  React.useEffect(() => {
    if (!openShotId) return;
    // v07zz549 — Review "Updates" → "Open shot": apply the pending version ONCE (the memo already
    // makes it the default on the cold/reset paths; this also covers the warm-cache path where the
    // reset effect skips setActiveVersion). Applied-once survives StrictMode's re-invoke AND a later
    // manual pick; while pending owns the shot the video-first pick below never runs.
    try {
      const pv = window.__pendingShotVersion;
      if (pv && pv.id === openShotId && pv.version) {
        // v07zz557 — the guard is now the marker's SEQ (bumped by every openShot(id, version)
        // call), not "once per shot": clicking Open-shot for a shot that's ALREADY open must
        // re-apply the new pick (same id → no remount, so this effect is the only writer).
        const _seq = pv.seq || 0;
        if (_pendingAppliedFor.current !== openShotId + ":" + _seq) {
          // family-aware: video events target the "video-" slot.
          const _c = String(pv.version).replace(/^video-/, "");
          // v722 — a NOTE's label can be ambiguous ("v004", "v006_4k" — could be an image or
          // a video). family:null now means "work it out", resolved against the shot's own
          // rows: if the only version carrying that label is a video file, target the video
          // slot. Previously anything not explicitly "video" fell to the frame slot, so a
          // note written on a video landed you on a frame — or on nothing.
          let _wantVid = pv.family === "video";
          if (pv.family == null) {
            const _cand = (realVersions || []).filter(v => String(v.version_label) === _c);
            if (_cand.length && _cand.every(v => _isVideoFile(v))) _wantVid = true;
          }
          setActiveVersion(_wantVid ? "video-" + _c : _c);
          _pendingAppliedFor.current = openShotId + ":" + _seq;
          _autoVidApplied.current = openShotId;
          // Flip the strip filter so a WIP target's tile is visible (digest rows are mostly WIP).
          const _rows = (realVersions || []).filter(v => String(v.version_label) === _c);
          const _row = _wantVid ? (_rows.find(v => _isVideoFile(v)) || _rows[0]) : (_rows.find(v => !_isVideoFile(v)) || _rows[0]);
          if (_row && !_row.archived && _row.published === 0) {
            if (_isVideoFile(_row)) setVideosFilter("wip"); else setFramesFilter("wip");
          }
        }
        return;
      }
    } catch (_) {}
    if (!realVersions || realVersions.length === 0) return;
    if (_autoVidApplied.current === openShotId) return;
    const liveShot = shot;
    const stage = (window.getCurrentStage ? window.getCurrentStage(liveShot) : "");
    const prefersVideo = (
      stage === "VIDEO-WIP"
      || stage === "VIDEO-APPROVED"
      || stage === "UPSCALED"
    );
    // v07zw — Only true video files (or video upscales) belong in the
    // VIDEO row. Image upscales (kind='upscale' but .png/.webp) go
    // into the FRAMES row with the rest of the image versions.
    const videos = realVersions
      .filter(v => _isVideoFile(v) && (v.kind === "video" || v.kind === "upscale"))
      .map(v => v.version_label)
      .sort((a, b) => String(a).localeCompare(String(b), undefined, { numeric: true }));
    const frames = realVersions
      .filter(v => v.kind === "hero" || v.kind === "frame" || (v.kind === "upscale" && !_isVideoFile(v)))
      .map(v => String(v.version_label).split("_")[0])
      .filter((v, i, arr) => arr.indexOf(v) === i)
      .sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));

    // v07zz527 — Hugo: whenever a shot HAS a video, the hero defaults to the video
    // (and autoplays), regardless of stage. Only fall back to a frame when there's
    // no video. (`prefersVideo` kept only as a tie-break comment; video always wins now.)
    void prefersVideo;
    if (videos.length > 0) {
      setActiveVersion("video-" + videos[videos.length - 1]);
    } else if (frames.length > 0) {
      setActiveVersion(frames[frames.length - 1]);
    }
    _autoVidApplied.current = openShotId;
  }, [openShotId, realVersions, shot, pendingSeq]);   // v07zz557 — pendingSeq re-fires this when Open-shot targets the already-open shot

  // When the user picks a non-video version, pause + reset the video so the
  // hero shows that version's still image instead of being stuck on the video.
  // v07zz527 — but when switching TO a video, DON'T pause: the hero video now
  // autoplays (muted), and pausing here would immediately kill that autoplay.
  React.useEffect(() => {
    if (String(activeVersion || "").startsWith("video-")) return;
    if (videoRef.current) {
      try { videoRef.current.pause(); videoRef.current.currentTime = 0; } catch (e) {}
    }
    setIsPlaying(false);
    setHeroLoaded(false);
  }, [activeVersion]);

  // v06q — re-align the hero when the SHOT'S STAGE changes (e.g. user
  // clicks a stepper circle or HERO button to bump FRAME-HERO ↔ VIDEO-
  // HERO). Detects the frame⇄video boundary crossing and swaps the
  // active version to match. Doesn't fire on stage transitions inside
  // the same media class (e.g. FRAME-WIP → FRAME-HERO), so the user's
  // current version pick is preserved across those tweaks.
  const _lastStageClass = React.useRef(null);
  React.useEffect(() => {
    if (!openShotId || !realVersions) return;
    const stage = (window.getCurrentStage ? window.getCurrentStage(shot) : "");
    const cls = (stage === "VIDEO-WIP" || stage === "VIDEO-APPROVED" || stage === "UPSCALED")
      ? "video"
      : "frame";
    const prev = _lastStageClass.current;
    _lastStageClass.current = cls;
    if (prev === null || prev === cls) return; // first read, or no class flip
    // v07zw — Same image-vs-video upscale split as the auto-pick effect.
    const videos = realVersions
      .filter(v => _isVideoFile(v) && (v.kind === "video" || v.kind === "upscale"))
      .map(v => v.version_label)
      .sort((a, b) => String(a).localeCompare(String(b), undefined, { numeric: true }));
    const frames = realVersions
      .filter(v => v.kind === "hero" || v.kind === "frame" || (v.kind === "upscale" && !_isVideoFile(v)))
      .map(v => String(v.version_label).split("_")[0])
      .filter((v, i, arr) => arr.indexOf(v) === i)
      .sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
    if (cls === "video" && videos.length > 0) {
      setActiveVersion("video-" + videos[videos.length - 1]);
    } else if (cls === "frame" && frames.length > 0) {
      setActiveVersion(frames[frames.length - 1]);
    }
  }, [shot && shot.stage_status, openShotId, realVersions, shot]);

  // v07zz597 — Hugo: "i need the actual file resolution in here as well".
  // The info block's Resolution row shows the REQUESTED tier ("2K"); this
  // fetches the file's true pixel dimensions from /api/asset-versions/:id/dims
  // (server probes once, caches on the row). The active row (realCur) is only
  // derived AFTER the early returns below, so render stashes its id in
  // _activeRowIdRef and this effect — keyed on the same inputs realCur derives
  // from — reads it post-render. Module cache makes version flips instant.
  const _activeRowIdRef = React.useRef(null);
  const [fileDims, setFileDims] = React.useState(null);
  React.useEffect(() => {
    if (!openShotId) { setFileDims(null); return; }   // closed → render never refreshed the ref
    const id = _activeRowIdRef.current;
    if (!id) { setFileDims(null); return; }
    const hit = _dimsCache.get(id);
    if (hit) { setFileDims(hit); return; }
    setFileDims(null);
    let dead = false;
    const fetcher = window.authFetch || fetch;
    fetcher(`/api/asset-versions/${id}/dims`)
      .then(r => (r && r.ok) ? r.json() : null)
      .then(d => {
        if (dead || !d || !d.width || !d.height) return;
        _dimsCache.set(id, d);
        setFileDims(d);
      })
      .catch(() => {});
    return () => { dead = true; };
  }, [openShotId, activeVersion, realVersions, allShotVersions]);

  if (!openShotId) return null;
  // v07zz48 — `shot` is now declared at the top of the function (see
  // the comment above the function signature). Just guard the null
  // case here so the rest of the body can rely on a non-null `shot`.
  if (!shot) return null;

  const seq = sequences.find(s => s.number === shot.seq);
  // v07zz350 — guard seq.slug (see ShotsPanel): a null slug made `seq.slug.toUpperCase()` throw
  // mid-render and crash the whole app via the root error boundary — most visibly right after
  // duplicating a shot, which auto-opens this modal on the new shot.
  const seqLabel = seq
    ? `SEQUENCE ${String(seq.number ?? shot.seq).padStart(2, "0")}${seq.slug ? " · " + String(seq.slug).toUpperCase() : ""}`
    : `SEQUENCE ${shot.seq}`;
  const hue = window.__seqHue(shot.seq, 100);

  // Real images from the project folder (when available) — first-pass + variants stand in
  // for v001/v002/v003 in the demo. Selected (if present) becomes the locked v003.
  const imgPaths = shot.image_paths || {};
  const variants = imgPaths.first_pass_variants || [];
  const v1Img = variants[0] || imgPaths.first_pass || null;
  const v2Img = variants[1] || imgPaths.first_pass || null;
  const v3Img = imgPaths.selected || imgPaths.first_pass || null;

  // v852 — these SAMPLE prompts blindly interpolated the shot row and appended a full
  // stop, so a field that is empty printed a bare "Light: ." and one that already ended
  // in "." printed "..". Hugo saw "Light: ." on SH0360 right after its candlelight
  // lighting was cleared and reasonably read it as a real injected prompt. `_pline` drops
  // the row entirely when the field is empty and never doubles the stop.
  // NOTE: this block is DEMO TEXT — the dates and model names are invented and nothing
  // here is ever sent to a model. The real prompt is assembled on the Generate page.
  const _pline = (label, val) => {
    const s = (val == null ? "" : String(val)).trim();
    if (!s) return null;
    return `${label}: ${s.replace(/\.+$/, "")}.`;
  };
  const _sample = (head, ...rows) => [head, ...rows.filter(Boolean)].join("\n");
  const _head = `${shot.frame_title || shot.id}${shot.shot_type ? " — " + String(shot.shot_type).replace(/\.+$/, "") + "." : ""}`;
  const versions = [
    { v: "v001", t: "May 18, 2026", note: "Initial draft from prompt", model: "Nano Banana 2", hueShift: 0, image: v1Img,
      prompt: _sample(_head, _pline("Landscape", shot.landscape), _pline("Subject", shot.species), _pline("Light", "bright midday"))
        + `\n\nStyle: photorealistic, 21:9, 2K. (initial draft, no refinement).` },
    { v: "v002", t: "May 21, 2026", note: "Adjusted lighting + composition", model: "Nano Banana 2", hueShift: 12, image: v2Img,
      prompt: _sample(_head, _pline("Landscape", shot.landscape), _pline("Light", shot.weather_light), _pline("Subject", shot.species), _pline("Action", shot.action))
        + `\n\nStyle: blue-chip nature documentary, photorealistic, 21:9, 2K. Slightly warmer skin tones, lower camera height.` },
    { v: "v003", t: "May 24, 2026", note: "Final concept frame — locked", model: "Nano Banana Pro", hueShift: 24, image: v3Img,
      prompt: _sample(_head, _pline("Landscape", shot.landscape), _pline("Light", shot.weather_light), _pline("Subject", shot.species), _pline("Action", shot.action))
        + `\n\nStyle: blue-chip nature documentary, photorealistic, 21:9, 2K, true-to-life colour science, no AI tells, intercut-ready with BBC archive.` },
  ];
  const cur = versions.find(v => v.v === activeVersion) || versions[versions.length - 1];
  const heroBg = window.__seqGradient(hue + cur.hueShift);
  // t19 — overlay real generation metadata when available. Find the
  // asset_versions row whose label matches the active mock version
  // (e.g. "v003"); fall back to the latest if no match.
  // v07zw — Match activeVersion to a row's version_label, OR to its
  // parent (stripped of _4k / _fN suffixes) so clicking a v005 tile
  // that's actually backed by an upscale (v005_4k) or a frame slice
  // (v005_f1) still pulls up a valid row. Preference: exact match
  // first → upscale of the parent → hero → frame.
  // v07perf — Hugo: "model shows Nano Banana instead of the actual
  // video." When activeVersion is "video-vNNN" (the strip's video
  // tile), strip the prefix and look up the VIDEO asset_version
  // explicitly. Without this, _candidates was empty (no row has
  // a label literally equal to "video-v001") and realCur fell back
  // to the first frame row — so the metadata block showed the
  // FRAME's model + prompt instead of the video's.
  const _stripSuffix = (lab) => String(lab || "").replace(/_(4k|f\d+)$/i, "");
  const isVideoActive = String(activeVersion || "").startsWith("video-");
  const activeBase = isVideoActive
    ? activeVersion.slice("video-".length)
    : activeVersion;
  // v07zz547 — resolve the SELECTED version's metadata row. Prefer the published (HERO) list,
  // but fall back to the RAW list (allShotVersions) so a WIP candidate (published=0, excluded
  // from realVersions since v07zz466) resolves its OWN prompt / model / resolution when its
  // tile is clicked. Without the fallback the info + prompt block stayed on the old frame.
  const _matchActiveRow = (list) => (list || []).filter(v => {
    if (isVideoActive) {
      return v.version_label === activeBase
        && (v.kind === "video" || v.kind === "upscale");
    }
    return v.version_label === activeVersion
      || _stripSuffix(v.version_label) === activeVersion;
  });
  let _candidates = _matchActiveRow(realVersions);
  if (_candidates.length === 0) _candidates = _matchActiveRow(allShotVersions);
  const realCur = isVideoActive
    ? (_candidates.find(v => v.kind === "upscale")
       || _candidates.find(v => v.kind === "video")
       || _candidates[0]
       || null)
    : (_candidates.find(v => v.version_label === activeVersion)
       || _candidates.find(v => v.kind === "upscale")
       || _candidates.find(v => v.kind === "hero")
       || _candidates.find(v => v.kind === "frame")
       || _candidates[0]
       || (realVersions || [])[0]
       || null);
  // v07zz597 — stash the active row's id for the file-dims effect declared
  // above the early returns (see its comment). Render always runs before
  // effects, so the effect reads a fresh value.
  _activeRowIdRef.current = realCur ? realCur.id : null;
  const realPrompt = realCur && realCur.prompt_text ? realCur.prompt_text : null;
  // v855 — Hugo: "for videos, I need to see the model being the VIDEO model, not the image
  // mode. fix that." … "base it on the file name."
  // Video clips are made by hand on Kling / Topview / Astra and downloaded, so 277 of the
  // 394 video rows have NO model at all — the Info panel fell through to the row it could
  // find and printed the IMAGE model ("Nano Banana Pro") next to a video. The filename is
  // the reliable record: the folder watcher already stamps the download origin into it
  // (topview.ai -> _seedance, klingai.com -> _kling, astra.app -> _topaz, see
  // db/folderWatcher.js ~line 819). So read it off the name, and only fall back to the
  // stored column when there is no tag — which is also what he asked for.
  const _VIDEO_MODEL_BY_TAG = [
    [/_seedance\b/i, "Seedance"],
    [/_kling\b/i,    "Kling"],
    [/_topaz\b/i,    "Topaz (upscale)"],
    [/_minimax\b/i,  "MiniMax"],
    [/_depth\b/i,    "Depth pass"],
  ];
  function _videoModelFromName(row) {
    const name = String((row && (row.file_path || row.cloud_url)) || "").split(/[\\/]/).pop() || "";
    if (!name) return null;
    for (const [re, label] of _VIDEO_MODEL_BY_TAG) if (re.test(name)) return label;
    return null;
  }
  // A stored value is more SPECIFIC than the filename tag ("seedance-2.0" beats "Seedance"),
  // so it wins where it exists — just tidied, since it is stored as a slug.
  function _prettyVideoModel(m) {
    const s = String(m || "").trim();
    if (!s) return null;
    return s.replace(/^([a-z]+)[-_ ]?([\d.]+)?$/i, (_, name, ver) =>
      name.charAt(0).toUpperCase() + name.slice(1) + (ver ? " " + ver : ""));
  }
  const _isVideoRow = !!(realCur && (realCur.kind === "video" || (realCur.kind === "upscale" && isVideoActive)));
  const realModel  = _isVideoRow
    ? (_prettyVideoModel(realCur.model) || _videoModelFromName(realCur))
    : (realCur && realCur.model ? realCur.model : null);
  const realRes    = realCur && realCur.resolution  ? realCur.resolution  : null;
  // v01i — references come strictly from the assets table now (via
  // asset_versions.reference_paths on the active version). No more
  // borrowing other shots' images as filler — when nothing is linked
  // the panel renders a 'No references linked' placeholder so it's
  // obvious. Falls back to shot.reference_paths when asset_versions
  // hasn't supplied a row yet.
  // v06m — server dedupes once but the client adds a belt-and-braces
  // pass keyed on lowercased filename so anything that slipped through
  // (different casing, mixed cloud vs /local URLs) is collapsed too.
  const rawRefs = Array.isArray(realCur && realCur.reference_paths)
    ? realCur.reference_paths
    : (Array.isArray(shot.reference_paths) ? shot.reference_paths : []);
  const realRefs = (() => {
    const seen = new Set();
    const out = [];
    for (const src of rawRefs || []) {
      if (!src) continue;
      const fname = String(src).split(/[\\/]/).pop().toLowerCase();
      if (!fname || seen.has(fname)) continue;
      seen.add(fname);
      out.push(src);
    }
    return out;
  })();
  // v07zz69 — Hugo: "References ... should only show references USED
  // during a specific image generation, nothing to do with what
  // character is supposed to be in the shot based on the shotlist."
  // Stop resolving shot.reference_assets (the shotlist character slugs)
  // — those are CASTING intent, not actual refs fed into a real
  // generation. References here come only from realCur.reference_paths
  // (the asset_version's actual generation inputs).
  // v07zz236 — Hugo: show ONLY the ACTUAL references used in this version's
  // generation, not the shot's casting slugs. The earlier reference_assets
  // fallback wrongly surfaced linked-but-unused assets (e.g. a "Kentucky, 1813"
  // location he never attached). The real refs come from realCur.reference_paths,
  // which the server now derives from the prompt log's "References attached:"
  // relative paths (see resolvePromptRefPath, server.js). So keep this empty.
  const resolvedAssetRefs = [];
  // v07zz60 — Hugo: "Looks like we once again have some info,
  // reference and prompt sample placeholder in the shot modals.
  // dont want that." When the shot has no real asset_versions at
  // all, the legacy `cur` mock would surface its Nano-Banana model
  // string + sample prompt text. Gate every fallback behind
  // hasRealVersions so shots with no generated content show "—"
  // and an empty prompt area instead of fake metadata.
  const hasRealVersions = !!(realVersions && realVersions.length > 0);
  const displayRefs = realRefs;
  const displayPrompt = realPrompt || (hasRealVersions ? (cur.prompt || "") : "");
  // v855 — the `cur.model` fallback is the MOCK sample row, whose model is always an IMAGE
  // one ("Nano Banana Pro"). On a video that is not a fallback, it is a wrong answer — it is
  // literally what put an image model next to Hugo's video. A video with no filename tag and
  // no stored model shows the dash instead.
  const displayModel  = realModel || (_isVideoRow ? "—" : (hasRealVersions ? (cur.model || "—") : "—"));
  const displayRes    = realRes    || "—";

  // v07zz63 — Hugo: "Clicking outside the model is like using the
  // arrows now, rather than quitting the modal". The old drag-vs-
  // click gating (mousedown + mouseup BOTH on backdrop) was being
  // bypassed when the user pressed near the modal edge and the
  // browser routed the event slightly differently. Simplified: if
  // the actual click event's target is the backdrop itself (i.e.
  // not the modal-card or an arrow), close. e.stopPropagation on
  // those children already prevents bubbling, so this reads as a
  // genuine "clicked outside the modal" intent.
  const onBackdropMouseDown = (e) => { _backdropDown.current = (e.target === e.currentTarget); };
  const onBackdropClick = (e) => {
    if (e.target === e.currentTarget) {
      _backdropDown.current = false;
      onClose && onClose();
    }
  };
  // v07zz61 — Hugo: "the arrows you added are terrible and are
  // NOTHING like the ones you implemented for the Asset modals.
  // i want the exact same implementation and behaviour, arrows
  // outside the modal, no background shift when going to the
  // next modal." Build prev/next from the visible shots list and
  // render them as SIBLINGS of .modal-card on the backdrop with
  // the same .char-modal-nav round-glass-disc styling.
  const navTargets = (() => {
    if (!Array.isArray(shots) || shots.length < 2 || !openShotId) return null;
    const idx = shots.findIndex(s => s.id === openShotId);
    if (idx < 0) return null;
    // v07zz517 — Hugo: prev/next must NOT land on omitted shots (cut from the edit).
    // Walk outward past any omitted / hidden-archive shot in each direction so nav
    // only visits real shots — even when the current shot itself is omitted (opened
    // directly), you can still step away to the nearest visible neighbour.
    const isNavigable = (s) => s && !s.omitted && !s.is_archive;
    let prev = null, next = null;
    for (let i = idx - 1; i >= 0; i--) { if (isNavigable(shots[i])) { prev = shots[i]; break; } }
    for (let i = idx + 1; i < shots.length; i++) { if (isNavigable(shots[i])) { next = shots[i]; break; } }
    return { prev, next };
  })();
  const goToShot = (target) => {
    if (!target) return;
    if (typeof onNavigate === "function") onNavigate(target.id);
    else if (window.__nav && window.__nav.openShot) window.__nav.openShot(target.id);
  };
  // v07zz287 — mobile swipe: left → next shot, right → previous shot (arrows are
  // hidden on phone). Reuses goToShot; no-ops at the list ends.
  const _swipeTier = window.useUiTier ? window.useUiTier() : "";
  const swipeRef = useSwipeNav({
    onPrev: () => goToShot(navTargets && navTargets.prev),
    onNext: () => goToShot(navTargets && navTargets.next),
    enabled: _swipeTier === "s",
  });

  return (
    <div className="modal-backdrop"
         onMouseDown={onBackdropMouseDown}
         onClick={onBackdropClick}>
      {navTargets && (
        <>
          <button
            type="button"
            className="char-modal-nav char-modal-nav--prev"
            onClick={(e) => { e.stopPropagation(); goToShot(navTargets.prev); }}
            disabled={!navTargets.prev}
            aria-label="Previous shot"
            title={navTargets.prev ? `Previous: ${navTargets.prev.id}` : "No previous shot"}
          >
            <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
              <path d="m15 6-6 6 6 6"/>
            </svg>
          </button>
          <button
            type="button"
            className="char-modal-nav char-modal-nav--next"
            onClick={(e) => { e.stopPropagation(); goToShot(navTargets.next); }}
            disabled={!navTargets.next}
            aria-label="Next shot"
            title={navTargets.next ? `Next: ${navTargets.next.id}` : "No next shot"}
          >
            <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
              <path d="m9 6 6 6-6 6"/>
            </svg>
          </button>
        </>
      )}
      <div
        ref={swipeRef}
        className="modal-card modal-card--shot glass"
        onClick={(e) => e.stopPropagation()}
        /* v07x — Earlier v07w hid the entire modal until dataReady.
           That caused the panel to MOUNT visible (briefly), then
           DISAPPEAR for ~200 ms while the fetch ran, then REAPPEAR
           — Hugo called this "way worse than before, absolutely
           horrendous". Reverted. The img-level key={openShotId} +
           per-img onLoad opacity gate that lives on the hero
           itself remains — that's narrowly scoped to the one
           element where the stale-bitmap was visible. */
      >
        <button className="modal-close-btn" onClick={onClose} aria-label="Close">
          <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
        </button>

        {/* v07zz321 — Edit + Duplicate overlays moved to row-triggered singletons
            (ShotEditPicker / ShotDupPicker in ShotsPanel.jsx). Not rendered here. */}

        <div className="modal-header">
          <div className="md-id">{shot.id}</div>
          {/* v07zz314 — click the title to rename the shot (edit_shots). Enter/blur saves,
              Esc cancels. Optimistic + PATCH /api/shots/:id { frame_title }. */}
          {editingTitle ? (
            <input
              className="md-frame md-frame-edit"
              autoFocus
              value={titleDraft}
              maxLength={120}
              placeholder="Shot title…"
              onChange={(e) => setTitleDraft(e.target.value)}
              onKeyDown={(e) => {
                if (e.key === "Enter") { e.preventDefault(); commitTitle(); }
                else if (e.key === "Escape") { e.preventDefault(); setEditingTitle(false); }
              }}
              onBlur={commitTitle}
            />
          ) : (
            <div
              className={"md-frame" + (canEditShots ? " md-frame--editable" : "")}
              title={canEditShots ? "Click to rename this shot" : undefined}
              onClick={canEditShots ? () => { setTitleDraft(shot.frame_title || ""); setEditingTitle(true); } : undefined}
            >
              {shot.frame_title || (canEditShots ? "Click to name this shot" : "Untitled shot")}
            </div>
          )}
          {/* v07zz321 — Cut / Link / Edit / Duplicate all moved OFF the modal onto the shot
              row (next to omit/cut). Only the sequence chip remains here. */}
          <div className="md-chip-row">
            <div className="md-chip">{seqLabel}</div>
            {/* v07zz535 — Copy this shot's VIDEO folder path (same action as the shot row +
                shot queue) so a Kling/Seedance "Save As" dialog can target it. Local-only. */}
            <button type="button" className="md-vidpath-btn"
              title="Copy this shot's video folder path (paste into Kling/Seedance Save As)"
              aria-label="Copy video folder path"
              onClick={(e) => { e.stopPropagation(); if (window.__copyShotVideoFolder) window.__copyShotVideoFolder(shot.id); }}>
              <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><rect x="6" y="3" width="12" height="18" rx="1.5"/><path d="M9 3v2h6V3"/><path d="M9 12h6M9 16h4"/></svg>
            </button>
            {/* v07zz583 — ✓ reviewed-by chip (same derivation as the shot rows: authors who
                commented since the last new image/video version; other people's notes never
                clear YOUR mark — only a new version resets the chip). */}
            {(() => {
              const authors = Array.isArray(shot.note_authors) ? shot.note_authors : [];
              const lastVer = shot.last_version_at || null;
              const reviewers = authors.filter(a => a && a.last_note_at && (!lastVer || String(a.last_note_at) > String(lastVer)));
              if (!reviewers.length) return null;
              const initials = (n) => { const p = String(n || "").trim().split(/\s+/).filter(Boolean); return p.length ? (p[0][0] + (p[1] ? p[1][0] : "")).toUpperCase() : "?"; };
              return (
                <span className="md-reviewed-chip" title={"Reviewed (commented) since the last new version: " + reviewers.map(r => r.name).join(", ")}>
                  <svg viewBox="0 0 24 24" width="10" height="10" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"><path d="m4 12 5 5 11-12"/></svg>
                  {reviewers.slice(0, 4).map(r => <span key={r.id} className="src-init">{initials(r.name)}</span>)}
                  {reviewers.length > 4 && <span className="src-init">+{reviewers.length - 4}</span>}
                </span>
              );
            })()}
            {/* v07zz583 — admin/producer "Needs Reviewing" switch. ON = an open review
                request; it clears PER PERSON as each reviewer comments (Markus's comment
                clears Markus, John still sees REVIEW), and admin can turn it fully off. */}
            {["admin", "producer"].includes(window.__effectiveRole) && (
              <button type="button"
                className={"md-needsreview-btn" + (shot.needs_review ? " is-on" : "")}
                title={shot.needs_review
                  ? "Flagged 'Needs Reviewing' (clears per person as they comment) — click to turn OFF for everyone"
                  : "Flag this shot as 'Needs Reviewing' — reviewers see a REVIEW mark until they comment"}
                onClick={(e) => {
                  e.stopPropagation();
                  const next = !shot.needs_review;
                  const nowSql = new Date().toISOString().slice(0, 19).replace("T", " ");   // match SQLite datetime('now') format for string compares
                  if (window.__updateShotField) window.__updateShotField(shot.id, { needs_review: next, needs_review_at: next ? nowSql : (shot.needs_review_at || null) });
                  (window.authFetch || fetch)(`/api/shots/${encodeURIComponent(shot.id)}/needs-review`, {
                    method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ on: next }),
                  }).then(() => { if (window.reloadAppData) window.reloadAppData(); }).catch(() => {});
                }}>
                <svg viewBox="0 0 24 24" width="10" height="10" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M4 21V4"/><path d="M4 4h12l-2 4 2 4H4"/></svg>
                {shot.needs_review ? "Needs Reviewing" : "Flag for review"}
              </button>
            )}
            {/* v928 — Hugo: "up top i need the button to cut the shot." toggleOmit has
                existed here since v07zz517 with nothing rendering it — the row was the
                only place to cut a shot. Reversible toggle, same pill family as the
                review flag; omitted shots grey out in the shotlist and prev/next skips
                them. Producer/admin only (the endpoint is REQ_PRODUCER_PLUS). */}
            {canCutShots && (
              <button type="button"
                className={"md-needsreview-btn" + (omittedLocal ? " is-on" : "")}
                title={omittedLocal
                  ? "This shot is CUT from the edit — click to restore it"
                  : "Cut this shot from the edit (reversible — it greys out in the shotlist and prev/next skips it)"}
                onClick={(e) => { e.stopPropagation(); toggleOmit(); }}>
                <svg viewBox="0 0 24 24" width="10" height="10" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><circle cx="6" cy="6" r="2.6"/><circle cx="6" cy="18" r="2.6"/><path d="M8 7.5 20 19M8 16.5 20 5"/></svg>
                {omittedLocal ? "CUT — restore" : "Cut shot"}
              </button>
            )}
          </div>
          {/* v07perf (round 4) — Hugo: "Cant it not be in line and
              move to the side when the shot status pill gets longer?"
              Both pills now live inside a single .md-pill-cluster
              wrapper that's absolutely positioned at bottom-right
              with flex layout + a gap. So when the hero pill swaps
              between "FRAME HERO" and "HERO THIS FRAME" (different
              widths), the Generate pill naturally slides leftward
              with it — no overlap, no manual `right` offset to keep
              in sync. */}
          <div className="md-pill-cluster">
          {(() => {
            const hasPerm = (window.hasPerm && window.hasPerm("generate_assets"))
              || (window.__currentUser && window.__currentUser.role === "admin");
            if (!hasPerm) return null;
            const stage = window.getCurrentStage ? window.getCurrentStage(shot) : "PENDING";
            const STAGE_TO_MODE = {
              "PENDING":           "grid",
              "PROMPT":            "grid",
              "FIRST-PASS":        "grid",
              "CONCEPT-WIP":       "single",
              "CONCEPT-APPROVED":  "video",
              "VIDEO-WIP":         "video",
              "VIDEO-APPROVED":    null,
              "UPSCALED":          null,
              "ARCHIVE":           null,
            };
            const nextMode = STAGE_TO_MODE[stage];
            if (nextMode == null) return null;
            const MODE_LABEL = { grid: "Grid", single: "Single image", video: "Video" };
            return (
              <button
                type="button"
                className="md-generate-pill"
                onClick={() => {
                  window.__pendingGenerateShotId = shot.id;
                  window.__pendingGenerateMode = nextMode;
                  try { window.dispatchEvent(new CustomEvent("paradise-pending-generate-shot")); } catch (_) {}
                  if (window.__nav && typeof window.__nav.setView === "function") {
                    window.__nav.setView("generate");
                  }
                  if (onClose) onClose();
                }}
                title={`Open Generate page with ${shot.id} — defaulting to ${MODE_LABEL[nextMode]} mode (stage: ${stage})`}
              >
                <svg viewBox="0 0 24 24" width="11" height="11" fill="currentColor" stroke="none" aria-hidden="true" style={{ marginRight: 5, verticalAlign: "-1px" }}>
                  <path d="M13 2 4 14h6l-1 8 9-12h-6l1-8z"/>
                </svg>
                Generate
              </button>
            );
          })()}
          </div>
        </div>

        {(() => {
          // Video takes precedence in the hero area whenever the
          // active selection is a video version (activeVersion starts
          // with "video-") OR the shot has a static shot.video_path
          // and there's no frame version being actively selected.
          const videoVer = activeVersion && activeVersion.startsWith("video-")
            ? activeVersion.slice("video-".length)
            : null;
          // v07zz553 — SELECTED-version rule (same as frames since v07zz547): prefer the
          // published list, then fall back to the RAW + archived lists so a WIP or
          // archived video picked from the funnel tabs actually plays in the hero.
          const _findVid = (list) => (list || []).find(v => v && v.version_label === videoVer && (v.kind === "video" || v.kind === "upscale"));
          const realVideoRow = videoVer
            ? (_findVid(realVersions) || _findVid(allShotVersions) || _findVid(archivedVersions))
            : null;
          // Server already returns the right URL in file_path
          // (/local/* preferred, cloud_url as fallback). No need to
          // re-pick on the frontend — that just risks reverting to a
          // slow R2 fetch when cloud_url happens to be populated.
          const realVideoSrc = realVideoRow ? (realVideoRow.file_path || realVideoRow.cloud_url) : null;
          const showVideo = !!realVideoSrc || !!shot.video_path;
          const playSrc = realVideoSrc || shot.video_path;
          // Show video in the hero only when the user has explicitly
          // picked a "video-vNNN" entry from the VIDEO row. The legacy
          // `activeVersion === "v003"` fallback (for demo shots that
          // only carry shot.video_path and no asset_versions) stays
          // ALIVE *only* when there is no real version data — once
          // realVersions is populated, "v003" means the v003 frame.
          const hasRealVersions = (realVersions || []).length > 0;
          const showVideoInHero = showVideo && (
            !!videoVer ||
            (!hasRealVersions && !!shot.video_path && activeVersion === "v003")
          );
          // v06f — derive the active hero src so the "reveal in
          // folder" button below has a real local path to open. Mirrors
          // the same selection logic the <img>/<video> tags below use.
          // v06h — fall back to the displayed `cur.image` (the mock /
          // approved path used by every shot) when no real frame is
          // matched, so the reveal button is present from the moment
          // the modal opens — not just after the user picks a version.
          let heroRevealSrc = null;
          if (showVideoInHero) {
            heroRevealSrc = playSrc;
          } else {
            const matchVer = activeVersion && !activeVersion.startsWith("video-") ? activeVersion : null;
            // v06y — ALWAYS prefer the _f1 slice for the displayed
            // hero image. Previously the find returned whichever hero
            // row happened to be first in the asset_versions array,
            // which is non-deterministic — so after a hero PATCH the
            // browser could swap to _f2/_f3/_f4 depending on row order.
            // Pinning to _f1 keeps the image rock-stable across the
            // promote/demote cycle.
            const matchingFrame = matchVer
              ? ((realVersions || []).find(v =>
                  v.version_label === matchVer + "_f1"
                  && (v.kind === "frame" || v.kind === "hero"))
                || (realVersions || []).find(v =>
                  v.kind === "hero" && v.version_label === matchVer))
              : null;
            const realSrc = matchingFrame ? (matchingFrame.file_path || matchingFrame.cloud_url) : null;
            // v07zz220 — Hugo (Railway): if a non-latest frame is hero'd, a COLD
            // version cache made the modal render the guessed "v003"/last frame
            // first, then switch to the hero once the fetch resolved. Until the
            // versions have loaded (dataReady), show the cream placeholder instead
            // of a guessed frame — so the FIRST frame painted is the correct hero,
            // never a wrong one that swaps.
            heroRevealSrc = (!dataReady && !_hasInitialCache) ? null : (realSrc || cur.image || null);
          }
          // v07zz182 — capture the raw URL BEFORE the http-null below, so the
          // Railway download button has the cloud URL to pull from.
          const heroDownloadSrc = heroRevealSrc;
          // Normalise: paths from asset_versions.file_path are
          // WATCH-relative, paths from cur.image are project-relative,
          // and cloud URLs can't be revealed locally. Reject https://
          // entirely so the button hides itself; otherwise the reveal
          // endpoint resolves both /local/... and assets/... shapes.
          if (heroRevealSrc && /^https?:\/\//i.test(heroRevealSrc)) {
            heroRevealSrc = null;
          } else if (heroRevealSrc
                     && !heroRevealSrc.startsWith("/local/")
                     && !heroRevealSrc.startsWith("/")
                     && !heroRevealSrc.startsWith("assets/")) {
            // Bare WATCH-relative — prefix with /local/.
            heroRevealSrc = `/local/${heroRevealSrc}`;
          }
          const RevealBtn = window.RevealInFolderBtn;
          return (
            // v07zz212 — placeholder is the cream card colour (CSS .modal-hero-image),
            // not the old dark heroBg gradient, so the hero materialises onto the card.
            <div className="modal-hero-image">
              {/* v06f — reveal-in-folder button sits next to the
                  full-screen review affordance in the top-right.
                  v06s — round hero star moved out of here; the HERO
                  toggle now lives as a pill in the modal-header. */}
              {/* v07zz — HERO star + CURRENT lock on the big preview, for the
                  version currently shown. Replaces the old header pills:
                  star = hero this frame/video; lock = set as the shot-card
                  thumbnail without heroing (teal when active). */}
              {(() => {
                const spActive = String(activeVersion || "");
                if (!spActive || !(realVersions || []).length) return null;
                const spIsVideo = spActive.startsWith("video-");
                const spBase = spIsVideo ? spActive.slice("video-".length) : spActive.split("_")[0];
                const isHero = spIsVideo
                  ? (!!shot.hero_video_label && String(shot.hero_video_label).split("_")[0] === spBase)
                  : (realVersions || []).some(v => v.kind === "hero" && String(v.version_label).split("_")[0] === spBase);
                // v07zz573 — the current-thumbnail LOCK must use the FULL version label
                //   (spActive, e.g. "v012_4k" / "v007_f1"), NOT the base ("v012"/"v007").
                //   The FRAMES-strip tile lock already uses the full label, and _activeSliceRow
                //   only resolves the full label — so using the base HERE made the two locks
                //   disagree: the highlight never matched what the other set, the toggle-off
                //   never fired, and clicking flipped forever between "v012" and "v012_4k".
                //   That's Hugo's "can't lock the thumbnail, reverts to the one before" on
                //   SH0060 (its active is an upscale, v012_4k). Hero-star still uses spBase —
                //   heroing is base-version-scoped, which is correct.
                const isCurrent = !spIsVideo && activeFrameVer === spActive;
                const heroClick = (e) => {
                  e.stopPropagation();
                  const kind = spIsVideo
                    ? ((realVersions || []).some(v => v.version_label === spBase && v.kind === "upscale") ? "upscale" : "video")
                    : "frame";
                  promoteHero(kind, spBase, !!isHero);
                };
                // v778 — Hugo: "move all the buttons that are on the video and frame
                // strips to the main hero frame… i keep clicking accidentally." The
                // funnel moves (push/WIP/archive) + ◫ depth + ⏮ first-frame now act on
                // the SELECTED version from up here; the tiles are click-to-select only.
                // Resolve the active version's row(s): frames select by FULL label,
                // videos by base family (video + its _4k twin move together, as before).
                const _pool = [
                  ...((allShotVersions && allShotVersions.length ? allShotVersions : realVersions) || []),
                  ...(Array.isArray(archivedVersions) ? archivedVersions : []),
                ];
                let _slot = null, _row = null, _genSrc = null;
                if (spIsVideo) {
                  const fam = _pool.filter(v => String(v.version_label) === spBase && _isVideoFile(v));
                  _row = fam.find(v => v.kind === "video") || fam[0] || null;
                  _genSrc = fam.find(v => v.kind === "upscale") || fam.find(v => v.kind === "video") || fam[0] || null;
                  if (fam.length) _slot = { v: spBase, items: fam };
                } else {
                  _row = _pool.find(v => String(v.version_label) === spActive && !_isVideoFile(v)) || null;
                  _genSrc = _row;
                  if (_row) _slot = { v: spActive, items: [_row] };
                }
                const _bucket = _row ? (_row.archived ? "archived" : (_row.published === 0 ? "wip" : "hero")) : null;
                const _fromArch = _bucket === "archived";
                const _canGen = (!window.hasPerm || window.hasPerm("generate_assets"));
                return (
                  <div className="mhi-hero-tools">
                    {/* v778 — funnel moves for the SELECTED version (bucket-aware, exactly
                        the moves its strip tab used to offer). ⬆ = push to HERO. */}
                    {_slot && (_bucket === "wip" || _bucket === "archived") && canApprove && (
                      <button type="button" className="mhi-tool-btn mhi-move-hero"
                        onClick={(e) => { e.stopPropagation(); moveFrame(_slot, "hero", _fromArch); }}
                        title={spIsVideo ? "Push to HERO (moves to the shot's video folder root)" : "Push to HERO (moves to the shot folder root)"}>
                        <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><path d="M12 19V6"/><path d="M6 11l6-6 6 6"/><path d="M5 21h14"/></svg>
                      </button>
                    )}
                    {_slot && (_bucket === "hero" || _bucket === "archived") && canApprove && (
                      <button type="button" className="mhi-tool-btn mhi-move-wip"
                        onClick={(e) => { e.stopPropagation(); moveFrame(_slot, "wip", _fromArch); }}
                        title="Send to WIP">
                        <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="8.5"/><path d="M12 3.5a8.5 8.5 0 0 0 0 17z" fill="currentColor" stroke="none"/></svg>
                      </button>
                    )}
                    {_slot && (_bucket === "hero" || _bucket === "wip") && canEditShots && (
                      <button type="button" className="mhi-tool-btn mhi-move-arch"
                        onClick={(e) => { e.stopPropagation(); moveFrame(_slot, "archive", false); }}
                        title={spIsVideo ? "Archive (moves to this shot's video/_archived folder)" : "Archive (moves to this shot's _archived folder)"}>
                        <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="4" width="18" height="4" rx="1"/><path d="M5 8v11a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V8M10 12h4"/></svg>
                      </button>
                    )}
                    {/* v778 — ◫ depth map of the SELECTED version (frame → depth PNG,
                        video → depth clip). Hidden for depth outputs themselves. */}
                    {_genSrc && _genSrc.id && _canGen && !_isDepthRow(_row) && (
                      <button type="button" className="mhi-tool-btn mhi-depth"
                        onClick={(e) => { e.stopPropagation(); _requestDepthMap(_genSrc.id); }}
                        title={spIsVideo ? "Convert to DEPTH-MAP VIDEO — lands as a new clip in the DEPTH tab" : "Convert to DEPTH MAP — lands as a new WIP frame"}>
                        <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3 3 8l9 5 9-5-9-5z"/><path d="M3 13l9 5 9-5"/></svg>
                      </button>
                    )}
                    {/* v777 — ⏮ extract this video's FIRST FRAME as a new WIP frame. */}
                    {spIsVideo && _canGen && _genSrc && _genSrc.id && (
                      <button type="button"
                        className={"mhi-tool-btn mhi-firstframe" + (ffBusy === spBase ? " is-active" : "")}
                        disabled={ffBusy === spBase}
                        onClick={(e) => {
                          e.stopPropagation();
                          setFfBusy(spBase);
                          _requestFirstFrame(_genSrc.id, openShotId).then((j) => {
                            setTimeout(() => { setFfBusy(null); if (j && j.ok) setFramesFilter("wip"); }, 1500);
                          });
                        }}
                        title={ffBusy === spBase ? "Extracting…" : "Extract this video's FIRST FRAME as a new WIP frame"}>
                        <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M7 5v14"/><path d="M18 6.5v11L9.5 12z" fill="currentColor" stroke="none"/></svg>
                      </button>
                    )}
                    {/* v976 — ⏭ the mirror of the button above: this video's LAST FRAME. */}
                    {spIsVideo && _canGen && _genSrc && _genSrc.id && (
                      <button type="button"
                        className={"mhi-tool-btn mhi-lastframe" + (lfBusy === spBase ? " is-active" : "")}
                        disabled={lfBusy === spBase}
                        onClick={(e) => {
                          e.stopPropagation();
                          setLfBusy(spBase);
                          _requestLastFrame(_genSrc.id, openShotId).then((j) => {
                            setTimeout(() => { setLfBusy(null); if (j && j.ok) setFramesFilter("wip"); }, 1500);
                          });
                        }}
                        title={lfBusy === spBase ? "Extracting…" : "Extract this video's LAST FRAME as a new WIP frame"}>
                        <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M17 5v14"/><path d="M6 6.5v11L14.5 12z" fill="currentColor" stroke="none"/></svg>
                      </button>
                    )}
                    {!spIsVideo && canEditShots && (
                      <button type="button"
                        className={"mhi-tool-btn mhi-current-lock" + (isCurrent ? " is-active" : "")}
                        onClick={(e) => { e.stopPropagation(); setCurrentVersion(spActive); }}
                        disabled={settingActive}
                        title={isCurrent ? "Shown on the shot card — click to clear" : "Set as the shot's current thumbnail (no hero)"}>
                        <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>
                      </button>
                    )}
                    {canApprove && (
                      <button type="button"
                        className={"mhi-tool-btn mhi-hero-star" + (isHero ? " is-active" : "")}
                        onClick={heroClick}
                        title={isHero ? (spIsVideo ? "Proposed video hero — click to withdraw" : "Frame hero — click to un-hero") : (spIsVideo ? "Propose as video hero (doesn't change the shot status)" : "Hero this frame")}>
                        <svg viewBox="0 0 24 24" width="14" height="14" fill={isHero ? "currentColor" : "none"} stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="m12 3 2.6 5.4 5.9.8-4.3 4.1 1.1 5.9L12 16.8 6.7 19.2l1.1-5.9L3.5 9.2l5.9-.8z"/></svg>
                      </button>
                    )}
                  </div>
                );
              })()}
              <div className="mhi-action-cluster">
                {/* v07perf — Hugo: "we need to have that open grid
                    button on the hero frame, to the left of Open
                    Image location. not on the thumbnails versions
                    where it's too small." Find the grid row for the
                    currently-displayed version base. Only renders if
                    a grid exists for this version. */}
                {(() => {
                  const baseVer = activeVersion && !activeVersion.startsWith("video-")
                    ? String(activeVersion).split("_")[0]
                    : null;
                  if (!baseVer) return null;
                  const gridRow = (realVersions || []).find(v =>
                    v.kind === "grid" && v.version_label === baseVer);
                  if (!gridRow) return null;
                  return (
                    <button
                      className="mhi-expand-btn"
                      type="button"
                      title="Open the grid this frame came from"
                      aria-label="Open grid"
                      onClick={(e) => { e.stopPropagation(); setGridOverlay(gridRow); }}
                    >
                      <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                        <rect x="2" y="2" width="5" height="5" rx="0.6"/>
                        <rect x="9" y="2" width="5" height="5" rx="0.6"/>
                        <rect x="2" y="9" width="5" height="5" rx="0.6"/>
                        <rect x="9" y="9" width="5" height="5" rx="0.6"/>
                      </svg>
                    </button>
                  );
                })()}
                {/* v792 — 🔄 refresh images (Hugo: "sometimes i update the image in
                    photoshop and overwrite so i need to refresh it for it to appear").
                    Bumps the global thumb cache-bust token → every image URL changes →
                    the browser refetches and the server re-encodes anything whose
                    source file is newer than its cached thumb. Also refetches this
                    shot's version rows. */}
                <button
                  className="mhi-expand-btn"
                  type="button"
                  title="Refresh images — reload this shot's pictures after overwriting a file in Photoshop"
                  aria-label="Refresh images"
                  onClick={(e) => {
                    e.stopPropagation();
                    if (window.__bumpThumbCacheBust) window.__bumpThumbCacheBust();
                    _bumpImgTick(t => t + 1);
                    try { window.dispatchEvent(new CustomEvent("paradise-shot-frames-changed")); } catch (_) {}
                  }}
                >
                  <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>
                </button>
                {/* v07zz330 — ALWAYS show Open-folder: reveals the file when there's an image,
                    else opens the shot's (empty) frames folder so Hugo can drop one in. */}
                {RevealBtn && (openShotId || heroRevealSrc) && (
                  <RevealBtn src={heroRevealSrc || null} shotId={openShotId}
                    label={heroRevealSrc ? "Open this file in your file explorer" : "Open this shot's folder — drop an image in to add it"}/>
                )}
                {/* v07zz182 — Railway has no local disk to reveal, so offer a
                    download instead (shows when the frame is a cloud URL). */}
                {window.DownloadFileBtn && heroDownloadSrc && /^https?:\/\//i.test(heroDownloadSrc) && (
                  <window.DownloadFileBtn
                    src={heroDownloadSrc}
                    filename={`${shot.id}${activeVersion ? "_" + activeVersion : ""}${showVideoInHero ? ".mp4" : ".png"}`}
                    label="Download this frame to your computer"/>
                )}
                <button
                  className="mhi-expand-btn"
                  type="button"
                  title="Open full-screen review"
                  aria-label="Open full-screen review"
                  onClick={(e) => { e.stopPropagation(); setReviewOpen(true); }}
                >
                  <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
                    <path d="M4 9V4h5"/><path d="M20 15v5h-5"/>
                    <path d="M9 20H4v-5"/><path d="M15 4h5v5"/>
                  </svg>
                </button>
              </div>
              {showVideoInHero ? (
                <>
                  {(() => {
                    // v07zz357 — the hero placeholder MUST be the VIDEO's OWN frame, not a separate
                    // still. Earlier versions used a matching FRAME still as the <video poster>, but
                    // that still is frequently a DIFFERENT image than the video's actual content, so
                    // clicking a video showed "another frame as placeholder". The VIDEO strip tile
                    // (version-tile--video) gets this right: NO poster, src seeked to #t=0.1 so the
                    // <video> paints its OWN first frame. Mirror that exactly here — each video
                    // version shows its own frame, never a mismatched still. preload="auto" so it
                    // loads promptly; the #t=0.1 seek avoids a black first frame.
                    const seekSrc = playSrc ? (playSrc.indexOf("#") >= 0 ? playSrc : playSrc + "#t=0.1") : playSrc;
                    return (
                      <video
                        ref={videoRef}
                        key={shot.id + ":video:" + (videoVer || "default")}
                        className="mhi-inline-video"
                        src={seekSrc}
                        preload="auto"
                        playsInline
                        /* v07zz527 — Hugo: when a shot has a video, the hero autoplays it.
                           muted + loop so the browser allows autoplay (unmuted autoplay is
                           blocked); the controls (shown once playing) let him unmute. */
                        autoPlay
                        muted={heroMuted}
                        loop
                        controls={isPlaying}
                        /* v855 — a pointer landing on the player is a real user gesture;
                           the opening autoplay never produces one. Flag it on the element
                           and let onPlay decide, so pressing the NATIVE play button unmutes
                           while the autoplay stays silent. */
                        onPointerDown={(e) => { e.currentTarget.dataset.userGesture = "1"; }}
                        onPlay={(e) => {
                          setIsPlaying(true);
                          if (e.currentTarget.dataset.userGesture === "1") setHeroMuted(false);
                        }}
                        onPause={() => setIsPlaying(false)}
                        onEnded={() => setIsPlaying(false)}
                      />
                    );
                  })()}
                  {!isPlaying && (
                    <button
                      className="mhi-play-btn--icon"
                      /* v855 — the overlay ▶ is unambiguously him pressing play, so it
                         unmutes too. onPointerDown wouldn't fire on the <video> here (this
                         button sits over it), hence setting the state directly. */
                      onClick={() => { setHeroMuted(false); try { videoRef.current && videoRef.current.play(); } catch (e) {} }}
                      aria-label="Play video">
                      <svg viewBox="0 0 24 24" width="28" height="28" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
                    </button>
                  )}
                </>
              ) : (
                <>
                  {(() => {
                    // v06y/v07e — ALWAYS prefer the canonical _f1 slice
                    // (regardless of kind="frame" or kind="hero") so
                    // the displayed image stays stable across a hero
                    // promote/demote.
                    // v07e — Hugo's "grid appears on hold" bug: cur.image
                    // for some shots points at imgPaths.first_pass which
                    // is literally the 2x2 grid file. If matchingFrame
                    // briefly evaluates to null during a re-render, the
                    // src would fall back to cur.image → grid in the
                    // hero. Drop the fallback entirely; if no real f1
                    // slice exists, show no img (the parent gradient
                    // fills the frame). The version filmstrip carries
                    // the same image so the user can still identify it.
                    const matchVer = activeVersion && !activeVersion.startsWith("video-") ? activeVersion : null;
                    // v07perf — Two related bugs:
                    //  (a) Clicking an upscaled tile didn't surface the
                    //      upscale (matchingFrame only searched kind="frame"|"hero").
                    //  (b) The hero display was hardcoded to the _f1 slice,
                    //      so picking a different F# via GridDetailModal
                    //      had no visible effect.
                    // Priority order now: upscale → kind="hero" on any F#
                    // for this version (user's pick) → _f1 frame slice →
                    // non-slice hero row.
                    // v07perf — Single shared picker (see helper
                    // pickPreferredFrameRow at module scope). Used here
                    // for the BIG hero AND below in repFor() for the
                    // strip thumb so they can never disagree.
                    // v07zz547 — prefer the published (HERO) list, then fall back to the RAW list
                    // so clicking a WIP tile (published=0, dropped from realVersions in v07zz466)
                    // surfaces THAT frame at the top. Without the fallback the hero stayed on the
                    // previous frame — Hugo: "select a WIP image, it doesn't show at the top."
                    let matchingFrame = matchVer ? pickPreferredFrameRow(realVersions || [], matchVer) : null;
                    if (!matchingFrame && matchVer) matchingFrame = pickPreferredFrameRow(allShotVersions || [], matchVer);
                    // Use the server-decided file_path (already /local/* in dev).
                    const realSrc = matchingFrame ? (matchingFrame.file_path || matchingFrame.cloud_url) : null;
                    // Always fall back to cur.image when no real frame
                    // matches the active version. cur.image now comes
                    // from rowToShot's asset_versions-based fallback —
                    // it's the canonical hero/latest /local/ URL for
                    // this shot, so the modal hero always renders
                    // something rather than a bare gradient.
                    // v07zz343 — COLD open (no prefetch cache): show the cream modal panel (null →
                    // the transparent .modal-hero-image) until realVersions has loaded AND the picker
                    // has set activeVersion to the locked/active version — THEN reveal realSrc, which
                    // fades in over the panel. WARM open (prefetch/reopen): realVersions is seeded from
                    // the cache so realSrc resolves on the first render = the locked version shows at
                    // once, no cream, no swap. cur.image is the last-resort fallback so a shot with
                    // only image_paths (no asset_versions rows) never blanks. The actual "flash the
                    // latest, then snap to locked" was the PHANTOM archived slice surfacing as the
                    // shot's latest version — fixed server-side in _isLiveFolderFrame (v07zz343).
                    const src = (!dataReady && !_hasInitialCache) ? null : (realSrc || cur.image);
                    // v07f — no `key={src}`. With a src-keyed img, React
                    // unmounted + remounted the element every time the
                    // src changed (e.g. promote/demote within the same
                    // shot), so the browser dropped the previously-
                    // decoded bitmap and had to re-fetch + re-decode
                    // → visible quality flicker. Letting React
                    // reconcile keeps the same <img> DOM node so the
                    // in-shot swap is seamless.
                    //
                    // v07g — `key={openShotId}` IS intentional. When
                    // the user clicks a DIFFERENT shot, the persistent
                    // modal re-renders with a new src. Without a key,
                    // the same <img> node keeps showing the PREVIOUS
                    // shot's decoded bitmap until the new src finishes
                    // loading (~1ms on /local/, but 100–300ms on R2 →
                    // visible "wrong shot" flash on Railway). Keying
                    // on openShotId forces a fresh DOM node per shot
                    // so there is no stale bitmap to linger. This does
                    // NOT conflict with v07f: openShotId is stable
                    // across in-shot src changes (promote/demote), so
                    // those still hit React's reconciliation path.
                    // Pipe through window.thumbUrl so the server-side sharp
                    // resizer returns a 1200-wide variant instead of the
                    // full-res original. The full-res render PNGs can be
                    // 4–10 MB each — opening a modal used to download the
                    // whole file before the hero painted. 1200 px is plenty
                    // for the modal hero (which is rendered ~700 px wide
                    // at most) and the resized variant is cached on disk
                    // in db/cache/thumbs after the first hit. R2/Railway
                    // URLs route through /api/r2-thumb which fetches the
                    // original from R2 once, resizes, and caches; smaller
                    // width = smaller transfer to client on first hit.
                    // v07zb/v07ze/v07zt/v07zu — Modal hero width:
                    // 1600 → 1200 → 800 → 1200 → 800. Bumping to
                    // 1200 doubled the Railway transfer size and
                    // Hugo flagged the regression. 800 is the sweet
                    // spot: pre-generated mipmap, ~30-60 KB WebP,
                    // sharp enough at typical modal display sizes.
                    // v07zz398 — hero now displays at ~1124px (modal widened to 1180px), so
                    // the old width-800 fetch was upscaled → blurry. Request 1600: thumbUrl
                    // returns the raw full-res original for /local at >=1200, and an allowlisted
                    // w=1600 r2-thumb for cloud URLs — crisp at the larger display size.
                    const tileSrc = window.thumbUrl ? window.thumbUrl(src, 1600) : src;
                    // v07z8 — Key on openShotId ONLY (not + activeVersion).
                    // Previously the img remounted every time activeVersion
                    // changed (e.g. after the fetch picked a default
                    // hero), causing a re-fade and the "appears, flicks
                    // back to placeholder, fades again" glitch Hugo saw
                    // on Railway. With openShotId-only keying, src updates
                    // in place when the user picks a different version —
                    // the browser handles it as a normal src swap with
                    // the existing decoded bitmap remaining visible
                    // until the new one paints, and no fade-out
                    // intermediate state.
                    // v07zz40 — Hero fade gate (the actual fix the comments above
                    // describe — it had fallen off the <img>). Start at opacity 0
                    // (the .modal-hero-image gradient shows) and reveal only when
                    // THIS src has painted, so a back-to-back shot open never
                    // flashes the previous shot's bitmap. The callback ref adds
                    // the class synchronously (pre-paint) when the image is
                    // already browser-cached → warm opens are instant, no fade;
                    // cold opens fade in. onError + a 3s safety guarantee it can
                    // never stay invisible.
                    // v07zz41 — WARM = instant (no fade), only COLD fades. A URL
                    // we've already loaded this session (prefetch or a prior open)
                    // is in window.__warmedThumbs → render it visible from the
                    // first frame. /local/* is no-cache (revalidates), so <img>.
                    // complete is unreliable for "is it cached" — the set is the
                    // truth. onLoad records the URL so the next open is instant.
                    // v07zz343 — "warm" = this SHOT's hero has already faded in once this session.
                    // Keyed by shot id, NOT the thumb URL: the URL key (window.__warmedThumbs) failed
                    // to match on reopen, so the hero faded on EVERY open. Now the FIRST open of a shot
                    // fades in from the cream panel; every reopen is instant.
                    const _heroFaded = (window.__heroFadedShots = window.__heroFadedShots || new Set());
                    const _heroWarm = _heroFaded.has(openShotId);
                    return tileSrc
                      ? <img
                          key={openShotId}
                          className={"mhi-img" + (_heroWarm ? " mhi-warm" : "")}
                          src={tileSrc}
                          alt={`${shot.id} ${matchVer || cur.v}`}
                          draggable={false}
                          ref={(el) => {
                            if (!el) return;
                            // v07zz342 — only a PRIOR modal open (window.__warmedThumbs) counts as warm.
                            // Dropped the `el.complete && naturalWidth` check: the shot-row prefetch
                            // leaves the hero browser-cached, so el.complete was true on the FIRST
                            // modal open → it popped in instantly with no fade ("fade way too fast").
                            // Now a cold (first) open always fades in from the cream panel.
                            if (_heroWarm) { el.classList.add("mhi-warm"); return; }
                            setTimeout(() => { try { el.classList.add("mhi-loaded"); } catch (_) {} }, 3000);
                          }}
                          onLoad={(e) => {
                            try { (window.__warmedThumbs = window.__warmedThumbs || new Set()).add(tileSrc); } catch (_) {}
                            try { (window.__heroFadedShots = window.__heroFadedShots || new Set()).add(openShotId); } catch (_) {}
                            if (!e.currentTarget.classList.contains("mhi-warm")) e.currentTarget.classList.add("mhi-loaded");
                          }}
                          onError={(e) => e.currentTarget.classList.add("mhi-warm")}
                        />
                      : null;
                  })()}
                </>
              )}
              {!isPlaying && (
                <span className="mhi-tag">{shot.id} · {activeVersion && activeVersion.startsWith("video-") ? activeVersion.slice(6) + " · video" : (activeVersion || cur.v)}</span>
              )}
            </div>
          );
        })()}

        {editingAction ? (
          <textarea
            className="modal-action modal-action-edit"
            autoFocus
            rows={2}
            value={actionDraft}
            placeholder="Action / description…"
            onChange={(e) => setActionDraft(e.target.value)}
            onBlur={() => { setEditingAction(false); commitField("action", actionDraft); }}
            onKeyDown={(e) => {
              if (e.key === "Escape") { e.preventDefault(); setEditingAction(false); }
              else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); setEditingAction(false); commitField("action", actionDraft); }
            }}
          />
        ) : (
          <p
            className={"modal-action" + (canEditShots ? " modal-action--editable" : "")}
            title={canEditShots ? "Click to edit the action / description" : undefined}
            onClick={canEditShots ? () => { setActionDraft(shot.action || ""); setEditingAction(true); } : undefined}
          >
            {shot.action || (canEditShots ? "Click to add an action / description…" : "")}
          </p>
        )}

        {editingVO ? (
          <textarea
            className="modal-vo modal-vo-edit"
            autoFocus
            rows={2}
            value={voDraft}
            placeholder="VO / narration line…"
            onChange={(e) => setVoDraft(e.target.value)}
            onBlur={() => { setEditingVO(false); commitField("narration", voDraft); }}
            onKeyDown={(e) => {
              if (e.key === "Escape") { e.preventDefault(); setEditingVO(false); }
              else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); setEditingVO(false); commitField("narration", voDraft); }
            }}
          />
        ) : shot.narration ? (
          <blockquote
            className={"modal-vo" + (canEditShots ? " modal-vo--editable" : "")}
            title={canEditShots ? "Click to edit the VO line" : undefined}
            onClick={canEditShots ? () => { setVoDraft(shot.narration || ""); setEditingVO(true); } : undefined}
          >
            <span className="notes-glyph" style={{fontFamily: "var(--font-display)", fontSize: "var(--fs-22)", color: "var(--amber)", marginRight: 4}}>“</span>{shot.narration}
          </blockquote>
        ) : (
          <div
            className={"modal-vo-empty" + (canEditShots ? " modal-vo--editable" : "")}
            title={canEditShots ? "Click to add a VO line" : undefined}
            onClick={canEditShots ? () => { setVoDraft(""); setEditingVO(true); } : undefined}
          >
            {canEditShots ? "+ Add a VO line" : "(no VO line for this shot)"}
          </div>
        )}

        <PipelineStepper shot={shot}/>

        {/* v07zb — Info block (Model / Active version / Created /
            References) — same fade treatment as the prompt block.
            Real values until stripReady; visually hidden via CSS
            transition on color while still reserving height so the
            modal doesn't jump. */}
        <div className={"meta-block" + (stripReady ? " meta-block--ready" : " meta-block--loading")}>
          {/* v07zz354 — Shot type + Location: shown on the shot ROW (shot_type subtitle + 📍 landscape)
              but were missing from the modal. Surface them here in the info section. Only when set. */}
          {[
            { f: "shot_type", label: "Shot type", ph: "e.g. 50mm prime. Mid-shot." },
            { f: "landscape", label: "Location",  ph: "e.g. Inside weathered timber barn" },
          ].map(({ f, label, ph }) => (
            <div className="meta-row" key={f}>
              <span className="meta-k">{label}</span>
              {canEditShots && editingMeta === f ? (
                <input
                  className="meta-edit-input" autoFocus value={metaDraft}
                  onChange={e => setMetaDraft(e.target.value)}
                  onBlur={() => { commitField(f, metaDraft); setEditingMeta(null); }}
                  onKeyDown={e => {
                    if (e.key === "Enter") { e.preventDefault(); commitField(f, metaDraft); setEditingMeta(null); }
                    else if (e.key === "Escape") { e.preventDefault(); setEditingMeta(null); }
                  }}
                  placeholder={ph}
                />
              ) : (
                <span
                  className={"meta-v" + (canEditShots ? " meta-v--editable" : "") + (shot[f] ? "" : " meta-v--empty")}
                  onClick={canEditShots ? () => { setMetaDraft(shot[f] || ""); setEditingMeta(f); } : undefined}
                  title={canEditShots ? "Click to edit" : undefined}
                >
                  {shot[f] || (canEditShots ? "Add " + label.toLowerCase() : "—")}
                </span>
              )}
            </div>
          ))}
          <div className="meta-row"><span className="meta-k">Model</span><span className="meta-v mono">{displayModel}</span></div>
          <div className="meta-row"><span className="meta-k">Resolution</span><span className="meta-v mono">{displayRes}</span></div>
          {/* v07zz597 — Hugo: "i need the actual file resolution in here as
              well". True pixel dimensions of the ACTIVE version's file (the
              Resolution row above is the requested tier). Always rendered
              ("—" until the probe returns) so the block height never shifts. */}
          <div className="meta-row"><span className="meta-k">File resolution</span><span className="meta-v mono">{fileDims ? `${fileDims.width} × ${fileDims.height} px` : "—"}</span></div>
          <div className="meta-row"><span className="meta-k">Active version</span><span className="meta-v">{(() => {
            // v07zz60 — When no real versions exist, show "—" instead
            // of the mock activeVersion ("v003").
            if (!hasRealVersions && !realCur) return "—";
            const labelClean = activeVersion && activeVersion.startsWith("video-")
              ? activeVersion.slice("video-".length) + " · video"
              : (activeVersion || cur.v);
            let note = "";
            if (realCur) {
              note = realCur.kind === "hero" ? "Frame hero"
                   : realCur.kind === "frame" ? "Frame candidate"
                   : realCur.kind === "video" ? "Video"
                   : realCur.kind === "upscale" ? "4K upscale"
                   : realCur.kind === "edit" ? "Edit"
                   : realCur.version_label;
            }
            return <>{labelClean}{note ? <> · <em>{note}</em></> : null}</>;
          })()}</span></div>
          {/* v06l — Hugo: date moved off the filmstrip thumbnails into
              the info block. Shows the real asset_version's created_at
              when available, falling back to the mock's friendly date. */}
          <div className="meta-row"><span className="meta-k">Created</span><span className="meta-v">{(() => {
            // v07zz60 — Skip the mock `cur.t` fallback when no real
            // versions exist. Otherwise the modal always shows
            // "May 24, 2026" (the v003 demo date).
            const raw = (realCur && realCur.created_at) || (hasRealVersions ? (cur && cur.t) : null);
            if (!raw) return "—";
            try {
              const d = new Date(raw);
              return Number.isNaN(d.getTime())
                ? raw
                : d.toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" });
            } catch (e) { return raw; }
          })()}</span></div>
          <div className="meta-row"><span className="meta-k">References{(canEditShots && (refsLocked || (shot.reference_assets && shot.reference_assets.length))) ? <button type="button" className={"ref-lock-btn" + (refsLocked ? " is-locked" : "")} onClick={toggleLockRefs} disabled={lockingRefs} title={refsLocked ? "References are pinned — Generate auto-fills + locks them. Click to unlock." : "Pin these references so Generate always pre-fills + locks them"}>{lockingRefs ? "…" : refsLocked ? "🔒 Locked" : "Lock refs"}</button> : null}</span>
            <span className="ref-thumbs">
              {/* v03e — Asset-slug references (from shot.reference_assets,
                  resolved against window.__appData.assets) render
                  alongside the path-based references. Each card shows
                  the asset name + type on hover. */}
              {resolvedAssetRefs.map((a, i) => (
                <span key={`asset-${a.slug}-${i}`} className="ref-card ref-card--asset">
                  <span
                    className="ref-thumb ref-thumb--img"
                    style={a.image ? { backgroundImage: `url(${window.thumbUrl ? window.thumbUrl(a.image, 240) : a.image})`, backgroundSize: "cover", backgroundPosition: "center" } : { background: "linear-gradient(160deg, oklch(0.55 0.04 60), oklch(0.40 0.04 30))" }}
                    aria-label={`Reference: ${a.name}`}
                  />
                  <span className="ref-tip" role="tooltip">
                    {/* v06i — real <img> so the preview takes the image's
                        natural aspect instead of forcing 1:1. */}
                    {a.image
                      ? <img className="ref-tip-img" src={window.thumbUrl ? window.thumbUrl(a.image, 560) : a.image} alt={a.name} loading="eager"/>
                      : <span className="ref-tip-img" style={{ background: "linear-gradient(160deg, oklch(0.55 0.04 60), oklch(0.40 0.04 30))", width: 160, height: 160 }}/>}
                    <span className="ref-tip-meta">
                      <span className="ref-tip-name">{a.name}</span>
                      <span className="ref-tip-type">{a.type}</span>
                    </span>
                  </span>
                </span>
              ))}
              {displayRefs.length === 0 && resolvedAssetRefs.length === 0 ? (
                /* v01i — empty placeholder per spec. No mock thumbs. */
                <span className="ref-empty">No references linked</span>
              ) : (
                displayRefs.slice(0, 8).map((src, i) => {
                  const fname = String(src).split(/[\\/]/).pop() || `ref ${i + 1}`;
                  const ext = (fname.split(".").pop() || "").toLowerCase();
                  const kind = ["mp4","mov","webm","avi","mkv"].includes(ext) ? "video"
                             : ["mp3","wav","aiff","m4a"].includes(ext) ? "audio"
                             : "image";
                  return (
                    <span key={`${src}-${i}`} className="ref-card">
                      <button
                        type="button"
                        className="ref-thumb ref-thumb--img"
                        onClick={(e) => { e.stopPropagation(); setLightbox({ src, caption: `${shot.id} · ${fname}` }); }}
                        style={{ backgroundImage: `url(${window.thumbUrl ? window.thumbUrl(src, 240) : src})`, backgroundSize: "cover", backgroundPosition: "center" }}
                        aria-label={`Open reference ${fname}`}
                      />
                      {/* v01i — hover tooltip with asset name + type +
                          thumbnail. v06i — preview is a real <img> so
                          it takes the source's natural aspect ratio.
                          v07zz597 — Hugo: "what is this icon? says nothing."
                          (a) loading="lazy" never fired inside this hover-only
                          popup (the lazy + hidden-container deadlock — same
                          class as the content-visibility gotcha), so the tip
                          popped as a near-empty sliver → eager. (b) the type
                          line now SAYS what the icon is instead of just
                          "image". */}
                      <span className="ref-tip" role="tooltip">
                        <img className="ref-tip-img" src={window.thumbUrl ? window.thumbUrl(src, 560) : src} alt={fname} loading="eager"/>
                        <span className="ref-tip-meta">
                          <span className="ref-tip-name">{fname}</span>
                          <span className="ref-tip-type">{kind} · reference used to generate this version</span>
                        </span>
                      </span>
                    </span>
                  );
                })
              )}
            </span>
          </div>
        </div>

        {/* v07z4 — Hugo: prompt block was "flickering" — first frame
            showed cur.v with "· sample" suffix + cur.prompt (a hard-
            coded demo prompt from the versions array), then snapped
            to the real prompt_text. Same problem as the version
            strip: we render mock data until the per-shot fetch
            resolves. Solution: don't render the prompt block at
            ALL until realVersions has loaded (dataReady=true). The
            placeholder below holds the same vertical space so the
            modal doesn't reflow. When dataReady flips, the real
            block fades in via the same keyframe used for the
            version strip. */}
        {/* v07z6 — Identical structure loading vs ready so the
            block doesn't visually resize. Loading just hides the
            text content via CSS (color: transparent + visibility on
            the head). Same .pb-head (with copy button placeholder
            kept invisible) so heights match exactly. */}
        <div className={"prompt-block" + (stripReady ? " prompt-block--ready" : " prompt-block--loading")}>
          <div className="pb-head">
            {/* v07perf — Hugo: "past v003 on the frames, it always
                says 'Prompt - v003' instead of the actual number."
                cur.v was stuck at v003 because the `versions` mock
                only has v001-v003; activeVersion="v004"/"v005"/
                "video-v001" fell through to versions[2]. Use the
                actual active version label here instead, with the
                "video" qualifier when applicable. */}
            <span className="pb-label">{stripReady
              ? (hasRealVersions
                  ? `PROMPT — ${isVideoActive ? `${activeBase} · video` : (activeVersion || cur.v)}${realPrompt ? "" : " · sample"}`
                  : "PROMPT")
              : "PROMPT"}</span>
            <button
              className="copy-btn"
              onClick={() => { try { navigator.clipboard && navigator.clipboard.writeText(displayPrompt); } catch (e) {} }}
              disabled={!stripReady}
              style={stripReady ? null : { visibility: "hidden" }}
            >{MIcon.copy}<span>Copy</span></button>
          </div>
          <pre className="pb-code">{stripReady ? displayPrompt : " "}</pre>
        </div>

        {(() => {
          // Build version history from real asset_versions rows.
          // Frames row: group all frame/hero rows by parent "vNNN"; pick
          // a representative per version (hero kind wins, else f1, else
          // any). Versions sort ascending v001 → vNNN.
          // Video row: video + upscale rows; one entry per vNNN with a
          // 4K badge when an upscale exists for that version.
          // v07zz43 — Hugo: "each upscale version should be a new
          // version. we should be able to see every version and click
          // on different upscale variations." parentVer now strips
          // ONLY the _fN slice suffix, NOT _4k. That means an upscale
          // row (label "v001_4k", "v002_4k", …) gets its own slot
          // with its own filmstrip tile instead of being collapsed
          // into the parent version. v001 (frame) and v001_4k (its
          // upscale) become two distinct, separately-clickable tiles.
          const parentVer = (lbl) => String(lbl || "").replace(/_f\d+$/i, "");
          // v07zz316 — Hugo: "mirror the folder." ONE tile per frame ROW (every quadrant
          // v007_f1/_f2/_f3/_f4 shows separately) instead of collapsing a grid version into
          // a single tile. Videos still group by version. Each frame slot carries its own
          // file so the tile + (when clicked) the big hero show that exact frame.
          // v07zz553 — the VIDEO strip has the same Hero | WIP | Archived funnel as FRAMES:
          //   HERO     = pushed videos at video/ root (published, non-archived) = realVersions
          //   WIP      = un-pushed candidates in video/_wip (published=0, non-archived)
          //   ARCHIVED = discarded videos in this shot's video/_archived (lazy-fetched)
          const _isVideoRow = (v) => v && (v.kind === "video" || (v.kind === "upscale" && _isVideoFile(v)));
          // v774 — DEPTH tab: depth-map clips (identified by their "Depth map of …"
          // prompt_text provenance) get their own bucket between Hero and WIP, and are
          // EXCLUDED from Hero + WIP so the gray clips stop drowning the real videos.
          // Archived stays inclusive — a discarded depth map still shows there.
          // v856 — the 4-TIER funnel: Archived (bad takes) | WIP (usable) | Good (⭐ the
          // good takes, many) | Hero (client-ready, many). `video_tier` is the server's
          // cache of which folder the clip sits in. Rows written before v856 have no tier
          // yet, so each bucket falls back to the old published/archived reading — that
          // keeps every existing clip visible until the boot reconcile has stamped them.
          const _tierOf = (v) => {
            const t = String((v && v.video_tier) || "").toLowerCase();
            if (t === "archived" || t === "wip" || t === "good" || t === "hero") return t;
            if (v && v.archived) return "archived";
            return (v && v.published === 0) ? "wip" : "hero";
          };
          const _inTier = (v, tier) => !_isDepthRow(v) && _tierOf(v) === tier;
          const _videoSource = videosFilter === "wip"
            ? (allShotVersions || []).filter(v => !v.archived && _inTier(v, "wip"))
            : videosFilter === "good" ? (allShotVersions || []).filter(v => !v.archived && _inTier(v, "good"))
            : videosFilter === "depth" ? (allShotVersions || []).filter(v => !v.archived && _isDepthRow(v))
            : videosFilter === "nodupes" ? (allShotVersions || []).filter(v => !v.archived && v.kind === "nodupes")
            : videosFilter === "archived" ? (archivedVersions || [])
            // v912 — the MASTER's client-ready videos, display only (read-only tiles).
            : videosFilter === "master" ? (masterVersions || []).filter(v => v && v.published !== 0 && !v.archived && !_isDepthRow(v) && _tierOf(v) === "hero")
            : (realVersions || []).filter(v => !_isDepthRow(v) && _tierOf(v) === "hero");   // hero
          const _vGoodCount = (allShotVersions || []).filter(v => _isVideoRow(v) && !v.archived && _inTier(v, "good")).length;
          const _vWipCount = (allShotVersions || []).filter(v => _isVideoRow(v) && !v.archived && _inTier(v, "wip")).length;
          const _vDepthCount = (allShotVersions || []).filter(v => _isVideoRow(v) && !v.archived && _isDepthRow(v)).length;
          // v1042 — hand-fixed duplicates. Counted off the KIND, not a path sniff:
          // they are ingested as kind="nodupes" so there is nothing to guess at.
          const _vNodupCount = (allShotVersions || []).filter(v => v && v.kind === "nodupes" && !v.archived).length;
          const _vArchCount = Array.isArray(archivedVersions) ? archivedVersions.filter(_isVideoRow).length : null;
          // v912 — count of the master's displayable hero videos for the pill label.
          const _vMasterCount = _linkMasterId
            ? (masterVersions || []).filter(v => v && _isVideoRow(v) && v.published !== 0 && !v.archived && !_isDepthRow(v) && _tierOf(v) === "hero").length
            : 0;
          const _videosToggle = (
            <span className="vs-pubfilter" role="group" aria-label="Videos filter">
              <button type="button" className={"vs-pf-btn" + (videosFilter === "hero" ? " is-on" : "")}
                onClick={() => setVideosFilter("hero")} title="Client-ready picks — the files at the root of the shot's video folder">Hero</button>
              {/* v856 — GOOD sits between WIP and Hero: the takes worth keeping, from which
                  the hero is crowned. Placed in funnel order (best → worst) reading left to
                  right so the row tells the story of the pipeline. */}
              <button type="button" className={"vs-pf-btn" + (videosFilter === "good" ? " is-on" : "")}
                onClick={() => setVideosFilter("good")} title="Good takes — the keepers (video/_good). Crown any of them Hero for the client cut.">
                Good{_vGoodCount ? ` (${_vGoodCount})` : ""}</button>
              <button type="button" className={"vs-pf-btn" + (videosFilter === "depth" ? " is-on" : "")}
                onClick={() => setVideosFilter("depth")} title="Depth-map conversions of this shot's videos (made with the ◫ button) — drag one straight to Topview">
                Depth{_vDepthCount ? ` (${_vDepthCount})` : ""}</button>
              {/* v1042 — the hand-fixed duplicates (…_nodupes.mp4). Sits beside Depth
                  because that is what they are for: run ◫ on one of these rather than
                  on the take that still has the repeated frames in it. */}
              <button type="button" className={"vs-pf-btn" + (videosFilter === "nodupes" ? " is-on" : "")}
                onClick={() => setVideosFilter("nodupes")} title="Hand-fixed duplicates (…_nodupes.mp4) — the take with its repeated frames removed. Run the depth map (◫) on these.">
                No-dupes{_vNodupCount ? ` (${_vNodupCount})` : ""}</button>
              <button type="button" className={"vs-pf-btn" + (videosFilter === "wip" ? " is-on" : "")}
                onClick={() => setVideosFilter("wip")} title="Work-in-progress candidates — push one to Hero with the ★ star">
                WIP{_vWipCount ? ` (${_vWipCount})` : ""}</button>
              <button type="button" className={"vs-pf-btn" + (videosFilter === "archived" ? " is-on" : "")}
                onClick={() => setVideosFilter("archived")} title="Discarded videos (in this shot's video/_archived) — restore to WIP or Hero">
                Archived{_vArchCount != null ? ` (${_vArchCount})` : ""}</button>
              {/* v912 — only on a CONTINUATION shot: display the master's hero videos.
                  Read-only: no funnel buttons render on this tab, tiles open the file. */}
              {_linkMasterId && (
                <button type="button" className={"vs-pf-btn" + (videosFilter === "master" ? " is-on" : "")}
                  onClick={() => setVideosFilter("master")}
                  title={`The master shot's Hero videos — display only. Manage them in ${_linkMasterId}'s own modal.`}>
                  ⛓ {_linkMasterId}{_vMasterCount ? ` (${_vMasterCount})` : ""}</button>
              )}
            </span>
          );
          const videoVersions = {};
          for (const v of _videoSource) {
            const parent = parentVer(v.version_label);
            if (!parent) continue;
            const isUpscale = v.kind === "upscale";
            const isVideoUpscale = isUpscale && _isVideoFile(v);
            if (v.kind === "video" || isVideoUpscale) {
              const slot = videoVersions[parent] = videoVersions[parent] || { v: parent, video: null, upscale: null, when: v.created_at };
              if (v.kind === "video") slot.video = v;
              else if (isVideoUpscale) slot.upscale = v;
            }
          }
          // v07zz534 — FRAMES funnel source:
          //   HERO     = chosen frames at the shot-folder root (published, non-archived) = realVersions
          //   WIP      = un-pushed candidates in frames/_wip (published=0, non-archived)
          //   ARCHIVED = discarded frames in this shot's frames/_archived (lazy-fetched)
          const _isImageFrameRow = (v) => {
            const isImageUpscale = v.kind === "upscale" && !_isVideoFile(v);
            return (v.kind === "frame" || v.kind === "hero" || isImageUpscale);
          };
          // v900 — GOOD tier for frames (video_tier === "good"): starred keepers that are
          // not pushed. WIP excludes them so a frame lives in exactly one bucket.
          const _isGoodFrame = (v) => v && v.video_tier === "good" && !v.archived;
          const _frameSource = framesFilter === "wip"
            ? (allShotVersions || []).filter(v => v.published === 0 && !v.archived && !_isGoodFrame(v))
            : framesFilter === "good" ? (allShotVersions || []).filter(v => _isGoodFrame(v))
            : framesFilter === "archived" ? (archivedVersions || [])
            : (realVersions || []);   // hero
          const _goodCount = (allShotVersions || []).filter(v => _isImageFrameRow(v) && _isGoodFrame(v)).length;
          const _wipCount = (allShotVersions || []).filter(v => _isImageFrameRow(v) && v.published === 0 && !v.archived && !_isGoodFrame(v)).length;
          const _archCount = Array.isArray(archivedVersions) ? archivedVersions.filter(_isImageFrameRow).length : null;
          const _framesToggle = (
            <span className="vs-pubfilter" role="group" aria-label="Frames filter">
              <button type="button" className={"vs-pf-btn" + (framesFilter === "hero" ? " is-on" : "")}
                onClick={() => setFramesFilter("hero")} title="Chosen frames — the images at the root of the shot folder">Hero</button>
              <button type="button" className={"vs-pf-btn" + (framesFilter === "good" ? " is-on" : "")}
                onClick={() => setFramesFilter("good")} title="Good takes — starred keepers (frames/_good). Crown one Hero when it wins.">
                Good{_goodCount ? ` (${_goodCount})` : ""}</button>
              <button type="button" className={"vs-pf-btn" + (framesFilter === "wip" ? " is-on" : "")}
                onClick={() => setFramesFilter("wip")} title="Work-in-progress candidates — push one to Hero with the ★ star">
                WIP{_wipCount ? ` (${_wipCount})` : ""}</button>
              <button type="button" className={"vs-pf-btn" + (framesFilter === "archived" ? " is-on" : "")}
                onClick={() => setFramesFilter("archived")} title="Discarded frames (in this shot's _archived) — restore to WIP or Hero">
                Archived{_archCount != null ? ` (${_archCount})` : ""}</button>
            </span>
          );
          const _seenFrameLabels = new Set();
          const frameList = _frameSource
            .filter(v => {
              const isImageUpscale = v.kind === "upscale" && !_isVideoFile(v);
              return (v.kind === "frame" || v.kind === "hero" || isImageUpscale) && v.version_label;
            })
            .filter(v => { const k = String(v.version_label).toLowerCase(); if (_seenFrameLabels.has(k)) return false; _seenFrameLabels.add(k); return true; })
            .map(v => ({ v: v.version_label, src: v.file_path || v.cloud_url, hero: v.kind === "hero", upscale: v.kind === "upscale", grid: null, items: [v], when: v.created_at }))
            .sort((a, b) => {
              const ab = parentVer(a.v), bb = parentVer(b.v);
              if (ab !== bb) return ab.localeCompare(bb, undefined, { numeric: true });
              return a.v.localeCompare(b.v, undefined, { numeric: true });   // bare < _f1 < _f2 ... ; _4k last-ish
            });
          const videoList = Object.values(videoVersions).sort((a, b) => a.v.localeCompare(b.v, undefined, { numeric: true }));
          // Each frame tile is one row → show THAT row's own image (no collapsing).
          const repFor = (slot) => slot.src || null;
          const fmtWhen = (s) => {
            if (!s) return "";
            const d = new Date(s);
            return Number.isFinite(d.getTime())
              ? d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })
              : "";
          };
          // v07w — Hugo: don't render the 3 placeholder mock versions
          // (v001/v002/v003 gradient blanks) when realVersions is
          // either still loading OR genuinely empty. Hugo found the
          // 3→N transition distracting — the panel would start with
          // 3 blank tiles, then suddenly drop to 1 after the fetch
          // returned. Render an empty scroller of the same height
          // instead so the panel itself doesn't shrink.
          if (frameList.length === 0 && videoList.length === 0) {
            // v07z9 — Render BOTH FRAMES + VIDEO empty placeholders
            // so the modal panel height is identical whether or not
            // the shot has versions yet. Hugo: "video row doesn't
            // show up straight away" — was missing in this branch.
            return (
              <>
                <div className="version-section">
                  <div className="vs-label vs-label--row">
                    <span>FRAMES</span>
                    {_framesToggle}
                    {/* v1020 — pull this shot's frame out of the cut. _framesToggle
                        carries margin-right:auto, so this sits hard right. Both the
                        has-frames and the empty branch get it — a shot linked to a
                        master with no image of its own lands in the empty one, which
                        is exactly the case this is for. */}
                    {(!window.hasPerm || window.hasPerm("upload_assets")) && window.EditStillButton && (
                      <window.EditStillButton shotIds={[openShotId]}
                        onDone={() => { if (window.reloadAppData) window.reloadAppData(); }}/>
                    )}
                  </div>
                  <div className="version-strip-wrap">
                    <div className="version-scroller">{addFrameTile}</div>
                  </div>
                </div>
                <div className="version-section">
                  <div className="vs-label vs-label--row">
                    <span>VIDEO</span>
                    {_videosToggle}
                  </div>
                  <div className="version-strip-wrap">
                    <div className="version-scroller">{addVideoTile}</div>
                  </div>
                </div>
              </>
            );
          }
          // v07z3 — Set the strip's load target so onTileImgLoad
          // knows when "all images loaded" condition is met. Counts
          // only tiles that have an src — tiles falling back to a
          // gradient don't issue an HTTP request to track.
          const tilesWithSrc = frameList.filter(s => !!repFor(s)).length;
          if (_stripTotalRef.current !== tilesWithSrc) {
            _stripTotalRef.current = tilesWithSrc;
            // If new total is 0, mark ready instantly.
            if (tilesWithSrc === 0 && !stripReady) {
              // Defer to avoid setState during render.
              setTimeout(() => setStripReady(true), 0);
            }
          }
          return (
            <>
              {/* v07zz673 — ALWAYS render the FRAMES row. Hugo: "having no frames shouldnt
                  make the row disappear, what if i want to add a frame, now i cant."
                  This used to be gated on (frameList.length > 0 || _wipCount > 0 ||
                  framesFilter !== "hero"), which unmounted the whole section for a shot
                  with videos but no frames — and the "+ FROM LIBRARY" tile lives INSIDE it,
                  so the one action that fixes an empty shot was the one thing hidden by it
                  being empty. A dead end you can't click your way out of.
                  It only looked fine before because the branch above catches
                  frames==0 AND videos==0 and draws both empty rows; the moment a video
                  arrived, the shot fell through to here and the FRAMES row vanished
                  (SH0141-SH0144). VIDEO never had this problem — it has an explicit
                  empty-state branch. FRAMES now behaves the same: always present, empty
                  scroller plus the add tile when there's nothing in it. */}
              {true && (
                <div className="version-section">
                  <div className="vs-label vs-label--row">
                    <span>FRAMES</span>
                    {_framesToggle}
                    {/* v1020 — pull this shot's frame out of the cut. _framesToggle
                        carries margin-right:auto, so this sits hard right. Both the
                        has-frames and the empty branch get it — a shot linked to a
                        master with no image of its own lands in the empty one, which
                        is exactly the case this is for. */}
                    {(!window.hasPerm || window.hasPerm("upload_assets")) && window.EditStillButton && (
                      <window.EditStillButton shotIds={[openShotId]}
                        onDone={() => { if (window.reloadAppData) window.reloadAppData(); }}/>
                    )}
                  </div>
                  <div className="version-strip-wrap">
                    {/* v07z7 — Empty placeholder is ALWAYS in flow (no
                        conditional unmount). Real scroller is always
                        absolutely overlaid on top. When stripReady
                        flips, the real scroller transitions opacity
                        0 → 1 over the empty placeholder — no
                        "disappear then re-fade" glitch. */}
                    <div className="version-scroller version-scroller--empty"/>
                    <button type="button" className="version-strip-arrow version-strip-arrow--left"
                            onMouseDown={(e) => e.preventDefault()}
                            onClick={() => heroEl.current && heroEl.current.scrollBy({ left: -320, behavior: "smooth" })}
                            aria-label="Scroll left"
                            style={stripReady ? null : { visibility: "hidden" }}>‹</button>
                    <div
                      className={"version-scroller version-scroller--overlay" + (stripReady ? " is-ready" : "")}
                      ref={heroScrollerRef}
                    >
                      {frameList.map((slot, i) => {
                        const src = repFor(slot);
                        const tileSrc = window.thumbUrl ? window.thumbUrl(src, 320) : src;
                        const isHero = !!slot.hero;
                        const _fromArch = framesFilter === "archived";
                        return (
                          <button key={slot.v}
                            type="button"
                            className={"version-tile" + ((activeVersion === slot.v || (parentVer(activeVersion || "") === parentVer(slot.v) && frameList.findIndex(s => parentVer(s.v) === parentVer(slot.v)) === i)) ? " active" : "") + (isHero ? " is-hero" : "")}
                            onClick={() => setActiveVersion(slot.v)}
                            /* v07d — kill native drag on the tile too:
                               browsers can start an HTML5 drag from the
                               inner <img> even when the click target is
                               a child span. */
                            draggable={false}
                            onDragStart={(e) => e.preventDefault()}
                            style={!src ? {background: `linear-gradient(155deg, oklch(0.45 0.05 ${hue + i*8}), oklch(0.7 0.06 ${hue + 30 + i*8}))`} : undefined}>
                            {src && <img
                                className={"vt-img" + ((window.__warmedThumbs && window.__warmedThumbs.has(tileSrc)) ? " vt-img-warm" : "")}
                                src={tileSrc}
                                alt={slot.v}
                                loading="lazy"
                                draggable={false}
                                /* v07zz41 — already-warm tiles (in the registry, or
                                   decoded at mount) reveal instantly; only cold
                                   tiles fade via onLoad. */
                                ref={(el) => { if (el && ((window.__warmedThumbs && window.__warmedThumbs.has(tileSrc)) || (el.complete && el.naturalWidth > 0))) el.classList.add("vt-img-warm"); }}
                                onLoad={onTileImgLoad}
                                onError={onTileImgLoad}
                              />}
                            {/* v07zz43 — Tile label adapts to the slot:
                                - "v001"       for a frame/grid slot
                                - "v001 · 4K"  for an upscale slot (slot.v
                                  ends in _4k, surface the base + badge) */}
                            {(() => {
                              // v07zz316 — per-quadrant labels: "v007 · 2" for _f2, "v007 · 4K" for _4k.
                              const up = /^(v\d+)_4k$/i.exec(slot.v || "");
                              if (up) return <span className="vt-label">{up[1]} · 4K</span>;
                              const fm = /^(v\d+)_f([1-4])$/i.exec(slot.v || "");
                              if (fm) return <span className="vt-label">{fm[1]} · {fm[2]}</span>;
                              return <span className="vt-label">{slot.v}{slot.upscale ? " · 4K" : ""}</span>;
                            })()}
                            {/* v778 — the tile overlay buttons moved UP to the big-preview hero
                                tools ("too cluttered… i keep clicking accidentally").
                                v790 — Hugo: "add the Hero and Send To wip buttons back to the
                                thumbnails". v791 — "the lock button for thumbnail also needs to
                                get back": 🔒 set-as-current returns too (top-left, teal when
                                active). Archive / depth / open-grid stay in the hero bar. */}
                            {canEditShots && <span
                              className={"vt-current-btn" + (activeFrameVer === slot.v ? " is-active" : "")}
                              onMouseDown={(e) => { e.stopPropagation(); e.preventDefault(); }}
                              onClick={(e) => { e.stopPropagation(); setCurrentVersion(slot.v); }}
                              title={activeFrameVer === slot.v ? "Shown on the shot card — click to clear" : "Set as the shot's current thumbnail (no hero)"}
                              role="button"
                              tabIndex={0}
                              onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setCurrentVersion(slot.v); } }}
                            >
                              <svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>
                            </span>}
                            <span className="vt-actions">
                              {/* v900 — ✓ Mark GOOD — everywhere except the Good strip itself */}
                              {framesFilter !== "good" && canApprove && (
                                <span className="vt-move-btn vt-move-good"
                                  onMouseDown={(e) => { e.stopPropagation(); e.preventDefault(); }}
                                  onClick={(e) => { e.stopPropagation(); moveFrame(slot, "good", _fromArch); }}
                                  title="Mark GOOD take — a keeper (moves to frames/_good). Crown it Hero later."
                                  role="button" tabIndex={0}
                                  onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); moveFrame(slot, "good", _fromArch); } }}>
                                  <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
                                </span>
                              )}
                              {/* ★ Push to HERO — in WIP + GOOD + ARCHIVED */}
                              {(framesFilter === "wip" || framesFilter === "good" || framesFilter === "archived") && canApprove && (
                                <span className="vt-move-btn vt-move-hero"
                                  onMouseDown={(e) => { e.stopPropagation(); e.preventDefault(); }}
                                  onClick={(e) => { e.stopPropagation(); moveFrame(slot, "hero", _fromArch); }}
                                  title="Push to HERO (chosen frames — moves to the shot folder root)"
                                  role="button" tabIndex={0}
                                  onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); moveFrame(slot, "hero", _fromArch); } }}>
                                  <svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round"><path d="m12 3 2.6 5.4 5.9.8-4.3 4.1 1.1 5.9L12 16.8 6.7 19.2l1.1-5.9L3.5 9.2l5.9-.8z"/></svg>
                                </span>
                              )}
                              {/* ◐ Send to WIP — in HERO + GOOD + ARCHIVED */}
                              {(framesFilter === "hero" || framesFilter === "good" || framesFilter === "archived") && canApprove && (
                                <span className="vt-move-btn vt-move-wip"
                                  onMouseDown={(e) => { e.stopPropagation(); e.preventDefault(); }}
                                  onClick={(e) => { e.stopPropagation(); moveFrame(slot, "wip", _fromArch); }}
                                  title="Send to WIP"
                                  role="button" tabIndex={0}
                                  onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); moveFrame(slot, "wip", _fromArch); } }}>
                                  <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="8.5"/><path d="M12 3.5a8.5 8.5 0 0 0 0 17z" fill="currentColor" stroke="none"/></svg>
                                </span>
                              )}
                              {/* v807 — 📁 move this image to another section (Storyboards / 3D) */}
                              {canEditShots && (() => {
                                const _row = (slot.items || [])[0];
                                if (!_row || !_row.id) return null;
                                return (
                                  <span className="vt-move-btn vt-move-section"
                                    onMouseDown={(e) => { e.stopPropagation(); e.preventDefault(); }}
                                    onClick={(e) => { e.stopPropagation(); setMoveErr(""); setMovePick({ id: _row.id, label: slot.v, current: "frames" }); }}
                                    title="Move this image to another section (Storyboards / 3D)"
                                    role="button" tabIndex={0}
                                    onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setMoveErr(""); setMovePick({ id: _row.id, label: slot.v, current: "frames" }); } }}>
                                    <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
                                  </span>
                                );
                              })()}
                            </span>
                          </button>
                        );
                      })}
                      {addFrameTile}
                    </div>
                    <button type="button" className="version-strip-arrow version-strip-arrow--right"
                            onMouseDown={(e) => e.preventDefault()}
                            onClick={() => heroEl.current && heroEl.current.scrollBy({ left: 320, behavior: "smooth" })}
                            aria-label="Scroll right">›</button>
                  </div>
                </div>
              )}
              {/* v07z8 — Hugo: always render the VIDEO row, even
                  empty, so the modal panel doesn't shift in height
                  when a video version arrives. When videoList is
                  empty, show the same .version-scroller--empty
                  placeholder as the FRAMES row uses. */}
              {videoList.length === 0 ? (
                <div className="version-section">
                  <div className="vs-label vs-label--row">
                    <span>VIDEO</span>
                    {_videosToggle}
                  </div>
                  <div className="version-strip-wrap">
                    <div className="version-scroller">{addVideoTile}</div>
                  </div>
                </div>
              ) : (
                <div className="version-section">
                  <div className="vs-label vs-label--row">
                    <span>VIDEO</span>
                    {_videosToggle}
                  </div>
                  <div className="version-strip-wrap">
                    <button type="button" className="version-strip-arrow version-strip-arrow--left"
                            onMouseDown={(e) => e.preventDefault()}
                            onClick={() => videoEl.current && videoEl.current.scrollBy({ left: -320, behavior: "smooth" })}
                            aria-label="Scroll left">‹</button>
                    <div className="version-scroller" ref={videoScrollerRef}>
                      {videoList.map((slot, i) => {
                        const r = slot.upscale || slot.video;
                        // file_path is server-resolved (/local/* on dev, R2 on prod).
                        const src = r && (r.file_path || r.cloud_url);
                        const srcWithSeek = src ? (src + (src.includes("#") ? "" : "#t=0.1")) : null;
                        // v06x — Video hero now persists in
                        // shot.hero_video_label (set by /api/shots/:id/hero).
                        // Compare base labels so v003 === v003_f1 etc.
                        const heroVidBase = shot.hero_video_label
                          ? String(shot.hero_video_label).split("_")[0]
                          : null;
                        // v912 — master tiles never show THIS shot's hero mark, and clicking
                        // one opens the file itself: setActiveVersion resolves labels against
                        // THIS shot's rows, so a master label would select the wrong video.
                        const _isMasterTile = videosFilter === "master";
                        const isHero = !_isMasterTile && heroVidBase && heroVidBase === slot.v;
                        // v07zz553 — the funnel actions move BOTH rows of the slot
                        // (video + its _4k twin) together; publish pairs server-side.
                        const vSlot = { ...slot, items: [slot.video, slot.upscale].filter(Boolean) };
                        const _vFromArch = videosFilter === "archived";
                        const promoteToHero = (e) => {
                          e.stopPropagation();
                          // v07a — toggle: click un-heroes if this is
                          // the current hero, otherwise promotes. No
                          // active-version side-effect.
                          promoteHero(slot.upscale ? "upscale" : "video", slot.v, !!isHero);
                        };
                        return (
                          <button key={"vid-" + slot.v}
                            type="button"
                            className={"version-tile version-tile--video" + (activeVersion === ("video-" + slot.v) ? " active" : "") + (isHero ? " is-hero" : "")}
                            onClick={() => { if (_isMasterTile) { if (src) window.open(src, "_blank"); } else setActiveVersion("video-" + slot.v); }}
                            /* v07zz593 — video tiles are now DRAGGABLE OUT of the app (Hugo:
                               "drag videos to another website, same as images"). Hover prefetches
                               the bytes so the drag carries a real File into Kling/Seedance
                               upload zones; a desktop drop saves the file via DownloadURL. */
                            draggable={!!src}
                            /* v785 — drag rides the GLOBAL data-dragfile machinery (App.jsx),
                               the same proven path as image drags. The old __videoDragStart
                               was a second dataTransfer writer beside the global capture
                               handler; the mixed payload made upload zones reject the drop. */
                            data-dragfile={src || undefined}
                            style={!src ? {background: `linear-gradient(155deg, oklch(0.35 0.06 220), oklch(0.55 0.08 200))`} : undefined}>
                            {srcWithSeek && (
                              <video
                                className="vt-video"
                                src={srcWithSeek}
                                preload="metadata"
                                muted
                                playsInline
                                tabIndex={-1}
                                aria-hidden="true"
                                onLoadedData={(e) => e.currentTarget.classList.add("vt-img-loaded")}
                                onError={(e) => e.currentTarget.classList.add("vt-img-loaded")}
                              />
                            )}
                            <span className="vt-play-overlay" aria-hidden="true">▶</span>
                            <span className="vt-label">{slot.v}{slot.upscale ? " · 4K" : ""}</span>
                            {/* v778 — the tile overlay buttons moved UP to the big-preview hero
                                tools. v790 — Hugo: "add the Hero and Send To wip buttons back to
                                the thumbnails". ONLY those return: the hero-video ★ on the HERO
                                tab, the funnel ★/◐ pair elsewhere. Archive / ⏮ first-frame /
                                ◫ depth stay in the hero bar. */}
                            {canApprove && videosFilter === "hero" && <span
                              className={"vt-hero-btn" + (isHero ? " is-active" : "")}
                              onMouseDown={(e) => { e.stopPropagation(); e.preventDefault(); }}
                              onClick={promoteToHero}
                              title={isHero ? "Proposed video hero — click to withdraw" : "Propose as video hero (doesn't change the shot status)"}
                              role="button"
                              tabIndex={0}
                              onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); promoteToHero(e); } }}
                            >
                              <svg viewBox="0 0 24 24" width="11" height="11" fill={isHero ? "currentColor" : "none"} stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="m12 3 2.6 5.4 5.9.8-4.3 4.1 1.1 5.9L12 16.8 6.7 19.2l1.1-5.9L3.5 9.2l5.9-.8z"/></svg>
                            </span>}
                            <span className="vt-actions">
                              {/* v856 — ✓ Mark GOOD. Hugo's 4-tier video sorting: archived
                                  (bad takes) → wip (usable) → good (the keepers, as many as
                                  he likes) → hero (client-ready). Offered from every tier
                                  except Good itself, so a clip can be promoted from WIP,
                                  rescued from Archived, or stepped back down from Hero. */}
                              {videosFilter !== "good" && !_isMasterTile && canApprove && (
                                <span className="vt-move-btn vt-move-good"
                                  onMouseDown={(e) => { e.stopPropagation(); e.preventDefault(); }}
                                  onClick={(e) => { e.stopPropagation(); moveFrame(vSlot, "good", _vFromArch); }}
                                  title="Mark GOOD — a keeper (moves to video/_good). Crown it Hero later for the client cut."
                                  role="button" tabIndex={0}
                                  onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); moveFrame(vSlot, "good", _vFromArch); } }}>
                                  <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
                                </span>
                              )}
                              {/* ★ Push to HERO — in WIP + GOOD + ARCHIVED */}
                              {(videosFilter === "wip" || videosFilter === "archived" || videosFilter === "good") && canApprove && (
                                <span className="vt-move-btn vt-move-hero"
                                  onMouseDown={(e) => { e.stopPropagation(); e.preventDefault(); }}
                                  onClick={(e) => { e.stopPropagation(); moveFrame(vSlot, "hero", _vFromArch); }}
                                  title="Push to HERO (chosen videos — moves to the shot's video folder root)"
                                  role="button" tabIndex={0}
                                  onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); moveFrame(vSlot, "hero", _vFromArch); } }}>
                                  <svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor" stroke="currentColor" strokeWidth="1.2" strokeLinejoin="round"><path d="m12 3 2.6 5.4 5.9.8-4.3 4.1 1.1 5.9L12 16.8 6.7 19.2l1.1-5.9L3.5 9.2l5.9-.8z"/></svg>
                                </span>
                              )}
                              {/* ◐ Send to WIP — in HERO + GOOD + ARCHIVED.
                                  v979 — GOOD was missing here. This gate was written in v790,
                                  BEFORE v856 gave videos a Good tier; adding that tier never
                                  added it to this button, so a video in Good could be pushed
                                  up to Hero but never stepped back down. The frames strip has
                                  always offered all three (Hugo: "where is the Send To Wip
                                  button on the thumbnails on the videos"). */}
                              {(videosFilter === "hero" || videosFilter === "good" || videosFilter === "archived") && canApprove && (
                                <span className="vt-move-btn vt-move-wip"
                                  onMouseDown={(e) => { e.stopPropagation(); e.preventDefault(); }}
                                  onClick={(e) => { e.stopPropagation(); moveFrame(vSlot, "wip", _vFromArch); }}
                                  title="Send to WIP"
                                  role="button" tabIndex={0}
                                  onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); moveFrame(vSlot, "wip", _vFromArch); } }}>
                                  <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="8.5"/><path d="M12 3.5a8.5 8.5 0 0 0 0 17z" fill="currentColor" stroke="none"/></svg>
                                </span>
                              )}
                            </span>
                          </button>
                        );
                      })}
                      {addVideoTile}
                    </div>
                    <button type="button" className="version-strip-arrow version-strip-arrow--right"
                            onMouseDown={(e) => e.preventDefault()}
                            onClick={() => videoEl.current && videoEl.current.scrollBy({ left: 320, behavior: "smooth" })}
                            aria-label="Scroll right">›</button>
                  </div>
                </div>
              )}
            </>
          );
        })()}

        {/* v781 — STORYBOARDS: Canvas-captured sheets + their depth twins (kind
            'storyboard', outside the frame/video funnel). Renders only when the
            shot has some. Tiles: click = lightbox, drag = full-res image export.
            v783 — scrolls like the other strips (arrows + drag-scroll) and gains
            a Colour | Depth toggle (default Colour). */}
        {(() => {
          const sbAll = ((allShotVersions && allShotVersions.length ? allShotVersions : realVersions) || [])
            .filter(v => v.kind === "storyboard" && !v.archived)
            .sort((a, b) => String(b.version_label).localeCompare(String(a.version_label), undefined, { numeric: true }));
          if (!sbAll.length) return null;
          const _isSbDepth = (v) => /_depth$/i.test(String(v.version_label));
          const sbColour = sbAll.filter(v => !_isSbDepth(v));
          const sbDepth = sbAll.filter(_isSbDepth);
          const sbs = sbFilter === "depth" ? sbDepth : sbColour;
          return (
            <div className="version-section">
              <div className="vs-label vs-label--row">
                <span>STORYBOARDS</span>
                <span className="vs-pubfilter" role="group" aria-label="Storyboards filter">
                  <button type="button" className={"vs-pf-btn" + (sbFilter === "colour" ? " is-on" : "")}
                    onClick={() => setSbFilter("colour")} title="The captured sheets as arranged (full colour)">
                    Colour{sbColour.length ? ` (${sbColour.length})` : ""}</button>
                  <button type="button" className={"vs-pf-btn" + (sbFilter === "depth" ? " is-on" : "")}
                    onClick={() => setSbFilter("depth")} title="The gray depth-map twins — drag one to a depth-control input">
                    Depth{sbDepth.length ? ` (${sbDepth.length})` : ""}</button>
                </span>
              </div>
              <div className="version-strip-wrap">
                <button type="button" className="version-strip-arrow version-strip-arrow--left"
                        onMouseDown={(e) => e.preventDefault()}
                        onClick={() => sbEl.current && sbEl.current.scrollBy({ left: -320, behavior: "smooth" })}
                        aria-label="Scroll left">‹</button>
                <div className="version-scroller" ref={sbScrollerRef}>
                  {sbs.length === 0 && (
                    <div className="version-scroller--empty">No {sbFilter === "depth" ? "depth twins" : "colour sheets"} yet.</div>
                  )}
                  {sbs.map(v => {
                    const src = v.file_path || v.cloud_url;
                    const isDepth = _isSbDepth(v);
                    return (
                      <button key={"sb-" + v.id} type="button" className="version-tile version-tile--storyboard"
                        onClick={() => src && setLightbox({ src, caption: `${openShotId} · storyboard ${v.version_label}` })}
                        title={`Storyboard ${v.version_label}${isDepth ? " (depth-map version)" : ""} — click to view, drag to export the full sheet`}>
                        {src && <img className="vt-img" src={window.thumbUrl ? window.thumbUrl(src, 320) : src} alt="" loading="eager"
                          onLoad={(e) => e.currentTarget.classList.add("vt-img-loaded")}
                          onError={(e) => e.currentTarget.classList.add("vt-img-loaded")} />}
                        <span className="vt-label">{String(v.version_label).replace(/_depth$/i, "")}{isDepth ? " · DEPTH" : ""}</span>
                        {canEditShots && (
                          <span className="vt-actions">
                            <span className="vt-move-btn vt-move-section"
                              onMouseDown={(e) => { e.stopPropagation(); e.preventDefault(); }}
                              onClick={(e) => { e.stopPropagation(); setMoveErr(""); setMovePick({ id: v.id, label: v.version_label, current: "storyboards" }); }}
                              title="Move this image to another section (Frames / 3D)"
                              role="button" tabIndex={0}
                              onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setMoveErr(""); setMovePick({ id: v.id, label: v.version_label, current: "storyboards" }); } }}>
                              <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
                            </span>
                          </span>
                        )}
                      </button>
                    );
                  })}
                </div>
                <button type="button" className="version-strip-arrow version-strip-arrow--right"
                        onMouseDown={(e) => e.preventDefault()}
                        onClick={() => sbEl.current && sbEl.current.scrollBy({ left: 320, behavior: "smooth" })}
                        aria-label="Scroll right">›</button>
              </div>
            </div>
          );
        })()}

        {/* v807 — 3D: renders / previz living in the shot's 3d/ folder (kind 'render3d',
            outside the frame + video funnels, exactly like storyboards). Hugo: "i'm working
            on some 3d shots so I guess we need to add a 3d section to the shot modal, and if
            i use shots from there, they should go in there." Same strip shape as STORYBOARDS
            — arrows, drag-scroll, click for the lightbox, drag for the full-res file — plus
            the 📁 move button on every tile. */}
        {(() => {
          const d3All = ((allShotVersions && allShotVersions.length ? allShotVersions : realVersions) || [])
            .filter(v => v.kind === "render3d" && !v.archived)
            .sort((a, b) => String(a.version_label).localeCompare(String(b.version_label), undefined, { numeric: true }));
          if (!d3All.length) return null;
          const _isD3Depth = (v) => /_depth$/i.test(String(v.version_label));
          return (
            <div className="version-section">
              <div className="vs-label vs-label--row"><span>3D</span></div>
              <div className="version-strip-wrap">
                <button type="button" className="version-strip-arrow version-strip-arrow--left"
                        onMouseDown={(e) => e.preventDefault()}
                        onClick={() => d3El.current && d3El.current.scrollBy({ left: -320, behavior: "smooth" })}
                        aria-label="Scroll left">‹</button>
                <div className="version-scroller" ref={d3ScrollerRef}>
                  {d3All.map(v => {
                    const src = v.file_path || v.cloud_url;
                    const isDepth = _isD3Depth(v);
                    return (
                      <button key={"d3-" + v.id} type="button" className="version-tile version-tile--storyboard"
                        onClick={() => src && setLightbox({ src, caption: `${openShotId} · 3D ${v.version_label}` })}
                        title={`3D render ${v.version_label}${isDepth ? " (depth-map version)" : ""} — click to view, drag to export the full image`}>
                        {src && <img className="vt-img" src={window.thumbUrl ? window.thumbUrl(src, 320) : src} alt="" loading="eager"
                          onLoad={(e) => e.currentTarget.classList.add("vt-img-loaded")}
                          onError={(e) => e.currentTarget.classList.add("vt-img-loaded")} />}
                        <span className="vt-label">{String(v.version_label).replace(/_depth$/i, "")}{isDepth ? " · DEPTH" : ""}</span>
                        {canEditShots && (
                          <span className="vt-actions">
                            <span className="vt-move-btn vt-move-section"
                              onMouseDown={(e) => { e.stopPropagation(); e.preventDefault(); }}
                              onClick={(e) => { e.stopPropagation(); setMoveErr(""); setMovePick({ id: v.id, label: v.version_label, current: "3d" }); }}
                              title="Move this image to another section (Frames / Storyboards)"
                              role="button" tabIndex={0}
                              onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setMoveErr(""); setMovePick({ id: v.id, label: v.version_label, current: "3d" }); } }}>
                              <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
                            </span>
                          </span>
                        )}
                      </button>
                    );
                  })}
                </div>
                <button type="button" className="version-strip-arrow version-strip-arrow--right"
                        onMouseDown={(e) => e.preventDefault()}
                        onClick={() => d3El.current && d3El.current.scrollBy({ left: 320, behavior: "smooth" })}
                        aria-label="Scroll right">›</button>
              </div>
            </div>
          );
        })()}

        {/* v825 — DEPTH: depth passes of this shot's frames, living in the shot's depth/
            folder (kind 'depth', outside the frame + video funnels exactly like 3D and
            storyboards). Hugo: "we need to add a Depth section so when i create a depth
            pass of a frame, it lands in Depth and not wip." Same strip shape as 3D. */}
        {(() => {
          const dpAll = ((allShotVersions && allShotVersions.length ? allShotVersions : realVersions) || [])
            .filter(v => v.kind === "depth" && !v.archived)
            .sort((a, b) => String(a.version_label).localeCompare(String(b.version_label), undefined, { numeric: true }));
          if (!dpAll.length) return null;
          return (
            <div className="version-section">
              <div className="vs-label vs-label--row"><span>DEPTH</span></div>
              <div className="version-strip-wrap">
                <button type="button" className="version-strip-arrow version-strip-arrow--left"
                        onMouseDown={(e) => e.preventDefault()}
                        onClick={() => dpEl.current && dpEl.current.scrollBy({ left: -320, behavior: "smooth" })}
                        aria-label="Scroll left">‹</button>
                <div className="version-scroller" ref={dpScrollerRef}>
                  {dpAll.map(v => {
                    const src = v.file_path || v.cloud_url;
                    return (
                      <button key={"dp-" + v.id} type="button" className="version-tile version-tile--storyboard"
                        onClick={() => src && setLightbox({ src, caption: `${openShotId} · Depth ${v.version_label}` })}
                        title={`Depth pass ${v.version_label} — click to view, drag to export the full image`}>
                        {src && <img className="vt-img" src={window.thumbUrl ? window.thumbUrl(src, 320) : src} alt="" loading="eager"
                          onLoad={(e) => e.currentTarget.classList.add("vt-img-loaded")}
                          onError={(e) => e.currentTarget.classList.add("vt-img-loaded")} />}
                        <span className="vt-label">{v.version_label}</span>
                        {canEditShots && (
                          <span className="vt-actions">
                            <span className="vt-move-btn vt-move-section"
                              onMouseDown={(e) => { e.stopPropagation(); e.preventDefault(); }}
                              onClick={(e) => { e.stopPropagation(); setMoveErr(""); setMovePick({ id: v.id, label: v.version_label, current: "depth" }); }}
                              title="Move this image to another section (Frames / Storyboards / 3D)"
                              role="button" tabIndex={0}
                              onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setMoveErr(""); setMovePick({ id: v.id, label: v.version_label, current: "depth" }); } }}>
                              <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
                            </span>
                          </span>
                        )}
                      </button>
                    );
                  })}
                </div>
                <button type="button" className="version-strip-arrow version-strip-arrow--right"
                        onMouseDown={(e) => e.preventDefault()}
                        onClick={() => dpEl.current && dpEl.current.scrollBy({ left: 320, behavior: "smooth" })}
                        aria-label="Scroll right">›</button>
              </div>
            </div>
          );
        })()}

        {/* t12 — Notes panel for this shot. Default version_label is the
            currently-active version so notes attach to whichever frame is
            on display. */}
        <NotesPanel entityType="shot" entityId={shot.id} versionLabel={activeVersion}/>

        {/* v07zz289 — Add-from-library picker (archived frames + Green Echo) */}
        {addLibOpen && <AddFromLibraryPicker shotId={openShotId} onClose={() => setAddLibOpen(false)} />}
        {/* v07zz321 — Add-from-library picker for VIDEOS (the project's video library) */}
        {addLibVideoOpen && <AddFromLibraryPicker mode="video" shotId={openShotId} onClose={() => setAddLibVideoOpen(false)} />}

        {/* v807 — MOVE-TO-SECTION picker. Opened by the 📁 button on a Frames /
            Storyboards / 3D tile. Styled portal, never a native prompt (invariant #22).
            Every section is always listed — the one the image is already in is disabled
            rather than hidden, so the list never changes height between tiles. */}
        {movePick && ReactDOM.createPortal((
          <div className="modal-backdrop" style={{ zIndex: "var(--z-modal-6)" }} onClick={() => !moveBusy && setMovePick(null)}>
            <div className="confirm-delete-modal glass" onClick={(e) => e.stopPropagation()}>
              <div className="confirm-delete-eyebrow" style={{ color: "var(--warn-deep)" }}>MOVE IMAGE · REVERSIBLE</div>
              <div className="confirm-delete-title">Move <strong>{movePick.label}</strong> to…</div>
              <div className="confirm-delete-body">
                <p>The file moves into that folder on disk and is renamed to the next free version there. You can move it back at any time.</p>
                <div className="move-section-picker">
                  {SECTIONS.map(s => {
                    const here = s.key === movePick.current;
                    return (
                      <button key={s.key} type="button"
                        className={"move-section-opt" + (here ? " is-here" : "")}
                        disabled={here || moveBusy}
                        onClick={() => doMoveSection(movePick.id, s.key)}>
                        <span className="mso-name">{s.label}</span>
                        <span className="mso-hint">{here ? "already here" : s.hint}</span>
                      </button>
                    );
                  })}
                </div>
                {moveErr && <p style={{ color: "var(--red-7)", marginTop: 8 }}>{moveErr}</p>}
              </div>
              <div className="confirm-delete-actions">
                <button type="button" className="admin-suspend-btn" onClick={() => setMovePick(null)} disabled={moveBusy}>
                  {moveBusy ? "Moving…" : "Cancel"}
                </button>
              </div>
            </div>
          </div>
        ), document.getElementById("modal-root") || document.body)}

        {/* t19 — reference lightbox. Mounted inside the modal-card so
            it sits above everything; backdrop click + Escape close. */}
        {window.Lightbox && (
          <window.Lightbox
            src={lightbox && lightbox.src}
            caption={lightbox && lightbox.caption}
            onClose={() => setLightbox(null)}
          />
        )}
        {/* v07zz17 — Grid overlay modal. Renders ABOVE the shot
            modal-card when the user clicks the open-grid button on a
            version tile. */}
        {gridOverlay && window.GridDetailModal && (
          <window.GridDetailModal
            gridVersion={gridOverlay}
            onClose={() => setGridOverlay(null)}
          />
        )}

      </div>

      {/* t14 — full-screen asset review overlay. Rendered as a sibling
          of the modal-card so it sits ABOVE everything else on the
          modal-backdrop. */}
      {window.AssetReviewModal && (() => {
        // v06k — Build TWO ordered lists (frames then videos) the
        // AssetReviewModal can split into two filmstrip rows. Dedupe
        // by base label per row so v003 doesn't appear twice in the
        // same row. Do NOT auto-create a fake v003 video from
        // shot.video_path when real video rows exist — that was the
        // source of SH0010's phantom v003 video entry.
        const stripF = (lab) => String(lab || "").replace(/_f\d+$/i, "");

        // v07zw — Image upscales (kind='upscale' + .png/.webp) belong
        // in the FRAMES row. Only true video files + video upscales
        // (kind='upscale' + .mp4 etc.) go into the VIDEOS row.
        // Build the frames row.
        //
        // Hard-won lesson: when asset_versions ("real") data exists for
        // this shot, we use ONLY that — never mix in the legacy
        // hard-coded v001/v002/v003 mocks. Reason: the 3 mock entries all
        // default to the same shot.image_paths.first_pass when
        // first_pass_variants is missing (which it is for most shots
        // post-folder-watcher), so they show up as 3 IDENTICAL tiles in
        // the filmstrip. We only fall back to the mocks for shots that
        // genuinely have no real frame/hero data (early-stage demos).
        const frameBases = new Set();
        const frames = [];
        // v816 — Hugo: "i need to be able to go through WIP images, promote them or send
        // them back to archive". This strip was built from realVersions — the PUSHED
        // list — so candidates sitting in frames/_wip were invisible here and the only
        // way to judge one full-screen was to push it first. Source the RAW list
        // (allShotVersions = pushed + candidates) and carry each row's published flag
        // through, so the modal can badge a WIP tile and offer the right action.
        // Archived rows stay out: they're the discard pile, reachable from the shot
        // modal's Archived tab, and mixing them in would bury the live candidates.
        const _rawForStrip = (allShotVersions && allShotVersions.length ? allShotVersions : realVersions) || [];
        const realFrameRows = _rawForStrip.filter(rv =>
          !rv.archived &&
          (rv.kind === "hero" || rv.kind === "frame" ||
           (rv.kind === "upscale" && !_isVideoFile(rv)))
        );
        if (realFrameRows.length > 0) {
          // v07zz43 — Upscales as their own filmstrip entries. Two
          // separate buckets:
          //   - frameByBase: kind='frame'|'hero' rows, collapsed by
          //     the parent version (so v001_f1/_f2/_f3/_f4 → one
          //     "v001" tile showing the preferred slice).
          //   - upscaleRows: kind='upscale' rows kept as-is, one
          //     tile per row, labeled with their full version_label
          //     ("v001_4k") rendered as "v001 · 4K" in the UI.
          // This way clicking V001 shows the original frame and
          // clicking V001 · 4K shows the upscale — Hugo can compare
          // different upscale passes (e.g. GPT vs Nano Banana) side
          // by side.
          const frameByBase = new Map();
          const upscaleRows = [];
          for (const rv of realFrameRows) {
            if (rv.kind === "upscale") {
              upscaleRows.push(rv);
              continue;
            }
            const base = stripF(rv.version_label);
            if (!base) continue;
            const existing = frameByBase.get(base);
            if (!existing || (rv.kind === "hero" && existing.kind !== "hero")) {
              frameByBase.set(base, rv);
            }
          }
          for (const [base, rv0] of frameByBase.entries()) {
            if (frameBases.has(base)) continue;
            frameBases.add(base);
            // v07zz308 — pick the representative slice with the SAME function the shot
            // modal's own FRAMES strip uses (pickPreferredFrameRow → prefers _f1), so the
            // review lightbox filmstrip shows the IDENTICAL image per version. The old
            // frameByBase "first row wins" landed on a different quadrant (the endpoint
            // returns v006_f4 before v006_f1), so the two filmstrips disagreed.
            // v816 — pick the representative slice from the SAME raw pool the row came
            // from; passing realVersions here would look up a WIP base in the published
            // list, miss, and fall back to rv0 — a different quadrant than the strip.
            const rv = pickPreferredFrameRow(_rawForStrip, base) || rv0;
            frames.push({
              label: base, image: rv.file_path, video: null, kind: "image",
              time: rv.created_at, model: rv.model,
              is4k: false,
              prompt: rv.prompt_text || null,
              row_kind: rv.kind,
              // v816 — what the modal needs to badge it and act on it.
              av_id: rv.id,
              published: rv.published !== 0,
            });
          }
          for (const rv of upscaleRows) {
            if (frameBases.has(rv.version_label)) continue;
            frameBases.add(rv.version_label);
            frames.push({
              label: rv.version_label, image: rv.file_path, video: null, kind: "image",
              time: rv.created_at, model: rv.model,
              is4k: true,
              prompt: rv.prompt_text || null,
              row_kind: "upscale",
              av_id: rv.id,
              published: rv.published !== 0,
            });
          }
        }
        // v07zz60 — Hugo: "why do I have 3 placeholder versions in
        // the filmstrip still?" The legacy `versions` mock fallback
        // (v001/v002/v003 with no images) is gone. When the shot
        // has no real frame rows, frames stays empty and the
        // AssetReviewModal early-returns instead of rendering
        // phantom tiles.
        frames.sort((a, b) => a.label.localeCompare(b.label, undefined, { numeric: true }));

        // Build the videos row — ONLY from real video / video-upscale rows.
        const videoBases = new Set();
        const videos = [];
        for (const rv of (realVersions || [])) {
          // v07zw — kind='upscale' + image extension stays in frames;
          // only video files (or video upscales) land here.
          const isVideoUpscale = rv.kind === "upscale" && _isVideoFile(rv);
          if (rv.kind !== "video" && !isVideoUpscale) continue;
          const base = stripF(rv.version_label);
          if (!base || videoBases.has(base)) continue;
          videoBases.add(base);
          videos.push({
            label: base, image: null, video: rv.file_path, kind: "video",
            time: rv.created_at, model: rv.model,
            is4k: rv.kind === "upscale",
            prompt: rv.prompt_text || null,
            row_kind: rv.kind,   // 'video' | 'upscale'
          });
        }
        // v07zz60 — Hugo: kill the legacy shot.video_path → v003
        // demo fallback for the same reason as the frames mock.
        // Real videos land via asset_versions rows; promoted videos
        // set shot.hero_video_label which already points to a real
        // row. Without this fallback, shots with no video stay
        // genuinely empty.
        videos.sort((a, b) => a.label.localeCompare(b.label, undefined, { numeric: true }));

        // v07zz397 — the shot strip is per-slice (activeVersion like "v014_f2"), but this
        // review filmstrip collapses frames to the base label ("v014"). Strip the _fN
        // quadrant suffix so the lookup matches; without it findIndex missed and fell back
        // to index 0 — opening the FIRST image instead of the one the user was viewing.
        const _isVid = !!(activeVersion && activeVersion.startsWith("video-"));
        const targetLabel = String(_isVid ? activeVersion.slice("video-".length) : (activeVersion || "")).replace(/_f\d+$/i, "");
        const targetKind = _isVid ? "video" : "image";
        // Combined list for back-compat (some logic uses `versions`).
        const reviewVersions = [...frames, ...videos];
        const initialIdx = reviewVersions.findIndex(v => v.label === targetLabel && v.kind === targetKind);
        const fallbackIdx = reviewVersions.findIndex(v => v.label === targetLabel);
        return (
          <window.AssetReviewModal
            open={reviewOpen}
            onClose={() => setReviewOpen(false)}
            entityType="shot"
            entityId={shot.id}
            title={`${shot.id} — ${shot.frame_title || ""}`}
            subtitle={seqLabel}
            status={(() => {
              const stage = window.getCurrentStage ? window.getCurrentStage(shot) : "PENDING";
              const tint = window.STAGE_TINTS && window.STAGE_TINTS[stage];
              return tint ? tint.label : stage;
            })()}
            versions={reviewVersions}
            frames={frames}
            videos={videos}
            initialIndex={Math.max(0, initialIdx >= 0 ? initialIdx : fallbackIdx)}
            /* v06y — HERO pill (replaces the old SQA bar). Same shared
               promoteHero helper as the cream popup, so toggling here
               keeps everything in lock-step with the strip stars and
               the cream popup pill. */
            onHero={(kind, label, isToggleOff) => promoteHero(kind, label, isToggleOff)}
            /* v816 — push a WIP candidate / send one back to WIP / discard it, without
               leaving the full-screen review. */
            onSetPublished={(v, publish) => reviewAct(v && v.av_id, publish ? "push" : "wip")}
            onArchiveVersion={(v) => reviewAct(v && v.av_id, "archive")}
            heroFrameLabel={(() => {
              const r = (realVersions || []).find(v => v.kind === "hero");
              return r ? String(r.version_label).split("_")[0] : null;
            })()}
            heroVideoLabel={shot.hero_video_label
              ? String(shot.hero_video_label).split("_")[0]
              : null}
          />
        );
      })()}
    </div>
  );
}

// t12 — reusable notes panel. Drops into any modal where you have a
// (entity_type, entity_id) pair. Lists existing unresolved notes, lets
// any authenticated user add a new one, and lets admin/producer flip
// the resolved state. Resolved notes become collapsible at the bottom.
function NotesPanel({ entityType, entityId, versionLabel }) {
  const [notes, setNotes] = React.useState([]);
  const [loading, setLoading] = React.useState(true);
  // v07zz344 — `showLoading` is the DELAYED loading indicator. `loading` flips instantly but the
  // "Loading…" TEXT only shows if the fetch is still running after 180ms — a local notes fetch
  // resolves in ~10ms, so the text used to paint for one frame then pop to the list = the "notes
  // flicker when they appear". Now fast loads never show it; only a genuinely slow load does.
  const [showLoading, setShowLoading] = React.useState(false);
  const loadingTimer = React.useRef(null);
  const [error, setError] = React.useState(null);
  const [draft, setDraft] = React.useState("");
  const [submitting, setSubmitting] = React.useState(false);
  const [showResolved, setShowResolved] = React.useState(false);
  // v944 — notes scope switch (Hugo: "i need a switch for the notes to see only the
  // notes per images or ALL notes for that shot"). "version" = the v07zz427 behaviour
  // (this image + general notes); "all" = every note on the shot, any version.
  const [scope, setScope] = React.useState("version");
  // v07zz603 — edit-review comments auto-linked to this shot (Option B read-through).
  const [reviewNotes, setReviewNotes] = React.useState([]);
  const fetcher = window.authFetch || fetch;
  const userCtx = React.useContext(window.UserContext || React.createContext({ user: null }));
  const role = (userCtx && userCtx.user && userCtx.user.role) || null;
  const canResolve = role === "admin" || role === "producer";
  // v07zz278 — hide the composer for roles without comment_on_shots (server
  // gates POST /api/notes on the same key). The thread stays readable.
  const canComment = !window.hasPerm || window.hasPerm("comment_on_shots");

  const load = React.useCallback((silent) => {
    // v07zz333/344 — `silent` skips the loading state on BACKGROUND refreshes (App.jsx re-emits
    // paradise-sse note_added on every sync.applied touching the notes tables). On a foreground
    // load the "Loading…" TEXT is delayed 180ms (see showLoading) so a fast local fetch resolves
    // first and never flashes it. Read window.authFetch INSIDE (not as a dep) so the mount effect
    // is keyed only on the entity and can't re-fire on unrelated re-renders.
    if (!silent) {
      setLoading(true);
      clearTimeout(loadingTimer.current);
      loadingTimer.current = setTimeout(() => setShowLoading(true), 180);
    }
    setError(null);
    const done = () => { clearTimeout(loadingTimer.current); setShowLoading(false); setLoading(false); };
    const f = window.authFetch || fetch;
    // v07zz427 — scope to the active version so a note left on v017 doesn't show on v025_f4.
    // Un-versioned ("general") notes still come back (the server includes NULL-version rows).
    // v944 — with the scope switch on "all", drop the param: the server then returns
    // every note on the shot regardless of version.
    const verQ = (scope === "version" && versionLabel) ? `&version_label=${encodeURIComponent(versionLabel)}` : "";
    f(`/api/notes?entity_type=${encodeURIComponent(entityType)}&entity_id=${encodeURIComponent(entityId)}&include_resolved=true${verQ}`)
      .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); })
      .then(({ notes: rows, review_notes }) => {
        setNotes(Array.isArray(rows) ? rows : []);
        // v07zz603 — edit-review comments auto-linked to this shot (Gemini read the
        // burned-in SH#### off the note's frame). Read-through, never copies.
        setReviewNotes(Array.isArray(review_notes) ? review_notes : []);
        done();
      })
      .catch(err => { setError(err.message); done(); });
  }, [entityType, entityId, versionLabel, scope]);
  React.useEffect(() => { load(); }, [load]);
  React.useEffect(() => () => clearTimeout(loadingTimer.current), []);   // v07zz344 — clear the delayed-spinner timer on unmount
  // v07u — refresh when the sync bus says notes might have changed.
  // App.jsx re-emits paradise-sse note_added on every sync.applied
  // touching the notes table, AND the manual refresh button fires
  // the same event. Without this listener the panel only reloaded
  // on mount, so the user had to close + reopen the modal to see
  // changes from a peer or from his own refresh click.
  React.useEffect(() => {
    const fn = (e) => {
      try {
        const msg = (e && e.detail) || JSON.parse(e.data || "{}");
        if (msg && (msg.type === "note_added" || msg.type === "note_resolved" || msg.type === "note_reply")) {
          load(true);   // silent — background sync refresh, don't flash "Loading…"
        }
      } catch (_) {}
    };
    window.addEventListener("paradise-sse", fn);
    return () => window.removeEventListener("paradise-sse", fn);
  }, [load]);

  const submit = (e) => {
    e && e.preventDefault();
    const body = draft.trim();
    if (!body || submitting) return;
    setSubmitting(true); setError(null);
    fetcher("/api/notes", {
      method: "POST",
      body: JSON.stringify({ entity_type: entityType, entity_id: entityId, body, version_label: versionLabel || null }),
    })
      .then(async r => {
        const b = await r.json().catch(() => ({}));
        if (!r.ok) throw new Error(b.error || `HTTP ${r.status}`);
      })
      .then(() => { setDraft(""); load(true); })
      .catch(err => setError(err.message))
      .finally(() => setSubmitting(false));
  };
  const toggleResolved = (n) => {
    fetcher(`/api/notes/${n.id}/resolve`, {
      method: "PATCH",
      body: JSON.stringify({ resolved: !n.resolved }),
    })
      .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); })
      .then(() => load(true))
      .catch(err => setError(err.message));
  };
  // v07zz603 — resolving a review note here resolves the UNDERLYING video_comment
  // (same endpoint the Review page + Notes page use) — one note, one state.
  const toggleReviewResolved = (n) => {
    fetcher(`/api/video-comments/${n.id}/resolve`, {
      method: "PATCH",
      body: JSON.stringify({ resolved: !n.resolved }),
    })
      .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); })
      .then(() => load(true))
      .catch(err => setError(err.message));
  };
  // v07zz603 — jump to the Edit Review modal seeked to this note's timecode
  // (same hand-off the Notes/Todo pages use). Closes the shot modal first.
  const openReviewAt = (n) => {
    try {
      window.__pendingReviewId = n.review_id;
      if (n.timecode_seconds != null) window.__pendingReviewSeek = { id: n.review_id, t: Number(n.timecode_seconds) || 0 };
      if (window.__nav && window.__nav.openShot) window.__nav.openShot(null);
      ((window.__nav && window.__nav.setView) || window.__navigate)("review");
    } catch (_) {}
  };
  const fmtTc = (t) => {
    const s = Math.max(0, Math.floor(Number(t) || 0));
    return Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0");
  };

  const fmt = (s) => {
    if (!s) return "";
    const d = new Date(s.replace(" ", "T") + "Z");
    if (isNaN(d.getTime())) return s;
    const diff = (Date.now() - d.getTime()) / 1000;
    if (diff < 60)        return `${Math.floor(diff)}s ago`;
    if (diff < 3600)      return `${Math.floor(diff / 60)}m ago`;
    if (diff < 86400)     return `${Math.floor(diff / 3600)}h ago`;
    if (diff < 86400 * 7) return `${Math.floor(diff / 86400)}d ago`;
    return d.toLocaleDateString();
  };
  const initials = (name) => {
    if (!name) return "??";
    const parts = name.trim().split(/\s+/);
    return ((parts[0] || "")[0] || "") + ((parts[1] || "")[0] || "");
  };

  // v01h — scope notes to the currently-selected version. A note matches
  // when either (a) its version_label is null/empty (legacy or
  // version-agnostic note — visible under every version per the
  // backwards-compat rule) or (b) it equals the active versionLabel.
  // When no versionLabel is provided, the panel falls back to showing
  // every note (used by entity types that don't have versions).
  const versionMatches = (n) => {
    if (scope === "all") return true;   // v944 — ALL mode shows every note on the shot
    if (!versionLabel) return true;
    if (!n.version_label) return true;
    if (n.version_label === versionLabel) return true;
    // v07zz517 — a note on a base version ("v021") is the SAME frame as its promoted
    // candidate / upscale ("v021_f1", shown as "v021 · 1", or "v021_4k"). Keep the note
    // visible whether you're viewing the hero (base label) or its candidate tile — the
    // tile highlighting already strips the suffix, so the note matcher must too. Only
    // bridge base<->candidate (not candidate<->candidate), so grid quadrants stay distinct.
    const SUF = /_(f[1-4]|4k)$/i;
    const nHasSuf = SUF.test(String(n.version_label));
    const aHasSuf = SUF.test(String(versionLabel));
    if (nHasSuf === aHasSuf) return false;
    return String(n.version_label).replace(SUF, "") === String(versionLabel).replace(SUF, "");
  };
  const scoped = notes.filter(versionMatches);
  const unresolved = scoped.filter(n => !n.resolved);
  const resolved   = scoped.filter(n =>  n.resolved);
  // v07zz603 — review notes are about the shot AS CUT into the edit, not any one
  // frame/video version — deliberately NOT version-scoped: pinned and visible
  // whichever version tile is selected, until resolved (Hugo confirmed).
  const rvOpen     = reviewNotes.filter(n => !n.resolved);
  const rvResolved = reviewNotes.filter(n =>  n.resolved);

  return (
    <section className="notes-panel">
      <div className="notes-head">
        <div className="notes-title">NOTES{(unresolved.length + rvOpen.length) ? ` · ${unresolved.length + rvOpen.length}` : ""}</div>
        {/* v944 — scope switch (Hugo: "a switch for the notes to see only the notes
            per images or ALL notes for that shot"). "This image" keeps the v07zz427
            per-version view; "All notes" shows the shot's whole thread — each row's
            version chip says which image/video it was left on. */}
        {versionLabel && (
          <div className="notes-scope" role="group" aria-label="Notes scope">
            <button type="button" className={"notes-scope-btn" + (scope === "version" ? " is-active" : "")}
              title={`Only notes left on ${versionLabel}, plus general shot notes`}
              onClick={() => setScope("version")}>This image</button>
            <button type="button" className={"notes-scope-btn" + (scope === "all" ? " is-active" : "")}
              title="Every note on this shot — all images and videos"
              onClick={() => setScope("all")}>All notes</button>
          </div>
        )}
        {versionLabel && scope === "version" && <div className="notes-version">on {versionLabel}</div>}
      </div>
      {error && <div className="notes-error">{error}</div>}
      {showLoading && <div className="notes-empty">Loading…</div>}
      {!loading && rvOpen.length > 0 && (
        <div className="notes-review-group notes-fade">
          <div className="notes-review-head">FROM EDIT REVIEW</div>
          <ul className="notes-list">
            {rvOpen.map(n => (
              <li key={"rv" + n.id} className="note-row note-row--review">
                <button type="button" className="note-frame-thumb"
                  title={`Open ${n.review_title || "the edit"} at ${fmtTc(n.timecode_seconds)}`}
                  onClick={() => openReviewAt(n)}>
                  {n.frame_url
                    ? <img src={window.thumbUrl ? window.thumbUrl(n.frame_url, 240) : n.frame_url} alt="" loading="eager"/>
                    : <span className="note-frame-ph">▶</span>}
                </button>
                <div className="note-body">
                  <div className="note-meta">
                    <span className="note-author">{n.user_name || "system"}</span>
                    <button type="button" className="note-version-tag note-edit-tag"
                      title={`Open ${n.review_title || "the edit"} at ${fmtTc(n.timecode_seconds)}`}
                      onClick={() => openReviewAt(n)}>
                      {(n.review_title || "Edit")}{n.timecode_seconds != null ? ` @ ${fmtTc(n.timecode_seconds)}` : ""}
                    </button>
                    <span className="note-time">{fmt(n.created_at)}</span>
                  </div>
                  <div className="note-text">{n.body}</div>
                </div>
                {canResolve && (
                  <button type="button" className="note-resolve" title="Mark as resolved (resolves it in the Review too)" onClick={() => toggleReviewResolved(n)}>
                    Resolve
                  </button>
                )}
              </li>
            ))}
          </ul>
        </div>
      )}
      {!loading && unresolved.length === 0 && resolved.length === 0 && rvOpen.length === 0 && rvResolved.length === 0 && (
        <div className="notes-empty notes-fade">No notes yet — leave the first one below.</div>
      )}
      {!loading && unresolved.length > 0 && (
        <ul className="notes-list notes-fade">
          {unresolved.map(n => (
            <li key={n.id} className="note-row">
              <span className="note-avatar">{initials(n.user_name).toUpperCase()}</span>
              <div className="note-body">
                <div className="note-meta">
                  <span className="note-author">{n.user_name || "system"}</span>
                  {n.version_label && <span className="note-version-tag">{n.version_label}</span>}
                  <span className="note-time">{fmt(n.created_at)}</span>
                </div>
                <div className="note-text">{n.body}</div>
              </div>
              {canResolve && (
                <button type="button" className="note-resolve" title="Mark as resolved" onClick={() => toggleResolved(n)}>
                  Resolve
                </button>
              )}
            </li>
          ))}
        </ul>
      )}
      {!loading && (resolved.length + rvResolved.length) > 0 && (
        <>
          <button type="button" className="notes-toggle-resolved" onClick={() => setShowResolved(s => !s)}>
            {showResolved ? "Hide" : "Show"} {resolved.length + rvResolved.length} resolved
          </button>
          {showResolved && (
            <ul className="notes-list notes-list--resolved">
              {resolved.map(n => (
                <li key={n.id} className="note-row note-row--resolved">
                  <span className="note-avatar">{initials(n.user_name).toUpperCase()}</span>
                  <div className="note-body">
                    <div className="note-meta">
                      <span className="note-author">{n.user_name || "system"}</span>
                      {n.version_label && <span className="note-version-tag">{n.version_label}</span>}
                      <span className="note-time">{fmt(n.created_at)}</span>
                      <span className="note-resolved-tag">resolved by {n.resolved_by_name || "system"}</span>
                    </div>
                    <div className="note-text">{n.body}</div>
                  </div>
                  {canResolve && (
                    <button type="button" className="note-resolve note-resolve--undo" onClick={() => toggleResolved(n)} title="Re-open this note">
                      Re-open
                    </button>
                  )}
                </li>
              ))}
              {/* v07zz603 — resolved review notes join the same history bucket. */}
              {rvResolved.map(n => (
                <li key={"rv" + n.id} className="note-row note-row--resolved note-row--review">
                  <span className="note-avatar">{initials(n.user_name).toUpperCase()}</span>
                  <div className="note-body">
                    <div className="note-meta">
                      <span className="note-author">{n.user_name || "system"}</span>
                      <button type="button" className="note-version-tag note-edit-tag" onClick={() => openReviewAt(n)}
                        title={`Open ${n.review_title || "the edit"} at ${fmtTc(n.timecode_seconds)}`}>
                        {(n.review_title || "Edit")}{n.timecode_seconds != null ? ` @ ${fmtTc(n.timecode_seconds)}` : ""}
                      </button>
                      <span className="note-time">{fmt(n.created_at)}</span>
                      <span className="note-resolved-tag">resolved</span>
                    </div>
                    <div className="note-text">{n.body}</div>
                  </div>
                  {canResolve && (
                    <button type="button" className="note-resolve note-resolve--undo" onClick={() => toggleReviewResolved(n)} title="Re-open this note">
                      Re-open
                    </button>
                  )}
                </li>
              ))}
            </ul>
          )}
        </>
      )}
      {canComment && (
      <form className="note-add-form" onSubmit={submit}>
        <textarea
          className="note-add-input"
          placeholder={versionLabel ? `Leave a note on ${versionLabel}…` : "Leave a note…"}
          value={draft}
          onChange={(e) => setDraft(e.target.value)}
          onKeyDown={(e) => {
            // Cmd/Ctrl-Enter submits.
            if ((e.metaKey || e.ctrlKey) && e.key === "Enter") submit(e);
          }}
          rows={2}
          maxLength={2000}
        />
        <button type="submit" className="note-add-submit" disabled={submitting || !draft.trim()}>
          {submitting ? "Posting…" : "Post note"}
        </button>
      </form>
      )}
    </section>
  );
}

Object.assign(window, { ShotDetailModal, PipelineStepper, NotesPanel });
