// Reader prototype — main app. // Holds state machine (idle / running / done / error), orchestrates the // simulated transcription run, composes views from reader-views.jsx, and // wires up the Tweaks panel. const { useState, useEffect, useRef, useMemo } = React; const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ "accent": "#5eead4", "density": "comfortable", "bodyFont": "sans", "showAvatars": true, "colorPerSpeaker": true }/*EDITMODE-END*/; const ACCENT_OPTIONS = [ "#5eead4", // scout / cyan "#f5a623", // analyst / amber "#a78bfa", // watchdog / purple "#4fa3ff", // risk / blue "#2ed3a3", // exec / green ]; // 0.18.0: whether a job URL is a Facebook link, judged by host. Only the // phase-strip guess at submit and the error banner's title use it; the // server's facebook/links.py makes the real decision. Anything that is not // an absolute URL (a file job, or the masked "Facebook link (private to the // owner)" another caller sees) is not a Facebook link here. function isFacebookLink(url) { let host; try { host = new URL(url).hostname.toLowerCase(); } catch (err) { return false; } host = host.replace(/\.$/, ""); return /(^|\.)(facebook\.com|fb\.com)$/.test(host) || host === "fb.me" || host === "fb.watch"; } // The strip to show at submit, before /status answers: a Facebook post link, // or any Facebook link with Capture comments ticked, runs the Facebook strip; // a reel or video link with comments off keeps today's strip. pollStatus // replaces this guess with the server's flags within a second. function guessFacebookFlags(url, comments) { if (!isFacebookLink(url)) return { facebook: false, comments: false }; const u = new URL(url); const video = u.hostname.toLowerCase().replace(/\.$/, "") === "fb.watch" || /^\/(reel|watch|share\/[vr])(\/|$)|\/videos\/|^\/video\.php$/.test(u.pathname); return { facebook: comments || !video, comments }; } function App() { const D = window.AppData; const [tweaks, setTweak] = window.useTweaks(TWEAK_DEFAULTS); const [view, setView] = useState("main"); // main | voiceprints const [status, setStatus] = useState({ kind: "idle" }); // idle | running | done | error const [interpret, setInterpret] = useState(false); const [captureComments, setCaptureComments] = useState(false); const [captureEmotionCategorical, setCaptureEmotionCategorical] = useState(false); const [captureEmotionDimensional, setCaptureEmotionDimensional] = useState(false); const [captureSounds, setCaptureSounds] = useState(false); const [phaseIdx, setPhaseIdx] = useState(0); const [subIdx, setSubIdx] = useState(0); const [elapsed, setElapsed] = useState(0); const [error, setError] = useState(null); const [showTs, setShowTs] = useState(true); const [showScenes, setShowScenes] = useState(true); const [showEmotion, setShowEmotion] = useState(true); const [query, setQuery] = useState(""); const [copied, setCopied] = useState(false); const [voices, setVoices] = useState([]); // Live data from the backend. The prototype kept all of these in // window.AppData mock; here they come from /result on completion and // /voices/list on demand. const [transcript, setTranscript] = useState([]); const [comments, setComments] = useState([]); // 0.17.0: result.meeting (summary / action items / follow-ups) or null. const [meeting, setMeeting] = useState(null); // 0.18.0: result.post (a Facebook capture's post) or null. const [post, setPost] = useState(null); // 0.18.0: /status's batch_done / batch_total as {done, total}, or null. // The Facebook comments step shows them as "N of about M comments". const [batch, setBatch] = useState(null); const [speakers, setSpeakers] = useState([]); const [video, setVideo] = useState({ title: "", channel: "", duration: "", uploaded: "", views: "" }); const [meta, setMeta] = useState({ elapsedTotal: "", gpuPeak: "", scenesDescribed: 0 }); const [phases, setPhases] = useState(D.phases); const [gpu, setGpu] = useState(""); const [history, setHistory] = useState([]); const [modelsLoaded, setModelsLoaded] = useState(null); // null until first fetch const [modelsBusy, setModelsBusy] = useState(false); const pollRef = useRef(null); const startTsRef = useRef(0); // Tracks original SPEAKER_NN -> display name so we can enroll by original // label even after the server has renamed segments to display names. // Populated by transformResult(). const originalLabelsRef = useRef({}); // Apply tweaks to CSS variables on the root useEffect(() => { const root = document.documentElement; root.style.setProperty("--accent", tweaks.accent); root.style.setProperty("--density-pad", tweaks.density === "compact" ? "20px" : "32px"); root.style.setProperty("--density-gap", tweaks.density === "compact" ? "18px" : "26px"); root.style.setProperty("--density-body-size", tweaks.density === "compact" ? "14.5px" : "16px"); root.style.setProperty("--density-body-lh", tweaks.density === "compact" ? "1.55" : "1.65"); root.style.setProperty("--body-font", tweaks.bodyFont === "mono" ? "var(--font-mono)" : "var(--font-sans)"); }, [tweaks]); // ---------- backend integration ---------- const stopPolling = () => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } }; // Compose phases the same way state.py does: base, then emotion, then // vision, then (0.17.0) meeting synthesis last. // 0.18.0: `fb` is {facebook, comments}. A full-mode Facebook job gets only // the Facebook strip, without fb_comments when comments are off. const composePhases = (vision, emotion, meeting, fb) => { if (fb && fb.facebook) { return D.facebookPhases.filter((p) => fb.comments || p.id !== "fb_comments"); } const out = [...D.phases]; if (emotion) out.push(...D.emotionExtraPhases); if (vision) out.push(...D.visionExtraPhases); if (meeting) out.push(...D.meetingExtraPhases); return out; }; // The Facebook argument of composePhases, from a /status body. const statusFbFlags = (data) => ({ facebook: !!data.facebook, comments: !!data.facebook_comments }); const formatGpu = (g) => { if (!g || g.memory_total_mb == null) return ""; const used = (g.memory_used_mb / 1024).toFixed(1); const total = (g.memory_total_mb / 1024).toFixed(1); return `${used} / ${total} GB`; }; // source is either a URL string or a File (local upload). For a file the // backend reports url as "file:", so mirror that in the status. const startRun = async (source) => { const file = typeof source === "string" ? null : source; const url = file ? `file:${file.name}` : source; stopPolling(); setStatus({ kind: "running", url }); setPhaseIdx(0); setSubIdx(0); setElapsed(0); setError(null); setGpu(""); setBatch(null); // The home page never requests meeting notes; that lives on /meetings. setPhases(composePhases( interpret, captureEmotionCategorical || captureEmotionDimensional || captureSounds, false, guessFacebookFlags(file ? "" : url, captureComments) )); startTsRef.current = Date.now(); const fd = new FormData(); if (file) fd.append("file", file, file.name); else fd.append("url", url); if (interpret) fd.append("interpret_video", "on"); if (captureComments) fd.append("capture_comments", "on"); if (captureEmotionCategorical) fd.append("capture_emotion_categorical", "on"); if (captureEmotionDimensional) fd.append("capture_emotion_dimensional", "on"); if (captureSounds) fd.append("capture_sounds", "on"); // 0.18.0: error states keep the job URL so the banner can title a // Facebook refusal (400 / 403 / 409 with the server's text as detail). let resp; try { resp = await fetch("/transcribe", { method: "POST", body: fd }); } catch (err) { setStatus({ kind: "error", url }); setError(`Network error: ${err}`); return; } if (resp.status === 409) { const body = await resp.json().catch(() => ({})); setStatus({ kind: "error", url }); setError(body.detail || "A job is already running."); return; } if (!resp.ok) { const body = await resp.json().catch(() => ({})); setStatus({ kind: "error", url }); setError(body.detail || `HTTP ${resp.status}`); return; } pollRef.current = setInterval(pollStatus, 1000); }; const pollStatus = async () => { let data; try { const resp = await fetch("/status"); if (!resp.ok) return; data = await resp.json(); } catch (err) { return; } if (data.elapsed_seconds != null) setElapsed(data.elapsed_seconds); if (data.gpu) setGpu(formatGpu(data.gpu)); if (data.interpret_video != null || data.analyze_emotion != null || data.facebook != null) { setPhases(composePhases(!!data.interpret_video, !!data.analyze_emotion, !!data.meeting, statusFbFlags(data))); } if (data.phase || data.phase_index != null) { // Derive the index from a strip composed off THIS response. pollStatus // runs inside a setInterval closure created once per run, so component // state read here is frozen at interval-creation time: after a refresh // mid-job that snapshot holds only the base phases, so every vision and // emotion phase fails to map and the strip stays pinned at step 1. // phase_index is the server's own answer and backstops an unknown name. const strip = composePhases(!!data.interpret_video, !!data.analyze_emotion, !!data.meeting, statusFbFlags(data)); let idx = data.phase ? strip.findIndex((p) => p.id === data.phase) : -1; if (idx < 0 && data.phase_index != null) idx = data.phase_index; if (idx >= 0 && idx < strip.length) setPhaseIdx(idx); } if (data.batch_done != null && data.batch_total != null && data.batch_total > 0) { // Reuse subIdx as fractional progress within describing_scenes. setSubIdx(data.batch_done); } // 0.18.0: both numbers as reported, zeros included; the runner resets // them when fb_comments starts, so the count starts over there. setBatch(data.batch_done != null && data.batch_total != null ? { done: data.batch_done, total: data.batch_total } : null); if (data.state === "done") { stopPolling(); // 0.18.0: "done" only once /result actually opened (R15). Anyone but // the owner gets 403 on an owner's Facebook result, and their page // goes back to idle instead of showing an empty done shell. const outcome = await fetchAndApplyResult(); setStatus(outcome === "ok" ? { kind: "done", url: data.url || "" } : { kind: "idle" }); fetchVoices(); fetchHistory(); fetchModelsStatus(); } else if (data.state === "error") { stopPolling(); setStatus({ kind: "error", url: data.url || "" }); setError(data.error || "Pipeline failed."); } else if (data.state === "cancelled") { stopPolling(); setStatus({ kind: "error", url: data.url || "" }); setError(`Cancelled after ${window.fmtTime(data.elapsed_seconds || 0)}.`); } }; // Brand-click home. Navigation between view pages (main/voiceprints) is // setView's job; this additionally resets a finished or failed job back // to the idle hero, which is what "take me home" means to a user staring // at a completed transcript. The result stays one History click away and // GET /result still serves it. A RUNNING job is never reset from here. const goHome = () => { setView("main"); if (status.kind === "done" || status.kind === "error") { stopPolling(); setStatus({ kind: "idle" }); setError(null); } }; const cancelRun = async () => { if (status.kind === "running") { try { await fetch("/cancel", { method: "POST" }); } catch (err) { // Server may have already finished; pollStatus will reconcile. } // Don't immediately stop polling — let /status report the cancelled // state so the UI flips correctly with the right elapsed value. } else { stopPolling(); setStatus({ kind: "idle" }); setElapsed(0); setError(null); } }; // Convert the backend TranscriptResult into the shape the design's // presentational components expect (D.transcript / D.speakers / D.video / D.meta). const transformResult = (result, jobUrl) => { const palette = ["scout", "analyst", "watchdog", "risk", "executioner", "orchestrator"]; const originalLabelToSpeaker = {}; const summariesByOriginal = {}; const summaries = result.speakers || []; // SpeakerSummary.speaker is either SPEAKER_NN (still raw) or the renamed // name. We pair each summary with whatever raw label still exists in // result.speaker_embeddings (those keys are always SPEAKER_NN). const embeddingKeys = Object.keys(result.speaker_embeddings || {}); const usedKeys = new Set(); summaries.forEach((s, i) => { let rawLabel = s.speaker; if (!/^SPEAKER_\d+$/.test(s.speaker)) { // This speaker was renamed. Try to find their original SPEAKER_NN // by matching duration. If we can't (>1 candidate), fall back to // ordinal: nth renamed speaker gets nth unused SPEAKER_NN key. const candidate = embeddingKeys.find((k) => !usedKeys.has(k)); if (candidate) rawLabel = candidate; } usedKeys.add(rawLabel); originalLabelToSpeaker[s.speaker] = rawLabel; summariesByOriginal[s.speaker] = s; }); // Count segments per speaker (lines). const linesPerSpeaker = {}; (result.segments || []).forEach((seg) => { linesPerSpeaker[seg.speaker] = (linesPerSpeaker[seg.speaker] || 0) + 1; }); const builtSpeakers = summaries.map((s, i) => ({ id: s.speaker, display: s.suggested_name || s.speaker, raw: originalLabelToSpeaker[s.speaker] || s.speaker, colorKey: palette[i % palette.length], matched: !/^SPEAKER_\d+$/.test(s.speaker), matchConfidence: s.suggested_confidence != null ? s.suggested_confidence : 1.0, lines: linesPerSpeaker[s.speaker] || 0, durationSec: s.total_seconds || 0, })); const transcriptItems = []; (result.segments || []).forEach((seg) => { transcriptItems.push({ kind: "speech", speaker: seg.speaker, text: seg.text, start: seg.start, end: seg.end, emotionCategorical: seg.emotion_categorical || null, emotionDimensional: seg.emotion_dimensional || null, }); }); (result.scene_descriptions || []).forEach((s) => { transcriptItems.push({ kind: "scene", text: s.description, start: s.timestamp, end: s.timestamp + (s.duration || 0), }); }); transcriptItems.sort((a, b) => a.start - b.start); const dur = result.duration_seconds || 0; const formatPostedDate = (iso) => { if (!iso) return ""; try { return new Date(iso).toLocaleDateString("en-US", { month: "numeric", day: "numeric", year: "numeric", }); } catch (err) { return ""; } }; const formatTranscribedDateTime = (iso) => { if (!iso) return ""; try { return new Date(iso).toLocaleString("en-US", { month: "numeric", day: "numeric", year: "numeric", hour: "numeric", minute: "2-digit", hour12: true, timeZoneName: "short", }); } catch (err) { return ""; } }; // 0.18.0: a Facebook result names its own source: the post's permalink // (https only), else the Facebook address it came from. That avoids the // job URL, which can be stale here. A post with no timed segments is a // post-only result: the post panel and comments, no transcript. const postUrl = result.post && /^https:\/\//i.test(result.post.url || "") ? result.post.url : ""; const videoObj = { title: result.title || result.video_id || "Untitled", channel: result.channel || "", duration: window.fmtTime(dur), uploaded: formatPostedDate(result.upload_date), transcribed: formatTranscribedDateTime(result.created_at), views: "", // 0.10.2: surface video_id + source url so DoneHero can render a real // YouTube thumbnail and link to the source. Video_id alone isn't always // a YouTube ID (Facebook Reels use a different scheme); DoneHero // probes the url shape and falls back to the glow art when it isn't. videoId: result.video_id || "", sourceUrl: postUrl || result.facebook_url || jobUrl || "", postOnly: !!result.post && (result.segments || []).length === 0, }; const metaObj = { elapsedTotal: window.fmtTime(elapsed), gpuPeak: gpu || "", scenesDescribed: (result.scene_descriptions || []).length, }; return { transcript: transcriptItems, speakers: builtSpeakers, video: videoObj, meta: metaObj, originalLabels: originalLabelToSpeaker, jobUrl: jobUrl, }; }; // 0.18.0: reports what happened (R15): "ok" once the result is applied, // "forbidden" when /result answers 403 (an owner's Facebook result opened // by anyone else), "failed" when it could not be read. Callers set "done" // only on "ok"; nothing is applied otherwise. const fetchAndApplyResult = async () => { let result; try { const resp = await fetch("/result"); if (resp.status === 403) return "forbidden"; if (!resp.ok) return "failed"; result = await resp.json(); } catch (err) { return "failed"; } window.__lastResult = result; // referenced by _videoIdFromStatus for enroll const transformed = transformResult(result, status.url || ""); setTranscript(transformed.transcript); setSpeakers(transformed.speakers); setVideo(transformed.video); setMeta(transformed.meta); // Keep null apart from []: a Facebook result tells "comments not // captured" (null) from "none were visible" ([]) (R15). setComments(result.comments ?? null); setMeeting(result.meeting || null); setPost(result.post || null); originalLabelsRef.current = transformed.originalLabels; return "ok"; }; const fetchModelsStatus = async () => { try { const resp = await fetch("/models/status"); if (!resp.ok) return; const body = await resp.json(); setModelsLoaded(!!body.any); } catch (err) { /* noop */ } }; const toggleModels = async () => { if (modelsBusy) return; if (status.kind === "running") { alert("Can't load or unload models while a job is running."); return; } setModelsBusy(true); const endpoint = modelsLoaded ? "/models/unload" : "/models/load"; try { const resp = await fetch(endpoint, { method: "POST" }); if (!resp.ok) { const body = await resp.json().catch(() => ({})); alert(body.detail || `HTTP ${resp.status}`); } else { const body = await resp.json(); setModelsLoaded(!!body.any); } } catch (err) { alert(`Request failed: ${err}`); } finally { setModelsBusy(false); } }; const fetchHistory = async () => { try { const resp = await fetch("/history/list"); if (!resp.ok) return; const body = await resp.json(); setHistory(body.history || []); } catch (err) { /* noop */ } }; // Reopen a past transcript as if it had just finished. Posts to // /history/{id}/load which mutates job_state.result on the server, then // re-pulls /result so the existing render path (transformResult → // setTranscript / setSpeakers / setVideo / setMeta) applies it as normal. // No re-transcription; the SQLite payload is the source of truth. const loadFromHistory = async (entry) => { if (status.kind === "running") { alert("Can't load history while a job is running."); return; } stopPolling(); let resp; try { resp = await fetch(`/history/${entry.id}/load`, { method: "POST" }); } catch (err) { alert(`Load failed: ${err}`); return; } if (!resp.ok) { const body = await resp.json().catch(() => ({})); alert(`Load failed: ${body.detail || resp.statusText}`); return; } const body = await resp.json(); // 0.18.0: "done" only after /result opened (R15); otherwise the page // goes back to idle rather than showing an empty done shell. const outcome = await fetchAndApplyResult(); setStatus(outcome === "ok" ? { kind: "done", url: body.url || entry.url || "" } : { kind: "idle" }); setError(null); }; const fetchVoices = async () => { let body; try { const resp = await fetch("/voices/list"); if (!resp.ok) return; body = await resp.json(); } catch (err) { return; } const mapped = (body.voices || []).map((v) => ({ name: v.name, samples: (v.samples || []).map((s) => ({ id: s.id, created: s.created_at ? new Date(s.created_at).toLocaleDateString() : "", source: s.source_video_id || "", duration: s.sample_duration_s || 0, jumpUrl: s.jump_url || null, startTimeFormatted: s.start_time_formatted || null, excerpt: s.transcript_excerpt || null, })), })); setVoices(mapped); }; // Initial load: fetch voices + history + model state, sync to any in-flight job. useEffect(() => { fetchVoices(); fetchHistory(); fetchModelsStatus(); (async () => { try { const resp = await fetch("/status"); if (!resp.ok) return; const data = await resp.json(); if (data.state === "running") { setStatus({ kind: "running", url: data.url || "" }); // Restore the strip and the clock now instead of waiting up to a // second for the first poll, so a refresh mid-job paints the truth. setPhases(composePhases(!!data.interpret_video, !!data.analyze_emotion, !!data.meeting, statusFbFlags(data))); setElapsed(data.elapsed_seconds || 0); startTsRef.current = Date.now() - (data.elapsed_seconds || 0) * 1000; pollRef.current = setInterval(pollStatus, 1000); } else if (data.state === "done") { // 0.18.0: "done" only once /result opened (R15); a 403 (someone // else's view of an owner's Facebook result) leaves the page idle. if (await fetchAndApplyResult() === "ok") { setStatus({ kind: "done", url: data.url || "" }); } } else if (data.state === "error") { // Without these a refresh after a failed or cancelled job silently // dropped back to the idle hero, losing the reason it stopped. setStatus({ kind: "error", url: data.url || "" }); setError(data.error || "Pipeline failed."); } else if (data.state === "cancelled") { setStatus({ kind: "error", url: data.url || "" }); setError(`Cancelled after ${window.fmtTime(data.elapsed_seconds || 0)}.`); } } catch (err) { /* noop */ } })(); return () => stopPolling(); }, []); // ---------- actions ---------- const buildClipboardText = () => { const lines = []; if (video.title) lines.push(`Title: ${video.title}`); if (video.channel) lines.push(`Channel: ${video.channel}`); // 0.18.0: a Facebook capture names the post's permalink, not the link // as typed; a post-only one was captured, not transcribed. const srcUrl = (post && post.url) || status.url; if (srcUrl) lines.push(`URL: ${srcUrl}`); if (video.uploaded) lines.push(`Posted: ${video.uploaded}`); if (video.transcribed) lines.push(`${video.postOnly ? "Captured" : "Transcribed"}: ${video.transcribed}`); const header = lines.join("\n"); const out = []; let lastSpeaker = null; transcript.forEach((seg) => { if (seg.kind === "scene") { if (showScenes) { const tsBit = showTs ? `[scene ${window.fmtTime(seg.start)}] ` : "[scene] "; out.push(`${tsBit}${seg.text}`); lastSpeaker = null; } return; } const sp = speakers.find((s) => s.id === seg.speaker); const name = sp ? sp.display : seg.speaker; const tsBit = showTs ? `[${window.fmtTime(seg.start)}] ` : ""; const emoBit = showEmotion ? formatEmotionForCopy(seg) : ""; if (sp && sp.id === lastSpeaker) { out[out.length - 1] += ` ${tsBit}${emoBit}${seg.text.trim()}`; } else { out.push(`${name}: ${tsBit}${emoBit}${seg.text.trim()}`); lastSpeaker = sp ? sp.id : null; } }); const body = out.join("\n\n"); const postBlock = formatPostForCopy(post, comments); const commentBlock = formatCommentsForCopy(comments); return ( (header ? header + "\n\n" : "") + (postBlock ? postBlock + (body ? "\n\n" : "") : "") + body + (commentBlock ? "\n\n" + commentBlock : "") ); }; // 0.18.0: the Facebook post block for the clipboard, between the header // and the transcript, as in the .txt download. `cs` is the comment list, // or null when comments were not captured. Link targets stay plain text. const formatPostForCopy = (p, cs) => { if (!p) return ""; const lines = ["--- FACEBOOK POST ---"]; if (p.author) lines.push(`Author: ${p.author}`); const posted = window.fmtExactTime(p.created_at); if (posted) lines.push(`Posted: ${posted}`); const breakdown = Object.entries(p.reactions || {}).map(([label, n]) => `${label} ${n}`).join(" · "); if (p.reaction_count != null) lines.push(`Reactions: ${p.reaction_count}${breakdown ? ` (${breakdown})` : ""}`); else if (breakdown) lines.push(`Reactions: ${breakdown}`); const commentBits = []; if (p.comment_count != null) commentBits.push(`${p.comment_count} reported`); if (Array.isArray(cs)) commentBits.push(`${cs.length} captured`); if (p.comments_complete != null) commentBits.push(p.comments_complete ? "complete" : "partial"); if (p.comment_sort) commentBits.push(p.comment_sort); if (commentBits.length > 0) lines.push(`Comments: ${commentBits.join(" · ")}`); if (p.share_count != null) lines.push(`Shares: ${p.share_count}`); for (const a of p.attachments || []) { const m = a.media; lines.push(`Attachment: ${[a.kind, a.caption, a.url, m && m.error].filter(Boolean).join(" · ")}`); lines.push(...formatMediaReadForCopy(m, " ")); } for (const note of p.notes || []) lines.push(`Note: ${note}`); if (p.text) lines.push("", p.text); return lines.join("\n"); }; // 0.19.0: what the local vision model read in a picture, as in the .txt: // its one-sentence description, then "Text in image:" with the text's // lines indented two more spaces (blank ones dropped). `m` is a // FacebookMedia or null; `indent` is where these lines start. const formatMediaReadForCopy = (m, indent) => { if (!m) return []; const out = []; if (m.description) out.push(`${indent}Description: ${m.description.trim().split(/\s+/).join(" ")}`); if (m.ocr_text) { out.push(`${indent}Text in image:`); for (const line of m.ocr_text.split("\n")) { if (line.trim()) out.push(`${indent} ${line.trimEnd()}`); } } return out; }; // Compact emotion tag for clipboard. Categorical first, then AVD dims if // present. Returns empty string when both are missing. const formatEmotionForCopy = (seg) => { const bits = []; if (seg.emotionCategorical && seg.emotionCategorical.label) { const ec = seg.emotionCategorical; const pct = Math.round((ec.confidence || 0) * 100); bits.push(`${window.emotionLabel(ec.label)} ${pct}%`); } if (seg.emotionDimensional) { const ed = seg.emotionDimensional; bits.push( `A${ed.arousal.toFixed(2)} V${ed.valence.toFixed(2)} D${ed.dominance.toFixed(2)}` ); } return bits.length ? `(${bits.join(" · ")}) ` : ""; }; // 0.18.0: threads come from window.threadComments, so replies recurse to // any depth (two spaces per level), each comment is written once, and a // reply whose parent was not captured is written at the top level and // marked. A Facebook result gets the Facebook meta line and counts. // 0.19.0: as in the .txt, a comment's picture or shared link follows its // text: "Media: kind · alt text · link title · site · target · error", // then what reading the picture found, two spaces further in. const formatCommentsForCopy = (cs) => { if (!cs || cs.length === 0) return ""; const facebook = !!post; const { roots, rendered } = window.threadComments(cs); const count = facebook && post.comment_count != null ? `${rendered} of ${post.comment_count} reported` : `${rendered}`; const lines = [`--- COMMENTS (${count}) ---`]; const renderOne = (node, depth) => { const c = node.comment; const indent = " ".repeat(depth); const meta = [c.author]; if (c.is_pinned) meta.push("pinned"); if (c.is_creator_reply) meta.push(facebook ? "author" : "creator"); if (c.like_count > 0) { meta.push(`${c.like_count} ${facebook ? (c.like_count === 1 ? "reaction" : "reactions") : "likes"}`); } if (facebook && c.attachment) meta.push(c.attachment); if (node.orphan) meta.push("reply to a comment not captured"); const when = c.relative_time || (facebook && c.timestamp ? window.fmtExactTime(c.timestamp * 1000) : ""); if (when) meta.push(when); lines.push(`${indent}[${meta.join(" · ")}]`); const textLines = (c.text || "").split("\n"); for (const tl of textLines) lines.push(`${indent}${tl}`); if (facebook && c.media) { const m = c.media; lines.push(`${indent}Media: ${[m.kind, m.alt_text, m.link_title, m.link_source, m.url, m.error].filter(Boolean).join(" · ")}`); lines.push(...formatMediaReadForCopy(m, `${indent} `)); } for (const r of node.replies) { lines.push(""); renderOne(r, depth + 1); } }; for (const root of roots) { renderOne(root, 0); lines.push(""); } return lines.join("\n").trimEnd(); }; const onDownload = (fmt) => { // Anchor click triggers a download because /download/{fmt} returns // Content-Disposition: attachment. No page navigation. const a = document.createElement("a"); a.href = `/download/${fmt}`; a.rel = "noopener"; document.body.appendChild(a); a.click(); document.body.removeChild(a); }; const onCopy = async () => { const text = buildClipboardText(); try { if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(text); } else { const ta = document.createElement("textarea"); ta.value = text; ta.style.position = "fixed"; ta.style.opacity = "0"; document.body.appendChild(ta); ta.select(); document.execCommand("copy"); document.body.removeChild(ta); } setCopied(true); setTimeout(() => setCopied(false), 1400); } catch (err) { alert(`Copy failed: ${err}`); } }; const onReset = () => { stopPolling(); setStatus({ kind: "idle" }); setElapsed(0); setError(null); }; // 0.17.0: a meeting-notes [mm:ss] link lands on the speech turn whose // start is nearest the item's start. Turns carry id="turn-" where i is // the index into `transcript` (scenes included, so the ids stay stable // whether or not scenes are shown). block:"center" keeps the target clear // of the sticky toolbar; the class flashes it so the eye finds it. const jumpToSegment = (start) => { let best = -1; let bestDelta = Infinity; transcript.forEach((seg, i) => { if (seg.kind !== "speech") return; const delta = Math.abs(seg.start - start); if (delta < bestDelta) { bestDelta = delta; best = i; } }); if (best < 0) return; const el = document.getElementById(`turn-${best}`); if (!el) return; el.scrollIntoView({ behavior: "smooth", block: "center" }); el.classList.remove("is-jumped"); void el.offsetWidth; // restart the animation on repeat clicks el.classList.add("is-jumped"); setTimeout(() => el.classList.remove("is-jumped"), 1600); }; const onDeleteSample = async (voiceName, sampleId) => { let resp; try { resp = await fetch(`/voices/${sampleId}`, { method: "DELETE" }); } catch (err) { alert(`Delete failed: ${err}`); return; } if (!resp.ok) { alert(`Delete failed: HTTP ${resp.status}`); return; } fetchVoices(); }; const onRenameSpeaker = async (speakerView, segSpeakerId) => { // segSpeakerId is whatever's currently on seg.speaker — may be SPEAKER_NN // or a previously-applied display name. We resolve back to the original // SPEAKER_NN via originalLabelsRef (populated by transformResult). const original = originalLabelsRef.current[segSpeakerId] || segSpeakerId; if (!/^SPEAKER_\d+$/.test(original)) { alert("This speaker has already been enrolled. To rename, delete the voiceprint from the Voiceprints page and re-transcribe."); return; } const suggestion = speakerView && speakerView.display && /^SPEAKER_\d+$/.test(speakerView.display) ? "" : (speakerView && speakerView.display) || ""; const newName = window.prompt(`Name this speaker:`, suggestion); if (!newName || !newName.trim()) return; const fd = new FormData(); fd.append("video_id", _videoIdFromStatus()); fd.append("speaker_label", original); fd.append("name", newName.trim()); let resp; try { resp = await fetch("/voices/enroll", { method: "POST", body: fd }); } catch (err) { alert(`Enroll failed: ${err}`); return; } if (!resp.ok) { const body = await resp.json().catch(() => ({})); alert(`Enroll failed: ${body.detail || resp.statusText}`); return; } // Server mutated job_state.result; re-fetch + re-render. await fetchAndApplyResult(); fetchVoices(); }; const _videoIdFromStatus = () => { // The /result we already pulled has video_id; we keep a single live result // so any current video matches. We can recover it from the speakers array // — but we built transformResult with the result body in scope; simpler: // refetch quickly or hold last-fetched video_id in state. For now, grab // from the latest fetch. if (window.__lastResult && window.__lastResult.video_id) { return window.__lastResult.video_id; } return ""; }; // ---------- derived ---------- const speakerById = useMemo( () => Object.fromEntries(speakers.map((s) => [s.id, s])), [speakers] ); const wordCount = useMemo( () => transcript .filter((s) => s.kind === "speech") .reduce((a, s) => a + s.text.split(/\s+/).length, 0), [transcript] ); const turnCount = transcript.filter((s) => s.kind === "speech").length; const sceneCount = transcript.filter((s) => s.kind === "scene").length; // ---------- header status chip ---------- const headerStatus = status.kind === "running" ? { kind: "running", label: `running · ${window.fmtTime(elapsed)}` } : status.kind === "done" ? { kind: "done", label: "ready" } : status.kind === "error" ? { kind: "error", label: "error" } : { kind: "idle", label: "idle" }; // ---------- main page render ---------- const renderMain = () => { return (
{/* HERO: switches by status */}
{status.kind === "error" && ( onReset()} onDismiss={onReset} /> )} {status.kind === "idle" && ( )} {status.kind === "running" && ( p.id === "describing_scenes")} facebook={phases.some((p) => p.id === "fb_post")} batch={batch} /> )} {status.kind === "done" && ( onRenameSpeaker(sp, sp.id)} post={post} commentCount={Array.isArray(comments) ? comments.length : null} /> )}
{/* TRANSCRIPT — only when done */} {status.kind === "done" && ( <> {/* 0.18.0: a Facebook capture's post sits above the transcript. */} {post && } {meeting && } {/* 0.18.0: a post-only result has no transcript to show or search. */} {!video.postOnly && ( <>
{transcript.map((seg, i) => { if (seg.kind === "scene") { if (!showScenes) return null; return ( ); } const sp = speakerById[seg.speaker] || { id: seg.speaker, display: seg.speaker, raw: seg.speaker, colorKey: "scout", matched: false, matchConfidence: 0, lines: 0, durationSec: 0, }; // If colorPerSpeaker is off, override colorKey to match the global accent const effectiveSp = tweaks.colorPerSpeaker ? sp : { ...sp, colorKey: "_accent" }; return ( onRenameSpeaker(sp, seg.speaker)} /> ); })}
)} )} {/* When idle: show a faint "what you can do" panel below the hero */} {status.kind === "idle" && (
} title="Speaker diarization" body="Per-turn labels via pyannote-3.1. Matched against your enrolled voiceprints."/> } title="Scene descriptions" body="Optional. Qwen-VL describes keyframes between turns, so context isn't lost."/> } title="Three export formats" body="Clean .txt, full .json with timestamps, and .srt for video editors."/>
)}
); }; return (
{view === "main" ? ( renderMain() ) : ( setView("main")} onDelete={onDeleteSample} /> )} {/* Tweaks panel */} setTweak("accent", v)} options={ACCENT_OPTIONS} /> setTweak("colorPerSpeaker", v)} /> setTweak("density", v)} options={[ { label: "Comfy", value: "comfortable" }, { label: "Compact", value: "compact" }, ]} /> setTweak("bodyFont", v)} options={[ { label: "Sans", value: "sans" }, { label: "Mono", value: "mono" }, ]} /> setTweak("showAvatars", v)} />
); } function IdleStripItem({ icon, title, body }) { return (
{icon}
{title}
{body}
); } ReactDOM.createRoot(document.getElementById("root")).render();