clipwhit / app.js
CyberMax-tools's picture
Deploy clipwhit
fb97fbe verified
Raw History Blame Contribute Delete
46.8 kB
// 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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 <video> element), so any codec ffmpeg reads is tracked, in every browser.
const det = await faceDetector();
const ff = await ffmpeg();
const path = await mountFile(job);
const dur = m.end - m.start, fps = dur > 50 ? 0.67 : 1;
const W = 320, H = Math.round((W * job.h) / job.w / 2) * 2;
const rc = await ff.exec(['-hide_banner', '-ss', m.start.toFixed(2), '-t', dur.toFixed(2), '-i', path, '-an', '-vf', `fps=${fps},scale=${W}:${H}`, '-f', 'rawvideo', '-pix_fmt', 'rgba', '/frames.raw']);
if (rc !== 0) return [];
const raw = await ff.readFile('/frames.raw');
await ff.deleteFile('/frames.raw');
const size = W * H * 4, out = [];
for (let i = 0; (i + 1) * size <= raw.byteLength; i++) {
const img = new ImageData(new Uint8ClampedArray(raw.buffer, raw.byteOffset + i * size, size), W, H);
const faces = det.detect(img).detections || [];
// the biggest face is usually the person talking to camera
const f = faces.sort((a, b) => b.boundingBox.width * b.boundingBox.height - a.boundingBox.width * a.boundingBox.height)[0];
out.push({ t: i / fps, cx: f ? ((f.boundingBox.originX + f.boundingBox.width / 2) / W) * job.w : null });
}
return out;
}
async function renderClip(job, m, idx, total) {
const ff = await ffmpeg();
const path = await mountFile(job);
const res = pro() && $('res').value === '1080' ? 1080 : 720;
const OW = res, OH = Math.round((res * 16) / 9);
const kit = activeKit();
const t0 = performance.now();
setStatus(`Clip ${idx}/${total}: finding the face…`, 0);
const samples = await trackFaces(job, m);
const keys = job.w ? thinKeys(cropPath(samples, job.w, job.h), 24) : [];
const faceHits = samples.filter((s) => s.cx != null).length;
const tFace = (performance.now() - t0) / 1000;
const style = $('style').value;
const brand = kit ? { base: kit.base, hi: kit.hi, font: kit.font } : null;
const mark = pro() ? (kit?.handle || null) : MARK;
await ff.writeFile('/c.ass', buildAss(job.words, m.start, m.end, { style, brand, mark }));
let vf;
if (job.w) {
const { w, h } = cropBox(job.w, job.h);
vf = `crop=w=${w}:h=${h}:x='${cropExpr(keys)}':y=${Math.round((job.h - h) / 2)},scale=${OW}:${OH}:flags=bicubic,setsar=1,subtitles=filename=/c.ass:fontsdir=/fonts`;
} else {
vf = null; // audio-only source: an audiogram (artwork + moving waveform + the same captions)
await ff.writeFile('/bg.jpg', await audiogramBackground(job, OW, OH, kit));
}
const out = `/clip-${idx}.mp4`;
const t1 = performance.now();
const onProg = ({ progress }) => setStatus(`Clip ${idx}/${total}: rendering ${OW}×${OH} with captions… ${Math.round(Math.max(0, Math.min(1, progress)) * 100)}%`, progress);
ff.on('progress', onProg);
const dur = (m.end - m.start).toFixed(2);
const args = vf
? ['-hide_banner', '-ss', m.start.toFixed(2), '-t', dur, '-i', path, '-vf', vf, '-r', '30', '-c:v', 'libx264', '-preset', 'ultrafast', '-crf', res === 1080 ? '21' : '23', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '128k', '-movflags', '+faststart', out]
: ['-hide_banner', '-loop', '1', '-framerate', '30', '-i', '/bg.jpg', '-ss', m.start.toFixed(2), '-t', dur, '-i', path, '-filter_complex', waveGraph(OW, OH, kit), '-map', '[v]', '-map', '1:a', '-t', dur, '-r', '30', '-c:v', 'libx264', '-preset', 'ultrafast', '-crf', res === 1080 ? '21' : '23', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '128k', '-movflags', '+faststart', out];
const rc = await ff.exec(args);
ff.off('progress', onProg);
if (rc !== 0) throw new Error(`the renderer stopped (${(S.lastLog || []).slice(-2).join(' ')})`);
const data = await ff.readFile(out);
// poster frame (shown before playback; also where a browser can't play H.264 inline)
let poster = null;
try {
if (await ff.exec(['-hide_banner', '-ss', String(Math.min(3, (m.end - m.start) / 3)), '-i', out, '-frames:v', '1', '-vf', 'scale=270:-2', '-q:v', '4', '/poster.jpg']) === 0) {
poster = URL.createObjectURL(new Blob([await ff.readFile('/poster.jpg')], { type: 'image/jpeg' }));
await ff.deleteFile('/poster.jpg');
}
} catch {}
await ff.deleteFile(out);
const tRender = (performance.now() - t1) / 1000;
const name = `${baseName(job.name)}-clip-${String(idx).padStart(2, '0')}`;
const srt = buildSrt(job.words, m.start, m.end);
const meta = `${m.title}\n\n${m.description}\n\n${m.hashtags.join(' ')}\n\nFrom ${job.name} at ${clock(m.start)}–${clock(m.end)}\n`;
return { name, poster, mp4: new Blob([data], { type: 'video/mp4' }), bytes: data.byteLength, srt, meta, m, width: OW, height: OH, faceHits, samples: samples.length, keys: keys.length, tFace, tRender, style };
}
// ---------------------------------------------------------------- audiograms (audio-only sources)
// The still layer (blurred artwork, the artwork square, the show name) is drawn once per clip on a canvas; ffmpeg adds
// the moving waveform (showwaves) and burns in the same word-by-word captions.
const AG = { artTop: 0.12, artSize: 0.74, waveY: 0.555, waveH: 0.09 };
function waveGraph(OW, OH, kit) {
const pad = Math.round(OW * 0.08), ww = Math.round((OW - 2 * pad) / 2) * 2, wh = Math.round((OH * AG.waveH) / 2) * 2;
const col = (kit?.hi || STYLES[$('style').value]?.hi || '#FFD23F').replace('#', '0x');
return `[1:a]aformat=channel_layouts=mono,showwaves=s=${ww}x${wh}:mode=cline:rate=30:scale=sqrt:draw=full:colors=${col},format=rgba[wv];`
+ `[0:v]scale=${OW}:${OH},setsar=1,format=rgba[bg];[bg][wv]overlay=${pad}:${Math.round(OH * AG.waveY)}:shortest=1,subtitles=filename=/c.ass:fontsdir=/fonts,format=yuv420p[v]`;
}
async function audiogramBackground(job, OW, OH, kit) {
const c = document.createElement('canvas'); c.width = OW; c.height = OH;
const g = c.getContext('2d');
let img = null;
try { const art = await job.art; if (art) img = await createImageBitmap(art); } catch { img = null; }
if (img) {
// blurred, darkened cover of the artwork (downscale + upscale = a blur that works in every browser)
const t = document.createElement('canvas'); t.width = 18; t.height = 32;
const s = Math.max(18 / img.width, 32 / img.height);
t.getContext('2d').drawImage(img, (18 - img.width * s) / 2, (32 - img.height * s) / 2, img.width * s, img.height * s);
g.imageSmoothingEnabled = true; g.imageSmoothingQuality = 'high';
g.drawImage(t, 0, 0, OW, OH);
g.fillStyle = 'rgba(10,9,16,.45)'; g.fillRect(0, 0, OW, OH);
} else {
const grad = g.createLinearGradient(0, 0, OW, OH);
grad.addColorStop(0, '#2A1A66'); grad.addColorStop(1, '#0F0E15');
g.fillStyle = grad; g.fillRect(0, 0, OW, OH);
}
const sq = Math.round(OW * AG.artSize), x = (OW - sq) / 2, y = Math.round(OH * AG.artTop), r = Math.round(OW * 0.04);
g.save(); g.shadowColor = 'rgba(0,0,0,.45)'; g.shadowBlur = OW * 0.04; g.shadowOffsetY = OW * 0.012;
g.beginPath(); g.roundRect ? g.roundRect(x, y, sq, sq, r) : g.rect(x, y, sq, sq); g.fillStyle = '#1A1824'; g.fill(); g.restore();
g.save(); g.beginPath(); g.roundRect ? g.roundRect(x, y, sq, sq, r) : g.rect(x, y, sq, sq); g.clip();
if (img) { const s = Math.max(sq / img.width, sq / img.height); g.drawImage(img, x + (sq - img.width * s) / 2, y + (sq - img.height * s) / 2, img.width * s, img.height * s); }
else { // no artwork: brand gradient with simple equaliser bars
const gg = g.createLinearGradient(x, y, x + sq, y + sq); gg.addColorStop(0, '#6C3CF0'); gg.addColorStop(1, '#2A1A66');
g.fillStyle = gg; g.fillRect(x, y, sq, sq); g.fillStyle = kit?.hi || '#FFD23F';
const hs = [0.35, 0.6, 0.85, 0.5, 0.7, 0.4, 0.25], bw = sq / 16;
hs.forEach((h, i) => { const bh = sq * 0.5 * h; g.fillRect(x + sq / 2 + (i - 3) * bw * 1.6 - bw / 2, y + sq / 2 - bh / 2, bw, bh); });
}
g.restore();
// show / episode name above the artwork
const label = (job.show || baseName(job.name).replace(/[-_]+/g, ' ')).slice(0, 80);
g.fillStyle = '#FFFFFF'; g.textAlign = 'center'; g.textBaseline = 'middle';
g.font = `800 ${Math.round(OW * 0.042)}px system-ui, -apple-system, "Segoe UI", sans-serif`;
const words = label.split(/\s+/), lines = [];
let cur = '';
for (const w of words) { const t = cur ? `${cur} ${w}` : w; if (g.measureText(t).width > OW * 0.84 && cur) { lines.push(cur); cur = w; } else cur = t; }
if (cur) lines.push(cur);
lines.slice(0, 2).forEach((l, i, a) => g.fillText(i === 1 && lines.length > 2 ? `${l}…` : l, OW / 2, y * 0.55 + (i - (a.length - 1) / 2) * OW * 0.055));
const blob = await new Promise((res) => c.toBlob(res, 'image/jpeg', 0.9));
img?.close?.();
return new Uint8Array(await blob.arrayBuffer());
}
async function renderPicked(job = S.active) {
if (S.busy || !job) return;
const picked = job.moments.filter((m, i) => m.pick && (pro() || i < FREE_CLIPS));
if (!picked.length) return;
S.busy = true; $('render').disabled = true; $('find').disabled = true;
job.status = 'rendering'; renderQueue();
const t0 = performance.now();
try {
for (let i = 0; i < picked.length; i++) {
const clip = await renderClip(job, picked[i], i + 1, picked.length);
job.clips.push(clip);
renderResults(job);
}
S.timings.render = (performance.now() - t0) / 1000;
job.status = `${job.clips.length} clips`;
setStatus(`Done: ${picked.length} clip${picked.length === 1 ? '' : 's'} in ${S.timings.render.toFixed(0)} s. Download below.`, 1);
} catch (e) {
job.status = 'error';
setStatus(`Rendering failed: ${e.message || e}. Try 720p or a shorter clip.`);
} finally { S.busy = false; $('find').disabled = false; updateRenderBtn(); renderQueue(); }
}
// ---------------------------------------------------------------- results + export
function dl(blob, name) {
const a = el('a', { href: URL.createObjectURL(blob), download: name });
document.body.append(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 10000);
}
function renderResults(job) {
$('resultsbox').hidden = !job?.clips.length;
if (!job?.clips.length) return $('results').replaceChildren();
$('results').replaceChildren(...job.clips.map((c) => {
const url = c.url || (c.url = URL.createObjectURL(c.mp4));
return el('li', { class: 'clip' },
el('video', { src: url, poster: c.poster, controls: true, playsinline: true, preload: 'none', width: 216, height: 384 }),
el('div', { class: 'cbody' },
el('b', {}, c.m.title),
el('p', { class: 'muted' }, `${c.width}×${c.height} · ${(c.bytes / 1e6).toFixed(1)} MB · ${Math.round(c.m.end - c.m.start)} s · face found in ${c.faceHits}/${c.samples} checks`),
el('p', {}, c.m.description),
el('p', { class: 'tags' }, c.m.hashtags.join(' ')),
el('div', { class: 'row' },
el('button', { type: 'button', class: 'btn sm', onclick: () => dl(c.mp4, `${c.name}.mp4`) }, 'MP4'),
el('button', { type: 'button', class: 'btn sm ghost', onclick: () => dl(new Blob([c.srt], { type: 'application/x-subrip' }), `${c.name}.srt`) }, 'SRT'),
el('button', { type: 'button', class: 'btn sm ghost', onclick: () => dl(new Blob([c.meta], { type: 'text/plain' }), `${c.name}.txt`) }, 'Title + tags'),
el('button', { type: 'button', class: 'btn sm ghost', onclick: () => navigator.clipboard?.writeText(`${c.m.title}\n\n${c.m.description}\n\n${c.m.hashtags.join(' ')}`) }, 'Copy text'))));
}));
}
function zipFor(jobs) {
const files = [];
for (const j of jobs) for (const c of j.clips) files.push({ c, prefix: jobs.length > 1 ? `${baseName(j.name)}/` : '' });
return Promise.all(files.map(async ({ c, prefix }) => [
{ name: `${prefix}${c.name}.mp4`, data: new Uint8Array(await c.mp4.arrayBuffer()) },
{ name: `${prefix}${c.name}.srt`, data: c.srt }, { name: `${prefix}${c.name}.txt`, data: c.meta }])).then((x) => x.flat());
}
async function zipAll(all) {
const jobs = all ? S.jobs.filter((j) => j.clips.length) : [S.active];
const files = await zipFor(jobs);
const blob = new Blob(zipParts(files), { type: 'application/zip' });
S.lastZip = { bytes: blob.size, files: files.length };
dl(blob, all ? 'clipwhit-all-clips.zip' : `${baseName(S.active.name)}-clips.zip`);
}
// Batch (Pro): find + render the top N of every queued video, one after another.
async function autoAll() {
const n = Math.max(1, Math.min(20, +$('autoN').value || 5));
for (const j of S.jobs) {
if (j.clips.length) continue;
select(j);
await findMoments(j);
j.moments.forEach((m, i) => { m.pick = i < n; });
await renderPicked(j);
}
setStatus(`Batch done: ${S.jobs.reduce((a, j) => a + j.clips.length, 0)} clips from ${S.jobs.length} videos.`, 1);
}
// ---------------------------------------------------------------- paste a link
// Links are classified in the browser (lib/links.js). Media comes through our Worker's /fetch proxy and becomes a File,
// exactly like a dropped one (mounted read-only into ffmpeg, never stored by us). YouTube, TikTok, Instagram, X, Twitch
// and Facebook are never fetched: their terms forbid it, so we show how to download your own original instead.
const L = { ctrl: null };
const EXT = { 'video/mp4': 'mp4', 'video/quicktime': 'mov', 'video/webm': 'webm', 'video/x-matroska': 'mkv', 'audio/mpeg': 'mp3', 'audio/mp4': 'm4a', 'audio/x-m4a': 'm4a', 'audio/aac': 'aac', 'audio/wav': 'wav', 'audio/x-wav': 'wav', 'audio/ogg': 'ogg', 'audio/opus': 'opus', 'audio/flac': 'flac' };
const TYPE = Object.fromEntries(Object.entries(EXT).map(([k, v]) => [v, k]));
const linkHeaders = () => { const k = lic.current().key; return { 'x-clipwhit-app': '1', ...(k && pro() ? { 'x-licence': k } : {}) }; };
const linkMsg = (...nodes) => $('linkmsg').replaceChildren(...nodes.flat().filter(Boolean));
const warnP = (t) => el('p', { class: 'warn' }, t);
async function api(path) {
const r = await fetch(`${API}${path}`, { headers: linkHeaders() });
let j = {}; try { j = await r.json(); } catch {}
return { ok: r.ok, status: r.status, j };
}
const pickFile = () => el('button', { type: 'button', class: 'btn sm', onclick: () => $('file').click() }, 'Choose the downloaded file');
function helper(c) {
const cap = c.captions ? c.captions.split('Hook Finder') : null;
return el('div', { class: 'helper', 'data-platform': c.platform },
el('b', {}, `${c.name} links can't be clipped directly`),
el('p', {}, c.how),
cap ? el('p', {}, cap[0], el('a', { href: 'hooks/' }, 'Hook Finder'), cap[1] || '') : null,
el('p', { class: 'note' }, `${c.name}'s terms don't allow other apps to download its videos, so Clipwhit never fetches them. Only clip videos you own or have permission to use.`),
el('div', { class: 'row' }, pickFile()));
}
async function loadLink(raw) {
if (L.ctrl || S.busy) return;
const c = classify(raw);
S.lastLink = c;
$('linkpanel').hidden = false; $('feedbox').hidden = true; $('dlbox').hidden = true; linkMsg();
if (c.kind === 'invalid') return linkMsg(warnP(c.reason));
if (c.kind === 'blocked') { linkMsg(helper(c)); return $('linkpanel').scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }
if (c.kind === 'apple') return loadApple(c);
if (c.kind === 'feed') return loadFeed(c.url);
if (c.kind === 'vimeo') return loadVimeo(c);
return download(c.url, { name: c.name });
}
async function loadApple(c) {
linkMsg(el('p', { class: 'note' }, 'Finding the show’s public RSS feed with Apple’s lookup service…'));
try {
const { ok, j } = await api(`/apple?id=${c.id}${c.episode ? `&ep=${c.episode}` : ''}`);
if (!ok) return linkMsg(warnP(j.message || 'Apple’s lookup service did not answer. Paste the show’s RSS feed link instead.'));
let want = j.episode && !j.episode.missing ? j.episode : null;
if (c.episode && !want) {
// Apple refuses episode lookups from our server, but its Lookup API allows browsers (CORS *): ask from here.
try {
const r = await fetch(`https://itunes.apple.com/lookup?id=${c.id}&entity=podcastEpisode&limit=200`);
const e = ((await r.json()).results || []).find((x) => String(x.trackId) === c.episode);
if (e) want = { title: e.trackName || '', guid: e.episodeGuid || '', url: e.episodeUrl || '' };
} catch {}
want = want || { title: '\u0000' }; // still say "not among recent episodes"
}
return loadFeed(j.feedUrl, { want, art: j.image, apple: true });
} catch { linkMsg(warnP('Could not reach the lookup service. Check your connection and try again.')); }
}
async function loadVimeo(c) {
linkMsg(el('p', { class: 'note' }, 'Checking whether the owner allows downloads…'));
let j = {};
try { j = (await api(`/vimeo?id=${c.id}`)).j; } catch {}
if (j.download) return download(j.download, { name: `${baseName(j.title || `vimeo-${c.id}`)}.mp4` });
linkMsg(el('div', { class: 'helper', 'data-platform': 'vimeo' },
el('b', {}, j.reason === 'private' ? 'That Vimeo video is private' : 'Download it from Vimeo first'),
el('p', {}, 'Open the video on vimeo.com and press Download under the player (it appears when the owner allows downloads), then drop the file here. Your own video? vimeo.com → your video → ⋯ → Download, or switch downloads on in its settings.'),
el('p', { class: 'note' }, 'Clipwhit only fetches Vimeo files when the owner has made a download public; streaming copies are never saved.'),
el('div', { class: 'row' }, pickFile())));
}
async function loadFeed(url, opt = {}) {
linkMsg(el('p', { class: 'note' }, 'Reading the podcast feed…'));
let res;
try { res = await api(`/feed?url=${encodeURIComponent(url)}`); } catch { return linkMsg(warnP('Could not reach the feed. Check your connection and try again.')); }
const { ok, j } = res;
if (!ok) { if (j.error === 'media') return download(url, {}); return linkMsg(warnP(j.message || 'That feed could not be read.')); }
S.feed = j;
$('feedbox').hidden = false;
const art = j.image || opt.art || '';
$('feedart').src = art; $('feedart').hidden = !art;
$('feedtitle').textContent = j.title;
$('feedby').textContent = [j.author, `${j.episodes.length} recent episode${j.episodes.length === 1 ? '' : 's'}`].filter(Boolean).join(' · ');
const hit = opt.want ? matchEpisode(j.episodes, opt.want) : -1;
$('episodes').replaceChildren(...j.episodes.map((ep, i) => el('li', { class: `ep${i === hit ? ' match' : ''}` },
el('div', {}, el('b', {}, ep.title), el('span', {}, [ep.date ? new Date(ep.date).toDateString().slice(4) : '', ep.duration ? clock(ep.duration) : '', ep.bytes ? mb(ep.bytes) : '', /^video\//.test(ep.type) ? 'video' : 'audio'].filter(Boolean).join(' · '))),
el('button', { type: 'button', class: 'btn sm', onclick: () => pickEpisode(j, ep, art) }, 'Clip this'))));
linkMsg(hit >= 0 ? el('p', { class: 'note' }, `Found the episode from your link: “${j.episodes[hit].title}”. Fetching it…`)
: opt.want ? el('p', { class: 'note' }, 'That episode is not among the feed’s recent episodes; pick one below.') : null);
if (hit >= 0) { $('episodes').children[hit].scrollIntoView({ block: 'nearest' }); return pickEpisode(j, j.episodes[hit], art); }
}
async function loadArt(url) {
if (!url) return null;
try {
const r = await fetch(`${API}/fetch?kind=image&url=${encodeURIComponent(url)}`, { headers: linkHeaders() });
return r.ok ? await r.blob() : null;
} catch { return null; }
}
function pickEpisode(feed, ep, art) {
const ext = (ep.url.split('?')[0].match(MEDIA_EXT) || [])[1] || EXT[ep.type] || 'mp3';
const art$ = loadArt(ep.image || art).then((b) => b || (ep.image && art ? loadArt(art) : null));
return download(ep.url, { name: `${baseName(ep.title)}.${ext}`, art: art$, show: feed.title, type: ep.type });
}
async function download(url, { name = '', art = null, show = '', type = '' } = {}) {
if (L.ctrl) return;
const ctrl = new AbortController(); L.ctrl = ctrl;
$('dlbox').hidden = false; $('linkgo').disabled = true; $('dlbar').style.width = '0%';
$('dltext').textContent = 'Connecting…';
const t0 = performance.now();
let got = 0, cap = 0;
try {
const r = await fetch(`${API}/fetch?url=${encodeURIComponent(url)}`, { headers: linkHeaders(), signal: ctrl.signal });
if (!r.ok) {
let j = {}; try { j = await r.json(); } catch {}
if (j.error === 'feed') { L.ctrl = null; $('dlbox').hidden = true; $('linkgo').disabled = false; return loadFeed(url); }
if (j.error === 'platform') { const c = classify(url); throw Object.assign(new Error(j.message), { node: c.kind === 'blocked' ? helper(c) : null }); }
throw Object.assign(new Error(j.message || `The download failed (${r.status}).`), { node: j.error === 'too_big' && !pro() ? el('p', {}, el('a', { href: '#pricing' }, 'Pro fetches links up to 2 GB.')) : null });
}
const total = +r.headers.get('x-file-size') || +r.headers.get('content-length') || 0;
cap = +r.headers.get('x-size-cap') || 0;
const ctype = (r.headers.get('content-type') || '').split(';')[0].trim();
let fname = name || decodeURIComponent(r.headers.get('x-file-name') || '') || 'linked-video';
if (!MEDIA_EXT.test(fname)) fname = `${baseName(fname)}.${EXT[ctype] || EXT[type] || 'mp4'}`;
const label = fname.length > 48 ? `${fname.slice(0, 45)}…` : fname;
const reader = r.body.getReader();
const parts = []; let batch = [], batchBytes = 0, last = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
batch.push(value); batchBytes += value.byteLength; got += value.byteLength;
if (batchBytes >= 64e6) { parts.push(new Blob(batch)); batch = []; batchBytes = 0; } // let the browser page big downloads out of memory
const now = performance.now();
if (now - last > 200) {
last = now;
const rate = got / Math.max(0.001, (now - t0) / 1000);
$('dltext').textContent = `Downloading ${label}: ${mb(got)}${total ? ` of ${mb(total)}` : ''} · ${(rate / 1e6).toFixed(1)} MB/s`;
$('dlbar').style.width = total ? `${Math.min(100, (got / total) * 100).toFixed(1)}%` : `${Math.min(95, (got / (cap || 5e8)) * 100).toFixed(1)}%`;
}
}
if (total && got < total) throw new Error(`The download stopped at ${mb(got)} of ${mb(total)}. Try again, or download the file and drop it here.`);
parts.push(new Blob(batch));
const ftype = /^(video|audio)\//.test(ctype) ? ctype : TYPE[(fname.match(MEDIA_EXT) || [])[1]?.toLowerCase()] || type || 'video/mp4';
const file = new File(parts, fname, { type: ftype });
const secs = (performance.now() - t0) / 1000;
S.timings.linkDownload = secs; S.lastDownload = { bytes: got, total, name: fname, type: ftype, seconds: secs };
$('dlbar').style.width = '100%';
$('dltext').textContent = `Downloaded ${label}: ${mb(got)} in ${secs.toFixed(0)} s. Opening it…`;
L.ctrl = null;
await addFiles([file], { art, show, source: url });
$('dlbox').hidden = true;
linkMsg(el('p', { class: 'note' }, `Loaded “${label}” (${mb(got)}) from the link${/^audio\//.test(ftype) ? '. Audio only: your clips will be audiograms with the show artwork and a moving waveform' : ''}. Now press Find the best moments.`));
$('work').scrollIntoView({ behavior: 'smooth', block: 'start' });
} catch (e) {
$('dlbox').hidden = true;
if (ctrl.signal.aborted) linkMsg(el('p', { class: 'note' }, `Download cancelled${got ? ` at ${mb(got)}` : ''}.`));
else if (cap && got >= cap) linkMsg(warnP(`That file is bigger than ${mb(cap)}, the limit for links on your plan. Download it and drop it here instead.`));
else linkMsg(warnP(e.message && !/fetch|network/i.test(e.message) ? e.message : 'The download was interrupted. Check your connection and try again, or download the file and drop it here.'), e.node || null);
} finally { L.ctrl = null; $('linkgo').disabled = false; }
}
// ---------------------------------------------------------------- wiring
function wire() {
const drop = $('drop');
drop.addEventListener('click', () => $('file').click());
drop.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); $('file').click(); } });
$('file').addEventListener('change', () => addFiles($('file').files));
['dragenter', 'dragover'].forEach((t) => drop.addEventListener(t, (e) => { e.preventDefault(); drop.classList.add('over'); }));
['dragleave', 'drop'].forEach((t) => drop.addEventListener(t, (e) => { e.preventDefault(); drop.classList.remove('over'); }));
drop.addEventListener('drop', (e) => addFiles(e.dataTransfer.files));
$('sample').addEventListener('click', async () => {
setStatus('Loading the 74-second sample episode…');
const r = await fetch(new URL('./sample/growth-show-sample.mp4', import.meta.url));
await addFiles([new File([await r.blob()], 'growth-show-sample.mp4', { type: 'video/mp4' })]);
});
$('linkform').addEventListener('submit', (e) => { e.preventDefault(); loadLink($('link').value); });
$('link').addEventListener('paste', () => setTimeout(() => { if (/^\s*(https?:\/\/|www\.)\S+\s*$/i.test($('link').value)) loadLink($('link').value); }, 0));
$('dlcancel').addEventListener('click', () => L.ctrl?.abort());
$('find').addEventListener('click', () => findMoments());
$('render').addEventListener('click', () => renderPicked());
$('zip').addEventListener('click', () => zipAll(false));
$('zipall').addEventListener('click', () => zipAll(true));
$('auto').addEventListener('click', () => autoAll());
$('keyform').addEventListener('submit', (e) => { e.preventDefault(); applyKey($('key').value, true); });
$('kit').addEventListener('change', () => { store.set('clipwhit.kit', $('kit').value); renderKits(); });
for (const f of ['name', 'base', 'hi', 'font', 'handle']) $(`kit-${f}`).addEventListener('change', saveKit);
$('kitnew').addEventListener('click', () => {
const list = kits(); const id = `k${Date.now()}`;
list.push({ id, name: `Client ${list.length + 1}`, base: '#FFFFFF', hi: '#3DDC97', font: 'Anton', handle: '' });
store.set('clipwhit.kits', list); store.set('clipwhit.kit', id); renderKits();
});
$('style').replaceChildren(...Object.entries(STYLES).map(([k, s]) => el('option', { value: k }, `${s.label}${s.free ? '' : ' (Pro)'}`)));
}
async function init() {
wire();
const hash = new URLSearchParams(location.hash.slice(1));
const fromHash = hash.get('key');
if (fromHash) history.replaceState(null, '', location.pathname + location.search);
const shared = new URLSearchParams(location.search).get('link') || hash.get('link');
if (shared) { $('link').value = shared; setTimeout(() => loadLink(shared), 0); }
const cur = lic.current();
S.tier = cur.tier;
renderTier();
const key = fromHash || cur.key;
if (key) { $('key').value = key; await applyKey(key, !!fromHash); }
const cap = 'gpu' in navigator ? 'WebGPU available: transcription uses your graphics card.' : 'No WebGPU in this browser: transcription runs on the CPU (slower; Chrome or Edge on desktop is fastest).';
$('caps').textContent = cap;
}
init();