readVDOpromt2 / index.html
victor's picture
victor HF Staff
Show centered play button when video is paused
2880ff3 verified
Raw
History Blame Contribute Delete
32.3 kB
<!doctype html>
<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 !important; }
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) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;",
}[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>