Spaces:
Running
Running
File size: 46,831 Bytes
be07307 b46a11d be07307 b46a11d be07307 b46a11d be07307 b46a11d be07307 b46a11d be07307 b46a11d be07307 b46a11d fb97fbe b46a11d be07307 b46a11d be07307 b46a11d be07307 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 | // 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 <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();
|