auto-save 2026-05-14 03:20 (~4)
This commit is contained in:
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
121
web/app/page.tsx
121
web/app/page.tsx
@@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
============================================================ */
|
||||
|
||||
Reference in New Issue
Block a user