Implement distributed cluster upscaling, custom output dirs, date-based naming, active preemption, and SMB mappings

- 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
This commit is contained in:
2026-06-29 13:13:08 -04:00
parent f662c5806f
commit f610c86f8b
4 changed files with 1228 additions and 46 deletions
+533 -18
View File
@@ -4,7 +4,9 @@ import queue
import threading
import asyncio
import subprocess
import time
import urllib.request
import urllib.parse
import json
from typing import Dict, List, Any
from fastapi import FastAPI, UploadFile, File, Form, BackgroundTasks, HTTPException, WebSocket, WebSocketDisconnect
@@ -90,7 +92,16 @@ SETTINGS_FILE = os.path.join(upscaler.BASE_DIR, "settings.json")
def load_settings() -> dict:
"""Load persistent app settings from settings.json"""
defaults = {"default_temp_dir": ""}
defaults = {
"default_temp_dir": "",
"default_output_dir": "",
"role": "standalone",
"coordinator_url": "",
"worker_node_url": "http://localhost:8000",
"use_shared_storage": False,
"shared_storage_path": "",
"smb_shares": []
}
if os.path.exists(SETTINGS_FILE):
try:
with open(SETTINGS_FILE, "r") as f:
@@ -141,6 +152,7 @@ def startup_event():
global main_loop
main_loop = asyncio.get_event_loop()
load_jobs_db()
restart_dist_worker()
global_webhook_url = None
@@ -259,6 +271,7 @@ class StartUpscaleRequest(BaseModel):
ai_rife_interpolation: bool = False
ai_audio_denoise: bool = False
temp_dir: str | None = None
output_dir: str | None = None
class StartBatchUpscaleRequest(BaseModel):
file_ids: List[str]
@@ -287,6 +300,7 @@ class StartBatchUpscaleRequest(BaseModel):
ai_rife_interpolation: bool = False
ai_audio_denoise: bool = False
temp_dir: str | None = None
output_dir: str | None = None
class PreviewRequest(BaseModel):
file_id: str
@@ -447,6 +461,18 @@ def start_upscale(req: StartUpscaleRequest):
if not file_path:
raise HTTPException(status_code=404, detail="Uploaded file not found.")
metadata = load_upload_metadata()
source_filename = None
if req.file_id in metadata:
source_filename = metadata[req.file_id].get("original_filename")
if not source_filename and file_path:
source_filename = os.path.basename(file_path)
settings = load_settings()
output_dir = req.output_dir or settings.get("default_output_dir") or None
if output_dir:
output_dir = output_dir.strip()
job_id = str(uuid.uuid4())
job = upscaler.UpscaleJob(
job_id=job_id,
@@ -475,7 +501,9 @@ def start_upscale(req: StartUpscaleRequest):
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 or load_settings().get("default_temp_dir") or None
temp_dir=req.temp_dir or settings.get("default_temp_dir") or None,
output_dir=output_dir,
source_filename=source_filename
)
jobs_db[job_id] = job
@@ -513,6 +541,11 @@ def start_upscale_batch(req: StartBatchUpscaleRequest):
if not file_path:
raise HTTPException(status_code=404, detail=f"Uploaded file {file_id} not found.")
settings = load_settings()
output_dir = req.output_dir or settings.get("default_output_dir") or None
if output_dir:
output_dir = output_dir.strip()
# 2. Queueing loop
for file_id in req.file_ids:
file_path = None
@@ -522,6 +555,13 @@ def start_upscale_batch(req: StartBatchUpscaleRequest):
file_path = test_path
break
metadata = load_upload_metadata()
source_filename = None
if file_id in metadata:
source_filename = metadata[file_id].get("original_filename")
if not source_filename and file_path:
source_filename = os.path.basename(file_path)
job_id = str(uuid.uuid4())
job = upscaler.UpscaleJob(
job_id=job_id,
@@ -550,7 +590,9 @@ def start_upscale_batch(req: StartBatchUpscaleRequest):
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 or load_settings().get("default_temp_dir") or None
temp_dir=req.temp_dir or settings.get("default_temp_dir") or None,
output_dir=output_dir,
source_filename=source_filename
)
jobs_db[job_id] = job
job_queue.put(job_id)
@@ -685,11 +727,40 @@ def get_models():
return sorted(list(models))
@app.get("/api/download/{filename}")
def download_file(filename: str):
def download_file(filename: str, path: str | None = None):
allowed_dirs = {os.path.abspath(upscaler.OUTPUT_DIR)}
settings = load_settings()
default_out = settings.get("default_output_dir", "").strip()
if default_out:
allowed_dirs.add(os.path.abspath(default_out))
for job in jobs_db.values():
if getattr(job, "output_dir", None):
allowed_dirs.add(os.path.abspath(job.output_dir))
if path:
resolved_path = os.path.abspath(path)
if os.path.isdir(resolved_path):
file_path = os.path.join(resolved_path, filename)
else:
file_path = resolved_path
else:
file_path = os.path.join(upscaler.OUTPUT_DIR, filename)
resolved_file_path = os.path.abspath(file_path)
allowed = False
for allowed_dir in allowed_dirs:
if resolved_file_path.startswith(allowed_dir):
allowed = True
break
if not allowed:
raise HTTPException(status_code=403, detail="Access denied.")
if not os.path.exists(file_path):
raise HTTPException(status_code=404, detail="File not found.")
return FileResponse(file_path, media_type="application/octet-stream", filename=filename)
return FileResponse(file_path, media_type="application/octet-stream", filename=os.path.basename(file_path))
def safe_delete_file(file_path: str):
if file_path and os.path.exists(file_path):
@@ -814,15 +885,53 @@ class ReorderQueueRequest(BaseModel):
@app.post("/api/queue/reorder")
def reorder_queue(req: ReorderQueueRequest):
"""Reorder the job queue"""
"""Reorder the job queue with preemption support"""
global current_running_job_id
running_id = current_running_job_id
if running_id and running_id in req.job_ids:
idx = req.job_ids.index(running_id)
if idx > 0:
# Preempt the running job!
job = jobs_db.get(running_id)
if job:
print(f"Preempting running job {running_id} (moved to index {idx} in queue)")
try:
job.pause()
except Exception as e:
print(f"Error pausing job {running_id} for preemption: {e}")
# Re-queue the job
job.status = "queued"
job.error = None
job.eta = "Queued (preempted)..."
if hasattr(job, "_is_paused"):
job._is_paused = False
job_queue.put(running_id)
broadcast_progress(running_id, {
"status": "queued",
"progress": job.progress,
"current_frame": job.current_frame,
"total_frames": job.total_frames,
"eta": "Queued (preempted)..."
})
job_queue.reorder(req.job_ids)
save_jobs_db()
return {"status": "success", "queue": job_queue.get_all()}
q = job_queue.get_all()
if current_running_job_id:
q = [current_running_job_id] + q
return {"status": "success", "queue": q}
@app.get("/api/queue")
def get_queue():
"""Get the current job queue order"""
return {"queue": job_queue.get_all()}
q = job_queue.get_all()
if current_running_job_id:
q = [current_running_job_id] + q
return {"queue": q}
@app.post("/api/upscale/resume/{job_id}")
def resume_job(job_id: str):
@@ -999,32 +1108,83 @@ def get_diagnostics():
# Outputs Gallery Endpoints
@app.get("/api/outputs")
def list_outputs():
output_dir = upscaler.OUTPUT_DIR
if not os.path.exists(output_dir):
return []
output_dirs = {upscaler.OUTPUT_DIR}
settings = load_settings()
default_out = settings.get("default_output_dir", "").strip()
if default_out:
output_dirs.add(default_out)
for job in jobs_db.values():
if job.status == "completed" and job.output_file:
output_dirs.add(os.path.dirname(job.output_file))
elif getattr(job, "output_dir", None):
output_dirs.add(job.output_dir)
files = []
for filename in os.listdir(output_dir):
seen_files = set()
for out_dir in output_dirs:
if not os.path.exists(out_dir):
continue
try:
for filename in os.listdir(out_dir):
if filename.startswith("original_"):
continue
file_path = os.path.join(output_dir, filename)
file_path = os.path.join(out_dir, filename)
abs_file_path = os.path.abspath(file_path)
if abs_file_path in seen_files:
continue
if os.path.isfile(file_path):
seen_files.add(abs_file_path)
stat = os.stat(file_path)
if os.path.abspath(out_dir) == os.path.abspath(upscaler.OUTPUT_DIR):
url = f"/api/download/{filename}"
else:
url = f"/api/download/{filename}?path={urllib.parse.quote(out_dir)}"
files.append({
"filename": filename,
"size": stat.st_size,
"modified": stat.st_mtime,
"url": f"/api/download/{filename}"
"url": url,
"path": file_path
})
except Exception as e:
print(f"Error scanning output dir {out_dir}: {e}")
files.sort(key=lambda x: x["modified"], reverse=True)
return files
@app.delete("/api/outputs/delete/{filename}")
@app.post("/api/outputs/delete/{filename}")
def delete_output(filename: str):
def delete_output(filename: str, path: str | None = None):
allowed_dirs = {os.path.abspath(upscaler.OUTPUT_DIR)}
settings = load_settings()
default_out = settings.get("default_output_dir", "").strip()
if default_out:
allowed_dirs.add(os.path.abspath(default_out))
for job in jobs_db.values():
if getattr(job, "output_dir", None):
allowed_dirs.add(os.path.abspath(job.output_dir))
if path:
resolved_path = os.path.abspath(path)
if os.path.isdir(resolved_path):
file_path = os.path.join(resolved_path, filename)
else:
file_path = resolved_path
else:
file_path = os.path.join(upscaler.OUTPUT_DIR, filename)
resolved_path = os.path.abspath(file_path)
if not resolved_path.startswith(os.path.abspath(upscaler.OUTPUT_DIR)):
resolved_file_path = os.path.abspath(file_path)
allowed = False
for allowed_dir in allowed_dirs:
if resolved_file_path.startswith(allowed_dir):
allowed = True
break
if not allowed:
raise HTTPException(status_code=403, detail="Access denied.")
if not os.path.exists(file_path):
@@ -1090,6 +1250,13 @@ def setup_webhook(req: WebhookSetupRequest):
# ─── Settings Endpoints ─────────────────────────────────────────────
class UpdateSettingsRequest(BaseModel):
default_temp_dir: str = ""
default_output_dir: str = ""
role: str = "standalone"
coordinator_url: str = ""
worker_node_url: str = "http://localhost:8000"
use_shared_storage: bool = False
shared_storage_path: str = ""
smb_shares: List[Any] = []
@app.get("/api/settings")
def get_settings():
@@ -1165,12 +1332,38 @@ def update_settings(req: UpdateSettingsRequest, background_tasks: BackgroundTask
try:
os.makedirs(new_dir, exist_ok=True)
except OSError as e:
raise HTTPException(status_code=400, detail=f"Invalid directory path: {e}")
raise HTTPException(status_code=400, detail=f"Invalid default_temp_dir path: {e}")
new_out_dir = req.default_output_dir.strip()
if new_out_dir:
try:
os.makedirs(new_out_dir, exist_ok=True)
except OSError as e:
raise HTTPException(status_code=400, detail=f"Invalid default_output_dir path: {e}")
new_shared_path = req.shared_storage_path.strip()
if req.use_shared_storage and new_shared_path:
try:
os.makedirs(new_shared_path, exist_ok=True)
except OSError as e:
raise HTTPException(status_code=400, detail=f"Invalid shared_storage_path path: {e}")
role_changed = (settings.get("role") != req.role)
# Save settings
settings["default_temp_dir"] = new_dir
settings["default_output_dir"] = new_out_dir
settings["role"] = req.role
settings["coordinator_url"] = req.coordinator_url
settings["worker_node_url"] = req.worker_node_url
settings["use_shared_storage"] = req.use_shared_storage
settings["shared_storage_path"] = req.shared_storage_path
settings["smb_shares"] = req.smb_shares
save_settings(settings)
if role_changed:
restart_dist_worker()
# Identify and pause active jobs
active_jobs = []
old_real = os.path.realpath(old_dir)
@@ -1400,5 +1593,327 @@ def cleanup_temp(req: CleanupTempRequest):
save_jobs_db()
return {"cleaned": cleaned, "errors": errors}
# ─── Distributed Processing Coordinator & Worker Endpoints ────────────────
class ChunkDoneRequest(BaseModel):
job_id: str
chunk_id: str
@app.get("/api/dist/request-chunk")
def request_chunk(worker_url: str = ""):
settings = load_settings()
if settings.get("role") != "coordinator":
raise HTTPException(status_code=400, detail="Node is not configured as a coordinator.")
with upscaler.dist_chunks_lock:
job_id = current_running_job_id
if not job_id:
return {"status": "idle"}
job = jobs_db.get(job_id)
if not job or job.status != "upscaling":
return {"status": "idle"}
chunks = upscaler.dist_chunks.get(job_id, [])
for chunk in chunks:
if chunk["status"] == "pending":
chunk["status"] = "assigned"
chunk["worker_url"] = worker_url
chunk["updated_at"] = time.time()
return {
"status": "assigned",
"job_id": job_id,
"chunk_id": chunk["chunk_id"],
"model": job.model,
"scale": job.scale,
"tile_size": job.tile_size,
"gpu_ids": job.gpu_ids,
"tta": job.tta,
"files": chunk["files"],
"use_shared_storage": settings.get("use_shared_storage", False),
"shared_storage_path": settings.get("shared_storage_path", ""),
"job_temp_dir": os.path.join(settings.get("shared_storage_path") if settings.get("use_shared_storage") else upscaler.TEMP_DIR, job_id)
}
return {"status": "no_chunks"}
@app.get("/api/dist/download-frame/{job_id}/{filename}")
def download_frame(job_id: str, filename: str):
settings = load_settings()
if settings.get("role") != "coordinator":
raise HTTPException(status_code=400, detail="Node is not configured as a coordinator.")
if "/" in filename or "\\" in filename or filename in [".", ".."]:
raise HTTPException(status_code=400, detail="Invalid filename")
base_temp = settings.get("shared_storage_path") if settings.get("use_shared_storage") else upscaler.TEMP_DIR
file_path = os.path.join(base_temp, job_id, "input_frames", filename)
if not os.path.exists(file_path):
raise HTTPException(status_code=404, detail="Frame not found")
return FileResponse(file_path)
@app.post("/api/dist/upload-chunk/{job_id}")
async def upload_chunk(job_id: str, files: List[UploadFile] = File(...)):
settings = load_settings()
if settings.get("role") != "coordinator":
raise HTTPException(status_code=400, detail="Node is not configured as a coordinator.")
base_temp = settings.get("shared_storage_path") if settings.get("use_shared_storage") else upscaler.TEMP_DIR
output_frames_dir = os.path.join(base_temp, job_id, "output_frames")
os.makedirs(output_frames_dir, exist_ok=True)
for file in files:
filename = os.path.basename(file.filename)
if "/" in filename or "\\" in filename or filename in [".", ".."]:
continue
save_path = os.path.join(output_frames_dir, filename)
with open(save_path, "wb") as buffer:
content = await file.read()
buffer.write(content)
return {"status": "success"}
@app.post("/api/dist/chunk-done")
def chunk_done(req: ChunkDoneRequest):
settings = load_settings()
if settings.get("role") != "coordinator":
raise HTTPException(status_code=400, detail="Node is not configured as a coordinator.")
with upscaler.dist_chunks_lock:
chunks = upscaler.dist_chunks.get(req.job_id, [])
for chunk in chunks:
if chunk["chunk_id"] == req.chunk_id:
chunk["status"] = "completed"
chunk["updated_at"] = time.time()
return {"status": "completed"}
raise HTTPException(status_code=404, detail="Chunk not found.")
# ─── SMB Mounting & Background Worker Thread ───────────────────────────────
def mount_smb_share(share: dict) -> bool:
"""Mount an SMB share if not already mounted"""
local_path = share.get("local_path", "").strip()
share_path = share.get("share_path", "").strip()
username = share.get("username", "").strip()
password = share.get("password", "").strip()
if not local_path or not share_path:
print("SMB Share: missing local_path or share_path")
return False
try:
os.makedirs(local_path, exist_ok=True)
except Exception as e:
print(f"SMB Share: failed to create mount point {local_path}: {e}")
return False
try:
with open("/proc/mounts", "r") as f:
mounts = f.read()
if os.path.realpath(local_path) in mounts or share_path in mounts:
print(f"SMB Share: {share_path} is already mounted at {local_path}")
return True
except Exception:
pass
options = []
if username:
options.append(f"username={username}")
if password:
options.append(f"password={password}")
cmd = ["mount", "-t", "cifs", share_path, local_path]
if options:
cmd.extend(["-o", ",".join(options)])
try:
print(f"Mounting SMB share: {' '.join(cmd)}")
res = subprocess.run(cmd, capture_output=True, text=True)
if res.returncode == 0:
print(f"SMB Share: successfully mounted {share_path} to {local_path}")
return True
else:
print(f"SMB Share: failed to mount (exit {res.returncode}): {res.stderr}")
return False
except Exception as e:
print(f"SMB Share: exception mounting: {e}")
return False
def upload_files_multipart(url: str, files: List[tuple[str, str]]):
"""Upload files using multipart/form-data via urllib"""
import uuid
import mimetypes
boundary = f"----WebKitFormBoundary{uuid.uuid4().hex}"
parts = []
for field_name, file_path in files:
filename = os.path.basename(file_path)
mime_type = mimetypes.guess_type(file_path)[0] or "image/jpeg"
parts.append(f"--{boundary}".encode('utf-8'))
parts.append(f'Content-Disposition: form-data; name="{field_name}"; filename="{filename}"'.encode('utf-8'))
parts.append(f"Content-Type: {mime_type}".encode('utf-8'))
parts.append(b"")
with open(file_path, "rb") as f:
parts.append(f.read())
parts.append(f"--{boundary}--".encode('utf-8'))
body = b"\r\n".join(parts)
headers = {
"Content-Type": f"multipart/form-data; boundary={boundary}",
"Content-Length": str(len(body))
}
req = urllib.request.Request(url, data=body, headers=headers, method="POST")
with urllib.request.urlopen(req) as res:
return res.read()
def process_dist_chunk(chunk: dict) -> bool:
job_id = chunk["job_id"]
chunk_id = chunk["chunk_id"]
model = chunk["model"]
scale = chunk["scale"]
tile_size = chunk["tile_size"]
gpu_ids = chunk.get("gpu_ids")
files = chunk["files"]
use_shared_storage = chunk.get("use_shared_storage", False)
coordinator_url = load_settings().get("coordinator_url", "").rstrip("/")
print(f"Worker: Processing chunk {chunk_id} for job {job_id} ({len(files)} files)")
local_job_temp = os.path.join(upscaler.TEMP_DIR, f"worker_{job_id}_{chunk_id}")
local_input_dir = os.path.join(local_job_temp, "input_frames")
local_output_dir = os.path.join(local_job_temp, "output_frames")
if not use_shared_storage:
os.makedirs(local_input_dir, exist_ok=True)
os.makedirs(local_output_dir, exist_ok=True)
try:
input_paths = []
output_paths = []
for filename in files:
if use_shared_storage:
job_temp_dir = chunk["job_temp_dir"]
in_path = os.path.join(job_temp_dir, "input_frames", filename)
out_path = os.path.join(job_temp_dir, "output_frames", filename)
else:
in_path = os.path.join(local_input_dir, filename)
out_path = os.path.join(local_output_dir, filename)
download_url = f"{coordinator_url}/api/dist/download-frame/{job_id}/{filename}"
try:
urllib.request.urlretrieve(download_url, in_path)
except Exception as dl_err:
print(f"Worker: Failed to download frame {filename}: {dl_err}")
return False
input_paths.append(in_path)
output_paths.append(out_path)
for in_p, out_p in zip(input_paths, output_paths):
success = upscaler.upscale_image_file(in_p, out_p, model, scale, tile_size, gpu_ids)
if not success:
print(f"Worker: Failed to upscale frame {in_p}")
return False
if not use_shared_storage:
upload_url = f"{coordinator_url}/api/dist/upload-chunk/{job_id}"
file_tuples = [("files", out_p) for out_p in output_paths]
try:
upload_files_multipart(upload_url, file_tuples)
except Exception as up_err:
print(f"Worker: Failed to upload output frames: {up_err}")
return False
done_url = f"{coordinator_url}/api/dist/chunk-done"
try:
req = urllib.request.Request(
done_url,
data=json.dumps({"job_id": job_id, "chunk_id": chunk_id}).encode('utf-8'),
headers={"Content-Type": "application/json"},
method="POST"
)
with urllib.request.urlopen(req) as res:
res.read()
except Exception as done_err:
print(f"Worker: Failed to notify done: {done_err}")
return False
return True
finally:
if not use_shared_storage and os.path.exists(local_job_temp):
try:
shutil.rmtree(local_job_temp)
except Exception:
pass
dist_worker_running = False
dist_worker_thread = None
dist_worker_stop_event = threading.Event()
def dist_worker_loop():
global dist_worker_running
dist_worker_running = True
print("Background worker loop started.")
settings = load_settings()
for share in settings.get("smb_shares", []):
mount_smb_share(share)
while not dist_worker_stop_event.is_set():
settings = load_settings()
role = settings.get("role", "standalone")
if role != "worker":
time.sleep(5.0)
continue
coordinator_url = settings.get("coordinator_url", "").strip().rstrip("/")
if not coordinator_url:
time.sleep(10.0)
continue
worker_node_url = settings.get("worker_node_url", "http://localhost:8000")
try:
req_url = f"{coordinator_url}/api/dist/request-chunk?worker_url={urllib.parse.quote(worker_node_url)}"
req = urllib.request.Request(req_url, method="GET")
with urllib.request.urlopen(req, timeout=10) as res:
chunk_data = json.loads(res.read().decode('utf-8'))
if chunk_data.get("status") == "assigned":
success = process_dist_chunk(chunk_data)
if not success:
print(f"Worker: failed to process chunk {chunk_data.get('chunk_id')}. Will retry later.")
else:
print(f"Worker: successfully processed chunk {chunk_data.get('chunk_id')}")
else:
time.sleep(3.0)
except Exception as e:
print(f"Worker loop error: {e}")
time.sleep(5.0)
dist_worker_running = False
print("Background worker loop stopped.")
def restart_dist_worker():
global dist_worker_thread, dist_worker_stop_event
dist_worker_stop_event.set()
if dist_worker_thread and dist_worker_thread.is_alive():
dist_worker_thread.join(timeout=2.0)
dist_worker_stop_event.clear()
dist_worker_thread = threading.Thread(target=dist_worker_loop, daemon=True)
dist_worker_thread.start()
# Mount static folder
app.mount("/", StaticFiles(directory=os.path.join(upscaler.BASE_DIR, "static"), html=True), name="static")
+108 -5
View File
@@ -7,6 +7,9 @@ import time
import threading
from typing import Dict, Any, Callable
dist_chunks: Dict[str, list] = {}
dist_chunks_lock = threading.Lock()
_ffmpeg_filters_cache = {}
def has_ffmpeg_filter(filter_name: str) -> bool:
global _ffmpeg_filters_cache
@@ -39,9 +42,12 @@ 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, temp_dir: str = None):
ai_audio_denoise: bool = False, temp_dir: str = None,
output_dir: str = None, source_filename: str = None):
self.job_id = job_id
self.temp_dir = temp_dir
self.output_dir = output_dir
self.source_filename = source_filename
self.video_path = video_path
self.model = model
self.scale = scale
@@ -404,8 +410,45 @@ def run_upscale_pipeline(job: UpscaleJob, on_progress_update: Callable[[str, Dic
job.update_status("upscaling", progress=20, current_frame=actual_total - remaining_inputs)
on_progress_update(job.job_id, {"status": "upscaling", "progress": 20, "current_frame": actual_total - remaining_inputs, "total_frames": actual_total})
# Check if role in settings is "coordinator"
settings = {}
main_mod = sys.modules.get("app.main")
if main_mod and hasattr(main_mod, "load_settings"):
try:
settings = main_mod.load_settings()
except Exception:
pass
if not settings:
settings_path = os.path.join(BASE_DIR, "settings.json")
if os.path.exists(settings_path):
try:
with open(settings_path, "r") as f:
settings = json.load(f)
except Exception:
pass
is_coordinator = settings.get("role") == "coordinator"
skip_local_upscale = False
if is_coordinator:
skip_local_upscale = True
files_to_upscale = sorted([f for f in os.listdir(input_frames_dir) if f.startswith("frame_")])
chunk_size = 50
chunks_list = []
for idx_chunk, i in enumerate(range(0, len(files_to_upscale), chunk_size)):
chunk_files = files_to_upscale[i : i + chunk_size]
chunks_list.append({
"chunk_id": f"{job.job_id}_{idx_chunk}",
"files": chunk_files,
"status": "pending",
"worker_url": None,
"updated_at": time.time()
})
with dist_chunks_lock:
dist_chunks[job.job_id] = chunks_list
print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] [Job {job.job_id}] Coordinator mode active. Initialized {len(chunks_list)} chunks.")
current_tile_size = job.tile_size
while True:
while not skip_local_upscale:
# Launch Real-ESRGAN on directory
upscale_cmd = [
BIN_PATH,
@@ -509,6 +552,57 @@ def run_upscale_pipeline(job: UpscaleJob, on_progress_update: Callable[[str, Dic
else:
break
# If coordinator, wait for all chunks to be completed
if is_coordinator:
print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] [Job {job.job_id}] Coordinator waiting for all chunks to complete...")
while True:
if job._is_cancelled:
return
if getattr(job, "_is_paused", False) or job.status == "paused":
return
with dist_chunks_lock:
chunks = dist_chunks.get(job.job_id, [])
if not chunks:
break
all_done = all(c["status"] == "completed" for c in chunks)
completed_count = sum(1 for c in chunks if c["status"] == "completed")
total_chunks = len(chunks)
upscale_progress = 20.0
if total_chunks > 0:
upscale_progress += (completed_count / total_chunks) * 60.0
processed_files = len(os.listdir(output_frames_dir))
job.update_status(
"upscaling",
progress=upscale_progress,
current_frame=processed_files,
eta=f"Waiting for workers... Chunks: {completed_count}/{total_chunks}"
)
on_progress_update(job.job_id, {
"status": "upscaling",
"progress": upscale_progress,
"current_frame": processed_files,
"total_frames": actual_total,
"eta": f"Workers processing chunks: {completed_count}/{total_chunks}"
})
if all_done:
break
# Timeout check: reset chunk if assigned but no update in 60s
with dist_chunks_lock:
for c in chunks:
if c["status"] == "assigned" and time.time() - c["updated_at"] > 60:
print(f"Chunk {c['chunk_id']} timed out. Requeuing.")
c["status"] = "pending"
c["worker_url"] = None
c["updated_at"] = time.time()
time.sleep(1.0)
# Final validation of upscale output
processed_files = len(os.listdir(output_frames_dir))
job.update_status("upscaling", progress=80.0, current_frame=processed_files)
@@ -599,9 +693,18 @@ def run_upscale_pipeline(job: UpscaleJob, on_progress_update: Callable[[str, Dic
job.update_status("assembling", progress=85.0)
on_progress_update(job.job_id, {"status": "assembling", "progress": 85.0})
# Get source filename basename, append with _upscaled_YYYY-MM-DD
source_file = job.source_filename if getattr(job, "source_filename", None) else job.video_path
base_name = os.path.basename(source_file)
name_without_ext, _ = os.path.splitext(base_name)
date_str = time.strftime("%Y-%m-%d")
transcode_fmt = getattr(job, "transcode_format", "mp4")
out_filename = f"upscaled_{job.job_id}.{transcode_fmt}"
out_filepath = os.path.join(OUTPUT_DIR, out_filename)
out_filename = f"{name_without_ext}_upscaled_{date_str}.{transcode_fmt}"
output_dir = job.output_dir if getattr(job, "output_dir", None) else OUTPUT_DIR
os.makedirs(output_dir, exist_ok=True)
out_filepath = os.path.join(output_dir, out_filename)
job.output_file = out_filepath
# Choose codecs based on format
@@ -697,7 +800,7 @@ def run_upscale_pipeline(job: UpscaleJob, on_progress_update: Callable[[str, Dic
except Exception as e:
import traceback
traceback.print_exc()
if not job._is_cancelled and not getattr(job, "_is_paused", False) and job.status != "paused":
if not job._is_cancelled and not getattr(job, "_is_paused", False) and job.status not in ["paused", "queued"] and "paused" not in str(e).lower():
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}")
+423 -12
View File
@@ -6,6 +6,16 @@ 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;
@@ -1000,8 +1010,8 @@ function updateProgressUI(data) {
vidOriginal.style.display = "block";
vidUpscaledWrapper.style.display = "block";
vidOriginal.src = `/api/download/${data.original_preview_file}`;
vidUpscaled.src = `/api/download/${data.output_file}`;
vidOriginal.src = getDownloadUrl(data.original_preview_file);
vidUpscaled.src = getDownloadUrl(data.output_file);
vidOriginal.load();
vidUpscaled.load();
@@ -1060,7 +1070,7 @@ function updateProgressUI(data) {
if (socket) socket.close();
stepProgress.classList.remove("active");
stepFinished.classList.add("active");
downloadLink.href = `/api/download/${data.output_file}`;
downloadLink.href = getDownloadUrl(data.output_file);
loadGallery();
}, 1500);
@@ -1302,7 +1312,7 @@ if (finishedPlayComparisonBtn) {
if (job) {
const originalFilename = job.video_path.split("/").pop();
const originalUrl = `/api/uploads/${originalFilename}`;
const upscaledUrl = `/api/download/${job.output_file.split("/").pop()}`;
const upscaledUrl = getDownloadUrl(job.output_file);
openVideoComparisonModal(originalUrl, upscaledUrl);
}
} catch (err) {
@@ -1521,10 +1531,16 @@ async function loadQueue() {
let actionsHtml = "";
let reorderHtml = "";
if (job.status === "queued" && job.queue_position !== null && job.queue_position !== undefined) {
const queuedJobs = displayJobs.filter(j => j.status === "queued");
const isFirst = job.queue_position === 0;
const isLast = job.queue_position === queuedJobs.length - 1;
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">
@@ -1698,7 +1714,7 @@ async function loadQueue() {
document.querySelectorAll(".step-container").forEach(el => el.classList.remove("active"));
if (job.status === "completed") {
document.getElementById("step-finished").classList.add("active");
document.getElementById("download-link").href = `/api/download/${job.output_file}`;
document.getElementById("download-link").href = getDownloadUrl(job.output_file);
} else {
document.getElementById("step-progress").classList.add("active");
document.getElementById("job-id-display").textContent = currentJobId;
@@ -2128,8 +2144,9 @@ async function initializeApp() {
const defaultTempDirInput = document.getElementById("default-temp-dir-input");
const tempDirStatus = document.getElementById("temp-dir-status");
// Load saved default temp dir on init
// Load saved default temp dir and cluster settings on init
loadDefaultTempDir();
loadClusterSettings();
if (saveTempDirBtn) {
saveTempDirBtn.addEventListener("click", async () => {
@@ -2139,10 +2156,19 @@ async function initializeApp() {
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({ default_temp_dir: newDir })
body: JSON.stringify(updatedSettings)
});
if (res.ok) {
tempDirStatus.style.color = "var(--success, #4caf50)";
@@ -2172,9 +2198,67 @@ async function initializeApp() {
});
}
// 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");
@@ -2194,6 +2278,27 @@ async function initializeApp() {
});
}
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);
}
@@ -2247,6 +2352,183 @@ async function initializeApp() {
}
});
}
// 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';
}
});
}
}
/* ----------------------------------------------------
@@ -2483,19 +2765,148 @@ async function loadTempInfo() {
}
}
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;
// Update per-job input placeholder
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);
}
+153
View File
@@ -290,6 +290,20 @@
</p>
</div>
<!-- Custom Output Directory -->
<div class="form-group">
<label for="output-dir-input"><i class="fa-solid fa-folder-closed" style="color: var(--primary); margin-right: 0.25rem;"></i> Custom Output Directory</label>
<div style="display: flex; gap: 0.5rem; align-items: stretch;">
<input type="text" id="output-dir-input" placeholder="e.g. /mnt/fast_ssd/outputs (Leave empty for default outputs/)" style="flex: 1;">
<button type="button" id="browse-job-output-btn" class="btn btn-secondary" style="padding: 0 1rem;" title="Browse files">
<i class="fa-solid fa-folder-open"></i>
</button>
</div>
<p style="font-size: 0.72rem; color: var(--text-muted); margin-top: 4px;">
Specify a custom location to save the final upscaled video file.
</p>
</div>
<!-- Pre-filters selection checkboxes -->
<div class="filter-group">
<span style="font-weight: 600; font-size: 0.85rem; display: block; color: var(--primary);">Pre-Filters & Enhancing</span>
@@ -609,6 +623,95 @@
<div id="temp-dir-status" style="font-size: 0.78rem; margin-top: 0.5rem; min-height: 1.2em;"></div>
</div>
<!-- Default Output Directory Setting -->
<div class="output-dir-setting" style="margin-bottom: 1.5rem; padding: 1.25rem; border-radius: 12px; background: rgba(255,255,255,0.02); border: 1px solid var(--border-color);">
<div style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem;">
<i class="fa-solid fa-folder-closed" style="color: var(--primary); font-size: 1.1rem;"></i>
<span style="font-weight: 600; font-size: 0.95rem;">Default Output Directory</span>
</div>
<p style="font-size: 0.82rem; color: var(--text-muted); margin-bottom: 0.75rem;">
Set a persistent default directory where completed upscale videos will be saved. This is applied automatically unless overridden per-job in Advanced Options.
</p>
<div style="display: flex; gap: 0.5rem; align-items: stretch;">
<input type="text" id="default-output-dir-input"
placeholder="e.g. /mnt/fast_ssd/upscaled_outputs (Leave empty for default outputs/)"
style="flex: 1; padding: 0.6rem 0.85rem; border-radius: 8px; background: var(--bg-card); border: 1px solid var(--border-color); color: var(--text-primary); font-size: 0.88rem; font-family: 'JetBrains Mono', monospace;">
<button type="button" id="browse-default-output-btn" class="btn btn-secondary" style="padding: 0 1rem;" title="Browse files">
<i class="fa-solid fa-folder-open"></i>
</button>
<button type="button" id="save-output-dir-btn" class="btn btn-primary btn-small" style="white-space: nowrap; padding: 0.6rem 1.2rem;">
<i class="fa-solid fa-floppy-disk"></i> Save
</button>
</div>
<div id="output-dir-status" style="font-size: 0.78rem; margin-top: 0.5rem; min-height: 1.2em;"></div>
</div>
<!-- Cluster & Network Settings -->
<div class="cluster-settings" style="margin-bottom: 1.5rem; padding: 1.25rem; border-radius: 12px; background: rgba(255,255,255,0.02); border: 1px solid var(--border-color);">
<div style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem;">
<i class="fa-solid fa-network-wired" style="color: var(--primary); font-size: 1.1rem;"></i>
<span style="font-weight: 600; font-size: 0.95rem;">Distributed Processing & Node Roles</span>
</div>
<p style="font-size: 0.82rem; color: var(--text-muted); margin-bottom: 0.75rem;">
Link multiple video upscaler servers together to accelerate upscaling tasks by distributing frame processing workload.
</p>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 1rem;">
<div class="form-group" style="margin-bottom: 0;">
<label for="node-role-select" style="font-size: 0.8rem; font-weight: 600; display: block; margin-bottom: 0.25rem;">Server Node Role</label>
<select id="node-role-select" style="padding: 0.5rem; border-radius: 6px; background: var(--bg-card); border: 1px solid var(--border-color); color: var(--text-main); font-size: 0.85rem; width: 100%;">
<option value="standalone">Standalone Node (Default)</option>
<option value="coordinator">Coordinator Node (Distribute tasks)</option>
<option value="worker">Worker Node (upscale assigned chunks)</option>
</select>
</div>
<div class="form-group" id="coord-url-group" style="display: none; margin-bottom: 0;">
<label for="coordinator-url-input" style="font-size: 0.8rem; font-weight: 600; display: block; margin-bottom: 0.25rem;">Coordinator API URL</label>
<input type="text" id="coordinator-url-input" placeholder="e.g. http://192.168.1.100:8000" style="padding: 0.5rem; border-radius: 6px; background: var(--bg-card); border: 1px solid var(--border-color); color: var(--text-main); font-size: 0.85rem; width: 100%;">
</div>
<div class="form-group" id="worker-url-group" style="display: none; margin-bottom: 0;">
<label for="worker-url-input" style="font-size: 0.8rem; font-weight: 600; display: block; margin-bottom: 0.25rem;">My Public Worker URL</label>
<input type="text" id="worker-url-input" placeholder="e.g. http://192.168.1.101:8000" style="padding: 0.5rem; border-radius: 6px; background: var(--bg-card); border: 1px solid var(--border-color); color: var(--text-main); font-size: 0.85rem; width: 100%;">
</div>
</div>
<!-- Shared Storage toggle -->
<div style="margin-bottom: 1rem; padding: 0.75rem; border-radius: 8px; background: rgba(255,255,255,0.01); border: 1px solid var(--border-color);">
<label class="checkbox-container" style="font-size: 0.85rem; display: flex; align-items: center; gap: 0.5rem; cursor: pointer; user-select: none;">
<input type="checkbox" id="use-shared-storage-checkbox"> Use Shared Network Storage (Fastest — skips uploads/downloads)
</label>
<div id="shared-storage-path-group" style="display: none; margin-top: 0.75rem; display: flex; gap: 0.5rem; align-items: stretch;">
<input type="text" id="shared-storage-path-input" placeholder="Local path to mounted share, e.g. /mnt/shared_temp" style="flex: 1; padding: 0.5rem; border-radius: 6px; background: var(--bg-card); border: 1px solid var(--border-color); color: var(--text-main); font-size: 0.85rem; font-family: monospace;">
<button type="button" id="browse-shared-storage-btn" class="btn btn-secondary" style="padding: 0 0.75rem;" title="Browse files">
<i class="fa-solid fa-folder-open"></i>
</button>
</div>
</div>
<!-- SMB Shares section -->
<div style="margin-bottom: 1.25rem;">
<h4 style="font-size: 0.85rem; margin-bottom: 0.5rem; display: flex; justify-content: space-between; align-items: center;">
<span><i class="fa-solid fa-share-nodes"></i> SMB Network Shares Mapping</span>
<button type="button" id="add-smb-share-btn" class="btn btn-secondary btn-small" style="padding: 0.25rem 0.5rem; font-size: 0.75rem;">
<i class="fa-solid fa-plus"></i> Add Share
</button>
</h4>
<div id="smb-shares-list" style="display: flex; flex-direction: column; gap: 0.5rem;">
<!-- Mapped shares render dynamically -->
<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>
</div>
</div>
<button type="button" id="save-cluster-settings-btn" class="btn btn-primary btn-small" style="padding: 0.6rem 1.5rem;">
<i class="fa-solid fa-floppy-disk"></i> Save Cluster Settings
</button>
<div id="cluster-settings-status" style="font-size: 0.78rem; margin-top: 0.5rem; min-height: 1.2em;"></div>
</div>
<div class="temp-stats-summary" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 1.5rem;">
<div class="glass-card" style="padding: 1rem; background: rgba(255,255,255,0.01); text-align: center;">
<span style="font-size: 0.75rem; color: var(--text-muted); text-transform: uppercase; display: block;">Total Temp Size</span>
@@ -768,6 +871,56 @@
</div>
</div>
<!-- SMB Share Modal -->
<div id="smb-modal" class="modal-overlay" style="display: none; z-index: 1200;">
<div class="modal-card" style="max-width: 500px;">
<div class="modal-header">
<h3><i class="fa-solid fa-folder-plus"></i> Map SMB Network Share</h3>
<button type="button" class="close-btn" id="smb-modal-close-btn">&times;</button>
</div>
<div class="modal-body" style="padding: 1.5rem; display: flex; flex-direction: column; gap: 1rem;">
<div class="form-group">
<label for="smb-name" style="font-size: 0.85rem; font-weight: 600;">Share Name (Identifier)</label>
<input type="text" id="smb-name" placeholder="e.g. video_share" style="padding: 0.5rem; border-radius: 6px; background: var(--bg-card); border: 1px solid var(--border-color); color: var(--text-main); width: 100%;">
</div>
<div class="form-group">
<label for="smb-server" style="font-size: 0.85rem; font-weight: 600;">SMB Server (IP or Hostname)</label>
<input type="text" id="smb-server" placeholder="e.g. 192.168.1.10" style="padding: 0.5rem; border-radius: 6px; background: var(--bg-card); border: 1px solid var(--border-color); color: var(--text-main); width: 100%;">
</div>
<div class="form-group">
<label for="smb-share-name" style="font-size: 0.85rem; font-weight: 600;">Share Folder Name</label>
<input type="text" id="smb-share-name" placeholder="e.g. videos" style="padding: 0.5rem; border-radius: 6px; background: var(--bg-card); border: 1px solid var(--border-color); color: var(--text-main); width: 100%;">
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 0;">
<div class="form-group" style="margin-bottom: 0;">
<label for="smb-username" style="font-size: 0.85rem; font-weight: 600;">Username (Optional)</label>
<input type="text" id="smb-username" placeholder="Guest" style="padding: 0.5rem; border-radius: 6px; background: var(--bg-card); border: 1px solid var(--border-color); color: var(--text-main); width: 100%;">
</div>
<div class="form-group" style="margin-bottom: 0;">
<label for="smb-password" style="font-size: 0.85rem; font-weight: 600;">Password (Optional)</label>
<input type="password" id="smb-password" placeholder="••••••••" style="padding: 0.5rem; border-radius: 6px; background: var(--bg-card); border: 1px solid var(--border-color); color: var(--text-main); width: 100%;">
</div>
</div>
<div class="form-group">
<label for="smb-mount-path" style="font-size: 0.85rem; font-weight: 600;">Local Mount Path</label>
<div style="display: flex; gap: 0.5rem; align-items: stretch;">
<input type="text" id="smb-mount-path" placeholder="e.g. /home/david/smb_mount/videos" style="flex: 1; padding: 0.5rem; border-radius: 6px; background: var(--bg-card); border: 1px solid var(--border-color); color: var(--text-main); font-family: monospace; font-size: 0.85rem;">
<button type="button" id="browse-smb-mount-btn" class="btn btn-secondary" style="padding: 0 0.75rem;" title="Browse files">
<i class="fa-solid fa-folder-open"></i>
</button>
</div>
</div>
<div id="smb-modal-status" style="font-size: 0.78rem; min-height: 1.2em;"></div>
<div style="display: flex; justify-content: flex-end; gap: 0.75rem; border-top: 1px solid var(--border-color); padding-top: 1rem;">
<button type="button" id="smb-cancel-btn" class="btn btn-secondary">Cancel</button>
<button type="button" id="smb-save-btn" class="btn btn-primary">Map & Save Share</button>
</div>
</div>
</div>
</div>
<!-- Javascript Orchestration -->
<script src="app.js"></script>
</body>