fix: 自由创作分类入库区分视频与图片展示

This commit is contained in:
hh
2026-07-07 18:39:01 +08:00
parent 45ff7838d8
commit ff723b4281
7 changed files with 228 additions and 22 deletions
+3 -2
View File
@@ -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 {
+58 -1
View File
@@ -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。
+78
View File
@@ -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。"""
+7 -2
View File
@@ -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)
+5 -1
View File
@@ -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; };
}, []);
+74 -16
View File
@@ -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<Asset[]>([]);
const [total, setTotal] = useState(0);
const [counts, setCounts] = useState<Record<LibTab, number>>({ tryon: 0, kits: 0, creations: 0, videopacks: 0, others: 0 });
const [counts, setCounts] = useState<Record<LibCountKey, number>>({ tryon: 0, kits: 0, image_creations: 0, video_creations: 0, videopacks: 0, others: 0, creations: 0 });
// 图片成品:按生成批次成组(点批次卡 → 弹窗看整批图片)
const [batches, setBatches] = useState<AssetBatch[]>([]);
const [openBatch, setOpenBatch] = useState<AssetBatch | null>(null);
// 视频成品:按项目素材包(点包卡 → 弹窗看该项目所有片段)
const [packs, setPacks] = useState<import("../types").VideoPack[]>([]);
const [openPack, setOpenPack] = useState<import("../types").VideoPack | null>(null);
const [packs, setPacks] = useState<VideoPack[]>([]);
const [openPack, setOpenPack] = useState<VideoPack | null>(null);
const [facets, setFacets] = useState<{ sources: string[]; kinds: string[]; metadata: Record<string, string[]>; 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
<div className="page-head">
<div>
<h1></h1>
<div className="sub"><span className="mono">// 你的成品 · 图片 {counts.tryon + counts.kits + counts.creations} · 视频 {counts.videopacks} 包</span></div>
<div className="sub"><span className="mono">// 你的成品 · 图片 {imageBatchCount} 批 · 视频 {counts.video_creations} · 视频成品 {counts.videopacks} 包</span></div>
</div>
<div className="actions">
<button className={`btn btn-edit-toggle${editMode ? " active" : ""}`} type="button" id="lib-manage-btn" onClick={() => (editMode ? exitEdit() : setEditMode(true))}>
@@ -783,7 +804,7 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
</div>
</div>
<div className="result-meta" id="result-meta">// 显示 <span className="count">{isBatchTab ? batches.length : shown.length}</span> / {total} {isBatchTab ? "个批次" : "个资产"}{hasFilter ? "(已筛选)" : ""}{loading ? " · 加载中…" : ""}</div>
<div className="result-meta" id="result-meta">// 显示 <span className="count">{isBatchTab ? batches.length : shown.length}</span> / {total} {resultUnit}{hasFilter ? "(已筛选)" : ""}{loading ? " · 加载中…" : ""}</div>
{isBatchTab ? (
batches.length ? (
@@ -809,6 +830,43 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
) : (
<div className="empty-filter">// 当前分类暂无真实资产</div>
)
) : isVideoCreationTab ? (
shown.length ? (
<div className="packs-grid" id="free-video-grid">
{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 (
<article className={`pack-card batch-card${isSelected ? " selected" : ""}`} key={asset.id} onClick={onVideoClick} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onVideoClick(); } }}>
<span className="card-check" aria-hidden="true"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 8 7 12 13 4" /></svg></span>
{editMode && onDelete && (
<button className="card-del-btn" type="button" title="删除视频" onClick={(event) => { event.stopPropagation(); setConfirmIds([asset.id]); }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2" /><path d="M19 6l-1.5 14a2 2 0 01-2 1.8H8.5a2 2 0 01-2-1.8L5 6" /></svg>
</button>
)}
<div className="placeholder asset-thumb pack-thumb">
{pack.product_cover ? (
<img src={pack.product_cover} alt={pack.project_name} loading="lazy" />
) : first?.url ? (
<video src={first.url} muted playsInline preload="metadata" style={{ width: "100%", height: "100%", objectFit: "cover", borderRadius: "inherit" }} />
) : (
<span className="ph-frame"></span>
)}
<span className="pack-count mono">1 </span>
<span className="lib-play-badge" aria-hidden="true"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z" /></svg></span>
</div>
<div className="asset-body"><div className="asset-name">{pack.project_name}</div><div className="asset-meta mono"></div></div>
</article>
);
})}
</div>
) : loading ? (
<SkeletonGrid count={8} />
) : (
<div className="empty-filter">// 当前分类暂无真实资产</div>
)
) : shown.length ? (
<div className="asset-grid" id="asset-grid">
{shown.map((asset) => {
@@ -880,12 +938,12 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
{/* 视频素材包弹窗:看该项目所有视频片段(可播) */}
{openPack && createPortal(
<div className="pack-modal-bg" role="dialog" aria-modal="true" aria-label="视频素材包" onClick={(e) => { if (e.target === e.currentTarget) setOpenPack(null); }}>
<div className="pack-modal-bg" role="dialog" aria-modal="true" aria-label={openPackIsFreeVideo ? "视频自由创作" : "视频素材包"} onClick={(e) => { if (e.target === e.currentTarget) setOpenPack(null); }}>
<div className="pack-modal">
<div className="pack-modal-h">
<div>
<h2>{openPack.project_name}</h2>
<span className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)" }}>// 视频素材包 · {openPack.clips.length} </span>
<span className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)" }}>// {openPackIsFreeVideo ? "视频自由创作" : "视频素材包"} · {openPack.clips.length} {openPackIsFreeVideo ? "条" : "段"}</span>
</div>
<button className="x" type="button" aria-label="关闭" onClick={() => setOpenPack(null)}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M6 6l12 12M6 18L18 6" /></svg>
@@ -894,8 +952,8 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
<div className="pack-clip-grid">
{openPack.clips.map((c, i) => (
<div className="pack-clip" key={c.id}>
<video src={c.url} controls muted playsInline preload="metadata" />
<div className="pack-clip-name mono"> {i + 1}</div>
{c.url ? <video src={c.url} controls muted playsInline preload="metadata" /> : <span className="pack-clip-ph ph-frame"></span>}
<div className="pack-clip-name mono">{openPackIsFreeVideo ? (c.name || "视频") : `${i + 1}`}</div>
</div>
))}
</div>
@@ -973,7 +1031,7 @@ export function LibraryPage({ onUpload, onDelete }: { onUpload: (formData: FormD
title="删除资产"
subtitle="// DELETE ASSET"
icon={<Trash2 size={16} />}
detail={`已选中 ${confirmIds?.length || 0} 个资产。删除的图片将回收到垃圾桶,是否需要删除?`}
detail={`已选中 ${confirmIds?.length || 0} 个资产。删除的资产将回收到垃圾桶,是否需要删除?`}
confirmText="删除"
onCancel={() => setConfirmIds(null)}
onConfirm={doDelete}