feat(images): 期2 图片趴三类 — 模特上身图/平台套图/自由创作 归类+成组+接模特库

后端:
- 独立生图三类归类:model+product→model_tryon(模特上身图,引用模特库,不送审) / cover→platform_kit(平台套图) / image→free_create(自由创作);生成演员(model 无 product)仍→person(视频角色,留期3)
- 成组:每次提交一个 batch_id 串起整批,落 asset.metadata;另记 mode + model_entity_id(上身图溯源模特库)
- generate-image 端点透传 model_entity_id;资产库 _tab_q+summary 加 tryon/kits/creations 三类桶
- 单测 StandaloneCategoryTests 直跑 worker 验四态归类全绿(绕开异步 .delay)

前端:
- 资产库 library.tsx 加三类 tab(模特上身图/平台套图/自由创作)+ 计数 + 筛选维度
- ai-tools 模特选择器数据源 person→模特库(listModels 映射,选中传形象图当参考 = 引用模特库),ActorLibrary 同源
- api.ts submitGenerateImage 加 model_entity_id

验收:tsc+build 全绿;无头 0 console error(资产库三类 tab 各归类正确、模特选择器 5 张取自模特库)
基线既有 3 失败(StandaloneImageReferenceTests 异步漂移)零新增

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-21 16:36:29 +08:00
co-authored by Claude Opus 4.8
parent 44e90233ff
commit c8f3f91d38
8 changed files with 187 additions and 29 deletions
+1 -1
View File
@@ -511,7 +511,7 @@ export const api = {
return request<Paginated<AITask>>("/api/ai/tasks/");
},
// 异步生图:提交后秒级返回任务列表(慢出图在 worker 跑),再用 generateImageStatus 轮询取结果
submitGenerateImage(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; reference_product?: boolean; model_id?: string; ratio?: string }) {
submitGenerateImage(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; reference_product?: boolean; model_id?: string; model_entity_id?: string; ratio?: string }) {
return request<{ tasks: { id: string; status: string }[] }>("/api/ai/generate-image/", { method: "POST", body: JSON.stringify(payload) });
},
generateImageStatus(ids: string[]) {
+30 -12
View File
@@ -1,4 +1,4 @@
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import type { ChangeEvent } from "react";
import {
@@ -23,7 +23,7 @@ import {
WandSparkles,
X
} from "lucide-react";
import type { AITask, Asset, ModelConfig, Product } from "../types";
import type { AITask, Asset, ModelConfig, ModelEntity, Product } from "../types";
import { api } from "../api";
import { ActorLibrary } from "../components/actor-library";
import { SkeletonRows } from "../components/loading";
@@ -580,16 +580,34 @@ export function ImageWorkbenchPage({
const imageModels = modelConfigs.filter((model) => model.capability.includes("image"));
/* 模特卡数据来源:服务端按 category=person 懒加载(不再吃全局 assets 全量数组);
无则回退到基线占位模特卡 Ava/Luna/Mia/Zoe */
/* 模特卡数据来源:期2 改为「模特库」(顶级实体,引用其形象图当上身图参考)。
映射 ModelEntity→Asset 形:id=形象图资产 id(选中即作 model_id 参考图),metadata 记 model_entity_id 溯源。
无模特则回退到基线占位卡 Ava/Luna/Mia/Zoe。深埋 ActorLibrary 弹窗同源,逻辑不动。 */
const [personAssets, setPersonAssets] = useState<Asset[]>([]);
useEffect(() => {
let alive = true;
api.assetsPage({ category: "person", pageSize: 200 })
.then((res) => { if (alive) setPersonAssets(res.results); })
.catch(() => { if (alive) setPersonAssets([]); });
return () => { alive = false; };
const loadModels = useCallback(() => {
api.listModels({ pageSize: 200 })
.then((res) => {
const mapped: Asset[] = res.results
.filter((m: ModelEntity) => m.portrait_asset)
.map((m: ModelEntity) => ({
id: m.portrait_asset as string,
name: m.name,
asset_type: "image",
source: m.source === "upload" ? "upload" : "ai_generated",
category: "model_portrait",
description: m.description || "",
metadata: { model_entity_id: m.id },
files: m.portrait
? [{ id: m.portrait_asset as string, object_key: "", bucket: "", content_type: "image/png", size_bytes: 0, preview_url: m.portrait, is_primary: true }]
: [],
created_at: m.created_at,
updated_at: m.updated_at,
}));
setPersonAssets(mapped);
})
.catch(() => setPersonAssets([]));
}, []);
useEffect(() => { loadModels(); }, [loadModels]);
useEffect(() => {
if (product) setPrompt(meta.promptTemplate(product.title));
@@ -1524,7 +1542,7 @@ export function ImageWorkbenchPage({
onPick={(assetId, assetName) => {
setPickedIds([assetId]);
setPickedModelName(assetName || "");
void api.assetsPage({ category: "person", pageSize: 200 }).then((res) => setPersonAssets(res.results)).catch(() => {});
loadModels();
setActorLibOpen(false);
}}
onGenerate={(p) => onGenerate({ prompt: p, mode: "model", count: 1 })}
@@ -1537,7 +1555,7 @@ export function ImageWorkbenchPage({
return api.uploadAsset(fd).catch(() => null);
}}
onRename={(assetId, name) => api.updateAsset(assetId, { name }).catch(() => null)}
onRefresh={() => { void api.assetsPage({ category: "person", pageSize: 200 }).then((res) => setPersonAssets(res.results)).catch(() => {}); }}
onRefresh={() => { loadModels(); }}
/>
{/* P0④:商品库全屏选择器(pl-modal · 多选 + 右上勾 + 已选汇总 · restraint token) */}
+6 -4
View File
@@ -34,10 +34,12 @@ const UPLOAD_KINDS: Array<{ value: string; label: string; accept: string; hint:
{ value: "subtitle", label: "字幕", accept: ".srt,.vtt,.ass", hint: "SRT / VTT / ASS" }
];
type LibTab = "people" | "scenes" | "products" | "finals" | "uploads" | "unclassified";
// 图片趴三类(期2):tryon=模特上身图 / kits=平台套图 / creations=自由创作
type LibTab = "people" | "scenes" | "products" | "tryon" | "kits" | "creations" | "finals" | "uploads" | "unclassified";
const LIB_TABS: Array<{ key: LibTab; label: string }> = [
{ key: "people", label: "人物" }, { key: "scenes", label: "场景" }, { key: "products", label: "商品图" },
{ key: "tryon", label: "模特上身图" }, { key: "kits", label: "平台套图" }, { key: "creations", label: "自由创作" },
{ key: "finals", label: "成片" }, { key: "uploads", label: "我的上传" }, { key: "unclassified", label: "未分类" }
];
@@ -47,11 +49,11 @@ const LIB_CHIPS: Array<{ key: string; label: string; tabs: LibTab[] }> = [
{ key: "age", label: "年龄段", tabs: ["people"] },
{ key: "role", label: "角色标签", tabs: ["people"] },
{ key: "sceneType", label: "场景类型", tabs: ["scenes"] },
{ key: "product", label: "关联商品", tabs: ["products"] },
{ key: "product", label: "关联商品", tabs: ["products", "tryon", "kits"] },
{ key: "project", label: "关联项目", tabs: ["finals"] },
{ key: "duration", label: "时长", tabs: ["finals"] },
{ key: "kind", label: "资产类型", tabs: ["uploads"] },
{ key: "source", label: "来源", tabs: ["people", "scenes", "products", "uploads"] }
{ key: "source", label: "来源", tabs: ["people", "scenes", "products", "tryon", "kits", "creations", "uploads"] }
];
// metadata 里可能用的中文属性键(真实存在才渲染,缺则不显;属性区不造假)
@@ -425,7 +427,7 @@ 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>>({ people: 0, scenes: 0, products: 0, finals: 0, uploads: 0, unclassified: 0 });
const [counts, setCounts] = useState<Record<LibTab, number>>({ people: 0, scenes: 0, products: 0, tryon: 0, kits: 0, creations: 0, finals: 0, uploads: 0, unclassified: 0 });
const [facets, setFacets] = useState<{ sources: string[]; kinds: string[]; metadata: Record<string, string[]> }>({ sources: [], kinds: [], metadata: {} });
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);