feat(video): 视频页「导出全部」— 把项目所有视频片段打包成 zip 下载
- 后端 ProjectViewSet 加 export-clips action:逐段从 TOS 拉取已采用视频片段、内存 zip 打包返回下载 (单段失败跳过不毁整包;空项目 400);文件名走 RFC5987 UTF-8 编码兼容中文 - 前端视频阶段 queue-bar 在「上传视频」旁加「导出全部」按钮(下载图标 + 导出中/失败态), api.exportProjectClips 取 blob → 触发浏览器下载;仅有已完成片段时可点 - 单测 ExportClipsTests:打包全部片段 + 空项目 400 验收:后端单测绿(基线 4 失败零新增);tsc+build 绿;真打包 补水面膜 项目得 950KB zip;无头 0 报错 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,7 @@ import uuid
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models import Count
|
||||
from django.http import JsonResponse, StreamingHttpResponse
|
||||
from django.http import HttpResponse, JsonResponse, StreamingHttpResponse
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.parsers import FormParser, MultiPartParser
|
||||
@@ -154,6 +154,59 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
return qs.filter(**{self.team_field: self.get_team()}).order_by("-updated_at")
|
||||
return super().get_queryset()
|
||||
|
||||
@action(detail=True, methods=["get"], url_path="export-clips")
|
||||
def export_clips(self, request, pk=None):
|
||||
"""导出全部:把该项目所有已采用的视频片段打成一个 zip(逐段从 TOS 拉取,内存打包后下载)。
|
||||
片段短(每段 ~15s),内存打包足够;单段拉取失败跳过不毁整包。"""
|
||||
import io
|
||||
import zipfile
|
||||
from urllib.parse import quote
|
||||
|
||||
import requests
|
||||
|
||||
project = self.get_object()
|
||||
segs = (
|
||||
project.video_segments.filter(adopted_version__isnull=False)
|
||||
.select_related("adopted_version__asset")
|
||||
.prefetch_related("adopted_version__asset__files")
|
||||
.order_by("sort_order")
|
||||
)
|
||||
clips = []
|
||||
for seg in segs:
|
||||
asset = getattr(seg.adopted_version, "asset", None)
|
||||
if asset is None:
|
||||
continue
|
||||
f = asset.files.filter(is_primary=True).first() or asset.files.first()
|
||||
if f and f.object_key:
|
||||
clips.append((seg.sort_order, f))
|
||||
if not clips:
|
||||
return Response({"detail": "该项目还没有可导出的视频片段"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
buf = io.BytesIO()
|
||||
storage = TosStorage()
|
||||
packed = 0
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_STORED) as zf:
|
||||
for order, f in clips:
|
||||
try:
|
||||
url = storage.presigned_get_url(object_key=f.object_key)
|
||||
data = requests.get(url, timeout=120).content
|
||||
except Exception: # noqa: BLE001 — 单段失败跳过,不毁整包
|
||||
continue
|
||||
ext = ".mp4"
|
||||
if f.content_type and "/" in f.content_type:
|
||||
sub = f.content_type.rsplit("/", 1)[-1]
|
||||
ext = "." + ("mov" if sub == "quicktime" else sub if sub in ("mp4", "webm") else "mp4")
|
||||
zf.writestr(f"{order + 1:02d}-镜{order + 1}{ext}", data)
|
||||
packed += 1
|
||||
if packed == 0:
|
||||
return Response({"detail": "视频片段拉取失败,请稍后重试"}, status=status.HTTP_502_BAD_GATEWAY)
|
||||
payload = buf.getvalue()
|
||||
resp = HttpResponse(payload, content_type="application/zip")
|
||||
fname = quote(f"{project.name}-视频素材.zip")
|
||||
resp["Content-Disposition"] = f"attachment; filename=videos.zip; filename*=UTF-8''{fname}"
|
||||
resp["Content-Length"] = str(len(payload))
|
||||
return resp
|
||||
|
||||
@action(detail=False, methods=["get"])
|
||||
def summary(self, request):
|
||||
"""项目统计(总数 + 各状态计数),供仪表盘/侧栏徽标——只跑 COUNT,不拉项目对象。"""
|
||||
|
||||
Reference in New Issue
Block a user