"use client"; import { useState } from "react"; import Link from "next/link"; import NoteField from "@/components/NoteField"; import type { NoteMap } from "@/lib/notes"; import type { ThumbView } from "@/lib/thumbs"; // The cover art, and what accepting one would spend. // // The gimmick this enforces is that NO FACE APPEARS ON TWO COVERS, and the // currency is source episodes: four per cover, and once spent they are gone // from the pool the next cover draws from. So the bench shows the four corner // videos of every candidate, each linking to the exact moment in the archive, // and says up front which ones a proposal would spend. // // Accepting runs song/accept-thumb.mjs -- see the route for why the app reads // these files and never writes them. const mediaUrl = (label: string) => `/api/mix/media?path=${encodeURIComponent(label)}`; export default function ThumbBench({ song, view, notes, }: { song: string; view: ThumbView; notes: NoteMap; }) { const [busy, setBusy] = useState(null); const [error, setError] = useState(null); const [log, setLog] = useState([]); if (!view.candidates.length && !view.accepted) return null; const accept = async (name: string, file: string) => { setBusy(name); setError(null); setLog([]); try { const res = await fetch("/api/browse/thumbs", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ song, name, file }), cache: "no-store", }); const j = (await res.json()) as { error?: string; job?: { id: string } }; if (!res.ok || !j.job) throw new Error(j.error || `HTTP ${res.status}`); // Poll the same way every other job in this app is polled. The script // finishes in milliseconds, but its OUTPUT is the point -- it prints the // videos the cover claims and re-verifies the rule. for (let i = 0; i < 40; i += 1) { await new Promise((r) => setTimeout(r, 150)); const s = await fetch(`/api/browse/build?job=${encodeURIComponent(j.job.id)}`, { cache: "no-store", }); const view2 = (await s.json()) as { state: string; log: string[]; error: string | null }; setLog(view2.log ?? []); if (view2.state === "done") break; if (view2.state === "failed") throw new Error(view2.error || "accept-thumb failed"); } // A reload rather than router.refresh(): thumbFor() memoises the accepted // manifest for a few seconds, so an immediate re-render can still draw // the old cover. setTimeout(() => window.location.reload(), 600); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setBusy(null); } }; return (
cover art {view.check.ok ? `unique-face rule holds — ${view.check.covers} covers, ${view.check.videos} distinct source videos` : `RULE BROKEN: ${view.check.dupes.map((d) => `${d.video} on ${d.covers.join(" + ")}`).join("; ")}`}
{!view.accepted && (

nothing accepted for this song — the index is drawing a frame by filename convention

)}
    {view.candidates.map((c) => (
  • {c.label ? ( /* eslint-disable-next-line @next/next/no-img-element */ ) : (
    outside the roots
    )}
    {c.name} {c.accepted && accepted} {c.file}
    {/* Which four episodes this cover spends. The clip: note target is the one the arrangement already uses, so a note written here shows up beside the same clip everywhere else. */}
      {c.corners.map((v) => (
    • {v.link ? ( {v.video} ) : ( {v.video} )} @{v.srcStart.toFixed(1)}
    • ))} {/* THE WAY IN to the face judger. It is here rather than in the nav -- AppNav is at nine items and a tenth wraps the header -- and here is also where the question comes up: a person looking at these four faces is the person who notices one of them is a strip of YouTube chrome. */} {c.corners.length > 0 && (
    • judge {c.corners.length === 1 ? "this face" : `these ${c.corners.length} faces`} →
    • )}
    {c.clash ? (

    accepting this would break the rule — {c.clash}

    ) : ( !c.accepted && ( ) )}
  • ))}
{log.length > 0 && (
          {log.join("\n")}
        
)} {error &&

{error}

}
); }