auto-save 2026-05-14 06:11 (~6)

This commit is contained in:
2026-05-14 06:11:29 +08:00
parent 2b546168f7
commit 871ced6d2d
6 changed files with 225 additions and 82 deletions

View File

@@ -1,19 +1,5 @@
{
"entries": [
{
"files_changed": 3,
"hash": "4779c26",
"message": "auto-save 2026-05-12 16:49 (~3)",
"ts": "2026-05-12T16:50:05+08:00",
"type": "commit"
},
{
"files_changed": 4,
"hash": "345391d",
"message": "auto-save 2026-05-12 16:55 (~4)",
"ts": "2026-05-12T16:55:37+08:00",
"type": "commit"
},
{
"files_changed": 3,
"hash": "4138bea",
@@ -3354,6 +3340,19 @@
"type": "session-heartbeat",
"message": "Claude 会话活跃 · 最近命令claude · 1 项未提交变更 · 最近提交auto-save 2026-05-14 06:00 (~2)",
"files_changed": 1
},
{
"ts": "2026-05-14T06:05:57+08:00",
"type": "commit",
"message": "auto-save 2026-05-14 06:05 (~1)",
"hash": "2b54616",
"files_changed": 1
},
{
"ts": "2026-05-13T22:08:51Z",
"type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令codex · 4 项未提交变更 · 最近提交auto-save 2026-05-14 06:05 (~1)",
"files_changed": 4
}
]
}

View File

@@ -97,6 +97,8 @@ AssetSize = Literal["source", "1024", "1536", "2048"]
AssetQuality = Literal["hd"]
SubjectKind = Literal["object", "living"]
SubjectView = str
SceneMode = Literal["remove_subject", "similar", "style"]
SceneStyle = Literal["source", "premium_product", "clean_studio", "warm_lifestyle", "cinematic"]
FRAME_TARGET_LABELS: dict[FrameExtractTarget, str] = {
"balanced": "综合关键帧",
"subject": "清晰主体",
@@ -191,6 +193,8 @@ class SceneAsset(BaseModel):
height: int = 0
quality: AssetQuality = "hd"
size: AssetSize = "source"
scene_mode: SceneMode = "remove_subject"
scene_style: SceneStyle = "source"
quality_report: QualityReport | None = None
created_at: float = 0.0
@@ -1930,6 +1934,8 @@ class UpdateElementReq(BaseModel):
class GenerateSceneAssetReq(BaseModel):
quality: AssetQuality = "hd"
size: AssetSize = "source"
scene_mode: SceneMode = "remove_subject"
scene_style: SceneStyle = "source"
class GenerateSubjectAssetsReq(BaseModel):
@@ -2058,7 +2064,8 @@ def delete_element(job_id: str, idx: int, element_id: str) -> Job:
@app.post("/jobs/{job_id}/frames/{idx}/scene-asset", response_model=Job)
def generate_scene_asset(job_id: str, idx: int, req: GenerateSceneAssetReq) -> Job:
"""为关键帧生成一张干净、高清的场景参考图。默认一帧只需要一张,重跑会保留历史供人工比对。"""
"""为关键帧生成一张干净、高清的场景参考图。默认一帧只需要一张,重跑会保留历史供人工比对。
场景图排在主体资产之后:优先依据已确认主体,去主体并补全背景,再按模式生成原场景/相似场景/换风格场景。"""
import time as _time
job = JOBS.get(job_id)
if not job:
@@ -2068,12 +2075,51 @@ def generate_scene_asset(job_id: str, idx: int, req: GenerateSceneAssetReq) -> J
if not src.exists():
raise HTTPException(404, "source frame file missing")
confirmed_subjects = [
(e.name_en or e.name_zh).strip()
for e in (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 [])
if (e.name_en or e.name_zh).strip()
][:3]
subject_clause = (
"Confirmed foreground subject(s) to remove: " + ", ".join(confirmed_subjects) + ". "
if confirmed_subjects
else "Remove the main foreground subject from the frame if present. "
)
mode_clause = {
"remove_subject": (
"Keep the original environment, camera angle, perspective, composition, lighting direction, color mood, and spatial layout. "
"The result should be an empty clean scene/background plate with the subject removed and the occluded background reconstructed."
),
"similar": (
"Create a similar but not identical scene/background plate: keep the same camera angle, rough spatial layout, lighting direction, and usage context, "
"but vary props, surface details, textures, and small environmental details so it is not a duplicate of the source."
),
"style": (
"Create a scene/background plate with the same camera angle and spatial layout, but reinterpret the environment in the selected visual style. "
"Keep it believable and useful for image-to-video generation."
),
}[req.scene_mode]
style_clause = {
"source": "Follow the original source style.",
"premium_product": "Use a premium product-advertising style: polished, high-end, clean commercial lighting, refined materials.",
"clean_studio": "Use a clean studio style: simple surfaces, controlled lighting, minimal distractions.",
"warm_lifestyle": "Use a warm lifestyle style: realistic lived-in details, soft natural light, approachable atmosphere.",
"cinematic": "Use a cinematic style: dramatic but natural lighting, richer depth, filmic contrast, not fantasy.",
}[req.scene_style]
prompt = (
"Create one clean high-definition scene reference image from this frame. "
"Remove watermarks, platform UI, captions, usernames, hashtags, logos, and overlay graphics. "
"Preserve the original camera angle, composition, environment, lighting style, and believable spatial layout. "
"Do not create multiple views. Do not isolate objects. Keep it useful as the scene/background reference for image-to-video generation. "
"Enhance clarity and texture while avoiding over-smoothing or changing important visual details."
"Create one clean high-definition scene/background reference image from this frame. "
+ subject_clause
+ "Do not include the removed subject, duplicate people, animals, products, text, watermark, platform UI, captions, usernames, hashtags, logos, or overlay graphics. "
+ mode_clause + " "
+ style_clause + " "
+ "Enhance clarity and texture while avoiding over-smoothing, warped geometry, or changing important perspective details. "
+ "Do not create multiple views. Do not isolate objects."
)
models = [IMAGE_MODEL, "gemini-3.1-flash-image-preview", "gemini-2.5-flash-image"]
try:
@@ -2093,6 +2139,8 @@ def generate_scene_asset(job_id: str, idx: int, req: GenerateSceneAssetReq) -> J
height=height,
quality=req.quality,
size=req.size,
scene_mode=req.scene_mode,
scene_style=req.scene_style,
quality_report=report,
created_at=_time.time(),
)

