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:
seaislee1209
2026-06-21 20:38:31 +08:00
parent 9e791a4db9
commit a39d2b8603
5 changed files with 158 additions and 1 deletions
+42
View File
@@ -490,3 +490,45 @@ class WorkerGateTests(TestCase):
with patch.object(celery_health, "_ping_workers", side_effect=ConnectionError("broker down")): with patch.object(celery_health, "_ping_workers", side_effect=ConnectionError("broker down")):
self.assertFalse(celery_health.celery_worker_available()) self.assertFalse(celery_health.celery_worker_available())
celery_health._cache["expires"] = 0.0 celery_health._cache["expires"] = 0.0
class ExportClipsTests(TestCase):
"""导出全部:把项目已采用视频片段打成一个 zip 下载。"""
def setUp(self):
self.user = User.objects.create_user(username="exp", password="p")
self.team = Team.objects.create(name="ExpT", owner=self.user)
TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER)
self.product = Product.objects.create(team=self.team, created_by=self.user, title="P")
self.project = Project.objects.create(team=self.team, name="项目甲", product=self.product, created_by=self.user)
self.client = APIClient()
self.client.force_authenticate(self.user)
def _add_clip(self, order):
from apps.assets.models import AssetFile
asset = Asset.objects.create(team=self.team, name=f"clip{order}", asset_type="video", source="ai_generated", category="video_clip")
AssetFile.objects.create(asset=asset, object_key=f"k{order}.mp4", bucket="b", content_type="video/mp4", is_primary=True)
seg = VideoSegment.objects.create(project=self.project, sort_order=order)
ver = VideoSegmentVersion.objects.create(video_segment=seg, asset=asset, is_adopted=True)
seg.adopted_version = ver
seg.save(update_fields=["adopted_version"])
def test_export_zip_packs_all_clips(self):
self._add_clip(0)
self._add_clip(1)
with patch("apps.projects.views.TosStorage") as Tos, patch("requests.get") as rget:
Tos.return_value.presigned_get_url.return_value = "http://x/c.mp4"
rget.return_value.content = b"MP4DATA"
res = self.client.get(f"/api/projects/{self.project.id}/export-clips/")
self.assertEqual(res.status_code, 200)
self.assertEqual(res["Content-Type"], "application/zip")
import io
import zipfile
zf = zipfile.ZipFile(io.BytesIO(res.content))
self.assertEqual(len(zf.namelist()), 2)
def test_export_empty_returns_400(self):
res = self.client.get(f"/api/projects/{self.project.id}/export-clips/")
self.assertEqual(res.status_code, 400)
+54 -1
View File
@@ -4,7 +4,7 @@ import uuid
from django.db import transaction from django.db import transaction
from django.db.models import Count 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 import status
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.parsers import FormParser, MultiPartParser 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 qs.filter(**{self.team_field: self.get_team()}).order_by("-updated_at")
return super().get_queryset() 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"]) @action(detail=False, methods=["get"])
def summary(self, request): def summary(self, request):
"""项目统计(总数 + 各状态计数),供仪表盘/侧栏徽标——只跑 COUNT,不拉项目对象。""" """项目统计(总数 + 各状态计数),供仪表盘/侧栏徽标——只跑 COUNT,不拉项目对象。"""
+13
View File
@@ -443,6 +443,19 @@ export const api = {
if (metaKeys.length) qs.set("meta_keys", metaKeys.join(",")); if (metaKeys.length) qs.set("meta_keys", metaKeys.join(","));
return request<{ sources: string[]; kinds: string[]; metadata: Record<string, string[]> }>(`/api/assets/facets/?${qs.toString()}`); return request<{ sources: string[]; kinds: string[]; metadata: Record<string, string[]> }>(`/api/assets/facets/?${qs.toString()}`);
}, },
// 导出全部:把项目所有已采用视频片段打成一个 zip 下载(后端逐段从 TOS 拉取打包)
async exportProjectClips(projectId: string): Promise<Blob> {
const token = getToken();
const res = await fetch(`${API_BASE}/api/projects/${projectId}/export-clips/`, {
headers: token ? { Authorization: `Token ${token}` } : undefined
});
if (!res.ok) {
let msg = `导出失败 (${res.status})`;
try { msg = (await res.json()).detail || msg; } catch { /* 非 JSON 错误体 */ }
throw new ApiError(res.status, msg);
}
return res.blob();
},
// 经后端同源代理取资产原始文件(TOS 未配 CORS,浏览器抽帧/解码必须同源) // 经后端同源代理取资产原始文件(TOS 未配 CORS,浏览器抽帧/解码必须同源)
async fetchAssetBlob(id: string): Promise<Blob> { async fetchAssetBlob(id: string): Promise<Blob> {
const token = getToken(); const token = getToken();
+29
View File
@@ -2093,6 +2093,30 @@ export function PipelinePage(props: {
setUploadTargetSeg(segmentId); setUploadTargetSeg(segmentId);
videoUploadRef.current?.click(); videoUploadRef.current?.click();
} }
// 导出全部:把本项目所有已采用视频片段打成一个 zip 下载
const [exporting, setExporting] = useState(false);
const [exportErr, setExportErr] = useState("");
async function exportAllVideos() {
if (exporting) return;
setExporting(true);
setExportErr("");
try {
const blob = await api.exportProjectClips(project.id);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${project.name}-视频素材.zip`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (err) {
setExportErr(err instanceof ApiError ? err.message : "导出失败,请重试");
window.setTimeout(() => setExportErr(""), 3000);
} finally {
setExporting(false);
}
}
function onPickBgmFile(event: ChangeEvent<HTMLInputElement>) { function onPickBgmFile(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
event.target.value = ""; event.target.value = "";
@@ -2977,6 +3001,11 @@ export function PipelinePage(props: {
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: "4px" }}><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><path d="M17 8l-5-5-5 5" /><path d="M12 3v12" /></svg> <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: "4px" }}><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><path d="M17 8l-5-5-5 5" /><path d="M12 3v12" /></svg>
</button> </button>
{/* 导出全部:把所有已完成片段打包成 zip 一次性下载 */}
<button className="btn btn-sm" type="button" disabled={exporting || segDone === 0} title={exportErr || "把所有已完成视频片段打包下载"} onClick={() => void exportAllVideos()}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: "4px" }}><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><path d="M7 10l5 5 5-5" /><path d="M12 15V3" /></svg>
{exporting ? "导出中…" : exportErr ? "导出失败" : "导出全部"}
</button>
</div> </div>
<input ref={videoUploadRef} type="file" accept="video/*" style={{ display: "none" }} onChange={onPickVideoFile} /> <input ref={videoUploadRef} type="file" accept="video/*" style={{ display: "none" }} onChange={onPickVideoFile} />
+20
View File
@@ -0,0 +1,20 @@
import { chromium } from "playwright";
import fs from "node:fs"; import path from "node:path";
const BASE="http://localhost:5200", PROJ="bdbf39d3-1a89-4135-b1b7-26d3331a7021";
const OUT=path.resolve("../../../_qa_shots/export"); fs.mkdirSync(OUT,{recursive:true});
const b=await chromium.launch({headless:true}); const r={consoleErrors:[]};
const p=await (await b.newContext({viewport:{width:1440,height:900}})).newPage();
p.on("console",m=>{if(m.type()==="error")r.consoleErrors.push(m.text())});
p.on("pageerror",e=>r.consoleErrors.push("PE:"+e.message));
await p.goto(BASE+"/login"); await p.waitForTimeout(400);
await p.fill("#auth-username","airshelf"); await p.fill("#auth-pwd","Restraint2026");
await p.click("button.btn-cta"); await p.waitForFunction(()=>!location.pathname.startsWith("/login"),{timeout:12000});
await p.goto(BASE+"/pipeline/"+PROJ); await p.waitForTimeout(2500);
await p.locator('a[data-stage="4"]').first().click().catch(()=>{}); await p.waitForTimeout(2500);
const qb=await p.locator(".queue-bar").innerText().catch(()=>"");
r.queueBar=qb.replace(/\n/g," | ");
r.hasExportBtn=await p.locator(".queue-bar button",{hasText:"导出全部"}).count();
r.exportEnabled=r.hasExportBtn? !(await p.locator(".queue-bar button",{hasText:"导出全部"}).first().isDisabled()):false;
await p.locator('[data-stage-pane="4"] .queue-bar').screenshot({path:path.join(OUT,"queue-bar.png")}).catch(()=>p.screenshot({path:path.join(OUT,"stage4.png"),fullPage:true}));
await p.screenshot({path:path.join(OUT,"stage4-full.png"),fullPage:true});
await b.close(); console.log(JSON.stringify(r,null,2));