auto-save 2026-05-14 13:04 (+1, ~3)

This commit is contained in:
2026-05-14 13:05:12 +08:00
parent 887c9a0b09
commit 69e73d44e6
39 changed files with 555 additions and 13 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

@@ -0,0 +1,367 @@
{
"source": "/Users/kangwan/Desktop/skg_anatomy_characters_20260514_120852",
"model": "gpt-image-2",
"quality": "high",
"characters": [
{
"id": "character-01",
"name": "运动阳光男",
"folder": "01_运动阳光男",
"description": "运动阳光男透明骨架人角色含正面、左右45度、侧面、背面、半身近景和背部特写参考。",
"primary_image": "character-01-front",
"images": [
{
"id": "character-01-front",
"view": "front",
"label": "正面",
"filename": "images/character-01-front.png",
"width": 1536,
"height": 2048,
"source_path": "/Users/kangwan/Desktop/skg_anatomy_characters_20260514_120852/01_运动阳光男/01_正面.png"
},
{
"id": "character-01-left_45",
"view": "left_45",
"label": "左45度",
"filename": "images/character-01-left_45.png",
"width": 1536,
"height": 2048,
"source_path": "/Users/kangwan/Desktop/skg_anatomy_characters_20260514_120852/01_运动阳光男/02_左45度.png"
},
{
"id": "character-01-right_45",
"view": "right_45",
"label": "右45度",
"filename": "images/character-01-right_45.png",
"width": 1536,
"height": 2048,
"source_path": "/Users/kangwan/Desktop/skg_anatomy_characters_20260514_120852/01_运动阳光男/03_右45度.png"
},
{
"id": "character-01-side",
"view": "side",
"label": "侧面",
"filename": "images/character-01-side.png",
"width": 1536,
"height": 2048,
"source_path": "/Users/kangwan/Desktop/skg_anatomy_characters_20260514_120852/01_运动阳光男/04_侧面.png"
},
{
"id": "character-01-back",
"view": "back",
"label": "背面",
"filename": "images/character-01-back.png",
"width": 1536,
"height": 2048,
"source_path": "/Users/kangwan/Desktop/skg_anatomy_characters_20260514_120852/01_运动阳光男/05_背面.png"
},
{
"id": "character-01-bust",
"view": "bust",
"label": "半身近景",
"filename": "images/character-01-bust.png",
"width": 2048,
"height": 2048,
"source_path": "/Users/kangwan/Desktop/skg_anatomy_characters_20260514_120852/01_运动阳光男/06_半身近景.png"
},
{
"id": "character-01-back_detail",
"view": "back_detail",
"label": "背部特写",
"filename": "images/character-01-back_detail.png",
"width": 2048,
"height": 2048,
"source_path": "/Users/kangwan/Desktop/skg_anatomy_characters_20260514_120852/01_运动阳光男/07_背部特写.png"
}
]
},
{
"id": "character-02",
"name": "都市型男",
"folder": "02_都市型男",
"description": "都市型男透明骨架人角色含正面、左右45度、侧面、背面、半身近景和背部特写参考。",
"primary_image": "character-02-front",
"images": [
{
"id": "character-02-front",
"view": "front",
"label": "正面",
"filename": "images/character-02-front.png",
"width": 1536,
"height": 2048,
"source_path": "/Users/kangwan/Desktop/skg_anatomy_characters_20260514_120852/02_都市型男/01_正面.png"
},
{
"id": "character-02-left_45",
"view": "left_45",
"label": "左45度",
"filename": "images/character-02-left_45.png",
"width": 1536,
"height": 2048,
"source_path": "/Users/kangwan/Desktop/skg_anatomy_characters_20260514_120852/02_都市型男/02_左45度.png"
},
{
"id": "character-02-right_45",
"view": "right_45",
"label": "右45度",
"filename": "images/character-02-right_45.png",
"width": 1536,
"height": 2048,
"source_path": "/Users/kangwan/Desktop/skg_anatomy_characters_20260514_120852/02_都市型男/03_右45度.png"
},
{
"id": "character-02-side",
"view": "side",
"label": "侧面",
"filename": "images/character-02-side.png",
"width": 1536,
"height": 2048,
"source_path": "/Users/kangwan/Desktop/skg_anatomy_characters_20260514_120852/02_都市型男/04_侧面.png"
},
{
"id": "character-02-back",
"view": "back",
"label": "背面",
"filename": "images/character-02-back.png",
"width": 1536,
"height": 2048,
"source_path": "/Users/kangwan/Desktop/skg_anatomy_characters_20260514_120852/02_都市型男/05_背面.png"
},
{
"id": "character-02-bust",
"view": "bust",
"label": "半身近景",
"filename": "images/character-02-bust.png",
"width": 2048,
"height": 2048,
"source_path": "/Users/kangwan/Desktop/skg_anatomy_characters_20260514_120852/02_都市型男/06_半身近景.png"
},
{
"id": "character-02-back_detail",
"view": "back_detail",
"label": "背部特写",
"filename": "images/character-02-back_detail.png",
"width": 2048,
"height": 2048,
"source_path": "/Users/kangwan/Desktop/skg_anatomy_characters_20260514_120852/02_都市型男/07_背部特写.png"
}
]
},
{
"id": "character-03",
"name": "优雅白领女",
"folder": "03_优雅白领女",
"description": "优雅白领女透明骨架人角色含正面、左右45度、侧面、背面、半身近景和背部特写参考。",
"primary_image": "character-03-front",
"images": [
{
"id": "character-03-front",
"view": "front",
"label": "正面",
"filename": "images/character-03-front.png",
"width": 1536,
"height": 2048,
"source_path": "/Users/kangwan/Desktop/skg_anatomy_characters_20260514_120852/03_优雅白领女/01_正面.png"
},
{
"id": "character-03-left_45",
"view": "left_45",
"label": "左45度",
"filename": "images/character-03-left_45.png",
"width": 1536,
"height": 2048,
"source_path": "/Users/kangwan/Desktop/skg_anatomy_characters_20260514_120852/03_优雅白领女/02_左45度.png"
},
{
"id": "character-03-right_45",
"view": "right_45",
"label": "右45度",
"filename": "images/character-03-right_45.png",
"width": 1536,
"height": 2048,
"source_path": "/Users/kangwan/Desktop/skg_anatomy_characters_20260514_120852/03_优雅白领女/03_右45度.png"
},
{
"id": "character-03-side",
"view": "side",
"label": "侧面",
"filename": "images/character-03-side.png",
"width": 1536,
"height": 2048,
"source_path": "/Users/kangwan/Desktop/skg_anatomy_characters_20260514_120852/03_优雅白领女/04_侧面.png"
},
{
"id": "character-03-back",
"view": "back",
"label": "背面",
"filename": "images/character-03-back.png",
"width": 1536,
"height": 2048,
"source_path": "/Users/kangwan/Desktop/skg_anatomy_characters_20260514_120852/03_优雅白领女/05_背面.png"
},
{
"id": "character-03-bust",
"view": "bust",
"label": "半身近景",
"filename": "images/character-03-bust.png",
"width": 2048,
"height": 2048,
"source_path": "/Users/kangwan/Desktop/skg_anatomy_characters_20260514_120852/03_优雅白领女/06_半身近景.png"
},
{
"id": "character-03-back_detail",
"view": "back_detail",
"label": "背部特写",
"filename": "images/character-03-back_detail.png",
"width": 2048,
"height": 2048,
"source_path": "/Users/kangwan/Desktop/skg_anatomy_characters_20260514_120852/03_优雅白领女/07_背部特写.png"
}
]
},
{
"id": "character-04",
"name": "运动辣妹",
"folder": "04_运动辣妹",
"description": "运动辣妹透明骨架人角色含正面、左右45度、侧面、背面、半身近景和背部特写参考。",
"primary_image": "character-04-front",
"images": [
{
"id": "character-04-front",
"view": "front",
"label": "正面",
"filename": "images/character-04-front.png",
"width": 1536,
"height": 2048,
"source_path": "/Users/kangwan/Desktop/skg_anatomy_characters_20260514_120852/04_运动辣妹/01_正面.png"
},
{
"id": "character-04-left_45",
"view": "left_45",
"label": "左45度",
"filename": "images/character-04-left_45.png",
"width": 1536,
"height": 2048,
"source_path": "/Users/kangwan/Desktop/skg_anatomy_characters_20260514_120852/04_运动辣妹/02_左45度.png"
},
{
"id": "character-04-right_45",
"view": "right_45",
"label": "右45度",
"filename": "images/character-04-right_45.png",
"width": 1536,
"height": 2048,
"source_path": "/Users/kangwan/Desktop/skg_anatomy_characters_20260514_120852/04_运动辣妹/03_右45度.png"
},
{
"id": "character-04-side",
"view": "side",
"label": "侧面",
"filename": "images/character-04-side.png",
"width": 1536,
"height": 2048,
"source_path": "/Users/kangwan/Desktop/skg_anatomy_characters_20260514_120852/04_运动辣妹/04_侧面.png"
},
{
"id": "character-04-back",
"view": "back",
"label": "背面",
"filename": "images/character-04-back.png",
"width": 1536,
"height": 2048,
"source_path": "/Users/kangwan/Desktop/skg_anatomy_characters_20260514_120852/04_运动辣妹/05_背面.png"
},
{
"id": "character-04-bust",
"view": "bust",
"label": "半身近景",
"filename": "images/character-04-bust.png",
"width": 2048,
"height": 2048,
"source_path": "/Users/kangwan/Desktop/skg_anatomy_characters_20260514_120852/04_运动辣妹/06_半身近景.png"
},
{
"id": "character-04-back_detail",
"view": "back_detail",
"label": "背部特写",
"filename": "images/character-04-back_detail.png",
"width": 2048,
"height": 2048,
"source_path": "/Users/kangwan/Desktop/skg_anatomy_characters_20260514_120852/04_运动辣妹/07_背部特写.png"
}
]
},
{
"id": "character-05",
"name": "绅士大叔",
"folder": "05_绅士大叔",
"description": "绅士大叔透明骨架人角色含正面、左右45度、侧面、背面、半身近景和背部特写参考。",
"primary_image": "character-05-front",
"images": [
{
"id": "character-05-front",
"view": "front",
"label": "正面",
"filename": "images/character-05-front.png",
"width": 1536,
"height": 2048,
"source_path": "/Users/kangwan/Desktop/skg_anatomy_characters_20260514_120852/05_绅士大叔/01_正面.png"
},
{
"id": "character-05-left_45",
"view": "left_45",
"label": "左45度",
"filename": "images/character-05-left_45.png",
"width": 1536,
"height": 2048,
"source_path": "/Users/kangwan/Desktop/skg_anatomy_characters_20260514_120852/05_绅士大叔/02_左45度.png"
},
{
"id": "character-05-right_45",
"view": "right_45",
"label": "右45度",
"filename": "images/character-05-right_45.png",
"width": 1536,
"height": 2048,
"source_path": "/Users/kangwan/Desktop/skg_anatomy_characters_20260514_120852/05_绅士大叔/03_右45度.png"
},
{
"id": "character-05-side",
"view": "side",
"label": "侧面",
"filename": "images/character-05-side.png",
"width": 1536,
"height": 2048,
"source_path": "/Users/kangwan/Desktop/skg_anatomy_characters_20260514_120852/05_绅士大叔/04_侧面.png"
},
{
"id": "character-05-back",
"view": "back",
"label": "背面",
"filename": "images/character-05-back.png",
"width": 1536,
"height": 2048,
"source_path": "/Users/kangwan/Desktop/skg_anatomy_characters_20260514_120852/05_绅士大叔/05_背面.png"
},
{
"id": "character-05-bust",
"view": "bust",
"label": "半身近景",
"filename": "images/character-05-bust.png",
"width": 2048,
"height": 2048,
"source_path": "/Users/kangwan/Desktop/skg_anatomy_characters_20260514_120852/05_绅士大叔/06_半身近景.png"
},
{
"id": "character-05-back_detail",
"view": "back_detail",
"label": "背部特写",
"filename": "images/character-05-back_detail.png",
"width": 2048,
"height": 2048,
"source_path": "/Users/kangwan/Desktop/skg_anatomy_characters_20260514_120852/05_绅士大叔/07_背部特写.png"
}
]
}
]
}

