diff --git a/app/main.py b/app/main.py index 342f699..020d0a6 100644 --- a/app/main.py +++ b/app/main.py @@ -235,6 +235,35 @@ class StartUpscaleRequest(BaseModel): ai_face_restoration: bool = False ai_rife_interpolation: bool = False ai_audio_denoise: bool = False + temp_dir: str | None = None + +class StartBatchUpscaleRequest(BaseModel): + file_ids: List[str] + model: str = "realesr-animevideov3" + scale: int = 4 + tile_size: int = 256 + preserve_audio: bool = True + ss: str | None = None + t: str | None = None + gpu_ids: str | None = None + tta: bool = False + unsharp: bool = False + double_fps: bool = False + preserve_subtitles: bool = True + start_sec: float | None = None + end_sec: float | None = None + crf: int = 18 + preset: str = "medium" + denoise: bool = False + sharpen: bool = False + interpolation: bool = False + webhook_url: str | None = None + transcode_format: str = "mp4" + is_preview: bool = False + ai_face_restoration: bool = False + ai_rife_interpolation: bool = False + ai_audio_denoise: bool = False + temp_dir: str | None = None class PreviewRequest(BaseModel): file_id: str @@ -422,7 +451,8 @@ def start_upscale(req: StartUpscaleRequest): is_preview=req.is_preview, ai_face_restoration=req.ai_face_restoration, ai_rife_interpolation=req.ai_rife_interpolation, - ai_audio_denoise=req.ai_audio_denoise + ai_audio_denoise=req.ai_audio_denoise, + temp_dir=req.temp_dir ) jobs_db[job_id] = job @@ -443,6 +473,80 @@ def start_upscale(req: StartUpscaleRequest): "status": "queued" } +@app.post("/api/upscale/start/batch") +def start_upscale_batch(req: StartBatchUpscaleRequest): + """Queue multiple upscaling tasks with one set of settings""" + queued_jobs = [] + + # 1. Validation loop + for file_id in req.file_ids: + file_path = None + exts = [".mp4", ".mkv", ".avi", ".mov", ".webm"] + for ext in exts: + test_path = os.path.join(upscaler.UPLOAD_DIR, f"{file_id}{ext}") + if os.path.exists(test_path): + file_path = test_path + break + if not file_path: + raise HTTPException(status_code=404, detail=f"Uploaded file {file_id} not found.") + + # 2. Queueing loop + for file_id in req.file_ids: + file_path = None + for ext in [".mp4", ".mkv", ".avi", ".mov", ".webm"]: + test_path = os.path.join(upscaler.UPLOAD_DIR, f"{file_id}{ext}") + if os.path.exists(test_path): + file_path = test_path + break + + job_id = str(uuid.uuid4()) + job = upscaler.UpscaleJob( + job_id=job_id, + video_path=file_path, + model=req.model, + scale=req.scale, + tile_size=req.tile_size, + preserve_audio=req.preserve_audio, + ss=req.ss, + t=req.t, + gpu_ids=req.gpu_ids, + tta=req.tta, + unsharp=req.unsharp, + double_fps=req.double_fps, + preserve_subtitles=req.preserve_subtitles, + start_sec=req.start_sec, + end_sec=req.end_sec, + crf=req.crf, + preset=req.preset, + denoise=req.denoise, + sharpen=req.sharpen, + interpolation=req.interpolation, + webhook_url=req.webhook_url, + transcode_format=req.transcode_format, + is_preview=req.is_preview, + ai_face_restoration=req.ai_face_restoration, + ai_rife_interpolation=req.ai_rife_interpolation, + ai_audio_denoise=req.ai_audio_denoise, + temp_dir=req.temp_dir + ) + jobs_db[job_id] = job + job_queue.put(job_id) + queued_jobs.append({"job_id": job_id, "status": "queued"}) + + save_jobs_db() + + # Broadcast initial queued progress for all queued jobs + for qj in queued_jobs: + broadcast_progress(qj["job_id"], { + "status": "queued", + "progress": 0.0, + "current_frame": 0, + "total_frames": 0, + "eta": "Calculating..." + }) + + return {"jobs": queued_jobs} + @app.get("/api/upscale/status/{job_id}") def get_status(job_id: str): """Get status of upscale job""" @@ -699,7 +803,7 @@ def get_queue(): @app.post("/api/upscale/resume/{job_id}") def resume_job(job_id: str): - """Resume an interrupted/failed upscale job""" + """Resume an interrupted/failed/paused upscale job""" job = jobs_db.get(job_id) if not job: raise HTTPException(status_code=404, detail="Job not found.") @@ -708,6 +812,8 @@ def resume_job(job_id: str): job.status = "queued" job.error = None job.eta = "Queued for resume..." + if hasattr(job, "_is_paused"): + job._is_paused = False job_queue.put(job_id) save_jobs_db() @@ -722,6 +828,33 @@ def resume_job(job_id: str): return {"job_id": job_id, "status": "queued"} +@app.post("/api/upscale/pause/{job_id}") +def pause_job(job_id: str): + """Pause a running or queued job""" + job = jobs_db.get(job_id) + if not job: + raise HTTPException(status_code=404, detail="Job not found.") + + if job.status not in ["queued", "pending", "analyzing", "extracting", "upscaling", "restoring_faces", "interpolating", "assembling"]: + raise HTTPException(status_code=400, detail=f"Job in status {job.status} cannot be paused.") + + # Remove from queue if it is in queue + job_queue.remove(job_id) + + # Call pause logic on the job + job.pause() + save_jobs_db() + + broadcast_progress(job_id, { + "status": "paused", + "progress": job.progress, + "current_frame": job.current_frame, + "total_frames": job.total_frames, + "eta": "Paused" + }) + + return {"job_id": job_id, "status": "paused"} + # Websocket endpoint for real-time progress updates @app.websocket("/ws/progress/{job_id}") async def websocket_progress(websocket: WebSocket, job_id: str): diff --git a/app/upscaler.py b/app/upscaler.py index 7ea583d..3822a66 100644 --- a/app/upscaler.py +++ b/app/upscaler.py @@ -26,8 +26,9 @@ class UpscaleJob: denoise: bool = False, sharpen: bool = False, interpolation: bool = False, webhook_url: str = None, transcode_format: str = "mp4", is_preview: bool = False, ai_face_restoration: bool = False, ai_rife_interpolation: bool = False, - ai_audio_denoise: bool = False): + ai_audio_denoise: bool = False, temp_dir: str = None): self.job_id = job_id + self.temp_dir = temp_dir self.video_path = video_path self.model = model self.scale = scale @@ -79,9 +80,10 @@ class UpscaleJob: self.start_time = None self.output_file = None - # Track processes to allow cancellation + # Track processes to allow cancellation/pause self._processes = [] self._is_cancelled = False + self._is_paused = False self._lock = threading.Lock() def to_dict(self) -> dict: @@ -106,6 +108,7 @@ class UpscaleJob: setattr(job, k, v) job._processes = [] job._is_cancelled = False + job._is_paused = data.get('_is_paused', False) or (data.get('status') == 'paused') job._lock = threading.Lock() return job @@ -137,10 +140,32 @@ class UpscaleJob: pass self._processes.clear() + def pause(self): + with self._lock: + if self.status in ["queued", "pending"]: + self.status = "paused" + self.eta = "Paused" + elif self.status in ["analyzing", "extracting", "upscaling", "restoring_faces", "interpolating", "assembling"]: + self._is_paused = True + self.status = "paused" + self.eta = "Paused" + for p in self._processes: + try: + p.terminate() + p.wait(timeout=2) + except Exception: + try: + p.kill() + except Exception: + pass + self._processes.clear() + def run_command(self, cmd: list, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=False) -> subprocess.Popen: with self._lock: if self._is_cancelled: raise InterruptedError("Job was cancelled") + if getattr(self, "_is_paused", False): + raise InterruptedError("Job was paused") p = subprocess.Popen( cmd, @@ -240,6 +265,7 @@ def upscale_image_file(input_path: str, output_path: str, model: str, scale: int return False def run_upscale_pipeline(job: UpscaleJob, on_progress_update: Callable[[str, Dict[str, Any]], None]): + print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] [Job {job.job_id}] starting pipeline. Video path: {job.video_path}, Model: {job.model}, Scale: {job.scale}") job.start_time = time.time() job.update_status("analyzing", progress=5) @@ -254,7 +280,8 @@ def run_upscale_pipeline(job: UpscaleJob, on_progress_update: Callable[[str, Dic fps = info["fps"] # Create job temp directories - job_temp_dir = os.path.join(TEMP_DIR, job.job_id) + base_temp = job.temp_dir if getattr(job, "temp_dir", None) else TEMP_DIR + job_temp_dir = os.path.join(base_temp, job.job_id) input_frames_dir = os.path.join(job_temp_dir, "input_frames") output_frames_dir = os.path.join(job_temp_dir, "output_frames") @@ -299,6 +326,7 @@ def run_upscale_pipeline(job: UpscaleJob, on_progress_update: Callable[[str, Dic if not skip_extraction: job.update_status("extracting", progress=10) on_progress_update(job.job_id, {"status": "extracting", "progress": 10}) + print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] [Job {job.job_id}] extracting frames. Command: {' '.join(extract_cmd)}") # High quality JPG frames to balance disk usage and speed extract_cmd = ["ffmpeg", "-y"] @@ -327,6 +355,7 @@ def run_upscale_pipeline(job: UpscaleJob, on_progress_update: Callable[[str, Dic # Count actual frames extracted extracted_files = sorted([f for f in os.listdir(input_frames_dir) if f.startswith("frame_")]) actual_total = len(extracted_files) + print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] [Job {job.job_id}] extraction completed. Extracted {actual_total} frames.") if actual_total == 0: raise RuntimeError("No frames extracted from video") @@ -384,6 +413,7 @@ def run_upscale_pipeline(job: UpscaleJob, on_progress_update: Callable[[str, Dic upscale_stderr_path = os.path.join(job_temp_dir, "upscale_stderr.log") with open(upscale_stdout_path, "w") as f_out, open(upscale_stderr_path, "w") as f_err: + print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] [Job {job.job_id}] starting realesrgan upscaling. Model: {job.model}, Tile size: {current_tile_size}. Command: {' '.join(upscale_cmd)}") p_upscale = job.run_command(upscale_cmd, stdout=f_out, stderr=f_err) # Monitor thread for output files @@ -479,7 +509,7 @@ def run_upscale_pipeline(job: UpscaleJob, on_progress_update: Callable[[str, Dic gfpgan_installed = importlib.util.find_spec("gfpgan") is not None if gfpgan_installed: - print(f"Job {job.job_id}: GFPGAN detected. Running Face Restoration...") + print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] [Job {job.job_id}] starting GFPGAN face restoration.") restored_dir = os.path.join(job_temp_dir, "restored_frames") os.makedirs(restored_dir, exist_ok=True) @@ -521,6 +551,7 @@ def run_upscale_pipeline(job: UpscaleJob, on_progress_update: Callable[[str, Dic if getattr(job, "ai_rife_interpolation", False): rife_bin = os.path.join(BASE_DIR, "rife-bin", "rife-ncnn-vulkan") if os.path.isfile(rife_bin): + print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] [Job {job.job_id}] starting RIFE frame interpolation.") job.update_status("interpolating", progress=83.0) on_progress_update(job.job_id, {"status": "interpolating", "progress": 83.0}) os.makedirs(rife_frames_dir, exist_ok=True) @@ -630,6 +661,7 @@ def run_upscale_pipeline(job: UpscaleJob, on_progress_update: Callable[[str, Dic out_filepath ]) + print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] [Job {job.job_id}] starting video assembly. Command: {' '.join(assemble_cmd)}") p_assemble = job.run_command(assemble_cmd) stdout, stderr = p_assemble.communicate() job.cleanup_process(p_assemble) @@ -645,13 +677,17 @@ def run_upscale_pipeline(job: UpscaleJob, on_progress_update: Callable[[str, Dic "eta": "Done", "output_file": out_filename }) + print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] [Job {job.job_id}] pipeline completed successfully. Output file: {job.output_file}") except Exception as e: import traceback traceback.print_exc() - if not job._is_cancelled: + if not job._is_cancelled and not getattr(job, "_is_paused", False) and job.status != "paused": job.update_status("failed", error=str(e)) on_progress_update(job.job_id, {"status": "failed", "error": str(e)}) + print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] [Job {job.job_id}] pipeline failed. Error: {e}") + else: + print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] [Job {job.job_id}] pipeline halted. Status: {job.status}") finally: # Clean up temp frames to save space only if completed or cancelled try: diff --git a/static/app.js b/static/app.js index fefb3e5..b174788 100644 --- a/static/app.js +++ b/static/app.js @@ -241,13 +241,99 @@ if (uploadModelBtn) { dropZone.addEventListener('drop', (e) => { const dt = e.dataTransfer; const files = dt.files; - if (files.length) handleVideoUpload(files[0]); + if (files.length > 1) { + handleBatchUpload(files); + } else if (files.length === 1) { + handleVideoUpload(files[0]); + } }); fileInput.addEventListener('change', (e) => { - if (e.target.files.length) handleVideoUpload(e.target.files[0]); + const files = e.target.files; + if (files.length > 1) { + handleBatchUpload(files); + } else if (files.length === 1) { + handleVideoUpload(files[0]); + } }); +function resetDropZone() { + dropZone.innerHTML = ` + +