View File

@@ -555,7 +555,7 @@
<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>主体识别</h3><p>识别场景和主体候选,只是候选,不应锁死。</p></div>
<div class="step"><div class="num">5</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>
@@ -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,13 +618,13 @@ 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>addElement</code><code>generateSceneAsset</code><code>generateSubjectAssets</code></span></div>
<div><strong>适合怎么描述</strong><span>“某张关键帧的水印、场景图、主体多视角/动作/表情图和质量风险应该如何审核”。</span></div>
<div><strong>主要源码</strong><span><code>FrameLightbox</code>;按“原图/清洗、主体资产、场景图、审核”四个页签组织;左侧只放主图/框选画布,右侧承载当前页操作、状态和结果;场景图依赖主体资产,支持去主体原场景、相似新场景和同构换风格。相关接口包括 <code>cleanupFrame</code><code>addElement</code><code>generateSubjectAssets</code><code>generateSceneAsset</code></span></div>
<div><strong>适合怎么描述</strong><span>“某张关键帧的水印、主体多视角/动作/表情图、去主体场景图和质量风险应该如何审核”。</span></div>
</div>
<div class="flow-row">
<div><strong>你看到的区域</strong><span>顶部分镜头编排下拉面板</span></div>
@@ -728,8 +728,8 @@ SubjectAsset {
<tr><td>应用清洗</td><td><code>POST /cleanup/apply</code></td><td><code>applyCleanedFrame</code></td><td>物理覆盖 frames/{idx}.jpg并备份原图。</td></tr>
<tr><td>元素增改删</td><td><code>POST/PATCH/DELETE /elements</code></td><td><code>addElement/updateElement/deleteElement</code></td><td>让用户修正 Vision 错误,避免候选结果锁死。</td></tr>
<tr><td>元素提取</td><td><code>POST /elements/{element_id}/cutout</code></td><td><code>cutoutElement</code></td><td>调用图像模型生成独立白底素材图,每次累积一张 cutout。</td></tr>
<tr><td>场景资产</td><td><code>POST /frames/{idx}/scene-asset</code></td><td><code>generateSceneAsset</code></td><td>为每张已选关键帧生成一张去水印、高清增强的场景图,保留历史版本用于人工审核。</td></tr>
<tr><td>主体资产包</td><td><code>POST /elements/{element_id}/subject-assets</code></td><td><code>generateSubjectAssets</code></td><td>根据用户选择的视图、动作和表情生成主体资产包;当多个关键帧都指向同一主体时,前端把已选关键帧作为 <code>source_frame_indices</code> 传入,后端拼参考板。</td></tr>
<tr><td>场景资产</td><td><code>POST /frames/{idx}/scene-asset</code></td><td><code>generateSceneAsset</code></td><td>在主体资产之后生成去主体背景板;请求包含 <code>scene_mode</code><code>scene_style</code>,可做原场景补背景、相似新场景或同构换风格,保留历史版本用于人工审核。</td></tr>
<tr><td>分镜保存</td><td><code>PUT /frames/{idx}/storyboard</code></td><td><code>updateStoryboard</code></td><td>保存 4 图槽、时长和改造说明。</td></tr>
<tr><td>生图</td><td><code>POST /frames/{idx}/generate</code></td><td><code>generateImage</code></td><td>基于关键帧或已选生成图做 image-to-image目前可用。</td></tr>
</tbody>
@@ -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>
@@ -814,7 +814,7 @@ SubjectAsset {
<div class="todo">
<div class="todo-item">
<h3>改关键帧素材准备</h3>
<p>“我在关键帧素材准备面板里,主体候选应该怎么编辑/删除;场景图和主体资产包怎么生成、审核、复制到分镜。”</p>
<p>“我在关键帧素材准备面板里,主体候选应该怎么编辑/删除;主体资产包怎么生成;场景图怎么基于主体去除、换风格、审核、复制到分镜。”</p>
</div>
<div class="todo-item">
<h3>改 Storyboard 节点</h3>
@@ -841,13 +841,26 @@ SubjectAsset {
<div class="changelog">
<article class="change">
<header>
<h3>2026-05-14 · 关键帧素材面板右侧改为紧凑状态栏</h3>
<h3>2026-05-14 · 场景图改为主体资产之后生成</h3>
<span class="tag violet">FrameLightbox</span>
<span class="tag blue">API</span>
</header>
<div class="body">
<p><strong>问题:</strong>场景图如果先于主体资产生成,只能做普通背景清理,无法准确知道要移除哪个主体,也不利于后续生成相似但不同或同构换风格的场景。</p>
<p><strong>改动:</strong><code>FrameLightbox</code> 页签顺序改为“原图/清洗 → 主体资产 → 场景图 → 审核”;画面工作台缩略图和进度文案也同步为主体资产先于场景图。场景图页新增“去主体原场景 / 相似新场景 / 同构换风格”和风格选择,且在没有主体资产时提示先生成主体资产。</p>
<p><strong>后端:</strong><code>generateSceneAsset</code> 请求新增 <code>scene_mode</code><code>scene_style</code>;后端提示词会优先读取已生成主体资产对应的主体名称,生成去主体并补背景的场景图,再按模式决定是否做相似变化或风格变化。</p>
<p><strong>影响:</strong><code>web/components/lightbox.tsx</code><code>web/components/nodes/index.tsx</code><code>web/lib/api.ts</code><code>api/main.py</code><code>docs/source-analysis.html</code></p>
</div>
</article>
<article class="change">
<header>
<h3>2026-05-14 · 关键帧素材面板统一右侧操作栏</h3>
<span class="tag violet">FrameLightbox</span>
<span class="tag blue">Layout</span>
</header>
<div class="body">
<p><strong>问题:</strong>移除旧元素提取和手工加主体入口后,关键帧详情右侧内容变少,继续占用大列会压缩左侧主图和清洗操作区</p>
<p><strong>改动:</strong><code>FrameLightbox</code> 在“原图/清洗、场景图、审核”页把右侧改成固定窄状态栏,左侧主图和操作区获得更大宽度;“主体资产”页仍保留较宽右栏,用于主体识别主体清单和资产包。</p>
<p><strong>问题:</strong>“原图/清洗、场景图、主体资产、审核”都应遵循同一结构:左侧负责看图和框选,右侧负责操作、状态和结果;旧布局把部分操作塞在左侧下方,导致左侧满、右侧空</p>
<p><strong>改动:</strong><code>FrameLightbox</code> 统一为左侧主图、右侧操作栏。清洗按钮、批量清洗、清洗结果预览、场景图生成/复制、主体识别/主体资产包和审核状态都在右侧;切换到非清洗页时会退出框选模式,避免画框状态残留</p>
<p><strong>影响:</strong><code>web/components/lightbox.tsx</code><code>docs/source-analysis.html</code></p>
</div>
</article>

View File

@@ -6,7 +6,7 @@ import {
frameUrl, cleanedFrameUrl, apiAssetUrl,
describeFrame, cleanupFrame, applyCleanedFrame, discardCleanedFrame, addElement, updateElement, deleteElement,
generateSceneAsset, generateSubjectAssets,
type AssetBackground, type AssetSize, type KeyFrame, type Job, type ImageRef, type SubjectKind,
type AssetBackground, type AssetSize, type KeyFrame, type Job, type ImageRef, type SceneMode, type SceneStyle, type SubjectKind,
} from "@/lib/api"
import { toast } from "sonner"
@@ -51,11 +51,25 @@ type LightboxTab = "clean" | "scene" | "subject" | "review"
const LIGHTBOX_TABS: Array<{ key: LightboxTab; label: string }> = [
{ key: "clean", label: "原图/清洗" },
{ key: "scene", label: "场景图" },
{ key: "subject", label: "主体资产" },
{ key: "scene", label: "场景图" },
{ key: "review", label: "审核" },
]
const SCENE_MODE_OPTIONS: Array<[SceneMode, string]> = [
["remove_subject", "去主体原场景"],
["similar", "相似新场景"],
["style", "同构换风格"],
]
const SCENE_STYLE_OPTIONS: Array<[SceneStyle, string]> = [
["source", "跟随原图"],
["premium_product", "高端产品感"],
["clean_studio", "干净工作室"],
["warm_lifestyle", "真实生活感"],
["cinematic", "电影感"],
]
export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, onChange, onToggleSelect, onJobUpdate, onSwitchPanel, onCopyImage, embedded = false }: Props) {
const [describing, setDescribing] = useState(false)
const [cleaningFrameIds, setCleaningFrameIds] = useState<Set<number>>(new Set())
@@ -65,6 +79,8 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
const [sceneGenerating, setSceneGenerating] = useState(false)
const [subjectGenerating, setSubjectGenerating] = useState<string | null>(null)
const [assetSize, setAssetSize] = useState<AssetSize>("source")
const [sceneMode, setSceneMode] = useState<SceneMode>("remove_subject")
const [sceneStyle, setSceneStyle] = useState<SceneStyle>("source")
const [subjectKinds, setSubjectKinds] = useState<Record<string, SubjectKind>>({})
const [subjectBackgrounds, setSubjectBackgrounds] = useState<Record<string, AssetBackground>>({})
const [subjectViews, setSubjectViews] = useState<Record<string, string[]>>({})
@@ -124,6 +140,7 @@ 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 hasSubjectAssets = subjectAssetCount > 0
const qualityWarnings = [
...(f.quality_report?.warnings ?? []),
...(latestSceneAsset?.quality_report?.warnings ?? []),
@@ -197,9 +214,14 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
}
const handleGenerateSceneAsset = async () => {
if (!hasSubjectAssets) {
setActiveTab("subject")
toast.message("先生成主体资产,再生成去主体场景图")
return
}
setSceneGenerating(true)
try {
const updated = await generateSceneAsset(jobId, f.index, { size: assetSize })
const updated = await generateSceneAsset(jobId, f.index, { size: assetSize, scene_mode: sceneMode, scene_style: sceneStyle })
onJobUpdate?.(updated)
toast.success(`分镜 ${f.index + 1} 场景图已生成`)
} catch (e) {
@@ -406,7 +428,15 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
<button
key={tab.key}
type="button"
onClick={() => setActiveTab(tab.key)}
onClick={() => {
setActiveTab(tab.key)
if (tab.key !== "clean") {
setCropMode(false)
setRegions([])
setDraftRegion(null)
setDragStart(null)
}
}}
className={`h-7 rounded-md px-2.5 text-[11px] font-medium transition ${
activeTab === tab.key
? "bg-white text-black shadow"
@@ -417,13 +447,13 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
</button>
))}
<div className="ml-auto hidden items-center gap-2 text-[10px] text-white/42 sm:flex">
<span>{latestSceneAsset ? "场景已生成" : "场景待生成"}</span>
<span>{hasSubjectAssets ? `${subjectAssetCount} 主体资产` : "主体待生成"}</span>
<span>·</span>
<span>{subjectAssetCount > 0 ? `${subjectAssetCount} 主体资产` : "主体待生成"}</span>
<span>{latestSceneAsset ? "场景已生成" : "场景待生成"}</span>
</div>
</div>
{/* 主体 — 左:大图 + 主操作;右:当前页上下文 / 主体资产 */}
{/* 主体 — 左:主图;右:当前页操作 / 状态 / 主体资产 */}
<div className="flex gap-3 p-3 overflow-hidden flex-1 min-h-0">
{/* 左侧大图区 */}
<div
@@ -448,11 +478,7 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
alt={`frame ${f.index}`}
className="rounded-lg object-contain w-full pointer-events-none"
style={{
maxHeight: isSubjectTab
? (hasCleaned ? "38vh" : "62vh")
: isCleanTab
? "68vh"
: (hasCleaned ? "44vh" : "68vh"),
maxHeight: isSubjectTab ? "64vh" : "68vh",
}}
draggable={false}
/>
@@ -650,11 +676,50 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
<option value="2048">2048</option>
</select>
</div>
<div className="mb-2 rounded-md border border-white/10 bg-black/25 px-2 py-1.5 text-[10px] leading-relaxed text-white/50">
</div>
<div className="mb-2 grid grid-cols-2 gap-1.5">
<label className="space-y-1">
<span className="block text-[9px] text-white/35"></span>
<select
value={sceneMode}
onChange={(e) => setSceneMode(e.target.value as SceneMode)}
className="w-full rounded border border-white/10 bg-black/35 px-1.5 py-1 text-[10px] text-white/75 outline-none"
>
{SCENE_MODE_OPTIONS.map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</label>
<label className="space-y-1">
<span className="block text-[9px] text-white/35"></span>
<select
value={sceneStyle}
onChange={(e) => setSceneStyle(e.target.value as SceneStyle)}
className="w-full rounded border border-white/10 bg-black/35 px-1.5 py-1 text-[10px] text-white/75 outline-none"
>
{SCENE_STYLE_OPTIONS.map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</label>
</div>
{!hasSubjectAssets && (
<div className="mb-2 rounded border border-amber-300/25 bg-amber-500/10 px-2 py-1.5 text-[10px] leading-snug text-amber-100/85">
</div>
)}
{latestSceneAsset ? (
<div className="mb-2 overflow-hidden rounded-md border border-emerald-300/25 bg-black/30">
<img src={apiAssetUrl(latestSceneAsset.url)} alt={latestSceneAsset.label} className="max-h-44 w-full object-contain bg-black" />
<div className="flex items-center justify-between gap-2 border-t border-white/10 px-2 py-1 text-[9.5px] text-white/50">
<span>{latestSceneAsset.width}×{latestSceneAsset.height}</span>
<span>
{latestSceneAsset.width}×{latestSceneAsset.height}
{latestSceneAsset.scene_mode && (
<> · {SCENE_MODE_OPTIONS.find(([value]) => value === latestSceneAsset.scene_mode)?.[1] ?? latestSceneAsset.scene_mode}</>
)}
</span>
{onCopyImage && (
<button
type="button"
@@ -680,13 +745,22 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
<button
type="button"
onClick={handleGenerateSceneAsset}
disabled={sceneGenerating || isCleaningCurrentFrame || batchCleaning}
disabled={sceneGenerating || isCleaningCurrentFrame || batchCleaning || !hasSubjectAssets}
className="w-full rounded-md bg-emerald-500/65 px-2 py-1.5 text-[11px] font-medium text-white transition hover:bg-emerald-400 disabled:cursor-wait disabled:opacity-45 inline-flex items-center justify-center gap-1"
title="生成一张去水印、高清增强后的场景参考图"
title={hasSubjectAssets ? "基于主体资产去主体、补背景并生成场景参考图" : "先生成主体资产"}
>
{sceneGenerating ? <Loader2 className="h-3 w-3 animate-spin" /> : <Sparkles className="h-3 w-3" />}
{sceneGenerating ? "生成场景图中…" : latestSceneAsset ? "重新生成场景图" : "生成场景图"}
{sceneGenerating ? "生成场景图中…" : latestSceneAsset ? "重新生成场景图" : "生成去主体场景图"}
</button>
{!hasSubjectAssets && (
<button
type="button"
onClick={() => setActiveTab("subject")}
className="mt-1.5 w-full rounded-md border border-violet-300/25 bg-violet-500/15 px-2 py-1.5 text-[10.5px] font-medium text-violet-100 transition hover:bg-violet-500/25"
>
</button>
)}
</section>
)}
{activeTab === "review" && (
@@ -697,10 +771,6 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
<div className="text-[9px] opacity-70"></div>
<div>{f.cleaned_applied ? "已应用" : hasCleaned ? "待确认" : "未处理"}</div>
</div>
<div className={`rounded border px-2 py-1 ${latestSceneAsset ? "border-emerald-300/30 bg-emerald-500/10 text-emerald-100" : "border-white/10 bg-black/25 text-white/55"}`}>
<div className="text-[9px] opacity-70"></div>
<div>{latestSceneAsset ? "已生成" : "未生成"}</div>
</div>
<div className={`rounded border px-2 py-1 ${elements.length > 0 ? "border-violet-300/35 bg-violet-500/12 text-violet-100" : "border-white/10 bg-black/25 text-white/55"}`}>
<div className="text-[9px] opacity-70"></div>
<div>{elements.length} </div>
@@ -709,6 +779,10 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
<div className="text-[9px] opacity-70"></div>
<div>{subjectAssetCount} </div>
</div>
<div className={`rounded border px-2 py-1 ${latestSceneAsset ? "border-emerald-300/30 bg-emerald-500/10 text-emerald-100" : "border-white/10 bg-black/25 text-white/55"}`}>
<div className="text-[9px] opacity-70"></div>
<div>{latestSceneAsset ? "已生成" : "未生成"}</div>
</div>
</div>
{qualityWarnings.length > 0 ? (
<div className="rounded border border-amber-300/25 bg-amber-500/10 px-2 py-1.5 text-[10px] leading-snug text-amber-100/85">
@@ -722,7 +796,7 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
</div>
)}
<div className="mt-2 text-[10px] leading-relaxed text-white/42">
</div>
</section>
)}

View File

@@ -1257,18 +1257,6 @@ export function VisualLabNode({ data, selected }: any) {
borderClass: f.quality_report?.risk === "bad" ? "border-rose-300/70" : f.quality_report?.risk === "warn" ? "border-amber-300/70" : "border-orange-300/50",
aspect,
})) : []),
...sceneAssets.map((p) => ({
id: `scene:${p.frameIdx}:${p.assetId}`,
kind: "scene" as const,
group: "场景图",
frameIdx: p.frameIdx,
assetId: p.assetId,
src: p.src,
label: p.label,
caption: `${p.width}×${p.height}`,
borderClass: p.risk === "bad" ? "border-rose-300/70" : p.risk === "warn" ? "border-amber-300/70" : "border-emerald-300/60",
aspect: p.width && p.height ? `${p.width}/${p.height}` : aspect,
})),
...subjectAssets.map((p) => ({
id: `subject:${p.frameIdx}:${p.assetId}`,
kind: "subject" as const,
@@ -1281,6 +1269,18 @@ export function VisualLabNode({ data, selected }: any) {
borderClass: "border-violet-300/65",
aspect: p.width && p.height ? `${p.width}/${p.height}` : "1/1",
})),
...sceneAssets.map((p) => ({
id: `scene:${p.frameIdx}:${p.assetId}`,
kind: "scene" as const,
group: "场景图",
frameIdx: p.frameIdx,
assetId: p.assetId,
src: p.src,
label: p.label,
caption: `${p.width}×${p.height}`,
borderClass: p.risk === "bad" ? "border-rose-300/70" : p.risk === "warn" ? "border-amber-300/70" : "border-emerald-300/60",
aspect: p.width && p.height ? `${p.width}/${p.height}` : aspect,
})),
...videos.map((v, i) => {
const videoSrc = apiAssetUrl(v.url)
const posterSrc = apiAssetUrl(v.poster_url)
@@ -1556,19 +1556,6 @@ export function VisualLabNode({ data, selected }: any) {
</div>
<div></div>
</button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); openFirstFrame() }}
disabled={!job || frames.length === 0}
className="min-h-14 rounded-md border border-white/10 px-2 py-2 text-left transition hover:border-emerald-300/50 hover:bg-emerald-400/10 disabled:opacity-35"
title="生成 / 审核每张关键帧的场景图"
>
<div className="mb-1 flex items-center gap-1 text-[var(--text-strong)] text-[12px] font-semibold">
<Sparkles className="h-3 w-3 text-emerald-300" />
{sceneAssetCount}/{targetFrameCount}
</div>
<div></div>
</button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); openFirstFrame() }}
@@ -1582,6 +1569,19 @@ export function VisualLabNode({ data, selected }: any) {
</div>
<div></div>
</button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); openFirstFrame() }}
disabled={!job || frames.length === 0}
className="min-h-14 rounded-md border border-white/10 px-2 py-2 text-left transition hover:border-emerald-300/50 hover:bg-emerald-400/10 disabled:opacity-35"
title="基于主体资产生成去主体 / 相似 / 换风格场景图"
>
<div className="mb-1 flex items-center gap-1 text-[var(--text-strong)] text-[12px] font-semibold">
<Sparkles className="h-3 w-3 text-emerald-300" />
{sceneAssetCount}/{targetFrameCount}
</div>
<div></div>
</button>
<button
type="button"
onClick={(e) => { e.stopPropagation(); d.onOpenWorkbench?.(frames.find((f) => d.selectedFrames.has(f.index))?.index ?? frames[0]?.index) }}
@@ -1599,10 +1599,10 @@ 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} · {targetFrameCount} · {completedVideos.length}
{cleanedCount} · {subjectAssetCount} · {sceneAssetCount} · {targetFrameCount} · {completedVideos.length}
</>
) : (
"解析后这里变成素材准备看板:先审关键帧,再生成场景图和主体资产包。"
"解析后这里变成素材准备看板:先审关键帧,再生成主体资产包和去主体场景图。"
)}
</div>
</NodeShell>
@@ -1771,7 +1771,7 @@ export function KeyframeNode({ data, selected }: any) {
<span className={elementsCount > 0 ? "text-violet-300/90 font-medium" : ""}>{elementsCount} </span>
<br />
<span className="text-[10.5px] text-[var(--text-faint)]">
/ SKG
/ SKG
</span>
</div>
)

View File

@@ -139,6 +139,8 @@ export type AssetBackground = "white" | "black"
export type AssetSize = "source" | "1024" | "1536" | "2048"
export type SubjectKind = "object" | "living"
export type SubjectView = string
export type SceneMode = "remove_subject" | "similar" | "style"
export type SceneStyle = "source" | "premium_product" | "clean_studio" | "warm_lifestyle" | "cinematic"
export interface QualityReport {
width: number
@@ -157,6 +159,8 @@ export interface SceneAsset {
height: number
quality: "hd"
size: AssetSize
scene_mode?: SceneMode
scene_style?: SceneStyle
quality_report?: QualityReport | null
created_at: number
}
@@ -653,12 +657,17 @@ export async function cutoutElement(jobId: string, frameIdx: number, elementId:
export async function generateSceneAsset(
jobId: string,
frameIdx: number,
body: { size?: AssetSize } = {},
body: { size?: AssetSize; scene_mode?: SceneMode; scene_style?: SceneStyle } = {},
): Promise<Job> {
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/scene-asset`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ quality: "hd", size: body.size ?? "source" }),
body: JSON.stringify({
quality: "hd",
size: body.size ?? "source",
scene_mode: body.scene_mode ?? "remove_subject",
scene_style: body.scene_style ?? "source",
}),
})
if (!res.ok) {
const txt = await res.text().catch(() => "")