Files
video_upscaler/static/app.js
T

3316 lines
140 KiB
JavaScript

// App State
let uploadFileId = null;
let uploadMetadata = null;
let currentJobId = null;
let socket = null;
let sliderInitialized = false;
// Error formatting helper
function getDownloadUrl(filePath) {
if (!filePath) return "";
if (filePath.startsWith("http://") || filePath.startsWith("https://")) return filePath;
if (filePath.startsWith("/")) {
const filename = filePath.split("/").pop();
return `/api/download/${encodeURIComponent(filename)}?path=${encodeURIComponent(filePath)}`;
}
return `/api/download/${encodeURIComponent(filePath)}`;
}
function formatFetchError(err, defaultMsg) {
if (!err) return defaultMsg;
if (typeof err === "string") return err;
if (err.detail) {
if (typeof err.detail === "string") {
return err.detail;
}
if (Array.isArray(err.detail)) {
return err.detail.map(d => {
const locStr = (d.loc && Array.isArray(d.loc)) ? d.loc.join(".") : "";
return locStr ? `${locStr}: ${d.msg}` : (d.msg || JSON.stringify(d));
}).join("\n");
}
if (typeof err.detail === "object") {
return err.detail.message || err.detail.error || JSON.stringify(err.detail);
}
}
if (err.message) return err.message;
return JSON.stringify(err) || defaultMsg;
}
// DOM Elements
const stepUpload = document.getElementById("step-upload");
const stepConfig = document.getElementById("step-config");
const stepProgress = document.getElementById("step-progress");
const stepFinished = document.getElementById("step-finished");
const dropZone = document.getElementById("drop-zone");
const fileInput = document.getElementById("file-input");
// Meta fields
const metaName = document.getElementById("meta-name");
const metaSize = document.getElementById("meta-size");
const metaRes = document.getElementById("meta-res");
const metaDuration = document.getElementById("meta-duration");
const metaFps = document.getElementById("meta-fps");
// Config inputs
const modelSelect = document.getElementById("model-select");
const scaleRange = document.getElementById("scale-range");
const scaleValue = document.getElementById("scale-value");
const tileRange = document.getElementById("tile-range");
const tileValue = document.getElementById("tile-value");
const audioToggle = document.getElementById("audio-toggle");
// Timeline and Preview elements
const timelineSlider = document.getElementById("timeline-slider");
const timelineTime = document.getElementById("timeline-time");
const generatePreviewBtn = document.getElementById("generate-preview-btn");
const generateVideoPreviewBtn = document.getElementById("generate-video-preview-btn");
const previewPlaceholderBox = document.getElementById("preview-placeholder-box");
const comparisonSliderContainer = document.getElementById("comparison-slider-container");
const previewLoader = document.getElementById("preview-loader");
const imgOriginal = document.getElementById("img-original");
const imgUpscaled = document.getElementById("img-upscaled");
const sliderSplit = document.getElementById("slider-split");
// Progress elements
const jobIdDisplay = document.getElementById("job-id-display");
const statusBadge = document.getElementById("status-badge");
const progressPercent = document.getElementById("progress-percent");
const progressBarFill = document.getElementById("progress-bar-fill");
const statStatus = document.getElementById("stat-status");
const statFrames = document.getElementById("stat-frames");
const statEta = document.getElementById("stat-eta");
const logTerminal = document.getElementById("log-terminal");
const cancelUpscaleBtn = document.getElementById("cancel-upscale-btn");
const startUpscaleBtn = document.getElementById("start-upscale-btn");
// Finished elements
const downloadLink = document.getElementById("download-link");
const finishedPlayComparisonBtn = document.getElementById("finished-play-comparison-btn");
// Synced Playback Modal Elements
const videoModal = document.getElementById("video-modal");
const modalVideoOriginal = document.getElementById("modal-video-original");
const modalVideoUpscaled = document.getElementById("modal-video-upscaled");
const modalPlayBtn = document.getElementById("modal-play-btn");
const modalTimeSlider = document.getElementById("modal-time-slider");
const modalTimeCurrent = document.getElementById("modal-time-current");
const modalTimeDuration = document.getElementById("modal-time-duration");
const modalMuteBtn = document.getElementById("modal-mute-btn");
const modalCloseBtn = document.getElementById("modal-close-btn");
/* ----------------------------------------------------
1. Theme Selector
---------------------------------------------------- */
const themeSelect = document.getElementById("theme-select");
function applyTheme(theme) {
document.body.className = "";
if (theme && theme !== "cyberpunk") {
document.body.classList.add(`theme-${theme}`);
} else {
document.body.classList.add("theme-cyberpunk");
}
localStorage.setItem("selected-theme", theme);
if (themeSelect) themeSelect.value = theme;
}
if (themeSelect) {
themeSelect.addEventListener("change", (e) => {
applyTheme(e.target.value);
});
}
applyTheme(localStorage.getItem("selected-theme") || "cyberpunk");
/* ----------------------------------------------------
2. Dynamic Sliders & Parameter Listeners
---------------------------------------------------- */
scaleRange.addEventListener("input", (e) => {
scaleValue.textContent = `${e.target.value}x`;
// Lock model scaling if needed: Real-ESRGAN x4 models are scale 4. Real-ESRGAN-animevideov3 supports 2,3,4.
const model = modelSelect.value;
if (model.includes("x4plus") && e.target.value !== "4") {
scaleRange.value = 4;
scaleValue.textContent = "4x (Locked)";
}
});
modelSelect.addEventListener("change", (e) => {
const val = e.target.value;
if (val.includes("x4plus")) {
scaleRange.value = 4;
scaleRange.disabled = true;
scaleValue.textContent = "4x (Fixed)";
} else {
scaleRange.disabled = false;
scaleValue.textContent = `${scaleRange.value}x`;
}
});
tileRange.addEventListener("input", (e) => {
tileValue.textContent = e.target.value === "0" ? "Auto" : e.target.value;
});
const crfRange = document.getElementById("crf-range");
const crfValue = document.getElementById("crf-value");
if (crfRange && crfValue) {
crfRange.addEventListener("input", (e) => {
let label = e.target.value;
if (e.target.value === "0") label += " (Lossless)";
else if (e.target.value === "18") label += " (Pristine)";
else if (e.target.value > "28") label += " (Compress)";
crfValue.textContent = label;
});
}
/* ----------------------------------------------------
3. Custom Model Upload Action
---------------------------------------------------- */
const customModelInput = document.getElementById("custom-model-input");
const modelUploadFilename = document.getElementById("model-upload-filename");
const uploadModelBtnWrapper = document.getElementById("upload-model-btn-wrapper");
const uploadModelBtn = document.getElementById("upload-model-btn");
if (customModelInput) {
customModelInput.addEventListener("change", (e) => {
const files = e.target.files;
if (files.length > 0) {
const names = Array.from(files).map(f => f.name).join(", ");
modelUploadFilename.textContent = names;
uploadModelBtnWrapper.style.display = "block";
} else {
modelUploadFilename.textContent = "No files selected";
uploadModelBtnWrapper.style.display = "none";
}
});
}
if (uploadModelBtn) {
uploadModelBtn.addEventListener("click", async () => {
const files = customModelInput.files;
if (files.length === 0) return;
uploadModelBtn.disabled = true;
uploadModelBtn.innerHTML = `<div class="spinner" style="width:14px;height:14px;border-width:2px;display:inline-block;margin-right:5px;"></div> Uploading...`;
const formData = new FormData();
for (let i = 0; i < files.length; i++) {
const ext = files[i].name.split('.').pop().toLowerCase();
if (ext === 'param') {
formData.append("param_file", files[i]);
} else if (ext === 'bin') {
formData.append("bin_file", files[i]);
} else {
formData.append("files", files[i]);
}
}
try {
const res = await fetch("/api/models/upload", {
method: "POST",
body: formData
});
if (!res.ok) {
const err = await res.json();
throw new Error(formatFetchError(err, "Model upload failed."));
}
alert("Custom model uploaded successfully!");
modelUploadFilename.textContent = "Uploaded successfully!";
uploadModelBtnWrapper.style.display = "none";
await loadModels();
} catch (err) {
alert(err.message);
} finally {
uploadModelBtn.disabled = false;
uploadModelBtn.innerHTML = `<i class="fa-solid fa-cloud-arrow-up"></i> Upload Model Files`;
}
});
}
/* ----------------------------------------------------
4. Video File Upload Routing
---------------------------------------------------- */
['dragenter', 'dragover'].forEach(eventName => {
dropZone.addEventListener(eventName, (e) => {
e.preventDefault();
dropZone.classList.add('drag-over');
}, false);
});
['dragleave', 'drop'].forEach(eventName => {
dropZone.addEventListener(eventName, (e) => {
e.preventDefault();
dropZone.classList.remove('drag-over');
}, false);
});
dropZone.addEventListener('drop', (e) => {
const dt = e.dataTransfer;
const files = dt.files;
if (files.length > 1) {
handleBatchUpload(files);
} else if (files.length === 1) {
handleVideoUpload(files[0]);
}
});
fileInput.addEventListener('change', (e) => {
const files = e.target.files;
if (files.length > 1) {
handleBatchUpload(files);
} else if (files.length === 1) {
handleVideoUpload(files[0]);
}
});
function resetDropZone() {
dropZone.innerHTML = `
<span class="upload-icon"><i class="fa-solid fa-cloud-arrow-up"></i></span>
<h3>Drag & Drop Video File(s)</h3>
<p>Supports MP4, MKV, AVI, MOV, WEBM (Max 500MB recommended for local upscaling)</p>
<button type="button" class="btn" onclick="document.getElementById('file-input').click()">
<i class="fa-solid fa-folder-open"></i> Browse Files
</button>
`;
}
async function handleBatchUpload(files) {
const totalFiles = files.length;
let uploadedCount = 0;
dropZone.innerHTML = `
<div class="spinner" style="margin: 0 auto 1.5rem auto;"></div>
<h3>Uploading <span id="batch-upload-current">1</span> of ${totalFiles} Files...</h3>
<p id="upload-progress-text">Preparing batch upload...</p>
<div class="bar-container" style="max-width: 400px; margin: 1rem auto; height: 10px;">
<div id="upload-bar" class="bar-fill" style="width: 0%;"></div>
</div>
`;
const progressText = document.getElementById("upload-progress-text");
const progressBar = document.getElementById("upload-bar");
const currentText = document.getElementById("batch-upload-current");
for (let i = 0; i < totalFiles; i++) {
const file = files[i];
if (currentText) currentText.textContent = i + 1;
if (progressText) progressText.textContent = `Uploading "${file.name}"...`;
if (progressBar) progressBar.style.width = "0%";
try {
await new Promise((resolve, reject) => {
const formData = new FormData();
formData.append("file", file);
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/upload", true);
xhr.upload.addEventListener("progress", (e) => {
if (e.lengthComputable) {
const percentComplete = (e.loaded / e.total) * 100;
if (progressText) {
progressText.textContent = `Uploading "${file.name}": ${formatBytes(e.loaded)} / ${formatBytes(e.total)} (${Math.round(percentComplete)}%)`;
}
if (progressBar) progressBar.style.width = `${percentComplete}%`;
}
});
xhr.onload = function() {
if (xhr.status === 200) {
resolve(JSON.parse(xhr.responseText));
} else {
reject(new Error(`Upload failed for ${file.name}`));
}
};
xhr.onerror = function() {
reject(new Error(`Network error uploading ${file.name}`));
};
xhr.send(formData);
});
uploadedCount++;
} catch (err) {
console.error(err);
alert(`Error uploading "${file.name}": ${err.message}`);
}
}
await loadUploadsBrowser();
resetDropZone();
}
function formatBytes(bytes, decimals = 2) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
function handleVideoUpload(file) {
dropZone.innerHTML = `
<div class="spinner" style="margin: 0 auto 1.5rem auto;"></div>
<h3>Uploading Video File...</h3>
<p id="upload-progress-text">Preparing upload...</p>
<div class="bar-container" style="max-width: 400px; margin: 1rem auto; height: 10px;">
<div id="upload-bar" class="bar-fill" style="width: 0%;"></div>
</div>
`;
const formData = new FormData();
formData.append("file", file);
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/upload", true);
xhr.upload.addEventListener("progress", (e) => {
if (e.lengthComputable) {
const percentComplete = (e.loaded / e.total) * 100;
const progressText = document.getElementById("upload-progress-text");
const progressBar = document.getElementById("upload-bar");
if (progressText) progressText.textContent = `Uploaded ${formatBytes(e.loaded)} / ${formatBytes(e.total)} (${Math.round(percentComplete)}%)`;
if (progressBar) progressBar.style.width = `${percentComplete}%`;
}
});
xhr.onload = function() {
if (xhr.status === 200) {
const res = JSON.parse(xhr.responseText);
uploadFileId = res.file_id;
uploadMetadata = res.metadata;
// Populate Config Step Info
metaName.textContent = res.filename;
metaSize.textContent = formatBytes(file.size);
// Populate Accordions Stream details
const streams = res.metadata.streams || [];
const videoStream = streams.find(s => s.codec_type === "video") || {};
const audioStream = streams.find(s => s.codec_type === "audio") || {};
// Video stream metadata
document.getElementById("meta-res-codec").textContent = (videoStream.codec_name || "unknown").toUpperCase();
document.getElementById("meta-res").textContent = `${res.metadata.width} x ${res.metadata.height}`;
document.getElementById("meta-fps").textContent = `${res.metadata.fps} FPS`;
document.getElementById("meta-duration").textContent = `${res.metadata.duration}s`;
document.getElementById("meta-total-frames").textContent = res.metadata.total_frames || "-";
document.getElementById("meta-aspect-ratio").textContent = videoStream.display_aspect_ratio || "N/A";
// Audio stream metadata
if (audioStream.codec_name) {
document.getElementById("meta-audio-codec").textContent = (audioStream.codec_name || "unknown").toUpperCase();
document.getElementById("meta-audio-channels").textContent = audioStream.channels || "N/A";
document.getElementById("meta-audio-samplerate").textContent = audioStream.sample_rate ? `${audioStream.sample_rate} Hz` : "N/A";
document.getElementById("meta-audio-bitrate").textContent = audioStream.bit_rate ? formatBytes(parseInt(audioStream.bit_rate)) + "/s" : "N/A";
} else {
document.getElementById("meta-audio-codec").textContent = "No Audio Stream";
document.getElementById("meta-audio-channels").textContent = "-";
document.getElementById("meta-audio-samplerate").textContent = "-";
document.getElementById("meta-audio-bitrate").textContent = "-";
}
// Configure timeline slider constraints
timelineSlider.min = 0.1;
timelineSlider.max = res.metadata.duration - 0.5;
timelineSlider.value = (res.metadata.duration * 0.15).toFixed(2);
timelineTime.textContent = `${timelineSlider.value}s`;
// Configure Trimming panel range values
const trimStartRange = document.getElementById("trim-start-range");
const trimEndRange = document.getElementById("trim-end-range");
const trimStartValue = document.getElementById("trim-start-value");
const trimEndValue = document.getElementById("trim-end-value");
trimStartRange.min = 0;
trimStartRange.max = res.metadata.duration;
trimStartRange.value = 0;
trimStartValue.textContent = "0.00s";
trimEndRange.min = 0;
trimEndRange.max = res.metadata.duration;
trimEndRange.value = res.metadata.duration;
trimEndValue.textContent = `${res.metadata.duration.toFixed(2)}s`;
// Show config view
stepUpload.classList.remove("active");
stepConfig.classList.add("active");
loadUploadsBrowser();
} else {
alert("Upload failed: " + (JSON.parse(xhr.responseText).detail || xhr.statusText));
window.location.reload();
}
};
xhr.onerror = function() {
alert("Upload error occurred.");
window.location.reload();
};
xhr.send(formData);
}
// Timeline slider change
timelineSlider.addEventListener("input", (e) => {
timelineTime.textContent = `${parseFloat(e.target.value).toFixed(2)}s`;
});
// Trimming sliders inputs listeners
const trimStartRange = document.getElementById("trim-start-range");
const trimEndRange = document.getElementById("trim-end-range");
const trimStartValue = document.getElementById("trim-start-value");
const trimEndValue = document.getElementById("trim-end-value");
trimStartRange.addEventListener("input", (e) => {
let val = parseFloat(e.target.value);
const endVal = parseFloat(trimEndRange.value);
if (val >= endVal) {
val = endVal - 0.05;
trimStartRange.value = val;
}
trimStartValue.textContent = `${val.toFixed(2)}s`;
});
trimEndRange.addEventListener("input", (e) => {
let val = parseFloat(e.target.value);
const startVal = parseFloat(trimStartRange.value);
if (val <= startVal) {
val = startVal + 0.05;
trimEndRange.value = val;
}
trimEndValue.textContent = `${val.toFixed(2)}s`;
});
/* ----------------------------------------------------
5. Single Frame Preview Comparisons & Synced Zoom/Pan
---------------------------------------------------- */
const zoomSlider = document.getElementById("zoom-slider");
const zoomVal = document.getElementById("zoom-val");
const panXSlider = document.getElementById("pan-x-slider");
const panXVal = document.getElementById("pan-x-val");
const panYSlider = document.getElementById("pan-y-slider");
const panYVal = document.getElementById("pan-y-val");
const resetZoomPanBtn = document.getElementById("reset-zoom-pan-btn");
let previewZoom = 1.0;
let previewPanX = 0;
let previewPanY = 0;
function updatePreviewTransform() {
const transformStr = `translate(${previewPanX}px, ${previewPanY}px) scale(${previewZoom})`;
imgOriginal.style.transform = transformStr;
imgOriginal.style.transformOrigin = "center center";
imgOriginal.style.transition = "transform 0.1s ease-out";
imgUpscaled.style.transform = transformStr;
imgUpscaled.style.transformOrigin = "center center";
imgUpscaled.style.transition = "transform 0.1s ease-out";
const vidOriginal = document.getElementById("video-preview-original");
const vidUpscaled = document.getElementById("video-preview-upscaled");
if (vidOriginal) {
vidOriginal.style.transform = transformStr;
vidOriginal.style.transformOrigin = "center center";
vidOriginal.style.transition = "transform 0.1s ease-out";
}
if (vidUpscaled) {
vidUpscaled.style.transform = transformStr;
vidUpscaled.style.transformOrigin = "center center";
vidUpscaled.style.transition = "transform 0.1s ease-out";
}
zoomSlider.value = previewZoom;
zoomVal.textContent = `${previewZoom.toFixed(1)}x`;
panXSlider.value = previewPanX;
panXVal.textContent = `${previewPanX}px`;
panYSlider.value = previewPanY;
panYVal.textContent = `${previewPanY}px`;
}
zoomSlider.addEventListener("input", (e) => {
previewZoom = parseFloat(e.target.value);
updatePreviewTransform();
});
panXSlider.addEventListener("input", (e) => {
previewPanX = parseInt(e.target.value);
updatePreviewTransform();
});
panYSlider.addEventListener("input", (e) => {
previewPanY = parseInt(e.target.value);
updatePreviewTransform();
});
resetZoomPanBtn.addEventListener("click", () => {
previewZoom = 1.0;
previewPanX = 0;
previewPanY = 0;
updatePreviewTransform();
});
// Dragging to pan comparison image
let isDraggingPreview = false;
let startDragX = 0;
let startDragY = 0;
comparisonSliderContainer.addEventListener("mousedown", (e) => {
if (e.target.id === "slider-split" || e.target.classList.contains("slider-handle")) {
return;
}
isDraggingPreview = true;
startDragX = e.clientX - previewPanX;
startDragY = e.clientY - previewPanY;
comparisonSliderContainer.style.cursor = "grabbing";
});
window.addEventListener("mouseup", () => {
if (isDraggingPreview) {
isDraggingPreview = false;
comparisonSliderContainer.style.cursor = "default";
}
});
window.addEventListener("mousemove", (e) => {
if (!isDraggingPreview) return;
previewPanX = e.clientX - startDragX;
previewPanY = e.clientY - startDragY;
if (previewPanX < -400) previewPanX = -400;
if (previewPanX > 400) previewPanX = 400;
if (previewPanY < -400) previewPanY = -400;
if (previewPanY > 400) previewPanY = 400;
updatePreviewTransform();
});
// Generate single frame preview trigger
generatePreviewBtn.addEventListener("click", async () => {
if (!uploadFileId && !window.selectedServerFilePath) return;
const loaderTitle = document.getElementById("preview-loader-title");
const loaderContainer = document.getElementById("preview-loader-progress-container");
const loaderText = document.getElementById("preview-loader-text");
previewLoader.style.display = "flex";
previewPlaceholderBox.style.display = "none";
comparisonSliderContainer.style.display = "none";
document.getElementById("zoom-pan-controls").style.display = "none";
document.getElementById("video-preview-controls").style.display = "none";
if (loaderTitle) loaderTitle.textContent = "Orchestrating Vulkan GPU...";
if (loaderContainer) loaderContainer.style.display = "none";
if (loaderText) loaderText.textContent = "Extracting and upscaling frame. Please wait...";
const gpuSelect = document.getElementById("gpu-select");
try {
const res = await fetch("/api/preview/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
file_id: uploadFileId || null,
server_file_path: window.selectedServerFilePath || null,
timestamp_sec: parseFloat(timelineSlider.value),
model: modelSelect.value,
scale: parseInt(scaleRange.value),
tile_size: parseInt(tileRange.value),
gpu_ids: gpuSelect.value === 'auto' ? null : gpuSelect.value
})
});
if (!res.ok) {
const err = await res.json();
throw new Error(formatFetchError(err, "Frame preview rendering failed."));
}
const data = await res.json();
const previewId = data.preview_id;
imgOriginal.src = `/api/preview/original/${previewId}?t=${Date.now()}`;
imgUpscaled.src = `/api/preview/upscaled/${previewId}?t=${Date.now()}`;
await Promise.all([
new Promise(resolve => imgOriginal.onload = resolve),
new Promise(resolve => imgUpscaled.onload = resolve)
]);
// Hide videos, show images
imgOriginal.style.display = "block";
document.getElementById("img-upscaled-wrapper").style.display = "block";
document.getElementById("video-preview-original").style.display = "none";
document.getElementById("video-preview-upscaled-wrapper").style.display = "none";
previewPlaceholderBox.style.display = "none";
comparisonSliderContainer.style.display = "block";
document.getElementById("zoom-pan-controls").style.display = "flex";
initComparisonSlider();
// Reset transforms
previewZoom = 1.0;
previewPanX = 0;
previewPanY = 0;
updatePreviewTransform();
} catch (err) {
alert(err.message);
} finally {
previewLoader.style.display = "none";
}
});
generateVideoPreviewBtn.addEventListener("click", async () => {
if (!uploadFileId) return;
const loaderTitle = document.getElementById("preview-loader-title");
const loaderContainer = document.getElementById("preview-loader-progress-container");
const loaderBar = document.getElementById("preview-loader-progress-bar");
const loaderText = document.getElementById("preview-loader-text");
previewLoader.style.display = "flex";
previewPlaceholderBox.style.display = "none";
comparisonSliderContainer.style.display = "none";
document.getElementById("zoom-pan-controls").style.display = "none";
document.getElementById("video-preview-controls").style.display = "none";
if (loaderTitle) loaderTitle.textContent = "Generating Video Preview";
if (loaderContainer) loaderContainer.style.display = "block";
if (loaderBar) loaderBar.style.width = "0%";
if (loaderText) loaderText.textContent = "Queuing preview job in pipeline...";
const gpuSelect = document.getElementById("gpu-select");
const crfRange = document.getElementById("crf-range");
const presetSelect = document.getElementById("preset-select");
const transcodeSelect = document.getElementById("transcode-select");
const filterDenoise = document.getElementById("filter-denoise");
const filterSharpen = document.getElementById("filter-sharpen");
const filterFps = document.getElementById("filter-fps");
const aiFaceRestoration = document.getElementById("ai-face-restoration");
const aiRifeInterpolation = document.getElementById("ai-rife-interpolation");
const aiAudioDenoise = document.getElementById("ai-audio-denoise");
const transcodeFormat = transcodeSelect.value;
try {
const res = await fetch("/api/upscale/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
file_id: uploadFileId,
model: modelSelect.value,
scale: parseInt(scaleRange.value),
tile_size: parseInt(tileRange.value),
preserve_audio: audioToggle.checked,
start_sec: parseFloat(timelineSlider.value),
end_sec: parseFloat(timelineSlider.value) + 5.0,
gpu_ids: gpuSelect.value === 'auto' ? null : gpuSelect.value,
tta: false,
unsharp: false,
double_fps: false,
preserve_subtitles: false,
crf: parseInt(crfRange.value),
preset: presetSelect.value,
denoise: filterDenoise.checked,
sharpen: filterSharpen.checked,
interpolation: filterFps.checked,
webhook_url: null,
transcode_format: transcodeFormat,
is_preview: true,
ai_face_restoration: aiFaceRestoration ? aiFaceRestoration.checked : false,
ai_rife_interpolation: aiRifeInterpolation ? aiRifeInterpolation.checked : false,
ai_audio_denoise: aiAudioDenoise ? aiAudioDenoise.checked : false
})
});
if (!res.ok) {
const err = await res.json();
throw new Error(formatFetchError(err, "Failed to start video preview."));
}
const data = await res.json();
currentJobId = data.job_id;
connectProgressWebSocket(currentJobId);
} catch (err) {
alert(err.message);
previewLoader.style.display = "none";
previewPlaceholderBox.style.display = "flex";
}
});
function initComparisonSlider() {
const upscaledWrapper = document.getElementById("img-upscaled-wrapper");
const videoUpscaledWrapper = document.getElementById("video-preview-upscaled-wrapper");
// Reset split positions to 50%
if (upscaledWrapper) upscaledWrapper.style.clipPath = `inset(0 0 0 50%)`;
if (videoUpscaledWrapper) videoUpscaledWrapper.style.clipPath = `inset(0 0 0 50%)`;
sliderSplit.style.left = `50%`;
if (sliderInitialized) return;
sliderInitialized = true;
let active = false;
function setSliderPosition(x) {
const rect = comparisonSliderContainer.getBoundingClientRect();
let posX = x - rect.left;
if (posX < 0) posX = 0;
if (posX > rect.width) posX = rect.width;
const pct = (posX / rect.width) * 100;
if (upscaledWrapper) upscaledWrapper.style.clipPath = `inset(0 0 0 ${pct}%)`;
if (videoUpscaledWrapper) videoUpscaledWrapper.style.clipPath = `inset(0 0 0 ${pct}%)`;
sliderSplit.style.left = `${pct}%`;
}
sliderSplit.addEventListener('mousedown', (e) => { e.stopPropagation(); active = true; });
window.addEventListener('mouseup', () => { active = false; });
window.addEventListener('mousemove', (e) => {
if (!active) return;
setSliderPosition(e.pageX);
});
sliderSplit.addEventListener('touchstart', (e) => { e.stopPropagation(); active = true; });
window.addEventListener('touchend', () => { active = false; });
window.addEventListener('touchmove', (e) => {
if (!active) return;
setSliderPosition(e.touches[0].pageX);
});
comparisonSliderContainer.addEventListener('dblclick', () => {
upscaledWrapper.style.clipPath = `inset(0 0 0 50%)`;
sliderSplit.style.left = `50%`;
});
}
/* ----------------------------------------------------
6. Video Pipeline Jobs Orchestration
---------------------------------------------------- */
startUpscaleBtn.addEventListener("click", async () => {
if (!uploadFileId && !window.selectedServerFilePath && (!window.selectedServerFilePaths || window.selectedServerFilePaths.length === 0)) return;
startUpscaleBtn.disabled = true;
startUpscaleBtn.innerHTML = `<div class="spinner" style="width:16px;height:16px;border-width:2px;display:inline-block;"></div> Starting Job...`;
const gpuSelect = document.getElementById("gpu-select");
const crfRange = document.getElementById("crf-range");
const presetSelect = document.getElementById("preset-select");
const transcodeSelect = document.getElementById("transcode-select");
const filterDenoise = document.getElementById("filter-denoise");
const filterSharpen = document.getElementById("filter-sharpen");
const filterFps = document.getElementById("filter-fps");
const webhookUrl = document.getElementById("webhook-url");
const tempDirInput = document.getElementById("temp-dir-input");
const aiFaceRestoration = document.getElementById("ai-face-restoration");
const aiRifeInterpolation = document.getElementById("ai-rife-interpolation");
const aiAudioDenoise = document.getElementById("ai-audio-denoise");
try {
if (window.isBatchMode && ((window.batchFileIds && window.batchFileIds.length > 0) || (window.selectedServerFilePaths && window.selectedServerFilePaths.length > 0))) {
const res = await fetch("/api/upscale/start/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
file_ids: (window.batchFileIds && window.batchFileIds.length > 0) ? window.batchFileIds : null,
server_file_paths: (window.selectedServerFilePaths && window.selectedServerFilePaths.length > 0) ? window.selectedServerFilePaths : null,
model: modelSelect.value,
scale: parseInt(scaleRange.value),
tile_size: parseInt(tileRange.value),
preserve_audio: audioToggle.checked,
// Advanced fields (disabled for batch)
start_sec: null,
end_sec: null,
gpu_ids: gpuSelect.value === "auto" ? null : gpuSelect.value,
crf: parseInt(crfRange.value),
preset: presetSelect.value,
transcode_format: transcodeSelect.value,
denoise: filterDenoise.checked,
sharpen: filterSharpen.checked,
interpolation: filterFps.checked,
webhook_url: webhookUrl.value.trim() || null,
ai_face_restoration: aiFaceRestoration ? aiFaceRestoration.checked : false,
ai_rife_interpolation: aiRifeInterpolation ? aiRifeInterpolation.checked : false,
ai_audio_denoise: aiAudioDenoise ? aiAudioDenoise.checked : false,
temp_dir: (tempDirInput && tempDirInput.value.trim()) || null
})
});
if (!res.ok) {
const err = await res.json();
throw new Error(formatFetchError(err, "Orchestrator failed to launch batch jobs."));
}
window.isBatchMode = false;
window.batchFileIds = [];
window.selectedServerFilePaths = null;
document.querySelectorAll(".batch-select-checkbox").forEach(cb => cb.checked = false);
const batchConfigBar = document.getElementById("batch-config-bar");
if (batchConfigBar) batchConfigBar.style.display = "none";
document.querySelectorAll(".step-container").forEach(el => el.classList.remove("active"));
stepUpload.classList.add("active");
alert("Batch jobs successfully queued!");
loadQueue();
startUpscaleBtn.disabled = false;
startUpscaleBtn.innerHTML = `<i class="fa-solid fa-play"></i> Start Full Upscale`;
} else {
const res = await fetch("/api/upscale/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
file_id: uploadFileId || null,
server_file_path: window.selectedServerFilePath || null,
model: modelSelect.value,
scale: parseInt(scaleRange.value),
tile_size: parseInt(tileRange.value),
preserve_audio: audioToggle.checked,
// Advanced fields
start_sec: parseFloat(trimStartRange.value),
end_sec: parseFloat(trimEndRange.value),
gpu_ids: gpuSelect.value === "auto" ? null : gpuSelect.value,
crf: parseInt(crfRange.value),
preset: presetSelect.value,
transcode_format: transcodeSelect.value,
denoise: filterDenoise.checked,
sharpen: filterSharpen.checked,
interpolation: filterFps.checked,
webhook_url: webhookUrl.value.trim() || null,
ai_face_restoration: aiFaceRestoration ? aiFaceRestoration.checked : false,
ai_rife_interpolation: aiRifeInterpolation ? aiRifeInterpolation.checked : false,
ai_audio_denoise: aiAudioDenoise ? aiAudioDenoise.checked : false,
temp_dir: (tempDirInput && tempDirInput.value.trim()) || null
})
});
if (!res.ok) {
const err = await res.json();
throw new Error(formatFetchError(err, "Orchestrator failed to launch job."));
}
const data = await res.json();
currentJobId = data.job_id;
stepConfig.classList.remove("active");
stepProgress.classList.add("active");
jobIdDisplay.textContent = currentJobId;
connectProgressWebSocket(currentJobId);
}
} catch (err) {
alert(err.message);
startUpscaleBtn.disabled = false;
startUpscaleBtn.innerHTML = `<i class="fa-solid fa-play"></i> Start Full Upscale`;
}
});
function connectProgressWebSocket(jobId) {
const loc = window.location;
const protocol = loc.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${loc.host}/ws/progress/${jobId}`;
socket = new WebSocket(wsUrl);
logTerminal.innerHTML = `<div class="log-line">Connected to backend job orchestrator...</div>`;
socket.onmessage = function(event) {
const data = JSON.parse(event.data);
updateProgressUI(data);
};
socket.onclose = function() {
console.log("WebSocket progress connection closed");
};
socket.onerror = function(err) {
console.error("WebSocket error:", err);
startFallbackPolling(jobId);
};
}
function appendLogLine(text, type = "info") {
const line = document.createElement("div");
line.className = `log-line ${type}`;
line.textContent = `[${new Date().toLocaleTimeString()}] ${text}`;
logTerminal.appendChild(line);
logTerminal.scrollTop = logTerminal.scrollHeight;
}
function updateProgressUI(data) {
if (data.is_preview) {
// Update inline loader inside the preview panel
const loaderTitle = document.getElementById("preview-loader-title");
const loaderContainer = document.getElementById("preview-loader-progress-container");
const loaderBar = document.getElementById("preview-loader-progress-bar");
const loaderText = document.getElementById("preview-loader-text");
if (previewLoader.style.display !== "flex") {
previewLoader.style.display = "flex";
previewPlaceholderBox.style.display = "none";
comparisonSliderContainer.style.display = "none";
document.getElementById("zoom-pan-controls").style.display = "none";
document.getElementById("video-preview-controls").style.display = "none";
}
if (loaderTitle) loaderTitle.textContent = "Generating Video Preview";
if (loaderContainer) loaderContainer.style.display = "block";
if (loaderBar) loaderBar.style.width = `${Math.round(data.progress || 0)}%`;
let statusText = `Status: ${data.status}`;
if (data.status === "upscaling" && data.total_frames > 0) {
statusText = `Upscaling frame ${data.current_frame} of ${data.total_frames} (ETA: ${data.eta})`;
} else if (data.status === "queued") {
statusText = "Queued in pipeline. Waiting for GPU lock...";
} else if (data.status === "extracting") {
statusText = "Extracting video frames...";
} else if (data.status === "restoring_faces") {
statusText = "GFPGAN: Running Face Restoration on upscaled frames...";
} else if (data.status === "interpolating") {
statusText = "RIFE: Interpolating frames for smooth motion...";
} else if (data.status === "assembling") {
statusText = "Assembling video preview and audio tracks...";
}
if (loaderText) loaderText.textContent = statusText;
if (data.status === "completed") {
if (socket) socket.close();
// Hide loader
previewLoader.style.display = "none";
// Show comparison elements
previewPlaceholderBox.style.display = "none";
comparisonSliderContainer.style.display = "block";
document.getElementById("zoom-pan-controls").style.display = "flex";
document.getElementById("video-preview-controls").style.display = "flex";
// Toggle elements visibility: hide images, show videos
imgOriginal.style.display = "none";
document.getElementById("img-upscaled-wrapper").style.display = "none";
const vidOriginal = document.getElementById("video-preview-original");
const vidUpscaledWrapper = document.getElementById("video-preview-upscaled-wrapper");
const vidUpscaled = document.getElementById("video-preview-upscaled");
vidOriginal.style.display = "block";
vidUpscaledWrapper.style.display = "block";
vidOriginal.src = getDownloadUrl(data.original_preview_file);
vidUpscaled.src = getDownloadUrl(data.output_file);
vidOriginal.load();
vidUpscaled.load();
initComparisonSlider();
initVideoPreviewControls(vidOriginal, vidUpscaled);
// Reset zoom/pan
previewZoom = 1.0;
previewPanX = 0;
previewPanY = 0;
updatePreviewTransform();
} else if (data.status === "failed") {
alert(`Preview generation failed: ${data.error}`);
previewLoader.style.display = "none";
previewPlaceholderBox.style.display = "flex";
}
loadQueue();
return;
}
statusBadge.className = `status-badge ${data.status}`;
statusBadge.textContent = data.status;
statStatus.textContent = data.status.toUpperCase();
if (data.total_frames > 0) {
statFrames.textContent = `${data.current_frame} / ${data.total_frames}`;
} else {
statFrames.textContent = "-";
}
statEta.textContent = data.eta || "Calculating...";
const progressVal = Math.round(data.progress || 0);
progressPercent.textContent = `${progressVal}%`;
progressBarFill.style.width = `${progressVal}%`;
if (data.status === "queued") {
appendLogLine("Job added to FIFO queue. Waiting for GPU lock...", "info");
} else if (data.status === "analyzing") {
appendLogLine("GPU locked. Analyzing video parameters & framerates...", "info");
} else if (data.status === "extracting") {
appendLogLine("FFmpeg: Splitting video stream to high-fidelity JPG frames...", "info");
} else if (data.status === "upscaling") {
appendLogLine(`Vulkan GPU: Processing frame ${data.current_frame} of ${data.total_frames} (ETA: ${data.eta})...`, "info");
} else if (data.status === "restoring_faces") {
appendLogLine("GFPGAN: Running Face Restoration on upscaled frames...", "info");
} else if (data.status === "interpolating") {
appendLogLine("RIFE: Interpolating frames for smooth motion...", "info");
} else if (data.status === "assembling") {
appendLogLine("FFmpeg: Reassembling enhanced frames & merging audio tracks...", "info");
} else if (data.status === "completed") {
appendLogLine("Success! Upscaling process finished cleanly. Temp files deleted.", "success");
setTimeout(() => {
if (socket) socket.close();
stepProgress.classList.remove("active");
stepFinished.classList.add("active");
downloadLink.href = getDownloadUrl(data.output_file);
loadGallery();
}, 1500);
} else if (data.status === "failed") {
appendLogLine(`Critical Error: ${data.error}`, "error");
alert(`Upscaling job failed: ${data.error}`);
} else if (data.status === "cancelled") {
appendLogLine("Process aborted by user.", "error");
alert("Upscaling job cancelled.");
window.location.reload();
}
loadQueue();
}
let vprepScrubbing = false;
function initVideoPreviewControls(vidOriginal, vidUpscaled) {
const playBtn = document.getElementById("vprep-play-btn");
const scrubber = document.getElementById("vprep-scrubber");
const timeCurrent = document.getElementById("vprep-time-current");
const timeDuration = document.getElementById("vprep-time-duration");
// Play/Pause button click
playBtn.onclick = () => {
if (vidOriginal.paused) {
vidOriginal.play();
vidUpscaled.play();
playBtn.innerHTML = `<i class="fa-solid fa-pause"></i>`;
} else {
vidOriginal.pause();
vidUpscaled.pause();
playBtn.innerHTML = `<i class="fa-solid fa-play"></i>`;
}
};
// Time Sync logic
vidOriginal.onplay = () => { vidUpscaled.play(); playBtn.innerHTML = `<i class="fa-solid fa-pause"></i>`; };
vidOriginal.onpause = () => { vidUpscaled.pause(); playBtn.innerHTML = `<i class="fa-solid fa-play"></i>`; };
vidOriginal.onloadedmetadata = () => {
scrubber.max = vidOriginal.duration || 5;
timeDuration.textContent = formatTime(vidOriginal.duration || 5);
};
vidOriginal.ontimeupdate = () => {
if (!vprepScrubbing) {
scrubber.value = vidOriginal.currentTime;
timeCurrent.textContent = formatTime(vidOriginal.currentTime);
}
// Sync check
const diff = Math.abs(vidOriginal.currentTime - vidUpscaled.currentTime);
if (diff > 0.15 && !vidOriginal.seeking && !vidUpscaled.seeking) {
vidUpscaled.currentTime = vidOriginal.currentTime;
}
};
// Scrubber drag seeking
scrubber.onmousedown = () => { vprepScrubbing = true; };
scrubber.onmouseup = () => {
vprepScrubbing = false;
vidOriginal.currentTime = parseFloat(scrubber.value);
vidUpscaled.currentTime = parseFloat(scrubber.value);
};
scrubber.oninput = () => {
timeCurrent.textContent = formatTime(parseFloat(scrubber.value));
};
// Mobile touch scrubber events
scrubber.ontouchstart = () => { vprepScrubbing = true; };
scrubber.ontouchend = () => {
vprepScrubbing = false;
vidOriginal.currentTime = parseFloat(scrubber.value);
vidUpscaled.currentTime = parseFloat(scrubber.value);
};
}
let pollingInterval = null;
function startFallbackPolling(jobId) {
if (pollingInterval) clearInterval(pollingInterval);
appendLogLine("Fallback: WebSockets offline. Switched to REST polling...", "warning");
pollingInterval = setInterval(async () => {
try {
const res = await fetch(`/api/upscale/status/${jobId}`);
if (res.ok) {
const data = await res.json();
updateProgressUI(data);
if (data.status === "completed" || data.status === "failed" || data.status === "cancelled") {
clearInterval(pollingInterval);
}
}
} catch (err) {
console.error("Polling error:", err);
}
}, 1000);
}
cancelUpscaleBtn.addEventListener("click", async () => {
if (!currentJobId) return;
if (confirm("Are you sure you want to abort the upscaling process? All progress will be lost.")) {
try {
const res = await fetch(`/api/upscale/cancel/${currentJobId}`, { method: "POST" });
if (res.ok) {
appendLogLine("Sending abort command to worker...", "error");
}
} catch (err) {
console.error("Error cancelling job:", err);
}
}
});
async function checkActiveJobs() {
try {
const res = await fetch("/api/jobs");
if (!res.ok) return;
const jobs = await res.json();
const activeJob = jobs.find(job =>
["queued", "analyzing", "extracting", "upscaling", "restoring_faces", "interpolating", "assembling"].includes(job.status)
);
if (activeJob) {
currentJobId = activeJob.job_id;
}
} catch (err) {
console.error("Error checking active jobs:", err);
}
}
/* ----------------------------------------------------
7. Synced Playback Modal & Control Handlers
---------------------------------------------------- */
let isScrubbing = false;
let syncInterval = null;
function formatTime(seconds) {
if (isNaN(seconds)) return "00:00";
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
function openVideoComparisonModal(originalSrc, upscaledSrc) {
modalVideoOriginal.src = originalSrc;
modalVideoUpscaled.src = upscaledSrc;
modalVideoOriginal.load();
modalVideoUpscaled.load();
videoModal.style.display = "flex";
document.body.style.overflow = "hidden";
modalPlayBtn.innerHTML = `<i class="fa-solid fa-play"></i>`;
modalTimeSlider.value = 0;
modalTimeCurrent.textContent = "00:00";
modalTimeDuration.textContent = "00:00";
modalVideoOriginal.onloadedmetadata = () => {
modalTimeSlider.max = modalVideoOriginal.duration;
modalTimeDuration.textContent = formatTime(modalVideoOriginal.duration);
};
// Strict periodic sync check
syncInterval = setInterval(() => {
if (!modalVideoOriginal.paused && !modalVideoOriginal.seeking && !modalVideoUpscaled.seeking) {
const diff = Math.abs(modalVideoOriginal.currentTime - modalVideoUpscaled.currentTime);
if (diff > 0.15) {
console.log(`Syncing video channels: diff was ${diff}s`);
modalVideoUpscaled.currentTime = modalVideoOriginal.currentTime;
}
}
if (!isScrubbing && !modalVideoOriginal.paused) {
modalTimeSlider.value = modalVideoOriginal.currentTime;
modalTimeCurrent.textContent = formatTime(modalVideoOriginal.currentTime);
}
}, 100);
}
function closeVideoModal() {
if (syncInterval) clearInterval(syncInterval);
modalVideoOriginal.pause();
modalVideoUpscaled.pause();
modalVideoOriginal.removeAttribute("src");
modalVideoUpscaled.removeAttribute("src");
modalVideoOriginal.load();
modalVideoUpscaled.load();
videoModal.style.display = "none";
document.body.style.overflow = "";
}
modalCloseBtn.addEventListener("click", closeVideoModal);
videoModal.addEventListener("click", (e) => {
if (e.target === videoModal) closeVideoModal();
});
modalPlayBtn.addEventListener("click", () => {
if (modalVideoOriginal.paused) {
modalVideoOriginal.play();
modalVideoUpscaled.play();
modalPlayBtn.innerHTML = `<i class="fa-solid fa-pause"></i>`;
} else {
modalVideoOriginal.pause();
modalVideoUpscaled.pause();
modalPlayBtn.innerHTML = `<i class="fa-solid fa-play"></i>`;
}
});
modalTimeSlider.addEventListener("mousedown", () => { isScrubbing = true; });
modalTimeSlider.addEventListener("mouseup", () => { isScrubbing = false; });
modalTimeSlider.addEventListener("input", (e) => {
const val = parseFloat(e.target.value);
modalVideoOriginal.currentTime = val;
modalVideoUpscaled.currentTime = val;
modalTimeCurrent.textContent = formatTime(val);
});
modalMuteBtn.addEventListener("click", () => {
const isMuted = modalVideoOriginal.muted;
modalVideoOriginal.muted = !isMuted;
modalVideoUpscaled.muted = !isMuted;
modalMuteBtn.innerHTML = isMuted ? `<i class="fa-solid fa-volume-high"></i>` : `<i class="fa-solid fa-volume-xmark"></i>`;
});
/* ----------------------------------------------------
8. Finished/Completed Runs Direct Comparison Launch
---------------------------------------------------- */
if (finishedPlayComparisonBtn) {
finishedPlayComparisonBtn.addEventListener("click", async () => {
if (!currentJobId) return;
try {
const res = await fetch("/api/jobs");
if (!res.ok) return;
const jobs = await res.json();
const job = jobs.find(j => j.job_id === currentJobId);
if (job) {
const originalFilename = job.video_path.split("/").pop();
const originalUrl = `/api/uploads/${originalFilename}`;
const upscaledUrl = getDownloadUrl(job.output_file);
openVideoComparisonModal(originalUrl, upscaledUrl);
}
} catch (err) {
console.error("Comparison playback trigger error:", err);
}
});
}
/* ----------------------------------------------------
9. Visual Outputs Gallery & Delete Action
---------------------------------------------------- */
async function loadGallery() {
const galleryGrid = document.getElementById("gallery-grid");
if (!galleryGrid) return;
try {
const [outputsRes, jobsRes] = await Promise.all([
fetch("/api/outputs"),
fetch("/api/jobs")
]);
if (!outputsRes.ok || !jobsRes.ok) return;
const outputs = await outputsRes.json();
const jobs = await jobsRes.json();
if (outputs.length === 0) {
galleryGrid.innerHTML = `
<div class="gallery-empty-state">
<i class="fa-solid fa-photo-film"></i>
<p>No completed jobs found. Start upscaling a video above to see it in the gallery!</p>
</div>
`;
return;
}
galleryGrid.innerHTML = "";
outputs.forEach(file => {
const matchingJob = jobs.find(j => {
if (j.output_file) {
const jobFilename = j.output_file.split("/").pop();
if (jobFilename === file.filename) return true;
}
const basename = file.filename.replace("upscaled_", "");
const jobId = basename.split(".")[0];
return j.job_id === jobId;
});
const card = document.createElement("div");
card.className = "gallery-card";
let modelInfo = "AI Upscaled";
let originalFilename = null;
let originalUrl = null;
let upscaledUrl = file.url;
if (matchingJob) {
modelInfo = `${matchingJob.model || 'AI Model'} (${matchingJob.scale || 4}x)`;
originalFilename = matchingJob.video_path ? matchingJob.video_path.split("/").pop() : null;
originalUrl = originalFilename ? `/api/uploads/${originalFilename}` : null;
}
card.innerHTML = `
<div class="gallery-thumbnail">
<i class="fa-solid fa-file-video"></i>
<div class="play-overlay" title="Play comparison playback">
<i class="fa-solid fa-circle-play"></i>
</div>
</div>
<div class="gallery-info">
<h4 title="${file.filename}">${file.filename}</h4>
<p>Size: ${formatBytes(file.size)}</p>
<p>Model: ${modelInfo}</p>
</div>
<div class="gallery-actions">
<button type="button" class="btn btn-play-compare">
<i class="fa-solid fa-film"></i> Compare
</button>
<a href="${file.url}" class="btn" download>
<i class="fa-solid fa-download"></i>
</a>
<button type="button" class="btn btn-danger btn-delete" title="Delete completed file">
<i class="fa-solid fa-trash"></i>
</button>
</div>
`;
const playOverlay = card.querySelector(".play-overlay");
const playCompareBtn = card.querySelector(".btn-play-compare");
const playAction = () => {
if (originalUrl) {
openVideoComparisonModal(originalUrl, upscaledUrl);
} else {
alert("Original video source file not found on disk. Performing upscaled output playback comparison.");
openVideoComparisonModal(upscaledUrl, upscaledUrl);
}
};
playOverlay.addEventListener("click", playAction);
playCompareBtn.addEventListener("click", playAction);
const deleteBtn = card.querySelector(".btn-delete");
deleteBtn.addEventListener("click", async () => {
if (confirm(`Are you sure you want to delete ${file.filename}? This will remove all related uploads, outputs, and logs permanently.`)) {
try {
let delSuccess = false;
if (matchingJob) {
const delJobRes = await fetch(`/api/jobs/${matchingJob.job_id}`, {
method: "DELETE"
});
delSuccess = delJobRes.ok;
} else {
const delOutputRes = await fetch(`/api/outputs/delete/${file.filename}`, {
method: "DELETE"
});
delSuccess = delOutputRes.ok;
}
if (delSuccess) {
loadGallery();
} else {
alert("Failed to delete video file and related resources.");
}
} catch (err) {
console.error("Delete error:", err);
}
}
});
galleryGrid.appendChild(card);
});
} catch (err) {
console.error("Error loading gallery:", err);
}
}
async function moveQueueItem(jobId, direction) {
try {
const res = await fetch("/api/queue");
if (!res.ok) return;
const data = await res.json();
const queue = data.queue;
const index = queue.indexOf(jobId);
if (index === -1) return;
const newIndex = index + direction;
if (newIndex < 0 || newIndex >= queue.length) return;
// Swap
const temp = queue[index];
queue[index] = queue[newIndex];
queue[newIndex] = temp;
const reorderRes = await fetch("/api/queue/reorder", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ job_ids: queue })
});
if (reorderRes.ok) {
loadQueue();
}
} catch (err) {
console.error("Error reordering queue:", err);
}
}
async function loadQueue() {
const queueList = document.getElementById("queue-list");
if (!queueList) return;
try {
const res = await fetch("/api/jobs");
if (!res.ok) return;
const jobs = await res.json();
const displayJobs = jobs.filter(job =>
["queued", "analyzing", "extracting", "upscaling", "restoring_faces", "interpolating", "assembling", "completed", "failed", "cancelled", "interrupted", "paused"].includes(job.status)
);
if (displayJobs.length === 0) {
queueList.innerHTML = `
<div class="queue-empty-state" style="text-align: center; padding: 2rem; border: 1px dashed var(--border-color); border-radius: 12px; color: var(--text-muted);">
<i class="fa-solid fa-list-ul" style="font-size: 2rem; margin-bottom: 0.5rem; color: var(--primary);"></i>
<p>No active or queued jobs at the moment.</p>
</div>
`;
return;
}
queueList.innerHTML = "";
displayJobs.forEach(job => {
const item = document.createElement("div");
item.className = "queue-item";
const titleLabel = job.is_preview ? `Preview (5s) - ${job.job_id.substring(0, 8)}...` : `Job - ${job.job_id.substring(0, 8)}...`;
const modelLabel = job.model;
const scaleLabel = `${job.scale}x`;
const progress = Math.round(job.progress || 0);
let metaHtml = `
<span><i class="fa-solid fa-microchip"></i> Model: ${modelLabel} (${scaleLabel})</span>
<span><i class="fa-solid fa-file-video"></i> Source: ${job.video_path ? job.video_path.split('/').pop() : 'Unknown'}</span>
`;
let errorHtml = "";
if (job.status === "failed" && job.error) {
errorHtml = `
<div class="queue-item-error" style="margin-top: 0.5rem; padding: 0.5rem 0.75rem; background: rgba(255, 0, 85, 0.08); border-left: 3px solid var(--danger); border-radius: 4px; font-size: 0.78rem; color: var(--danger); width: 100%; word-break: break-word;">
<i class="fa-solid fa-triangle-exclamation" style="margin-right: 0.25rem;"></i> <strong>Failure Cause:</strong> ${job.error}
</div>
`;
} else if (job.status !== "queued" && job.status !== "failed" && job.status !== "cancelled" && job.status !== "completed" && job.status !== "interrupted") {
metaHtml += `<span><i class="fa-solid fa-clock"></i> ETA: ${job.eta || 'Calculating...'}</span>`;
}
let actionsHtml = "";
let reorderHtml = "";
const isActiveOrQueued = ["queued", "analyzing", "extracting", "upscaling", "restoring_faces", "interpolating", "assembling"].includes(job.status);
if (isActiveOrQueued && job.queue_position !== null && job.queue_position !== undefined) {
const activeQueuedJobs = displayJobs.filter(j =>
["queued", "analyzing", "extracting", "upscaling", "restoring_faces", "interpolating", "assembling"].includes(j.status)
).filter(j => j.queue_position !== null && j.queue_position !== undefined);
const minPos = Math.min(...activeQueuedJobs.map(j => j.queue_position));
const maxPos = Math.max(...activeQueuedJobs.map(j => j.queue_position));
const isFirst = job.queue_position === minPos;
const isLast = job.queue_position === maxPos;
reorderHtml = `
<button class="btn btn-secondary btn-small btn-move-up" data-id="${job.job_id}" style="padding: 0.25rem 0.5rem; width: 30px; height: 30px; border-radius: 8px; font-size: 0.8rem; display: inline-flex; align-items: center; justify-content: center; ${isFirst ? 'visibility: hidden;' : ''}" title="Move Up">
<i class="fa-solid fa-arrow-up"></i>
</button>
<button class="btn btn-secondary btn-small btn-move-down" data-id="${job.job_id}" style="padding: 0.25rem 0.5rem; width: 30px; height: 30px; border-radius: 8px; font-size: 0.8rem; display: inline-flex; align-items: center; justify-content: center; ${isLast ? 'visibility: hidden;' : ''}" title="Move Down">
<i class="fa-solid fa-arrow-down"></i>
</button>
`;
}
if (job.status === "interrupted" || job.status === "failed" || job.status === "paused") {
const btnLabel = job.status === "failed" ? "Retry" : "Resume";
const btnIcon = job.status === "failed" ? "fa-rotate-right" : "fa-play";
actionsHtml = `
<button class="btn btn-primary btn-small btn-resume-job" data-id="${job.job_id}">
<i class="fa-solid ${btnIcon}"></i> ${btnLabel}
</button>
<button class="btn btn-secondary btn-small btn-view-job" data-id="${job.job_id}">
<i class="fa-solid fa-eye"></i> View
</button>
<button class="btn btn-secondary btn-small btn-delete-job" data-id="${job.job_id}">
<i class="fa-solid fa-trash"></i> Clear
</button>
`;
} else if (["queued", "analyzing", "extracting", "upscaling", "restoring_faces", "interpolating", "assembling"].includes(job.status)) {
actionsHtml = `
${reorderHtml}
<button class="btn btn-primary btn-small btn-view-job" data-id="${job.job_id}">
<i class="fa-solid fa-eye"></i> View
</button>
<button class="btn btn-secondary btn-small btn-pause-job" data-id="${job.job_id}">
<i class="fa-solid fa-pause"></i> Pause
</button>
<button class="btn btn-danger btn-small btn-abort-job" data-id="${job.job_id}">
<i class="fa-solid fa-circle-stop"></i> Abort
</button>
`;
} else {
actionsHtml = `
<button class="btn btn-secondary btn-small btn-view-job" data-id="${job.job_id}">
<i class="fa-solid fa-eye"></i> View
</button>
<button class="btn btn-secondary btn-small btn-delete-job" data-id="${job.job_id}">
<i class="fa-solid fa-trash"></i> Clear
</button>
`;
}
const showProgress = ["analyzing", "extracting", "upscaling", "restoring_faces", "interpolating", "assembling", "paused", "interrupted"].includes(job.status);
const progressStyle = showProgress ? "display: block;" : "display: none;";
item.innerHTML = `
<div class="queue-item-details" style="width: 100%;">
<div class="queue-item-title">
${titleLabel}
<span class="status-badge ${job.status}" style="font-size: 0.7rem; padding: 0.15rem 0.5rem; margin-left: 0.5rem;">${job.status}</span>
</div>
<div class="queue-item-meta">
${metaHtml}
</div>
${errorHtml}
</div>
<div class="queue-item-status-bar">
<div class="queue-item-progress-container" style="${progressStyle}">
<div class="queue-item-progress-fill" style="width: ${progress}%;"></div>
</div>
<div class="queue-item-actions">
${actionsHtml}
</div>
</div>
`;
const upBtn = item.querySelector(".btn-move-up");
if (upBtn) {
upBtn.addEventListener("click", async (e) => {
e.stopPropagation();
await moveQueueItem(job.job_id, -1);
});
}
const downBtn = item.querySelector(".btn-move-down");
if (downBtn) {
downBtn.addEventListener("click", async (e) => {
e.stopPropagation();
await moveQueueItem(job.job_id, 1);
});
}
const resumeBtn = item.querySelector(".btn-resume-job");
if (resumeBtn) {
resumeBtn.addEventListener("click", async (e) => {
e.stopPropagation();
try {
const resumeRes = await fetch(`/api/upscale/resume/${job.job_id}`, { method: "POST" });
if (resumeRes.ok) {
loadQueue();
} else {
const err = await resumeRes.json();
alert("Failed to resume job: " + formatFetchError(err, "unknown error"));
}
} catch (err) {
console.error("Error resuming job:", err);
}
});
}
const abortBtn = item.querySelector(".btn-abort-job");
if (abortBtn) {
abortBtn.addEventListener("click", async (e) => {
e.stopPropagation();
if (confirm("Are you sure you want to abort this upscaling job?")) {
try {
const abortRes = await fetch(`/api/upscale/cancel/${job.job_id}`, { method: "POST" });
if (abortRes.ok) {
loadQueue();
}
} catch (err) {
console.error("Error aborting job:", err);
}
}
});
}
const pauseBtn = item.querySelector(".btn-pause-job");
if (pauseBtn) {
pauseBtn.addEventListener("click", async (e) => {
e.stopPropagation();
try {
const pauseRes = await fetch(`/api/upscale/pause/${job.job_id}`, { method: "POST" });
if (pauseRes.ok) {
loadQueue();
} else {
const err = await pauseRes.json();
alert("Failed to pause job: " + formatFetchError(err, "unknown error"));
}
} catch (err) {
console.error("Error pausing job:", err);
}
});
}
const deleteBtn = item.querySelector(".btn-delete-job");
if (deleteBtn) {
deleteBtn.addEventListener("click", async (e) => {
e.stopPropagation();
if (confirm("Are you sure you want to clear this job from history?")) {
try {
const delRes = await fetch(`/api/jobs/${job.job_id}`, { method: "DELETE" });
if (delRes.ok) {
loadQueue();
loadGallery();
}
} catch (err) {
console.error("Error clearing job:", err);
}
}
});
}
const viewBtn = item.querySelector(".btn-view-job");
if (viewBtn) {
viewBtn.addEventListener("click", (e) => {
e.stopPropagation();
currentJobId = job.job_id;
if (socket) {
try {
socket.close();
} catch (e) {}
}
document.querySelectorAll(".step-container").forEach(el => el.classList.remove("active"));
if (job.status === "completed") {
document.getElementById("step-finished").classList.add("active");
document.getElementById("download-link").href = getDownloadUrl(job.output_file);
} else {
document.getElementById("step-progress").classList.add("active");
document.getElementById("job-id-display").textContent = currentJobId;
connectProgressWebSocket(currentJobId);
}
});
}
queueList.appendChild(item);
});
} catch (err) {
console.error("Error loading queue:", err);
}
}
const refreshGalleryBtn = document.getElementById("refresh-gallery-btn");
if (refreshGalleryBtn) {
refreshGalleryBtn.addEventListener("click", loadGallery);
}
const refreshQueueBtn = document.getElementById("refresh-queue-btn");
if (refreshQueueBtn) {
refreshQueueBtn.addEventListener("click", loadQueue);
}
const purgeAllBtn = document.getElementById("purge-all-btn");
if (purgeAllBtn) {
purgeAllBtn.addEventListener("click", async () => {
if (confirm("⚠️ WARNING: This will permanently delete ALL uploaded source videos, completed outputs, temp frames, and history logs. Are you sure you want to proceed?")) {
try {
const res = await fetch("/api/jobs/purge-all", {
method: "POST"
});
if (res.ok) {
alert("System files purged successfully!");
loadGallery();
} else {
alert("Failed to purge system files.");
}
} catch (err) {
console.error("Purge all error:", err);
}
}
});
}
/* ----------------------------------------------------
10. Core Setup & Models Loading
---------------------------------------------------- */
const standardModelNames = {
"realesr-animevideov3": "Real-ESRGAN Anime Video V3 (Fast / Anime & Video)",
"realesrgan-x4plus": "Real-ESRGAN X4 Plus (Ultra Quality / Real World)",
"realesrgan-x4plus-anime": "Real-ESRGAN X4 Plus Anime (Ultra Quality / Illustration)",
"realesrnet-x4plus": "Real-ESRNet X4 Plus (Alternate / Real World)"
};
async function loadModels() {
try {
const res = await fetch("/api/models");
if (!res.ok) return;
const models = await res.json();
if (models && models.length > 0) {
modelSelect.innerHTML = "";
models.forEach(model => {
const opt = document.createElement("option");
opt.value = model;
opt.textContent = standardModelNames[model] || `Custom Model: ${model}`;
if (model === "realesr-animevideov3") {
opt.selected = true;
}
modelSelect.appendChild(opt);
});
modelSelect.dispatchEvent(new Event("change"));
}
} catch (err) {
console.error("Error loading models list:", err);
}
}
function useUploadedVideo(entry) {
window.isBatchMode = false;
window.batchFileIds = [];
// Enable inputs
timelineSlider.disabled = false;
generatePreviewBtn.disabled = false;
generateVideoPreviewBtn.disabled = false;
document.getElementById("trim-start-range").disabled = false;
document.getElementById("trim-end-range").disabled = false;
previewPlaceholderBox.style.display = "flex";
previewPlaceholderBox.querySelector("p").textContent = "Move timeline slider and generate frame preview to calibrate parameters.";
comparisonSliderContainer.style.display = "none";
uploadFileId = entry.file_id;
uploadMetadata = entry.metadata;
// Populate Config Step Info
metaName.textContent = entry.original_filename;
metaSize.textContent = formatBytes(entry.size_bytes);
// Populate Accordions Stream details
const streams = entry.metadata.streams || [];
const videoStream = streams.find(s => s.codec_type === "video") || {};
const audioStream = streams.find(s => s.codec_type === "audio") || {};
// Video stream metadata
document.getElementById("meta-res-codec").textContent = (videoStream.codec_name || "unknown").toUpperCase();
document.getElementById("meta-res").textContent = `${entry.metadata.width} x ${entry.metadata.height}`;
document.getElementById("meta-fps").textContent = `${entry.metadata.fps} FPS`;
document.getElementById("meta-duration").textContent = `${entry.metadata.duration}s`;
document.getElementById("meta-total-frames").textContent = entry.metadata.total_frames || "-";
document.getElementById("meta-aspect-ratio").textContent = videoStream.display_aspect_ratio || "N/A";
// Audio stream metadata
if (audioStream.codec_name) {
document.getElementById("meta-audio-codec").textContent = (audioStream.codec_name || "unknown").toUpperCase();
document.getElementById("meta-audio-channels").textContent = audioStream.channels || "N/A";
document.getElementById("meta-audio-samplerate").textContent = audioStream.sample_rate ? `${audioStream.sample_rate} Hz` : "N/A";
document.getElementById("meta-audio-bitrate").textContent = audioStream.bit_rate ? formatBytes(parseInt(audioStream.bit_rate)) + "/s" : "N/A";
} else {
document.getElementById("meta-audio-codec").textContent = "No Audio Stream";
document.getElementById("meta-audio-channels").textContent = "-";
document.getElementById("meta-audio-samplerate").textContent = "-";
document.getElementById("meta-audio-bitrate").textContent = "-";
}
// Configure timeline slider constraints
timelineSlider.min = 0.1;
timelineSlider.max = entry.metadata.duration - 0.5;
timelineSlider.value = (entry.metadata.duration * 0.15).toFixed(2);
timelineTime.textContent = `${timelineSlider.value}s`;
// Configure Trimming panel range values
const trimStartRange = document.getElementById("trim-start-range");
const trimEndRange = document.getElementById("trim-end-range");
const trimStartValue = document.getElementById("trim-start-value");
const trimEndValue = document.getElementById("trim-end-value");
trimStartRange.min = 0;
trimStartRange.max = entry.metadata.duration;
trimStartRange.value = 0;
trimStartValue.textContent = "0.00s";
trimEndRange.min = 0;
trimEndRange.max = entry.metadata.duration;
trimEndRange.value = entry.metadata.duration;
trimEndValue.textContent = `${entry.metadata.duration.toFixed(2)}s`;
// Show config view
stepUpload.classList.remove("active");
stepConfig.classList.add("active");
}
function useBatchUploadedVideos(entries) {
window.isBatchMode = true;
window.batchFileIds = entries.map(e => e.file_id);
uploadFileId = entries[0].file_id; // Set fallback file ID for safety
// Set configuration name
metaName.textContent = `Batch Processing - ${entries.length} video(s)`;
metaSize.textContent = `Total size: ${formatBytes(entries.reduce((acc, e) => acc + (e.size_bytes || 0), 0))}`;
// Set video/audio stream specs to default/mixed
document.getElementById("meta-res-codec").textContent = "MIXED";
document.getElementById("meta-res").textContent = "Varies";
document.getElementById("meta-fps").textContent = "Varies";
document.getElementById("meta-duration").textContent = "Varies";
document.getElementById("meta-total-frames").textContent = "Varies";
document.getElementById("meta-aspect-ratio").textContent = "Varies";
document.getElementById("meta-audio-codec").textContent = "Varies";
document.getElementById("meta-audio-channels").textContent = "Varies";
document.getElementById("meta-audio-samplerate").textContent = "Varies";
document.getElementById("meta-audio-bitrate").textContent = "Varies";
// Disable timeline and previews
timelineSlider.disabled = true;
generatePreviewBtn.disabled = true;
generateVideoPreviewBtn.disabled = true;
// Hide trimming panel or disable inputs
document.getElementById("trim-start-range").disabled = true;
document.getElementById("trim-end-range").disabled = true;
// Hide preview placeholders
previewPlaceholderBox.style.display = "flex";
previewPlaceholderBox.querySelector("p").textContent = "Preview and trimming are disabled in Batch Mode.";
comparisonSliderContainer.style.display = "none";
// Show config view
stepUpload.classList.remove("active");
stepConfig.classList.add("active");
}
async function loadUploadsBrowser() {
const browserList = document.getElementById("uploads-browser-list");
if (!browserList) return;
const batchConfigBar = document.getElementById("batch-config-bar");
const batchSelectedCount = document.getElementById("batch-selected-count");
const batchConfigureBtn = document.getElementById("batch-configure-btn");
try {
const res = await fetch("/api/uploads");
if (!res.ok) return;
const uploads = await res.json();
if (uploads.length === 0) {
browserList.innerHTML = `
<div style="text-align: center; padding: 2rem; color: var(--text-muted);">
<i class="fa-solid fa-photo-film" style="font-size: 2rem; margin-bottom: 0.5rem; color: rgba(255,255,255,0.15);"></i>
<p>No uploaded files found. Upload a video above to get started!</p>
</div>
`;
if (batchConfigBar) batchConfigBar.style.display = "none";
return;
}
browserList.innerHTML = "";
let selectedEntries = [];
uploads.forEach(entry => {
const item = document.createElement("div");
item.className = "queue-item";
const sizeStr = formatBytes(entry.size_bytes || 0);
const durationStr = entry.metadata ? `${entry.metadata.duration}s` : "-";
const resStr = entry.metadata ? `${entry.metadata.width}x${entry.metadata.height}` : "-";
const fpsStr = entry.metadata ? `${entry.metadata.fps} FPS` : "-";
item.innerHTML = `
<div class="uploads-browser-item" style="width: 100%;">
<input type="checkbox" class="batch-select-checkbox" data-id="${entry.file_id}" style="width: 18px; height: 18px; cursor: pointer; accent-color: var(--primary);">
<div class="queue-item-details" style="flex: 1;">
<div class="queue-item-title">
<i class="fa-solid fa-file-video" style="color: var(--primary);"></i> ${entry.original_filename}
</div>
<div class="queue-item-meta">
<span>Size: ${sizeStr}</span>
<span>Resolution: ${resStr}</span>
<span>Duration: ${durationStr}</span>
<span>FPS: ${fpsStr}</span>
</div>
</div>
<div class="queue-item-status-bar" style="border: none; padding: 0;">
<div class="queue-item-actions">
<button class="btn btn-primary btn-small btn-use-upload" data-id="${entry.file_id}">
<i class="fa-solid fa-play"></i> Use
</button>
<button class="btn btn-danger btn-small btn-delete-upload" data-id="${entry.file_id}">
<i class="fa-solid fa-trash"></i> Delete
</button>
</div>
</div>
</div>
`;
const checkbox = item.querySelector(".batch-select-checkbox");
checkbox.addEventListener("change", () => {
if (checkbox.checked) {
selectedEntries.push(entry);
} else {
selectedEntries = selectedEntries.filter(se => se.file_id !== entry.file_id);
}
if (selectedEntries.length > 0) {
if (batchSelectedCount) batchSelectedCount.textContent = selectedEntries.length;
if (batchConfigBar) batchConfigBar.style.display = "flex";
} else {
if (batchConfigBar) batchConfigBar.style.display = "none";
}
});
item.querySelector(".btn-use-upload").addEventListener("click", () => {
useUploadedVideo(entry);
});
item.querySelector(".btn-delete-upload").addEventListener("click", async () => {
if (confirm(`Are you sure you want to delete "${entry.original_filename}" from the workspace?`)) {
try {
const delRes = await fetch(`/api/uploads/${entry.file_id}`, { method: "DELETE" });
if (delRes.ok) {
loadUploadsBrowser();
}
} catch (err) {
console.error("Error deleting upload:", err);
}
}
});
browserList.appendChild(item);
});
if (batchConfigureBtn) {
const newBtn = batchConfigureBtn.cloneNode(true);
batchConfigureBtn.parentNode.replaceChild(newBtn, batchConfigureBtn);
newBtn.addEventListener("click", () => {
if (selectedEntries.length > 0) {
useBatchUploadedVideos(selectedEntries);
}
});
}
} catch (err) {
console.error("Error loading uploads browser:", err);
}
}
async function initializeApp() {
try { await loadModels(); } catch(e) { console.error("initializeApp: loadModels failed:", e); }
try { await checkActiveJobs(); } catch(e) { console.error("initializeApp: checkActiveJobs failed:", e); }
try { await loadGallery(); } catch(e) { console.error("initializeApp: loadGallery failed:", e); }
try { await loadQueue(); } catch(e) { console.error("initializeApp: loadQueue failed:", e); }
try { await loadUploadsBrowser(); } catch(e) { console.error("initializeApp: loadUploadsBrowser failed:", e); }
// Load GPU Diagnostics
try {
const res = await fetch("/api/diagnostics");
if (res.ok) {
const diag = await res.json();
const gpuInfo = document.getElementById("gpu-info");
if (gpuInfo) {
if (diag.gpu && diag.gpu.available && diag.gpu.gpus.length > 0) {
const gpuName = diag.gpu.gpus[0].name;
gpuInfo.textContent = `GPU Active: ${gpuName} (Vulkan)`;
} else {
gpuInfo.textContent = "GPU Active: Vulkan Device";
}
}
}
} catch (err) {
console.error("Error loading GPU diagnostics:", err);
}
// Register btn-refresh-uploads click handler
const refreshUploadsBtn = document.getElementById("refresh-uploads-btn");
if (refreshUploadsBtn) {
refreshUploadsBtn.addEventListener("click", loadUploadsBrowser);
}
// Register back-to-workspace-btn click handler
const backToWorkspaceBtn = document.getElementById("back-to-workspace-btn");
if (backToWorkspaceBtn) {
backToWorkspaceBtn.addEventListener("click", () => {
if (socket) {
try {
socket.close();
} catch(e) {}
}
document.querySelectorAll(".step-container").forEach(el => el.classList.remove("active"));
document.getElementById("step-upload").classList.add("active");
uploadFileId = null;
window.selectedServerFilePath = null;
window.selectedServerFilePaths = null;
loadUploadsBrowser();
});
}
// Register documentation modal handlers
const docsBtn = document.getElementById("docs-btn");
const docsModal = document.getElementById("docs-modal");
const docsCloseBtn = document.getElementById("docs-modal-close-btn");
if (docsBtn && docsModal) {
docsBtn.addEventListener("click", () => {
docsModal.style.display = "flex";
document.body.style.overflow = "hidden";
});
}
if (docsCloseBtn && docsModal) {
docsCloseBtn.addEventListener("click", () => {
docsModal.style.display = "none";
document.body.style.overflow = "";
});
docsModal.addEventListener("click", (e) => {
if (e.target === docsModal) {
docsModal.style.display = "none";
document.body.style.overflow = "";
}
});
}
// Temporary storage maintenance handlers
const refreshTempBtn = document.getElementById("refresh-temp-btn");
if (refreshTempBtn) {
refreshTempBtn.addEventListener("click", loadTempInfo);
}
const cleanupTempBtn = document.getElementById("cleanup-temp-btn");
if (cleanupTempBtn) {
cleanupTempBtn.addEventListener("click", async () => {
if (confirm("Are you sure you want to clean up all inactive temporary directories?\nThis will clear any completed, cancelled, failed, or orphaned job frames. Paused or interrupted jobs will have to restart from the beginning on resume.")) {
try {
const cleanRes = await fetch("/api/system/temp-cleanup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ paths: null })
});
if (cleanRes.ok) {
loadTempInfo();
loadQueue();
} else {
alert("Cleanup failed.");
}
} catch (err) {
console.error(err);
}
}
});
}
// Refresh queue button should also refresh temp files info
const refreshQueueBtn = document.getElementById("refresh-queue-btn");
if (refreshQueueBtn) {
refreshQueueBtn.addEventListener("click", loadTempInfo);
}
// Load initial temp info
loadTempInfo();
// Default temp directory setting
const saveTempDirBtn = document.getElementById("save-temp-dir-btn");
const defaultTempDirInput = document.getElementById("default-temp-dir-input");
const tempDirStatus = document.getElementById("temp-dir-status");
// Load saved default temp dir and cluster settings on init
loadDefaultTempDir();
loadClusterSettings();
if (saveTempDirBtn) {
saveTempDirBtn.addEventListener("click", async () => {
const newDir = defaultTempDirInput ? defaultTempDirInput.value.trim() : "";
tempDirStatus.textContent = "";
tempDirStatus.style.color = "";
saveTempDirBtn.disabled = true;
saveTempDirBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Saving...';
try {
// Fetch current settings to preserve other values
const currentRes = await fetch("/api/settings");
const currentSettings = currentRes.ok ? await currentRes.json() : {};
const updatedSettings = {
...currentSettings,
default_temp_dir: newDir
};
const res = await fetch("/api/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updatedSettings)
});
if (res.ok) {
tempDirStatus.style.color = "var(--success, #4caf50)";
tempDirStatus.textContent = newDir
? `\u2713 Default temp directory saved: ${newDir}`
: "\u2713 Reset to project default temp/ directory.";
// Also update the per-job input placeholder/value
const perJobInput = document.getElementById("temp-dir-input");
if (perJobInput && !perJobInput.value.trim()) {
perJobInput.placeholder = newDir
? `Default: ${newDir} (override here)`
: "e.g. /mnt/fast_ssd/temp (Leave empty for default temp/)";
}
} else {
const errData = await res.json().catch(() => ({}));
tempDirStatus.style.color = "var(--danger, #f44336)";
tempDirStatus.textContent = `\u2717 ${errData.detail || "Failed to save setting."}`;
}
} catch (err) {
tempDirStatus.style.color = "var(--danger, #f44336)";
tempDirStatus.textContent = "\u2717 Network error saving setting.";
console.error(err);
} finally {
saveTempDirBtn.disabled = false;
saveTempDirBtn.innerHTML = '<i class="fa-solid fa-floppy-disk"></i> Save';
}
});
}
// Default output directory setting
const saveOutputDirBtn = document.getElementById("save-output-dir-btn");
const defaultOutputDirInput = document.getElementById("default-output-dir-input");
const outputDirStatus = document.getElementById("output-dir-status");
if (saveOutputDirBtn) {
saveOutputDirBtn.addEventListener("click", async () => {
const newDir = defaultOutputDirInput ? defaultOutputDirInput.value.trim() : "";
outputDirStatus.textContent = "";
outputDirStatus.style.color = "";
saveOutputDirBtn.disabled = true;
saveOutputDirBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Saving...';
try {
// Fetch current settings to preserve other values
const currentRes = await fetch("/api/settings");
const currentSettings = currentRes.ok ? await currentRes.json() : {};
const updatedSettings = {
...currentSettings,
default_output_dir: newDir
};
const res = await fetch("/api/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updatedSettings)
});
if (res.ok) {
outputDirStatus.style.color = "var(--success, #4caf50)";
outputDirStatus.textContent = newDir
? `\u2713 Default output directory saved: ${newDir}`
: "\u2713 Reset to project default outputs/ directory.";
const perJobInput = document.getElementById("output-dir-input");
if (perJobInput && !perJobInput.value.trim()) {
perJobInput.placeholder = newDir
? `Default: ${newDir} (override here)`
: "e.g. /mnt/fast_ssd/outputs (Leave empty for default outputs/)";
}
} else {
const errData = await res.json().catch(() => ({}));
outputDirStatus.style.color = "var(--danger, #f44336)";
outputDirStatus.textContent = `\u2717 ${errData.detail || "Failed to save setting."}`;
}
} catch (err) {
outputDirStatus.style.color = "var(--danger, #f44336)";
outputDirStatus.textContent = "\u2717 Network error saving setting.";
console.error(err);
} finally {
saveOutputDirBtn.disabled = false;
saveOutputDirBtn.innerHTML = '<i class="fa-solid fa-floppy-disk"></i> Save';
}
});
}
// Wire up folder browser modals
const browseJobTempBtn = document.getElementById("browse-job-temp-btn");
const browseDefaultTempBtn = document.getElementById("browse-default-temp-btn");
const browseJobOutputBtn = document.getElementById("browse-job-output-btn");
const browseDefaultOutputBtn = document.getElementById("browse-default-output-btn");
const browseSharedStorageBtn = document.getElementById("browse-shared-storage-btn");
const browseSmbMountBtn = document.getElementById("browse-smb-mount-btn");
const folderModalCloseBtn = document.getElementById("folder-modal-close-btn");
const folderCancelBtn = document.getElementById("folder-cancel-btn");
const folderSelectBtn = document.getElementById("folder-select-btn");
const folderUpBtn = document.getElementById("folder-up-btn");
const createFolderBtn = document.getElementById("create-folder-btn");
const newFolderNameInput = document.getElementById("new-folder-name");
if (browseJobTempBtn) {
browseJobTempBtn.addEventListener("click", () => {
openFolderBrowser(document.getElementById("temp-dir-input"));
});
}
if (browseDefaultTempBtn) {
browseDefaultTempBtn.addEventListener("click", () => {
openFolderBrowser(document.getElementById("default-temp-dir-input"));
});
}
if (browseJobOutputBtn) {
browseJobOutputBtn.addEventListener("click", () => {
openFolderBrowser(document.getElementById("output-dir-input"));
});
}
if (browseDefaultOutputBtn) {
browseDefaultOutputBtn.addEventListener("click", () => {
openFolderBrowser(document.getElementById("default-output-dir-input"));
});
}
if (browseSharedStorageBtn) {
browseSharedStorageBtn.addEventListener("click", () => {
openFolderBrowser(document.getElementById("shared-storage-path-input"));
});
}
if (browseSmbMountBtn) {
browseSmbMountBtn.addEventListener("click", () => {
openFolderBrowser(document.getElementById("smb-mount-path"));
});
}
if (folderModalCloseBtn) {
folderModalCloseBtn.addEventListener("click", closeFolderBrowser);
}
if (folderCancelBtn) {
folderCancelBtn.addEventListener("click", closeFolderBrowser);
}
// Close modal when clicking outside card
const folderModal = document.getElementById("folder-modal");
if (folderModal) {
folderModal.addEventListener("click", (e) => {
if (e.target === folderModal) {
closeFolderBrowser();
}
});
}
if (folderSelectBtn) {
folderSelectBtn.addEventListener("click", () => {
if (activeTargetInput && currentBrowsePath) {
activeTargetInput.value = currentBrowsePath;
activeTargetInput.dispatchEvent(new Event("change"));
activeTargetInput.dispatchEvent(new Event("input"));
}
closeFolderBrowser();
});
}
if (folderUpBtn) {
folderUpBtn.addEventListener("click", () => {
if (currentBrowsePath) {
const parts = currentBrowsePath.split("/");
if (parts.length > 1) {
parts.pop();
const parent = parts.join("/") || "/";
loadFolderDir(parent);
}
}
});
}
if (createFolderBtn) {
createFolderBtn.addEventListener("click", createFolder);
}
if (newFolderNameInput) {
newFolderNameInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
createFolder();
}
});
}
// Cluster and SMB UI Settings
const nodeRoleSelect = document.getElementById("node-role-select");
const useSharedStorageCheckbox = document.getElementById("use-shared-storage-checkbox");
const coordUrlGroup = document.getElementById("coord-url-group");
const workerUrlGroup = document.getElementById("worker-url-group");
const sharedStoragePathGroup = document.getElementById("shared-storage-path-group");
// Toggle fields based on selects
if (nodeRoleSelect) {
nodeRoleSelect.addEventListener("change", (e) => {
const role = e.target.value;
coordUrlGroup.style.display = role === "worker" ? "block" : "none";
workerUrlGroup.style.display = role === "worker" || role === "coordinator" ? "block" : "none";
});
}
if (useSharedStorageCheckbox) {
useSharedStorageCheckbox.addEventListener("change", (e) => {
sharedStoragePathGroup.style.display = e.target.checked ? "flex" : "none";
});
}
// SMB Modals
const addSmbShareBtn = document.getElementById("add-smb-share-btn");
const smbModal = document.getElementById("smb-modal");
const smbModalCloseBtn = document.getElementById("smb-modal-close-btn");
const smbCancelBtn = document.getElementById("smb-cancel-btn");
const smbSaveBtn = document.getElementById("smb-save-btn");
const smbModalStatus = document.getElementById("smb-modal-status");
if (addSmbShareBtn) {
addSmbShareBtn.addEventListener("click", () => {
if (smbModal) {
document.getElementById("smb-name").value = "";
document.getElementById("smb-server").value = "";
document.getElementById("smb-share-name").value = "";
document.getElementById("smb-username").value = "";
document.getElementById("smb-password").value = "";
document.getElementById("smb-mount-path").value = "";
smbModalStatus.textContent = "";
smbModalStatus.style.color = "";
smbModal.style.display = "flex";
document.body.style.overflow = "hidden";
}
});
}
const closeSmbModal = () => {
if (smbModal) {
smbModal.style.display = "none";
document.body.style.overflow = "";
}
};
if (smbModalCloseBtn) smbModalCloseBtn.addEventListener("click", closeSmbModal);
if (smbCancelBtn) smbCancelBtn.addEventListener("click", closeSmbModal);
if (smbModal) {
smbModal.addEventListener("click", (e) => {
if (e.target === smbModal) {
closeSmbModal();
}
});
}
if (smbSaveBtn) {
smbSaveBtn.addEventListener("click", async () => {
const name = document.getElementById("smb-name").value.trim();
const server = document.getElementById("smb-server").value.trim();
const share = document.getElementById("smb-share-name").value.trim();
const username = document.getElementById("smb-username").value.trim();
const password = document.getElementById("smb-password").value;
const mountPath = document.getElementById("smb-mount-path").value.trim();
if (!name || !server || !share || !mountPath) {
smbModalStatus.style.color = "var(--danger)";
smbModalStatus.textContent = "Please fill in all required fields.";
return;
}
smbModalStatus.style.color = "var(--text-muted)";
smbModalStatus.textContent = "Attempting to mount SMB share...";
try {
// Client-side SMB share management
const getRes = await fetch("/api/settings");
if (!getRes.ok) throw new Error("Failed to load settings.");
const settings = await getRes.json();
if (!settings.smb_shares) {
settings.smb_shares = [];
}
const newShare = { name, server, share, username, password, mount_path: mountPath };
const idx = settings.smb_shares.findIndex(s => s.name === name);
if (idx !== -1) {
settings.smb_shares[idx] = newShare;
} else {
settings.smb_shares.push(newShare);
}
const saveRes = await fetch("/api/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(settings)
});
if (saveRes.ok) {
closeSmbModal();
loadClusterSettings(); // Reload SMB list
} else {
const err = await saveRes.json().catch(() => ({}));
smbModalStatus.style.color = "var(--danger)";
smbModalStatus.textContent = `\u2717 ${err.detail || "Mount failed."}`;
}
} catch (ex) {
smbModalStatus.style.color = "var(--danger)";
smbModalStatus.textContent = "\u2717 Network error mapping share.";
console.error(ex);
}
});
}
const saveClusterSettingsBtn = document.getElementById("save-cluster-settings-btn");
const clusterSettingsStatus = document.getElementById("cluster-settings-status");
if (saveClusterSettingsBtn) {
saveClusterSettingsBtn.addEventListener("click", async () => {
const role = nodeRoleSelect ? nodeRoleSelect.value : "standalone";
const coordinatorUrl = document.getElementById("coordinator-url-input") ? document.getElementById("coordinator-url-input").value.trim() : "";
const workerNodeUrl = document.getElementById("worker-url-input") ? document.getElementById("worker-url-input").value.trim() : "";
const useSharedStorage = useSharedStorageCheckbox ? useSharedStorageCheckbox.checked : false;
const sharedStoragePath = document.getElementById("shared-storage-path-input") ? document.getElementById("shared-storage-path-input").value.trim() : "";
clusterSettingsStatus.textContent = "";
clusterSettingsStatus.style.color = "";
saveClusterSettingsBtn.disabled = true;
saveClusterSettingsBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Saving...';
try {
// Fetch current settings to preserve other values
const currentRes = await fetch("/api/settings");
const currentSettings = currentRes.ok ? await currentRes.json() : {};
const updatedSettings = {
...currentSettings,
role,
coordinator_url: coordinatorUrl,
worker_node_url: workerNodeUrl,
use_shared_storage: useSharedStorage,
shared_storage_path: sharedStoragePath
};
const res = await fetch("/api/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updatedSettings)
});
if (res.ok) {
clusterSettingsStatus.style.color = "var(--success, #4caf50)";
clusterSettingsStatus.textContent = "\u2713 Cluster settings saved successfully.";
} else {
const errData = await res.json().catch(() => ({}));
clusterSettingsStatus.style.color = "var(--danger, #f44336)";
clusterSettingsStatus.textContent = `\u2717 ${errData.detail || "Failed to save setting."}`;
}
} catch (err) {
clusterSettingsStatus.style.color = "var(--danger, #f44336)";
clusterSettingsStatus.textContent = "\u2717 Network error saving setting.";
console.error(err);
} finally {
saveClusterSettingsBtn.disabled = false;
saveClusterSettingsBtn.innerHTML = '<i class="fa-solid fa-floppy-disk"></i> Save Cluster Settings';
}
});
}
}
/* ----------------------------------------------------
Folder Browser Modal & API Helpers
---------------------------------------------------- */
let activeTargetInput = null;
let currentBrowsePath = "";
function openFolderBrowser(targetInput) {
activeTargetInput = targetInput;
const modal = document.getElementById("folder-modal");
if (!modal) return;
const statusDiv = document.getElementById("folder-modal-status");
if (statusDiv) statusDiv.textContent = "";
let initialPath = targetInput ? targetInput.value.trim() : "";
modal.style.display = "flex";
document.body.style.overflow = "hidden";
loadFolderDir(initialPath);
}
function closeFolderBrowser() {
const modal = document.getElementById("folder-modal");
if (modal) {
modal.style.display = "none";
document.body.style.overflow = "";
}
activeTargetInput = null;
}
async function loadFolderDir(path) {
const listContainer = document.getElementById("folder-list");
const currentPathInput = document.getElementById("folder-current-path");
const statusDiv = document.getElementById("folder-modal-status");
if (!listContainer) return;
listContainer.innerHTML = '<div style="text-align: center; padding: 2rem; color: var(--text-muted);"><i class="fa-solid fa-spinner fa-spin"></i> Loading...</div>';
try {
const url = `/api/system/browse-dir?path=${encodeURIComponent(path)}`;
const res = await fetch(url);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || "Failed to load directory.");
}
const data = await res.json();
currentBrowsePath = data.current_path;
if (currentPathInput) currentPathInput.value = currentBrowsePath;
listContainer.innerHTML = "";
const entries = data.entries || [];
if (entries.length === 0) {
listContainer.innerHTML = '<div style="text-align: center; padding: 2rem; color: var(--text-muted);">Empty directory</div>';
return;
}
entries.forEach(entry => {
const row = document.createElement("div");
row.style.display = "flex";
row.style.alignItems = "center";
row.style.gap = "0.75rem";
row.style.padding = "0.6rem 0.85rem";
row.style.borderRadius = "6px";
row.style.cursor = "pointer";
row.style.transition = "background 0.2s";
row.addEventListener("mouseenter", () => {
row.style.background = "rgba(255, 255, 255, 0.05)";
});
row.addEventListener("mouseleave", () => {
row.style.background = "";
});
const icon = document.createElement("i");
if (entry.name === "..") {
icon.className = "fa-solid fa-arrow-turn-up";
icon.style.color = "var(--text-muted)";
icon.style.transform = "rotate(-90deg)";
} else {
icon.className = "fa-solid fa-folder";
icon.style.color = "var(--primary)";
}
row.appendChild(icon);
const label = document.createElement("span");
label.textContent = entry.name;
label.style.fontSize = "0.9rem";
label.style.fontFamily = "monospace";
row.appendChild(label);
row.addEventListener("click", () => {
loadFolderDir(entry.path);
});
listContainer.appendChild(row);
});
} catch (err) {
listContainer.innerHTML = `<div style="text-align: center; padding: 2rem; color: var(--danger);"><i class="fa-solid fa-triangle-exclamation"></i> Error: ${err.message}</div>`;
if (statusDiv) statusDiv.textContent = err.message;
}
}
async function createFolder() {
const nameInput = document.getElementById("new-folder-name");
const statusDiv = document.getElementById("folder-modal-status");
if (!nameInput || !nameInput.value.trim() || !currentBrowsePath) return;
const folderName = nameInput.value.trim();
if (statusDiv) statusDiv.textContent = "";
try {
const res = await fetch("/api/system/create-dir", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
parent_path: currentBrowsePath,
name: folderName
})
});
if (res.ok) {
nameInput.value = "";
const data = await res.json();
loadFolderDir(data.path);
} else {
const err = await res.json().catch(() => ({}));
if (statusDiv) statusDiv.textContent = err.detail || "Failed to create folder.";
}
} catch (err) {
console.error(err);
if (statusDiv) statusDiv.textContent = "Network error creating folder.";
}
}
async function loadTempInfo() {
const listContainer = document.getElementById("temp-files-list");
const totalSizeBadge = document.getElementById("temp-total-size");
const folderCountBadge = document.getElementById("temp-folder-count");
if (!listContainer) return;
try {
const res = await fetch("/api/system/temp-info");
if (!res.ok) return;
const data = await res.json();
const folders = data.temp_folders || [];
let totalSize = 0;
folders.forEach(f => totalSize += (f.size_bytes || 0));
if (totalSizeBadge) totalSizeBadge.textContent = formatBytes(totalSize);
if (folderCountBadge) folderCountBadge.textContent = folders.length;
if (folders.length === 0) {
listContainer.innerHTML = `
<div style="text-align: center; padding: 2rem; border: 1px dashed var(--border-color); border-radius: 12px; color: var(--text-muted);">
<p>No temporary directories found.</p>
</div>
`;
return;
}
listContainer.innerHTML = "";
folders.forEach(f => {
const item = document.createElement("div");
item.className = "queue-item";
const sizeStr = formatBytes(f.size_bytes || 0);
const isActive = ["analyzing", "extracting", "upscaling", "restoring_faces", "interpolating", "assembling"].includes(f.status);
let statusLabel = f.status.toUpperCase();
let statusClass = f.status;
if (f.status === "orphaned") {
statusLabel = "Orphaned (No Job)";
statusClass = "interrupted";
}
item.innerHTML = `
<div style="display: flex; align-items: center; gap: 1rem; width: 100%; flex-wrap: wrap;">
<div class="queue-item-details" style="flex: 1; min-width: 200px;">
<div class="queue-item-title" style="word-break: break-all; font-size: 0.88rem;">
<i class="fa-solid fa-folder-open" style="color: var(--primary);"></i> ${f.path}
</div>
<div class="queue-item-meta" style="margin-top: 0.25rem;">
<span><i class="fa-solid fa-file-video"></i> Video: ${f.video_name}</span>
<span>Size: ${sizeStr} (${f.file_count} files)</span>
</div>
</div>
<div class="queue-item-status-bar" style="border: none; padding: 0;">
<span class="status-badge ${statusClass}" style="font-size: 0.7rem; padding: 0.15rem 0.5rem; margin-right: 0.5rem;">${statusLabel}</span>
<div class="queue-item-actions">
${f.status === "orphaned" ? `
<button class="btn btn-primary btn-small btn-reimport-temp" data-job-id="${f.job_id}" data-path="${f.path}">
<i class="fa-solid fa-cloud-arrow-down"></i> Re-import
</button>
` : ''}
<button class="btn btn-danger btn-small btn-delete-temp" data-path="${f.path}" ${isActive ? 'disabled style="opacity: 0.5; cursor: not-allowed;" title="Cannot delete active job frames"' : ''}>
<i class="fa-solid fa-trash"></i> Delete
</button>
</div>
</div>
</div>
`;
const deleteBtn = item.querySelector(".btn-delete-temp");
if (deleteBtn && !isActive) {
deleteBtn.addEventListener("click", async (e) => {
e.stopPropagation();
if (confirm(`Are you sure you want to delete this temporary folder?\nPath: ${f.path}`)) {
try {
const cleanRes = await fetch("/api/system/temp-cleanup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ paths: [f.path] })
});
if (cleanRes.ok) {
loadTempInfo();
loadQueue();
} else {
alert("Failed to delete temp folder.");
}
} catch (err) {
console.error(err);
}
}
});
}
const reimportBtn = item.querySelector(".btn-reimport-temp");
if (reimportBtn) {
reimportBtn.addEventListener("click", async (e) => {
e.stopPropagation();
showReimportModal(f.job_id, f.path);
});
}
listContainer.appendChild(item);
});
} catch (err) {
console.error("Error loading temp files info:", err);
}
}
async function loadClusterSettings() {
const nodeRoleSelect = document.getElementById("node-role-select");
const coordinatorUrlInput = document.getElementById("coordinator-url-input");
const workerUrlInput = document.getElementById("worker-url-input");
const useSharedStorageCheckbox = document.getElementById("use-shared-storage-checkbox");
const sharedStoragePathInput = document.getElementById("shared-storage-path-input");
const smbSharesList = document.getElementById("smb-shares-list");
const coordUrlGroup = document.getElementById("coord-url-group");
const workerUrlGroup = document.getElementById("worker-url-group");
const sharedStoragePathGroup = document.getElementById("shared-storage-path-group");
try {
const res = await fetch("/api/settings");
if (!res.ok) return;
const settings = await res.json();
// Node role
if (nodeRoleSelect) {
nodeRoleSelect.value = settings.role || "standalone";
const role = nodeRoleSelect.value;
if (coordUrlGroup) coordUrlGroup.style.display = role === "worker" ? "block" : "none";
if (workerUrlGroup) workerUrlGroup.style.display = role === "worker" || role === "coordinator" ? "block" : "none";
}
if (coordinatorUrlInput) coordinatorUrlInput.value = settings.coordinator_url || "";
if (workerUrlInput) workerUrlInput.value = settings.worker_node_url || "http://localhost:8000";
// Shared Storage
if (useSharedStorageCheckbox) {
useSharedStorageCheckbox.checked = !!settings.use_shared_storage;
if (sharedStoragePathGroup) sharedStoragePathGroup.style.display = settings.use_shared_storage ? "flex" : "none";
}
if (sharedStoragePathInput) sharedStoragePathInput.value = settings.shared_storage_path || "";
// Render SMB Shares
if (smbSharesList) {
const shares = settings.smb_shares || [];
if (shares.length === 0) {
smbSharesList.innerHTML = `
<div style="text-align: center; padding: 1rem; border: 1px dashed var(--border-color); border-radius: 8px; color: var(--text-muted); font-size: 0.8rem;">
No mapped network shares.
</div>
`;
} else {
smbSharesList.innerHTML = "";
shares.forEach(share => {
const item = document.createElement("div");
item.className = "queue-item";
item.style.padding = "0.75rem";
item.style.display = "flex";
item.style.justifyContent = "space-between";
item.style.alignItems = "center";
item.style.gap = "1rem";
item.innerHTML = `
<div style="flex: 1; min-width: 0;">
<div style="font-weight: 600; font-size: 0.88rem; display: flex; align-items: center; gap: 0.4rem;">
<i class="fa-solid fa-folder-open" style="color: var(--primary);"></i> ${share.name}
</div>
<div style="font-size: 0.78rem; color: var(--text-muted); margin-top: 0.2rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
<span><i class="fa-solid fa-server"></i> //${share.server}/${share.share}</span>
<span style="margin-left: 0.75rem;"><i class="fa-solid fa-right-to-bracket"></i> Mount: ${share.mount_path}</span>
</div>
</div>
<div>
<button class="btn btn-danger btn-small btn-delete-smb" data-name="${share.name}" style="padding: 0.25rem 0.5rem; font-size: 0.75rem;">
<i class="fa-solid fa-trash"></i> Unmap
</button>
</div>
`;
const deleteBtn = item.querySelector(".btn-delete-smb");
if (deleteBtn) {
deleteBtn.addEventListener("click", async (e) => {
e.stopPropagation();
if (confirm(`Are you sure you want to unmap SMB share "${share.name}"?`)) {
await deleteSmbShare(share.name);
}
});
}
smbSharesList.appendChild(item);
});
}
}
} catch (err) {
console.error("Error loading cluster settings:", err);
}
}
async function deleteSmbShare(shareName) {
try {
const getRes = await fetch("/api/settings");
if (!getRes.ok) throw new Error("Failed to load settings.");
const settings = await getRes.json();
if (settings.smb_shares) {
settings.smb_shares = settings.smb_shares.filter(s => s.name !== shareName);
}
const saveRes = await fetch("/api/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(settings)
});
if (saveRes.ok) {
loadClusterSettings();
} else {
const err = await saveRes.json().catch(() => ({}));
alert(`Failed to delete SMB share: ${err.detail || "Unknown error"}`);
}
} catch (err) {
console.error("Error deleting SMB share:", err);
alert("Network error deleting SMB share.");
}
}
async function loadDefaultTempDir() {
try {
const res = await fetch("/api/settings");
if (!res.ok) return;
const settings = await res.json();
// Temp dir settings
const savedDir = settings.default_temp_dir || "";
const defaultInput = document.getElementById("default-temp-dir-input");
if (defaultInput) defaultInput.value = savedDir;
const perJobInput = document.getElementById("temp-dir-input");
if (perJobInput && savedDir && !perJobInput.value.trim()) {
perJobInput.placeholder = `Default: ${savedDir} (override here)`;
}
// Output dir settings
const savedOutputDir = settings.default_output_dir || "";
const defaultOutputDirInput = document.getElementById("default-output-dir-input");
if (defaultOutputDirInput) defaultOutputDirInput.value = savedOutputDir;
const perJobOutputDirInput = document.getElementById("output-dir-input");
if (perJobOutputDirInput && savedOutputDir && !perJobOutputDirInput.value.trim()) {
perJobOutputDirInput.placeholder = `Default: ${savedOutputDir} (override here)`;
}
} catch (err) {
console.error("Failed to load settings:", err);
}
}
async function showReimportModal(jobId, tempPath) {
const modal = document.getElementById("reimport-modal");
const jobIdInput = document.getElementById("reimport-job-id");
const tempDirInput = document.getElementById("reimport-temp-dir");
const videoSelect = document.getElementById("reimport-video-select");
if (!modal || !jobIdInput || !tempDirInput || !videoSelect) return;
jobIdInput.value = jobId;
// Resolve custom parent directory if tempPath contains jobId
const parentDir = tempPath.substring(0, tempPath.lastIndexOf("/" + jobId));
tempDirInput.value = parentDir;
// Populate video files dropdown
videoSelect.innerHTML = '<option value="">-- Loading Uploaded Videos --</option>';
try {
const res = await fetch("/api/uploads");
if (res.ok) {
const uploads = await res.json();
if (uploads.length === 0) {
videoSelect.innerHTML = '<option value="">No uploaded videos found. Upload one first.</option>';
} else {
videoSelect.innerHTML = '<option value="">-- Select a Video File --</option>';
uploads.forEach(u => {
// Reimport requires the absolute path to the uploaded video file
const videoPath = `/var/home/david/Documents/vscode/video_upscaler/uploads/${u.original_filename}`;
const opt = document.createElement("option");
opt.value = videoPath;
opt.textContent = `${u.original_filename} (${formatBytes(u.size_bytes)})`;
videoSelect.appendChild(opt);
});
// Try to auto-match files by looking for standard uploaded prefix or creation time
try {
const matches = Array.from(videoSelect.options).filter(opt => opt.value.includes("8d4d3e0e-e5a5-4101-9e2d-550f96c1674f"));
if (matches.length > 0) {
matches[0].selected = true;
}
} catch(e) {}
}
} else {
videoSelect.innerHTML = '<option value="">Error loading uploads.</option>';
}
} catch (err) {
console.error(err);
videoSelect.innerHTML = '<option value="">Error loading uploads.</option>';
}
modal.style.display = "flex";
document.body.style.overflow = "hidden";
}
// Re-import modal close event
const reimportModal = document.getElementById("reimport-modal");
const reimportCloseBtn = document.getElementById("reimport-modal-close-btn");
if (reimportModal && reimportCloseBtn) {
reimportCloseBtn.addEventListener("click", () => {
reimportModal.style.display = "none";
document.body.style.overflow = "";
});
reimportModal.addEventListener("click", (e) => {
if (e.target === reimportModal) {
reimportModal.style.display = "none";
document.body.style.overflow = "";
}
});
}
// Re-import form submission handler
const reimportForm = document.getElementById("reimport-form");
if (reimportForm) {
reimportForm.addEventListener("submit", async (e) => {
e.preventDefault();
const jobId = document.getElementById("reimport-job-id").value;
const tempDir = document.getElementById("reimport-temp-dir").value;
const videoPath = document.getElementById("reimport-video-select").value;
const model = document.getElementById("reimport-model-select").value;
const faceRestoration = document.getElementById("reimport-face-restoration").checked;
const rifeInterpolation = document.getElementById("reimport-rife-interpolation").checked;
const audioDenoise = document.getElementById("reimport-audio-denoise").checked;
if (!videoPath) {
alert("Please select a video file.");
return;
}
try {
const res = await fetch(`/api/system/temp-reimport/${jobId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
video_path: videoPath,
model: model,
scale: 4,
ai_face_restoration: faceRestoration,
ai_rife_interpolation: rifeInterpolation,
ai_audio_denoise: audioDenoise,
temp_dir: tempDir
})
});
if (res.ok) {
reimportModal.style.display = "none";
document.body.style.overflow = "";
loadQueue();
loadTempInfo();
} else {
const err = await res.json();
alert("Failed to reimport job: " + (err.detail || "unknown error"));
}
} catch (err) {
console.error(err);
alert("Network error occurred.");
}
});
}
initializeApp();
/* ----------------------------------------------------
7. Server File Browser logic
---------------------------------------------------- */
let selectedServerFiles = [];
let fileBrowserCurrentPath = "";
window.selectedServerFilePath = null;
window.selectedServerFilePaths = null;
function openServerFileBrowser() {
const modal = document.getElementById("file-browser-modal");
if (!modal) return;
selectedServerFiles = [];
document.getElementById("file-browser-selected-count").textContent = "0";
document.getElementById("file-browser-select-btn").disabled = true;
document.getElementById("file-browser-modal-status").textContent = "";
modal.style.display = "flex";
document.body.style.overflow = "hidden";
loadFileBrowserDir("");
}
function closeServerFileBrowser() {
const modal = document.getElementById("file-browser-modal");
if (modal) {
modal.style.display = "none";
document.body.style.overflow = "";
}
}
async function loadFileBrowserDir(path) {
const listContainer = document.getElementById("file-browser-list");
const currentPathInput = document.getElementById("file-browser-current-path");
const statusDiv = document.getElementById("file-browser-modal-status");
if (!listContainer) return;
listContainer.innerHTML = '<div style="text-align: center; padding: 2rem; color: var(--text-muted);"><i class="fa-solid fa-spinner fa-spin"></i> Loading...</div>';
try {
const url = `/api/system/browse-dir?path=${encodeURIComponent(path)}&show_files=true`;
const res = await fetch(url);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || "Failed to load directory.");
}
const data = await res.json();
fileBrowserCurrentPath = data.current_path;
if (currentPathInput) currentPathInput.value = fileBrowserCurrentPath;
listContainer.innerHTML = "";
const entries = data.entries || [];
if (entries.length === 0) {
listContainer.innerHTML = '<div style="text-align: center; padding: 2rem; color: var(--text-muted);">Empty directory</div>';
return;
}
// Parent directory entry first
const parentEntry = entries.find(e => e.name === "..");
const restEntries = entries.filter(e => e.name !== "..");
const allEntries = parentEntry ? [parentEntry, ...restEntries] : restEntries;
allEntries.forEach(entry => {
const row = document.createElement("div");
row.style.display = "flex";
row.style.alignItems = "center";
row.style.gap = "0.75rem";
row.style.padding = "0.6rem 0.85rem";
row.style.borderRadius = "6px";
row.style.cursor = "pointer";
row.style.transition = "background 0.2s";
row.style.borderBottom = "1px solid rgba(255, 255, 255, 0.02)";
const isSelected = selectedServerFiles.includes(entry.path);
if (isSelected) {
row.style.background = "rgba(var(--primary-rgb), 0.15)";
row.style.borderLeft = "3px solid var(--primary)";
}
row.addEventListener("mouseenter", () => {
if (!selectedServerFiles.includes(entry.path)) {
row.style.background = "rgba(255, 255, 255, 0.05)";
}
});
row.addEventListener("mouseleave", () => {
if (!selectedServerFiles.includes(entry.path)) {
row.style.background = "";
}
});
const icon = document.createElement("i");
if (entry.name === "..") {
icon.className = "fa-solid fa-arrow-turn-up";
icon.style.color = "var(--text-muted)";
icon.style.transform = "rotate(-90deg)";
} else if (entry.is_dir) {
icon.className = "fa-solid fa-folder";
icon.style.color = "var(--primary)";
} else {
icon.className = "fa-solid fa-file-video";
icon.style.color = "#00bcd4";
}
row.appendChild(icon);
const label = document.createElement("span");
label.textContent = entry.name;
label.style.fontSize = "0.9rem";
label.style.fontFamily = "monospace";
row.appendChild(label);
row.addEventListener("click", () => {
if (entry.name === "..") {
loadFileBrowserDir(entry.path);
} else if (entry.is_dir) {
loadFileBrowserDir(entry.path);
} else {
const idx = selectedServerFiles.indexOf(entry.path);
if (idx > -1) {
selectedServerFiles.splice(idx, 1);
row.style.background = "";
row.style.borderLeft = "";
} else {
selectedServerFiles.push(entry.path);
row.style.background = "rgba(var(--primary-rgb), 0.15)";
row.style.borderLeft = "3px solid var(--primary)";
}
const countSpan = document.getElementById("file-browser-selected-count");
if (countSpan) countSpan.textContent = selectedServerFiles.length;
const selectBtn = document.getElementById("file-browser-select-btn");
if (selectBtn) selectBtn.disabled = selectedServerFiles.length === 0;
}
});
listContainer.appendChild(row);
});
} catch (err) {
if (statusDiv) statusDiv.textContent = err.message;
listContainer.innerHTML = '<div style="text-align: center; padding: 2rem; color: var(--danger);"><i class="fa-solid fa-circle-exclamation"></i> Error loading directory</div>';
}
}
// Wire up events once DOM is ready or after initializeApp
setTimeout(() => {
const browseServerBtn = document.getElementById("browse-server-btn");
const fileBrowserCloseBtn = document.getElementById("file-browser-modal-close-btn");
const fileBrowserCancelBtn = document.getElementById("file-browser-cancel-btn");
const fileBrowserSelectBtn = document.getElementById("file-browser-select-btn");
const fileBrowserUpBtn = document.getElementById("file-browser-up-btn");
if (browseServerBtn) {
browseServerBtn.addEventListener("click", openServerFileBrowser);
}
if (fileBrowserCloseBtn) {
fileBrowserCloseBtn.addEventListener("click", closeServerFileBrowser);
}
if (fileBrowserCancelBtn) {
fileBrowserCancelBtn.addEventListener("click", closeServerFileBrowser);
}
// Close modal when clicking outside card
const fileBrowserModal = document.getElementById("file-browser-modal");
if (fileBrowserModal) {
fileBrowserModal.addEventListener("click", (e) => {
if (e.target === fileBrowserModal) {
closeServerFileBrowser();
}
});
}
if (fileBrowserUpBtn) {
fileBrowserUpBtn.addEventListener("click", () => {
if (fileBrowserCurrentPath) {
const parts = fileBrowserCurrentPath.split("/");
if (parts.length > 1) {
parts.pop();
const parent = parts.join("/") || "/";
loadFileBrowserDir(parent);
}
}
});
}
if (fileBrowserSelectBtn) {
fileBrowserSelectBtn.addEventListener("click", async () => {
if (selectedServerFiles.length === 0) return;
fileBrowserSelectBtn.disabled = true;
fileBrowserSelectBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Analyzing...';
try {
if (selectedServerFiles.length === 1) {
const path = selectedServerFiles[0];
const url = `/api/system/file-metadata?path=${encodeURIComponent(path)}`;
const res = await fetch(url);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || "Failed to read file metadata.");
}
const data = await res.json();
window.selectedServerFilePath = path;
window.selectedServerFilePaths = null;
uploadFileId = null;
useUploadedVideo({
file_id: null,
original_filename: data.original_filename,
size_bytes: data.size_bytes,
metadata: data.metadata
});
} else {
const entries = [];
for (const path of selectedServerFiles) {
const url = `/api/system/file-metadata?path=${encodeURIComponent(path)}`;
const res = await fetch(url);
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(`Failed to read metadata for: ${path}`);
}
const data = await res.json();
entries.push({
file_id: null,
server_file_path: path,
original_filename: data.original_filename,
size_bytes: data.size_bytes,
metadata: data.metadata
});
}
window.selectedServerFilePath = null;
window.selectedServerFilePaths = selectedServerFiles;
uploadFileId = null;
useBatchUploadedVideos(entries);
}
closeServerFileBrowser();
} catch (err) {
const statusDiv = document.getElementById("file-browser-modal-status");
if (statusDiv) statusDiv.textContent = err.message;
} finally {
fileBrowserSelectBtn.disabled = false;
fileBrowserSelectBtn.innerHTML = 'Select File(s)';
}
});
}
}, 500);