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

@@ -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}
>