diff --git a/.gitignore b/.gitignore index fb2fc3d..e529d80 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,6 @@ core/.tools/ # 本地一键启动脚本,不入库 start-code.bat stop-code.bat + +# bug_todo,不入库,用于记录 bug 修复进度 +docs/bug_todo/ diff --git a/core/backend/apps/ai/free_video.py b/core/backend/apps/ai/free_video.py index 91e5341..1da5a3c 100644 --- a/core/backend/apps/ai/free_video.py +++ b/core/backend/apps/ai/free_video.py @@ -695,7 +695,8 @@ def serialize_free_video_task(task: AITask) -> dict: return "" return "" - for asset in task.generated_assets.all(): + generated_assets = list(task.generated_assets.all()) + for asset in generated_assets: if asset.is_deleted: continue for f in asset.files.all(): @@ -706,7 +707,7 @@ def serialize_free_video_task(task: AITask) -> dict: video_url = url elif not f.is_primary and "image" in (f.content_type or "") and not thumbnail_url: thumbnail_url = url - if not video_url: + if not video_url and not generated_assets: video_url = payload.get("fallback_video_url") or "" return { diff --git a/core/backend/apps/ai/tests.py b/core/backend/apps/ai/tests.py index ece6451..5867ea9 100644 --- a/core/backend/apps/ai/tests.py +++ b/core/backend/apps/ai/tests.py @@ -178,7 +178,7 @@ from django.test import TestCase from rest_framework.test import APIClient from apps.accounts.models import Team, TeamMember, User -from apps.ai.models import AITask, ImageConversation, ModelConfig +from apps.ai.models import AITask, ImageConversation, ModelConfig, ModelProvider from apps.ai.providers.volcano import VolcanoArkProvider from apps.ai.services import enqueue_standalone_images from apps.assets.models import Asset, AssetFile @@ -186,6 +186,63 @@ from apps.billing.models import CreditAccount from apps.products.models import Product +class FreeVideoAssetDeletionTests(TestCase): + """自由创作视频资产删除后,任务流应降级为无可播成品,不能回退到旧 fallback URL。""" + + def setUp(self): + self.user = User.objects.create_user(username="fvdel", password="pass") + self.team = Team.objects.create(name="FreeVideoDel", owner=self.user) + TeamMember.objects.create(team=self.team, user=self.user, role="owner", status="active") + provider = ModelProvider.objects.create(name="fv-provider", display_name="FV Provider") + self.model = ModelConfig.objects.create( + provider=provider, + name="fv-model", + display_name="FV Model", + capability=ModelConfig.Capability.VIDEO, + ) + self.task = AITask.objects.create( + team=self.team, + created_by=self.user, + task_type=AITask.Type.FREE_VIDEO, + status=AITask.Status.SUCCEEDED, + model_config=self.model, + idempotency_key="free-video-delete-test", + request_payload={ + "prompt": "生成一个广告视频", + "fallback_video_url": "http://fallback.example/video.mp4", + "fallback_note": "fallback", + }, + ) + self.asset = Asset.objects.create( + team=self.team, + created_by=self.user, + name="自由创作视频", + asset_type=Asset.Type.VIDEO, + source=Asset.Source.AI_GENERATED, + category=Asset.Category.FREE_CREATE, + origin_task=self.task, + ) + AssetFile.objects.create( + asset=self.asset, + object_key="free-video.mp4", + bucket="b", + content_type="video/mp4", + preview_url="http://tos.example/video.mp4", + is_primary=True, + ) + + def test_deleted_persisted_asset_does_not_fall_back_to_raw_video_url(self): + from apps.ai.free_video import serialize_free_video_task + + self.assertEqual(serialize_free_video_task(self.task)["video_url"], "http://tos.example/video.mp4") + self.asset.is_deleted = True + self.asset.save(update_fields=["is_deleted"]) + task = AITask.objects.prefetch_related("generated_assets", "generated_assets__files").get(id=self.task.id) + data = serialize_free_video_task(task) + self.assertEqual(data["video_url"], "") + self.assertEqual(data["fallback_note"], "fallback") + + class StandaloneImageReferenceTests(TestCase): """独立生图(平台套图 / 模特上身图)必须把商品真实主图(+ 模特图)作为参考图走 image_edit, 而不是纯文生图——回归保护 #图片生成没参考商品主图# 这个 bug。 diff --git a/core/backend/apps/assets/tests.py b/core/backend/apps/assets/tests.py index a7480c2..9d7709b 100644 --- a/core/backend/apps/assets/tests.py +++ b/core/backend/apps/assets/tests.py @@ -220,6 +220,84 @@ class AssetBatchesTests(TestCase): self.assertEqual(self.client.get("/api/assets/summary/").json()["tryon"], 2) +class FreeCreateSplitTabTests(TestCase): + """Free create assets share category=free_create; tabs split by asset_type.""" + + def setUp(self): + self.user, self.team = _mk_team("ufc", "TeamFC") + self.client = APIClient() + self.client.force_authenticate(self.user) + self.image1 = Asset.objects.create( + team=self.team, + name="free image 1", + asset_type=Asset.Type.IMAGE, + source=Asset.Source.AI_GENERATED, + category=Asset.Category.FREE_CREATE, + metadata={"batch_id": "IMG-B1"}, + ) + self.image2 = Asset.objects.create( + team=self.team, + name="free image 2", + asset_type=Asset.Type.IMAGE, + source=Asset.Source.AI_GENERATED, + category=Asset.Category.FREE_CREATE, + metadata={"batch_id": "IMG-B1"}, + ) + self.video = Asset.objects.create( + team=self.team, + name="free video", + asset_type=Asset.Type.VIDEO, + source=Asset.Source.AI_GENERATED, + category=Asset.Category.FREE_CREATE, + metadata={"feature": "free_video"}, + ) + + def test_image_creations_batches_exclude_videos(self): + data = self.client.get("/api/assets/batches/?tab=image_creations&page_size=10").json() + self.assertEqual(data["count"], 1) + self.assertEqual(data["results"][0]["count"], 2) + self.assertEqual({a["asset_type"] for a in data["results"][0]["items"]}, {"image"}) + + def test_video_creations_list_excludes_images(self): + data = self.client.get("/api/assets/?tab=video_creations&page_size=10").json() + self.assertEqual(data["count"], 1) + self.assertEqual(data["results"][0]["id"], str(self.video.id)) + self.assertEqual(data["results"][0]["asset_type"], "video") + + def test_summary_exposes_split_counts_and_keeps_legacy_creations(self): + data = self.client.get("/api/assets/summary/").json() + self.assertEqual(data["image_creations"], 1) + self.assertEqual(data["video_creations"], 1) + self.assertEqual(data["creations"], 2) + + def test_delete_and_restore_video_returns_to_video_creations_tab(self): + res = self.client.delete(f"/api/assets/{self.video.id}/") + self.assertEqual(res.status_code, 204) + self.video.refresh_from_db() + self.assertTrue(self.video.is_deleted) + + video_tab = self.client.get("/api/assets/?tab=video_creations&page_size=10").json() + self.assertEqual(video_tab["count"], 0) + summary = self.client.get("/api/assets/summary/").json() + self.assertEqual(summary["video_creations"], 0) + self.assertEqual(summary["image_creations"], 1) + trash_ids = {a["id"] for a in self.client.get("/api/assets/trash/").json()["results"]} + self.assertIn(str(self.video.id), trash_ids) + + restore = self.client.post(f"/api/assets/{self.video.id}/restore/") + self.assertEqual(restore.status_code, 200) + self.video.refresh_from_db() + self.assertFalse(self.video.is_deleted) + self.assertEqual(self.video.category, Asset.Category.FREE_CREATE) + self.assertEqual(self.video.asset_type, Asset.Type.VIDEO) + + video_tab = self.client.get("/api/assets/?tab=video_creations&page_size=10").json() + self.assertEqual(video_tab["count"], 1) + self.assertEqual(video_tab["results"][0]["id"], str(self.video.id)) + image_batches = self.client.get("/api/assets/batches/?tab=image_creations&page_size=10").json() + self.assertEqual({a["asset_type"] for a in image_batches["results"][0]["items"]}, {"image"}) + + class FacetsProductsTests(TestCase): """facets 返回本 tab 下真实关联的商品(?product= 三路之一:metadata.product_id 等), 并按 tab 隔离、team 隔离、只回 id/title。""" diff --git a/core/backend/apps/assets/views.py b/core/backend/apps/assets/views.py index d825882..0d6fa62 100644 --- a/core/backend/apps/assets/views.py +++ b/core/backend/apps/assets/views.py @@ -50,7 +50,11 @@ def _tab_q(tab: str) -> Q: return Q(category="model_tryon") if tab == "kits": # 平台套图(图片趴) return Q(category="platform_kit") - if tab == "creations": # 自由创作(图片趴) + if tab == "image_creations": # 图片自由创作 + return Q(category="free_create", asset_type="image") + if tab == "video_creations": # 视频自由创作 + return Q(category="free_create", asset_type="video") + if tab == "creations": # 自由创作(兼容旧入口:图片+视频) return Q(category="free_create") if tab == "uploads": return Q(category="upload") @@ -64,7 +68,7 @@ def _tab_q(tab: str) -> Q: # 图片趴三类按「生成批次」成组展示:同一次提交的 N 张图共享 metadata.batch_id。 -BATCH_TABS = {"tryon", "kits", "creations"} +BATCH_TABS = {"tryon", "kits", "creations", "image_creations"} # 资产名形如「AI 生成 · 模特上身图 · 4」,去掉尾部的「· 序号」得到批次名「AI 生成 · 模特上身图」。 _BATCH_NAME_RE = re.compile(r"\s*·\s*\d+\s*$") @@ -180,6 +184,7 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet): for row in rows: seen.add(_batch_key((row["metadata"] or {}).get("batch_id"), row["origin_task_id"], row["id"])) out[t] = len(seen) + out["video_creations"] = base.filter(_tab_q("video_creations")).count() out["others"] = base.filter(_tab_q("others")).count() return Response(out) diff --git a/core/frontend/src/routes/dashboard.tsx b/core/frontend/src/routes/dashboard.tsx index 9541b72..b5f29cd 100644 --- a/core/frontend/src/routes/dashboard.tsx +++ b/core/frontend/src/routes/dashboard.tsx @@ -51,7 +51,11 @@ export function Dashboard({ products, projects, productTotal, projectTotal, bill useEffect(() => { let alive = true; api.assetSummary() - .then((c) => { if (alive) setAssetCount(Object.values(c).reduce((a, b) => a + b, 0)); }) + .then((c) => { + if (!alive) return; + const imageCreations = c.image_creations ?? c.creations ?? 0; + setAssetCount((c.tryon || 0) + (c.kits || 0) + imageCreations + (c.video_creations || 0) + (c.others || 0)); + }) .catch(() => {}); return () => { alive = false; }; }, []); diff --git a/core/frontend/src/routes/library.tsx b/core/frontend/src/routes/library.tsx index 5b8c8bc..57737ea 100644 --- a/core/frontend/src/routes/library.tsx +++ b/core/frontend/src/routes/library.tsx @@ -5,7 +5,7 @@ import { Download, Image as ImageIcon, Images, Info, LayoutGrid, Music, Trash2, import { api } from "../api"; import { SkeletonGrid } from "../components/loading"; import { ReviewBadge, type ReviewStatus } from "../components/review-badge"; -import type { Asset, AssetBatch } from "../types"; +import type { Asset, AssetBatch, VideoPack } from "../types"; import { ConfirmModal, MediaLightbox, useBodyScrollLock, useOverlayTransition } from "../components/overlays"; import { Pager } from "../components/pager"; @@ -36,22 +36,25 @@ const UPLOAD_KINDS: Array<{ value: string; label: string; accept: string; hint: ]; // 资产库 = 成品库(商品/模特/半成品各回各家): -// 图片成品 tryon=模特上身图 / kits=平台套图 / creations=自由创作;视频成品 videopacks=按项目素材包;others=其他兜底 -type LibTab = "tryon" | "kits" | "creations" | "videopacks" | "others"; +// 图片成品 tryon=模特上身图 / kits=平台套图 / image_creations=图片自由创作; +// video_creations=视频自由创作;视频成品 videopacks=按项目素材包;others=其他兜底 +type LibTab = "tryon" | "kits" | "image_creations" | "video_creations" | "videopacks" | "others"; +type LibCountKey = LibTab | "creations"; // creations 是旧后端兼容字段,不再作为可见 tab const LIB_TABS: Array<{ key: LibTab; label: string }> = [ - { key: "tryon", label: "模特上身图" }, { key: "kits", label: "平台套图" }, { key: "creations", label: "自由创作" }, + { key: "tryon", label: "模特上身图" }, { key: "kits", label: "平台套图" }, { key: "image_creations", label: "图片自由创作" }, + { key: "video_creations", label: "视频自由创作" }, { key: "videopacks", label: "视频成品" }, { key: "others", label: "其他" } ]; // 图片成品三类按「生成批次」成组展示(一次提交的多张图 = 一张批次卡,点开看整批);其余 tab 仍平铺 -const BATCH_TABS: LibTab[] = ["tryon", "kits", "creations"]; +const BATCH_TABS: LibTab[] = ["tryon", "kits", "image_creations"]; // 对齐 api-bridge:工具栏 chip 按 tab 显隐(视频成品走素材包,不用这些扁平筛选) const LIB_CHIPS: Array<{ key: string; label: string; tabs: LibTab[] }> = [ { key: "product", label: "关联商品", tabs: ["tryon", "kits"] }, { key: "kind", label: "资产类型", tabs: ["others"] }, - { key: "source", label: "来源", tabs: ["tryon", "kits", "creations", "others"] } + { key: "source", label: "来源", tabs: ["tryon", "kits", "image_creations", "video_creations", "others"] } ]; // metadata 里可能用的中文属性键(真实存在才渲染,缺则不显;属性区不造假) @@ -80,6 +83,20 @@ function personMissingTri(asset: Asset): boolean { return asset.source === "upload"; } +function freeVideoToPack(asset: Asset): VideoPack { + const files = asset.files || []; + const video = files.find((f) => f.is_primary && (f.content_type || "").startsWith("video/")) + || files.find((f) => (f.content_type || "").startsWith("video/")); + const poster = files.find((f) => !f.is_primary && (f.content_type || "").startsWith("image/")) + || files.find((f) => (f.content_type || "").startsWith("image/")); + return { + project_id: `free-video:${asset.id}`, + project_name: asset.name || "视频自由创作", + product_cover: poster?.preview_url || "", + clips: [{ id: asset.id, name: asset.name || "视频", url: video?.preview_url || "" }] + }; +} + // ───────────────────────────────────────────────────────────────────────── // 资产详情弹窗 · 立绘大图 + 三视图/版本历史(人物)+ 简介/标签/属性 + 媒体画廊(商品) // 用真实 Asset 字段渲染;缺字段留「-」或整段降级,不造 mock。 @@ -452,13 +469,13 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD // ── 服务端懒加载:列表按页拉、tab 计数走 summary、筛选项走 facets(不再前端取全量再切片)── const [items, setItems] = useState([]); const [total, setTotal] = useState(0); - const [counts, setCounts] = useState>({ tryon: 0, kits: 0, creations: 0, videopacks: 0, others: 0 }); + const [counts, setCounts] = useState>({ tryon: 0, kits: 0, image_creations: 0, video_creations: 0, videopacks: 0, others: 0, creations: 0 }); // 图片成品:按生成批次成组(点批次卡 → 弹窗看整批图片) const [batches, setBatches] = useState([]); const [openBatch, setOpenBatch] = useState(null); // 视频成品:按项目素材包(点包卡 → 弹窗看该项目所有片段) - const [packs, setPacks] = useState([]); - const [openPack, setOpenPack] = useState(null); + const [packs, setPacks] = useState([]); + const [openPack, setOpenPack] = useState(null); const [facets, setFacets] = useState<{ sources: string[]; kinds: string[]; metadata: Record; products: { id: string; title: string }[] }>({ sources: [], kinds: [], metadata: {}, products: [] }); const [loading, setLoading] = useState(false); const [page, setPage] = useState(1); @@ -477,6 +494,9 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD // 图片成品三类按批次成组展示;其余 tab 平铺 const isBatchTab = BATCH_TABS.includes(tab); + const isVideoCreationTab = tab === "video_creations"; + const imageBatchCount = counts.tryon + counts.kits + counts.image_creations; + const resultUnit = isBatchTab ? "个批次" : (tab === "video_creations" ? "条视频" : "个资产"); // 列表数据(分页 + 过滤) const [reloadFlag, setReloadFlag] = useState(0); @@ -542,6 +562,7 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD const packTotalPages = Math.max(1, Math.ceil(filteredPacks.length / LIB_PAGE_SIZE)); const packCurPage = Math.min(page, packTotalPages); const shownPacks = filteredPacks.slice((packCurPage - 1) * LIB_PAGE_SIZE, packCurPage * LIB_PAGE_SIZE); + const openPackIsFreeVideo = Boolean(openPack?.project_id?.startsWith("free-video:")); // 批量删除:批次 tab 走 selectedBatches(展开成整批 asset id),其余 tab 走 selected(asset id) const selCount = isBatchTab ? selectedBatches.size : selected.size; @@ -598,7 +619,7 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD

