Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68ed37eefb | ||
|
|
cbd071c872 | ||
|
|
7b7cb2866a | ||
|
|
212291ea94 | ||
|
|
2ceb948b98 |
+270
-9
@@ -27,21 +27,97 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
# In-memory databases
|
||||
class CustomJobQueue:
|
||||
def __init__(self):
|
||||
self.queue = []
|
||||
self.lock = threading.Lock()
|
||||
self.condition = threading.Condition(self.lock)
|
||||
|
||||
def put(self, job_id: str):
|
||||
with self.lock:
|
||||
if job_id not in self.queue:
|
||||
self.queue.append(job_id)
|
||||
self.condition.notify()
|
||||
|
||||
def get(self) -> str:
|
||||
with self.lock:
|
||||
while not self.queue:
|
||||
self.condition.wait()
|
||||
return self.queue.pop(0)
|
||||
|
||||
def remove(self, job_id: str) -> bool:
|
||||
with self.lock:
|
||||
if job_id in self.queue:
|
||||
self.queue.remove(job_id)
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_all(self) -> List[str]:
|
||||
with self.lock:
|
||||
return list(self.queue)
|
||||
|
||||
def reorder(self, job_ids: List[str]):
|
||||
with self.lock:
|
||||
valid_ids = [jid for jid in job_ids if jid in self.queue]
|
||||
missing_ids = [jid for jid in self.queue if jid not in valid_ids]
|
||||
self.queue = valid_ids + missing_ids
|
||||
|
||||
def task_done(self):
|
||||
pass
|
||||
|
||||
def empty(self) -> bool:
|
||||
with self.lock:
|
||||
return len(self.queue) == 0
|
||||
|
||||
def qsize(self) -> int:
|
||||
with self.lock:
|
||||
return len(self.queue)
|
||||
|
||||
jobs_db: Dict[str, upscaler.UpscaleJob] = {}
|
||||
ws_connections: Dict[str, List[WebSocket]] = {}
|
||||
preview_db: Dict[str, Dict[str, str]] = {} # preview_id -> {orig, upscaled}
|
||||
|
||||
# FIFO queue for upscaling jobs to prevent GPU memory overload
|
||||
job_queue = queue.Queue()
|
||||
# Custom thread-safe queue for upscaling jobs to support reordering & cancellation
|
||||
job_queue = CustomJobQueue()
|
||||
queue_lock = threading.Lock()
|
||||
current_running_job_id = None
|
||||
|
||||
main_loop = None
|
||||
|
||||
JOBS_FILE = os.path.join(upscaler.BASE_DIR, "jobs.json")
|
||||
|
||||
def load_jobs_db():
|
||||
global jobs_db
|
||||
if os.path.exists(JOBS_FILE):
|
||||
try:
|
||||
with open(JOBS_FILE, "r") as f:
|
||||
data = json.load(f)
|
||||
for job_id, job_data in data.items():
|
||||
job = upscaler.UpscaleJob.from_dict(job_data)
|
||||
# Automatically put queued items back in the queue
|
||||
if job.status == "queued":
|
||||
job_queue.put(job_id)
|
||||
# Mark active items as interrupted so they can be resumed
|
||||
elif job.status in ["analyzing", "extracting", "upscaling", "restoring_faces", "interpolating", "assembling"]:
|
||||
job.status = "interrupted"
|
||||
job.eta = "Interrupted"
|
||||
jobs_db[job_id] = job
|
||||
except Exception as e:
|
||||
print(f"Error loading jobs database: {e}")
|
||||
|
||||
def save_jobs_db():
|
||||
try:
|
||||
with open(JOBS_FILE, "w") as f:
|
||||
data = {job_id: job.to_dict() for job_id, job in jobs_db.items()}
|
||||
json.dump(data, f, indent=4)
|
||||
except Exception as e:
|
||||
print(f"Error saving jobs database: {e}")
|
||||
|
||||
@app.on_event("startup")
|
||||
def startup_event():
|
||||
global main_loop
|
||||
main_loop = asyncio.get_event_loop()
|
||||
load_jobs_db()
|
||||
|
||||
global_webhook_url = None
|
||||
|
||||
@@ -64,6 +140,7 @@ def send_webhook_notification(url: str, payload: dict):
|
||||
|
||||
# Broadcast updates to websockets and webhooks
|
||||
def broadcast_progress(job_id: str, data: dict):
|
||||
save_jobs_db()
|
||||
job = jobs_db.get(job_id)
|
||||
if job:
|
||||
data["is_preview"] = getattr(job, "is_preview", False)
|
||||
@@ -155,6 +232,9 @@ class StartUpscaleRequest(BaseModel):
|
||||
webhook_url: str | None = None
|
||||
transcode_format: str = "mp4"
|
||||
is_preview: bool = False
|
||||
ai_face_restoration: bool = False
|
||||
ai_rife_interpolation: bool = False
|
||||
ai_audio_denoise: bool = False
|
||||
|
||||
class PreviewRequest(BaseModel):
|
||||
file_id: str
|
||||
@@ -166,9 +246,28 @@ class PreviewRequest(BaseModel):
|
||||
|
||||
|
||||
# Endpoints
|
||||
UPLOAD_METADATA_FILE = os.path.join(upscaler.UPLOAD_DIR, "metadata.json")
|
||||
|
||||
def load_upload_metadata():
|
||||
if os.path.exists(UPLOAD_METADATA_FILE):
|
||||
try:
|
||||
with open(UPLOAD_METADATA_FILE, "r") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
def save_upload_metadata(metadata):
|
||||
try:
|
||||
with open(UPLOAD_METADATA_FILE, "w") as f:
|
||||
json.dump(metadata, f, indent=4)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@app.post("/api/upload")
|
||||
async def upload_video(file: UploadFile = File(...)):
|
||||
"""Upload video to workspace directory and parse metadata"""
|
||||
import time
|
||||
file_id = str(uuid.uuid4())
|
||||
ext = os.path.splitext(file.filename)[1].lower()
|
||||
if ext not in [".mp4", ".mkv", ".avi", ".mov", ".webm"]:
|
||||
@@ -186,6 +285,18 @@ async def upload_video(file: UploadFile = File(...)):
|
||||
os.remove(save_path)
|
||||
raise HTTPException(status_code=400, detail="Could not read video file metadata. File may be corrupted.")
|
||||
|
||||
# Save upload metadata
|
||||
metadata = load_upload_metadata()
|
||||
metadata[file_id] = {
|
||||
"file_id": file_id,
|
||||
"original_filename": file.filename,
|
||||
"ext": ext,
|
||||
"size_bytes": os.path.getsize(save_path),
|
||||
"upload_time": time.time(),
|
||||
"metadata": info
|
||||
}
|
||||
save_upload_metadata(metadata)
|
||||
|
||||
return {
|
||||
"file_id": file_id,
|
||||
"filename": file.filename,
|
||||
@@ -193,6 +304,82 @@ async def upload_video(file: UploadFile = File(...)):
|
||||
"metadata": info
|
||||
}
|
||||
|
||||
@app.get("/api/uploads")
|
||||
def list_uploads():
|
||||
"""List all uploaded video source files"""
|
||||
metadata = load_upload_metadata()
|
||||
valid_uploads = []
|
||||
metadata_updated = False
|
||||
|
||||
if os.path.exists(upscaler.UPLOAD_DIR):
|
||||
all_files = os.listdir(upscaler.UPLOAD_DIR)
|
||||
|
||||
for filename in all_files:
|
||||
if filename == "metadata.json":
|
||||
continue
|
||||
|
||||
file_path = os.path.join(upscaler.UPLOAD_DIR, filename)
|
||||
file_id, ext = os.path.splitext(filename)
|
||||
|
||||
if file_id in metadata:
|
||||
valid_uploads.append(metadata[file_id])
|
||||
else:
|
||||
info = upscaler.get_video_info(file_path)
|
||||
if info:
|
||||
entry = {
|
||||
"file_id": file_id,
|
||||
"original_filename": filename,
|
||||
"ext": ext,
|
||||
"size_bytes": os.path.getsize(file_path),
|
||||
"upload_time": os.path.getmtime(file_path),
|
||||
"metadata": info
|
||||
}
|
||||
metadata[file_id] = entry
|
||||
valid_uploads.append(entry)
|
||||
metadata_updated = True
|
||||
|
||||
# Clean up missing files from metadata
|
||||
for fid in list(metadata.keys()):
|
||||
ext = metadata[fid].get("ext", ".mp4")
|
||||
expected_file = os.path.join(upscaler.UPLOAD_DIR, f"{fid}{ext}")
|
||||
if not os.path.exists(expected_file):
|
||||
del metadata[fid]
|
||||
metadata_updated = True
|
||||
|
||||
if metadata_updated:
|
||||
save_upload_metadata(metadata)
|
||||
|
||||
# Sort by upload time desc
|
||||
valid_uploads.sort(key=lambda x: x.get("upload_time", 0), reverse=True)
|
||||
return valid_uploads
|
||||
|
||||
@app.delete("/api/uploads/{file_id}")
|
||||
@app.post("/api/uploads/delete/{file_id}")
|
||||
def delete_upload(file_id: str):
|
||||
"""Delete an uploaded source file"""
|
||||
metadata = load_upload_metadata()
|
||||
if file_id not in metadata:
|
||||
exts = [".mp4", ".mkv", ".avi", ".mov", ".webm"]
|
||||
file_path = None
|
||||
for ext in exts:
|
||||
p = os.path.join(upscaler.UPLOAD_DIR, f"{file_id}{ext}")
|
||||
if os.path.exists(p):
|
||||
file_path = p
|
||||
break
|
||||
if not file_path:
|
||||
raise HTTPException(status_code=404, detail="Upload file not found.")
|
||||
os.remove(file_path)
|
||||
return {"file_id": file_id, "status": "deleted"}
|
||||
|
||||
ext = metadata[file_id].get("ext", ".mp4")
|
||||
file_path = os.path.join(upscaler.UPLOAD_DIR, f"{file_id}{ext}")
|
||||
if os.path.exists(file_path):
|
||||
os.remove(file_path)
|
||||
|
||||
del metadata[file_id]
|
||||
save_upload_metadata(metadata)
|
||||
return {"file_id": file_id, "status": "deleted"}
|
||||
|
||||
@app.post("/api/upscale/start")
|
||||
def start_upscale(req: StartUpscaleRequest):
|
||||
"""Queue upscaling task"""
|
||||
@@ -232,11 +419,15 @@ def start_upscale(req: StartUpscaleRequest):
|
||||
interpolation=req.interpolation,
|
||||
webhook_url=req.webhook_url,
|
||||
transcode_format=req.transcode_format,
|
||||
is_preview=req.is_preview
|
||||
is_preview=req.is_preview,
|
||||
ai_face_restoration=req.ai_face_restoration,
|
||||
ai_rife_interpolation=req.ai_rife_interpolation,
|
||||
ai_audio_denoise=req.ai_audio_denoise
|
||||
)
|
||||
|
||||
jobs_db[job_id] = job
|
||||
job_queue.put(job_id)
|
||||
save_jobs_db()
|
||||
|
||||
# Broadcast initial queued progress
|
||||
broadcast_progress(job_id, {
|
||||
@@ -279,6 +470,9 @@ def cancel_job(job_id: str):
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found.")
|
||||
|
||||
# Remove from queue if it was queued
|
||||
job_queue.remove(job_id)
|
||||
|
||||
job.cancel()
|
||||
# Broadcast cancellation status
|
||||
broadcast_progress(job_id, {
|
||||
@@ -288,6 +482,7 @@ def cancel_job(job_id: str):
|
||||
"total_frames": job.total_frames,
|
||||
"eta": "N/A"
|
||||
})
|
||||
save_jobs_db()
|
||||
return {"job_id": job_id, "status": "cancelled"}
|
||||
|
||||
@app.post("/api/preview/generate")
|
||||
@@ -379,7 +574,22 @@ def safe_delete_file(file_path: str):
|
||||
|
||||
@app.get("/api/jobs")
|
||||
def list_jobs():
|
||||
"""List details of all submitted jobs"""
|
||||
"""List details of all submitted jobs in queue-sorted order"""
|
||||
active_id = current_running_job_id
|
||||
queued_ids = job_queue.get_all()
|
||||
|
||||
# Sort active first, then queued in order, then history by start time descending
|
||||
def get_sort_key(job):
|
||||
if job.job_id == active_id:
|
||||
return (0, 0)
|
||||
elif job.job_id in queued_ids:
|
||||
return (1, queued_ids.index(job.job_id))
|
||||
else:
|
||||
t = job.start_time if job.start_time is not None else 0
|
||||
return (2, -t)
|
||||
|
||||
sorted_jobs = sorted(jobs_db.values(), key=get_sort_key)
|
||||
|
||||
return [
|
||||
{
|
||||
"job_id": job.job_id,
|
||||
@@ -393,9 +603,10 @@ def list_jobs():
|
||||
"scale": job.scale,
|
||||
"output_file": os.path.basename(job.output_file) if job.output_file else None,
|
||||
"video_path": job.video_path,
|
||||
"is_preview": getattr(job, "is_preview", False)
|
||||
"is_preview": getattr(job, "is_preview", False),
|
||||
"queue_position": queued_ids.index(job.job_id) if job.job_id in queued_ids else -1 if job.job_id == active_id else None
|
||||
}
|
||||
for job in jobs_db.values()
|
||||
for job in sorted_jobs
|
||||
]
|
||||
|
||||
@app.delete("/api/jobs/{job_id}")
|
||||
@@ -405,9 +616,8 @@ def delete_job(job_id: str):
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found.")
|
||||
|
||||
# Safely delete uploaded input file
|
||||
if job.video_path:
|
||||
safe_delete_file(job.video_path)
|
||||
# Remove from queue if it is queued
|
||||
job_queue.remove(job_id)
|
||||
|
||||
# Safely delete original preview video if present
|
||||
for ext in [".mp4", ".mkv", ".avi", ".mov", ".webm"]:
|
||||
@@ -432,6 +642,8 @@ def delete_job(job_id: str):
|
||||
if job_id in jobs_db:
|
||||
del jobs_db[job_id]
|
||||
|
||||
save_jobs_db()
|
||||
|
||||
return {"job_id": job_id, "status": "purged"}
|
||||
|
||||
@app.post("/api/jobs/purge-all")
|
||||
@@ -459,8 +671,57 @@ def purge_all_jobs():
|
||||
# Reset in-memory database
|
||||
jobs_db.clear()
|
||||
|
||||
# Re-initialize custom queue
|
||||
global job_queue
|
||||
job_queue = CustomJobQueue()
|
||||
|
||||
# Reset upload metadata file
|
||||
save_upload_metadata({})
|
||||
|
||||
save_jobs_db()
|
||||
|
||||
return {"status": "all purged"}
|
||||
|
||||
class ReorderQueueRequest(BaseModel):
|
||||
job_ids: List[str]
|
||||
|
||||
@app.post("/api/queue/reorder")
|
||||
def reorder_queue(req: ReorderQueueRequest):
|
||||
"""Reorder the job queue"""
|
||||
job_queue.reorder(req.job_ids)
|
||||
save_jobs_db()
|
||||
return {"status": "success", "queue": job_queue.get_all()}
|
||||
|
||||
@app.get("/api/queue")
|
||||
def get_queue():
|
||||
"""Get the current job queue order"""
|
||||
return {"queue": job_queue.get_all()}
|
||||
|
||||
@app.post("/api/upscale/resume/{job_id}")
|
||||
def resume_job(job_id: str):
|
||||
"""Resume an interrupted/failed upscale job"""
|
||||
job = jobs_db.get(job_id)
|
||||
if not job:
|
||||
raise HTTPException(status_code=404, detail="Job not found.")
|
||||
|
||||
# Re-queue the job
|
||||
job.status = "queued"
|
||||
job.error = None
|
||||
job.eta = "Queued for resume..."
|
||||
|
||||
job_queue.put(job_id)
|
||||
save_jobs_db()
|
||||
|
||||
broadcast_progress(job_id, {
|
||||
"status": "queued",
|
||||
"progress": job.progress,
|
||||
"current_frame": job.current_frame,
|
||||
"total_frames": job.total_frames,
|
||||
"eta": "Queued for resume..."
|
||||
})
|
||||
|
||||
return {"job_id": job_id, "status": "queued"}
|
||||
|
||||
# Websocket endpoint for real-time progress updates
|
||||
@app.websocket("/ws/progress/{job_id}")
|
||||
async def websocket_progress(websocket: WebSocket, job_id: str):
|
||||
|
||||
+200
-27
@@ -23,13 +23,18 @@ class UpscaleJob:
|
||||
unsharp: bool = False, double_fps: bool = False, preserve_subtitles: bool = True,
|
||||
start_sec: float = None, end_sec: float = None, crf: int = 18, preset: str = "medium",
|
||||
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_audio_denoise: bool = False):
|
||||
self.job_id = job_id
|
||||
self.video_path = video_path
|
||||
self.model = model
|
||||
self.scale = scale
|
||||
self.tile_size = tile_size
|
||||
self.preserve_audio = preserve_audio
|
||||
self.ai_face_restoration = ai_face_restoration
|
||||
self.ai_rife_interpolation = ai_rife_interpolation
|
||||
self.ai_audio_denoise = ai_audio_denoise
|
||||
|
||||
# Trim mapping
|
||||
if ss is not None:
|
||||
@@ -78,6 +83,31 @@ class UpscaleJob:
|
||||
self._is_cancelled = False
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Serialize job attributes, excluding internal thread/process resources."""
|
||||
return {k: v for k, v in self.__dict__.items() if not k.startswith('_')}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> 'UpscaleJob':
|
||||
"""Deserialize job from dictionary, reconstructing internal locks and processes."""
|
||||
job = cls(
|
||||
job_id=data.get('job_id'),
|
||||
video_path=data.get('video_path'),
|
||||
model=data.get('model'),
|
||||
scale=data.get('scale', 4),
|
||||
tile_size=data.get('tile_size', 256),
|
||||
preserve_audio=data.get('preserve_audio', True),
|
||||
webhook_url=data.get('webhook_url'),
|
||||
transcode_format=data.get('transcode_format', 'mp4'),
|
||||
is_preview=data.get('is_preview', False)
|
||||
)
|
||||
for k, v in data.items():
|
||||
setattr(job, k, v)
|
||||
job._processes = []
|
||||
job._is_cancelled = False
|
||||
job._lock = threading.Lock()
|
||||
return job
|
||||
|
||||
def update_status(self, status: str, progress: float = None, current_frame: int = None, eta: str = None, error: str = None):
|
||||
with self._lock:
|
||||
self.status = status
|
||||
@@ -106,15 +136,15 @@ class UpscaleJob:
|
||||
pass
|
||||
self._processes.clear()
|
||||
|
||||
def run_command(self, cmd: list, shell=False) -> subprocess.Popen:
|
||||
def run_command(self, cmd: list, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=False) -> subprocess.Popen:
|
||||
with self._lock:
|
||||
if self._is_cancelled:
|
||||
raise InterruptedError("Job was cancelled")
|
||||
|
||||
p = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
text=True,
|
||||
shell=shell
|
||||
)
|
||||
@@ -256,7 +286,16 @@ def run_upscale_pipeline(job: UpscaleJob, on_progress_update: Callable[[str, Dic
|
||||
except Exception as cut_err:
|
||||
print(f"Error cutting original preview video: {cut_err}")
|
||||
|
||||
# Step 1: Extract Frames
|
||||
# Step 1: Extract Frames (Support Skipping on Resume)
|
||||
skip_extraction = False
|
||||
if os.path.exists(input_frames_dir):
|
||||
extracted_files = sorted([f for f in os.listdir(input_frames_dir) if f.startswith("frame_")])
|
||||
if len(extracted_files) > 0:
|
||||
skip_extraction = True
|
||||
print(f"Job {job.job_id}: Found existing input frames ({len(extracted_files)} frames). Skipping extraction step.")
|
||||
job.total_frames = len(extracted_files)
|
||||
|
||||
if not skip_extraction:
|
||||
job.update_status("extracting", progress=10)
|
||||
on_progress_update(job.job_id, {"status": "extracting", "progress": 10})
|
||||
|
||||
@@ -291,10 +330,36 @@ def run_upscale_pipeline(job: UpscaleJob, on_progress_update: Callable[[str, Dic
|
||||
raise RuntimeError("No frames extracted from video")
|
||||
|
||||
job.total_frames = actual_total
|
||||
else:
|
||||
actual_total = job.total_frames
|
||||
|
||||
# Step 2: Upscale Frames
|
||||
job.update_status("upscaling", progress=20, current_frame=0)
|
||||
on_progress_update(job.job_id, {"status": "upscaling", "progress": 20, "current_frame": 0, "total_frames": actual_total})
|
||||
# Step 2: Upscale Frames (Support Resuming by Skipping already upscaled frames)
|
||||
if os.path.exists(output_frames_dir):
|
||||
output_files = os.listdir(output_frames_dir)
|
||||
skipped_frames = 0
|
||||
for f in output_files:
|
||||
if f.startswith("frame_") and f.endswith(".jpg"):
|
||||
out_path = os.path.join(output_frames_dir, f)
|
||||
if os.path.exists(out_path) and os.path.getsize(out_path) > 0:
|
||||
in_path = os.path.join(input_frames_dir, f)
|
||||
if os.path.exists(in_path):
|
||||
try:
|
||||
os.remove(in_path)
|
||||
skipped_frames += 1
|
||||
except Exception as ex:
|
||||
print(f"Error removing resumed frame {in_path}: {ex}")
|
||||
if skipped_frames > 0:
|
||||
print(f"Job {job.job_id}: Skipping {skipped_frames} already upscaled frames.")
|
||||
|
||||
remaining_inputs = len(os.listdir(input_frames_dir)) if os.path.exists(input_frames_dir) else 0
|
||||
|
||||
if remaining_inputs == 0:
|
||||
print(f"Job {job.job_id}: All frames already upscaled. Skipping upscaling step.")
|
||||
job.update_status("upscaling", progress=80.0, current_frame=actual_total)
|
||||
on_progress_update(job.job_id, {"status": "upscaling", "progress": 80.0, "current_frame": actual_total, "total_frames": actual_total})
|
||||
else:
|
||||
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})
|
||||
|
||||
current_tile_size = job.tile_size
|
||||
while True:
|
||||
@@ -314,7 +379,11 @@ def run_upscale_pipeline(job: UpscaleJob, on_progress_update: Callable[[str, Dic
|
||||
upscale_cmd.append("-x")
|
||||
|
||||
upscale_start_time = time.time()
|
||||
p_upscale = job.run_command(upscale_cmd)
|
||||
upscale_stdout_path = os.path.join(job_temp_dir, "upscale_stdout.log")
|
||||
upscale_stderr_path = os.path.join(job_temp_dir, "upscale_stderr.log")
|
||||
|
||||
with open(upscale_stdout_path, "w") as f_out, open(upscale_stderr_path, "w") as f_err:
|
||||
p_upscale = job.run_command(upscale_cmd, stdout=f_out, stderr=f_err)
|
||||
|
||||
# Monitor thread for output files
|
||||
while p_upscale.poll() is None:
|
||||
@@ -326,8 +395,9 @@ def run_upscale_pipeline(job: UpscaleJob, on_progress_update: Callable[[str, Dic
|
||||
|
||||
# Estimate ETA
|
||||
elapsed = time.time() - upscale_start_time
|
||||
if processed_files > 0:
|
||||
sec_per_frame = elapsed / processed_files
|
||||
this_run_processed = processed_files - (actual_total - remaining_inputs)
|
||||
if this_run_processed > 0:
|
||||
sec_per_frame = elapsed / this_run_processed
|
||||
rem_frames = actual_total - processed_files
|
||||
eta_sec = rem_frames * sec_per_frame
|
||||
|
||||
@@ -349,7 +419,19 @@ def run_upscale_pipeline(job: UpscaleJob, on_progress_update: Callable[[str, Dic
|
||||
})
|
||||
time.sleep(0.5)
|
||||
|
||||
stdout, stderr = p_upscale.communicate()
|
||||
# Read stdout/stderr from files
|
||||
if os.path.exists(upscale_stdout_path):
|
||||
with open(upscale_stdout_path, "r") as f_out:
|
||||
stdout = f_out.read()
|
||||
else:
|
||||
stdout = ""
|
||||
|
||||
if os.path.exists(upscale_stderr_path):
|
||||
with open(upscale_stderr_path, "r") as f_err:
|
||||
stderr = f_err.read()
|
||||
else:
|
||||
stderr = ""
|
||||
|
||||
job.cleanup_process(p_upscale)
|
||||
|
||||
if job._is_cancelled:
|
||||
@@ -368,16 +450,15 @@ def run_upscale_pipeline(job: UpscaleJob, on_progress_update: Callable[[str, Dic
|
||||
if next_tile_size >= 32:
|
||||
print(f"Job {job.job_id}: Real-ESRGAN failed with VRAM allocation error. Retrying with tile size halved from {current_tile_size} to {next_tile_size}.")
|
||||
current_tile_size = next_tile_size
|
||||
# Clean up output frames directory before retrying
|
||||
for filename in os.listdir(output_frames_dir):
|
||||
file_path = os.path.join(output_frames_dir, filename)
|
||||
|
||||
# Clean up only output frames that we attempted to upscale in this run
|
||||
for filename in os.listdir(input_frames_dir):
|
||||
out_path = os.path.join(output_frames_dir, filename)
|
||||
if os.path.exists(out_path):
|
||||
try:
|
||||
if os.path.isfile(file_path) or os.path.islink(file_path):
|
||||
os.unlink(file_path)
|
||||
elif os.path.isdir(file_path):
|
||||
shutil.rmtree(file_path)
|
||||
except Exception as cleanup_err:
|
||||
print(f"Error cleaning file {file_path}: {cleanup_err}")
|
||||
os.unlink(out_path)
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
|
||||
raise RuntimeError(f"Real-ESRGAN failed with exit code {p_upscale.returncode}: {err_msg}")
|
||||
@@ -388,6 +469,87 @@ def run_upscale_pipeline(job: UpscaleJob, on_progress_update: Callable[[str, Dic
|
||||
processed_files = len(os.listdir(output_frames_dir))
|
||||
job.update_status("upscaling", progress=80.0, current_frame=processed_files)
|
||||
|
||||
# Step 2.6: AI Face Restoration (GFPGAN)
|
||||
if getattr(job, "ai_face_restoration", False):
|
||||
job.update_status("restoring_faces", progress=81.0)
|
||||
on_progress_update(job.job_id, {"status": "restoring_faces", "progress": 81.0})
|
||||
|
||||
import importlib.util
|
||||
gfpgan_installed = importlib.util.find_spec("gfpgan") is not None
|
||||
|
||||
if gfpgan_installed:
|
||||
print(f"Job {job.job_id}: GFPGAN detected. Running Face Restoration...")
|
||||
restored_dir = os.path.join(job_temp_dir, "restored_frames")
|
||||
os.makedirs(restored_dir, exist_ok=True)
|
||||
|
||||
gfpgan_cmd = [
|
||||
sys.executable, "-m", "gfpgan.inference_gfpgan",
|
||||
"-i", output_frames_dir,
|
||||
"-o", restored_dir,
|
||||
"-v", "1.4",
|
||||
"-s", "1",
|
||||
"--bg_upsampler", "None"
|
||||
]
|
||||
|
||||
p_gfp = job.run_command(gfpgan_cmd)
|
||||
stdout, stderr = p_gfp.communicate()
|
||||
job.cleanup_process(p_gfp)
|
||||
|
||||
if p_gfp.returncode == 0:
|
||||
gfp_output_path = os.path.join(restored_dir, "restored_imgs")
|
||||
if os.path.exists(gfp_output_path) and len(os.listdir(gfp_output_path)) > 0:
|
||||
for filename in os.listdir(gfp_output_path):
|
||||
src_f = os.path.join(gfp_output_path, filename)
|
||||
dst_f = os.path.join(output_frames_dir, filename)
|
||||
try:
|
||||
shutil.copy2(src_f, dst_f)
|
||||
except Exception as e:
|
||||
print(f"Error copying restored face frame: {e}")
|
||||
print(f"Job {job.job_id}: Face Restoration completed successfully.")
|
||||
else:
|
||||
print(f"Job {job.job_id}: GFPGAN did not generate outputs in restored_imgs.")
|
||||
else:
|
||||
print(f"Job {job.job_id}: GFPGAN failed (exit code {p_gfp.returncode}). Continuing with normal upscale.")
|
||||
else:
|
||||
print(f"Job {job.job_id}: 'gfpgan' package is not installed in the virtual environment. Skipping face restoration. To enable, run: pip install gfpgan realesrgan")
|
||||
|
||||
# Step 2.7: AI Frame Interpolation (RIFE)
|
||||
rife_frames_dir = os.path.join(job_temp_dir, "rife_frames")
|
||||
use_rife = False
|
||||
|
||||
if getattr(job, "ai_rife_interpolation", False):
|
||||
rife_bin = os.path.join(BASE_DIR, "rife-bin", "rife-ncnn-vulkan")
|
||||
if os.path.isfile(rife_bin):
|
||||
job.update_status("interpolating", progress=83.0)
|
||||
on_progress_update(job.job_id, {"status": "interpolating", "progress": 83.0})
|
||||
os.makedirs(rife_frames_dir, exist_ok=True)
|
||||
|
||||
try:
|
||||
os.chmod(rife_bin, 0o755)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
rife_cmd = [
|
||||
rife_bin,
|
||||
"-i", output_frames_dir,
|
||||
"-o", rife_frames_dir,
|
||||
"-f", "jpg"
|
||||
]
|
||||
if getattr(job, "gpu_ids", None) is not None:
|
||||
rife_cmd.extend(["-g", str(job.gpu_ids)])
|
||||
|
||||
p_rife = job.run_command(rife_cmd)
|
||||
stdout, stderr = p_rife.communicate()
|
||||
job.cleanup_process(p_rife)
|
||||
|
||||
if p_rife.returncode == 0:
|
||||
use_rife = True
|
||||
print(f"Job {job.job_id}: Successfully ran RIFE frame interpolation.")
|
||||
else:
|
||||
print(f"Job {job.job_id}: RIFE failed (exit code {p_rife.returncode}). Falling back to FFmpeg interpolation.")
|
||||
else:
|
||||
print(f"Job {job.job_id}: RIFE binary not found at {rife_bin}. Falling back to FFmpeg interpolation.")
|
||||
|
||||
# Step 3: Reassemble video
|
||||
job.update_status("assembling", progress=85.0)
|
||||
on_progress_update(job.job_id, {"status": "assembling", "progress": 85.0})
|
||||
@@ -404,11 +566,14 @@ def run_upscale_pipeline(job: UpscaleJob, on_progress_update: Callable[[str, Dic
|
||||
vcodec = "libvpx-vp9"
|
||||
acodec = "libvorbis"
|
||||
|
||||
assemble_frames_dir = rife_frames_dir if use_rife else output_frames_dir
|
||||
assemble_fps = fps * 2 if (use_rife or getattr(job, "double_fps", False) or getattr(job, "interpolation", False)) else fps
|
||||
|
||||
# Construct ffmpeg reassembly command
|
||||
assemble_cmd = [
|
||||
"ffmpeg", "-y",
|
||||
"-framerate", str(fps),
|
||||
"-i", os.path.join(output_frames_dir, "frame_%08d.jpg")
|
||||
"-framerate", str(assemble_fps),
|
||||
"-i", os.path.join(assemble_frames_dir, "frame_%08d.jpg")
|
||||
]
|
||||
|
||||
# We need the original video as the second input (index 1) if we preserve audio or subtitles
|
||||
@@ -425,10 +590,15 @@ def run_upscale_pipeline(job: UpscaleJob, on_progress_update: Callable[[str, Dic
|
||||
assemble_cmd.extend(["-map", "0:v:0"])
|
||||
|
||||
if job.preserve_audio:
|
||||
assemble_cmd.extend(["-map", "1:a:0?"])
|
||||
if getattr(job, "ai_audio_denoise", False):
|
||||
acodec_denoise = "libvorbis" if transcode_fmt == "webm" else "aac"
|
||||
assemble_cmd.extend([
|
||||
"-map", "1:a:0?",
|
||||
"-c:a", acodec
|
||||
"-af", "arnnoise",
|
||||
"-c:a", acodec_denoise
|
||||
])
|
||||
else:
|
||||
assemble_cmd.extend(["-c:a", acodec])
|
||||
|
||||
if getattr(job, "preserve_subtitles", True):
|
||||
assemble_cmd.extend([
|
||||
@@ -442,7 +612,7 @@ def run_upscale_pipeline(job: UpscaleJob, on_progress_update: Callable[[str, Dic
|
||||
vf_filters.append("hqdn3d")
|
||||
if getattr(job, "sharpen", False) or getattr(job, "unsharp", False):
|
||||
vf_filters.append("unsharp=3:3:0.5:3:3:0.5")
|
||||
if getattr(job, "interpolation", False) or getattr(job, "double_fps", False):
|
||||
if (getattr(job, "double_fps", False) or getattr(job, "interpolation", False) or getattr(job, "ai_rife_interpolation", False)) and not use_rife:
|
||||
target_fps = fps * 2 if getattr(job, "double_fps", False) else 60
|
||||
if target_fps < fps:
|
||||
target_fps = fps
|
||||
@@ -482,10 +652,13 @@ def run_upscale_pipeline(job: UpscaleJob, on_progress_update: Callable[[str, Dic
|
||||
job.update_status("failed", error=str(e))
|
||||
on_progress_update(job.job_id, {"status": "failed", "error": str(e)})
|
||||
finally:
|
||||
# Clean up temp frames to save space
|
||||
# Clean up temp frames to save space only if completed or cancelled
|
||||
try:
|
||||
if job.status in ["completed", "cancelled"]:
|
||||
if os.path.exists(job_temp_dir):
|
||||
shutil.rmtree(job_temp_dir)
|
||||
else:
|
||||
print(f"Job {job.job_id} finished with status {job.status}. Preserving temp directory {job_temp_dir} for potential resume.")
|
||||
except Exception as cleanup_err:
|
||||
print(f"Error during temp cleanup: {cleanup_err}")
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# 🧠 AI Video Upscaler - Project Context & Reference
|
||||
|
||||
Welcome! This document acts as a memory reference folder for AI agents working on this project. It details the system architecture, code organization, pipeline stages, and custom feature implementations (Resumption & Queue Management).
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ System Architecture
|
||||
|
||||
The AI Video Upscaler is a full-stack, single-user application designed to upscale videos locally using Vulkan GPU-accelerated AI models.
|
||||
|
||||
### Technology Stack
|
||||
1. **FastAPI (Python Backend)**: Handles API endpoints, serving static assets, WebSockets, background threads, and orchestrates subprocess execution.
|
||||
2. **Real-ESRGAN ncnn-vulkan (AI Engine)**: A compiled C++ Vulkan binary located in `realesrgan-bin/realesrgan-ncnn-vulkan`.
|
||||
3. **FFmpeg & FFprobe (Multimedia Toolkit)**: Splitting videos into high-quality JPEG frames, querying metadata, extracting audio streams, and re-muxing audio/subtitles back into the upscaled output.
|
||||
4. **Vanilla HTML/CSS/JS (Frontend)**: Cyberpunk-themed web dashboard with real-time progress logging, side-by-side comparative preview, and queue management.
|
||||
|
||||
---
|
||||
|
||||
## 📁 Key File Map
|
||||
|
||||
- **`app/main.py`**: API route definitions, WebSocket orchestrator, custom thread-safe job queue, and JSON database state preservation (`jobs.json`).
|
||||
- **`app/upscaler.py`**: Core pipeline orchestrator (`run_upscale_pipeline`). Executes external FFmpeg and Real-ESRGAN commands. Contains the resume logic.
|
||||
- **`static/index.html`**, **`static/styles.css`**, **`static/app.js`**: Frontend interface, WebSocket handlers, interactive comparison slider, zoom/pan tool, and queue reordering actions.
|
||||
- **`jobs.json`**: Persisted file storing details of all jobs (automatically generated at root).
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Upscale Pipeline Lifecycle
|
||||
|
||||
A typical upscale job follows these sequential steps:
|
||||
1. **Queued (`queued`)**: Added to the FIFO queue.
|
||||
2. **Analyzing (`analyzing`)**: GPU lock acquired. Querying stream specs with `ffprobe`.
|
||||
3. **Extracting (`extracting`)**: FFmpeg extracts video frames into `temp/<job_id>/input_frames/frame_%08d.jpg`.
|
||||
4. **Upscaling (`upscaling`)**: Real-ESRGAN binary upscales images to `temp/<job_id>/output_frames/frame_%08d.jpg`.
|
||||
5. **Assembling (`assembling`)**: FFmpeg merges upscaled frames with original audio/subtitles.
|
||||
6. **Completed (`completed`)** / **Failed (`failed`)** / **Cancelled (`cancelled`)** / **Interrupted (`interrupted`)**.
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Custom Enhancements
|
||||
|
||||
### 1. Job Resumption (`interrupted` status)
|
||||
- **Persistency**: The status of all jobs is stored in `jobs.json` at the root. On application restart, any incomplete/active job is automatically loaded in the `interrupted` status.
|
||||
- **Skip Extraction**: On resume, if `temp/<job_id>/input_frames` contains frames, the extraction step is bypassed.
|
||||
- **Incremental Upscaling**: The upscaler inspects `output_frames/` and checks for already upscaled frames. It removes corresponding files from `input_frames/`, meaning the AI model only processes the remaining un-upscaled frames.
|
||||
- **Fast Assembly**: If all frames are already upscaled, it skips the upscaling step entirely and directly runs the FFmpeg reassembly.
|
||||
|
||||
### 2. Queue Management (Cancel & Reorder)
|
||||
- **Custom Queue (`CustomJobQueue`)**: A thread-safe, list-backed FIFO queue replacing the standard `queue.Queue`. It allows:
|
||||
- Querying the active queue list (`GET /api/queue`).
|
||||
- Swapping queued jobs and re-ordering (`POST /api/queue/reorder`).
|
||||
- Graceful removal upon cancellation.
|
||||
- **Interactive UI Arrows**: Arrows are displayed next to queued items in the dashboard to move jobs up and down, triggering the API to swap their order on-the-fly.
|
||||
|
||||
### 3. Automatic Virtual Environment Setup (`start.py`)
|
||||
- **Self-Sufficiency**: Running `python3 start.py` automatically checks for a local virtual environment (`venv/` or `.venv/`).
|
||||
- **Auto-Provisioning**: If no virtual environment is found, `start.py` will initialize one in `venv/`, upgrade `pip`, install all dependencies listed in `requirements.txt`, mark the Real-ESRGAN binary as executable (`chmod +x`), and create necessary folders (`uploads/`, `outputs/`, `temp/`).
|
||||
- **Seamless Launch**: It then automatically launches the server process using the newly created environment interpreter.
|
||||
|
||||
### 4. Advanced AI Enhancements
|
||||
- **AI Face Restoration (GFPGAN)**: Runs as a python subprocess invoking `gfpgan.inference_gfpgan` to restore and clear up human faces in low-resolution video frames. Results are copied directly back into the frame output folder before motion interpolation and final video assembly.
|
||||
- **AI Frame Interpolation (RIFE)**: Runs using the `rife-ncnn-vulkan` binary (expected in `rife-bin/`). Smooths motion by generating and inserting intermediate frames, doubling the framerate. Falls back to FFmpeg's `minterpolate` optical flow filter if the Vulkan binary is not present.
|
||||
- **AI Audio Denoising (RNNoise)**: Transports and filters audio using the deep-learning-based `arnnoise` FFmpeg filter, eliminating background noise from output tracks during assembly.
|
||||
|
||||
@@ -7,3 +7,6 @@ python-multipart>=0.0.9
|
||||
websockets>=13.0
|
||||
pydantic>=2.0
|
||||
torch>=2.0
|
||||
gfpgan>=1.3.8
|
||||
realesrgan>=0.3.0
|
||||
|
||||
|
||||
@@ -21,18 +21,83 @@ import webbrowser
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
PID_FILE = os.path.join(BASE_DIR, ".uvicorn.pid")
|
||||
LOG_FILE = os.path.join(BASE_DIR, "server.log")
|
||||
# Detect the right Python interpreter:
|
||||
# 1. If running inside an activated venv, use that interpreter
|
||||
# 2. Otherwise look for venv/ then .venv/ in the project
|
||||
|
||||
VENV_PYTHON = sys.executable # default fallback
|
||||
|
||||
def ensure_venv():
|
||||
"""Detect or create the virtual environment and install dependencies."""
|
||||
global VENV_PYTHON
|
||||
|
||||
# 1. Already inside an activated venv — use it
|
||||
if sys.prefix != sys.base_prefix:
|
||||
# Already inside an activated venv — use it
|
||||
VENV_PYTHON = sys.executable
|
||||
return
|
||||
|
||||
venv_dir = os.path.join(BASE_DIR, "venv")
|
||||
dot_venv_dir = os.path.join(BASE_DIR, ".venv")
|
||||
|
||||
# Check if either venv/ or .venv/ exists with a python interpreter
|
||||
selected_venv = None
|
||||
if os.path.isdir(os.path.join(venv_dir, "bin")):
|
||||
selected_venv = venv_dir
|
||||
elif os.path.isdir(os.path.join(dot_venv_dir, "bin")):
|
||||
selected_venv = dot_venv_dir
|
||||
|
||||
if selected_venv:
|
||||
python_exe = os.path.join(selected_venv, "bin", "python")
|
||||
if os.path.isfile(python_exe):
|
||||
VENV_PYTHON = python_exe
|
||||
return
|
||||
|
||||
# No valid venv found — let's build it!
|
||||
print("⚙️ Virtual environment not detected. Initializing setup...")
|
||||
print(f" Creating virtual environment at: {venv_dir}")
|
||||
try:
|
||||
import venv
|
||||
venv.create(venv_dir, with_pip=True)
|
||||
print("✅ Virtual environment created.")
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to create virtual environment via 'venv' module: {e}")
|
||||
print(" Attempting subprocess fallback...")
|
||||
try:
|
||||
subprocess.run([sys.executable, "-m", "venv", venv_dir], check=True)
|
||||
print("✅ Virtual environment created (fallback).")
|
||||
except Exception as err:
|
||||
print(f"❌ Subprocess fallback failed: {err}")
|
||||
sys.exit(1)
|
||||
|
||||
VENV_PYTHON = os.path.join(venv_dir, "bin", "python")
|
||||
pip_exe = os.path.join(venv_dir, "bin", "pip")
|
||||
|
||||
# Install dependencies
|
||||
requirements_file = os.path.join(BASE_DIR, "requirements.txt")
|
||||
if os.path.isfile(requirements_file):
|
||||
print("📦 Installing Python dependencies from requirements.txt...")
|
||||
try:
|
||||
# Upgrade pip
|
||||
subprocess.run([pip_exe, "install", "--upgrade", "pip", "--quiet"], check=True)
|
||||
# Install requirements
|
||||
subprocess.run([pip_exe, "install", "-r", requirements_file], check=True)
|
||||
print("✅ All Python dependencies installed successfully.")
|
||||
except Exception as e:
|
||||
print(f"❌ Error installing dependencies: {e}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
VENV_PYTHON = os.path.join(BASE_DIR, "venv", "bin", "python")
|
||||
if not os.path.isfile(VENV_PYTHON):
|
||||
VENV_PYTHON = os.path.join(BASE_DIR, ".venv", "bin", "python")
|
||||
if not os.path.isfile(VENV_PYTHON):
|
||||
VENV_PYTHON = sys.executable # last resort: system python
|
||||
print("⚠ requirements.txt not found. Skipping dependency installation.")
|
||||
|
||||
# Configure Real-ESRGAN binary permissions
|
||||
binary_path = os.path.join(BASE_DIR, "realesrgan-bin", "realesrgan-ncnn-vulkan")
|
||||
if os.path.isfile(binary_path):
|
||||
try:
|
||||
os.chmod(binary_path, 0o755)
|
||||
print("✅ Real-ESRGAN binary marked as executable.")
|
||||
except Exception as e:
|
||||
print(f"⚠ Failed to change binary permissions: {e}")
|
||||
|
||||
# Create working directories
|
||||
for d in ["uploads", "outputs", "temp"]:
|
||||
os.makedirs(os.path.join(BASE_DIR, d), exist_ok=True)
|
||||
print("✅ Working directories verified.")
|
||||
|
||||
|
||||
def _is_running(pid: int) -> bool:
|
||||
@@ -153,4 +218,5 @@ if __name__ == "__main__":
|
||||
help="Don't open the browser automatically")
|
||||
args = parser.parse_args()
|
||||
|
||||
ensure_venv()
|
||||
start(args.host, args.port, open_browser=not args.no_browser)
|
||||
|
||||
+362
-15
@@ -344,6 +344,7 @@ function handleVideoUpload(file) {
|
||||
// Show config view
|
||||
stepUpload.classList.remove("active");
|
||||
stepConfig.classList.add("active");
|
||||
loadUploadsBrowser();
|
||||
} else {
|
||||
alert("Upload failed: " + (JSON.parse(xhr.responseText).detail || xhr.statusText));
|
||||
window.location.reload();
|
||||
@@ -599,6 +600,10 @@ generateVideoPreviewBtn.addEventListener("click", async () => {
|
||||
const filterSharpen = document.getElementById("filter-sharpen");
|
||||
const filterFps = document.getElementById("filter-fps");
|
||||
|
||||
const aiFaceRestoration = document.getElementById("ai-face-restoration");
|
||||
const aiRifeInterpolation = document.getElementById("ai-rife-interpolation");
|
||||
const aiAudioDenoise = document.getElementById("ai-audio-denoise");
|
||||
|
||||
const transcodeFormat = transcodeSelect.value;
|
||||
|
||||
try {
|
||||
@@ -625,7 +630,10 @@ generateVideoPreviewBtn.addEventListener("click", async () => {
|
||||
interpolation: filterFps.checked,
|
||||
webhook_url: null,
|
||||
transcode_format: transcodeFormat,
|
||||
is_preview: true
|
||||
is_preview: true,
|
||||
ai_face_restoration: aiFaceRestoration ? aiFaceRestoration.checked : false,
|
||||
ai_rife_interpolation: aiRifeInterpolation ? aiRifeInterpolation.checked : false,
|
||||
ai_audio_denoise: aiAudioDenoise ? aiAudioDenoise.checked : false
|
||||
})
|
||||
});
|
||||
|
||||
@@ -711,6 +719,10 @@ startUpscaleBtn.addEventListener("click", async () => {
|
||||
const filterFps = document.getElementById("filter-fps");
|
||||
const webhookUrl = document.getElementById("webhook-url");
|
||||
|
||||
const aiFaceRestoration = document.getElementById("ai-face-restoration");
|
||||
const aiRifeInterpolation = document.getElementById("ai-rife-interpolation");
|
||||
const aiAudioDenoise = document.getElementById("ai-audio-denoise");
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/upscale/start", {
|
||||
method: "POST",
|
||||
@@ -732,7 +744,10 @@ startUpscaleBtn.addEventListener("click", async () => {
|
||||
denoise: filterDenoise.checked,
|
||||
sharpen: filterSharpen.checked,
|
||||
interpolation: filterFps.checked,
|
||||
webhook_url: webhookUrl.value.trim() || null
|
||||
webhook_url: webhookUrl.value.trim() || null,
|
||||
ai_face_restoration: aiFaceRestoration ? aiFaceRestoration.checked : false,
|
||||
ai_rife_interpolation: aiRifeInterpolation ? aiRifeInterpolation.checked : false,
|
||||
ai_audio_denoise: aiAudioDenoise ? aiAudioDenoise.checked : false
|
||||
})
|
||||
});
|
||||
|
||||
@@ -815,6 +830,10 @@ function updateProgressUI(data) {
|
||||
statusText = "Queued in pipeline. Waiting for GPU lock...";
|
||||
} else if (data.status === "extracting") {
|
||||
statusText = "Extracting video frames...";
|
||||
} else if (data.status === "restoring_faces") {
|
||||
statusText = "GFPGAN: Running Face Restoration on upscaled frames...";
|
||||
} else if (data.status === "interpolating") {
|
||||
statusText = "RIFE: Interpolating frames for smooth motion...";
|
||||
} else if (data.status === "assembling") {
|
||||
statusText = "Assembling video preview and audio tracks...";
|
||||
}
|
||||
@@ -890,6 +909,10 @@ function updateProgressUI(data) {
|
||||
appendLogLine("FFmpeg: Splitting video stream to high-fidelity JPG frames...", "info");
|
||||
} else if (data.status === "upscaling") {
|
||||
appendLogLine(`Vulkan GPU: Processing frame ${data.current_frame} of ${data.total_frames} (ETA: ${data.eta})...`, "info");
|
||||
} else if (data.status === "restoring_faces") {
|
||||
appendLogLine("GFPGAN: Running Face Restoration on upscaled frames...", "info");
|
||||
} else if (data.status === "interpolating") {
|
||||
appendLogLine("RIFE: Interpolating frames for smooth motion...", "info");
|
||||
} else if (data.status === "assembling") {
|
||||
appendLogLine("FFmpeg: Reassembling enhanced frames & merging audio tracks...", "info");
|
||||
} else if (data.status === "completed") {
|
||||
@@ -1020,17 +1043,11 @@ async function checkActiveJobs() {
|
||||
|
||||
const jobs = await res.json();
|
||||
const activeJob = jobs.find(job =>
|
||||
["queued", "analyzing", "extracting", "upscaling", "assembling"].includes(job.status)
|
||||
["queued", "analyzing", "extracting", "upscaling", "restoring_faces", "interpolating", "assembling"].includes(job.status)
|
||||
);
|
||||
|
||||
if (activeJob) {
|
||||
currentJobId = activeJob.job_id;
|
||||
|
||||
document.querySelectorAll(".step-container").forEach(el => el.classList.remove("active"));
|
||||
stepProgress.classList.add("active");
|
||||
jobIdDisplay.textContent = currentJobId;
|
||||
|
||||
connectProgressWebSocket(currentJobId);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error checking active jobs:", err);
|
||||
@@ -1281,6 +1298,37 @@ async function loadGallery() {
|
||||
}
|
||||
}
|
||||
|
||||
async function moveQueueItem(jobId, direction) {
|
||||
try {
|
||||
const res = await fetch("/api/queue");
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const queue = data.queue;
|
||||
const index = queue.indexOf(jobId);
|
||||
if (index === -1) return;
|
||||
|
||||
const newIndex = index + direction;
|
||||
if (newIndex < 0 || newIndex >= queue.length) return;
|
||||
|
||||
// Swap
|
||||
const temp = queue[index];
|
||||
queue[index] = queue[newIndex];
|
||||
queue[newIndex] = temp;
|
||||
|
||||
const reorderRes = await fetch("/api/queue/reorder", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ job_ids: queue })
|
||||
});
|
||||
|
||||
if (reorderRes.ok) {
|
||||
loadQueue();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error reordering queue:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadQueue() {
|
||||
const queueList = document.getElementById("queue-list");
|
||||
if (!queueList) return;
|
||||
@@ -1292,7 +1340,7 @@ async function loadQueue() {
|
||||
const jobs = await res.json();
|
||||
|
||||
const displayJobs = jobs.filter(job =>
|
||||
["queued", "analyzing", "extracting", "upscaling", "assembling", "failed", "cancelled"].includes(job.status)
|
||||
["queued", "analyzing", "extracting", "upscaling", "restoring_faces", "interpolating", "assembling", "completed", "failed", "cancelled", "interrupted"].includes(job.status)
|
||||
);
|
||||
|
||||
if (displayJobs.length === 0) {
|
||||
@@ -1323,26 +1371,59 @@ async function loadQueue() {
|
||||
|
||||
if (job.status === "failed" && job.error) {
|
||||
metaHtml += `<span style="color: var(--danger);"><i class="fa-solid fa-triangle-exclamation"></i> Error: ${job.error}</span>`;
|
||||
} else if (job.status !== "queued" && job.status !== "failed" && job.status !== "cancelled") {
|
||||
} else if (job.status !== "queued" && job.status !== "failed" && job.status !== "cancelled" && job.status !== "completed" && job.status !== "interrupted") {
|
||||
metaHtml += `<span><i class="fa-solid fa-clock"></i> ETA: ${job.eta || 'Calculating...'}</span>`;
|
||||
}
|
||||
|
||||
let actionsHtml = "";
|
||||
if (["queued", "analyzing", "extracting", "upscaling", "assembling"].includes(job.status)) {
|
||||
let reorderHtml = "";
|
||||
|
||||
if (job.status === "queued" && job.queue_position !== null && job.queue_position !== undefined) {
|
||||
const queuedJobs = displayJobs.filter(j => j.status === "queued");
|
||||
const isFirst = job.queue_position === 0;
|
||||
const isLast = job.queue_position === queuedJobs.length - 1;
|
||||
|
||||
reorderHtml = `
|
||||
<button class="btn btn-secondary btn-small btn-move-up" data-id="${job.job_id}" style="padding: 0.25rem 0.5rem; width: 30px; height: 30px; border-radius: 8px; font-size: 0.8rem; display: inline-flex; align-items: center; justify-content: center; ${isFirst ? 'visibility: hidden;' : ''}" title="Move Up">
|
||||
<i class="fa-solid fa-arrow-up"></i>
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-small btn-move-down" data-id="${job.job_id}" style="padding: 0.25rem 0.5rem; width: 30px; height: 30px; border-radius: 8px; font-size: 0.8rem; display: inline-flex; align-items: center; justify-content: center; ${isLast ? 'visibility: hidden;' : ''}" title="Move Down">
|
||||
<i class="fa-solid fa-arrow-down"></i>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
if (job.status === "interrupted") {
|
||||
actionsHtml = `
|
||||
<button class="btn btn-primary btn-small btn-resume-job" data-id="${job.job_id}">
|
||||
<i class="fa-solid fa-play"></i> Resume
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-small btn-delete-job" data-id="${job.job_id}">
|
||||
<i class="fa-solid fa-trash"></i> Clear
|
||||
</button>
|
||||
`;
|
||||
} else if (["queued", "analyzing", "extracting", "upscaling", "restoring_faces", "interpolating", "assembling"].includes(job.status)) {
|
||||
actionsHtml = `
|
||||
${reorderHtml}
|
||||
<button class="btn btn-primary btn-small btn-view-job" data-id="${job.job_id}">
|
||||
<i class="fa-solid fa-eye"></i> View
|
||||
</button>
|
||||
<button class="btn btn-danger btn-small btn-abort-job" data-id="${job.job_id}">
|
||||
<i class="fa-solid fa-circle-stop"></i> Abort
|
||||
</button>
|
||||
`;
|
||||
} else {
|
||||
actionsHtml = `
|
||||
<button class="btn btn-secondary btn-small btn-view-job" data-id="${job.job_id}">
|
||||
<i class="fa-solid fa-eye"></i> View
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-small btn-delete-job" data-id="${job.job_id}">
|
||||
<i class="fa-solid fa-trash"></i> Clear
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
const showProgress = ["analyzing", "extracting", "upscaling", "assembling"].includes(job.status);
|
||||
const showProgress = ["analyzing", "extracting", "upscaling", "restoring_faces", "interpolating", "assembling"].includes(job.status);
|
||||
const progressStyle = showProgress ? "display: block;" : "display: none;";
|
||||
|
||||
item.innerHTML = `
|
||||
@@ -1365,9 +1446,44 @@ async function loadQueue() {
|
||||
</div>
|
||||
`;
|
||||
|
||||
const upBtn = item.querySelector(".btn-move-up");
|
||||
if (upBtn) {
|
||||
upBtn.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
await moveQueueItem(job.job_id, -1);
|
||||
});
|
||||
}
|
||||
|
||||
const downBtn = item.querySelector(".btn-move-down");
|
||||
if (downBtn) {
|
||||
downBtn.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
await moveQueueItem(job.job_id, 1);
|
||||
});
|
||||
}
|
||||
|
||||
const resumeBtn = item.querySelector(".btn-resume-job");
|
||||
if (resumeBtn) {
|
||||
resumeBtn.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const resumeRes = await fetch(`/api/upscale/resume/${job.job_id}`, { method: "POST" });
|
||||
if (resumeRes.ok) {
|
||||
loadQueue();
|
||||
} else {
|
||||
const err = await resumeRes.json();
|
||||
alert("Failed to resume job: " + formatFetchError(err, "unknown error"));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error resuming job:", err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const abortBtn = item.querySelector(".btn-abort-job");
|
||||
if (abortBtn) {
|
||||
abortBtn.addEventListener("click", async () => {
|
||||
abortBtn.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
if (confirm("Are you sure you want to abort this upscaling job?")) {
|
||||
try {
|
||||
const abortRes = await fetch(`/api/upscale/cancel/${job.job_id}`, { method: "POST" });
|
||||
@@ -1383,7 +1499,8 @@ async function loadQueue() {
|
||||
|
||||
const deleteBtn = item.querySelector(".btn-delete-job");
|
||||
if (deleteBtn) {
|
||||
deleteBtn.addEventListener("click", async () => {
|
||||
deleteBtn.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
if (confirm("Are you sure you want to clear this job from history?")) {
|
||||
try {
|
||||
const delRes = await fetch(`/api/jobs/${job.job_id}`, { method: "DELETE" });
|
||||
@@ -1398,6 +1515,28 @@ async function loadQueue() {
|
||||
});
|
||||
}
|
||||
|
||||
const viewBtn = item.querySelector(".btn-view-job");
|
||||
if (viewBtn) {
|
||||
viewBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
currentJobId = job.job_id;
|
||||
if (socket) {
|
||||
try {
|
||||
socket.close();
|
||||
} catch (e) {}
|
||||
}
|
||||
document.querySelectorAll(".step-container").forEach(el => el.classList.remove("active"));
|
||||
if (job.status === "completed") {
|
||||
document.getElementById("step-finished").classList.add("active");
|
||||
document.getElementById("download-link").href = `/api/download/${job.output_file}`;
|
||||
} else {
|
||||
document.getElementById("step-progress").classList.add("active");
|
||||
document.getElementById("job-id-display").textContent = currentJobId;
|
||||
connectProgressWebSocket(currentJobId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
queueList.appendChild(item);
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -1473,11 +1612,219 @@ async function loadModels() {
|
||||
}
|
||||
}
|
||||
|
||||
function useUploadedVideo(entry) {
|
||||
uploadFileId = entry.file_id;
|
||||
uploadMetadata = entry.metadata;
|
||||
|
||||
// Populate Config Step Info
|
||||
metaName.textContent = entry.original_filename;
|
||||
metaSize.textContent = formatBytes(entry.size_bytes);
|
||||
|
||||
// Populate Accordions Stream details
|
||||
const streams = entry.metadata.streams || [];
|
||||
const videoStream = streams.find(s => s.codec_type === "video") || {};
|
||||
const audioStream = streams.find(s => s.codec_type === "audio") || {};
|
||||
|
||||
// Video stream metadata
|
||||
document.getElementById("meta-res-codec").textContent = (videoStream.codec_name || "unknown").toUpperCase();
|
||||
document.getElementById("meta-res").textContent = `${entry.metadata.width} x ${entry.metadata.height}`;
|
||||
document.getElementById("meta-fps").textContent = `${entry.metadata.fps} FPS`;
|
||||
document.getElementById("meta-duration").textContent = `${entry.metadata.duration}s`;
|
||||
document.getElementById("meta-total-frames").textContent = entry.metadata.total_frames || "-";
|
||||
document.getElementById("meta-aspect-ratio").textContent = videoStream.display_aspect_ratio || "N/A";
|
||||
|
||||
// Audio stream metadata
|
||||
if (audioStream.codec_name) {
|
||||
document.getElementById("meta-audio-codec").textContent = (audioStream.codec_name || "unknown").toUpperCase();
|
||||
document.getElementById("meta-audio-channels").textContent = audioStream.channels || "N/A";
|
||||
document.getElementById("meta-audio-samplerate").textContent = audioStream.sample_rate ? `${audioStream.sample_rate} Hz` : "N/A";
|
||||
document.getElementById("meta-audio-bitrate").textContent = audioStream.bit_rate ? formatBytes(parseInt(audioStream.bit_rate)) + "/s" : "N/A";
|
||||
} else {
|
||||
document.getElementById("meta-audio-codec").textContent = "No Audio Stream";
|
||||
document.getElementById("meta-audio-channels").textContent = "-";
|
||||
document.getElementById("meta-audio-samplerate").textContent = "-";
|
||||
document.getElementById("meta-audio-bitrate").textContent = "-";
|
||||
}
|
||||
|
||||
// Configure timeline slider constraints
|
||||
timelineSlider.min = 0.1;
|
||||
timelineSlider.max = entry.metadata.duration - 0.5;
|
||||
timelineSlider.value = (entry.metadata.duration * 0.15).toFixed(2);
|
||||
timelineTime.textContent = `${timelineSlider.value}s`;
|
||||
|
||||
// Configure Trimming panel range values
|
||||
const trimStartRange = document.getElementById("trim-start-range");
|
||||
const trimEndRange = document.getElementById("trim-end-range");
|
||||
const trimStartValue = document.getElementById("trim-start-value");
|
||||
const trimEndValue = document.getElementById("trim-end-value");
|
||||
|
||||
trimStartRange.min = 0;
|
||||
trimStartRange.max = entry.metadata.duration;
|
||||
trimStartRange.value = 0;
|
||||
trimStartValue.textContent = "0.00s";
|
||||
|
||||
trimEndRange.min = 0;
|
||||
trimEndRange.max = entry.metadata.duration;
|
||||
trimEndRange.value = entry.metadata.duration;
|
||||
trimEndValue.textContent = `${entry.metadata.duration.toFixed(2)}s`;
|
||||
|
||||
// Show config view
|
||||
stepUpload.classList.remove("active");
|
||||
stepConfig.classList.add("active");
|
||||
}
|
||||
|
||||
async function loadUploadsBrowser() {
|
||||
const browserList = document.getElementById("uploads-browser-list");
|
||||
if (!browserList) return;
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/uploads");
|
||||
if (!res.ok) return;
|
||||
|
||||
const uploads = await res.json();
|
||||
if (uploads.length === 0) {
|
||||
browserList.innerHTML = `
|
||||
<div style="text-align: center; padding: 2rem; color: var(--text-muted);">
|
||||
<i class="fa-solid fa-photo-film" style="font-size: 2rem; margin-bottom: 0.5rem; color: rgba(255,255,255,0.15);"></i>
|
||||
<p>No uploaded files found. Upload a video above to get started!</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
browserList.innerHTML = "";
|
||||
|
||||
uploads.forEach(entry => {
|
||||
const item = document.createElement("div");
|
||||
item.className = "queue-item";
|
||||
|
||||
const sizeStr = formatBytes(entry.size_bytes || 0);
|
||||
const durationStr = entry.metadata ? `${entry.metadata.duration}s` : "-";
|
||||
const resStr = entry.metadata ? `${entry.metadata.width}x${entry.metadata.height}` : "-";
|
||||
const fpsStr = entry.metadata ? `${entry.metadata.fps} FPS` : "-";
|
||||
|
||||
item.innerHTML = `
|
||||
<div class="queue-item-details">
|
||||
<div class="queue-item-title">
|
||||
<i class="fa-solid fa-file-video" style="color: var(--primary);"></i> ${entry.original_filename}
|
||||
</div>
|
||||
<div class="queue-item-meta">
|
||||
<span>Size: ${sizeStr}</span>
|
||||
<span>Resolution: ${resStr}</span>
|
||||
<span>Duration: ${durationStr}</span>
|
||||
<span>FPS: ${fpsStr}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="queue-item-status-bar">
|
||||
<div class="queue-item-actions">
|
||||
<button class="btn btn-primary btn-small btn-use-upload" data-id="${entry.file_id}">
|
||||
<i class="fa-solid fa-play"></i> Use
|
||||
</button>
|
||||
<button class="btn btn-danger btn-small btn-delete-upload" data-id="${entry.file_id}">
|
||||
<i class="fa-solid fa-trash"></i> Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
item.querySelector(".btn-use-upload").addEventListener("click", () => {
|
||||
useUploadedVideo(entry);
|
||||
});
|
||||
|
||||
item.querySelector(".btn-delete-upload").addEventListener("click", async () => {
|
||||
if (confirm(`Are you sure you want to delete "${entry.original_filename}" from the workspace?`)) {
|
||||
try {
|
||||
const delRes = await fetch(`/api/uploads/${entry.file_id}`, { method: "DELETE" });
|
||||
if (delRes.ok) {
|
||||
loadUploadsBrowser();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error deleting upload:", err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
browserList.appendChild(item);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error loading uploads browser:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeApp() {
|
||||
await loadModels();
|
||||
await checkActiveJobs();
|
||||
await loadGallery(); // Load enhancement gallery at page load
|
||||
await loadQueue(); // Load active jobs queue at page load
|
||||
await loadUploadsBrowser(); // Load previously uploaded videos browser
|
||||
|
||||
// Load GPU Diagnostics
|
||||
try {
|
||||
const res = await fetch("/api/diagnostics");
|
||||
if (res.ok) {
|
||||
const diag = await res.json();
|
||||
const gpuInfo = document.getElementById("gpu-info");
|
||||
if (gpuInfo) {
|
||||
if (diag.gpu && diag.gpu.available && diag.gpu.gpus.length > 0) {
|
||||
const gpuName = diag.gpu.gpus[0].name;
|
||||
gpuInfo.textContent = `GPU Active: ${gpuName} (Vulkan)`;
|
||||
} else {
|
||||
gpuInfo.textContent = "GPU Active: Vulkan Device";
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error loading GPU diagnostics:", err);
|
||||
}
|
||||
|
||||
|
||||
// Register btn-refresh-uploads click handler
|
||||
const refreshUploadsBtn = document.getElementById("refresh-uploads-btn");
|
||||
if (refreshUploadsBtn) {
|
||||
refreshUploadsBtn.addEventListener("click", loadUploadsBrowser);
|
||||
}
|
||||
|
||||
// Register back-to-workspace-btn click handler
|
||||
const backToWorkspaceBtn = document.getElementById("back-to-workspace-btn");
|
||||
if (backToWorkspaceBtn) {
|
||||
backToWorkspaceBtn.addEventListener("click", () => {
|
||||
if (socket) {
|
||||
try {
|
||||
socket.close();
|
||||
} catch(e) {}
|
||||
}
|
||||
document.querySelectorAll(".step-container").forEach(el => el.classList.remove("active"));
|
||||
document.getElementById("step-upload").classList.add("active");
|
||||
uploadFileId = null;
|
||||
loadUploadsBrowser();
|
||||
});
|
||||
}
|
||||
|
||||
// Register documentation modal handlers
|
||||
const docsBtn = document.getElementById("docs-btn");
|
||||
const docsModal = document.getElementById("docs-modal");
|
||||
const docsCloseBtn = document.getElementById("docs-modal-close-btn");
|
||||
|
||||
if (docsBtn && docsModal) {
|
||||
docsBtn.addEventListener("click", () => {
|
||||
docsModal.style.display = "flex";
|
||||
document.body.style.overflow = "hidden";
|
||||
});
|
||||
}
|
||||
|
||||
if (docsCloseBtn && docsModal) {
|
||||
docsCloseBtn.addEventListener("click", () => {
|
||||
docsModal.style.display = "none";
|
||||
document.body.style.overflow = "";
|
||||
});
|
||||
|
||||
docsModal.addEventListener("click", (e) => {
|
||||
if (e.target === docsModal) {
|
||||
docsModal.style.display = "none";
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
initializeApp();
|
||||
|
||||
+82
-2
@@ -20,6 +20,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button type="button" id="docs-btn" class="btn btn-secondary" style="height: 38px; border-radius: 50px; padding: 0 1.25rem; display: inline-flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; box-shadow: none; border: 1px solid var(--border-color);">
|
||||
<i class="fa-solid fa-book-open"></i> Docs
|
||||
</button>
|
||||
<div class="theme-selector">
|
||||
<label for="theme-select"><i class="fa-solid fa-palette"></i> Theme</label>
|
||||
<select id="theme-select">
|
||||
@@ -30,7 +33,7 @@
|
||||
</div>
|
||||
<div class="gpu-badge">
|
||||
<div class="gpu-dot"></div>
|
||||
<span id="gpu-info">GPU Active: Quadro P2000 (Vulkan)</span>
|
||||
<span id="gpu-info">GPU Active: Detecting... (Vulkan)</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -49,6 +52,23 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Previously Uploaded Files Browser -->
|
||||
<div class="glass-card" style="margin-top: 2rem; padding: 1.5rem;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border-color); padding-bottom: 0.75rem; margin-bottom: 1rem;">
|
||||
<h3 style="margin: 0; font-family: var(--font-heading); font-size: 1.25rem;"><i class="fa-solid fa-folder-tree" style="color: var(--primary); margin-right: 0.5rem;"></i> Previously Uploaded Source Videos</h3>
|
||||
<button type="button" id="refresh-uploads-btn" class="btn btn-secondary btn-small">
|
||||
<i class="fa-solid fa-arrows-rotate"></i> Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div id="uploads-browser-list" style="display: flex; flex-direction: column; gap: 0.75rem;">
|
||||
<!-- Dynamically populated files -->
|
||||
<div style="text-align: center; padding: 2rem; color: var(--text-muted);">
|
||||
<i class="fa-solid fa-photo-film" style="font-size: 2rem; margin-bottom: 0.5rem; color: rgba(255,255,255,0.15);"></i>
|
||||
<p>No uploaded files found. Upload a video above to get started!</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Configuration & Preview -->
|
||||
@@ -263,6 +283,22 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI Enhancements Section -->
|
||||
<div class="filter-group" style="margin-top: 1rem;">
|
||||
<span style="font-weight: 600; font-size: 0.85rem; display: block; color: var(--primary);">Advanced AI Enhancements</span>
|
||||
<div class="filter-checkboxes">
|
||||
<label class="checkbox-container">
|
||||
<input type="checkbox" id="ai-face-restoration"> AI Face Restoration (GFPGAN)
|
||||
</label>
|
||||
<label class="checkbox-container">
|
||||
<input type="checkbox" id="ai-rife-interpolation"> AI Frame Interpolation (RIFE)
|
||||
</label>
|
||||
<label class="checkbox-container">
|
||||
<input type="checkbox" id="ai-audio-denoise"> AI Audio Denoising (RNNoise)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Webhook Notify Input -->
|
||||
<div class="form-group">
|
||||
<label for="webhook-url">Webhook Notification Callback</label>
|
||||
@@ -455,7 +491,10 @@
|
||||
</div>
|
||||
|
||||
<!-- Action control -->
|
||||
<div style="display: flex; justify-content: flex-end;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 1.5rem;">
|
||||
<button type="button" id="back-to-workspace-btn" class="btn btn-secondary">
|
||||
<i class="fa-solid fa-arrow-left"></i> Add Another Job
|
||||
</button>
|
||||
<button type="button" id="cancel-upscale-btn" class="btn btn-danger">
|
||||
<i class="fa-solid fa-circle-stop"></i> Abort Upscaling Job
|
||||
</button>
|
||||
@@ -570,6 +609,47 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Documentation Modal -->
|
||||
<div id="docs-modal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-card" style="max-width: 800px; max-height: 85vh; display: flex; flex-direction: column;">
|
||||
<div class="modal-header">
|
||||
<h3><i class="fa-solid fa-book-open"></i> Help & System Documentation</h3>
|
||||
<button type="button" class="close-btn" id="docs-modal-close-btn">×</button>
|
||||
</div>
|
||||
<div class="modal-body" style="overflow-y: auto; padding: 2rem; font-family: var(--font-body); line-height: 1.6; color: var(--text-main);">
|
||||
<div class="docs-markdown-body">
|
||||
<h2>🎬 AI Video Upscaler Guide</h2>
|
||||
<p>Welcome to the AI Video Upscaler! This application leverages neural network models running locally to upscale and enhance video footage.</p>
|
||||
|
||||
<h3>💡 Quick Start</h3>
|
||||
<ol>
|
||||
<li><strong>Upload:</strong> Drag & drop a video file onto the upload zone, or browse to select one.</li>
|
||||
<li><strong>Configure:</strong> Choose your AI model, scale factor, and adjust parameters.</li>
|
||||
<li><strong>Preview:</strong> Select a frame using the timeline and render a single-frame preview or a 5-second video preview to test your options.</li>
|
||||
<li><strong>Run:</strong> Click "Start Full Upscale" to add the job to the GPU queue.</li>
|
||||
</ol>
|
||||
|
||||
<h3>⚙️ AI Enhancement Features</h3>
|
||||
<ul>
|
||||
<li><strong>AI Face Restoration (GFPGAN):</strong> Restores human faces in blurry or low-res footage. Highly recommended for family home videos or film restoration. Requires python <code>gfpgan</code> package.</li>
|
||||
<li><strong>AI Frame Interpolation (RIFE):</strong> Smooths motion by inserting high-quality AI generated frames (e.g. converting 24/30 FPS to 60 FPS). Requires <code>rife-ncnn-vulkan</code> binary.</li>
|
||||
<li><strong>AI Audio Denoising (RNNoise):</strong> Eliminates steady background noise, wind, and camera hiss directly from audio tracks using a deep learning RNN model.</li>
|
||||
</ul>
|
||||
|
||||
<h3>📊 Advanced Settings</h3>
|
||||
<ul>
|
||||
<li><strong>CRF (Constant Rate Factor):</strong> Controls output quality/compression. Lower values (like 18) are near-lossless, higher values compress more.</li>
|
||||
<li><strong>Tile Size:</strong> Decreasing this (e.g., to 128 or 64) helps run upscaling on low-end GPUs with limited VRAM.</li>
|
||||
<li><strong>Video Trimming:</strong> Trim start and end times to only upscale the parts you need.</li>
|
||||
</ul>
|
||||
|
||||
<h3>🔄 Job Resumption</h3>
|
||||
<p>If the application stops or the server crashes while upscaling, the job will be marked as <strong>interrupted</strong> in the queue. Simply click <strong>Resume</strong> to pick up exactly where you left off. Already upscaled frames will be skipped, saving time and energy.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Javascript Orchestration -->
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -653,9 +653,13 @@ input:checked + .slider:before {
|
||||
.status-badge.queued { background: rgba(255, 184, 0, 0.1); border: 1px solid var(--warning); color: var(--warning); }
|
||||
.status-badge.extracting { background: rgba(189, 0, 255, 0.15); border: 1px solid var(--secondary); color: #e499ff; }
|
||||
.status-badge.upscaling { background: rgba(0, 240, 255, 0.1); border: 1px solid var(--primary); color: var(--primary); }
|
||||
.status-badge.restoring_faces { background: rgba(255, 0, 255, 0.1); border: 1px solid var(--secondary); color: var(--secondary); }
|
||||
.status-badge.interpolating { background: rgba(0, 255, 184, 0.1); border: 1px solid var(--primary); color: var(--primary); }
|
||||
.status-badge.assembling { background: rgba(0, 240, 255, 0.1); border: 1px solid var(--primary); color: var(--primary); }
|
||||
.status-badge.completed { background: rgba(0, 255, 135, 0.1); border: 1px solid var(--success); color: var(--success); }
|
||||
.status-badge.failed { background: rgba(255, 0, 85, 0.1); border: 1px solid var(--danger); color: var(--danger); }
|
||||
.status-badge.interrupted { background: rgba(255, 255, 255, 0.08); border: 1px solid var(--text-muted); color: var(--text-muted); }
|
||||
|
||||
|
||||
.bar-container {
|
||||
width: 100%;
|
||||
|
||||
Reference in New Issue
Block a user