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

@@ -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 ?? "",