Merge branch 'development' of http://truenas.local:30008/david/video_upscaler into development
This commit is contained in:
+544
-29
@@ -4,7 +4,9 @@ import queue
|
|||||||
import threading
|
import threading
|
||||||
import asyncio
|
import asyncio
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import time
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
import urllib.parse
|
||||||
import json
|
import json
|
||||||
from typing import Dict, List, Any
|
from typing import Dict, List, Any
|
||||||
from fastapi import FastAPI, UploadFile, File, Form, BackgroundTasks, HTTPException, WebSocket, WebSocketDisconnect
|
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:
|
def load_settings() -> dict:
|
||||||
"""Load persistent app settings from settings.json"""
|
"""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):
|
if os.path.exists(SETTINGS_FILE):
|
||||||
try:
|
try:
|
||||||
with open(SETTINGS_FILE, "r") as f:
|
with open(SETTINGS_FILE, "r") as f:
|
||||||
@@ -141,6 +152,7 @@ def startup_event():
|
|||||||
global main_loop
|
global main_loop
|
||||||
main_loop = asyncio.get_event_loop()
|
main_loop = asyncio.get_event_loop()
|
||||||
load_jobs_db()
|
load_jobs_db()
|
||||||
|
restart_dist_worker()
|
||||||
|
|
||||||
global_webhook_url = None
|
global_webhook_url = None
|
||||||
|
|
||||||
@@ -259,6 +271,7 @@ class StartUpscaleRequest(BaseModel):
|
|||||||
ai_rife_interpolation: bool = False
|
ai_rife_interpolation: bool = False
|
||||||
ai_audio_denoise: bool = False
|
ai_audio_denoise: bool = False
|
||||||
temp_dir: str | None = None
|
temp_dir: str | None = None
|
||||||
|
output_dir: str | None = None
|
||||||
|
|
||||||
class StartBatchUpscaleRequest(BaseModel):
|
class StartBatchUpscaleRequest(BaseModel):
|
||||||
file_ids: List[str]
|
file_ids: List[str]
|
||||||
@@ -287,6 +300,7 @@ class StartBatchUpscaleRequest(BaseModel):
|
|||||||
ai_rife_interpolation: bool = False
|
ai_rife_interpolation: bool = False
|
||||||
ai_audio_denoise: bool = False
|
ai_audio_denoise: bool = False
|
||||||
temp_dir: str | None = None
|
temp_dir: str | None = None
|
||||||
|
output_dir: str | None = None
|
||||||
|
|
||||||
class PreviewRequest(BaseModel):
|
class PreviewRequest(BaseModel):
|
||||||
file_id: str
|
file_id: str
|
||||||
@@ -447,6 +461,18 @@ def start_upscale(req: StartUpscaleRequest):
|
|||||||
if not file_path:
|
if not file_path:
|
||||||
raise HTTPException(status_code=404, detail="Uploaded file not found.")
|
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_id = str(uuid.uuid4())
|
||||||
job = upscaler.UpscaleJob(
|
job = upscaler.UpscaleJob(
|
||||||
job_id=job_id,
|
job_id=job_id,
|
||||||
@@ -475,7 +501,9 @@ def start_upscale(req: StartUpscaleRequest):
|
|||||||
ai_face_restoration=req.ai_face_restoration,
|
ai_face_restoration=req.ai_face_restoration,
|
||||||
ai_rife_interpolation=req.ai_rife_interpolation,
|
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 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
|
jobs_db[job_id] = job
|
||||||
@@ -513,6 +541,11 @@ def start_upscale_batch(req: StartBatchUpscaleRequest):
|
|||||||
if not file_path:
|
if not file_path:
|
||||||
raise HTTPException(status_code=404, detail=f"Uploaded file {file_id} not found.")
|
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
|
# 2. Queueing loop
|
||||||
for file_id in req.file_ids:
|
for file_id in req.file_ids:
|
||||||
file_path = None
|
file_path = None
|
||||||
@@ -522,6 +555,13 @@ def start_upscale_batch(req: StartBatchUpscaleRequest):
|
|||||||
file_path = test_path
|
file_path = test_path
|
||||||
break
|
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_id = str(uuid.uuid4())
|
||||||
job = upscaler.UpscaleJob(
|
job = upscaler.UpscaleJob(
|
||||||
job_id=job_id,
|
job_id=job_id,
|
||||||
@@ -550,7 +590,9 @@ def start_upscale_batch(req: StartBatchUpscaleRequest):
|
|||||||
ai_face_restoration=req.ai_face_restoration,
|
ai_face_restoration=req.ai_face_restoration,
|
||||||
ai_rife_interpolation=req.ai_rife_interpolation,
|
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 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
|
jobs_db[job_id] = job
|
||||||
job_queue.put(job_id)
|
job_queue.put(job_id)
|
||||||
@@ -685,11 +727,40 @@ def get_models():
|
|||||||
return sorted(list(models))
|
return sorted(list(models))
|
||||||
|
|
||||||
@app.get("/api/download/{filename}")
|
@app.get("/api/download/{filename}")
|
||||||
def download_file(filename: str):
|
def download_file(filename: str, path: str | None = None):
|
||||||
file_path = os.path.join(upscaler.OUTPUT_DIR, filename)
|
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):
|
if not os.path.exists(file_path):
|
||||||
raise HTTPException(status_code=404, detail="File not found.")
|
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):
|
def safe_delete_file(file_path: str):
|
||||||
if file_path and os.path.exists(file_path):
|
if file_path and os.path.exists(file_path):
|
||||||
@@ -814,15 +885,53 @@ class ReorderQueueRequest(BaseModel):
|
|||||||
|
|
||||||
@app.post("/api/queue/reorder")
|
@app.post("/api/queue/reorder")
|
||||||
def reorder_queue(req: ReorderQueueRequest):
|
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)
|
job_queue.reorder(req.job_ids)
|
||||||
save_jobs_db()
|
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")
|
@app.get("/api/queue")
|
||||||
def get_queue():
|
def get_queue():
|
||||||
"""Get the current job queue order"""
|
"""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}")
|
@app.post("/api/upscale/resume/{job_id}")
|
||||||
def resume_job(job_id: str):
|
def resume_job(job_id: str):
|
||||||
@@ -999,32 +1108,83 @@ def get_diagnostics():
|
|||||||
# Outputs Gallery Endpoints
|
# Outputs Gallery Endpoints
|
||||||
@app.get("/api/outputs")
|
@app.get("/api/outputs")
|
||||||
def list_outputs():
|
def list_outputs():
|
||||||
output_dir = upscaler.OUTPUT_DIR
|
output_dirs = {upscaler.OUTPUT_DIR}
|
||||||
if not os.path.exists(output_dir):
|
settings = load_settings()
|
||||||
return []
|
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 = []
|
files = []
|
||||||
for filename in os.listdir(output_dir):
|
seen_files = set()
|
||||||
if filename.startswith("original_"):
|
for out_dir in output_dirs:
|
||||||
|
if not os.path.exists(out_dir):
|
||||||
continue
|
continue
|
||||||
file_path = os.path.join(output_dir, filename)
|
try:
|
||||||
if os.path.isfile(file_path):
|
for filename in os.listdir(out_dir):
|
||||||
stat = os.stat(file_path)
|
if filename.startswith("original_"):
|
||||||
files.append({
|
continue
|
||||||
"filename": filename,
|
file_path = os.path.join(out_dir, filename)
|
||||||
"size": stat.st_size,
|
abs_file_path = os.path.abspath(file_path)
|
||||||
"modified": stat.st_mtime,
|
if abs_file_path in seen_files:
|
||||||
"url": f"/api/download/{filename}"
|
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": 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)
|
files.sort(key=lambda x: x["modified"], reverse=True)
|
||||||
return files
|
return files
|
||||||
|
|
||||||
@app.delete("/api/outputs/delete/{filename}")
|
@app.delete("/api/outputs/delete/{filename}")
|
||||||
@app.post("/api/outputs/delete/{filename}")
|
@app.post("/api/outputs/delete/{filename}")
|
||||||
def delete_output(filename: str):
|
def delete_output(filename: str, path: str | None = None):
|
||||||
file_path = os.path.join(upscaler.OUTPUT_DIR, filename)
|
allowed_dirs = {os.path.abspath(upscaler.OUTPUT_DIR)}
|
||||||
resolved_path = os.path.abspath(file_path)
|
settings = load_settings()
|
||||||
if not resolved_path.startswith(os.path.abspath(upscaler.OUTPUT_DIR)):
|
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.")
|
raise HTTPException(status_code=403, detail="Access denied.")
|
||||||
|
|
||||||
if not os.path.exists(file_path):
|
if not os.path.exists(file_path):
|
||||||
@@ -1090,6 +1250,13 @@ def setup_webhook(req: WebhookSetupRequest):
|
|||||||
# ─── Settings Endpoints ─────────────────────────────────────────────
|
# ─── Settings Endpoints ─────────────────────────────────────────────
|
||||||
class UpdateSettingsRequest(BaseModel):
|
class UpdateSettingsRequest(BaseModel):
|
||||||
default_temp_dir: str = ""
|
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")
|
@app.get("/api/settings")
|
||||||
def get_settings():
|
def get_settings():
|
||||||
@@ -1165,12 +1332,38 @@ def update_settings(req: UpdateSettingsRequest, background_tasks: BackgroundTask
|
|||||||
try:
|
try:
|
||||||
os.makedirs(new_dir, exist_ok=True)
|
os.makedirs(new_dir, exist_ok=True)
|
||||||
except OSError as e:
|
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
|
# Save settings
|
||||||
settings["default_temp_dir"] = new_dir
|
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)
|
save_settings(settings)
|
||||||
|
|
||||||
|
if role_changed:
|
||||||
|
restart_dist_worker()
|
||||||
|
|
||||||
# Identify and pause active jobs
|
# Identify and pause active jobs
|
||||||
active_jobs = []
|
active_jobs = []
|
||||||
old_real = os.path.realpath(old_dir)
|
old_real = os.path.realpath(old_dir)
|
||||||
@@ -1400,5 +1593,327 @@ def cleanup_temp(req: CleanupTempRequest):
|
|||||||
save_jobs_db()
|
save_jobs_db()
|
||||||
return {"cleaned": cleaned, "errors": errors}
|
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
|
# Mount static folder
|
||||||
app.mount("/", StaticFiles(directory=os.path.join(upscaler.BASE_DIR, "static"), html=True), name="static")
|
app.mount("/", StaticFiles(directory=os.path.join(upscaler.BASE_DIR, "static"), html=True), name="static")
|
||||||
|
|||||||
+108
-5
@@ -7,6 +7,9 @@ import time
|
|||||||
import threading
|
import threading
|
||||||
from typing import Dict, Any, Callable
|
from typing import Dict, Any, Callable
|
||||||
|
|
||||||
|
dist_chunks: Dict[str, list] = {}
|
||||||
|
dist_chunks_lock = threading.Lock()
|
||||||
|
|
||||||
_ffmpeg_filters_cache = {}
|
_ffmpeg_filters_cache = {}
|
||||||
def has_ffmpeg_filter(filter_name: str) -> bool:
|
def has_ffmpeg_filter(filter_name: str) -> bool:
|
||||||
global _ffmpeg_filters_cache
|
global _ffmpeg_filters_cache
|
||||||
@@ -39,9 +42,12 @@ class UpscaleJob:
|
|||||||
denoise: bool = False, sharpen: bool = False, interpolation: bool = False,
|
denoise: bool = False, sharpen: bool = False, interpolation: bool = False,
|
||||||
webhook_url: str = None, transcode_format: str = "mp4", is_preview: 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_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.job_id = job_id
|
||||||
self.temp_dir = temp_dir
|
self.temp_dir = temp_dir
|
||||||
|
self.output_dir = output_dir
|
||||||
|
self.source_filename = source_filename
|
||||||
self.video_path = video_path
|
self.video_path = video_path
|
||||||
self.model = model
|
self.model = model
|
||||||
self.scale = scale
|
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)
|
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})
|
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
|
current_tile_size = job.tile_size
|
||||||
while True:
|
while not skip_local_upscale:
|
||||||
# Launch Real-ESRGAN on directory
|
# Launch Real-ESRGAN on directory
|
||||||
upscale_cmd = [
|
upscale_cmd = [
|
||||||
BIN_PATH,
|
BIN_PATH,
|
||||||
@@ -509,6 +552,57 @@ def run_upscale_pipeline(job: UpscaleJob, on_progress_update: Callable[[str, Dic
|
|||||||
else:
|
else:
|
||||||
break
|
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
|
# Final validation of upscale output
|
||||||
processed_files = len(os.listdir(output_frames_dir))
|
processed_files = len(os.listdir(output_frames_dir))
|
||||||
job.update_status("upscaling", progress=80.0, current_frame=processed_files)
|
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)
|
job.update_status("assembling", progress=85.0)
|
||||||
on_progress_update(job.job_id, {"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")
|
transcode_fmt = getattr(job, "transcode_format", "mp4")
|
||||||
out_filename = f"upscaled_{job.job_id}.{transcode_fmt}"
|
out_filename = f"{name_without_ext}_upscaled_{date_str}.{transcode_fmt}"
|
||||||
out_filepath = os.path.join(OUTPUT_DIR, out_filename)
|
|
||||||
|
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
|
job.output_file = out_filepath
|
||||||
|
|
||||||
# Choose codecs based on format
|
# 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:
|
except Exception as e:
|
||||||
import traceback
|
import traceback
|
||||||
traceback.print_exc()
|
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))
|
job.update_status("failed", error=str(e))
|
||||||
on_progress_update(job.job_id, {"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}")
|
print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] [Job {job.job_id}] pipeline failed. Error: {e}")
|
||||||
|
|||||||
+423
-12
@@ -6,6 +6,16 @@ let socket = null;
|
|||||||
let sliderInitialized = false;
|
let sliderInitialized = false;
|
||||||
|
|
||||||
// Error formatting helper
|
// 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) {
|
function formatFetchError(err, defaultMsg) {
|
||||||
if (!err) return defaultMsg;
|
if (!err) return defaultMsg;
|
||||||
if (typeof err === "string") return err;
|
if (typeof err === "string") return err;
|
||||||
@@ -1000,8 +1010,8 @@ function updateProgressUI(data) {
|
|||||||
vidOriginal.style.display = "block";
|
vidOriginal.style.display = "block";
|
||||||
vidUpscaledWrapper.style.display = "block";
|
vidUpscaledWrapper.style.display = "block";
|
||||||
|
|
||||||
vidOriginal.src = `/api/download/${data.original_preview_file}`;
|
vidOriginal.src = getDownloadUrl(data.original_preview_file);
|
||||||
vidUpscaled.src = `/api/download/${data.output_file}`;
|
vidUpscaled.src = getDownloadUrl(data.output_file);
|
||||||
|
|
||||||
vidOriginal.load();
|
vidOriginal.load();
|
||||||
vidUpscaled.load();
|
vidUpscaled.load();
|
||||||
@@ -1060,7 +1070,7 @@ function updateProgressUI(data) {
|
|||||||
if (socket) socket.close();
|
if (socket) socket.close();
|
||||||
stepProgress.classList.remove("active");
|
stepProgress.classList.remove("active");
|
||||||
stepFinished.classList.add("active");
|
stepFinished.classList.add("active");
|
||||||
downloadLink.href = `/api/download/${data.output_file}`;
|
downloadLink.href = getDownloadUrl(data.output_file);
|
||||||
loadGallery();
|
loadGallery();
|
||||||
}, 1500);
|
}, 1500);
|
||||||
|
|
||||||
@@ -1302,7 +1312,7 @@ if (finishedPlayComparisonBtn) {
|
|||||||
if (job) {
|
if (job) {
|
||||||
const originalFilename = job.video_path.split("/").pop();
|
const originalFilename = job.video_path.split("/").pop();
|
||||||
const originalUrl = `/api/uploads/${originalFilename}`;
|
const originalUrl = `/api/uploads/${originalFilename}`;
|
||||||
const upscaledUrl = `/api/download/${job.output_file.split("/").pop()}`;
|
const upscaledUrl = getDownloadUrl(job.output_file);
|
||||||
openVideoComparisonModal(originalUrl, upscaledUrl);
|
openVideoComparisonModal(originalUrl, upscaledUrl);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1521,10 +1531,16 @@ async function loadQueue() {
|
|||||||
let actionsHtml = "";
|
let actionsHtml = "";
|
||||||
let reorderHtml = "";
|
let reorderHtml = "";
|
||||||
|
|
||||||
if (job.status === "queued" && job.queue_position !== null && job.queue_position !== undefined) {
|
const isActiveOrQueued = ["queued", "analyzing", "extracting", "upscaling", "restoring_faces", "interpolating", "assembling"].includes(job.status);
|
||||||
const queuedJobs = displayJobs.filter(j => j.status === "queued");
|
if (isActiveOrQueued && job.queue_position !== null && job.queue_position !== undefined) {
|
||||||
const isFirst = job.queue_position === 0;
|
const activeQueuedJobs = displayJobs.filter(j =>
|
||||||
const isLast = job.queue_position === queuedJobs.length - 1;
|
["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 = `
|
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">
|
<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"));
|
document.querySelectorAll(".step-container").forEach(el => el.classList.remove("active"));
|
||||||
if (job.status === "completed") {
|
if (job.status === "completed") {
|
||||||
document.getElementById("step-finished").classList.add("active");
|
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 {
|
} else {
|
||||||
document.getElementById("step-progress").classList.add("active");
|
document.getElementById("step-progress").classList.add("active");
|
||||||
document.getElementById("job-id-display").textContent = currentJobId;
|
document.getElementById("job-id-display").textContent = currentJobId;
|
||||||
@@ -2128,8 +2144,9 @@ async function initializeApp() {
|
|||||||
const defaultTempDirInput = document.getElementById("default-temp-dir-input");
|
const defaultTempDirInput = document.getElementById("default-temp-dir-input");
|
||||||
const tempDirStatus = document.getElementById("temp-dir-status");
|
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();
|
loadDefaultTempDir();
|
||||||
|
loadClusterSettings();
|
||||||
|
|
||||||
if (saveTempDirBtn) {
|
if (saveTempDirBtn) {
|
||||||
saveTempDirBtn.addEventListener("click", async () => {
|
saveTempDirBtn.addEventListener("click", async () => {
|
||||||
@@ -2139,10 +2156,19 @@ async function initializeApp() {
|
|||||||
saveTempDirBtn.disabled = true;
|
saveTempDirBtn.disabled = true;
|
||||||
saveTempDirBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Saving...';
|
saveTempDirBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Saving...';
|
||||||
try {
|
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", {
|
const res = await fetch("/api/settings", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ default_temp_dir: newDir })
|
body: JSON.stringify(updatedSettings)
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
tempDirStatus.style.color = "var(--success, #4caf50)";
|
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
|
// Wire up folder browser modals
|
||||||
const browseJobTempBtn = document.getElementById("browse-job-temp-btn");
|
const browseJobTempBtn = document.getElementById("browse-job-temp-btn");
|
||||||
const browseDefaultTempBtn = document.getElementById("browse-default-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 folderModalCloseBtn = document.getElementById("folder-modal-close-btn");
|
||||||
const folderCancelBtn = document.getElementById("folder-cancel-btn");
|
const folderCancelBtn = document.getElementById("folder-cancel-btn");
|
||||||
const folderSelectBtn = document.getElementById("folder-select-btn");
|
const folderSelectBtn = document.getElementById("folder-select-btn");
|
||||||
@@ -2193,6 +2277,27 @@ async function initializeApp() {
|
|||||||
openFolderBrowser(document.getElementById("default-temp-dir-input"));
|
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) {
|
if (folderModalCloseBtn) {
|
||||||
folderModalCloseBtn.addEventListener("click", closeFolderBrowser);
|
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() {
|
async function loadDefaultTempDir() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/settings");
|
const res = await fetch("/api/settings");
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const settings = await res.json();
|
const settings = await res.json();
|
||||||
|
|
||||||
|
// Temp dir settings
|
||||||
const savedDir = settings.default_temp_dir || "";
|
const savedDir = settings.default_temp_dir || "";
|
||||||
const defaultInput = document.getElementById("default-temp-dir-input");
|
const defaultInput = document.getElementById("default-temp-dir-input");
|
||||||
if (defaultInput) defaultInput.value = savedDir;
|
if (defaultInput) defaultInput.value = savedDir;
|
||||||
// Update per-job input placeholder
|
|
||||||
const perJobInput = document.getElementById("temp-dir-input");
|
const perJobInput = document.getElementById("temp-dir-input");
|
||||||
if (perJobInput && savedDir && !perJobInput.value.trim()) {
|
if (perJobInput && savedDir && !perJobInput.value.trim()) {
|
||||||
perJobInput.placeholder = `Default: ${savedDir} (override here)`;
|
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) {
|
} catch (err) {
|
||||||
console.error("Failed to load settings:", err);
|
console.error("Failed to load settings:", err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -290,6 +290,20 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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 -->
|
<!-- Pre-filters selection checkboxes -->
|
||||||
<div class="filter-group">
|
<div class="filter-group">
|
||||||
<span style="font-weight: 600; font-size: 0.85rem; display: block; color: var(--primary);">Pre-Filters & Enhancing</span>
|
<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 id="temp-dir-status" style="font-size: 0.78rem; margin-top: 0.5rem; min-height: 1.2em;"></div>
|
||||||
</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="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;">
|
<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>
|
<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>
|
||||||
</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">×</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 -->
|
<!-- Javascript Orchestration -->
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in New Issue
Block a user