auto-save 2026-05-14 03:20 (~4)

This commit is contained in:
2026-05-14 03:20:46 +08:00
parent b8fa19aeaa
commit 2144c374bd
4 changed files with 376 additions and 27 deletions

View File

@@ -3024,6 +3024,19 @@
"type": "session-heartbeat",
"message": "Claude 会话活跃 · 最近命令claude · 1 项未提交变更 · 最近提交auto-save 2026-05-14 03:09 (~3)",
"files_changed": 1
},
{
"ts": "2026-05-14T03:15:11+08:00",
"type": "commit",
"message": "auto-save 2026-05-14 03:14 (~2)",
"hash": "b8fa19a",
"files_changed": 2
},
{
"ts": "2026-05-13T19:18:49Z",
"type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令codex · 3 项未提交变更 · 最近提交auto-save 2026-05-14 03:14 (~2)",
"files_changed": 3
}
]
}

View File

@@ -569,8 +569,8 @@
<h3>前端核心</h3>
<table>
<tbody>
<tr><td><code>web/app/page.tsx</code></td><td>产品工作台主状态jobs、activeJobId、selectedFrames、clipboard、ReactFlow 节点和边。</td></tr>
<tr><td><code>web/components/nodes/index.tsx</code></td><td>DAG 节点定义Input、VisualLab、Audio、Compose、KeyframePanel旧 Keyframe/Storyboard/VideoGen 组件保留但不再挂主画布。</td></tr>
<tr><td><code>web/app/page.tsx</code></td><td>产品工作台主状态jobs、activeJobId、selectedFrames、clipboard、ReactFlow 节点和边;负责打开/找回画布工作面板</td></tr>
<tr><td><code>web/components/nodes/index.tsx</code></td><td>DAG 节点定义Input、VisualLab、Audio、Compose,以及画布工作面板 KeyframePanel / VideoFramePanel旧 Keyframe/Storyboard/VideoGen 组件保留但不再挂主画布。</td></tr>
<tr><td><code>web/components/lightbox.tsx</code></td><td>镜头拆解和元素提取的主工作面板:清洗、识别、元素编辑、区域提取、抠图。</td></tr>
<tr><td><code>web/components/storyboard-bar.tsx</code></td><td>顶部分镜编排条:展示选入编排的关键帧,并作为唯一分镜导航。</td></tr>
<tr><td><code>web/components/storyboard-workbench.tsx</code></td><td>顶部分镜编排条下方的明细区4 图槽、改造目标、时长、自动保存。</td></tr>
@@ -596,6 +596,7 @@
web/app/page.tsx
-> ReactFlow 节点web/components/nodes/index.tsx
-> 主画布Input → VisualLab / Audio → Compose
-> 画布内视频抽帧面板InputNode 双击视频缩略图打开 videoFramePanel
-> 画布内镜头拆解面板VisualLabNode 打开 keyframePanel内嵌 web/components/lightbox.tsx
-> 分镜工作台web/components/storyboard-workbench.tsx底层保留
-> API 契约web/lib/api.ts
@@ -722,9 +723,9 @@ api/main.py
<tbody>
<tr>
<td><span class="tag blue">输入 Input</span></td>
<td>创建/上传任务,显示视频就绪,触发解析。</td>
<td>创建/上传任务,显示视频就绪,触发解析;双击视频缩略图打开画布内抽帧面板</td>
<td>不要自动一路跑到 ASR 或生图;用户需要控制解析节奏。</td>
<td><code>page.tsx</code><code>InputNode</code><code>api/main.py</code></td>
<td><code>page.tsx</code><code>InputNode</code><code>VideoFramePanelNode</code><code>api/main.py</code></td>
</tr>
<tr>
<td><span class="tag violet">画面工作台 Visual Lab</span></td>
@@ -816,6 +817,18 @@ api/main.py
<h2>变更记录</h2>
<p>这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。</p>
<div class="changelog">
<article class="change">
<header>
<h3>2026-05-14 · 输入视频双击改为画布抽帧面板</h3>
<span class="tag violet">Canvas Panel</span>
<span class="tag blue">Input</span>
</header>
<div class="body">
<p><strong>问题:</strong>输入视频缩略图双击原来只在 Input 节点上方展开一个临时播放器,不能作为无限画布工作台移动、找回或吸附,后续其他板块也无法复用这种交互。</p>
<p><strong>改动:</strong>新增 <code>videoFramePanel</code> ReactFlow 节点和 <code>VideoFramePanelNode</code>。双击输入视频缩略图会打开/找回画布内抽帧面板,面板可拖动、右下角缩放,也可一键吸附到左侧、右侧或底部边缘;面板内支持播放、时间轴定位、当前时间抽帧和查看已抽关键帧。</p>
<p><strong>影响:</strong><code>web/app/page.tsx</code><code>web/components/nodes/index.tsx</code><code>docs/source-analysis.html</code>。原固定全屏 <code>VideoLightbox</code> 不再从 Input 双击路径进入;后续处理板块应复用同类画布工作面板语义。</p>
</div>
</article>
<article class="change">
<header>
<h3>2026-05-14 · Hover 大预览尺寸信息增强</h3>

