auto-save 2026-05-13 15:00 (~2)
This commit is contained in:
@@ -1722,6 +1722,19 @@
|
||||
"message": "auto-save 2026-05-13 14:49 (~5)",
|
||||
"hash": "ffffb1e",
|
||||
"files_changed": 5
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-13T14:55:04+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-05-13 14:54 (~2)",
|
||||
"hash": "7a5c07b",
|
||||
"files_changed": 2
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-13T06:57:39Z",
|
||||
"type": "session-heartbeat",
|
||||
"message": "Claude 会话活跃 · 最近命令:claude · 2 项未提交变更 · 最近提交:auto-save 2026-05-13 14:54 (~2)",
|
||||
"files_changed": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"use client"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
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"
|
||||
import { LayoutGrid, ChevronDown, ChevronUp, Sparkle, X } from "lucide-react"
|
||||
import { type Job, type KeyFrame, cutoutUrl, effectiveFrameUrl, hasCutout } from "@/lib/api"
|
||||
|
||||
interface Props {
|
||||
job: Job | null
|
||||
@@ -13,48 +12,13 @@ interface Props {
|
||||
onJobUpdate?: (j: Job) => void
|
||||
}
|
||||
|
||||
const emptyScene = (): StoryboardScene => ({
|
||||
subject: "", product: "", scene: "", action: "", duration: 0, reference_ids: [],
|
||||
})
|
||||
|
||||
export function StoryboardBar({ job, selectedFrames, focusedFrame, onFocusFrame, onJobUpdate }: Props) {
|
||||
export function StoryboardBar({ job, selectedFrames, focusedFrame, onFocusFrame }: Props) {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
useEffect(() => setMounted(true), [])
|
||||
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
|
||||
@@ -75,7 +39,6 @@ export function StoryboardBar({ job, selectedFrames, focusedFrame, onFocusFrame,
|
||||
? job.frames.filter((f) => selectedFrames.has(f.index) && f.timestamp <= focusFrame.timestamp).length
|
||||
: 0
|
||||
const focusElements = focusFrame?.elements ?? []
|
||||
const focusCutCount = focusElements.filter((e) => hasCutout(e)).length
|
||||
|
||||
return (
|
||||
<div className="relative z-20 flex-shrink-0 border-b border-white/5 bg-black/30 backdrop-blur-xl">
|
||||
@@ -235,135 +198,6 @@ export function StoryboardBar({ job, selectedFrames, focusedFrame, onFocusFrame,
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* 旧 form 面板已暂时移除(用户要求只展示提取图) */}
|
||||
{false && focusFrame && (
|
||||
<div style={{ display: "none" }}>
|
||||
{/* 占位防止 saving / form / FieldX 等变量未使用报错 */}
|
||||
<span>{saving}{savedTick}{form.subject}{focusCutCount}</span>
|
||||
<FieldText label="" value="" onChange={() => {}} />
|
||||
<FieldNum label="" value={0} onChange={() => {}} />
|
||||
<FieldTextarea label="" value="" onChange={() => {}} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 旧 form 面板代码(保留 fallback 渲染) */}
|
||||
{false && focusFrame && !collapsed && (
|
||||
<div
|
||||
className="border-t border-white/10 bg-black/20 overflow-y-auto"
|
||||
style={{ maxHeight: "50vh" }}
|
||||
>
|
||||
<div className="flex gap-4 p-4">
|
||||
<div className="flex-shrink-0 flex flex-col gap-1.5" style={{ width: 260 }}>
|
||||
<img
|
||||
src={effectiveFrameUrl(job.id, focusFrame)}
|
||||
alt={`frame ${focusFrame.index}`}
|
||||
className="w-full rounded-lg object-contain bg-black"
|
||||
style={{ maxHeight: "38vh" }}
|
||||
/>
|
||||
<div className="text-[10px] text-white/50 text-center">
|
||||
{focusFrame.cleaned_applied ? "✨ 已清洗版" : "原图"} · 分镜 {focusSeq} · {focusFrame.timestamp.toFixed(2)}s
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右:编排表单 */}
|
||||
<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>
|
||||
<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-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 (
|
||||
<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>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hover 大图预览 · 浮在缩略图下方(不挡其他界面) */}
|
||||
{mounted && hover && (() => {
|
||||
@@ -403,49 +237,3 @@ export function StoryboardBar({ job, selectedFrames, focusedFrame, onFocusFrame,
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user