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 {
|
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) {
|
||||||
|
|||||||
+324
-188
@@ -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,7 +635,18 @@ async function loadVideoFile(path: string) {
|
|||||||
state.selectedTracks = [0];
|
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<'div'>('filePill').classList.remove('hidden');
|
||||||
el<'strong'>('fileName').textContent = res.info.fileName;
|
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<'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<'button'>('resetTrimBtn').disabled = false;
|
||||||
el<'input'>('trimStartInput').disabled = false;
|
el<'input'>('trimStartInput').disabled = false;
|
||||||
el<'input'>('trimEndInput').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');
|
const mixBtn = el<'button'>('previewMixBtn');
|
||||||
mixBtn.classList.toggle('hidden', res.info.audioStreams.length < 2);
|
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);
|
loadFilmstrip(res);
|
||||||
setPlayerSource(res, false);
|
fitVideoStage();
|
||||||
syncControlsFromState();
|
if (reloadSource) {
|
||||||
updatePlayhead(0);
|
setPlayerSource(res, state.usingProxy, seekSec, autoPlay);
|
||||||
} catch (err) {
|
|
||||||
showError(String(err));
|
|
||||||
}
|
}
|
||||||
|
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 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');
|
||||||
|
if (b) {
|
||||||
b.textContent = msg;
|
b.textContent = msg;
|
||||||
b.classList.remove('hidden');
|
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,18 +1342,18 @@ 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;
|
||||||
|
} catch (err) {
|
||||||
|
initErr = String(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
renderShell();
|
renderShell();
|
||||||
el<'span'>('verBadge').textContent = `v${info.version}`;
|
if (initErr) {
|
||||||
updateFFmpegBadge();
|
showError(initErr);
|
||||||
syncControlsFromState();
|
|
||||||
|
|
||||||
if (info.initialFile) {
|
|
||||||
await loadVideoFile(info.initialFile);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
showError(String(err));
|
|
||||||
}
|
}
|
||||||
|
if (initialFile) {
|
||||||
|
await loadVideoFile(initialFile);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+2
@@ -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;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
if !(existedBefore && hasNoClobber) {
|
||||||
_ = os.Remove(built.OutputPath)
|
_ = os.Remove(built.OutputPath)
|
||||||
|
}
|
||||||
return fmt.Errorf("conversion cancelled")
|
return fmt.Errorf("conversion cancelled")
|
||||||
}
|
}
|
||||||
if waitErr != nil {
|
if waitErr != nil {
|
||||||
|
if !(existedBefore && hasNoClobber) {
|
||||||
_ = os.Remove(built.OutputPath)
|
_ = 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:]
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user