auto-save 2026-05-14 05:32 (~4)

This commit is contained in:
2026-05-14 05:32:54 +08:00
parent 2c19b52a81
commit f3636a5ec7
4 changed files with 60 additions and 97 deletions

View File

@@ -3348,6 +3348,19 @@
"type": "session-heartbeat",
"message": "Claude 会话活跃 · 最近命令claude · 1 项未提交变更 · 最近提交auto-save 2026-05-14 05:21 (~3)",
"files_changed": 1
},
{
"ts": "2026-05-14T05:27:24+08:00",
"type": "commit",
"message": "auto-save 2026-05-14 05:27 (~3)",
"hash": "2c19b52",
"files_changed": 3
},
{
"ts": "2026-05-13T21:28:50Z",
"type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令codex · 2 项未提交变更 · 最近提交auto-save 2026-05-14 05:27 (~3)",
"files_changed": 2
}
]
}

View File

@@ -554,9 +554,9 @@
<div class="step"><div class="num">1</div><h3>输入</h3><p>TK 链接或本地上传,后端下载/保存源视频。</p></div>
<div class="step"><div class="num">2</div><h3>镜头拆解</h3><p>拆轨、抽关键帧、手动加帧,形成参考分镜池。</p></div>
<div class="step"><div class="num">3</div><h3>清洗水印</h3><p>对关键帧做全图或区域清洗,必要时应用为当前参考图。</p></div>
<div class="step"><div class="num">4</div><h3>Vision 识别</h3><p>识别场景和候选元素,只是候选,不应锁死。</p></div>
<div class="step"><div class="num">5</div><h3>元素提取</h3><p>编辑/新增/删除元素,对元素反复生成提取图</p></div>
<div class="step"><div class="num">6</div><h3>元素改造</h3><p>把参考主体、场景、动作和 SKG 产品放入分镜结构。</p></div>
<div class="step"><div class="num">4</div><h3>主体识别</h3><p>识别场景和主体候选,只是候选,不应锁死。</p></div>
<div class="step"><div class="num">5</div><h3>素材准备</h3><p>清洗关键帧,生成场景图和主体多视角/动作/表情资产包</p></div>
<div class="step"><div class="num">6</div><h3>分镜改造</h3><p>把参考主体、场景、动作和 SKG 产品放入分镜结构。</p></div>
<div class="step"><div class="num">7</div><h3>生成视频</h3><p>用分镜 4 图槽、改造目标和时长调用 Seedance / Kling / Veo 3 生视频 API结果回写到画面工作台节点。</p></div>
<div class="step"><div class="num">8</div><h3>合成成品</h3><p>片段、字幕、配音、转场合成最终 mp4。当前未实现。</p></div>
</div>
@@ -571,7 +571,7 @@
<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 / VideoFramePanel旧 Keyframe/Storyboard/VideoGen 组件保留但不再挂主画布。</td></tr>
<tr><td><code>web/components/lightbox.tsx</code></td><td>镜头拆解和元素提取的主工作面板:清洗、识别、元素编辑、区域提取、抠图</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/lib/api.ts</code></td><td>前端类型和 API client是前后端数据契约镜像。</td></tr>
@@ -618,12 +618,12 @@ api/main.py
</div>
<div class="flow-row">
<div><strong>你看到的区域</strong><span>画面工作台 · Visual Lab</span></div>
<div><strong>主要源码</strong><span><code>VisualLabNode</code> in <code>web/components/nodes/index.tsx</code>;它现在是素材准备看板,汇总关键帧、场景图、主体资产包、普通抠图和视频任务。</span></div>
<div><strong>主要源码</strong><span><code>VisualLabNode</code> in <code>web/components/nodes/index.tsx</code>;它现在是素材准备看板,汇总关键帧、场景图、主体资产包和视频任务。</span></div>
<div><strong>适合怎么描述</strong><span>“画面工作台的素材准备进度、分组缩略图、关键帧审核入口和后续分镜入口应该如何组织”。</span></div>
</div>
<div class="flow-row">
<div><strong>你看到的区域</strong><span>关键帧素材审核面板</span></div>
<div><strong>主要源码</strong><span><code>FrameLightbox</code>;按“原图/清洗、场景图、主体、审核”四个页签组织;相关接口包括 <code>cleanupFrame</code><code>generateSceneAsset</code><code>generateSubjectAssets</code><code>cutoutElement</code></span></div>
<div><strong>主要源码</strong><span><code>FrameLightbox</code>;按“原图/清洗、场景图、主体资产、审核”四个页签组织;相关接口包括 <code>cleanupFrame</code><code>addElement</code><code>generateSceneAsset</code><code>generateSubjectAssets</code></span></div>
<div><strong>适合怎么描述</strong><span>“某张关键帧的水印、场景图、主体多视角/动作/表情图和质量风险应该如何审核”。</span></div>
</div>
<div class="flow-row">
@@ -664,7 +664,7 @@ api/main.py
</div>
<div class="card">
<h3>KeyElement</h3>
<p>从关键帧里识别或手动添加的可借鉴元素。Vision 给的是候选,用户可编辑,并可多次生成提取图</p>
<p>从关键帧里识别或手动添加的主体候选。Vision 给的是候选,用户可编辑、删除,并可基于它生成主体资产包</p>
<pre>KeyElement {
id,
name_zh, name_en, position,
@@ -751,7 +751,7 @@ SubjectAsset {
</tr>
<tr>
<td><span class="tag violet">画面工作台 Visual Lab</span></td>
<td>作为素材准备看板:显示准备进度、质量风险、关键帧 / 场景图 / 主体包 / 分镜视频四个入口;上方缩略图按关键帧、场景图、主体包、普通抠图、视频任务分组。点击关键帧进入素材审核面板,点击资产图复制到分镜编排。</td>
<td>作为素材准备看板:显示准备进度、质量风险、关键帧 / 场景图 / 主体包 / 分镜视频四个入口;上方缩略图按关键帧、场景图、主体包、视频任务分组。点击关键帧进入素材审核面板,点击资产图复制到分镜编排。</td>
<td>不要在主卡片里堆复杂表单;主卡片只做状态总览和入口。</td>
<td><code>VisualLabNode</code><code>FrameLightbox</code><code>generateSceneAsset</code><code>generateSubjectAssets</code>、视频任务接口</td>
</tr>
@@ -787,8 +787,8 @@ SubjectAsset {
<li>视频下载或本地保存ffmpeg 抽关键帧。</li>
<li>手动按时间戳加关键帧。</li>
<li>关键帧清洗水印,全图或区域清洗。</li>
<li>Vision 识别关键帧,输出 scene、objects、style、suggested_prompt。</li>
<li>元素增改删、区域元素添加、元素多次提取图</li>
<li>Vision 识别关键帧,输出 scene、objects、style、suggested_prompt,并作为主体候选来源</li>
<li>主体候选增改删、区域主体添加、主体资产包生成</li>
<li>分镜工作台 4 图槽和改造说明自动保存。</li>
<li>nano-banana-pro image-to-image 生图。</li>
</ul>
@@ -813,8 +813,8 @@ SubjectAsset {
<h2>需求描述模板</h2>
<div class="todo">
<div class="todo-item">
<h3>镜头拆解 / 元素提取</h3>
<p>“我在关键帧 lightbox 里Vision 识别后的元素列表应该怎么编辑/重提取/删除;点击元素不要跳转;提取图怎么预览和复制。”</p>
<h3>关键帧素材准备</h3>
<p>“我在关键帧素材准备面板里,主体候选应该怎么编辑/删除;场景图和主体资产包怎么生成、审核、复制到分镜。”</p>
</div>
<div class="todo-item">
<h3>改 Storyboard 节点</h3>
@@ -839,6 +839,18 @@ SubjectAsset {
<h2>变更记录</h2>
<p>这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。</p>
<div class="changelog">
<article class="change">
<header>
<h3>2026-05-14 · 关键帧详情改为素材准备面板</h3>
<span class="tag violet">FrameLightbox</span>
<span class="tag blue">UX</span>
</header>
<div class="body">
<p><strong>问题:</strong>面板标题仍叫“关键帧详情 · 元素提取”,里面还露出普通 cutout 抠图、AI 提取、元素清单等旧流程入口;“选用此帧”也无法说明它其实是在维护目标关键帧集合。</p>
<p><strong>改动:</strong><code>KeyframePanelNode</code> 标题改为“关键帧素材准备”;<code>FrameLightbox</code> 的主体页改成“主体识别 / 主体清单 / 主体资产包”,移除普通抠图列表和 AI 提取按钮;“选用此帧”改为“加入目标帧 / 目标帧 · 点击移出”。<code>VisualLabNode</code> 上方缩略图也不再展示普通抠图分组,只展示关键帧、场景图、主体包和视频任务。</p>
<p><strong>影响:</strong><code>web/components/lightbox.tsx</code><code>web/components/nodes/index.tsx</code><code>docs/source-analysis.html</code>。底层旧 cutout 数据和接口暂保留兼容历史任务,但不再作为新素材准备流程的可见入口。</p>
</div>
</article>
<article class="change">
<header>
<h3>2026-05-14 · 画面工作台改为素材准备看板</h3>
@@ -847,7 +859,7 @@ SubjectAsset {
</header>
<div class="body">
<p><strong>问题:</strong>画面工作台从展示缩略图扩展为素材生产中枢后,关键帧、场景图、主体资产包和视频任务继续混在一个列表里会让流程不清晰;关键帧详情面板也把清洗、识别、场景和主体生成都堆在一屏。</p>
<p><strong>改动:</strong><code>VisualLabNode</code> 改成素材准备进度看板,显示目标关键帧、场景图、主体资产和分镜/视频四个入口,并在上方缩略图中按关键帧、场景图、主体包、普通抠图、视频任务分组<code>FrameLightbox</code> 新增“原图/清洗、场景图、主体、审核”四个页签,素材审核信息从普通元素列表中拆出来。</p>
<p><strong>改动:</strong><code>VisualLabNode</code> 改成素材准备进度看板,显示目标关键帧、场景图、主体资产和分镜/视频四个入口。<code>FrameLightbox</code> 新增“原图/清洗、场景图、主体资产、审核”四个页签,素材审核信息从普通列表中拆出来。</p>
<p><strong>影响:</strong><code>web/components/nodes/index.tsx</code><code>web/components/lightbox.tsx</code><code>docs/source-analysis.html</code>。这轮只重排工作台信息架构,批量自动准备队列仍留到下一阶段。</p>
</div>
</article>

View File

@@ -107,21 +107,16 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
const pos = frames.findIndex((x) => x.index === activeIndex)
if (e.key === "ArrowLeft" && pos > 0) onChange(frames[pos - 1].index)
if (e.key === "ArrowRight" && pos < frames.length - 1) onChange(frames[pos + 1].index)
if (e.key === " " || e.key === "Enter") {
e.preventDefault()
onToggleSelect(activeIndex)
}
}
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
}, [activeIndex, frames.length, onClose, onChange, onToggleSelect])
}, [activeIndex, frames.length, onClose, onChange])
const f = activeIndex !== null ? frames.find((x) => x.index === activeIndex) : undefined
const arrayPos = f ? frames.findIndex((x) => x.index === f.index) : -1
if (activeIndex === null || !f || !mounted) return null
const isSelected = selected.has(f.index)
const desc = f.description
const elements = f.elements ?? []
const hasCleaned = !!f.cleaned_url
@@ -412,7 +407,7 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
</div>
</div>
{/* 主体 — 左:大图 + 清洗 / 目标帧;右:主体识别 + 主体资产 */}
{/* 主体 — 左:大图 + 清洗状态;右:主体识别 + 主体资产 */}
<div className="flex gap-3 p-3 overflow-hidden flex-1 min-h-0">
{/* 左侧大图区 */}
<div
@@ -696,18 +691,15 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
</div>
)}
<button
onClick={() => onToggleSelect(f.index)}
className={`w-full px-3 py-1.5 rounded-md text-[12px] font-medium inline-flex items-center justify-center gap-1.5 transition ${
isSelected
? "bg-emerald-500 text-white hover:bg-emerald-400"
: "bg-white/10 text-white hover:bg-white/20"
}`}
title="目标关键帧会参与素材准备进度、主体跨帧参考和后续分镜编排"
>
<Check className="h-3.5 w-3.5" />
{isSelected ? "目标帧 · 点击移出" : "加入目标帧"}
</button>
<div className="rounded-md border border-emerald-300/20 bg-emerald-500/10 px-3 py-2 text-[11px] leading-relaxed text-emerald-50/80">
<div className="mb-0.5 inline-flex items-center gap-1 font-medium text-emerald-100">
<Check className="h-3 w-3" />
</div>
<div className="text-emerald-50/55">
</div>
</div>
</div>
{/* 右侧主体识别 + 主体资产 */}
@@ -1092,7 +1084,7 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
</div>
<div className="px-4 py-1.5 text-[10px] text-white/40 font-mono text-center border-t border-white/5 bg-white/[0.02]">
/ · Space · ESC
/ · ESC
</div>
</div>
)

