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-brandskg-stat-cardskg-primary-actionskg-secondary-actionskg-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,空状态复用 AnimatedLoginCharactersbuildWorkflowSteps 仍统一生成 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 展开为完整 StoryboardScenegenerateStoryboardVideocount 可由单行数字控件选择,候选新生成后持续向右追加,不再用 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 /elementsaddElement/updateElement/deleteElement让用户修正 Vision 错误,避免候选结果锁死。
             元素提取POST /elements/{element_id}/cutoutcutoutElement调用图像模型生成独立白底素材图,每次累积一张 cutout。
             主体资产包POST /elements/{element_id}/subject-assets
DELETE /elements/{element_id}/subject-assets/{asset_id}generateSubjectAssets
deleteSubjectAsset根据转换层里的参考帧重新绘制一个统一主体资产包;前端按真人重构、卡通重构、元素重构、自主描述四个方向分别管理 source_frame_indices,每个方向最多 3 张参考帧,固定请求 frontthree_quarter_leftleftbackrightthree_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_idweb/app/page.tsx
SourceSubjectPipelinegenerateSubjectAssets 现在先写入同一个 pack_id 下的 queued 占位卡并立即返回,后台按视角逐张生成,单张完成就把该占位替换成 completed 图片。前端轮询会把 queued / in_progress 主体资产纳入运行状态;主体元素区按 pack 显示套图文件夹,点击某个文件夹后展开该套图,其他套图顺位进入下方可滚动列表。 首尾帧资产POST /frames/{idx}/scene-assetgenerateSceneAsset同一接口兼容旧场景图和新首尾帧;当前信息流复刻流程传 asset_role=first_frame/last_framesubject_brief 和最多 1-2 张 product_images。首尾帧不再传主体图、不再把主体图和产品图拼成 contact sheet;主体只走文字 brief,允许新动作、新景别、新表情和新环境。若本条需要产品,后端只把产品参考图作为 gpt-image-2 image-edit 的硬视觉真源;若不需要产品,则走纯文字生图。关键帧只作为行数据承载位置。生成结果保存在 scene_assets,前端再写入 StoryboardScene.first_image/last_image。 产品图库GET /product-library/skglistProductLibrary读取内置 SKG 白底图库 manifest,返回产品标题、品类、尺寸、白底评分和预览图 URL。 产品图入库到 jobPOST /jobs/{id}/assetsPOST /jobs/{id}/assets/product-libraryuploadStoryboardAssetcopyProductLibraryAsset上传产品图或把内置产品图库条目复制为当前 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/errorpack_id/pack_label/pack_mode/pack_created_atgenerateSubjectAssets 先写 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) {