auto-save 2026-05-18 20:51 (~2)
This commit is contained in:
@@ -1,40 +1,5 @@
|
|||||||
{
|
{
|
||||||
"entries": [
|
"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,
|
"files_changed": 1,
|
||||||
"hash": "0abb1a2",
|
"hash": "0abb1a2",
|
||||||
@@ -3217,6 +3182,38 @@
|
|||||||
"message": "auto-save 2026-05-18 20:13 (~3)",
|
"message": "auto-save 2026-05-18 20:13 (~3)",
|
||||||
"hash": "69bb692",
|
"hash": "69bb692",
|
||||||
"files_changed": 3
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
369
api/main.py
369
api/main.py
@@ -20,7 +20,7 @@ from typing import Literal
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from dotenv import load_dotenv
|
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.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from pydantic import BaseModel, Field
|
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_IMAGE_DIR = SUBJECT_TEMPLATE_DIR / "images"
|
||||||
SUBJECT_TEMPLATE_MANIFEST = SUBJECT_TEMPLATE_DIR / "manifest.json"
|
SUBJECT_TEMPLATE_MANIFEST = SUBJECT_TEMPLATE_DIR / "manifest.json"
|
||||||
SUBJECT_TEMPLATE_IMAGE_DIR.mkdir(parents=True, exist_ok=True)
|
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_BASE_URL = os.getenv("LLM_BASE_URL", "").strip()
|
||||||
LLM_API_KEY = os.getenv("LLM_API_KEY", "").strip()
|
LLM_API_KEY = os.getenv("LLM_API_KEY", "").strip()
|
||||||
@@ -493,6 +506,93 @@ class SubjectTemplateItem(BaseModel):
|
|||||||
updated_at: float = 0.0
|
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):
|
class ProductFusionRegion(BaseModel):
|
||||||
x: float = 0
|
x: float = 0
|
||||||
y: float = 0
|
y: float = 0
|
||||||
@@ -890,6 +990,267 @@ def subject_template_image_file(filename: str) -> Path:
|
|||||||
return p
|
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:
|
def storyboard_ref_url(job_id: str, ref: dict | None) -> str:
|
||||||
if not ref:
|
if not ref:
|
||||||
return ""
|
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"
|
return f"/jobs/{job_id}/frames/{int(frame_idx)}/elements/{element_id}/cutout.jpg"
|
||||||
if kind == "asset" and ref.get("element_id"):
|
if kind == "asset" and ref.get("element_id"):
|
||||||
return f"/jobs/{job_id}/assets/{ref.get('element_id')}.jpg"
|
return f"/jobs/{job_id}/assets/{ref.get('element_id')}.jpg"
|
||||||
|
if kind in {"library_subject", "library_product", "library_scene"}:
|
||||||
|
return ""
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
@@ -936,6 +1299,10 @@ def update_generated_video(job_id: str, video_id: str, **kw) -> None:
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(_: FastAPI):
|
async def lifespan(_: FastAPI):
|
||||||
|
try:
|
||||||
|
_rebuild_library_index()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[resource library] index rebuild failed: {e}", flush=True)
|
||||||
# 启动时从磁盘恢复 jobs(简化版:只列目录)
|
# 启动时从磁盘恢复 jobs(简化版:只列目录)
|
||||||
for p in JOBS_DIR.iterdir():
|
for p in JOBS_DIR.iterdir():
|
||||||
if p.is_dir() and (p / "state.json").exists():
|
if p.is_dir() and (p / "state.json").exists():
|
||||||
|
|||||||
Reference in New Issue
Block a user