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] = []