Fix watermark detection + S3 upload + NVENC color + task download on S3 failure
Docker Build and Push / check-secrets (push) Successful in 11s
Docker Build and Push / build-and-push (cpu, latest) (push) Has been skipped
Docker Build and Push / build-and-push (cuda, 11.8) (push) Has been skipped
Docker Build and Push / build-and-push (cuda, 12.6) (push) Has been skipped
Docker Build and Push / build-and-push (cuda, 12.8) (push) Has been skipped
Docker Build and Push / build-and-push (directml, latest) (push) Has been skipped
Build Windows CPU / build (push) Has been cancelled
Build Windows CUDA 11.8 / build (push) Has been cancelled
Build Windows CUDA 12.6 / build (push) Has been cancelled
Build Windows CUDA 12.8 / build (push) Has been cancelled
Build Windows DirectML / build (push) Has been cancelled

- smart_detect.py: rewrite detect_static_regions using saturation-signature method
  (high-sat logo frames vs no-logo frames diff) to handle animated/periodic watermarks
  that break temporal-std approach; merge top-region boxes; add print debugging
- web_app.py: fix S3 upload (async chunked streaming, error visible in task log);
  s3_failed flag keeps task downloadable even when S3 upload fails; add s3_failed
  column to DB; download/stream endpoints allow s3_failed tasks
- web/index.html: show play/download buttons for s3_failed tasks; task sort by
  created_at desc; watermark mode label update; isLocalDone() helper
