auto-save 2026-05-18 20:57 (~3)

This commit is contained in:
2026-05-18 20:57:23 +08:00
parent 58fe17c5e0
commit 32620af91d
3 changed files with 487 additions and 15 deletions

View File

@@ -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
}
]
}

View File

@@ -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:

View File

@@ -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<PromptLibraryItem[]> {
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<PromptLibraryItem> {
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<PromptLibraryItem> {
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<AssetLibraryItem[]> {
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<string, unknown>,
files: File[],
): Promise<AssetLibraryItem> {
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<ImageRef | { kind: "video"; video_id: string; url: string; label?: string }> {
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",