- Add Output Destination folder selector with file browser and fallback to outputs/ - Apply YYYY-MM-DD start-date suffix naming rule to upscaled outputs - Implement active job preemption (demoting active running job back to queue) via reorder - Implement Coordinator / Worker roles with automatic pull-based chunk processing - Support Shared Storage mode and HTTP-based frame upload/download fallback - Add SMB mapped share configuration and background CIFS mount support - Update index.html and app.js with the new Settings sections and modals
2916 lines
123 KiB
JavaScript
2916 lines
123 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) 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 = 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 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 = "";
|
|
|
|
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() {
|
|
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 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">
|
|
<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 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);
|
|
}
|
|
}
|
|
|
|
initializeApp();
|