图片视频高清修复软件免费版 | 支持AMD/NVIDIA显卡,AI超分辨率+补帧画质增强工具,视频补帧动漫老番修复,本地离线批量处理一键4K60帧高清重制——AI高清修复整合包
·
我用夸克网盘给你分享了「AI超分+AI插帧动漫修复」,点击链接或复制整段内容,打开「夸克APP」即可获取。
/~524d3ZmQzx~:/
链接:https://pan.quark.cn/s/c1d8c27a7ad1
需要自取
图片视频高清修复软件免费版 | 支持AMD/NVIDIA显卡,AI超分辨率+补帧画质增强工具,视频补帧动漫老番修复,本地离线批量处理一键4K60帧高清重制——AI高清修复整合包

这个项目是这样的,兼容AMD和N卡
Real-CUGAN 超分 + RIFE 插帧
这个工具对于动漫来说是非常优秀的一个画质工具,能够提升画质,提升帧率
高清修复放大到2K然后插帧,从图上可以看出来左右手清晰度差距明显
原作者
项目1地址:https://github.com/nihui/realcugan-ncnn-vulkan
项目2地址:https://github.com/nihui/rife-ncnn-vulkan
下面是整合后的视频
https://www.bilibili.com/video/BV1MgMt6UE24/?vd_source=fe7bb811925a28a63721889095e09c78
这两个文件下载下来后解压放在一个文件夹下即可使用,但是有一个前提需要安装python配置好环境还要ffmpeg是否有添加到环境变量,或者放在本地文件夹下

