Fix muxing
This commit is contained in:
1
dist/server/db/jobStore.d.ts
vendored
1
dist/server/db/jobStore.d.ts
vendored
@@ -2,6 +2,7 @@ export interface OutputOptions {
|
||||
audio: boolean;
|
||||
subtitles: boolean;
|
||||
muxed: boolean;
|
||||
muxMode: 'separate' | 'mixed';
|
||||
}
|
||||
export interface Job {
|
||||
id: string;
|
||||
|
||||
2
dist/server/db/jobStore.js.map
vendored
2
dist/server/db/jobStore.js.map
vendored
@@ -1 +1 @@
|
||||
{"version":3,"file":"jobStore.js","sourceRoot":"","sources":["../../../src/server/db/jobStore.ts"],"names":[],"mappings":"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"}
|
||||
{"version":3,"file":"jobStore.js","sourceRoot":"","sources":["../../../src/server/db/jobStore.ts"],"names":[],"mappings":"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"}
|
||||
6
dist/server/public/app.d.ts
vendored
6
dist/server/public/app.d.ts
vendored
@@ -48,7 +48,6 @@ declare let authToken: string | null;
|
||||
declare let selectedFilePath: string | null;
|
||||
declare const sseMap: Map<string, EventSource>;
|
||||
declare let pollTimer: number | null;
|
||||
declare const $: (sel: string) => HTMLElement;
|
||||
declare const $$: (sel: string) => NodeListOf<HTMLElement>;
|
||||
declare const el: (id: string) => HTMLElement;
|
||||
declare function apiHeaders(): Record<string, string>;
|
||||
@@ -56,12 +55,15 @@ declare function api(method: string, url: string, body?: unknown): Promise<Respo
|
||||
declare function apiJson<T>(method: string, url: string, body?: unknown): Promise<T>;
|
||||
declare function showLoginScreen(): void;
|
||||
declare function showMainScreen(): void;
|
||||
declare function switchTab(name: string): void;
|
||||
declare function activateTab(tablistId: string, tabId: string): void;
|
||||
declare function onTabActivated(tablistId: string, panelId: string): void;
|
||||
declare function wireTablist(tablistId: string): void;
|
||||
declare function escapeHtml(str: string | null | undefined): string;
|
||||
declare function formatSize(bytes: number): string;
|
||||
declare function loadBrowseFiles(): Promise<void>;
|
||||
declare const videoUpload: HTMLInputElement;
|
||||
declare const uploadName: HTMLElement;
|
||||
declare let youtubeStream: EventSource | null;
|
||||
declare function loadJobs(): Promise<void>;
|
||||
declare function renderJobs(jobs: Job[]): void;
|
||||
declare function handleJobAction(id: string, action: string): Promise<void>;
|
||||
|
||||
320
dist/server/public/app.js
vendored
320
dist/server/public/app.js
vendored
@@ -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 “New Job” 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 ───────────────────────────────────────────
|
||||
|
||||
2
dist/server/public/app.js.map
vendored
2
dist/server/public/app.js.map
vendored
File diff suppressed because one or more lines are too long
40
dist/server/routes/config.js
vendored
40
dist/server/routes/config.js
vendored
@@ -2,10 +2,43 @@
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const express_1 = require("express");
|
||||
const jobStore_1 = require("../db/jobStore");
|
||||
const config_1 = require("../../config/config");
|
||||
const router = (0, express_1.Router)();
|
||||
// Optional .env overrides for the (long) prompt strings — keep getDefaultConfig()'s
|
||||
// hardcoded prompts as the final fallback. Users who want to tweak prompts without
|
||||
// editing source can set these in .env, or set them per-job in the Settings UI.
|
||||
const ENV_OVERRIDES = {
|
||||
defaultPrompt: process.env.AIDIO_DEFAULT_PROMPT,
|
||||
changePrompt: process.env.AIDIO_CHANGE_PROMPT,
|
||||
batchPrompt: process.env.AIDIO_BATCH_PROMPT,
|
||||
};
|
||||
// Fields in Config that are nested objects (provider configs, etc.) and shouldn't
|
||||
// be flattened into the form-facing config map. API keys live inside these — keep
|
||||
// them off the wire.
|
||||
const NESTED_FIELDS = new Set(['visionProviders', 'ttsProviders']);
|
||||
function buildLayeredConfig() {
|
||||
const defaults = (0, config_1.getDefaultConfig)();
|
||||
const db = (0, jobStore_1.getAllConfig)();
|
||||
const merged = {};
|
||||
for (const [key, value] of Object.entries(defaults)) {
|
||||
if (NESTED_FIELDS.has(key))
|
||||
continue;
|
||||
if (value === undefined || value === null)
|
||||
continue;
|
||||
merged[key] = String(value);
|
||||
}
|
||||
for (const [key, value] of Object.entries(ENV_OVERRIDES)) {
|
||||
if (value !== undefined && value !== '')
|
||||
merged[key] = value;
|
||||
}
|
||||
for (const [key, value] of Object.entries(db)) {
|
||||
if (value !== undefined && value !== '')
|
||||
merged[key] = value;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
router.get('/', (_req, res) => {
|
||||
const config = (0, jobStore_1.getAllConfig)();
|
||||
res.json({ config });
|
||||
res.json({ config: buildLayeredConfig() });
|
||||
});
|
||||
router.put('/', (req, res) => {
|
||||
const updates = req.body;
|
||||
@@ -16,8 +49,7 @@ router.put('/', (req, res) => {
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
(0, jobStore_1.setConfigValue)(key, String(value));
|
||||
}
|
||||
const config = (0, jobStore_1.getAllConfig)();
|
||||
res.json({ config });
|
||||
res.json({ config: buildLayeredConfig() });
|
||||
});
|
||||
exports.default = router;
|
||||
//# sourceMappingURL=config.js.map
|
||||
2
dist/server/routes/config.js.map
vendored
2
dist/server/routes/config.js.map
vendored
@@ -1 +1 @@
|
||||
{"version":3,"file":"config.js","sourceRoot":"","sources":["../../../src/server/routes/config.ts"],"names":[],"mappings":";;AAAA,qCAAoD;AACpD,6CAA8D;AAE9D,MAAM,MAAM,GAAG,IAAA,gBAAM,GAAE,CAAC;AAExB,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC,IAAa,EAAE,GAAa,EAAE,EAAE;IAC/C,MAAM,MAAM,GAAG,IAAA,uBAAY,GAAE,CAAC;IAC9B,GAAG,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC,CAAC;AACvB,CAAC,CAAC,CAAC;AAEH,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC,GAAY,EAAE,GAAa,EAAE,EAAE;IAC9C,MAAM,OAAO,GAAG,GAAG,CAAC,IAAI,CAAC;IACzB,IAAI,OAAO,OAAO,KAAK,QAAQ,IAAI,OAAO,KAAK,IAAI,EAAE,CAAC;QACpD,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,+CAA+C,EAAE,CAAC,CAAC;QACjF,OAAO;IACT,CAAC;IACD,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC;QACnD,IAAA,yBAAc,EAAC,GAAG,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC;IACrC,CAAC;IACD,MAAM,MAAM,GAAG,IAAA,uBAAY,GAAE,CAAC;IAC9B,GAAG,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC,CAAC;AACvB,CAAC,CAAC,CAAC;AAEH,kBAAe,MAAM,CAAC"}
|
||||
{"version":3,"file":"config.js","sourceRoot":"","sources":["../../../src/server/routes/config.ts"],"names":[],"mappings":";;AAAA,qCAAoD;AACpD,6CAA8D;AAC9D,gDAAuD;AAEvD,MAAM,MAAM,GAAG,IAAA,gBAAM,GAAE,CAAC;AAExB,oFAAoF;AACpF,mFAAmF;AACnF,gFAAgF;AAChF,MAAM,aAAa,GAAuC;IACxD,aAAa,EAAE,OAAO,CAAC,GAAG,CAAC,oBAAoB;IAC/C,YAAY,EAAE,OAAO,CAAC,GAAG,CAAC,mBAAmB;IAC7C,WAAW,EAAE,OAAO,CAAC,GAAG,CAAC,kBAAkB;CAC5C,CAAC;AAEF,kFAAkF;AAClF,kFAAkF;AAClF,qBAAqB;AACrB,MAAM,aAAa,GAAG,IAAI,GAAG,CAAC,CAAC,iBAAiB,EAAE,cAAc,CAAC,CAAC,CAAC;AAEnE,SAAS,kBAAkB;IACzB,MAAM,QAAQ,GAAG,IAAA,yBAAgB,GAAwC,CAAC;IAC1E,MAAM,EAAE,GAAG,IAAA,uBAAY,GAAE,CAAC;IAE1B,MAAM,MAAM,GAA2B,EAAE,CAAC;IAC1C,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAC;QACpD,IAAI,aAAa,CAAC,GAAG,CAAC,GAAG,CAAC;YAAE,SAAS;QACrC,IAAI,KAAK,KAAK,SAAS,IAAI,KAAK,KAAK,IAAI;YAAE,SAAS;QACpD,MAAM,CAAC,GAAG,CAAC,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC;IAC9B,CAAC;IACD,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,aAAa,CAAC,EAAE,CAAC;QACzD,IAAI,KAAK,KAAK,SAAS,IAAI,KAAK,KAAK,EAAE;YAAE,MAAM,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC;IAC/D,CAAC;IACD,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,EAAE,CAAC,EAAE,CAAC;QAC9C,IAAI,KAAK,KAAK,SAAS,IAAI,KAAK,KAAK,EAAE;YAAE,MAAM,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC;IAC/D,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC,IAAa,EAAE,GAAa,EAAE,EAAE;IAC/C,GAAG,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,kBAAkB,EAAE,EAAE,CAAC,CAAC;AAC7C,CAAC,CAAC,CAAC;AAEH,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC,GAAY,EAAE,GAAa,EAAE,EAAE;IAC9C,MAAM,OAAO,GAAG,GAAG,CAAC,IAAI,CAAC;IACzB,IAAI,OAAO,OAAO,KAAK,QAAQ,IAAI,OAAO,KAAK,IAAI,EAAE,CAAC;QACpD,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,+CAA+C,EAAE,CAAC,CAAC;QACjF,OAAO;IACT,CAAC;IACD,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC;QACnD,IAAA,yBAAc,EAAC,GAAG,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC;IACrC,CAAC;IACD,GAAG,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,kBAAkB,EAAE,EAAE,CAAC,CAAC;AAC7C,CAAC,CAAC,CAAC;AAEH,kBAAe,MAAM,CAAC"}
|
||||
72
dist/server/routes/files.js
vendored
72
dist/server/routes/files.js
vendored
@@ -65,23 +65,69 @@ router.get('/', (_req, res) => {
|
||||
.sort((a, b) => b.filePath.localeCompare(a.filePath));
|
||||
res.json({ files });
|
||||
});
|
||||
router.post('/youtube', (req, res) => {
|
||||
router.delete('/:filename', (req, res) => {
|
||||
const raw = req.params.filename;
|
||||
const requested = Array.isArray(raw) ? raw[0] : raw;
|
||||
if (!requested) {
|
||||
res.status(400).json({ error: 'filename is required' });
|
||||
return;
|
||||
}
|
||||
const resolved = path_1.default.resolve(UPLOADS_DIR, requested);
|
||||
const uploadsWithSep = UPLOADS_DIR.endsWith(path_1.default.sep) ? UPLOADS_DIR : UPLOADS_DIR + path_1.default.sep;
|
||||
if (!resolved.startsWith(uploadsWithSep)) {
|
||||
res.status(400).json({ error: 'Invalid filename' });
|
||||
return;
|
||||
}
|
||||
if (!fs_1.default.existsSync(resolved)) {
|
||||
res.status(404).json({ error: 'File not found' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
fs_1.default.unlinkSync(resolved);
|
||||
res.json({ ok: true });
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: `Failed to delete: ${err.message}` });
|
||||
}
|
||||
});
|
||||
// Stream yt-dlp download progress over SSE.
|
||||
// Returns events: {type:'progress', percent} ... {type:'done', filePath, filename, title}
|
||||
// or {type:'error', message}
|
||||
router.get('/youtube/stream', (req, res) => {
|
||||
const url = req.query.url || '';
|
||||
if (!url) {
|
||||
res.status(400).json({ error: 'url query param is required' });
|
||||
return;
|
||||
}
|
||||
if (!(0, ytDlp_1.isYtDlpAvailable)()) {
|
||||
res.status(400).json({ error: 'yt-dlp is not installed or not in PATH' });
|
||||
return;
|
||||
}
|
||||
const { url } = req.body;
|
||||
if (!url) {
|
||||
res.status(400).json({ error: 'URL is required' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = (0, ytDlp_1.downloadVideo)(url, UPLOADS_DIR);
|
||||
res.json(result);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: `Failed to download: ${err.message}` });
|
||||
}
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.setHeader('X-Accel-Buffering', 'no');
|
||||
res.flushHeaders?.();
|
||||
const send = (data) => {
|
||||
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
let clientGone = false;
|
||||
req.on('close', () => { clientGone = true; });
|
||||
(0, ytDlp_1.downloadVideo)(url, UPLOADS_DIR, (percent) => {
|
||||
if (clientGone)
|
||||
return;
|
||||
send({ type: 'progress', percent });
|
||||
}).then((result) => {
|
||||
if (clientGone)
|
||||
return;
|
||||
send({ type: 'done', ...result });
|
||||
res.end();
|
||||
}).catch((err) => {
|
||||
if (clientGone)
|
||||
return;
|
||||
send({ type: 'error', message: err.message });
|
||||
res.end();
|
||||
});
|
||||
});
|
||||
exports.default = router;
|
||||
//# sourceMappingURL=files.js.map
|
||||
2
dist/server/routes/files.js.map
vendored
2
dist/server/routes/files.js.map
vendored
File diff suppressed because one or more lines are too long
7
dist/server/routes/jobs.js
vendored
7
dist/server/routes/jobs.js
vendored
@@ -145,10 +145,13 @@ function createJobsRouter(jobManager) {
|
||||
filePath = format === 'vtt' ? job.output_subtitles_vtt : job.output_subtitles_srt;
|
||||
filename = `${path_1.default.basename(job.video_filename, path_1.default.extname(job.video_filename))}_description.${format}`;
|
||||
break;
|
||||
case 'muxed':
|
||||
case 'muxed': {
|
||||
const opts = JSON.parse(job.output_options || '{}');
|
||||
const suffix = opts.muxMode === 'mixed' ? '_described_mixed' : '_described';
|
||||
filePath = job.output_muxed;
|
||||
filename = `${path_1.default.basename(job.video_filename, path_1.default.extname(job.video_filename))}_described.mkv`;
|
||||
filename = `${path_1.default.basename(job.video_filename, path_1.default.extname(job.video_filename))}${suffix}.mkv`;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
res.status(400).json({ error: 'Invalid download type' });
|
||||
return;
|
||||
|
||||
2
dist/server/routes/jobs.js.map
vendored
2
dist/server/routes/jobs.js.map
vendored
File diff suppressed because one or more lines are too long
61
dist/server/services/jobManager.js
vendored
61
dist/server/services/jobManager.js
vendored
@@ -13,6 +13,20 @@ const muxer_1 = require("./muxer");
|
||||
const config_1 = require("../../config/config");
|
||||
const mediaUtils_1 = require("../../utils/mediaUtils");
|
||||
const events_1 = require("events");
|
||||
function jobTempDir(baseTempDir, jobId) {
|
||||
return path_1.default.join(baseTempDir, jobId);
|
||||
}
|
||||
function safeCleanupJobTmp(dir) {
|
||||
try {
|
||||
if (!fs_1.default.existsSync(dir))
|
||||
return;
|
||||
(0, mediaUtils_1.cleanupTempFiles)(dir);
|
||||
fs_1.default.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
catch (err) {
|
||||
console.warn(`Failed to clean up tmp dir ${dir}:`, err.message);
|
||||
}
|
||||
}
|
||||
class JobManager {
|
||||
constructor() {
|
||||
this.queue = [];
|
||||
@@ -33,12 +47,22 @@ class JobManager {
|
||||
}
|
||||
createJob(videoPath, configOverride = {}, outputOptions = {}) {
|
||||
const baseConfig = (0, config_1.getDefaultConfig)();
|
||||
const mergedConfig = { ...baseConfig, ...configOverride };
|
||||
// Drop empty/undefined/null values so blank form fields don't clobber the
|
||||
// baked-in defaults (a blank prompt textarea must NOT overwrite the real
|
||||
// prompt with "").
|
||||
const cleanedOverride = {};
|
||||
for (const [k, v] of Object.entries(configOverride)) {
|
||||
if (v === '' || v === null || v === undefined)
|
||||
continue;
|
||||
cleanedOverride[k] = v;
|
||||
}
|
||||
const mergedConfig = { ...baseConfig, ...cleanedOverride };
|
||||
const filename = path_1.default.basename(videoPath);
|
||||
const opts = {
|
||||
audio: outputOptions.audio !== false,
|
||||
subtitles: outputOptions.subtitles !== false,
|
||||
muxed: outputOptions.muxed || false
|
||||
muxed: outputOptions.muxed || false,
|
||||
muxMode: outputOptions.muxMode === 'mixed' ? 'mixed' : 'separate'
|
||||
};
|
||||
return (0, jobStore_1.createJob)(videoPath, filename, mergedConfig, opts);
|
||||
}
|
||||
@@ -92,6 +116,18 @@ class JobManager {
|
||||
throw new Error('Job not found');
|
||||
if (job.status === 'processing')
|
||||
throw new Error('Cannot delete a running job');
|
||||
try {
|
||||
const config = JSON.parse(job.config);
|
||||
// job.config may contain either the base tempDir (older jobs) or the
|
||||
// per-job tempDir (newer jobs). Trim a trailing job-id segment if present;
|
||||
// otherwise compute the per-job dir from the stored base.
|
||||
const stored = config.tempDir || './desc/tmp/';
|
||||
const candidate = path_1.default.basename(stored) === jobId ? stored : jobTempDir(stored, jobId);
|
||||
safeCleanupJobTmp(candidate);
|
||||
}
|
||||
catch {
|
||||
// ignore: cleanup is best-effort and must not block deletion
|
||||
}
|
||||
(0, jobStore_1.deleteJob)(jobId);
|
||||
}
|
||||
listJobs() {
|
||||
@@ -156,6 +192,14 @@ class JobManager {
|
||||
this.emitProgress(job.id);
|
||||
const config = JSON.parse(job.config);
|
||||
const outputOptions = JSON.parse(job.output_options);
|
||||
// Isolate this job's intermediates so concurrent jobs (or future resumes)
|
||||
// don't collide on filenames like frame_00001.jpg / segment_3_std.wav.
|
||||
// The pipeline already reads config.tempDir, so just override it here.
|
||||
const baseTempDir = config.tempDir || './desc/tmp/';
|
||||
if (path_1.default.basename(baseTempDir) !== job.id) {
|
||||
config.tempDir = jobTempDir(baseTempDir, job.id);
|
||||
}
|
||||
fs_1.default.mkdirSync(config.tempDir, { recursive: true });
|
||||
const existingSegments = JSON.parse(job.segments || '[]');
|
||||
const lastContext = JSON.parse(job.last_context || '{}');
|
||||
const startIndex = existingSegments.length > 0 ? job.current_index : 0;
|
||||
@@ -209,8 +253,14 @@ class JobManager {
|
||||
outputSubtitlesVtt = vttPath;
|
||||
}
|
||||
if (outputOptions.muxed && fs_1.default.existsSync(outputAudio)) {
|
||||
const muxedPath = path_1.default.join(outputDir, `${baseName}_described.mkv`);
|
||||
(0, muxer_1.muxAudioDescription)(job.video_path, outputAudio, muxedPath);
|
||||
const isMixed = outputOptions.muxMode === 'mixed';
|
||||
const muxedPath = path_1.default.join(outputDir, `${baseName}${isMixed ? '_described_mixed' : '_described'}.mkv`);
|
||||
if (isMixed) {
|
||||
(0, muxer_1.muxMixedAudioDescription)(job.video_path, outputAudio, muxedPath);
|
||||
}
|
||||
else {
|
||||
(0, muxer_1.muxAudioDescription)(job.video_path, outputAudio, muxedPath);
|
||||
}
|
||||
outputMuxed = muxedPath;
|
||||
}
|
||||
(0, jobStore_1.saveJobOutputs)(job.id, {
|
||||
@@ -222,9 +272,11 @@ class JobManager {
|
||||
(0, jobStore_1.saveCheckpoint)(job.id, JSON.stringify(segments), totalUnits, totalUnits, 0, '{}', 100);
|
||||
(0, jobStore_1.updateJobStatus)(job.id, 'completed');
|
||||
this.emitProgress(job.id);
|
||||
safeCleanupJobTmp(config.tempDir);
|
||||
}
|
||||
catch (err) {
|
||||
if (err.message === 'JOB_PAUSED') {
|
||||
// Keep config.tempDir intact — restart will resume into the same dir.
|
||||
(0, jobStore_1.updateJobStatus)(job.id, 'paused');
|
||||
this.emitProgress(job.id);
|
||||
return;
|
||||
@@ -232,6 +284,7 @@ class JobManager {
|
||||
const errorMsg = err.message || 'Unknown error';
|
||||
(0, jobStore_1.updateJobStatus)(job.id, 'failed', errorMsg);
|
||||
this.emitProgress(job.id);
|
||||
safeCleanupJobTmp(config.tempDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
2
dist/server/services/jobManager.js.map
vendored
2
dist/server/services/jobManager.js.map
vendored
File diff suppressed because one or more lines are too long
1
dist/server/services/muxer.d.ts
vendored
1
dist/server/services/muxer.d.ts
vendored
@@ -1 +1,2 @@
|
||||
export declare function muxAudioDescription(videoPath: string, audioPath: string, outputPath: string): void;
|
||||
export declare function muxMixedAudioDescription(videoPath: string, audioPath: string, outputPath: string): void;
|
||||
|
||||
90
dist/server/services/muxer.js
vendored
90
dist/server/services/muxer.js
vendored
@@ -4,26 +4,80 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.muxAudioDescription = muxAudioDescription;
|
||||
exports.muxMixedAudioDescription = muxMixedAudioDescription;
|
||||
const child_process_1 = require("child_process");
|
||||
const path_1 = __importDefault(require("path"));
|
||||
const fs_1 = __importDefault(require("fs"));
|
||||
function muxAudioDescription(videoPath, audioPath, outputPath) {
|
||||
const ext = path_1.default.extname(outputPath).toLowerCase();
|
||||
const isMkv = ext === '.mkv';
|
||||
const cmd = [
|
||||
'ffmpeg -v error',
|
||||
`-i "${videoPath}"`,
|
||||
`-i "${audioPath}"`,
|
||||
'-map 0:v',
|
||||
'-map 0:a?',
|
||||
'-map 1:a',
|
||||
'-c:v copy',
|
||||
'-c:a copy',
|
||||
isMkv
|
||||
? '-metadata:s:a:1 title="Audio Description"'
|
||||
: '-metadata:s:a:1 title="Audio Description"',
|
||||
`"${outputPath}"`,
|
||||
'-y'
|
||||
].join(' ');
|
||||
(0, child_process_1.execSync)(cmd);
|
||||
if (!fs_1.default.existsSync(videoPath)) {
|
||||
throw new Error(`mux: video not found: ${videoPath}`);
|
||||
}
|
||||
if (!fs_1.default.existsSync(audioPath)) {
|
||||
throw new Error(`mux: audio not found: ${audioPath}`);
|
||||
}
|
||||
fs_1.default.mkdirSync(path_1.default.dirname(outputPath), { recursive: true });
|
||||
// Argv form — no shell, no quoting issues, and -y is a global option (placed
|
||||
// up front, not after the output). Stderr is captured so failures aren't
|
||||
// silent.
|
||||
const args = [
|
||||
'-y',
|
||||
'-v', 'error',
|
||||
'-i', videoPath,
|
||||
'-i', audioPath,
|
||||
'-map', '0:v',
|
||||
'-map', '0:a?',
|
||||
'-map', '1:a',
|
||||
'-c:v', 'copy',
|
||||
'-c:a', 'copy',
|
||||
'-metadata:s:a:1', 'title=Audio Description',
|
||||
'-disposition:a:1', 'visual_impaired',
|
||||
outputPath,
|
||||
];
|
||||
const result = (0, child_process_1.spawnSync)('ffmpeg', args, { shell: false, encoding: 'utf-8' });
|
||||
if (result.error) {
|
||||
throw new Error(`mux: ffmpeg failed to start: ${result.error.message}`);
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
const tail = (result.stderr || '').trim().split('\n').slice(-5).join(' | ');
|
||||
throw new Error(`mux: ffmpeg exited ${result.status}: ${tail || '(no stderr)'}`);
|
||||
}
|
||||
}
|
||||
function muxMixedAudioDescription(videoPath, audioPath, outputPath) {
|
||||
if (!fs_1.default.existsSync(videoPath)) {
|
||||
throw new Error(`mux: video not found: ${videoPath}`);
|
||||
}
|
||||
if (!fs_1.default.existsSync(audioPath)) {
|
||||
throw new Error(`mux: audio not found: ${audioPath}`);
|
||||
}
|
||||
fs_1.default.mkdirSync(path_1.default.dirname(outputPath), { recursive: true });
|
||||
// Sidechain-ducked mix: original audio dips when the AD track is speaking,
|
||||
// then both are summed into a single output audio stream. The AD track is
|
||||
// already a full-length file that is silent between description segments
|
||||
// (built by combineAudioSegments), so asplit gives us one copy to drive the
|
||||
// sidechain detector and another to mix in on top.
|
||||
const filterGraph = '[1:a]asplit=2[ad_mix][ad_sc];' +
|
||||
'[0:a][ad_sc]sidechaincompress=threshold=0.03:ratio=20:attack=5:release=300:level_sc=2[ducked];' +
|
||||
'[ducked][ad_mix]amix=inputs=2:duration=first:dropout_transition=0:normalize=0[aout]';
|
||||
const args = [
|
||||
'-y',
|
||||
'-v', 'error',
|
||||
'-i', videoPath,
|
||||
'-i', audioPath,
|
||||
'-filter_complex', filterGraph,
|
||||
'-map', '0:v',
|
||||
'-map', '[aout]',
|
||||
'-c:v', 'copy',
|
||||
'-c:a', 'aac',
|
||||
'-b:a', '192k',
|
||||
outputPath,
|
||||
];
|
||||
const result = (0, child_process_1.spawnSync)('ffmpeg', args, { shell: false, encoding: 'utf-8' });
|
||||
if (result.error) {
|
||||
throw new Error(`mux: ffmpeg failed to start: ${result.error.message}`);
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
const tail = (result.stderr || '').trim().split('\n').slice(-5).join(' | ');
|
||||
throw new Error(`mux: ffmpeg exited ${result.status}: ${tail || '(no stderr)'}`);
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=muxer.js.map
|
||||
2
dist/server/services/muxer.js.map
vendored
2
dist/server/services/muxer.js.map
vendored
@@ -1 +1 @@
|
||||
{"version":3,"file":"muxer.js","sourceRoot":"","sources":["../../../src/server/services/muxer.ts"],"names":[],"mappings":";;;;;AAGA,kDAyBC;AA5BD,iDAAyC;AACzC,gDAAwB;AAExB,SAAgB,mBAAmB,CACjC,SAAiB,EACjB,SAAiB,EACjB,UAAkB;IAElB,MAAM,GAAG,GAAG,cAAI,CAAC,OAAO,CAAC,UAAU,CAAC,CAAC,WAAW,EAAE,CAAC;IACnD,MAAM,KAAK,GAAG,GAAG,KAAK,MAAM,CAAC;IAE7B,MAAM,GAAG,GAAG;QACV,iBAAiB;QACjB,OAAO,SAAS,GAAG;QACnB,OAAO,SAAS,GAAG;QACnB,UAAU;QACV,WAAW;QACX,UAAU;QACV,WAAW;QACX,WAAW;QACX,KAAK;YACH,CAAC,CAAC,2CAA2C;YAC7C,CAAC,CAAC,2CAA2C;QAC/C,IAAI,UAAU,GAAG;QACjB,IAAI;KACL,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAEZ,IAAA,wBAAQ,EAAC,GAAG,CAAC,CAAC;AAChB,CAAC"}
|
||||
{"version":3,"file":"muxer.js","sourceRoot":"","sources":["../../../src/server/services/muxer.ts"],"names":[],"mappings":"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"}
|
||||
3
dist/server/services/ytDlp.d.ts
vendored
3
dist/server/services/ytDlp.d.ts
vendored
@@ -3,5 +3,6 @@ export interface YtDlpResult {
|
||||
filename: string;
|
||||
title: string;
|
||||
}
|
||||
export type YtDlpProgress = (percent: number) => void;
|
||||
export declare function isYtDlpAvailable(): boolean;
|
||||
export declare function downloadVideo(url: string, outputDir: string): YtDlpResult;
|
||||
export declare function downloadVideo(url: string, outputDir: string, onProgress?: YtDlpProgress): Promise<YtDlpResult>;
|
||||
|
||||
110
dist/server/services/ytDlp.js
vendored
110
dist/server/services/ytDlp.js
vendored
@@ -17,22 +17,98 @@ function isYtDlpAvailable() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
function downloadVideo(url, outputDir) {
|
||||
if (!fs_1.default.existsSync(outputDir)) {
|
||||
fs_1.default.mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
const outputTemplate = path_1.default.join(outputDir, '%(title)s.%(ext)s');
|
||||
const result = (0, child_process_1.execSync)(`yt-dlp -f "best[ext=mp4]/best" -o "${outputTemplate}" --print filename --print title "${url}"`, { encoding: 'utf-8', timeout: 600000 });
|
||||
const lines = result.trim().split('\n');
|
||||
const filename = lines[0]?.trim();
|
||||
const title = lines[1]?.trim() || filename;
|
||||
if (!filename) {
|
||||
throw new Error('yt-dlp: Failed to parse downloaded filename');
|
||||
}
|
||||
const filePath = path_1.default.resolve(outputDir, filename);
|
||||
if (!fs_1.default.existsSync(filePath)) {
|
||||
throw new Error(`yt-dlp: Downloaded file not found at ${filePath}`);
|
||||
}
|
||||
return { filePath, filename, title };
|
||||
const PROGRESS_PREFIX = 'PROG ';
|
||||
function downloadVideo(url, outputDir, onProgress) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!fs_1.default.existsSync(outputDir)) {
|
||||
fs_1.default.mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
const outputTemplate = path_1.default.join(outputDir, '%(title)s.%(ext)s');
|
||||
// Pass arguments as an array — no shell, no quoting issues, no truncation
|
||||
// on URLs containing & | % ^ etc. (the original execSync bug on Windows).
|
||||
const args = [
|
||||
'-f', 'best[ext=mp4]/best',
|
||||
'-o', outputTemplate,
|
||||
'--newline',
|
||||
'--progress-template', `${PROGRESS_PREFIX}%(progress._percent_str)s`,
|
||||
'--print', 'after_move:filepath',
|
||||
'--print', 'title',
|
||||
'--no-simulate',
|
||||
url,
|
||||
];
|
||||
const child = (0, child_process_1.spawn)('yt-dlp', args, { shell: false });
|
||||
const stderrLines = [];
|
||||
const outputLines = [];
|
||||
let stdoutBuf = '';
|
||||
let stderrBuf = '';
|
||||
const handleStdoutLine = (line) => {
|
||||
if (!line)
|
||||
return;
|
||||
if (line.startsWith(PROGRESS_PREFIX)) {
|
||||
const m = line.slice(PROGRESS_PREFIX.length).match(/([\d.]+)\s*%/);
|
||||
if (m && onProgress) {
|
||||
const pct = parseFloat(m[1]);
|
||||
if (!isNaN(pct))
|
||||
onProgress(pct);
|
||||
}
|
||||
return;
|
||||
}
|
||||
outputLines.push(line);
|
||||
};
|
||||
child.stdout.on('data', (chunk) => {
|
||||
stdoutBuf += chunk.toString('utf-8');
|
||||
let nl;
|
||||
while ((nl = stdoutBuf.indexOf('\n')) !== -1) {
|
||||
const line = stdoutBuf.slice(0, nl).replace(/\r$/, '');
|
||||
stdoutBuf = stdoutBuf.slice(nl + 1);
|
||||
handleStdoutLine(line.trim());
|
||||
}
|
||||
});
|
||||
child.stderr.on('data', (chunk) => {
|
||||
stderrBuf += chunk.toString('utf-8');
|
||||
let nl;
|
||||
while ((nl = stderrBuf.indexOf('\n')) !== -1) {
|
||||
const line = stderrBuf.slice(0, nl).replace(/\r$/, '').trim();
|
||||
stderrBuf = stderrBuf.slice(nl + 1);
|
||||
if (line)
|
||||
stderrLines.push(line);
|
||||
}
|
||||
});
|
||||
child.on('error', (err) => {
|
||||
reject(new Error(`yt-dlp failed to start: ${err.message}`));
|
||||
});
|
||||
const timeoutMs = 600000;
|
||||
const timer = setTimeout(() => {
|
||||
child.kill();
|
||||
reject(new Error(`yt-dlp timed out after ${timeoutMs / 1000}s`));
|
||||
}, timeoutMs);
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timer);
|
||||
if (stdoutBuf.trim())
|
||||
handleStdoutLine(stdoutBuf.trim());
|
||||
if (stderrBuf.trim())
|
||||
stderrLines.push(stderrBuf.trim());
|
||||
if (code !== 0) {
|
||||
const tail = stderrLines.slice(-3).join(' | ') || `exit code ${code}`;
|
||||
reject(new Error(tail));
|
||||
return;
|
||||
}
|
||||
const filePath = outputLines[0];
|
||||
const title = outputLines[1] || (filePath ? path_1.default.basename(filePath) : '');
|
||||
if (!filePath) {
|
||||
reject(new Error('yt-dlp completed but did not report a filename'));
|
||||
return;
|
||||
}
|
||||
if (!fs_1.default.existsSync(filePath)) {
|
||||
reject(new Error(`yt-dlp reported success but file not found: ${filePath}`));
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
filePath: path_1.default.resolve(filePath),
|
||||
filename: path_1.default.basename(filePath),
|
||||
title,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
//# sourceMappingURL=ytDlp.js.map
|
||||
2
dist/server/services/ytDlp.js.map
vendored
2
dist/server/services/ytDlp.js.map
vendored
@@ -1 +1 @@
|
||||
{"version":3,"file":"ytDlp.js","sourceRoot":"","sources":["../../../src/server/services/ytDlp.ts"],"names":[],"mappings":";;;;;AAUA,4CAOC;AAED,sCA2BC;AA9CD,iDAAyC;AACzC,gDAAwB;AACxB,4CAAoB;AAQpB,SAAgB,gBAAgB;IAC9B,IAAI,CAAC;QACH,IAAA,wBAAQ,EAAC,kBAAkB,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,CAAC,CAAC;QAChD,OAAO,IAAI,CAAC;IACd,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED,SAAgB,aAAa,CAAC,GAAW,EAAE,SAAiB;IAC1D,IAAI,CAAC,YAAE,CAAC,UAAU,CAAC,SAAS,CAAC,EAAE,CAAC;QAC9B,YAAE,CAAC,SAAS,CAAC,SAAS,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAC/C,CAAC;IAED,MAAM,cAAc,GAAG,cAAI,CAAC,IAAI,CAAC,SAAS,EAAE,mBAAmB,CAAC,CAAC;IAEjE,MAAM,MAAM,GAAG,IAAA,wBAAQ,EACrB,sCAAsC,cAAc,qCAAqC,GAAG,GAAG,EAC/F,EAAE,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,CACvC,CAAC;IAEF,MAAM,KAAK,GAAG,MAAM,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IACxC,MAAM,QAAQ,GAAG,KAAK,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC;IAClC,MAAM,KAAK,GAAG,KAAK,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,IAAI,QAAQ,CAAC;IAE3C,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,MAAM,IAAI,KAAK,CAAC,6CAA6C,CAAC,CAAC;IACjE,CAAC;IAED,MAAM,QAAQ,GAAG,cAAI,CAAC,OAAO,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC;IAEnD,IAAI,CAAC,YAAE,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE,CAAC;QAC7B,MAAM,IAAI,KAAK,CAAC,wCAAwC,QAAQ,EAAE,CAAC,CAAC;IACtE,CAAC;IAED,OAAO,EAAE,QAAQ,EAAE,QAAQ,EAAE,KAAK,EAAE,CAAC;AACvC,CAAC"}
|
||||
{"version":3,"file":"ytDlp.js","sourceRoot":"","sources":["../../../src/server/services/ytDlp.ts"],"names":[],"mappings":";;;;;AAYA,4CAOC;AAID,sCA2GC;AAlID,iDAAgD;AAChD,gDAAwB;AACxB,4CAAoB;AAUpB,SAAgB,gBAAgB;IAC9B,IAAI,CAAC;QACH,IAAA,wBAAQ,EAAC,kBAAkB,EAAE,EAAE,KAAK,EAAE,MAAM,EAAE,CAAC,CAAC;QAChD,OAAO,IAAI,CAAC;IACd,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED,MAAM,eAAe,GAAG,OAAO,CAAC;AAEhC,SAAgB,aAAa,CAC3B,GAAW,EACX,SAAiB,EACjB,UAA0B;IAE1B,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACrC,IAAI,CAAC,YAAE,CAAC,UAAU,CAAC,SAAS,CAAC,EAAE,CAAC;YAC9B,YAAE,CAAC,SAAS,CAAC,SAAS,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QAC/C,CAAC;QAED,MAAM,cAAc,GAAG,cAAI,CAAC,IAAI,CAAC,SAAS,EAAE,mBAAmB,CAAC,CAAC;QAEjE,0EAA0E;QAC1E,0EAA0E;QAC1E,MAAM,IAAI,GAAG;YACX,IAAI,EAAE,oBAAoB;YAC1B,IAAI,EAAE,cAAc;YACpB,WAAW;YACX,qBAAqB,EAAE,GAAG,eAAe,2BAA2B;YACpE,SAAS,EAAE,qBAAqB;YAChC,SAAS,EAAE,OAAO;YAClB,eAAe;YACf,GAAG;SACJ,CAAC;QAEF,MAAM,KAAK,GAAG,IAAA,qBAAK,EAAC,QAAQ,EAAE,IAAI,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC,CAAC;QAEtD,MAAM,WAAW,GAAa,EAAE,CAAC;QACjC,MAAM,WAAW,GAAa,EAAE,CAAC;QACjC,IAAI,SAAS,GAAG,EAAE,CAAC;QACnB,IAAI,SAAS,GAAG,EAAE,CAAC;QAEnB,MAAM,gBAAgB,GAAG,CAAC,IAAY,EAAE,EAAE;YACxC,IAAI,CAAC,IAAI;gBAAE,OAAO;YAClB,IAAI,IAAI,CAAC,UAAU,CAAC,eAAe,CAAC,EAAE,CAAC;gBACrC,MAAM,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,eAAe,CAAC,MAAM,CAAC,CAAC,KAAK,CAAC,cAAc,CAAC,CAAC;gBACnE,IAAI,CAAC,IAAI,UAAU,EAAE,CAAC;oBACpB,MAAM,GAAG,GAAG,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;oBAC7B,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC;wBAAE,UAAU,CAAC,GAAG,CAAC,CAAC;gBACnC,CAAC;gBACD,OAAO;YACT,CAAC;YACD,WAAW,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACzB,CAAC,CAAC;QAEF,KAAK,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,KAAa,EAAE,EAAE;YACxC,SAAS,IAAI,KAAK,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC;YACrC,IAAI,EAAE,CAAC;YACP,OAAO,CAAC,EAAE,GAAG,SAAS,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC;gBAC7C,MAAM,IAAI,GAAG,SAAS,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;gBACvD,SAAS,GAAG,SAAS,CAAC,KAAK,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC;gBACpC,gBAAgB,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC;YAChC,CAAC;QACH,CAAC,CAAC,CAAC;QAEH,KAAK,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,KAAa,EAAE,EAAE;YACxC,SAAS,IAAI,KAAK,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC;YACrC,IAAI,EAAE,CAAC;YACP,OAAO,CAAC,EAAE,GAAG,SAAS,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC;gBAC7C,MAAM,IAAI,GAAG,SAAS,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;gBAC9D,SAAS,GAAG,SAAS,CAAC,KAAK,CAAC,EAAE,GAAG,CAAC,CAAC,CAAC;gBACpC,IAAI,IAAI;oBAAE,WAAW,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YACnC,CAAC;QACH,CAAC,CAAC,CAAC;QAEH,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,GAAG,EAAE,EAAE;YACxB,MAAM,CAAC,IAAI,KAAK,CAAC,2BAA2B,GAAG,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC;QAC9D,CAAC,CAAC,CAAC;QAEH,MAAM,SAAS,GAAG,MAAM,CAAC;QACzB,MAAM,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE;YAC5B,KAAK,CAAC,IAAI,EAAE,CAAC;YACb,MAAM,CAAC,IAAI,KAAK,CAAC,0BAA0B,SAAS,GAAG,IAAI,GAAG,CAAC,CAAC,CAAC;QACnE,CAAC,EAAE,SAAS,CAAC,CAAC;QAEd,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,IAAI,EAAE,EAAE;YACzB,YAAY,CAAC,KAAK,CAAC,CAAC;YAEpB,IAAI,SAAS,CAAC,IAAI,EAAE;gBAAE,gBAAgB,CAAC,SAAS,CAAC,IAAI,EAAE,CAAC,CAAC;YACzD,IAAI,SAAS,CAAC,IAAI,EAAE;gBAAE,WAAW,CAAC,IAAI,CAAC,SAAS,CAAC,IAAI,EAAE,CAAC,CAAC;YAEzD,IAAI,IAAI,KAAK,CAAC,EAAE,CAAC;gBACf,MAAM,IAAI,GAAG,WAAW,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,aAAa,IAAI,EAAE,CAAC;gBACtE,MAAM,CAAC,IAAI,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC;gBACxB,OAAO;YACT,CAAC;YAED,MAAM,QAAQ,GAAG,WAAW,CAAC,CAAC,CAAC,CAAC;YAChC,MAAM,KAAK,GAAG,WAAW,CAAC,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,CAAC,cAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;YAE1E,IAAI,CAAC,QAAQ,EAAE,CAAC;gBACd,MAAM,CAAC,IAAI,KAAK,CAAC,gDAAgD,CAAC,CAAC,CAAC;gBACpE,OAAO;YACT,CAAC;YAED,IAAI,CAAC,YAAE,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE,CAAC;gBAC7B,MAAM,CAAC,IAAI,KAAK,CAAC,+CAA+C,QAAQ,EAAE,CAAC,CAAC,CAAC;gBAC7E,OAAO;YACT,CAAC;YAED,OAAO,CAAC;gBACN,QAAQ,EAAE,cAAI,CAAC,OAAO,CAAC,QAAQ,CAAC;gBAChC,QAAQ,EAAE,cAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC;gBACjC,KAAK;aACN,CAAC,CAAC;QACL,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;AACL,CAAC"}
|
||||
Reference in New Issue
Block a user