View File

@@ -1212,11 +1212,9 @@ export function VisualLabNode({ data, selected }: any) {
const aspect = job && (job.width ?? 0) > 0 && (job.height ?? 0) > 0
? `${job.width}/${job.height}`
: "9/16"
const elementCrops = collectElementCrops(job)
const sceneAssets = collectSceneAssets(job)
const subjectAssets = collectSubjectAssets(job)
const cleanedCount = frames.filter((x) => x.cleaned_url).length
const cutoutCount = frames.reduce((s, x) => s + (x.elements?.filter((e) => hasCutout(e)).length ?? 0), 0)
const sceneAssetCount = sceneAssets.length
const subjectAssetCount = subjectAssets.length
const selectedFrameCount = frames.filter((f) => d.selectedFrames.has(f.index)).length
@@ -1232,7 +1230,7 @@ export function VisualLabNode({ data, selected }: any) {
? "running"
: failedVideo
? "failed"
: frames.length > 0 || elementCrops.length > 0 || completedVideos.length > 0
: frames.length > 0 || subjectAssets.length > 0 || completedVideos.length > 0
? "done"
: keyframeStatus(job)
@@ -1240,7 +1238,6 @@ export function VisualLabNode({ data, selected }: any) {
| { id: string; kind: "frame"; group: string; frameIdx: number; src: string; label: string; caption: string; borderClass: string; aspect: string }
| { id: string; kind: "scene"; group: string; frameIdx: number; assetId: string; src: string; label: string; caption: string; borderClass: string; aspect: string }
| { id: string; kind: "subject"; group: string; frameIdx: number; assetId: string; src: string; label: string; caption: string; borderClass: string; aspect: string }
| { id: string; kind: "cutout"; group: string; frameIdx: number; elementId: string; cutoutId: string; src: string; label: string; caption: string; borderClass: string; aspect: string }
| { id: string; kind: "video"; group: string; videoId: string; videoSrc?: string; posterSrc?: string; label: string; caption: string; borderClass: string; aspect: string }
const [hoverPreview, setHoverPreview] = useState<PreviewAnchor<string> | null>(null)
@@ -1284,19 +1281,6 @@ export function VisualLabNode({ data, selected }: any) {
borderClass: "border-violet-300/65",
aspect: p.width && p.height ? `${p.width}/${p.height}` : "1/1",
})),
...elementCrops.map((p) => ({
id: `cutout:${p.frameIdx}:${p.elementId}:${p.cid}`,
kind: "cutout" as const,
group: "普通抠图",
frameIdx: p.frameIdx,
elementId: p.elementId,
cutoutId: p.cid,
src: p.src,
label: p.name,
caption: `分镜 ${p.frameIdx + 1}`,
borderClass: "border-violet-300/60",
aspect: "1/1",
})),
...videos.map((v, i) => {
const videoSrc = apiAssetUrl(v.url)
const posterSrc = apiAssetUrl(v.poster_url)
@@ -1344,7 +1328,7 @@ export function VisualLabNode({ data, selected }: any) {
p.kind === "frame"
? isSelected ? "border-emerald-400 ring-2 ring-emerald-400/60" : "border-white/30 dark:border-white/20"
: p.borderClass
} ${p.kind === "cutout" || p.kind === "subject" ? "bg-white" : "bg-black"}`}
} ${p.kind === "subject" ? "bg-white" : "bg-black"}`}
style={{ height: THUMBNAIL_HEIGHT, aspectRatio: p.aspect }}
onMouseEnter={(e) => setHoverPreview({ id: p.id, ...canvasThumbnailAnchor(rootRef.current, e.currentTarget) })}
onMouseLeave={() => setHoverPreview(null)}
@@ -1370,10 +1354,6 @@ export function VisualLabNode({ data, selected }: any) {
})
if (!d.selectedFrames.has(p.frameIdx)) d.onToggleFrame(p.frameIdx)
d.onOpenWorkbench?.(p.frameIdx)
} else if (p.kind === "cutout") {
if (!d.selectedFrames.has(p.frameIdx)) d.onToggleFrame(p.frameIdx)
d.onOpenStoryboard?.(p.frameIdx)
d.onOpenWorkbench?.(p.frameIdx)
} else {
const video = videos.find((v) => v.id === p.videoId)
if (video) {
@@ -1400,7 +1380,7 @@ export function VisualLabNode({ data, selected }: any) {
<div className="absolute inset-0 bg-emerald-400/15 rounded-md pointer-events-none" />
)}
<div className="absolute bottom-0 right-0 bg-black/70 px-1 py-0.5 text-[8.5px] font-mono leading-none text-white rounded-bl rounded-br-md">
{p.kind === "frame" ? p.caption.replace("s", "") + "s" : p.kind === "scene" ? "场景" : p.kind === "subject" ? "主体" : p.kind === "cutout" ? "抠图" : "视频"}
{p.kind === "frame" ? p.caption.replace("s", "") + "s" : p.kind === "scene" ? "场景" : p.kind === "subject" ? "主体" : "视频"}
</div>
</button>
@@ -1438,26 +1418,6 @@ export function VisualLabNode({ data, selected }: any) {
</button>
)}
{p.kind === "cutout" && d.onCopyImage && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
d.onCopyImage?.({
kind: "cutout",
frame_idx: p.frameIdx,
element_id: p.elementId,
cutout_id: p.cutoutId,
label: p.label,
})
}}
title="复制此图(到分镜头编排工作台插槽粘贴)"
className="absolute top-1.5 left-1.5 z-[70] inline-flex h-7 w-7 items-center justify-center rounded-full bg-violet-500/95 text-white shadow-lg backdrop-blur transition hover:scale-110 hover:bg-violet-400"
>
<Copy className="h-3.5 w-3.5" />
</button>
)}
{p.kind === "video" && (
<button
type="button"
@@ -1489,20 +1449,6 @@ export function VisualLabNode({ data, selected }: any) {
</button>
)}
{p.kind === "cutout" && d.onDeleteCutout && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
d.onDeleteCutout?.(p.frameIdx, p.elementId, p.cutoutId)
}}
title="删除该提取图"
className="absolute top-1.5 right-1.5 z-[70] inline-flex h-7 w-7 items-center justify-center rounded-full bg-rose-500/95 text-white shadow-lg backdrop-blur transition hover:scale-110 hover:bg-rose-400"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
{p.kind === "video" && d.onDeleteVideo && (
<button
type="button"
@@ -1581,7 +1527,7 @@ export function VisualLabNode({ data, selected }: any) {
/>
</div>
<div className="mt-1.5 flex items-center justify-between gap-2 text-[9.5px] text-[var(--text-faint)]">
<span>{targetFrameCount} </span>
<span>{targetFrameCount} </span>
{qualityRiskCount > 0 ? (
<span className="inline-flex items-center gap-1 text-amber-300/85">
<AlertTriangle className="h-2.5 w-2.5" />
@@ -1606,9 +1552,9 @@ export function VisualLabNode({ data, selected }: any) {
>
<div className="mb-1 flex items-center gap-1 text-[var(--text-strong)] text-[12px] font-semibold">
<ImageIcon className="h-3 w-3 text-orange-300" />
{targetFrameCount}/{frames.length}
{frames.length}
</div>
<div></div>
<div></div>
</button>
<button
type="button"
@@ -1632,7 +1578,7 @@ export function VisualLabNode({ data, selected }: any) {
>
<div className="mb-1 flex items-center gap-1 text-[var(--text-strong)] text-[12px] font-semibold">
<Package className="h-3 w-3 text-violet-300" />
{subjectAssetCount || cutoutCount}
{subjectAssetCount}
</div>
<div></div>
</button>
@@ -1653,7 +1599,7 @@ export function VisualLabNode({ data, selected }: any) {
<div className="mt-2 text-[10.5px] leading-snug text-[var(--text-faint)]">
{frames.length > 0 ? (
<>
{cleanedCount} · {sceneAssetCount} · {subjectAssetCount || cutoutCount} · {selectedFrameCount}/{frames.length} · {completedVideos.length}
{cleanedCount} · {sceneAssetCount} · {subjectAssetCount} · {targetFrameCount} · {completedVideos.length}
</>
) : (
"解析后这里变成素材准备看板:先审关键帧,再生成场景图和主体资产包。"
@@ -1808,7 +1754,7 @@ export function KeyframeNode({ data, selected }: any) {
type="process" status={st}
icon={<ImageIcon className="h-4 w-4" />}
title="镜头拆解 · 素材准备"
subtitle={`STEP 2 · ${frames.length ? `${d.selectedFrames.size}/${frames.length} 目标` : "等待抽取"}`}
subtitle={`STEP 2 · ${frames.length ? `${frames.length} 素材` : "等待抽取"}`}
selected={selected}
pinned={d.pinnedNodes?.has("keyframe")}
onTogglePin={() => d.onToggleNodePin?.("keyframe")}
@@ -2342,7 +2288,7 @@ export function StoryboardNode({ data, selected }: any) {
onClick={(e) => { e.stopPropagation(); d.onOpenWorkbench?.() }}
disabled={!job || storyboardCount === 0}
className="mt-2 w-full rounded-md bg-gradient-to-r from-violet-500 to-pink-500 px-3 py-2 text-[12px] font-semibold text-white shadow-lg shadow-violet-500/25 transition hover:opacity-95 disabled:cursor-not-allowed disabled:opacity-35"
title={storyboardCount === 0 ? "先关键帧节点选用分镜" : "进入 4 图槽分镜编排"}
title={storyboardCount === 0 ? "先准备关键帧素材" : "进入 4 图槽分镜编排"}
>
</button>