diff --git a/.memory/worklog.json b/.memory/worklog.json
index eb806a4..8f126e4 100644
--- a/.memory/worklog.json
+++ b/.memory/worklog.json
@@ -1,19 +1,5 @@
{
"entries": [
- {
- "files_changed": 3,
- "hash": "4138bea",
- "message": "auto-save 2026-05-12 17:00 (~3)",
- "ts": "2026-05-12T17:01:09+08:00",
- "type": "commit"
- },
- {
- "files_changed": 4,
- "hash": "94afd6d",
- "message": "auto-save 2026-05-12 17:06 (+1, ~3)",
- "ts": "2026-05-12T17:06:43+08:00",
- "type": "commit"
- },
{
"files_changed": 3,
"hash": "e1bc89a",
@@ -3353,6 +3339,19 @@
"type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令:codex · 4 项未提交变更 · 最近提交:auto-save 2026-05-14 06:05 (~1)",
"files_changed": 4
+ },
+ {
+ "ts": "2026-05-14T06:11:29+08:00",
+ "type": "commit",
+ "message": "auto-save 2026-05-14 06:11 (~6)",
+ "hash": "871ced6",
+ "files_changed": 6
+ },
+ {
+ "ts": "2026-05-13T22:13:14Z",
+ "type": "session-heartbeat",
+ "message": "Claude 会话活跃 · 最近命令:claude · 2 项未提交变更 · 最近提交:auto-save 2026-05-14 06:11 (~6)",
+ "files_changed": 2
}
]
}
diff --git a/api/main.py b/api/main.py
index c95d2bd..6fcd747 100644
--- a/api/main.py
+++ b/api/main.py
@@ -2077,15 +2077,18 @@ def generate_scene_asset(job_id: str, idx: int, req: GenerateSceneAssetReq) -> J
confirmed_subjects = [
(e.name_en or e.name_zh).strip()
- for e in (frame.elements or [])
+ for ref_frame in job.frames
+ for e in (ref_frame.elements or [])
if (e.subject_assets or [])
]
if not confirmed_subjects:
confirmed_subjects = [
(e.name_en or e.name_zh).strip()
- for e in (frame.elements or [])
+ for ref_frame in job.frames
+ for e in (ref_frame.elements or [])
if (e.name_en or e.name_zh).strip()
][:3]
+ confirmed_subjects = list(dict.fromkeys([x for x in confirmed_subjects if x]))[:3]
subject_clause = (
"Confirmed foreground subject(s) to remove: " + ", ".join(confirmed_subjects) + ". "
if confirmed_subjects
diff --git a/docs/source-analysis.html b/docs/source-analysis.html
index 5f2d86c..0f01d1b 100644
--- a/docs/source-analysis.html
+++ b/docs/source-analysis.html
@@ -682,6 +682,8 @@ api/main.py
SceneAsset {
id, label, url,
width, height, quality, size,
+ scene_mode: remove_subject | similar | style,
+ scene_style,
quality_report
}
@@ -859,7 +861,7 @@ SubjectAsset {
Layout
-
问题:“原图/清洗、场景图、主体资产、审核”都应遵循同一结构:左侧负责看图和框选,右侧负责操作、状态和结果;旧布局把部分操作塞在左侧下方,导致左侧满、右侧空。
+
问题:“原图/清洗、主体资产、场景图、审核”都应遵循同一结构:左侧负责看图和框选,右侧负责操作、状态和结果;旧布局把部分操作塞在左侧下方,导致左侧满、右侧空。
改动:FrameLightbox 统一为左侧主图、右侧操作栏。清洗按钮、批量清洗、清洗结果预览、场景图生成/复制、主体识别/主体资产包和审核状态都在右侧;切换到非清洗页时会退出框选模式,避免画框状态残留。
影响:web/components/lightbox.tsx、docs/source-analysis.html。
@@ -896,7 +898,7 @@ SubjectAsset {
问题:面板标题仍叫“关键帧详情 · 元素提取”,里面还露出普通 cutout 抠图、AI 提取、元素清单等旧流程入口;“选用此帧 / 加入目标帧”也无法说明实际价值,因为抽帧阶段已经筛过图,进入画面工作台的关键帧默认就应该参与素材准备。
-
改动:KeyframePanelNode 标题改为“关键帧素材准备”;FrameLightbox 的主体页改成“主体识别 / 主体清单 / 主体资产包”,移除普通抠图列表、AI 提取按钮、详情内的目标帧开关以及手动/框选加主体入口,改为只显示“已在素材准备流程”的状态说明;VisualLabNode 上方缩略图也不再展示普通抠图分组,只展示关键帧、场景图、主体包和视频任务。
+
改动:KeyframePanelNode 标题改为“关键帧素材准备”;FrameLightbox 的主体页改成“主体识别 / 主体清单 / 主体资产包”,移除普通抠图列表、AI 提取按钮、详情内的目标帧开关以及手动/框选加主体入口,改为只显示“已在素材准备流程”的状态说明;VisualLabNode 上方缩略图也不再展示普通抠图分组,只展示关键帧、主体包、场景图和视频任务。
影响:web/components/lightbox.tsx、web/components/nodes/index.tsx、docs/source-analysis.html。底层旧 cutout 数据和接口暂保留兼容历史任务,但不再作为新素材准备流程的可见入口。
@@ -907,20 +909,20 @@ SubjectAsset {
UX
-
问题:画面工作台从展示缩略图扩展为素材生产中枢后,关键帧、场景图、主体资产包和视频任务继续混在一个列表里会让流程不清晰;关键帧详情面板也把清洗、识别、场景和主体生成都堆在一屏。
-
改动:VisualLabNode 改成素材准备进度看板,显示关键帧素材、场景图、主体资产和分镜/视频四个入口。FrameLightbox 新增“原图/清洗、场景图、主体资产、审核”四个页签,素材审核信息从普通列表中拆出来。
+
问题:画面工作台从展示缩略图扩展为素材生产中枢后,关键帧、主体资产包、场景图和视频任务继续混在一个列表里会让流程不清晰;关键帧详情面板也把清洗、识别、主体生成和场景生成都堆在一屏。
+
改动:VisualLabNode 改成素材准备进度看板,显示关键帧素材、主体资产、场景图和分镜/视频四个入口。FrameLightbox 新增“原图/清洗、主体资产、场景图、审核”四个页签,素材审核信息从普通列表中拆出来。
影响:web/components/nodes/index.tsx、web/components/lightbox.tsx、docs/source-analysis.html。这轮只重排工作台信息架构,批量自动准备队列仍留到下一阶段。
- 2026-05-14 · 画面工作台增加场景图和主体资产包
+ 2026-05-14 · 画面工作台增加主体资产包和场景图
Visual Lab
Assets
-
问题:抽帧阶段已经筛过图,画面工作台第一步应把已选关键帧转成可用于生视频的干净素材:每帧一张场景图,同一主体一套多视角/动作/表情图,而不是继续手动逐张抠普通 cutout。
-
改动:KeyFrame 新增 scene_assets 和 quality_report;KeyElement 新增 subject_kind 与 subject_assets。后端新增 generateSceneAsset 和 generateSubjectAssets,主体资产支持白/黑背景、原尺寸/固定尺寸、物体视角、人物/生物动作与喜怒哀乐等表情;当已选关键帧共同指向一个主体时,前端会把这些帧作为 source_frame_indices 传入,后端拼接参考板。
+
问题:抽帧阶段已经筛过图,画面工作台第一步应把已选关键帧转成可用于生视频的干净素材:同一主体一套多视角/动作/表情图,再基于主体生成每帧去主体场景图,而不是继续手动逐张抠普通 cutout。
+
改动:KeyFrame 新增 scene_assets 和 quality_report;KeyElement 新增 subject_kind 与 subject_assets。后端新增 generateSubjectAssets 和 generateSceneAsset,主体资产支持白/黑背景、原尺寸/固定尺寸、物体视角、人物/生物动作与喜怒哀乐等表情;当已选关键帧共同指向一个主体时,前端会把这些帧作为 source_frame_indices 传入,后端拼接参考板。
影响:api/main.py、web/lib/api.ts、web/components/lightbox.tsx、web/components/nodes/index.tsx、docs/source-analysis.html。生成出的素材保存在 jobs/<jobId>/assets,可作为 asset 类型复制到后续分镜槽位。
diff --git a/web/components/lightbox.tsx b/web/components/lightbox.tsx
index 961d44b..087944a 100644
--- a/web/components/lightbox.tsx
+++ b/web/components/lightbox.tsx
@@ -86,6 +86,7 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
const [subjectViews, setSubjectViews] = useState>({})
const [activeTab, setActiveTab] = useState("clean")
const [editingElement, setEditingElement] = useState<{
+ frameIndex: number
id: string
name_zh: string
name_en: string
@@ -138,8 +139,28 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
const cleanedFrameCount = frames.filter((frame) => frame.cleaned_applied || frame.cleaned_url).length
const pendingCleanFrames = frames.filter((frame) => !frame.cleaned_applied && !frame.cleaned_url)
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 subjectReferenceFrameIndices = (selectedFrameIndices.length > 0 ? selectedFrameIndices : frames.map((frame) => frame.index))
+ .filter((idx, pos, arr) => arr.indexOf(idx) === pos)
+ const subjectReferenceLabel = selectedFrameIndices.length > 0
+ ? `${subjectReferenceFrameIndices.length} 已选帧参考`
+ : `${subjectReferenceFrameIndices.length} 全部帧参考`
+ const subjectElementRefs = frames.flatMap((frame) =>
+ (frame.elements ?? []).map((element) => ({
+ frameIndex: frame.index,
+ frameLabel: `分镜 ${frame.index + 1}`,
+ element,
+ })),
+ )
+ const activeSubjectRefs = elements.map((element) => ({
+ frameIndex: f.index,
+ frameLabel: `分镜 ${f.index + 1}`,
+ element,
+ }))
+ const subjectDisplayRefs = activeSubjectRefs.length > 0
+ ? activeSubjectRefs
+ : subjectElementRefs.slice(0, 1)
+ const hasUnifiedSubject = subjectElementRefs.length > 0
+ const subjectAssetCount = subjectElementRefs.reduce((sum, item) => sum + (item.element.subject_assets?.length ?? 0), 0)
const hasSubjectAssets = subjectAssetCount > 0
const qualityWarnings = [
...(f.quality_report?.warnings ?? []),
@@ -231,21 +252,21 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
}
}
- const handleGenerateSubjectPackage = async (elementId: string) => {
+ const handleGenerateSubjectPackage = async (elementId: string, frameIdx = f.index) => {
const kind = subjectKinds[elementId] ?? "object"
const defaultViews = (kind === "living" ? LIVING_VIEW_OPTIONS : OBJECT_VIEW_OPTIONS).map(([value]) => value)
const views = subjectViews[elementId]?.length ? subjectViews[elementId] : defaultViews
setSubjectGenerating(elementId)
try {
- const updated = await generateSubjectAssets(jobId, f.index, elementId, {
+ const updated = await generateSubjectAssets(jobId, frameIdx, elementId, {
subject_kind: kind,
background: subjectBackgrounds[elementId] ?? "white",
size: assetSize,
- source_frame_indices: sharedSubjectFrameIndices,
+ source_frame_indices: subjectReferenceFrameIndices,
views,
})
onJobUpdate?.(updated)
- toast.success(`主体资产包已生成 · ${views.length} 张`)
+ toast.success(`统一主体资产包已生成 · ${views.length} 张 · ${subjectReferenceFrameIndices.length} 帧参考`)
} catch (e) {
toast.error("主体资产包生成失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
@@ -325,6 +346,10 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
const handleAddElement = async (name_zh: string, name_en?: string, position?: string, source: "auto" | "manual" = "manual") => {
const zh = name_zh.trim()
if (!zh) return
+ if (hasUnifiedSubject) {
+ toast.message("当前流程只保留一个主体;如需更换,请先删除现有统一主体")
+ return
+ }
try {
const updated = await addElement(jobId, f.index, { name_zh: zh, name_en, position, source })
onJobUpdate?.(updated)
@@ -333,9 +358,9 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
}
}
- const handleDeleteElement = async (id: string) => {
+ const handleDeleteElement = async (id: string, frameIdx = f.index) => {
try {
- const updated = await deleteElement(jobId, f.index, id)
+ const updated = await deleteElement(jobId, frameIdx, id)
onJobUpdate?.(updated)
if (editingElement?.id === id) setEditingElement(null)
} catch (e) {
@@ -346,7 +371,7 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
const handleUpdateElement = async () => {
if (!editingElement || !editingElement.name_zh.trim()) return
try {
- const updated = await updateElement(jobId, f.index, editingElement.id, {
+ const updated = await updateElement(jobId, editingElement.frameIndex, editingElement.id, {
name_zh: editingElement.name_zh,
name_en: editingElement.name_en,
position: editingElement.position,
@@ -447,7 +472,7 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
))}
- {hasSubjectAssets ? `${subjectAssetCount} 主体资产` : "主体待生成"}
+ {hasSubjectAssets ? `统一主体 ${subjectAssetCount} 张` : hasUnifiedSubject ? "统一主体待生成" : "统一主体待选择"}
·
{latestSceneAsset ? "场景已生成" : "场景待生成"}
@@ -677,7 +702,7 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
- 先根据主体资产确认要移除的主体,再补全空场景;之后可生成相似新场景或同构换风格场景。
+ 先用多张关键帧生成统一主体资产,再按当前关键帧去除主体并补全空场景;之后可生成相似新场景或同构换风格场景。
0 ? "border-violet-300/35 bg-violet-500/12 text-violet-100" : "border-white/10 bg-black/25 text-white/55"}`}>
主体
-
{elements.length} 个
+
{hasUnifiedSubject ? "1 个" : "未选"}
0 ? "border-violet-300/35 bg-violet-500/12 text-violet-100" : "border-white/10 bg-black/25 text-white/55"}`}>
-
资产
+
主体包
{subjectAssetCount} 张
@@ -805,7 +830,7 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
- 主体识别
+ 统一主体识别
{desc && 已识别}
) : (
@@ -847,20 +872,23 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
{desc.objects && desc.objects.length > 0 && (
- 候选主体 · 点击加入「主体清单」
+ 候选主体 · 选择唯一主体
{desc.objects.map((o, i) => {
const alreadyIn = elements.some((e) => e.name_zh === o.name)
+ const locked = hasUnifiedSubject && !alreadyIn
return (