IDK man, changes I guess

This commit is contained in:
2026-11-06 17:57:01 +00:00
parent 9fb9395f99
commit 4c8cafd733
15 changed files with 785 additions and 61 deletions

View File

@@ -121,6 +121,7 @@ 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'];
@@ -129,6 +130,7 @@ 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)
@@ -185,7 +187,7 @@ const thumbURL = (id, t, hgt) => `/api/thumb?id=${id}&t=${Math.max(0, t).toFixed
// ---------------------------------------------------------------- undo
function snapshot() {
return { clips: S.clips.map(c => ({ ...c })), sources: S.sources.slice(), sel: S.sel };
return { clips: S.clips.map(c => ({ ...c })), sources: S.sources.slice(), captions: { ...S.captions }, sel: S.sel };
}
function commit(fn) {
S.undo.push(snapshot());
@@ -197,6 +199,7 @@ function commit(fn) {
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;
@@ -236,26 +239,27 @@ function confirmDlg(text, okLabel = 'OK') {
}
// ---------------------------------------------------------------- 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');
// 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 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;
}
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;
if (e.status === 409) return [];
}
}
const paths = await fileBrowser({ mode: 'open' });
if (paths && paths.length) await addPaths(paths);
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) {
@@ -292,6 +296,26 @@ async function removeSource(id) {
}
}
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;
@@ -303,7 +327,7 @@ function addWhole(id) {
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; });
commit(() => { S.sources = []; S.clips = []; S.captions = {}; S.sel = null; });
S.marks = {};
S.settings.output = '';
S.cur = null;
@@ -1014,6 +1038,7 @@ function renderSources() {
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' })
@@ -1023,6 +1048,9 @@ function renderSources() {
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'))));
@@ -1097,6 +1125,7 @@ function renderMarks() {
}
function renderTime() {
renderCaptions();
const m = cur();
tcCurEl.textContent = fmtT(m ? S.time : 0);
$('#tcDur').textContent = fmtT(m ? m.duration : 0);
@@ -1132,6 +1161,7 @@ function renderScreen() {
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';
@@ -1154,6 +1184,64 @@ function renderTopbar() {
}
}
// ---------------------------------------------------------------- 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', () => {
@@ -1206,6 +1294,10 @@ function exportExts(mode) {
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 '';
@@ -1282,6 +1374,8 @@ function fillExport() {
$('#exGifFPS').value = st.gifFps;
$('#exGifDither').value = st.gifDither;
$('#exGifLoop').value = st.gifLoop ? '1' : '0';
$('#exCapSize').value = st.captionSize;
$('#exCapStyle').value = st.captionStyle;
updateExportView();
}
@@ -1317,7 +1411,23 @@ function updateExportView() {
$('#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}`;
$('#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) {
@@ -1378,7 +1488,10 @@ function updateExportView() {
$('#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';
if (!running) {
$('#exABitrate').disabled = st.acodec === 'none' || st.acodec === 'flac';
$('#exCaptions').disabled = !capOK;
}
}
function renderExportProgress() {
@@ -1422,7 +1535,11 @@ async function startExport(overwrite = false) {
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);
@@ -1495,6 +1612,9 @@ function bindExport() {
$('#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(() => {});
@@ -1519,13 +1639,16 @@ function setMode(mode) {
const dlgF = $('#dlgFiles');
let fb = null;
function fileBrowser({ mode = 'open', path = '' } = {}) {
// 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 => {
fb = { mode, resolve, sel: new Set(), path: '', entries: [], parent: '' };
$('#fbTitle').textContent = mode === 'open' ? 'Add videos' : 'Export as';
$('#fbOk').textContent = mode === 'open' ? 'Add' : 'Save here';
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');
let dir = S.settings.lastDir || '';
dir = dir || S.settings.lastDir || '';
if (mode === 'save' && path) {
const [d, n] = splitPath(path);
dir = d;
@@ -1543,7 +1666,7 @@ 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' : ''));
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();
@@ -1563,7 +1686,8 @@ async function fbLoad(path) {
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'));
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),
@@ -1584,7 +1708,7 @@ function fbFinish() {
if (!name) return toast('Type a file name.');
result = joinPath(fb.path, name);
}
S.settings.lastDir = fb.path;
if (fb.kind !== 'captions') S.settings.lastDir = fb.path;
const f = fb;
fb = null;
dlgF.close();
@@ -1598,7 +1722,9 @@ function bindFileBrowser() {
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);
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];
}
@@ -1607,8 +1733,10 @@ function bindFileBrowser() {
$('#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];
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);
@@ -1683,6 +1811,7 @@ function saveNow() {
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(() => {});
@@ -1706,10 +1835,17 @@ async function restoreSession() {
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 });
@@ -1770,6 +1906,8 @@ function bindUI() {
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);
});

View File

@@ -78,6 +78,7 @@
<div id="screen" class="screen">
<video id="video" preload="auto" playsinline></video>
<img id="still" class="still hidden" alt="">
<div id="captions" class="captions hidden"></div>
<div id="screenMsg" class="screen-msg hidden"></div>
<div id="noSource" class="screen-empty">Add a video, then click it in the list to preview it here.</div>
</div>
@@ -307,6 +308,31 @@
<div class="hint" id="exGifHint"></div>
</div>
<div id="exCapOpts">
<div class="field">
<div class="label">Captions</div>
<label><input type="checkbox" id="exCaptions"> Burn the captions into the picture</label>
<div class="hint" id="exCapHint"></div>
</div>
<div class="grid2" id="exCapLook">
<div class="field">
<div class="label">Caption size</div>
<select id="exCapSize">
<option value="small">Small</option>
<option value="medium">Medium</option>
<option value="large">Large</option>
</select>
</div>
<div class="field">
<div class="label">Caption style</div>
<select id="exCapStyle">
<option value="outline">White with a black outline</option>
<option value="box">White on black boxes</option>
</select>
</div>
</div>
</div>
<div id="exWarn" class="notice warn hidden"></div>
<div id="exProgress" class="progress hidden">
<div class="bar"><div id="exBar"></div></div>
@@ -356,6 +382,7 @@
<li>Move the playhead and press <kbd>I</kbd> / <kbd>O</kbd> to mark the start and end of a part you want, then <kbd>Enter</kbd> to add it as a clip. Repeat for every part (from any source).</li>
<li>Or add a whole video with <b>+ Whole</b>, put the playhead where you want to cut and press <kbd>S</kbd> to split; delete the pieces you don't want.</li>
<li>Drag clips to put them in order, then <b>Export</b> — joined into one video or as separate files.</li>
<li>To add captions, click <b>CC</b> on a source and pick its <b>.srt</b> file. They show in the viewer, and Re-encode and GIF exports burn them into the picture, timed to each clip.</li>
</ol>
<p class="dim">The In point includes the frame you see; the Out point cuts just before the frame you see. Your project is saved automatically.</p>
<table class="keys">

View File

@@ -129,7 +129,7 @@ kbd {
.src-thumb .audio-only { position: absolute; inset: 0; display: grid; place-items: center; color: var(--dim); font-size: 22px; }
.src-name { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; padding-right: 18px; }
.src-meta { color: var(--dim); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.src-actions { display: flex; gap: 4px; margin-top: 3px; align-items: center; }
.src-actions { display: flex; flex-wrap: wrap; gap: 3px 4px; margin-top: 3px; align-items: center; }
.chip-btn {
font-size: 11.5px; height: 22px; padding: 0 7px; border-radius: 5px;
border: 1px solid var(--line2); background: var(--panel3); color: var(--dim); cursor: pointer;
@@ -137,6 +137,10 @@ kbd {
}
.chip-btn:hover { color: var(--text); border-color: #4a5364; }
.chip-btn .ic { width: 13px; height: 13px; }
.chip-btn.on { background: #1c2b48; border-color: #3a62ad; color: #cfe0ff; }
.chip-btn .chip-x { display: inline-flex; margin-right: -3px; padding: 1px; border-radius: 3px; opacity: .7; }
.chip-btn .chip-x:hover { opacity: 1; background: rgba(255,255,255,.12); }
.chip-btn .chip-x .ic { width: 11px; height: 11px; }
.src-x { position: absolute; top: 5px; right: 5px; opacity: 0; }
.src:hover .src-x, .src.cur .src-x { opacity: 1; }
.src-status { font-size: 11px; color: var(--dim2); }
@@ -145,6 +149,22 @@ kbd {
.viewer { grid-row: 1; grid-column: 2; overflow: hidden; }
.screen { flex: 1; min-height: 120px; position: relative; background: #000; border-radius: var(--radius) var(--radius) 0 0; overflow: hidden; }
.screen video, .screen .still { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; background: #000; }
/* Positioned and sized by layoutCaptions() in app.js to match the export. */
.captions {
position: absolute; display: flex; flex-direction: column-reverse; align-items: center;
font-family: Arial, "Liberation Sans", Helvetica, sans-serif; color: #fff; text-align: center;
white-space: pre-line; text-wrap: balance; overflow-wrap: anywhere; pointer-events: none;
--o: calc(var(--u, 1px) * 1.3);
--d: calc(var(--o) * .71);
}
.captions > div > span {
text-shadow: var(--o) 0 #000, calc(-1 * var(--o)) 0 #000, 0 var(--o) #000, 0 calc(-1 * var(--o)) #000,
var(--d) var(--d) #000, calc(-1 * var(--d)) var(--d) #000, var(--d) calc(-1 * var(--d)) #000, calc(-1 * var(--d)) calc(-1 * var(--d)) #000;
}
.captions.box > div > span {
text-shadow: none; background: #000; padding: 0 calc(var(--u, 1px) * 1.5);
-webkit-box-decoration-break: clone; box-decoration-break: clone;
}
.screen-empty, .screen-msg {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
color: var(--dim); text-align: center; padding: 20px; pointer-events: none;