From 478dbff6548c939067de38f117ff5468996baebe Mon Sep 17 00:00:00 2001 From: m4xx1m Date: Thu, 12 Nov 2026 18:04:53 +0300 Subject: [PATCH] fix: Wails file drop, duplicate listeners, trim drag, crop stage, preview cache/pixfmt, NSIS temp quoting --- app.go | 6 - frontend/src/main.ts | 552 +++++++++++++++++++------------ frontend/src/style.css | 7 +- frontend/src/wails.d.ts | 2 + installer/installer.nsi | 2 +- internal/convert/command.go | 2 +- internal/convert/command_test.go | 78 +++++ internal/convert/runner.go | 21 +- internal/media/probe.go | 8 + internal/media/probe_test.go | 33 ++ internal/preview/server.go | 23 +- internal/preview/server_test.go | 55 +++ 12 files changed, 563 insertions(+), 226 deletions(-) diff --git a/app.go b/app.go index 6d722d8..9aad33a 100644 --- a/app.go +++ b/app.go @@ -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) { diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 6ba7656..15c04e0 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -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(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 = `
▶
ConvertGUI - v1.0 + ${verText}
@@ -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 ``; } 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,42 +635,75 @@ async function loadVideoFile(path: string) { state.selectedTracks = [0]; } - // Update UI header & enable controls - 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); + applyLoadedUI(res, 0, false, true); } catch (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 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'); - b.textContent = msg; - b.classList.remove('hidden'); + 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,19 +1342,19 @@ async function init() { state.crf = info.config.defaultCrf ?? 20; state.fps = info.config.defaultFps ?? 60; state.tune = info.config.defaultTune || 'film'; - - renderShell(); - el<'span'>('verBadge').textContent = `v${info.version}`; - updateFFmpegBadge(); - syncControlsFromState(); - - if (info.initialFile) { - await loadVideoFile(info.initialFile); - } + initialFile = info.initialFile; } catch (err) { - showError(String(err)); + initErr = String(err); } } + + renderShell(); + if (initErr) { + showError(initErr); + } + if (initialFile) { + await loadVideoFile(initialFile); + } } void init(); diff --git a/frontend/src/style.css b/frontend/src/style.css index f9c4ccd..5192573 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -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; } diff --git a/frontend/src/wails.d.ts b/frontend/src/wails.d.ts index 81f5ada..cf819ff 100644 --- a/frontend/src/wails.d.ts +++ b/frontend/src/wails.d.ts @@ -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; }; } } diff --git a/installer/installer.nsi b/installer/installer.nsi index 64b696f..fdb03e7 100644 --- a/installer/installer.nsi +++ b/installer/installer.nsi @@ -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 diff --git a/internal/convert/command.go b/internal/convert/command.go index 4a0a669..5051845 100644 --- a/internal/convert/command.go +++ b/internal/convert/command.go @@ -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) } diff --git a/internal/convert/command_test.go b/internal/convert/command_test.go index f1463b4..527bc51 100644 --- a/internal/convert/command_test.go +++ b/internal/convert/command_test.go @@ -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)) + } +} diff --git a/internal/convert/runner.go b/internal/convert/runner.go index 28ef0df..ae8820a 100644 --- a/internal/convert/runner.go +++ b/internal/convert/runner.go @@ -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 { - _ = os.Remove(built.OutputPath) + if !(existedBefore && hasNoClobber) { + _ = os.Remove(built.OutputPath) + } return fmt.Errorf("conversion cancelled") } if waitErr != nil { - _ = os.Remove(built.OutputPath) + if !(existedBefore && hasNoClobber) { + _ = os.Remove(built.OutputPath) + } errTail := strings.TrimSpace(stderrBuf.String()) if len(errTail) > 800 { errTail = errTail[len(errTail)-800:] diff --git a/internal/media/probe.go b/internal/media/probe.go index fc1d4c6..f5805e6 100644 --- a/internal/media/probe.go +++ b/internal/media/probe.go @@ -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 { diff --git a/internal/media/probe_test.go b/internal/media/probe_test.go index 815102e..f1e6bad 100644 --- a/internal/media/probe_test.go +++ b/internal/media/probe_test.go @@ -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") + } +} diff --git a/internal/preview/server.go b/internal/preview/server.go index 1ed4c0a..c70deed 100644 --- a/internal/preview/server.go +++ b/internal/preview/server.go @@ -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) diff --git a/internal/preview/server_test.go b/internal/preview/server_test.go index a5087d5..13d654e 100644 --- a/internal/preview/server_test.go +++ b/internal/preview/server_test.go @@ -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) + } +}