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)",
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user