View File

@@ -30,6 +30,10 @@ PRODUCT_LIBRARY_DIR = Path(
os.getenv("PRODUCT_LIBRARY_DIR", Path(__file__).resolve().parent / "product_library" / "skg-products")
).resolve()
PRODUCT_LIBRARY_MANIFEST = PRODUCT_LIBRARY_DIR / "manifest.json"
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"
LLM_BASE_URL = os.getenv("LLM_BASE_URL", "").strip()
LLM_API_KEY = os.getenv("LLM_API_KEY", "").strip()
@@ -306,6 +310,26 @@ class ProductLibraryItem(BaseModel):
tags: list[str] = Field(default_factory=list)
class CharacterLibraryImage(BaseModel):
id: str
view: str
label: str
filename: str
width: int = 0
height: int = 0
source_path: str = ""
url: str = ""
class CharacterLibraryItem(BaseModel):
id: str
name: str
folder: str = ""
description: str = ""
primary_image: str = ""
images: list[CharacterLibraryImage] = Field(default_factory=list)
class ProductFusionRegion(BaseModel):
x: float = 0
y: float = 0
@@ -319,6 +343,10 @@ class ProductFusionShot(BaseModel):
last_image: dict | None = None
product_images: list[dict] = Field(default_factory=list)
product_image: dict | None = None
character_id: str = ""
character_name: str = ""
subject_image: dict | None = None
subject_images: list[dict] = Field(default_factory=list)
person_image: dict | None = None
product_region: ProductFusionRegion | None = None
scene_image: dict | None = None
@@ -541,6 +569,41 @@ def product_library_file(item: ProductLibraryItem) -> Path:
return p
def load_character_library_items() -> list[CharacterLibraryItem]:
if not CHARACTER_LIBRARY_MANIFEST.exists():
return []
try:
data = json.loads(CHARACTER_LIBRARY_MANIFEST.read_text(encoding="utf-8"))
items: list[CharacterLibraryItem] = []
for raw in data.get("characters", []):
item = CharacterLibraryItem(**raw)
for image in item.images:
image.url = f"/character-library/skg/images/{image.filename}"
items.append(item)
return items
except Exception as e:
raise HTTPException(500, f"character library manifest invalid: {e}")
def find_character_library_item(character_id: str) -> CharacterLibraryItem:
character_id = character_id.strip()
for item in load_character_library_items():
if item.id == character_id:
return item
raise HTTPException(404, "character library item not found")
def character_library_file(filename: str) -> Path:
p = (CHARACTER_LIBRARY_DIR / filename).resolve()
try:
p.relative_to(CHARACTER_LIBRARY_DIR)
except ValueError:
raise HTTPException(400, "invalid character library path")
if not p.exists():
raise HTTPException(404, "character library image missing")
return p
def storyboard_ref_url(job_id: str, ref: dict | None) -> str:
if not ref:
return ""
@@ -3310,6 +3373,7 @@ class GenerateStoryboardVideoReq(BaseModel):
last_image: dict | None = None
product_images: list[dict] = Field(default_factory=list)
subject_image: dict | None = None
subject_images: list[dict] = Field(default_factory=list)
scene_image: dict | None = None
product_image: dict | None = None
action_image: dict | None = None
@@ -3433,6 +3497,7 @@ def submit_video_create(
source_ref: VideoSourceRef | None = None,
last_img: Path | None = None,
product_imgs: list[Path] | None = None,
primary_role: str = "first_frame",
):
if video_uses_ark():
content = [{"type": "text", "text": payload["prompt"]}]
@@ -3448,7 +3513,7 @@ def submit_video_create(
{
"type": "image_url",
"image_url": {"url": ark_reference_data_url(ref_img)},
"role": "first_frame",
"role": primary_role,
}
)
if last_img and last_img.exists():
@@ -3505,6 +3570,7 @@ def render_storyboard_video(
source_ref: VideoSourceRef | None = None,
last_ref_path: Path | None = None,
product_ref_paths: list[Path] | None = None,
primary_role: str = "first_frame",
) -> None:
import httpx
@@ -3534,16 +3600,16 @@ def render_storyboard_video(
create = None
create_errors: list[str] = []
for create_path in VIDEO_CREATE_PATHS:
resp = submit_video_create(client, f"{base}{video_path(create_path)}", headers, ref_img, payload, source_ref, prepared_last_img, prepared_product_imgs)
resp = submit_video_create(client, f"{base}{video_path(create_path)}", headers, ref_img, payload, source_ref, prepared_last_img, prepared_product_imgs, primary_role)
if video_uses_ark() and source_ref and resp.status_code in {400, 422}:
create_errors.append(f"{video_path(create_path)} + reference_video -> HTTP {resp.status_code}: {resp.text[:160]}")
resp = submit_video_create(client, f"{base}{video_path(create_path)}", headers, ref_img, payload, None, prepared_last_img, prepared_product_imgs)
resp = submit_video_create(client, f"{base}{video_path(create_path)}", headers, ref_img, payload, None, prepared_last_img, prepared_product_imgs, primary_role)
if video_uses_ark() and prepared_last_img and resp.status_code in {400, 422}:
create_errors.append(f"{video_path(create_path)} + last_frame -> HTTP {resp.status_code}: {resp.text[:160]}")
resp = submit_video_create(client, f"{base}{video_path(create_path)}", headers, ref_img, payload, None, None, prepared_product_imgs)
resp = submit_video_create(client, f"{base}{video_path(create_path)}", headers, ref_img, payload, None, None, prepared_product_imgs, primary_role)
if video_uses_ark() and prepared_product_imgs and resp.status_code in {400, 422}:
create_errors.append(f"{video_path(create_path)} + product_reference -> HTTP {resp.status_code}: {resp.text[:160]}")
resp = submit_video_create(client, f"{base}{video_path(create_path)}", headers, ref_img, payload, None, prepared_last_img, None)
resp = submit_video_create(client, f"{base}{video_path(create_path)}", headers, ref_img, payload, None, prepared_last_img, None, primary_role)
if resp.status_code < 400:
create = resp
break
@@ -3601,6 +3667,7 @@ def generate_storyboard_video(job_id: str, idx: int, req: GenerateStoryboardVide
raise HTTPException(400, "prompt required")
ref = req.first_image or req.subject_image or req.product_image or req.scene_image or req.action_image
primary_role = "first_frame" if req.first_image else "reference_image"
ref_path = storyboard_ref_path(job_id, ref) or (job_dir(job_id) / "frames" / f"{idx:03d}.jpg")
if not ref_path.exists():
raise HTTPException(404, "reference image missing")
@@ -3608,6 +3675,14 @@ def generate_storyboard_video(job_id: str, idx: int, req: GenerateStoryboardVide
last_ref_path = storyboard_ref_path(job_id, req.last_image)
raw_product_refs = req.product_images[:6] if req.product_images else ([req.product_image] if req.product_image else [])
product_ref_paths = [p for p in (storyboard_ref_path(job_id, r) for r in raw_product_refs) if p]
subject_ref_paths = [p for p in (storyboard_ref_path(job_id, r) for r in req.subject_images[:8]) if p]
reference_ref_paths = []
seen_ref_paths: set[str] = set()
for p in [*subject_ref_paths, *product_ref_paths]:
key = str(p)
if key not in seen_ref_paths:
reference_ref_paths.append(p)
seen_ref_paths.add(key)
local_id = uuid.uuid4().hex[:12]
model = resolve_video_model(req.model)
@@ -3629,7 +3704,7 @@ def generate_storyboard_video(job_id: str, idx: int, req: GenerateStoryboardVide
source_ref = req.source_ref
if source_ref and source_ref.kind == "source_video" and not source_ref.url:
source_ref = None
bg.add_task(render_storyboard_video, job_id, local_id, "", ref_path, prompt, model, seconds, req.size, source_ref, last_ref_path, product_ref_paths)
bg.add_task(render_storyboard_video, job_id, local_id, "", ref_path, prompt, model, seconds, req.size, source_ref, last_ref_path, reference_ref_paths, primary_role)
return job
@@ -3645,6 +3720,10 @@ class CopyProductLibraryAssetReq(BaseModel):
product_id: str
class CopyCharacterLibraryAssetReq(BaseModel):
character_id: str
@app.get("/product-library/skg", response_model=list[ProductLibraryItem])
def list_skg_product_library() -> list[ProductLibraryItem]:
"""内置 SKG 白底产品图库。来源是本地筛选后的产品图 manifest。"""
@@ -3660,6 +3739,19 @@ def get_skg_product_library_image(filename: str):
return FileResponse(product_library_file(item), media_type="image/jpeg")
@app.get("/character-library/skg", response_model=list[CharacterLibraryItem])
def list_skg_character_library() -> list[CharacterLibraryItem]:
"""内置透明骨架人角色库。来源是桌面生成的 5 个角色参考组。"""
return load_character_library_items()
@app.get("/character-library/skg/images/{filename:path}")
def get_skg_character_library_image(filename: str):
p = character_library_file(filename)
media_type = "image/png" if p.suffix.lower() == ".png" else "image/jpeg"
return FileResponse(p, media_type=media_type)
@app.post("/jobs/{job_id}/assets")
async def upload_storyboard_asset(job_id: str, file: UploadFile = File(...)) -> dict:
if job_id not in JOBS:
@@ -3716,6 +3808,38 @@ def copy_product_library_asset(job_id: str, req: CopyProductLibraryAssetReq) ->
}
@app.post("/jobs/{job_id}/assets/character-library")
def copy_character_library_assets(job_id: str, req: CopyCharacterLibraryAssetReq) -> dict:
if job_id not in JOBS:
raise HTTPException(404, "job not found")
character = find_character_library_item(req.character_id)
out_dir = job_dir(job_id) / "assets"
out_dir.mkdir(parents=True, exist_ok=True)
refs = []
for image in character.images:
src = character_library_file(image.filename)
asset_id = uuid.uuid4().hex[:12]
out = out_dir / f"{asset_id}.jpg"
try:
img = Image.open(src).convert("RGB")
img.thumbnail((1600, 1600), Image.Resampling.LANCZOS)
img.save(out, "JPEG", quality=94)
except Exception as e:
raise HTTPException(400, f"character library copy failed: {e}")
refs.append({
"kind": "asset",
"frame_idx": -1,
"element_id": asset_id,
"cutout_id": asset_id,
"label": f"角色 · {character.name} · {image.label}",
})
return {
"character_id": character.id,
"character_name": character.name,
"images": refs,
}
def product_image_alpha(img: Image.Image) -> Image.Image:
rgba = img.convert("RGBA")
rgb = rgba.convert("RGB")