auto-save 2026-05-13 15:22 (~5)

This commit is contained in:
2026-05-13 15:22:51 +08:00
parent 6390472c27
commit de1254ff10
5 changed files with 167 additions and 39 deletions

View File

@@ -1762,6 +1762,19 @@
"message": "auto-save 2026-05-13 15:11 (~3)", "message": "auto-save 2026-05-13 15:11 (~3)",
"hash": "02df0c5", "hash": "02df0c5",
"files_changed": 3 "files_changed": 3
},
{
"ts": "2026-05-13T15:17:18+08:00",
"type": "commit",
"message": "auto-save 2026-05-13 15:17 (~6)",
"hash": "6390472",
"files_changed": 6
},
{
"ts": "2026-05-13T07:17:40Z",
"type": "session-heartbeat",
"message": "Claude 会话活跃 · 最近命令claude · 1 项未提交变更 · 最近提交auto-save 2026-05-13 15:17 (~6)",
"files_changed": 1
} }
] ]
} }

View File

@@ -15,7 +15,7 @@ import {
import { ThemeToggle } from "@/components/theme-toggle" import { ThemeToggle } from "@/components/theme-toggle"
import { Dashboard, type DashboardHandle } from "@/components/dashboard" import { Dashboard, type DashboardHandle } from "@/components/dashboard"
import { StoryboardBar } from "@/components/storyboard-bar" import { StoryboardBar } from "@/components/storyboard-bar"
import { addManualFrame, analyzeJob, createJob, getJob, uploadJob, deleteFrame, deleteGeneratedImage, type Job } from "@/lib/api" import { addManualFrame, analyzeJob, createJob, getJob, uploadJob, deleteFrame, deleteGeneratedImage, pushStoryboardImage, type Job } from "@/lib/api"
import { VideoLightbox } from "@/components/video-lightbox" import { VideoLightbox } from "@/components/video-lightbox"
const NODE_TYPES = { const NODE_TYPES = {
@@ -191,6 +191,23 @@ export default function Home() {
} }
}, [activeJobId, setJob]) }, [activeJobId, setJob])
const handlePushToStoryboard = useCallback(async (payload: { kind: "keyframe" | "cutout"; frameIdx: number; elementId?: string; cutoutId?: string; label?: string }) => {
if (!activeJobId) return
try {
const updated = await pushStoryboardImage(activeJobId, {
kind: payload.kind,
frame_idx: payload.frameIdx,
element_id: payload.elementId,
cutout_id: payload.cutoutId,
label: payload.label,
})
setJob(updated)
toast.success("已推送到分镜头编排")
} catch (e) {
toast.error("推送失败:" + (e instanceof Error ? e.message : String(e)))
}
}, [activeJobId, setJob])
// URL ?job=xxx,yyy 自动恢复多个 job // URL ?job=xxx,yyy 自动恢复多个 job
useEffect(() => { useEffect(() => {
const params = new URLSearchParams(window.location.search) const params = new URLSearchParams(window.location.search)
@@ -261,7 +278,8 @@ export default function Home() {
onDeleteFrame: handleDeleteFrame, onDeleteFrame: handleDeleteFrame,
onDeleteGenerated: handleDeleteGenerated, onDeleteGenerated: handleDeleteGenerated,
onOpenStoryboard: (idx: number) => setStoryboardFrame(idx), onOpenStoryboard: (idx: number) => setStoryboardFrame(idx),
}), [job, jobs, activeJobId, submitting, analyzing, selectedFrames, expandedFrame, handleSubmit, handleUpload, handleAnalyze, handleToggleFrame, handleAddManualFrame, handleSwitchJob, setJob, handleDeleteFrame, handleDeleteGenerated]) onPushToStoryboard: handlePushToStoryboard,
}), [job, jobs, activeJobId, submitting, analyzing, selectedFrames, expandedFrame, handleSubmit, handleUpload, handleAnalyze, handleToggleFrame, handleAddManualFrame, handleSwitchJob, setJob, handleDeleteFrame, handleDeleteGenerated, handlePushToStoryboard])
// 用 useNodesState 让 ReactFlow 自己管位置(避免轮询时重置 drag // 用 useNodesState 让 ReactFlow 自己管位置(避免轮询时重置 drag
const [nodes, setNodes, onNodesChange] = useNodesState<Node>( const [nodes, setNodes, onNodesChange] = useNodesState<Node>(

View File

@@ -5,6 +5,7 @@ import { X, ChevronLeft, ChevronRight, Check, Sparkles, Wand2, Loader2, Eye, Ref
import { import {
frameUrl, cleanedFrameUrl, cutoutUrl, frameUrl, cleanedFrameUrl, cutoutUrl,
describeFrame, cleanupFrame, applyCleanedFrame, discardCleanedFrame, addElement, deleteElement, cutoutElement, deleteCutout, describeFrame, cleanupFrame, applyCleanedFrame, discardCleanedFrame, addElement, deleteElement, cutoutElement, deleteCutout,
pushStoryboardImage,
type KeyFrame, type Job, type KeyFrame, type Job,
} from "@/lib/api" } from "@/lib/api"
import { toast } from "sonner" import { toast } from "sonner"
@@ -233,6 +234,23 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
} }
} }
const handlePushCutout = async (elementId: string, cutoutId: string, label: string, isLegacy: boolean) => {
if (activeIndex === null) return
try {
const updated = await pushStoryboardImage(jobId, {
kind: "cutout",
frame_idx: activeIndex,
element_id: elementId,
cutout_id: isLegacy ? elementId : cutoutId, // legacy 兼容
label,
})
onJobUpdate?.(updated)
toast.success("已推送到分镜头编排")
} catch (e) {
toast.error("推送失败:" + (e instanceof Error ? e.message : String(e)))
}
}
const handleDeleteCutout = async (elementId: string, cutoutId: string) => { const handleDeleteCutout = async (elementId: string, cutoutId: string) => {
try { try {
const updated = await deleteCutout(jobId, f.index, elementId, cutoutId) const updated = await deleteCutout(jobId, f.index, elementId, cutoutId)
@@ -696,6 +714,18 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
<div className="absolute bottom-0 left-0 text-[8.5px] font-mono text-white bg-black/70 backdrop-blur px-1 rounded-tr"> <div className="absolute bottom-0 left-0 text-[8.5px] font-mono text-white bg-black/70 backdrop-blur px-1 rounded-tr">
#{ci + 1} #{ci + 1}
</div> </div>
{/* 上推按钮:左上角 */}
<button
onClick={(ev) => {
ev.preventDefault(); ev.stopPropagation()
const isLegacy = !(e.cutouts && e.cutouts.length > 0)
handlePushCutout(e.id, cid, `${e.name_zh} #${ci + 1}`, isLegacy)
}}
className="absolute left-0.5 top-0.5 h-4 w-4 rounded-sm bg-black/70 text-white/85 hover:bg-violet-500 hover:text-white inline-flex items-center justify-center opacity-0 group-hover/img:opacity-100 transition text-[11px] leading-none font-bold"
title="⬆ 上推到分镜头编排"
>
</button>
{/* 删除该张 — 仅 v2 多图支持,老 fallback 不显示 */} {/* 删除该张 — 仅 v2 多图支持,老 fallback 不显示 */}
{e.cutouts && e.cutouts.length > 0 && ( {e.cutouts && e.cutouts.length > 0 && (
<button <button

View File

@@ -31,6 +31,7 @@ export interface NodeData {
onDeleteFrame?: (idx: number) => void // 删整张关键帧 onDeleteFrame?: (idx: number) => void // 删整张关键帧
onDeleteGenerated?: (frameIdx: number, genId: string) => void // 删单张生成图 onDeleteGenerated?: (frameIdx: number, genId: string) => void // 删单张生成图
onOpenStoryboard?: (frameIdx: number) => void // 打开分镜头编排专属面板 onOpenStoryboard?: (frameIdx: number) => void // 打开分镜头编排专属面板
onPushToStoryboard?: (payload: { kind: "keyframe" | "cutout"; frameIdx: number; elementId?: string; cutoutId?: string; label?: string }) => void
} }
/* ---- 状态映射工具 ---- */ /* ---- 状态映射工具 ---- */
@@ -408,6 +409,23 @@ export function KeyframeNode({ data, selected }: any) {
{f.timestamp.toFixed(1)}s {f.timestamp.toFixed(1)}s
</div> </div>
</button> </button>
{/* 上推按钮hover 时浮出 — 推送关键帧本身到分镜头编排 */}
{d.onPushToStoryboard && (
<button
onClick={(e) => {
e.stopPropagation()
d.onPushToStoryboard?.({
kind: "keyframe",
frameIdx: f.index,
label: `分镜 ${f.index + 1} 关键帧`,
})
}}
title="⬆ 上推到分镜头编排"
className="absolute top-1 left-1 h-5 w-5 rounded-full bg-black/70 backdrop-blur text-white/85 hover:bg-violet-500 hover:text-white inline-flex items-center justify-center opacity-0 group-hover:opacity-100 transition z-[70] text-[12px] leading-none font-bold"
>
</button>
)}
{/* 删除按钮hover 时右上角浮出 */} {/* 删除按钮hover 时右上角浮出 */}
{d.onDeleteFrame && ( {d.onDeleteFrame && (
<button <button
@@ -607,14 +625,17 @@ export function ImageGenNode({ data, selected }: any) {
useEffect(() => setMounted(true), []) useEffect(() => setMounted(true), [])
// 上方浮条 = 所有 frame 的 elements 已提取图("分镜头编排"的输入素材) // 上方浮条 = 所有 frame 的 elements 已提取图("分镜头编排"的输入素材)
type ElPreview = { frameIdx: number; elementId: string; name: string; src: string } type ElPreview = { frameIdx: number; elementId: string; name: string; src: string; cid: string }
const elementCrops: ElPreview[] = job const elementCrops: ElPreview[] = job
? job.frames.flatMap((f) => ? job.frames.flatMap((f) =>
(f.elements ?? []) (f.elements ?? [])
.filter((e) => hasCutout(e)) .filter((e) => hasCutout(e))
.map((e) => { .map((e) => {
const src = representativeCutoutUrl(job.id, f.index, e) || "" const src = representativeCutoutUrl(job.id, f.index, e) || ""
return { frameIdx: f.index, elementId: e.id, name: e.name_zh, src } const cid = (e.cutouts && e.cutouts.length > 0)
? e.cutouts[e.cutouts.length - 1]
: (e.cutout_id ?? "")
return { frameIdx: f.index, elementId: e.id, name: e.name_zh, src, cid }
}) })
.filter((p) => p.src), .filter((p) => p.src),
) )
@@ -637,14 +658,14 @@ export function ImageGenNode({ data, selected }: any) {
return ( return (
<div <div
key={key} key={key}
className="relative rounded-md border border-violet-300/50 transition shadow-lg hover:-translate-y-0.5 bg-black/40 overflow-hidden" className="group relative rounded-md border border-violet-300/50 transition shadow-lg hover:-translate-y-0.5 bg-white overflow-hidden"
style={{ aspectRatio: aspect }} style={{ aspectRatio: aspect }}
> >
<button <button
onClick={(e) => { e.stopPropagation(); d.onOpenStoryboard?.(p.frameIdx) }} onClick={(e) => { e.stopPropagation(); d.onOpenStoryboard?.(p.frameIdx) }}
onMouseEnter={(e) => setHover({ key, rect: (e.currentTarget as HTMLElement).getBoundingClientRect() })} onMouseEnter={(e) => setHover({ key, rect: (e.currentTarget as HTMLElement).getBoundingClientRect() })}
onMouseLeave={() => setHover(null)} onMouseLeave={() => setHover(null)}
title={`${p.name} · 来自分镜 ${p.frameIdx + 1} · hover 看大图 · 点击进入分镜头编排`} title={`${p.name} · 来自分镜 ${p.frameIdx + 1} · hover 看大图`}
className="absolute inset-0 w-full h-full" className="absolute inset-0 w-full h-full"
> >
<img <img
@@ -653,6 +674,25 @@ export function ImageGenNode({ data, selected }: any) {
className="absolute inset-0 w-full h-full object-contain" className="absolute inset-0 w-full h-full object-contain"
/> />
</button> </button>
{/* 上推按钮hover 时浮出 */}
{d.onPushToStoryboard && (
<button
onClick={(e) => {
e.stopPropagation()
d.onPushToStoryboard?.({
kind: "cutout",
frameIdx: p.frameIdx,
elementId: p.elementId,
cutoutId: p.cid,
label: p.name,
})
}}
title="⬆ 上推到分镜头编排"
className="absolute top-1 left-1 h-5 w-5 rounded-full bg-black/70 backdrop-blur text-white/85 hover:bg-violet-500 hover:text-white inline-flex items-center justify-center opacity-0 group-hover:opacity-100 transition z-[70] text-[12px] leading-none font-bold"
>
</button>
)}
</div> </div>
) )
})} })}

View File

@@ -145,44 +145,71 @@ export function StoryboardBar({ job, selectedFrames, focusedFrame, onFocusFrame,
) )
)} )}
{/* 所有提取图(按分镜顺序展平) */} {/* 已推送到分镜头编排区的图片 */}
{!collapsed && allShots.length > 0 && ( {!collapsed && (
<div className="border-t border-white/10 px-4 py-2 bg-black/10"> <div className="border-t border-white/10 px-4 py-2 bg-black/10">
<div className="text-[10px] text-white/45 mb-1.5 inline-flex items-center gap-1"> <div className="text-[10px] text-white/45 mb-1.5 inline-flex items-center gap-1">
<Sparkle className="h-2.5 w-2.5 text-violet-300" /> <Sparkle className="h-2.5 w-2.5 text-violet-300" />
· {allShots.length} · {pushedImages.length}
<span className="text-white/30 ml-1"> </span>
</div> </div>
<div className="flex gap-1.5 overflow-x-auto pb-1"> {pushedImages.length === 0 ? (
{allShots.map((s) => { <div className="rounded-md border border-dashed border-white/15 p-2.5 text-[10.5px] text-white/35">
const url = s.isLegacy · / /
? cutoutUrl(job.id, s.frameIdx, s.elementId) </div>
: cutoutUrl(job.id, s.frameIdx, s.elementId, s.cid) ) : (
const isFocusFrame = focusedFrame === s.frameIdx <div className="flex gap-1.5 overflow-x-auto pb-1">
return ( {pushedImages.map((p) => {
<button const seq = frameSeqByIdx[p.frame_idx] ?? p.frame_idx + 1
key={`${s.frameIdx}_${s.elementId}_${s.cid}`} const url = p.kind === "keyframe"
onClick={() => onFocusFrame(s.frameIdx)} ? effectiveFrameUrl(job.id, { index: p.frame_idx, cleaned_applied: false })
title={`${s.elementName} · 来自分镜 #${s.seq} · 点击聚焦该分镜`} : (p.element_id && p.cutout_id
className={`relative shrink-0 rounded-md overflow-hidden border bg-white transition hover:-translate-y-0.5 ${ ? (p.cutout_id === p.element_id
isFocusFrame ? cutoutUrl(job.id, p.frame_idx, p.element_id) // legacy
? "border-violet-300 ring-2 ring-violet-300/60" : cutoutUrl(job.id, p.frame_idx, p.element_id, p.cutout_id))
: "border-white/15 hover:border-violet-300/50" : "")
}`} const isFocusFrame = focusedFrame === p.frame_idx
style={{ width: 80, height: 80 }} return (
> <div
<img src={url} alt={s.elementName} className="absolute inset-0 w-full h-full object-contain" /> key={p.ref_id}
{/* 左上:来自分镜号 */} className={`group/p relative shrink-0 rounded-md overflow-hidden border bg-white transition hover:-translate-y-0.5 ${
<div className="absolute top-0.5 left-0.5 text-[8.5px] font-bold text-white bg-violet-500/85 backdrop-blur px-1 py-0.5 rounded leading-none"> isFocusFrame
#{s.seq} ? "border-violet-300 ring-2 ring-violet-300/60"
: "border-white/15 hover:border-violet-300/50"
}`}
style={{ width: 88, height: 88 }}
>
<button
onClick={() => onFocusFrame(p.frame_idx)}
title={`${p.label || p.kind} · 来自分镜 #${seq} · 点击聚焦该分镜`}
className="absolute inset-0 w-full h-full"
>
{url && <img src={url} alt={p.label} className="absolute inset-0 w-full h-full object-contain" />}
<div className="absolute top-0.5 left-0.5 text-[8.5px] font-bold text-white bg-violet-500/85 backdrop-blur px-1 py-0.5 rounded leading-none">
#{seq}
</div>
{p.kind === "keyframe" && (
<div className="absolute top-0.5 right-5 text-[8.5px] text-white bg-amber-500/85 backdrop-blur px-1 py-0.5 rounded leading-none">
KF
</div>
)}
<div className="absolute bottom-0 left-0 right-0 px-1 py-0.5 text-[8.5px] text-white bg-black/70 truncate leading-tight">
{p.label || (p.kind === "keyframe" ? "关键帧" : "元素")}
</div>
</button>
{/* 右上移除按钮hover 显示) */}
<button
onClick={(ev) => { ev.stopPropagation(); handleRemovePushed(p.ref_id) }}
title="从分镜头编排移除"
className="absolute top-0.5 right-0.5 h-4 w-4 rounded-sm bg-black/70 text-white/80 hover:bg-rose-500 hover:text-white inline-flex items-center justify-center opacity-0 group-hover/p:opacity-100 transition"
>
<X className="h-2.5 w-2.5" />
</button>
</div> </div>
{/* 底部:元素名 */} )
<div className="absolute bottom-0 left-0 right-0 px-1 py-0.5 text-[8.5px] text-white bg-black/70 truncate leading-tight"> })}
{s.elementName} </div>
</div> )}
</button>
)
})}
</div>
</div> </div>
)} )}