Spaces:
Sleeping
Sleeping
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <title>Marlin-2B Video Understanding</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com" /> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet" /> | |
| <script src="https://unpkg.com/lucide@latest"></script> | |
| <style> | |
| :root { | |
| --bg: #0b0b0c; | |
| --surface: #111113; | |
| --surface-2: #161618; | |
| --surface-3: #1c1c1f; | |
| --border: rgba(255, 255, 255, 0.06); | |
| --border-strong: rgba(255, 255, 255, 0.12); | |
| --ink: #e6e6e7; | |
| --ink-strong: #f4f4f5; | |
| --muted: #8a8f98; | |
| --muted-2: #5b6069; | |
| --accent: #f4f4f5; | |
| --accent-ink: #0b0b0c; | |
| --green: #4ec57c; | |
| --red: #f46b5e; | |
| --radius: 6px; | |
| --font: "Inter", system-ui, -apple-system, "SF Pro Text", sans-serif; | |
| } | |
| * { box-sizing: border-box; } | |
| [hidden] { display: none ; } | |
| html, body { | |
| margin: 0; | |
| min-height: 100%; | |
| background: var(--bg); | |
| color: var(--ink); | |
| font-family: var(--font); | |
| font-size: 13px; | |
| line-height: 1.5; | |
| -webkit-font-smoothing: antialiased; | |
| text-rendering: geometricPrecision; | |
| } | |
| button, input, textarea, select { font: inherit; } | |
| button { border: 0; background: none; color: inherit; cursor: pointer; } | |
| svg.lucide { width: 14px; height: 14px; flex: 0 0 auto; } | |
| .app { min-height: 100vh; display: grid; grid-template-rows: 44px 1fr; } | |
| .topbar { | |
| display: flex; align-items: center; justify-content: space-between; | |
| padding: 0 18px; border-bottom: 1px solid var(--border); | |
| background: var(--bg); position: sticky; top: 0; z-index: 10; | |
| } | |
| .brand { display: flex; align-items: center; gap: 10px; font-weight: 500; color: var(--ink-strong); } | |
| .brand-mark { | |
| width: 20px; height: 20px; border-radius: 5px; | |
| display: grid; place-items: center; background: var(--ink-strong); color: var(--bg); | |
| } | |
| .brand-mark svg { width: 12px; height: 12px; } | |
| .brand-divider { width: 1px; height: 14px; background: var(--border-strong); } | |
| .brand-context { color: var(--muted); font-weight: 400; } | |
| .chip { | |
| display: inline-flex; align-items: center; gap: 7px; | |
| border: 1px solid var(--border); border-radius: 999px; | |
| padding: 3px 10px; font-size: 11.5px; color: var(--muted); font-weight: 500; | |
| } | |
| .chip .dot { | |
| width: 6px; height: 6px; border-radius: 50%; background: var(--green); | |
| box-shadow: 0 0 0 3px rgba(78, 197, 124, 0.12); | |
| } | |
| .layout { | |
| display: grid; | |
| grid-template-columns: 330px minmax(0, 1fr); | |
| width: min(calc(100% - 32px), 1380px); | |
| min-height: calc(100vh - 44px); | |
| margin: 0 auto; | |
| border-left: 1px solid var(--border); | |
| border-right: 1px solid var(--border); | |
| } | |
| .column { min-width: 0; min-height: 0; border-right: 1px solid var(--border); } | |
| .viewer-column { border-right: 0; display: grid; grid-template-rows: auto minmax(0, 1fr); } | |
| .column-header { | |
| height: 42px; display: flex; align-items: center; justify-content: space-between; | |
| padding: 0 18px; border-bottom: 1px solid var(--border); | |
| } | |
| .column-header h2 { margin: 0; font-size: 12px; font-weight: 500; color: var(--ink); } | |
| .controls { padding: 18px; display: grid; gap: 18px; } | |
| .field-group { display: grid; gap: 8px; } | |
| .field-row { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; } | |
| .field-label { font-size: 11px; font-weight: 500; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; } | |
| .field-hint { color: var(--muted-2); font-size: 11px; } | |
| input[type="text"], input[type="number"], textarea { | |
| width: 100%; background: var(--surface); color: var(--ink); | |
| border: 1px solid var(--border); border-radius: var(--radius); | |
| padding: 9px 10px; outline: none; | |
| transition: border-color .15s ease, background .15s ease; | |
| } | |
| textarea { min-height: 76px; resize: vertical; } | |
| input::placeholder, textarea::placeholder { color: var(--muted-2); } | |
| input:focus, textarea:focus { border-color: rgba(255,255,255,.2); background: var(--surface-2); } | |
| input[type="range"] { width: 100%; accent-color: var(--ink-strong); } | |
| .dropzone { | |
| position: relative; min-height: 156px; display: grid; place-items: center; | |
| border: 1px dashed var(--border-strong); border-radius: var(--radius); | |
| background: var(--surface); overflow: hidden; | |
| transition: border-color .15s ease, background .15s ease; | |
| } | |
| .dropzone:hover, .dropzone.dragging { border-color: rgba(255,255,255,.22); background: var(--surface-2); } | |
| .dropzone input { position: absolute; inset: 0; opacity: 0; cursor: pointer; } | |
| .drop-empty { display: grid; gap: 6px; justify-items: center; color: var(--muted); padding: 18px; text-align: center; } | |
| .drop-empty strong { color: var(--ink); font-weight: 500; } | |
| .drop-empty span { color: var(--muted-2); font-size: 11.5px; } | |
| .file-name { display: none; position: absolute; left: 10px; right: 10px; bottom: 10px; padding: 7px 8px; border-radius: 4px; background: rgba(0,0,0,.62); color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } | |
| .dropzone.has-file .file-name { display: block; } | |
| .segmented { | |
| display: grid; grid-template-columns: repeat(2, 1fr); gap: 2px; padding: 2px; | |
| background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); | |
| } | |
| .segmented button { border-radius: 4px; padding: 6px 0; font-size: 12px; color: var(--muted); font-weight: 500; } | |
| .segmented button:hover { color: var(--ink); } | |
| .segmented button.active { background: var(--surface-3); color: var(--ink-strong); } | |
| .grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } | |
| .primary { | |
| width: 100%; min-height: 36px; border-radius: var(--radius); | |
| color: var(--accent-ink); background: var(--accent); | |
| font-weight: 500; display: inline-flex; align-items: center; justify-content: center; gap: 7px; | |
| } | |
| .primary:hover { background: #fff; } | |
| .primary:disabled { opacity: .45; cursor: not-allowed; } | |
| .example-list { display: grid; gap: 6px; } | |
| .example-btn { | |
| text-align: left; border: 1px solid var(--border); border-radius: var(--radius); | |
| background: var(--surface); padding: 8px; display: grid; | |
| grid-template-columns: 68px minmax(0, 1fr); gap: 10px; align-items: center; | |
| } | |
| .example-btn:hover, .example-btn.active { background: var(--surface-2); border-color: var(--border-strong); } | |
| .example-thumb { | |
| width: 68px; height: 42px; object-fit: cover; border-radius: 4px; | |
| background: #050506; border: 1px solid var(--border); | |
| } | |
| .example-copy { min-width: 0; display: grid; gap: 2px; } | |
| .example-copy strong { font-size: 12.5px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } | |
| .example-copy span { color: var(--muted); font-size: 11.5px; } | |
| .viewer-header { display: flex; align-items: center; justify-content: space-between; gap: 10px; } | |
| .status { display: inline-flex; align-items: center; gap: 7px; color: var(--muted); font-size: 12px; } | |
| .status svg { color: var(--muted-2); } | |
| .viewer { padding: 18px; min-height: 0; display: block; } | |
| .stage { | |
| min-width: 0; border: 1px solid var(--border); border-radius: var(--radius); | |
| background: var(--surface); overflow: hidden; | |
| } | |
| .video-wrap { position: relative; background: #050506; } | |
| video { width: 100%; aspect-ratio: 16 / 9; display: block; background: #050506; } | |
| .video-play-toggle { | |
| position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); | |
| width: 48px; height: 48px; display: grid; place-items: center; | |
| border-radius: 999px; color: var(--accent-ink); background: rgba(244,244,245,.92); | |
| box-shadow: 0 10px 30px rgba(0,0,0,.28); z-index: 4; | |
| transition: opacity .15s ease, transform .15s ease, background .15s ease; | |
| } | |
| .video-play-toggle svg { width: 20px; height: 20px; margin-left: 2px; } | |
| .video-play-toggle:hover { background: #fff; transform: translate(-50%, -50%) scale(1.04); } | |
| .video-wrap.no-video .video-play-toggle, | |
| .video-wrap.playing .video-play-toggle { | |
| opacity: 0; pointer-events: none; transform: translate(-50%, -50%) scale(.96); | |
| } | |
| .caption-overlay { | |
| position: absolute; left: 12px; right: 12px; bottom: 12px; | |
| padding: 10px 12px; border: 1px solid rgba(255,255,255,.08); border-radius: var(--radius); | |
| background: rgba(8,8,10,.78); color: var(--ink-strong); | |
| backdrop-filter: blur(8px); min-height: 42px; | |
| } | |
| .caption-overlay small { display: block; color: var(--muted); margin-bottom: 2px; font-size: 11px; } | |
| .timeline-shell { padding: 14px; border-top: 1px solid var(--border); display: grid; gap: 12px; } | |
| .timebar { position: relative; height: 34px; border-radius: var(--radius); background: var(--surface-2); overflow: hidden; } | |
| .progress-line { position: absolute; top: 0; bottom: 0; width: 1px; background: #fff; opacity: .9; z-index: 4; } | |
| .segment { | |
| position: absolute; top: 8px; height: 18px; border-radius: 4px; | |
| background: rgba(244,244,245,.22); border: 1px solid rgba(255,255,255,.16); | |
| transition: background .15s ease; | |
| } | |
| .segment.active { background: rgba(244,244,245,.72); } | |
| .time-readout { display: flex; justify-content: space-between; color: var(--muted); font-size: 11.5px; } | |
| .events { display: grid; gap: 6px; padding-right: 2px; } | |
| .event-row { | |
| width: 100%; text-align: left; border: 1px solid var(--border); border-radius: var(--radius); | |
| background: var(--surface); padding: 9px 10px; display: grid; gap: 3px; | |
| } | |
| .event-row:hover, .event-row.active { background: var(--surface-2); border-color: var(--border-strong); } | |
| .event-row time { color: var(--muted); font-size: 11.5px; } | |
| .event-row span { color: var(--ink); } | |
| .section { display: grid; gap: 7px; min-width: 0; } | |
| .section h3 { margin: 0; color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .05em; font-weight: 500; } | |
| .scene, .raw { | |
| margin: 0; color: var(--ink); white-space: pre-wrap; | |
| background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius); | |
| padding: 10px; overflow: auto; | |
| } | |
| .raw { color: var(--muted); max-height: 260px; } | |
| .model-details { | |
| border: 1px solid var(--border); border-radius: var(--radius); | |
| background: var(--surface); overflow: hidden; | |
| } | |
| .model-details summary { | |
| min-height: 38px; padding: 0 10px; display: flex; align-items: center; justify-content: space-between; gap: 12px; | |
| cursor: pointer; color: var(--muted); list-style: none; | |
| } | |
| .model-details summary::-webkit-details-marker { display: none; } | |
| .model-details summary span:first-child { | |
| font-size: 11px; font-weight: 500; text-transform: uppercase; letter-spacing: .05em; | |
| } | |
| .model-details summary span:last-child { | |
| min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: 11.5px; | |
| } | |
| .model-details summary::after { | |
| content: ""; width: 7px; height: 7px; border-right: 1px solid var(--muted); border-bottom: 1px solid var(--muted); | |
| transform: rotate(45deg); flex: 0 0 auto; transition: transform .15s ease; | |
| } | |
| .model-details[open] summary { border-bottom: 1px solid var(--border); } | |
| .model-details[open] summary::after { transform: rotate(225deg); } | |
| .details-body { padding: 12px; display: grid; gap: 14px; } | |
| .empty { color: var(--muted-2); } | |
| .toast { | |
| position: fixed; left: 50%; bottom: 18px; transform: translateX(-50%) translateY(14px); | |
| opacity: 0; pointer-events: none; background: var(--surface-3); color: var(--ink); | |
| border: 1px solid var(--border-strong); border-radius: var(--radius); | |
| padding: 8px 12px; transition: opacity .16s ease, transform .16s ease; | |
| } | |
| .toast.show { opacity: 1; transform: translateX(-50%) translateY(0); } | |
| @media (max-width: 1080px) { | |
| .layout { grid-template-columns: 1fr; width: 100%; border-left: 0; border-right: 0; } | |
| .column { border-right: 0; border-bottom: 1px solid var(--border); } | |
| } | |
| @media (max-width: 680px) { | |
| .topbar { padding: 0 12px; } | |
| .brand-context { display: none; } | |
| .controls, .viewer { padding: 12px; } | |
| .grid-2 { grid-template-columns: 1fr; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <main class="app"> | |
| <nav class="topbar"> | |
| <div class="brand"> | |
| <span class="brand-mark"><i data-lucide="scan-search"></i></span> | |
| <span>Marlin-2B</span> | |
| <span class="brand-divider"></span> | |
| <span class="brand-context">Video Understanding</span> | |
| </div> | |
| <div class="chip"><span class="dot"></span><span>ZeroGPU</span></div> | |
| </nav> | |
| <section class="layout"> | |
| <aside class="column"> | |
| <header class="column-header"><h2>Analyze</h2></header> | |
| <div class="controls"> | |
| <div class="field-group"> | |
| <div class="field-row"><span class="field-label">Clip</span><span class="field-hint">MP4/WebM</span></div> | |
| <div class="dropzone" id="dropzone"> | |
| <input id="videoInput" type="file" accept="video/mp4,video/webm,video/quicktime" /> | |
| <div class="drop-empty" id="dropEmpty"> | |
| <i data-lucide="file-video"></i> | |
| <strong>Drop video</strong> | |
| <span>Short clips work best</span> | |
| </div> | |
| <div class="file-name" id="fileName"></div> | |
| </div> | |
| </div> | |
| <div class="field-group"> | |
| <span class="field-label">Task</span> | |
| <div class="segmented" id="taskGroup"> | |
| <button type="button" data-task="Caption" class="active">Caption</button> | |
| <button type="button" data-task="Find">Find</button> | |
| </div> | |
| </div> | |
| <div class="field-group" id="eventField" hidden> | |
| <span class="field-label">Event to find</span> | |
| <textarea id="eventInput" placeholder="the skateboard spins under the rider's feet"></textarea> | |
| </div> | |
| <div class="grid-2"> | |
| <div class="field-group"> | |
| <div class="field-row"><span class="field-label">Caption cap</span><span id="tokenValue" class="field-hint">768</span></div> | |
| <input id="tokenInput" type="range" min="128" max="2048" step="128" value="768" /> | |
| </div> | |
| <div class="field-group"> | |
| <div class="field-row"><span class="field-label">Temperature</span><span id="tempValue" class="field-hint">0.0</span></div> | |
| <input id="tempInput" type="range" min="0" max="0.8" step="0.1" value="0" /> | |
| </div> | |
| </div> | |
| <button id="runBtn" class="primary" type="button"><i data-lucide="play"></i><span>Analyze</span></button> | |
| <div class="field-group"> | |
| <span class="field-label">Examples</span> | |
| <div class="example-list" id="exampleList"></div> | |
| </div> | |
| </div> | |
| </aside> | |
| <section class="viewer-column"> | |
| <header class="column-header viewer-header"> | |
| <div class="status" id="statusText"><i data-lucide="circle-dot"></i><span>Ready</span></div> | |
| <a class="chip" href="https://huggingface.co/NemoStation/Marlin-2B" target="_blank" rel="noreferrer">Model card</a> | |
| </header> | |
| <div class="viewer"> | |
| <section class="stage"> | |
| <div class="video-wrap no-video" id="videoWrap"> | |
| <video id="video" controls playsinline></video> | |
| <button class="video-play-toggle" id="videoPlayToggle" type="button" aria-label="Play video"> | |
| <i data-lucide="play"></i> | |
| </button> | |
| <div class="caption-overlay" id="captionOverlay"> | |
| <small>Active caption</small> | |
| <div id="activeCaption" class="empty">Run analysis to sync captions with playback.</div> | |
| </div> | |
| </div> | |
| <div class="timeline-shell"> | |
| <div class="timebar" id="timebar"><div class="progress-line" id="progressLine"></div></div> | |
| <div class="time-readout"><span id="currentTime">0.0s</span><span id="durationTime">0.0s</span></div> | |
| <div class="events" id="eventsList"></div> | |
| <details class="model-details" id="modelDetails"> | |
| <summary><span>Model output</span><span id="detailsSummary">Idle</span></summary> | |
| <div class="details-body"> | |
| <div class="section"> | |
| <h3>Scene</h3> | |
| <p class="scene empty" id="sceneText">No scene yet.</p> | |
| </div> | |
| <div class="section"> | |
| <h3>Status</h3> | |
| <p class="scene" id="runStatus">Idle</p> | |
| </div> | |
| <div class="section"> | |
| <h3>Raw output</h3> | |
| <pre class="raw" id="rawText"></pre> | |
| </div> | |
| </div> | |
| </details> | |
| </div> | |
| </section> | |
| </div> | |
| </section> | |
| </section> | |
| </main> | |
| <div class="toast" id="toast"></div> | |
| <script type="module"> | |
| import { Client, handle_file } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js"; | |
| const state = { | |
| client: null, | |
| file: null, | |
| filePromise: null, | |
| fileUrl: "", | |
| task: "Caption", | |
| examples: [], | |
| exampleIndex: null, | |
| exampleMatchesPreset: false, | |
| analysis: null, | |
| activeIndex: -1, | |
| }; | |
| const $ = (id) => document.getElementById(id); | |
| const els = { | |
| dropzone: $("dropzone"), | |
| dropEmpty: $("dropEmpty"), | |
| fileName: $("fileName"), | |
| videoInput: $("videoInput"), | |
| videoWrap: $("videoWrap"), | |
| video: $("video"), | |
| videoPlayToggle: $("videoPlayToggle"), | |
| taskGroup: $("taskGroup"), | |
| eventField: $("eventField"), | |
| eventInput: $("eventInput"), | |
| tokenInput: $("tokenInput"), | |
| tokenValue: $("tokenValue"), | |
| tempInput: $("tempInput"), | |
| tempValue: $("tempValue"), | |
| runBtn: $("runBtn"), | |
| exampleList: $("exampleList"), | |
| statusText: $("statusText"), | |
| timebar: $("timebar"), | |
| progressLine: $("progressLine"), | |
| eventsList: $("eventsList"), | |
| activeCaption: $("activeCaption"), | |
| currentTime: $("currentTime"), | |
| durationTime: $("durationTime"), | |
| sceneText: $("sceneText"), | |
| rawText: $("rawText"), | |
| runStatus: $("runStatus"), | |
| detailsSummary: $("detailsSummary"), | |
| toast: $("toast"), | |
| }; | |
| function escapeHtml(value) { | |
| return String(value ?? "").replace(/[&<>"']/g, (char) => ({ | |
| "&": "&", | |
| "<": "<", | |
| ">": ">", | |
| '"': """, | |
| "'": "'", | |
| }[char])); | |
| } | |
| function setStatus(text, icon = "circle-dot") { | |
| els.statusText.innerHTML = `<i data-lucide="${icon}"></i><span>${text}</span>`; | |
| lucide.createIcons(); | |
| } | |
| function toast(text) { | |
| els.toast.textContent = text; | |
| els.toast.classList.add("show"); | |
| window.setTimeout(() => els.toast.classList.remove("show"), 2400); | |
| } | |
| async function getClient() { | |
| if (!state.client) state.client = await Client.connect(window.location.origin); | |
| return state.client; | |
| } | |
| function seconds(value) { | |
| const n = Number(value || 0); | |
| return `${n.toFixed(1)}s`; | |
| } | |
| function setTask(task) { | |
| state.task = task; | |
| for (const button of els.taskGroup.querySelectorAll("button")) { | |
| button.classList.toggle("active", button.dataset.task === task); | |
| } | |
| els.eventField.hidden = task !== "Find"; | |
| state.exampleMatchesPreset = false; | |
| } | |
| function setVideoFile(file, url, label, poster = "") { | |
| state.file = file; | |
| if (state.fileUrl && state.fileUrl.startsWith("blob:")) URL.revokeObjectURL(state.fileUrl); | |
| state.fileUrl = url || URL.createObjectURL(file); | |
| els.video.poster = poster; | |
| els.video.src = state.fileUrl; | |
| els.video.currentTime = 0; | |
| els.dropzone.classList.add("has-file"); | |
| els.fileName.textContent = label || file?.name || "video"; | |
| setStatus("Clip loaded", "file-video"); | |
| syncPlayOverlay(); | |
| } | |
| function syncPlayOverlay() { | |
| const hasVideo = Boolean(els.video.getAttribute("src")); | |
| const playing = hasVideo && !els.video.paused && !els.video.ended; | |
| els.videoWrap.classList.toggle("no-video", !hasVideo); | |
| els.videoWrap.classList.toggle("playing", playing); | |
| } | |
| function toggleVideoPlayback() { | |
| if (!els.video.getAttribute("src")) return; | |
| if (els.video.ended) els.video.currentTime = 0; | |
| if (els.video.paused) els.video.play().catch(() => {}); | |
| else els.video.pause(); | |
| syncPlayOverlay(); | |
| } | |
| function playCurrentVideo() { | |
| els.video.muted = true; | |
| els.video.load(); | |
| const start = () => els.video.play().catch(() => {}); | |
| if (els.video.readyState >= 2) start(); | |
| else els.video.addEventListener("canplay", start, { once: true }); | |
| } | |
| function resetAnalysis() { | |
| state.analysis = null; | |
| state.activeIndex = -1; | |
| els.timebar.querySelectorAll(".segment").forEach((node) => node.remove()); | |
| els.eventsList.innerHTML = `<div class="empty">No events yet.</div>`; | |
| els.activeCaption.textContent = "Run analysis to sync captions with playback."; | |
| els.activeCaption.classList.add("empty"); | |
| els.sceneText.textContent = "No scene yet."; | |
| els.sceneText.classList.add("empty"); | |
| els.rawText.textContent = ""; | |
| els.runStatus.textContent = "Idle"; | |
| els.detailsSummary.textContent = "Idle"; | |
| } | |
| async function selectExample(index, options = {}) { | |
| const { autoAnalyze = true, autoPlay = false } = options; | |
| const example = state.examples[index]; | |
| if (!example) return; | |
| state.exampleIndex = index; | |
| for (const button of els.exampleList.querySelectorAll(".example-btn")) { | |
| button.classList.toggle("active", Number(button.dataset.index) === index); | |
| } | |
| setTask(example.task); | |
| els.eventInput.value = example.event || ""; | |
| els.tokenInput.value = example.tokens; | |
| els.tokenValue.textContent = String(example.tokens); | |
| els.tempInput.value = example.temperature; | |
| els.tempValue.textContent = Number(example.temperature).toFixed(1); | |
| state.exampleMatchesPreset = true; | |
| resetAnalysis(); | |
| setVideoFile(null, example.playback_url || example.url, example.title, example.thumbnail || ""); | |
| state.filePromise = fetch(example.url) | |
| .then((response) => response.blob()) | |
| .then((blob) => { | |
| state.file = new File([blob], example.file, { type: blob.type || "video/mp4" }); | |
| return state.file; | |
| }); | |
| if (autoPlay) playCurrentVideo(); | |
| if (autoAnalyze && example.cached) await analyze(); | |
| } | |
| async function loadExamples() { | |
| const response = await fetch("/examples.json"); | |
| const payload = await response.json(); | |
| state.examples = payload.examples || []; | |
| els.exampleList.innerHTML = ""; | |
| state.examples.forEach((example, index) => { | |
| const button = document.createElement("button"); | |
| button.className = "example-btn"; | |
| button.dataset.index = index; | |
| button.type = "button"; | |
| const thumb = example.thumbnail | |
| ? `<img class="example-thumb" src="${escapeHtml(example.thumbnail)}" alt="" loading="lazy" />` | |
| : `<span class="example-thumb"></span>`; | |
| button.innerHTML = `${thumb}<span class="example-copy"><strong>${escapeHtml(example.title)}</strong><span>${escapeHtml(example.task)}${example.cached ? " - cached" : ""}</span></span>`; | |
| button.addEventListener("click", () => selectExample(index, { autoPlay: true })); | |
| els.exampleList.appendChild(button); | |
| }); | |
| if (state.examples.length) await selectExample(0); | |
| } | |
| function renderTimeline(payload) { | |
| els.timebar.querySelectorAll(".segment").forEach((node) => node.remove()); | |
| const duration = payload.duration || els.video.duration || 1; | |
| for (const [index, event] of (payload.events || []).entries()) { | |
| const segment = document.createElement("button"); | |
| segment.className = "segment"; | |
| segment.type = "button"; | |
| segment.dataset.index = index; | |
| const left = Math.max(0, Math.min(100, (event.start / duration) * 100)); | |
| const width = Math.max(0.8, Math.min(100 - left, ((event.end - event.start) / duration) * 100)); | |
| segment.style.left = `${left}%`; | |
| segment.style.width = `${width}%`; | |
| segment.title = `${seconds(event.start)} - ${seconds(event.end)} ${event.description}`; | |
| segment.addEventListener("click", () => seekTo(event.start)); | |
| els.timebar.appendChild(segment); | |
| } | |
| els.durationTime.textContent = seconds(duration); | |
| } | |
| function renderEvents(payload) { | |
| const events = payload.events || []; | |
| if (!events.length) { | |
| els.eventsList.innerHTML = `<div class="empty">No timestamped events parsed.</div>`; | |
| return; | |
| } | |
| els.eventsList.innerHTML = ""; | |
| for (const [index, event] of events.entries()) { | |
| const row = document.createElement("button"); | |
| row.type = "button"; | |
| row.className = "event-row"; | |
| row.dataset.index = index; | |
| row.innerHTML = `<time>${seconds(event.start)} - ${seconds(event.end)}</time><span>${escapeHtml(event.description)}</span>`; | |
| row.addEventListener("click", () => seekTo(event.start)); | |
| els.eventsList.appendChild(row); | |
| } | |
| } | |
| function renderAnalysis(payload) { | |
| state.analysis = payload; | |
| renderTimeline(payload); | |
| renderEvents(payload); | |
| els.sceneText.textContent = payload.scene || (payload.span ? payload.span.label : "No scene returned for this mode."); | |
| els.sceneText.classList.toggle("empty", !payload.scene && !payload.span); | |
| els.rawText.textContent = payload.raw || ""; | |
| els.runStatus.textContent = `${payload.status}${payload.cached ? " | cache hit" : ""}`; | |
| els.detailsSummary.textContent = payload.cached ? "Cache hit" : "Ready"; | |
| updateActiveCaption(); | |
| } | |
| function seekTo(time) { | |
| els.video.currentTime = Math.max(0, Number(time || 0)); | |
| els.video.play().catch(() => {}); | |
| updateActiveCaption(); | |
| } | |
| function updateActiveCaption() { | |
| const duration = state.analysis?.duration || els.video.duration || 1; | |
| const time = els.video.currentTime || 0; | |
| els.currentTime.textContent = seconds(time); | |
| els.progressLine.style.left = `${Math.max(0, Math.min(100, (time / duration) * 100))}%`; | |
| const events = state.analysis?.events || []; | |
| const active = events.findIndex((event) => time >= event.start && time <= event.end); | |
| if (active !== state.activeIndex) { | |
| state.activeIndex = active; | |
| for (const node of document.querySelectorAll(".event-row, .segment")) { | |
| node.classList.toggle("active", Number(node.dataset.index) === active); | |
| } | |
| } | |
| if (active >= 0) { | |
| const event = events[active]; | |
| els.activeCaption.innerHTML = `<small>${seconds(event.start)} - ${seconds(event.end)}</small>${escapeHtml(event.description)}`; | |
| els.activeCaption.classList.remove("empty"); | |
| } else if (state.analysis?.scene) { | |
| els.activeCaption.innerHTML = `<small>Scene</small>${escapeHtml(state.analysis.scene)}`; | |
| els.activeCaption.classList.remove("empty"); | |
| } else { | |
| els.activeCaption.textContent = "Run analysis to sync captions with playback."; | |
| els.activeCaption.classList.add("empty"); | |
| } | |
| } | |
| function parsePayload(result) { | |
| const raw = Array.isArray(result.data) ? result.data[0] : result.data; | |
| return typeof raw === "string" ? JSON.parse(raw) : raw; | |
| } | |
| function currentControlsMatchExample() { | |
| const example = state.examples[state.exampleIndex]; | |
| if (!example || !state.exampleMatchesPreset) return false; | |
| return state.task === example.task | |
| && els.eventInput.value === (example.event || "") | |
| && Number(els.tokenInput.value) === Number(example.tokens) | |
| && Number(els.tempInput.value) === Number(example.temperature); | |
| } | |
| async function analyze() { | |
| if (!state.file && state.exampleIndex === null) { | |
| toast("Choose a video first."); | |
| return; | |
| } | |
| els.runBtn.disabled = true; | |
| setStatus("Analyzing", "loader"); | |
| try { | |
| const client = await getClient(); | |
| let result; | |
| if (currentControlsMatchExample()) { | |
| result = await client.predict("/analyze_example", [state.exampleIndex]); | |
| } else { | |
| if (!state.file && state.filePromise) state.file = await state.filePromise; | |
| result = await client.predict("/analyze", [ | |
| handle_file(state.file), | |
| state.task, | |
| els.eventInput.value, | |
| Number(els.tokenInput.value), | |
| Number(els.tempInput.value), | |
| ]); | |
| } | |
| const payload = parsePayload(result); | |
| if (!payload.ok) throw new Error(payload.error || "Analysis failed"); | |
| renderAnalysis(payload); | |
| setStatus(payload.cached ? "Loaded cached analysis" : "Analysis ready", payload.cached ? "database" : "check"); | |
| } catch (error) { | |
| setStatus("Error", "circle-alert"); | |
| toast(error.message || String(error)); | |
| } finally { | |
| els.runBtn.disabled = false; | |
| } | |
| } | |
| els.videoInput.addEventListener("change", (event) => { | |
| const file = event.target.files?.[0]; | |
| if (!file) return; | |
| state.exampleIndex = null; | |
| state.exampleMatchesPreset = false; | |
| state.filePromise = null; | |
| resetAnalysis(); | |
| setVideoFile(file, "", file.name); | |
| }); | |
| ["dragenter", "dragover"].forEach((name) => { | |
| els.dropzone.addEventListener(name, (event) => { | |
| event.preventDefault(); | |
| els.dropzone.classList.add("dragging"); | |
| }); | |
| }); | |
| ["dragleave", "drop"].forEach((name) => { | |
| els.dropzone.addEventListener(name, (event) => { | |
| event.preventDefault(); | |
| els.dropzone.classList.remove("dragging"); | |
| }); | |
| }); | |
| els.dropzone.addEventListener("drop", (event) => { | |
| const file = event.dataTransfer?.files?.[0]; | |
| if (!file) return; | |
| state.exampleIndex = null; | |
| state.exampleMatchesPreset = false; | |
| state.filePromise = null; | |
| resetAnalysis(); | |
| setVideoFile(file, "", file.name); | |
| }); | |
| els.taskGroup.addEventListener("click", (event) => { | |
| const button = event.target.closest("button[data-task]"); | |
| if (!button) return; | |
| setTask(button.dataset.task); | |
| }); | |
| els.tokenInput.addEventListener("input", () => { | |
| els.tokenValue.textContent = els.tokenInput.value; | |
| state.exampleMatchesPreset = false; | |
| }); | |
| els.tempInput.addEventListener("input", () => { | |
| els.tempValue.textContent = Number(els.tempInput.value).toFixed(1); | |
| state.exampleMatchesPreset = false; | |
| }); | |
| els.eventInput.addEventListener("input", () => { state.exampleMatchesPreset = false; }); | |
| els.video.addEventListener("timeupdate", updateActiveCaption); | |
| ["play", "pause", "ended", "loadeddata", "emptied"].forEach((name) => { | |
| els.video.addEventListener(name, syncPlayOverlay); | |
| }); | |
| els.video.addEventListener("loadedmetadata", () => { | |
| els.durationTime.textContent = seconds(els.video.duration || 0); | |
| syncPlayOverlay(); | |
| }); | |
| els.videoPlayToggle.addEventListener("click", toggleVideoPlayback); | |
| els.runBtn.addEventListener("click", analyze); | |
| resetAnalysis(); | |
| loadExamples().catch((error) => toast(error.message || String(error))); | |
| lucide.createIcons(); | |
| </script> | |
| </body> | |
| </html> | |