- video_io.py: add -vf format=yuv420p before h264_nvenc to fix green video

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
liuchen
2026-06-28 00:20:44 +08:00
co-authored by Claude Sonnet 4.6
parent c65ec20119
commit a1eb5bb28e
3 changed files with 168 additions and 84 deletions
+76 -55
View File
@@ -332,15 +332,13 @@ def detect_color_logo(video_path: str, sample_n: int = 40):
return merged return merged
def detect_watermark_per_frame(video_path: str, sample_step: int = 2, pad: int = 6): def detect_watermark_per_frame(video_path: str, sample_step: int = None, pad: int = 6):
"""逐帧检测半透明/移动文字水印,返回 {frame_no: [(xmin,xmax,ymin,ymax), ...]}。 """逐帧检测半透明/移动文字水印,返回 {frame_no: [(xmin,xmax,ymin,ymax), ...]}。
适用于 OCR 检测不到的淡色水印(白字、半透明)以及垂直滚动水印。 适用于 OCR 检测不到的淡色水印(白字、半透明)以及垂直滚动水印。
原理: 采样:每秒一帧(cap.set 跳帧,长视频也快)。
1. 每帧 white top-hat 提取亮文字笔画 → 候选框 判据:基于轨迹自身(绝对出现秒数 + 出现时段内密度),不依赖视频总长,
2. 粗网格投票:水印(固定或缓慢移动)会让相邻网格被大量帧命中, 因此「只在部分时段出现的滚动水印」「贯穿全程偶有漏检的固定水印」都能被保留。
瞬时亮点(皮肤反光等)命中少 → 过滤掉
3. 每帧只保留落在「水印网格」内的候选框,并补全相邻帧(插值)
""" """
cap = cv2.VideoCapture(video_path) cap = cv2.VideoCapture(video_path)
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
@@ -351,88 +349,111 @@ def detect_watermark_per_frame(video_path: str, sample_step: int = 2, pad: int =
tophat_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (15, 15)) tophat_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (15, 15))
conn_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (25, 9)) conn_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (25, 9))
# 阶段1:逐帧候选(1-based 帧号,与 find_subtitle_frame_no/inpaint 管线一致) # 阶段1:每秒一帧采样(cap.set 跳帧;frame_no 用 1-based 与 inpaint 管线一致)
frame_boxes = {} step = max(1, int(round(fps))) # 每秒对应的帧步长
fno = 0 sample_frames = list(range(0, total, step))
while True: frame_boxes = {} # sample_idx -> [box]
sidx_to_fno = {} # sample_idx -> 1-based 帧号
for si, pos in enumerate(sample_frames):
cap.set(cv2.CAP_PROP_POS_FRAMES, pos)
ret, frame = cap.read() ret, frame = cap.read()
if not ret: if not ret:
break continue
fno += 1 gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
if (fno - 1) % sample_step == 0: frame_boxes[si] = _frame_text_candidates(gray, tophat_kernel, conn_kernel)
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) sidx_to_fno[si] = pos + 1
frame_boxes[fno] = _frame_text_candidates(gray, tophat_kernel, conn_kernel)
cap.release() cap.release()
n_sampled = len(frame_boxes) n_sampled = len(frame_boxes)
if n_sampled == 0: if n_sampled == 0:
return {} return {}
# 阶段2:轨迹连接 # 阶段2:轨迹连接(按「采样序号」相邻,间断容忍 3 个采样点=3秒)
# 真水印(固定/垂直滚动)跨帧形成连续轨迹:宽度稳定、X几乎不变、Y平滑变化。 sampled_sidx = sorted(frame_boxes.keys())
# 人体亮区(皮肤/内衣)断续出现、形状多变 → 轨迹短或宽度抖动 → 被过滤。
sampled_nos = sorted(frame_boxes.keys())
def cx_of(b): return (b[0] + b[1]) / 2 def cx_of(b): return (b[0] + b[1]) / 2
def cy_of(b): return (b[2] + b[3]) / 2 def cy_of(b): return (b[2] + b[3]) / 2
def wid_of(b): return b[1] - b[0] def wid_of(b): return b[1] - b[0]
tracks = [] # 每条: {"boxes": {fno:box}, "last_f":, "last_box":} GAP_TOL = 5 # 允许漏检 5 个采样点(5秒,滚动水印经过浅背景会暂时漏检)
for f in sampled_nos: tracks = [] # {"boxes": {sidx:box}, "last_si":, "last_box":}
for b in frame_boxes[f]: for si in sampled_sidx:
for b in frame_boxes[si]:
best, best_d = None, 1e9 best, best_d = None, 1e9
for tr in tracks: for tr in tracks:
lb = tr["last_box"] if si - tr["last_si"] > GAP_TOL:
# 已结束的轨迹(间隔太久)跳过
if f - tr["last_f"] > sample_step * 6:
continue continue
lb = tr["last_box"]
gap = si - tr["last_si"]
dx = abs(cx_of(b) - cx_of(lb)) dx = abs(cx_of(b) - cx_of(lb))
dy = abs(cy_of(b) - cy_of(lb))
dw = abs(wid_of(b) - wid_of(lb)) dw = abs(wid_of(b) - wid_of(lb))
# 同一水印:X几乎不变(<40)、宽度接近(<25)、Y可移动(滚动) dy = abs(cy_of(b) - cy_of(lb))
if dx < 40 and dw < 25 and dy < 120: # 同一水印:X几乎不变、宽度接近;Y 可滚动(按间隔放宽,每秒最多移动~60px)
d = dx * 2 + dw * 2 + dy * 0.3 if dx < 45 and dw < 28 and dy < 60 * max(1, gap):
d = dx * 2 + dw * 2 + dy * 0.2
if d < best_d: if d < best_d:
best, best_d = tr, d best, best_d = tr, d
if best is None: if best is None:
tracks.append({"boxes": {f: b}, "last_f": f, "last_box": b}) tracks.append({"boxes": {si: b}, "last_si": si, "last_box": b})
else: else:
best["boxes"][f] = b best["boxes"][si] = b
best["last_f"] = f best["last_si"] = si
best["last_box"] = b best["last_box"] = b
# 阶段3:筛选有效轨迹 # 阶段3:筛选有效轨迹 —— 不依赖视频总长
# 出现帧数 >= 35% 且 宽度稳定(变异系数<0.35) # ① 至少出现 MIN_SECONDS 秒(绝对阈值,滤掉一闪而过的噪点)
min_frames = max(3, int(n_sampled * 0.35)) # ② 在其出现时段 [first,last] 内密度高(持续存在,不是零散巧合)
per_frame = {} # ③ 宽度稳定(文字水印宽度恒定,人体亮区会抖)
valid_tracks = 0 MIN_SECONDS = 4
valid = [] # 有效轨迹的 boxes dict 列表
for tr in tracks: for tr in tracks:
boxes = tr["boxes"] boxes = tr["boxes"]
if len(boxes) < min_frames: n_pts = len(boxes)
if n_pts < MIN_SECONDS:
continue
sids = sorted(boxes.keys())
span = sids[-1] - sids[0] + 1
density = n_pts / span
if density < 0.5:
continue continue
widths = [wid_of(b) for b in boxes.values()] widths = [wid_of(b) for b in boxes.values()]
wmean = sum(widths) / len(widths) wmean = sum(widths) / len(widths)
wstd = (sum((x - wmean) ** 2 for x in widths) / len(widths)) ** 0.5 wstd = (sum((x - wmean) ** 2 for x in widths) / len(widths)) ** 0.5
if wmean > 0 and wstd / wmean > 0.35: if wmean > 0 and wstd / wmean > 0.4:
continue # 宽度抖动大 → 不是文字水印 continue
valid_tracks += 1 valid.append(boxes)
for f, b in boxes.items():
x1, x2, y1, y2 = b
per_frame.setdefault(f, []).append(
(max(0, x1 - pad), min(w, x2 + pad), max(0, y1 - pad), min(h, y2 + pad)))
print(f"[WM-perframe] 采样{n_sampled}帧 轨迹{len(tracks)}条 有效{valid_tracks}条(>={min_frames}帧)", flush=True) print(f"[WM-perframe] 每秒一帧采样{n_sampled}帧 轨迹{len(tracks)}条 有效{len(valid)}条", flush=True)
# 阶段4:插值填充文字水印框(用最近的已检测帧) # 阶段4:每条有效轨迹在其 [first,last] 时段内逐采样点线性插值,
# 补全中间漏检的采样点(滚动水印经过浅背景漏检时也能被覆盖),
# 再把每个采样点的框展开到对应的 1 秒帧区间,y 方向外扩覆盖滚动移动。
ROLL_PAD = 16
full = {} full = {}
pf_nos = sorted(per_frame.keys())
if pf_nos: def lerp_box(ba, bb, t):
for f, nf in zip(pf_nos, pf_nos[1:]): return tuple(int(round(ba[i] + (bb[i] - ba[i]) * t)) for i in range(4))
full[f] = list(per_frame[f])
if nf - f <= sample_step * 3: for boxes in valid:
for fill in range(f + 1, nf): sids = sorted(boxes.keys())
full[fill] = list(per_frame[f]) first, last = sids[0], sids[-1]
full[pf_nos[-1]] = list(per_frame[pf_nos[-1]]) for si in range(first, last + 1):
if si in boxes:
b = boxes[si]
else:
# 线性插值:前后最近的已知采样点
prev = max(s for s in sids if s < si)
nxt = min(s for s in sids if s > si)
b = lerp_box(boxes[prev], boxes[nxt], (si - prev) / (nxt - prev))
x1, x2, y1, y2 = b
bx = (max(0, x1 - pad), min(w, x2 + pad),
max(0, y1 - ROLL_PAD), min(h, y2 + ROLL_PAD))
# 该采样点覆盖 [本帧, 下一采样帧) 的所有原始帧
f_start = si * step + 1
f_end = (si + 1) * step + 1
for f in range(f_start, min(f_end, total + 1)):
full.setdefault(f, [])
full[f].append(bx)
# 阶段5:彩色 logo(高饱和+静止)→ 静止框,注入所有帧 # 阶段5:彩色 logo(高饱和+静止)→ 静止框,注入所有帧
color_logos = detect_color_logo(video_path) color_logos = detect_color_logo(video_path)
+14 -2
View File
@@ -944,7 +944,7 @@ function renderTasks() {
<td style="white-space:nowrap;font-size:12px;color:var(--muted)">${fmtDur(t)}</td> <td style="white-space:nowrap;font-size:12px;color:var(--muted)">${fmtDur(t)}</td>
<td> <td>
<div style="display:flex;gap:6px"> <div style="display:flex;gap:6px">
${t.status==='done' ? ` ${isLocalDone(t) ? `
<button class="button secondary" style="min-height:28px;padding:0 10px;font-size:12px" onclick="event.stopPropagation();playVideo('${t.id}')">▶ 播放</button> <button class="button secondary" style="min-height:28px;padding:0 10px;font-size:12px" onclick="event.stopPropagation();playVideo('${t.id}')">▶ 播放</button>
` : ''} ` : ''}
<button class="button secondary" style="min-height:28px;padding:0 10px;font-size:12px;color:var(--bad)" onclick="event.stopPropagation();deleteTask('${t.id}')">✕</button> <button class="button secondary" style="min-height:28px;padding:0 10px;font-size:12px;color:var(--bad)" onclick="event.stopPropagation();deleteTask('${t.id}')">✕</button>
@@ -954,7 +954,13 @@ function renderTasks() {
}).join(''); }).join('');
} }
function isLocalDone(t) {
// 本地处理完成(含 S3 上传失败但文件在本地的情况)
return t.status === 'done' || (t.status === 'error' && t.s3_failed && t.output_path);
}
function taskStatus(t) { function taskStatus(t) {
if (t.status === 'error' && t.s3_failed) return { cls:'warn', label:'完成(S3失败)' };
const map = { const map = {
pending: { cls:'neutral', label:'等待中' }, pending: { cls:'neutral', label:'等待中' },
downloading: { cls:'warn', label:'下载中' }, downloading: { cls:'warn', label:'下载中' },
@@ -994,6 +1000,10 @@ function progressCell(t) {
if (t.status === 'done') { if (t.status === 'done') {
return `<div class="prog-track"><div class="prog-fill good" style="width:100%"></div></div>`; return `<div class="prog-track"><div class="prog-fill good" style="width:100%"></div></div>`;
} }
if (t.status === 'error' && t.s3_failed) {
return `<div class="prog-track"><div class="prog-fill good" style="width:100%"></div></div>
<div style="font-size:10px;color:var(--warn);margin-top:2px">S3上传失败,本地可下载</div>`;
}
if (t.status === 'error') { if (t.status === 'error') {
return `<div style="font-size:11px;color:var(--bad);max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(t.error||'')}">${esc((t.error||'').slice(0,60))}</div>`; return `<div style="font-size:11px;color:var(--bad);max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(t.error||'')}">${esc((t.error||'').slice(0,60))}</div>`;
} }
@@ -1097,12 +1107,14 @@ function updateDetailDynamic(t) {
} }
const actEl = document.getElementById('detailActions'); const actEl = document.getElementById('detailActions');
if (actEl && t.status === 'done') { if (actEl && isLocalDone(t)) {
// 只在完成时补充按钮,避免重复加 // 只在完成时补充按钮,避免重复加
if (!actEl.querySelector('.btn-output')) { if (!actEl.querySelector('.btn-output')) {
const s3Warn = t.s3_failed ? `<span style="font-size:11px;color:var(--warn);margin-left:8px">⚠ S3上传失败</span>` : '';
actEl.insertAdjacentHTML('beforeend', ` actEl.insertAdjacentHTML('beforeend', `
<button class="button btn-output" style="min-height:32px;padding:0 14px;font-size:13px" onclick="playVideo('${t.id}', 'output')">▶ 处理结果</button> <button class="button btn-output" style="min-height:32px;padding:0 14px;font-size:13px" onclick="playVideo('${t.id}', 'output')">▶ 处理结果</button>
<a href="/api/tasks/${t.id}/download" download class="button secondary" style="min-height:32px;padding:0 14px;font-size:13px;text-decoration:none">↓ 下载</a> <a href="/api/tasks/${t.id}/download" download class="button secondary" style="min-height:32px;padding:0 14px;font-size:13px;text-decoration:none">↓ 下载</a>
${s3Warn}
`); `);
} }
} }
+78 -27
View File
@@ -194,6 +194,7 @@ def init_db():
("platform_task_id", "INTEGER"), ("platform_task_id", "INTEGER"),
("started_at", "REAL"), ("started_at", "REAL"),
("completed_at", "REAL"), ("completed_at", "REAL"),
("s3_failed", "BOOLEAN DEFAULT 0"),
]: ]:
try: try:
conn.execute(f"ALTER TABLE tasks ADD COLUMN {col} {default}") conn.execute(f"ALTER TABLE tasks ADD COLUMN {col} {default}")
@@ -291,8 +292,8 @@ def _save_task_to_db(task: dict):
(id, url, is_cache_file, cache_path, inpaint_mode, subtitle_areas, (id, url, is_cache_file, cache_path, inpaint_mode, subtitle_areas,
preview_mode, detect_mode, status, progress, download_progress, preview_mode, detect_mode, status, progress, download_progress,
cached, output_path, error, platform_task_id, cached, output_path, error, platform_task_id,
created_at, started_at, completed_at) created_at, started_at, completed_at, s3_failed)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", ( """, (
task["id"], task["url"], task.get("is_cache_file", False), task["id"], task["url"], task.get("is_cache_file", False),
task.get("cache_path", ""), task["inpaint_mode"], task.get("cache_path", ""), task["inpaint_mode"],
@@ -301,6 +302,7 @@ def _save_task_to_db(task: dict):
task["download_progress"], task["cached"], task["output_path"], task["download_progress"], task["cached"], task["output_path"],
task["error"], task.get("platform_task_id"), task["error"], task.get("platform_task_id"),
task.get("created_at"), task.get("started_at"), task.get("completed_at"), task.get("created_at"), task.get("started_at"), task.get("completed_at"),
task.get("s3_failed", False),
)) ))
conn.commit() conn.commit()
conn.close() conn.close()
@@ -330,6 +332,7 @@ def _load_tasks_from_db():
"error": r["error"], "platform_task_id": r.get("platform_task_id"), "error": r["error"], "platform_task_id": r.get("platform_task_id"),
"created_at": r.get("created_at"), "started_at": r.get("started_at"), "created_at": r.get("created_at"), "started_at": r.get("started_at"),
"completed_at": r.get("completed_at"), "completed_at": r.get("completed_at"),
"s3_failed": bool(r.get("s3_failed", False)),
"logs": [], "logs": [],
} }
# 重启时 downloading/processing 说明上次处理被中断,标为 error # 重启时 downloading/processing 说明上次处理被中断,标为 error
@@ -529,16 +532,26 @@ def _process(task_id: str, video_path: str, inpaint_mode_str: str, subtitle_area
if not subtitle_areas: if not subtitle_areas:
if detect_mode == "smart": if detect_mode == "smart":
# 水印模式:逐帧 top-hat 检测半透明/移动文字水印 # 水印模式:逐帧检测半透明/移动文字水印 + 彩色 logo(每秒一帧采样)
push_log(task_id, "水印检测中(逐帧 top-hat)...") push_log(task_id, "水印检测中(逐帧 top-hat + 彩色 logo)...")
from backend.tools.smart_detect import detect_watermark_per_frame from backend.tools.smart_detect import detect_watermark_per_frame
per_frame = detect_watermark_per_frame(video_path, sample_step=2) per_frame = detect_watermark_per_frame(video_path)
if per_frame: if per_frame:
n_boxes = sum(len(v) for v in per_frame.values()) n_boxes = sum(len(v) for v in per_frame.values())
sr._per_frame_boxes = per_frame sr._per_frame_boxes = per_frame
push_log(task_id, f"水印检测:{len(per_frame)} 帧共 {n_boxes} 个水印框") push_log(task_id, f"水印检测:{len(per_frame)} 帧共 {n_boxes} 个水印框")
else: else:
push_log(task_id, "未检测到水印,回退到全帧 OCR") # 未检测到水印:这类淡水印 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: else:
# 文字模式:标准 OCR 逐帧检测(走原生 find_subtitle_frame_no) # 文字模式:标准 OCR 逐帧检测(走原生 find_subtitle_frame_no)
push_log(task_id, "文字检测(OCR)将逐帧进行") push_log(task_id, "文字检测(OCR)将逐帧进行")
@@ -754,6 +767,7 @@ class VSRTaskWorker:
"cached": cached, "cached": cached,
"output_path": None, "output_path": None,
"error": None, "error": None,
"s3_failed": False,
"platform_task_id": platform_task_id, "platform_task_id": platform_task_id,
"logs": [], "logs": [],
"created_at": time.time(), "created_at": time.time(),
@@ -802,8 +816,18 @@ class VSRTaskWorker:
final = tasks.get(task_id, {}) final = tasks.get(task_id, {})
if final.get("status") == "done" and final.get("output_path"): if final.get("status") == "done" and final.get("output_path"):
output_url = await self._upload_result_video(final["output_path"]) output_url = await self._upload_result_video(final["output_path"], task_id=task_id)
await self._report_result(platform_task_id, status="done", output_url=output_url) 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: else:
await self._report_result(platform_task_id, status="failed", await self._report_result(platform_task_id, status="failed",
error_message=final.get("error", "unknown")) error_message=final.get("error", "unknown"))
@@ -825,9 +849,13 @@ class VSRTaskWorker:
) )
logger.info(f"[vsr_worker] 任务 #{platform_task_id} 上报结果 status={status}") logger.info(f"[vsr_worker] 任务 #{platform_task_id} 上报结果 status={status}")
async def _upload_result_video(self, local_path: str) -> str | None: async def _upload_result_video(self, local_path: str, task_id: str | None = None) -> str | None:
"""处理完成后将输出视频上传到平台,返回 play_url(S3 地址)。流式上传,不一次性读入内存。""" """处理完成后将输出视频上传到平台,返回 play_url(S3 地址)。流式上传,不一次性读入内存。"""
import hashlib as _hl import hashlib as _hl
def _log(msg: str):
logger.info(msg)
if task_id:
push_log(task_id, msg)
try: try:
file_path = Path(local_path) file_path = Path(local_path)
file_size = file_path.stat().st_size file_size = file_path.stat().st_size
@@ -839,7 +867,7 @@ class VSRTaskWorker:
for chunk in iter(lambda: f.read(8 * 1024 * 1024), b""): for chunk in iter(lambda: f.read(8 * 1024 * 1024), b""):
h.update(chunk) h.update(chunk)
sha256 = h.hexdigest() sha256 = h.hexdigest()
logger.info(f"[vsr_worker] 文件 {filename} size={file_size//1048576}MB sha256={sha256[:12]}…") _log(f"[S3] 准备上传 {filename},大小={file_size//1048576}MB")
sign_resp = await self._signed_post( sign_resp = await self._signed_post(
"/openapi/file/upload/get_upload_sign", "/openapi/file/upload/get_upload_sign",
@@ -854,29 +882,41 @@ class VSRTaskWorker:
}, },
) )
if sign_resp is None: if sign_resp is None:
logger.warning("[vsr_worker] get_upload_sign 返回空,跳过上传") _log("[S3] get_upload_sign 返回空")
return None return None
action = sign_resp.get("action", "") action = sign_resp.get("action", "")
if action == "skip": if action == "skip":
logger.info(f"[vsr_worker] 文件已存在,直接返回 play_url") _log("[S3] 文件已存在,直接复用")
return sign_resp.get("play_url") return sign_resp.get("play_url")
presigned_url = sign_resp.get("presigned_url", "") presigned_url = sign_resp.get("presigned_url", "")
object_key = sign_resp.get("object_key", "") object_key = sign_resp.get("object_key", "")
if not presigned_url or not object_key: if not presigned_url or not object_key:
logger.warning(f"[vsr_worker] upload sign 未返回 presigned_url/object_key: {sign_resp}") _log(f"[S3] upload sign 未返回 presigned_url/object_key: {sign_resp}")
return None return None
# 流式 PUT 上传,不把文件加载到内存 # 用 asyncio executor 做同步分块读取,避免 sync file 阻塞 async event loop
logger.info(f"[vsr_worker] 开始流式上传 {file_size//1048576}MB → S3…") _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: async with httpx.AsyncClient(timeout=7200) as upload_client:
with open(local_path, "rb") as f: put_resp = await upload_client.put(
put_resp = await upload_client.put( presigned_url,
presigned_url, content=_file_chunks(),
content=f, headers={"Content-Type": "video/mp4", "Content-Length": str(file_size)},
headers={"Content-Type": "video/mp4", "Content-Length": str(file_size)}, )
) if put_resp.status_code not in (200, 204):
put_resp.raise_for_status() _log(f"[S3] PUT 失败 HTTP {put_resp.status_code}: {put_resp.text[:200]}")
logger.info(f"[vsr_worker] S3 上传完成,注册资产…") return None
_log("[S3] 上传完成,注册资产…")
reg_resp = await self._signed_post( reg_resp = await self._signed_post(
"/openapi/file/assets/register", "/openapi/file/assets/register",
@@ -892,9 +932,11 @@ class VSRTaskWorker:
}, },
) )
play_url = reg_resp.get("play_url") if reg_resp else None play_url = reg_resp.get("play_url") if reg_resp else None
logger.info(f"[vsr_worker] 资产注册完成 play_url={play_url}") _log(f"[S3] 资产注册完成 play_url={play_url}")
return play_url return play_url
except Exception as e: except Exception as e:
import traceback as _tb
_log(f"[S3] 上传失败: {e}\n{_tb.format_exc()[-400:]}")
logger.warning(f"[vsr_worker] 上传结果视频失败: {e}") logger.warning(f"[vsr_worker] 上传结果视频失败: {e}")
return None return None
@@ -1078,7 +1120,7 @@ async def create_task(body: TaskCreate):
"status": "pending", "progress": 0, "status": "pending", "progress": 0,
"phase": "pending", "detect_pct": 0, "process_pct": 0, "merge_pct": 0, "phase": "pending", "detect_pct": 0, "process_pct": 0, "merge_pct": 0,
"download_progress": 0, "cached": cached, "download_progress": 0, "cached": cached,
"output_path": None, "error": None, "platform_task_id": None, "output_path": None, "error": None, "s3_failed": False, "platform_task_id": None,
"logs": [], "created_at": now, "started_at": None, "completed_at": None, "logs": [], "created_at": now, "started_at": None, "completed_at": None,
} }
task_queue.put(task_id) task_queue.put(task_id)
@@ -1127,7 +1169,12 @@ async def get_task_logs(task_id: str):
async def download_result(task_id: str): async def download_result(task_id: str):
with task_lock: with task_lock:
task = tasks.get(task_id) task = tasks.get(task_id)
if not task or task["status"] != "done" or not task.get("output_path"): 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") raise HTTPException(404, "Result not ready")
out = Path(task["output_path"]) out = Path(task["output_path"])
if not out.exists(): if not out.exists():
@@ -1139,7 +1186,11 @@ async def download_result(task_id: str):
async def stream_result(task_id: str, request: Request): async def stream_result(task_id: str, request: Request):
with task_lock: with task_lock:
task = tasks.get(task_id) task = tasks.get(task_id)
if not task or task["status"] != "done" or not task.get("output_path"): 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") raise HTTPException(404, "Result not ready")
out = Path(task["output_path"]) out = Path(task["output_path"])
if not out.exists(): if not out.exists():