Drag & Drop Video File(s)

+

Supports MP4, MKV, AVI, MOV, WEBM (Max 500MB recommended for local upscaling)

+ + `; +} + +async function handleBatchUpload(files) { + const totalFiles = files.length; + let uploadedCount = 0; + + dropZone.innerHTML = ` +
+

Uploading 1 of ${totalFiles} Files...

+

Preparing batch upload...

+
+
+
+ `; + + 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; @@ -718,53 +804,105 @@ startUpscaleBtn.addEventListener("click", async () => { 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 { - 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 - }) - }); - - if (!res.ok) { - const err = await res.json(); - throw new Error(formatFetchError(err, "Orchestrator failed to launch job.")); + 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 = ` 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); } - - 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; @@ -1398,7 +1536,7 @@ async function loadQueue() { `; } - if (job.status === "interrupted" || job.status === "failed") { + 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 = ` @@ -1418,6 +1556,9 @@ async function loadQueue() { + @@ -1433,7 +1574,7 @@ async function loadQueue() { `; } - const showProgress = ["analyzing", "extracting", "upscaling", "restoring_faces", "interpolating", "assembling"].includes(job.status); + const showProgress = ["analyzing", "extracting", "upscaling", "restoring_faces", "interpolating", "assembling", "paused", "interrupted"].includes(job.status); const progressStyle = showProgress ? "display: block;" : "display: none;"; item.innerHTML = ` @@ -1508,6 +1649,24 @@ async function loadQueue() { }); } + 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) => { @@ -1624,6 +1783,19 @@ async function loadModels() { } 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; @@ -1684,10 +1856,55 @@ function useUploadedVideo(entry) { 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; @@ -1700,10 +1917,12 @@ async function loadUploadsBrowser() {

No uploaded files found. Upload a video above to get started!

`; + if (batchConfigBar) batchConfigBar.style.display = "none"; return; } browserList.innerHTML = ""; + let selectedEntries = []; uploads.forEach(entry => { const item = document.createElement("div"); @@ -1715,29 +1934,48 @@ async function loadUploadsBrowser() { const fpsStr = entry.metadata ? `${entry.metadata.fps} FPS` : "-"; item.innerHTML = ` -
-
- ${entry.original_filename} +
+ +
+
+ ${entry.original_filename} +
+
+ Size: ${sizeStr} + Resolution: ${resStr} + Duration: ${durationStr} + FPS: ${fpsStr} +
-
- Size: ${sizeStr} - Resolution: ${resStr} - Duration: ${durationStr} - FPS: ${fpsStr} -
-
-
-
- - +
+
+ + +
`; + 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); }); @@ -1757,6 +1995,16 @@ async function loadUploadsBrowser() { 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); } diff --git a/static/index.html b/static/index.html index 1c55163..d5aa7d8 100644 --- a/static/index.html +++ b/static/index.html @@ -42,10 +42,10 @@
+
- -

Drag & Drop Video File

+

Drag & Drop Video File(s)

Supports MP4, MKV, AVI, MOV, WEBM (Max 500MB recommended for local upscaling)

+ + + +
@@ -267,6 +276,15 @@
+ +
+ + +

+ Specify a custom location for temporary image frames to prevent running out of primary drive space. +

+
+
Pre-Filters & Enhancing diff --git a/static/styles.css b/static/styles.css index bbe40c9..a87c204 100644 --- a/static/styles.css +++ b/static/styles.css @@ -659,6 +659,7 @@ input:checked + .slider:before { .status-badge.completed { background: rgba(0, 255, 135, 0.1); border: 1px solid var(--success); color: var(--success); } .status-badge.failed { background: rgba(255, 0, 85, 0.1); border: 1px solid var(--danger); color: var(--danger); } .status-badge.interrupted { background: rgba(255, 255, 255, 0.08); border: 1px solid var(--text-muted); color: var(--text-muted); } +.status-badge.paused { background: rgba(255, 184, 0, 0.08); border: 1px solid var(--warning); color: var(--warning); } .bar-container {