diff --git a/.memory/worklog.json b/.memory/worklog.json
index e9679f1..95ce866 100644
--- a/.memory/worklog.json
+++ b/.memory/worklog.json
@@ -1330,6 +1330,19 @@
"message": "auto-save 2026-05-13 11:17 (~2)",
"hash": "f4ce533",
"files_changed": 2
+ },
+ {
+ "ts": "2026-05-13T11:23:24+08:00",
+ "type": "commit",
+ "message": "auto-save 2026-05-13 11:23 (~4)",
+ "hash": "647b05a",
+ "files_changed": 4
+ },
+ {
+ "ts": "2026-05-13T03:27:37Z",
+ "type": "session-heartbeat",
+ "message": "Claude 会话活跃 · 最近命令:claude · 2 项未提交变更 · 最近提交:auto-save 2026-05-13 11:23 (~4)",
+ "files_changed": 2
}
]
}
diff --git a/web/components/lightbox.tsx b/web/components/lightbox.tsx
index 801ed32..deb6788 100644
--- a/web/components/lightbox.tsx
+++ b/web/components/lightbox.tsx
@@ -109,17 +109,17 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
}
const handleExtractRegion = async () => {
- if (!region || !extractName.trim()) return
+ // 提取语义只在恰好 1 个框时支持
+ if (regions.length !== 1 || !extractName.trim()) return
+ const r = regions[0]
setExtracting(true)
try {
- // 先加 element 拿到 id
const added = await addElement(jobId, f.index, {
name_zh: extractName.trim(),
source: "region",
- region,
+ region: r,
})
onJobUpdate?.(added)
- // 找到新加的 element id(按 created_at desc 取最新一条)
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) {
@@ -129,7 +129,7 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
} else {
toast.success(`「${extractName.trim()}」已加入元素清单 · 但抠图未触发`)
}
- setCropMode(false); setRegion(null); setExtractNamePrompt(false); setExtractName("")
+ setCropMode(false); setRegions([]); setExtractNamePrompt(false); setExtractName("")
} catch (e) {
toast.error("提取失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
@@ -153,20 +153,26 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
const p = getRelXY(e.clientX, e.clientY)
if (!p) return
setDragStart(p)
- setRegion({ x: p.x, y: p.y, w: 0, h: 0 })
+ setDraftRegion({ x: p.x, y: p.y, w: 0, h: 0 })
}
const onCropMouseMove = (e: React.MouseEvent) => {
if (!cropMode || !dragStart) return
const p = getRelXY(e.clientX, e.clientY)
if (!p) return
- setRegion({
+ setDraftRegion({
x: Math.min(dragStart.x, p.x),
y: Math.min(dragStart.y, p.y),
w: Math.abs(p.x - dragStart.x),
h: Math.abs(p.y - dragStart.y),
})
}
- const onCropMouseUp = () => setDragStart(null)
+ const onCropMouseUp = () => {
+ if (draftRegion && draftRegion.w >= 0.02 && draftRegion.h >= 0.02) {
+ setRegions((prev) => [...prev, draftRegion])
+ }
+ setDraftRegion(null)
+ setDragStart(null)
+ }
const handleApplyCleaned = async () => {
setApplying(true)
@@ -301,31 +307,36 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
{f.cleaned_applied ? "✨ 已替换为清洗版" : "原图"}
- {/* 画框 overlay */}
- {cropMode && region && region.w > 0 && region.h > 0 && (
- <>
- {/* 选区外暗化 — 用 4 个半透 div 围出 */}
-
-
-
-
- {/* 选区高亮边框 */}
-
- >
+ {/* 已确认的多个选区 */}
+ {cropMode && regions.map((r, i) => (
+
+ #{i + 1}
+
+ ))}
+
+ {/* 当前正在拖的草稿框 */}
+ {cropMode && draftRegion && draftRegion.w > 0 && draftRegion.h > 0 && (
+
)}
- {/* 画框模式提示 */}
+ {/* 画框模式角标(小,左上) — 不再遮挡画面 */}
{cropMode && (
-
- {region && region.w > 0 ? `选区:${(region.w * 100).toFixed(0)}% × ${(region.h * 100).toFixed(0)}%` : "在图上拖动鼠标 → 框选清洗范围"}
+
+ 画框 · 已选 {regions.length}
)}
@@ -333,7 +344,7 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
{/* 画框工具栏 */}
{cropMode ? (
extractNamePrompt ? (
- // 提取模式:要用户填名字
+ // 提取模式:要用户填名字(仅 1 框时进入此模式)
) : (
- // 画框完成 → 选操作
-
-
-
-
+ // 画框模式 — 可连续画多框
+
+
+ {regions.length === 0
+ ? "在图上拖动鼠标 → 框选要处理的区域(可连续画多个)"
+ : `已框 ${regions.length} 个 · 继续拖鼠标加框 · 「去掉」批量清洗 · 单框可「提取」为元素`}
+
+
+
+
+
+
+
)
) : (
)}
diff --git a/web/components/nodes/index.tsx b/web/components/nodes/index.tsx
index 272f18c..1d320b5 100644
--- a/web/components/nodes/index.tsx
+++ b/web/components/nodes/index.tsx
@@ -6,7 +6,7 @@ import {
Mic, Languages, FileEdit, Sparkles, Film, FileVideo, Loader2, Plus, X,
} from "lucide-react"
import { NodeShell, type NodeStatus, type NodeKind } from "./node-shell"
-import { type Job, frameUrl, videoUrl, generatedImageUrl } from "@/lib/api"
+import { type Job, frameUrl, effectiveFrameUrl, videoUrl, generatedImageUrl } from "@/lib/api"
export interface NodeData {
job: Job | null // 当前 active job
diff --git a/web/lib/api.ts b/web/lib/api.ts
index 130d018..04fecd1 100644
--- a/web/lib/api.ts
+++ b/web/lib/api.ts
@@ -196,6 +196,12 @@ export function frameUrl(jobId: string, frameIndex: number): string {
return `${API_BASE}/jobs/${jobId}/frames/${frameIndex}.jpg`
}
+// 接 frame 对象时返回正确版本 URL(已应用清洗版时加 cache-bust)
+export function effectiveFrameUrl(jobId: string, frame: { index: number; cleaned_applied?: boolean | null }): string {
+ const base = `${API_BASE}/jobs/${jobId}/frames/${frame.index}.jpg`
+ return frame.cleaned_applied ? `${base}?v=applied` : base
+}
+
export function videoUrl(jobId: string): string {
return `${API_BASE}/jobs/${jobId}/video.mp4`
}