auto-save 2026-05-18 20:57 (~3)
This commit is contained in:
283
api/main.py
283
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:
|
||||
|
||||
Reference in New Issue
Block a user