diff --git a/.memory/worklog.json b/.memory/worklog.json index 6808885..2702693 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -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 } ] } diff --git a/api/main.py b/api/main.py index 45dad7a..5b9a4be 100644 --- a/api/main.py +++ b/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():