资产库

-
// 你的成品 · 图片 {counts.tryon + counts.kits + counts.creations} 批 · 视频 {counts.videopacks} 包
+
// 你的成品 · 图片 {imageBatchCount} 批 · 视频 {counts.video_creations} 条 · 视频成品 {counts.videopacks} 包
-
// 显示 {isBatchTab ? batches.length : shown.length} / {total} {isBatchTab ? "个批次" : "个资产"}{hasFilter ? "(已筛选)" : ""}{loading ? " · 加载中…" : ""}
+
// 显示 {isBatchTab ? batches.length : shown.length} / {total} {resultUnit}{hasFilter ? "(已筛选)" : ""}{loading ? " · 加载中…" : ""}
{isBatchTab ? ( batches.length ? ( @@ -809,6 +830,43 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD ) : (
// 当前分类暂无真实资产
) + ) : isVideoCreationTab ? ( + shown.length ? ( +
+ {shown.map((asset) => { + const pack = freeVideoToPack(asset); + const first = pack.clips[0]; + const isSelected = selected.has(asset.id); + const onVideoClick = editMode ? () => toggleSelect(asset.id) : () => setOpenPack(pack); + return ( +
{ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onVideoClick(); } }}> + + {editMode && onDelete && ( + + )} +
+ {pack.product_cover ? ( + {pack.project_name} + ) : first?.url ? ( +
+
{pack.project_name}
视频自由创作
+
+ ); + })} +
+ ) : loading ? ( + + ) : ( +
// 当前分类暂无真实资产
+ ) ) : shown.length ? (
{shown.map((asset) => { @@ -880,12 +938,12 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD {/* 视频素材包弹窗:看该项目所有视频片段(可播) */} {openPack && createPortal( -
{ if (e.target === e.currentTarget) setOpenPack(null); }}> +
{ if (e.target === e.currentTarget) setOpenPack(null); }}>

{openPack.project_name}

- // 视频素材包 · {openPack.clips.length} 段 + // {openPackIsFreeVideo ? "视频自由创作" : "视频素材包"} · {openPack.clips.length} {openPackIsFreeVideo ? "条" : "段"}