View File

@@ -11,6 +11,8 @@ import { LayoutGrid } from "lucide-react"
import {
InputNode, VisualLabNode, AudioNode,
ComposeNode, KeyframePanelNode,
VideoFramePanelNode,
type CanvasPanelDock,
type NodeData,
} from "@/components/nodes"
import { ThemeToggle } from "@/components/theme-toggle"
@@ -19,7 +21,6 @@ import {
deleteGeneratedVideo, deleteCutout, generateStoryboardVideo,
type Job, type ImageRef, type StoryboardScene,
} from "@/lib/api"
import { VideoLightbox } from "@/components/video-lightbox"
const NODE_TYPES = {
input: InputNode,
@@ -27,9 +28,12 @@ const NODE_TYPES = {
audio: AudioNode,
compose: ComposeNode,
keyframePanel: KeyframePanelNode,
videoFramePanel: VideoFramePanelNode,
}
const KEYFRAME_PANEL_ID = "keyframe-detail-panel"
const VIDEO_FRAME_PANEL_ID = "video-frame-panel"
const FLOATING_PANEL_IDS = new Set([KEYFRAME_PANEL_ID, VIDEO_FRAME_PANEL_ID])
// 合并 input + download + split 为一个节点
// 分叉:上路 input → visual lab ↘
@@ -85,11 +89,15 @@ export default function Home() {
const [expandedFrame, setExpandedFrame] = useState<number | null>(null)
const [framePanelScale, setFramePanelScale] = useState(1)
const [framePanelPinned, setFramePanelPinned] = useState(false)
const [videoLightboxOpen, setVideoLightboxOpen] = useState(false)
const [videoPanelJobId, setVideoPanelJobId] = useState<string | null>(null)
const [videoPanelScale, setVideoPanelScale] = useState(1)
const [videoPanelDock, setVideoPanelDock] = useState<CanvasPanelDock>("canvas")
const [videoPanelOpenTick, setVideoPanelOpenTick] = useState(0)
const [storyboardFrame, setStoryboardFrame] = useState<number | null>(null)
const [workbenchOpen, setWorkbenchOpen] = useState(false)
const [clipboard, setClipboard] = useState<ImageRef | null>(null)
const flowRef = useRef<any>(null)
const lastVideoPanelFocusKey = useRef("")
// 把 setJob(prev=>...) 翻译成 setJobs 里更新当前 active
const setJob = useCallback((updater: Job | ((prev: Job | null) => Job | null) | null) => {
@@ -165,16 +173,31 @@ export default function Home() {
}
}, [job?.id])
const handleAddManualFrame = useCallback(async (t: number) => {
if (!job) return
const handleAddManualFrameForJob = useCallback(async (jobId: string, t: number) => {
try {
const updated = await addManualFrame(job.id, t)
setJob(updated)
const updated = await addManualFrame(jobId, t)
setJobs((prev) => prev.map((item) => item.id === updated.id ? updated : item))
setActiveJobId(updated.id)
toast.success(`已加帧 @ ${t.toFixed(1)}s · 共 ${updated.frames.length}`)
} catch (e) {
toast.error("加帧失败:" + (e instanceof Error ? e.message : String(e)))
}
}, [job?.id])
}, [])
const handleAddManualFrame = useCallback(async (t: number) => {
if (!job) return
await handleAddManualFrameForJob(job.id, t)
}, [job?.id, handleAddManualFrameForJob])
const handleOpenVideoPanel = useCallback((jobId: string) => {
setActiveJobId(jobId)
setVideoPanelJobId(jobId)
setVideoPanelOpenTick((n) => n + 1)
}, [])
const handleVideoPanelScaleChange = useCallback((scale: number) => {
setVideoPanelScale(Math.max(0.65, Math.min(1.6, Number(scale.toFixed(2)))))
}, [])
const handleToggleFrame = useCallback((idx: number) => {
setSelectedFrames((prev) => {
@@ -236,6 +259,7 @@ export default function Home() {
const handleDeleteJob = useCallback(async (jobId: string) => {
try {
await deleteJob(jobId)
setVideoPanelJobId((prev) => prev === jobId ? null : prev)
setJobs((prev) => {
const idx = prev.findIndex((x) => x.id === jobId)
const next = prev.filter((x) => x.id !== jobId)
@@ -244,7 +268,6 @@ export default function Home() {
setActiveJobId(fallback?.id ?? null)
setSelectedFrames(new Set())
setExpandedFrame(null)
setVideoLightboxOpen(false)
setStoryboardFrame(null)
setWorkbenchOpen(false)
}
@@ -467,6 +490,9 @@ export default function Home() {
expandedFrame,
framePanelScale,
framePanelPinned,
videoPanelJobId,
videoPanelScale,
videoPanelDock,
onSubmitUrl: handleSubmit,
onUploadFile: handleUpload,
onAnalyze: handleAnalyze,
@@ -477,7 +503,11 @@ export default function Home() {
onFramePanelPinnedChange: setFramePanelPinned,
onCloseExpandedFrame: () => setExpandedFrame(null),
onAddManualFrame: handleAddManualFrame,
onOpenVideoLightbox: () => setVideoLightboxOpen(true),
onAddManualFrameForJob: handleAddManualFrameForJob,
onOpenVideoPanel: handleOpenVideoPanel,
onCloseVideoPanel: () => setVideoPanelJobId(null),
onVideoPanelScaleChange: handleVideoPanelScaleChange,
onVideoPanelDockChange: setVideoPanelDock,
onSwitchJob: handleSwitchJob,
onJobUpdate: setJob as any,
onDeleteJob: handleDeleteJob,
@@ -493,7 +523,7 @@ export default function Home() {
onCopyImage: handleCopyImage,
pinnedNodes,
onToggleNodePin: handleToggleNodePin,
}), [job, jobs, activeJobId, submitting, analyzing, selectedFrames, expandedFrame, framePanelScale, framePanelPinned, handleSubmit, handleUpload, handleAnalyze, handleToggleFrame, handleOpenFramePanel, handleFramePanelScaleChange, handleAddManualFrame, handleSwitchJob, setJob, handleDeleteJob, handleDeleteFrame, handleDeleteGenerated, handleDeleteVideo, handleDeleteCutout, handleCopyImage, pinnedNodes, handleToggleNodePin])
}), [job, jobs, activeJobId, submitting, analyzing, selectedFrames, expandedFrame, framePanelScale, framePanelPinned, videoPanelJobId, videoPanelScale, videoPanelDock, handleSubmit, handleUpload, handleAnalyze, handleToggleFrame, handleOpenFramePanel, handleFramePanelScaleChange, handleAddManualFrame, handleAddManualFrameForJob, handleOpenVideoPanel, handleVideoPanelScaleChange, handleSwitchJob, setJob, handleDeleteJob, handleDeleteFrame, handleDeleteGenerated, handleDeleteVideo, handleDeleteCutout, handleCopyImage, pinnedNodes, handleToggleNodePin])
// 用 useNodesState 让 ReactFlow 自己管位置(避免轮询时重置 drag
const savedSizes = useMemo(() => loadNodeSizes(), [])
@@ -519,7 +549,7 @@ export default function Home() {
// pinned 变化时同步每个节点 draggable
useEffect(() => {
setNodes((prev) => prev.map((n) =>
n.id === KEYFRAME_PANEL_ID ? n : { ...n, draggable: !pinnedNodes.has(n.id) },
FLOATING_PANEL_IDS.has(n.id) ? n : { ...n, draggable: !pinnedNodes.has(n.id) },
))
}, [pinnedNodes, setNodes])
@@ -563,7 +593,7 @@ export default function Home() {
}
setNodes((prev) => prev.map((n) => {
if (n.id === KEYFRAME_PANEL_ID) return n
if (FLOATING_PANEL_IDS.has(n.id)) return n
const p = positions[n.id]
if (!p) return n
return { ...n, position: { x: p.x, y: p.y } }
@@ -575,7 +605,7 @@ export default function Home() {
// 首次:等所有节点都被 ReactFlow 测量到n.measured 出现)后自动排版一次,避免叠在一起
useEffect(() => {
if (initialLayoutDone.current) return
const main = nodes.filter((n) => n.id !== KEYFRAME_PANEL_ID)
const main = nodes.filter((n) => !FLOATING_PANEL_IDS.has(n.id))
if (main.length === 0) return
const allMeasured = main.every((n) => {
const m = (n as any).measured as { width?: number; height?: number } | undefined
@@ -590,7 +620,7 @@ export default function Home() {
useEffect(() => {
const sizes: Record<string, NodeSize> = {}
for (const n of nodes) {
if (n.id === KEYFRAME_PANEL_ID) continue
if (FLOATING_PANEL_IDS.has(n.id)) continue
const w = typeof n.width === "number" ? Math.round(n.width) : undefined
const h = typeof n.height === "number" ? Math.round(n.height) : undefined
if (w === undefined && h === undefined) continue
@@ -662,6 +692,61 @@ export default function Home() {
}
}, [job?.id, expandedFrame, framePanelPinned, nodeData, setNodes])
// 视频抽帧面板也是独立 ReactFlow 节点:默认在 Input 附近打开,可拖动;吸附后走 portal 固定到屏幕边缘。
useEffect(() => {
const panelJob = videoPanelJobId ? jobs.find((j) => j.id === videoPanelJobId) ?? null : null
if (!panelJob?.video_url) {
setNodes((prev) => prev.filter((n) => n.id !== VIDEO_FRAME_PANEL_ID))
return
}
const focusKey = `${videoPanelJobId}:${videoPanelOpenTick}:${videoPanelDock}`
let panelWasCreated = false
setNodes((prev) => {
const inputNode = prev.find((n) => n.id === "input")
const defaultPosition = {
x: inputNode?.position.x ?? 40,
y: (inputNode?.position.y ?? 240) - 650,
}
const exists = prev.some((n) => n.id === VIDEO_FRAME_PANEL_ID)
if (exists) {
return prev.map((n) => n.id === VIDEO_FRAME_PANEL_ID
? {
...n,
data: nodeData,
draggable: videoPanelDock === "canvas",
dragHandle: videoPanelDock === "canvas" ? ".video-frame-panel-drag" : undefined,
}
: n,
)
}
panelWasCreated = true
return [
...prev,
{
id: VIDEO_FRAME_PANEL_ID,
type: "videoFramePanel",
position: defaultPosition,
data: nodeData,
draggable: videoPanelDock === "canvas",
dragHandle: videoPanelDock === "canvas" ? ".video-frame-panel-drag" : undefined,
selectable: true,
},
]
})
if (videoPanelDock === "canvas" && (panelWasCreated || lastVideoPanelFocusKey.current !== focusKey)) {
lastVideoPanelFocusKey.current = focusKey
window.setTimeout(() => {
flowRef.current?.fitView?.({
nodes: [{ id: VIDEO_FRAME_PANEL_ID }, { id: "input" }],
padding: 0.18,
duration: 260,
})
}, 0)
}
}, [videoPanelJobId, videoPanelOpenTick, videoPanelDock, jobs, nodeData, setNodes])
// 边的 animated 状态跟 Job 进度联动
useEffect(() => {
const doneOf: Record<string, boolean> = {
@@ -721,14 +806,6 @@ export default function Home() {
<Toaster theme="system" position="bottom-center" />
{/* Video lightbox — InputNode 缩略图点击进入 */}
<VideoLightbox
jobId={job?.id ?? null}
open={videoLightboxOpen}
onClose={() => setVideoLightboxOpen(false)}
onAddFrame={handleAddManualFrame}
/>
</main>
</>
)

View File

@@ -51,7 +51,6 @@ export interface NodeData {
onCloseVideoPanel?: () => void
onVideoPanelScaleChange?: (scale: number) => void
onVideoPanelDockChange?: (dock: CanvasPanelDock) => void
onOpenVideoLightbox: () => void
onSwitchJob: (id: string) => void
onJobUpdate: (j: Job) => void
onDeleteJob?: (id: string) => void
@@ -627,6 +626,253 @@ export function InputNode({ data, selected }: NodeProps<{ data: NodeData }> | an
)
}
/* ============================================================
1b. VideoFramePanelNode — 画布内视频抽帧工作面板
============================================================ */
export function VideoFramePanelNode({ data }: any) {
const d: NodeData = data
const { getZoom } = useReactFlow()
const panelJob = d.videoPanelJobId
? d.jobs.find((j) => j.id === d.videoPanelJobId) ?? null
: null
const videoRef = useRef<HTMLVideoElement>(null)
const scale = d.videoPanelScale ?? 1
const dock = d.videoPanelDock ?? "canvas"
const docked = dock !== "canvas"
const [currentT, setCurrentT] = useState(0)
const [adding, setAdding] = useState(false)
useEffect(() => {
setCurrentT(0)
setAdding(false)
}, [panelJob?.id])
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") d.onCloseVideoPanel?.()
}
window.addEventListener("keydown", onKeyDown)
return () => window.removeEventListener("keydown", onKeyDown)
}, [d])
if (!panelJob?.video_url) return null
const panelWidth = Math.round(760 * scale)
const panelHeight = Math.round(620 * scale)
const bodyHeight = Math.max(500, panelHeight - 28)
const duration = panelJob.duration ?? 0
const frames = [...panelJob.frames].sort((a, b) => a.timestamp - b.timestamp)
const aspect = panelJob.width && panelJob.height ? `${panelJob.width}/${panelJob.height}` : "9/16"
const dockText: Record<CanvasPanelDock, string> = {
canvas: "画布模式",
left: "吸附左侧",
right: "吸附右侧",
bottom: "吸附底部",
}
const setScale = (next: number) => {
const clamped = Math.max(0.65, Math.min(1.6, Number(next.toFixed(2))))
d.onVideoPanelScaleChange?.(clamped)
}
const startResize = (e: ReactPointerEvent) => {
e.preventDefault()
e.stopPropagation()
const startX = e.clientX
const startY = e.clientY
const startScale = scale
const zoom = docked ? 1 : getZoom()
const onMove = (ev: PointerEvent) => {
const dx = (ev.clientX - startX) / zoom
const dy = (ev.clientY - startY) / zoom
const delta = Math.abs(dx) > Math.abs(dy) ? dx / 760 : dy / 620
setScale(startScale + delta)
}
const onUp = () => {
window.removeEventListener("pointermove", onMove)
window.removeEventListener("pointerup", onUp)
}
window.addEventListener("pointermove", onMove)
window.addEventListener("pointerup", onUp)
}
const seekTo = (next: number) => {
const t = clamp(next, 0, Math.max(duration, 0))
setCurrentT(t)
if (videoRef.current) videoRef.current.currentTime = t
}
const addCurrentFrame = async () => {
const t = videoRef.current?.currentTime ?? currentT
setAdding(true)
try {
if (d.onAddManualFrameForJob) await d.onAddManualFrameForJob(panelJob.id, t)
else await d.onAddManualFrame(t)
} finally {
setAdding(false)
}
}
const dockButtonClass = (value: CanvasPanelDock) =>
`nodrag inline-flex h-6 w-6 items-center justify-center rounded transition ${
dock === value
? "bg-white text-violet-700 shadow"
: "bg-white/10 text-white/75 hover:bg-white/20 hover:text-white"
}`
const panel = (
<div
className="relative overflow-hidden rounded-2xl border border-white/15 bg-black/78 text-white shadow-2xl backdrop-blur-xl"
style={{
width: panelWidth,
height: panelHeight,
maxWidth: "calc(100vw - 32px)",
maxHeight: "calc(100vh - 84px)",
boxShadow: "0 30px 80px -20px rgba(0,0,0,0.75), 0 0 0 1px rgba(255,255,255,0.05)",
}}
>
<div className={`video-frame-panel-drag flex h-7 items-center justify-between bg-gradient-to-r from-indigo-500 to-violet-500 px-3 text-white ${docked ? "cursor-default" : "cursor-move"}`}>
<div className="flex min-w-0 items-center gap-2">
<FileVideo className="h-3.5 w-3.5 shrink-0" />
<span className="truncate text-[12px] font-semibold"> · Input</span>
<span className="shrink-0 text-[10px] font-mono text-white/65">
{panelJob.width}×{panelJob.height} · {duration.toFixed(1)}s
</span>
</div>
<div className="flex items-center gap-1.5">
<span className="mr-1 text-[10px] text-white/60">
{dockText[dock]}
</span>
<button type="button" onClick={(e) => { e.stopPropagation(); d.onVideoPanelDockChange?.("canvas") }} className={dockButtonClass("canvas")} title="回到画布模式">
<Move className="h-3.5 w-3.5" />
</button>
<button type="button" onClick={(e) => { e.stopPropagation(); d.onVideoPanelDockChange?.("left") }} className={dockButtonClass("left")} title="吸附到左侧">
<PanelLeft className="h-3.5 w-3.5" />
</button>
<button type="button" onClick={(e) => { e.stopPropagation(); d.onVideoPanelDockChange?.("right") }} className={dockButtonClass("right")} title="吸附到右侧">
<PanelRight className="h-3.5 w-3.5" />
</button>
<button type="button" onClick={(e) => { e.stopPropagation(); d.onVideoPanelDockChange?.("bottom") }} className={dockButtonClass("bottom")} title="吸附到底部">
<PanelBottom className="h-3.5 w-3.5" />
</button>
<button type="button" onClick={(e) => { e.stopPropagation(); setScale(scale - 0.1) }} className="nodrag h-6 w-6 rounded bg-white/10 text-white/85 hover:bg-white/20 hover:text-white inline-flex items-center justify-center text-[14px] leading-none" title="缩小面板">
-
</button>
<button type="button" onClick={(e) => { e.stopPropagation(); setScale(1) }} className="nodrag h-6 min-w-10 rounded bg-white/10 px-1.5 text-[10px] font-mono text-white/80 hover:bg-white/20 hover:text-white inline-flex items-center justify-center" title="重置为 100%">
{Math.round(scale * 100)}%
</button>
<button type="button" onClick={(e) => { e.stopPropagation(); setScale(scale + 0.1) }} className="nodrag h-6 w-6 rounded bg-white/10 text-white/85 hover:bg-white/20 hover:text-white inline-flex items-center justify-center text-[14px] leading-none" title="放大面板">
+
</button>
<button type="button" onClick={(e) => { e.stopPropagation(); d.onCloseVideoPanel?.() }} className="nodrag h-6 w-6 rounded bg-white/10 text-white/80 hover:bg-white/20 hover:text-white inline-flex items-center justify-center" title="关闭">
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>
<div className="nodrag nowheel grid gap-3 p-3 lg:grid-cols-[minmax(260px,0.8fr)_minmax(280px,1fr)]" style={{ height: bodyHeight }} onWheel={(e) => e.stopPropagation()}>
<div className="flex min-h-0 items-center justify-center overflow-hidden rounded-xl border border-white/10 bg-black">
<video
ref={videoRef}
src={videoUrl(panelJob.id)}
controls
autoPlay
playsInline
preload="auto"
onTimeUpdate={(e) => setCurrentT((e.target as HTMLVideoElement).currentTime)}
className="max-h-full max-w-full bg-black"
style={{ aspectRatio: aspect }}
/>
</div>
<div className="flex min-h-0 flex-col gap-3 overflow-hidden">
<div className="rounded-xl border border-white/10 bg-white/[0.04] p-3">
<div className="mb-2 flex items-center justify-between gap-3">
<div className="font-mono text-[12px] text-white/72">
<span className="text-white">{currentT.toFixed(2)}s</span>
</div>
<div className="font-mono text-[11px] text-white/45">
{frames.length}
</div>
</div>
<input
type="range"
min={0}
max={Math.max(duration, 0.1)}
step={0.01}
value={clamp(currentT, 0, Math.max(duration, 0.1))}
onChange={(e) => seekTo(Number(e.target.value))}
className="w-full accent-violet-400"
aria-label="视频时间轴"
/>
<button
type="button"
disabled={adding}
onClick={(e) => { e.stopPropagation(); void addCurrentFrame() }}
className="mt-3 w-full rounded-lg bg-emerald-500 px-4 py-2.5 text-[13px] font-semibold text-white shadow-lg shadow-emerald-950/35 transition hover:bg-emerald-400 disabled:cursor-wait disabled:opacity-55 inline-flex items-center justify-center gap-2"
>
{adding ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
{adding ? "抽帧中…" : `${currentT.toFixed(1)}s 加为关键帧`}
</button>
</div>
<div className="min-h-0 flex-1 overflow-hidden rounded-xl border border-white/10 bg-white/[0.035] p-3">
<div className="mb-2 flex items-center justify-between">
<div className="text-[12px] font-semibold text-white/85"></div>
<div className="text-[10px] font-mono text-white/40"></div>
</div>
{frames.length > 0 ? (
<div className="grid max-h-full grid-cols-3 gap-2 overflow-y-auto pr-1">
{frames.map((f) => (
<button
key={f.index}
type="button"
onClick={(e) => { e.stopPropagation(); seekTo(f.timestamp) }}
className="group overflow-hidden rounded-md border border-white/10 bg-black text-left transition hover:border-violet-300/70"
title={`跳到 ${f.timestamp.toFixed(2)}s`}
>
<img src={effectiveFrameUrl(panelJob.id, f)} alt="" className="aspect-[9/16] w-full object-cover opacity-90 transition group-hover:opacity-100" />
<div className="px-1.5 py-1 font-mono text-[9.5px] text-white/65">
{f.timestamp.toFixed(1)}s
</div>
</button>
))}
</div>
) : (
<div className="flex h-full min-h-32 items-center justify-center rounded-lg border border-dashed border-white/10 text-[12px] text-white/35">
</div>
)}
</div>
</div>
</div>
<button
type="button"
onPointerDown={startResize}
className="nodrag absolute bottom-0 right-0 z-[5] h-7 w-7 cursor-nwse-resize rounded-tl-md bg-white/10 text-white/65 hover:bg-violet-400/35 hover:text-white inline-flex items-center justify-center"
title="拖动右下角缩放面板"
>
<Maximize2 className="h-3.5 w-3.5" />
</button>
</div>
)
if (docked && typeof document !== "undefined") {
const fixedStyle =
dock === "left"
? { left: 16, top: 72 }
: dock === "right"
? { right: 16, top: 72 }
: { left: "50%", bottom: 16, transform: "translateX(-50%)" }
return createPortal(
<div className="fixed z-[240]" style={fixedStyle}>
{panel}
</div>,
document.body,
)
}
return panel
}
/* ============================================================
2. DownloadNode
============================================================ */