#!/usr/bin/env python3
"""
Web interface for video-subtitle-remover.
Usage:
cp .env.example .env # 编辑 .env 设置 ADMIN_PASSWORD
python web_app.py
Platform worker: 在「系统设置」页面配置,无需环境变量。
"""
from __future__ import annotations
import asyncio
import collections
import cv2
import hashlib
import hmac
import itertools
import json
import logging
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 Any, Optional
# 加载 .env(同目录)
try:
from dotenv import load_dotenv
load_dotenv(Path(__file__).parent / ".env")
except ImportError:
pass
import httpx
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Request
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse, Response
from pydantic import BaseModel
# ── project path ─────────────────────────────────────────────────────────────
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
_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)
# ── paths ─────────────────────────────────────────────────────────────────────
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)
# ── auth ──────────────────────────────────────────────────────────────────────
_COOKIE = "vsr_session"
_COOKIE_MAX_AGE = 86400 * 30 # 30 天
_LOGIN_HTML = """
登录 · 视频字幕去除器
视频字幕去除器
"""
def _admin_password() -> str:
return (os.environ.get("ADMIN_PASSWORD") or "").strip()
def _make_token() -> str:
pw = _admin_password()
ts = str(int(time.time()))
sig = hmac.new(pw.encode(), ts.encode(), hashlib.sha256).hexdigest()
return f"{ts}.{sig}"
def _verify_token(token: str) -> bool:
pw = _admin_password()
if not pw:
return True
try:
ts, sig = token.split(".", 1)
expected = hmac.new(pw.encode(), ts.encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(sig, expected) and int(ts) > time.time() - _COOKIE_MAX_AGE
except Exception:
return False
def _auth_ok(request: Request) -> bool:
if not _admin_password():
return True
return _verify_token(request.cookies.get(_COOKIE, ""))
# ── database ──────────────────────────────────────────────────────────────────
def _db() -> sqlite3.Connection:
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
return conn
def init_db():
conn = _db()
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,
platform_task_id INTEGER,
created_at REAL,
started_at REAL,
completed_at REAL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS task_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id TEXT NOT NULL,
message TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_task_logs_task_id ON task_logs(task_id)")
for col, default in [
("preview_mode", "BOOLEAN DEFAULT 0"),
("detect_mode", "TEXT DEFAULT 'text'"),
("platform_task_id", "INTEGER"),
("started_at", "REAL"),
("completed_at", "REAL"),
("s3_failed", "BOOLEAN DEFAULT 0"),
]:
try:
conn.execute(f"ALTER TABLE tasks ADD COLUMN {col} {default}")
except Exception:
pass
conn.commit()
conn.close()
init_db()
WORKER_KEYS = ("platform_base_url", "platform_app_id", "platform_app_secret",
"worker_concurrency", "worker_poll_interval")
def _settings_get_all() -> dict[str, str]:
conn = _db()
rows = conn.execute("SELECT key, value FROM settings").fetchall()
conn.close()
return {r["key"]: r["value"] for r in rows}
def _settings_set_many(data: dict[str, str]) -> None:
conn = _db()
for k, v in data.items():
conn.execute("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", (k, v))
conn.commit()
conn.close()
# ── inpaint mode map ──────────────────────────────────────────────────────────
# STTN 时序 inpaint 无法去除持久水印(参考帧里也有水印),已移除。
# 未知/不支持的模式统一回退到 LaMa(空间 inpaint,对持久水印有效)。
INPAINT_MODE_MAP = {
"lama": lambda: InpaintMode.LAMA,
"propainter": lambda: InpaintMode.PROPAINTER,
"opencv": lambda: InpaintMode.OPENCV,
}
DEFAULT_INPAINT = "lama"
# ── task 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
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):
try:
conn = _db()
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, platform_task_id,
created_at, started_at, completed_at, s3_failed)
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"], task.get("platform_task_id"),
task.get("created_at"), task.get("started_at"), task.get("completed_at"),
task.get("s3_failed", False),
))
conn.commit()
conn.close()
except Exception as e:
print(f"Failed to save task to DB: {e}")
def _load_tasks_from_db():
try:
conn = _db()
cursor = conn.execute("SELECT * FROM tasks ORDER BY created_at DESC LIMIT 100")
keys = [d[0] for d in cursor.description]
for row in cursor.fetchall():
r = dict(zip(keys, row))
task = {
"id": r["id"], "url": r["url"],
"is_cache_file": r.get("is_cache_file", False),
"cache_path": r.get("cache_path", ""),
"inpaint_mode": r["inpaint_mode"],
"subtitle_areas": json.loads(r["subtitle_areas"] or "[]"),
"preview_mode": r.get("preview_mode", False),
"detect_mode": r.get("detect_mode", "text"),
"status": r["status"], "progress": r["progress"],
"phase": "pending", "detect_pct": 0, "process_pct": 0, "merge_pct": 0,
"download_progress": r["download_progress"],
"cached": r["cached"], "output_path": r["output_path"],
"error": r["error"], "platform_task_id": r.get("platform_task_id"),
"created_at": r.get("created_at"), "started_at": r.get("started_at"),
"completed_at": r.get("completed_at"),
"s3_failed": bool(r.get("s3_failed", False)),
"logs": [],
}
# 重启时 downloading/processing 说明上次处理被中断,标为 error
if task["status"] in ("downloading", "processing"):
task["status"] = "error"
task["error"] = "服务重启,任务中断"
_save_task_to_db(task)
with task_lock:
tasks[task["id"]] = task
if task["status"] == "pending":
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)
# 持久化到 DB
try:
conn = _db()
conn.execute("INSERT INTO task_logs (task_id, message) VALUES (?, ?)", (task_id, message))
conn.commit()
conn.close()
except Exception:
pass
_push(_broadcast({"type": "log", "task_id": task_id, "message": message}))
# ── local 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
update_task(task_id, download_progress=pct)
push_log(task_id, f"下载 {pct}%({done//1048576} MB / {total//1048576} 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 _ensure_h264(task_id: str, video_path: str) -> str:
"""如果视频无法被 OpenCV 解码(如 AV1/HEVC),先用 FFmpeg 转成 H.264。
返回可用的视频路径(原路径或转码后路径)。"""
cap = cv2.VideoCapture(video_path)
ret, _ = cap.read()
cap.release()
if ret:
return video_path # 可正常解码,无需转换
# 无法解码,转码
converted = str(Path(video_path).with_suffix('')) + '_h264.mp4'
if Path(converted).exists():
push_log(task_id, f"使用已有 H.264 转码版本: {Path(converted).name}")
return converted
push_log(task_id, "⚠ 视频格式(可能是 AV1/HEVC)OpenCV 无法解码,自动转换为 H.264…")
try:
import subprocess, shutil
# 优先系统 ffmpeg(有 NVENC),项目自带的没有编译 NVENC 支持
sys_ffmpeg = shutil.which('ffmpeg') or '/usr/bin/ffmpeg'
push_log(task_id, f"FFmpeg 转码中(H.264 NVENC)…")
cmd = [
sys_ffmpeg, '-y', '-i', video_path,
'-c:v', 'h264_nvenc', '-cq', '18', '-preset', 'p4',
'-c:a', 'copy', '-loglevel', 'error', converted,
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=3600)
if result.returncode != 0 or not Path(converted).exists():
err = (result.stderr or '').strip()[:200]
push_log(task_id, f"NVENC 失败({err or 'unknown'}),回退 libx264…")
cmd_x264 = [
sys_ffmpeg, '-y', '-i', video_path,
'-c:v', 'libx264', '-crf', '18', '-preset', 'fast',
'-c:a', 'copy', '-loglevel', 'error', converted,
]
result = subprocess.run(cmd_x264, capture_output=True, text=True, timeout=3600)
if result.returncode == 0 and Path(converted).exists():
size_mb = Path(converted).stat().st_size / 1048576
push_log(task_id, f"✓ 转码完成 → {Path(converted).name} ({size_mb:.1f} MB)")
return converted
else:
push_log(task_id, f"转码失败: {result.stderr[:200]},尝试用原始文件继续")
return video_path
except Exception as e:
push_log(task_id, f"转码异常: {e},尝试用原始文件继续")
return video_path
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
# 未知/已废弃模式(含旧的 sttn-*)统一回退 LaMa
if inpaint_mode_str not in INPAINT_MODE_MAP:
inpaint_mode_str = DEFAULT_INPAINT
modeLabels = {'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())
# ── 预检:AV1/HEVC 等 OpenCV 无法解码的格式,先转换为 H.264 ──────────────
video_path = _ensure_h264(task_id, video_path)
class _WebRemover(SubtitleRemover):
_static_boxes = [] # 静态框(手动指定时用),注入所有帧
_per_frame_boxes = None # {frame_no: [(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
def run(self_):
if self_._per_frame_boxes or self_._static_boxes:
from backend.tools.subtitle_detect import SubtitleDetect as _SD
_orig = _SD.find_subtitle_frame_no
_static = list(self_._static_boxes or [])
_perframe = self_._per_frame_boxes or {}
def _patched(sd_self, sub_remover=None):
# 逐帧框模式:直接用检测到的逐帧字典,不跑 OCR(淡水印 OCR 检测不到)
cap = cv2.VideoCapture(sd_self.video_path)
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
cap.release()
result = {}
for frame_no in range(1, total + 1):
boxes = list(_perframe.get(frame_no, []))
if _static:
boxes = boxes + _static
if boxes:
result[frame_no] = boxes
push_log(task_id, f"水印逐帧框:{len(result)}/{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_clip_path = None
if preview_mode:
try:
from backend.tools.ffmpeg_cli import FFmpegCLI
import subprocess
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秒...")
subprocess.run(cut_cmd, check=True, timeout=60)
push_log(task_id, "截取完成,开始处理预览片段")
video_path = preview_clip_path
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[DEFAULT_INPAINT])
config.set(config.inpaintMode, mode_factory())
if not subtitle_areas:
if detect_mode == "smart":
# 水印模式:逐帧检测半透明/移动文字水印 + 彩色 logo(每秒一帧采样)
push_log(task_id, "水印检测中(逐帧 top-hat + 彩色 logo)...")
from backend.tools.smart_detect import detect_watermark_per_frame
per_frame = detect_watermark_per_frame(video_path)
if per_frame:
n_boxes = sum(len(v) for v in per_frame.values())
sr._per_frame_boxes = per_frame
push_log(task_id, f"水印检测:{len(per_frame)} 帧共 {n_boxes} 个水印框")
else:
# 未检测到水印:这类淡水印 OCR 也看不到,跑 OCR 纯浪费 → 原样输出
push_log(task_id, "未检测到水印,原样输出原视频")
out_stem = Path(video_path).stem
sr.video_out_path = str(OUTPUT_DIR / f"{out_stem}_{task_id[:8]}_no_sub.mp4")
import shutil
shutil.copy2(video_path, sr.video_out_path)
update_task(task_id, status="done", progress=100,
detect_pct=100, process_pct=100, merge_pct=100,
output_path=sr.video_out_path, completed_at=time.time())
push_log(task_id, f"完成(未检测到水印,输出原视频)→ {sr.video_out_path}")
return
else:
# 文字模式:标准 OCR 逐帧检测(走原生 find_subtitle_frame_no)
push_log(task_id, "文字检测(OCR)将逐帧进行")
if subtitle_areas:
if detect_mode == "smart":
sr._static_boxes = (sr._static_boxes or []) + [
(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
]
push_log(task_id, f"手动指定 {len(subtitle_areas)} 个水印区域 → 强制每帧去除")
else:
sr.sub_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
]
out_stem = Path(video_path).stem
sr.video_out_path = str(OUTPUT_DIR / f"{out_stem}_{task_id[:8]}_no_sub.mp4")
_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
if not _state["processing"] and prev >= 40 and raw < 15:
_state["processing"] = True
if _state["processing"] or raw > 50:
_state["processing"] = True
update_task(task_id, detect_pct=100, process_pct=min(raw, 98), merge_pct=0, phase="processing")
else:
update_task(task_id, detect_pct=min(raw * 2, 99), process_pct=0, merge_pct=0, phase="detecting")
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)'}")
sr.add_progress_listener(_on_progress)
sr.run()
if preview_mode and hasattr(sr, 'video_temp_file') and sr.video_temp_file:
import shutil
push_log(task_id, "预览模式:跳过音频合并,直接输出")
try:
shutil.copy2(sr.video_temp_file.name, 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:
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
if task.get("is_cache_file"):
cache_path = Path(task["cache_path"])
if not cache_path.exists():
update_task(task_id, status="error", error="缓存文件不存在")
return
push_log(task_id, f"使用本地缓存: {cache_path.name}")
update_task(task_id, cached=True)
else:
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:
if not _download(task_id, url, cache_path):
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()
# ── platform worker ───────────────────────────────────────────────────────────
logger = logging.getLogger(__name__)
class WorkerLogBuffer:
_seq = itertools.count(1)
def __init__(self, maxlen: int = 1000) -> None:
self._buf: collections.deque[dict] = collections.deque(maxlen=maxlen)
self._lock = threading.Lock()
def push(self, level: str, msg: str) -> None:
with self._lock:
self._buf.append({"seq": next(self._seq), "ts": time.time(), "level": level, "msg": msg})
def since(self, seq: int) -> tuple[list[dict], int]:
with self._lock:
lines = [e for e in self._buf if e["seq"] > seq]
return lines, (lines[-1]["seq"] if lines else seq)
class _WorkerLogHandler(logging.Handler):
"""把 Python logging 桥接到 WorkerLogBuffer,供前端轮询。"""
def __init__(self, buf: WorkerLogBuffer) -> None:
super().__init__()
self._buf = buf
def emit(self, record: logging.LogRecord) -> None:
try:
self._buf.push(record.levelname, self.format(record))
except Exception:
pass
class VSRTaskWorker:
"""轮询外部平台认领 VSR 任务,执行后上报结果。"""
def __init__(self, *, base_url: str, app_id: str, app_secret: str,
slot_id: int, slot_total: int, poll_interval: float = 5.0) -> None:
self._base_url = base_url.rstrip("/")
self._app_id = app_id
self._app_secret = app_secret
self._slot_id = slot_id
self._slot_total = slot_total
self._poll_interval = poll_interval
self._active: set[asyncio.Task] = set()
async def run(self) -> None:
logger.info(f"[vsr_worker] 启动 slot={self._slot_id} → {self._base_url}")
async with httpx.AsyncClient(base_url=self._base_url, timeout=30.0) as client:
self._client = client
while True:
try:
await self._tick()
except Exception:
logger.exception("[vsr_worker] tick 异常")
await asyncio.sleep(self._poll_interval)
async def _tick(self) -> None:
if self._active:
return
task = await self._claim()
if task is None:
return
t = asyncio.create_task(self._run_task(task))
self._active.add(t)
t.add_done_callback(self._active.discard)
async def _claim(self) -> dict[str, Any] | None:
resp = await self._signed_post(
"/openapi/watermark-task/claim",
{"slot_id": self._slot_id, "slot_total": self._slot_total},
)
if resp is None:
return None
task = resp.get("task")
if task is None:
logger.debug(f"[vsr_worker] slot={self._slot_id} → 无待处理任务")
return task
async def _run_task(self, ptask: dict[str, Any]) -> None:
platform_task_id: int = ptask["id"]
video_url: str = ptask.get("media_url", "") or ptask.get("video_url", "")
logger.info(f"[vsr_worker] 认领任务 #{platform_task_id} url={video_url[:60]}")
task_id = uuid.uuid4().hex[:8]
cache_path = url_to_cache_path(video_url)
cached = cache_path.exists()
new_task = {
"id": task_id,
"url": video_url,
"is_cache_file": False,
"cache_path": str(cache_path),
"inpaint_mode": ptask.get("inpaint_mode", "lama"),
"subtitle_areas": ptask.get("subtitle_areas", []),
"preview_mode": ptask.get("preview_mode", False),
"detect_mode": ptask.get("detect_mode", "text"),
"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,
"s3_failed": False,
"platform_task_id": platform_task_id,
"logs": [],
"created_at": time.time(),
"started_at": None,
"completed_at": None,
}
with task_lock:
tasks[task_id] = new_task
# 立即广播给前端,使任务出现在列表中
_save_task_to_db(new_task)
_push(_broadcast({"type": "task_update", "task": _task_snapshot(new_task)}))
# 在线程中同步执行任务
def _do():
_run_task(task_id)
loop = asyncio.get_running_loop()
# 进度上报后台 loop
async def _progress_reporter():
reported_logs = 0
while True:
await asyncio.sleep(2)
with task_lock:
t = tasks.get(task_id)
if not t:
break
logs = t.get("logs", [])
for msg in logs[reported_logs:]:
await self._report_progress(platform_task_id, msg)
reported_logs = len(logs)
if t["status"] in ("done", "error"):
break
reporter = asyncio.create_task(_progress_reporter())
try:
await loop.run_in_executor(None, _do)
except Exception:
logger.exception(f"[vsr_worker] 任务 #{platform_task_id} 执行异常")
update_task(task_id, status="error", error="worker 执行异常,详见 Worker 日志")
finally:
reporter.cancel()
with task_lock:
final = tasks.get(task_id, {})
if final.get("status") == "done" and final.get("output_path"):
output_url = await self._upload_result_video(final["output_path"], task_id=task_id)
if output_url:
# 上传 S3 成功,才算真正完成
await self._report_result(platform_task_id, status="done", output_url=output_url)
push_log(task_id, f"✓ 已上传 S3:{output_url}")
else:
# 处理成功但上传失败 → 保留 done 状态(本地文件可播放/下载),仅报错给平台
logger.error(f"[vsr_worker] 任务 #{platform_task_id} 处理成功但上传 S3 失败")
update_task(task_id, s3_failed=True, error="S3 上传失败,视频已在本地,可直接下载")
push_log(task_id, "✗ S3 上传失败,但本地文件完好,可点击下载按钮获取")
await self._report_result(platform_task_id, status="failed",
error_message="处理完成但视频上传 S3 失败")
else:
await self._report_result(platform_task_id, status="failed",
error_message=final.get("error", "unknown"))
async def _report_progress(self, platform_task_id: int, message: str) -> None:
await self._signed_post(
f"/openapi/watermark-task/{platform_task_id}/progress",
{"message": message},
headers={"X-Worker-Slot": str(self._slot_id)},
)
async def _report_result(self, platform_task_id: int, *, status: str,
output_url: str | None = None,
error_message: str = "") -> None:
await self._signed_post(
f"/openapi/watermark-task/{platform_task_id}/result",
{"status": status, "output_url": output_url, "error_message": error_message},
headers={"X-Worker-Slot": str(self._slot_id)},
)
logger.info(f"[vsr_worker] 任务 #{platform_task_id} 上报结果 status={status}")
async def _upload_result_video(self, local_path: str, task_id: str | None = None) -> str | None:
"""处理完成后将输出视频上传到平台,返回 play_url(S3 地址)。流式上传,不一次性读入内存。"""
import hashlib as _hl
def _log(msg: str):
logger.info(msg)
if task_id:
push_log(task_id, msg)
try:
file_path = Path(local_path)
file_size = file_path.stat().st_size
filename = file_path.name
# 流式计算 SHA256,避免 read_bytes() 把几百 MB 全加载进内存
h = _hl.sha256()
with open(local_path, "rb") as f:
for chunk in iter(lambda: f.read(8 * 1024 * 1024), b""):
h.update(chunk)
sha256 = h.hexdigest()
_log(f"[S3] 准备上传 {filename},大小={file_size//1048576}MB")
sign_resp = await self._signed_post(
"/openapi/file/upload/get_upload_sign",
{
"sha256": sha256,
"size": file_size,
"filename": filename,
"content_type": "video/mp4",
"file_type": "video",
"biz_ref": "vsr-output",
"no_transcode": True,
},
)
if sign_resp is None:
_log("[S3] get_upload_sign 返回空")
return None
action = sign_resp.get("action", "")
if action == "skip":
_log("[S3] 文件已存在,直接复用")
return sign_resp.get("play_url")
presigned_url = sign_resp.get("presigned_url", "")
object_key = sign_resp.get("object_key", "")
if not presigned_url or not object_key:
_log(f"[S3] upload sign 未返回 presigned_url/object_key: {sign_resp}")
return None
# 用 asyncio executor 做同步分块读取,避免 sync file 阻塞 async event loop
_log(f"[S3] 开始上传 {file_size//1048576}MB → S3…")
CHUNK = 8 * 1024 * 1024 # 8MB chunks
async def _file_chunks():
loop = asyncio.get_running_loop()
with open(local_path, "rb") as fobj:
while True:
chunk = await loop.run_in_executor(None, fobj.read, CHUNK)
if not chunk:
break
yield chunk
async with httpx.AsyncClient(timeout=7200) as upload_client:
put_resp = await upload_client.put(
presigned_url,
content=_file_chunks(),
headers={"Content-Type": "video/mp4", "Content-Length": str(file_size)},
)
if put_resp.status_code not in (200, 204):
_log(f"[S3] PUT 失败 HTTP {put_resp.status_code}: {put_resp.text[:200]}")
return None
_log("[S3] 上传完成,注册资产…")
reg_resp = await self._signed_post(
"/openapi/file/assets/register",
{
"sha256": sha256,
"object_key": object_key,
"size": file_size,
"filename": filename,
"content_type": "video/mp4",
"file_type": "video",
"biz_ref": "vsr-output",
"no_transcode": True,
},
)
play_url = reg_resp.get("play_url") if reg_resp else None
_log(f"[S3] 资产注册完成 play_url={play_url}")
return play_url
except Exception as e:
import traceback as _tb
_log(f"[S3] 上传失败: {e}\n{_tb.format_exc()[-400:]}")
logger.warning(f"[vsr_worker] 上传结果视频失败: {e}")
return None
async def _signed_post(self, path: str, body: dict[str, Any],
headers: dict[str, str] | None = None) -> dict[str, Any] | None:
body_str = json.dumps(body, separators=(",", ":"), ensure_ascii=False)
timestamp = str(int(time.time()))
nonce = uuid.uuid4().hex
payload_str = "\n".join([self._app_id, timestamp, nonce, body_str])
signature = hmac.new(self._app_secret.encode(), payload_str.encode(), hashlib.sha256).hexdigest()
req_headers = {
"Content-Type": "application/json",
"X-App-Id": self._app_id,
"X-Timestamp": timestamp,
"X-Nonce": nonce,
"X-Signature": signature,
}
if headers:
req_headers.update(headers)
try:
resp = await self._client.post(path, content=body_str, headers=req_headers)
resp.raise_for_status()
return resp.json()
except httpx.HTTPStatusError as exc:
logger.warning(f"[vsr_worker] POST {path} → {exc.response.status_code}: {exc.response.text[:200]}")
except Exception:
logger.exception(f"[vsr_worker] POST {path} 失败")
return None
class WorkerManager:
def __init__(self) -> None:
self._tasks: list[asyncio.Task] = []
self.log_buffer = WorkerLogBuffer()
_handler = _WorkerLogHandler(self.log_buffer)
_handler.setFormatter(logging.Formatter("%(asctime)s %(message)s", datefmt="%H:%M:%S"))
_wl = logging.getLogger(__name__)
_wl.setLevel(logging.DEBUG)
_wl.addHandler(_handler)
def start(self, *, base_url: str, app_id: str, app_secret: str,
concurrency: int, poll_interval: float) -> None:
concurrency = max(1, concurrency)
for slot_id in range(concurrency):
worker = VSRTaskWorker(
base_url=base_url, app_id=app_id, app_secret=app_secret,
slot_id=slot_id, slot_total=concurrency, poll_interval=poll_interval,
)
self._tasks.append(asyncio.create_task(worker.run()))
logger.info(f"[WorkerManager] 启动 {concurrency} 个 worker slot")
self.log_buffer.push("info", f"启动 {concurrency} 个 worker slot → {base_url}")
def stop(self) -> None:
for t in self._tasks:
t.cancel()
self._tasks.clear()
def restart(self, *, base_url: str, app_id: str, app_secret: str,
concurrency: int, poll_interval: float) -> None:
self.stop()
if base_url and app_id and app_secret and concurrency >= 1:
self.start(base_url=base_url, app_id=app_id, app_secret=app_secret,
concurrency=concurrency, poll_interval=poll_interval)
@property
def running(self) -> bool:
return bool(self._tasks)
# ── FastAPI app ───────────────────────────────────────────────────────────────
app = FastAPI(title="VSR Web")
_worker_manager = WorkerManager()
_worker_thread = threading.Thread(target=_worker, daemon=True, name="vsr-worker")
_worker_thread.start()
@app.middleware("http")
async def _auth_middleware(request: Request, call_next):
path = request.url.path
skip = {"/login", "/auth/login", "/auth/logout", "/health", "/ws"}
if path in skip or path.startswith("/static"):
return await call_next(request)
if not _auth_ok(request):
if path.startswith("/api/"):
return Response(content='{"detail":"Unauthorized"}',
status_code=401, media_type="application/json")
return RedirectResponse(url="/login", status_code=302)
return await call_next(request)
@app.on_event("startup")
async def _startup():
global _loop
_loop = asyncio.get_event_loop()
_load_tasks_from_db()
saved = _settings_get_all()
base_url = saved.get("platform_base_url", "").strip()
app_id = saved.get("platform_app_id", "").strip()
app_secret = saved.get("platform_app_secret", "").strip()
concurrency = int(saved.get("worker_concurrency", "1"))
poll_interval = float(saved.get("worker_poll_interval", "5"))
_worker_manager.restart(base_url=base_url, app_id=app_id, app_secret=app_secret,
concurrency=concurrency, poll_interval=poll_interval)
@app.on_event("shutdown")
async def _shutdown():
_worker_manager.stop()
# ── auth endpoints ────────────────────────────────────────────────────────────
@app.get("/login", include_in_schema=False)
async def login_page():
return HTMLResponse(_LOGIN_HTML.format(error=""))
@app.post("/auth/login", include_in_schema=False)
async def do_login(request: Request):
form = await request.form()
pw = (form.get("password") or "").strip()
if hmac.compare_digest(pw.encode(), _admin_password().encode()):
resp = RedirectResponse(url="/", status_code=302)
resp.set_cookie(_COOKIE, _make_token(), max_age=_COOKIE_MAX_AGE, httponly=True, samesite="lax")
return resp
html = _LOGIN_HTML.format(error='密码错误,请重试
')
return HTMLResponse(html, status_code=401)
@app.get("/auth/logout", include_in_schema=False)
async def do_logout():
resp = RedirectResponse(url="/login", status_code=302)
resp.delete_cookie(_COOKIE)
return resp
# ── main page ─────────────────────────────────────────────────────────────────
@app.get("/")
async def index():
html = (BASE_DIR / "web" / "index.html").read_text(encoding="utf-8")
return HTMLResponse(html)
@app.get("/health")
async def health():
return {"status": "ok"}
# ── task API ──────────────────────────────────────────────────────────────────
class TaskCreate(BaseModel):
url: str
inpaint_mode: str = "lama"
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]
is_cache_file = body.url.startswith("cache_")
if is_cache_file:
cache_path = CACHE_DIR / body.url[6:]
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, "s3_failed": False, "platform_task_id": 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)
conn = _db()
conn.execute("DELETE FROM tasks WHERE id = ?", (task_id,))
conn.execute("DELETE FROM task_logs WHERE task_id = ?", (task_id,))
conn.commit()
conn.close()
return {"ok": True}
@app.get("/api/tasks/{task_id}/logs")
async def get_task_logs(task_id: str):
conn = _db()
rows = conn.execute(
"SELECT message, created_at FROM task_logs WHERE task_id = ? ORDER BY id ASC",
(task_id,)
).fetchall()
conn.close()
return {"logs": [{"message": r[0], "ts": r[1]} for r in rows]}
@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 not task.get("output_path"):
raise HTTPException(404, "Result not ready")
# 允许 done 或 S3 上传失败(本地文件完好)的任务下载
if task["status"] not in ("done", "error") or (
task["status"] == "error" and not task.get("s3_failed")
):
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 not task.get("output_path"):
raise HTTPException(404, "Result not ready")
if task["status"] not in ("done", "error") or (
task["status"] == "error" and not task.get("s3_failed")
):
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:
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")
return FileResponse(str(out), media_type="video/mp4", status_code=206, headers={
"Content-Range": f"bytes {start}-{end}/{file_size}",
"Accept-Ranges": "bytes",
"Content-Length": str(end - start + 1),
})
except (ValueError, IndexError):
pass
return FileResponse(str(out), filename=out.name, media_type="video/mp4",
headers={"Accept-Ranges": "bytes"})
@app.get("/api/tasks/{task_id}/source-stream")
async def stream_source(task_id: str, request: Request):
"""Stream the original (cached) video file."""
with task_lock:
task = tasks.get(task_id)
if not task:
raise HTTPException(404, "Not found")
cache_path = Path(task.get("cache_path") or "")
if not cache_path.exists():
raise HTTPException(404, "Source file not available")
file_size = cache_path.stat().st_size
range_header = request.headers.get("range")
if range_header:
try:
start_str, end_str = range_header.replace("bytes=", "").split("-")
start = int(start_str) if start_str else 0
end = int(end_str) if end_str else file_size - 1
if start >= file_size or end >= file_size or start > end:
raise HTTPException(416, "Range Not Satisfiable")
return FileResponse(str(cache_path), media_type="video/mp4", status_code=206, headers={
"Content-Range": f"bytes {start}-{end}/{file_size}",
"Accept-Ranges": "bytes",
"Content-Length": str(end - start + 1),
})
except (ValueError, IndexError):
pass
return FileResponse(str(cache_path), filename=cache_path.name, media_type="video/mp4",
headers={"Accept-Ranges": "bytes"})
# ── cache API ─────────────────────────────────────────────────────────────────
@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}
# ── settings API ──────────────────────────────────────────────────────────────
@app.get("/api/settings")
async def get_settings_api():
saved = _settings_get_all()
return {
"platform_base_url": saved.get("platform_base_url", ""),
"platform_app_id": saved.get("platform_app_id", ""),
"platform_app_secret": saved.get("platform_app_secret", ""),
"worker_concurrency": int(saved.get("worker_concurrency", "1")),
"worker_poll_interval": float(saved.get("worker_poll_interval", "5")),
}
@app.put("/api/settings")
async def put_settings(body: dict):
allowed = set(WORKER_KEYS)
to_save = {k: str(v) for k, v in body.items() if k in allowed}
_settings_set_many(to_save)
saved = _settings_get_all()
_worker_manager.restart(
base_url=saved.get("platform_base_url", "").strip(),
app_id=saved.get("platform_app_id", "").strip(),
app_secret=saved.get("platform_app_secret", "").strip(),
concurrency=int(saved.get("worker_concurrency", "1")),
poll_interval=float(saved.get("worker_poll_interval", "5")),
)
return {"ok": True, "worker_running": _worker_manager.running}
# ── worker log API ────────────────────────────────────────────────────────────
@app.get("/api/worker/logs")
async def get_worker_logs(since: int = 0):
lines, next_seq = _worker_manager.log_buffer.since(since)
return {"lines": lines, "next_seq": next_seq}
# ── status API ────────────────────────────────────────────────────────────────
@app.get("/api/status")
async def server_status():
return {
"vsr_available": VSR_AVAILABLE,
"vsr_path": VSR_PATH,
"queue_size": task_queue.qsize(),
"worker_running": _worker_manager.running,
"auth_enabled": bool(_admin_password()),
}
# ── websocket ──────────────────────────────────────────────────────────────────
@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"Auth enabled: {bool(_admin_password())}")
print(f"Starting on http://{host}:{port}")
uvicorn.run(app, host=host, port=port)