fix: Wails file drop, duplicate listeners, trim drag, crop stage, preview cache/pixfmt, NSIS temp quoting
Build Windows Installer / build (push) Successful in 8m46s
Build Windows Installer / build (push) Successful in 8m46s
This commit is contained in:
12 files changed
+541
-204
No files matched your search
@@ -64,12 +64,6 @@ func (a *App) startup(ctx context.Context) {
|
||||
if err == nil {
|
||||
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) {
|
||||
|
||||
+324
-188
@@ -16,6 +16,7 @@ interface State {
|
||||
trimEnd: number;
|
||||
loopTrim: boolean;
|
||||
previewMixAudio: boolean;
|
||||
mutedPreview: boolean;
|
||||
usingProxy: boolean;
|
||||
encoder: string;
|
||||
hwDecode: string;
|
||||
@@ -50,6 +51,7 @@ const state: State = {
|
||||
trimEnd: 0,
|
||||
loopTrim: true,
|
||||
previewMixAudio: false,
|
||||
mutedPreview: false,
|
||||
usingProxy: false,
|
||||
encoder: 'libx264',
|
||||
hwDecode: 'none',
|
||||
@@ -76,6 +78,19 @@ const state: State = {
|
||||
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 t = () => dict[state.lang];
|
||||
|
||||
@@ -83,15 +98,36 @@ function el<K extends keyof HTMLElementTagNameMap>(id: string): HTMLElementTagNa
|
||||
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() {
|
||||
const tr = t();
|
||||
const root = document.getElementById('app')!;
|
||||
const verText = state.appInfo?.version ? `v${state.appInfo.version}` : 'v2.0';
|
||||
root.innerHTML = `
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<div class="brand-icon">▶</div>
|
||||
<span class="brand-title">ConvertGUI</span>
|
||||
<span class="badge" id="verBadge">v1.0</span>
|
||||
<span class="badge" id="verBadge">${verText}</span>
|
||||
</div>
|
||||
<div class="topbar-center">
|
||||
<button id="openBtn" class="primary">📂 ${tr.openVideo}</button>
|
||||
@@ -385,7 +421,9 @@ function syncControlsFromState() {
|
||||
|
||||
const video = el<'video'>('videoEl');
|
||||
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;
|
||||
@@ -395,6 +433,7 @@ function syncControlsFromState() {
|
||||
el<'input'>('cropY').value = String(state.cropY);
|
||||
el<'input'>('cropW').value = String(state.cropW);
|
||||
el<'input'>('cropH').value = String(state.cropH);
|
||||
fitVideoStage();
|
||||
updateCropBoxVisual();
|
||||
|
||||
renderAudioSection();
|
||||
@@ -432,6 +471,10 @@ function renderAudioSection() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.audioMode === 'merge' && state.selectedTracks.length === 0) {
|
||||
state.selectedTracks = tracks.map((a) => a.audioOrder);
|
||||
}
|
||||
|
||||
container.innerHTML = tracks
|
||||
.map((s) => {
|
||||
const label = [
|
||||
@@ -444,8 +487,7 @@ function renderAudioSection() {
|
||||
.join(' · ');
|
||||
|
||||
if (state.audioMode === 'merge') {
|
||||
const checked =
|
||||
state.selectedTracks.length === 0 || state.selectedTracks.includes(s.audioOrder);
|
||||
const checked = state.selectedTracks.includes(s.audioOrder);
|
||||
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;
|
||||
@@ -460,6 +502,11 @@ function renderAudioSection() {
|
||||
if (box.checked) next.push(Number(box.dataset.atrackCheck));
|
||||
});
|
||||
state.selectedTracks = next;
|
||||
if (next.length === 0) {
|
||||
state.audioMode = 'mute';
|
||||
syncControlsFromState();
|
||||
return;
|
||||
}
|
||||
void refreshCLI();
|
||||
});
|
||||
});
|
||||
@@ -588,7 +635,18 @@ async function loadVideoFile(path: string) {
|
||||
state.selectedTracks = [0];
|
||||
}
|
||||
|
||||
// Update UI header & enable controls
|
||||
applyLoadedUI(res, 0, false, true);
|
||||
} catch (err) {
|
||||
showError(String(err));
|
||||
}
|
||||
}
|
||||
|
||||
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)}`;
|
||||
@@ -603,27 +661,49 @@ async function loadVideoFile(path: string) {
|
||||
el<'button'>('resetTrimBtn').disabled = false;
|
||||
el<'input'>('trimStartInput').disabled = false;
|
||||
el<'input'>('trimEndInput').disabled = false;
|
||||
el<'button'>('convertBtn').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.remove('pill-active');
|
||||
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);
|
||||
setPlayerSource(res, false);
|
||||
syncControlsFromState();
|
||||
updatePlayhead(0);
|
||||
} catch (err) {
|
||||
showError(String(err));
|
||||
fitVideoStage();
|
||||
if (reloadSource) {
|
||||
setPlayerSource(res, state.usingProxy, seekSec, autoPlay);
|
||||
}
|
||||
syncControlsFromState();
|
||||
updatePlayhead(seekSec);
|
||||
}
|
||||
|
||||
function setPlayerSource(res: LoadedVideo, forceProxy: boolean) {
|
||||
function setPlayerSource(
|
||||
res: LoadedVideo,
|
||||
forceProxy: boolean,
|
||||
seekSec = 0,
|
||||
autoPlay = false
|
||||
) {
|
||||
const video = el<'video'>('videoEl');
|
||||
const banner = el<'div'>('proxyBanner');
|
||||
|
||||
let src = res.previewUrl;
|
||||
let needsProxy = forceProxy || !res.info.browserNative || state.previewMixAudio;
|
||||
const needsProxy = forceProxy || !res.info.browserNative || state.previewMixAudio;
|
||||
if (needsProxy) {
|
||||
src = state.previewMixAudio ? res.proxyMergedUrl : res.proxyUrl;
|
||||
state.usingProxy = true;
|
||||
@@ -633,8 +713,33 @@ function setPlayerSource(res: LoadedVideo, forceProxy: boolean) {
|
||||
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.playbackRate = state.speed;
|
||||
video.load();
|
||||
}
|
||||
|
||||
@@ -670,19 +775,185 @@ function updateCropBoxVisual() {
|
||||
function showError(msg: string) {
|
||||
state.errorMsg = msg;
|
||||
const b = el<'div'>('errorBanner');
|
||||
if (b) {
|
||||
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() {
|
||||
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 () => {
|
||||
const path = await api?.OpenVideoDialog();
|
||||
if (path) await loadVideoFile(path);
|
||||
});
|
||||
|
||||
el<'div'>('dropOverlay').addEventListener('click', async () => {
|
||||
dropOverlay.addEventListener('click', async () => {
|
||||
const path = await api?.OpenVideoDialog();
|
||||
if (path) await loadVideoFile(path);
|
||||
});
|
||||
@@ -696,6 +967,9 @@ function bindEvents() {
|
||||
});
|
||||
|
||||
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';
|
||||
if (state.appInfo) {
|
||||
state.appInfo.config.language = state.lang;
|
||||
@@ -704,25 +978,9 @@ function bindEvents() {
|
||||
const prevLoaded = state.loaded;
|
||||
renderShell();
|
||||
if (prevLoaded) {
|
||||
state.loaded = prevLoaded;
|
||||
el<'div'>('filePill').classList.remove('hidden');
|
||||
el<'strong'>('fileName').textContent = prevLoaded.info.fileName;
|
||||
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();
|
||||
applyLoadedUI(prevLoaded, prevTime, wasPlaying, true);
|
||||
} else {
|
||||
if (state.errorMsg) showError(state.errorMsg);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -730,6 +988,8 @@ function bindEvents() {
|
||||
const video = el<'video'>('videoEl');
|
||||
video.addEventListener('canplay', () => {
|
||||
el<'div'>('proxyBanner').classList.add('hidden');
|
||||
const r = Math.max(0.25, Math.min(8, state.speed));
|
||||
video.playbackRate = r;
|
||||
});
|
||||
video.addEventListener('error', () => {
|
||||
if (state.loaded && !state.usingProxy) {
|
||||
@@ -746,17 +1006,6 @@ function bindEvents() {
|
||||
});
|
||||
|
||||
// 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'>('stepBackBtn').addEventListener('click', () => {
|
||||
video.pause();
|
||||
@@ -791,30 +1040,17 @@ function bindEvents() {
|
||||
state.previewMixAudio = !state.previewMixAudio;
|
||||
el<'button'>('previewMixBtn').classList.toggle('pill-active', state.previewMixAudio);
|
||||
const cur = video.currentTime;
|
||||
setPlayerSource(state.loaded, state.previewMixAudio);
|
||||
video.addEventListener(
|
||||
'loadedmetadata',
|
||||
() => {
|
||||
video.currentTime = cur;
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
const wasPlaying = !video.paused;
|
||||
setPlayerSource(state.loaded, state.previewMixAudio, cur, wasPlaying);
|
||||
});
|
||||
el<'button'>('mutePreviewBtn').addEventListener('click', () => {
|
||||
video.muted = !video.muted;
|
||||
el<'button'>('mutePreviewBtn').textContent = video.muted ? '🔇' : '🔊';
|
||||
state.mutedPreview = !state.mutedPreview;
|
||||
video.muted = state.mutedPreview;
|
||||
el<'button'>('mutePreviewBtn').textContent = state.mutedPreview ? '🔇' : '🔊';
|
||||
});
|
||||
|
||||
// Timeline drag (handles + seek)
|
||||
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) => {
|
||||
e.stopPropagation();
|
||||
dragTarget = 'start';
|
||||
@@ -831,34 +1067,6 @@ function bindEvents() {
|
||||
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
|
||||
el<'input'>('trimStartInput').addEventListener('change', () => {
|
||||
if (!state.loaded) return;
|
||||
@@ -992,7 +1200,6 @@ function bindEvents() {
|
||||
});
|
||||
|
||||
// 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) => {
|
||||
if (!state.loaded) return;
|
||||
e.stopPropagation();
|
||||
@@ -1008,47 +1215,6 @@ function bindEvents() {
|
||||
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
|
||||
el<'input'>('outputInput').addEventListener('input', (e) => {
|
||||
@@ -1084,6 +1250,8 @@ function bindEvents() {
|
||||
if (!state.loaded || state.converting) return;
|
||||
state.errorMsg = '';
|
||||
state.donePath = '';
|
||||
state.doneBytes = 0;
|
||||
state.progress = null;
|
||||
el<'div'>('errorBanner').classList.add('hidden');
|
||||
el<'div'>('doneBanner').classList.add('hidden');
|
||||
state.converting = true;
|
||||
@@ -1115,50 +1283,25 @@ function bindEvents() {
|
||||
el<'button'>('playOutBtn').addEventListener('click', () => {
|
||||
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() {
|
||||
const rt = window.runtime;
|
||||
if (!rt) return;
|
||||
|
||||
rt.EventsOn('app:file-dropped', (filePath: string) => {
|
||||
void loadVideoFile(filePath);
|
||||
});
|
||||
if (rt.OnFileDrop) {
|
||||
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) => {
|
||||
state.progress = p;
|
||||
state.doneBytes = p.outputBytes || p.totalSize || state.doneBytes;
|
||||
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(' · ');
|
||||
renderProgress(p);
|
||||
});
|
||||
|
||||
rt.EventsOn('convert:done', (outPath: string) => {
|
||||
@@ -1167,16 +1310,7 @@ function setupWailsEvents() {
|
||||
el<'button'>('convertBtn').disabled = false;
|
||||
el<'button'>('cancelBtn').classList.add('hidden');
|
||||
el<'div'>('progressWrap').classList.add('hidden');
|
||||
|
||||
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');
|
||||
renderDoneBanner();
|
||||
});
|
||||
|
||||
rt.EventsOn('convert:error', (errMsg: string) => {
|
||||
@@ -1189,10 +1323,12 @@ function setupWailsEvents() {
|
||||
}
|
||||
|
||||
async function init() {
|
||||
renderShell();
|
||||
bindGlobalEvents();
|
||||
setupWailsEvents();
|
||||
|
||||
const api = appApi();
|
||||
let initialFile = '';
|
||||
let initErr = '';
|
||||
if (api) {
|
||||
try {
|
||||
const info = await api.GetAppInfo();
|
||||
@@ -1206,18 +1342,18 @@ async function init() {
|
||||
state.crf = info.config.defaultCrf ?? 20;
|
||||
state.fps = info.config.defaultFps ?? 60;
|
||||
state.tune = info.config.defaultTune || 'film';
|
||||
initialFile = info.initialFile;
|
||||
} catch (err) {
|
||||
initErr = String(err);
|
||||
}
|
||||
}
|
||||
|
||||
renderShell();
|
||||
el<'span'>('verBadge').textContent = `v${info.version}`;
|
||||
updateFFmpegBadge();
|
||||
syncControlsFromState();
|
||||
|
||||
if (info.initialFile) {
|
||||
await loadVideoFile(info.initialFile);
|
||||
}
|
||||
} catch (err) {
|
||||
showError(String(err));
|
||||
if (initErr) {
|
||||
showError(initErr);
|
||||
}
|
||||
if (initialFile) {
|
||||
await loadVideoFile(initialFile);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -224,15 +224,16 @@ button.pill-active {
|
||||
|
||||
.video-stage {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
video {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
Vendored
+2
@@ -127,6 +127,8 @@ declare global {
|
||||
};
|
||||
runtime?: {
|
||||
EventsOn(eventName: string, callback: (...args: any[]) => void): () => void;
|
||||
OnFileDrop?(callback: (x: number, y: number, paths: string[]) => void, useDropTarget?: boolean): void;
|
||||
OnFileDropOff?(): void;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -70,7 +70,7 @@ Section "ConvertGUI Application (Required)" SEC_APP
|
||||
ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
|
||||
StrCmp $0 "" 0 webview2_done
|
||||
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:
|
||||
SectionEnd
|
||||
|
||||
|
||||
@@ -216,7 +216,7 @@ func BuildCommand(req Request, info *media.VideoInfo) (*BuiltCommand, error) {
|
||||
postArgs = append(postArgs, "-an")
|
||||
case "merge":
|
||||
tracks := req.SelectedTracks
|
||||
if len(tracks) == 0 {
|
||||
if tracks == nil {
|
||||
for i := 0; i < numAudio; i++ {
|
||||
tracks = append(tracks, i)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,19 @@ func (r *Runner) Run(
|
||||
var stderrBuf bytes.Buffer
|
||||
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 {
|
||||
return fmt.Errorf("failed to start ffmpeg: %w", err)
|
||||
}
|
||||
@@ -114,11 +127,15 @@ func (r *Runner) Run(
|
||||
|
||||
waitErr := cmd.Wait()
|
||||
if ctx.Err() != nil {
|
||||
if !(existedBefore && hasNoClobber) {
|
||||
_ = os.Remove(built.OutputPath)
|
||||
}
|
||||
return fmt.Errorf("conversion cancelled")
|
||||
}
|
||||
if waitErr != nil {
|
||||
if !(existedBefore && hasNoClobber) {
|
||||
_ = os.Remove(built.OutputPath)
|
||||
}
|
||||
errTail := strings.TrimSpace(stderrBuf.String())
|
||||
if len(errTail) > 800 {
|
||||
errTail = errTail[len(errTail)-800:]
|
||||
|
||||
@@ -198,6 +198,11 @@ func parseRationalFPS(expr string) float64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
func IsH264BrowserNativePixFmt(pixFmt string) bool {
|
||||
p := strings.ToLower(strings.TrimSpace(pixFmt))
|
||||
return p == "" || p == "yuv420p" || p == "yuvj420p"
|
||||
}
|
||||
|
||||
func isBrowserNative(info *VideoInfo) bool {
|
||||
extOK := info.Extension == ".mp4" || info.Extension == ".m4v" || info.Extension == ".webm"
|
||||
if !extOK {
|
||||
@@ -207,6 +212,9 @@ func isBrowserNative(info *VideoInfo) bool {
|
||||
if !vOK {
|
||||
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,
|
||||
// we still treat single-compatible audio as native.
|
||||
for _, a := range info.AudioStreams {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,8 @@ import (
|
||||
type Server struct {
|
||||
addr string
|
||||
httpSrv *http.Server
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
ffmpegBin string
|
||||
mu sync.RWMutex
|
||||
files map[string]registeredFile
|
||||
@@ -41,8 +43,11 @@ func Start(ffmpegBin string) (*Server, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s := &Server{
|
||||
addr: ln.Addr().String(),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
ffmpegBin: ffmpegBin,
|
||||
files: make(map[string]registeredFile),
|
||||
proxyLocks: make(map[string]*sync.Mutex),
|
||||
@@ -78,7 +83,12 @@ func (s *Server) BaseURL() 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])
|
||||
|
||||
s.mu.Lock()
|
||||
@@ -88,6 +98,9 @@ func (s *Server) Register(info *media.VideoInfo) string {
|
||||
}
|
||||
|
||||
func (s *Server) Close() {
|
||||
if s.cancel != nil {
|
||||
s.cancel()
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
if s.httpSrv != nil {
|
||||
@@ -166,7 +179,7 @@ func (s *Server) handleProxy(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if proxyPath == "" {
|
||||
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)
|
||||
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")
|
||||
}
|
||||
|
||||
if info.VideoCodec == "h264" {
|
||||
// Remux video stream instantaneously when codec is already H.264
|
||||
if info.VideoCodec == "h264" && media.IsH264BrowserNativePixFmt(info.PixFmt) {
|
||||
// Remux video stream instantaneously when codec is already browser-native 8-bit H.264
|
||||
args = append(args, "-c:v", "copy")
|
||||
} else {
|
||||
args = append(args,
|
||||
@@ -220,7 +233,7 @@ func (s *Server) buildProxy(ctx context.Context, ffmpegBin string, info *media.V
|
||||
"-preset", "ultrafast",
|
||||
"-crf", "26",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-vf", "scale='min(1280,iw)':-2",
|
||||
"-vf", "scale='min(1280,trunc(iw/2)*2)':-2",
|
||||
)
|
||||
}
|
||||
args = append(args, "-movflags", "+faststart", outPath)
|
||||
|
||||
@@ -3,6 +3,7 @@ package preview
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -85,3 +86,57 @@ func TestPreviewServer_RangeThumbAndProxy(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user