From a39d2b8603b95136cf66a3ccccdf598a8b2c10ed Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Sun, 21 Jun 2026 20:38:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(video):=20=E8=A7=86=E9=A2=91=E9=A1=B5?= =?UTF-8?q?=E3=80=8C=E5=AF=BC=E5=87=BA=E5=85=A8=E9=83=A8=E3=80=8D=E2=80=94?= =?UTF-8?q?=20=E6=8A=8A=E9=A1=B9=E7=9B=AE=E6=89=80=E6=9C=89=E8=A7=86?= =?UTF-8?q?=E9=A2=91=E7=89=87=E6=AE=B5=E6=89=93=E5=8C=85=E6=88=90=20zip=20?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端 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 --- core/backend/apps/projects/tests.py | 42 ++++++++++++++++++++ core/backend/apps/projects/views.py | 55 ++++++++++++++++++++++++++- core/frontend/src/api.ts | 13 +++++++ core/frontend/src/routes/pipeline.tsx | 29 ++++++++++++++ core/qa/visual-parity/_export-btn.mjs | 20 ++++++++++ 5 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 core/qa/visual-parity/_export-btn.mjs diff --git a/core/backend/apps/projects/tests.py b/core/backend/apps/projects/tests.py index de10c3f..8648525 100644 --- a/core/backend/apps/projects/tests.py +++ b/core/backend/apps/projects/tests.py @@ -490,3 +490,45 @@ class WorkerGateTests(TestCase): with patch.object(celery_health, "_ping_workers", side_effect=ConnectionError("broker down")): self.assertFalse(celery_health.celery_worker_available()) 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) diff --git a/core/backend/apps/projects/views.py b/core/backend/apps/projects/views.py index b86bda0..00a1e25 100644 --- a/core/backend/apps/projects/views.py +++ b/core/backend/apps/projects/views.py @@ -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,不拉项目对象。""" diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index 0998d03..347dc1c 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -443,6 +443,19 @@ export const api = { if (metaKeys.length) qs.set("meta_keys", metaKeys.join(",")); return request<{ sources: string[]; kinds: string[]; metadata: Record }>(`/api/assets/facets/?${qs.toString()}`); }, + // 导出全部:把项目所有已采用视频片段打成一个 zip 下载(后端逐段从 TOS 拉取打包) + async exportProjectClips(projectId: string): Promise { + 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,浏览器抽帧/解码必须同源) async fetchAssetBlob(id: string): Promise { const token = getToken(); diff --git a/core/frontend/src/routes/pipeline.tsx b/core/frontend/src/routes/pipeline.tsx index 33d432d..ef29aa3 100644 --- a/core/frontend/src/routes/pipeline.tsx +++ b/core/frontend/src/routes/pipeline.tsx @@ -2093,6 +2093,30 @@ export function PipelinePage(props: { setUploadTargetSeg(segmentId); 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) { const file = event.target.files?.[0]; event.target.value = ""; @@ -2977,6 +3001,11 @@ export function PipelinePage(props: { 上传视频 + {/* 导出全部:把所有已完成片段打包成 zip 一次性下载 */} + diff --git a/core/qa/visual-parity/_export-btn.mjs b/core/qa/visual-parity/_export-btn.mjs new file mode 100644 index 0000000..72213a7 --- /dev/null +++ b/core/qa/visual-parity/_export-btn.mjs @@ -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));