2059 lines
77 KiB
JavaScript
2059 lines
77 KiB
JavaScript
'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,
|
||
gifHeight: 480, gifFps: '15', gifDither: 'sierra2_4a', gifLoop: true,
|
||
burnCaptions: true, captionSize: 'medium', captionStyle: 'outline',
|
||
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}
|
||
captions: {}, // source id -> {path, name, cues: [{start, end, text}]} (a loaded .srt file)
|
||
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(), captions: { ...S.captions }, 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.captions = s.captions;
|
||
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
|
||
// pickFiles asks for files to open, with the native dialog when there is one
|
||
// and else with the built-in browser. kind is 'video' or 'captions'.
|
||
async function pickFiles({ kind = 'video', dir = '', builtin = false } = {}) {
|
||
if (!builtin && S.info.nativeDialogs) {
|
||
try {
|
||
const r = await api('/api/dialog/open', { dir, kind });
|
||
if (r.supported) return r.paths || [];
|
||
if (r.error) toast('The file dialog could not be opened, using the built-in browser instead.', 'error');
|
||
} catch (e) {
|
||
if (e.status === 409) return [];
|
||
}
|
||
}
|
||
return await fileBrowser({ mode: 'open', kind, dir }) || [];
|
||
}
|
||
|
||
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');
|
||
const paths = await pickFiles({ dir: S.settings.lastDir || (cur() ? splitPath(cur().path)[0] : ''), builtin });
|
||
if (!paths.length) return;
|
||
S.settings.lastDir = splitPath(paths[0])[0];
|
||
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();
|
||
}
|
||
}
|
||
|
||
async function loadCaptions(id) {
|
||
const m = srcById(id);
|
||
if (!m) return;
|
||
const old = S.captions[id];
|
||
const [path] = await pickFiles({ kind: 'captions', dir: splitPath(old ? old.path : m.path)[0] });
|
||
if (!path || !srcById(id)) return;
|
||
try {
|
||
const c = await api('/api/captions', { path });
|
||
commit(() => { S.captions[id] = c; });
|
||
if (S.cur !== id) loadSource(id);
|
||
toast(`Loaded ${c.cues.length} caption${c.cues.length !== 1 ? 's' : ''} from "${c.name}"`, 'ok');
|
||
} catch (e) {
|
||
toast(e.message, 'error');
|
||
}
|
||
}
|
||
|
||
function removeCaptions(id) {
|
||
commit(() => { delete S.captions[id]; });
|
||
}
|
||
|
||
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.captions = {}; 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];
|
||
const cap = S.captions[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'),
|
||
cap ? h('button', { class: 'chip-btn on', 'data-sact': 'captions', title: `Captions: ${cap.name} (${cap.cues.length})\nClick to load a different .srt file.` }, 'CC',
|
||
h('span', { class: 'chip-x', 'data-sact': 'nocaptions', title: 'Remove the captions' }, icon('x')))
|
||
: h('button', { class: 'chip-btn', 'data-sact': 'captions', title: 'Load captions (.srt) for this video' }, icon('plus'), 'CC'),
|
||
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() {
|
||
renderCaptions();
|
||
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.';
|
||
}
|
||
layoutCaptions();
|
||
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 + '%');
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- captions preview
|
||
// The viewer shows the current source's captions the way an export burns them
|
||
// in (captionFilter in captions.go). Sizes are in 1/288ths of the picture's
|
||
// shorter side and match captionSizes there; libass makes each line as tall as
|
||
// the font size, which for Arial is 1.117 em.
|
||
const CAPTION_SIZES = { small: 12, medium: 16, large: 21 };
|
||
const capEl = $('#captions');
|
||
let capShown = null;
|
||
|
||
function renderCaptions() {
|
||
const m = cur();
|
||
const c = m && S.captions[m.id];
|
||
const t = S.time + 1e-3;
|
||
const cues = c ? c.cues.filter(q => q.start <= t && t < q.end) : [];
|
||
const key = cues.map(q => q.text).join('\0');
|
||
if (key === capShown) return;
|
||
capShown = key;
|
||
capEl.replaceChildren(...cues.map(q => h('div', {}, captionNodes(q.text))));
|
||
capEl.classList.toggle('hidden', !cues.length);
|
||
}
|
||
|
||
// layoutCaptions places the captions over the picture inside the viewer.
|
||
function layoutCaptions() {
|
||
const scr = $('#screen');
|
||
const W = scr.clientWidth, H = scr.clientHeight;
|
||
let w = W, hh = H;
|
||
const v = cur() && cur().video;
|
||
if (v && v.displayWidth && v.displayHeight) {
|
||
const a = v.displayWidth / v.displayHeight;
|
||
if (W / H > a) w = H * a; else hh = W / a;
|
||
}
|
||
const u = Math.min(w, hh) / 288;
|
||
const size = (CAPTION_SIZES[S.settings.captionSize] || CAPTION_SIZES.medium) * u;
|
||
const margin = 10 * u; // ffmpeg's default MarginL/R/V
|
||
Object.assign(capEl.style, {
|
||
left: (W - w) / 2 + margin + 'px', width: Math.max(0, w - 2 * margin) + 'px', bottom: (H - hh) / 2 + margin + 'px',
|
||
fontSize: size / 1.117 + 'px', lineHeight: size + 'px',
|
||
});
|
||
capEl.style.setProperty('--u', u + 'px');
|
||
capEl.classList.toggle('box', S.settings.captionStyle === 'box');
|
||
}
|
||
new ResizeObserver(layoutCaptions).observe($('#screen'));
|
||
|
||
// captionNodes keeps the <i>, <b> and <u> tags of SRT caption text and drops
|
||
// other tags and {\...} override codes.
|
||
function captionNodes(text) {
|
||
const root = h('span');
|
||
let el = root;
|
||
for (const part of text.replace(/\{\\[^}]*\}/g, '').split(/(<\/?[a-z]+[^>]*>)/i)) {
|
||
const tag = /^<(\/?)([a-z]+)[^>]*>$/i.exec(part);
|
||
if (!tag) el.append(part);
|
||
else if (!['i', 'b', 'u'].includes(tag[2].toLowerCase())) continue;
|
||
else if (!tag[1]) el = el.appendChild(h(tag[2].toLowerCase()));
|
||
else if (el !== root) el = el.parentNode;
|
||
}
|
||
return root;
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 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 exportExts(mode) {
|
||
if (mode === 'copy') return copyContainerChoices();
|
||
if (mode === 'gif') return ['gif'];
|
||
return S.info.containers.map(c => c.ext);
|
||
}
|
||
|
||
function firstClipSource() { return srcById(S.clips[0] && S.clips[0].src) || cur(); }
|
||
|
||
// willBurnCaptions tells whether the export draws captions into the picture.
|
||
const willBurnCaptions = () => S.settings.mode !== 'copy' && S.settings.burnCaptions && S.info.captionsOK &&
|
||
S.clips.some(c => S.captions[c.src]);
|
||
|
||
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 === 'gif') return 'gif';
|
||
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 = exportExts(st.mode);
|
||
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';
|
||
|
||
$('#exGifHeight').value = String(st.gifHeight);
|
||
$('#exGifFPS').value = st.gifFps;
|
||
$('#exGifDither').value = st.gifDither;
|
||
$('#exGifLoop').value = st.gifLoop ? '1' : '0';
|
||
$('#exCapSize').value = st.captionSize;
|
||
$('#exCapStyle').value = st.captionStyle;
|
||
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', gif = st.mode === 'gif';
|
||
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', st.mode !== 'encode');
|
||
$('#exGifOpts').classList.toggle('hidden', !gif);
|
||
const result = st.join || n === 1 ? (gif ? 'one animated GIF' : 'one video file') : n + (gif ? ' separate GIFs' : ' separate files');
|
||
$('#exSummary').textContent = `${n} clip${n !== 1 ? 's' : ''} · ${fmtLen(total)} total → ${result}${willBurnCaptions() ? ' with captions' : ''}`;
|
||
|
||
const capFiles = [...new Set(S.clips.map(c => S.captions[c.src]).filter(Boolean))];
|
||
const capOK = !copy && S.info.captionsOK;
|
||
let capHint = '';
|
||
if (copy) capHint = 'Lossless export copies the picture unchanged, so it can\'t add captions. Choose Re-encode or GIF to burn them in.';
|
||
else if (!S.info.codecsReady) capHint = 'Checking whether ffmpeg can draw captions…';
|
||
else if (!S.info.captionsOK) capHint = 'This ffmpeg can\'t draw captions: it was built without libass (the subtitles filter).';
|
||
else if (st.burnCaptions) {
|
||
const none = S.clips.filter(c => !S.captions[c.src]).length;
|
||
capHint = 'From ' + capFiles.map(c => c.name).join(', ') +
|
||
(none ? ` · ${none} clip${none > 1 ? 's come' : ' comes'} from a video without captions` : '');
|
||
}
|
||
$('#exCapOpts').classList.toggle('hidden', !capFiles.length);
|
||
$('#exCaptions').checked = capOK && st.burnCaptions;
|
||
$('#exCapHint').textContent = capHint;
|
||
$('#exCapLook').classList.toggle('hidden', !capOK || !st.burnCaptions);
|
||
|
||
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, gif ? +st.gifHeight : +st.height);
|
||
const fps = gif ? st.gifFps + ' fps, no sound' : st.fps ? $('#exFPS').selectedOptions[0].textContent + ' fps' : fmtFps(firstV.video.fps);
|
||
encHint = `Output: ${w}×${hh}, ${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 = gif ? '' : encHint;
|
||
$('#exGifHint').textContent = gif ? 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 (gif) {
|
||
const longest = st.join ? total : Math.max(...S.clips.map(c => c.out - c.in));
|
||
if (longest > 30) {
|
||
warns.push(`${st.join || n === 1 ? 'The GIF' : 'The longest GIF'} will be ${fmtLen(longest)} long. GIF files get large quickly; a lower resolution or frame rate keeps them smaller.`);
|
||
}
|
||
} 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';
|
||
$('#exCaptions').disabled = !capOK;
|
||
}
|
||
}
|
||
|
||
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 gif = st.mode === 'gif';
|
||
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: gif ? +st.gifHeight : +st.height, fps: gif ? st.gifFps : st.fps,
|
||
acodec: st.acodec, abitrate: +st.abitrate,
|
||
dither: st.gifDither, loop: st.gifLoop,
|
||
captions: {}, captionSize: st.captionSize, captionStyle: st.captionStyle,
|
||
};
|
||
if (willBurnCaptions()) {
|
||
for (const c of S.clips) if (S.captions[c.src]) body.captions[c.src] = S.captions[c.src].path;
|
||
}
|
||
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(); });
|
||
$('#exGifHeight').addEventListener('change', e => { st().gifHeight = +e.target.value; refill(); });
|
||
$('#exGifFPS').addEventListener('change', e => { st().gifFps = e.target.value; refill(); });
|
||
$('#exGifDither').addEventListener('change', e => { st().gifDither = e.target.value; refill(); });
|
||
$('#exGifLoop').addEventListener('change', e => { st().gifLoop = e.target.value === '1'; refill(); });
|
||
$('#exCaptions').addEventListener('change', e => { st().burnCaptions = e.target.checked; refill(); });
|
||
$('#exCapSize').addEventListener('change', e => { st().captionSize = e.target.value; refill(); layoutCaptions(); });
|
||
$('#exCapStyle').addEventListener('change', e => { st().captionStyle = e.target.value; refill(); layoutCaptions(); });
|
||
$('#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 = exportExts(mode);
|
||
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;
|
||
|
||
// fileBrowser picks files to open (of a kind: 'video' or 'captions') or the
|
||
// path to save an export to.
|
||
function fileBrowser({ mode = 'open', kind = 'video', dir = '', path = '' } = {}) {
|
||
return new Promise(resolve => {
|
||
const caps = kind === 'captions';
|
||
fb = { mode, kind, resolve, sel: new Set(), path: '', entries: [], parent: '' };
|
||
$('#fbTitle').textContent = mode === 'save' ? 'Export as' : caps ? 'Load captions' : 'Add videos';
|
||
$('#fbOk').textContent = mode === 'save' ? 'Save here' : caps ? 'Load' : 'Add';
|
||
$('#fbSaveRow').classList.toggle('hidden', mode !== 'save');
|
||
dir = 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) + '&kind=' + fb.kind + ($('#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;
|
||
// The deepest root containing the folder (on Linux, / contains the mounted drives too).
|
||
const P = r.path.toUpperCase(), sep = S.info.sep || '/';
|
||
const inRoot = x => { const X = x.toUpperCase(); return P === X || P.startsWith(X.replace(/[\\/]$/, '') + sep); };
|
||
const root = r.roots.filter(inRoot).sort((a, b) => b.length - a.length)[0] || 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();
|
||
const none = fb.mode !== 'open' ? 'This folder is empty' : fb.kind === 'captions' ? 'No .srt files in this folder' : 'No videos in this folder';
|
||
if (!fb.entries.length) ul.append(h('li', { class: 'fb-empty' }, none));
|
||
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);
|
||
}
|
||
if (fb.kind !== 'captions') 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') {
|
||
const had = fb.sel.has(p);
|
||
if (fb.kind === 'captions') fb.sel.clear(); // one caption file at a time
|
||
if (had) 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') {
|
||
if (fb.kind === 'captions') fb.sel.clear();
|
||
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,
|
||
captions: Object.fromEntries(S.sources.filter(m => S.captions[m.id]).map(m => [m.path, S.captions[m.id].path])),
|
||
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 };
|
||
}
|
||
const caps = Object.entries(s.captions || {}).filter(([p]) => byPath[p]);
|
||
const loaded = await Promise.all(caps.map(([, srt]) => api('/api/captions', { path: srt }).catch(() => null)));
|
||
const lost = [];
|
||
caps.forEach(([p, srt], i) => { if (loaded[i]) S.captions[byPath[p]] = loaded[i]; else lost.push(srt); });
|
||
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');
|
||
}
|
||
if (lost.length) {
|
||
toast('Captions from your last session could not be loaded:\n' + lost.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 (act && act.dataset.sact === 'captions') return loadCaptions(li.dataset.id);
|
||
if (act && act.dataset.sact === 'nocaptions') return removeCaptions(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 with your package manager (or put ffmpeg and ffprobe from ',
|
||
win ? h('a', { href: 'https://www.gyan.dev/ffmpeg/builds/', target: '_blank', rel: 'noopener' }, 'gyan.dev/ffmpeg/builds')
|
||
: h('a', { href: 'https://ffmpeg.org/download.html', target: '_blank', rel: 'noopener' }, 'ffmpeg.org/download.html'),
|
||
win ? '), copy ffmpeg.exe and ffprobe.exe from its bin folder next to vibedit.exe, then restart VibEdit.'
|
||
: ' next to vibedit) 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();
|
||
})();
|