Linux versino and Gif output

This commit is contained in:
2026-10-17 06:21:19 +00:00
parent 396a676146
commit f6ac9c2f34
12 changed files with 728 additions and 122 deletions

View File

@@ -120,6 +120,7 @@ const tcCurEl = $('#tcCur'), markInEl = $('#markIn'), markOutEl = $('#markOut');
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,
lastDir: '', volume: 1, muted: false,
};
const PALETTE = ['#4c8dff', '#3ccf91', '#ff8a4c', '#c77dff', '#ffd23f', '#ff5d8f', '#2ec4d6', '#a3d14b'];
@@ -248,7 +249,7 @@ async function addFiles(builtin = false) {
}
return;
}
if (r.error) toast('The Windows file dialog could not be opened, using the built-in browser instead.', 'error');
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;
}
@@ -1197,6 +1198,12 @@ function copyContainerChoices() {
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(); }
function defaultOutput() {
@@ -1207,6 +1214,7 @@ function defaultOutput() {
}
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) : '';
@@ -1240,7 +1248,7 @@ function fillExport() {
$$('input[name=mode]').forEach(r => { r.checked = r.value === st.mode; });
$$('input[name=join]').forEach(r => { r.checked = (r.value === '1') === st.join; });
const exts = st.mode === 'copy' ? copyContainerChoices() : info.containers.map(c => c.ext);
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;
@@ -1269,6 +1277,11 @@ function fillExport() {
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';
updateExportView();
}
@@ -1297,12 +1310,14 @@ function formatSig(m, allAudio) {
function updateExportView() {
const st = S.settings;
const copy = st.mode === 'copy';
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', copy);
$('#exSummary').textContent = `${n} clip${n !== 1 ? 's' : ''} · ${fmtLen(total)} total → ${st.join || n === 1 ? 'one video file' : n + ' separate files'}`;
$('#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}`;
let outHint = '';
if (!st.join && n > 1 && st.output) {
@@ -1319,14 +1334,16 @@ function updateExportView() {
let encHint = '';
const firstV = S.clips.map(c => srcById(c.src)).find(m => m && m.video);
if (!copy && firstV) {
const [w, hh] = outSize(firstV, +st.height);
encHint = `Output: ${w}×${hh}, ${st.fps ? $('#exFPS').selectedOptions[0].textContent + ' fps' : fmtFps(firstV.video.fps)}`;
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 = encHint;
$('#exEncHint').textContent = gif ? '' : encHint;
$('#exGifHint').textContent = gif ? encHint : '';
const warns = [];
if (copy) {
@@ -1346,6 +1363,11 @@ function updateExportView() {
}
}
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…');
}
@@ -1391,12 +1413,15 @@ 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: +st.height, fps: st.fps,
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,
};
saveSoon();
try {
@@ -1466,6 +1491,10 @@ function bindExport() {
$('#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(); });
$('#exGo').addEventListener('click', () => startExport(false));
$('#exCancel').addEventListener('click', () => {
if (S.exportJob) api('/api/job/cancel', { id: S.exportJob.id }).catch(() => {});
@@ -1480,7 +1509,7 @@ function setMode(mode) {
const st = S.settings;
if (st.mode === mode) return;
st.mode = mode;
const exts = mode === 'copy' ? copyContainerChoices() : S.info.containers.map(c => c.ext);
const exts = exportExts(mode);
if (st.output && !exts.includes(extOf(st.output))) st.output = replaceExt(st.output, defaultExt());
if (dlgEx.open) fillExport();
changed();
@@ -1519,7 +1548,10 @@ async function fbLoad(path) {
Object.assign(fb, { path: r.path, entries: r.entries, parent: r.parent });
fb.sel.clear();
$('#fbPath').value = r.path;
const root = r.roots.find(x => r.path.toUpperCase().startsWith(x.toUpperCase())) || r.roots[0];
// 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();
@@ -1861,10 +1893,11 @@ async function init() {
const win = S.info.os === 'windows';
const b = $('#banner');
b.replaceChildren(h('b', {}, 'ffmpeg was not found. '),
win ? 'Download a Windows build (for example "ffmpeg-release-essentials.zip" from ' : 'Install ffmpeg (for example from ',
h('a', { href: 'https://www.gyan.dev/ffmpeg/builds/', target: '_blank', rel: 'noopener' }, 'gyan.dev/ffmpeg/builds'),
win ? '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.'
: ' or your package manager) and restart VibEdit.');
: ' next to vibedit) and restart VibEdit.');
b.classList.remove('hidden');
}
$('#btnBrowse').classList.toggle('hidden', !S.info.nativeDialogs);

View File

@@ -141,6 +141,7 @@
<div class="seg" id="modeSeg">
<button data-mode="copy" title="Copy the original streams: no quality loss, very fast. Cuts land on keyframes.">Lossless</button>
<button data-mode="encode" title="Re-encode: frame-exact cuts and a choice of format, size and quality. Slower.">Re-encode</button>
<button data-mode="gif" title="Animated GIF: a looping animation without sound, with frame-exact cuts.">GIF</button>
</div>
</div>
<div id="clipList" class="clip-list"></div>
@@ -159,11 +160,13 @@
<div class="field">
<div class="label">Method</div>
<div class="cards2">
<div class="cards3">
<label class="rcard"><input type="radio" name="mode" value="copy">
<div><b>Lossless</b><small>Copies the original video and audio: no quality loss and very fast. Cuts can only start on keyframes, so a clip may begin a little early.</small></div></label>
<label class="rcard"><input type="radio" name="mode" value="encode">
<div><b>Re-encode</b><small>Frame-exact cuts. Choose the format, resolution and quality. Clips of different sizes are fitted together. Slower.</small></div></label>
<label class="rcard"><input type="radio" name="mode" value="gif">
<div><b>Animated GIF</b><small>A looping animation without sound that plays almost anywhere. Frame-exact cuts. Best for short clips: GIF files get large quickly.</small></div></label>
</div>
</div>
@@ -260,6 +263,50 @@
<div class="hint" id="exEncHint"></div>
</div>
<div id="exGifOpts">
<div class="grid2">
<div class="field">
<div class="label">Resolution</div>
<select id="exGifHeight">
<option value="0">Same as source</option>
<option value="720">720p</option>
<option value="480">480p</option>
<option value="360">360p</option>
<option value="240">240p</option>
</select>
</div>
<div class="field">
<div class="label">Frame rate</div>
<select id="exGifFPS">
<option value="10">10</option>
<option value="12">12</option>
<option value="15">15</option>
<option value="20">20</option>
<option value="25">25</option>
<option value="30">30</option>
</select>
</div>
</div>
<div class="grid2">
<div class="field">
<div class="label">Dithering</div>
<select id="exGifDither">
<option value="sierra2_4a">Smooth (best looking)</option>
<option value="bayer">Pattern (smaller file)</option>
<option value="none">None (smallest, may show bands)</option>
</select>
</div>
<div class="field">
<div class="label">Playback</div>
<select id="exGifLoop">
<option value="1">Loop forever</option>
<option value="0">Play once</option>
</select>
</div>
</div>
<div class="hint" id="exGifHint"></div>
</div>
<div id="exWarn" class="notice warn hidden"></div>
<div id="exProgress" class="progress hidden">
<div class="bar"><div id="exBar"></div></div>

View File

@@ -269,7 +269,7 @@ kbd {
.radios { display: flex; gap: 18px; flex-wrap: wrap; }
.radios label, .rcard { cursor: pointer; }
input[type=radio], input[type=checkbox] { accent-color: var(--accent); }
.cards2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.cards3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 10px; }
.rcard { display: flex; gap: 10px; align-items: flex-start; padding: 10px 12px; border: 1px solid var(--line2); border-radius: 8px; background: var(--panel2); }
.rcard:has(input:checked) { border-color: var(--accent); background: #18233a; }
.rcard input { margin-top: 3px; }