diff --git a/.memory/worklog.json b/.memory/worklog.json index 2702693..ef30469 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -1,19 +1,5 @@ { "entries": [ - { - "files_changed": 1, - "hash": "0abb1a2", - "message": "auto-save 2026-05-16 15:26 (~1)", - "ts": "2026-05-16T15:26:26+08:00", - "type": "commit" - }, - { - "files_changed": 1, - "hash": "11506f6", - "message": "auto-save 2026-05-16 15:32 (~1)", - "ts": "2026-05-16T15:32:12+08:00", - "type": "commit" - }, { "files_changed": 1, "hash": "59687a5", @@ -3214,6 +3200,19 @@ "type": "session-heartbeat", "message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:docs: document skg brand workbench theme", "files_changed": 1 + }, + { + "ts": "2026-05-18T20:51:56+08:00", + "type": "commit", + "message": "auto-save 2026-05-18 20:51 (~2)", + "hash": "58fe17c", + "files_changed": 2 + }, + { + "ts": "2026-05-18T12:52:20Z", + "type": "session-heartbeat", + "message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 2 项未提交变更 · 最近提交:auto-save 2026-05-18 20:51 (~2)", + "files_changed": 2 } ] } diff --git a/api/main.py b/api/main.py index 5b9a4be..c3d0530 100644 --- a/api/main.py +++ b/api/main.py @@ -1396,6 +1396,289 @@ def auth_logout(response: Response) -> dict: return {"ok": True} +def _parse_library_metadata(raw: str) -> dict: + if not raw.strip(): + return {} + try: + data = json.loads(raw) + return data if isinstance(data, dict) else {} + except Exception as e: + raise HTTPException(400, f"metadata json invalid: {e}") + + +async def _save_upload_to_path(upload: UploadFile, dst: Path) -> None: + dst.parent.mkdir(parents=True, exist_ok=True) + data = await upload.read() + if not data: + raise HTTPException(400, f"{upload.filename or 'file'} is empty") + dst.write_bytes(data) + + +def _save_library_image(src: Path, dst: Path) -> AssetLibraryImage: + dst.parent.mkdir(parents=True, exist_ok=True) + try: + with Image.open(src) as im: + rgb = im.convert("RGB") + rgb.save(dst, "JPEG", quality=94) + except Exception: + shutil.copy2(src, dst) + width, height = _library_media_size(dst) + return AssetLibraryImage( + id=dst.stem, + view=dst.stem, + label=dst.stem, + filename=str(dst.relative_to(dst.parents[1])), + width=width, + height=height, + created_at=_now_ts(), + ) + + +@app.get("/prompt-library", response_model=list[PromptLibraryItem]) +def list_prompt_library(category: PromptLibraryCategory | None = None, q: str = "", sort: str = "") -> list[PromptLibraryItem]: + items = load_prompt_library_items() + if category: + items = [item for item in items if item.category == category] + items = _prompt_library_search(items, q) + if sort == "use_count": + items.sort(key=lambda item: (item.use_count, item.updated_at or item.created_at), reverse=True) + return items + + +@app.get("/prompt-library/{item_id}", response_model=PromptLibraryItem) +def get_prompt_library_item(item_id: str) -> PromptLibraryItem: + return find_prompt_library_item(item_id) + + +@app.post("/prompt-library", response_model=PromptLibraryItem) +def create_prompt_library_item(req: PromptLibraryWriteReq) -> PromptLibraryItem: + now = _now_ts() + name = req.name.strip() + prompt_en = _ensure_english(req.prompt_en.strip()) if req.prompt_en.strip() else "" + if not name: + raise HTTPException(400, "prompt name required") + if not prompt_en and not req.prompt_zh.strip(): + raise HTTPException(400, "prompt content required") + item = PromptLibraryItem( + id=f"lib_prompt_{uuid.uuid4().hex[:12]}", + category=req.category, + name=name, + tags=_safe_tags(req.tags), + prompt_en=prompt_en or _ensure_english(req.prompt_zh.strip()), + prompt_zh=req.prompt_zh.strip(), + source_job_id=req.source_job_id.strip(), + created_at=now, + updated_at=now, + ) + _write_prompt_item(item) + return item + + +@app.patch("/prompt-library/{item_id}", response_model=PromptLibraryItem) +def patch_prompt_library_item(item_id: str, req: PromptLibraryPatchReq) -> PromptLibraryItem: + item = find_prompt_library_item(item_id) + data = item.model_dump() + patch = req.model_dump(exclude_unset=True) + if "tags" in patch: + patch["tags"] = _safe_tags(patch["tags"]) + if "name" in patch: + patch["name"] = str(patch["name"]).strip() + if "prompt_en" in patch and str(patch["prompt_en"]).strip(): + patch["prompt_en"] = _ensure_english(str(patch["prompt_en"]).strip()) + data.update(patch) + data["updated_at"] = _now_ts() + updated = PromptLibraryItem(**data) + if not updated.name.strip(): + raise HTTPException(400, "prompt name required") + _write_prompt_item(updated) + return updated + + +@app.delete("/prompt-library/{item_id}") +def delete_prompt_library_item(item_id: str) -> dict: + item = find_prompt_library_item(item_id) + src = _prompt_item_file(item) + trash = LIBRARY_TRASH_DIR / "prompt_library" / f"{item.id}_{int(_now_ts())}.json" + trash.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(src), str(trash)) + _write_prompt_library_index() + return {"ok": True, "id": item.id, "trashed": str(trash)} + + +@app.post("/prompt-library/{item_id}/use", response_model=PromptLibraryItem) +def use_prompt_library_item(item_id: str) -> PromptLibraryItem: + item = find_prompt_library_item(item_id) + item.use_count += 1 + item.updated_at = _now_ts() + _write_prompt_item(item) + return item + + +@app.get("/asset-library/{kind}", response_model=list[AssetLibraryItem]) +def list_asset_library(kind: AssetLibraryKind, q: str = "", sort: str = "") -> list[AssetLibraryItem]: + items = _asset_library_search(load_asset_library_items(kind), q) + if sort == "use_count": + items.sort(key=lambda item: (item.use_count, item.updated_at or item.created_at), reverse=True) + return items + + +@app.get("/asset-library/{kind}/{item_id}", response_model=AssetLibraryItem) +def get_asset_library_item(kind: AssetLibraryKind, item_id: str) -> AssetLibraryItem: + return find_asset_library_item(kind, item_id) + + +@app.post("/asset-library/{kind}", response_model=AssetLibraryItem) +async def create_asset_library_item( + kind: AssetLibraryKind, + metadata: str = Form("{}"), + files: list[UploadFile] = File(default=[]), +) -> AssetLibraryItem: + meta = _parse_library_metadata(metadata) + if not files: + raise HTTPException(400, "at least one file required") + now = _now_ts() + item_id = f"lib_{kind}_{uuid.uuid4().hex[:12]}" + item_dir = _asset_library_item_dir(kind, item_id) + tmp_dir = item_dir / "_tmp" + item_dir.mkdir(parents=True, exist_ok=True) + tmp_dir.mkdir(parents=True, exist_ok=True) + name = str(meta.get("name") or meta.get("name_zh") or kind).strip() + item = AssetLibraryItem( + id=item_id, + kind=kind, + name=name, + name_zh=str(meta.get("name_zh") or "").strip(), + note=str(meta.get("note") or "").strip(), + tags=_safe_tags(meta.get("tags")), + source_job_id=str(meta.get("source_job_id") or "").strip(), + is_official=bool(meta.get("is_official") or False), + prompt_brief=str(meta.get("prompt_brief") or "").strip(), + prompt_brief_zh=str(meta.get("prompt_brief_zh") or "").strip(), + subject_style=str(meta.get("subject_style") or "transparent_human") if kind == "subjects" else "transparent_human", + product_type=str(meta.get("product_type") or "").strip(), + asset_role=str(meta.get("asset_role") or "").strip(), + aspect_ratio=str(meta.get("aspect_ratio") or "").strip(), + created_at=now, + updated_at=now, + ) + requested_views = [str(v).strip() for v in (meta.get("views") or []) if str(v).strip()] + saved_images: list[AssetLibraryImage] = [] + for index, upload in enumerate(files): + suffix = Path(upload.filename or "").suffix.lower() or ".jpg" + tmp = tmp_dir / f"{index}{suffix}" + await _save_upload_to_path(upload, tmp) + if kind == "videos": + if suffix in {".jpg", ".jpeg", ".png", ".webp"}: + image = _save_library_image(tmp, item_dir / "poster.jpg") + image.id = "poster" + image.view = "poster" + image.label = "poster" + image.filename = "poster.jpg" + item.poster = image + else: + shutil.copy2(tmp, item_dir / "video.mp4") + item.video_url = f"/asset-library/videos/{item_id}/file/video.mp4" + continue + view = requested_views[index] if index < len(requested_views) else (Path(upload.filename or "").stem or f"view_{index + 1}") + safe_view = re.sub(r"[^a-zA-Z0-9_-]+", "_", view).strip("_") or f"view_{index + 1}" + image = _save_library_image(tmp, item_dir / "images" / f"{safe_view}.jpg") + image.id = safe_view + image.view = safe_view + image.label = view + image.filename = f"images/{safe_view}.jpg" + saved_images.append(image) + shutil.rmtree(tmp_dir, ignore_errors=True) + if kind == "subjects": + item.images = saved_images + item.views = saved_images + item.prompt_brief = _ensure_english(item.prompt_brief) if item.prompt_brief else item.note + elif kind == "products": + item.views = saved_images + item.images = saved_images + elif kind == "scenes": + item.image = saved_images[0] if saved_images else None + if item.image: + item.image.id = "image" + item.image.view = "scene" + item.image.label = "scene" + elif kind == "videos" and not item.video_url: + raise HTTPException(400, "video file required") + _hydrate_asset_library_urls(item) + _write_asset_item(item) + return item + + +@app.patch("/asset-library/{kind}/{item_id}", response_model=AssetLibraryItem) +def patch_asset_library_item(kind: AssetLibraryKind, item_id: str, req: AssetLibraryPatchReq) -> AssetLibraryItem: + item = find_asset_library_item(kind, item_id) + data = item.model_dump() + patch = req.model_dump(exclude_unset=True) + if "tags" in patch: + patch["tags"] = _safe_tags(patch["tags"]) + data.update(patch) + data["updated_at"] = _now_ts() + updated = AssetLibraryItem(**data) + _hydrate_asset_library_urls(updated) + _write_asset_item(updated) + return updated + + +@app.get("/asset-library/{kind}/{item_id}/refs") +def asset_library_refs(kind: AssetLibraryKind, item_id: str) -> dict: + find_asset_library_item(kind, item_id) + return _library_ref_usage(kind, item_id) + + +@app.delete("/asset-library/{kind}/{item_id}") +def delete_asset_library_item(kind: AssetLibraryKind, item_id: str, force: bool = False) -> dict: + item = find_asset_library_item(kind, item_id) + refs = _library_ref_usage(kind, item_id) + if refs["count"] and not force: + raise HTTPException(409, {"message": "asset library item is referenced", **refs}) + src = _asset_library_item_dir(kind, item.id) + trash = LIBRARY_TRASH_DIR / "asset_library" / kind / f"{item.id}_{int(_now_ts())}" + trash.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(src), str(trash)) + _write_asset_library_index() + return {"ok": True, "id": item.id, "refs": refs, "trashed": str(trash)} + + +@app.post("/asset-library/{kind}/{item_id}/copy-to-job/{job_id}") +def copy_asset_library_to_job(kind: AssetLibraryKind, item_id: str, job_id: str) -> dict: + return _copy_library_to_job(kind, item_id, job_id) + + +@app.get("/asset-library/{kind}/{item_id}/file/{filename:path}") +def get_asset_library_file(kind: AssetLibraryKind, item_id: str, filename: str): + item = find_asset_library_item(kind, item_id) + p = _library_item_file_path(item, filename) + suffix = p.suffix.lower() + if suffix == ".mp4": + return FileResponse(p, media_type="video/mp4") + if suffix == ".png": + return FileResponse(p, media_type="image/png") + if suffix == ".webp": + return FileResponse(p, media_type="image/webp") + return FileResponse(p, media_type="image/jpeg") + + +@app.get("/resource-library/recent") +def resource_library_recent(hours: int = 24) -> dict: + cutoff = _now_ts() - max(1, min(hours, 24 * 30)) * 3600 + prompts = [ + {"type": "prompt", "category": item.category, "id": item.id, "name": item.name, "created_at": item.created_at, "item": item.model_dump()} + for item in load_prompt_library_items() + if item.created_at >= cutoff + ] + assets = [ + {"type": "asset", "kind": item.kind, "id": item.id, "name": item.name, "created_at": item.created_at, "item": item.model_dump()} + for item in load_asset_library_items() + if item.created_at >= cutoff + ] + items = sorted(prompts + assets, key=lambda item: item.get("created_at", 0), reverse=True) + return {"items": items} + + # ---------- Pipeline 实现 ---------- def _binary_works(path: str) -> bool: diff --git a/web/lib/api.ts b/web/lib/api.ts index 23e9e2a..61edea6 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -64,7 +64,7 @@ export interface KeyElement { } export interface ImageRef { - kind: "keyframe" | "cutout" | "asset" + kind: "keyframe" | "cutout" | "asset" | "library_subject" | "library_product" | "library_scene" frame_idx: number element_id?: string | null cutout_id?: string | null @@ -87,6 +87,69 @@ export interface ImageRef { } } +export type PromptLibraryCategory = "scene_desc" | "video_desc" | "subject_desc" | "skg_script" | "product_angle" +export type AssetLibraryKind = "subjects" | "products" | "scenes" | "videos" + +export interface PromptLibraryItem { + id: string + category: PromptLibraryCategory + name: string + tags: string[] + prompt_en: string + prompt_zh: string + use_count: number + source_job_id: string + created_at: number + updated_at: number +} + +export interface AssetLibraryImage { + id: string + view: string + label: string + filename: string + url: string + width: number + height: number + created_at: number +} + +export interface AssetLibraryItem { + id: string + kind: AssetLibraryKind + name: string + name_zh?: string + note?: string + tags: string[] + source_job_id?: string + use_count: number + created_at: number + updated_at: number + is_official?: boolean + prompt_brief?: string + prompt_brief_zh?: string + subject_style?: "transparent_human" | "source_actor" + product_type?: string + views?: AssetLibraryImage[] + images?: AssetLibraryImage[] + asset_role?: string + aspect_ratio?: string + image?: AssetLibraryImage | null + duration?: number + poster?: AssetLibraryImage | null + video_url?: string +} + +export interface ResourceLibraryRecentItem { + type: "prompt" | "asset" + id: string + name: string + category?: PromptLibraryCategory + kind?: AssetLibraryKind + created_at: number + item: PromptLibraryItem | AssetLibraryItem +} + export interface ProductFusionRegion { x: number y: number @@ -209,6 +272,9 @@ export function resolveImageRefUrl(jobId: string, ref: ImageRef): string { if (ref.kind === "asset" && ref.element_id) { return `${API_BASE}/jobs/${jobId}/assets/${ref.element_id}.jpg` } + if (ref.kind === "library_subject" || ref.kind === "library_product" || ref.kind === "library_scene") { + return "" + } if (ref.element_id && ref.cutout_id) { if (ref.cutout_id === ref.element_id) { // legacy v1 @@ -402,6 +468,130 @@ export async function saveSubjectTemplate( return res.json() } +export function assetLibraryFileUrl(kind: AssetLibraryKind, itemId: string, filename: string): string { + return `${API_BASE}/asset-library/${kind}/${itemId}/file/${filename}` +} + +export async function listPromptLibrary(category?: PromptLibraryCategory, q = ""): Promise { + const qs = new URLSearchParams() + if (category) qs.set("category", category) + if (q.trim()) qs.set("q", q.trim()) + const res = await fetch(`${API_BASE}/prompt-library${qs.toString() ? `?${qs}` : ""}`, { cache: "no-store" }) + if (!res.ok) { + const txt = await res.text().catch(() => "") + throw new Error(`listPromptLibrary ${res.status} ${txt.slice(0, 300)}`) + } + return res.json() +} + +export async function createPromptLibraryItem(body: { + category: PromptLibraryCategory + name: string + tags?: string[] + prompt_en: string + prompt_zh?: string + source_job_id?: string +}): Promise { + const res = await fetch(`${API_BASE}/prompt-library`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + category: body.category, + name: body.name, + tags: body.tags ?? [], + prompt_en: body.prompt_en, + prompt_zh: body.prompt_zh ?? "", + source_job_id: body.source_job_id ?? "", + }), + }) + if (!res.ok) { + const txt = await res.text().catch(() => "") + throw new Error(`createPromptLibraryItem ${res.status} ${txt.slice(0, 300)}`) + } + return res.json() +} + +export async function usePromptLibraryItem(id: string): Promise { + const res = await fetch(`${API_BASE}/prompt-library/${id}/use`, { method: "POST" }) + if (!res.ok) { + const txt = await res.text().catch(() => "") + throw new Error(`usePromptLibraryItem ${res.status} ${txt.slice(0, 300)}`) + } + return res.json() +} + +export async function deletePromptLibraryItem(id: string): Promise<{ ok: boolean }> { + const res = await fetch(`${API_BASE}/prompt-library/${id}`, { method: "DELETE" }) + if (!res.ok) { + const txt = await res.text().catch(() => "") + throw new Error(`deletePromptLibraryItem ${res.status} ${txt.slice(0, 300)}`) + } + return res.json() +} + +export async function listAssetLibrary(kind: AssetLibraryKind, q = ""): Promise { + const qs = new URLSearchParams() + if (q.trim()) qs.set("q", q.trim()) + const res = await fetch(`${API_BASE}/asset-library/${kind}${qs.toString() ? `?${qs}` : ""}`, { cache: "no-store" }) + if (!res.ok) { + const txt = await res.text().catch(() => "") + throw new Error(`listAssetLibrary ${res.status} ${txt.slice(0, 300)}`) + } + return res.json() +} + +export async function createAssetLibraryItem( + kind: AssetLibraryKind, + metadata: Record, + files: File[], +): Promise { + const fd = new FormData() + fd.append("metadata", JSON.stringify(metadata)) + for (const file of files) fd.append("files", file) + const res = await fetch(`${API_BASE}/asset-library/${kind}`, { method: "POST", body: fd }) + if (!res.ok) { + const txt = await res.text().catch(() => "") + throw new Error(`createAssetLibraryItem ${res.status} ${txt.slice(0, 300)}`) + } + return res.json() +} + +export async function getAssetLibraryRefs(kind: AssetLibraryKind, id: string): Promise<{ count: number; jobs: string[] }> { + const res = await fetch(`${API_BASE}/asset-library/${kind}/${id}/refs`, { cache: "no-store" }) + if (!res.ok) { + const txt = await res.text().catch(() => "") + throw new Error(`getAssetLibraryRefs ${res.status} ${txt.slice(0, 300)}`) + } + return res.json() +} + +export async function deleteAssetLibraryItem(kind: AssetLibraryKind, id: string, force = false): Promise<{ ok: boolean }> { + const res = await fetch(`${API_BASE}/asset-library/${kind}/${id}${force ? "?force=true" : ""}`, { method: "DELETE" }) + if (!res.ok) { + const txt = await res.text().catch(() => "") + throw new Error(`deleteAssetLibraryItem ${res.status} ${txt.slice(0, 300)}`) + } + return res.json() +} + +export async function copyAssetLibraryToJob(kind: AssetLibraryKind, id: string, jobId: string): Promise { + const res = await fetch(`${API_BASE}/asset-library/${kind}/${id}/copy-to-job/${jobId}`, { method: "POST" }) + if (!res.ok) { + const txt = await res.text().catch(() => "") + throw new Error(`copyAssetLibraryToJob ${res.status} ${txt.slice(0, 300)}`) + } + return res.json() +} + +export async function getResourceLibraryRecent(hours = 24): Promise<{ items: ResourceLibraryRecentItem[] }> { + const res = await fetch(`${API_BASE}/resource-library/recent?hours=${encodeURIComponent(String(hours))}`, { cache: "no-store" }) + if (!res.ok) { + const txt = await res.text().catch(() => "") + throw new Error(`getResourceLibraryRecent ${res.status} ${txt.slice(0, 300)}`) + } + return res.json() +} + 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",