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:
@@ -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)
|
||||||
|
|||||||
@@ -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,不拉项目对象。"""
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
Reference in New Issue
Block a user