diff --git a/RULES.md b/RULES.md
index 0312a57..4ddba89 100644
--- a/RULES.md
+++ b/RULES.md
@@ -11,11 +11,11 @@
- 详见 `CLAUDE.md` 立项决策段 + `.memory/plan.md` 七步管线拆解
- 风格:`04-Dark-Gallery-Ambient`(路径:`~/Projects/research/20260305-网页风格库/04-Dark-Gallery-Ambient.md`)
- 第一冲刺:步骤 1-4(下载 / 拆轨 / 关键帧 / ASR+翻译)
-- 当前产品方向(2026-05-19 再确认):信息流广告快速复刻默认进入“三字段候选生成”工作流。主界面为“左侧素材输入列 + 右侧信息流复刻工作表”。用户粘贴 TK 链接或上传视频后点击“开始分析”,系统自动下载源视频;下载完成后并行启动两条路:音频文案路提取原音频文案/字幕,并分析讲话人、语速节奏、背景音乐/环境声/音效;视频视觉路自动抽取参考帧。源视频工作区右侧主体链路是“参考帧池 → 转换层 → 主体元素”:参考帧池竖向排列;转换层只保留真人重构、卡通重构、元素重构、自主描述四个入口,每个入口最多拖入 3 张参考帧,拖入只加入参考队列,不自动生成;用户放好参考和文字后点击生成,右侧主体元素区按重构类型分组展示全新 6 视图主体。这四类都属于参考重构,不抠图、不复制原人、不复刻原画面。旧下方“相似主体 / 主体模板库”不再作为主路径。波形下方的画面胶片只是临时预览,点击只跳转原视频时间点,双击或拖进参考帧池才正式加入关键帧,已加入的胶片直接显示“已添加”。产品图上传后独立形成产品资产包,自动识别视角/结构/比例并补缺角度。分镜工作台按逐句时间轴默认只露“文案 / 场景一句话 / 人物+产品+动作”,产品素材池、批量控制、三字段、视频候选和高级区都必须可折叠;视频候选无内容时默认不占大面积,有候选时默认只显示迷你缩略条,展开后才显示 4-grid。单条默认生成 4 个视频候选,顶部支持整片批量生成候选;首尾帧、视觉规划、产品出现方式和旧 6 字段保留在“高级”抽屉与后端 quick-plan 自动展开中,不能再作为客户默认闸门。
+- 当前产品方向(2026-05-19 再确认):信息流广告快速复刻默认进入“三字段候选生成”工作流。主界面为“左侧素材输入列 + 右侧信息流复刻工作表”。用户粘贴 TK 链接或上传视频后点击“开始分析”,系统自动下载源视频;下载完成后并行启动两条路:音频文案路提取原音频文案/字幕,并分析讲话人、语速节奏、背景音乐/环境声/音效;视频视觉路自动抽取参考帧。源视频工作区右侧主体链路是“参考帧池 → 转换层 → 主体元素”:参考帧池竖向排列;转换层只保留真人重构、卡通重构、元素重构、自主描述四个入口,每个入口最多拖入 3 张参考帧,拖入只加入参考队列,不自动生成;用户放好参考和文字后点击生成,右侧主体元素区按每次生成的套图文件夹展示全新 6 视图主体,当前套图在最上层展开,其他套图顺位进入下方可滚动列表,同一重构方向允许保留多套。这四类都属于参考重构,不抠图、不复制原人、不复刻原画面。旧下方“相似主体 / 主体模板库”不再作为主路径。波形下方的画面胶片只是临时预览,点击只跳转原视频时间点,双击或拖进参考帧池才正式加入关键帧,已加入的胶片直接显示“已添加”。产品图上传后独立形成产品资产包,自动识别视角/结构/比例并补缺角度。分镜工作台按逐句时间轴默认只露“文案 / 场景一句话 / 人物+产品+动作”,产品素材池、批量控制、三字段、视频候选和高级区都必须可折叠;视频候选无内容时默认不占大面积,有候选时默认只显示迷你缩略条,展开后才显示 4-grid。单条默认生成 4 个视频候选,顶部支持整片批量生成候选;首尾帧、视觉规划、产品出现方式和旧 6 字段保留在“高级”抽屉与后端 quick-plan 自动展开中,不能再作为客户默认闸门。
## 部署事实
- 平台:VPS `76.13.31.179`(Ubuntu 24.04 / Docker Compose / Coolify Traefik)
-- 发布状态:已部署并验证(2026-05-19,转换层改为真人重构 / 卡通重构 / 元素重构 / 自主描述四个入口,每个入口最多 3 张参考帧;拖入只加入参考队列,点击生成后固定生成全新 6 视图,主体元素按重构类型分组展示;胶片双击/拖拽加入参考帧池 + 胶片缓存复用 + 音频解析失败可重试,右侧三栏主体管线:竖向参考帧池 + 转换层 + 主体元素,参考帧缩略图保持小尺寸 9:16 比例 + hover 左侧紧凑预览 + 转换层多参考滚动,旧主体模板区移出主路径 + 逐句时间轴移到原版视频下方并支持双行文案 + 波形同框时间对齐画面胶片 + 胶片密度按钮上移波形顶部 + 去分隔线 + 胶片上下错落 + body 顶层原位大放大 + 隐藏源视频工作区音频解析摘要卡 + 隐藏工作区顶部状态提示条 + 三字段候选生成工作流 + 折叠紧凑候选区);`https://marketing.skg.com` 已启用应用内登录页,未登录 API 返回 401,认证后首页 200;容器内 `/health` 返回 `ok:true`
+- 发布状态:已部署并验证(2026-05-19,主体元素改为按套图文件夹分组展示,主体生成接口提交后立即返回 queued 占位并后台逐视角生成、逐张回填;转换层为真人重构 / 卡通重构 / 元素重构 / 自主描述四个入口,每个入口最多 3 张参考帧;拖入只加入参考队列,点击生成后固定生成全新 6 视图;胶片双击/拖拽加入参考帧池 + 胶片缓存复用 + 音频解析失败可重试,右侧三栏主体管线:竖向参考帧池 + 转换层 + 主体元素,参考帧缩略图保持小尺寸 9:16 比例 + hover 左侧紧凑预览 + 转换层多参考滚动,旧主体模板区移出主路径 + 逐句时间轴移到原版视频下方并支持双行文案 + 波形同框时间对齐画面胶片 + 胶片密度按钮上移波形顶部 + 去分隔线 + 胶片上下错落 + body 顶层原位大放大 + 隐藏源视频工作区音频解析摘要卡 + 隐藏工作区顶部状态提示条 + 三字段候选生成工作流 + 折叠紧凑候选区);`https://marketing.skg.com` 已启用应用内登录页,未登录 API 返回 401,认证后首页 200;容器内 `/health` 返回 `ok:true`
- 主站 / 前端:`https://marketing.skg.com`
- API / 后端:`https://marketing.skg.com/api`
- 代码仓库 / Gitea:`https://git.kang-kang.com/kangwan/20260512-skg-tk`
diff --git a/api/main.py b/api/main.py
index f503338..dd73860 100644
--- a/api/main.py
+++ b/api/main.py
@@ -289,6 +289,7 @@ AssetSize = Literal["source", "1024", "1536", "2048"]
AssetQuality = Literal["hd"]
SubjectKind = Literal["object", "living"]
SubjectView = str
+SubjectAssetStatus = Literal["queued", "in_progress", "completed", "failed"]
SceneMode = Literal["remove_subject", "similar", "style"]
SceneStyle = Literal["source", "premium_product", "clean_studio", "warm_lifestyle", "cinematic"]
SceneAssetRole = Literal["scene", "first_frame", "last_frame"]
@@ -462,6 +463,13 @@ class SubjectAsset(BaseModel):
size: AssetSize = "source"
source_frame_indices: list[int] = Field(default_factory=list)
ai_completed: bool = True
+ status: SubjectAssetStatus = "completed"
+ progress: int = 100
+ error: str = ""
+ pack_id: str = ""
+ pack_label: str = ""
+ pack_mode: str = ""
+ pack_created_at: float = 0.0
created_at: float = 0.0
@@ -1371,6 +1379,26 @@ async def lifespan(_: FastAPI):
audio_script=audio_script,
message="服务重启 · 上次音频处理已中断,可重新处理",
)
+ subject_generation_interrupted = False
+ recovered_frames = []
+ for f in job.frames:
+ for e in f.elements or []:
+ recovered_assets = []
+ for asset in e.subject_assets or []:
+ if asset.status in {"queued", "in_progress"}:
+ recovered_assets.append(asset.model_copy(update={
+ "status": "failed",
+ "progress": 100,
+ "error": "服务重启 · 上次主体生成已中断,可重新生成",
+ "ai_completed": False,
+ }))
+ subject_generation_interrupted = True
+ else:
+ recovered_assets.append(asset)
+ e.subject_assets = recovered_assets
+ recovered_frames.append(f)
+ if subject_generation_interrupted:
+ update(job, frames=recovered_frames, message="服务重启 · 上次主体生成已中断,可重新生成")
JOBS[p.name] = job
except Exception:
pass
@@ -4793,6 +4821,11 @@ class GenerateSubjectAssetsReq(BaseModel):
subject_profile: SubjectProfilePreference | None = None
prompt: str = ""
replace_views: bool = False
+ source_subject_brief: str = ""
+ pack_id: str = ""
+ pack_label: str = ""
+ pack_mode: str = ""
+ pack_created_at: float = 0.0
def _subject_profile_prompt_clause(profile: SubjectProfilePreference | None) -> str:
@@ -5252,8 +5285,195 @@ def cutout_element(job_id: str, idx: int, element_id: str) -> Job:
return job
+def _subject_source_indices(req: GenerateSubjectAssetsReq, idx: int) -> list[int]:
+ source_indices = [int(x) for x in (req.source_frame_indices or [idx]) if isinstance(x, int) or str(x).isdigit()]
+ if idx not in source_indices:
+ source_indices = [idx] + source_indices
+ return list(dict.fromkeys(source_indices))[:12]
+
+
+def _normalize_subject_pack_id(value: str, idx: int, element_id: str) -> str:
+ cleaned = "".join(ch for ch in (value or "").strip() if ch.isalnum() or ch in {"_", "-"})
+ return cleaned[:96] or f"subject_pack_{idx:03d}_{element_id}_{uuid.uuid4().hex[:8]}"
+
+
+def _update_subject_asset_status(
+ job_id: str,
+ idx: int,
+ element_id: str,
+ asset_id: str,
+ *,
+ status: SubjectAssetStatus,
+ progress: int,
+ error: str = "",
+ message: str = "",
+) -> None:
+ job = JOBS.get(job_id)
+ if not job:
+ return
+ new_frames = []
+ for f in job.frames:
+ if f.index == idx:
+ for e in f.elements:
+ if e.id == element_id:
+ updated_assets = []
+ for asset in e.subject_assets or []:
+ if asset.id == asset_id:
+ updated_assets.append(asset.model_copy(update={
+ "status": status,
+ "progress": max(0, min(100, int(progress))),
+ "error": error,
+ "ai_completed": status == "completed",
+ }))
+ else:
+ updated_assets.append(asset)
+ e.subject_assets = updated_assets
+ new_frames.append(f)
+ update(job, frames=new_frames, message=message or job.message, error=error if status == "failed" else job.error)
+
+
+def _subject_assets_background_worker(
+ job_id: str,
+ idx: int,
+ element_id: str,
+ req: GenerateSubjectAssetsReq,
+ queued: list[tuple[SubjectView, str, str]],
+) -> None:
+ if req.reconstruction_mode == "similar" and not req.source_subject_brief.strip():
+ try:
+ req.source_subject_brief = _describe_source_subject(job_id, _subject_source_indices(req, idx))
+ except Exception as e:
+ print(f"[subject assets] source brief failed job={job_id} error={e}", flush=True)
+ for position, (view, view_label, placeholder_id) in enumerate(queued, start=1):
+ _update_subject_asset_status(
+ job_id,
+ idx,
+ element_id,
+ placeholder_id,
+ status="in_progress",
+ progress=10,
+ message=f"主体资产生成中 · {view_label} · {position}/{len(queued)}",
+ )
+ one_req = req.model_copy(deep=True)
+ one_req.views = [view]
+ one_req.replace_views = True
+ try:
+ _generate_subject_assets_sync(job_id, idx, element_id, one_req)
+ except HTTPException as e:
+ detail = str(e.detail)
+ _update_subject_asset_status(
+ job_id,
+ idx,
+ element_id,
+ placeholder_id,
+ status="failed",
+ progress=100,
+ error=detail,
+ message=f"主体资产生成失败 · {view_label}",
+ )
+ except Exception as e:
+ detail = str(e)
+ _update_subject_asset_status(
+ job_id,
+ idx,
+ element_id,
+ placeholder_id,
+ status="failed",
+ progress=100,
+ error=detail,
+ message=f"主体资产生成失败 · {view_label}",
+ )
+
+
@app.post("/jobs/{job_id}/frames/{idx}/elements/{element_id}/subject-assets", response_model=Job)
def generate_subject_assets(job_id: str, idx: int, element_id: str, req: GenerateSubjectAssetsReq) -> Job:
+ """提交主体多视角生成任务,立即返回占位卡;后台逐张生成并逐张写回。"""
+ job = JOBS.get(job_id)
+ if not job:
+ raise HTTPException(404, "job not found")
+ frame = _find_frame(job, idx)
+ el = next((e for e in frame.elements if e.id == element_id), None)
+ if not el:
+ raise HTTPException(404, "element not found")
+
+ views = _subject_view_labels(req.subject_kind, req.views)
+ source_indices = _subject_source_indices(req, idx)
+ target_views = {view for view, _label in views}
+ now = time.time()
+ explicit_pack_id = bool((req.pack_id or "").strip())
+ pack_id = _normalize_subject_pack_id(req.pack_id, idx, element_id)
+ pack_label = (req.pack_label or "").strip()[:120] or f"{el.name_zh} · 主体套图"
+ pack_mode = (req.pack_mode or "").strip()[:40] or req.subject_style
+ pack_created_at = req.pack_created_at or now
+ placeholders: list[SubjectAsset] = []
+ queued: list[tuple[SubjectView, str, str]] = []
+ for view, view_label in views:
+ asset_id = f"subject_{idx:03d}_{element_id}_{view}_{uuid.uuid4().hex[:8]}"
+ placeholders.append(SubjectAsset(
+ id=asset_id,
+ view=view,
+ label=f"{el.name_zh} · {view_label}",
+ url="",
+ width=0,
+ height=0,
+ background=req.background,
+ quality=req.quality,
+ size=req.size,
+ source_frame_indices=source_indices,
+ ai_completed=False,
+ status="queued",
+ progress=0,
+ error="",
+ pack_id=pack_id,
+ pack_label=pack_label,
+ pack_mode=pack_mode,
+ pack_created_at=pack_created_at,
+ created_at=now,
+ ))
+ queued.append((view, view_label, asset_id))
+
+ new_frames = []
+ for f in job.frames:
+ if f.index == idx:
+ for e in f.elements:
+ if e.id == element_id:
+ e.subject_kind = req.subject_kind
+ e.cutout_background = req.background
+ current_assets = e.subject_assets or []
+ if req.replace_views:
+ for old_asset in current_assets:
+ should_replace = old_asset.view in target_views and (
+ old_asset.pack_id == pack_id if explicit_pack_id else True
+ )
+ if should_replace and old_asset.url:
+ _delete_subject_asset_file(job_id, old_asset.id)
+ current_assets = [
+ asset for asset in current_assets
+ if not (
+ asset.view in target_views and (
+ asset.pack_id == pack_id if explicit_pack_id else True
+ )
+ )
+ ]
+ e.subject_assets = current_assets + placeholders
+ new_frames.append(f)
+ update(job, frames=new_frames, message=f"主体资产已提交 · {el.name_zh} · {len(placeholders)} 张逐张生成中", error="")
+
+ worker_req = req.model_copy(deep=True)
+ worker_req.views = [view for view, _label in views]
+ worker_req.pack_id = pack_id
+ worker_req.pack_label = pack_label
+ worker_req.pack_mode = pack_mode
+ worker_req.pack_created_at = pack_created_at
+ threading.Thread(
+ target=_subject_assets_background_worker,
+ args=(job_id, idx, element_id, worker_req, queued),
+ daemon=True,
+ ).start()
+ return job
+
+
+def _generate_subject_assets_sync(job_id: str, idx: int, element_id: str, req: GenerateSubjectAssetsReq) -> Job:
"""为一个主体生成多视角资产包。
如果传入 source_frame_indices 或内置 character_id,则把多张参考图作为独立 image[] 证据提交。"""
import time as _time
@@ -5265,10 +5485,7 @@ def generate_subject_assets(job_id: str, idx: int, element_id: str, req: Generat
if not el:
raise HTTPException(404, "element not found")
- source_indices = [int(x) for x in (req.source_frame_indices or [idx]) if isinstance(x, int) or str(x).isdigit()]
- if idx not in source_indices:
- source_indices = [idx] + source_indices
- source_indices = list(dict.fromkeys(source_indices))[:12]
+ source_indices = _subject_source_indices(req, idx)
similar_mode = req.reconstruction_mode == "similar"
character_reference_paths: list[Path] = []
@@ -5311,7 +5528,11 @@ def generate_subject_assets(job_id: str, idx: int, element_id: str, req: Generat
tmp_focus: Path | None = None
model_src: Path | list[Path] | None = None
frame_reference_paths = [p for p in (_source_frame_path(job_id, i) for i in source_indices) if p.exists()]
- source_subject_brief = _describe_source_subject(job_id, source_indices) if similar_mode else ""
+ source_subject_brief = (
+ _ensure_english(req.source_subject_brief.strip())
+ if similar_mode and req.source_subject_brief.strip()
+ else (_describe_source_subject(job_id, source_indices) if similar_mode else "")
+ )
source_subject_clause = (
f"Source video role brief from selected keyframes: {source_subject_brief}. "
"Use this brief to preserve role category, creator-ad energy, camera readability, and broad styling, while creating a new non-identical subject. "
@@ -5484,6 +5705,13 @@ def generate_subject_assets(job_id: str, idx: int, element_id: str, req: Generat
quality=req.quality,
size=req.size,
source_frame_indices=source_indices,
+ status="completed",
+ progress=100,
+ error="",
+ pack_id=req.pack_id,
+ pack_label=req.pack_label,
+ pack_mode=req.pack_mode,
+ pack_created_at=req.pack_created_at or _time.time(),
created_at=_time.time(),
))
finally:
@@ -5509,10 +5737,21 @@ def generate_subject_assets(job_id: str, idx: int, element_id: str, req: Generat
current_assets = e.subject_assets or []
if req.replace_views:
replaced_views = {asset.view for asset in generated}
+ replace_pack_id = (req.pack_id or "").strip()
for old_asset in current_assets:
- if old_asset.view in replaced_views:
+ should_replace = old_asset.view in replaced_views and (
+ old_asset.pack_id == replace_pack_id if replace_pack_id else True
+ )
+ if should_replace:
_delete_subject_asset_file(job_id, old_asset.id)
- current_assets = [asset for asset in current_assets if asset.view not in replaced_views]
+ current_assets = [
+ asset for asset in current_assets
+ if not (
+ asset.view in replaced_views and (
+ asset.pack_id == replace_pack_id if replace_pack_id else True
+ )
+ )
+ ]
final_assets = current_assets + generated
e.subject_assets = final_assets
if req.subject_kind == "living":
diff --git a/docs/source-analysis.html b/docs/source-analysis.html
index 50a8d85..621e27d 100644
--- a/docs/source-analysis.html
+++ b/docs/source-analysis.html
@@ -594,7 +594,7 @@
web/app/globals.css | 全局主题变量、登录页视觉样式、信息流工作台同源品牌 token、ReactFlow 样式引用,以及本地开发态 nextjs-portal 遮挡隐藏规则。工作台在 skg-board-theme 内定义 --skg-gold-1、--skg-gold-2、--skg-cream、--skg-bg-*、--skg-text-*、--skg-radius-* 和按钮阴影等变量,并新增 skg-board-brand、skg-stat-card、skg-primary-action、skg-secondary-action、skg-empty-state 等样式。暗色工作台复用登录页金色聚焦、米白主按钮和弱暖光氛围;明亮模式通过 skg-board-theme--light 复用同一套结构,改成暖白底、白色 panel、黑底主 CTA 和深色文本,不另起一套界面。 |
web/app/page.tsx | 产品工作台主状态:jobs、activeJobId、生成任务状态;主渲染为全屏素材输入列 + 信息流广告复刻工作表;“开始分析”会把 job 放入并行素材分析队列,下载完成后触发 triggerTranscribe 解析音频,并触发 analyzeJob 自动抽 12 张参考帧,形成“音频文案路 + 视频视觉路”同步推进;音频失败时会忽略失败状态下残留的半成品 transcript,允许再次触发音频解析;底部吸附音频条和旧全局浮动主题按钮不再从主界面渲染,避免和工作台内的明暗模式切换重复。 |
web/components/ad-recreation-board.tsx | 信息流广告复刻工作表:顶部先展示与登录页连续的 SKG brand strip,包含 SKG 字标、“未来健康 · 营销内容工作台”和“营销内容工作台 · TK 二创”;右侧素材/任务/视频/文案统计改为米白 stat 卡片,主动作按钮统一走 skg-primary-action,次动作走 skg-secondary-action,空状态复用 AnimatedLoginCharacters。buildWorkflowSteps 仍统一生成 01-09 流程顺序、状态和判定依据,WorkflowStepBadge / PipelineLane / 分镜列标题也继续共用同一套编号;但完整 WorkflowOrderBar、右侧素材/视频/音频/文案/参考帧需求 chips、文案依据下拉和“音频文案、抽帧参考、主体重构、产品素材池”四个状态条不再默认渲染在工作区顶部。左侧素材输入只负责链接/上传和任务切换,不再重复放横版原视频预览;右侧源视频工作区直接进入核心操作。讲话人、节奏和背景音分析仍写入 AudioScript,但不再作为“音频解析结果”卡片默认渲染;主工作区左侧宽度调整为 430-460px,上方是按 9:16 显示的竖版原视频播放器,播放器内覆盖“当前点抽帧”,按当前播放秒数手动补参考帧,播放器下方是逐句时间轴,英文和中文都最多显示两行;右侧上方是无标题的波形与切点参考框,下方是三栏主体管线。音频波形用参考图式的连续灰色包络显示响度、停顿和密集爆点,顶部把低/中/高密度按钮和当前播放秒数、总时长、鼠标指针停点秒数直接放在波形上方。视频播放时通过 requestAnimationFrame 平滑驱动波形播放线,同时同步高亮并滚动当前句;点击音频波形或字幕行会跳转原视频时间。音频波形下方同框渲染无标题的 TimelineFilmstrip 临时画面胶片,前端按低/中/高密度从源视频 canvas 截取预览缩略图,并按 frame.time / duration 的百分比定位到和波形同一条时间轴上;波形与胶片之间不显示分隔横线,胶片轨道贴近波形,缩略图轻微上下错落并倾斜重叠排列,hover 时用同一张胶片卡在原位置生成固定顶层克隆,约 4.8 倍放大并自动限制在视口内,避免被工作区、滚动容器或相邻面板遮挡;单击胶片只跳转原视频时间,不写入任务数据,双击胶片或拖进参考帧池时才调用手动抽帧并正式加入 job.frames,已加入的胶片显示“已添加”;胶片预览按 job、视频、密度和时长缓存,未切换低/中/高时返回页面不重新扫视频。右侧参考帧池的主入口是“自动抽帧 12 张”,一键按动作峰值目标重新抽取 12 张源视频参考帧,优先抓手势、表情变化、节奏点和镜头变化;缩略图按竖版完整比例显示不裁切,点选状态直接叠在参考帧池缩略图上,鼠标停留会通过固定浮层放大展示完整帧。转换层不再暴露“生成 10 张高清图”、透明骨架/真人或完整/常用视图开关,改成真人重构、卡通重构、元素重构、自主描述四个投放区;每区最多 3 张参考帧,拖入只加入该区参考队列,用户放好参考和文字后点击按钮才调用 generateSubjectAssets 固定生成 6 视图,卡通重构可选择具体卡通风格,文字方向会进入 prompt。主体元素区按重构类型分组显示结果;只展示每个 view 的最新一张,缩略图上提供“重新生成这一张”和“删除这一张”,单张重生会用 replace_views=true 替换同一视角。前端当前对真人/元素/自主描述传 subject_style=source_actor,对卡通重构传 subject_style=cartoon_subject,并使用 reconstruction_mode=similar;后端会把关键帧反推成非身份化文字 brief,再走 gpt-image-2 文字生图,避免复制原人、原脸和原画面。主体生成完成后会形成 subject_consensus_brief。音频结果下方是信息流复刻分镜工作台:顶部产品参考区是“同一产品素材池”,不限量上传产品图,不做不同产品身份判断;上传原图推荐长边 1200-2000px、短边至少 600px,但后端会统一生成最长边 1600px、JPEG 92 的 AI 工作副本,并回显尺寸、自动转换和风险标注;上传后按“套在脖子上的 U 形肩颈按摩仪”进行同一产品批量识别,左/右按佩戴者身体左右、上/下按佩戴方向,额外标注内外侧、开口方向、局部结构点、背景类型、用途标签、生成风险和备注,用户只检查备注,鼠标悬停通过固定浮层显示大图预览,能盖过滚动容器和分镜框架;缺视角补图失败时保留重试入口。脚本区在分镜行上方提供“作者想法”和“整片改写”,每行新口播文案可直接编辑并可单段 AI 改写。每条音频分镜默认是左侧三字段、右侧横向视频候选轨;高级区仍保留首尾帧 prompt、产品出现方式和旧 6 字段。ModelTrace 会在音频解析、产品识别/补图、主体重构视图包、脚本改写等入口旁直接展示模型名;所有生图入口都显示并使用 gpt-image-2,没有其他图片模型 fallback;点击后用固定浮层展示模型链路、输入输出和回退逻辑。旧分镜卡、抽帧控制和视频生成组件仍保留在文件里,但当前主路径不渲染。 |
- SourceSubjectPipeline | 源视频工作区右侧主体管线主路径:三栏分别是竖向 参考帧池、转换层 和 主体元素。参考帧池保留自动 12 张、胶片拖入正式成帧、点击勾选和删除;参考帧缩略图保持小尺寸固定宽度、aspect-[9/16] 和 object-contain 显示,hover 预览通过 MediaAssetTile 的左侧紧凑浮层显示。转换层取消旧的“透明骨架 / 真人”和“完整 10 / 常用 4”开关,改成真人重构、卡通重构、元素重构、自主描述四个投放区;每个区最多保留 3 张参考帧,拖入只加入参考队列,不自动调用生成;用户放好参考和文字后点击按钮才调用 generateSubjectAssets 生成固定 6 视图。文字输入会参与 prompt,卡通重构额外提供 3D 动画、潮玩公仔、日系清爽、美式插画、黏土玩具、极简扁平等风格。四种模式都强调参考重构:不抠图区、不复制原人原脸、不复刻原画面。主体元素区按重构类型分组显示生成套图,缩略图复用 MediaAssetTile,支持 hover 放大、单张重生和删除。旧下方 SourceReferenceBuildPanel 不再主路径渲染。 |
+ SourceSubjectPipeline | 源视频工作区右侧主体管线主路径:三栏分别是竖向 参考帧池、转换层 和 主体元素。参考帧池保留自动 12 张、胶片拖入正式成帧、点击勾选和删除;参考帧缩略图保持小尺寸固定宽度、aspect-[9/16] 和 object-contain 显示,hover 预览通过 MediaAssetTile 的左侧紧凑浮层显示。转换层取消旧的“透明骨架 / 真人”和“完整 10 / 常用 4”开关,改成真人重构、卡通重构、元素重构、自主描述四个投放区;每个区最多保留 3 张参考帧,拖入只加入参考队列,不自动调用生成;用户放好参考和文字后点击按钮才调用 generateSubjectAssets 生成固定 6 视图。文字输入会参与 prompt,卡通重构额外提供 3D 动画、潮玩公仔、日系清爽、美式插画、黏土玩具、极简扁平等风格。四种模式都强调参考重构:不抠图区、不复制原人原脸、不复刻原画面。主体元素区按每次生成的 pack_id 组织成“套图文件夹”:顶部展开当前选中套图,下面是可滚动的套图包列表;同一方向可保留多套,生成中按 pack 显示 2/6 这类进度,单张完成就替换对应占位卡。缩略图复用 MediaAssetTile,支持 hover 放大、单张重生和删除。旧下方 SourceReferenceBuildPanel 不再主路径渲染。 |
AudioStoryboardPlanPanel 三字段候选生成 | 当前分镜主路径:每行是左右双栏,左侧默认显示 skg_copy_*、scene_one_line_*、action_one_line_* 三组中英字段,右侧直接显示视频候选横向轨。用户改中文镜像后,字段失焦会通过 refineStoryboard 优化对应英文主值,失败时退回 translateText;英文仍是后续 prompt 主值。quickPlanStoryboard 把三字段和主体 brief 展开为完整 StoryboardScene,generateStoryboardVideo 的 count 可由单行数字控件选择,候选新生成后持续向右追加,不再用 4-grid 撑高每行。整片生成同样可选择每行数量,并以 concurrency=1 按行排队提交。产品素材池、批量控制、每行主体区和高级区都可折叠,高级抽屉仍展示旧 6 字段、首尾帧 prompt 和首尾帧资产槽,但客户默认不用先处理首尾帧。 |
web/components/resource-library/library-drawer.tsx | 全局资源中心浮窗:由工作台顶部“资源库”按钮打开,叠加在工作台上方但不阻塞主界面;尺寸、位置和当前 Tab 写入 localStorage["skg-resource-library-drawer"]。提示词 Tab 固定 5 列(场景描述、视频描述、主体描述、SKG 文案、产品角度),每列先显示 use_count 排名前 5 的“常用”,再按月份倒序分组;提示词节点常驻复制按钮,hover 可选英文/中文/双语复制,并调用 use 接口。素材 Tab 固定 4 列(主体、产品、场景、视频),节点不可拖动,按月份倒序硬编码排列;“应用到当前 job”只调用后端复制接口,得到普通 ImageRef(kind="asset") 后再写入产品素材池或复制 ID。浮窗顶部最近 24 小时横条混合显示提示词和素材;新建提示词、上传素材、删除前查引用、详情侧栏都在该组件内完成。 |
AdRecreationBoard 主题切换 | 顶部指标区左侧有“明亮/暗色”按钮,使用 Sun / Moon 图标切换 skg-board-theme--light 类名,并把选择写入 localStorage["skg-board-theme"]。暗色仍是默认模式;明亮模式只改变工作台外观,不改变任务、素材、分镜、模型调用或接口数据。 |
@@ -792,7 +792,10 @@ SubjectAsset {
id, view, label, url,
background: white | black,
width, height, size,
- source_frame_indices[]
+ source_frame_indices[],
+ status: queued | in_progress | completed | failed,
+ progress, error,
+ pack_id, pack_label, pack_mode, pack_created_at
}
SubjectTemplateItem 保存用户确认过的主体视图包。prompt_brief 是后端从模板图反推的英文文字特征,后续相似生成优先读取它,而不是再次把模板图作为强参考图传给 image-edit;prompt_brief_zh 仅用于模板库卡片和团队阅读。
SubjectTemplateItem {
@@ -984,6 +987,7 @@ ProductRefStateItem {
| 元素增改删 | POST/PATCH/DELETE /elements | addElement/updateElement/deleteElement | 让用户修正 Vision 错误,避免候选结果锁死。 |
| 元素提取 | POST /elements/{element_id}/cutout | cutoutElement | 调用图像模型生成独立白底素材图,每次累积一张 cutout。 |
| 主体资产包 | POST /elements/{element_id}/subject-assets
DELETE /elements/{element_id}/subject-assets/{asset_id} | generateSubjectAssets
deleteSubjectAsset | 根据转换层里的参考帧重新绘制一个统一主体资产包;前端按真人重构、卡通重构、元素重构、自主描述四个方向分别管理 source_frame_indices,每个方向最多 3 张参考帧,固定请求 front、three_quarter_left、left、back、right、three_quarter_right 六个视图,不再暴露完整 10 / 常用 4 选择。当前源视频工作区使用 subject_style=source_actor 承接真人、元素和自主描述,使用 subject_style=cartoon_subject 承接卡通重构;旧 transparent_human 仍为兼容类型但不是当前转换层默认入口。reconstruction_mode=similar 是创新路径:后端先用 VISION_MODEL 把关键帧反推成非身份化文字 brief,再调用 gpt-image-2 的 /images/generations 文字生图,日志会显示 image_refs=0;这里是参考重构生成套图,不是抠图、复制或 image-edit 复刻。卡通重构在后端额外加入原创卡通/插画主体约束,明确不输出真实人物复制 likeness。生成完成后,后端会把生成视图反推/写入 KeyElement.subject_consensus_brief,作为后续首尾帧的唯一主体身份文字依据。reconstruction_mode=same 仍保留旧 image-edit 路径,用于确实需要精确复刻且有授权的场景。每个 view 单独调用一次生图,明确禁止多视图拼图、contact sheet、多主体、多面板、标签或对比排版。后端不再要求整包全成功才写入:单个视图失败时会保留已成功生成的主体图,返回“部分生成完成”,只有一张都没生成出来才返回错误。replace_views=true 时会替换同一视角旧图;删除接口会移除对应 subject asset 记录并删除本地 jpg 文件。 |
+ | 主体套图状态 | SubjectAsset.status
pack_id | web/app/page.tsx
SourceSubjectPipeline | generateSubjectAssets 现在先写入同一个 pack_id 下的 queued 占位卡并立即返回,后台按视角逐张生成,单张完成就把该占位替换成 completed 图片。前端轮询会把 queued / in_progress 主体资产纳入运行状态;主体元素区按 pack 显示套图文件夹,点击某个文件夹后展开该套图,其他套图顺位进入下方可滚动列表。 |
| 首尾帧资产 | POST /frames/{idx}/scene-asset | generateSceneAsset | 同一接口兼容旧场景图和新首尾帧;当前信息流复刻流程传 asset_role=first_frame/last_frame、subject_brief 和最多 1-2 张 product_images。首尾帧不再传主体图、不再把主体图和产品图拼成 contact sheet;主体只走文字 brief,允许新动作、新景别、新表情和新环境。若本条需要产品,后端只把产品参考图作为 gpt-image-2 image-edit 的硬视觉真源;若不需要产品,则走纯文字生图。关键帧只作为行数据承载位置。生成结果保存在 scene_assets,前端再写入 StoryboardScene.first_image/last_image。 |
| 产品图库 | GET /product-library/skg | listProductLibrary | 读取内置 SKG 白底图库 manifest,返回产品标题、品类、尺寸、白底评分和预览图 URL。 |
| 产品图入库到 job | POST /jobs/{id}/assets、POST /jobs/{id}/assets/product-library | uploadStoryboardAsset、copyProductLibraryAsset | 上传产品图或把内置产品图库条目复制为当前 job 的普通 asset。后端统一生成最长边 1600px、JPEG 92 的 AI 工作副本,透明底铺白,过大/过小图片会在 ImageRef.asset_meta 里返回转换动作和风险;黑底/白底背景本身不强行转换。注意该接口只写图片文件,产品素材池列表另由 PUT /jobs/{id}/product-refs 持久化。 |
@@ -1109,6 +1113,19 @@ ProductRefStateItem {
变更记录
这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。
+
+
+ 2026-05-19 · 主体元素改为套图文件夹并逐张回填
+ UI
+ Workflow
+ API
+
+
+
问题:主体 6 视图生成一次性等待太久,且多次生成后所有图片平铺会迅速挤满主体元素区。
+
改动:SubjectAsset 新增 status/progress/error 和 pack_id/pack_label/pack_mode/pack_created_at;generateSubjectAssets 先写 queued 占位卡并后台按视角逐张生成。web/app/page.tsx 轮询主体资产运行态,SourceSubjectPipeline 按 pack 显示套图文件夹,点击文件夹在最上层展开该套,其他套图进入下方可滚动列表。
+
影响:用户可以连续生成多套真人/卡通/元素/自主描述主体图,不会被平铺图片淹没;生成过程会逐张出现,单张失败不阻塞其他视角。
+
+
2026-05-19 · 转换层拖入参考不再自动生成
diff --git a/web/app/page.tsx b/web/app/page.tsx
index 05edae6..97ad983 100644
--- a/web/app/page.tsx
+++ b/web/app/page.tsx
@@ -892,7 +892,12 @@ export default function Home() {
.filter((item) => {
const runningVideo = !!item.generated_videos?.some((v) => v.status === "queued" || v.status === "in_progress")
const runningAudio = item.audio_script?.status === "rewriting"
- return runningVideo || runningAudio || !TERMINAL.includes(item.status)
+ const runningSubject = item.frames.some((frame) =>
+ frame.elements?.some((element) =>
+ element.subject_assets?.some((asset) => asset.status === "queued" || asset.status === "in_progress"),
+ ),
+ )
+ return runningVideo || runningAudio || runningSubject || !TERMINAL.includes(item.status)
})
.map((item) => item.id)
@@ -913,7 +918,14 @@ export default function Home() {
}, [
job?.id,
job?.status,
- jobs.map((item) => `${item.id}:${item.status}:${item.progress}:${item.audio_script?.status ?? ""}:${item.audio_script?.voice_url ?? ""}:${item.generated_videos?.map((v) => `${v.id}:${v.status}:${v.progress}`).join(",")}`).join("|"),
+ jobs.map((item) => {
+ const subjectState = item.frames.flatMap((frame) =>
+ frame.elements?.flatMap((element) =>
+ element.subject_assets?.map((asset) => `${asset.id}:${asset.status ?? "completed"}:${asset.progress ?? 100}:${asset.url ?? ""}`) ?? [],
+ ) ?? [],
+ ).join(",")
+ return `${item.id}:${item.status}:${item.progress}:${item.audio_script?.status ?? ""}:${item.audio_script?.voice_url ?? ""}:${item.generated_videos?.map((v) => `${v.id}:${v.status}:${v.progress}`).join(",")}:${subjectState}`
+ }).join("|"),
])
const [pinnedNodes, setPinnedNodes] = useState>(() => new Set(loadNodePins()))
diff --git a/web/components/ad-recreation-board.tsx b/web/components/ad-recreation-board.tsx
index 0e6a703..f6facbf 100644
--- a/web/components/ad-recreation-board.tsx
+++ b/web/components/ad-recreation-board.tsx
@@ -214,6 +214,20 @@ type ResolvedSubjectProfile = {
promptSummary: string
payload: SubjectProfilePreference
}
+type SubjectAssetPack = {
+ key: string
+ id: string
+ label: string
+ mode: SubjectReconstructionMode
+ frame: KeyFrame
+ element: KeyElement
+ createdAt: number
+ assets: SubjectAsset[]
+ total: number
+ completed: number
+ failed: number
+ running: boolean
+}
type StoryboardVisualMode = NonNullable
type RowPlanPatch = Partial>
type WorkflowStepId = "input" | "source" | "audio" | "visual" | "subject" | "product" | "script" | "scene" | "video"
@@ -1119,9 +1133,46 @@ function buildSimilarSubjectPrompt(
}
function subjectAssetUrl(job: Job, asset: SubjectAsset) {
+ if (!asset.url && asset.status && asset.status !== "completed") return ""
return apiAssetUrl(asset.url) || resolveImageRefUrl(job.id, { kind: "asset", frame_idx: 0, element_id: asset.id })
}
+function subjectAssetStatus(asset: SubjectAsset) {
+ return asset.status ?? (asset.url ? "completed" : "completed")
+}
+
+function subjectAssetIsRunning(asset: SubjectAsset) {
+ const status = subjectAssetStatus(asset)
+ return status === "queued" || status === "in_progress"
+}
+
+function subjectAssetStatusLabel(asset: SubjectAsset) {
+ const status = subjectAssetStatus(asset)
+ if (status === "queued") return "排队中"
+ if (status === "in_progress") return `生成中 ${asset.progress ?? 0}%`
+ if (status === "failed") return "失败"
+ return asset.width && asset.height ? `${asset.width}x${asset.height}` : undefined
+}
+
+function subjectAssetPackKey(frame: KeyFrame, element: KeyElement, asset: SubjectAsset) {
+ return `${frame.index}:${element.id}:${asset.pack_id || `legacy-${element.id}`}`
+}
+
+function subjectAssetPackSortAssets(assets: SubjectAsset[]) {
+ return [...assets].sort((a, b) => {
+ const ai = SUBJECT_VIEW_ORDER.indexOf(a.view)
+ const bi = SUBJECT_VIEW_ORDER.indexOf(b.view)
+ if (ai !== bi) return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi)
+ return (a.created_at || 0) - (b.created_at || 0)
+ })
+}
+
+function subjectAssetPackSummary(pack: SubjectAssetPack) {
+ if (pack.running) return `${pack.completed}/${pack.total} 生成中`
+ if (pack.failed) return `${pack.completed}/${pack.total} · 失败 ${pack.failed}`
+ return `${pack.completed || pack.total} 张`
+}
+
function characterPreviewImage(character?: { primary_image?: string; images?: Array<{ id: string; view?: string; filename: string; label?: string }> } | null) {
if (!character?.images?.length) return null
return character.images.find((image) => image.id === character.primary_image)
@@ -3156,6 +3207,7 @@ function SourceSubjectPipeline({
const [cartoonStyleOpen, setCartoonStyleOpen] = useState(false)
const [subjectBusyFor, setSubjectBusyFor] = useState<{ jobId: string; jobLabel: string; mode: SubjectReconstructionMode; viewCount: number; sourceCount: number; profileLabel: string } | null>(null)
const [subjectAssetBusy, setSubjectAssetBusy] = useState(null)
+ const [expandedSubjectPackKey, setExpandedSubjectPackKey] = useState(null)
const [lastSubjectProfile, setLastSubjectProfile] = useState(null)
const subjectBusy = !!subjectBusyFor
const selectedSubjectViews = RECONSTRUCTION_SUBJECT_VIEW_VALUES
@@ -3182,25 +3234,65 @@ function SourceSubjectPipeline({
}
return items
}, [frames])
- const visibleActorAssets = useMemo(() => {
- const items: Array<{ frame: KeyFrame; element: KeyElement; mode: SubjectReconstructionMode; asset: SubjectAsset }> = []
+ const subjectAssetPacks = useMemo(() => {
+ const packs = new Map()
for (const source of actorSources) {
- const latestByView = new Map()
for (const asset of source.element.subject_assets ?? []) {
+ const key = subjectAssetPackKey(source.frame, source.element, asset)
+ const rawMode = asset.pack_mode as SubjectReconstructionMode | undefined
+ const packMode = rawMode && RECONSTRUCTION_MODES.some((item) => item.value === rawMode) ? rawMode : source.mode
+ const createdAt = asset.pack_created_at || asset.created_at || 0
+ const existing = packs.get(key)
+ if (existing) {
+ existing.assets.push(asset)
+ existing.createdAt = Math.min(existing.createdAt || createdAt, createdAt)
+ } else {
+ packs.set(key, {
+ key,
+ id: asset.pack_id || key,
+ label: asset.pack_label || `${reconstructionModeConfig(packMode).label}套图`,
+ mode: packMode,
+ frame: source.frame,
+ element: source.element,
+ createdAt,
+ assets: [asset],
+ total: 0,
+ completed: 0,
+ failed: 0,
+ running: false,
+ })
+ }
+ }
+ }
+ return [...packs.values()].map((pack) => {
+ const latestByView = new Map()
+ for (const asset of pack.assets) {
const current = latestByView.get(asset.view)
if (!current || (asset.created_at || 0) >= (current.created_at || 0)) latestByView.set(asset.view, asset)
}
- for (const asset of latestByView.values()) items.push({ ...source, asset })
- }
- return items.sort((a, b) => {
+ const assets = subjectAssetPackSortAssets([...latestByView.values()])
+ const completed = assets.filter((asset) => subjectAssetStatus(asset) === "completed").length
+ const failed = assets.filter((asset) => subjectAssetStatus(asset) === "failed").length
+ const running = assets.some(subjectAssetIsRunning)
+ return { ...pack, assets, total: assets.length, completed, failed, running }
+ }).sort((a, b) => {
const mi = RECONSTRUCTION_MODES.findIndex((item) => item.value === a.mode)
const mj = RECONSTRUCTION_MODES.findIndex((item) => item.value === b.mode)
if (mi !== mj) return mi - mj
- const ai = SUBJECT_VIEW_ORDER.indexOf(a.asset.view)
- const bi = SUBJECT_VIEW_ORDER.indexOf(b.asset.view)
- return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi)
+ return (b.createdAt || 0) - (a.createdAt || 0)
})
}, [actorSources])
+ const activeSubjectPack = useMemo(
+ () => subjectAssetPacks.find((pack) => pack.key === expandedSubjectPackKey) ?? subjectAssetPacks[0] ?? null,
+ [expandedSubjectPackKey, subjectAssetPacks],
+ )
+ const runningActorModes = useMemo(() => {
+ const next = new Set()
+ for (const pack of subjectAssetPacks) {
+ if (pack.running) next.add(pack.mode)
+ }
+ return next
+ }, [subjectAssetPacks])
useEffect(() => {
setConversionFrameIndicesByMode({ ...EMPTY_RECONSTRUCTION_FRAME_MAP })
@@ -3210,8 +3302,15 @@ function SourceSubjectPipeline({
setSubjectAssetBusy(null)
setActiveDropMode(null)
setCartoonStyleOpen(false)
+ setExpandedSubjectPackKey(null)
}, [job.id])
+ useEffect(() => {
+ if (expandedSubjectPackKey && !subjectAssetPacks.some((pack) => pack.key === expandedSubjectPackKey)) {
+ setExpandedSubjectPackKey(null)
+ }
+ }, [expandedSubjectPackKey, subjectAssetPacks])
+
useEffect(() => {
setConversionFrameIndicesByMode((current) => {
const next = {} as Record
@@ -3233,6 +3332,10 @@ function SourceSubjectPipeline({
toast.warning("主体套图正在生成中,完成后再重生。")
return
}
+ if (runningActorModes.has(mode)) {
+ toast.warning(`${reconstructionModeConfig(mode).label}还有主体图正在逐张生成。`)
+ return
+ }
const sourceFrames = sourceIndices
.map((index) => frames.find((frame) => frame.index === index))
.filter((frame): frame is KeyFrame => !!frame)
@@ -3288,10 +3391,18 @@ function SourceSubjectPipeline({
views: selectedSubjectViews,
subject_profile: requestProfile?.payload ?? null,
prompt: buildSimilarSubjectPrompt(subjectStyle, userDirection, null, requestProfile),
- replace_views: true,
+ replace_views: false,
+ pack_label: `${reconstructionModeConfig(mode).label} ${new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false })}`,
+ pack_mode: mode,
})
onJobUpdate(updated)
- toast.success(`${reconstructionModeConfig(mode).label}已生成:${selectedSubjectViews.length} 张`)
+ const updatedFrame = updated.frames.find((frame) => frame.index === baseFrame.index)
+ const updatedElement = updatedFrame?.elements?.find((item) => item.id === element.id)
+ const newestAsset = [...(updatedElement?.subject_assets ?? [])].sort((a, b) => (b.pack_created_at || b.created_at || 0) - (a.pack_created_at || a.created_at || 0))[0]
+ if (updatedFrame && updatedElement && newestAsset) {
+ setExpandedSubjectPackKey(subjectAssetPackKey(updatedFrame, updatedElement, newestAsset))
+ }
+ toast.success(`${reconstructionModeConfig(mode).label}已提交:${selectedSubjectViews.length} 张会逐张出来`)
} catch (e) {
try {
onJobUpdate(await getJob(requestJobId))
@@ -3359,9 +3470,13 @@ function SourceSubjectPipeline({
requestProfile,
),
replace_views: true,
+ pack_id: asset.pack_id ?? "",
+ pack_label: asset.pack_label ?? "",
+ pack_mode: asset.pack_mode ?? mode,
+ pack_created_at: asset.pack_created_at ?? asset.created_at ?? 0,
})
onJobUpdate(updated)
- toast.success("已重新生成这张主体元素")
+ toast.success("已提交重生,这张主体元素会生成完成后替换")
} catch (e) {
toast.error("主体元素重生失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
@@ -3497,6 +3612,7 @@ function SourceSubjectPipeline({
const canGenerate = mode === "custom"
? Boolean(reconstructionDirections.custom.trim() || modeFrames.length)
: modeFrames.length > 0
+ const modeRunning = runningActorModes.has(mode)
return (
void generateSubjectPack(mode)}
- disabled={subjectBusy || !canGenerate}
+ disabled={subjectBusy || modeRunning || !canGenerate}
className="skg-primary-action mt-2 inline-flex h-8 w-full items-center justify-center gap-1 px-3 text-[10.5px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40"
>
- {subjectBusyFor?.mode === mode ? : }
- {subjectBusyFor?.mode === mode ? `生成中 · ${subjectBusyFor.sourceCount || "描述"} 参考` : `生成${modeConfig.label} 6视图`}
+ {subjectBusyFor?.mode === mode || modeRunning ? : }
+ {subjectBusyFor?.mode === mode || modeRunning ? "逐张生成中" : `生成${modeConfig.label} 6视图`}
)
@@ -3628,7 +3744,7 @@ function SourceSubjectPipeline({
} title="主体元素" />
- {visibleActorAssets.length ? `${visibleActorAssets.length} 张` : "待生成"}
+ {subjectAssetPacks.length ? `${subjectAssetPacks.length} 套` : "待生成"}
@@ -3638,52 +3754,90 @@ function SourceSubjectPipeline({
主体设定:{subjectBusyFor.profileLabel}
) : null}
- {visibleActorAssets.length ? (
-
- {RECONSTRUCTION_MODES.map((modeConfig) => {
- const items = visibleActorAssets.filter((item) => item.mode === modeConfig.value)
- if (!items.length) return null
- return (
-
-
- {modeConfig.label}
- {items.length} 张
-
-
- {items.map((item) => {
- const { asset } = item
- const busyMode = subjectAssetBusy?.endsWith(asset.id) ? subjectAssetBusy.split(":")[0] : ""
- return (
-
,
- tone: "cyan",
- busy: busyMode === "regen",
- disabled: !!subjectAssetBusy || subjectBusy,
- onClick: () => void regenerateSubjectAsset(item),
- }]}
- onDelete={() => void deleteActorAsset(item)}
- deleting={busyMode === "delete"}
- deleteDisabled={!!subjectAssetBusy || subjectBusy}
- deleteLabel="删除这一张"
- />
- )
- })}
+ {subjectAssetPacks.length ? (
+
+ {activeSubjectPack ? (
+
+
+
+
{activeSubjectPack.label}
+
+ {reconstructionModeConfig(activeSubjectPack.mode).label} · {subjectAssetPackSummary(activeSubjectPack)}
+
+
+ {activeSubjectPack.assets.length} 张
+
- )
- })}
+
+ {activeSubjectPack.assets.map((asset) => {
+ const busyMode = subjectAssetBusy?.endsWith(asset.id) ? subjectAssetBusy.split(":")[0] : ""
+ const status = subjectAssetStatus(asset)
+ const running = subjectAssetIsRunning(asset)
+ const failed = status === "failed"
+ const mediaUrl = subjectAssetUrl(job, asset)
+ const item = { frame: activeSubjectPack.frame, element: activeSubjectPack.element, mode: activeSubjectPack.mode, asset }
+ return (
+ ,
+ tone: "cyan",
+ busy: busyMode === "regen",
+ disabled: !!subjectAssetBusy || subjectBusy || running,
+ onClick: () => void regenerateSubjectAsset(item),
+ }]}
+ onDelete={() => void deleteActorAsset(item)}
+ deleting={busyMode === "delete"}
+ deleteDisabled={!!subjectAssetBusy || subjectBusy || running}
+ deleteLabel="删除这一张"
+ />
+ )
+ })}
+
+
+ ) : null}
+
+
+ {subjectAssetPacks.map((pack, index) => {
+ const active = activeSubjectPack?.key === pack.key
+ return (
+
+ )
+ })}
+
+
) : (
@@ -4022,9 +4176,11 @@ function SourceReferenceBuildPanel({
subject_profile: requestProfile.payload,
prompt: buildSimilarSubjectPrompt(subjectStyle, subjectDirection, selectedTemplatePrompt, requestProfile),
replace_views: true,
+ pack_label: `主体视图 ${new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false })}`,
+ pack_mode: "realistic",
})
onJobUpdate(updated)
- toast.success(`相似主体 ${selectedSubjectViews.length} 张高清白底图已生成`)
+ toast.success(`相似主体已提交:${selectedSubjectViews.length} 张会逐张出来`)
} catch (e) {
try {
onJobUpdate(await getJob(requestJobId))
@@ -4060,9 +4216,13 @@ function SourceReferenceBuildPanel({
subject_profile: requestProfile.payload,
prompt: buildSimilarSubjectPrompt(subjectStyle, subjectDirection, selectedTemplatePrompt, requestProfile),
replace_views: true,
+ pack_id: asset.pack_id ?? "",
+ pack_label: asset.pack_label ?? "",
+ pack_mode: asset.pack_mode ?? "realistic",
+ pack_created_at: asset.pack_created_at ?? asset.created_at ?? 0,
})
onJobUpdate(updated)
- toast.success("已重新生成这张主体视图")
+ toast.success("已提交重生,这张主体视图会生成完成后替换")
} catch (e) {
toast.error("单张主体视图重生失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
@@ -4335,16 +4495,22 @@ function SourceReferenceBuildPanel({
{visibleActorAssets.map((asset) => {
const busyMode = subjectAssetBusy?.endsWith(asset.id) ? subjectAssetBusy.split(":")[0] : ""
+ const status = subjectAssetStatus(asset)
+ const running = subjectAssetIsRunning(asset)
+ const failed = status === "failed"
+ const mediaUrl = subjectAssetUrl(job, asset)
return (
,
tone: "cyan",
busy: busyMode === "regen",
- disabled: !!subjectAssetBusy,
+ disabled: !!subjectAssetBusy || running,
onClick: () => void regenerateSubjectAsset(asset),
}]}
onDelete={() => void deleteActorAsset(asset)}
deleting={busyMode === "delete"}
- deleteDisabled={!!subjectAssetBusy}
+ deleteDisabled={!!subjectAssetBusy || running}
deleteLabel="删除这一张"
/>
)
diff --git a/web/lib/api.ts b/web/lib/api.ts
index 00fa6b5..634ba72 100644
--- a/web/lib/api.ts
+++ b/web/lib/api.ts
@@ -701,6 +701,7 @@ export type AssetBackground = "white" | "black"
export type AssetSize = "source" | "1024" | "1536" | "2048"
export type SubjectKind = "object" | "living"
export type SubjectView = string
+export type SubjectAssetStatus = "queued" | "in_progress" | "completed" | "failed"
export type SceneMode = "remove_subject" | "similar" | "style"
export type SceneStyle = "source" | "premium_product" | "clean_studio" | "warm_lifestyle" | "cinematic"
export type SceneAssetRole = "scene" | "first_frame" | "last_frame"
@@ -754,6 +755,13 @@ export interface SubjectAsset {
size: AssetSize
source_frame_indices?: number[]
ai_completed?: boolean
+ status?: SubjectAssetStatus
+ progress?: number
+ error?: string
+ pack_id?: string
+ pack_label?: string
+ pack_mode?: string
+ pack_created_at?: number
created_at: number
}
@@ -1500,6 +1508,10 @@ export async function generateSubjectAssets(
subject_profile?: SubjectProfilePreference | null
prompt?: string
replace_views?: boolean
+ pack_id?: string
+ pack_label?: string
+ pack_mode?: string
+ pack_created_at?: number
} = {},
): Promise {
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/elements/${elementId}/subject-assets`, {
@@ -1519,6 +1531,10 @@ export async function generateSubjectAssets(
subject_profile: body.subject_profile ?? null,
prompt: body.prompt ?? "",
replace_views: body.replace_views ?? false,
+ pack_id: body.pack_id ?? "",
+ pack_label: body.pack_label ?? "",
+ pack_mode: body.pack_mode ?? "",
+ pack_created_at: body.pack_created_at ?? 0,
}),
})
if (!res.ok) {