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])

View File

@@ -4,10 +4,10 @@ import { createPortal } from "react-dom"
import { type NodeProps } from "@xyflow/react"
import {
Link2, Upload, Download, Scissors, Image as ImageIcon,
Mic, Languages, FileEdit, Sparkles, Film, FileVideo, Loader2, Plus, X, LayoutGrid,
Mic, Languages, FileEdit, Film, FileVideo, Loader2, Plus, X, LayoutGrid,
} from "lucide-react"
import { NodeShell, type NodeStatus, type NodeKind } from "./node-shell"
import { type Job, type ImageRef, frameUrl, effectiveFrameUrl, videoUrl, generatedImageUrl, cutoutUrl, hasCutout, representativeCutoutUrl } from "@/lib/api"
import { type Job, type ImageRef, effectiveFrameUrl, videoUrl, hasCutout, representativeCutoutUrl } from "@/lib/api"
export interface NodeData {
job: Job | null // 当前 active job
@@ -31,7 +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
onOpenWorkbench?: () => void // 打开全屏分镜编排工作台
onCopyImage?: (ref: ImageRef) => void // 复制图片到全局剪贴板(粘贴到分镜头编排工作台插槽)
}
@@ -85,7 +85,7 @@ export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | an
// 是否已下载 → 显示视频 + 解析按钮
const hasVideo = !!job?.video_url
const isDownloading = job?.status === "downloading" || job?.status === "created"
const isAnalyzing = !!job && ["splitting", "frames_extracted", "transcribing"].includes(job.status)
const isAnalyzing = !!job && ["splitting", "transcribing"].includes(job.status)
const isDone = job?.status === "transcribed"
const hasFrames = (job?.frames.length ?? 0) > 0
const inputLocked = isDownloading || d.submitting
@@ -466,9 +466,9 @@ export function KeyframeNode({ data, selected }: any) {
<NodeShell
type="process" status={st}
icon={<ImageIcon className="h-4 w-4" />}
title="关键帧 · 清洗 + 提取"
subtitle={`STEP 2 · ${frames.length ? `${d.selectedFrames.size}/${frames.length} 选用` : "等待抽取"}`}
icon={<ImageIcon className="h-4 w-4" />}
title="镜头拆解 · 元素提取"
subtitle={`STEP 2 · ${frames.length ? `${d.selectedFrames.size}/${frames.length} 入编排` : "等待抽取"}`}
width={KEYFRAME_WIDTH}
selected={selected}
>
@@ -485,7 +485,7 @@ export function KeyframeNode({ data, selected }: any) {
<span className={cutoutCount > 0 ? "text-violet-300/90 font-medium" : ""}>{cutoutCount}/{elementsCount} </span>
<br />
<span className="text-[10.5px] text-[var(--text-faint)]">
/
/ SKG
</span>
</div>
)
@@ -509,8 +509,8 @@ export function ASRNode({ data, selected }: any) {
<NodeShell
type="ai" status={asrStatus(d.job)}
icon={<Mic className="h-4 w-4" />}
title="转录 · ASR"
subtitle="STEP 5 · Gemini"
title="声音文案 · ASR"
subtitle="STEP 3 · 可选文案轨"
selected={selected}
>
<div className="text-[11.5px] text-[var(--text-soft)]">
@@ -550,8 +550,8 @@ export function TranslateNode({ data, selected }: any) {
<NodeShell
type="ai" status={st}
icon={<Languages className="h-4 w-4" />}
title="翻译 · Translate"
subtitle="STEP 6 · EN → ZH"
title="翻译理解 · Translate"
subtitle="STEP 4 · EN → ZH"
selected={selected}
>
<div className="text-[11.5px] text-[var(--text-soft)]">
@@ -576,12 +576,12 @@ export function RewriteNode({ selected }: any) {
<NodeShell
type="ai" status="pending"
icon={<FileEdit className="h-4 w-4" />}
title="文案改写 · Rewrite"
subtitle="STEP 7 · 接产品信息"
title="产品文案 · Rewrite"
subtitle="STEP 5 · 接 SKG 卖点"
selected={selected}
>
<textarea
placeholder="粘贴 SKG 产品信息 / 关键卖点(占位,未接通"
placeholder="粘贴 SKG 产品信息 / 关键卖点(可作为视频脚本和镜头动作参考"
rows={3}
disabled
className="w-full text-[11.5px] px-2.5 py-2 rounded-md bg-white/30 dark:bg-white/[0.03] border border-dashed border-black/15 dark:border-white/10 placeholder:text-[var(--text-faint)] text-[var(--text-strong)] resize-none opacity-70"
@@ -592,11 +592,11 @@ export function RewriteNode({ selected }: any) {
}
/* ============================================================
8. ImageGenNode — 显示 selected frames 的代表生成图
6. StoryboardNode — 元素改造 + 分镜编排入口
============================================================ */
const IMAGEGEN_WIDTH = 360
export function ImageGenNode({ data, selected }: any) {
export function StoryboardNode({ data, selected }: any) {
const d: NodeData = data
const job = d?.job
@@ -618,7 +618,8 @@ export function ImageGenNode({ data, selected }: any) {
: []
const totalElements = elementCrops.length
const status: NodeStatus = !job ? "pending" : totalElements > 0 ? "done" : "pending"
const storyboardCount = job?.frames.filter((f) => d.selectedFrames.has(f.index)).length ?? 0
const status: NodeStatus = !job ? "pending" : storyboardCount > 0 || totalElements > 0 ? "done" : "pending"
const aspect = job && job.height > 0 ? `${job.width}/${job.height}` : "9/16"
return (
@@ -638,8 +639,12 @@ export function ImageGenNode({ data, selected }: any) {
style={{ aspectRatio: aspect }}
>
<button
onClick={(e) => { e.stopPropagation(); d.onOpenStoryboard?.(p.frameIdx) }}
title={`${p.name} · 来自分镜 ${p.frameIdx + 1} · hover 看大图`}
onClick={(e) => {
e.stopPropagation()
d.onOpenStoryboard?.(p.frameIdx)
d.onOpenWorkbench?.()
}}
title={`${p.name} · 来自分镜 ${p.frameIdx + 1} · 点击进入分镜编排`}
className="absolute inset-0 w-full h-full"
>
<img
@@ -696,26 +701,27 @@ export function ImageGenNode({ data, selected }: any) {
<NodeShell
type="ai" status={status}
icon={<LayoutGrid className="h-4 w-4" />}
title="分镜头编排 · Storyboard"
subtitle={`STEP 6 · 元素 + 场景${totalElements > 0 ? ` · ${totalElements} 个元素` : ""}`}
title="元素改造 · Storyboard"
subtitle={`STEP 6 · 参考元素 → SKG 画面${storyboardCount > 0 ? ` · ${storyboardCount} 分镜` : ""}`}
width={IMAGEGEN_WIDTH}
selected={selected}
>
{totalElements > 0 ? (
<div className="text-[11.5px] leading-relaxed text-[var(--text-soft)]">
<span className="text-[var(--text-strong)] font-medium">{totalElements}</span> +
<br />
<span className="text-[10.5px] text-[var(--text-faint)]">
· / / Phase 2
</span>
</div>
) : (
<div className="text-[11.5px] text-[var(--text-faint)] leading-relaxed">
<span className="text-[var(--text-strong)]"></span>
<br />
<span className="text-[10.5px]"> </span>
</div>
)}
<div className="text-[11.5px] leading-relaxed text-[var(--text-soft)]">
/ / / SKG
<br />
<span className="text-[10.5px] text-[var(--text-faint)]">
{totalElements} · {storyboardCount}
</span>
</div>
<button
type="button"
onClick={(e) => { e.stopPropagation(); d.onOpenWorkbench?.() }}
disabled={!job || storyboardCount === 0}
className="mt-2 w-full rounded-md bg-gradient-to-r from-violet-500 to-pink-500 px-3 py-2 text-[12px] font-semibold text-white shadow-lg shadow-violet-500/25 transition hover:opacity-95 disabled:cursor-not-allowed disabled:opacity-35"
title={storyboardCount === 0 ? "先在关键帧节点选用分镜" : "进入 4 图槽分镜编排"}
>
</button>
</NodeShell>
</div>
@@ -730,8 +736,8 @@ export function VideoGenNode({ selected }: any) {
<NodeShell
type="ai" status="pending"
icon={<Film className="h-4 w-4" />}
title="生视频 · Video Gen"
subtitle="STEP 9 · 多家可切"
title="生视频 · Video Gen"
subtitle="STEP 7 · 首帧 + 动作 prompt"
selected={selected}
>
<div className="grid grid-cols-3 gap-1.5 text-[10.5px]">
@@ -754,7 +760,7 @@ export function ComposeNode({ selected }: any) {
type="output" status="pending"
icon={<FileVideo className="h-4 w-4" />}
title="合成成品 · Compose"
subtitle="STEP 10 · ffmpeg + TTS"
subtitle="STEP 8 · ffmpeg + 字幕"
selected={selected}
hasSource={false}
>

View File

@@ -205,13 +205,13 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU
</div>
</div>
{/* 4 图槽 grid */}
{/* 4 图槽 grid:图片是参考,不是最终复刻素材 */}
<div className="grid grid-cols-4 gap-4">
{([
{ key: "subject_image" as const, label: "主体", placeholder: "戴头带的骨架人" },
{ key: "scene_image" as const, label: "场景", placeholder: "药店柜台 / 卧室" },
{ key: "product_image" as const, label: "产品", placeholder: "Goli 营养软糖" },
{ key: "action_image" as const, label: "在干什么", placeholder: "递给顾客" },
{ key: "subject_image" as const, label: "参考主体", placeholder: "人物 / 手部 / 模特姿态" },
{ key: "scene_image" as const, label: "参考场景", placeholder: "药店柜台 / 卧室 / 浴室" },
{ key: "product_image" as const, label: "SKG 产品", placeholder: "产品图 / 包装 / 使用状态" },
{ key: "action_image" as const, label: "参考动作", placeholder: "拿起 / 佩戴 / 展示 / 递给顾客" },
]).map(({ key, label, placeholder }) => {
const ref = form[key]
const url = ref ? resolveImageRefUrl(job.id, ref) : ""
@@ -268,6 +268,46 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU
})}
</div>
{/* 改造 brief明确“借鉴参考 → 变成 SKG 产品视频”,避免直接复刻 */}
<section className="rounded-lg bg-white/[0.035] border border-white/10 p-3">
<div className="text-[12.5px] font-semibold text-white mb-2">
<span className="ml-2 text-[10px] font-normal text-white/35">
SKG
</span>
</div>
<div className="grid grid-cols-2 gap-3">
<FieldTextarea
label="主体怎么改"
value={form.subject || ""}
onChange={(v) => queueSave({ ...form, subject: v })}
placeholder="例:保留手部拿取动作,但人物改为更干净的产品演示模特"
rows={2}
/>
<FieldTextarea
label="产品怎么替换"
value={form.product || ""}
onChange={(v) => queueSave({ ...form, product: v })}
placeholder="例:把原视频里的瓶子 / 糖果替换成 SKG 颈椎按摩仪,突出佩戴形态"
rows={2}
/>
<FieldTextarea
label="场景怎么借鉴"
value={form.scene || ""}
onChange={(v) => queueSave({ ...form, scene: v })}
placeholder="例:借鉴药店货架的可信感,但换成现代家居 / 办公桌场景"
rows={2}
/>
<FieldTextarea
label="动作和镜头"
value={form.action || ""}
onChange={(v) => queueSave({ ...form, action: v })}
placeholder="例:缓慢推近,展示佩戴、按键、表情放松,镜头节奏参考原视频"
rows={2}
/>
</div>
</section>
{/* 生成按钮Phase 2 占位) */}
<section>
<button
@@ -276,10 +316,10 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU
title="Phase 2 实施"
>
<Wand2 className="h-4 w-4" />
Phase 2
/ Phase 2
</button>
<div className="mt-2 text-[10.5px] text-white/35 leading-relaxed">
4 / / / + Seedance / Kling / Veo3
4 + + SKG Seedance / Kling / Veo3
</div>
</section>
</div>