feat: connect subject template library
This commit is contained in:
192
api/main.py
192
api/main.py
@@ -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
@@ -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">
|
||||
|
||||
@@ -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 ?? "",
|
||||
|
||||
Reference in New Issue
Block a user