auto-save 2026-05-13 20:07 (~5)
This commit is contained in:
@@ -2,17 +2,19 @@
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
import { LayoutGrid, ChevronDown, ChevronUp, Sparkle } from "lucide-react"
|
||||
import { type Job, type KeyFrame, effectiveFrameUrl, hasCutout } from "@/lib/api"
|
||||
import { type Job, effectiveFrameUrl, hasCutout } from "@/lib/api"
|
||||
|
||||
interface Props {
|
||||
job: Job | null
|
||||
selectedFrames: Set<number>
|
||||
focusedFrame: number | null
|
||||
onFocusFrame: (idx: number | null) => void
|
||||
workbenchOpen?: boolean
|
||||
onOpenWorkbench?: (frameIdx?: number) => void
|
||||
onCloseWorkbench?: () => void
|
||||
}
|
||||
|
||||
export function StoryboardBar({ job, selectedFrames, focusedFrame, onFocusFrame, onOpenWorkbench }: Props) {
|
||||
export function StoryboardBar({ job, selectedFrames, focusedFrame, onFocusFrame, workbenchOpen = false, onOpenWorkbench, onCloseWorkbench }: Props) {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
useEffect(() => setMounted(true), [])
|
||||
@@ -64,6 +66,10 @@ export function StoryboardBar({ job, selectedFrames, focusedFrame, onFocusFrame,
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (workbenchOpen) {
|
||||
onCloseWorkbench?.()
|
||||
return
|
||||
}
|
||||
if (frames.length === 0) return
|
||||
const nextFrame = focusedFrame ?? frames[0].index
|
||||
if (focusedFrame === null) onFocusFrame(nextFrame)
|
||||
@@ -72,13 +78,17 @@ export function StoryboardBar({ job, selectedFrames, focusedFrame, onFocusFrame,
|
||||
}}
|
||||
disabled={frames.length === 0}
|
||||
className="text-[11px] px-2.5 py-1 rounded-md bg-gradient-to-r from-violet-500 to-pink-500 hover:from-violet-400 hover:to-pink-400 text-white inline-flex items-center gap-1 disabled:opacity-40 disabled:cursor-not-allowed font-medium shadow"
|
||||
title={frames.length === 0 ? "先到关键帧节点选用分镜" : "下拉展开分镜头编排"}
|
||||
title={frames.length === 0 ? "先到关键帧节点选用分镜" : workbenchOpen ? "收起分镜头编排明细" : "下拉展开分镜头编排"}
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
展开编排
|
||||
{workbenchOpen ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
||||
{workbenchOpen ? "收起编排" : "展开编排"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
onClick={() => {
|
||||
const nextCollapsed = !collapsed
|
||||
setCollapsed(nextCollapsed)
|
||||
if (nextCollapsed) onCloseWorkbench?.()
|
||||
}}
|
||||
className="text-white/50 hover:text-white text-[11px] inline-flex items-center gap-1"
|
||||
title={collapsed ? "展开" : "折叠"}
|
||||
>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client"
|
||||
import { useEffect, useState, useRef, type ReactNode } from "react"
|
||||
import { X, LayoutGrid, Loader2, Check, Wand2 } from "lucide-react"
|
||||
import { useEffect, useState, useRef } from "react"
|
||||
import { X, Loader2, Check, Wand2, GripHorizontal } from "lucide-react"
|
||||
import {
|
||||
type Job, type StoryboardScene, type ImageRef,
|
||||
effectiveFrameUrl, updateStoryboard, resolveImageRefUrl,
|
||||
updateStoryboard, resolveImageRefUrl,
|
||||
} from "@/lib/api"
|
||||
import { toast } from "sonner"
|
||||
|
||||
@@ -29,6 +29,7 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU
|
||||
const [form, setForm] = useState<StoryboardScene>(emptyScene())
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [savedTick, setSavedTick] = useState(0)
|
||||
const [panelHeight, setPanelHeight] = useState(320)
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// Esc 关闭
|
||||
@@ -91,98 +92,37 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU
|
||||
}, 600)
|
||||
}
|
||||
|
||||
const aspect = job.height > 0 ? `${job.width}/${job.height}` : "9/16"
|
||||
const clampPanelHeight = (height: number) => {
|
||||
const max = typeof window === "undefined" ? 680 : Math.max(300, window.innerHeight - 190)
|
||||
return Math.max(180, Math.min(max, Math.round(height)))
|
||||
}
|
||||
|
||||
const startResize = (e: React.PointerEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const startY = e.clientY
|
||||
const startHeight = panelHeight
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
setPanelHeight(clampPanelHeight(startHeight + ev.clientY - startY))
|
||||
}
|
||||
const onUp = () => {
|
||||
window.removeEventListener("pointermove", onMove)
|
||||
window.removeEventListener("pointerup", onUp)
|
||||
}
|
||||
window.addEventListener("pointermove", onMove)
|
||||
window.addEventListener("pointerup", onUp)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative z-20 h-[min(680px,calc(100vh-170px))] flex-shrink-0 border-b border-white/10 bg-black/90 backdrop-blur-xl flex flex-col shadow-2xl"
|
||||
style={{ animation: "drawer-in 0.2s cubic-bezier(0.32, 0.72, 0, 1)" }}
|
||||
className="relative z-20 flex-shrink-0 border-t border-white/5 border-b border-white/10 bg-black/70 backdrop-blur-xl shadow-2xl"
|
||||
style={{ height: panelHeight, animation: "drawer-in 0.2s cubic-bezier(0.32, 0.72, 0, 1)" }}
|
||||
>
|
||||
{/* Header */}
|
||||
<header className="flex items-center justify-between px-5 py-2.5 border-b border-white/10 bg-black/40 flex-shrink-0">
|
||||
<div className="flex items-center gap-2 text-white">
|
||||
<LayoutGrid className="h-4 w-4 text-violet-300" />
|
||||
<span className="text-[14px] font-semibold">分镜头编排工作台</span>
|
||||
<span className="text-[11px] text-white/40 font-mono ml-2">
|
||||
{frames.length} 分镜
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[11px] text-white/45 font-mono inline-flex items-center gap-1">
|
||||
{saving ? (<><Loader2 className="h-2.5 w-2.5 animate-spin" /> 保存中</>)
|
||||
: savedTick > 0 ? (<><Check className="h-2.5 w-2.5 text-emerald-300" /> 已自动保存</>)
|
||||
: "字段变更自动保存"}
|
||||
</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="h-8 px-3 rounded-md bg-white/10 hover:bg-white/20 text-white inline-flex items-center gap-1.5 text-[12px]"
|
||||
title="收起编排 (Esc)"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" /> 收起
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* 左侧分镜列表 */}
|
||||
<aside
|
||||
className="flex-shrink-0 border-r border-white/10 overflow-y-auto bg-black/20"
|
||||
style={{ width: 232 }}
|
||||
>
|
||||
{frames.length === 0 ? (
|
||||
<div className="p-4 text-[11px] text-white/40 leading-relaxed">
|
||||
暂无分镜 · 到关键帧节点缩略图点「选用此帧」
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2 space-y-1">
|
||||
{frames.map((f, i) => {
|
||||
const isFocused = focusedIdx === f.index
|
||||
const sb = f.storyboard
|
||||
return (
|
||||
<button
|
||||
key={f.index}
|
||||
onClick={() => setFocusedIdx(f.index)}
|
||||
className={`w-full text-left rounded-lg p-2 transition flex gap-2 items-start ${
|
||||
isFocused
|
||||
? "bg-violet-500/25 border border-violet-300/60"
|
||||
: "hover:bg-white/[0.04] border border-transparent"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="flex-shrink-0 rounded-md overflow-hidden bg-black"
|
||||
style={{ width: 64, aspectRatio: aspect }}
|
||||
>
|
||||
<img src={effectiveFrameUrl(job.id, f)} className="w-full h-full object-cover" alt="" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[11.5px] font-semibold text-white flex items-center gap-1.5">
|
||||
<span>分镜 {i + 1}</span>
|
||||
{sb?.duration ? (
|
||||
<span className="text-[9px] text-violet-300/80 font-mono">{sb.duration}s</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-[9.5px] text-white/40 font-mono">
|
||||
{f.timestamp.toFixed(2)}s · {f.elements?.length ?? 0} 元素
|
||||
</div>
|
||||
{sb?.subject && (
|
||||
<div className="text-[10px] text-white/65 truncate mt-0.5" title={sb.subject}>
|
||||
{sb.subject}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{/* 右侧详情 — 4 图槽 + 时长 */}
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
{!focusFrame ? (
|
||||
<div className="p-8 text-[13px] text-white/40">从左侧选一个分镜开始编排</div>
|
||||
) : (
|
||||
<div className="max-w-5xl mx-auto p-6 space-y-5">
|
||||
<div className="h-full overflow-y-auto pb-4">
|
||||
{!focusFrame ? (
|
||||
<div className="px-4 py-5 text-[12px] text-white/40">在上方选择一个分镜开始编排</div>
|
||||
) : (
|
||||
<div className="max-w-6xl mx-auto px-4 py-4 space-y-4">
|
||||
{/* 顶栏:分镜信息 + 剪贴板提示 + 时长 */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="text-[15.5px] font-semibold text-white">
|
||||
@@ -210,6 +150,18 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU
|
||||
/>
|
||||
秒
|
||||
</label>
|
||||
<span className="text-[11px] text-white/45 font-mono inline-flex items-center gap-1">
|
||||
{saving ? (<><Loader2 className="h-2.5 w-2.5 animate-spin" /> 保存中</>)
|
||||
: savedTick > 0 ? (<><Check className="h-2.5 w-2.5 text-emerald-300" /> 已保存</>)
|
||||
: "自动保存"}
|
||||
</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="h-7 px-2.5 rounded-md bg-white/10 hover:bg-white/20 text-white inline-flex items-center gap-1 text-[11.5px]"
|
||||
title="收起编排 (Esc)"
|
||||
>
|
||||
<X className="h-3 w-3" /> 收起
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -330,15 +282,17 @@ export function StoryboardWorkbench({ job, selectedFrames, open, onClose, onJobU
|
||||
下一阶段:基于 4 图槽 + 改造目标 + 时长,先生成符合 SKG 产品的首帧,再调 Seedance / Kling / Veo3 生成视频片段
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部快捷 */}
|
||||
<footer className="border-t border-white/10 px-5 py-1.5 text-[10px] text-white/40 font-mono text-center bg-black/40">
|
||||
ESC 收起编排 · 字段变更自动保存
|
||||
</footer>
|
||||
<button
|
||||
type="button"
|
||||
onPointerDown={startResize}
|
||||
className="absolute bottom-0 left-0 right-0 z-10 h-4 cursor-ns-resize border-t border-white/10 bg-black/50 text-white/45 hover:bg-violet-500/25 hover:text-white inline-flex items-center justify-center transition"
|
||||
title="拖动上推 / 下拉调整编排区高度"
|
||||
>
|
||||
<GripHorizontal className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user