auto-save 2026-05-13 14:49 (~5)

This commit is contained in:
2026-05-13 14:49:32 +08:00
parent 59f6c16225
commit ffffb1e19c
5 changed files with 258 additions and 52 deletions

View File

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

View File

@@ -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"

View File

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

View File

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

View File

@@ -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",