auto-save 2026-05-13 19:28 (~4)
This commit is contained in:
@@ -2195,6 +2195,13 @@
|
|||||||
"type": "session-heartbeat",
|
"type": "session-heartbeat",
|
||||||
"message": "Codex 会话活跃 · 最近命令:codex · 1 项未提交变更 · 最近提交:auto-save 2026-05-13 19:17 (~4)",
|
"message": "Codex 会话活跃 · 最近命令:codex · 1 项未提交变更 · 最近提交:auto-save 2026-05-13 19:17 (~4)",
|
||||||
"files_changed": 1
|
"files_changed": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-05-13T19:23:17+08:00",
|
||||||
|
"type": "commit",
|
||||||
|
"message": "auto-save 2026-05-13 19:23 (~4)",
|
||||||
|
"hash": "1f9c094",
|
||||||
|
"files_changed": 4
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -830,6 +830,39 @@ api/main.py
|
|||||||
<h2>变更记录</h2>
|
<h2>变更记录</h2>
|
||||||
<p>这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。</p>
|
<p>这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。</p>
|
||||||
<div class="changelog">
|
<div class="changelog">
|
||||||
|
<article class="change">
|
||||||
|
<header>
|
||||||
|
<h3>2026-05-13 · 关键帧详情面板增加钉住按钮</h3>
|
||||||
|
<span class="tag orange">KeyframePanelNode</span>
|
||||||
|
</header>
|
||||||
|
<div class="body">
|
||||||
|
<p><strong>问题:</strong>面板可以拖动后,用户仍可能误拖;切换图片时希望保持固定工作位置。</p>
|
||||||
|
<p><strong>改动:</strong>在标题栏增加钉子按钮。钉住后面板节点禁止拖动,切换关键帧只切换内容不移动位置;取消钉住后可继续拖动。</p>
|
||||||
|
<p><strong>影响:</strong><code>web/app/page.tsx</code>、<code>web/components/nodes/index.tsx</code>。</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article class="change">
|
||||||
|
<header>
|
||||||
|
<h3>2026-05-13 · 切换关键帧不再重置详情面板位置</h3>
|
||||||
|
<span class="tag orange">KeyframePanelNode</span>
|
||||||
|
</header>
|
||||||
|
<div class="body">
|
||||||
|
<p><strong>问题:</strong>用户把关键帧详情面板拖到合适位置后,再点击下一张关键帧会把面板拉回默认位置,造成视觉疲劳。</p>
|
||||||
|
<p><strong>改动:</strong>已打开的面板只切换内容,不移动位置;只有面板不存在、首次打开时才放到默认位置并自动聚焦。</p>
|
||||||
|
<p><strong>影响:</strong><code>web/app/page.tsx</code>;关闭后重新打开仍会出现在默认位置。</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article class="change">
|
||||||
|
<header>
|
||||||
|
<h3>2026-05-13 · 关键帧详情面板增加缩放控制</h3>
|
||||||
|
<span class="tag orange">KeyframePanelNode</span>
|
||||||
|
</header>
|
||||||
|
<div class="body">
|
||||||
|
<p><strong>问题:</strong>关键帧详情面板作为画布节点后可以随画布缩放,但面板自身没有尺寸控制,用户无法单独放大或缩小它。</p>
|
||||||
|
<p><strong>改动:</strong>在面板标题栏增加 <code>-</code>、百分比重置、<code>+</code> 控制,支持 75% 到 135% 的面板级缩放。</p>
|
||||||
|
<p><strong>影响:</strong><code>web/app/page.tsx</code>、<code>web/components/nodes/index.tsx</code>;点击新关键帧仍会找回到默认位置,缩放比例保留。</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
<article class="change">
|
<article class="change">
|
||||||
<header>
|
<header>
|
||||||
<h3>2026-05-13 · 关键帧详情从固定左侧抽屉迁到无限画布</h3>
|
<h3>2026-05-13 · 关键帧详情从固定左侧抽屉迁到无限画布</h3>
|
||||||
|
|||||||
@@ -67,7 +67,8 @@ export default function Home() {
|
|||||||
const [analyzing, setAnalyzing] = useState(false)
|
const [analyzing, setAnalyzing] = useState(false)
|
||||||
const [selectedFrames, setSelectedFrames] = useState<Set<number>>(new Set())
|
const [selectedFrames, setSelectedFrames] = useState<Set<number>>(new Set())
|
||||||
const [expandedFrame, setExpandedFrame] = useState<number | null>(null)
|
const [expandedFrame, setExpandedFrame] = useState<number | null>(null)
|
||||||
const [framePanelResetNonce, setFramePanelResetNonce] = useState(0)
|
const [framePanelScale, setFramePanelScale] = useState(1)
|
||||||
|
const [framePanelPinned, setFramePanelPinned] = useState(false)
|
||||||
const [videoLightboxOpen, setVideoLightboxOpen] = useState(false)
|
const [videoLightboxOpen, setVideoLightboxOpen] = useState(false)
|
||||||
const [storyboardFrame, setStoryboardFrame] = useState<number | null>(null)
|
const [storyboardFrame, setStoryboardFrame] = useState<number | null>(null)
|
||||||
const [workbenchOpen, setWorkbenchOpen] = useState(false)
|
const [workbenchOpen, setWorkbenchOpen] = useState(false)
|
||||||
@@ -170,7 +171,10 @@ export default function Home() {
|
|||||||
|
|
||||||
const handleOpenFramePanel = useCallback((idx: number) => {
|
const handleOpenFramePanel = useCallback((idx: number) => {
|
||||||
setExpandedFrame(idx)
|
setExpandedFrame(idx)
|
||||||
setFramePanelResetNonce((n) => n + 1)
|
}, [])
|
||||||
|
|
||||||
|
const handleFramePanelScaleChange = useCallback((scale: number) => {
|
||||||
|
setFramePanelScale(Math.max(0.75, Math.min(1.35, Number(scale.toFixed(2)))))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleDeleteFrame = useCallback(async (idx: number) => {
|
const handleDeleteFrame = useCallback(async (idx: number) => {
|
||||||
@@ -281,12 +285,16 @@ export default function Home() {
|
|||||||
analyzing,
|
analyzing,
|
||||||
selectedFrames,
|
selectedFrames,
|
||||||
expandedFrame,
|
expandedFrame,
|
||||||
|
framePanelScale,
|
||||||
|
framePanelPinned,
|
||||||
onSubmitUrl: handleSubmit,
|
onSubmitUrl: handleSubmit,
|
||||||
onUploadFile: handleUpload,
|
onUploadFile: handleUpload,
|
||||||
onAnalyze: handleAnalyze,
|
onAnalyze: handleAnalyze,
|
||||||
onToggleFrame: handleToggleFrame,
|
onToggleFrame: handleToggleFrame,
|
||||||
onExpandFrame: setExpandedFrame,
|
onExpandFrame: setExpandedFrame,
|
||||||
onOpenFramePanel: handleOpenFramePanel,
|
onOpenFramePanel: handleOpenFramePanel,
|
||||||
|
onFramePanelScaleChange: handleFramePanelScaleChange,
|
||||||
|
onFramePanelPinnedChange: setFramePanelPinned,
|
||||||
onCloseExpandedFrame: () => setExpandedFrame(null),
|
onCloseExpandedFrame: () => setExpandedFrame(null),
|
||||||
onAddManualFrame: handleAddManualFrame,
|
onAddManualFrame: handleAddManualFrame,
|
||||||
onOpenVideoLightbox: () => setVideoLightboxOpen(true),
|
onOpenVideoLightbox: () => setVideoLightboxOpen(true),
|
||||||
@@ -300,7 +308,7 @@ export default function Home() {
|
|||||||
setWorkbenchOpen(true)
|
setWorkbenchOpen(true)
|
||||||
},
|
},
|
||||||
onCopyImage: handleCopyImage,
|
onCopyImage: handleCopyImage,
|
||||||
}), [job, jobs, activeJobId, submitting, analyzing, selectedFrames, expandedFrame, handleSubmit, handleUpload, handleAnalyze, handleToggleFrame, handleOpenFramePanel, handleAddManualFrame, handleSwitchJob, setJob, handleDeleteFrame, handleDeleteGenerated, handleCopyImage])
|
}), [job, jobs, activeJobId, submitting, analyzing, selectedFrames, expandedFrame, framePanelScale, framePanelPinned, handleSubmit, handleUpload, handleAnalyze, handleToggleFrame, handleOpenFramePanel, handleFramePanelScaleChange, handleAddManualFrame, handleSwitchJob, setJob, handleDeleteFrame, handleDeleteGenerated, handleCopyImage])
|
||||||
|
|
||||||
// 用 useNodesState 让 ReactFlow 自己管位置(避免轮询时重置 drag)
|
// 用 useNodesState 让 ReactFlow 自己管位置(避免轮询时重置 drag)
|
||||||
const [nodes, setNodes, onNodesChange] = useNodesState<Node>(
|
const [nodes, setNodes, onNodesChange] = useNodesState<Node>(
|
||||||
@@ -324,16 +332,14 @@ export default function Home() {
|
|||||||
}, [nodeData, setNodes])
|
}, [nodeData, setNodes])
|
||||||
|
|
||||||
// 关键帧详情面板是独立 ReactFlow 节点:可拖动、跟随画布缩放。
|
// 关键帧详情面板是独立 ReactFlow 节点:可拖动、跟随画布缩放。
|
||||||
// 再次点击任意关键帧缩略图时,重新放回关键帧节点左侧附近,避免拖丢后找不到。
|
// 已打开时点击其他关键帧只切换内容,不移动用户拖好的面板位置。
|
||||||
const panelResetHandledRef = useRef(0)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!job || expandedFrame === null) {
|
if (!job || expandedFrame === null) {
|
||||||
setNodes((prev) => prev.filter((n) => n.id !== KEYFRAME_PANEL_ID))
|
setNodes((prev) => prev.filter((n) => n.id !== KEYFRAME_PANEL_ID))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldReset = panelResetHandledRef.current !== framePanelResetNonce
|
let shouldFocusNewPanel = false
|
||||||
panelResetHandledRef.current = framePanelResetNonce
|
|
||||||
setNodes((prev) => {
|
setNodes((prev) => {
|
||||||
const keyframeNode = prev.find((n) => n.id === "keyframe")
|
const keyframeNode = prev.find((n) => n.id === "keyframe")
|
||||||
const inputNode = prev.find((n) => n.id === "input")
|
const inputNode = prev.find((n) => n.id === "input")
|
||||||
@@ -347,13 +353,13 @@ export default function Home() {
|
|||||||
? {
|
? {
|
||||||
...n,
|
...n,
|
||||||
data: nodeData,
|
data: nodeData,
|
||||||
position: shouldReset ? defaultPosition : n.position,
|
draggable: !framePanelPinned,
|
||||||
draggable: true,
|
dragHandle: framePanelPinned ? undefined : ".keyframe-panel-drag",
|
||||||
dragHandle: ".keyframe-panel-drag",
|
|
||||||
}
|
}
|
||||||
: n,
|
: n,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
shouldFocusNewPanel = true
|
||||||
return [
|
return [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
@@ -361,13 +367,13 @@ export default function Home() {
|
|||||||
type: "keyframePanel",
|
type: "keyframePanel",
|
||||||
position: defaultPosition,
|
position: defaultPosition,
|
||||||
data: nodeData,
|
data: nodeData,
|
||||||
draggable: true,
|
draggable: !framePanelPinned,
|
||||||
dragHandle: ".keyframe-panel-drag",
|
dragHandle: framePanelPinned ? undefined : ".keyframe-panel-drag",
|
||||||
selectable: true,
|
selectable: true,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
if (shouldReset) {
|
if (shouldFocusNewPanel) {
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
flowRef.current?.fitView?.({
|
flowRef.current?.fitView?.({
|
||||||
nodes: [{ id: KEYFRAME_PANEL_ID }, { id: "keyframe" }],
|
nodes: [{ id: KEYFRAME_PANEL_ID }, { id: "keyframe" }],
|
||||||
@@ -376,7 +382,7 @@ export default function Home() {
|
|||||||
})
|
})
|
||||||
}, 0)
|
}, 0)
|
||||||
}
|
}
|
||||||
}, [job?.id, expandedFrame, framePanelResetNonce, nodeData, setNodes])
|
}, [job?.id, expandedFrame, framePanelPinned, nodeData, setNodes])
|
||||||
|
|
||||||
// 边的 animated 状态跟 Job 进度联动
|
// 边的 animated 状态跟 Job 进度联动
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from "react"
|
|||||||
import { type NodeProps } from "@xyflow/react"
|
import { type NodeProps } from "@xyflow/react"
|
||||||
import {
|
import {
|
||||||
Link2, Upload, Download, Scissors, Image as ImageIcon,
|
Link2, Upload, Download, Scissors, Image as ImageIcon,
|
||||||
Mic, Languages, FileEdit, Film, FileVideo, Loader2, Plus, X, LayoutGrid,
|
Mic, Languages, FileEdit, Film, FileVideo, Loader2, Plus, X, LayoutGrid, Pin,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { NodeShell, type NodeStatus, type NodeKind } from "./node-shell"
|
import { NodeShell, type NodeStatus, type NodeKind } from "./node-shell"
|
||||||
import { type Job, type ImageRef, effectiveFrameUrl, videoUrl, hasCutout, representativeCutoutUrl } from "@/lib/api"
|
import { type Job, type ImageRef, effectiveFrameUrl, videoUrl, hasCutout, representativeCutoutUrl } from "@/lib/api"
|
||||||
@@ -17,12 +17,16 @@ export interface NodeData {
|
|||||||
analyzing: boolean
|
analyzing: boolean
|
||||||
selectedFrames: Set<number>
|
selectedFrames: Set<number>
|
||||||
expandedFrame: number | null
|
expandedFrame: number | null
|
||||||
|
framePanelScale?: number
|
||||||
|
framePanelPinned?: boolean
|
||||||
onSubmitUrl: (url: string) => void
|
onSubmitUrl: (url: string) => void
|
||||||
onUploadFile: (file: File) => void
|
onUploadFile: (file: File) => void
|
||||||
onAnalyze: () => void
|
onAnalyze: () => void
|
||||||
onToggleFrame: (idx: number) => void
|
onToggleFrame: (idx: number) => void
|
||||||
onExpandFrame: (idx: number) => void
|
onExpandFrame: (idx: number) => void
|
||||||
onOpenFramePanel?: (idx: number) => void // 打开/找回画布内关键帧详情面板
|
onOpenFramePanel?: (idx: number) => void // 打开/找回画布内关键帧详情面板
|
||||||
|
onFramePanelScaleChange?: (scale: number) => void
|
||||||
|
onFramePanelPinnedChange?: (pinned: boolean) => void
|
||||||
onCloseExpandedFrame: () => void
|
onCloseExpandedFrame: () => void
|
||||||
onAddManualFrame: (t: number) => void
|
onAddManualFrame: (t: number) => void
|
||||||
onOpenVideoLightbox: () => void
|
onOpenVideoLightbox: () => void
|
||||||
@@ -511,12 +515,21 @@ export function KeyframePanelNode({ data }: any) {
|
|||||||
const d: NodeData = data
|
const d: NodeData = data
|
||||||
if (!d.job || d.expandedFrame === null) return null
|
if (!d.job || d.expandedFrame === null) return null
|
||||||
const active = d.job.frames.find((f) => f.index === d.expandedFrame)
|
const active = d.job.frames.find((f) => f.index === d.expandedFrame)
|
||||||
|
const scale = d.framePanelScale ?? 1
|
||||||
|
const pinned = d.framePanelPinned ?? false
|
||||||
|
const panelWidth = Math.round(760 * scale)
|
||||||
|
const panelHeight = Math.round(746 * scale)
|
||||||
|
const bodyHeight = Math.max(520, panelHeight - 27)
|
||||||
|
const setScale = (next: number) => {
|
||||||
|
const clamped = Math.max(0.75, Math.min(1.35, Number(next.toFixed(2))))
|
||||||
|
d.onFramePanelScaleChange?.(clamped)
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="rounded-2xl border border-white/15 bg-black/70 shadow-2xl overflow-hidden"
|
className="rounded-2xl border border-white/15 bg-black/70 shadow-2xl overflow-hidden"
|
||||||
style={{ width: 760, height: 746, boxShadow: "0 30px 80px -20px rgba(0,0,0,0.75), 0 0 0 1px rgba(255,255,255,0.05)" }}
|
style={{ width: panelWidth, height: panelHeight, boxShadow: "0 30px 80px -20px rgba(0,0,0,0.75), 0 0 0 1px rgba(255,255,255,0.05)" }}
|
||||||
>
|
>
|
||||||
<div className="keyframe-panel-drag flex h-7 cursor-move items-center justify-between bg-gradient-to-r from-orange-500 to-red-500 px-3 text-white">
|
<div className={`keyframe-panel-drag flex h-7 items-center justify-between bg-gradient-to-r from-orange-500 to-red-500 px-3 text-white ${pinned ? "cursor-default" : "cursor-move"}`}>
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<ImageIcon className="h-3.5 w-3.5 shrink-0" />
|
<ImageIcon className="h-3.5 w-3.5 shrink-0" />
|
||||||
<span className="truncate text-[12px] font-semibold">关键帧详情 · 元素提取</span>
|
<span className="truncate text-[12px] font-semibold">关键帧详情 · 元素提取</span>
|
||||||
@@ -524,8 +537,46 @@ export function KeyframePanelNode({ data }: any) {
|
|||||||
{active ? `分镜 ${active.index + 1} · ${active.timestamp.toFixed(2)}s` : "未选分镜"}
|
{active ? `分镜 ${active.index + 1} · ${active.timestamp.toFixed(2)}s` : "未选分镜"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-[10px] text-white/60">拖动标题栏移动 · 再点缩略图可找回</span>
|
<span className="mr-1 text-[10px] text-white/60">
|
||||||
|
{pinned ? "已钉住 · 切图不移动" : "拖动标题栏移动 · 可钉住"}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); d.onFramePanelPinnedChange?.(!pinned) }}
|
||||||
|
className={`nodrag h-5 w-5 rounded inline-flex items-center justify-center transition ${
|
||||||
|
pinned
|
||||||
|
? "bg-white text-orange-600 shadow"
|
||||||
|
: "bg-white/10 text-white/85 hover:bg-white/20 hover:text-white"
|
||||||
|
}`}
|
||||||
|
title={pinned ? "取消钉住,允许拖动" : "钉住面板,防止被拖动"}
|
||||||
|
>
|
||||||
|
<Pin className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); setScale(scale - 0.1) }}
|
||||||
|
className="nodrag h-5 w-5 rounded bg-white/10 text-white/85 hover:bg-white/20 hover:text-white inline-flex items-center justify-center text-[13px] leading-none"
|
||||||
|
title="缩小面板"
|
||||||
|
>
|
||||||
|
-
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); setScale(1) }}
|
||||||
|
className="nodrag h-5 min-w-9 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-5 w-5 rounded bg-white/10 text-white/85 hover:bg-white/20 hover:text-white inline-flex items-center justify-center text-[13px] leading-none"
|
||||||
|
title="放大面板"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => { e.stopPropagation(); d.onCloseExpandedFrame() }}
|
onClick={(e) => { e.stopPropagation(); d.onCloseExpandedFrame() }}
|
||||||
@@ -536,7 +587,7 @@ export function KeyframePanelNode({ data }: any) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="nodrag nowheel h-[719px]" onWheel={(e) => e.stopPropagation()}>
|
<div className="nodrag nowheel" style={{ height: bodyHeight }} onWheel={(e) => e.stopPropagation()}>
|
||||||
<FrameLightbox
|
<FrameLightbox
|
||||||
embedded
|
embedded
|
||||||
jobId={d.job.id}
|
jobId={d.job.id}
|
||||||
|
|||||||
Reference in New Issue
Block a user