Files
video_upscaler/static/app.js
T
david c7b637e70a Implement full system directory browser modal for temp directories
- Add GET /api/system/browse-dir and POST /api/system/create-dir endpoints
- Add Folder Browser Modal overlay in index.html
- Add browse buttons to both job-specific and default setting temp dir inputs
- Add JS helpers and event handlers for directory list loading, parent navigation, directory selection, and new folder creation
2026-06-29 10:02:58 -04:00

2505 lines
103 KiB
JavaScript

// App State
let uploadFileId = null;
let uploadMetadata = null;
let currentJobId = null;
let socket = null;
let sliderInitialized = false;
// Error formatting helper
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) 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,
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) 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) {
const res = await fetch("/api/upscale/start/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
file_ids: window.batchFileIds,
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 = [];
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,
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 = `/api/download/${data.original_preview_file}`;
vidUpscaled.src = `/api/download/${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 = `/api/download/${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 = `/api/download/${job.output_file.split("/").pop()}`;
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 basename = file.filename.replace("upscaled_", "");
const jobId = basename.split(".")[0];
const matchingJob = jobs.find(j => 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} (${matchingJob.scale}x)`;
originalFilename = matchingJob.video_path.split("/").pop();
originalUrl = `/api/uploads/${originalFilename}`;
}
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"].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.split('/').pop()}</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 = "";
if (job.status === "queued" && job.queue_position !== null && job.queue_position !== undefined) {
const queuedJobs = displayJobs.filter(j => j.status === "queued");
const isFirst = job.queue_position === 0;
const isLast = job.queue_position === queuedJobs.length - 1;
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 = `/api/download/${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() {
await loadModels();
await checkActiveJobs();
await loadGallery(); // Load enhancement gallery at page load
await loadQueue(); // Load active jobs queue at page load
await loadUploadsBrowser(); // Load previously uploaded videos browser
// 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;
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 on init
loadDefaultTempDir();
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 {
const res = await fetch("/api/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ default_temp_dir: newDir })
});
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';
}
});
}
// Wire up folder browser modals
const browseJobTempBtn = document.getElementById("browse-job-temp-btn");
const browseDefaultTempBtn = document.getElementById("browse-default-temp-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 (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();
}
});
}
}
/* ----------------------------------------------------
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">
<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);
}
}
});
}
listContainer.appendChild(item);
});
} catch (err) {
console.error("Error loading temp files info:", err);
}
}
async function loadDefaultTempDir() {
try {
const res = await fetch("/api/settings");
if (!res.ok) return;
const settings = await res.json();
const savedDir = settings.default_temp_dir || "";
const defaultInput = document.getElementById("default-temp-dir-input");
if (defaultInput) defaultInput.value = savedDir;
// Update per-job input placeholder
const perJobInput = document.getElementById("temp-dir-input");
if (perJobInput && savedDir && !perJobInput.value.trim()) {
perJobInput.placeholder = `Default: ${savedDir} (override here)`;
}
} catch (err) {
console.error("Failed to load settings:", err);
}
}
initializeApp();