auto-save 2026-05-13 17:50 (~4)

This commit is contained in:
2026-05-13 17:51:10 +08:00
parent 3bfb827e3a
commit f5bdda90c6
4 changed files with 139 additions and 77 deletions

View File

@@ -9,14 +9,14 @@ import {
import { Toaster, toast } from "sonner"
import {
InputNode, KeyframeNode, ASRNode,
TranslateNode, RewriteNode, ImageGenNode, VideoGenNode, ComposeNode,
TranslateNode, RewriteNode, StoryboardNode, VideoGenNode, ComposeNode,
type NodeData,
} from "@/components/nodes"
import { ThemeToggle } from "@/components/theme-toggle"
import { Dashboard, type DashboardHandle } from "@/components/dashboard"
import { StoryboardBar } from "@/components/storyboard-bar"
import { StoryboardWorkbench } from "@/components/storyboard-workbench"
import { addManualFrame, analyzeJob, createJob, getJob, uploadJob, deleteFrame, deleteGeneratedImage, pushStoryboardImage, type Job, type ImageRef } from "@/lib/api"
import { addManualFrame, analyzeJob, createJob, getJob, uploadJob, deleteFrame, deleteGeneratedImage, type Job, type ImageRef } from "@/lib/api"
import { VideoLightbox } from "@/components/video-lightbox"
const NODE_TYPES = {
@@ -25,20 +25,20 @@ const NODE_TYPES = {
asr: ASRNode,
translate: TranslateNode,
rewrite: RewriteNode,
imagegen: ImageGenNode,
storyboard: StoryboardNode,
videogen: VideoGenNode,
compose: ComposeNode,
}
// 合并 input + download + split 为一个节点
// 分叉:上路 input → keyframe → imagegen → videogen ↘
// 下路 input → asr → translate → rewrite ──── compose
// 分叉:上路 input → keyframe → storyboard → videogen ↘
// 下路 input → asr → translate → rewrite ──────→ storyboard / compose
const LAYOUT: Array<{ id: string; type: keyof typeof NODE_TYPES; x: number; y: number }> = [
{ id: "input", type: "input", x: 40, y: 240 },
{ id: "keyframe", type: "keyframe", x: 460, y: 60 },
{ id: "asr", type: "asr", x: 460, y: 440 },
{ id: "translate", type: "translate", x: 840, y: 440 },
{ id: "imagegen", type: "imagegen", x: 880, y: 60 },
{ id: "storyboard", type: "storyboard", x: 880, y: 60 },
{ id: "rewrite", type: "rewrite", x: 1220, y: 440 },
{ id: "videogen", type: "videogen", x: 1260, y: 60 },
{ id: "compose", type: "compose", x: 1640, y: 240 },
@@ -49,9 +49,9 @@ const EDGES_RAW: Array<[string, string]> = [
["input", "asr"],
["asr", "translate"],
["translate", "rewrite"],
["keyframe", "imagegen"],
["rewrite", "imagegen"],
["imagegen", "videogen"],
["keyframe", "storyboard"],
["rewrite", "storyboard"],
["storyboard", "videogen"],
["videogen", "compose"],
["rewrite", "compose"],
]
@@ -135,7 +135,7 @@ export default function Home() {
setSelectedFrames(new Set())
try {
await analyzeJob(job.id, 5)
toast.info("开始解析:拆轨 → 抽帧 → ASR → 翻译")
toast.info("开始解析:拆轨 → 抽帧。声音文案轨单独处理")
// 乐观更新本地状态,让轮询 useEffect 重新启动
setJob((prev) => prev ? { ...prev, status: "splitting", message: "拆轨中…", progress: 30 } : prev)
} catch (e) {
@@ -199,23 +199,6 @@ export default function Home() {
toast.success(`已复制:${ref.label || (ref.kind === "keyframe" ? "关键帧" : "元素")} · 到分镜头编排工作台粘贴`)
}, [])
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)
@@ -240,6 +223,24 @@ export default function Home() {
window.history.replaceState({}, "", url.toString())
}, [jobs.length])
// 恢复已保存的分镜选择:刷新页面后,已有 storyboard 的帧仍应出现在顶部编排栏。
useEffect(() => {
if (!job || job.frames.length === 0) return
const persisted = job.frames.filter((f) => !!f.storyboard).map((f) => f.index)
if (persisted.length === 0) return
setSelectedFrames((prev) => {
let changed = false
const next = new Set(prev)
for (const idx of persisted) {
if (!next.has(idx)) {
next.add(idx)
changed = true
}
}
return changed ? next : prev
})
}, [job?.id, job?.frames])
// 轮询 Jobdownloaded / transcribed / failed 三态停止)
const prevStatusRef = useRef<string | null>(null)
useEffect(() => {
@@ -250,7 +251,7 @@ export default function Home() {
}
prevStatusRef.current = job.status
const TERMINAL: Job["status"][] = ["downloaded", "transcribed", "failed"]
const TERMINAL: Job["status"][] = ["downloaded", "frames_extracted", "transcribed", "failed"]
if (TERMINAL.includes(job.status)) {
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null }
return
@@ -286,9 +287,9 @@ export default function Home() {
onDeleteFrame: handleDeleteFrame,
onDeleteGenerated: handleDeleteGenerated,
onOpenStoryboard: (idx: number) => setStoryboardFrame(idx),
onPushToStoryboard: handlePushToStoryboard,
onOpenWorkbench: () => setWorkbenchOpen(true),
onCopyImage: handleCopyImage,
}), [job, jobs, activeJobId, submitting, analyzing, selectedFrames, expandedFrame, handleSubmit, handleUpload, handleAnalyze, handleToggleFrame, handleAddManualFrame, handleSwitchJob, setJob, handleDeleteFrame, handleDeleteGenerated, handlePushToStoryboard, handleCopyImage])
}), [job, jobs, activeJobId, submitting, analyzing, selectedFrames, expandedFrame, handleSubmit, handleUpload, handleAnalyze, handleToggleFrame, handleAddManualFrame, handleSwitchJob, setJob, handleDeleteFrame, handleDeleteGenerated, handleCopyImage])
// 用 useNodesState 让 ReactFlow 自己管位置(避免轮询时重置 drag
const [nodes, setNodes, onNodesChange] = useNodesState<Node>(
@@ -318,6 +319,8 @@ export default function Home() {
keyframe: !!job && job.frames.length > 0,
asr: !!job && job.transcript.length > 0,
translate: !!job && (job.transcript.some((s) => s.zh) ?? false),
rewrite: !!job && (job.transcript.some((s) => s.zh) ?? false),
storyboard: selectedFrames.size > 0,
}
setEdges((prev) => prev.map((e) => ({ ...e, animated: !!doneOf[e.source] })))
}, [job, setEdges])