auto-save 2026-05-13 20:07 (~5)

This commit is contained in:
2026-05-13 20:07:24 +08:00
parent 3f9075f2ce
commit 52c120cd50
5 changed files with 104 additions and 108 deletions

View File

@@ -2268,6 +2268,13 @@
"type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令codex · 6 项未提交变更 · 最近提交auto-save 2026-05-13 19:56 (~4)",
"files_changed": 6
},
{
"ts": "2026-05-13T20:01:52+08:00",
"type": "commit",
"message": "auto-save 2026-05-13 20:01 (~6)",
"hash": "3f9075f",
"files_changed": 6
}
]
}

View File

@@ -500,7 +500,7 @@
<div class="grid-3">
<div class="card">
<h3>1. 先说你在改哪个产品区</h3>
<p>例如“镜头拆解 / 元素提取面板”、“元素改造 Storyboard 节点”、“分镜头编排工作台 4 图槽”。不要只说“这里乱”,要指向页面里的功能区。</p>
<p>例如“镜头拆解 / 元素提取面板”、“元素改造 Storyboard 节点”、“分镜头编排下拉区 4 图槽”。不要只说“这里乱”,要指向页面里的功能区。</p>
</div>
<div class="card">
<h3>2. 再说这个区应该承担什么职责</h3>
@@ -572,8 +572,8 @@
<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、Keyframe、ASR、Translate、Rewrite、Storyboard、VideoGen、Compose。</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>
<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>
<tr><td><code>web/lib/api.ts</code></td><td>前端类型和 API client是前后端数据契约镜像。</td></tr>
</tbody>
</table>
@@ -830,6 +830,29 @@ api/main.py
<h2>变更记录</h2>
<p>这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。</p>
<div class="changelog">
<article class="change">
<header>
<h3>2026-05-13 · 分镜编排下拉区支持上推缩小</h3>
<span class="tag violet">StoryboardWorkbench</span>
</header>
<div class="body">
<p><strong>问题:</strong>分镜编排明细区默认占用太多顶部面积,展开后下方画布空间不足。</p>
<p><strong>改动:</strong>明细区默认高度降为 320px并增加底部拖拽手柄可上推缩到 180px也可下拉放大查看完整内容。</p>
<p><strong>影响:</strong><code>web/components/storyboard-workbench.tsx</code></p>
</div>
</article>
<article class="change">
<header>
<h3>2026-05-13 · 分镜缩略图条与编排明细合并为一个下拉区</h3>
<span class="tag violet">StoryboardBar</span>
<span class="tag violet">StoryboardWorkbench</span>
</header>
<div class="body">
<p><strong>问题:</strong>顶部分镜缩略图条和下方内嵌工作台都带分镜导航,看起来像两个不同板块。</p>
<p><strong>改动:</strong><code>StoryboardBar</code> 成为唯一分镜导航;<code>StoryboardWorkbench</code> 移除自己的标题栏、左侧分镜列表和底部快捷栏,只保留当前分镜的 4 图槽与改造目标明细。</p>
<p><strong>影响:</strong><code>web/components/storyboard-bar.tsx</code><code>web/components/storyboard-workbench.tsx</code><code>web/app/page.tsx</code></p>
</div>
</article>
<article class="change">
<header>
<h3>2026-05-13 · 分镜头编排工作台改为内嵌下拉</h3>

View File

@@ -414,10 +414,12 @@ export default function Home() {
selectedFrames={selectedFrames}
focusedFrame={storyboardFrame}
onFocusFrame={setStoryboardFrame}
workbenchOpen={workbenchOpen}
onOpenWorkbench={(idx?: number) => {
if (typeof idx === "number") setStoryboardFrame(idx)
setWorkbenchOpen(true)
}}
onCloseWorkbench={() => setWorkbenchOpen(false)}
/>
<StoryboardWorkbench
job={job}

View File

@@ -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 ? "展开" : "折叠"}
>

View File

@@ -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>
)
}