有这三个文件就能调用文件了,
这个启动.bat,server.py,index.html 这三个都是很重要的,不然只能命令行运行了
命令行有点复杂了,不过多赘述,直接三个文件代码如下自行获取。
直接运行这个超分插帧的按钮,启动的bat代码如下
@echo off
chcp 65001 >nul
cd /d "%~dp0"
echo =============================================
echo 🚀 鸣潮超分工具箱 · 后端服务启动中...
echo =============================================
echo.
echo 正在检查 Python 环境...
python --version >nul 2>&1
if errorlevel 1 (
echo ❌ 未找到 Python,请先安装 Python 3.8+
pause
exit /b
)
echo ✅ 依赖检查中,若缺少 aiohttp 将自动安装...
pip install aiohttp -q
echo.
echo ✅ 启动服务器 (访问 http://localhost:8080)
echo ⚠️ 请保持此窗口打开,关闭即停止服务
echo.
:: 自动打开默认浏览器
start http://localhost:8080
python server.py
pause
server代码如下:
import asyncio
import json
import os
import subprocess
import shutil
import uuid
import sys
import re
from pathlib import Path
from datetime import datetime
from aiohttp import web, WSMsgType
from aiohttp.web import FileResponse
# ---------- 配置 ----------
BASE_DIR = Path(__file__).parent
UPLOAD_DIR = BASE_DIR / "uploads"
OUTPUT_DIR = BASE_DIR / "outputs"
EXE_NAME = "realcugan-ncnn-vulkan.exe"
RIFE_EXE_NAME = "rife-ncnn-vulkan.exe"
UPLOAD_DIR.mkdir(exist_ok=True)
OUTPUT_DIR.mkdir(exist_ok=True)
current_process = None
# ---------- 查找可执行文件 ----------
def find_exe(name):
local_path = BASE_DIR / name
if local_path.exists():
return str(local_path)
path_exe = shutil.which(name)
if path_exe:
return path_exe
return None
REALCUGAN_EXE = find_exe(EXE_NAME)
RIFE_EXE = find_exe(RIFE_EXE_NAME)
FFMPEG_EXE = find_exe("ffmpeg")
if not REALCUGAN_EXE:
print(f"❌ 未找到 {EXE_NAME},请将其放在 {BASE_DIR} 目录下")
if not RIFE_EXE:
print(f"⚠️ 未找到 {RIFE_EXE_NAME},请将其放在 {BASE_DIR} 目录下或加入PATH")
if not FFMPEG_EXE:
print("⚠️ 未找到 ffmpeg,请将 ffmpeg.exe 放在当前目录或加入 PATH")
# ---------- 辅助函数 ----------
async def check_video_file(video_path):
if not FFMPEG_EXE:
return False, "ffmpeg 未找到"
cmd = f'"{FFMPEG_EXE}" -v error -i "{video_path}" -f null -'
proc = await asyncio.create_subprocess_shell(
cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
err_msg = stderr.decode("utf-8", errors="ignore").strip()
return False, err_msg
return True, ""
def safe_rmtree(path):
if path and Path(path).exists():
try:
shutil.rmtree(path, ignore_errors=True)
return True
except Exception:
return False
return False
# ---------- WebSocket 处理 ----------
async def websocket_handler(request):
global current_process
ws = web.WebSocketResponse()
await ws.prepare(request)
print(f"✅ 客户端已连接: {request.remote}")
async def send_progress(percent, message, frame=None, total=None):
if ws.closed:
return
await ws.send_json({
"type": "progress",
"percent": percent,
"message": message,
"frame": frame,
"total": total
})
async def send_log(msg, level=""):
if ws.closed:
return
await ws.send_json({"type": "log", "message": msg, "level": level})
# ---------- 超分函数 ----------
async def run_upscale(params, ws, auto_interp=False):
global current_process
filename = params.get("file")
scale = params.get("scale", 2)
denoise = params.get("denoise", 2)
gpu = params.get("gpu", 0)
tile = params.get("tile", 0)
threads = params.get("threads", "1:2:2")
# 补帧参数(使用 interp_factor)
interp_model = params.get("interp_model", "rife-v4.6")
interp_factor = params.get("interp_factor", 2.5) # 默认2.5
interp_gpu = params.get("interp_gpu", 0)
interp_tile = params.get("interp_tile", 0)
interp_threads = params.get("interp_threads", "1:2:2")
input_path = UPLOAD_DIR / filename
if not input_path.exists():
await ws.send_json({"type": "error", "message": f"文件不存在: {filename}"})
return
valid, err = await check_video_file(input_path)
if not valid:
await ws.send_json({"type": "error", "message": f"视频文件损坏或不完整: {err}"})
return
if not REALCUGAN_EXE:
await ws.send_json({"type": "error", "message": f"未找到 {EXE_NAME}"})
return
if not FFMPEG_EXE:
await ws.send_json({"type": "error", "message": "未找到 ffmpeg.exe"})
return
# 拆帧
frame_dir = OUTPUT_DIR / f"{input_path.stem}_frames"
frame_dir.mkdir(exist_ok=True)
frame_pattern = frame_dir / "frame_%06d.png"
await send_log("📹 正在将视频拆分为 PNG 帧...", "highlight")
split_cmd = f'"{FFMPEG_EXE}" -i "{input_path}" -vsync 0 -pix_fmt rgb24 "{frame_pattern}"'
print("🔧 拆帧命令:", split_cmd)
try:
split_proc = await asyncio.create_subprocess_shell(
split_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
)
current_process = split_proc
await split_proc.wait()
current_process = None
if split_proc.returncode != 0:
stderr = await split_proc.stderr.read()
err_msg = stderr.decode("utf-8", errors="ignore")
await ws.send_json({"type": "error", "message": f"拆帧失败: {err_msg}"})
return
except asyncio.CancelledError:
await send_log("⏹ 拆帧被取消", "")
raise
except Exception as e:
await ws.send_json({"type": "error", "message": f"拆帧异常: {str(e)}"})
return
finally:
current_process = None
frame_files = sorted(frame_dir.glob("frame_*.png"))
if not frame_files:
await ws.send_json({"type": "error", "message": "拆帧后未找到任何图片"})
return
total_frames = len(frame_files)
await send_log(f"✅ 拆帧完成,共 {total_frames} 帧", "done")
# 超分每一帧
upscaled_dir = OUTPUT_DIR / f"{input_path.stem}_upscaled_frames"
upscaled_dir.mkdir(exist_ok=True)
await send_log(f"🔄 开始超分 {total_frames} 帧(放大 {scale} 倍,降噪 {denoise})", "highlight")
processed = 0
for frame_path in frame_files:
out_name = f"upscaled_{frame_path.name}"
out_path = upscaled_dir / out_name
cmd = [
REALCUGAN_EXE,
"-i", str(frame_path),
"-o", str(out_path),
"-s", str(scale),
"-n", str(denoise),
"-g", str(gpu),
"-t", str(tile),
"-j", threads
]
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
)
current_process = proc
await proc.wait()
current_process = None
if proc.returncode != 0:
stderr = await proc.stderr.read()
err_msg = stderr.decode("utf-8", errors="ignore")
await ws.send_json({"type": "error", "message": f"处理第 {processed+1} 帧失败: {err_msg}"})
return
except asyncio.CancelledError:
await send_log("⏹ 超分被取消", "")
raise
except Exception as e:
await ws.send_json({"type": "error", "message": f"超分第 {processed+1} 帧异常: {str(e)}"})
return
finally:
current_process = None
processed += 1
percent = int(processed / total_frames * 100)
await send_progress(percent, f"超分中... {processed}/{total_frames}", processed, total_frames)
await send_log("✅ 所有帧超分完成", "done")
# 获取原视频帧率
probe_cmd = f'"{FFMPEG_EXE}" -i "{input_path}" -f null -'
probe_proc = await asyncio.create_subprocess_shell(
probe_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
)
_, stderr = await probe_proc.communicate()
stderr_text = stderr.decode("utf-8", errors="ignore")
fps_match = re.search(r"(\d+\.?\d*)\s+fps", stderr_text)
fps = float(fps_match.group(1)) if fps_match else 24.0
# 如果是自动补帧,则调用补帧
if auto_interp:
if not RIFE_EXE:
await ws.send_json({"type": "error", "message": "未找到 rife-ncnn-vulkan.exe,无法完成一键任务"})
return
await send_log("🔄 自动进入补帧阶段...", "highlight")
interp_params = {
"input_video": str(input_path),
"frames_dir": str(upscaled_dir),
"model": interp_model,
"interp_factor": interp_factor, # 统一使用 interp_factor
"gpu": interp_gpu,
"tile": interp_tile,
"threads": interp_threads
}
await run_interpolation(interp_params, ws, skip_audio_extract=False)
safe_rmtree(frame_dir)
return
# 仅超分:合成视频
output_video = OUTPUT_DIR / f"{input_path.stem}_upscaled_{scale}x.mp4"
await send_log(f"🎞️ 正在合成视频(帧率 {fps:.2f})...", "highlight")
concat_pattern = str(upscaled_dir / "upscaled_frame_%06d.png")
concat_cmd = f'"{FFMPEG_EXE}" -framerate {fps} -i "{concat_pattern}" -i "{input_path}" -c:v libx264 -pix_fmt yuv420p -crf 18 -c:a copy -map 0:v -map 1:a? -shortest "{output_video}"'
print("🔧 合成命令:", concat_cmd)
try:
concat_proc = await asyncio.create_subprocess_shell(
concat_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
)
current_process = concat_proc
await concat_proc.wait()
current_process = None
if concat_proc.returncode != 0:
stderr = await concat_proc.stderr.read()
err_msg = stderr.decode("utf-8", errors="ignore")
await ws.send_json({"type": "error", "message": f"合成视频失败: {err_msg}"})
return
except asyncio.CancelledError:
await send_log("⏹ 合成被取消", "")
raise
except Exception as e:
await ws.send_json({"type": "error", "message": f"合成视频异常: {str(e)}"})
return
finally:
current_process = None
await send_log(f"🎉 超分完成!视频已保存为 {output_video.name}", "done")
safe_rmtree(frame_dir)
await ws.send_json({
"type": "done",
"output": str(output_video),
"input": str(input_path),
"folder": str(OUTPUT_DIR),
"upscaled_frames_dir": str(upscaled_dir),
"fps": fps
})
# ---------- 补帧函数(修正倍数参数) ----------
async def run_interpolation(params, ws, skip_audio_extract=False):
global current_process
input_video = params.get("input_video")
frames_dir = params.get("frames_dir")
file = params.get("file")
model = params.get("model", "rife-v4.6")
# ★ 修正:优先取 interp_factor,兼容旧字段 factor ★
factor = params.get("interp_factor")
if factor is None:
factor = params.get("factor", 2.0) # 兼容旧写法
factor = float(factor)
gpu = params.get("gpu", 0)
tile = params.get("tile", 0)
threads = params.get("threads", "1:2:2")
if not frames_dir and not input_video:
if file:
video_path = UPLOAD_DIR / file
if video_path.exists():
input_video = str(video_path)
else:
await ws.send_json({"type": "error", "message": f"上传的文件不存在: {file}"})
return
else:
await ws.send_json({"type": "error", "message": "缺少视频文件或帧目录"})
return
if not RIFE_EXE:
await ws.send_json({"type": "error", "message": f"未找到 {RIFE_EXE_NAME}"})
return
if not FFMPEG_EXE:
await ws.send_json({"type": "error", "message": "未找到 ffmpeg.exe"})
return
temp_frame_dir = None
if not frames_dir or not Path(frames_dir).exists():
if not input_video:
await ws.send_json({"type": "error", "message": "缺少视频文件或帧目录"})
return
video_path = Path(input_video)
if not video_path.exists() and not video_path.is_absolute():
video_path = UPLOAD_DIR / input_video
if not video_path.exists():
await ws.send_json({"type": "error", "message": f"视频文件不存在: {input_video}"})
return
valid, err = await check_video_file(video_path)
if not valid:
await ws.send_json({"type": "error", "message": f"视频文件损坏或不完整: {err}"})
return
temp_frame_dir = OUTPUT_DIR / f"{video_path.stem}_interp_temp_frames"
temp_frame_dir.mkdir(exist_ok=True)
frame_pattern = temp_frame_dir / "frame_%06d.png"
await send_log(f"📹 正在拆帧(原始视频)...", "highlight")
split_cmd = f'"{FFMPEG_EXE}" -i "{video_path}" -vsync 0 -pix_fmt rgb24 "{frame_pattern}"'
print("🔧 拆帧命令:", split_cmd)
try:
proc = await asyncio.create_subprocess_shell(
split_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
)
current_process = proc
await proc.wait()
current_process = None
if proc.returncode != 0:
stderr = await proc.stderr.read()
err_msg = stderr.decode("utf-8", errors="ignore")
await ws.send_json({"type": "error", "message": f"拆帧失败: {err_msg}"})
return
except asyncio.CancelledError:
await send_log("⏹ 拆帧被取消", "")
raise
except Exception as e:
await ws.send_json({"type": "error", "message": f"拆帧异常: {str(e)}"})
return
finally:
current_process = None
frames_dir = str(temp_frame_dir)
frames_dir_path = Path(frames_dir)
if not frames_dir_path.exists():
await ws.send_json({"type": "error", "message": f"帧目录不存在: {frames_dir}"})
return
# 获取原始帧率
if input_video and Path(input_video).exists():
probe_cmd = f'"{FFMPEG_EXE}" -i "{input_video}" -f null -'
probe_proc = await asyncio.create_subprocess_shell(
probe_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
)
_, stderr = await probe_proc.communicate()
stderr_text = stderr.decode("utf-8", errors="ignore")
fps_match = re.search(r"(\d+\.?\d*)\s+fps", stderr_text)
original_fps = float(fps_match.group(1)) if fps_match else 24.0
else:
original_fps = 24.0
new_fps = original_fps * factor
# 统计输入帧数
frame_files = list(frames_dir_path.glob("*.png"))
if not frame_files:
await ws.send_json({"type": "error", "message": "输入帧目录中没有图片"})
return
total_input_frames = len(frame_files)
target_frames = int(round(total_input_frames * factor))
if target_frames < 2:
target_frames = 2
await send_log(f"📊 输入帧数: {total_input_frames},目标帧数: {target_frames} (倍数 {factor}x)", "highlight")
# 准备输出目录
interp_out_dir = frames_dir_path.parent / f"{frames_dir_path.name}_interp"
if interp_out_dir.exists():
shutil.rmtree(interp_out_dir, ignore_errors=True)
interp_out_dir.mkdir(exist_ok=True)
await send_log(f"🔄 开始补帧(模型: {model},目标帧数: {target_frames})", "highlight")
# 补帧命令
cmd = [
RIFE_EXE,
"-i", str(frames_dir_path),
"-o", str(interp_out_dir),
"-m", model,
"-n", str(target_frames),
"-g", str(gpu),
"-j", threads,
"-v"
]
print("🔧 补帧命令:", " ".join(cmd))
# 启动子进程并监控进度
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
)
current_process = proc
except Exception as e:
await ws.send_json({"type": "error", "message": f"启动补帧进程失败: {str(e)}"})
return
async def monitor_progress():
last_percent = -1
while True:
if proc.returncode is not None:
break
try:
existing = list(interp_out_dir.glob("*.png"))
count = len(existing)
if target_frames > 0:
percent = int(count / target_frames * 100)
if percent > 100:
percent = 100
if percent != last_percent:
last_percent = percent
await send_progress(percent, f"补帧中... {count}/{target_frames}", count, target_frames)
except Exception:
pass
await asyncio.sleep(0.5)
monitor_task = asyncio.create_task(monitor_progress())
try:
while True:
line = await proc.stderr.readline()
if not line:
break
line_str = line.decode("utf-8", errors="ignore").strip()
if line_str:
level = "error" if ("failed" in line_str.lower() or "error" in line_str.lower()) else ""
await send_log(line_str, level)
await proc.wait()
except asyncio.CancelledError:
await send_log("⏹ 补帧被取消", "")
proc.terminate()
await proc.wait()
raise
finally:
current_process = None
monitor_task.cancel()
try:
await monitor_task
except asyncio.CancelledError:
pass
if proc.returncode != 0:
stderr = await proc.stderr.read()
err_msg = stderr.decode("utf-8", errors="ignore")
if not err_msg:
err_msg = f"补帧进程异常退出,返回码 {proc.returncode}"
await ws.send_json({"type": "error", "message": f"补帧失败: {err_msg}"})
return
await send_log("✅ 补帧完成,开始合成视频", "done")
# 合成视频
sample_files = list(interp_out_dir.glob("*.png"))
if not sample_files:
await ws.send_json({"type": "error", "message": "补帧后未生成任何图片"})
return
sample_files.sort()
first_name = sample_files[0].stem
num_match = re.search(r"(\d+)$", first_name)
if num_match:
digit_len = len(num_match.group(1))
pattern = f"{first_name[:-digit_len]}%0{digit_len}d.png"
else:
pattern = "frame_%06d.png"
concat_pattern = str(interp_out_dir / pattern)
if input_video and Path(input_video).exists():
stem = Path(input_video).stem
output_video = OUTPUT_DIR / f"{stem}_interp_{factor}x.mp4"
else:
output_video = OUTPUT_DIR / f"interp_{uuid.uuid4().hex}.mp4"
await send_log(f"🎞️ 合成视频(帧率 {new_fps:.2f})...", "highlight")
if input_video and Path(input_video).exists():
concat_cmd = (
f'"{FFMPEG_EXE}" -framerate {new_fps} -i "{concat_pattern}" '
f'-i "{input_video}" '
f'-c:v libx264 -pix_fmt yuv420p -crf 18 '
f'-c:a copy -map 0:v -map 1:a? '
f'-shortest "{output_video}"'
)
else:
concat_cmd = (
f'"{FFMPEG_EXE}" -framerate {new_fps} -i "{concat_pattern}" '
f'-c:v libx264 -pix_fmt yuv420p -crf 18 '
f'"{output_video}"'
)
print("🔧 合成命令:", concat_cmd)
try:
proc = await asyncio.create_subprocess_shell(
concat_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
)
current_process = proc
await proc.wait()
current_process = None
if proc.returncode != 0:
stderr = await proc.stderr.read()
err_msg = stderr.decode("utf-8", errors="ignore")
await ws.send_json({"type": "error", "message": f"合成视频失败: {err_msg}"})
return
except asyncio.CancelledError:
await send_log("⏹ 合成被取消", "")
raise
except Exception as e:
await ws.send_json({"type": "error", "message": f"合成视频异常: {str(e)}"})
return
finally:
current_process = None
await send_log(f"🎉 补帧视频已保存为 {output_video.name}", "done")
# 清理中间文件
safe_rmtree(interp_out_dir)
if temp_frame_dir:
safe_rmtree(temp_frame_dir)
if frames_dir_path and "upscaled_frames" in str(frames_dir_path):
safe_rmtree(frames_dir_path)
await ws.send_json({
"type": "done",
"output": str(output_video),
"input": str(input_video) if input_video else "",
"folder": str(OUTPUT_DIR),
"fps": new_fps,
"is_interp": True
})
# ---- WebSocket 消息循环 ----
try:
async for msg in ws:
if msg.type == WSMsgType.TEXT:
try:
data = json.loads(msg.data)
cmd_type = data.get("type")
if cmd_type == "start":
if current_process and current_process.returncode is None:
await send_log("⚠️ 已有任务运行中,自动停止旧任务", "error")
if sys.platform == "win32":
subprocess.run(f"taskkill /F /T /PID {current_process.pid}", shell=True)
else:
current_process.terminate()
await asyncio.sleep(0.5)
asyncio.create_task(run_upscale(data, ws, auto_interp=False))
await ws.send_json({
"type": "started",
"taskId": datetime.now().strftime("%Y%m%d%H%M%S")
})
elif cmd_type == "full":
if current_process and current_process.returncode is None:
await send_log("⚠️ 已有任务运行中,自动停止旧任务", "error")
if sys.platform == "win32":
subprocess.run(f"taskkill /F /T /PID {current_process.pid}", shell=True)
else:
current_process.terminate()
await asyncio.sleep(0.5)
asyncio.create_task(run_upscale(data, ws, auto_interp=True))
await ws.send_json({
"type": "started",
"taskId": datetime.now().strftime("%Y%m%d%H%M%S")
})
elif cmd_type == "interp":
if current_process and current_process.returncode is None:
await send_log("⚠️ 已有任务运行中,自动停止旧任务", "error")
if sys.platform == "win32":
subprocess.run(f"taskkill /F /T /PID {current_process.pid}", shell=True)
else:
current_process.terminate()
await asyncio.sleep(0.5)
asyncio.create_task(run_interpolation(data, ws))
await ws.send_json({
"type": "started",
"taskId": datetime.now().strftime("%Y%m%d%H%M%S")
})
elif cmd_type == "stop":
if current_process and current_process.returncode is None:
pid = current_process.pid
if sys.platform == "win32":
subprocess.run(f"taskkill /F /T /PID {pid}", shell=True)
else:
current_process.terminate()
await ws.send_json({"type": "stopped", "message": "已终止任务"})
await send_log("⏹ 任务已停止", "")
else:
await ws.send_json({"type": "error", "message": "没有运行中的任务"})
except json.JSONDecodeError:
await ws.send_json({"type": "error", "message": "无效的 JSON"})
finally:
if current_process and current_process.returncode is None:
if sys.platform == "win32":
subprocess.run(f"taskkill /F /T /PID {current_process.pid}", shell=True)
else:
current_process.terminate()
print("🔌 WebSocket 连接关闭")
return ws
# ---------- HTTP 路由 ----------
async def handle_upload(request):
reader = await request.multipart()
field = await reader.next()
if field.name != "file":
return web.json_response({"error": "字段名必须为 file"}, status=400)
original_filename = field.filename
if not original_filename:
return web.json_response({"error": "未选择文件"}, status=400)
ext = Path(original_filename).suffix
safe_name = f"{uuid.uuid4().hex}{ext}"
save_path = UPLOAD_DIR / safe_name
with open(save_path, "wb") as f:
while True:
chunk = await field.read_chunk(1024 * 1024)
if not chunk:
break
f.write(chunk)
return web.json_response({"ok": True, "filename": safe_name})
async def handle_video(request):
path = request.query.get("path")
if not path:
return web.json_response({"error": "missing path"}, status=400)
file_path = Path(path)
if not file_path.exists():
alt_path = OUTPUT_DIR / Path(path).name
if alt_path.exists():
file_path = alt_path
else:
alt_path2 = UPLOAD_DIR / Path(path).name
if alt_path2.exists():
file_path = alt_path2
else:
return web.json_response({"error": "file not found"}, status=404)
return FileResponse(file_path, headers={"Cache-Control": "no-cache"})
async def handle_open_folder(request):
data = await request.json()
folder = data.get("folder")
if not folder:
return web.json_response({"ok": False, "error": "missing folder"})
folder_path = Path(folder)
if not folder_path.exists():
return web.json_response({"ok": False, "error": "folder not exists"})
if os.name == "nt":
os.startfile(str(folder_path))
else:
subprocess.Popen(["open", str(folder_path)] if sys.platform == "darwin" else ["xdg-open", str(folder_path)])
return web.json_response({"ok": True})
async def handle_clean_all(request):
if request.method == "POST":
if not OUTPUT_DIR.exists():
return web.json_response({"ok": True, "count": 0, "freed_size": "0 MB"})
items = list(OUTPUT_DIR.iterdir())
return web.json_response({"ok": True, "count": len(items)})
elif request.method == "DELETE":
if not OUTPUT_DIR.exists():
return web.json_response({"ok": True, "count": 0, "freed_size": "0 MB"})
items = list(OUTPUT_DIR.iterdir())
total_size = 0
for item in items:
if item.is_dir():
size = sum(f.stat().st_size for f in item.rglob('*') if f.is_file())
total_size += size
shutil.rmtree(item, ignore_errors=True)
else:
total_size += item.stat().st_size
item.unlink(missing_ok=True)
freed_mb = total_size / (1024 * 1024)
freed_str = f"{freed_mb:.1f} MB" if freed_mb > 0 else "0 MB"
return web.json_response({"ok": True, "count": len(items), "freed_size": freed_str})
else:
return web.json_response({"error": "Method not allowed"}, status=405)
async def handle_index(request):
index_path = BASE_DIR / "index.html"
if index_path.exists():
return FileResponse(index_path)
return web.Response(text="未找到 index.html", status=404)
# ---------- 创建应用 ----------
app = web.Application()
app.router.add_get("/", handle_index)
app.router.add_post("/upload", handle_upload)
app.router.add_get("/videos", handle_video)
app.router.add_post("/api/open-folder", handle_open_folder)
app.router.add_route("*", "/api/clean-all", handle_clean_all)
app.router.add_get("/ws", websocket_handler)
if __name__ == "__main__":
print(f"📁 上传目录: {UPLOAD_DIR}")
print(f"📁 输出目录: {OUTPUT_DIR}")
print("🌐 服务地址: http://localhost:8080")
web.run_app(app, host="0.0.0.0", port=8080)
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>二创画质工具箱 · 超分+补帧</title>
<style>
/* 全局样式(与您现有样式完全一致,保留原样) */
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Segoe UI', system-ui, sans-serif;
background: #0b1120;
color: #e8edf5;
padding: 20px;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: flex-start;
}
.app {
max-width: 1500px;
width: 100%;
background: linear-gradient(145deg, #141c2b, #1a2538);
border-radius: 28px;
padding: 28px 30px 30px;
box-shadow: 0 20px 60px rgba(0,0,0,0.7);
border: 1px solid rgba(255,255,255,0.06);
display: flex;
flex-direction: column;
gap: 20px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 2px solid rgba(37,99,235,0.3);
padding-bottom: 14px;
flex-wrap: wrap;
gap: 12px;
}
.header h1 {
font-size: 26px;
font-weight: 700;
background: linear-gradient(135deg, #60a5fa, #a78bfa);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.header .badge {
background: rgba(37,99,235,0.25);
padding: 6px 16px;
border-radius: 40px;
font-size: 13px;
border: 1px solid rgba(37,99,235,0.3);
color: #93bbfc;
}
.main-grid {
display: grid;
grid-template-columns: 1fr 2fr 1.2fr;
gap: 20px;
align-items: stretch;
}
@media (max-width: 1100px) { .main-grid { grid-template-columns: 1fr 1fr; } .main-grid .right-col { grid-column: 1 / -1; } }
@media (max-width: 700px) { .main-grid { grid-template-columns: 1fr; } .main-grid .right-col { grid-column: auto; } }
.card {
background: rgba(255,255,255,0.04);
border-radius: 16px;
padding: 18px 20px 20px;
border: 1px solid rgba(255,255,255,0.06);
backdrop-filter: blur(2px);
display: flex;
flex-direction: column;
height: 100%;
}
.card-title {
font-size: 15px;
font-weight: 600;
color: #94a3b8;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 6px;
}
.card-title span { color: #60a5fa; }
.file-upload-area {
position: relative;
border: 2px dashed #25344f;
border-radius: 12px;
padding: 16px 10px;
text-align: center;
cursor: pointer;
background: rgba(255,255,255,0.02);
transition: border-color 0.25s, background 0.25s;
flex-shrink: 0;
}
.file-upload-area:hover { background: rgba(37,99,235,0.06); border-color: #3b82f6; }
.file-upload-area input[type="file"] { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
.file-upload-area .icon { font-size: 28px; }
.file-upload-area .text { font-size: 13px; color: #94a3b8; }
.file-upload-area .filename {
font-weight: 600;
color: #60a5fa;
margin-top: 2px;
font-size: 14px;
word-break: break-all;
}
.file-list {
margin-top: 10px;
max-height: 150px;
overflow-y: auto;
background: #0a111f;
border-radius: 8px;
padding: 6px 8px;
border: 1px solid #1e2d47;
flex-shrink: 0;
}
.file-list .file-item {
display: flex;
justify-content: space-between;
font-size: 13px;
padding: 3px 0;
border-bottom: 1px solid rgba(255,255,255,0.04);
color: #94a3b8;
}
.file-list .file-item .status { font-weight: 600; }
.file-list .file-item .status.pending { color: #94a3b8; }
.file-list .file-item .status.processing { color: #60a5fa; }
.file-list .file-item .status.done { color: #4ade80; }
.file-list .file-item .status.error { color: #f87171; }
.tips {
margin-top: 12px;
padding: 10px 12px;
background: rgba(37,99,235,0.08);
border-radius: 10px;
border-left: 3px solid #3b82f6;
flex-shrink: 0;
}
.tips ul {
color: #94a3b8;
font-size: 13px;
list-style: none;
padding-left: 0;
line-height: 1.8;
}
.tips ul li::before { content: "▸ "; color: #60a5fa; }
.btn-group-vertical {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 12px;
}
.btn-group-vertical .btn { width: 100%; justify-content: center; }
.params-grid {
display: flex;
flex-direction: column;
gap: 16px;
flex: 1;
}
.params-grid .card { flex: 1; }
.form-group { margin-bottom: 10px; }
.form-group label {
display: block;
font-size: 13px;
font-weight: 500;
color: #c8d0dc;
margin-bottom: 4px;
}
.form-group label .hint {
font-weight: 400;
color: #64748b;
font-size: 11px;
margin-left: 4px;
}
select, input[type="file"] {
width: 100%;
padding: 8px 12px;
background: #0f1a2e;
border: 1.5px solid #25344f;
border-radius: 10px;
color: #e8edf5;
font-size: 14px;
outline: none;
transition: border-color 0.2s;
}
select:focus, input:focus { border-color: #3b82f6; }
select option { background: #0f1a2e; }
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.btn-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 6px;
}
.btn-row .btn {
flex: 1 1 auto;
min-width: 80px;
}
.btn-row .btn-sm {
flex: 0 1 auto;
padding: 8px 14px;
font-size: 14px;
min-width: 60px;
}
.btn-row .btn-main {
flex: 1;
min-width: 130px;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px 20px;
border: none;
border-radius: 12px;
font-size: 16px;
font-weight: 700;
cursor: pointer;
transition: transform 0.15s, box-shadow 0.2s, background 0.2s;
background: #2563eb;
color: #fff;
box-shadow: 0 4px 16px rgba(37,99,235,0.3);
min-width: 120px;
}
.btn:hover {
background: #1d4ed8;
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(37,99,235,0.4);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none !important;
box-shadow: none !important;
}
.btn-success { background: #16a34a; box-shadow: 0 4px 16px rgba(22,163,74,0.3); }
.btn-success:hover { background: #15803d; }
.btn-outline {
background: transparent;
border: 1.5px solid #3b82f6;
color: #60a5fa;
box-shadow: none;
}
.btn-outline:hover { background: rgba(37,99,235,0.1); }
.btn-danger { background: #dc2626; box-shadow: 0 4px 16px rgba(220,38,38,0.3); }
.btn-danger:hover { background: #b91c1c; }
.btn-sm {
padding: 6px 14px;
font-size: 14px;
min-width: auto;
box-shadow: 0 2px 8px rgba(37,99,235,0.2);
}
.btn-sm:hover { box-shadow: 0 4px 12px rgba(37,99,235,0.3); }
.status-badge {
display: inline-block;
padding: 4px 14px;
border-radius: 30px;
font-size: 13px;
font-weight: 600;
}
.status-badge.idle { background: #1e2d47; color: #94a3b8; }
.status-badge.running { background: #2563eb; color: #fff; animation: pulse 1.2s infinite; }
.status-badge.done { background: #16a34a; color: #fff; }
.status-badge.error { background: #b91c1c; color: #fff; }
@keyframes pulse { 0%,100%{opacity:0.6;} 50%{opacity:1;} }
.status-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.status-sub {
font-size: 13px;
color: #94a3b8;
margin-bottom: 4px;
}
.status-sub strong { color: #e8edf5; }
.progress-wrap { margin: 8px 0; }
.progress-bar-bg {
width: 100%;
height: 8px;
background: #1a2a44;
border-radius: 40px;
overflow: hidden;
}
.progress-bar-fill {
height: 100%;
width: 0%;
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
border-radius: 40px;
transition: width 0.4s ease;
}
.progress-label {
display: flex;
justify-content: space-between;
font-size: 13px;
color: #94a3b8;
margin-top: 2px;
}
.progress-label .percent { font-weight: 700; color: #e8edf5; }
.log-box {
background: #0a111f;
border-radius: 10px;
padding: 10px 14px;
max-height: 120px;
overflow-y: auto;
font-family: monospace;
font-size: 12px;
color: #94a3b8;
border: 1px solid #1e2d47;
white-space: pre-wrap;
word-break: break-all;
line-height: 1.5;
margin-top: 6px;
flex: 1;
min-height: 80px;
}
.log-box .highlight { color: #60a5fa; }
.log-box .done { color: #4ade80; }
.log-box .error { color: #f87171; }
.status-footer {
display: flex;
gap: 10px;
margin-top: 10px;
flex-wrap: wrap;
}
.status-footer .btn {
flex: 1;
min-width: 100px;
padding: 10px 16px;
}
.right-col .status-footer { margin-top: auto; }
/* 对比区(简化版) */
.compare-section {
margin-top: 10px;
border-top: 1px solid rgba(255,255,255,0.05);
padding-top: 16px;
}
.compare-section .section-title {
font-size: 18px;
font-weight: 600;
color: #94a3b8;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
.compare-container {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
background: #0a111f;
border-radius: 16px;
overflow: hidden;
border: 1px solid #1e2d47;
user-select: none;
}
.compare-container video {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: contain;
background: #0a111f;
}
.compare-container .video-left {
clip-path: inset(0 50% 0 0);
}
.compare-container .video-right {
clip-path: inset(0 0 0 50%);
}
.compare-slider {
position: absolute;
top: 0;
left: 50%;
width: 4px;
height: 100%;
background: #fff;
transform: translateX(-50%);
z-index: 10;
cursor: ew-resize;
box-shadow: 0 0 20px rgba(0,0,0,0.6);
transition: background 0.15s;
}
.compare-slider::after {
content: '⏸';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 22px;
color: #fff;
background: rgba(0,0,0,0.5);
border-radius: 50%;
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
border: 1px solid rgba(255,255,255,0.15);
}
.compare-slider:hover { background: #60a5fa; }
.compare-slider:hover::after { background: rgba(37,99,235,0.6); }
.compare-placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #475569;
font-size: 15px;
flex-direction: column;
gap: 8px;
}
.compare-placeholder .big-icon {
font-size: 44px;
opacity: 0.4;
}
.compare-controls {
display: flex;
gap: 12px;
margin-top: 12px;
flex-wrap: wrap;
}
.compare-controls .btn {
flex: 0 1 auto;
padding: 8px 20px;
font-size: 14px;
min-width: auto;
}
.compare-controls .btn-sm { padding: 6px 14px; }
@media (max-width: 1100px) { .compare-grid { grid-template-columns: 1fr 1fr; } }
@media (max-width: 700px) {
.compare-grid { grid-template-columns: 1fr; }
.grid-2 { grid-template-columns: 1fr; }
}
::-webkit-scrollbar { width: 4px; height: 4px; }
::-webkit-scrollbar-track { background: #0b1120; }
::-webkit-scrollbar-thumb { background: #2d4a72; border-radius: 10px; }
</style>
</head>
<body>
<div class="app">
<!-- 头部 -->
<div class="header">
<h1>🎯 b站:python代码翻译官AI视频工具箱</h1>
<div class="badge">⚡ 超分 + 补帧 · 滑动对比</div>
</div>
<!-- 主网格(保持不变) -->
<div class="main-grid">
<!-- 左侧:文件列表 -->
<div class="card left-col">
<div class="card-title">📁 源视频 <span>(支持多选)</span></div>
<div class="file-upload-area" id="dropZone">
<input type="file" id="fileInput" accept=".mp4,.mkv,.avi,.mov,.webm" multiple>
<div class="icon">🎬</div>
<div class="text">点击选择或拖拽多个视频</div>
<div class="filename" id="fileCountDisplay">未选择文件</div>
</div>
<div class="file-list" id="fileList" style="display:none;"></div>
<div class="tips">
<ul>
<li>「开始超分」仅放大画面,提升清晰度</li>
<li>「开始补帧」仅插帧,让动作更丝滑</li>
<li>超分完成后可再补帧,自动使用超分帧</li>
<li>批量处理依次执行,进度实时显示</li>
</ul>
</div>
<div class="btn-group-vertical">
<button class="btn btn-success" id="btnBatchFull" disabled>📦 批量处理(超分+补帧)</button>
<button class="btn btn-outline" id="btnClearFiles" style="min-width:auto;">🗑️ 清空列表</button>
<button class="btn btn-danger" id="btnCleanAll" style="min-width:auto; margin-top:4px;">🧹 清理视频</button>
</div>
</div>
<!-- 中间:参数(不变) -->
<div class="params-grid">
<div class="card">
<div class="card-title">⚙️ 超分参数</div>
<div class="grid-2">
<div class="form-group">
<label>放大倍数 <span class="hint">(Scale)</span></label>
<select id="scale">
<option value="2">2x (2K)</option>
<option value="3">3x (精细)</option>
<option value="4">4x (4K)</option>
</select>
</div>
<div class="form-group">
<label>去噪强度 <span class="hint">(Denoise)</span></label>
<select id="denoise">
<option value="2" selected>强度 2 (均衡)</option>
<option value="1">强度 1 (轻度)</option>
<option value="3">强度 3 (重度)</option>
<option value="0">强度 0 (纯锐化)</option>
</select>
</div>
</div>
<div class="grid-2">
<div class="form-group">
<label>GPU 加速 <span class="hint">(设备ID)</span></label>
<select id="gpu">
<option value="0">AMD / NVIDIA (设备0)</option>
<option value="-1">CPU 慢速</option>
</select>
</div>
<div class="form-group">
<label>分块大小 <span class="hint">(Tile)</span></label>
<select id="tile">
<option value="0">自动</option>
<option value="200">200 (6GB显存)</option>
<option value="400">400 (大显存)</option>
</select>
</div>
</div>
<div class="form-group">
<label>线程模式 <span class="hint">(Threads)</span></label>
<select id="threads">
<option value="1:2:2">1:2:2 (标准)</option>
<option value="2:2:2">2:2:2 (多核)</option>
</select>
</div>
<div class="btn-row">
<button class="btn btn-main" id="btnStart" disabled>▶ 开始超分</button>
<button class="btn btn-sm btn-outline" id="btnBatchUpscale" disabled>📦 批量超分</button>
</div>
</div>
<div class="card">
<div class="card-title">🎞️ 补帧参数 <span>(插帧)</span></div>
<div class="grid-2">
<div class="form-group">
<label>补帧模型 <span class="hint">(Model)</span></label>
<select id="interpModel">
<option value="rife-v4.6">rife-v4.6 (推荐)</option>
<option value="rife-anime">rife-anime (动漫优化)</option>
<option value="rife-HD">rife-HD (高清)</option>
<option value="rife-UHD">rife-UHD (超高清)</option>
<option value="rife-v4">rife-v4</option>
<option value="rife-v3.1">rife-v3.1</option>
</select>
</div>
<div class="form-group">
<label>补帧倍数 <span class="hint">(自动适配原帧率)</span></label>
<select id="interpFactor">
<option value="2.0">2.0x (30→60)</option>
<option value="2.5" selected>2.5x (24→60)</option>
<option value="3.0">3.0x (24→72)</option>
</select>
</div>
</div>
<div class="grid-2">
<div class="form-group">
<label>GPU 设备 <span class="hint">(同超分)</span></label>
<input type="text" id="interpGpu" value="0" readonly style="background:#0f1a2e;color:#94a3b8;">
</div>
<div class="form-group">
<label>分块大小 <span class="hint">(Tile)</span></label>
<select id="interpTile">
<option value="0">自动</option>
<option value="200">200 (6GB显存)</option>
<option value="400">400 (大显存)</option>
</select>
</div>
</div>
<div class="form-group">
<label>线程模式 <span class="hint">(Threads)</span></label>
<select id="interpThreads">
<option value="1:2:2">1:2:2 (标准)</option>
<option value="2:2:2">2:2:2 (多核)</option>
</select>
</div>
<div class="btn-row">
<button class="btn btn-success btn-main" id="btnInterp" disabled>🔁 开始补帧</button>
<button class="btn btn-sm btn-outline" id="btnBatchInterp" disabled>📦 批量补帧</button>
</div>
</div>
</div>
<!-- 右侧:状态(不变) -->
<div class="card right-col">
<div class="card-title">📊 任务状态</div>
<div class="status-header">
<span id="statusBadge" class="status-badge idle">就绪</span>
<button class="btn btn-outline btn-sm" id="btnStop" disabled>⏹ 停止</button>
</div>
<div class="status-sub" id="statusSub">等待操作...</div>
<div class="progress-wrap">
<div class="progress-bar-bg">
<div class="progress-bar-fill" id="progressFill" style="width:0%;"></div>
</div>
<div class="progress-label">
<span id="progressText">等待开始...</span>
<span class="percent" id="progressPercent">0%</span>
</div>
</div>
<div class="log-box" id="logBox">📌 选择视频,然后点击对应按钮。</div>
<div class="status-footer">
<button class="btn btn-success" id="btnOpenFolder" disabled>📂 打开文件夹</button>
</div>
</div>
</div>
<!-- ====== 对比区(修改为简化版) ====== -->
<div class="compare-section">
<div class="section-title">
<span>🖼️ 滑动对比</span>
<span style="font-size:13px; font-weight:400; color:#64748b; margin-left:auto;">
<span style="color:#60a5fa;">⬅ 原版</span> | <span style="color:#4ade80;">生成版 ➡</span>
</span>
</div>
<div class="compare-container" id="compareContainer">
<video class="video-left" id="videoLeft" muted playsinline></video>
<video class="video-right" id="videoRight" muted playsinline></video>
<div class="compare-slider" id="compareSlider"></div>
<div class="compare-placeholder" id="comparePlaceholder">
<div class="big-icon">🎞️</div>
<div>处理完成后,对比视频自动加载</div>
<div style="font-size:13px; color:#475569;">拖拽中间竖线查看细节</div>
</div>
</div>
<div class="compare-controls">
<button class="btn btn-outline btn-sm" id="btnPlayCompare">▶ 同步播放</button>
<button class="btn btn-outline btn-sm" id="btnResetCompare">⟲ 重置位置</button>
<span style="color:#64748b; font-size:13px; margin-left:auto; align-self:center;">💡 拖拽竖线左右滑动 · 同步播放</span>
</div>
</div>
<div style="padding-top:12px; border-top:1px solid rgba(255,255,255,0.05); display:flex; justify-content:space-between; flex-wrap:wrap; gap:6px; font-size:12px; color:#475569;">
<span>🔧 引擎: realcugan-ncnn-vulkan · rife-ncnn-vulkan</span>
<span>⚡ 支持 AMD / NVIDIA Vulkan 加速</span>
</div>
</div>
<script>
(function() {
'use strict';
// ---------- DOM ----------
const fileInput = document.getElementById('fileInput');
const fileCountDisplay = document.getElementById('fileCountDisplay');
const fileListEl = document.getElementById('fileList');
const dropZone = document.getElementById('dropZone');
const scale = document.getElementById('scale');
const denoise = document.getElementById('denoise');
const gpu = document.getElementById('gpu');
const tile = document.getElementById('tile');
const threads = document.getElementById('threads');
const interpModel = document.getElementById('interpModel');
const interpFactor = document.getElementById('interpFactor');
const interpGpu = document.getElementById('interpGpu');
const interpTile = document.getElementById('interpTile');
const interpThreads = document.getElementById('interpThreads');
const btnStart = document.getElementById('btnStart');
const btnInterp = document.getElementById('btnInterp');
const btnBatchUpscale = document.getElementById('btnBatchUpscale');
const btnBatchInterp = document.getElementById('btnBatchInterp');
const btnBatchFull = document.getElementById('btnBatchFull');
const btnClearFiles = document.getElementById('btnClearFiles');
const btnCleanAll = document.getElementById('btnCleanAll');
const btnStop = document.getElementById('btnStop');
const btnOpenFolder = document.getElementById('btnOpenFolder');
const statusBadge = document.getElementById('statusBadge');
const statusSub = document.getElementById('statusSub');
const progressFill = document.getElementById('progressFill');
const progressText = document.getElementById('progressText');
const progressPercent = document.getElementById('progressPercent');
const logBox = document.getElementById('logBox');
// 对比相关 DOM(简化)
const videoLeft = document.getElementById('videoLeft');
const videoRight = document.getElementById('videoRight');
const compareSlider = document.getElementById('compareSlider');
const comparePlaceholder = document.getElementById('comparePlaceholder');
const compareContainer = document.getElementById('compareContainer');
const btnPlayCompare = document.getElementById('btnPlayCompare');
const btnResetCompare = document.getElementById('btnResetCompare');
// ---------- 状态 ----------
let ws = null;
let isRunning = false;
let currentTaskId = null;
let outputFilePath = null;
let outputFolder = null;
let isComparePlaying = false;
let originalVideoPath = null;
let upscaledVideoPath = null;
let interpedVideoPath = null;
let lastUpscaledFramesDir = null;
let lastInputVideoPath = null;
let lastFps = null;
let fileQueue = [];
let isBatchProcessing = false;
let batchType = null;
let currentBatchIndex = 0;
let batchResolve = null;
let stopTimer = null;
let startTimeout = null;
// 用于 ETA 计算
let taskStartTime = null;
let lastProgressTime = null;
let lastPercent = 0;
// ---------- 辅助 ----------
function formatETA(seconds) {
if (seconds < 0 || !isFinite(seconds)) return '剩余时间未知';
seconds = Math.ceil(seconds);
if (seconds < 60) return `剩余 ${seconds} 秒`;
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
if (mins < 60) return `剩余 ${mins} 分 ${secs} 秒`;
const hours = Math.floor(mins / 60);
const remainMins = mins % 60;
return `剩余 ${hours} 小时 ${remainMins} 分 ${secs} 秒`;
}
function setStatus(text, type) {
statusBadge.textContent = text;
statusBadge.className = 'status-badge ' + type;
}
function appendLog(msg, cls = '') {
const line = document.createElement('div');
line.className = cls;
line.textContent = msg;
logBox.appendChild(line);
logBox.scrollTop = logBox.scrollHeight;
}
function clearLog() { logBox.innerHTML = ''; }
function setProgress(pct, label, eta) {
const v = Math.min(100, Math.max(0, pct));
progressFill.style.width = v + '%';
progressPercent.textContent = Math.round(v) + '%';
let displayLabel = label || '处理中...';
if (eta !== undefined && eta !== null && isFinite(eta) && eta > 0) {
displayLabel += ' | ' + formatETA(eta);
}
progressText.textContent = displayLabel;
}
function resetProgress() {
progressFill.style.width = '0%';
progressPercent.textContent = '0%';
progressText.textContent = '等待开始...';
taskStartTime = null;
lastProgressTime = null;
lastPercent = 0;
}
// ---------- 按钮启用逻辑 ----------
function updateButtons() {
const hasFiles = fileQueue.length > 0;
const idle = !isRunning && !isBatchProcessing;
btnStart.disabled = !(hasFiles && idle);
btnInterp.disabled = !(hasFiles && idle);
btnBatchUpscale.disabled = !(hasFiles && idle);
btnBatchInterp.disabled = !(hasFiles && idle);
btnBatchFull.disabled = !(hasFiles && idle);
btnStop.disabled = idle;
btnOpenFolder.disabled = (outputFolder === null);
btnCleanAll.disabled = !idle;
}
// ---------- 文件列表 ----------
function updateFileList() {
const count = fileQueue.length;
fileCountDisplay.textContent = count === 0 ? '未选择文件' : `已选 ${count} 个文件`;
if (count === 0) {
fileListEl.style.display = 'none';
} else {
fileListEl.style.display = 'block';
let html = '';
fileQueue.forEach(f => {
const statusText = f._status || '待处理';
const statusClass = f._statusClass || 'pending';
html += `<div class="file-item">
<span>${f.name}</span>
<span class="status ${statusClass}">${statusText}</span>
</div>`;
});
fileListEl.innerHTML = html;
}
updateButtons();
}
function clearFileList() {
fileQueue = [];
updateFileList();
// 不清除视频路径,保留对比
}
function handleFiles(files) {
if (!files || files.length === 0) return;
for (let i = 0; i < files.length; i++) {
const f = files[i];
f._status = '待处理';
f._statusClass = 'pending';
fileQueue.push(f);
}
updateFileList();
fileInput.value = '';
}
fileInput.addEventListener('change', function(e) {
if (this.files && this.files.length > 0) {
handleFiles(this.files);
}
});
dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.style.borderColor = '#3b82f6'; dropZone.style.background = 'rgba(37,99,235,0.08)'; });
dropZone.addEventListener('dragleave', () => { dropZone.style.borderColor = '#25344f'; dropZone.style.background = 'rgba(255,255,255,0.02)'; });
dropZone.addEventListener('drop', async (e) => {
e.preventDefault();
dropZone.style.borderColor = '#25344f';
dropZone.style.background = 'rgba(255,255,255,0.02)';
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
handleFiles(e.dataTransfer.files);
}
});
btnClearFiles.addEventListener('click', clearFileList);
// ---------- 清理视频 ----------
btnCleanAll.addEventListener('click', async function() {
if (isRunning || isBatchProcessing) {
appendLog('⚠️ 任务运行中,无法清理', 'error');
return;
}
try {
const resp = await fetch('/api/clean-all', { method: 'POST' });
const data = await resp.json();
if (data.ok) {
const count = data.count || 0;
if (count === 0) {
appendLog('📂 output 文件夹已为空', '');
return;
}
if (!confirm(`确定要删除 output 文件夹下的 ${count} 个文件吗?\n(包括所有视频和中间文件)`)) {
return;
}
const delResp = await fetch('/api/clean-all', { method: 'DELETE' });
const delData = await delResp.json();
if (delData.ok) {
appendLog(`✅ 已删除 ${delData.count} 个文件,释放 ${delData.freed_size}`, 'done');
clearFileList(); // ← 新增这一行
originalVideoPath = null;
upscaledVideoPath = null;
interpedVideoPath = null;
lastUpscaledFramesDir = null;
lastInputVideoPath = null;
outputFolder = null;
btnOpenFolder.disabled = true;
// 清空对比视频
videoLeft.src = '';
videoRight.src = '';
comparePlaceholder.style.display = 'flex';
} else {
appendLog('❌ 清理失败: ' + (delData.error || '未知错误'), 'error');
}
} else {
appendLog('❌ 获取文件列表失败: ' + (data.error || ''), 'error');
}
} catch (err) {
appendLog('❌ 请求失败: ' + err.message, 'error');
}
});
// ---------- WebSocket ----------
function connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = protocol + '//' + window.location.host + '/ws';
ws = new WebSocket(wsUrl);
ws.onopen = function() { appendLog('🔗 已连接至服务', 'highlight'); };
ws.onmessage = function(event) {
try {
const data = JSON.parse(event.data);
handleWsMessage(data);
} catch (e) {
appendLog('📨 ' + event.data);
}
};
ws.onclose = function() {
if (isRunning) {
appendLog('⚠️ 连接断开,任务可能中断', 'error');
setStatus('连接断开', 'error');
isRunning = false;
if (isBatchProcessing) {
isBatchProcessing = false;
batchResolve && batchResolve();
}
updateButtons();
}
resetProgress();
};
ws.onerror = function() { appendLog('❌ WebSocket 错误', 'error'); };
}
function handleWsMessage(data) {
switch (data.type) {
case 'started':
if (startTimeout) {
clearTimeout(startTimeout);
startTimeout = null;
}
isRunning = true;
currentTaskId = data.taskId;
setStatus('运行中', 'running');
updateButtons();
if (!isBatchProcessing) clearLog();
appendLog('🚀 任务已启动: ' + data.taskId, 'highlight');
taskStartTime = Date.now();
lastProgressTime = Date.now();
lastPercent = 0;
setProgress(0, '初始化...');
btnOpenFolder.disabled = true;
break;
case 'progress':
if (!isRunning && !isBatchProcessing) return;
const now = Date.now();
const percent = data.percent || 0;
let eta = null;
if (taskStartTime && percent > 0) {
const elapsed = (now - taskStartTime) / 1000;
if (elapsed > 0) {
const totalEstimate = elapsed / (percent / 100);
eta = totalEstimate - elapsed;
if (eta < 0) eta = 0;
}
}
setProgress(percent, data.message || '处理中...', eta);
if (data.frame) appendLog('🎞️ 帧 ' + data.frame + (data.total ? '/' + data.total : ''), '');
lastProgressTime = now;
lastPercent = percent;
break;
case 'log':
appendLog(data.message, data.level || '');
break;
case 'done':
isRunning = false;
setStatus('已完成', 'done');
updateButtons();
setProgress(100, '✅ 任务完成!', 0);
appendLog('🎉 任务完成!', 'done');
taskStartTime = null;
if (data.output) {
outputFilePath = data.output;
outputFolder = data.folder || data.output.substring(0, data.output.lastIndexOf('\\'));
btnOpenFolder.disabled = false;
if (data.is_interp) {
interpedVideoPath = data.output;
appendLog('💡 补帧视频已加载', 'done');
if (originalVideoPath) {
loadCompareVideos(originalVideoPath, data.output);
} else if (data.input) {
originalVideoPath = data.input;
loadCompareVideos(data.input, data.output);
}
} else {
upscaledVideoPath = data.output;
originalVideoPath = data.input;
lastUpscaledFramesDir = data.upscaled_frames_dir;
lastInputVideoPath = data.input;
lastFps = data.fps;
appendLog('💡 超分完成,可点击「开始补帧」', 'highlight');
if (originalVideoPath && upscaledVideoPath) {
loadCompareVideos(originalVideoPath, upscaledVideoPath);
}
}
}
if (isBatchProcessing) {
if (currentBatchIndex < fileQueue.length) {
fileQueue[currentBatchIndex]._status = '已完成';
fileQueue[currentBatchIndex]._statusClass = 'done';
updateFileList();
}
if (batchResolve) {
batchResolve();
batchResolve = null;
}
}
break;
case 'error':
isRunning = false;
setStatus('错误', 'error');
updateButtons();
appendLog('❌ ' + data.message, 'error');
resetProgress();
if (isBatchProcessing) {
if (currentBatchIndex < fileQueue.length) {
fileQueue[currentBatchIndex]._status = '错误';
fileQueue[currentBatchIndex]._statusClass = 'error';
updateFileList();
}
if (batchResolve) {
batchResolve();
batchResolve = null;
}
}
break;
case 'stopped':
if (stopTimer) {
clearTimeout(stopTimer);
stopTimer = null;
}
if (startTimeout) {
clearTimeout(startTimeout);
startTimeout = null;
}
isRunning = false;
setStatus('已停止', 'idle');
updateButtons();
appendLog('⏹ 任务已停止', '');
resetProgress();
if (isBatchProcessing) {
isBatchProcessing = false;
if (batchResolve) {
batchResolve();
batchResolve = null;
}
}
break;
default:
appendLog('📨 ' + JSON.stringify(data));
}
}
// ---------- 对比视频加载(简化) ----------
function loadCompareVideos(leftPath, rightPath) {
const leftUrl = '/videos?path=' + encodeURIComponent(leftPath);
const rightUrl = '/videos?path=' + encodeURIComponent(rightPath);
videoLeft.src = leftUrl;
videoRight.src = rightUrl;
comparePlaceholder.style.display = 'none';
videoLeft.load();
videoRight.load();
// 同步时间
videoLeft.addEventListener('timeupdate', () => {
if (Math.abs(videoLeft.currentTime - videoRight.currentTime) > 0.1) videoRight.currentTime = videoLeft.currentTime;
});
videoRight.addEventListener('timeupdate', () => {
if (Math.abs(videoRight.currentTime - videoLeft.currentTime) > 0.1) videoLeft.currentTime = videoRight.currentTime;
});
// 同步播放/暂停
videoLeft.addEventListener('play', () => { if (videoRight.paused) videoRight.play(); });
videoRight.addEventListener('play', () => { if (videoLeft.paused) videoLeft.play(); });
videoLeft.addEventListener('pause', () => { if (!videoRight.paused) videoRight.pause(); });
videoRight.addEventListener('pause', () => { if (!videoLeft.paused) videoLeft.pause(); });
appendLog('🔄 对比视频已加载', 'done');
}
// ---------- 滑动条 ----------
let isDragging = false;
function updateSlider(x) {
const rect = compareContainer.getBoundingClientRect();
let pos = (x - rect.left) / rect.width;
pos = Math.max(0, Math.min(1, pos));
const pct = pos * 100;
compareSlider.style.left = pct + '%';
videoLeft.style.clipPath = 'inset(0 ' + (100 - pct) + '% 0 0)';
videoRight.style.clipPath = 'inset(0 0 0 ' + pct + '%)';
}
compareSlider.addEventListener('mousedown', (e) => { isDragging = true; e.preventDefault(); });
document.addEventListener('mousemove', (e) => { if (isDragging) updateSlider(e.clientX); });
document.addEventListener('mouseup', () => { isDragging = false; });
compareSlider.addEventListener('touchstart', (e) => { isDragging = true; e.preventDefault(); });
document.addEventListener('touchmove', (e) => { if (isDragging && e.touches[0]) updateSlider(e.touches[0].clientX); });
document.addEventListener('touchend', () => { isDragging = false; });
compareContainer.addEventListener('click', (e) => {
if (e.target === compareSlider || e.target.closest('.compare-slider')) return;
updateSlider(e.clientX);
});
// ---------- 播放控制 ----------
btnPlayCompare.addEventListener('click', () => {
if (videoLeft.src && videoRight.src) {
if (videoLeft.paused) {
videoLeft.play();
videoRight.play();
isComparePlaying = true;
btnPlayCompare.textContent = '⏸ 暂停';
} else {
videoLeft.pause();
videoRight.pause();
isComparePlaying = false;
btnPlayCompare.textContent = '▶ 播放';
}
} else {
appendLog('⚠️ 请先完成超分或补帧任务', 'error');
}
});
btnResetCompare.addEventListener('click', () => {
if (videoLeft.src && videoRight.src) {
videoLeft.currentTime = 0;
videoRight.currentTime = 0;
if (isComparePlaying) { videoLeft.play(); videoRight.play(); }
}
updateSlider(compareContainer.getBoundingClientRect().left + compareContainer.offsetWidth / 2);
});
// ---------- 单文件任务 ----------
async function startSingle(taskType) {
if (isRunning || isBatchProcessing) {
appendLog('⚠️ 已有任务运行中,请等待或停止', 'error');
return;
}
if (fileQueue.length === 0) {
appendLog('⚠️ 请先选择视频', 'error');
return;
}
const file = fileQueue[0];
clearLog();
const taskLabel = taskType === 'upscale' ? '超分' : '补帧';
appendLog(`📌 开始单文件处理: ${file.name}`, 'highlight');
appendLog(`📌 任务类型: ${taskLabel}`, 'highlight');
setStatus(`${taskLabel}中...`, 'running');
statusSub.textContent = `${taskLabel}准备中...`;
btnStart.disabled = true;
btnInterp.disabled = true;
btnBatchUpscale.disabled = true;
btnBatchInterp.disabled = true;
btnBatchFull.disabled = true;
btnStop.disabled = false;
btnOpenFolder.disabled = true;
const params = {
scale: parseInt(scale.value),
denoise: parseInt(denoise.value),
gpu: parseInt(gpu.value),
tile: parseInt(tile.value),
threads: threads.value,
interp_model: interpModel.value,
interp_factor: parseFloat(interpFactor.value),
interp_gpu: parseInt(interpGpu.value) || 0,
interp_tile: parseInt(interpTile.value) || 0,
interp_threads: interpThreads.value,
};
try {
appendLog('📤 正在上传文件...', 'highlight');
const formData = new FormData();
formData.append('file', file);
const uploadRes = await fetch('/upload', { method: 'POST', body: formData });
const uploadData = await uploadRes.json();
if (!uploadData.ok) {
throw new Error(uploadData.error || '上传失败');
}
const filename = uploadData.filename;
appendLog('✅ 上传成功: ' + filename, 'done');
let taskParams = {
type: (taskType === 'upscale') ? 'start' : (taskType === 'interp' ? 'interp' : 'full'),
scale: params.scale,
denoise: params.denoise,
gpu: params.gpu,
tile: params.tile,
threads: params.threads,
interp_model: params.interp_model,
interp_factor: params.interp_factor,
interp_gpu: params.interp_gpu,
interp_tile: params.interp_tile,
interp_threads: params.interp_threads,
};
if (taskType === 'interp') {
if (lastUpscaledFramesDir && lastInputVideoPath) {
taskParams.frames_dir = lastUpscaledFramesDir;
taskParams.input_video = lastInputVideoPath;
appendLog('📌 检测到已超分,将基于超分帧进行补帧', 'highlight');
} else {
taskParams.file = filename;
}
} else if (taskType === 'upscale' || taskType === 'full') {
taskParams.file = filename;
}
if (!ws || ws.readyState !== WebSocket.OPEN) {
throw new Error('WebSocket 未连接,请刷新页面');
}
ws.send(JSON.stringify(taskParams));
appendLog('📤 任务已发送,等待后端响应...', 'highlight');
if (startTimeout) clearTimeout(startTimeout);
startTimeout = setTimeout(() => {
startTimeout = null;
if (!isRunning && !isBatchProcessing) {
appendLog('❌ 后端未响应(超时),可能服务异常或可执行文件缺失', 'error');
setStatus('超时', 'error');
updateButtons();
resetProgress();
}
}, 10000);
} catch (err) {
appendLog('❌ 启动任务失败: ' + err.message, 'error');
setStatus('错误', 'error');
updateButtons();
resetProgress();
}
}
// ---------- 批量任务 ----------
async function startBatch(taskType) {
if (isRunning || isBatchProcessing) {
appendLog('⚠️ 已有任务运行中,请等待或停止', 'error');
return;
}
if (fileQueue.length === 0) {
appendLog('⚠️ 文件列表为空', 'error');
return;
}
if (!ws || ws.readyState !== WebSocket.OPEN) {
appendLog('⚠️ WebSocket 未连接,正在尝试重连...', 'error');
connectWebSocket();
await new Promise(resolve => setTimeout(resolve, 500));
if (!ws || ws.readyState !== WebSocket.OPEN) {
appendLog('❌ WebSocket 连接失败,请刷新页面', 'error');
return;
}
}
isBatchProcessing = true;
batchType = taskType;
currentBatchIndex = 0;
const taskLabel = taskType === 'upscale' ? '批量超分' : (taskType === 'interp' ? '批量补帧' : '批量全流程');
setStatus('批量处理', 'running');
statusSub.textContent = `${taskLabel} (0/${fileQueue.length})`;
const params = {
scale: parseInt(scale.value),
denoise: parseInt(denoise.value),
gpu: parseInt(gpu.value),
tile: parseInt(tile.value),
threads: threads.value,
interp_model: interpModel.value,
interp_factor: parseFloat(interpFactor.value),
interp_gpu: parseInt(interpGpu.value) || 0,
interp_tile: parseInt(interpTile.value) || 0,
interp_threads: interpThreads.value,
};
btnBatchUpscale.disabled = true;
btnBatchInterp.disabled = true;
btnBatchFull.disabled = true;
btnStart.disabled = true;
btnInterp.disabled = true;
btnStop.disabled = false;
btnOpenFolder.disabled = true;
try {
for (let i = 0; i < fileQueue.length; i++) {
currentBatchIndex = i;
const file = fileQueue[i];
statusSub.textContent = `${taskLabel} (${i+1}/${fileQueue.length}) - ${file.name}`;
file._status = '处理中';
file._statusClass = 'processing';
updateFileList();
appendLog(`📦 开始处理 (${i+1}/${fileQueue.length}): ${file.name}`, 'highlight');
try {
const formData = new FormData();
formData.append('file', file);
const uploadRes = await fetch('/upload', { method: 'POST', body: formData });
const uploadData = await uploadRes.json();
if (!uploadData.ok) {
throw new Error(uploadData.error || '上传失败');
}
const filename = uploadData.filename;
appendLog(`📤 上传: ${file.name} → ${filename}`, 'highlight');
let taskParams = {
type: (taskType === 'upscale') ? 'start' : (taskType === 'interp' ? 'interp' : 'full'),
scale: params.scale,
denoise: params.denoise,
gpu: params.gpu,
tile: params.tile,
threads: params.threads,
interp_model: params.interp_model,
interp_factor: params.interp_factor,
interp_gpu: params.interp_gpu,
interp_tile: params.interp_tile,
interp_threads: params.interp_threads,
file: filename,
};
await new Promise((resolve, reject) => {
if (!ws || ws.readyState !== WebSocket.OPEN) {
reject(new Error('WebSocket未连接'));
return;
}
batchResolve = resolve;
ws.send(JSON.stringify(taskParams));
appendLog('📤 任务已发送,等待完成...', 'highlight');
const timeout = setTimeout(() => {
batchResolve = null;
reject(new Error('任务超时(10分钟)'));
}, 600000);
resolve._timeout = timeout;
});
} catch (err) {
appendLog(`❌ 处理 ${file.name} 失败: ${err.message}`, 'error');
file._status = '错误';
file._statusClass = 'error';
updateFileList();
}
}
appendLog('🎉 批量处理全部完成!', 'done');
statusSub.textContent = '批量处理完成';
} catch (err) {
appendLog('❌ 批量处理异常: ' + err.message, 'error');
} finally {
isBatchProcessing = false;
setStatus('就绪', 'idle');
updateButtons();
btnStop.disabled = true;
statusSub.textContent = '就绪';
batchResolve = null;
resetProgress();
}
}
// ---------- 按钮绑定 ----------
btnStart.addEventListener('click', () => startSingle('upscale'));
btnInterp.addEventListener('click', () => startSingle('interp'));
btnBatchUpscale.addEventListener('click', () => startBatch('upscale'));
btnBatchInterp.addEventListener('click', () => startBatch('interp'));
btnBatchFull.addEventListener('click', () => startBatch('full'));
// ---------- 停止 ----------
function stopTask() {
if (!isRunning && !isBatchProcessing) {
appendLog('⚠️ 没有运行中的任务', '');
return;
}
if (stopTimer) {
clearTimeout(stopTimer);
stopTimer = null;
}
setStatus('停止中...', 'idle');
btnStop.disabled = true;
statusSub.textContent = '正在停止...';
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'stop', taskId: currentTaskId }));
appendLog('⏹ 已发送停止命令', '');
} else {
appendLog('⚠️ WebSocket未连接,无法发送停止命令', 'error');
}
stopTimer = setTimeout(() => {
stopTimer = null;
if (!isRunning && !isBatchProcessing) return;
isRunning = false;
isBatchProcessing = false;
currentTaskId = null;
if (batchResolve) {
batchResolve();
batchResolve = null;
}
setStatus('已停止(强制)', 'idle');
statusSub.textContent = '已强制停止';
updateButtons();
btnStop.disabled = true;
appendLog('⏹ 强制停止任务(后端未及时响应)', 'error');
resetProgress();
}, 3000);
}
btnStop.addEventListener('click', stopTask);
// ---------- 打开文件夹 ----------
function openOutputFolder() {
if (!outputFolder) { appendLog('⚠️ 尚未生成输出文件', 'error'); return; }
fetch('/api/open-folder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ folder: outputFolder })
})
.then(r => r.json())
.then(data => {
if (data.ok) appendLog('📂 已打开文件夹: ' + outputFolder, 'done');
else appendLog('❌ 打开失败: ' + (data.error || ''), 'error');
})
.catch(err => appendLog('❌ 请求失败: ' + err.message, 'error'));
}
btnOpenFolder.addEventListener('click', openOutputFolder);
// ---------- 初始化 ----------
function init() {
connectWebSocket();
resetProgress();
setStatus('就绪', 'idle');
updateButtons();
btnStop.disabled = true;
btnOpenFolder.disabled = true;
clearFileList();
appendLog('💡 选择多个视频,使用批量处理', '');
appendLog('📌 也支持单个文件操作', '');
comparePlaceholder.style.display = 'flex';
}
init();
})();
</script>
</body>
</html>
代码index。html在上面
复制到你的项目里面就可以运行了。
-
AI 超分 + AI 插帧动漫修复天花板!Real-CUGAN 超分辨率 + RIFE 补帧,模糊老番一键 4K 60FPS 高清重制 动漫 AI 视频修复教程|AI 超分辨率 + AI 插帧双引擎,低清老动画去噪放大变 4K 顺滑 60 帧 老番画质救星!AI 超分插帧一体工具,动漫超分辨率无损放大,24 帧卡顿动画秒变流畅 4K 动漫二创必备 AI 工具|Real-CUGAN 超分辨率 + RIFE 插帧,模糊动漫视频高清修复无马赛克 全网动漫高清天花板!AI 超分辨率超分 + AI 智能插帧,老旧低清番剧 4K 60FPS 重制教程 2. 短标题(适合封面标题、搜索精简,关键词密集) AI 超分插帧|动漫超分辨率修复,老番 4K 60 帧高清重制 Real-CUGAN 超分 + RIFE 插帧,动漫 AI 视频画质增强神器 动漫超分辨率修复,AI 超分 + 插帧一键去除模糊卡顿 老番高清修复天花板:AI 超分辨率 + 智能插帧一体化方案 二、SEO 长尾关键词拆解(标题包含高搜索词,精准抓搜索流量) 核心高搜词: AI 超分、AI 插帧、动漫视频修复、超分辨率、老番 4K 修复、60 帧补帧、Real-CUGAN、RIFE、动漫去马赛克、低清动画变高清、动漫二创素材增强、老旧动画画质提升 长尾组合词: 动漫 AI 超分辨率视频修复、老番 AI 超分插帧教程、模糊动漫 4K 高清重制、24 帧动漫补帧 60 帧、RealCUGAN 动漫超分工具 三、分类适配标题(按使用场景区分) 【教程向|B 站技术分享 / CSDN 博客,适合搜教程用户】 完整教程:AI 超分 + AI 插帧动漫视频修复,动漫超分辨率把 480P 老番无损转为 4K 60FPS 开源工具实操|Real-CUGAN 超分辨率搭配 RIFE 插帧,动漫模糊卡顿视频一键高清修复 动漫超分辨率详解!AI 超分 + 智能插帧双功能,解决老番马赛克、画面卡顿两大痛点 【工具宣传向|软件 / 工具包介绍,吸引找工具的搜索用户】 动漫高清天花板!集成 AI 超分辨率 + AI 插帧,一站式动漫视频高清修复增强工具 专为二次元打造 AI 引擎,Real-CUGAN 动漫超分 + RIFE 插帧,低清动画秒变顺滑 4K 超清 动漫超分辨率修复神器,同时支持 AI 放大超分 + 智能插帧,老番画质重塑一步到位 【短视频吸睛向|抖音 / 快手,短句抓眼球,关键词前置】 AI 超分 + 插帧天花板!模糊老动漫超分辨率修复,直接 4K 60 帧丝滑高清 老番救星!动漫 AI 超分辨率超分 + 智能插帧,马赛克卡顿全部消失 二次元画质升级|AI 超分放大 + 插帧补帧,老旧动画高清重制 四、最优推荐主标题(兼顾流量、关键词、吸引力,直接可用) 首选完整版: AI 超分 + AI 插帧动漫高清天花板!AI 超分辨率视频修复,模糊老番一键 4K 60FPS 重制 精简封面版: 动漫 AI 超分插帧|超分辨率修复,老番 4K 60 帧高清重制
更多推荐



所有评论(0)