Docker Build and Push / check-secrets (push) Successful in 30s
Docker Build and Push / build-and-push (cuda, 12.6) (push) Has been skipped
Docker Build and Push / build-and-push (cuda, 12.8) (push) Has been skipped
Docker Build and Push / build-and-push (directml, latest) (push) Has been skipped
Docker Build and Push / build-and-push (cpu, latest) (push) Has been skipped
Docker Build and Push / build-and-push (cuda, 11.8) (push) Has been skipped
Build Windows CPU / build (push) Has been cancelled
Build Windows CUDA 11.8 / build (push) Has been cancelled
Build Windows CUDA 12.6 / build (push) Has been cancelled
Build Windows CUDA 12.8 / build (push) Has been cancelled
Build Windows DirectML / build (push) Has been cancelled
- Add gpu_id parameter to SubtitleDetect for GPU acceleration - Implement multi-threaded frame scanning with thread-safe CUDA context - Add smart_detect module and web application components - Update .gitignore to exclude web cache Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
769 lines
29 KiB
Python
769 lines
29 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Web interface for video-subtitle-remover.
|
||
|
||
Usage (from project root):
|
||
python web_app.py
|
||
# then open http://localhost:8000
|
||
|
||
Or from any directory:
|
||
VSR_PATH=/path/to/video-subtitle-remover python web_app.py
|
||
"""
|
||
|
||
import asyncio
|
||
import cv2
|
||
import hashlib
|
||
import io
|
||
import json
|
||
import os
|
||
import queue as q_module
|
||
import sqlite3
|
||
import sys
|
||
import threading
|
||
import time
|
||
import traceback
|
||
import uuid
|
||
from pathlib import Path
|
||
from typing import Optional
|
||
|
||
import httpx
|
||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Request
|
||
from fastapi.responses import FileResponse, HTMLResponse
|
||
from pydantic import BaseModel
|
||
|
||
# ── project path ────────────────────────────────────────────────────────────
|
||
# Default: the directory containing this file (run from project root)
|
||
VSR_PATH = os.environ.get("VSR_PATH", str(Path(__file__).parent))
|
||
if VSR_PATH not in sys.path:
|
||
sys.path.insert(0, VSR_PATH)
|
||
|
||
try:
|
||
from backend.config import config, tr
|
||
from backend.tools.constant import InpaintMode
|
||
from backend.main import SubtitleRemover
|
||
import configparser as _cp
|
||
# Force Chinese interface for web app
|
||
_ch_ini = os.path.join(VSR_PATH, 'backend', 'interface', 'ch.ini')
|
||
tr.read(_ch_ini, encoding='utf-8')
|
||
VSR_AVAILABLE = True
|
||
_import_err_msg = ""
|
||
except ImportError as _import_err:
|
||
VSR_AVAILABLE = False
|
||
_import_err_msg = str(_import_err)
|
||
|
||
# ── storage dirs ────────────────────────────────────────────────────────────
|
||
BASE_DIR = Path(__file__).parent
|
||
CACHE_DIR = BASE_DIR / "web_cache" / "downloads"
|
||
OUTPUT_DIR = BASE_DIR / "web_cache" / "outputs"
|
||
DB_PATH = BASE_DIR / "web_cache" / "tasks.db"
|
||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||
|
||
# ── database ─────────────────────────────────────────────────────────────
|
||
def init_db():
|
||
conn = sqlite3.connect(str(DB_PATH))
|
||
conn.execute("""
|
||
CREATE TABLE IF NOT EXISTS tasks (
|
||
id TEXT PRIMARY KEY,
|
||
url TEXT,
|
||
is_cache_file BOOLEAN,
|
||
cache_path TEXT,
|
||
inpaint_mode TEXT,
|
||
subtitle_areas TEXT,
|
||
preview_mode BOOLEAN DEFAULT 0,
|
||
detect_mode TEXT DEFAULT 'text',
|
||
status TEXT,
|
||
progress INTEGER,
|
||
download_progress INTEGER,
|
||
cached BOOLEAN,
|
||
output_path TEXT,
|
||
error TEXT,
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||
)
|
||
""")
|
||
# Try to add new columns if they don't exist
|
||
try:
|
||
conn.execute("ALTER TABLE tasks ADD COLUMN preview_mode BOOLEAN DEFAULT 0")
|
||
except:
|
||
pass
|
||
try:
|
||
conn.execute("ALTER TABLE tasks ADD COLUMN detect_mode TEXT DEFAULT 'text'")
|
||
except:
|
||
pass
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
init_db()
|
||
|
||
INPAINT_MODE_MAP = {
|
||
"sttn-auto": lambda: InpaintMode.STTN_AUTO,
|
||
"sttn-det": lambda: InpaintMode.STTN_DET,
|
||
"lama": lambda: InpaintMode.LAMA,
|
||
"propainter": lambda: InpaintMode.PROPAINTER,
|
||
"opencv": lambda: InpaintMode.OPENCV,
|
||
}
|
||
|
||
# ── state ────────────────────────────────────────────────────────────────────
|
||
tasks: dict[str, dict] = {}
|
||
task_lock = threading.Lock()
|
||
task_queue: q_module.Queue = q_module.Queue()
|
||
ws_connections: list[WebSocket] = []
|
||
_loop: Optional[asyncio.AbstractEventLoop] = None
|
||
|
||
# ── helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
def url_to_cache_path(url: str) -> Path:
|
||
h = hashlib.md5(url.encode()).hexdigest()[:12]
|
||
raw_name = url.rstrip("/").split("/")[-1].split("?")[0][:80]
|
||
name = raw_name if raw_name and "." in raw_name else f"{h}.mp4"
|
||
return CACHE_DIR / f"{h}_{name}"
|
||
|
||
|
||
def _task_snapshot(task: dict) -> dict:
|
||
return {k: v for k, v in task.items() if k != "logs"}
|
||
|
||
|
||
def _push(coro):
|
||
if _loop and not _loop.is_closed():
|
||
asyncio.run_coroutine_threadsafe(coro, _loop)
|
||
|
||
|
||
async def _broadcast(msg: dict):
|
||
dead = []
|
||
text = json.dumps(msg)
|
||
for ws in ws_connections:
|
||
try:
|
||
await ws.send_text(text)
|
||
except Exception:
|
||
dead.append(ws)
|
||
for ws in dead:
|
||
if ws in ws_connections:
|
||
ws_connections.remove(ws)
|
||
|
||
|
||
def update_task(task_id: str, **kwargs):
|
||
with task_lock:
|
||
if task_id not in tasks:
|
||
return
|
||
tasks[task_id].update(kwargs)
|
||
snap = _task_snapshot(tasks[task_id])
|
||
_save_task_to_db(tasks[task_id])
|
||
_push(_broadcast({"type": "task_update", "task": snap}))
|
||
|
||
def _save_task_to_db(task: dict):
|
||
"""Save task to database"""
|
||
try:
|
||
conn = sqlite3.connect(str(DB_PATH))
|
||
conn.execute("""
|
||
INSERT OR REPLACE INTO tasks
|
||
(id, url, is_cache_file, cache_path, inpaint_mode, subtitle_areas,
|
||
preview_mode, detect_mode, status, progress, download_progress, cached, output_path, error)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
""", (
|
||
task["id"],
|
||
task["url"],
|
||
task.get("is_cache_file", False),
|
||
task.get("cache_path", ""),
|
||
task["inpaint_mode"],
|
||
json.dumps(task["subtitle_areas"]),
|
||
task.get("preview_mode", False),
|
||
task.get("detect_mode", "text"),
|
||
task["status"],
|
||
task["progress"],
|
||
task["download_progress"],
|
||
task["cached"],
|
||
task["output_path"],
|
||
task["error"],
|
||
))
|
||
conn.commit()
|
||
conn.close()
|
||
except Exception as e:
|
||
print(f"Failed to save task to DB: {e}")
|
||
|
||
def _load_tasks_from_db():
|
||
"""Load tasks from database on startup"""
|
||
try:
|
||
conn = sqlite3.connect(str(DB_PATH))
|
||
conn.row_factory = sqlite3.Row # Use column names
|
||
cursor = conn.execute(
|
||
"SELECT * FROM tasks ORDER BY created_at DESC LIMIT 100"
|
||
)
|
||
for row in cursor.fetchall():
|
||
task = {
|
||
"id": row["id"],
|
||
"url": row["url"],
|
||
"is_cache_file": row["is_cache_file"],
|
||
"cache_path": row["cache_path"],
|
||
"inpaint_mode": row["inpaint_mode"],
|
||
"subtitle_areas": json.loads(row["subtitle_areas"]),
|
||
"preview_mode": row["preview_mode"] if "preview_mode" in row.keys() else False,
|
||
"detect_mode": row["detect_mode"] if "detect_mode" in row.keys() else "text",
|
||
"status": row["status"],
|
||
"progress": row["progress"],
|
||
"download_progress": row["download_progress"],
|
||
"cached": row["cached"],
|
||
"output_path": row["output_path"],
|
||
"error": row["error"],
|
||
"phase": "pending",
|
||
"detect_pct": 0,
|
||
"process_pct": 0,
|
||
"merge_pct": 0,
|
||
"logs": [],
|
||
}
|
||
with task_lock:
|
||
tasks[task["id"]] = task
|
||
# Re-queue pending/downloading/processing tasks (processing might have crashed)
|
||
if task["status"] in ("pending", "downloading", "processing"):
|
||
task_queue.put(task["id"])
|
||
conn.close()
|
||
except Exception as e:
|
||
print(f"Failed to load tasks from DB: {e}")
|
||
|
||
|
||
def push_log(task_id: str, message: str):
|
||
with task_lock:
|
||
if task_id in tasks:
|
||
tasks[task_id]["logs"].append(message)
|
||
_push(_broadcast({"type": "log", "task_id": task_id, "message": message}))
|
||
|
||
|
||
# ── worker ───────────────────────────────────────────────────────────────────
|
||
|
||
def _download(task_id: str, url: str, dest: Path) -> bool:
|
||
update_task(task_id, status="downloading", download_progress=0)
|
||
push_log(task_id, f"下载中: {url}")
|
||
last_pct = -1
|
||
try:
|
||
with httpx.stream("GET", url, follow_redirects=True, timeout=7200) as r:
|
||
r.raise_for_status()
|
||
total = int(r.headers.get("content-length", 0))
|
||
done = 0
|
||
with open(dest, "wb") as f:
|
||
for chunk in r.iter_bytes(chunk_size=1024 * 512):
|
||
f.write(chunk)
|
||
done += len(chunk)
|
||
if total:
|
||
pct = int(done / total * 100)
|
||
if pct >= last_pct + 2:
|
||
last_pct = pct
|
||
mb_done = done // 1048576
|
||
mb_total = total // 1048576
|
||
update_task(task_id, download_progress=pct)
|
||
push_log(task_id, f"下载 {pct}%({mb_done} MB / {mb_total} MB)")
|
||
push_log(task_id, f"下载完成 → {dest.name}")
|
||
return True
|
||
except Exception as e:
|
||
push_log(task_id, f"下载失败: {e}")
|
||
dest.unlink(missing_ok=True)
|
||
return False
|
||
|
||
|
||
def _process(task_id: str, video_path: str, inpaint_mode_str: str, subtitle_areas: list, preview_mode: bool = False, detect_mode: str = "text"):
|
||
if not VSR_AVAILABLE:
|
||
push_log(task_id, f"错误:无法加载后端模块。{_import_err_msg}")
|
||
update_task(task_id, status="error", error="VSR backend not available")
|
||
return
|
||
|
||
modeLabels = {'sttn-auto':'STTN 自动','sttn-det':'STTN 检测','lama':'LaMa','propainter':'ProPainter','opencv':'OpenCV'}
|
||
push_log(task_id, f"启动 — 算法:{modeLabels.get(inpaint_mode_str, inpaint_mode_str)}")
|
||
update_task(task_id, status="processing", progress=0, started_at=time.time())
|
||
|
||
class _WebRemover(SubtitleRemover):
|
||
_static_boxes = [] # SAM2检测到的静态区域,格式 (xmin, xmax, ymin, ymax) 像素
|
||
|
||
def append_output(self_, *args):
|
||
msg = " ".join(str(a) for a in args)
|
||
print(msg, flush=True)
|
||
push_log(task_id, msg)
|
||
|
||
def update_preview_with_comp(self_, frame_ori, frame_comp):
|
||
pass # no-op: no GUI
|
||
|
||
def run(self_):
|
||
if self_._static_boxes:
|
||
from backend.tools.subtitle_detect import SubtitleDetect as _SD
|
||
_orig = _SD.find_subtitle_frame_no
|
||
_boxes = list(self_._static_boxes)
|
||
def _patched(sd_self, sub_remover=None):
|
||
result = _orig(sd_self, sub_remover=sub_remover)
|
||
ocr_frames = len(result)
|
||
if ocr_frames > 0:
|
||
push_log(task_id, f"OCR 在 {ocr_frames} 帧中检测到文字")
|
||
cap = cv2.VideoCapture(sd_self.video_path)
|
||
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||
cap.release()
|
||
for frame_no in range(1, total + 1):
|
||
if frame_no not in result:
|
||
result[frame_no] = []
|
||
result[frame_no] = list(result[frame_no]) + _boxes
|
||
push_log(task_id, f"SAM2 静态区域已注入全部 {total} 帧")
|
||
return result
|
||
_SD.find_subtitle_frame_no = _patched
|
||
try:
|
||
super().run()
|
||
finally:
|
||
_SD.find_subtitle_frame_no = _orig
|
||
else:
|
||
super().run()
|
||
|
||
def merge_audio_to_video(self_):
|
||
push_log(task_id, "正在合并音频...")
|
||
update_task(task_id, detect_pct=100, process_pct=100, merge_pct=50, phase="merging")
|
||
super().merge_audio_to_video()
|
||
update_task(task_id, detect_pct=100, process_pct=100, merge_pct=100, phase="merging")
|
||
push_log(task_id, "音频合并完成")
|
||
|
||
# Preview mode: cut first 10 seconds using FFmpeg BEFORE processing
|
||
preview_clip_path = None
|
||
if preview_mode:
|
||
try:
|
||
from backend.tools.ffmpeg_cli import FFmpegCLI
|
||
preview_clip_path = str(Path(video_path).parent / f"_preview_{task_id}.mp4")
|
||
cut_cmd = [
|
||
FFmpegCLI.instance().ffmpeg_path,
|
||
"-y", "-i", video_path,
|
||
"-t", "10",
|
||
"-c", "copy",
|
||
"-loglevel", "error",
|
||
preview_clip_path
|
||
]
|
||
push_log(task_id, "预览模式:截取前10秒...")
|
||
import subprocess
|
||
subprocess.run(cut_cmd, check=True, timeout=60)
|
||
push_log(task_id, "截取完成,开始处理预览片段")
|
||
video_path = preview_clip_path # process the short clip
|
||
except Exception as e:
|
||
push_log(task_id, f"截取失败,改为处理完整视频: {e}")
|
||
preview_clip_path = None
|
||
|
||
try:
|
||
sr = _WebRemover(video_path, gui_mode=False)
|
||
|
||
if preview_mode:
|
||
push_log(task_id, f"预览片段:{sr.frame_count} 帧 / {sr.fps:.1f}fps")
|
||
|
||
mode_factory = INPAINT_MODE_MAP.get(inpaint_mode_str, INPAINT_MODE_MAP["sttn-auto"])
|
||
config.set(config.inpaintMode, mode_factory())
|
||
|
||
# Auto-detect regions if not provided
|
||
if not subtitle_areas:
|
||
combined_areas = []
|
||
import concurrent.futures
|
||
|
||
def _run_ocr():
|
||
from backend.tools.subtitle_detect import SubtitleDetect
|
||
detector = SubtitleDetect(video_path, [])
|
||
cap = cv2.VideoCapture(video_path)
|
||
ret, frame = cap.read()
|
||
cap.release()
|
||
if ret:
|
||
return detector.detect_subtitle(frame)
|
||
return []
|
||
|
||
def _run_sam2():
|
||
print(f"[DEBUG] _run_sam2 called, detect_mode={detect_mode}", flush=True)
|
||
if detect_mode != "smart":
|
||
print(f"[DEBUG] detect_mode != smart, skipping SAM2", flush=True)
|
||
return []
|
||
from backend.tools.smart_detect import smart_detect_watermark
|
||
return smart_detect_watermark(video_path, sample_frames=30)
|
||
|
||
push_log(task_id, "并发检测中(OCR + SAM2)..." if detect_mode == "smart" else "文字检测(OCR)中...")
|
||
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as ex:
|
||
f_ocr = ex.submit(_run_ocr)
|
||
f_sam2 = ex.submit(_run_sam2)
|
||
ocr_result = f_ocr.result()
|
||
sam2_result = f_sam2.result()
|
||
|
||
if ocr_result:
|
||
push_log(task_id, f"OCR 检测到 {len(ocr_result)} 个文字区域")
|
||
combined_areas += [
|
||
(
|
||
int(y1 / sr.frame_height * 100),
|
||
int(y2 / sr.frame_height * 100),
|
||
int(x1 / sr.frame_width * 100),
|
||
int(x2 / sr.frame_width * 100),
|
||
)
|
||
for x1, x2, y1, y2 in ocr_result
|
||
]
|
||
else:
|
||
push_log(task_id, "OCR 未检测到文字区域")
|
||
|
||
if sam2_result:
|
||
push_log(task_id, f"SAM2 检测到 {len(sam2_result)} 个区域,将强制每帧都处理")
|
||
for i, (x1, y1, x2, y2) in enumerate(sam2_result):
|
||
push_log(task_id, f" 区域{i+1}: 左上({x1},{y1}) 右下({x2},{y2}) 大小{x2-x1}x{y2-y1}px")
|
||
sr._static_boxes = [(x1, x2, y1, y2) for x1, y1, x2, y2 in sam2_result]
|
||
elif detect_mode == "smart":
|
||
push_log(task_id, "SAM2 未检测到静态水印区域")
|
||
|
||
# 单帧初探仅用于日志,不作为全扫描的空间约束
|
||
# 若把 21 个 15px 小框设为 sub_areas,全扫描几乎找不到任何匹配帧 → 报错
|
||
# SubtitleRemover.find_subtitle_frame_no 会自行全帧扫描
|
||
if not combined_areas and not sr._static_boxes:
|
||
push_log(task_id, "初探未检测到区域,全扫描将覆盖全屏")
|
||
elif combined_areas:
|
||
push_log(task_id, f"初探检测到 {len(combined_areas)} 个文字区域(全扫描不受此约束)")
|
||
if sr._static_boxes:
|
||
push_log(task_id, f"SAM2 静态区域已注入全部帧,无需区域约束")
|
||
# subtitle_areas 保持为空,sub_areas 不设置 → 全帧无约束扫描
|
||
|
||
if subtitle_areas:
|
||
pixel_areas = [
|
||
(
|
||
int(a[0] / 100 * sr.frame_height),
|
||
int(a[1] / 100 * sr.frame_height),
|
||
int(a[2] / 100 * sr.frame_width),
|
||
int(a[3] / 100 * sr.frame_width),
|
||
)
|
||
for a in subtitle_areas
|
||
]
|
||
if detect_mode == "smart":
|
||
# 水印模式:用户手动指定区域 + 智能检测 → 强制每帧处理该区域
|
||
# 适合位置变化的动态水印(OCR 无法识别装饰性字体)
|
||
force_boxes = [
|
||
(int(a[2]/100*sr.frame_width), int(a[3]/100*sr.frame_width),
|
||
int(a[0]/100*sr.frame_height), int(a[1]/100*sr.frame_height))
|
||
for a in subtitle_areas
|
||
]
|
||
if sr._static_boxes:
|
||
sr._static_boxes = sr._static_boxes + force_boxes
|
||
else:
|
||
sr._static_boxes = force_boxes
|
||
push_log(task_id, f"手动指定 {len(force_boxes)} 个水印区域 → 强制每帧去除")
|
||
print(f"[DEBUG] force_boxes={force_boxes}", flush=True)
|
||
else:
|
||
# 字幕模式:限制 OCR 搜索范围
|
||
sr.sub_areas = pixel_areas
|
||
|
||
out_stem = Path(video_path).stem
|
||
sr.video_out_path = str(OUTPUT_DIR / f"{out_stem}_{task_id[:8]}_no_sub.mp4")
|
||
|
||
# 检测阶段:progress_total = (100*frame/total)//2 → 0-50
|
||
# 处理阶段:update_progress → 0-100(从0重新开始)
|
||
# 判断切换:raw>50 OR 前值≥40后值<15(跌落 = 阶段重置)
|
||
_state = {"phase": "detecting", "processing": False, "prev_raw": 0}
|
||
|
||
def _on_progress(pct, finished):
|
||
try:
|
||
raw = int(pct)
|
||
prev = _state["prev_raw"]
|
||
_state["prev_raw"] = raw
|
||
# 跌落检测:从检测末尾(≥40)掉回处理起始(<15)
|
||
if not _state["processing"] and prev >= 40 and raw < 15:
|
||
_state["processing"] = True
|
||
if _state["processing"] or raw > 50:
|
||
_state["processing"] = True
|
||
_state["phase"] = "processing"
|
||
process_pct = min(raw, 98)
|
||
update_task(task_id, detect_pct=100, process_pct=process_pct, merge_pct=0, phase="processing")
|
||
else:
|
||
detect_pct = min(raw * 2, 99) # 0-50 → 0-99
|
||
update_task(task_id, detect_pct=detect_pct, process_pct=0, merge_pct=0, phase="detecting")
|
||
print(f"[PROGRESS] {task_id}: phase={_state['phase']} raw={raw}%", flush=True)
|
||
except Exception as e:
|
||
print(f"[ERROR] Progress callback failed: {e}", flush=True)
|
||
|
||
push_log(task_id, f"检测模式:{'智能(SAM2)' if detect_mode == 'smart' else '文字(OCR)'}")
|
||
|
||
print(f"[DEBUG] Adding progress listener for {task_id}", flush=True)
|
||
sr.add_progress_listener(_on_progress)
|
||
print(f"[DEBUG] Progress listeners count: {len(sr.progress_listeners)}", flush=True)
|
||
print(f"[DEBUG] Starting sr.run()", flush=True)
|
||
sr.run()
|
||
print(f"[DEBUG] sr.run() completed", flush=True)
|
||
|
||
# Preview mode: skip audio merge, directly use temp file
|
||
if preview_mode and hasattr(sr, 'video_temp_file') and sr.video_temp_file:
|
||
push_log(task_id, "预览模式:跳过音频合并,直接输出")
|
||
import shutil
|
||
try:
|
||
shutil.copy2(sr.video_temp_file.name, sr.video_out_path)
|
||
push_log(task_id, f"预览完成 → {sr.video_out_path}")
|
||
except Exception as e:
|
||
push_log(task_id, f"复制文件失败: {e}")
|
||
|
||
update_task(task_id, status="done", progress=100, output_path=sr.video_out_path, completed_at=time.time())
|
||
push_log(task_id, f"完成 → {sr.video_out_path}")
|
||
|
||
except Exception:
|
||
err = traceback.format_exc()
|
||
push_log(task_id, f"错误:\n{err}")
|
||
update_task(task_id, status="error", error=err.splitlines()[-1])
|
||
finally:
|
||
# Clean up preview clip
|
||
if preview_clip_path and Path(preview_clip_path).exists():
|
||
try:
|
||
Path(preview_clip_path).unlink()
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _run_task(task_id: str):
|
||
with task_lock:
|
||
task = tasks.get(task_id)
|
||
if not task:
|
||
return
|
||
|
||
# Handle cache file directly
|
||
if task.get("is_cache_file"):
|
||
cache_path = Path(task["cache_path"])
|
||
if cache_path.exists():
|
||
push_log(task_id, f"使用本地缓存: {cache_path.name}")
|
||
update_task(task_id, cached=True)
|
||
else:
|
||
update_task(task_id, status="error", error="缓存文件不存在")
|
||
return
|
||
else:
|
||
# Handle URL download
|
||
url = task["url"]
|
||
cache_path = url_to_cache_path(url)
|
||
|
||
if cache_path.exists():
|
||
push_log(task_id, f"缓存命中: {cache_path.name}")
|
||
update_task(task_id, cached=True)
|
||
else:
|
||
ok = _download(task_id, url, cache_path)
|
||
if not ok:
|
||
update_task(task_id, status="error", error="下载失败")
|
||
return
|
||
|
||
_process(task_id, str(cache_path), task["inpaint_mode"], task["subtitle_areas"],
|
||
task.get("preview_mode", False), task.get("detect_mode", "text"))
|
||
|
||
|
||
def _worker():
|
||
while True:
|
||
task_id = task_queue.get()
|
||
if task_id is None:
|
||
break
|
||
try:
|
||
_run_task(task_id)
|
||
except Exception:
|
||
traceback.print_exc()
|
||
finally:
|
||
task_queue.task_done()
|
||
|
||
|
||
# ── FastAPI ───────────────────────────────────────────────────────────────────
|
||
|
||
app = FastAPI(title="VSR Web")
|
||
|
||
_worker_thread = threading.Thread(target=_worker, daemon=True, name="vsr-worker")
|
||
_worker_thread.start()
|
||
|
||
|
||
@app.on_event("startup")
|
||
async def _startup():
|
||
global _loop
|
||
_loop = asyncio.get_event_loop()
|
||
_load_tasks_from_db()
|
||
|
||
|
||
@app.get("/")
|
||
async def index():
|
||
html = (BASE_DIR / "web" / "index.html").read_text(encoding="utf-8")
|
||
return HTMLResponse(html)
|
||
|
||
|
||
class TaskCreate(BaseModel):
|
||
url: str
|
||
inpaint_mode: str = "sttn-auto"
|
||
subtitle_areas: list = []
|
||
preview_mode: bool = False
|
||
detect_mode: str = "text"
|
||
|
||
|
||
@app.post("/api/tasks")
|
||
async def create_task(body: TaskCreate):
|
||
task_id = uuid.uuid4().hex[:8]
|
||
|
||
# Check if it's a cache file name (from the recent list)
|
||
is_cache_file = body.url.startswith("cache_")
|
||
if is_cache_file:
|
||
# Remove "cache_" prefix
|
||
filename = body.url[6:] # strip "cache_"
|
||
cache_path = CACHE_DIR / filename
|
||
cached = cache_path.exists()
|
||
else:
|
||
cache_path = url_to_cache_path(body.url)
|
||
cached = cache_path.exists()
|
||
|
||
with task_lock:
|
||
now = time.time()
|
||
tasks[task_id] = {
|
||
"id": task_id,
|
||
"url": body.url,
|
||
"is_cache_file": is_cache_file,
|
||
"cache_path": str(cache_path),
|
||
"inpaint_mode": body.inpaint_mode,
|
||
"subtitle_areas": body.subtitle_areas,
|
||
"preview_mode": body.preview_mode,
|
||
"detect_mode": body.detect_mode,
|
||
"status": "pending",
|
||
"progress": 0,
|
||
"phase": "pending",
|
||
"detect_pct": 0,
|
||
"process_pct": 0,
|
||
"merge_pct": 0,
|
||
"download_progress": 0,
|
||
"cached": cached,
|
||
"output_path": None,
|
||
"error": None,
|
||
"logs": [],
|
||
"created_at": now,
|
||
"started_at": None,
|
||
"completed_at": None,
|
||
}
|
||
task_queue.put(task_id)
|
||
return {"task_id": task_id}
|
||
|
||
|
||
@app.get("/api/tasks")
|
||
async def list_tasks():
|
||
with task_lock:
|
||
return [_task_snapshot(t) for t in tasks.values()]
|
||
|
||
|
||
@app.get("/api/tasks/{task_id}")
|
||
async def get_task(task_id: str):
|
||
with task_lock:
|
||
task = tasks.get(task_id)
|
||
if not task:
|
||
raise HTTPException(404, "Not found")
|
||
return task
|
||
|
||
|
||
@app.delete("/api/tasks/{task_id}")
|
||
async def delete_task(task_id: str):
|
||
with task_lock:
|
||
tasks.pop(task_id, None)
|
||
# Also delete from database
|
||
try:
|
||
conn = sqlite3.connect(str(DB_PATH))
|
||
conn.execute("DELETE FROM tasks WHERE id = ?", (task_id,))
|
||
conn.commit()
|
||
conn.close()
|
||
except Exception as e:
|
||
print(f"Failed to delete task from DB: {e}")
|
||
return {"ok": True}
|
||
|
||
|
||
@app.get("/api/tasks/{task_id}/download")
|
||
async def download_result(task_id: str):
|
||
with task_lock:
|
||
task = tasks.get(task_id)
|
||
if not task or task["status"] != "done" or not task.get("output_path"):
|
||
raise HTTPException(404, "Result not ready")
|
||
out = Path(task["output_path"])
|
||
if not out.exists():
|
||
raise HTTPException(404, "Output file missing")
|
||
return FileResponse(str(out), filename=out.name, media_type="video/mp4")
|
||
|
||
|
||
@app.get("/api/tasks/{task_id}/stream")
|
||
async def stream_result(task_id: str, request: Request):
|
||
with task_lock:
|
||
task = tasks.get(task_id)
|
||
if not task or task["status"] != "done" or not task.get("output_path"):
|
||
raise HTTPException(404, "Result not ready")
|
||
out = Path(task["output_path"])
|
||
if not out.exists():
|
||
raise HTTPException(404, "Output file missing")
|
||
|
||
file_size = out.stat().st_size
|
||
range_header = request.headers.get("range")
|
||
|
||
if range_header:
|
||
# Parse Range header: "bytes=start-end"
|
||
try:
|
||
range_value = range_header.replace("bytes=", "")
|
||
start, end = range_value.split("-")
|
||
start = int(start) if start else 0
|
||
end = int(end) if end else file_size - 1
|
||
|
||
if start >= file_size or end >= file_size or start > end:
|
||
raise HTTPException(416, "Range Not Satisfiable")
|
||
|
||
headers = {
|
||
"Content-Range": f"bytes {start}-{end}/{file_size}",
|
||
"Accept-Ranges": "bytes",
|
||
"Content-Length": str(end - start + 1),
|
||
}
|
||
return FileResponse(
|
||
str(out),
|
||
media_type="video/mp4",
|
||
headers=headers,
|
||
status_code=206,
|
||
)
|
||
except (ValueError, IndexError):
|
||
pass
|
||
|
||
return FileResponse(
|
||
str(out),
|
||
filename=out.name,
|
||
media_type="video/mp4",
|
||
headers={"Accept-Ranges": "bytes"},
|
||
)
|
||
|
||
|
||
@app.get("/api/cache-check")
|
||
async def cache_check(url: str):
|
||
return {"cached": url_to_cache_path(url).exists()}
|
||
|
||
|
||
@app.get("/api/cache-list")
|
||
async def cache_list():
|
||
files = []
|
||
if CACHE_DIR.exists():
|
||
for f in sorted(CACHE_DIR.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True):
|
||
if f.is_file():
|
||
files.append({
|
||
"name": f.name,
|
||
"size": f.stat().st_size,
|
||
"mtime": f.stat().st_mtime,
|
||
})
|
||
return {"files": files}
|
||
|
||
|
||
@app.get("/api/status")
|
||
async def server_status():
|
||
return {
|
||
"vsr_available": VSR_AVAILABLE,
|
||
"vsr_path": VSR_PATH,
|
||
"queue_size": task_queue.qsize(),
|
||
}
|
||
|
||
|
||
@app.websocket("/ws")
|
||
async def ws_endpoint(ws: WebSocket):
|
||
await ws.accept()
|
||
ws_connections.append(ws)
|
||
try:
|
||
with task_lock:
|
||
init_tasks = [_task_snapshot(t) for t in tasks.values()]
|
||
await ws.send_text(json.dumps({"type": "init", "tasks": init_tasks}))
|
||
while True:
|
||
await ws.receive_text()
|
||
except WebSocketDisconnect:
|
||
pass
|
||
finally:
|
||
if ws in ws_connections:
|
||
ws_connections.remove(ws)
|
||
|
||
|
||
# ── entry point ───────────────────────────────────────────────────────────────
|
||
|
||
if __name__ == "__main__":
|
||
import uvicorn
|
||
host = os.environ.get("HOST", "0.0.0.0")
|
||
port = int(os.environ.get("PORT", "8001"))
|
||
print(f"VSR_PATH = {VSR_PATH}")
|
||
print(f"VSR backend available: {VSR_AVAILABLE}")
|
||
if not VSR_AVAILABLE:
|
||
print(f" Import error: {_import_err_msg}")
|
||
print(f" Set VSR_PATH to the project root and ensure dependencies are installed.")
|
||
print(f"Starting on http://{host}:{port}")
|
||
uvicorn.run(app, host=host, port=port)
|