auto-save 2026-05-13 13:42 (+1, ~4)
This commit is contained in:
@@ -1576,6 +1576,19 @@
|
|||||||
"message": "auto-save 2026-05-13 13:31 (~1)",
|
"message": "auto-save 2026-05-13 13:31 (~1)",
|
||||||
"hash": "1e06c14",
|
"hash": "1e06c14",
|
||||||
"files_changed": 1
|
"files_changed": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-05-13T13:37:37+08:00",
|
||||||
|
"type": "commit",
|
||||||
|
"message": "auto-save 2026-05-13 13:37 (~1)",
|
||||||
|
"hash": "fa3fadd",
|
||||||
|
"files_changed": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-05-13T05:37:39Z",
|
||||||
|
"type": "session-heartbeat",
|
||||||
|
"message": "Claude 会话活跃 · 最近命令:claude · 1 项未提交变更 · 最近提交:auto-save 2026-05-13 13:37 (~1)",
|
||||||
|
"files_changed": 1
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from "@/components/nodes"
|
} from "@/components/nodes"
|
||||||
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 { addManualFrame, analyzeJob, createJob, getJob, uploadJob, deleteFrame, deleteGeneratedImage, type Job } from "@/lib/api"
|
import { addManualFrame, analyzeJob, createJob, getJob, uploadJob, deleteFrame, deleteGeneratedImage, type Job } from "@/lib/api"
|
||||||
import { VideoLightbox } from "@/components/video-lightbox"
|
import { VideoLightbox } from "@/components/video-lightbox"
|
||||||
|
|
||||||
@@ -317,8 +318,14 @@ export default function Home() {
|
|||||||
<Dashboard ref={dashboardRef} data={nodeData} />
|
<Dashboard ref={dashboardRef} data={nodeData} />
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* 右区:紧凑 DAG 节点流图(撑满剩余宽度) */}
|
{/* 右区:顶部 storyboard bar + DAG 节点流图 */}
|
||||||
<section className="relative flex-1 min-h-0">
|
<section className="relative flex-1 min-h-0 flex flex-col">
|
||||||
|
<StoryboardBar
|
||||||
|
job={job}
|
||||||
|
selectedFrames={selectedFrames}
|
||||||
|
onExpandFrame={setExpandedFrame}
|
||||||
|
/>
|
||||||
|
<div className="relative flex-1 min-h-0">
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
edges={edges}
|
edges={edges}
|
||||||
@@ -336,6 +343,7 @@ export default function Home() {
|
|||||||
<Controls position="bottom-left" />
|
<Controls position="bottom-left" />
|
||||||
<MiniMap position="bottom-right" pannable zoomable nodeStrokeWidth={2} />
|
<MiniMap position="bottom-right" pannable zoomable nodeStrokeWidth={2} />
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<Toaster theme="system" position="bottom-center" />
|
<Toaster theme="system" position="bottom-center" />
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ export const Dashboard = forwardRef<DashboardHandle, Props>(function Dashboard({
|
|||||||
{ key: "asr", title: "转录", type: "ai", icon: <Mic className="h-3.5 w-3.5" />, step: 3 },
|
{ key: "asr", title: "转录", type: "ai", icon: <Mic className="h-3.5 w-3.5" />, step: 3 },
|
||||||
{ key: "translate", title: "翻译", type: "ai", icon: <Languages className="h-3.5 w-3.5" />, step: 4 },
|
{ key: "translate", title: "翻译", type: "ai", icon: <Languages className="h-3.5 w-3.5" />, step: 4 },
|
||||||
{ key: "rewrite", title: "改写", type: "ai", icon: <FileEdit className="h-3.5 w-3.5" />, step: 5 },
|
{ key: "rewrite", title: "改写", type: "ai", icon: <FileEdit className="h-3.5 w-3.5" />, step: 5 },
|
||||||
{ key: "imagegen", title: "分镜头编排", type: "ai", icon: <LayoutGrid className="h-3.5 w-3.5" />, step: 6 },
|
// imagegen(分镜头编排)已移到顶部 StoryboardBar,不在 sidebar 里
|
||||||
{ key: "videogen", title: "生视频", type: "ai", icon: <Film className="h-3.5 w-3.5" />, step: 7 },
|
{ key: "videogen", title: "生视频", type: "ai", icon: <Film className="h-3.5 w-3.5" />, step: 7 },
|
||||||
{ key: "compose", title: "合成", type: "output", icon: <FileVideo className="h-3.5 w-3.5" />, step: 8 },
|
{ key: "compose", title: "合成", type: "output", icon: <FileVideo className="h-3.5 w-3.5" />, step: 8 },
|
||||||
]
|
]
|
||||||
@@ -251,10 +251,9 @@ export const Dashboard = forwardRef<DashboardHandle, Props>(function Dashboard({
|
|||||||
<div className="h-full flex flex-col gap-1 px-1.5 py-2">
|
<div className="h-full flex flex-col gap-1 px-1.5 py-2">
|
||||||
{/* 起点:输入(含下载+拆分) */}
|
{/* 起点:输入(含下载+拆分) */}
|
||||||
<Tile tkey="input" />
|
<Tile tkey="input" />
|
||||||
{/* 分叉:上路 关键帧/分镜头编排/生视频 */}
|
{/* 分叉:上路 关键帧 / 生视频(分镜头编排在顶部 bar) */}
|
||||||
<div className="border-l border-violet-400/25 pl-1 ml-[3px] space-y-1">
|
<div className="border-l border-violet-400/25 pl-1 ml-[3px] space-y-1">
|
||||||
<Tile tkey="keyframe" />
|
<Tile tkey="keyframe" />
|
||||||
<Tile tkey="imagegen" />
|
|
||||||
<Tile tkey="videogen" />
|
<Tile tkey="videogen" />
|
||||||
</div>
|
</div>
|
||||||
{/* 分叉:下路 转录/翻译/改写 */}
|
{/* 分叉:下路 转录/翻译/改写 */}
|
||||||
|
|||||||
@@ -583,7 +583,7 @@ export function RewriteNode({ selected }: any) {
|
|||||||
/* ============================================================
|
/* ============================================================
|
||||||
8. ImageGenNode — 显示 selected frames 的代表生成图
|
8. ImageGenNode — 显示 selected frames 的代表生成图
|
||||||
============================================================ */
|
============================================================ */
|
||||||
const IMAGEGEN_WIDTH = 320
|
const IMAGEGEN_WIDTH = 360
|
||||||
|
|
||||||
export function ImageGenNode({ data, selected }: any) {
|
export function ImageGenNode({ data, selected }: any) {
|
||||||
const d: NodeData = data
|
const d: NodeData = data
|
||||||
@@ -601,24 +601,25 @@ export function ImageGenNode({ data, selected }: any) {
|
|||||||
|
|
||||||
const totalElements = elementCrops.length
|
const totalElements = elementCrops.length
|
||||||
const status: NodeStatus = !job ? "pending" : totalElements > 0 ? "done" : "pending"
|
const status: NodeStatus = !job ? "pending" : totalElements > 0 ? "done" : "pending"
|
||||||
|
const aspect = job && job.height > 0 ? `${job.width}/${job.height}` : "9/16"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" style={{ width: IMAGEGEN_WIDTH }}>
|
<div className="relative" style={{ width: IMAGEGEN_WIDTH }}>
|
||||||
{/* 节点上方:所有元素 crop 图(编排输入素材) */}
|
{/* 节点上方:所有元素 crop 图(编排输入素材)· 跟 keyframe 节点样式一致 */}
|
||||||
{elementCrops.length > 0 && job && (
|
{elementCrops.length > 0 && job && (
|
||||||
<div
|
<div
|
||||||
className="absolute left-0 right-0 grid grid-cols-6 gap-1.5"
|
className="absolute left-0 right-0 grid grid-cols-5 gap-1.5"
|
||||||
style={{ bottom: "calc(100% + 12px)" }}
|
style={{ bottom: "calc(100% + 12px)" }}
|
||||||
>
|
>
|
||||||
{elementCrops.map((p) => (
|
{elementCrops.map((p) => (
|
||||||
<div
|
<div
|
||||||
key={`${p.frameIdx}_${p.elementId}`}
|
key={`${p.frameIdx}_${p.elementId}`}
|
||||||
className="group relative rounded-md border border-violet-300/40 dark:border-violet-300/30 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-black/40 overflow-hidden"
|
||||||
style={{ aspectRatio: "1/1" }}
|
style={{ aspectRatio: aspect }}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); d.onOpenPanel?.("imagegen") }}
|
onClick={(e) => { e.stopPropagation(); d.onOpenPanel?.("imagegen") }}
|
||||||
title={`${p.name} · 来自分镜 ${p.frameIdx + 1} · 打开「分镜头编排」`}
|
title={`${p.name} · 来自分镜 ${p.frameIdx + 1}`}
|
||||||
className="absolute inset-0 w-full h-full"
|
className="absolute inset-0 w-full h-full"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
@@ -627,7 +628,7 @@ 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"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Hover 大图预览 */}
|
{/* Hover 大图预览 — 跟 keyframe 样式一致 */}
|
||||||
<div
|
<div
|
||||||
className="pointer-events-none absolute opacity-0 group-hover:opacity-100 scale-95 group-hover:scale-100 transition-all duration-200 z-[60]"
|
className="pointer-events-none absolute opacity-0 group-hover:opacity-100 scale-95 group-hover:scale-100 transition-all duration-200 z-[60]"
|
||||||
style={{
|
style={{
|
||||||
@@ -643,15 +644,16 @@ export function ImageGenNode({ data, selected }: any) {
|
|||||||
alt={`preview ${p.elementId}`}
|
alt={`preview ${p.elementId}`}
|
||||||
className="block"
|
className="block"
|
||||||
style={{
|
style={{
|
||||||
maxWidth: "min(560px, 70vw)",
|
width: IMAGEGEN_WIDTH * 2,
|
||||||
|
maxWidth: "min(720px, 80vw)",
|
||||||
height: "auto",
|
height: "auto",
|
||||||
maxHeight: "70vh",
|
maxHeight: "82vh",
|
||||||
objectFit: "contain",
|
objectFit: "contain",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center justify-between px-3 py-2 bg-black/70 backdrop-blur-md">
|
<div className="flex items-center justify-between px-3 py-2 bg-black/70 backdrop-blur-md">
|
||||||
<span className="text-white text-[12.5px] font-medium">{p.name}</span>
|
<span className="text-white text-[12.5px] font-medium">{p.name}</span>
|
||||||
<span className="text-white/60 text-[11px] font-mono">分镜 {p.frameIdx + 1}</span>
|
<span className="text-white/60 text-[11px] font-mono">来自分镜 {p.frameIdx + 1}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
104
web/components/storyboard-bar.tsx
Normal file
104
web/components/storyboard-bar.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
"use client"
|
||||||
|
import { useState } from "react"
|
||||||
|
import { LayoutGrid, ChevronDown, ChevronUp, Sparkle } from "lucide-react"
|
||||||
|
import { type Job, effectiveFrameUrl } from "@/lib/api"
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
job: Job | null
|
||||||
|
selectedFrames: Set<number>
|
||||||
|
onExpandFrame: (idx: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StoryboardBar({ job, selectedFrames, onExpandFrame }: Props) {
|
||||||
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
|
if (!job) return null
|
||||||
|
|
||||||
|
// 按时间序排已选用的分镜
|
||||||
|
const frames = job.frames
|
||||||
|
.filter((f) => selectedFrames.has(f.index))
|
||||||
|
.sort((a, b) => a.timestamp - b.timestamp)
|
||||||
|
|
||||||
|
const aspect = job.height > 0 ? `${job.width}/${job.height}` : "9/16"
|
||||||
|
const totalElements = frames.reduce(
|
||||||
|
(sum, f) => sum + (f.elements?.filter((e) => e.cutout_id).length ?? 0),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative z-20 flex-shrink-0 border-b border-white/5 bg-black/30 backdrop-blur-xl">
|
||||||
|
{/* header */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-2">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<LayoutGrid className="h-3.5 w-3.5 text-violet-300 shrink-0" />
|
||||||
|
<span className="text-[12.5px] font-semibold text-white shrink-0">分镜头编排</span>
|
||||||
|
<span className="text-[10px] text-white/40 font-mono shrink-0">
|
||||||
|
{frames.length} 分镜 · {totalElements} 元素
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-white/30 truncate">
|
||||||
|
· 组织分镜画面 → 为生成视频做准备
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
|
className="text-white/50 hover:text-white text-[11px] inline-flex items-center gap-1 shrink-0"
|
||||||
|
title={collapsed ? "展开" : "折叠"}
|
||||||
|
>
|
||||||
|
{collapsed ? <ChevronDown className="h-3 w-3" /> : <ChevronUp className="h-3 w-3" />}
|
||||||
|
{collapsed ? "展开" : "折叠"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* thumbnails row */}
|
||||||
|
{!collapsed && (
|
||||||
|
frames.length === 0 ? (
|
||||||
|
<div className="px-4 pb-3 text-[11px] text-white/40">
|
||||||
|
还没选用分镜 · 在「关键帧」节点上点击缩略图右下勾选「选用此帧」,被选用的分镜按时间序出现在这里
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="px-4 pb-3 flex gap-2 overflow-x-auto">
|
||||||
|
{frames.map((f, i) => {
|
||||||
|
const elementCount = f.elements?.filter((e) => e.cutout_id).length ?? 0
|
||||||
|
const totalElCount = f.elements?.length ?? 0
|
||||||
|
const cleaned = f.cleaned_applied
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={f.index}
|
||||||
|
onClick={() => onExpandFrame(f.index)}
|
||||||
|
title={`分镜 ${i + 1} · ${f.timestamp.toFixed(2)}s${cleaned ? " · 已清洗" : ""} · ${elementCount}/${totalElCount} 元素 · 点击编辑`}
|
||||||
|
className="group relative shrink-0 rounded-md border border-white/15 hover:border-violet-300/60 overflow-hidden transition shadow-lg hover:-translate-y-0.5"
|
||||||
|
style={{ width: 88, aspectRatio: aspect }}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={effectiveFrameUrl(job.id, f)}
|
||||||
|
alt={`frame ${f.index}`}
|
||||||
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
{/* 左上:序号 */}
|
||||||
|
<div className="absolute top-1 left-1 text-[9.5px] font-bold text-white bg-violet-500/85 backdrop-blur px-1.5 py-0.5 rounded">
|
||||||
|
#{i + 1}
|
||||||
|
</div>
|
||||||
|
{/* 右上:清洗标记 */}
|
||||||
|
{cleaned && (
|
||||||
|
<div className="absolute top-1 right-1 text-[9px] text-white bg-cyan-500/85 backdrop-blur px-1 py-0.5 rounded font-bold" title="已清洗">
|
||||||
|
✨
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 底部:时间 + 元素数 */}
|
||||||
|
<div className="absolute bottom-0 right-0 left-0 px-1.5 py-0.5 text-[9px] font-mono text-white bg-gradient-to-t from-black/85 to-transparent flex items-center justify-between">
|
||||||
|
<span>{f.timestamp.toFixed(1)}s</span>
|
||||||
|
{totalElCount > 0 && (
|
||||||
|
<span className="inline-flex items-center gap-0.5">
|
||||||
|
<Sparkle className="h-2 w-2" />
|
||||||
|
{elementCount}/{totalElCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user