// Clipwhit app: long video -> Whisper transcript -> ranked moments -> 9:16 face-tracked, captioned shorts. // Everything runs in this browser tab (transformers.js, MediaPipe, ffmpeg.wasm). The only optional network call with // your content is the AI re-rank, which sends transcript text (never audio or video) and is off unless you tick it. // © 2026 CyberMax. All rights reserved. import { FFmpeg } from './vendor/ffmpeg/index.js'; import { toSegments, toWords, envelope, pickMoments, clock } from './lib/scoring.js'; import { STYLES, buildAss, buildSrt } from './lib/captions.js'; import { cropBox, cropPath, cropExpr, thinKeys } from './lib/crop.js'; import { zipParts } from './lib/zip.js'; import * as lic from './lib/licence.js'; import { classify, matchEpisode, mb, MEDIA_EXT } from './lib/links.js'; const API = /\.workers\.dev$/.test(location.hostname) || location.hostname === 'localhost' && location.port === '8787' ? location.origin : 'https://clipwhit.cybermax-tools.workers.dev'; const CORE = 'https://cdn.jsdelivr.net/npm/@ffmpeg/core@0.12.10/dist/esm'; const MP = 'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.21'; const FACE_MODEL = 'https://storage.googleapis.com/mediapipe-models/face_detector/blaze_face_short_range/float16/1/blaze_face_short_range.tflite'; const FONTS = { Anton: 'https://cdn.jsdelivr.net/fontsource/fonts/anton@latest/latin-400-normal.ttf', Inter: 'https://cdn.jsdelivr.net/fontsource/fonts/inter@latest/latin-800-normal.ttf' }; const FREE_CLIPS = 3; const MARK = 'Made with Clipwhit'; const $ = (id) => document.getElementById(id); const el = (tag, attrs = {}, ...kids) => { const n = document.createElement(tag); for (const [k, v] of Object.entries(attrs)) { if (k === 'class') n.className = v; else if (k.startsWith('on')) n.addEventListener(k.slice(2), v); else if (v !== false && v != null) n.setAttribute(k, v === true ? '' : v); } for (const c of kids.flat()) if (c != null) n.append(c.nodeType ? c : document.createTextNode(String(c))); return n; }; const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]); const store = { get: (k, d) => { try { return JSON.parse(localStorage.getItem(k)) ?? d; } catch { return d; } }, set: (k, v) => { try { localStorage.setItem(k, JSON.stringify(v)); } catch {} } }; const baseName = (n) => String(n || 'video').replace(/\.[^.]+$/, '').replace(/[^\w.-]+/g, '-').slice(0, 60) || 'video'; const parseClock = (s) => { const p = String(s).trim().split(':').map(Number); if (p.some((x) => !Number.isFinite(x))) return NaN; return p.reduce((a, b) => a * 60 + b, 0); }; // ---------------------------------------------------------------- state const S = { tier: 'free', jobs: [], active: null, busy: false, ff: null, ffLoaded: false, detector: null, worker: null, timings: {}, // measured per stage, shown in the status line and read by the test }; window.clipwhit = S; // test/automation hook function setStatus(t, frac) { $('status').textContent = t; if (frac != null) $('bar').style.width = `${Math.round(Math.max(0, Math.min(1, frac)) * 100)}%`; } const pro = () => S.tier === 'pro' || S.tier === 'agency'; // ---------------------------------------------------------------- licence function renderTier() { const t = S.tier; $('tier').textContent = t === 'agency' ? 'Agency' : t === 'pro' ? 'Pro' : 'Free'; $('tier').dataset.tier = t; document.body.dataset.tier = t; $('res').querySelector('option[value="1080"]').disabled = !pro(); if (!pro()) $('res').value = '720'; for (const o of $('style').options) o.disabled = !pro() && !STYLES[o.value].free; if (!pro() && !STYLES[$('style').value].free) $('style').value = 'pop'; $('kitbox').hidden = !pro(); $('kitnew').hidden = S.tier !== 'agency'; $('upsell').hidden = pro(); renderKits(); if (S.active) renderMoments(S.active); } async function applyKey(key, force = false) { const r = await lic.verify(API, key, { force }); if (r.valid) { S.tier = r.tier === 'agency' ? 'agency' : 'pro'; $('keymsg').textContent = `${S.tier === 'agency' ? 'Agency' : 'Pro'} unlocked on this browser.`; } else { S.tier = 'free'; $('keymsg').textContent = r.reason === 'offline' ? 'Could not reach the licence server; try again when online.' : 'That key was not accepted. Check it, or buy Pro below.'; } renderTier(); return r; } // ---------------------------------------------------------------- brand kits (Pro: one, Agency: many) const kits = () => store.get('clipwhit.kits', [{ id: 'k1', name: 'My brand', base: '#FFFFFF', hi: '#FFD23F', font: 'Anton', handle: '' }]); function renderKits() { const list = kits(), sel = $('kit'); const cur = store.get('clipwhit.kit', list[0].id); sel.replaceChildren(el('option', { value: '' }, 'No brand kit (style colours)'), ...list.map((k) => el('option', { value: k.id, selected: k.id === cur }, k.name))); const k = list.find((x) => x.id === sel.value); for (const f of ['name', 'base', 'hi', 'font', 'handle']) $(`kit-${f}`).value = k ? k[f] : ''; $('kitfields').hidden = !k; } function saveKit() { const list = kits(), id = $('kit').value, k = list.find((x) => x.id === id); if (!k) return; for (const f of ['name', 'base', 'hi', 'font', 'handle']) k[f] = $(`kit-${f}`).value.slice(0, 40); store.set('clipwhit.kits', list); store.set('clipwhit.kit', id); $('kit').selectedOptions[0].textContent = k.name; } function activeKit() { if (!pro()) return null; return kits().find((x) => x.id === $('kit').value) || null; } // ---------------------------------------------------------------- engines (lazy) async function blobURL(url, type) { const r = await fetch(url); if (!r.ok) throw new Error(`download failed (${r.status})`); return URL.createObjectURL(new Blob([await r.arrayBuffer()], { type })); } async function ffmpeg() { if (S.ffLoaded) return S.ff; const t0 = performance.now(); setStatus('Loading the video engine (ffmpeg.wasm, about 31 MB, cached after the first time)…'); S.ff = new FFmpeg(); S.ff.on('log', ({ message }) => { S.lastLog = (S.lastLog || []).slice(-40); S.lastLog.push(message); }); await S.ff.load({ coreURL: await blobURL(`${CORE}/ffmpeg-core.js`, 'text/javascript'), wasmURL: await blobURL(`${CORE}/ffmpeg-core.wasm`, 'application/wasm') }); await S.ff.createDir('/fonts'); for (const [name, url] of Object.entries(FONTS)) { const r = await fetch(url); await S.ff.writeFile(`/fonts/${name}.ttf`, new Uint8Array(await r.arrayBuffer())); } await S.ff.createDir('/in'); S.ffLoaded = true; S.timings.engineLoad = (performance.now() - t0) / 1000; return S.ff; } async function mountFile(job) { const ff = await ffmpeg(); if (S.mounted === job.id) return `/in/${job.file.name}`; if (S.mounted) { try { await ff.unmount('/in'); } catch {} } await ff.mount('WORKERFS', { files: [job.file] }, '/in'); S.mounted = job.id; return `/in/${job.file.name}`; } async function faceDetector() { if (S.detector) return S.detector; const { FaceDetector, FilesetResolver } = await import(`${MP}/vision_bundle.mjs`); const fs = await FilesetResolver.forVisionTasks(`${MP}/wasm`); S.detector = await FaceDetector.createFromOptions(fs, { baseOptions: { modelAssetPath: FACE_MODEL, delegate: 'CPU' }, runningMode: 'IMAGE', minDetectionConfidence: 0.5 }); return S.detector; } // ---------------------------------------------------------------- jobs let nextId = 1; async function addFiles(files, meta = {}) { const list = [...files].filter((f) => /^(video|audio)\//.test(f.type) || /\.(mp4|mov|m4v|webm|mkv|mp3|m4a|wav)$/i.test(f.name)); if (!list.length) return setStatus('That is not a video or audio file. Try MP4, MOV, WebM, MKV, MP3 or M4A.'); if (!pro() && (list.length > 1 || S.jobs.length)) { $('batchmsg').hidden = false; S.jobs = []; } for (const file of (pro() ? list : list.slice(0, 1))) { const job = { id: nextId++, file, name: file.name, url: URL.createObjectURL(file), status: 'ready', moments: [], clips: [], art: meta.art || null, show: meta.show || '', source: meta.source || '' }; await probe(job); S.jobs.push(job); } select(S.jobs[S.jobs.length - (pro() ? list.length : 1)]); renderQueue(); } function probe(job) { return new Promise((res) => { const v = el('video', { preload: 'metadata', muted: true, src: job.url }); const timer = setTimeout(() => fallback(), 4000); const fallback = async () => { clearTimeout(timer); v.onloadedmetadata = v.onerror = null; // The browser can't read this codec/container: ask ffmpeg.wasm instead (loads the engine once). try { const ff = await ffmpeg(); const path = await mountFile(job); S.lastLog = []; await ff.exec(['-hide_banner', '-i', path]); const log = S.lastLog.join('\n'); const d = log.match(/Duration: (\d+):(\d+):([\d.]+)/); const vs = log.match(/Video:.*?(\d{2,5})x(\d{2,5})/); if (!d) throw new Error('no duration'); job.duration = +d[1] * 3600 + +d[2] * 60 + +d[3]; if (vs) { job.w = +vs[1]; job.h = +vs[2]; } job.nativePreview = false; } catch { job.duration = 0; job.error = 'That file could not be read. Convert it to MP4 (H.264) first.'; } res(); }; v.onloadedmetadata = () => { if (!v.duration || !Number.isFinite(v.duration) || (v.videoWidth === 0 && /^video\//.test(job.file.type))) return fallback(); clearTimeout(timer); job.duration = v.duration; job.w = v.videoWidth; job.h = v.videoHeight; job.nativePreview = true; res(); }; v.onerror = () => fallback(); }); } function select(job) { S.active = job; $('work').hidden = false; $('preview').src = job.url; const d = job.duration || 0; $('fileinfo').textContent = `${job.name} · ${clock(d)}${job.w ? ` · ${job.w}×${job.h}` : ' · audio only'}`; $('from').value = clock(job.range?.[0] ?? 0); $('to').value = clock(job.range?.[1] ?? d); $('find').disabled = S.busy || !!job.error; if (job.error) setStatus(job.error); else setStatus(job.moments.length ? `${job.moments.length} moments found. Pick clips and press Render.` : 'Ready. Choose a range if you like, then press Find the best moments.'); renderMoments(job); renderResults(job); } function renderQueue() { $('queue').hidden = S.jobs.length < 2; $('queuelist').replaceChildren(...S.jobs.map((j) => el('li', {}, el('button', { class: `q${j === S.active ? ' on' : ''}`, type: 'button', onclick: () => select(j) }, j.name), ` ${j.status}`))); } // ---------------------------------------------------------------- 1. audio + transcript async function extractAudio(job, start, end) { const t0 = performance.now(); try { const ff = await ffmpeg(); const path = await mountFile(job); setStatus('Reading the audio track in your browser…', 0.02); const rc = await ff.exec(['-hide_banner', '-ss', String(start), '-t', String(end - start), '-i', path, '-vn', '-ac', '1', '-ar', '16000', '-f', 's16le', '/audio.raw']); if (rc !== 0) throw new Error('ffmpeg could not read the audio'); const raw = await ff.readFile('/audio.raw'); await ff.deleteFile('/audio.raw'); const pcm = new Int16Array(raw.buffer, raw.byteOffset, Math.floor(raw.byteLength / 2)); const out = new Float32Array(pcm.length); for (let i = 0; i < pcm.length; i++) out[i] = pcm[i] / 32768; S.timings.audio = (performance.now() - t0) / 1000; return out; } catch (e) { // Fallback: the browser's own decoder (whole file in memory; fine for shorter files) const ctx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 }); try { const buf = await ctx.decodeAudioData(await job.file.arrayBuffer()); const ch = buf.getChannelData(0); S.timings.audio = (performance.now() - t0) / 1000; return ch.slice(Math.floor(start * 16000), Math.floor(end * 16000)); } finally { ctx.close?.(); } } } function transcribe(audio, model) { return new Promise((resolve, reject) => { if (!S.worker) S.worker = new Worker(new URL('./asr-worker.js', import.meta.url), { type: 'module' }); const total = Math.max(1, Math.ceil(audio.length / 16000 / 25)); const files = new Map(); const t0 = performance.now(); let tLoad = null; S.worker.onerror = (e) => { e.preventDefault?.(); reject(new Error(e.message || 'the speech engine could not start')); }; S.worker.onmessage = ({ data: m }) => { if (m.type === 'load' && m.file && m.total) { files.set(m.file, [m.loaded || 0, m.total]); let l = 0, t = 0; for (const [a, b] of files.values()) { l += a; t += b; } setStatus(`Downloading the speech model: ${(l / 1e6).toFixed(0)} of ${(t / 1e6).toFixed(0)} MB (cached afterwards)`, t ? l / t : 0); } else if (m.type === 'info') setStatus(m.message); else if (m.type === 'ready') { tLoad = performance.now(); S.timings.modelLoad = (tLoad - t0) / 1000; S.timings.device = m.device; setStatus(`Transcribing on your ${m.device === 'webgpu' ? 'GPU (WebGPU)' : 'CPU (WASM)'}…`, 0); } else if (m.type === 'partial') $('partial').textContent = ($('partial').textContent + m.text).slice(-220); else if (m.type === 'chunk') { setStatus(`Transcribing… part ${Math.min(m.done, total)} of about ${total}`, m.done / total); $('partial').textContent = ''; } else if (m.type === 'done') { S.timings.transcribe = (performance.now() - (tLoad || t0)) / 1000; resolve(m); } else if (m.type === 'error') reject(new Error(m.message)); }; const copy = audio.slice(); S.worker.postMessage({ type: 'run', audio: copy, model, device: $('device').value === 'auto' ? ('gpu' in navigator ? 'webgpu' : 'wasm') : $('device').value }, [copy.buffer]); }); } async function aiRerank(job) { try { const items = job.moments.slice(0, 12).map((m) => ({ id: m.id, text: m.text.slice(0, 1200) })); const r = await fetch(`${API}/api/rank`, { method: 'POST', headers: { 'content-type': 'application/json', ...(lic.current().key ? { 'x-licence': lic.current().key } : {}) }, body: JSON.stringify({ items }) }); const j = await r.json(); if (!r.ok) return setStatus(j.message || 'AI re-rank is not available right now; the local ranking is used.'); for (const a of j.items || []) { const m = job.moments.find((x) => x.id === a.id); if (!m || !Number.isFinite(a.score)) continue; m.ai = a.score; m.score = Math.round((m.score + a.score * 0.8) * 100) / 100; if (a.title) m.title = String(a.title).slice(0, 90); if (Array.isArray(a.hashtags) && a.hashtags.length) m.hashtags = a.hashtags.slice(0, 5).map((h) => `#${String(h).replace(/^#/, '').replace(/[^\p{L}\p{N}_]/gu, '')}`); m.reasons = [...m.reasons, `AI rated ${a.score}/10`]; } job.moments.sort((a, b) => b.score - a.score); } catch { setStatus('AI re-rank is not reachable; the local ranking is used.'); } } async function findMoments(job = S.active) { if (S.busy || !job) return; const start = Math.max(0, parseClock($('from').value) || 0); let end = parseClock($('to').value); if (!Number.isFinite(end) || end <= start) end = job.duration; end = Math.min(end, job.duration); if (end - start < 20) return setStatus('Pick a range of at least 20 seconds.'); job.range = [start, end]; S.busy = true; $('find').disabled = true; job.status = 'transcribing'; renderQueue(); const t0 = performance.now(); try { const audio = await extractAudio(job, start, end); const res = await transcribe(audio, $('model').value); const segs = toSegments(res.chunks, end - start).map((s) => ({ ...s, start: s.start + start, end: s.end + start })); job.segments = segs; job.words = toWords(segs); const env = envelope(audio); job.env = { step: env.step, values: env.values, offset: start }; const shifted = { step: env.step, values: new Float32Array(Math.ceil(start / env.step) + env.values.length) }; shifted.values.set(env.values, Math.ceil(start / env.step)); job.moments = pickMoments(segs, shifted, { count: 12, minLen: +$('minlen').value, maxLen: +$('maxlen').value, target: (+$('minlen').value + +$('maxlen').value) / 2 }); if ($('ai').checked && job.moments.length) { setStatus('Asking the AI to re-rank the transcript moments (text only)…'); await aiRerank(job); } job.moments.forEach((m, i) => { m.rank = i + 1; m.pick = i < FREE_CLIPS; }); job.status = `${job.moments.length} moments`; S.timings.find = (performance.now() - t0) / 1000; setStatus(job.moments.length ? `Found ${job.moments.length} moments in ${S.timings.find.toFixed(0)} s. The best ${Math.min(FREE_CLIPS, job.moments.length)} are ticked; press Render.` : 'No speech was found in that range.', 1); renderMoments(job); } catch (e) { job.status = 'error'; setStatus(`Something went wrong: ${e.message || e}. Try the Tiny model, a shorter range, or Chrome/Edge.`); } finally { S.busy = false; $('find').disabled = false; updateRenderBtn(); renderQueue(); } } // ---------------------------------------------------------------- moments UI function renderMoments(job) { const box = $('moments'); $('momentsbox').hidden = !job?.moments.length; if (!job?.moments.length) return box.replaceChildren(); box.replaceChildren(...job.moments.map((m, i) => { const locked = !pro() && i >= FREE_CLIPS; if (locked) m.pick = false; const card = el('li', { class: `moment${locked ? ' locked' : ''}` }, el('label', { class: 'pick' }, el('input', { type: 'checkbox', checked: m.pick, disabled: locked, onchange: (e) => { m.pick = e.target.checked; updateRenderBtn(); } }), el('b', {}, `#${i + 1}`)), el('div', { class: 'mbody' }, el('div', { class: 'mhead' }, el('span', { class: 'time' }, `${clock(m.start)}–${clock(m.end)} · ${Math.round(m.end - m.start)} s`), el('span', { class: 'score', title: 'Moment score' }, `score ${m.score.toFixed(1)}`), el('button', { type: 'button', class: 'mini', onclick: () => { const v = $('preview'); v.currentTime = m.start; v.play(); } }, '▶ Preview')), el('input', { class: 'title', value: m.title, 'aria-label': 'Clip title', oninput: (e) => { m.title = e.target.value; } }), el('p', { class: 'excerpt' }, m.text.length > 260 ? `${m.text.slice(0, 260)}…` : m.text), el('div', { class: 'chips' }, ...m.reasons.slice(0, 4).map((r) => el('span', { class: 'chip' }, r)), el('span', { class: 'chip tags' }, m.hashtags.join(' '))), el('div', { class: 'nudge' }, 'Start ', el('button', { type: 'button', class: 'mini', onclick: () => nudge(m, 'start', -3) }, '−3 s'), el('button', { type: 'button', class: 'mini', onclick: () => nudge(m, 'start', 3) }, '+3 s'), ' End ', el('button', { type: 'button', class: 'mini', onclick: () => nudge(m, 'end', -3) }, '−3 s'), el('button', { type: 'button', class: 'mini', onclick: () => nudge(m, 'end', 3) }, '+3 s')), locked ? el('p', { class: 'lockmsg' }, 'Free plan: 3 clips per video. ', el('a', { href: '#pricing' }, 'Unlock every moment with Pro')) : null)); return card; })); updateRenderBtn(); } function nudge(m, k, d) { m[k] = Math.max(0, Math.min(S.active.duration, m[k] + d)); if (m.end - m.start < 5) m[k] -= d; renderMoments(S.active); } function updateRenderBtn() { const n = S.active?.moments.filter((m) => m.pick).length || 0; $('render').textContent = n ? `Render ${n} clip${n === 1 ? '' : 's'} (9:16, captions)` : 'Tick at least one moment'; $('render').disabled = !n || S.busy; } // ---------------------------------------------------------------- 2. face tracking + render async function trackFaces(job, m) { if (!job.w) return []; // Frames come from ffmpeg (not a