feat: connect subject template library

This commit is contained in:
2026-05-18 15:59:56 +08:00
parent d9b51348fe
commit 48d4002cbd
4 changed files with 503 additions and 38 deletions

View File

@@ -38,6 +38,10 @@ CHARACTER_LIBRARY_DIR = Path(
os.getenv("CHARACTER_LIBRARY_DIR", Path(__file__).resolve().parent / "character_library" / "skg-characters")
).resolve()
CHARACTER_LIBRARY_MANIFEST = CHARACTER_LIBRARY_DIR / "manifest.json"
SUBJECT_TEMPLATE_DIR = Path(os.getenv("SUBJECT_TEMPLATE_DIR", JOBS_DIR / "_subject_templates")).resolve()
SUBJECT_TEMPLATE_IMAGE_DIR = SUBJECT_TEMPLATE_DIR / "images"
SUBJECT_TEMPLATE_MANIFEST = SUBJECT_TEMPLATE_DIR / "manifest.json"
SUBJECT_TEMPLATE_IMAGE_DIR.mkdir(parents=True, exist_ok=True)
LLM_BASE_URL = os.getenv("LLM_BASE_URL", "").strip()
LLM_API_KEY = os.getenv("LLM_API_KEY", "").strip()
@@ -452,6 +456,38 @@ class CharacterLibraryItem(BaseModel):
images: list[CharacterLibraryImage] = Field(default_factory=list)
class SubjectTemplateImage(BaseModel):
id: str
view: str
label: str = ""
filename: str
url: str = ""
width: int = 0
height: int = 0
background: AssetBackground = "white"
quality: AssetQuality = "hd"
size: AssetSize = "source"
source_asset_id: str = ""
source_frame_indices: list[int] = Field(default_factory=list)
created_at: float = 0.0
class SubjectTemplateItem(BaseModel):
id: str
name: str
description: str = ""
note: str = ""
source: Literal["database"] = "database"
source_job_id: str = ""
source_frame_idx: int = -1
source_element_id: str = ""
subject_style: Literal["transparent_human", "source_actor"] = "transparent_human"
primary_image: str = ""
images: list[SubjectTemplateImage] = Field(default_factory=list)
created_at: float = 0.0
updated_at: float = 0.0
class ProductFusionRegion(BaseModel):
x: float = 0
y: float = 0
@@ -802,6 +838,50 @@ def character_library_file(filename: str) -> Path:
return p
def load_subject_template_items() -> list[SubjectTemplateItem]:
if not SUBJECT_TEMPLATE_MANIFEST.exists():
return []
try:
data = json.loads(SUBJECT_TEMPLATE_MANIFEST.read_text(encoding="utf-8"))
items: list[SubjectTemplateItem] = []
for raw in data.get("templates", []):
item = SubjectTemplateItem(**raw)
for image in item.images:
image.url = f"/subject-templates/images/{image.filename}"
items.append(item)
items.sort(key=lambda item: item.updated_at or item.created_at, reverse=True)
return items
except Exception as e:
raise HTTPException(500, f"subject template manifest invalid: {e}")
def save_subject_template_items(items: list[SubjectTemplateItem]) -> None:
SUBJECT_TEMPLATE_MANIFEST.parent.mkdir(parents=True, exist_ok=True)
SUBJECT_TEMPLATE_MANIFEST.write_text(
json.dumps({"templates": [item.model_dump() for item in items]}, ensure_ascii=False, indent=2),
encoding="utf-8",
)
def find_subject_template_item(template_id: str) -> SubjectTemplateItem:
template_id = template_id.strip()
for item in load_subject_template_items():
if item.id == template_id:
return item
raise HTTPException(404, "subject template not found")
def subject_template_image_file(filename: str) -> Path:
p = (SUBJECT_TEMPLATE_IMAGE_DIR / filename).resolve()
try:
p.relative_to(SUBJECT_TEMPLATE_IMAGE_DIR)
except ValueError:
raise HTTPException(400, "invalid subject template image path")
if not p.exists():
raise HTTPException(404, "subject template image missing")
return p
def storyboard_ref_url(job_id: str, ref: dict | None) -> str:
if not ref:
return ""
@@ -3783,6 +3863,7 @@ class GenerateSubjectAssetsReq(BaseModel):
source_frame_indices: list[int] | None = None
views: list[str] | None = None
character_id: str = ""
subject_template_id: str = ""
subject_style: Literal["transparent_human", "source_actor"] = "transparent_human"
reconstruction_mode: Literal["same", "similar"] = "same"
prompt: str = ""
@@ -4230,8 +4311,21 @@ def generate_subject_assets(job_id: str, idx: int, element_id: str, req: Generat
character_reference_paths: list[Path] = []
character_reference_clause = ""
character_label = ""
subject_template_id = (req.subject_template_id or "").strip()
character_id = (req.character_id or "").strip()
if character_id:
if subject_template_id:
template = find_subject_template_item(subject_template_id)
character_label = template.name
for image in template.images[:10]:
character_reference_paths.append(subject_template_image_file(image.filename))
character_reference_clause = (
f"Selected reusable subject template from database: {template.name}. "
"Use these saved generated subject views as a high-quality creative direction and identity bible only; "
"do not copy pixels, file artifacts, exact pose, labels, or accidental defects. "
"Create a new innovative variation that keeps the same broad subject type, transparent wellness character language, "
"camera readability, shoulder/neck product compatibility, and commercial role. "
)
elif character_id:
character = find_character_library_item(character_id)
character_label = character.name
for image in character.images[:7]:
@@ -4854,6 +4948,15 @@ class AnalyzeProductViewsReq(BaseModel):
refs: list[dict] = Field(default_factory=list)
class SaveSubjectTemplateReq(BaseModel):
name: str
note: str = ""
frame_idx: int
element_id: str
asset_ids: list[str] = Field(default_factory=list)
subject_style: Literal["transparent_human", "source_actor"] = "transparent_human"
@app.get("/product-library/skg", response_model=list[ProductLibraryItem])
def list_skg_product_library() -> list[ProductLibraryItem]:
"""内置 SKG 白底产品图库。来源是本地筛选后的产品图 manifest。"""
@@ -4882,6 +4985,93 @@ def get_skg_character_library_image(filename: str):
return FileResponse(p, media_type=media_type)
@app.get("/subject-templates", response_model=list[SubjectTemplateItem])
def list_subject_templates() -> list[SubjectTemplateItem]:
"""数据库化主体模板库。保存后的相似主体可被后续任务复用为创意参考。"""
return load_subject_template_items()
@app.get("/subject-templates/images/{filename:path}")
def get_subject_template_image(filename: str):
p = subject_template_image_file(filename)
return FileResponse(p, media_type="image/jpeg")
@app.post("/jobs/{job_id}/subject-templates", response_model=SubjectTemplateItem)
def save_subject_template(job_id: str, req: SaveSubjectTemplateReq) -> SubjectTemplateItem:
"""把当前 job 里已确认的相似主体视图复制到主体模板库。"""
import time as _time
job = JOBS.get(job_id)
if not job:
raise HTTPException(404, "job not found")
name = req.name.strip()
if not name:
raise HTTPException(400, "template name required")
frame = _find_frame(job, req.frame_idx)
element = next((e for e in frame.elements if e.id == req.element_id), None)
if not element:
raise HTTPException(404, "element not found")
requested_ids = [x.strip() for x in req.asset_ids if x.strip()]
selected_assets = [asset for asset in (element.subject_assets or []) if not requested_ids or asset.id in requested_ids]
if requested_ids:
selected_assets.sort(key=lambda asset: requested_ids.index(asset.id) if asset.id in requested_ids else 999)
else:
selected_assets.sort(key=lambda asset: asset.created_at, reverse=True)
if not selected_assets:
raise HTTPException(400, "no subject assets to save")
template_id = f"subject-template-{uuid.uuid4().hex[:10]}"
template_dir = SUBJECT_TEMPLATE_IMAGE_DIR / template_id
template_dir.mkdir(parents=True, exist_ok=True)
now = _time.time()
images: list[SubjectTemplateImage] = []
for asset in selected_assets:
src = job_dir(job_id) / "assets" / f"{asset.id}.jpg"
if not src.exists():
continue
image_id = f"{asset.view}_{uuid.uuid4().hex[:8]}"
filename = f"{template_id}/{image_id}.jpg"
dst = SUBJECT_TEMPLATE_IMAGE_DIR / filename
shutil.copy2(src, dst)
images.append(SubjectTemplateImage(
id=image_id,
view=asset.view,
label=asset.label or asset.view,
filename=filename,
url=f"/subject-templates/images/{filename}",
width=asset.width,
height=asset.height,
background=asset.background,
quality=asset.quality,
size=asset.size,
source_asset_id=asset.id,
source_frame_indices=asset.source_frame_indices,
created_at=asset.created_at or now,
))
if not images:
raise HTTPException(404, "subject asset files missing")
primary = next((image.id for image in images if image.view == "front"), images[0].id)
item = SubjectTemplateItem(
id=template_id,
name=name,
description=req.note.strip(),
note=req.note.strip(),
source_job_id=job_id,
source_frame_idx=frame.index,
source_element_id=element.id,
subject_style=req.subject_style,
primary_image=primary,
images=images,
created_at=now,
updated_at=now,
)
items = [item] + [existing for existing in load_subject_template_items() if existing.id != item.id]
save_subject_template_items(items)
return item
def normalize_product_asset_image(src: Path, out: Path) -> dict:
original_bytes = src.stat().st_size if src.exists() else 0
actions: list[str] = []

File diff suppressed because one or more lines are too long

View File

@@ -14,6 +14,7 @@ import {
type GeneratedVideo,
type ImageRef,
type CharacterLibraryItem,
type SubjectTemplateItem,
type Job,
type KeyElement,
type KeyFrame,
@@ -39,11 +40,14 @@ import {
getRuntimeHealth,
hasCutout,
listCharacterLibrary,
listSubjectTemplates,
representativeCutoutUrl,
resolveImageRefUrl,
rewriteStoryboardScript,
saveSubjectTemplate,
saveProductRefs,
sourceAudioUrl,
subjectTemplateImageUrl,
updateStoryboard,
uploadStoryboardAsset,
videoUrl,
@@ -485,7 +489,9 @@ function findSimilarActorSource(preferredFrames: KeyFrame[], allFrames: KeyFrame
return null
}
function buildSimilarSubjectPrompt(subjectStyle: SubjectStyleMode, direction: string, selectedCharacter?: CharacterLibraryItem | null) {
type SubjectTemplatePromptSource = { name: string; sourceLabel: string } | null
function buildSimilarSubjectPrompt(subjectStyle: SubjectStyleMode, direction: string, selectedTemplate?: SubjectTemplatePromptSource) {
const base = [
"Create a new similar but non-identical information-feed ad subject from the selected reference frames.",
"Treat all selected frames as evidence for ONE same subject, not multiple different subjects.",
@@ -495,10 +501,10 @@ function buildSimilarSubjectPrompt(subjectStyle: SubjectStyleMode, direction: st
"This is for SKG neck-and-shoulder wearable massage device videos: keep neck, collarbone, shoulders, side neck, upper back, shoulder blades, and product placement area clean and visible.",
"Output high-definition assets suitable for downstream video generation.",
]
if (selectedCharacter) {
if (selectedTemplate) {
base.push(
`Built-in creative character selected: ${selectedCharacter.name}.`,
"Use the built-in images as planned creative direction only; generate an innovative variation, not a duplicate of that character pack.",
`Creative subject template selected: ${selectedTemplate.name} (${selectedTemplate.sourceLabel}).`,
"Use the template images as planned creative direction only; generate an innovative variation, not a duplicate of that subject pack.",
)
}
if (subjectStyle === "transparent_human") {
@@ -523,7 +529,7 @@ function subjectAssetUrl(job: Job, asset: SubjectAsset) {
return apiAssetUrl(asset.url) || resolveImageRefUrl(job.id, { kind: "asset", frame_idx: 0, element_id: asset.id })
}
function characterPreviewImage(character?: CharacterLibraryItem | null) {
function characterPreviewImage(character?: { primary_image?: string; images?: Array<{ id: string; view?: string; filename: string; label?: string }> } | null) {
if (!character?.images?.length) return null
return character.images.find((image) => image.id === character.primary_image)
?? character.images.find((image) => image.view === "front")
@@ -2100,6 +2106,12 @@ function SourceReferenceBuildPanel({
const [subjectDirection, setSubjectDirection] = useState("")
const [characterLibrary, setCharacterLibrary] = useState<CharacterLibraryItem[]>([])
const [selectedCharacterId, setSelectedCharacterId] = useState("")
const [subjectTemplateLibrary, setSubjectTemplateLibrary] = useState<SubjectTemplateItem[]>([])
const [selectedSubjectTemplateId, setSelectedSubjectTemplateId] = useState("")
const [templateLibraryBusy, setTemplateLibraryBusy] = useState(false)
const [templateSaveBusy, setTemplateSaveBusy] = useState(false)
const [templateDraftName, setTemplateDraftName] = useState("")
const [templateDraftNote, setTemplateDraftNote] = useState("")
const frames = useMemo(() => [...job.frames].sort((a, b) => a.timestamp - b.timestamp), [job.frames])
const selectedReferenceFrames = useMemo(
() => frames.filter((frame) => selectedFrames.has(frame.index)),
@@ -2117,6 +2129,15 @@ function SourceReferenceBuildPanel({
() => characterLibrary.find((character) => character.id === selectedCharacterId) ?? null,
[characterLibrary, selectedCharacterId],
)
const selectedSubjectTemplate = useMemo(
() => subjectTemplateLibrary.find((template) => template.id === selectedSubjectTemplateId) ?? null,
[subjectTemplateLibrary, selectedSubjectTemplateId],
)
const selectedTemplatePrompt = selectedSubjectTemplate
? { name: selectedSubjectTemplate.name, sourceLabel: "数据库主体模板" }
: selectedCharacter
? { name: selectedCharacter.name, sourceLabel: "内置策划形象" }
: null
const visibleActorAssets = useMemo(() => {
const latestByView = new Map<string, SubjectAsset>()
for (const asset of actorAssets) {
@@ -2136,19 +2157,47 @@ function SourceReferenceBuildPanel({
: frames.length
? `默认使用全部 ${frames.length} 张参考帧`
: "待抽帧"
const templateSaveHint = visibleActorAssets.length
? templateDraftName.trim()
? "保存后会进入左侧主体模板库,后续任务可直接复用"
: "先给这套主体命名,再保存到主体模板库"
: "先生成本次主体视图,再决定是否入库"
const templateSourceLabel = selectedSubjectTemplate
? `${selectedSubjectTemplate.name} · 数据库模板`
: selectedCharacter
? `${selectedCharacter.name} · 模板参考`
: "源视频关键帧 · 相似创新"
const loadSubjectTemplateLibrary = async (silent = false) => {
setTemplateLibraryBusy(true)
try {
const items = await listSubjectTemplates()
setSubjectTemplateLibrary(items)
} catch (e) {
if (!silent) toast.error("主体模板库读取失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setTemplateLibraryBusy(false)
}
}
useEffect(() => {
let cancelled = false
listCharacterLibrary()
.then((items) => {
if (!cancelled) setCharacterLibrary(items)
})
.catch((e) => {
if (!cancelled) toast.error("内置形象读取失败:" + (e instanceof Error ? e.message : String(e)))
Promise.allSettled([listCharacterLibrary(), listSubjectTemplates()])
.then(([characters, templates]) => {
if (cancelled) return
if (characters.status === "fulfilled") setCharacterLibrary(characters.value)
else toast.error("内置形象读取失败:" + (characters.reason instanceof Error ? characters.reason.message : String(characters.reason)))
if (templates.status === "fulfilled") setSubjectTemplateLibrary(templates.value)
else toast.error("主体模板库读取失败:" + (templates.reason instanceof Error ? templates.reason.message : String(templates.reason)))
})
return () => { cancelled = true }
}, [])
useEffect(() => {
setTemplateDraftName("")
setTemplateDraftNote("")
}, [job.id])
const generateSimilarActor = async () => {
if (!frames.length) {
toast.warning("请先自动抽帧 12 张,或在原版视频上手动补帧。")
@@ -2163,11 +2212,11 @@ function SourceReferenceBuildPanel({
let element = workingFrame.elements?.find(isSimilarActorElement)
if (!element) {
workingJob = await addElement(job.id, baseFrame.index, {
name_zh: selectedCharacter
? `相似透明骨架主体 · ${selectedCharacter.name}`
name_zh: selectedTemplatePrompt
? `相似透明骨架主体 · ${selectedTemplatePrompt.name}`
: subjectStyle === "transparent_human" ? "相似透明骨架主体" : "相似广告主角",
name_en: selectedCharacter
? `similar innovative transparent skeleton humanoid subject based on ${selectedCharacter.name}`
name_en: selectedTemplatePrompt
? `similar innovative transparent skeleton humanoid subject based on ${selectedTemplatePrompt.name}`
: subjectStyle === "transparent_human" ? "similar transparent skeleton humanoid subject" : "similar ad actor",
position: "source-video main subject selected from global keyframes",
source: "manual",
@@ -2188,7 +2237,8 @@ function SourceReferenceBuildPanel({
source_frame_indices: subjectReferenceFrames.slice(0, 12).map((frame) => frame.index),
views: SUBJECT_ASSET_VIEWS.map((view) => view.value),
character_id: selectedCharacterId,
prompt: buildSimilarSubjectPrompt(subjectStyle, subjectDirection, selectedCharacter),
subject_template_id: selectedSubjectTemplateId,
prompt: buildSimilarSubjectPrompt(subjectStyle, subjectDirection, selectedTemplatePrompt),
replace_views: true,
})
onJobUpdate(updated)
@@ -2216,7 +2266,8 @@ function SourceReferenceBuildPanel({
source_frame_indices: sourceIndices,
views: [asset.view],
character_id: selectedCharacterId,
prompt: buildSimilarSubjectPrompt(subjectStyle, subjectDirection, selectedCharacter),
subject_template_id: selectedSubjectTemplateId,
prompt: buildSimilarSubjectPrompt(subjectStyle, subjectDirection, selectedTemplatePrompt),
replace_views: true,
})
onJobUpdate(updated)
@@ -2242,6 +2293,39 @@ function SourceReferenceBuildPanel({
}
}
const saveGeneratedSubjectTemplate = async () => {
if (!actorSource || !visibleActorAssets.length) {
toast.warning("请先生成相似主体视图。")
return
}
const name = templateDraftName.trim()
if (!name) {
toast.warning("请先给这套主体模板命名。")
return
}
setTemplateSaveBusy(true)
try {
const item = await saveSubjectTemplate(job.id, {
name,
note: templateDraftNote.trim(),
frame_idx: actorSource.frame.index,
element_id: actorSource.element.id,
asset_ids: visibleActorAssets.map((asset) => asset.id),
subject_style: subjectStyle,
})
setSubjectTemplateLibrary((items) => [item, ...items.filter((template) => template.id !== item.id)])
setSelectedSubjectTemplateId(item.id)
setSelectedCharacterId("")
setTemplateDraftName("")
setTemplateDraftNote("")
toast.success("已保存到主体模板库")
} catch (e) {
toast.error("保存主体模板失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setTemplateSaveBusy(false)
}
}
return (
<div className="min-w-0">
<div className="mb-2 flex items-center justify-between gap-3">
@@ -2253,31 +2337,63 @@ function SourceReferenceBuildPanel({
</div>
</div>
<div className="rounded-md border border-white/10 bg-black/32 p-2">
<div>
<div className="mb-1.5 flex flex-wrap items-center justify-between gap-2 text-[10px] text-white/36">
<div className="flex items-center gap-2">
<span></span>
<ModelTrace trace={similarSubjectModelTrace(runtimeModels, subjectStyle)} compact />
</div>
<span className="text-[10px] text-white/32"></span>
</div>
<div className="mb-2">
<div className="mb-1 flex items-center justify-between gap-2">
<span className="text-[10px] text-white/38"></span>
<span className="text-[9px] text-white/28">{selectedCharacter ? `${selectedCharacter.name} · ${selectedCharacter.images.length} 张参考` : "源视频主角相似创新"}</span>
<div className="mb-2 grid gap-2 lg:grid-cols-[minmax(360px,1fr)_minmax(300px,0.8fr)]">
<div className="rounded-md border border-white/10 bg-black/28 p-2">
<div className="mb-1.5 flex items-center justify-between gap-2">
<div>
<div className="text-[10.5px] font-semibold text-white/70"></div>
<div className="mt-0.5 text-[9px] text-white/32"></div>
</div>
<button
type="button"
onClick={() => void loadSubjectTemplateLibrary()}
disabled={templateLibraryBusy}
className="inline-flex h-6 items-center gap-1 rounded border border-emerald-200/20 bg-emerald-300/10 px-1.5 text-[9px] font-semibold text-emerald-100/80 transition hover:border-emerald-200/40 disabled:cursor-wait disabled:opacity-50"
>
{templateLibraryBusy ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
{subjectTemplateLibrary.length}
</button>
</div>
<div className="grid grid-cols-[repeat(auto-fill,minmax(86px,1fr))] gap-1.5">
<button
type="button"
onClick={() => setSelectedCharacterId("")}
onClick={() => {
setSelectedCharacterId("")
setSelectedSubjectTemplateId("")
}}
className={`min-h-[58px] rounded-md border px-2 py-1.5 text-left transition ${
!selectedCharacterId ? "border-cyan-200/55 bg-cyan-300/12 text-cyan-50" : "border-white/10 bg-black/25 text-white/45 hover:border-white/22 hover:text-white/70"
!selectedCharacterId && !selectedSubjectTemplateId ? "border-cyan-200/55 bg-cyan-300/12 text-cyan-50" : "border-white/10 bg-black/25 text-white/45 hover:border-white/22 hover:text-white/70"
}`}
>
<span className="block text-[10.5px] font-semibold"></span>
<span className="mt-1 block text-[9px] leading-tight opacity-70"></span>
</button>
{subjectTemplateLibrary.map((template) => {
const preview = characterPreviewImage(template)
const active = selectedSubjectTemplateId === template.id
return (
<button
key={template.id}
type="button"
onClick={() => {
setSelectedSubjectTemplateId(template.id)
setSelectedCharacterId("")
setSubjectStyle(template.subject_style || "transparent_human")
}}
className={`group flex min-h-[58px] items-center gap-1.5 rounded-md border px-1.5 py-1 text-left transition ${
active ? "border-cyan-200/65 bg-cyan-300/12 text-cyan-50" : "border-white/10 bg-black/25 text-white/50 hover:border-cyan-200/35 hover:text-white/80"
}`}
>
<span className="h-12 w-9 shrink-0 overflow-hidden rounded border border-white/10 bg-white">
{preview ? <img src={subjectTemplateImageUrl(preview.filename)} alt={template.name} className="h-full w-full object-cover" /> : null}
</span>
<span className="min-w-0">
<span className="block truncate text-[10px] font-semibold">{template.name}</span>
<span className="mt-0.5 block text-[8.5px] opacity-58"> · {template.images.length} </span>
</span>
</button>
)
})}
{characterLibrary.map((character) => {
const preview = characterPreviewImage(character)
const active = selectedCharacterId === character.id
@@ -2287,6 +2403,7 @@ function SourceReferenceBuildPanel({
type="button"
onClick={() => {
setSelectedCharacterId(character.id)
setSelectedSubjectTemplateId("")
setSubjectStyle("transparent_human")
}}
className={`group flex min-h-[58px] items-center gap-1.5 rounded-md border px-1.5 py-1 text-left transition ${
@@ -2298,13 +2415,26 @@ function SourceReferenceBuildPanel({
</span>
<span className="min-w-0">
<span className="block truncate text-[10px] font-semibold">{character.name}</span>
<span className="mt-0.5 block text-[8.5px] opacity-58">7 </span>
<span className="mt-0.5 block text-[8.5px] opacity-58"> · 7 </span>
</span>
</button>
)
})}
</div>
{selectedCharacter?.images?.length ? (
{!subjectTemplateLibrary.length ? (
<div className="mt-1.5 rounded border border-dashed border-white/10 px-2 py-1.5 text-[9px] leading-snug text-white/28">
</div>
) : null}
{selectedSubjectTemplate?.images?.length ? (
<div className="mt-1.5 flex gap-1 overflow-x-auto pb-0.5">
{selectedSubjectTemplate.images.slice(0, 10).map((image) => (
<div key={image.id} className="h-12 w-9 shrink-0 overflow-hidden rounded border border-white/10 bg-white" title={image.label}>
<img src={subjectTemplateImageUrl(image.filename)} alt={image.label} className="h-full w-full object-cover" />
</div>
))}
</div>
) : selectedCharacter?.images?.length ? (
<div className="mt-1.5 flex gap-1 overflow-x-auto pb-0.5">
{selectedCharacter.images.slice(0, 7).map((image) => (
<div key={image.id} className="h-12 w-9 shrink-0 overflow-hidden rounded border border-white/10 bg-white" title={image.label}>
@@ -2315,6 +2445,60 @@ function SourceReferenceBuildPanel({
) : null}
</div>
<div className="rounded-md border border-white/10 bg-black/28 p-2">
<div className="mb-1.5 flex flex-wrap items-start justify-between gap-2">
<div>
<div className="flex items-center gap-2 text-[10.5px] font-semibold text-white/70">
<span> / 稿</span>
<ModelTrace trace={similarSubjectModelTrace(runtimeModels, subjectStyle)} compact />
</div>
<div className="mt-0.5 text-[9px] text-white/32">{templateSourceLabel} · {visibleActorAssets.length}/{SUBJECT_ASSET_VIEWS.length} </div>
</div>
<span className={`rounded border px-1.5 py-0.5 text-[9px] font-semibold ${
visibleActorAssets.length ? "border-emerald-200/25 bg-emerald-300/10 text-emerald-100/80" : "border-white/10 bg-white/5 text-white/36"
}`}>
{visibleActorAssets.length ? "可命名待入库" : "未生成"}
</span>
</div>
<div className="grid gap-1.5">
<input
value={templateDraftName}
onChange={(event) => setTemplateDraftName(event.target.value)}
placeholder="模板命名:如透明骨架女性 01"
className="h-7 rounded-md border border-white/10 bg-black/35 px-2 text-[10.5px] text-white outline-none placeholder:text-white/28 focus:border-cyan-300/50"
/>
<textarea
value={templateDraftNote}
onChange={(event) => setTemplateDraftNote(event.target.value)}
placeholder="备注:适合什么广告、人物年龄/性别/材质、禁用点"
className="min-h-[46px] resize-none rounded-md border border-white/10 bg-black/35 px-2 py-1.5 text-[10.5px] leading-snug text-white outline-none placeholder:text-white/28 focus:border-cyan-300/50"
/>
<div className="flex items-center justify-between gap-2">
<span className="min-w-0 text-[9px] leading-snug text-white/32">{templateSaveHint}</span>
<button
type="button"
onClick={() => void saveGeneratedSubjectTemplate()}
disabled={!visibleActorAssets.length || !templateDraftName.trim() || templateSaveBusy}
title={!visibleActorAssets.length ? "先生成主体视图" : !templateDraftName.trim() ? "先填写模板名称" : "保存到主体模板库"}
className="inline-flex h-7 shrink-0 items-center justify-center gap-1 rounded-md border border-emerald-200/25 bg-emerald-300/12 px-2 text-[10px] font-semibold text-emerald-50 transition hover:border-emerald-200/45 hover:bg-emerald-300/18 disabled:cursor-not-allowed disabled:border-white/10 disabled:bg-white/6 disabled:text-white/32"
>
{templateSaveBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plus className="h-3.5 w-3.5" />}
</button>
</div>
</div>
</div>
</div>
<div>
<div className="mb-1.5 flex flex-wrap items-center justify-between gap-2 text-[10px] text-white/36">
<div className="flex items-center gap-2">
<span></span>
<span className="text-white/28">{referenceCountLabel}</span>
</div>
<span className="text-[10px] text-white/32"></span>
</div>
<div className="mb-1.5 flex flex-wrap items-center justify-end gap-2 text-[10px] text-white/36">
<div className="flex min-w-0 flex-wrap items-center justify-end gap-2">
<div className="flex rounded-md border border-white/10 bg-black/28 p-0.5">

View File

@@ -359,6 +359,49 @@ export function characterLibraryImageUrl(filename: string): string {
return `${API_BASE}/character-library/skg/images/${filename}`
}
export async function listSubjectTemplates(): Promise<SubjectTemplateItem[]> {
const res = await fetch(`${API_BASE}/subject-templates`)
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw new Error(`listSubjectTemplates ${res.status} ${txt.slice(0, 300)}`)
}
return res.json()
}
export function subjectTemplateImageUrl(filename: string): string {
return `${API_BASE}/subject-templates/images/${filename}`
}
export async function saveSubjectTemplate(
jobId: string,
body: {
name: string
note?: string
frame_idx: number
element_id: string
asset_ids: string[]
subject_style?: "transparent_human" | "source_actor"
},
): Promise<SubjectTemplateItem> {
const res = await fetch(`${API_BASE}/jobs/${jobId}/subject-templates`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: body.name,
note: body.note ?? "",
frame_idx: body.frame_idx,
element_id: body.element_id,
asset_ids: body.asset_ids,
subject_style: body.subject_style ?? "transparent_human",
}),
})
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw new Error(`saveSubjectTemplate ${res.status} ${txt.slice(0, 300)}`)
}
return res.json()
}
export async function copyCharacterLibraryAssets(jobId: string, characterId: string): Promise<{ character_id: string; character_name: string; images: ImageRef[] }> {
const res = await fetch(`${API_BASE}/jobs/${jobId}/assets/character-library`, {
method: "POST",
@@ -520,6 +563,38 @@ export interface CharacterLibraryItem {
images: CharacterLibraryImage[]
}
export interface SubjectTemplateImage {
id: string
view: string
label: string
filename: string
url: string
width: number
height: number
background: "white" | "black"
quality: "hd"
size: "source" | "1024" | "1536" | "2048"
source_asset_id: string
source_frame_indices: number[]
created_at: number
}
export interface SubjectTemplateItem {
id: string
name: string
description: string
note: string
source: "database"
source_job_id: string
source_frame_idx: number
source_element_id: string
subject_style: "transparent_human" | "source_actor"
primary_image: string
images: SubjectTemplateImage[]
created_at: number
updated_at: number
}
export interface TranscriptSegment {
index: number
start: number
@@ -1087,6 +1162,7 @@ export async function generateSubjectAssets(
source_frame_indices?: number[]
views?: string[]
character_id?: string
subject_template_id?: string
subject_style?: "transparent_human" | "source_actor"
reconstruction_mode?: "same" | "similar"
prompt?: string
@@ -1104,6 +1180,7 @@ export async function generateSubjectAssets(
source_frame_indices: body.source_frame_indices ?? null,
views: body.views ?? null,
character_id: body.character_id ?? "",
subject_template_id: body.subject_template_id ?? "",
subject_style: body.subject_style ?? "transparent_human",
reconstruction_mode: body.reconstruction_mode ?? "same",
prompt: body.prompt ?? "",