fix: Wails file drop, duplicate listeners, trim drag, crop stage, preview cache/pixfmt, NSIS temp quoting
Build Windows Installer / build (push) Successful in 8m46s

This commit is contained in:
m4xx1m committed 2026-11-12 18:04:53 +03:00
1 parent 65387c5dcd
commit 478dbff654
12 files changed
+563 -226

No files matched your search

-6
View File
@@ -64,12 +64,6 @@ func (a *App) startup(ctx context.Context) {
if err == nil { if err == nil {
a.previewSrv = srv a.previewSrv = srv
} }
runtime.OnFileDrop(ctx, func(x, y int, paths []string) {
if len(paths) > 0 {
runtime.EventsEmit(ctx, "app:file-dropped", paths[0])
}
})
} }
func (a *App) shutdown(_ context.Context) { func (a *App) shutdown(_ context.Context) {
+344 -208
View File
@@ -16,6 +16,7 @@ interface State {
trimEnd: number; trimEnd: number;
loopTrim: boolean; loopTrim: boolean;
previewMixAudio: boolean; previewMixAudio: boolean;
mutedPreview: boolean;
usingProxy: boolean; usingProxy: boolean;
encoder: string; encoder: string;
hwDecode: string; hwDecode: string;
@@ -50,6 +51,7 @@ const state: State = {
trimEnd: 0, trimEnd: 0,
loopTrim: true, loopTrim: true,
previewMixAudio: false, previewMixAudio: false,
mutedPreview: false,
usingProxy: false, usingProxy: false,
encoder: 'libx264', encoder: 'libx264',
hwDecode: 'none', hwDecode: 'none',
@@ -76,6 +78,19 @@ const state: State = {
cliText: '', cliText: '',
}; };
let dragTarget: 'seek' | 'start' | 'end' | null = null;
let cropDrag: {
mode: string;
startX: number;
startY: number;
origX: number;
origY: number;
origW: number;
origH: number;
} | null = null;
let playerResizeObserver: ResizeObserver | null = null;
let sourceLoadAbort: AbortController | null = null;
const appApi = () => window.go?.main?.App; const appApi = () => window.go?.main?.App;
const t = () => dict[state.lang]; const t = () => dict[state.lang];
@@ -83,15 +98,36 @@ function el<K extends keyof HTMLElementTagNameMap>(id: string): HTMLElementTagNa
return document.getElementById(id) as HTMLElementTagNameMap[K]; return document.getElementById(id) as HTMLElementTagNameMap[K];
} }
function fitVideoStage() {
const wrap = el<'div'>('playerWrap');
const stage = el<'div'>('videoStage');
const video = el<'video'>('videoEl');
if (!wrap || !stage || stage.classList.contains('hidden')) return;
const vw = video?.videoWidth || state.loaded?.info.width || 16;
const vh = video?.videoHeight || state.loaded?.info.height || 9;
if (vw <= 0 || vh <= 0) return;
const rect = wrap.getBoundingClientRect();
const availW = Math.max(1, rect.width - 2);
const availH = Math.max(1, rect.height - 2);
const scale = Math.min(availW / vw, availH / vh);
const fitW = Math.max(1, Math.floor(vw * scale));
const fitH = Math.max(1, Math.floor(vh * scale));
stage.style.width = `${fitW}px`;
stage.style.height = `${fitH}px`;
}
function renderShell() { function renderShell() {
const tr = t(); const tr = t();
const root = document.getElementById('app')!; const root = document.getElementById('app')!;
const verText = state.appInfo?.version ? `v${state.appInfo.version}` : 'v2.0';
root.innerHTML = ` root.innerHTML = `
<header class="topbar"> <header class="topbar">
<div class="brand"> <div class="brand">
<div class="brand-icon">▶</div> <div class="brand-icon">▶</div>
<span class="brand-title">ConvertGUI</span> <span class="brand-title">ConvertGUI</span>
<span class="badge" id="verBadge">v1.0</span> <span class="badge" id="verBadge">${verText}</span>
</div> </div>
<div class="topbar-center"> <div class="topbar-center">
<button id="openBtn" class="primary">📂 ${tr.openVideo}</button> <button id="openBtn" class="primary">📂 ${tr.openVideo}</button>
@@ -385,7 +421,9 @@ function syncControlsFromState() {
const video = el<'video'>('videoEl'); const video = el<'video'>('videoEl');
if (video) { if (video) {
video.playbackRate = Math.max(0.25, Math.min(8, state.speed)); const rate = Math.max(0.25, Math.min(8, state.speed));
video.defaultPlaybackRate = rate;
video.playbackRate = rate;
} }
el<'input'>('cropCheck').checked = state.cropEnabled; el<'input'>('cropCheck').checked = state.cropEnabled;
@@ -395,6 +433,7 @@ function syncControlsFromState() {
el<'input'>('cropY').value = String(state.cropY); el<'input'>('cropY').value = String(state.cropY);
el<'input'>('cropW').value = String(state.cropW); el<'input'>('cropW').value = String(state.cropW);
el<'input'>('cropH').value = String(state.cropH); el<'input'>('cropH').value = String(state.cropH);
fitVideoStage();
updateCropBoxVisual(); updateCropBoxVisual();
renderAudioSection(); renderAudioSection();
@@ -432,6 +471,10 @@ function renderAudioSection() {
return; return;
} }
if (state.audioMode === 'merge' && state.selectedTracks.length === 0) {
state.selectedTracks = tracks.map((a) => a.audioOrder);
}
container.innerHTML = tracks container.innerHTML = tracks
.map((s) => { .map((s) => {
const label = [ const label = [
@@ -444,8 +487,7 @@ function renderAudioSection() {
.join(' · '); .join(' · ');
if (state.audioMode === 'merge') { if (state.audioMode === 'merge') {
const checked = const checked = state.selectedTracks.includes(s.audioOrder);
state.selectedTracks.length === 0 || state.selectedTracks.includes(s.audioOrder);
return `<label class="track-item"><input type="checkbox" data-atrack-check="${s.audioOrder}" ${checked ? 'checked' : ''} /> <span>${label}</span></label>`; return `<label class="track-item"><input type="checkbox" data-atrack-check="${s.audioOrder}" ${checked ? 'checked' : ''} /> <span>${label}</span></label>`;
} }
const checked = state.audioTrack === s.audioOrder; const checked = state.audioTrack === s.audioOrder;
@@ -460,6 +502,11 @@ function renderAudioSection() {
if (box.checked) next.push(Number(box.dataset.atrackCheck)); if (box.checked) next.push(Number(box.dataset.atrackCheck));
}); });
state.selectedTracks = next; state.selectedTracks = next;
if (next.length === 0) {
state.audioMode = 'mute';
syncControlsFromState();
return;
}
void refreshCLI(); void refreshCLI();
}); });
}); });
@@ -588,42 +635,75 @@ async function loadVideoFile(path: string) {
state.selectedTracks = [0]; state.selectedTracks = [0];
} }
// Update UI header & enable controls applyLoadedUI(res, 0, false, true);
el<'div'>('filePill').classList.remove('hidden');
el<'strong'>('fileName').textContent = res.info.fileName;
el<'span'>('fileMeta').textContent = `${res.info.width}×${res.info.height} · ${res.info.fps ? res.info.fps.toFixed(0) + 'fps' : '?'} · ${res.info.videoCodec.toUpperCase()} · ${formatBytes(res.info.sizeBytes)}`;
el<'div'>('dropOverlay').classList.add('hidden');
el<'div'>('videoStage').classList.remove('hidden');
el<'button'>('playPauseBtn').disabled = false;
el<'button'>('stepBackBtn').disabled = false;
el<'button'>('stepFwdBtn').disabled = false;
el<'button'>('setStartBtn').disabled = false;
el<'button'>('setEndBtn').disabled = false;
el<'button'>('resetTrimBtn').disabled = false;
el<'input'>('trimStartInput').disabled = false;
el<'input'>('trimEndInput').disabled = false;
el<'button'>('convertBtn').disabled = false;
const mixBtn = el<'button'>('previewMixBtn');
mixBtn.classList.toggle('hidden', res.info.audioStreams.length < 2);
mixBtn.classList.remove('pill-active');
loadFilmstrip(res);
setPlayerSource(res, false);
syncControlsFromState();
updatePlayhead(0);
} catch (err) { } catch (err) {
showError(String(err)); showError(String(err));
} }
} }
function setPlayerSource(res: LoadedVideo, forceProxy: boolean) { function applyLoadedUI(
res: LoadedVideo,
seekSec: number,
autoPlay: boolean,
reloadSource: boolean
) {
el<'div'>('filePill').classList.remove('hidden');
el<'strong'>('fileName').textContent = res.info.fileName;
el<'span'>('fileMeta').textContent = `${res.info.width}×${res.info.height} · ${res.info.fps ? res.info.fps.toFixed(0) + 'fps' : '?'} · ${res.info.videoCodec.toUpperCase()} · ${formatBytes(res.info.sizeBytes)}`;
el<'div'>('dropOverlay').classList.add('hidden');
el<'div'>('videoStage').classList.remove('hidden');
el<'button'>('playPauseBtn').disabled = false;
el<'button'>('stepBackBtn').disabled = false;
el<'button'>('stepFwdBtn').disabled = false;
el<'button'>('setStartBtn').disabled = false;
el<'button'>('setEndBtn').disabled = false;
el<'button'>('resetTrimBtn').disabled = false;
el<'input'>('trimStartInput').disabled = false;
el<'input'>('trimEndInput').disabled = false;
el<'button'>('convertBtn').disabled = state.converting;
el<'button'>('loopBtn').classList.toggle('pill-active', state.loopTrim);
const mixBtn = el<'button'>('previewMixBtn');
mixBtn.classList.toggle('hidden', res.info.audioStreams.length < 2);
mixBtn.classList.toggle('pill-active', state.previewMixAudio);
el<'button'>('mutePreviewBtn').textContent = state.mutedPreview ? '🔇' : '🔊';
el<'pre'>('cliBox').classList.toggle('hidden', !state.cliExpanded);
el<'button'>('copyCliBtn').classList.toggle('hidden', !state.cliExpanded);
if (state.converting) {
el<'button'>('cancelBtn').classList.remove('hidden');
el<'div'>('progressWrap').classList.remove('hidden');
if (state.progress) renderProgress(state.progress);
}
if (state.donePath && !state.converting) {
renderDoneBanner();
}
if (state.errorMsg) {
showError(state.errorMsg);
}
loadFilmstrip(res);
fitVideoStage();
if (reloadSource) {
setPlayerSource(res, state.usingProxy, seekSec, autoPlay);
}
syncControlsFromState();
updatePlayhead(seekSec);
}
function setPlayerSource(
res: LoadedVideo,
forceProxy: boolean,
seekSec = 0,
autoPlay = false
) {
const video = el<'video'>('videoEl'); const video = el<'video'>('videoEl');
const banner = el<'div'>('proxyBanner'); const banner = el<'div'>('proxyBanner');
let src = res.previewUrl; let src = res.previewUrl;
let needsProxy = forceProxy || !res.info.browserNative || state.previewMixAudio; const needsProxy = forceProxy || !res.info.browserNative || state.previewMixAudio;
if (needsProxy) { if (needsProxy) {
src = state.previewMixAudio ? res.proxyMergedUrl : res.proxyUrl; src = state.previewMixAudio ? res.proxyMergedUrl : res.proxyUrl;
state.usingProxy = true; state.usingProxy = true;
@@ -633,8 +713,33 @@ function setPlayerSource(res: LoadedVideo, forceProxy: boolean) {
banner.classList.add('hidden'); banner.classList.add('hidden');
} }
const rate = Math.max(0.25, Math.min(8, state.speed));
video.muted = state.mutedPreview;
video.defaultPlaybackRate = rate;
video.playbackRate = rate;
sourceLoadAbort?.abort();
sourceLoadAbort = new AbortController();
video.addEventListener(
'loadedmetadata',
() => {
const r = Math.max(0.25, Math.min(8, state.speed));
video.defaultPlaybackRate = r;
video.playbackRate = r;
video.muted = state.mutedPreview;
fitVideoStage();
if (seekSec > 0 && Number.isFinite(seekSec)) {
video.currentTime = Math.min(seekSec, res.info.duration || seekSec);
}
if (autoPlay) {
void video.play();
}
},
{ once: true, signal: sourceLoadAbort.signal }
);
video.src = src; video.src = src;
video.playbackRate = state.speed;
video.load(); video.load();
} }
@@ -670,19 +775,185 @@ function updateCropBoxVisual() {
function showError(msg: string) { function showError(msg: string) {
state.errorMsg = msg; state.errorMsg = msg;
const b = el<'div'>('errorBanner'); const b = el<'div'>('errorBanner');
b.textContent = msg; if (b) {
b.classList.remove('hidden'); b.textContent = msg;
b.classList.remove('hidden');
}
}
function renderProgress(p: ProgressEvent) {
el<'div'>('progressFill').style.width = `${p.percent.toFixed(1)}%`;
el<'span'>('progressLeft').textContent = `${p.percent.toFixed(1)}% · ${p.speed || '-'} · ${p.fps ? p.fps.toFixed(0) + 'fps' : ''}`;
const etaStr = p.etaSec > 0 ? `${t().eta}: ${formatTimecode(p.etaSec, false)}` : '';
const sizeStr = p.totalSize > 0 ? formatBytes(p.totalSize) : '';
el<'span'>('progressRight').textContent = [sizeStr, etaStr].filter(Boolean).join(' · ');
}
function renderDoneBanner() {
const srcBytes = state.loaded?.info.sizeBytes ?? 0;
const outBytes = state.doneBytes;
let stat = formatBytes(outBytes);
if (srcBytes > 0 && outBytes > 0) {
const diffPct = Math.round(((outBytes - srcBytes) / srcBytes) * 100);
stat = `${formatBytes(srcBytes)} → ${formatBytes(outBytes)} (${diffPct > 0 ? '+' : ''}${diffPct}%)`;
}
el<'span'>('doneStats').textContent = stat;
el<'div'>('doneBanner').classList.remove('hidden');
}
function togglePlay() {
const video = el<'video'>('videoEl');
if (!state.loaded || !video) return;
if (video.paused) {
if (state.loopTrim && (video.currentTime < state.trimStart || video.currentTime >= state.trimEnd)) {
video.currentTime = state.trimStart;
}
void video.play();
} else {
video.pause();
}
}
function clientXToTime(clientX: number): number {
const track = el<'div'>('timelineTrack');
const rect = track.getBoundingClientRect();
if (rect.width <= 0) return 0;
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
return ratio * (state.loaded?.info.duration ?? 0);
}
function bindGlobalEvents() {
window.addEventListener('pointermove', (e) => {
if (dragTarget && state.loaded) {
const video = el<'video'>('videoEl');
const dur = state.loaded.info.duration;
const minGap = Math.min(0.05, dur);
const tSec = clientXToTime(e.clientX);
if (dragTarget === 'seek') {
video.currentTime = tSec;
updatePlayhead(tSec);
} else if (dragTarget === 'start') {
const maxStart = Math.max(0, state.trimEnd - minGap);
const nextStart = Math.max(0, Math.min(tSec, maxStart));
state.trimStart = nextStart;
video.currentTime = nextStart;
updateTimelineVisuals();
updatePlayhead(nextStart);
} else if (dragTarget === 'end') {
const minEnd = Math.min(dur, state.trimStart + minGap);
const nextEnd = Math.min(dur, Math.max(tSec, minEnd));
state.trimEnd = nextEnd;
video.currentTime = nextEnd;
updateTimelineVisuals();
updatePlayhead(nextEnd);
}
}
if (cropDrag && state.loaded) {
const stage = el<'div'>('videoStage').getBoundingClientRect();
if (stage.width <= 0 || stage.height <= 0) return;
const iw = state.loaded.info.width;
const ih = state.loaded.info.height;
const dx = Math.round((((e.clientX - cropDrag.startX) / stage.width) * iw) / 2) * 2;
const dy = Math.round((((e.clientY - cropDrag.startY) / stage.height) * ih) / 2) * 2;
if (cropDrag.mode === 'move') {
state.cropX = Math.max(0, Math.min(iw - cropDrag.origW, cropDrag.origX + dx)) & ~1;
state.cropY = Math.max(0, Math.min(ih - cropDrag.origH, cropDrag.origY + dy)) & ~1;
} else {
let nx = cropDrag.origX;
let ny = cropDrag.origY;
let nw = cropDrag.origW;
let nh = cropDrag.origH;
if (cropDrag.mode.includes('e')) nw = Math.max(16, Math.min(iw - nx, cropDrag.origW + dx));
if (cropDrag.mode.includes('s')) nh = Math.max(16, Math.min(ih - ny, cropDrag.origH + dy));
if (cropDrag.mode.includes('w')) {
const maxShift = cropDrag.origW - 16;
const shift = Math.max(-cropDrag.origX, Math.min(maxShift, dx));
nx = cropDrag.origX + shift;
nw = cropDrag.origW - shift;
}
if (cropDrag.mode.includes('n')) {
const maxShift = cropDrag.origH - 16;
const shift = Math.max(-cropDrag.origY, Math.min(maxShift, dy));
ny = cropDrag.origY + shift;
nh = cropDrag.origH - shift;
}
state.cropX = nx & ~1;
state.cropY = ny & ~1;
state.cropW = Math.max(16, nw & ~1);
state.cropH = Math.max(16, nh & ~1);
}
syncControlsFromState();
}
});
window.addEventListener('pointerup', () => {
if (dragTarget === 'start' || dragTarget === 'end') {
void refreshCLI();
}
dragTarget = null;
cropDrag = null;
});
window.addEventListener('resize', () => {
fitVideoStage();
});
window.addEventListener('keydown', (e) => {
const tag = (e.target as HTMLElement)?.tagName;
if (tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA') return;
if (!state.loaded) return;
const video = el<'video'>('videoEl');
if (e.code === 'Space') {
e.preventDefault();
togglePlay();
} else if (e.code === 'ArrowLeft') {
e.preventDefault();
video.pause();
video.currentTime = Math.max(0, video.currentTime - (e.shiftKey ? 1 : 0.1));
} else if (e.code === 'ArrowRight') {
e.preventDefault();
video.pause();
video.currentTime = Math.min(state.loaded.info.duration, video.currentTime + (e.shiftKey ? 1 : 0.1));
} else if (e.code === 'KeyI') {
e.preventDefault();
el<'button'>('setStartBtn').click();
} else if (e.code === 'KeyO') {
e.preventDefault();
el<'button'>('setEndBtn').click();
}
});
} }
function bindEvents() { function bindEvents() {
const api = appApi(); const api = appApi();
if (playerResizeObserver) {
playerResizeObserver.disconnect();
playerResizeObserver = null;
}
const playerWrap = el<'div'>('playerWrap');
const dropOverlay = el<'div'>('dropOverlay');
if (typeof ResizeObserver !== 'undefined') {
playerResizeObserver = new ResizeObserver(() => fitVideoStage());
playerResizeObserver.observe(playerWrap);
}
playerWrap.addEventListener('dragenter', () => dropOverlay.classList.add('drag-over'));
playerWrap.addEventListener('dragleave', (e) => {
if (!playerWrap.contains(e.relatedTarget as Node | null)) {
dropOverlay.classList.remove('drag-over');
}
});
playerWrap.addEventListener('drop', () => dropOverlay.classList.remove('drag-over'));
el<'button'>('openBtn').addEventListener('click', async () => { el<'button'>('openBtn').addEventListener('click', async () => {
const path = await api?.OpenVideoDialog(); const path = await api?.OpenVideoDialog();
if (path) await loadVideoFile(path); if (path) await loadVideoFile(path);
}); });
el<'div'>('dropOverlay').addEventListener('click', async () => { dropOverlay.addEventListener('click', async () => {
const path = await api?.OpenVideoDialog(); const path = await api?.OpenVideoDialog();
if (path) await loadVideoFile(path); if (path) await loadVideoFile(path);
}); });
@@ -696,6 +967,9 @@ function bindEvents() {
}); });
el<'button'>('langBtn').addEventListener('click', async () => { el<'button'>('langBtn').addEventListener('click', async () => {
const prevVideo = el<'video'>('videoEl');
const prevTime = prevVideo?.currentTime || 0;
const wasPlaying = prevVideo ? !prevVideo.paused : false;
state.lang = state.lang === 'ru' ? 'en' : 'ru'; state.lang = state.lang === 'ru' ? 'en' : 'ru';
if (state.appInfo) { if (state.appInfo) {
state.appInfo.config.language = state.lang; state.appInfo.config.language = state.lang;
@@ -704,25 +978,9 @@ function bindEvents() {
const prevLoaded = state.loaded; const prevLoaded = state.loaded;
renderShell(); renderShell();
if (prevLoaded) { if (prevLoaded) {
state.loaded = prevLoaded; applyLoadedUI(prevLoaded, prevTime, wasPlaying, true);
el<'div'>('filePill').classList.remove('hidden'); } else {
el<'strong'>('fileName').textContent = prevLoaded.info.fileName; if (state.errorMsg) showError(state.errorMsg);
el<'span'>('fileMeta').textContent = `${prevLoaded.info.width}×${prevLoaded.info.height} · ${prevLoaded.info.fps ? prevLoaded.info.fps.toFixed(0) + 'fps' : '?'} · ${prevLoaded.info.videoCodec.toUpperCase()} · ${formatBytes(prevLoaded.info.sizeBytes)}`;
el<'div'>('dropOverlay').classList.add('hidden');
el<'div'>('videoStage').classList.remove('hidden');
el<'button'>('playPauseBtn').disabled = false;
el<'button'>('stepBackBtn').disabled = false;
el<'button'>('stepFwdBtn').disabled = false;
el<'button'>('setStartBtn').disabled = false;
el<'button'>('setEndBtn').disabled = false;
el<'button'>('resetTrimBtn').disabled = false;
el<'input'>('trimStartInput').disabled = false;
el<'input'>('trimEndInput').disabled = false;
el<'button'>('convertBtn').disabled = false;
el<'button'>('previewMixBtn').classList.toggle('hidden', prevLoaded.info.audioStreams.length < 2);
loadFilmstrip(prevLoaded);
setPlayerSource(prevLoaded, state.usingProxy);
syncControlsFromState();
} }
}); });
@@ -730,6 +988,8 @@ function bindEvents() {
const video = el<'video'>('videoEl'); const video = el<'video'>('videoEl');
video.addEventListener('canplay', () => { video.addEventListener('canplay', () => {
el<'div'>('proxyBanner').classList.add('hidden'); el<'div'>('proxyBanner').classList.add('hidden');
const r = Math.max(0.25, Math.min(8, state.speed));
video.playbackRate = r;
}); });
video.addEventListener('error', () => { video.addEventListener('error', () => {
if (state.loaded && !state.usingProxy) { if (state.loaded && !state.usingProxy) {
@@ -746,17 +1006,6 @@ function bindEvents() {
}); });
// Transport // Transport
const togglePlay = () => {
if (!state.loaded) return;
if (video.paused) {
if (state.loopTrim && (video.currentTime < state.trimStart || video.currentTime >= state.trimEnd)) {
video.currentTime = state.trimStart;
}
void video.play();
} else {
video.pause();
}
};
el<'button'>('playPauseBtn').addEventListener('click', togglePlay); el<'button'>('playPauseBtn').addEventListener('click', togglePlay);
el<'button'>('stepBackBtn').addEventListener('click', () => { el<'button'>('stepBackBtn').addEventListener('click', () => {
video.pause(); video.pause();
@@ -791,30 +1040,17 @@ function bindEvents() {
state.previewMixAudio = !state.previewMixAudio; state.previewMixAudio = !state.previewMixAudio;
el<'button'>('previewMixBtn').classList.toggle('pill-active', state.previewMixAudio); el<'button'>('previewMixBtn').classList.toggle('pill-active', state.previewMixAudio);
const cur = video.currentTime; const cur = video.currentTime;
setPlayerSource(state.loaded, state.previewMixAudio); const wasPlaying = !video.paused;
video.addEventListener( setPlayerSource(state.loaded, state.previewMixAudio, cur, wasPlaying);
'loadedmetadata',
() => {
video.currentTime = cur;
},
{ once: true }
);
}); });
el<'button'>('mutePreviewBtn').addEventListener('click', () => { el<'button'>('mutePreviewBtn').addEventListener('click', () => {
video.muted = !video.muted; state.mutedPreview = !state.mutedPreview;
el<'button'>('mutePreviewBtn').textContent = video.muted ? '🔇' : '🔊'; video.muted = state.mutedPreview;
el<'button'>('mutePreviewBtn').textContent = state.mutedPreview ? '🔇' : '🔊';
}); });
// Timeline drag (handles + seek) // Timeline drag (handles + seek)
const track = el<'div'>('timelineTrack'); const track = el<'div'>('timelineTrack');
let dragTarget: 'seek' | 'start' | 'end' | null = null;
const clientXToTime = (clientX: number): number => {
const rect = track.getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
return ratio * (state.loaded?.info.duration ?? 0);
};
el<'div'>('handleStart').addEventListener('pointerdown', (e) => { el<'div'>('handleStart').addEventListener('pointerdown', (e) => {
e.stopPropagation(); e.stopPropagation();
dragTarget = 'start'; dragTarget = 'start';
@@ -831,34 +1067,6 @@ function bindEvents() {
updatePlayhead(tSec); updatePlayhead(tSec);
}); });
window.addEventListener('pointermove', (e) => {
if (!dragTarget || !state.loaded) return;
const dur = state.loaded.info.duration;
const tSec = clientXToTime(e.clientX);
if (dragTarget === 'seek') {
video.currentTime = tSec;
updatePlayhead(tSec);
} else if (dragTarget === 'start') {
const clamped = clampTrimRange(tSec, state.trimEnd, dur);
state.trimStart = clamped.start;
video.currentTime = clamped.start;
updateTimelineVisuals();
updatePlayhead(clamped.start);
} else if (dragTarget === 'end') {
const clamped = clampTrimRange(state.trimStart, tSec, dur);
state.trimEnd = clamped.end;
video.currentTime = clamped.end;
updateTimelineVisuals();
updatePlayhead(clamped.end);
}
});
window.addEventListener('pointerup', () => {
if (dragTarget === 'start' || dragTarget === 'end') {
void refreshCLI();
}
dragTarget = null;
});
// Trim manual inputs // Trim manual inputs
el<'input'>('trimStartInput').addEventListener('change', () => { el<'input'>('trimStartInput').addEventListener('change', () => {
if (!state.loaded) return; if (!state.loaded) return;
@@ -992,7 +1200,6 @@ function bindEvents() {
}); });
// Visual crop drag // Visual crop drag
let cropDrag: { mode: string; startX: number; startY: number; origX: number; origY: number; origW: number; origH: number } | null = null;
el<'div'>('cropBox').addEventListener('pointerdown', (e) => { el<'div'>('cropBox').addEventListener('pointerdown', (e) => {
if (!state.loaded) return; if (!state.loaded) return;
e.stopPropagation(); e.stopPropagation();
@@ -1008,47 +1215,6 @@ function bindEvents() {
origH: state.cropH, origH: state.cropH,
}; };
}); });
window.addEventListener('pointermove', (e) => {
if (!cropDrag || !state.loaded) return;
const stage = el<'div'>('videoStage').getBoundingClientRect();
if (stage.width <= 0 || stage.height <= 0) return;
const iw = state.loaded.info.width;
const ih = state.loaded.info.height;
const dx = Math.round(((e.clientX - cropDrag.startX) / stage.width) * iw / 2) * 2;
const dy = Math.round(((e.clientY - cropDrag.startY) / stage.height) * ih / 2) * 2;
if (cropDrag.mode === 'move') {
state.cropX = Math.max(0, Math.min(iw - cropDrag.origW, cropDrag.origX + dx)) & ~1;
state.cropY = Math.max(0, Math.min(ih - cropDrag.origH, cropDrag.origY + dy)) & ~1;
} else {
let nx = cropDrag.origX;
let ny = cropDrag.origY;
let nw = cropDrag.origW;
let nh = cropDrag.origH;
if (cropDrag.mode.includes('e')) nw = Math.max(16, Math.min(iw - nx, cropDrag.origW + dx));
if (cropDrag.mode.includes('s')) nh = Math.max(16, Math.min(ih - ny, cropDrag.origH + dy));
if (cropDrag.mode.includes('w')) {
const maxShift = cropDrag.origW - 16;
const shift = Math.max(-cropDrag.origX, Math.min(maxShift, dx));
nx = cropDrag.origX + shift;
nw = cropDrag.origW - shift;
}
if (cropDrag.mode.includes('n')) {
const maxShift = cropDrag.origH - 16;
const shift = Math.max(-cropDrag.origY, Math.min(maxShift, dy));
ny = cropDrag.origY + shift;
nh = cropDrag.origH - shift;
}
state.cropX = nx & ~1;
state.cropY = ny & ~1;
state.cropW = Math.max(16, nw & ~1);
state.cropH = Math.max(16, nh & ~1);
}
syncControlsFromState();
});
window.addEventListener('pointerup', () => {
cropDrag = null;
});
// Output & CLI // Output & CLI
el<'input'>('outputInput').addEventListener('input', (e) => { el<'input'>('outputInput').addEventListener('input', (e) => {
@@ -1084,6 +1250,8 @@ function bindEvents() {
if (!state.loaded || state.converting) return; if (!state.loaded || state.converting) return;
state.errorMsg = ''; state.errorMsg = '';
state.donePath = ''; state.donePath = '';
state.doneBytes = 0;
state.progress = null;
el<'div'>('errorBanner').classList.add('hidden'); el<'div'>('errorBanner').classList.add('hidden');
el<'div'>('doneBanner').classList.add('hidden'); el<'div'>('doneBanner').classList.add('hidden');
state.converting = true; state.converting = true;
@@ -1115,50 +1283,25 @@ function bindEvents() {
el<'button'>('playOutBtn').addEventListener('click', () => { el<'button'>('playOutBtn').addEventListener('click', () => {
if (state.donePath) void api?.OpenOutputFile(state.donePath); if (state.donePath) void api?.OpenOutputFile(state.donePath);
}); });
// Keyboard shortcuts
window.addEventListener('keydown', (e) => {
const tag = (e.target as HTMLElement)?.tagName;
if (tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA') return;
if (!state.loaded) return;
if (e.code === 'Space') {
e.preventDefault();
togglePlay();
} else if (e.code === 'ArrowLeft') {
e.preventDefault();
video.pause();
video.currentTime = Math.max(0, video.currentTime - (e.shiftKey ? 1 : 0.1));
} else if (e.code === 'ArrowRight') {
e.preventDefault();
video.pause();
video.currentTime = Math.min(state.loaded.info.duration, video.currentTime + (e.shiftKey ? 1 : 0.1));
} else if (e.code === 'KeyI') {
e.preventDefault();
el<'button'>('setStartBtn').click();
} else if (e.code === 'KeyO') {
e.preventDefault();
el<'button'>('setEndBtn').click();
}
});
} }
function setupWailsEvents() { function setupWailsEvents() {
const rt = window.runtime; const rt = window.runtime;
if (!rt) return; if (!rt) return;
rt.EventsOn('app:file-dropped', (filePath: string) => { if (rt.OnFileDrop) {
void loadVideoFile(filePath); rt.OnFileDrop((_x, _y, paths) => {
}); el<'div'>('dropOverlay')?.classList.remove('drag-over');
if (paths && paths.length > 0) {
void loadVideoFile(paths[0]);
}
}, false);
}
rt.EventsOn('convert:progress', (p: ProgressEvent) => { rt.EventsOn('convert:progress', (p: ProgressEvent) => {
state.progress = p; state.progress = p;
state.doneBytes = p.outputBytes || p.totalSize || state.doneBytes; state.doneBytes = p.outputBytes || p.totalSize || state.doneBytes;
el<'div'>('progressFill').style.width = `${p.percent.toFixed(1)}%`; renderProgress(p);
el<'span'>('progressLeft').textContent = `${p.percent.toFixed(1)}% · ${p.speed || '-'} · ${p.fps ? p.fps.toFixed(0) + 'fps' : ''}`;
const etaStr = p.etaSec > 0 ? `${t().eta}: ${formatTimecode(p.etaSec, false)}` : '';
const sizeStr = p.totalSize > 0 ? formatBytes(p.totalSize) : '';
el<'span'>('progressRight').textContent = [sizeStr, etaStr].filter(Boolean).join(' · ');
}); });
rt.EventsOn('convert:done', (outPath: string) => { rt.EventsOn('convert:done', (outPath: string) => {
@@ -1167,16 +1310,7 @@ function setupWailsEvents() {
el<'button'>('convertBtn').disabled = false; el<'button'>('convertBtn').disabled = false;
el<'button'>('cancelBtn').classList.add('hidden'); el<'button'>('cancelBtn').classList.add('hidden');
el<'div'>('progressWrap').classList.add('hidden'); el<'div'>('progressWrap').classList.add('hidden');
renderDoneBanner();
const srcBytes = state.loaded?.info.sizeBytes ?? 0;
const outBytes = state.doneBytes;
let stat = formatBytes(outBytes);
if (srcBytes > 0 && outBytes > 0) {
const diffPct = Math.round(((outBytes - srcBytes) / srcBytes) * 100);
stat = `${formatBytes(srcBytes)} → ${formatBytes(outBytes)} (${diffPct > 0 ? '+' : ''}${diffPct}%)`;
}
el<'span'>('doneStats').textContent = stat;
el<'div'>('doneBanner').classList.remove('hidden');
}); });
rt.EventsOn('convert:error', (errMsg: string) => { rt.EventsOn('convert:error', (errMsg: string) => {
@@ -1189,10 +1323,12 @@ function setupWailsEvents() {
} }
async function init() { async function init() {
renderShell(); bindGlobalEvents();
setupWailsEvents(); setupWailsEvents();
const api = appApi(); const api = appApi();
let initialFile = '';
let initErr = '';
if (api) { if (api) {
try { try {
const info = await api.GetAppInfo(); const info = await api.GetAppInfo();
@@ -1206,19 +1342,19 @@ async function init() {
state.crf = info.config.defaultCrf ?? 20; state.crf = info.config.defaultCrf ?? 20;
state.fps = info.config.defaultFps ?? 60; state.fps = info.config.defaultFps ?? 60;
state.tune = info.config.defaultTune || 'film'; state.tune = info.config.defaultTune || 'film';
initialFile = info.initialFile;
renderShell();
el<'span'>('verBadge').textContent = `v${info.version}`;
updateFFmpegBadge();
syncControlsFromState();
if (info.initialFile) {
await loadVideoFile(info.initialFile);
}
} catch (err) { } catch (err) {
showError(String(err)); initErr = String(err);
} }
} }
renderShell();
if (initErr) {
showError(initErr);
}
if (initialFile) {
await loadVideoFile(initialFile);
}
} }
void init(); void init();
+4 -3
View File
@@ -224,15 +224,16 @@ button.pill-active {
.video-stage { .video-stage {
position: relative; position: relative;
display: inline-flex; display: block;
max-width: 100%; max-width: 100%;
max-height: 100%; max-height: 100%;
overflow: hidden;
} }
video { video {
display: block; display: block;
max-width: 100%; width: 100%;
max-height: 100%; height: 100%;
object-fit: contain; object-fit: contain;
background: #000; background: #000;
} }
+2
View File
@@ -127,6 +127,8 @@ declare global {
}; };
runtime?: { runtime?: {
EventsOn(eventName: string, callback: (...args: any[]) => void): () => void; EventsOn(eventName: string, callback: (...args: any[]) => void): () => void;
OnFileDrop?(callback: (x: number, y: number, paths: string[]) => void, useDropTarget?: boolean): void;
OnFileDropOff?(): void;
}; };
} }
} }
+1 -1
View File
@@ -70,7 +70,7 @@ Section "ConvertGUI Application (Required)" SEC_APP
ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv" ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
StrCmp $0 "" 0 webview2_done StrCmp $0 "" 0 webview2_done
DetailPrint "Installing Microsoft Edge WebView2 Runtime..." DetailPrint "Installing Microsoft Edge WebView2 Runtime..."
nsExec::ExecToLog 'powershell -NoProfile -ExecutionPolicy Bypass -Command "Invoke-WebRequest -Uri https://go.microsoft.com/fwlink/p/?LinkId=2124703 -OutFile $env:TEMP\wv2setup.exe; Start-Process $env:TEMP\wv2setup.exe -ArgumentList /silent,/install -Wait; Remove-Item $env:TEMP\wv2setup.exe -ErrorAction SilentlyContinue"' nsExec::ExecToLog 'powershell -NoProfile -ExecutionPolicy Bypass -Command "Invoke-WebRequest -Uri https://go.microsoft.com/fwlink/p/?LinkId=2124703 -OutFile $\'$TEMP\wv2setup.exe$\'; Start-Process $\'$TEMP\wv2setup.exe$\' -ArgumentList $\'/silent$\',$\'/install$\' -Wait; Remove-Item $\'$TEMP\wv2setup.exe$\' -ErrorAction SilentlyContinue"'
webview2_done: webview2_done:
SectionEnd SectionEnd
+1 -1
View File
@@ -216,7 +216,7 @@ func BuildCommand(req Request, info *media.VideoInfo) (*BuiltCommand, error) {
postArgs = append(postArgs, "-an") postArgs = append(postArgs, "-an")
case "merge": case "merge":
tracks := req.SelectedTracks tracks := req.SelectedTracks
if len(tracks) == 0 { if tracks == nil {
for i := 0; i < numAudio; i++ { for i := 0; i < numAudio; i++ {
tracks = append(tracks, i) tracks = append(tracks, i)
} }
+78
View File
@@ -198,3 +198,81 @@ func TestIntegration_RealFFmpegConvert(t *testing.T) {
t.Fatalf("expected ~1.0s duration after 2s clip @ 2x speed, got %v", outInfo.Duration) t.Fatalf("expected ~1.0s duration after 2s clip @ 2x speed, got %v", outInfo.Duration)
} }
} }
func TestBuildCommand_EmptySelectedTracksMutes(t *testing.T) {
info := &media.VideoInfo{
Path: `C:\videos\clip.mp4`,
Duration: 10,
Width: 1280,
Height: 720,
AudioStreams: []media.AudioStream{
{Index: 1, AudioOrder: 0, Codec: "aac"},
{Index: 2, AudioOrder: 1, Codec: "aac"},
},
}
built, err := BuildCommand(Request{
InputPath: `C:\videos\clip.mp4`,
Encoder: "libx264",
Preset: "ultrafast",
CRF: 23,
Speed: 1.0,
AudioMode: "merge",
SelectedTracks: []int{},
}, info)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
joined := strings.Join(built.Args, " ")
if !strings.Contains(joined, " -an ") {
t.Fatalf("expected -an when SelectedTracks is explicit empty slice, got: %s", joined)
}
}
func TestRunner_NoClobberPreservesExistingOutput(t *testing.T) {
bins := media.FindBinaries("", "")
if bins.FFmpeg == "" {
t.Skip("ffmpeg not installed on host")
}
tmpDir := t.TempDir()
src := filepath.Join(tmpDir, "source.mp4")
dst := filepath.Join(tmpDir, "existing.mp4")
gen := exec.Command(bins.FFmpeg,
"-hide_banner", "-loglevel", "error",
"-f", "lavfi", "-i", "testsrc=duration=1:size=320x180:rate=30",
"-c:v", "libx264", "-preset", "ultrafast", "-y", src,
)
if out, err := gen.CombinedOutput(); err != nil {
t.Fatalf("failed generating test video: %v (%s)", err, string(out))
}
sentinel := []byte("keep-me-intact")
if err := os.WriteFile(dst, sentinel, 0o644); err != nil {
t.Fatalf("write sentinel failed: %v", err)
}
info := &media.VideoInfo{Path: src, Duration: 1, Width: 320, Height: 180}
built, err := BuildCommand(Request{
InputPath: src,
OutputPath: dst,
Encoder: "libx264",
Preset: "ultrafast",
CRF: 24,
Speed: 1.0,
AudioMode: "mute",
Overwrite: false,
}, info)
if err != nil {
t.Fatalf("BuildCommand failed: %v", err)
}
runner := NewRunner()
if err := runner.Run(context.Background(), bins.FFmpeg, built, nil); err == nil {
t.Fatalf("expected error when Overwrite=false and destination exists")
}
got, err := os.ReadFile(dst)
if err != nil || string(got) != string(sentinel) {
t.Fatalf("existing destination file was modified or deleted! err=%v got=%q", err, string(got))
}
}
+19 -2
View File
@@ -99,6 +99,19 @@ func (r *Runner) Run(
var stderrBuf bytes.Buffer var stderrBuf bytes.Buffer
cmd.Stderr = &limitedBuffer{buf: &stderrBuf, max: 32 * 1024} cmd.Stderr = &limitedBuffer{buf: &stderrBuf, max: 32 * 1024}
_, statErr := os.Stat(built.OutputPath)
existedBefore := statErr == nil
hasNoClobber := false
for _, a := range built.Args {
if a == "-n" {
hasNoClobber = true
break
}
}
if existedBefore && hasNoClobber {
return fmt.Errorf("output file already exists: %s", built.OutputPath)
}
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
return fmt.Errorf("failed to start ffmpeg: %w", err) return fmt.Errorf("failed to start ffmpeg: %w", err)
} }
@@ -114,11 +127,15 @@ func (r *Runner) Run(
waitErr := cmd.Wait() waitErr := cmd.Wait()
if ctx.Err() != nil { if ctx.Err() != nil {
_ = os.Remove(built.OutputPath) if !(existedBefore && hasNoClobber) {
_ = os.Remove(built.OutputPath)
}
return fmt.Errorf("conversion cancelled") return fmt.Errorf("conversion cancelled")
} }
if waitErr != nil { if waitErr != nil {
_ = os.Remove(built.OutputPath) if !(existedBefore && hasNoClobber) {
_ = os.Remove(built.OutputPath)
}
errTail := strings.TrimSpace(stderrBuf.String()) errTail := strings.TrimSpace(stderrBuf.String())
if len(errTail) > 800 { if len(errTail) > 800 {
errTail = errTail[len(errTail)-800:] errTail = errTail[len(errTail)-800:]
+8
View File
@@ -198,6 +198,11 @@ func parseRationalFPS(expr string) float64 {
return 0 return 0
} }
func IsH264BrowserNativePixFmt(pixFmt string) bool {
p := strings.ToLower(strings.TrimSpace(pixFmt))
return p == "" || p == "yuv420p" || p == "yuvj420p"
}
func isBrowserNative(info *VideoInfo) bool { func isBrowserNative(info *VideoInfo) bool {
extOK := info.Extension == ".mp4" || info.Extension == ".m4v" || info.Extension == ".webm" extOK := info.Extension == ".mp4" || info.Extension == ".m4v" || info.Extension == ".webm"
if !extOK { if !extOK {
@@ -207,6 +212,9 @@ func isBrowserNative(info *VideoInfo) bool {
if !vOK { if !vOK {
return false return false
} }
if info.VideoCodec == "h264" && !IsH264BrowserNativePixFmt(info.PixFmt) {
return false
}
// If multiple audio tracks exist, browser plays only track 0; if user wants to preview natively it's fine, // If multiple audio tracks exist, browser plays only track 0; if user wants to preview natively it's fine,
// we still treat single-compatible audio as native. // we still treat single-compatible audio as native.
for _, a := range info.AudioStreams { for _, a := range info.AudioStreams {
+33
View File
@@ -94,3 +94,36 @@ func TestParseRationalFPS(t *testing.T) {
} }
} }
} }
func TestParseProbeJSON_H264PixFmtCompatibility(t *testing.T) {
makeSample := func(pixFmt string) []byte {
return []byte(`{
"format": {"duration": "10.0"},
"streams": [{"index": 0, "codec_type": "video", "codec_name": "h264", "width": 1280, "height": 720, "pix_fmt": "` + pixFmt + `"}]
}`)
}
okInfo, err := ParseProbeJSON(makeSample("yuv420p"), "clip.mp4", 100)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !okInfo.BrowserNative {
t.Fatalf("expected yuv420p mp4 to be browser native")
}
hi10Info, err := ParseProbeJSON(makeSample("yuv420p10le"), "clip10.mp4", 100)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if hi10Info.BrowserNative {
t.Fatalf("expected 10-bit yuv420p10le mp4 NOT to be browser native")
}
fmt422Info, err := ParseProbeJSON(makeSample("yuv422p"), "clip422.mp4", 100)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if fmt422Info.BrowserNative {
t.Fatalf("expected yuv422p mp4 NOT to be browser native")
}
}
+18 -5
View File
@@ -22,6 +22,8 @@ import (
type Server struct { type Server struct {
addr string addr string
httpSrv *http.Server httpSrv *http.Server
ctx context.Context
cancel context.CancelFunc
ffmpegBin string ffmpegBin string
mu sync.RWMutex mu sync.RWMutex
files map[string]registeredFile files map[string]registeredFile
@@ -41,8 +43,11 @@ func Start(ffmpegBin string) (*Server, error) {
return nil, err return nil, err
} }
ctx, cancel := context.WithCancel(context.Background())
s := &Server{ s := &Server{
addr: ln.Addr().String(), addr: ln.Addr().String(),
ctx: ctx,
cancel: cancel,
ffmpegBin: ffmpegBin, ffmpegBin: ffmpegBin,
files: make(map[string]registeredFile), files: make(map[string]registeredFile),
proxyLocks: make(map[string]*sync.Mutex), proxyLocks: make(map[string]*sync.Mutex),
@@ -78,7 +83,12 @@ func (s *Server) BaseURL() string {
} }
func (s *Server) Register(info *media.VideoInfo) string { func (s *Server) Register(info *media.VideoInfo) string {
sum := sha1.Sum([]byte(info.Path)) var modUnix int64
if st, err := os.Stat(info.Path); err == nil {
modUnix = st.ModTime().UnixNano()
}
key := fmt.Sprintf("%s|%d|%d", info.Path, info.SizeBytes, modUnix)
sum := sha1.Sum([]byte(key))
id := hex.EncodeToString(sum[:8]) id := hex.EncodeToString(sum[:8])
s.mu.Lock() s.mu.Lock()
@@ -88,6 +98,9 @@ func (s *Server) Register(info *media.VideoInfo) string {
} }
func (s *Server) Close() { func (s *Server) Close() {
if s.cancel != nil {
s.cancel()
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel() defer cancel()
if s.httpSrv != nil { if s.httpSrv != nil {
@@ -166,7 +179,7 @@ func (s *Server) handleProxy(w http.ResponseWriter, r *http.Request) {
if proxyPath == "" { if proxyPath == "" {
proxyPath = filepath.Join(os.TempDir(), fmt.Sprintf("convert_preview_%s.mp4", cacheKey)) proxyPath = filepath.Join(os.TempDir(), fmt.Sprintf("convert_preview_%s.mp4", cacheKey))
if err := s.buildProxy(r.Context(), ffmpegBin, rf.info, merge, proxyPath); err != nil { if err := s.buildProxy(s.ctx, ffmpegBin, rf.info, merge, proxyPath); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
} }
@@ -211,8 +224,8 @@ func (s *Server) buildProxy(ctx context.Context, ffmpegBin string, info *media.V
args = append(args, "-map", "0:v:0", "-map", "0:a:0", "-c:a", "aac", "-b:a", "128k") args = append(args, "-map", "0:v:0", "-map", "0:a:0", "-c:a", "aac", "-b:a", "128k")
} }
if info.VideoCodec == "h264" { if info.VideoCodec == "h264" && media.IsH264BrowserNativePixFmt(info.PixFmt) {
// Remux video stream instantaneously when codec is already H.264 // Remux video stream instantaneously when codec is already browser-native 8-bit H.264
args = append(args, "-c:v", "copy") args = append(args, "-c:v", "copy")
} else { } else {
args = append(args, args = append(args,
@@ -220,7 +233,7 @@ func (s *Server) buildProxy(ctx context.Context, ffmpegBin string, info *media.V
"-preset", "ultrafast", "-preset", "ultrafast",
"-crf", "26", "-crf", "26",
"-pix_fmt", "yuv420p", "-pix_fmt", "yuv420p",
"-vf", "scale='min(1280,iw)':-2", "-vf", "scale='min(1280,trunc(iw/2)*2)':-2",
) )
} }
args = append(args, "-movflags", "+faststart", outPath) args = append(args, "-movflags", "+faststart", outPath)
+55
View File
@@ -3,6 +3,7 @@ package preview
import ( import (
"io" "io"
"net/http" "net/http"
"os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"testing" "testing"
@@ -85,3 +86,57 @@ func TestPreviewServer_RangeThumbAndProxy(t *testing.T) {
t.Fatalf("expected 404, got %d", nfResp.StatusCode) t.Fatalf("expected 404, got %d", nfResp.StatusCode)
} }
} }
func TestPreviewServer_CacheInvalidationAndOddWidthProxy(t *testing.T) {
bins := media.FindBinaries("", "")
if bins.FFmpeg == "" || bins.FFprobe == "" {
t.Skip("ffmpeg/ffprobe not installed")
}
tmpDir := t.TempDir()
src := filepath.Join(tmpDir, "odd.mkv")
// Odd width (321x180) vp8 source forces libx264 transcode in /proxy
gen := exec.Command(bins.FFmpeg,
"-hide_banner", "-loglevel", "error",
"-f", "lavfi", "-i", "testsrc=duration=1:size=321x180:rate=30",
"-c:v", "libvpx", "-y", src,
)
if out, err := gen.CombinedOutput(); err != nil {
t.Fatalf("failed generating odd-width mkv: %v (%s)", err, string(out))
}
info, err := media.Probe(bins.FFprobe, src)
if err != nil {
t.Fatalf("probe failed: %v", err)
}
srv, err := Start(bins.FFmpeg)
if err != nil {
t.Fatalf("start failed: %v", err)
}
defer srv.Close()
id1 := srv.Register(info)
// /proxy must succeed even on odd width (321 -> 320)
pResp, err := http.Get(srv.BaseURL() + "/proxy?id=" + id1 + "&merge=0")
if err != nil {
t.Fatalf("GET /proxy failed: %v", err)
}
pBody, _ := io.ReadAll(pResp.Body)
pResp.Body.Close()
if pResp.StatusCode != http.StatusOK || len(pBody) < 500 {
t.Fatalf("expected valid MP4 proxy for odd-width source, status=%d len=%d", pResp.StatusCode, len(pBody))
}
// Overwrite same path with different size -> Register must yield a new ID
if err := os.WriteFile(src, []byte("different-content-size"), 0o644); err != nil {
t.Fatalf("overwrite failed: %v", err)
}
info2 := *info
info2.SizeBytes = 22
id2 := srv.Register(&info2)
if id1 == id2 {
t.Fatalf("expected different videoId after file modification at same path, both were %q", id1)
}
}