auto-save 2026-05-14 00:48 (+4, ~3)

This commit is contained in:
2026-05-14 00:48:59 +08:00
parent 042efdc376
commit e8a653e524
7 changed files with 673 additions and 4 deletions

View File

@@ -64,6 +64,18 @@ function loadNodeSizes(): Record<string, NodeSize> {
}
}
const NODE_PINS_KEY = "skg-tk:node-pins:v1"
function loadNodePins(): string[] {
if (typeof window === "undefined") return []
try {
const raw = window.localStorage.getItem(NODE_PINS_KEY)
return raw ? JSON.parse(raw) : []
} catch {
return []
}
}
const EDGES_RAW: Array<[string, string]> = [
["input", "keyframe"],
["input", "asr"],
@@ -412,6 +424,16 @@ export default function Home() {
return () => { if (pollRef.current) clearInterval(pollRef.current) }
}, [job?.id, job?.status, job?.generated_videos?.map((v) => `${v.id}:${v.status}:${v.progress}`).join("|")])
const [pinnedNodes, setPinnedNodes] = useState<Set<string>>(() => new Set(loadNodePins()))
const handleToggleNodePin = useCallback((id: string) => {
setPinnedNodes((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id); else next.add(id)
try { window.localStorage.setItem(NODE_PINS_KEY, JSON.stringify([...next])) } catch {}
return next
})
}, [])
const nodeData: NodeData = useMemo(() => ({
job,
jobs,
@@ -444,7 +466,9 @@ export default function Home() {
setWorkbenchOpen(true)
},
onCopyImage: handleCopyImage,
}), [job, jobs, activeJobId, submitting, analyzing, selectedFrames, expandedFrame, framePanelScale, framePanelPinned, handleSubmit, handleUpload, handleAnalyze, handleToggleFrame, handleOpenFramePanel, handleFramePanelScaleChange, handleAddManualFrame, handleSwitchJob, setJob, handleDeleteFrame, handleDeleteGenerated, handleDeleteVideo, handleCopyImage])
pinnedNodes,
onToggleNodePin: handleToggleNodePin,
}), [job, jobs, activeJobId, submitting, analyzing, selectedFrames, expandedFrame, framePanelScale, framePanelPinned, handleSubmit, handleUpload, handleAnalyze, handleToggleFrame, handleOpenFramePanel, handleFramePanelScaleChange, handleAddManualFrame, handleSwitchJob, setJob, handleDeleteFrame, handleDeleteGenerated, handleDeleteVideo, handleCopyImage, pinnedNodes, handleToggleNodePin])
// 用 useNodesState 让 ReactFlow 自己管位置(避免轮询时重置 drag
const savedSizes = useMemo(() => loadNodeSizes(), [])
@@ -453,12 +477,13 @@ export default function Home() {
const s = savedSizes[n.id] ?? {}
const w = s.w ?? n.w
const h = s.h
const isPinned = pinnedNodes.has(n.id)
return {
id: n.id,
type: n.type,
position: { x: n.x, y: n.y },
data: nodeData,
draggable: true,
draggable: !isPinned,
width: w,
...(typeof h === "number" ? { height: h } : {}),
style: { width: w, ...(typeof h === "number" ? { height: h } : {}) },
@@ -466,6 +491,13 @@ export default function Home() {
}),
)
// pinned 变化时同步每个节点 draggable
useEffect(() => {
setNodes((prev) => prev.map((n) =>
n.id === KEYFRAME_PANEL_ID ? n : { ...n, draggable: !pinnedNodes.has(n.id) },
))
}, [pinnedNodes, setNodes])
// 持久化每个节点宽 / 高到 localStorageKeyframePanelNode 自己管尺寸,不写回)
useEffect(() => {
const sizes: Record<string, NodeSize> = {}

View File

@@ -46,6 +46,8 @@ export interface NodeData {
onOpenStoryboard?: (frameIdx: number) => void // 打开分镜头编排专属面板
onOpenWorkbench?: (frameIdx?: number) => void // 展开顶部分镜编排内嵌面板
onCopyImage?: (ref: ImageRef) => void // 复制图片到全局剪贴板(粘贴到分镜头编排插槽)
pinnedNodes?: Set<string> // 已钉住的节点 id 集合 — 钉住后位置 + 尺寸锁定
onToggleNodePin?: (id: string) => void
}
/* ---- 状态映射工具 ---- */
@@ -234,6 +236,8 @@ export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | an
subtitle={isDownloading ? "STEP 1 · 下载中" : hasVideo ? "STEP 1 · 视频就绪" : "STEP 1"}
selected={selected}
hasTarget={false}
pinned={d.pinnedNodes?.has("input")}
onTogglePin={() => d.onToggleNodePin?.("input")}
>
{/* URL + 上传入口 — 一直显示(即使已有视频,也可以继续加新的) */}
<>
@@ -499,6 +503,8 @@ export function KeyframeNode({ data, selected }: any) {
title="镜头拆解 · 元素提取"
subtitle={`STEP 2 · ${frames.length ? `${d.selectedFrames.size}/${frames.length} 入编排` : "等待抽取"}`}
selected={selected}
pinned={d.pinnedNodes?.has("keyframe")}
onTogglePin={() => d.onToggleNodePin?.("keyframe")}
>
{frames.length > 0 ? (() => {
const cleanedCount = frames.filter((x) => x.cleaned_url).length
@@ -726,6 +732,8 @@ export function ASRNode({ data, selected }: any) {
title="声音文案 · ASR"
subtitle="STEP 3 · 可选文案轨"
selected={selected}
pinned={d.pinnedNodes?.has("asr")}
onTogglePin={() => d.onToggleNodePin?.("asr")}
>
<div className="text-[11.5px] text-[var(--text-soft)]">
Gemini 2.5 ·
@@ -767,6 +775,8 @@ export function TranslateNode({ data, selected }: any) {
title="翻译理解 · Translate"
subtitle="STEP 4 · EN → ZH"
selected={selected}
pinned={d.pinnedNodes?.has("translate")}
onTogglePin={() => d.onToggleNodePin?.("translate")}
>
<div className="text-[11.5px] text-[var(--text-soft)]">
· ·
@@ -785,7 +795,8 @@ export function TranslateNode({ data, selected }: any) {
/* ============================================================
7. RewriteNode (placeholder)
============================================================ */
export function RewriteNode({ selected }: any) {
export function RewriteNode({ data, selected }: any) {
const d: NodeData = data
return (
<NodeShell
type="ai" status="pending"
@@ -793,6 +804,8 @@ export function RewriteNode({ selected }: any) {
title="产品文案 · Rewrite"
subtitle="STEP 5 · 接 SKG 卖点"
selected={selected}
pinned={d.pinnedNodes?.has("rewrite")}
onTogglePin={() => d.onToggleNodePin?.("rewrite")}
>
<textarea
placeholder="粘贴 SKG 产品信息 / 关键卖点(可作为视频脚本和镜头动作参考)"
@@ -929,6 +942,8 @@ export function StoryboardNode({ data, selected }: any) {
title="元素改造 · Storyboard"
subtitle={`STEP 6 · 参考元素 → SKG 画面${storyboardCount > 0 ? ` · ${storyboardCount} 分镜` : ""}`}
selected={selected}
pinned={d.pinnedNodes?.has("storyboard")}
onTogglePin={() => d.onToggleNodePin?.("storyboard")}
>
<div className="text-[11.5px] leading-relaxed text-[var(--text-soft)]">
/ / / SKG
@@ -1103,6 +1118,8 @@ export function VideoGenNode({ data, selected }: any) {
title="生成视频 · Video Gen"
subtitle={`STEP 7 · 首帧 + 动作 prompt${videos.length > 0 ? ` · ${videos.length} 个视频任务` : ""}`}
selected={selected}
pinned={d.pinnedNodes?.has("videogen")}
onTogglePin={() => d.onToggleNodePin?.("videogen")}
>
<div className="grid grid-cols-3 gap-1.5 text-[10.5px]">
{["Seedance", "Kling", "Veo 3"].map((m) => (
@@ -1124,7 +1141,8 @@ export function VideoGenNode({ data, selected }: any) {
/* ============================================================
10. ComposeNode (placeholder)
============================================================ */
export function ComposeNode({ selected }: any) {
export function ComposeNode({ data, selected }: any) {
const d: NodeData = data
return (
<NodeShell
type="output" status="pending"
@@ -1133,6 +1151,8 @@ export function ComposeNode({ selected }: any) {
subtitle="STEP 8 · ffmpeg + 字幕"
selected={selected}
hasSource={false}
pinned={d.pinnedNodes?.has("compose")}
onTogglePin={() => d.onToggleNodePin?.("compose")}
>
<div className="text-[11.5px] text-[var(--text-soft)] leading-relaxed">
+ / TTS<br /> mp4