Commit c082a92dcf9436c767e0d9001436f0fa88d81880
1 parent
02a8e705
h3-console: 下载视频加 Content-Disposition,文件名不再清一色 file.mp4
/v1/videos/{jid}/file 两条返回路径都没设文件名,浏览器只能从 URL 猜,
URL 以 /file 结尾没扩展名,猜出来都是 file.mp4,多个任务的视频存到同一个
下载目录会互相覆盖。加上 filename="h3-{任务id}.mp4",id 天然唯一。
Showing
2 changed files
with
28 additions
and
1 deletions
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 |