From 2c19b52a81ce9e460cf358305e581b5e4520cb25 Mon Sep 17 00:00:00 2001 From: kang Date: Thu, 14 May 2026 05:27:24 +0800 Subject: [PATCH] auto-save 2026-05-14 05:27 (~3) --- .memory/worklog.json | 13 ++ web/components/lightbox.tsx | 266 +++++++++------------------------ web/components/nodes/index.tsx | 11 +- 3 files changed, 90 insertions(+), 200 deletions(-) diff --git a/.memory/worklog.json b/.memory/worklog.json index 3de5e72..f98dab1 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -3335,6 +3335,19 @@ "type": "session-heartbeat", "message": "Codex 会话活跃 · 最近命令:codex · 3 项未提交变更 · 最近提交:auto-save 2026-05-14 05:16 (~3)", "files_changed": 3 + }, + { + "ts": "2026-05-14T05:21:54+08:00", + "type": "commit", + "message": "auto-save 2026-05-14 05:21 (~3)", + "hash": "4d02dcb", + "files_changed": 3 + }, + { + "ts": "2026-05-13T21:23:13Z", + "type": "session-heartbeat", + "message": "Claude 会话活跃 · 最近命令:claude · 1 项未提交变更 · 最近提交:auto-save 2026-05-14 05:21 (~3)", + "files_changed": 1 } ] } diff --git a/web/components/lightbox.tsx b/web/components/lightbox.tsx index 7b55834..b81372f 100644 --- a/web/components/lightbox.tsx +++ b/web/components/lightbox.tsx @@ -3,9 +3,9 @@ import { useEffect, useRef, useState } from "react" import { createPortal } from "react-dom" import { X, ChevronLeft, ChevronRight, Check, Sparkles, Wand2, Loader2, Eye, RefreshCw, Plus, Sparkle, Crop, Copy, PencilLine, Trash2, Save } from "lucide-react" import { - frameUrl, cleanedFrameUrl, cutoutUrl, apiAssetUrl, - describeFrame, cleanupFrame, applyCleanedFrame, discardCleanedFrame, addElement, updateElement, deleteElement, cutoutElement, deleteCutout, - pushStoryboardImage, generateSceneAsset, generateSubjectAssets, + frameUrl, cleanedFrameUrl, apiAssetUrl, + describeFrame, cleanupFrame, applyCleanedFrame, discardCleanedFrame, addElement, updateElement, deleteElement, + generateSceneAsset, generateSubjectAssets, type AssetBackground, type AssetSize, type KeyFrame, type Job, type ImageRef, type SubjectKind, } from "@/lib/api" import { toast } from "sonner" @@ -52,7 +52,7 @@ type LightboxTab = "clean" | "scene" | "subject" | "review" const LIGHTBOX_TABS: Array<{ key: LightboxTab; label: string }> = [ { key: "clean", label: "原图/清洗" }, { key: "scene", label: "场景图" }, - { key: "subject", label: "主体包" }, + { key: "subject", label: "主体资产" }, { key: "review", label: "审核" }, ] @@ -60,7 +60,6 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o const [describing, setDescribing] = useState(false) const [cleaning, setCleaning] = useState(false) const [applying, setApplying] = useState(false) - const [cuttingId, setCuttingId] = useState(null) const [sceneGenerating, setSceneGenerating] = useState(false) const [subjectGenerating, setSubjectGenerating] = useState(null) const [addingZh, setAddingZh] = useState(false) @@ -83,9 +82,9 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o const [regions, setRegions] = useState([]) const [draftRegion, setDraftRegion] = useState(null) // 当前正在拖的 const [dragStart, setDragStart] = useState<{ x: number; y: number } | null>(null) - const [extractNamePrompt, setExtractNamePrompt] = useState(false) // 提取模式:要用户填名字 - const [extractName, setExtractName] = useState("") - const [extracting, setExtracting] = useState(false) + const [subjectRegionPrompt, setSubjectRegionPrompt] = useState(false) + const [subjectRegionName, setSubjectRegionName] = useState("") + const [addingRegionSubject, setAddingRegionSubject] = useState(false) const imgWrapRef = useRef(null) useEffect(() => setMounted(true), []) @@ -95,8 +94,8 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o setRegions([]) setDraftRegion(null) setDragStart(null) - setExtractNamePrompt(false) - setExtractName("") + setSubjectRegionPrompt(false) + setSubjectRegionName("") }, [activeIndex]) useEffect(() => { @@ -130,7 +129,6 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o const selectedFrameIndices = Array.from(selected).sort((a, b) => a - b) const sharedSubjectFrameIndices = selectedFrameIndices.length > 1 ? selectedFrameIndices : [f.index] const subjectAssetCount = elements.reduce((sum, item) => sum + (item.subject_assets?.length ?? 0), 0) - const cutoutCount = elements.reduce((sum, item) => sum + ((item.cutouts?.length ?? 0) || (item.cutout_id ? 1 : 0)), 0) const qualityWarnings = [ ...(f.quality_report?.warnings ?? []), ...(latestSceneAsset?.quality_report?.warnings ?? []), @@ -208,32 +206,23 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o }) } - const handleExtractRegion = async () => { - // 提取语义只在恰好 1 个框时支持 - if (regions.length !== 1 || !extractName.trim()) return + const handleAddRegionSubject = async () => { + if (regions.length !== 1 || !subjectRegionName.trim()) return const r = regions[0] - setExtracting(true) + setAddingRegionSubject(true) try { const added = await addElement(jobId, f.index, { - name_zh: extractName.trim(), + name_zh: subjectRegionName.trim(), source: "region", region: r, }) onJobUpdate?.(added) - const fr = added.frames.find((x) => x.index === f.index) - const newEl = fr?.elements?.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))[0] - if (newEl) { - const cut = await cutoutElement(jobId, f.index, newEl.id) - onJobUpdate?.(cut) - toast.success(`「${extractName.trim()}」已提取并加入元素清单`) - } else { - toast.success(`「${extractName.trim()}」已加入元素清单 · 但抠图未触发`) - } - setCropMode(false); setRegions([]); setExtractNamePrompt(false); setExtractName("") + toast.success(`「${subjectRegionName.trim()}」已加入主体清单`) + setCropMode(false); setRegions([]); setSubjectRegionPrompt(false); setSubjectRegionName("") } catch (e) { - toast.error("提取失败:" + (e instanceof Error ? e.message : String(e))) + toast.error("添加主体失败:" + (e instanceof Error ? e.message : String(e))) } finally { - setExtracting(false) + setAddingRegionSubject(false) } } @@ -331,51 +320,12 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o }) onJobUpdate?.(updated) setEditingElement(null) - toast.success("元素已更新") + toast.success("主体已更新") } catch (e) { toast.error("更新失败:" + (e instanceof Error ? e.message : String(e))) } } - const handleCutout = async (id: string) => { - setCuttingId(id) - try { - const updated = await cutoutElement(jobId, f.index, id) - onJobUpdate?.(updated) - toast.success("提取完成") - } catch (e) { - toast.error("提取失败:" + (e instanceof Error ? e.message : String(e))) - } finally { - setCuttingId(null) - } - } - - const handlePushCutout = async (elementId: string, cutoutId: string, label: string, isLegacy: boolean) => { - if (activeIndex === null) return - try { - const updated = await pushStoryboardImage(jobId, { - kind: "cutout", - frame_idx: activeIndex, - element_id: elementId, - cutout_id: isLegacy ? elementId : cutoutId, // legacy 兼容 - label, - }) - onJobUpdate?.(updated) - toast.success("已推送到分镜头编排") - } catch (e) { - toast.error("推送失败:" + (e instanceof Error ? e.message : String(e))) - } - } - - const handleDeleteCutout = async (elementId: string, cutoutId: string) => { - try { - const updated = await deleteCutout(jobId, f.index, elementId, cutoutId) - onJobUpdate?.(updated) - } catch (e) { - toast.error("删除失败:" + (e instanceof Error ? e.message : String(e))) - } - } - // cleaned_url 是 /jobs/.../cleaned.jpg?t= 形式(后端写时带) // 这里直接当 absolute path 拼到 API_BASE 上即可:用 cleanedFrameUrl 但带 bust const cleanedSrc = (() => { @@ -462,7 +412,7 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o - {/* 主体 — 左:大图 + 清洗 / 选用;右:识别 + 元素清单 */} + {/* 主体 — 左:大图 + 清洗 / 目标帧;右:主体识别 + 主体资产 */}
{/* 左侧大图区 */}
{/* 画框工具栏 */} {cropMode ? ( - extractNamePrompt ? ( - // 提取模式:要用户填名字(仅 1 框时进入此模式) + subjectRegionPrompt ? (
setExtractName(e.target.value)} + value={subjectRegionName} + onChange={(e) => setSubjectRegionName(e.target.value)} onKeyDown={(e) => { - if (e.key === "Enter" && !e.nativeEvent.isComposing && extractName.trim()) { + if (e.key === "Enter" && !e.nativeEvent.isComposing && subjectRegionName.trim()) { e.preventDefault() - handleExtractRegion() + handleAddRegionSubject() } - if (e.key === "Escape") { setExtractNamePrompt(false); setExtractName("") } + if (e.key === "Escape") { setSubjectRegionPrompt(false); setSubjectRegionName("") } }} - placeholder="给这个元素起个中文名(如:左下角药瓶)" + placeholder="给这个主体起名(如:手持产品的人)" className="w-full text-[11.5px] px-2 py-1.5 rounded-md bg-black/40 border border-violet-300/50 outline-none text-white placeholder:text-white/30 focus:ring-2 focus:ring-violet-400/40" />
) : ( - // 画框模式 — 可连续画多框
{regions.length === 0 ? "在图上拖动鼠标 → 框选要处理的区域(可连续画多个)" - : `已框 ${regions.length} 个 · 继续拖鼠标加框 · 「去掉」批量清洗 · 单框可「提取」为元素`} + : `已框 ${regions.length} 个 · 继续加框 · 「去掉」批量清洗 · 单框可加入主体清单`}
)} @@ -645,10 +593,10 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o onClick={handleApplyCleaned} disabled={applying} className="w-full px-2 py-1.5 rounded-md text-[11px] font-medium inline-flex items-center justify-center gap-1 transition bg-emerald-500 hover:bg-emerald-400 text-white disabled:opacity-40 disabled:cursor-not-allowed" - title="替换原图为这张干净版 · 后续抠图 / 分镜头编排都基于干净版" + title="替换原图为这张干净版 · 后续场景图、主体资产和分镜编排都基于干净版" > {applying ? : } - {applying ? "替换中…" : "✓ 替换原图"} + {applying ? "替换中…" : "替换原图"}
)} @@ -661,7 +609,7 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o title="清掉水印 / @用户名 / 字幕 / 平台 logo" > {cleaning ? : } - {cleaning ? "清洗中…(5-15 秒)" : hasCleaned ? "重新清洗" : f.cleaned_applied ? "再次清洗" : "🧹 清洗水印"} + {cleaning ? "清洗中…(5-15 秒)" : hasCleaned ? "重新清洗" : f.cleaned_applied ? "再次清洗" : "清洗水印"} )} @@ -755,18 +703,19 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o ? "bg-emerald-500 text-white hover:bg-emerald-400" : "bg-white/10 text-white hover:bg-white/20" }`} + title="目标关键帧会参与素材准备进度、主体跨帧参考和后续分镜编排" > - {isSelected ? "已选用" : "选用此帧"} + {isSelected ? "目标帧 · 点击移出" : "加入目标帧"}
- {/* 右侧识别 + 元素清单 */} + {/* 右侧主体识别 + 主体资产 */}
{activeTab === "clean" && (
清洗审核
- 先看原图是否有水印、平台 UI、字幕或多余文字。全图清洗会生成待应用版本;如果只想处理局部,切换画框后可多框去水印,也可单框提取成元素。 + 先看原图是否有水印、平台 UI、字幕或多余文字。全图清洗会生成待应用版本;如果只想处理局部,画框后可多框去水印,也可单框加入主体清单。
)} {activeTab === "scene" && ( @@ -791,27 +740,27 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
清洗:{f.cleaned_applied ? "已应用" : hasCleaned ? "待确认" : "未处理"}
场景图:{latestSceneAsset ? `${latestSceneAsset.width}×${latestSceneAsset.height}` : "未生成"}
-
普通抠图:{cutoutCount} 张
+
主体候选:{elements.length} 个
主体资产:{subjectAssetCount} 张
)} - {/* 识别 */} + {/* 主体识别 */}
- Vision 识别 + 主体识别 {desc && 已识别}
@@ -820,10 +769,10 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o {describing ? (
- Gemini Vision 识别中… + 画面主体识别中…
) : ( - <>点击「识别」让 Gemini 给出场景 / 风格 / 候选物体 + <>点击「识别主体」让 Vision 给出可制作主体资产的候选对象 )}
) : ( @@ -843,7 +792,7 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o {desc.objects && desc.objects.length > 0 && (
- 候选物体 · 点击加入「元素清单」 + 候选主体 · 点击加入「主体清单」
{desc.objects.map((o, i) => { @@ -853,7 +802,7 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o key={i} onClick={() => !alreadyIn && handleAddElement(o.name, o.extract_prompt, o.position, "auto")} disabled={alreadyIn} - title={alreadyIn ? "已加入元素清单" : (o.position ? `位置:${o.position}` : undefined)} + title={alreadyIn ? "已加入主体清单" : (o.position ? `位置:${o.position}` : undefined)} className={`w-full text-left rounded border px-2 py-1 transition group/o ${ alreadyIn ? "bg-emerald-500/10 border-emerald-400/30 cursor-default" @@ -881,29 +830,23 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o )} - {/* 元素清单(持久化) */} + {/* 主体清单(持久化) */}
- 元素清单 + 主体清单 {elements.length > 0 && ( · {elements.length} )} - → 先修正,再给「分镜头编排」 + → 先修正,再生成资产
- Vision 识别只是候选。提取错了可以改名、删图、删元素、再提取;点提取图本身不会离开当前页面。 + Vision 只负责给主体候选。这里确认名称、类型、背景和需要的视角/动作/表情,再生成主体资产包。
{elements.length > 0 && (
{elements.map((e) => { - const isCutting = cuttingId === e.id - // 合并新旧字段:cutouts 优先,否则 fallback 用 cutout_id - const cutouts: string[] = (e.cutouts && e.cutouts.length > 0) - ? e.cutouts - : (e.cutout_id ? [e.cutout_id] : []) - const hasAny = cutouts.length > 0 const hasRegion = !!e.region const isEditing = editingElement?.id === e.id const currentKind = subjectKinds[e.id] ?? e.subject_kind ?? "object" @@ -928,13 +871,13 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o setEditingElement({ ...editingElement, name_zh: ev.target.value })} - placeholder="元素中文名" + placeholder="主体名称" className="w-full rounded-md border border-white/15 bg-black/35 px-2 py-1.5 text-[12px] text-white outline-none placeholder:text-white/30 focus:border-violet-300/60" /> setEditingElement({ ...editingElement, name_en: ev.target.value })} - placeholder="英文提取提示,可手动修正" + placeholder="英文主体提示,可手动修正" className="w-full rounded-md border border-white/15 bg-black/35 px-2 py-1.5 text-[11px] font-mono text-white outline-none placeholder:text-white/30 focus:border-violet-300/60" /> {e.name_zh} {e.source === "auto" && ( - auto + 识别 )} {e.source === "region" && ( - box + 框选 )} - {cutouts.length > 0 && ( - · {cutouts.length} 张 + {hasRegion && ( + · 有区域 + )} + {subjectAssets.length > 0 && ( + · {subjectAssets.length} 张资产 )}
@@ -1001,87 +947,19 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o 改名 - )}
- {/* 多张提取图横向 grid */} - {hasAny && ( -
- {cutouts.map((cid, ci) => { - // 旧数据兼容:当 e.cutouts 为空、靠 cutout_id fallback 时,cid 实际是 element_id 而非 versioned id - const url = (e.cutouts && e.cutouts.length > 0) - ? cutoutUrl(jobId, f.index, e.id, cid) - : cutoutUrl(jobId, f.index, e.id) - return ( -
-
- {`${e.name_zh} -
- {/* 序号 */} -
- #{ci + 1} -
-
- {onCopyImage && ( - - )} - {e.cutouts && e.cutouts.length > 0 && ( - - )} -
-
- ) - })} -
- )} -
主体资产包
@@ -1194,7 +1072,7 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o } } }} - placeholder="加自定义元素 · 中文回车自动翻英文" + placeholder="添加主体候选 · 中文回车自动翻英文" className="flex-1 text-[12px] px-2.5 py-1.5 rounded-md bg-black/40 border border-white/15 outline-none text-white placeholder:text-white/30 focus:ring-2 focus:ring-violet-400/40 focus:border-violet-400/40" />
- 元素持久化保存 · 已抠图的元素 + 干净版场景图 → 下一步「分镜头编排」(多视角 / 风格融合 / 布局都在那做) + 主体候选会持久化保存;主体资产包和场景图准备好后,再复制到「分镜头编排」。
- ←/→ 切换 · Space 选用 · ESC 关闭 + ←/→ 切换 · Space 切换目标帧 · ESC 关闭
) diff --git a/web/components/nodes/index.tsx b/web/components/nodes/index.tsx index b90d5ac..d9db426 100644 --- a/web/components/nodes/index.tsx +++ b/web/components/nodes/index.tsx @@ -1807,8 +1807,8 @@ export function KeyframeNode({ data, selected }: any) { } - title="镜头拆解 · 元素提取" - subtitle={`STEP 2 · ${frames.length ? `${d.selectedFrames.size}/${frames.length} 入编排` : "等待抽取"}`} + title="镜头拆解 · 素材准备" + subtitle={`STEP 2 · ${frames.length ? `${d.selectedFrames.size}/${frames.length} 目标帧` : "等待抽取"}`} selected={selected} pinned={d.pinnedNodes?.has("keyframe")} onTogglePin={() => d.onToggleNodePin?.("keyframe")} @@ -1816,17 +1816,16 @@ export function KeyframeNode({ data, selected }: any) { {frames.length > 0 ? (() => { const cleanedCount = frames.filter((x) => x.cleaned_url).length const elementsCount = frames.reduce((s, x) => s + (x.elements?.length ?? 0), 0) - const cutoutCount = frames.reduce((s, x) => s + (x.elements?.filter((e) => hasCutout(e)).length ?? 0), 0) return (
自动 {frames.length} 张 {" · "} 0 ? "text-cyan-300/90 font-medium" : ""}>{cleanedCount} 已清洗 {" · "} - 0 ? "text-violet-300/90 font-medium" : ""}>{cutoutCount}/{elementsCount} 已抠图 + 0 ? "text-violet-300/90 font-medium" : ""}>{elementsCount} 主体候选
- 点缩略图 → 清洗水印 / 提取可借鉴元素 → 改造成 SKG 画面素材 + 点缩略图 → 清洗水印 / 准备场景图和主体资产 → 改造成 SKG 画面素材
) @@ -1916,7 +1915,7 @@ export function KeyframePanelNode({ data }: any) { >
- 关键帧详情 · 元素提取 + 关键帧素材准备 {active ? `分镜 ${active.index + 1} · ${active.timestamp.toFixed(2)}s` : "未选分镜"}