auto-save 2026-05-18 20:51 (~2)
This commit is contained in:
@@ -1,40 +1,5 @@
|
||||
{
|
||||
"entries": [
|
||||
{
|
||||
"files_changed": 1,
|
||||
"hash": "2a259cc",
|
||||
"message": "auto-save 2026-05-16 14:57 (~1)",
|
||||
"ts": "2026-05-16T14:57:39+08:00",
|
||||
"type": "commit"
|
||||
},
|
||||
{
|
||||
"files_changed": 1,
|
||||
"hash": "b491624",
|
||||
"message": "auto-save 2026-05-16 15:03 (~1)",
|
||||
"ts": "2026-05-16T15:03:25+08:00",
|
||||
"type": "commit"
|
||||
},
|
||||
{
|
||||
"files_changed": 1,
|
||||
"hash": "6c09899",
|
||||
"message": "auto-save 2026-05-16 15:09 (~1)",
|
||||
"ts": "2026-05-16T15:09:12+08:00",
|
||||
"type": "commit"
|
||||
},
|
||||
{
|
||||
"files_changed": 1,
|
||||
"hash": "a3f5544",
|
||||
"message": "auto-save 2026-05-16 15:14 (~1)",
|
||||
"ts": "2026-05-16T15:14:56+08:00",
|
||||
"type": "commit"
|
||||
},
|
||||
{
|
||||
"files_changed": 1,
|
||||
"hash": "f06e55f",
|
||||
"message": "auto-save 2026-05-16 15:20 (~1)",
|
||||
"ts": "2026-05-16T15:20:41+08:00",
|
||||
"type": "commit"
|
||||
},
|
||||
{
|
||||
"files_changed": 1,
|
||||
"hash": "0abb1a2",
|
||||
@@ -3217,6 +3182,38 @@
|
||||
"message": "auto-save 2026-05-18 20:13 (~3)",
|
||||
"hash": "69bb692",
|
||||
"files_changed": 3
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-18T20:19:24+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-05-18 20:19 (~2)",
|
||||
"hash": "b886e02",
|
||||
"files_changed": 2
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-18T12:22:20Z",
|
||||
"type": "session-heartbeat",
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 2 项未提交变更 · 最近提交:auto-save 2026-05-18 20:19 (~2)",
|
||||
"files_changed": 2
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-18T20:23:21+08:00",
|
||||
"type": "commit",
|
||||
"message": "docs: document skg brand workbench theme",
|
||||
"hash": "2e2998c",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-18T12:32:20Z",
|
||||
"type": "session-heartbeat",
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:docs: document skg brand workbench theme",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-18T12:42:20Z",
|
||||
"type": "session-heartbeat",
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:docs: document skg brand workbench theme",
|
||||
"files_changed": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
369
api/main.py
369
api/main.py
@@ -20,7 +20,7 @@ from typing import Literal
|
||||
|
||||
import httpx
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import BackgroundTasks, FastAPI, File, HTTPException, Request, Response, UploadFile
|
||||
from fastapi import BackgroundTasks, FastAPI, File, Form, HTTPException, Request, Response, UploadFile
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -42,6 +42,19 @@ SUBJECT_TEMPLATE_DIR = Path(os.getenv("SUBJECT_TEMPLATE_DIR", JOBS_DIR / "_subje
|
||||
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)
|
||||
ASSET_LIBRARY_DIR = Path(os.getenv("ASSET_LIBRARY_DIR", JOBS_DIR.parent / "asset_library")).resolve()
|
||||
PROMPT_LIBRARY_DIR = Path(os.getenv("PROMPT_LIBRARY_DIR", JOBS_DIR.parent / "prompt_library")).resolve()
|
||||
PROMPT_LIBRARY_ITEMS_DIR = PROMPT_LIBRARY_DIR / "items"
|
||||
LIBRARY_TRASH_DIR = JOBS_DIR.parent / "_trash"
|
||||
for _library_dir in [
|
||||
ASSET_LIBRARY_DIR / "subjects",
|
||||
ASSET_LIBRARY_DIR / "products",
|
||||
ASSET_LIBRARY_DIR / "scenes",
|
||||
ASSET_LIBRARY_DIR / "videos",
|
||||
PROMPT_LIBRARY_ITEMS_DIR,
|
||||
LIBRARY_TRASH_DIR,
|
||||
]:
|
||||
_library_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()
|
||||
@@ -493,6 +506,93 @@ class SubjectTemplateItem(BaseModel):
|
||||
updated_at: float = 0.0
|
||||
|
||||
|
||||
PromptLibraryCategory = Literal["scene_desc", "video_desc", "subject_desc", "skg_script", "product_angle"]
|
||||
AssetLibraryKind = Literal["subjects", "products", "scenes", "videos"]
|
||||
|
||||
|
||||
class PromptLibraryItem(BaseModel):
|
||||
id: str
|
||||
category: PromptLibraryCategory
|
||||
name: str
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
prompt_en: str = ""
|
||||
prompt_zh: str = ""
|
||||
use_count: int = 0
|
||||
source_job_id: str = ""
|
||||
created_at: float = 0.0
|
||||
updated_at: float = 0.0
|
||||
|
||||
|
||||
class PromptLibraryWriteReq(BaseModel):
|
||||
category: PromptLibraryCategory
|
||||
name: str
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
prompt_en: str = ""
|
||||
prompt_zh: str = ""
|
||||
source_job_id: str = ""
|
||||
|
||||
|
||||
class PromptLibraryPatchReq(BaseModel):
|
||||
category: PromptLibraryCategory | None = None
|
||||
name: str | None = None
|
||||
tags: list[str] | None = None
|
||||
prompt_en: str | None = None
|
||||
prompt_zh: str | None = None
|
||||
source_job_id: str | None = None
|
||||
|
||||
|
||||
class AssetLibraryImage(BaseModel):
|
||||
id: str
|
||||
view: str = ""
|
||||
label: str = ""
|
||||
filename: str
|
||||
url: str = ""
|
||||
width: int = 0
|
||||
height: int = 0
|
||||
created_at: float = 0.0
|
||||
|
||||
|
||||
class AssetLibraryItem(BaseModel):
|
||||
id: str
|
||||
kind: AssetLibraryKind
|
||||
name: str
|
||||
name_zh: str = ""
|
||||
note: str = ""
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
source_job_id: str = ""
|
||||
use_count: int = 0
|
||||
created_at: float = 0.0
|
||||
updated_at: float = 0.0
|
||||
is_official: bool = False
|
||||
prompt_brief: str = ""
|
||||
prompt_brief_zh: str = ""
|
||||
subject_style: Literal["transparent_human", "source_actor"] = "transparent_human"
|
||||
product_type: str = ""
|
||||
views: list[AssetLibraryImage] = Field(default_factory=list)
|
||||
images: list[AssetLibraryImage] = Field(default_factory=list)
|
||||
asset_role: str = ""
|
||||
aspect_ratio: str = ""
|
||||
image: AssetLibraryImage | None = None
|
||||
duration: float = 0.0
|
||||
poster: AssetLibraryImage | None = None
|
||||
video_url: str = ""
|
||||
|
||||
|
||||
class AssetLibraryPatchReq(BaseModel):
|
||||
name: str | None = None
|
||||
name_zh: str | None = None
|
||||
note: str | None = None
|
||||
tags: list[str] | None = None
|
||||
source_job_id: str | None = None
|
||||
prompt_brief: str | None = None
|
||||
prompt_brief_zh: str | None = None
|
||||
subject_style: Literal["transparent_human", "source_actor"] | None = None
|
||||
product_type: str | None = None
|
||||
asset_role: str | None = None
|
||||
aspect_ratio: str | None = None
|
||||
is_official: bool | None = None
|
||||
|
||||
|
||||
class ProductFusionRegion(BaseModel):
|
||||
x: float = 0
|
||||
y: float = 0
|
||||
@@ -890,6 +990,267 @@ def subject_template_image_file(filename: str) -> Path:
|
||||
return p
|
||||
|
||||
|
||||
def _now_ts() -> float:
|
||||
return time.time()
|
||||
|
||||
|
||||
def _safe_tags(value: object) -> list[str]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
return [str(item).strip() for item in value if str(item).strip()][:20]
|
||||
|
||||
|
||||
def _library_media_size(path: Path) -> tuple[int, int]:
|
||||
try:
|
||||
with Image.open(path) as im:
|
||||
return im.width, im.height
|
||||
except Exception:
|
||||
return 0, 0
|
||||
|
||||
|
||||
def _library_kind_dir(kind: AssetLibraryKind | str) -> Path:
|
||||
if kind not in {"subjects", "products", "scenes", "videos"}:
|
||||
raise HTTPException(404, "asset library kind not found")
|
||||
return ASSET_LIBRARY_DIR / str(kind)
|
||||
|
||||
|
||||
def _asset_library_item_dir(kind: AssetLibraryKind | str, item_id: str) -> Path:
|
||||
item_id = item_id.strip()
|
||||
if not item_id or "/" in item_id or ".." in item_id:
|
||||
raise HTTPException(400, "invalid asset library id")
|
||||
return _library_kind_dir(kind) / item_id
|
||||
|
||||
|
||||
def _prompt_library_item_path(item_id: str) -> Path:
|
||||
item_id = item_id.strip()
|
||||
if not item_id or "/" in item_id or ".." in item_id:
|
||||
raise HTTPException(400, "invalid prompt library id")
|
||||
return PROMPT_LIBRARY_ITEMS_DIR / f"{item_id}.json"
|
||||
|
||||
|
||||
def _prompt_item_file(item: PromptLibraryItem) -> Path:
|
||||
return _prompt_library_item_path(item.id)
|
||||
|
||||
|
||||
def _asset_item_file(item: AssetLibraryItem) -> Path:
|
||||
return _asset_library_item_dir(item.kind, item.id) / "manifest.json"
|
||||
|
||||
|
||||
def _write_prompt_item(item: PromptLibraryItem) -> None:
|
||||
PROMPT_LIBRARY_ITEMS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
_prompt_item_file(item).write_text(item.model_dump_json(indent=2), encoding="utf-8")
|
||||
_write_prompt_library_index()
|
||||
|
||||
|
||||
def _write_asset_item(item: AssetLibraryItem) -> None:
|
||||
p = _asset_item_file(item)
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(item.model_dump_json(indent=2), encoding="utf-8")
|
||||
_write_asset_library_index()
|
||||
|
||||
|
||||
def _read_prompt_item(path: Path) -> PromptLibraryItem | None:
|
||||
try:
|
||||
return PromptLibraryItem.model_validate_json(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _read_asset_item(path: Path) -> AssetLibraryItem | None:
|
||||
try:
|
||||
item = AssetLibraryItem.model_validate_json(path.read_text(encoding="utf-8"))
|
||||
_hydrate_asset_library_urls(item)
|
||||
return item
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def load_prompt_library_items() -> list[PromptLibraryItem]:
|
||||
items = [_read_prompt_item(path) for path in PROMPT_LIBRARY_ITEMS_DIR.glob("*.json")]
|
||||
result = [item for item in items if item]
|
||||
result.sort(key=lambda item: item.updated_at or item.created_at, reverse=True)
|
||||
return result
|
||||
|
||||
|
||||
def load_asset_library_items(kind: AssetLibraryKind | str | None = None) -> list[AssetLibraryItem]:
|
||||
kinds = [kind] if kind else ["subjects", "products", "scenes", "videos"]
|
||||
items: list[AssetLibraryItem] = []
|
||||
for current in kinds:
|
||||
root = _library_kind_dir(str(current))
|
||||
for manifest in root.glob("*/manifest.json"):
|
||||
item = _read_asset_item(manifest)
|
||||
if item:
|
||||
items.append(item)
|
||||
items.sort(key=lambda item: item.updated_at or item.created_at, reverse=True)
|
||||
return items
|
||||
|
||||
|
||||
def _write_prompt_library_index() -> None:
|
||||
PROMPT_LIBRARY_DIR.mkdir(parents=True, exist_ok=True)
|
||||
items = [item.model_dump() for item in load_prompt_library_items()]
|
||||
(PROMPT_LIBRARY_DIR / "index.json").write_text(json.dumps({"items": items}, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def _write_asset_library_index() -> None:
|
||||
ASSET_LIBRARY_DIR.mkdir(parents=True, exist_ok=True)
|
||||
items = [item.model_dump() for item in load_asset_library_items()]
|
||||
(ASSET_LIBRARY_DIR / "index.json").write_text(json.dumps({"items": items}, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def _rebuild_library_index() -> None:
|
||||
_write_prompt_library_index()
|
||||
_write_asset_library_index()
|
||||
|
||||
|
||||
def _hydrate_asset_library_urls(item: AssetLibraryItem) -> None:
|
||||
for image in item.images:
|
||||
image.url = f"/asset-library/{item.kind}/{item.id}/file/{image.filename}"
|
||||
for image in item.views:
|
||||
image.url = f"/asset-library/{item.kind}/{item.id}/file/{image.filename}"
|
||||
if item.image:
|
||||
item.image.url = f"/asset-library/{item.kind}/{item.id}/file/{item.image.filename}"
|
||||
if item.poster:
|
||||
item.poster.url = f"/asset-library/{item.kind}/{item.id}/file/{item.poster.filename}"
|
||||
if item.kind == "videos":
|
||||
item.video_url = f"/asset-library/videos/{item.id}/file/video.mp4"
|
||||
|
||||
|
||||
def find_prompt_library_item(item_id: str) -> PromptLibraryItem:
|
||||
item = _read_prompt_item(_prompt_library_item_path(item_id))
|
||||
if not item:
|
||||
raise HTTPException(404, "prompt library item not found")
|
||||
return item
|
||||
|
||||
|
||||
def find_asset_library_item(kind: AssetLibraryKind | str, item_id: str) -> AssetLibraryItem:
|
||||
item = _read_asset_item(_asset_library_item_dir(kind, item_id) / "manifest.json")
|
||||
if not item:
|
||||
raise HTTPException(404, "asset library item not found")
|
||||
return item
|
||||
|
||||
|
||||
def _library_item_file_path(item: AssetLibraryItem, filename: str) -> Path:
|
||||
filename = filename.strip().lstrip("/")
|
||||
base = _asset_library_item_dir(item.kind, item.id).resolve()
|
||||
p = (base / filename).resolve()
|
||||
try:
|
||||
p.relative_to(base)
|
||||
except ValueError:
|
||||
raise HTTPException(400, "invalid library file path")
|
||||
if not p.exists():
|
||||
raise HTTPException(404, "library file not found")
|
||||
return p
|
||||
|
||||
|
||||
def _asset_library_search(items: list[AssetLibraryItem], q: str) -> list[AssetLibraryItem]:
|
||||
needle = q.strip().lower()
|
||||
if not needle:
|
||||
return items
|
||||
return [
|
||||
item for item in items
|
||||
if needle in " ".join([
|
||||
item.name,
|
||||
item.name_zh,
|
||||
item.note,
|
||||
item.prompt_brief,
|
||||
item.prompt_brief_zh,
|
||||
item.product_type,
|
||||
item.asset_role,
|
||||
" ".join(item.tags),
|
||||
]).lower()
|
||||
]
|
||||
|
||||
|
||||
def _prompt_library_search(items: list[PromptLibraryItem], q: str) -> list[PromptLibraryItem]:
|
||||
needle = q.strip().lower()
|
||||
if not needle:
|
||||
return items
|
||||
return [
|
||||
item for item in items
|
||||
if needle in " ".join([item.name, item.prompt_en, item.prompt_zh, " ".join(item.tags)]).lower()
|
||||
]
|
||||
|
||||
|
||||
def _library_ref_usage(kind: str, item_id: str) -> dict:
|
||||
refs: set[str] = set()
|
||||
library_kinds = {
|
||||
"subjects": "library_subject",
|
||||
"products": "library_product",
|
||||
"scenes": "library_scene",
|
||||
"videos": "library_video",
|
||||
}
|
||||
token = item_id
|
||||
ref_kind = library_kinds.get(kind, "")
|
||||
for job_id, job in JOBS.items():
|
||||
raw = job.model_dump_json()
|
||||
if token in raw and (not ref_kind or ref_kind in raw):
|
||||
refs.add(job_id)
|
||||
for state_path in JOBS_DIR.glob("*/state.json"):
|
||||
if state_path.parent.name in refs:
|
||||
continue
|
||||
try:
|
||||
raw = state_path.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
continue
|
||||
if token in raw and (not ref_kind or ref_kind in raw):
|
||||
refs.add(state_path.parent.name)
|
||||
return {"count": len(refs), "jobs": sorted(refs)}
|
||||
|
||||
|
||||
def _copy_library_file_to_job(src: Path, job_id: str, label: str = "") -> dict:
|
||||
if job_id not in JOBS:
|
||||
raise HTTPException(404, "job not found")
|
||||
asset_id = f"asset_{uuid.uuid4().hex[:12]}"
|
||||
out_dir = job_dir(job_id) / "assets"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
out = out_dir / f"{asset_id}.jpg"
|
||||
if src.suffix.lower() in {".jpg", ".jpeg", ".png", ".webp"}:
|
||||
try:
|
||||
with Image.open(src) as im:
|
||||
im.convert("RGB").save(out, "JPEG", quality=94)
|
||||
except Exception:
|
||||
shutil.copy2(src, out)
|
||||
else:
|
||||
shutil.copy2(src, out)
|
||||
width, height = _library_media_size(out)
|
||||
return {
|
||||
"kind": "asset",
|
||||
"frame_idx": -1,
|
||||
"element_id": asset_id,
|
||||
"cutout_id": asset_id,
|
||||
"label": label,
|
||||
"asset_meta": {"width": width, "height": height, "standard": "library-copy"},
|
||||
}
|
||||
|
||||
|
||||
def _copy_library_to_job(kind: AssetLibraryKind | str, item_id: str, job_id: str) -> dict:
|
||||
item = find_asset_library_item(kind, item_id)
|
||||
item_dir = _asset_library_item_dir(kind, item_id)
|
||||
if item.kind == "videos":
|
||||
if job_id not in JOBS:
|
||||
raise HTTPException(404, "job not found")
|
||||
src = item_dir / "video.mp4"
|
||||
if not src.exists():
|
||||
raise HTTPException(404, "library video missing")
|
||||
out_dir = job_dir(job_id) / "storyboard-videos"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
video_id = f"library_{uuid.uuid4().hex[:12]}"
|
||||
out = out_dir / f"{video_id}.mp4"
|
||||
shutil.copy2(src, out)
|
||||
item.use_count += 1
|
||||
item.updated_at = _now_ts()
|
||||
_write_asset_item(item)
|
||||
return {"kind": "video", "video_id": video_id, "url": f"/jobs/{job_id}/storyboard-videos/{video_id}.mp4", "label": item.name}
|
||||
image = item.image or item.poster or next(iter(item.views or item.images), None)
|
||||
if not image:
|
||||
raise HTTPException(404, "library image missing")
|
||||
result = _copy_library_file_to_job(item_dir / image.filename, job_id, item.name)
|
||||
item.use_count += 1
|
||||
item.updated_at = _now_ts()
|
||||
_write_asset_item(item)
|
||||
return result
|
||||
|
||||
|
||||
def storyboard_ref_url(job_id: str, ref: dict | None) -> str:
|
||||
if not ref:
|
||||
return ""
|
||||
@@ -905,6 +1266,8 @@ def storyboard_ref_url(job_id: str, ref: dict | None) -> str:
|
||||
return f"/jobs/{job_id}/frames/{int(frame_idx)}/elements/{element_id}/cutout.jpg"
|
||||
if kind == "asset" and ref.get("element_id"):
|
||||
return f"/jobs/{job_id}/assets/{ref.get('element_id')}.jpg"
|
||||
if kind in {"library_subject", "library_product", "library_scene"}:
|
||||
return ""
|
||||
return ""
|
||||
|
||||
|
||||
@@ -936,6 +1299,10 @@ def update_generated_video(job_id: str, video_id: str, **kw) -> None:
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI):
|
||||
try:
|
||||
_rebuild_library_index()
|
||||
except Exception as e:
|
||||
print(f"[resource library] index rebuild failed: {e}", flush=True)
|
||||
# 启动时从磁盘恢复 jobs(简化版:只列目录)
|
||||
for p in JOBS_DIR.iterdir():
|
||||
if p.is_dir() and (p / "state.json").exists():
|
||||
|
||||
Reference in New Issue
Block a user