auto-save 2026-05-14 13:04 (+1, ~3)
@@ -1,12 +1,5 @@
|
|||||||
{
|
{
|
||||||
"entries": [
|
"entries": [
|
||||||
{
|
|
||||||
"files_changed": 1,
|
|
||||||
"hash": "a072d49",
|
|
||||||
"message": "auto-save 2026-05-13 06:51 (~1)",
|
|
||||||
"ts": "2026-05-13T06:51:56+08:00",
|
|
||||||
"type": "commit"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"files_changed": 1,
|
"files_changed": 1,
|
||||||
"hash": "3417408",
|
"hash": "3417408",
|
||||||
@@ -3283,6 +3276,13 @@
|
|||||||
"type": "session-heartbeat",
|
"type": "session-heartbeat",
|
||||||
"message": "Codex 会话活跃 · 最近命令:codex · 1 项未提交变更 · 最近提交:auto-save 2026-05-14 12:53 (~2)",
|
"message": "Codex 会话活跃 · 最近命令:codex · 1 项未提交变更 · 最近提交:auto-save 2026-05-14 12:53 (~2)",
|
||||||
"files_changed": 1
|
"files_changed": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-05-14T12:59:37+08:00",
|
||||||
|
"type": "commit",
|
||||||
|
"message": "auto-save 2026-05-14 12:59 (~1)",
|
||||||
|
"hash": "887c9a0",
|
||||||
|
"files_changed": 1
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 3.8 MiB |
|
After Width: | Height: | Size: 3.9 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 3.7 MiB |
|
After Width: | Height: | Size: 3.6 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 3.8 MiB |
|
After Width: | Height: | Size: 3.5 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 4.3 MiB |
|
After Width: | Height: | Size: 3.6 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 5.8 MiB |
|
After Width: | Height: | Size: 4.3 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
367
api/character_library/skg-characters/manifest.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
136
api/main.py
@@ -30,6 +30,10 @@ PRODUCT_LIBRARY_DIR = Path(
|
|||||||
os.getenv("PRODUCT_LIBRARY_DIR", Path(__file__).resolve().parent / "product_library" / "skg-products")
|
os.getenv("PRODUCT_LIBRARY_DIR", Path(__file__).resolve().parent / "product_library" / "skg-products")
|
||||||
).resolve()
|
).resolve()
|
||||||
PRODUCT_LIBRARY_MANIFEST = PRODUCT_LIBRARY_DIR / "manifest.json"
|
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_BASE_URL = os.getenv("LLM_BASE_URL", "").strip()
|
||||||
LLM_API_KEY = os.getenv("LLM_API_KEY", "").strip()
|
LLM_API_KEY = os.getenv("LLM_API_KEY", "").strip()
|
||||||
@@ -306,6 +310,26 @@ class ProductLibraryItem(BaseModel):
|
|||||||
tags: list[str] = Field(default_factory=list)
|
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):
|
class ProductFusionRegion(BaseModel):
|
||||||
x: float = 0
|
x: float = 0
|
||||||
y: float = 0
|
y: float = 0
|
||||||
@@ -319,6 +343,10 @@ class ProductFusionShot(BaseModel):
|
|||||||
last_image: dict | None = None
|
last_image: dict | None = None
|
||||||
product_images: list[dict] = Field(default_factory=list)
|
product_images: list[dict] = Field(default_factory=list)
|
||||||
product_image: dict | None = None
|
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
|
person_image: dict | None = None
|
||||||
product_region: ProductFusionRegion | None = None
|
product_region: ProductFusionRegion | None = None
|
||||||
scene_image: dict | None = None
|
scene_image: dict | None = None
|
||||||
@@ -541,6 +569,41 @@ def product_library_file(item: ProductLibraryItem) -> Path:
|
|||||||
return p
|
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:
|
def storyboard_ref_url(job_id: str, ref: dict | None) -> str:
|
||||||
if not ref:
|
if not ref:
|
||||||
return ""
|
return ""
|
||||||
@@ -3310,6 +3373,7 @@ class GenerateStoryboardVideoReq(BaseModel):
|
|||||||
last_image: dict | None = None
|
last_image: dict | None = None
|
||||||
product_images: list[dict] = Field(default_factory=list)
|
product_images: list[dict] = Field(default_factory=list)
|
||||||
subject_image: dict | None = None
|
subject_image: dict | None = None
|
||||||
|
subject_images: list[dict] = Field(default_factory=list)
|
||||||
scene_image: dict | None = None
|
scene_image: dict | None = None
|
||||||
product_image: dict | None = None
|
product_image: dict | None = None
|
||||||
action_image: dict | None = None
|
action_image: dict | None = None
|
||||||
@@ -3433,6 +3497,7 @@ def submit_video_create(
|
|||||||
source_ref: VideoSourceRef | None = None,
|
source_ref: VideoSourceRef | None = None,
|
||||||
last_img: Path | None = None,
|
last_img: Path | None = None,
|
||||||
product_imgs: list[Path] | None = None,
|
product_imgs: list[Path] | None = None,
|
||||||
|
primary_role: str = "first_frame",
|
||||||
):
|
):
|
||||||
if video_uses_ark():
|
if video_uses_ark():
|
||||||
content = [{"type": "text", "text": payload["prompt"]}]
|
content = [{"type": "text", "text": payload["prompt"]}]
|
||||||
@@ -3448,7 +3513,7 @@ def submit_video_create(
|
|||||||
{
|
{
|
||||||
"type": "image_url",
|
"type": "image_url",
|
||||||
"image_url": {"url": ark_reference_data_url(ref_img)},
|
"image_url": {"url": ark_reference_data_url(ref_img)},
|
||||||
"role": "first_frame",
|
"role": primary_role,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
if last_img and last_img.exists():
|
if last_img and last_img.exists():
|
||||||
@@ -3505,6 +3570,7 @@ def render_storyboard_video(
|
|||||||
source_ref: VideoSourceRef | None = None,
|
source_ref: VideoSourceRef | None = None,
|
||||||
last_ref_path: Path | None = None,
|
last_ref_path: Path | None = None,
|
||||||
product_ref_paths: list[Path] | None = None,
|
product_ref_paths: list[Path] | None = None,
|
||||||
|
primary_role: str = "first_frame",
|
||||||
) -> None:
|
) -> None:
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
@@ -3534,16 +3600,16 @@ def render_storyboard_video(
|
|||||||
create = None
|
create = None
|
||||||
create_errors: list[str] = []
|
create_errors: list[str] = []
|
||||||
for create_path in VIDEO_CREATE_PATHS:
|
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}:
|
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]}")
|
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}:
|
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]}")
|
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}:
|
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]}")
|
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:
|
if resp.status_code < 400:
|
||||||
create = resp
|
create = resp
|
||||||
break
|
break
|
||||||
@@ -3601,6 +3667,7 @@ def generate_storyboard_video(job_id: str, idx: int, req: GenerateStoryboardVide
|
|||||||
raise HTTPException(400, "prompt required")
|
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
|
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")
|
ref_path = storyboard_ref_path(job_id, ref) or (job_dir(job_id) / "frames" / f"{idx:03d}.jpg")
|
||||||
if not ref_path.exists():
|
if not ref_path.exists():
|
||||||
raise HTTPException(404, "reference image missing")
|
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)
|
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 [])
|
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]
|
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]
|
local_id = uuid.uuid4().hex[:12]
|
||||||
model = resolve_video_model(req.model)
|
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
|
source_ref = req.source_ref
|
||||||
if source_ref and source_ref.kind == "source_video" and not source_ref.url:
|
if source_ref and source_ref.kind == "source_video" and not source_ref.url:
|
||||||
source_ref = None
|
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
|
return job
|
||||||
|
|
||||||
|
|
||||||
@@ -3645,6 +3720,10 @@ class CopyProductLibraryAssetReq(BaseModel):
|
|||||||
product_id: str
|
product_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class CopyCharacterLibraryAssetReq(BaseModel):
|
||||||
|
character_id: str
|
||||||
|
|
||||||
|
|
||||||
@app.get("/product-library/skg", response_model=list[ProductLibraryItem])
|
@app.get("/product-library/skg", response_model=list[ProductLibraryItem])
|
||||||
def list_skg_product_library() -> list[ProductLibraryItem]:
|
def list_skg_product_library() -> list[ProductLibraryItem]:
|
||||||
"""内置 SKG 白底产品图库。来源是本地筛选后的产品图 manifest。"""
|
"""内置 SKG 白底产品图库。来源是本地筛选后的产品图 manifest。"""
|
||||||
@@ -3660,6 +3739,19 @@ def get_skg_product_library_image(filename: str):
|
|||||||
return FileResponse(product_library_file(item), media_type="image/jpeg")
|
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")
|
@app.post("/jobs/{job_id}/assets")
|
||||||
async def upload_storyboard_asset(job_id: str, file: UploadFile = File(...)) -> dict:
|
async def upload_storyboard_asset(job_id: str, file: UploadFile = File(...)) -> dict:
|
||||||
if job_id not in JOBS:
|
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:
|
def product_image_alpha(img: Image.Image) -> Image.Image:
|
||||||
rgba = img.convert("RGBA")
|
rgba = img.convert("RGBA")
|
||||||
rgb = rgba.convert("RGB")
|
rgb = rgba.convert("RGB")
|
||||||
|
|||||||
@@ -71,6 +71,10 @@ export interface ProductFusionShot {
|
|||||||
last_image?: ImageRef | null
|
last_image?: ImageRef | null
|
||||||
product_images?: ImageRef[]
|
product_images?: ImageRef[]
|
||||||
product_image?: ImageRef | null
|
product_image?: ImageRef | null
|
||||||
|
character_id?: string
|
||||||
|
character_name?: string
|
||||||
|
subject_image?: ImageRef | null
|
||||||
|
subject_images?: ImageRef[]
|
||||||
person_image?: ImageRef | null
|
person_image?: ImageRef | null
|
||||||
product_region?: ProductFusionRegion | null
|
product_region?: ProductFusionRegion | null
|
||||||
scene_image?: ImageRef | null
|
scene_image?: ImageRef | null
|
||||||
@@ -165,6 +169,32 @@ export async function copyProductLibraryAsset(jobId: string, productId: string):
|
|||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listCharacterLibrary(): Promise<CharacterLibraryItem[]> {
|
||||||
|
const res = await fetch(`${API_BASE}/character-library/skg`)
|
||||||
|
if (!res.ok) {
|
||||||
|
const txt = await res.text().catch(() => "")
|
||||||
|
throw new Error(`listCharacterLibrary ${res.status} ${txt.slice(0, 300)}`)
|
||||||
|
}
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function characterLibraryImageUrl(filename: string): string {
|
||||||
|
return `${API_BASE}/character-library/skg/images/${filename}`
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ character_id: characterId }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const txt = await res.text().catch(() => "")
|
||||||
|
throw new Error(`copyCharacterLibraryAssets ${res.status} ${txt.slice(0, 300)}`)
|
||||||
|
}
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
export async function createProductFusionGuide(
|
export async function createProductFusionGuide(
|
||||||
jobId: string,
|
jobId: string,
|
||||||
body: ProductFusionShot,
|
body: ProductFusionShot,
|
||||||
@@ -293,6 +323,26 @@ export interface ProductLibraryItem {
|
|||||||
tags: string[]
|
tags: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CharacterLibraryImage {
|
||||||
|
id: string
|
||||||
|
view: string
|
||||||
|
label: string
|
||||||
|
filename: string
|
||||||
|
url: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
source_path: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CharacterLibraryItem {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
folder: string
|
||||||
|
description: string
|
||||||
|
primary_image: string
|
||||||
|
images: CharacterLibraryImage[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface TranscriptSegment {
|
export interface TranscriptSegment {
|
||||||
index: number
|
index: number
|
||||||
start: number
|
start: number
|
||||||
@@ -632,6 +682,7 @@ export async function generateStoryboardVideo(
|
|||||||
first_image?: ImageRef | null
|
first_image?: ImageRef | null
|
||||||
last_image?: ImageRef | null
|
last_image?: ImageRef | null
|
||||||
product_images?: ImageRef[]
|
product_images?: ImageRef[]
|
||||||
|
subject_images?: ImageRef[]
|
||||||
subject_image?: ImageRef | null
|
subject_image?: ImageRef | null
|
||||||
scene_image?: ImageRef | null
|
scene_image?: ImageRef | null
|
||||||
product_image?: ImageRef | null
|
product_image?: ImageRef | null
|
||||||
|
|||||||