auto-save 2026-05-13 15:22 (~5)
This commit is contained in:
@@ -1762,6 +1762,19 @@
|
||||
"message": "auto-save 2026-05-13 15:11 (~3)",
|
||||
"hash": "02df0c5",
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import { ThemeToggle } from "@/components/theme-toggle"
|
||||
import { Dashboard, type DashboardHandle } from "@/components/dashboard"
|
||||
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"
|
||||
|
||||
const NODE_TYPES = {
|
||||
@@ -191,6 +191,23 @@ export default function Home() {
|
||||
}
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
@@ -261,7 +278,8 @@ export default function Home() {
|
||||
onDeleteFrame: handleDeleteFrame,
|
||||
onDeleteGenerated: handleDeleteGenerated,
|
||||
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)
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState<Node>(
|
||||
|
||||
@@ -5,6 +5,7 @@ import { X, ChevronLeft, ChevronRight, Check, Sparkles, Wand2, Loader2, Eye, Ref
|
||||
import {
|
||||
frameUrl, cleanedFrameUrl, cutoutUrl,
|
||||
describeFrame, cleanupFrame, applyCleanedFrame, discardCleanedFrame, addElement, deleteElement, cutoutElement, deleteCutout,
|
||||
pushStoryboardImage,
|
||||
type KeyFrame, type Job,
|
||||
} from "@/lib/api"
|
||||
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) => {
|
||||
try {
|
||||
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">
|
||||
#{ci + 1}
|
||||
</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 不显示 */}
|
||||
{e.cutouts && e.cutouts.length > 0 && (
|
||||
<button
|
||||
|
||||
@@ -31,6 +31,7 @@ export interface NodeData {
|
||||
onDeleteFrame?: (idx: number) => void // 删整张关键帧
|
||||
onDeleteGenerated?: (frameIdx: number, genId: string) => 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
|
||||
</div>
|
||||
</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 时右上角浮出 */}
|
||||
{d.onDeleteFrame && (
|
||||
<button
|
||||
@@ -607,14 +625,17 @@ export function ImageGenNode({ data, selected }: any) {
|
||||
useEffect(() => setMounted(true), [])
|
||||
|
||||
// 上方浮条 = 所有 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
|
||||
? job.frames.flatMap((f) =>
|
||||
(f.elements ?? [])
|
||||
.filter((e) => hasCutout(e))
|
||||
.map((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),
|
||||
)
|
||||
@@ -637,14 +658,14 @@ export function ImageGenNode({ data, selected }: any) {
|
||||
return (
|
||||
<div
|
||||
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 }}
|
||||
>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); d.onOpenStoryboard?.(p.frameIdx) }}
|
||||
onMouseEnter={(e) => setHover({ key, rect: (e.currentTarget as HTMLElement).getBoundingClientRect() })}
|
||||
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"
|
||||
>
|
||||
<img
|
||||
@@ -653,6 +674,25 @@ export function ImageGenNode({ data, selected }: any) {
|
||||
className="absolute inset-0 w-full h-full object-contain"
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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="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" />
|
||||
分镜素材 · {allShots.length} 张提取图
|
||||
已推送素材 · {pushedImages.length} 张
|
||||
<span className="text-white/30 ml-1">(在各处图片右上角点 ⬆ 推送过来)</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5 overflow-x-auto pb-1">
|
||||
{allShots.map((s) => {
|
||||
const url = s.isLegacy
|
||||
? cutoutUrl(job.id, s.frameIdx, s.elementId)
|
||||
: cutoutUrl(job.id, s.frameIdx, s.elementId, s.cid)
|
||||
const isFocusFrame = focusedFrame === s.frameIdx
|
||||
return (
|
||||
<button
|
||||
key={`${s.frameIdx}_${s.elementId}_${s.cid}`}
|
||||
onClick={() => onFocusFrame(s.frameIdx)}
|
||||
title={`${s.elementName} · 来自分镜 #${s.seq} · 点击聚焦该分镜`}
|
||||
className={`relative shrink-0 rounded-md overflow-hidden border bg-white transition hover:-translate-y-0.5 ${
|
||||
isFocusFrame
|
||||
? "border-violet-300 ring-2 ring-violet-300/60"
|
||||
: "border-white/15 hover:border-violet-300/50"
|
||||
}`}
|
||||
style={{ width: 80, height: 80 }}
|
||||
>
|
||||
<img src={url} alt={s.elementName} 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">
|
||||
#{s.seq}
|
||||
{pushedImages.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed border-white/15 p-2.5 text-[10.5px] text-white/35">
|
||||
暂无 · 在关键帧节点缩略图 / 元素提取图 / 分镜头编排节点元素 等处点 ⬆ 上推按钮把图加进来
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-1.5 overflow-x-auto pb-1">
|
||||
{pushedImages.map((p) => {
|
||||
const seq = frameSeqByIdx[p.frame_idx] ?? p.frame_idx + 1
|
||||
const url = p.kind === "keyframe"
|
||||
? effectiveFrameUrl(job.id, { index: p.frame_idx, cleaned_applied: false })
|
||||
: (p.element_id && p.cutout_id
|
||||
? (p.cutout_id === p.element_id
|
||||
? cutoutUrl(job.id, p.frame_idx, p.element_id) // legacy
|
||||
: cutoutUrl(job.id, p.frame_idx, p.element_id, p.cutout_id))
|
||||
: "")
|
||||
const isFocusFrame = focusedFrame === p.frame_idx
|
||||
return (
|
||||
<div
|
||||
key={p.ref_id}
|
||||
className={`group/p relative shrink-0 rounded-md overflow-hidden border bg-white transition hover:-translate-y-0.5 ${
|
||||
isFocusFrame
|
||||
? "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 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>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user