auto-save 2026-05-13 21:40 (~6)
This commit is contained in:
@@ -2441,6 +2441,19 @@
|
||||
"message": "auto-save 2026-05-13 21:29 (~7)",
|
||||
"hash": "7b59ed9",
|
||||
"files_changed": 7
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-13T21:35:36+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-05-13 21:35 (~6)",
|
||||
"hash": "d36b5ca",
|
||||
"files_changed": 6
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-13T13:39:31Z",
|
||||
"type": "session-heartbeat",
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 6 项未提交变更 · 最近提交:auto-save 2026-05-13 21:35 (~6)",
|
||||
"files_changed": 6
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
93
api/main.py
93
api/main.py
@@ -127,6 +127,7 @@ class StoryboardScene(BaseModel):
|
||||
duration: float = 0
|
||||
first_image: dict | None = None
|
||||
last_image: dict | None = None
|
||||
product_images: list[dict] = Field(default_factory=list)
|
||||
# 4 图槽:dict 含 {kind, frame_idx, element_id?, cutout_id?, label}
|
||||
subject_image: dict | None = None
|
||||
scene_image: dict | None = None
|
||||
@@ -290,6 +291,12 @@ def storyboard_ref_path(job_id: str, ref: dict | None) -> Path | None:
|
||||
for p in candidates:
|
||||
if p.exists():
|
||||
return p
|
||||
if kind == "asset":
|
||||
asset_id = (ref.get("element_id") or ref.get("cutout_id") or "").strip()
|
||||
if not asset_id:
|
||||
return None
|
||||
p = job_dir(job_id) / "assets" / f"{asset_id}.jpg"
|
||||
return p if p.exists() else None
|
||||
return None
|
||||
|
||||
|
||||
@@ -306,6 +313,8 @@ def storyboard_ref_url(job_id: str, ref: dict | None) -> str:
|
||||
if cutout_id and cutout_id != element_id:
|
||||
return f"/jobs/{job_id}/frames/{int(frame_idx)}/elements/{element_id}/cutouts/{cutout_id}.jpg"
|
||||
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"
|
||||
return ""
|
||||
|
||||
|
||||
@@ -1651,6 +1660,7 @@ class GenerateStoryboardVideoReq(BaseModel):
|
||||
duration: float = 4
|
||||
first_image: dict | None = None
|
||||
last_image: dict | None = None
|
||||
product_images: list[dict] = Field(default_factory=list)
|
||||
subject_image: dict | None = None
|
||||
scene_image: dict | None = None
|
||||
product_image: dict | None = None
|
||||
@@ -1770,7 +1780,7 @@ def submit_video_create(
|
||||
payload: dict,
|
||||
source_ref: VideoSourceRef | None = None,
|
||||
last_img: Path | None = None,
|
||||
product_img: Path | None = None,
|
||||
product_imgs: list[Path] | None = None,
|
||||
):
|
||||
if video_uses_ark():
|
||||
content = [{"type": "text", "text": payload["prompt"]}]
|
||||
@@ -1797,14 +1807,15 @@ def submit_video_create(
|
||||
"role": "last_frame",
|
||||
}
|
||||
)
|
||||
if product_img and product_img.exists():
|
||||
content.append(
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": ark_reference_data_url(product_img)},
|
||||
"role": "reference_image",
|
||||
}
|
||||
)
|
||||
for product_img in (product_imgs or [])[:6]:
|
||||
if product_img.exists():
|
||||
content.append(
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": ark_reference_data_url(product_img)},
|
||||
"role": "reference_image",
|
||||
}
|
||||
)
|
||||
data = {
|
||||
"model": payload["model"],
|
||||
"content": content,
|
||||
@@ -1841,14 +1852,13 @@ def render_storyboard_video(
|
||||
size: str,
|
||||
source_ref: VideoSourceRef | None = None,
|
||||
last_ref_path: Path | None = None,
|
||||
product_ref_path: Path | None = None,
|
||||
product_ref_paths: list[Path] | None = None,
|
||||
) -> None:
|
||||
import httpx
|
||||
|
||||
out_dir = job_dir(job_id) / "storyboard_videos" / local_id
|
||||
ref_img = out_dir / "reference.jpg"
|
||||
last_img = out_dir / "last_reference.jpg"
|
||||
product_img = out_dir / "product_reference.jpg"
|
||||
out_mp4 = out_dir / "video.mp4"
|
||||
base = video_api_base()
|
||||
headers = {"Authorization": f"Bearer {video_api_key()}"}
|
||||
@@ -1859,10 +1869,12 @@ def render_storyboard_video(
|
||||
if last_ref_path and last_ref_path.exists():
|
||||
prepare_video_reference(last_ref_path, last_img)
|
||||
prepared_last_img = last_img
|
||||
prepared_product_img: Path | None = None
|
||||
if product_ref_path and product_ref_path.exists():
|
||||
prepare_video_reference(product_ref_path, product_img)
|
||||
prepared_product_img = product_img
|
||||
prepared_product_imgs: list[Path] = []
|
||||
for i, product_ref_path in enumerate((product_ref_paths or [])[:6], start=1):
|
||||
if product_ref_path.exists():
|
||||
product_img = out_dir / f"product_reference_{i}.jpg"
|
||||
prepare_video_reference(product_ref_path, product_img)
|
||||
prepared_product_imgs.append(product_img)
|
||||
update_generated_video(job_id, local_id, status="in_progress", progress=5)
|
||||
with httpx.Client(timeout=120) as client:
|
||||
payload = {"model": model, "prompt": prompt, "size": size}
|
||||
@@ -1870,14 +1882,14 @@ def render_storyboard_video(
|
||||
create = None
|
||||
create_errors: list[str] = []
|
||||
for create_path in VIDEO_CREATE_PATHS:
|
||||
resp = submit_video_create(client, f"{base}{video_path(create_path)}", headers, ref_img, payload, source_ref, prepared_last_img, prepared_product_img)
|
||||
resp = submit_video_create(client, f"{base}{video_path(create_path)}", headers, ref_img, payload, source_ref, prepared_last_img, prepared_product_imgs)
|
||||
if video_uses_ark() and source_ref and resp.status_code in {400, 422}:
|
||||
create_errors.append(f"{video_path(create_path)} + reference_video -> HTTP {resp.status_code}: {resp.text[:160]}")
|
||||
resp = submit_video_create(client, f"{base}{video_path(create_path)}", headers, ref_img, payload, None, prepared_last_img, prepared_product_img)
|
||||
resp = submit_video_create(client, f"{base}{video_path(create_path)}", headers, ref_img, payload, None, prepared_last_img, prepared_product_imgs)
|
||||
if video_uses_ark() and prepared_last_img and resp.status_code in {400, 422}:
|
||||
create_errors.append(f"{video_path(create_path)} + last_frame -> HTTP {resp.status_code}: {resp.text[:160]}")
|
||||
resp = submit_video_create(client, f"{base}{video_path(create_path)}", headers, ref_img, payload, None, None, prepared_product_img)
|
||||
if video_uses_ark() and prepared_product_img and resp.status_code in {400, 422}:
|
||||
resp = submit_video_create(client, f"{base}{video_path(create_path)}", headers, ref_img, payload, None, None, prepared_product_imgs)
|
||||
if video_uses_ark() and prepared_product_imgs and resp.status_code in {400, 422}:
|
||||
create_errors.append(f"{video_path(create_path)} + product_reference -> HTTP {resp.status_code}: {resp.text[:160]}")
|
||||
resp = submit_video_create(client, f"{base}{video_path(create_path)}", headers, ref_img, payload, None, prepared_last_img, None)
|
||||
if resp.status_code < 400:
|
||||
@@ -1942,7 +1954,8 @@ def generate_storyboard_video(job_id: str, idx: int, req: GenerateStoryboardVide
|
||||
raise HTTPException(404, "reference image missing")
|
||||
poster = storyboard_ref_url(job_id, ref) or f"/jobs/{job_id}/frames/{idx}.jpg"
|
||||
last_ref_path = storyboard_ref_path(job_id, req.last_image)
|
||||
product_ref_path = storyboard_ref_path(job_id, req.product_image)
|
||||
raw_product_refs = req.product_images[:6] if req.product_images else ([req.product_image] if req.product_image else [])
|
||||
product_ref_paths = [p for p in (storyboard_ref_path(job_id, r) for r in raw_product_refs) if p]
|
||||
|
||||
local_id = uuid.uuid4().hex[:12]
|
||||
model = resolve_video_model(req.model)
|
||||
@@ -1964,7 +1977,7 @@ def generate_storyboard_video(job_id: str, idx: int, req: GenerateStoryboardVide
|
||||
source_ref = req.source_ref
|
||||
if source_ref and source_ref.kind == "source_video" and not source_ref.url:
|
||||
source_ref = None
|
||||
bg.add_task(render_storyboard_video, job_id, local_id, "", ref_path, prompt, model, seconds, req.size, source_ref, last_ref_path, product_ref_path)
|
||||
bg.add_task(render_storyboard_video, job_id, local_id, "", ref_path, prompt, model, seconds, req.size, source_ref, last_ref_path, product_ref_paths)
|
||||
return job
|
||||
|
||||
|
||||
@@ -1976,6 +1989,44 @@ def get_storyboard_video(job_id: str, video_id: str):
|
||||
return FileResponse(p, media_type="video/mp4")
|
||||
|
||||
|
||||
@app.post("/jobs/{job_id}/assets")
|
||||
async def upload_storyboard_asset(job_id: str, file: UploadFile = File(...)) -> dict:
|
||||
if job_id not in JOBS:
|
||||
raise HTTPException(404, "job not found")
|
||||
asset_id = uuid.uuid4().hex[:12]
|
||||
out_dir = job_dir(job_id) / "assets"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
tmp = out_dir / f"{asset_id}.upload"
|
||||
out = out_dir / f"{asset_id}.jpg"
|
||||
try:
|
||||
tmp.write_bytes(await file.read())
|
||||
img = Image.open(tmp).convert("RGB")
|
||||
img.thumbnail((1600, 1600), Image.Resampling.LANCZOS)
|
||||
img.save(out, "JPEG", quality=94)
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"product image upload failed: {e}")
|
||||
finally:
|
||||
try:
|
||||
tmp.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"kind": "asset",
|
||||
"frame_idx": -1,
|
||||
"element_id": asset_id,
|
||||
"cutout_id": asset_id,
|
||||
"label": file.filename or "SKG 产品图",
|
||||
}
|
||||
|
||||
|
||||
@app.get("/jobs/{job_id}/assets/{asset_id}.jpg")
|
||||
def get_storyboard_asset(job_id: str, asset_id: str):
|
||||
p = job_dir(job_id) / "assets" / f"{asset_id}.jpg"
|
||||
if not p.exists():
|
||||
raise HTTPException(404, "asset not found")
|
||||
return FileResponse(p, media_type="image/jpeg")
|
||||
|
||||
|
||||
@app.delete("/jobs/{job_id}/storyboard-videos/{video_id}", response_model=Job)
|
||||
def delete_storyboard_video(job_id: str, video_id: str) -> Job:
|
||||
"""删除 Video Gen 节点里的一个视频任务(成功/失败/排队都可删)。"""
|
||||
|
||||
@@ -830,6 +830,18 @@ api/main.py
|
||||
<h2>变更记录</h2>
|
||||
<p>这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。</p>
|
||||
<div class="changelog">
|
||||
<article class="change">
|
||||
<header>
|
||||
<h3>2026-05-13 · 产品参考支持本地上传和拖拽</h3>
|
||||
<span class="tag violet">StoryboardWorkbench</span>
|
||||
<span class="tag blue">API</span>
|
||||
</header>
|
||||
<div class="body">
|
||||
<p><strong>问题:</strong>SKG 产品参考不能只依赖从关键帧/元素里复制,用户需要手动上传同一产品的多角度图。</p>
|
||||
<p><strong>改动:</strong><code>SKG 产品参考</code> 区支持点击上传和拖拽上传图片,一次可传多张,最多保留 6 张。后端新增 <code>POST /jobs/{job_id}/assets</code> 保存产品图资产,并返回 <code>kind: "asset"</code> 的 <code>ImageRef</code>;生成视频时这些资产会作为产品参考传入 Ark。</p>
|
||||
<p><strong>影响:</strong><code>web/components/storyboard-workbench.tsx</code>、<code>web/lib/api.ts</code>、<code>api/main.py</code>。</p>
|
||||
</div>
|
||||
</article>
|
||||
<article class="change">
|
||||
<header>
|
||||
<h3>2026-05-13 · 首尾帧编排增加 SKG 产品槽</h3>
|
||||
|
||||
@@ -246,6 +246,7 @@ export default function Home() {
|
||||
: null
|
||||
const firstRef = scene.first_image ?? keyframeRef
|
||||
const lastRef = scene.last_image ?? defaultLastRef
|
||||
const productRefs = (scene.product_images?.length ? scene.product_images : scene.product_image ? [scene.product_image] : [])
|
||||
const duration = scene.duration && scene.duration > 0 ? scene.duration : 5
|
||||
const sourceScene = frame.description?.scene ? `参考画面识别:${frame.description.scene}` : ""
|
||||
const sourceStyle = frame.description?.style ? `参考风格:${frame.description.style}` : ""
|
||||
@@ -272,7 +273,7 @@ export default function Home() {
|
||||
`连续动作和镜头:${actionDirection}`,
|
||||
`首帧:${labelOf(firstRef, "当前分镜关键帧")}`,
|
||||
`尾帧:${labelOf(lastRef, "未指定,按首帧小幅自然运动收尾")}`,
|
||||
`SKG 产品参考:${labelOf(scene.product_image, "SKG 产品视觉主角")}`,
|
||||
`SKG 产品参考:${productRefs.length ? productRefs.map((ref, i) => `${i + 1}. ${labelOf(ref, "SKG 产品角度")}`).join(";") : "SKG 产品视觉主角"}`,
|
||||
`动作参考:${labelOf(scene.action_image, "自然拿取、佩戴、展示或靠近产品的动作")}`,
|
||||
sourceScene,
|
||||
sourceStyle,
|
||||
@@ -290,9 +291,10 @@ export default function Home() {
|
||||
duration,
|
||||
first_image: firstRef,
|
||||
last_image: lastRef,
|
||||
product_images: productRefs,
|
||||
subject_image: firstRef,
|
||||
scene_image: null,
|
||||
product_image: scene.product_image ?? null,
|
||||
product_image: productRefs[0] ?? null,
|
||||
action_image: null,
|
||||
source_ref: sourceUrl ? { kind: "source_video", url: sourceUrl } : null,
|
||||
model,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useEffect, useState, useRef } from "react"
|
||||
import { X, Loader2, Check, Wand2, GripHorizontal } from "lucide-react"
|
||||
import {
|
||||
type Job, type StoryboardScene, type ImageRef,
|
||||
updateStoryboard, resolveImageRefUrl,
|
||||
updateStoryboard, resolveImageRefUrl, uploadStoryboardAsset,
|
||||
} from "@/lib/api"
|
||||
import { toast } from "sonner"
|
||||
|
||||
@@ -41,6 +41,7 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const loadedFormKey = useRef("")
|
||||
const productFileInput = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
// Esc 关闭
|
||||
useEffect(() => {
|
||||
@@ -139,6 +140,31 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU
|
||||
}
|
||||
|
||||
const currentModelLabel = VIDEO_MODELS.find((m) => m.value === videoModel)?.label ?? "Seedance"
|
||||
const productRefs = form.product_images?.length ? form.product_images : form.product_image ? [form.product_image] : []
|
||||
const setProductRefs = (refs: ImageRef[]) => {
|
||||
const next = refs.slice(0, 6)
|
||||
queueSave({ ...form, product_image: next[0] ?? null, product_images: next })
|
||||
}
|
||||
const addProductFiles = async (files: FileList | File[]) => {
|
||||
if (!job) return
|
||||
const room = 6 - productRefs.length
|
||||
if (room <= 0) {
|
||||
toast.error("最多添加 6 张产品参考")
|
||||
return
|
||||
}
|
||||
const imageFiles = Array.from(files).filter((file) => file.type.startsWith("image/")).slice(0, room)
|
||||
if (imageFiles.length === 0) {
|
||||
toast.error("请上传图片文件")
|
||||
return
|
||||
}
|
||||
try {
|
||||
const uploaded = await Promise.all(imageFiles.map((file) => uploadStoryboardAsset(job.id, file)))
|
||||
setProductRefs([...productRefs, ...uploaded])
|
||||
toast.success(`已上传 ${uploaded.length} 张产品参考`)
|
||||
} catch (e) {
|
||||
toast.error("产品图上传失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -193,11 +219,10 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU
|
||||
</div>
|
||||
|
||||
{/* 首尾帧:图片直接参与视频生成 */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{([
|
||||
{ key: "first_image" as const, label: "首帧", placeholder: "默认当前分镜关键帧" },
|
||||
{ key: "last_image" as const, label: "尾帧", placeholder: defaultLastRef ? "默认下一张已选分镜" : "粘贴一张结束画面" },
|
||||
{ key: "product_image" as const, label: "SKG 产品", placeholder: "粘贴产品图 / 包装 / 使用状态" },
|
||||
]).map(({ key, label, placeholder }) => {
|
||||
const fallback = key === "first_image" ? defaultFirstRef : key === "last_image" ? defaultLastRef : null
|
||||
const ref = form[key] ?? fallback
|
||||
@@ -255,9 +280,100 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU
|
||||
})}
|
||||
</div>
|
||||
<div className="rounded-md border border-violet-300/20 bg-violet-500/10 px-3 py-2 text-[11px] leading-relaxed text-violet-100/75">
|
||||
现在按首尾帧出片:首帧默认当前分镜,尾帧默认下一张已选分镜;「SKG 产品」用于锁定产品外观。需要指定素材时,在任意关键帧、产品图或生成图点 📋,再粘贴到对应槽位。
|
||||
现在按首尾帧出片:首帧默认当前分镜,尾帧默认下一张已选分镜;产品参考组用于锁定 SKG 外观。
|
||||
</div>
|
||||
|
||||
<section className="rounded-lg bg-white/[0.04] border border-white/10 p-2.5">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="text-[12px] text-white font-semibold">
|
||||
SKG 产品参考
|
||||
<span className="ml-1.5 text-[10px] font-mono text-white/35">{productRefs.length}/6</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!clipboard) {
|
||||
toast.error("先在产品图、关键帧或生成图上点 📋 复制")
|
||||
return
|
||||
}
|
||||
if (productRefs.length >= 6) {
|
||||
toast.error("最多添加 6 张产品参考")
|
||||
return
|
||||
}
|
||||
setProductRefs([...productRefs, clipboard])
|
||||
toast.success("已添加产品参考")
|
||||
}}
|
||||
disabled={!clipboard || productRefs.length >= 6}
|
||||
className={`rounded px-2 py-1 text-[10.5px] font-medium transition ${
|
||||
clipboard && productRefs.length < 6
|
||||
? "bg-violet-500 text-white hover:bg-violet-400"
|
||||
: "bg-white/[0.04] text-white/30 cursor-not-allowed"
|
||||
}`}
|
||||
title={clipboard ? "把剪贴板图片添加到产品参考组" : "剪贴板为空"}
|
||||
>
|
||||
📋 添加产品角度
|
||||
</button>
|
||||
<button
|
||||
onClick={() => productFileInput.current?.click()}
|
||||
disabled={productRefs.length >= 6}
|
||||
className={`rounded px-2 py-1 text-[10.5px] font-medium transition ${
|
||||
productRefs.length < 6
|
||||
? "bg-white/10 text-white/80 hover:bg-white/20 hover:text-white"
|
||||
: "bg-white/[0.04] text-white/30 cursor-not-allowed"
|
||||
}`}
|
||||
title="从本地上传产品图"
|
||||
>
|
||||
上传产品图
|
||||
</button>
|
||||
<input
|
||||
ref={productFileInput}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const files = e.target.files
|
||||
if (files) void addProductFiles(files)
|
||||
e.currentTarget.value = ""
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = productRefs.length < 6 ? "copy" : "none"
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
if (e.dataTransfer.files?.length) void addProductFiles(e.dataTransfer.files)
|
||||
}}
|
||||
>
|
||||
{productRefs.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-white/15 bg-black/25 px-3 py-4 text-center text-[11px] text-white/30">
|
||||
可添加同一 SKG 产品的不同角度,最多 6 张
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-6 gap-2">
|
||||
{productRefs.map((ref, i) => {
|
||||
const url = resolveImageRefUrl(job.id, ref)
|
||||
return (
|
||||
<div key={`${ref.kind}-${ref.frame_idx}-${ref.element_id ?? ""}-${ref.cutout_id ?? ""}-${i}`} className="relative overflow-hidden rounded-md border border-white/10 bg-white" style={{ aspectRatio: "1/1" }}>
|
||||
{url && <img src={url} alt={`产品参考 ${i + 1}`} className="absolute inset-0 h-full w-full object-contain" />}
|
||||
<div className="absolute left-0 top-0 rounded-br bg-black/70 px-1 text-[9px] font-mono text-white">#{i + 1}</div>
|
||||
<button
|
||||
onClick={() => setProductRefs(productRefs.filter((_, idx) => idx !== i))}
|
||||
className="absolute right-0 top-0 rounded-bl bg-rose-500/85 px-1 text-[10px] text-white hover:bg-rose-400"
|
||||
title="移除这张产品参考"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 改造 brief:明确“借鉴参考 → 变成 SKG 产品视频”,避免直接复刻 */}
|
||||
<section className="rounded-lg bg-white/[0.035] border border-white/10 p-3">
|
||||
<div className="text-[12.5px] font-semibold text-white mb-2">
|
||||
|
||||
@@ -48,7 +48,7 @@ export interface KeyElement {
|
||||
}
|
||||
|
||||
export interface ImageRef {
|
||||
kind: "keyframe" | "cutout"
|
||||
kind: "keyframe" | "cutout" | "asset"
|
||||
frame_idx: number
|
||||
element_id?: string | null
|
||||
cutout_id?: string | null
|
||||
@@ -92,6 +92,9 @@ export function resolveImageRefUrl(jobId: string, ref: ImageRef): string {
|
||||
if (ref.kind === "keyframe") {
|
||||
return effectiveFrameUrl(jobId, { index: ref.frame_idx, cleaned_applied: false })
|
||||
}
|
||||
if (ref.kind === "asset" && ref.element_id) {
|
||||
return `${API_BASE}/jobs/${jobId}/assets/${ref.element_id}.jpg`
|
||||
}
|
||||
if (ref.element_id && ref.cutout_id) {
|
||||
if (ref.cutout_id === ref.element_id) {
|
||||
// legacy v1
|
||||
@@ -102,6 +105,17 @@ export function resolveImageRefUrl(jobId: string, ref: ImageRef): string {
|
||||
return ""
|
||||
}
|
||||
|
||||
export async function uploadStoryboardAsset(jobId: string, file: File): Promise<ImageRef> {
|
||||
const fd = new FormData()
|
||||
fd.append("file", file)
|
||||
const res = await fetch(`${API_BASE}/jobs/${jobId}/assets`, { method: "POST", body: fd })
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => "")
|
||||
throw new Error(`uploadStoryboardAsset ${res.status} ${txt.slice(0, 300)}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export interface KeyFrame {
|
||||
index: number
|
||||
timestamp: number
|
||||
|
||||
Reference in New Issue
Block a user