Fix muxing

This commit is contained in:
2026-05-15 04:10:06 +02:00
parent 05faf1ce3b
commit 6deb883472
26 changed files with 662 additions and 169 deletions

View File

@@ -6,9 +6,13 @@ let selectedFilePath = null;
const sseMap = new Map();
let pollTimer = null;
// ── DOM helpers ───────────────────────────────────────
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => document.querySelectorAll(sel);
const el = (id) => document.getElementById(id);
const el = (id) => {
const e = document.getElementById(id);
if (!e)
throw new Error(`Missing element #${id}`);
return e;
};
// ── API ───────────────────────────────────────────────
function apiHeaders() {
const h = { 'Content-Type': 'application/json' };
@@ -39,45 +43,75 @@ async function apiJson(method, url, body) {
}
// ── Screen switching ──────────────────────────────────
function showLoginScreen() {
el('login-screen').classList.remove('hidden');
el('main-screen').classList.add('hidden');
el('login-screen').hidden = false;
el('main-screen').hidden = true;
}
function showMainScreen() {
el('login-screen').classList.add('hidden');
el('main-screen').classList.remove('hidden');
el('login-screen').hidden = true;
el('main-screen').hidden = false;
}
// ── Tab navigation ────────────────────────────────────
function switchTab(name) {
$$('button.tab').forEach(b => b.classList.remove('active'));
document.querySelector(`button.tab[data-tab="${name}"]`)?.classList.add('active');
$$('.tab-content').forEach(c => c.classList.remove('active'));
const pane = document.getElementById(name);
if (pane)
pane.classList.add('active');
if (name === 'dashboard')
// ── Tablist (WAI-ARIA) ────────────────────────────────
function activateTab(tablistId, tabId) {
const tablist = el(tablistId);
const tabs = Array.from(tablist.querySelectorAll('[role="tab"]'));
tabs.forEach(t => {
const selected = t.id === tabId;
t.setAttribute('aria-selected', selected ? 'true' : 'false');
t.setAttribute('tabindex', selected ? '0' : '-1');
t.classList.toggle('active', selected);
const panelId = t.getAttribute('aria-controls');
if (!panelId)
return;
const panel = document.getElementById(panelId);
if (panel)
panel.hidden = !selected;
});
const tab = tabs.find(t => t.id === tabId);
const tabName = tab?.getAttribute('aria-controls') || '';
onTabActivated(tablistId, tabName);
}
function onTabActivated(tablistId, panelId) {
if (tablistId !== 'main-tablist')
return;
if (panelId === 'dashboard')
loadJobs();
if (name === 'files')
if (panelId === 'files')
loadFilesList();
}
$$('button.tab').forEach(btn => {
btn.addEventListener('click', () => switchTab(btn.dataset.tab || ''));
});
// ── Mini tabs (video source) ──────────────────────────
$$('button.tab-mini').forEach(btn => {
btn.addEventListener('click', () => {
$$('button.tab-mini').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
$$('.src-panel').forEach(p => p.classList.remove('active'));
const panel = document.getElementById('src-' + (btn.dataset.src || ''));
if (panel)
panel.classList.add('active');
function wireTablist(tablistId) {
const tablist = el(tablistId);
const tabs = Array.from(tablist.querySelectorAll('[role="tab"]'));
tabs.forEach(tab => {
tab.addEventListener('click', () => activateTab(tablistId, tab.id));
});
});
tablist.addEventListener('keydown', (e) => {
const ke = e;
const current = document.activeElement;
if (!current || !tabs.includes(current))
return;
let next;
const idx = tabs.indexOf(current);
if (ke.key === 'ArrowRight')
next = tabs[(idx + 1) % tabs.length];
else if (ke.key === 'ArrowLeft')
next = tabs[(idx - 1 + tabs.length) % tabs.length];
else if (ke.key === 'Home')
next = tabs[0];
else if (ke.key === 'End')
next = tabs[tabs.length - 1];
if (next) {
ke.preventDefault();
activateTab(tablistId, next.id);
next.focus();
}
});
}
// ── Login ─────────────────────────────────────────────
el('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
const username = el('login-username').value;
const password = el('login-password').value;
const errorEl = el('login-error');
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
@@ -89,17 +123,18 @@ el('login-form').addEventListener('submit', async (e) => {
authToken = data.token;
if (authToken)
sessionStorage.setItem('authToken', authToken);
errorEl.hidden = true;
showMainScreen();
initApp();
}
else {
el('login-error').textContent = data.error;
el('login-error').classList.remove('hidden');
errorEl.textContent = data.error || 'Login failed';
errorEl.hidden = false;
}
}
catch {
el('login-error').textContent = 'Connection failed';
el('login-error').classList.remove('hidden');
errorEl.textContent = 'Connection failed';
errorEl.hidden = false;
}
});
el('logout-btn').addEventListener('click', () => {
@@ -160,37 +195,84 @@ const videoUpload = el('video-upload');
const uploadName = el('upload-name');
videoUpload.addEventListener('change', function () {
if (this.files?.length) {
selectedFilePath = null; // will upload on submit
selectedFilePath = null;
uploadName.textContent = `Selected: ${this.files[0].name} (${formatSize(this.files[0].size)})`;
}
else {
uploadName.textContent = '';
}
});
// ── YouTube download ──────────────────────────────────
el('download-url').addEventListener('click', async () => {
const url = el('youtube-url').value;
// ── YouTube download (SSE) ────────────────────────────
let youtubeStream = null;
el('download-url').addEventListener('click', () => {
const url = el('youtube-url').value.trim();
if (!url)
return;
if (!authToken)
return;
const status = el('download-status');
status.textContent = 'Downloading...';
const progressWrap = document.querySelector('.download-progress');
const progressbar = el('download-progressbar');
const fill = el('download-fill');
status.textContent = 'Starting download...';
status.className = 'status';
try {
const data = await apiJson('POST', '/api/files/youtube', { url });
status.textContent = `Downloaded: ${data.filename}`;
status.className = 'status success';
selectedFilePath = data.filePath;
const sel = el('video-select');
const opt = document.createElement('option');
opt.value = data.filePath;
opt.textContent = data.filename;
opt.selected = true;
sel.appendChild(opt);
}
catch (err) {
status.textContent = `Error: ${err.message}`;
if (progressWrap)
progressWrap.hidden = false;
progressbar.setAttribute('aria-valuenow', '0');
fill.style.width = '0%';
if (youtubeStream)
youtubeStream.close();
const streamUrl = `/api/files/youtube/stream?url=${encodeURIComponent(url)}&token=${encodeURIComponent(authToken)}`;
const es = new EventSource(streamUrl);
youtubeStream = es;
es.onmessage = (event) => {
let data;
try {
data = JSON.parse(event.data);
}
catch {
return;
}
if (data.type === 'progress' && typeof data.percent === 'number') {
const pct = Math.max(0, Math.min(100, data.percent));
progressbar.setAttribute('aria-valuenow', String(Math.round(pct)));
fill.style.width = `${pct}%`;
status.textContent = `Downloading ${pct.toFixed(1)}%`;
return;
}
if (data.type === 'done' && data.filePath && data.filename) {
progressbar.setAttribute('aria-valuenow', '100');
fill.style.width = '100%';
status.textContent = `Downloaded: ${data.filename}`;
status.className = 'status success';
selectedFilePath = data.filePath;
const sel = el('video-select');
const opt = document.createElement('option');
opt.value = data.filePath;
opt.textContent = data.filename;
opt.selected = true;
sel.appendChild(opt);
es.close();
youtubeStream = null;
return;
}
if (data.type === 'error') {
status.textContent = `Error: ${data.message || 'Download failed'}`;
status.className = 'status error';
if (progressWrap)
progressWrap.hidden = true;
es.close();
youtubeStream = null;
}
};
es.onerror = () => {
if (es.readyState === EventSource.CLOSED)
return;
status.textContent = 'Connection lost';
status.className = 'status error';
}
es.close();
youtubeStream = null;
};
});
// ── New Job form ──────────────────────────────────────
el('new-job-form').addEventListener('submit', async (e) => {
@@ -233,10 +315,18 @@ el('new-job-form').addEventListener('submit', async (e) => {
else
config[key] = val;
}
// Empty strings would clobber server-side defaults during the spread-merge in
// JobManager.createJob — drop them. (The server also filters defensively.)
for (const k of Object.keys(config)) {
const v = config[k];
if (v === '' || v === undefined || v === null)
delete config[k];
}
const outputOptions = {
audio: fd.get('output-audio') === 'on',
subtitles: fd.get('output-subtitles') === 'on',
muxed: fd.get('output-muxed') === 'on',
muxMode: fd.get('mux-mode') === 'mixed' ? 'mixed' : 'separate',
};
if (config.visionProvider) {
const vp = {};
@@ -260,6 +350,7 @@ el('new-job-form').addEventListener('submit', async (e) => {
delete config['output-audio'];
delete config['output-subtitles'];
delete config['output-muxed'];
delete config['mux-mode'];
try {
const data = await apiJson('POST', '/api/jobs', {
videoPath: selectedFilePath,
@@ -271,7 +362,7 @@ el('new-job-form').addEventListener('submit', async (e) => {
videoUpload.value = '';
uploadName.textContent = '';
el('new-job-form').reset();
switchTab('dashboard');
activateTab('main-tablist', 'tab-dashboard');
}
catch (err) {
alert('Error creating job: ' + err.message);
@@ -279,6 +370,8 @@ el('new-job-form').addEventListener('submit', async (e) => {
});
// ── Job list & rendering ──────────────────────────────
async function loadJobs() {
const container = el('jobs-list');
container.setAttribute('aria-busy', 'true');
try {
const data = await apiJson('GET', '/api/jobs');
renderJobs(data.jobs);
@@ -291,11 +384,14 @@ async function loadJobs() {
catch (err) {
console.error(err);
}
finally {
container.setAttribute('aria-busy', 'false');
}
}
function renderJobs(jobs) {
const container = el('jobs-list');
if (!jobs.length) {
container.innerHTML = '<p class="empty">No jobs yet. Create one from the "New Job" tab.</p>';
container.innerHTML = '<p class="empty">No jobs yet. Create one from the &ldquo;New Job&rdquo; tab.</p>';
return;
}
container.innerHTML = jobs.map(j => {
@@ -303,50 +399,57 @@ function renderJobs(jobs) {
const progressClass = j.status === 'completed' ? 'completed' : j.status === 'failed' ? 'failed' : '';
const downloads = [];
if (j.status === 'completed') {
// Plain <a download> navigations don't send our Authorization header.
// Pass the token via query string — middleware/auth.ts accepts ?token=.
const tok = authToken ? `token=${encodeURIComponent(authToken)}` : '';
const sep = (qs) => qs.includes('?') ? '&' : '?';
const url = (path) => tok ? `${path}${sep(path)}${tok}` : path;
if (j.output_audio)
downloads.push(`<a href="/api/jobs/${j.id}/download/audio" download>Audio</a>`);
downloads.push(`<a href="${url(`/api/jobs/${j.id}/download/audio`)}" download>Audio</a>`);
if (j.output_subtitles_srt)
downloads.push(`<a href="/api/jobs/${j.id}/download/subtitles?format=srt" download>SRT</a>`);
downloads.push(`<a href="${url(`/api/jobs/${j.id}/download/subtitles?format=srt`)}" download>SRT</a>`);
if (j.output_subtitles_vtt)
downloads.push(`<a href="/api/jobs/${j.id}/download/subtitles?format=vtt" download>VTT</a>`);
downloads.push(`<a href="${url(`/api/jobs/${j.id}/download/subtitles?format=vtt`)}" download>VTT</a>`);
if (j.output_muxed)
downloads.push(`<a href="/api/jobs/${j.id}/download/muxed" download>Muxed</a>`);
downloads.push(`<a href="${url(`/api/jobs/${j.id}/download/muxed`)}" download>Muxed</a>`);
}
let actions = '';
if (j.status === 'pending' || j.status === 'queued') {
actions += `<button class="act-start" data-id="${j.id}">Start</button>`;
actions += `<button type="button" class="act-start" data-id="${j.id}">Start</button>`;
}
if (j.status === 'processing') {
actions += `<button class="act-pause" data-id="${j.id}">Pause</button>`;
actions += `<button type="button" class="act-pause" data-id="${j.id}">Pause</button>`;
}
if (j.status === 'failed' || j.status === 'paused' || j.status === 'cancelled') {
actions += `<button class="act-restart" data-id="${j.id}">Restart</button>`;
actions += `<button type="button" class="act-restart" data-id="${j.id}">Restart</button>`;
}
if (j.status !== 'processing') {
actions += `<button class="act-delete danger" data-id="${j.id}">Delete</button>`;
actions += `<button type="button" class="act-delete danger" data-id="${j.id}">Delete</button>`;
}
const pct = Math.round(j.progress);
return `
<div class="job-card" data-id="${j.id}">
<article class="job-card" data-id="${j.id}" aria-labelledby="job-${j.id}-title">
<div class="job-card-header">
<h3>${escapeHtml(j.video_filename)}</h3>
<h3 id="job-${j.id}-title">${escapeHtml(j.video_filename)}</h3>
<div class="job-actions">${actions}</div>
</div>
<span class="status-badge status-${j.status}">${j.status}</span>
<div class="progress-bar"><div class="progress-fill ${progressClass}" style="width:${j.progress}%"></div></div>
<div role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${pct}" aria-label="Job progress" class="progress-bar">
<div class="progress-fill ${progressClass}" style="width:${pct}%"></div>
</div>
<div class="job-meta">
<span>${Math.round(j.progress)}%</span>
<span>${pct}%</span>
<span>Idx: ${j.current_index}/${j.total_units}</span>
<span>${new Date(j.created_at).toLocaleString()}</span>
</div>
${j.error ? `<div class="error-msg">${escapeHtml(j.error)}</div>` : ''}
${j.error ? `<div class="error-msg" role="alert">${escapeHtml(j.error)}</div>` : ''}
${downloads.length ? `<div class="download-links">${downloads.join('')}</div>` : ''}
<div class="job-detail" data-id="${j.id}">
<div class="segment-log">${segs.map((s, i) => `<div class="segment-entry"><span class="segment-time">[${s.startTime.toFixed(1)}s]</span> ${escapeHtml(s.description)}</div>`).join('')}</div>
<button type="button" class="toggle-detail" data-id="${j.id}" aria-expanded="false" aria-controls="job-${j.id}-detail">${segs.length} segments</button>
<div class="job-detail" id="job-${j.id}-detail" data-id="${j.id}" hidden>
<div class="segment-log">${segs.map(s => `<div class="segment-entry"><span class="segment-time">[${s.startTime.toFixed(1)}s]</span> ${escapeHtml(s.description)}</div>`).join('')}</div>
</div>
<button class="toggle-detail" data-id="${j.id}">${segs.length} segments</button>
</div>`;
</article>`;
}).join('');
// Wire up action buttons
container.querySelectorAll('.act-start').forEach(b => b.addEventListener('click', () => handleJobAction(b.dataset.id || '', 'start')));
container.querySelectorAll('.act-pause').forEach(b => b.addEventListener('click', () => handleJobAction(b.dataset.id || '', 'pause')));
container.querySelectorAll('.act-restart').forEach(b => b.addEventListener('click', () => handleJobAction(b.dataset.id || '', 'restart')));
@@ -357,10 +460,12 @@ function renderJobs(jobs) {
const detail = container.querySelector(`.job-detail[data-id="${jobId}"]`);
if (!detail)
return;
detail.classList.toggle('open');
const willOpen = detail.hidden;
detail.hidden = !willOpen;
b.setAttribute('aria-expanded', willOpen ? 'true' : 'false');
const job = jobs.find(j => j.id === jobId);
const segs = job ? JSON.parse(job.segments || '[]') : [];
b.textContent = detail.classList.contains('open') ? 'Hide segments' : `${segs.length} segments`;
b.textContent = willOpen ? 'Hide segments' : `${segs.length} segments`;
});
});
}
@@ -386,6 +491,8 @@ function startPolling() {
function connectSSE(jobId) {
if (sseMap.has(jobId))
return;
if (!authToken)
return;
const es = new EventSource(`/api/jobs/${jobId}/progress?token=${encodeURIComponent(authToken)}`);
es.onmessage = (event) => {
const data = JSON.parse(event.data);
@@ -410,9 +517,13 @@ function updateJobCard(jobId, data) {
badge.className = `status-badge status-${data.status}`;
badge.textContent = data.status;
}
const pct = Math.round(data.progress);
const bar = card.querySelector('[role="progressbar"]');
if (bar)
bar.setAttribute('aria-valuenow', String(pct));
const fill = card.querySelector('.progress-fill');
if (fill) {
fill.style.width = data.progress + '%';
fill.style.width = pct + '%';
fill.className = 'progress-fill';
if (data.status === 'completed')
fill.classList.add('completed');
@@ -421,7 +532,7 @@ function updateJobCard(jobId, data) {
}
const metaSpans = card.querySelectorAll('.job-meta span');
if (metaSpans[0])
metaSpans[0].textContent = Math.round(data.progress) + '%';
metaSpans[0].textContent = pct + '%';
if (metaSpans[1])
metaSpans[1].textContent = `Idx: ${data.currentIndex}/${data.totalUnits}`;
const log = card.querySelector('.segment-log');
@@ -430,7 +541,9 @@ function updateJobCard(jobId, data) {
}
const toggleBtn = card.querySelector('.toggle-detail');
if (toggleBtn && data.segments) {
toggleBtn.textContent = `${data.segments.length} segments`;
const expanded = toggleBtn.getAttribute('aria-expanded') === 'true';
if (!expanded)
toggleBtn.textContent = `${data.segments.length} segments`;
}
}
// ── Settings ──────────────────────────────────────────
@@ -443,7 +556,10 @@ async function loadSettings() {
container.innerHTML = '<p class="empty">No custom settings yet. Settings from .env are used as defaults.</p>';
return;
}
container.innerHTML = entries.map(([key, value]) => `<label>${escapeHtml(key)} <input type="text" name="${escapeHtml(key)}" value="${escapeHtml(String(value))}"></label>`).join('');
container.innerHTML = entries.map(([key, value]) => {
const safeKey = escapeHtml(key);
return `<div class="field"><label for="setting-${safeKey}">${safeKey}</label><input type="text" id="setting-${safeKey}" name="${safeKey}" value="${escapeHtml(String(value))}"></div>`;
}).join('');
}
catch (err) {
console.error(err);
@@ -472,7 +588,7 @@ async function loadFilesList() {
const tbody = document.querySelector('#files-table tbody');
tbody.innerHTML = data.files.map(f => `
<tr>
<td><input type="checkbox" class="file-checkbox" data-path="${escapeHtml(f.filePath)}"></td>
<td><input type="checkbox" class="file-checkbox" data-filename="${escapeHtml(f.filename)}" aria-label="Select ${escapeHtml(f.filename)}"></td>
<td>${escapeHtml(f.filename)}</td>
<td>${formatSize(f.size)}</td>
</tr>
@@ -480,6 +596,9 @@ async function loadFilesList() {
tbody.querySelectorAll('.file-checkbox').forEach(cb => {
cb.addEventListener('change', updateFileSelection);
});
el('select-all-files').checked = false;
selectedFiles.clear();
updateFileSelection();
}
catch (err) {
console.error(err);
@@ -488,8 +607,8 @@ async function loadFilesList() {
function updateFileSelection() {
selectedFiles.clear();
document.querySelectorAll('.file-checkbox:checked').forEach(cb => {
if (cb.dataset.path)
selectedFiles.add(cb.dataset.path);
if (cb.dataset.filename)
selectedFiles.add(cb.dataset.filename);
});
el('delete-selected-files').disabled = selectedFiles.size === 0;
}
@@ -499,10 +618,25 @@ el('select-all-files').addEventListener('change', function () {
});
updateFileSelection();
});
el('delete-selected-files').addEventListener('click', () => {
el('delete-selected-files').addEventListener('click', async () => {
if (!selectedFiles.size)
return;
if (!confirm(`Delete ${selectedFiles.size} file(s)?`))
return;
alert('File deletion not yet implemented');
const failures = [];
for (const filename of selectedFiles) {
try {
await api('DELETE', `/api/files/${encodeURIComponent(filename)}`);
}
catch (err) {
failures.push(`${filename}: ${err.message}`);
}
}
if (failures.length) {
alert(`Some deletions failed:\n${failures.join('\n')}`);
}
await loadFilesList();
await loadBrowseFiles();
});
el('refresh-files-list').addEventListener('click', loadFilesList);
// ── Config defaults for New Job form ─────────────────
@@ -525,15 +659,14 @@ async function loadConfigDefaults() {
}
}
const fields = [
['visionModel'], ['ttsModel'], ['ttsVoice'], ['ttsSpeedFactor'],
['ttsInstructions', 'textarea'], ['batchWindowDuration'], ['framesInBatch'],
['captureIntervalSeconds'], ['contextWindowSize'],
['defaultPrompt', 'textarea'], ['changePrompt', 'textarea'], ['batchPrompt', 'textarea'],
'visionModel', 'ttsModel', 'ttsVoice', 'ttsSpeedFactor', 'ttsInstructions',
'batchWindowDuration', 'framesInBatch', 'captureIntervalSeconds', 'contextWindowSize',
'defaultPrompt', 'changePrompt', 'batchPrompt',
];
for (const [name, tag] of fields) {
const el = document.querySelector(`[name="${name}"]`);
if (el && c[name] !== undefined)
el.value = c[name];
for (const name of fields) {
const field = document.querySelector(`[name="${name}"]`);
if (field && c[name] !== undefined)
field.value = c[name];
}
}
catch (err) {
@@ -542,9 +675,12 @@ async function loadConfigDefaults() {
}
// ── Init ──────────────────────────────────────────────
function initApp() {
wireTablist('main-tablist');
wireTablist('source-tablist');
loadJobs();
loadBrowseFiles();
loadConfigDefaults();
loadSettings();
startPolling();
}
// ── Startup ───────────────────────────────────────────