auto-save 2026-05-18 20:51 (~2)

This commit is contained in:
2026-05-18 20:51:56 +08:00
parent 2e2998c5df
commit 58fe17c5e0
2 changed files with 400 additions and 36 deletions

View File

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

View File

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