Commit c082a92dcf9436c767e0d9001436f0fa88d81880

Authored by yibo
1 parent 02a8e705

h3-console: 下载视频加 Content-Disposition,文件名不再清一色 file.mp4

/v1/videos/{jid}/file 两条返回路径都没设文件名,浏览器只能从 URL 猜,
URL 以 /file 结尾没扩展名,猜出来都是 file.mp4,多个任务的视频存到同一个
下载目录会互相覆盖。加上 filename="h3-{任务id}.mp4",id 天然唯一。
CHANGELOG.md
1 # 变更记录 1 # 变更记录
2 2
  3 +## 2026-08-20(夜,又续六)— 下载文件名统一叫 file.mp4 的问题
  4 +
  5 +**背景**:用户发现下载多个任务的视频,文件名都叫 `file.mp4`——存到同一个下载目录会互相
  6 +覆盖。查了 `/v1/videos/{jid}/file` 这个下载接口:两条返回路径(正常经 api.py 转发的
  7 +`StreamingResponse`,和后端忘了任务时读磁盘兜底的 `FileResponse`)都没设
  8 +`Content-Disposition` 头,浏览器只能从 URL 最后一段猜文件名——URL 是 `.../file`(没有
  9 +扩展名),配合 `Content-Type: video/mp4` 猜出来就是清一色 `file.mp4`,不是产物本身有问题,
  10 +纯粹是没告诉浏览器该存成什么名字。
  11 +
  12 +**结果**:两条路径都加上 `Content-Disposition: attachment; filename="h3-{任务id}.mp4"`——
  13 +用 console 自己的任务 id(12 位十六进制,任务间互不相同)拼文件名,不需要额外维护一份
  14 +命名方案。改的是 `server.py`(有状态的后端逻辑,需要重启),重启前确认 `/v1/backends`
  15 +四张卡都空闲(`queued=0,running=0`)不会打断任务,8080(局域网+调度器)和 8081
  16 +(`/console` 子路径)两个实例都重启验证过:`curl -sI` 直接 GET 一条已完成任务的文件,
  17 +返回头里 `content-disposition: attachment; filename="h3-63c91f4b6bc7.mp4"`,下载下来是
  18 +合法的 9MB mp4(`ISO Media, MP4 Base Media v1`)。
  19 +
  20 +---
  21 +
3 ## 2026-08-20(夜,又续五)— 832×480 高分辨率 5s/10s/15s 实测 + 揪出一个真实并发 bug 22 ## 2026-08-20(夜,又续五)— 832×480 高分辨率 5s/10s/15s 实测 + 揪出一个真实并发 bug
4 23
5 **背景**:用户想知道高分辨率(832×480,线上标准档)配 fl2va 首尾帧条件下,5s/10s/15s 三档能不能跑。 24 **背景**:用户想知道高分辨率(832×480,线上标准档)配 fl2va 首尾帧条件下,5s/10s/15s 三档能不能跑。
h3-console/server.py
@@ -293,6 +293,12 @@ async def download_video(jid: str, request: Request): @@ -293,6 +293,12 @@ async def download_video(jid: str, request: Request):
293 # gateway 的响应里存了 `backend`(真实拥有这条任务的那个 GPU 后端),直接绕过 gateway 293 # gateway 的响应里存了 `backend`(真实拥有这条任务的那个 GPU 后端),直接绕过 gateway
294 # 这层转发、把 Range 头透传给 api.py,把它的状态码/头原样带回去,问题在这一层就解决, 294 # 这层转发、把 Range 头透传给 api.py,把它的状态码/头原样带回去,问题在这一层就解决,
295 # 不用碰 gateway.py/api.py 一行代码。 295 # 不用碰 gateway.py/api.py 一行代码。
  296 + # 下载/播放到本地的文件名——不带这个的话浏览器只能从 URL 最后一段猜,我们的 URL 都是
  297 + # 以 "/file" 结尾(没有扩展名),猜出来清一色叫 "file.mp4",多个任务的视频存到同一个
  298 + # 下载目录会互相覆盖。用 console 自己的任务 id 拼一个每条任务都不同的文件名。
  299 + download_filename = f"h3-{jid}.mp4"
  300 + content_disposition = f'attachment; filename="{download_filename}"'
  301 +
296 upstream_url = f"{row['backend']}/v1/videos/{row['gateway_id']}/file" 302 upstream_url = f"{row['backend']}/v1/videos/{row['gateway_id']}/file"
297 headers = {} 303 headers = {}
298 if "range" in request.headers: 304 if "range" in request.headers:
@@ -313,7 +319,8 @@ async def download_video(jid: str, request: Request): @@ -313,7 +319,8 @@ async def download_video(jid: str, request: Request):
313 cached = json.loads(row["last_known_json"]) if row["last_known_json"] else {} 319 cached = json.loads(row["last_known_json"]) if row["last_known_json"] else {}
314 file_path = cached.get("file") 320 file_path = cached.get("file")
315 if file_path and os.path.exists(file_path): 321 if file_path and os.path.exists(file_path):
316 - return FileResponse(file_path, media_type="video/mp4") 322 + return FileResponse(file_path, media_type="video/mp4",
  323 + headers={"Content-Disposition": content_disposition})
317 raise HTTPException(404, "后端进程重启后忘了这条任务的记录,磁盘上也没找到对应文件") 324 raise HTTPException(404, "后端进程重启后忘了这条任务的记录,磁盘上也没找到对应文件")
318 325
319 async def stream(): 326 async def stream():
@@ -323,6 +330,7 @@ async def download_video(jid: str, request: Request): @@ -323,6 +330,7 @@ async def download_video(jid: str, request: Request):
323 330
324 passthrough = {k: v for k, v in upstream.headers.items() 331 passthrough = {k: v for k, v in upstream.headers.items()
325 if k.lower() in ("content-length", "content-range", "accept-ranges", "content-type")} 332 if k.lower() in ("content-length", "content-range", "accept-ranges", "content-type")}
  333 + passthrough["content-disposition"] = content_disposition
326 return StreamingResponse(stream(), status_code=upstream.status_code, headers=passthrough) 334 return StreamingResponse(stream(), status_code=upstream.status_code, headers=passthrough)
327 335
328 336