auto-save 2026-05-13 14:49 (~5)
This commit is contained in:
@@ -1702,6 +1702,19 @@
|
||||
"message": "auto-save 2026-05-13 14:38 (~4)",
|
||||
"hash": "9421836",
|
||||
"files_changed": 4
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-13T14:44:00+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-05-13 14:43 (~6)",
|
||||
"hash": "59f6c16",
|
||||
"files_changed": 6
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-13T06:47:39Z",
|
||||
"type": "session-heartbeat",
|
||||
"message": "Claude 会话活跃 · 最近命令:claude · 2 项未提交变更 · 最近提交:auto-save 2026-05-13 14:43 (~6)",
|
||||
"files_changed": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
45
api/main.py
45
api/main.py
@@ -63,6 +63,16 @@ class GeneratedImage(BaseModel):
|
||||
created_at: float = 0.0
|
||||
|
||||
|
||||
class StoryboardScene(BaseModel):
|
||||
"""分镜头编排:每个 selected 分镜对应一个 scene 描述"""
|
||||
subject: str = "" # 主体(如:戴头带的骨架人)
|
||||
product: str = "" # 产品(如:Goli 营养软糖)
|
||||
scene: str = "" # 场景(如:药店柜台)
|
||||
action: str = "" # 在干什么(如:递给顾客一瓶软糖)
|
||||
duration: float = 0 # 视频片段时长(秒)
|
||||
reference_ids: list[str] = [] # 参考图:选用该分镜里已提取的 element ids 作 reference
|
||||
|
||||
|
||||
class KeyElement(BaseModel):
|
||||
"""关键帧里识别 / 用户提取的元素 · 多次提取累积多张图,让用户挑选满意的"""
|
||||
id: str # uuid hex 8
|
||||
@@ -87,6 +97,7 @@ class KeyFrame(BaseModel):
|
||||
cleaned_url: str | None = None # 清洗后干净版(待应用)→ /jobs/{id}/frames/{idx}/cleaned.jpg
|
||||
cleaned_applied: bool = False # 是否已用清洗版替换原图(替换后 cleaned_url=null)
|
||||
elements: list[KeyElement] = [] # 提取的元素清单(持久化)
|
||||
storyboard: StoryboardScene | None = None # 分镜头编排字段
|
||||
generated_images: list[GeneratedImage] = []
|
||||
|
||||
|
||||
@@ -1389,6 +1400,40 @@ def delete_cutout(job_id: str, idx: int, element_id: str, cutout_id: str) -> Job
|
||||
return job
|
||||
|
||||
|
||||
class UpdateStoryboardReq(BaseModel):
|
||||
subject: str = ""
|
||||
product: str = ""
|
||||
scene: str = ""
|
||||
action: str = ""
|
||||
duration: float = 0
|
||||
reference_ids: list[str] = []
|
||||
|
||||
|
||||
@app.put("/jobs/{job_id}/frames/{idx}/storyboard", response_model=Job)
|
||||
def update_storyboard(job_id: str, idx: int, req: UpdateStoryboardReq) -> Job:
|
||||
"""更新分镜的编排字段(subject / product / scene / action / duration / reference_ids)"""
|
||||
job = JOBS.get(job_id)
|
||||
if not job:
|
||||
raise HTTPException(404, "job not found")
|
||||
frame = next((f for f in job.frames if f.index == idx), None)
|
||||
if not frame:
|
||||
raise HTTPException(404, "frame not found")
|
||||
new_frames = []
|
||||
for f in job.frames:
|
||||
if f.index == idx:
|
||||
f.storyboard = StoryboardScene(
|
||||
subject=req.subject.strip(),
|
||||
product=req.product.strip(),
|
||||
scene=req.scene.strip(),
|
||||
action=req.action.strip(),
|
||||
duration=max(0.0, float(req.duration)),
|
||||
reference_ids=list(req.reference_ids),
|
||||
)
|
||||
new_frames.append(f)
|
||||
update(job, frames=new_frames, message=f"分镜 {idx + 1} 编排已更新")
|
||||
return job
|
||||
|
||||
|
||||
@app.get("/jobs/{job_id}/frames/{idx}/elements/{element_id}/cutouts/{cutout_id}.jpg")
|
||||
def get_cutout_versioned(job_id: str, idx: int, element_id: str, cutout_id: str):
|
||||
p = job_dir(job_id) / "elements" / f"{idx:03d}_{element_id}_{cutout_id}.jpg"
|
||||
|
||||
@@ -327,6 +327,7 @@ export default function Home() {
|
||||
selectedFrames={selectedFrames}
|
||||
focusedFrame={storyboardFrame}
|
||||
onFocusFrame={setStoryboardFrame}
|
||||
onJobUpdate={setJob as any}
|
||||
/>
|
||||
<div className="relative flex-1 min-h-0">
|
||||
<ReactFlow
|
||||
|
||||
@@ -1,24 +1,60 @@
|
||||
"use client"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
import { LayoutGrid, ChevronDown, ChevronUp, Sparkle, X, Wand2, Brush } from "lucide-react"
|
||||
import { type Job, type KeyFrame, effectiveFrameUrl, hasCutout, representativeCutoutUrl } from "@/lib/api"
|
||||
import { LayoutGrid, ChevronDown, ChevronUp, Sparkle, X, Loader2, Check } from "lucide-react"
|
||||
import { type Job, type KeyFrame, type StoryboardScene, effectiveFrameUrl, hasCutout, representativeCutoutUrl, updateStoryboard } from "@/lib/api"
|
||||
import { toast } from "sonner"
|
||||
|
||||
interface Props {
|
||||
job: Job | null
|
||||
selectedFrames: Set<number>
|
||||
focusedFrame: number | null // 当前 focus 的分镜(imagegen 节点 / bar 缩略图点击触发)
|
||||
focusedFrame: number | null
|
||||
onFocusFrame: (idx: number | null) => void
|
||||
onJobUpdate?: (j: Job) => void
|
||||
}
|
||||
|
||||
export function StoryboardBar({ job, selectedFrames, focusedFrame, onFocusFrame }: Props) {
|
||||
const emptyScene = (): StoryboardScene => ({
|
||||
subject: "", product: "", scene: "", action: "", duration: 0, reference_ids: [],
|
||||
})
|
||||
|
||||
export function StoryboardBar({ job, selectedFrames, focusedFrame, onFocusFrame, onJobUpdate }: Props) {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
useEffect(() => setMounted(true), [])
|
||||
// hover preview state — portal 渲染到 body 避免被父级 overflow-x-auto clip
|
||||
const [hover, setHover] = useState<{ frame: KeyFrame; seq: number; rect: DOMRect } | null>(null)
|
||||
const btnRefs = useRef<Record<number, HTMLButtonElement | null>>({})
|
||||
|
||||
// 表单 state:每次切到新 focus frame 加载该帧的 storyboard
|
||||
const [form, setForm] = useState<StoryboardScene>(emptyScene())
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [savedTick, setSavedTick] = useState(0)
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!job || focusedFrame === null) return
|
||||
const f = job.frames.find((x) => x.index === focusedFrame)
|
||||
setForm(f?.storyboard ? { ...emptyScene(), ...f.storyboard } : emptyScene())
|
||||
}, [focusedFrame, job?.id])
|
||||
|
||||
// 自动保存:表单变化 600ms 后调 API
|
||||
const queueSave = (next: StoryboardScene) => {
|
||||
setForm(next)
|
||||
if (!job || focusedFrame === null) return
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current)
|
||||
saveTimer.current = setTimeout(async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const updated = await updateStoryboard(job.id, focusedFrame, next)
|
||||
onJobUpdate?.(updated)
|
||||
setSavedTick((t) => t + 1)
|
||||
} catch (e) {
|
||||
toast.error("保存失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, 600)
|
||||
}
|
||||
|
||||
if (!job) return null
|
||||
|
||||
const frames = job.frames
|
||||
@@ -163,64 +199,101 @@ export function StoryboardBar({ job, selectedFrames, focusedFrame, onFocusFrame
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右:元素 + Phase 2 操作 */}
|
||||
<div className="flex-1 min-w-0 space-y-3">
|
||||
<section>
|
||||
<div className="text-[12px] font-semibold text-white mb-1.5 flex items-center gap-1.5">
|
||||
<Sparkle className="h-3.5 w-3.5 text-violet-300" />
|
||||
该分镜元素
|
||||
<span className="text-[10px] text-white/40 font-mono">· {focusCutCount}/{focusElements.length} 已裁切</span>
|
||||
{/* 右:编排表单 */}
|
||||
<div className="flex-1 min-w-0 space-y-2.5">
|
||||
{/* 保存状态 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-[12px] font-semibold text-white flex items-center gap-1.5">
|
||||
<LayoutGrid className="h-3.5 w-3.5 text-violet-300" />
|
||||
分镜编排参数
|
||||
</div>
|
||||
{focusElements.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-white/15 p-2.5 text-[11px] text-white/40">
|
||||
暂无元素 · 到关键帧节点画框提取
|
||||
<span className="text-[10px] text-white/40 font-mono inline-flex items-center gap-1">
|
||||
{saving ? (<><Loader2 className="h-2.5 w-2.5 animate-spin" />保存中…</>)
|
||||
: savedTick > 0 ? (<><Check className="h-2.5 w-2.5 text-emerald-300" />已自动保存</>)
|
||||
: "字段变更自动保存"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 5 个字段 — 2×2 grid + 跨列的 action */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<FieldText
|
||||
label="主体" placeholder="如:戴头带的骨架人"
|
||||
value={form.subject}
|
||||
onChange={(v) => queueSave({ ...form, subject: v })}
|
||||
/>
|
||||
<FieldText
|
||||
label="产品" placeholder="如:Goli 营养软糖"
|
||||
value={form.product}
|
||||
onChange={(v) => queueSave({ ...form, product: v })}
|
||||
/>
|
||||
<FieldText
|
||||
label="场景" placeholder="如:药店柜台 / 夜晚卧室"
|
||||
value={form.scene}
|
||||
onChange={(v) => queueSave({ ...form, scene: v })}
|
||||
/>
|
||||
<FieldNum
|
||||
label="时长 (秒)" placeholder="3.5"
|
||||
value={form.duration}
|
||||
onChange={(v) => queueSave({ ...form, duration: v })}
|
||||
/>
|
||||
</div>
|
||||
<FieldTextarea
|
||||
label="在干什么"
|
||||
placeholder="如:骨架人递给顾客一瓶 Goli · 顾客接过并查看 · 灯光柔和"
|
||||
value={form.action}
|
||||
onChange={(v) => queueSave({ ...form, action: v })}
|
||||
rows={2}
|
||||
/>
|
||||
|
||||
{/* 参考图区 — 多选该分镜已提取元素 */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[10.5px] text-white/55 font-medium">参考图(多选)</span>
|
||||
<span className="text-[9.5px] text-white/35 font-mono">
|
||||
选用 {form.reference_ids.length} / 可选 {focusElements.filter((e) => hasCutout(e)).length}
|
||||
</span>
|
||||
</div>
|
||||
{focusElements.filter((e) => hasCutout(e)).length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-white/15 p-2.5 text-[10.5px] text-white/40">
|
||||
该分镜暂无可选参考图 · 到关键帧节点画框「AI 提取」后会出现
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-5 gap-1.5">
|
||||
{focusElements.map((e) => {
|
||||
<div className="grid grid-cols-6 gap-1.5">
|
||||
{focusElements.filter((e) => hasCutout(e)).map((e) => {
|
||||
const src = representativeCutoutUrl(job.id, focusFrame.index, e)
|
||||
const checked = form.reference_ids.includes(e.id)
|
||||
return (
|
||||
<div key={e.id} className="rounded-md bg-white/[0.04] border border-white/10 p-1.5">
|
||||
<div className="w-full aspect-square rounded bg-white overflow-hidden mb-1">
|
||||
{src ? (
|
||||
<img src={src} alt={e.name_zh} className="w-full h-full object-contain" />
|
||||
) : (
|
||||
<div className="w-full h-full inline-flex items-center justify-center bg-black/40">
|
||||
<Sparkle className="h-3.5 w-3.5 text-white/20" />
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
key={e.id}
|
||||
onClick={() => {
|
||||
const next = checked
|
||||
? form.reference_ids.filter((x) => x !== e.id)
|
||||
: [...form.reference_ids, e.id]
|
||||
queueSave({ ...form, reference_ids: next })
|
||||
}}
|
||||
title={e.name_zh}
|
||||
className={`relative rounded-md overflow-hidden border-2 transition ${
|
||||
checked
|
||||
? "border-emerald-400 ring-2 ring-emerald-400/40"
|
||||
: "border-white/15 hover:border-white/40"
|
||||
}`}
|
||||
style={{ aspectRatio: "1/1" }}
|
||||
>
|
||||
{src && <img src={src} alt={e.name_zh} className="absolute inset-0 w-full h-full object-contain bg-white" />}
|
||||
{checked && (
|
||||
<div className="absolute top-0.5 right-0.5 h-4 w-4 rounded-full bg-emerald-500 text-white inline-flex items-center justify-center">
|
||||
<Check className="h-2.5 w-2.5" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute bottom-0 left-0 right-0 px-1 py-0.5 text-[8.5px] text-white bg-black/70 truncate">
|
||||
{e.name_zh}
|
||||
</div>
|
||||
<div className="text-[10px] text-white truncate">{e.name_zh}</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Phase 2 操作占位 */}
|
||||
<section className="rounded-lg border border-dashed border-violet-300/30 bg-violet-500/5 p-2.5">
|
||||
<div className="text-[11.5px] font-semibold text-white mb-1.5 flex items-center gap-1.5">
|
||||
<Wand2 className="h-3 w-3 text-violet-300" />
|
||||
编排操作 · Phase 2 待实施
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
<button disabled className="rounded-md bg-white/[0.04] border border-white/10 p-2 text-left disabled:opacity-50 cursor-not-allowed">
|
||||
<div className="text-[11px] font-medium text-white mb-0.5">📐 多视角</div>
|
||||
<div className="text-[9px] text-white/45 leading-tight">侧面 / 仰视 / 俯视</div>
|
||||
</button>
|
||||
<button disabled className="rounded-md bg-white/[0.04] border border-white/10 p-2 text-left disabled:opacity-50 cursor-not-allowed">
|
||||
<div className="text-[11px] font-medium text-white mb-0.5 inline-flex items-center gap-1">
|
||||
<Brush className="h-3 w-3" /> 风格融合
|
||||
</div>
|
||||
<div className="text-[9px] text-white/45 leading-tight">元素 + 自由风格</div>
|
||||
</button>
|
||||
<button disabled className="rounded-md bg-white/[0.04] border border-white/10 p-2 text-left disabled:opacity-50 cursor-not-allowed">
|
||||
<div className="text-[11px] font-medium text-white mb-0.5">🎬 布局调整</div>
|
||||
<div className="text-[9px] text-white/45 leading-tight">拖动元素 / 重新组合</div>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -263,3 +336,50 @@ export function StoryboardBar({ job, selectedFrames, focusedFrame, onFocusFrame
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldText({ label, value, onChange, placeholder }: { label: string; value: string; onChange: (v: string) => void; placeholder?: string }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<div className="text-[10px] text-white/55 mb-1">{label}</div>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="w-full text-[12px] px-2 py-1.5 rounded-md bg-black/40 border border-white/15 outline-none text-white placeholder:text-white/30 focus:ring-2 focus:ring-violet-400/40 focus:border-violet-400/40"
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldNum({ label, value, onChange, placeholder }: { label: string; value: number; onChange: (v: number) => void; placeholder?: string }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<div className="text-[10px] text-white/55 mb-1">{label}</div>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
value={value || ""}
|
||||
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
|
||||
placeholder={placeholder}
|
||||
className="w-full text-[12px] px-2 py-1.5 rounded-md bg-black/40 border border-white/15 outline-none text-white placeholder:text-white/30 focus:ring-2 focus:ring-violet-400/40 focus:border-violet-400/40"
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldTextarea({ label, value, onChange, placeholder, rows = 2 }: { label: string; value: string; onChange: (v: string) => void; placeholder?: string; rows?: number }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<div className="text-[10px] text-white/55 mb-1">{label}</div>
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
className="w-full text-[12px] px-2 py-1.5 rounded-md bg-black/40 border border-white/15 outline-none text-white placeholder:text-white/30 focus:ring-2 focus:ring-violet-400/40 focus:border-violet-400/40 resize-none"
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -47,6 +47,15 @@ export interface KeyElement {
|
||||
created_at?: number
|
||||
}
|
||||
|
||||
export interface StoryboardScene {
|
||||
subject: string
|
||||
product: string
|
||||
scene: string
|
||||
action: string
|
||||
duration: number
|
||||
reference_ids: string[]
|
||||
}
|
||||
|
||||
export interface KeyFrame {
|
||||
index: number
|
||||
timestamp: number
|
||||
@@ -55,6 +64,7 @@ export interface KeyFrame {
|
||||
cleaned_url?: string | null
|
||||
cleaned_applied?: boolean
|
||||
elements?: KeyElement[]
|
||||
storyboard?: StoryboardScene | null
|
||||
generated_images?: GeneratedImage[]
|
||||
}
|
||||
|
||||
@@ -238,6 +248,23 @@ export function representativeCutoutUrl(
|
||||
return null
|
||||
}
|
||||
|
||||
export async function updateStoryboard(
|
||||
jobId: string,
|
||||
frameIdx: number,
|
||||
body: StoryboardScene,
|
||||
): Promise<Job> {
|
||||
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/storyboard`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => "")
|
||||
throw new Error(`updateStoryboard ${res.status} ${txt.slice(0, 300)}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function deleteCutout(jobId: string, frameIdx: number, elementId: string, cutoutId: string): Promise<Job> {
|
||||
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/elements/${elementId}/cutouts/${cutoutId}`, {
|
||||
method: "DELETE",
|
||||
|
||||
Reference in New Issue
Block a user