Files
vibedit/web/app.js
Sean MTS 396a676146 Initial commit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 02:28:47 +00:00

1888 lines
68 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
(() => {
// ---------------------------------------------------------------- helpers
const $ = (s, el = document) => el.querySelector(s);
const $$ = (s, el = document) => [...el.querySelectorAll(s)];
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
const sleep = ms => new Promise(r => setTimeout(r, ms));
function h(tag, attrs, ...kids) {
const el = document.createElement(tag);
for (const [k, v] of Object.entries(attrs || {})) {
if (v == null || v === false) continue;
if (k === 'class') el.className = v;
else if (k === 'style') el.style.cssText = v;
else if (k.startsWith('on')) el.addEventListener(k.slice(2), v);
else el.setAttribute(k, v === true ? '' : v);
}
for (const kid of kids.flat()) {
if (kid != null && kid !== false) el.append(kid.nodeType ? kid : document.createTextNode(kid));
}
return el;
}
function icon(name, cls = 'ic') {
const ns = 'http://www.w3.org/2000/svg';
const s = document.createElementNS(ns, 'svg');
s.setAttribute('class', cls);
const u = document.createElementNS(ns, 'use');
u.setAttribute('href', '#i-' + name);
s.append(u);
return s;
}
async function api(path, body) {
const opt = body === undefined ? {} : {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
};
const r = await fetch(path, opt);
let data = null;
try { data = await r.json(); } catch { /* not JSON */ }
if (!r.ok) {
const e = new Error((data && data.error) || `${r.status} ${r.statusText}`);
e.status = r.status;
e.data = data;
throw e;
}
return data;
}
function fmtTime(t, hours) {
if (t == null || !isFinite(t)) return '–';
const ms = Math.round(Math.max(0, t) * 1000);
const H = Math.floor(ms / 3600000), M = Math.floor(ms / 60000) % 60, sec = Math.floor(ms / 1000) % 60, f = ms % 1000;
const p = (n, w = 2) => String(n).padStart(w, '0');
return (H || hours ? H + ':' + p(M) : p(M)) + ':' + p(sec) + '.' + p(f, 3);
}
// fmtT formats a time on the current source's scale (hours only for long videos).
const fmtT = (t, m = cur()) => fmtTime(t, m && m.duration >= 3600);
function fmtLen(t) {
if (t == null || !isFinite(t)) return '–';
if (t < 60) return t.toFixed(t < 10 ? 2 : 1) + ' s';
t = Math.round(t * 10) / 10;
const H = Math.floor(t / 3600), M = Math.floor(t / 60) % 60, sec = t - Math.floor(t / 60) * 60;
return (H ? H + ':' + String(M).padStart(2, '0') : M) + ':' + sec.toFixed(1).padStart(4, '0');
}
function fmtETA(s) {
if (!isFinite(s) || s < 0) return '';
if (s < 60) return Math.max(1, Math.round(s)) + ' s';
if (s < 3600) return Math.floor(s / 60) + ' min ' + String(Math.round(s % 60)).padStart(2, '0') + ' s';
return Math.floor(s / 3600) + ' h ' + Math.round((s % 3600) / 60) + ' min';
}
function fmtBytes(n) {
if (n < 1024) return n + ' B';
const u = ['KB', 'MB', 'GB', 'TB'];
let i = -1;
do { n /= 1024; i++; } while (n >= 1024 && i < u.length - 1);
return n.toFixed(n < 10 ? 1 : 0) + ' ' + u[i];
}
const fmtFps = f => (Math.round(f * 1000) / 1000) + ' fps';
// parseTime accepts "1:02:03.5", "02:03", "83.5" (seconds).
function parseTime(str) {
str = String(str).trim().replace(',', '.');
if (!str) return null;
const parts = str.split(':');
if (parts.length > 3 || parts.some(p => !/^\d+(\.\d*)?$/.test(p))) return null;
return parts.reduce((acc, p) => acc * 60 + parseFloat(p), 0);
}
function splitPath(p) {
const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'));
return i < 0 ? ['', p] : [p.slice(0, i) || p.slice(0, 1), p.slice(i + 1)];
}
function joinPath(dir, name) {
if (!dir) return name;
const sep = (S.info && S.info.sep) || '/';
return /[\\/]$/.test(dir) ? dir + name : dir + sep + name;
}
function extOf(p) {
const n = splitPath(p || '')[1];
const i = n.lastIndexOf('.');
return i > 0 ? n.slice(i + 1).toLowerCase() : '';
}
const stripExt = n => { const i = n.lastIndexOf('.'); return i > 0 ? n.slice(0, i) : n; };
function replaceExt(p, ext) {
const [d, n] = splitPath(p);
return joinPath(d, stripExt(n) + '.' + ext);
}
// Elements that makeEditable temporarily swaps out of the DOM.
const tcCurEl = $('#tcCur'), markInEl = $('#markIn'), markOutEl = $('#markOut');
// ---------------------------------------------------------------- state
const DEFAULTS = {
mode: 'copy', join: true, output: '', allAudio: true, noAudio: false,
vcodec: 'libx264', quality: 20, speed: 'balanced', height: 0, fps: '', acodec: 'aac', abitrate: 192,
lastDir: '', volume: 1, muted: false,
};
const PALETTE = ['#4c8dff', '#3ccf91', '#ff8a4c', '#c77dff', '#ffd23f', '#ff5d8f', '#2ec4d6', '#a3d14b'];
const S = {
info: null,
sources: [], // probed media (see media.go Media)
clips: [], // {id, src, in, out}
cur: null, // source id shown in the viewer
marks: {}, // source id -> {in, out} (scratch selection)
sel: null, // selected clip id (being edited)
kf: {}, // source id -> {status, times}
play: {}, // source id -> 'direct' | 'proxy' | 'still'
proxyJob: {}, // source id -> running proxy job
jobs: {}, // job id -> last known job state
exportJob: null,
settings: { ...DEFAULTS },
time: 0,
view: { start: 0, end: 1 },
playSelEnd: null,
undo: [],
redo: [],
restored: false,
};
const srcById = id => S.sources.find(m => m.id === id);
const cur = () => srcById(S.cur);
const fpsOf = m => (m && m.video && m.video.fps) || 30;
const marksOf = id => S.marks[id] || (S.marks[id] = { in: null, out: null });
const selClip = () => S.clips.find(c => c.id === S.sel);
const editingClip = () => { const c = selClip(); return c && c.src === S.cur ? c : null; };
let idSeq = 0;
const newId = () => 'c' + Date.now().toString(36) + (idSeq++).toString(36);
function assignColor(m) {
const used = new Set(S.sources.map(s => s.color));
m.color = PALETTE.find(c => !used.has(c)) || PALETTE[S.sources.length % PALETTE.length];
}
// frameFloor snaps a time to the start of the frame shown at that time.
function frameFloor(t, m = cur()) {
const fps = fpsOf(m);
return Math.floor(t * fps + 1e-3) / fps;
}
// activeRange is what the In/Out controls edit: the selected clip if it
// belongs to the current source, else the scratch marks.
function activeRange() {
const c = editingClip();
if (c) return { in: c.in, out: c.out, clip: c };
const m = cur();
if (!m) return { in: null, out: null, clip: null };
const mk = marksOf(m.id);
return { in: mk.in, out: mk.out, clip: null };
}
// The timeline canvas is redrawn on the next animation frame when dirty.
let needDraw = true;
const requestDraw = () => { needDraw = true; };
const thumbURL = (id, t, hgt) => `/api/thumb?id=${id}&t=${Math.max(0, t).toFixed(3)}&h=${hgt}`;
// ---------------------------------------------------------------- undo
function snapshot() {
return { clips: S.clips.map(c => ({ ...c })), sources: S.sources.slice(), sel: S.sel };
}
function commit(fn) {
S.undo.push(snapshot());
if (S.undo.length > 300) S.undo.shift();
S.redo.length = 0;
fn();
changed();
}
function restoreSnap(s) {
S.clips = s.clips;
S.sources = s.sources;
S.sel = s.sel && S.clips.some(c => c.id === s.sel) ? s.sel : null;
if (!srcById(S.cur)) {
S.cur = null;
if (S.sources[0]) loadSource(S.sources[0].id); else unloadViewer();
}
changed();
}
function undo() { if (S.undo.length) { S.redo.push(snapshot()); restoreSnap(S.undo.pop()); } }
function redo() { if (S.redo.length) { S.undo.push(snapshot()); restoreSnap(S.redo.pop()); } }
function changed() {
renderAll();
saveSoon();
}
// ---------------------------------------------------------------- toasts & confirm
function toast(msg, kind = '') {
const box = $('#toasts');
const el = h('div', { class: 'toast ' + kind }, msg);
box.append(el);
while (box.children.length > 4) box.firstChild.remove();
setTimeout(() => el.remove(), kind === 'error' ? 8000 : 3500);
}
function confirmDlg(text, okLabel = 'OK') {
return new Promise(resolve => {
const d = $('#dlgConfirm');
$('#cfText').textContent = text;
$('#cfYes').textContent = okLabel;
let result = false;
$('#cfYes').onclick = () => { result = true; d.close(); };
$('#cfNo').onclick = () => d.close();
d.onclose = () => resolve(result);
d.showModal();
$('#cfYes').focus();
});
}
// ---------------------------------------------------------------- sources
async function addFiles(builtin = false) {
if (!S.info.ffmpegOK) return toast('ffmpeg was not found — see the message at the top of the window.', 'error');
if (!builtin && S.info.nativeDialogs) {
try {
const dir = S.settings.lastDir || (cur() ? splitPath(cur().path)[0] : '');
const r = await api('/api/dialog/open', { dir });
if (r.supported) {
if (r.paths && r.paths.length) {
S.settings.lastDir = splitPath(r.paths[0])[0];
await addPaths(r.paths);
}
return;
}
if (r.error) toast('The Windows file dialog could not be opened, using the built-in browser instead.', 'error');
} catch (e) {
if (e.status === 409) return;
}
}
const paths = await fileBrowser({ mode: 'open' });
if (paths && paths.length) await addPaths(paths);
}
async function addPaths(paths) {
const results = await Promise.all(paths.map(p =>
api('/api/probe', { path: p }).catch(e => { toast(e.message, 'error'); return null; })));
const fresh = [];
for (const m of results) {
if (m && !srcById(m.id) && !fresh.some(f => f.id === m.id)) fresh.push(m);
}
if (fresh.length) {
commit(() => {
for (const m of fresh) { assignColor(m); S.sources.push(m); }
});
fresh.forEach(m => ensureKeyframes(m.id));
}
const target = fresh[0] || results.find(Boolean);
if (target && (fresh.length || !S.cur)) loadSource(target.id);
if (fresh.length > 1) toast(`Added ${fresh.length} videos`, 'ok');
}
async function removeSource(id) {
const m = srcById(id);
if (!m) return;
const n = S.clips.filter(c => c.src === id).length;
if (n && !await confirmDlg(`Remove "${m.name}" and its ${n} clip${n > 1 ? 's' : ''} from the project?\n(The file itself is not touched.)`, 'Remove')) return;
commit(() => {
S.sources = S.sources.filter(s => s.id !== id);
S.clips = S.clips.filter(c => c.src !== id);
if (S.sel && !selClip()) S.sel = null;
});
if (S.cur === id) {
S.cur = null;
if (S.sources[0]) loadSource(S.sources[0].id); else unloadViewer();
}
}
function addWhole(id) {
const m = srcById(id);
if (!m) return;
commit(() => S.clips.push({ id: newId(), src: id, in: 0, out: m.duration }));
if (S.cur !== id) loadSource(id);
toast(`Added all of "${m.name}" as clip ${S.clips.length}. Press S at the playhead to split it.`, 'ok');
}
async function newProject() {
if ((S.sources.length || S.clips.length) &&
!await confirmDlg('Start a new, empty project?\nThis removes all sources and clips from the editor (the video files are not touched). You can undo this.', 'New project')) return;
commit(() => { S.sources = []; S.clips = []; S.sel = null; });
S.marks = {};
S.settings.output = '';
S.cur = null;
unloadViewer();
changed();
}
async function ensureKeyframes(id) {
const m = srcById(id);
if (!m) return;
if (!m.video) { S.kf[id] = { status: 'done', times: [] }; return; }
if (S.kf[id] && S.kf[id].status !== 'error') return;
S.kf[id] = { status: 'pending', times: [] };
renderSources();
for (let i = 0; ; i++) {
let r;
try {
r = await api('/api/keyframes?id=' + id);
} catch {
S.kf[id] = { status: 'error', times: [] };
break;
}
if (r.status !== 'pending') {
S.kf[id] = { status: r.status, times: r.times || [] };
break;
}
await sleep(Math.min(2000, 250 + i * 150));
}
renderSources(); renderClips(); renderMarks(); renderTime(); requestDraw();
if ($('#dlgExport').open) updateExportView();
}
// kfBefore returns the last keyframe at or before t (with a little tolerance).
function kfBefore(id, t) {
const a = S.kf[id] && S.kf[id].times;
if (!a || !a.length) return null;
let lo = 0, hi = a.length - 1, r = null;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (a[mid] <= t + 1e-3) { r = a[mid]; lo = mid + 1; } else hi = mid - 1;
}
return r;
}
function kfAfter(id, t) {
const a = S.kf[id] && S.kf[id].times;
if (!a || !a.length) return null;
let lo = 0, hi = a.length - 1, r = null;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (a[mid] > t + 1e-3) { r = a[mid]; hi = mid - 1; } else lo = mid + 1;
}
return r;
}
// kfOffset: how much earlier a lossless export starts than the marked In.
function kfOffset(srcId, tIn) {
const k = kfBefore(srcId, tIn);
if (k == null) return 0;
const off = tIn - k;
return off > 0.5 / fpsOf(srcById(srcId)) ? off : 0;
}
// ---------------------------------------------------------------- viewer
const video = $('#video');
const still = $('#still');
let pendingSeek = null;
let loadedFor = null;
const playMode = m => (m && S.play[m.id]) || 'direct';
const isVideoMode = () => { const m = cur(); return !!m && playMode(m) !== 'still'; };
function loadSource(id, { time = 0, keepView = false } = {}) {
const m = srcById(id);
if (!m) return;
const switching = S.cur !== id;
S.cur = id;
S.playSelEnd = null;
if (S.sel && (!selClip() || selClip().src !== id)) S.sel = null;
S.time = clamp(time, 0, m.duration);
if (switching || !keepView) S.view = { start: 0, end: m.duration };
ensureKeyframes(id);
attachMedia();
changed();
}
function attachMedia() {
const m = cur();
if (!m) return;
video.pause();
const mode = playMode(m);
const key = m.id + ':' + mode;
if (mode === 'still') {
if (video.getAttribute('src')) { video.removeAttribute('src'); video.load(); }
loadedFor = key;
updateStill(true);
} else if (loadedFor !== key) {
loadedFor = key;
pendingSeek = S.time;
video.src = `/api/media/${m.id}${mode === 'proxy' ? '?proxy=1' : ''}`;
} else {
seekVideo(S.time);
}
renderScreen();
}
function unloadViewer() {
video.pause();
if (video.getAttribute('src')) { video.removeAttribute('src'); video.load(); }
loadedFor = null;
S.time = 0;
S.view = { start: 0, end: 1 };
renderAll();
}
video.addEventListener('loadedmetadata', () => {
const m = cur();
if (!m || !loadedFor || !loadedFor.startsWith(m.id)) return;
if (m.video && !video.videoWidth) return unplayable();
if (pendingSeek != null) {
video.currentTime = Math.min(pendingSeek + 1e-4, m.duration);
pendingSeek = null;
}
renderScreen();
});
video.addEventListener('error', () => {
if (video.getAttribute('src')) unplayable();
});
for (const ev of ['play', 'pause', 'ended']) video.addEventListener(ev, renderPlay);
video.addEventListener('seeked', requestDraw);
// unplayable switches to still frames and asks the server for an H.264
// preview copy the browser can play.
function unplayable() {
const m = cur();
if (!m) return;
const mode = playMode(m);
S.play[m.id] = 'still';
video.removeAttribute('src');
video.load();
loadedFor = m.id + ':still';
if (mode === 'direct') startProxy(m.id);
else toast('The preview copy could not be played either — showing still frames.', 'error');
updateStill(true);
renderScreen();
}
async function startProxy(id) {
try {
const r = await api('/api/proxy', { id });
if (r.ready) return proxyReady(id);
if (!S.proxyJob[id]) S.proxyJob[id] = { id: r.jobId, state: 'running', progress: 0 };
} catch (e) {
toast('Could not create a preview: ' + e.message, 'error');
}
renderScreen();
}
function proxyReady(id) {
S.play[id] = 'proxy';
delete S.proxyJob[id];
if (S.cur === id) { loadedFor = null; attachMedia(); }
renderScreen();
}
let stillTimer = 0;
let stillWant = '';
function updateStill(now) {
const m = cur();
if (!m || playMode(m) !== 'still' || !m.video) return;
clearTimeout(stillTimer);
const go = () => {
const url = thumbURL(m.id, S.time, Math.min(720, m.video.displayHeight || 720));
if (stillWant === url) return;
stillWant = url;
const img = new Image();
img.onload = () => { if (stillWant === url) still.src = url; };
img.src = url;
};
if (now) go(); else stillTimer = setTimeout(go, 60);
}
function seekVideo(t) {
if (!isVideoMode()) return;
if (video.readyState >= 1) video.currentTime = Math.min(t + 1e-4, cur().duration);
else pendingSeek = t;
}
function seek(t, { quantize = true } = {}) {
const m = cur();
if (!m) return;
t = clamp(t, 0, m.duration);
if (quantize) t = frameFloor(t, m);
S.time = t;
seekVideo(t);
updateStill();
followPlayhead(true);
renderTime();
requestDraw();
}
function syncTime() {
if (isVideoMode() && video.readyState >= 1 && !video.seeking) S.time = video.currentTime;
}
const seekBy = d => { syncTime(); seek(S.time + d); };
function stepFrames(n) {
const m = cur();
if (!m) return;
video.pause();
syncTime();
const fps = fpsOf(m);
seek((Math.floor(S.time * fps + 1e-3) + n) / fps);
}
function gotoKey(dir) {
const m = cur();
if (!m) return;
const k = S.kf[m.id];
if (!k || k.status !== 'done') {
return toast(k && k.status === 'error' ? 'Keyframe positions are not available for this video.' : 'Still reading keyframe positions…');
}
syncTime();
const t = dir < 0 ? kfBefore(m.id, S.time - 0.5 / fpsOf(m) - 1e-3) : kfAfter(m.id, S.time);
if (t != null) seek(t, { quantize: false });
}
function togglePlay() {
const m = cur();
if (!m || !isVideoMode()) return;
if (video.paused) {
S.playSelEnd = null;
if (S.time >= m.duration - 0.05) seek(0);
video.play().catch(() => {});
} else {
video.pause();
}
}
function playSelection() {
const m = cur();
if (!m || !isVideoMode()) return;
const r = activeRange();
const a = r.in ?? 0, b = r.out ?? m.duration;
if (b <= a) return;
seek(a, { quantize: false });
S.playSelEnd = b;
video.play().catch(() => {});
}
function gotoMark(which) {
const m = cur();
const r = activeRange();
if (!m || r[which] == null) return;
// Out is exclusive, so show the last frame that is included.
seek(which === 'in' ? r.in : Math.max(r.in ?? 0, r.out - 1 / fpsOf(m)), { quantize: which === 'out' });
}
function toggleMute() {
video.muted = !video.muted;
S.settings.muted = video.muted;
renderMute();
saveSoon();
}
// ---------------------------------------------------------------- marks & clips
function outAt(t) {
const m = cur();
return t >= m.duration - 1 / fpsOf(m) ? m.duration : frameFloor(t, m);
}
function setIn(t) {
const m = cur();
if (!m) return toast('Add a video first.');
syncTime();
t = clamp(t ?? frameFloor(S.time, m), 0, m.duration);
const c = editingClip();
if (c) {
if (t >= c.out - 1e-6) return toast('The In point must be before the Out point.');
commit(() => { c.in = t; });
return;
}
const mk = marksOf(m.id);
mk.in = t;
if (mk.out != null && mk.out <= t) mk.out = null;
changed();
}
function setOut(t) {
const m = cur();
if (!m) return toast('Add a video first.');
syncTime();
t = clamp(t ?? outAt(S.time), 0, m.duration);
const c = editingClip();
if (c) {
if (t <= c.in + 1e-6) return toast('The Out point must be after the In point.');
commit(() => { c.out = t; });
return;
}
const mk = marksOf(m.id);
mk.out = t;
if (mk.in != null && mk.in >= t) mk.in = null;
changed();
}
function addClipOrDone() {
if (editingClip()) { S.sel = null; changed(); return; }
const m = cur();
if (!m) return toast('Add a video first.');
const mk = marksOf(m.id);
if (mk.in == null && mk.out == null) return toast('Set an In and/or Out point first (I and O keys).');
const a = mk.in ?? 0, b = mk.out ?? m.duration;
if (b - a < 0.01) return toast('The clip is empty — the Out point must be after the In point.');
commit(() => {
S.clips.push({ id: newId(), src: m.id, in: a, out: b });
S.marks[m.id] = { in: null, out: null };
});
toast(`Clip ${S.clips.length} added (${fmtLen(b - a)})`, 'ok');
const list = $('#clipList');
requestAnimationFrame(() => list.scrollTo({ left: list.scrollWidth, behavior: 'smooth' }));
}
function selectClip(id, { seekTo = true } = {}) {
const c = S.clips.find(x => x.id === id);
if (!c) return;
S.sel = id;
if (S.cur !== c.src) loadSource(c.src, { time: c.in });
else if (seekTo) seek(c.in, { quantize: false });
const v = S.view;
if (c.in < v.start || c.out > v.end) {
const span = Math.max(v.end - v.start, (c.out - c.in) * 1.2);
setView(c.in - (span - (c.out - c.in)) / 2, c.in - (span - (c.out - c.in)) / 2 + span);
}
changed();
}
function split() {
const m = cur();
if (!m) return;
syncTime();
const t = frameFloor(S.time, m);
const inside = x => x.src === m.id && x.in + 1e-3 < t && t < x.out - 1e-3;
let c = editingClip();
if (!c || !inside(c)) c = S.clips.find(inside);
if (!c) return toast('Put the playhead inside one of this video\'s clips to split it.\nTip: "+ Whole" adds the entire video as one clip.');
commit(() => {
const i = S.clips.indexOf(c);
S.clips.splice(i + 1, 0, { id: newId(), src: c.src, in: t, out: c.out });
c.out = t;
S.sel = null;
});
toast(`Split at ${fmtT(t)}`, 'ok');
}
function deleteClip(id) {
commit(() => {
S.clips = S.clips.filter(c => c.id !== id);
if (S.sel === id) S.sel = null;
});
}
function duplicateClip(id) {
const i = S.clips.findIndex(c => c.id === id);
if (i < 0) return;
commit(() => S.clips.splice(i + 1, 0, { ...S.clips[i], id: newId() }));
}
function moveClipTo(id, idx) {
const i = S.clips.findIndex(c => c.id === id);
if (i < 0 || idx === i || idx === i + 1) return;
commit(() => {
const [c] = S.clips.splice(i, 1);
S.clips.splice(idx > i ? idx - 1 : idx, 0, c);
});
}
function moveClipBy(id, d) {
const i = S.clips.findIndex(c => c.id === id);
const j = i + d;
if (i < 0 || j < 0 || j >= S.clips.length) return;
commit(() => {
const [c] = S.clips.splice(i, 1);
S.clips.splice(j, 0, c);
});
}
// ---------------------------------------------------------------- timeline
const cv = $('#tlCanvas');
const ctx = cv.getContext('2d');
const RULER = 18, KFH = 9;
function setView(s, e) {
const m = cur();
if (!m) return;
const D = m.duration, span = e - s;
if (span >= D) S.view = { start: 0, end: D };
else { s = clamp(s, 0, D - span); S.view = { start: s, end: s + span }; }
requestDraw();
}
function zoom(factor, anchor) {
const m = cur();
if (!m) return;
const v = S.view, span = v.end - v.start;
const minSpan = Math.min(m.duration, Math.max(0.25, 10 / fpsOf(m)));
const ns = clamp(span * factor, minSpan, m.duration);
const a = anchor ?? S.time;
const s = a - (a - v.start) * ns / span;
setView(s, s + ns);
}
const zoomFit = () => { const m = cur(); if (m) setView(0, m.duration); };
function followPlayhead(jump) {
const m = cur();
if (!m) return;
const v = S.view, span = v.end - v.start;
if (span >= m.duration - 1e-6) return;
if (S.time < v.start || S.time > v.end) {
const s = jump ? S.time - span / 2 : S.time - span * 0.1;
setView(s, s + span);
} else if (!jump && S.time > v.end - span * 0.02) {
setView(S.time - span * 0.1, S.time + span * 0.9);
}
}
function niceStep(raw) {
const steps = [0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600, 7200, 14400];
return steps.find(s => s >= raw) || steps[steps.length - 1];
}
function rulerLabel(t, step) {
const H = Math.floor(t / 3600), M = Math.floor(t / 60) % 60, sec = t % 60;
const ss = step < 1 ? sec.toFixed(1).padStart(4, '0') : String(Math.round(sec) % 60).padStart(2, '0');
return (H ? H + ':' + String(M).padStart(2, '0') : M) + ':' + ss;
}
function hexA(hex, a) {
const n = parseInt(hex.slice(1), 16);
return `rgba(${n >> 16},${(n >> 8) & 255},${n & 255},${a})`;
}
function drawTimeline() {
needDraw = false;
const dpr = window.devicePixelRatio || 1;
const W = cv.clientWidth, H = cv.clientHeight;
if (!W || !H) return;
if (cv.width !== Math.round(W * dpr) || cv.height !== Math.round(H * dpr)) {
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = '#0f1217';
ctx.fillRect(0, 0, W, H);
const m = cur();
if (!m) return;
const { start, end } = S.view;
const span = end - start || 1;
const X = t => (t - start) / span * W;
const top = RULER, bot = H - KFH;
const mono = getComputedStyle(document.documentElement).getPropertyValue('--mono');
// track
ctx.fillStyle = '#151922';
ctx.fillRect(0, top, W, bot - top);
if (X(m.duration) < W) {
ctx.fillStyle = '#0b0d11';
ctx.fillRect(X(m.duration), 0, W - X(m.duration), H);
}
// ruler
const step = niceStep(span / W * 95);
const minor = step / 5;
ctx.font = '10.5px ' + mono;
ctx.textBaseline = 'top';
ctx.fillStyle = '#7d8696';
for (let i = Math.ceil(start / minor); i * minor <= end; i++) {
const t = i * minor;
const x = Math.round(X(t)) + 0.5;
const major = i % 5 === 0;
ctx.fillStyle = major ? '#4a5262' : '#2e3440';
ctx.fillRect(x - 0.5, major ? 10 : 13, 1, major ? 8 : 5);
if (major) {
ctx.fillStyle = '#8a93a3';
ctx.fillText(rulerLabel(t, step), x + 3, 1);
}
}
// keyframe pre-roll (lossless mode) under each clip
const copy = S.settings.mode === 'copy';
// clips of this source
ctx.textBaseline = 'top';
S.clips.forEach((c, i) => {
if (c.src !== m.id) return;
const x1 = X(c.in), x2 = X(c.out);
if (x2 < 0 || x1 > W) return;
const sel = c.id === S.sel;
ctx.fillStyle = hexA(m.color, sel ? 0.42 : 0.22);
ctx.fillRect(x1, top + 3, x2 - x1, bot - top - 6);
ctx.strokeStyle = hexA(m.color, sel ? 1 : 0.75);
ctx.lineWidth = sel ? 2 : 1;
ctx.strokeRect(x1 + 0.5, top + 3.5, Math.max(1, x2 - x1 - 1), bot - top - 7);
if (copy) {
const off = kfOffset(c.src, c.in);
if (off > 0) {
ctx.fillStyle = 'rgba(255,181,71,.55)';
ctx.fillRect(X(c.in - off), bot - 7, x1 - X(c.in - off), 3);
}
}
if (x2 - x1 > 22) {
ctx.fillStyle = '#fff';
ctx.font = '600 11px ' + mono;
ctx.fillText('#' + (i + 1), Math.max(x1, 0) + 5, top + 7);
}
});
// In/Out selection
const r = activeRange();
if (!r.clip && (r.in != null || r.out != null)) {
const a = r.in ?? 0, b = r.out ?? m.duration;
ctx.fillStyle = 'rgba(255,181,71,.15)';
ctx.fillRect(X(a), top, X(b) - X(a), bot - top);
}
const flag = (t, dir) => {
const x = Math.round(X(t)) + 0.5;
if (x < -8 || x > W + 8) return;
ctx.strokeStyle = '#ffb547';
ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(x, top); ctx.lineTo(x, bot); ctx.stroke();
ctx.fillStyle = '#ffb547';
ctx.beginPath();
ctx.moveTo(x, top); ctx.lineTo(x + 9 * dir, top); ctx.lineTo(x, top + 9);
ctx.closePath(); ctx.fill();
ctx.beginPath();
ctx.moveTo(x, bot); ctx.lineTo(x + 9 * dir, bot); ctx.lineTo(x, bot - 9);
ctx.closePath(); ctx.fill();
};
if (r.in != null) flag(r.in, 1);
if (r.out != null) flag(r.out, -1);
// keyframes
const k = S.kf[m.id];
if (k && k.times.length) {
const a = k.times;
let lo = 0, hi = a.length;
while (lo < hi) { const mid = (lo + hi) >> 1; if (a[mid] < start) lo = mid + 1; else hi = mid; }
ctx.fillStyle = '#6f7a8c';
let lastX = -2;
for (let i = lo; i < a.length && a[i] <= end; i++) {
const x = Math.round(X(a[i]));
if (x === lastX) continue;
lastX = x;
ctx.fillRect(x, H - KFH + 2, 1, KFH - 3);
}
}
// playhead
const px = Math.round(X(S.time)) + 0.5;
if (px >= -6 && px <= W + 6) {
ctx.strokeStyle = '#fff';
ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(px, 0); ctx.lineTo(px, H); ctx.stroke();
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.moveTo(px - 6, 0); ctx.lineTo(px + 6, 0); ctx.lineTo(px, 8); ctx.closePath(); ctx.fill();
}
}
const tAt = e => { const m = cur(); const v = S.view; return clamp(v.start + e.offsetX / cv.clientWidth * (v.end - v.start), 0, m.duration); };
const xOf = t => (t - S.view.start) / (S.view.end - S.view.start) * cv.clientWidth;
function handleAt(e) {
if (e.offsetY < RULER - 2) return null;
const r = activeRange();
const near = t => t != null && Math.abs(xOf(t) - e.offsetX) <= 6;
if (near(r.out)) return 'out';
if (near(r.in)) return 'in';
return null;
}
let drag = null;
cv.addEventListener('pointerdown', e => {
const m = cur();
if (!m || e.button !== 0) return;
cv.setPointerCapture(e.pointerId);
hideHover();
const handle = handleAt(e);
if (handle) {
drag = { type: handle };
const c = editingClip();
if (c) { S.undo.push(snapshot()); S.redo.length = 0; drag.clip = c; }
} else {
drag = { type: 'scrub', wasPlaying: !video.paused };
video.pause();
seek(tAt(e));
}
});
cv.addEventListener('pointermove', e => {
const m = cur();
if (!m) return;
const t = tAt(e);
if (!drag) {
cv.style.cursor = handleAt(e) ? 'ew-resize' : 'pointer';
return showHover(e, t);
}
if (drag.type === 'scrub') return seek(t);
const fps = fpsOf(m);
const tq = drag.type === 'out' ? outAt(t) : frameFloor(t, m);
if (drag.clip) {
const c = drag.clip;
if (drag.type === 'in') c.in = clamp(tq, 0, c.out - 1 / fps);
else c.out = clamp(tq, c.in + 1 / fps, m.duration);
} else {
const mk = marksOf(m.id);
if (drag.type === 'in') mk.in = clamp(tq, 0, (mk.out ?? m.duration) - 1 / fps);
else mk.out = clamp(tq, (mk.in ?? 0) + 1 / fps, m.duration);
}
seek(drag.type === 'in' ? tq : Math.max(0, tq - 1 / fps));
renderMarks();
});
const endDrag = () => {
if (!drag) return;
if (drag.type === 'scrub' && drag.wasPlaying) video.play().catch(() => {});
if (drag.type !== 'scrub') changed();
drag = null;
};
cv.addEventListener('pointerup', endDrag);
cv.addEventListener('pointercancel', endDrag);
cv.addEventListener('pointerleave', hideHover);
cv.addEventListener('dblclick', e => {
const m = cur();
if (!m) return;
const t = tAt(e);
const c = [...S.clips].reverse().find(x => x.src === m.id && x.in <= t && t <= x.out);
if (c) selectClip(c.id, { seekTo: false });
});
cv.addEventListener('wheel', e => {
if (!cur()) return;
e.preventDefault();
const v = S.view, span = v.end - v.start;
if (e.shiftKey || Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
const d = (e.deltaX || e.deltaY) / cv.clientWidth * span;
setView(v.start + d, v.end + d);
} else {
zoom(Math.exp(e.deltaY * 0.0015), tAt(e));
}
}, { passive: false });
let hoverTimer = 0;
function showHover(e, t) {
const m = cur();
const tip = $('#tlHover');
tip.classList.remove('hidden');
tip.style.left = clamp(e.offsetX, 60, cv.clientWidth - 60) + 'px';
tip.querySelector('span').textContent = fmtT(t);
const img = tip.querySelector('img');
if (!m.video) { img.removeAttribute('src'); return; }
clearTimeout(hoverTimer);
hoverTimer = setTimeout(() => {
const q = Math.max((S.view.end - S.view.start) / 150, 1 / fpsOf(m));
const url = thumbURL(m.id, clamp(Math.round(t / q) * q, 0, m.duration), 72);
if (img.dataset.url !== url) { img.dataset.url = url; img.src = url; }
}, 90);
}
function hideHover() {
clearTimeout(hoverTimer);
$('#tlHover').classList.add('hidden');
}
new ResizeObserver(requestDraw).observe($('#timeline'));
function frame() {
if (isVideoMode() && !video.paused && !video.seeking) {
S.time = video.currentTime;
if (S.playSelEnd != null && S.time >= S.playSelEnd) {
const end = S.playSelEnd;
S.playSelEnd = null;
video.pause();
seek(Math.max(0, end - 1 / fpsOf(cur())));
}
followPlayhead(false);
renderTime();
drawTimeline();
} else if (needDraw) {
drawTimeline();
}
requestAnimationFrame(frame);
}
// ---------------------------------------------------------------- rendering
function renderAll() {
renderSources();
renderClips();
renderMarks();
renderTime();
renderPlay();
renderScreen();
renderTopbar();
requestDraw();
}
function renderSources() {
const ul = $('#sourceList');
ul.replaceChildren();
for (const m of S.sources) {
const v = m.video;
const n = S.clips.filter(c => c.src === m.id).length;
const meta = [fmtLen(m.duration), v ? `${v.displayWidth}×${v.displayHeight}` : 'audio only',
v ? v.codec : (m.audio[0] && m.audio[0].codec), v ? fmtFps(v.fps) : ''].filter(Boolean).join(' · ');
const k = S.kf[m.id];
ul.append(h('li', { class: 'src' + (m.id === S.cur ? ' cur' : ''), 'data-id': m.id, title: m.path, style: `--c:${m.color}` },
h('div', { class: 'src-thumb' },
v ? h('img', { src: thumbURL(m.id, Math.min(m.duration * 0.1, 30), 108), alt: '', loading: 'lazy', draggable: 'false' })
: h('div', { class: 'audio-only' }, '♪')),
h('div', { class: 'src-info' },
h('div', { class: 'src-name' }, m.name),
h('div', { class: 'src-meta' }, meta),
h('div', { class: 'src-actions' },
h('button', { class: 'chip-btn', 'data-sact': 'whole', title: 'Add the whole video as one clip' }, icon('plus'), 'Whole'),
n ? h('span', { class: 'src-status' }, `${n} clip${n > 1 ? 's' : ''}`) : null,
k && k.status === 'pending' ? h('span', { class: 'src-status' }, 'reading keyframes…') : null)),
h('button', { class: 'icon-btn src-x', 'data-sact': 'remove', title: 'Remove from project' }, icon('x'))));
}
$('#sourceEmpty').classList.toggle('hidden', S.sources.length > 0);
$('#srcCount').textContent = S.sources.length || '';
}
function renderClips() {
const list = $('#clipList');
const scroll = list.scrollLeft;
list.replaceChildren();
const copy = S.settings.mode === 'copy';
let total = 0;
S.clips.forEach((c, i) => {
const m = srcById(c.src);
if (!m) return;
total += c.out - c.in;
const off = copy ? kfOffset(c.src, c.in) : 0;
list.append(h('div', { class: 'clip' + (c.id === S.sel ? ' sel' : ''), draggable: 'true', 'data-id': c.id, style: `--c:${m.color}`, title: m.path },
h('div', { class: 'clip-thumb' },
m.video ? h('img', { src: thumbURL(m.id, c.in, 108), alt: '', loading: 'lazy', draggable: 'false' }) : h('div', { class: 'audio-only' }, '♪'),
h('span', { class: 'clip-num' }, String(i + 1)),
h('span', { class: 'clip-dur' }, fmtLen(c.out - c.in)),
off ? h('span', { class: 'clip-warn', title: `Lossless export starts this clip ${off.toFixed(2)} s earlier, at the keyframe at ${fmtT(c.in - off, m)}` }, icon('warn')) : null),
h('div', { class: 'clip-body' },
h('div', { class: 'clip-name' }, m.name),
h('div', { class: 'clip-range' }, `${fmtT(c.in, m)} → ${fmtT(c.out, m)}`)),
h('div', { class: 'clip-actions' },
h('button', { 'data-cact': 'left', title: 'Move left' }, icon('left')),
h('button', { 'data-cact': 'dup', title: 'Duplicate' }, icon('copy')),
h('button', { 'data-cact': 'del', class: 'del', title: 'Delete (Del)' }, icon('trash')),
h('button', { 'data-cact': 'right', title: 'Move right' }, icon('right')))));
});
list.scrollLeft = scroll;
$('#clipEmpty').classList.toggle('hidden', S.clips.length > 0);
$('#clipCount').textContent = S.clips.length || '';
$('#clipTotal').textContent = S.clips.length ? 'total ' + fmtLen(total) : '';
$$('#modeSeg button').forEach(b => b.classList.toggle('on', b.dataset.mode === S.settings.mode));
}
function renderMarks() {
const m = cur();
const r = activeRange();
markInEl.textContent = m && r.in != null ? fmtT(r.in) : '–';
markOutEl.textContent = m && r.out != null ? fmtT(r.out) : '–';
$('#markLen').textContent = m && (r.in != null || r.out != null) ? fmtLen((r.out ?? m.duration) - (r.in ?? 0)) : '–';
const btn = $('#btnAddClip');
btn.querySelector('span').textContent = r.clip ? 'Done' : 'Add clip';
btn.querySelector('use').setAttribute('href', r.clip ? '#i-check' : '#i-plus');
btn.title = r.clip ? 'Finish editing this clip (Enter)' : 'Add the In→Out range as a clip (Enter)';
const rows = [];
if (r.clip) {
rows.push(h('div', { class: 'hint-row edit' }, icon('cut'),
`Editing clip ${S.clips.indexOf(r.clip) + 1}: Set In / Set Out or drag the orange handles to trim it.`,
h('button', { class: 'chip-btn', 'data-hact': 'done' }, 'Done (Enter)')));
}
if (m && S.settings.mode === 'copy' && r.in != null) {
const off = kfOffset(m.id, r.in);
if (off > 0) {
const next = kfAfter(m.id, r.in);
rows.push(h('div', { class: 'hint-row warn' }, icon('warn'),
`Lossless: In is not on a keyframe, so the export starts ${off.toFixed(2)} s earlier (at ${fmtT(r.in - off)}).`,
h('button', { class: 'chip-btn', 'data-hact': 'snapPrev' }, 'Snap In to previous keyframe'),
next != null && next < (r.out ?? m.duration) ? h('button', { class: 'chip-btn', 'data-hact': 'snapNext' }, 'Snap to next keyframe') : null));
}
}
const box = $('#markHint');
box.replaceChildren(...rows);
box.classList.toggle('hidden', rows.length === 0);
}
function renderTime() {
const m = cur();
tcCurEl.textContent = fmtT(m ? S.time : 0);
$('#tcDur').textContent = fmtT(m ? m.duration : 0);
let info = '';
if (m && m.video) {
const fps = fpsOf(m);
info = 'frame ' + Math.floor(S.time * fps + 1e-3);
const k = kfBefore(m.id, S.time);
if (k != null && S.time - k < 0.5 / fps) info += ' · keyframe';
}
$('#tcFrame').textContent = info;
}
function renderPlay() {
$('#btnPlay use').setAttribute('href', video.paused ? '#i-play' : '#i-pause');
}
function renderMute() {
$('#btnMute use').setAttribute('href', video.muted ? '#i-mute' : '#i-vol');
}
function renderScreen() {
const m = cur();
$('#noSource').classList.toggle('hidden', !!m);
const mode = playMode(m);
video.classList.toggle('hidden', !m || mode === 'still');
still.classList.toggle('hidden', !m || mode !== 'still');
let msg = '';
let center = false;
if (m && !m.video) { msg = '♪ Audio only'; center = true; }
if (m && mode === 'still') {
const j = S.proxyJob[m.id];
msg = j ? `This format can't be played by the browser directly.\nCreating a preview copy… ${Math.round((j.progress || 0) * 100)}% (you can already scrub and cut)`
: 'Preview not available — showing still frames.';
}
const el = $('#screenMsg');
el.textContent = msg;
el.style.whiteSpace = 'pre-line';
el.classList.toggle('center', center);
el.classList.toggle('hidden', !msg);
}
function renderTopbar() {
$('#btnUndo').disabled = !S.undo.length;
$('#btnRedo').disabled = !S.redo.length;
$('#btnExport').disabled = !S.clips.length && !(S.exportJob && S.exportJob.state === 'running');
const j = S.exportJob;
const pill = $('#jobPill');
const running = j && j.state === 'running';
pill.classList.toggle('hidden', !running);
if (running) {
const p = Math.floor((j.progress || 0) * 100);
pill.textContent = `Exporting… ${p}%`;
pill.style.setProperty('--p', p + '%');
}
}
// ---------------------------------------------------------------- editable time fields
function makeEditable(el, getValue, onCommit) {
el.addEventListener('click', () => {
if (!cur()) return;
const input = h('input', { class: 'tc-input', value: getValue(), spellcheck: 'false' });
el.replaceWith(input);
input.focus();
input.select();
let done = false;
const finish = ok => {
if (done) return;
done = true;
const v = parseTime(input.value);
input.replaceWith(el);
if (ok) {
if (v == null) toast('Type a time like 1:23.5 or 83.5');
else onCommit(v);
}
renderAll();
};
input.addEventListener('keydown', e => {
e.stopPropagation();
if (e.key === 'Enter') finish(true);
if (e.key === 'Escape') finish(false);
});
input.addEventListener('blur', () => finish(true));
});
}
makeEditable(tcCurEl, () => fmtT(S.time), v => { video.pause(); seek(v); });
makeEditable(markInEl, () => fmtT(activeRange().in ?? S.time), v => setIn(frameFloor(clamp(v, 0, cur().duration))));
makeEditable(markOutEl, () => fmtT(activeRange().out ?? S.time), v => setOut(outAt(clamp(v, 0, cur().duration))));
// ---------------------------------------------------------------- export dialog
const dlgEx = $('#dlgExport');
function copyContainerChoices() {
const exts = ['mp4', 'mkv', 'mov', 'webm'];
for (const c of S.clips) {
const e = extOf(srcById(c.src).path);
if (S.info.copyExts.includes(e) && !exts.includes(e)) exts.push(e);
}
return exts;
}
function firstClipSource() { return srcById(S.clips[0] && S.clips[0].src) || cur(); }
function defaultOutput() {
const first = firstClipSource();
if (!first) return '';
const [dir, name] = splitPath(first.path);
return joinPath(dir, stripExt(name) + '_edit.' + defaultExt());
}
function defaultExt() {
if (S.settings.mode !== 'copy') return 'mp4';
const first = firstClipSource();
const e = first ? extOf(first.path) : '';
return S.info.copyExts.includes(e) ? e : 'mkv';
}
function openExport() {
if (!S.clips.length && !(S.exportJob && S.exportJob.state === 'running')) return toast('Add some clips first.');
if (!S.settings.output) S.settings.output = defaultOutput();
fillExport();
dlgEx.showModal();
refreshCodecs();
}
async function refreshCodecs() {
for (let i = 0; i < 60 && !S.info.codecsReady; i++) {
await sleep(500);
try { S.info = await api('/api/info'); } catch { /* ignore */ }
}
if (dlgEx.open) fillExport();
}
function setOptions(sel, items, value) {
sel.replaceChildren(...items.map(([v, label]) => h('option', { value: v }, label)));
sel.value = value;
}
function fillExport() {
const st = S.settings;
const info = S.info;
$$('input[name=mode]').forEach(r => { r.checked = r.value === st.mode; });
$$('input[name=join]').forEach(r => { r.checked = (r.value === '1') === st.join; });
const exts = st.mode === 'copy' ? copyContainerChoices() : info.containers.map(c => c.ext);
if (st.output && !exts.includes(extOf(st.output))) st.output = replaceExt(st.output, exts.includes(defaultExt()) ? defaultExt() : exts[0]);
setOptions($('#exContainer'), exts.map(e => [e, '.' + e]), extOf(st.output));
if (document.activeElement !== $('#exOutput')) $('#exOutput').value = st.output;
$('#exAllAudio').checked = st.allAudio;
$('#exNoAudioCopy').checked = st.noAudio;
$('#exAllAudioRow').classList.toggle('hidden', !S.clips.some(c => srcById(c.src).audio.length > 1));
const ct = info.containers.find(c => c.ext === extOf(st.output));
const vcs = (info.codecs || []).filter(c => c.kind === 'video' && (!ct || ct.video.includes(c.family)));
if (vcs.length && !vcs.some(c => c.name === st.vcodec)) {
st.vcodec = vcs[0].name;
st.quality = vcs[0].defQ;
}
setOptions($('#exVCodec'), vcs.length ? vcs.map(c => [c.name, c.label]) : [['', info.codecsReady ? 'No encoder available' : 'Checking encoders…']], st.vcodec);
const vc = vcs.find(c => c.name === st.vcodec);
const q = $('#exQuality');
q.max = vc ? vc.maxQ : 51;
q.value = st.quality;
$('#exSpeed').value = st.speed;
$('#exHeight').value = String(st.height);
$('#exFPS').value = st.fps;
const acs = (info.codecs || []).filter(c => c.kind === 'audio' && (!ct || ct.audio.includes(c.family)));
if (st.acodec !== 'none' && acs.length && !acs.some(c => c.name === st.acodec)) st.acodec = acs[0].name;
setOptions($('#exACodec'), [...acs.map(c => [c.name, c.label]), ['none', 'No audio']], st.acodec);
$('#exABitrate').value = String(st.abitrate);
$('#exABitrate').disabled = st.acodec === 'none' || st.acodec === 'flac';
updateExportView();
}
function qualityText(q, vc) {
if (!vc) return '';
const rel = q - vc.defQ;
const t = rel <= -6 ? 'near lossless, very large file' : rel <= -3 ? 'very high quality' : rel < 0 ? 'high quality'
: rel === 0 ? 'recommended balance' : rel <= 4 ? 'smaller file' : rel <= 9 ? 'small file, visible loss' : 'very small, low quality';
return `${q} — ${t}`;
}
function outSize(m, height) {
let w = m.video.displayWidth, hh = m.video.displayHeight;
if (height > 0 && height !== hh) { w = w * height / hh; hh = height; }
const even = n => { n = Math.round(n); return n % 2 ? n + 1 : n; };
return [even(w), even(hh)];
}
function formatSig(m, allAudio) {
const v = m.video;
const parts = [v ? `${v.codec} ${v.width}×${v.height} ${v.pixFmt || ''}`.trim() : 'no video'];
const audio = allAudio ? m.audio : m.audio.slice(0, 1);
for (const a of audio) parts.push(`${a.codec} ${a.sampleRate} Hz ${a.channels}ch`);
return parts.join(', ');
}
function updateExportView() {
const st = S.settings;
const copy = st.mode === 'copy';
const n = S.clips.length;
const total = S.clips.reduce((s, c) => s + c.out - c.in, 0);
$('#exCopyOpts').classList.toggle('hidden', !copy);
$('#exEncOpts').classList.toggle('hidden', copy);
$('#exSummary').textContent = `${n} clip${n !== 1 ? 's' : ''} · ${fmtLen(total)} total → ${st.join || n === 1 ? 'one video file' : n + ' separate files'}`;
let outHint = '';
if (!st.join && n > 1 && st.output) {
const [, name] = splitPath(st.output);
const w = Math.max(2, String(n).length);
const num = i => String(i).padStart(w, '0');
outHint = `Files are named ${stripExt(name)}_${num(1)}.${extOf(name)} … ${stripExt(name)}_${num(n)}.${extOf(name)}`;
}
$('#exOutHint').textContent = outHint;
const vc = (S.info.codecs || []).find(c => c.name === st.vcodec);
$('#exQLabel').textContent = qualityText(+st.quality, vc);
let encHint = '';
const firstV = S.clips.map(c => srcById(c.src)).find(m => m && m.video);
if (!copy && firstV) {
const [w, hh] = outSize(firstV, +st.height);
encHint = `Output: ${w}×${hh}, ${st.fps ? $('#exFPS').selectedOptions[0].textContent + ' fps' : fmtFps(firstV.video.fps)}`;
if (st.join) {
const shapes = new Set(S.clips.map(c => srcById(c.src)).filter(m => m.video).map(m => (m.video.displayWidth / m.video.displayHeight).toFixed(3)));
if (shapes.size > 1) encHint += ' · clips with a different shape get black bars';
}
}
$('#exEncHint').textContent = encHint;
const warns = [];
if (copy) {
const offs = S.clips.map(c => kfOffset(c.src, c.in)).filter(o => o > 0);
if (offs.length) {
warns.push(`${offs.length} clip${offs.length > 1 ? 's don\'t' : ' doesn\'t'} start on a keyframe and will begin up to ${Math.max(...offs).toFixed(2)} s earlier than marked. Choose Re-encode for frame-exact cuts.`);
}
if (st.join && n > 1) {
const seen = new Map();
for (const c of S.clips) {
const m = srcById(c.src);
seen.set(formatSig(m, st.allAudio && !st.noAudio), m.name);
}
if (seen.size > 1) {
warns.push('The source videos have different formats, so they can\'t be joined losslessly:\n' +
[...seen].map(([sig, name]) => `• ${name}: ${sig}`).join('\n') + '\nChoose Re-encode, or save each clip separately.');
}
}
if (S.clips.some(c => S.kf[c.src] && S.kf[c.src].status === 'pending')) warns.push('Still reading keyframe positions…');
} else if (!S.info.codecsReady) {
warns.push('Checking which encoders (including GPU encoders) are available…');
}
$('#exWarn').textContent = warns.join('\n\n');
$('#exWarn').classList.toggle('hidden', !warns.length);
const running = !!(S.exportJob && S.exportJob.state === 'running');
$('#exGo').disabled = running || !n;
$('#exCancel').classList.toggle('hidden', !running);
$$('#exportForm input, #exportForm select, #exBrowse').forEach(el => { el.disabled = running; });
if (!running) $('#exABitrate').disabled = st.acodec === 'none' || st.acodec === 'flac';
}
function renderExportProgress() {
const j = S.exportJob;
const prog = $('#exProgress'), res = $('#exResult');
const running = !!(j && j.state === 'running');
prog.classList.toggle('hidden', !running);
res.classList.toggle('hidden', !j || running);
if (running) {
$('#exBar').style.width = ((j.progress || 0) * 100).toFixed(1) + '%';
$('#exPText').textContent = `${j.step || 'Working'}… ${Math.floor((j.progress || 0) * 100)}%${j.speed ? ' · ' + j.speed : ''}`;
$('#exPEta').textContent = j.eta > 0 && j.progress > 0.02 ? `about ${fmtETA(j.eta)} left` : '';
} else if (j) {
res.replaceChildren();
if (j.state === 'done') {
const outs = j.outputs || [];
res.append(h('div', { class: 'notice ok' },
h('b', {}, `Export finished in ${fmtETA(j.elapsed)} — ${outs.length} file${outs.length !== 1 ? 's' : ''} saved`),
h('div', { class: 'paths' }, ...outs.map(o => h('div', {}, o))),
outs.length ? h('button', { class: 'btn', onclick: () => api('/api/reveal', { path: outs[0] }).catch(e => toast(e.message, 'error')) }, icon('folder'), 'Show in folder') : null));
} else if (j.state === 'error') {
res.append(h('div', { class: 'notice error' }, 'Export failed:\n' + j.error));
} else if (j.state === 'canceled') {
res.append(h('div', { class: 'notice warn' }, 'Export canceled.'));
}
requestAnimationFrame(() => res.scrollIntoView({ block: 'nearest', behavior: 'smooth' }));
}
updateExportView();
}
async function startExport(overwrite = false) {
const st = S.settings;
st.output = $('#exOutput').value.trim();
if (!st.output) return toast('Choose where to save the export.', 'error');
const body = {
clips: S.clips.map(c => ({ src: c.src, in: c.in, out: c.out })),
mode: st.mode, join: st.join, output: st.output, overwrite,
allAudio: st.allAudio, noAudio: st.mode === 'copy' ? st.noAudio : st.acodec === 'none',
vcodec: st.vcodec, quality: +st.quality, speed: st.speed, height: +st.height, fps: st.fps,
acodec: st.acodec, abitrate: +st.abitrate,
};
saveSoon();
try {
const r = await api('/api/export', body);
const known = S.jobs[r.jobId];
S.exportJob = known || { id: r.jobId, state: 'running', progress: 0, step: 'Starting', outputs: [] };
if (known && known.state !== 'running') exportFinished(known);
} catch (e) {
if (e.status === 409 && e.data && e.data.exists) {
const ex = e.data.exists;
const names = ex.slice(0, 4).map(p => splitPath(p)[1]).join('\n') + (ex.length > 4 ? '\n…' : '');
if (await confirmDlg(`${ex.length === 1 ? 'This file already exists' : ex.length + ' files already exist'}:\n${names}\n\nReplace ${ex.length === 1 ? 'it' : 'them'}?`, 'Replace')) {
return startExport(true);
}
return;
}
S.exportJob = { id: 'local', state: 'error', error: e.message, outputs: [] };
}
renderExportProgress();
renderTopbar();
}
function exportFinished(j) {
if (dlgEx.open) return; // the dialog shows the result
if (j.state === 'done') toast(`Export finished — ${(j.outputs || []).length} file(s) saved`, 'ok');
else if (j.state === 'error') toast('Export failed — open Export for details.', 'error');
}
async function browseOutput() {
const st = S.settings;
st.output = $('#exOutput').value.trim() || defaultOutput();
if (S.info.nativeDialogs) {
try {
const r = await api('/api/dialog/save', { path: st.output });
if (r.supported) {
if (r.path) { st.output = r.path; fillExport(); saveSoon(); }
return;
}
} catch (e) {
if (e.status === 409) return;
}
}
const p = await fileBrowser({ mode: 'save', path: st.output });
if (p) { st.output = p; fillExport(); saveSoon(); }
}
function bindExport() {
const st = () => S.settings;
const refill = () => { fillExport(); saveSoon(); };
$$('input[name=mode]').forEach(r => r.addEventListener('change', () => { setMode(r.value); }));
$$('input[name=join]').forEach(r => r.addEventListener('change', () => { st().join = r.value === '1'; refill(); }));
$('#exOutput').addEventListener('input', e => { st().output = e.target.value; updateExportView(); saveSoon(); });
$('#exOutput').addEventListener('change', e => { st().output = e.target.value.trim(); refill(); });
$('#exContainer').addEventListener('change', e => { st().output = replaceExt(st().output || defaultOutput(), e.target.value); refill(); });
$('#exBrowse').addEventListener('click', browseOutput);
$('#exAllAudio').addEventListener('change', e => { st().allAudio = e.target.checked; refill(); });
$('#exNoAudioCopy').addEventListener('change', e => { st().noAudio = e.target.checked; refill(); });
$('#exVCodec').addEventListener('change', e => {
st().vcodec = e.target.value;
const vc = (S.info.codecs || []).find(c => c.name === e.target.value);
if (vc) st().quality = vc.defQ;
refill();
});
$('#exQuality').addEventListener('input', e => { st().quality = +e.target.value; updateExportView(); saveSoon(); });
$('#exSpeed').addEventListener('change', e => { st().speed = e.target.value; refill(); });
$('#exHeight').addEventListener('change', e => { st().height = +e.target.value; refill(); });
$('#exFPS').addEventListener('change', e => { st().fps = e.target.value; refill(); });
$('#exACodec').addEventListener('change', e => { st().acodec = e.target.value; refill(); });
$('#exABitrate').addEventListener('change', e => { st().abitrate = +e.target.value; refill(); });
$('#exGo').addEventListener('click', () => startExport(false));
$('#exCancel').addEventListener('click', () => {
if (S.exportJob) api('/api/job/cancel', { id: S.exportJob.id }).catch(() => {});
});
dlgEx.addEventListener('close', () => {
// A finished result has been seen once the dialog is closed.
if (S.exportJob && S.exportJob.state !== 'running') { S.exportJob = null; renderExportProgress(); }
});
}
function setMode(mode) {
const st = S.settings;
if (st.mode === mode) return;
st.mode = mode;
const exts = mode === 'copy' ? copyContainerChoices() : S.info.containers.map(c => c.ext);
if (st.output && !exts.includes(extOf(st.output))) st.output = replaceExt(st.output, defaultExt());
if (dlgEx.open) fillExport();
changed();
}
// ---------------------------------------------------------------- built-in file browser
const dlgF = $('#dlgFiles');
let fb = null;
function fileBrowser({ mode = 'open', path = '' } = {}) {
return new Promise(resolve => {
fb = { mode, resolve, sel: new Set(), path: '', entries: [], parent: '' };
$('#fbTitle').textContent = mode === 'open' ? 'Add videos' : 'Export as';
$('#fbOk').textContent = mode === 'open' ? 'Add' : 'Save here';
$('#fbSaveRow').classList.toggle('hidden', mode !== 'save');
let dir = S.settings.lastDir || '';
if (mode === 'save' && path) {
const [d, n] = splitPath(path);
dir = d;
$('#fbName').value = n;
}
$('#fbList').replaceChildren(h('li', { class: 'fb-empty' }, 'Loading…'));
$('#fbOk').disabled = mode === 'open';
dlgF.showModal();
fbLoad(dir);
});
}
let fbSeq = 0;
async function fbLoad(path) {
if (!fb) return;
const seq = ++fbSeq;
try {
const r = await api('/api/fs/list?path=' + encodeURIComponent(path) + ($('#fbAll').checked ? '&all=1' : ''));
if (!fb || seq !== fbSeq) return; // closed, or a newer listing was requested
Object.assign(fb, { path: r.path, entries: r.entries, parent: r.parent });
fb.sel.clear();
$('#fbPath').value = r.path;
const root = r.roots.find(x => r.path.toUpperCase().startsWith(x.toUpperCase())) || r.roots[0];
setOptions($('#fbRoots'), r.roots.map(x => [x, x]), root);
$('#fbUp').disabled = !r.parent;
fbRender();
} catch (e) {
if (fb && seq === fbSeq) toast(e.message, 'error');
}
}
function fbRender() {
const ul = $('#fbList');
ul.replaceChildren();
if (!fb.entries.length) ul.append(h('li', { class: 'fb-empty' }, fb.mode === 'open' ? 'No videos in this folder' : 'This folder is empty'));
for (const en of fb.entries) {
ul.append(h('li', { class: (en.dir ? 'dir' : 'file') + (fb.sel.has(en.path) ? ' sel' : ''), 'data-path': en.path, 'data-dir': en.dir ? '1' : null },
icon(en.dir ? 'folder' : 'film'), h('span', { class: 'fb-name' }, en.name),
en.dir ? null : h('span', { class: 'fb-size' }, fmtBytes(en.size))));
}
const n = fb.sel.size;
$('#fbSelInfo').textContent = fb.mode === 'open' && n ? `${n} selected` : '';
$('#fbOk').disabled = fb.mode === 'open' && !n;
}
function fbFinish() {
if (!fb) return;
let result;
if (fb.mode === 'open') {
result = [...fb.sel];
} else {
const name = $('#fbName').value.trim();
if (!name) return toast('Type a file name.');
result = joinPath(fb.path, name);
}
S.settings.lastDir = fb.path;
const f = fb;
fb = null;
dlgF.close();
f.resolve(result);
}
function bindFileBrowser() {
$('#fbList').addEventListener('click', e => {
const li = e.target.closest('li[data-path]');
if (!li || !fb) return;
const p = li.dataset.path;
if (li.dataset.dir) return fbLoad(p);
if (fb.mode === 'open') {
if (fb.sel.has(p)) fb.sel.delete(p); else fb.sel.add(p);
} else {
$('#fbName').value = splitPath(p)[1];
}
fbRender();
});
$('#fbList').addEventListener('dblclick', e => {
const li = e.target.closest('li[data-path]');
if (!li || li.dataset.dir || !fb) return;
if (fb.mode === 'open') fb.sel.add(li.dataset.path);
else $('#fbName').value = splitPath(li.dataset.path)[1];
fbFinish();
});
$('#fbOk').addEventListener('click', fbFinish);
$('#fbUp').addEventListener('click', () => fb && fb.parent && fbLoad(fb.parent));
$('#fbRoots').addEventListener('change', e => fbLoad(e.target.value));
$('#fbAll').addEventListener('change', () => fb && fbLoad(fb.path));
$('#fbPath').addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); fbLoad(e.target.value); } });
$('#fbName').addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); fbFinish(); } });
dlgF.addEventListener('close', () => {
if (fb) { const f = fb; fb = null; f.resolve(null); }
});
}
// ---------------------------------------------------------------- events from the server
let offlineTimer = 0;
function connectEvents() {
const es = new EventSource('/api/events');
es.addEventListener('job', e => onJob(JSON.parse(e.data)));
es.onopen = () => { clearTimeout(offlineTimer); $('#offline').classList.add('hidden'); };
es.onerror = () => {
clearTimeout(offlineTimer);
offlineTimer = setTimeout(() => {
if (es.readyState !== EventSource.OPEN) $('#offline').classList.remove('hidden');
}, 2500);
};
}
function onJob(j) {
S.jobs[j.id] = j;
if (j.kind === 'proxy') {
if (j.state === 'running') S.proxyJob[j.sourceId] = j;
else if (j.state === 'done') proxyReady(j.sourceId);
else {
delete S.proxyJob[j.sourceId];
if (j.state === 'error' && j.sourceId === S.cur) toast('Could not create a preview copy:\n' + j.error, 'error');
}
if (j.sourceId === S.cur) renderScreen();
return;
}
if (j.kind === 'export') {
const prev = S.exportJob;
if (j.state === 'running' || (prev && prev.id === j.id)) {
const finishedNow = prev && prev.id === j.id && prev.state === 'running' && j.state !== 'running';
S.exportJob = j;
if (finishedNow) exportFinished(j);
}
if (dlgEx.open) renderExportProgress();
renderTopbar();
}
}
// ---------------------------------------------------------------- session autosave
let saveTimer = 0;
function saveSoon() {
if (!S.restored) return;
clearTimeout(saveTimer);
saveTimer = setTimeout(saveNow, 700);
}
function saveNow() {
if (!S.restored) return;
clearTimeout(saveTimer);
const marks = {};
for (const [id, mk] of Object.entries(S.marks)) {
const m = srcById(id);
if (m && (mk.in != null || mk.out != null)) marks[m.path] = mk;
}
const data = {
version: 1,
sources: S.sources.map(m => m.path),
clips: S.clips.map(c => ({ path: srcById(c.src).path, in: c.in, out: c.out })),
current: cur() ? cur().path : null,
time: S.time,
marks,
settings: S.settings,
};
fetch('/api/session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), keepalive: true }).catch(() => {});
}
async function restoreSession() {
let s = {};
try { s = await api('/api/session'); } catch { /* fresh start */ }
S.settings = { ...DEFAULTS, ...(s.settings || {}) };
if (Array.isArray(s.sources) && s.sources.length && S.info.ffmpegOK) {
const results = await Promise.all(s.sources.map(p => api('/api/probe', { path: p }).catch(() => null)));
const byPath = {};
const missing = [];
results.forEach((m, i) => {
if (!m) { missing.push(s.sources[i]); return; }
if (!srcById(m.id)) { assignColor(m); S.sources.push(m); }
byPath[s.sources[i]] = m.id;
});
S.clips = (s.clips || []).filter(c => byPath[c.path] && c.out > c.in)
.map(c => ({ id: newId(), src: byPath[c.path], in: +c.in, out: +c.out }));
for (const [p, mk] of Object.entries(s.marks || {})) {
if (byPath[p]) S.marks[byPath[p]] = { in: mk.in ?? null, out: mk.out ?? null };
}
if (missing.length) {
toast(`${missing.length} video${missing.length > 1 ? 's' : ''} from your last session could not be found:\n` +
missing.map(p => splitPath(p)[1]).join('\n'), 'error');
}
S.sources.forEach(m => ensureKeyframes(m.id));
const curId = byPath[s.current] || (S.sources[0] && S.sources[0].id);
if (curId) loadSource(curId, { time: s.time || 0 });
}
S.restored = true;
}
// ---------------------------------------------------------------- input bindings
const ACTIONS = {
start: () => seek(0),
end: () => { const m = cur(); if (m) seek(m.duration); },
prevKey: () => gotoKey(-1),
nextKey: () => gotoKey(1),
back1s: () => seekBy(-1),
fwd1s: () => seekBy(1),
prevFrame: () => stepFrames(-1),
nextFrame: () => stepFrames(1),
play: togglePlay,
mute: toggleMute,
zoomIn: () => zoom(0.5),
zoomOut: () => zoom(2),
zoomFit,
setIn: () => setIn(),
setOut: () => setOut(),
split,
playSel: playSelection,
addClip: addClipOrDone,
};
function bindUI() {
document.addEventListener('click', e => {
const b = e.target.closest('[data-act]');
if (b && ACTIONS[b.dataset.act]) { ACTIONS[b.dataset.act](); b.blur(); }
});
$$('[data-close]').forEach(b => b.addEventListener('click', () => b.closest('dialog').close()));
$('#btnAdd').addEventListener('click', () => addFiles(false));
$('#btnBrowse').addEventListener('click', () => addFiles(true));
$('#btnUndo').addEventListener('click', undo);
$('#btnRedo').addEventListener('click', redo);
$('#btnNew').addEventListener('click', newProject);
$('#btnHelp').addEventListener('click', () => $('#dlgHelp').showModal());
$('#btnExport').addEventListener('click', openExport);
$('#jobPill').addEventListener('click', openExport);
$('#volume').addEventListener('input', e => {
video.volume = +e.target.value;
video.muted = false;
S.settings.volume = video.volume;
S.settings.muted = false;
renderMute();
saveSoon();
});
$$('#modeSeg button').forEach(b => b.addEventListener('click', () => { setMode(b.dataset.mode); b.blur(); }));
$('#sourceList').addEventListener('click', e => {
const li = e.target.closest('.src');
if (!li) return;
const act = e.target.closest('[data-sact]');
if (act && act.dataset.sact === 'remove') return removeSource(li.dataset.id);
if (act && act.dataset.sact === 'whole') return addWhole(li.dataset.id);
if (li.dataset.id !== S.cur) loadSource(li.dataset.id);
});
$('#markHint').addEventListener('click', e => {
const a = e.target.closest('[data-hact]');
const m = cur();
if (!a || !m) return;
const r = activeRange();
if (a.dataset.hact === 'done') { S.sel = null; changed(); }
if (a.dataset.hact === 'snapPrev') { const k = kfBefore(m.id, r.in); if (k != null) { setIn(k); seek(k, { quantize: false }); } }
if (a.dataset.hact === 'snapNext') { const k = kfAfter(m.id, r.in); if (k != null) { setIn(k); seek(k, { quantize: false }); } }
});
// clip strip: click / actions / drag to reorder
const list = $('#clipList');
let dragId = null, dropIndex = null;
const clearDrop = () => $$('.drop-before, .drop-after', list).forEach(el => el.classList.remove('drop-before', 'drop-after'));
list.addEventListener('click', e => {
const card = e.target.closest('.clip');
if (!card) return;
const id = card.dataset.id;
const act = e.target.closest('[data-cact]');
if (act) {
({ del: deleteClip, dup: duplicateClip, left: x => moveClipBy(x, -1), right: x => moveClipBy(x, 1) })[act.dataset.cact](id);
return;
}
selectClip(id);
});
list.addEventListener('dragstart', e => {
const card = e.target.closest('.clip');
if (!card) return;
dragId = card.dataset.id;
card.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', dragId);
});
list.addEventListener('dragover', e => {
if (!dragId) return;
e.preventDefault();
clearDrop();
const card = e.target.closest('.clip');
if (!card) {
const last = list.lastElementChild;
if (last) last.classList.add('drop-after');
dropIndex = S.clips.length;
return;
}
const r = card.getBoundingClientRect();
const after = e.clientX > r.left + r.width / 2;
card.classList.add(after ? 'drop-after' : 'drop-before');
const i = S.clips.findIndex(c => c.id === card.dataset.id);
dropIndex = after ? i + 1 : i;
});
list.addEventListener('drop', e => {
e.preventDefault();
if (dragId != null && dropIndex != null) moveClipTo(dragId, dropIndex);
});
list.addEventListener('dragend', () => {
dragId = null;
dropIndex = null;
clearDrop();
$$('.dragging', list).forEach(el => el.classList.remove('dragging'));
});
// Files dropped from Explorer have no usable path in a browser window.
window.addEventListener('dragover', e => { if (!dragId) e.preventDefault(); });
window.addEventListener('drop', e => {
if (dragId) return;
e.preventDefault();
if (e.dataTransfer && e.dataTransfer.files.length) toast('Dropping files isn\'t supported here — use "Add videos" instead.');
});
document.addEventListener('keydown', onKey);
window.addEventListener('beforeunload', saveNow);
}
function onKey(e) {
if (e.target.closest('input, select, textarea') || document.querySelector('dialog[open]')) return;
const k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
const ctrl = e.ctrlKey || e.metaKey;
let handled = true;
if (ctrl && k === 'z') { if (e.shiftKey) redo(); else undo(); }
else if (ctrl && k === 'y') redo();
else if (ctrl && (k === 'ArrowLeft' || k === 'ArrowRight')) seekBy(k === 'ArrowLeft' ? -10 : 10);
else if (ctrl || e.altKey) handled = false;
else {
switch (k) {
case ' ': case 'k': togglePlay(); break;
case 'ArrowLeft': if (e.shiftKey) seekBy(-1); else stepFrames(-1); break;
case 'ArrowRight': if (e.shiftKey) seekBy(1); else stepFrames(1); break;
case 'ArrowUp': gotoKey(-1); break;
case 'ArrowDown': gotoKey(1); break;
case 'Home': seek(0); break;
case 'End': ACTIONS.end(); break;
case 'i': if (e.shiftKey) gotoMark('in'); else setIn(); break;
case 'o': if (e.shiftKey) gotoMark('out'); else setOut(); break;
case 'Enter': addClipOrDone(); break;
case 'p': playSelection(); break;
case 's': split(); break;
case 'm': toggleMute(); break;
case 'Delete': case 'Backspace': if (S.sel) deleteClip(S.sel); break;
case 'Escape': if (S.sel) { S.sel = null; changed(); } break;
case '+': case '=': zoom(0.5); break;
case '-': case '_': zoom(2); break;
case '0': zoomFit(); break;
default: handled = false;
}
}
if (handled) e.preventDefault();
}
// ---------------------------------------------------------------- start
async function init() {
try {
S.info = await api('/api/info');
} catch {
$('#offline').classList.remove('hidden');
return;
}
if (!S.info.ffmpegOK) {
const win = S.info.os === 'windows';
const b = $('#banner');
b.replaceChildren(h('b', {}, 'ffmpeg was not found. '),
win ? 'Download a Windows build (for example "ffmpeg-release-essentials.zip" from ' : 'Install ffmpeg (for example from ',
h('a', { href: 'https://www.gyan.dev/ffmpeg/builds/', target: '_blank', rel: 'noopener' }, 'gyan.dev/ffmpeg/builds'),
win ? '), copy ffmpeg.exe and ffprobe.exe from its bin folder next to vibedit.exe, then restart VibEdit.'
: ' or your package manager) and restart VibEdit.');
b.classList.remove('hidden');
}
$('#btnBrowse').classList.toggle('hidden', !S.info.nativeDialogs);
$('#helpVersion').textContent = `VibEdit ${S.info.version} · ${S.info.ffmpegVersion || 'ffmpeg not found'}`;
bindUI();
bindExport();
bindFileBrowser();
connectEvents();
await restoreSession();
video.volume = clamp(+S.settings.volume || 0, 0, 1);
video.muted = !!S.settings.muted;
$('#volume').value = video.volume;
renderMute();
renderAll();
requestAnimationFrame(frame);
}
window.vibedit = { S, api }; // handy for debugging from the console
init();
})();