From c245bff4b801a38fd55dcf0da732c2fbfe0d1fc7 Mon Sep 17 00:00:00 2001 From: kang Date: Wed, 20 May 2026 09:16:28 +0800 Subject: [PATCH] feat: add subject image model controls --- RULES.md | 4 +- api/main.py | 47 +++++-- docs/source-analysis.html | 19 ++- web/components/ad-recreation-board.tsx | 169 ++++++++++++++++++++++++- web/lib/api.ts | 3 + 5 files changed, 226 insertions(+), 16 deletions(-) diff --git a/RULES.md b/RULES.md index f56829e..f97f31c 100644 --- a/RULES.md +++ b/RULES.md @@ -11,7 +11,7 @@ - 详见 `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-20 再确认):信息流广告快速复刻默认进入“三字段候选生成”工作流。主界面为“左侧素材输入列 + 右侧信息流复刻工作表”。用户粘贴 TK 链接或上传视频后点击“开始分析”,系统自动下载源视频;下载完成后并行启动两条路:音频文案路提取原音频文案/字幕,并分析讲话人、语速节奏、背景音乐/环境声/音效;视频视觉路自动抽取参考帧。源视频工作区右侧主体链路是“参考帧池 → 转换层 → 主体元素”:参考帧池竖向排列;转换层只保留真人重构、卡通重构、元素重构、自主描述四个入口,每个入口最多拖入 3 张参考帧,拖入只加入参考队列,不自动生成;用户放好参考和文字后点击生成,右侧主体元素区按每次生成的套图文件夹展示全新 6 视图主体,当前套图在最上层展开,其他套图顺位进入下方可滚动列表,同一重构方向允许保留多套。转换层可直接选择自动 / GPT / Gemini 生图模型,偏好只影响主体套图生成;提示词输入有本地记忆,会把上次常用词生成可点击小按键。主体重构默认继承参考图里的性别、人种/肤色、年龄体态和角色气质这些广义特征,但生成同一个全新主体;同一套 6 视图必须统一脸部设定、发型、体态、服装类型、配色、材质、剪裁和配饰,避免一套图里每张衣服不同。这四类都属于参考重构,不抠图、不复制原人、不复刻原画面。旧下方“相似主体 / 主体模板库”不再作为主路径。波形下方的画面胶片只是临时预览,点击只跳转原视频时间点,双击或拖进参考帧池才正式加入关键帧,已加入的胶片直接显示“已添加”。产品图上传后独立形成产品资产包,自动识别视角/结构/比例并补缺角度。分镜工作台按逐句时间轴默认只露“文案 / 场景一句话 / 人物+产品+动作”,产品素材池、批量控制、三字段、视频候选和高级区都必须可折叠;视频候选无内容时默认不占大面积,有候选时默认只显示迷你缩略条,展开后才显示 4-grid。单条默认生成 4 个视频候选,顶部支持整片批量生成候选;首尾帧、视觉规划、产品出现方式和旧 6 字段保留在“高级”抽屉与后端 quick-plan 自动展开中,不能再作为客户默认闸门。 ## 部署事实 - 平台:VPS `76.13.31.179`(Ubuntu 24.04 / Docker Compose / Coolify Traefik) @@ -78,7 +78,7 @@ - `IMAGE_REQUEST_TIMEOUT_SECONDS`:单次图片网关请求超时,默认 60 秒;超时会直接把该视图标失败并继续下一张,避免主体 6 视图整包长时间无反馈 - `IMAGE_FALLBACK_ENABLED` / `IMAGE_FALLBACK_MODEL`:图片主模型故障兜底;当前允许在 `gpt-image-2` 超时、429、5xx 或网络错误时临时使用 `gemini-3-pro-image-preview`,400/401/403/404 和参数错误不兜底 - `IMAGE_CIRCUIT_FAILURE_THRESHOLD` / `IMAGE_CIRCUIT_COOLDOWN_SECONDS`:短时熔断配置,默认 `gpt-image-2` 连续 2 次上游类失败后 600 秒内直接走 Gemini 兜底;成功恢复后自动清空失败计数 -- `GPT_IMAGE_MODEL` / `SUBJECT_ASSET_IMAGE_MODEL` / `SUBJECT_ASSET_IMAGE_MODELS`:保留兼容旧环境变量名;主体 6 视图先用 `gpt-image-2`,同一套图内一旦触发 Gemini 兜底,后续视图沿用 Gemini,避免一张张等待主模型超时 +- `GPT_IMAGE_MODEL` / `SUBJECT_ASSET_IMAGE_MODEL` / `SUBJECT_ASSET_IMAGE_MODELS`:保留兼容旧环境变量名;主体 6 视图在转换层默认自动使用 `gpt-image-2`,同一套图内一旦触发 Gemini 兜底,后续视图沿用 Gemini,避免一张张等待主模型超时;用户显式选择 GPT 或 Gemini 时,`image_model_preference` 会让主体套图只走所选模型 - `AI_HTTP_PROXY` / `IMAGE_HTTP_PROXY`:可选的 AI 网关出站代理;本地 launchd 后台进程不一定继承 shell 的 `http_proxy/https_proxy`,如生图报 DNS / ConnectError,可在本地 `api/.env` 配置后重启后端。`/health` 只回传是否配置代理,不回传代理地址。 - `YTDLP_COOKIES_FILE` / `YTDLP_COOKIES_FROM_BROWSER`:可选 TikTok 下载登录态;生产云端固定使用 cookies 文件 `/run/secrets/tiktok_cookies.txt`(宿主机 `./secrets/tiktok_cookies.txt` 挂载进容器),本地开发可临时用浏览器 cookies。cookies 文件属于敏感登录态,只能放本机或服务器私有路径,不允许入库。 - `VOICE_PROVIDER`:配音通道,服务端固定使用 `azure_openai`;旧环境若写 `minimax` 会被忽略 diff --git a/api/main.py b/api/main.py index 728f984..336b1cb 100644 --- a/api/main.py +++ b/api/main.py @@ -3547,8 +3547,24 @@ def _image_primary_circuit_open() -> bool: return _image_circuit_snapshot()["primary_open"] -def _image_model_candidates(force_fallback: bool = False) -> list[str]: +def _normalize_image_model_preference(value: str | None) -> str: + raw = (value or "auto").strip().lower() + if raw in {"", "auto", "default"}: + return "auto" + if raw in {"gpt", "gpt-image", GPT_IMAGE_MODEL.lower()}: + return GPT_IMAGE_MODEL + if IMAGE_FALLBACK_MODEL and raw in {"gemini", IMAGE_FALLBACK_MODEL.lower()}: + return IMAGE_FALLBACK_MODEL + return "auto" + + +def _image_model_candidates(force_fallback: bool = False, preference: str | None = "auto") -> list[str]: + normalized = _normalize_image_model_preference(preference) fallbacks = _image_fallback_models() + if normalized == GPT_IMAGE_MODEL: + return [GPT_IMAGE_MODEL] + if normalized == IMAGE_FALLBACK_MODEL and fallbacks: + return [IMAGE_FALLBACK_MODEL] if not fallbacks: return [GPT_IMAGE_MODEL] if force_fallback or _image_primary_circuit_open(): @@ -3692,6 +3708,7 @@ def _image_edit_call( max_attempts: int = 3, max_side: int = 1024, force_fallback_model: bool = False, + image_model_preference: str | None = "auto", ) -> tuple[bytes, str]: """通用 image edit 调用 · 失败重试 + 可选 text fallback。 返回 (image_bytes, effective_mode) where effective_mode in {"edit","text"}。 @@ -3709,7 +3726,7 @@ def _image_edit_call( if not image_paths: raise RuntimeError("image edit reference image missing") img_bytes_list = [_prepare_image_edit_bytes(path, max_side) for path in image_paths] - model_candidates = _image_model_candidates(force_fallback=force_fallback_model) + model_candidates = _image_model_candidates(force_fallback=force_fallback_model, preference=image_model_preference) mode_plan: list[str] = ["edit"] if model_candidates != [GPT_IMAGE_MODEL] else ["edit"] * max_attempts if fallback_text: mode_plan.append("text") @@ -3803,6 +3820,7 @@ def _image_text_call( models: list[str] | None = None, max_attempts: int = 3, force_fallback_model: bool = False, + image_model_preference: str | None = "auto", ) -> tuple[bytes, str]: """Text-only image generation. gpt-image-2 primary, Gemini only as outage fallback.""" import base64 as b64lib @@ -3810,7 +3828,7 @@ def _image_text_call( import httpx if not IMAGE_API_KEY: raise RuntimeError("IMAGE_API_KEY 或 LLM_API_KEY 未配置") - candidates = _image_model_candidates(force_fallback=force_fallback_model) + candidates = _image_model_candidates(force_fallback=force_fallback_model, preference=image_model_preference) attempt_models = candidates if candidates != [GPT_IMAGE_MODEL] else [GPT_IMAGE_MODEL] * max_attempts last_err = "" capacity_seen = False @@ -5004,6 +5022,7 @@ class GenerateSubjectAssetsReq(BaseModel): reconstruction_mode: Literal["same", "similar"] = "same" subject_profile: SubjectProfilePreference | None = None prompt: str = "" + image_model_preference: str = "auto" replace_views: bool = False source_subject_brief: str = "" pack_id: str = "" @@ -5787,9 +5806,17 @@ def _generate_subject_assets_sync(job_id: str, idx: int, element_id: str, req: G "Identity lock: these API calls generate one high-definition multi-view pack for ONE single subject, but each individual output file must show only its one requested view. " "Before rendering, infer one consistent character bible from the supplied text brief and generation instructions: gender presentation, age range, body proportions, head shape, face direction cues, material, silhouette, wardrobe/material style, and commercial mood. " "Keep that same character bible unchanged across every generated view in separate files. " + "By default, inherit the reference frames' broad gender presentation, regional/ethnic appearance category, skin-tone family, body-proportion category, and ad-role energy unless the user explicitly overrides them. " + "The pack must depict the same newly designed person or character in every view: same face design, same hair design, same body proportions, same skin tone, same age range, and same commercial styling. " "If user direction requests a gender, age, or style change, apply that one change uniformly to all views; never mix male/female, young/old, or multiple style identities inside the same pack. " "For transparent humanoids, keep the same transparent skin shell, skeleton proportions, visible spine/rib cage/pelvis/limb bones, and non-horror wellness character style in every view. " ) + wardrobe_lock_clause = ( + "Wardrobe lock: choose one outfit bible before rendering and keep it identical across all views. " + "The same garment type, color palette, neckline, sleeve shape, straps, fabric/material, fit, seam logic, and visible accessories must remain consistent from front, side, three-quarter, and back views. " + "Do not change clothing between views; do not switch from sportswear to casualwear, dress, coat, hoodie, uniform, or underwear unless the user explicitly requests that single outfit for the whole pack. " + "If the reference outfit is useful, inherit its broad wardrobe category and color family, but redraw it as a new non-identical clean commercial outfit. " + ) neck_product_clause = ( "This subject pack is for SKG neck-and-shoulder wearable massage device videos. " "Make the neck, collarbone, shoulder line, upper back, side neck, and shoulder slope clear and product-ready. " @@ -5797,10 +5824,11 @@ def _generate_subject_assets_sync(job_id: str, idx: int, element_id: str, req: G "For back and close-up views, prioritize the cervical spine, shoulder blades, upper trapezius, and clean wearable-device contact area. " ) models = SUBJECT_ASSET_IMAGE_MODELS + model_preference = _normalize_image_model_preference(req.image_model_preference) generated: list[SubjectAsset] = [] generation_errors: list[str] = [] first_generation_error: RuntimeError | None = None - pack_force_fallback_model = _image_primary_circuit_open() + pack_force_fallback_model = model_preference == "auto" and _image_primary_circuit_open() try: for view, view_label in _subject_view_labels(req.subject_kind, req.views): closeup_view = view in {"bust", "back_detail", "bust_front", "bust_left_45", "bust_right_45", "back_neck_detail"} or "detail" in view @@ -5845,6 +5873,7 @@ def _generate_subject_assets_sync(job_id: str, idx: int, element_id: str, req: G + single_view_clause + identity_clause + identity_lock_clause + + wardrobe_lock_clause + neck_product_clause + canvas_clause + prompt_extra_clause @@ -5861,17 +5890,17 @@ def _generate_subject_assets_sync(job_id: str, idx: int, element_id: str, req: G try: if similar_mode: print( - f"[subject assets] reconstruction_mode=similar endpoint=/images/generations view={view} image_refs=0 model={'fallback' if pack_force_fallback_model else GPT_IMAGE_MODEL}", + f"[subject assets] reconstruction_mode=similar endpoint=/images/generations view={view} image_refs=0 model_preference={model_preference}", flush=True, ) - img_bytes, _mode = _image_text_call(prompt, models=models, max_attempts=3, force_fallback_model=pack_force_fallback_model) - if _mode.endswith(f":{IMAGE_FALLBACK_MODEL}"): + img_bytes, _mode = _image_text_call(prompt, models=models, max_attempts=3, force_fallback_model=pack_force_fallback_model, image_model_preference=model_preference) + if model_preference == "auto" and _mode.endswith(f":{IMAGE_FALLBACK_MODEL}"): pack_force_fallback_model = True else: if model_src is None: raise RuntimeError("subject asset edit reference image missing") - img_bytes, _mode = _image_edit_call(model_src, prompt, models=models, fallback_text=False, max_attempts=3, max_side=1280, force_fallback_model=pack_force_fallback_model) - if _mode.endswith(f":{IMAGE_FALLBACK_MODEL}"): + img_bytes, _mode = _image_edit_call(model_src, prompt, models=models, fallback_text=False, max_attempts=3, max_side=1280, force_fallback_model=pack_force_fallback_model, image_model_preference=model_preference) + if model_preference == "auto" and _mode.endswith(f":{IMAGE_FALLBACK_MODEL}"): pack_force_fallback_model = True except RuntimeError as e: if first_generation_error is None: diff --git a/docs/source-analysis.html b/docs/source-analysis.html index 4e3b8d7..02b9738 100644 --- a/docs/source-analysis.html +++ b/docs/source-analysis.html @@ -593,8 +593,8 @@ web/next.config.mjsNext.js 构建配置:静态导出、图片不走优化、禁用开发环境左下角 Next Dev Indicator,并移除 Next 16 已不支持的 eslint 顶层配置,避免本地 dev 出现配置 Issue 提示。 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 文字生图,只有主模型超时、429、5xx 或网络错误时才短时熔断并兜底 gemini-3-pro-image-preview,避免复制原人、原脸和原画面。主体生成完成后会形成 subject_consensus_brief。音频结果下方是信息流复刻分镜工作台:顶部产品参考区是“同一产品素材池”,不限量上传产品图,不做不同产品身份判断;上传原图推荐长边 1200-2000px、短边至少 600px,但后端会统一生成最长边 1600px、JPEG 92 的 AI 工作副本,并回显尺寸、自动转换和风险标注;上传后按“套在脖子上的 U 形肩颈按摩仪”进行同一产品批量识别,左/右按佩戴者身体左右、上/下按佩戴方向,额外标注内外侧、开口方向、局部结构点、背景类型、用途标签、生成风险和备注,用户只检查备注,鼠标悬停通过固定浮层显示大图预览,能盖过滚动容器和分镜框架;缺视角补图失败时保留重试入口。脚本区在分镜行上方提供“作者想法”和“整片改写”,每行新口播文案可直接编辑并可单段 AI 改写。每条音频分镜默认是左侧三字段、右侧横向视频候选轨;高级区仍保留首尾帧 prompt、产品出现方式和旧 6 字段。ModelTrace 会在音频解析、产品识别/补图、主体重构视图包、脚本改写等入口旁直接展示模型名;生图入口会显示 gpt-image-2 / gemini-3-pro-image-preview 链路和短时熔断规则,点击后用固定浮层展示模型链路、输入输出和回退逻辑。旧分镜卡、抽帧控制和视频生成组件仍保留在文件里,但当前主路径不渲染。 - SourceSubjectPipeline源视频工作区右侧主体管线主路径:三栏分别是竖向 参考帧池转换层主体元素。参考帧池保留自动 12 张、胶片拖入正式成帧、点击勾选和删除;参考帧缩略图保持小尺寸固定宽度、aspect-[9/16]object-contain 显示,hover 预览通过 MediaAssetTile 的左侧紧凑浮层显示。转换层取消旧的“透明骨架 / 真人”和“完整 10 / 常用 4”开关,改成真人重构、卡通重构、元素重构、自主描述四个投放区;每个区最多保留 3 张参考帧,拖入只加入参考队列,不自动调用生成;用户放好参考和文字后点击按钮才调用 generateSubjectAssets 生成固定 6 视图。文字输入会参与 prompt,卡通重构额外提供 3D 动画、潮玩公仔、日系清爽、美式插画、黏土玩具、极简扁平等风格。四种模式都强调参考重构:不抠图区、不复制原人原脸、不复刻原画面。主体元素区按每次生成的 pack_id 组织成“套图文件夹”:顶部展开当前选中套图,下面是可滚动的套图包列表;同一方向可保留多套,生成中按 pack 显示 2/6 这类进度,单张完成就替换对应占位卡。缩略图复用 MediaAssetTile,支持 hover 放大、单张重生和删除。旧下方 SourceReferenceBuildPanel 不再主路径渲染。 + 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;转换层顶部新增生图模型选择(自动 / GPT / Gemini),选择写入本地 localStorage,只影响主体套图生成;四个方向的提示词输入会记忆常用短语并生成可点击小按键,点击会追加到当前提示词。主体元素区按重构类型分组显示结果;只展示每个 view 的最新一张,缩略图上提供“重新生成这一张”和“删除这一张”,单张重生会用 replace_views=true 替换同一视角。前端当前对真人/元素/自主描述传 subject_style=source_actor,对卡通重构传 subject_style=cartoon_subject,并使用 reconstruction_mode=similar;后端会把关键帧反推成非身份化文字 brief,再按 image_model_preference 选择自动链路、仅 GPT 或仅 Gemini,自动链路仍以 gpt-image-2 为主,只有主模型超时、429、5xx 或网络错误时才短时熔断并兜底 gemini-3-pro-image-preview,避免复制原人、原脸和原画面。主体生成 prompt 默认继承参考里的性别、人种/肤色、年龄体态和角色气质这些广义特征,同时锁定同一个全新主体、同一套服装和同一套配饰贯穿 6 视图。主体生成完成后会形成 subject_consensus_brief。音频结果下方是信息流复刻分镜工作台:顶部产品参考区是“同一产品素材池”,不限量上传产品图,不做不同产品身份判断;上传原图推荐长边 1200-2000px、短边至少 600px,但后端会统一生成最长边 1600px、JPEG 92 的 AI 工作副本,并回显尺寸、自动转换和风险标注;上传后按“套在脖子上的 U 形肩颈按摩仪”进行同一产品批量识别,左/右按佩戴者身体左右、上/下按佩戴方向,额外标注内外侧、开口方向、局部结构点、背景类型、用途标签、生成风险和备注,用户只检查备注,鼠标悬停通过固定浮层显示大图预览,能盖过滚动容器和分镜框架;缺视角补图失败时保留重试入口。脚本区在分镜行上方提供“作者想法”和“整片改写”,每行新口播文案可直接编辑并可单段 AI 改写。每条音频分镜默认是左侧三字段、右侧横向视频候选轨;高级区仍保留首尾帧 prompt、产品出现方式和旧 6 字段。ModelTrace 会在音频解析、产品识别/补图、主体重构视图包、脚本改写等入口旁直接展示模型名;生图入口会显示 gpt-image-2 / gemini-3-pro-image-preview 链路和短时熔断规则,点击后用固定浮层展示模型链路、输入输出和回退逻辑。旧分镜卡、抽帧控制和视频生成组件仍保留在文件里,但当前主路径不渲染。 + SourceSubjectPipeline源视频工作区右侧主体管线主路径:三栏分别是竖向 参考帧池转换层主体元素。参考帧池保留自动 12 张、胶片拖入正式成帧、点击勾选和删除;参考帧缩略图保持小尺寸固定宽度、aspect-[9/16]object-contain 显示,hover 预览通过 MediaAssetTile 的左侧紧凑浮层显示。转换层取消旧的“透明骨架 / 真人”和“完整 10 / 常用 4”开关,改成真人重构、卡通重构、元素重构、自主描述四个投放区;每个区最多保留 3 张参考帧,拖入只加入参考队列,不自动调用生成;用户放好参考和文字后点击按钮才调用 generateSubjectAssets 生成固定 6 视图。转换层顶部提供 autogpt-image-2gemini-3-pro-image-preview 三档模型选择,偏好存到 localStorage["skg:subject-image-model:v1"];提示词输入存到 localStorage["skg:subject-prompt-memory:v1"],会把旧词解析成短 chip,点击 chip 会追加到当前方向。文字输入会参与 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"]。暗色仍是默认模式;明亮模式只改变工作台外观,不改变任务、素材、分镜、模型调用或接口数据。 @@ -986,7 +986,7 @@ ProductRefStateItem { 应用清洗POST /cleanup/applyapplyCleanedFrame物理覆盖 frames/{idx}.jpg,并备份原图。 元素增改删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、多主体、多面板、标签或对比排版。单次图片请求受 IMAGE_REQUEST_TIMEOUT_SECONDS 控制,默认 60 秒;gpt-image-2 超时、429、5xx、DNS 或连接失败时可兜底 gemini-3-pro-image-preview,连续 2 次主模型上游类失败后 600 秒内短时熔断。主体同一套图内一旦触发 Gemini,后续视图沿用 Gemini,避免风格混杂和重复等待主模型超时。后端不再要求整包全成功才写入:单个视图失败时会保留已成功生成的主体图,返回“部分生成完成”,只有一张都没生成出来才返回错误。replace_views=true 时会替换同一视角旧图;删除接口会移除对应 subject asset 记录并删除本地 jpg 文件。 + 主体资产包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,再按 image_model_preference 选择自动链路、仅 GPT 或仅 Gemini,自动链路仍优先调用 gpt-image-2/images/generations 文字生图,日志会显示 image_refs=0;这里是参考重构生成套图,不是抠图、复制或 image-edit 复刻。卡通重构在后端额外加入原创卡通/插画主体约束,明确不输出真实人物复制 likeness。生成完成后,后端会把生成视图反推/写入 KeyElement.subject_consensus_brief,作为后续首尾帧的唯一主体身份文字依据。reconstruction_mode=same 仍保留旧 image-edit 路径,用于确实需要精确复刻且有授权的场景。每个 view 单独调用一次生图,明确禁止多视图拼图、contact sheet、多主体、多面板、标签或对比排版。单次图片请求受 IMAGE_REQUEST_TIMEOUT_SECONDS 控制,默认 60 秒;gpt-image-2 超时、429、5xx、DNS 或连接失败时可兜底 gemini-3-pro-image-preview,连续 2 次主模型上游类失败后 600 秒内短时熔断。仅当 image_model_preference=auto 时才启用兜底和熔断;用户显式选择 GPT 或 Gemini 时只走所选模型,方便已知某个上游不可用时直接切换。主体同一套图内一旦触发 Gemini,后续视图沿用 Gemini,避免风格混杂和重复等待主模型超时。主体 prompt 会要求从参考图继承性别、人种/肤色、年龄体态和角色气质等广义特征,但生成同一个全新主体;六视图必须保持同一脸部设定、发型、体态、服装类型、配色、材质、剪裁和配饰,不允许每个视角换衣服。后端不再要求整包全成功才写入:单个视图失败时会保留已成功生成的主体图,返回“部分生成完成”,只有一张都没生成出来才返回错误。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。 @@ -1113,6 +1113,19 @@ ProductRefStateItem {

变更记录

这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。

+
+
+

2026-05-20 · 转换层增加模型选择、提示词记忆和主体服装锁定

+ UI + Workflow + API +
+
+

问题:用户已经知道某个生图模型不可用时,需要能在转换层直接切换;同时主体 6 视图容易出现不同衣服、不同人设,提示词每次也要重复输入。

+

改动:SourceSubjectPipeline 新增自动 / GPT / Gemini 三档生图模型选择,偏好写入 localStorage["skg:subject-image-model:v1"] 并通过 GenerateSubjectAssetsReq.image_model_preference 传到后端;提示词输入会保存到 localStorage["skg:subject-prompt-memory:v1"],旧词解析成可点击 chip。后端 _image_model_candidates 支持显式模型偏好,auto 保留 gpt-image-2 主模型、Gemini 兜底和短时熔断,显式 GPT / Gemini 只走所选模型。

+

影响:主体重构默认继承参考图里的性别、人种/肤色、年龄体态和角色气质这些广义特征,但生成同一个全新主体;六视图 prompt 强制统一脸部设定、发型、体态、服装类型、配色、材质、剪裁和配饰,避免一套图里每张衣服都不同。

+
+

2026-05-19 · 生图增加 Gemini 故障兜底和短时熔断

diff --git a/web/components/ad-recreation-board.tsx b/web/components/ad-recreation-board.tsx index c644b88..1c1a2e8 100644 --- a/web/components/ad-recreation-board.tsx +++ b/web/components/ad-recreation-board.tsx @@ -28,6 +28,7 @@ import { type StoryboardScriptRewriteSegment, type StoryboardScene, type SubjectAsset, + type SubjectImageModelPreference, type SubjectProfilePreference, type SubjectKind, addElement, @@ -317,6 +318,15 @@ const RECONSTRUCTION_MODES: Array<{ value: SubjectReconstructionMode; label: str }, ] +const SUBJECT_IMAGE_MODEL_OPTIONS: Array<{ value: SubjectImageModelPreference; label: string; detail: string }> = [ + { value: "auto", label: "自动", detail: "GPT 失败才兜底" }, + { value: "gpt-image-2", label: "GPT", detail: "只用 gpt-image-2" }, + { value: "gemini-3-pro-image-preview", label: "Gemini", detail: "直接用 Gemini" }, +] +const SUBJECT_MODEL_MEMORY_KEY = "skg:subject-image-model:v1" +const SUBJECT_PROMPT_MEMORY_KEY = "skg:subject-prompt-memory:v1" +const SUBJECT_PROMPT_MEMORY_LIMIT = 28 + const SUBJECT_ASSET_SIZE = "2048" as const const SUBJECT_PROFILE_CATEGORIES: SubjectProfileCategory[] = [ @@ -639,6 +649,77 @@ function resolveSubjectProfile( } } +function emptySubjectPromptMemory(): Record { + return { realistic: [], cartoon: [], elements: [], custom: [] } +} + +function loadSubjectPromptMemory(): Record { + if (typeof window === "undefined") return emptySubjectPromptMemory() + try { + const parsed = JSON.parse(window.localStorage.getItem(SUBJECT_PROMPT_MEMORY_KEY) || "{}") as Partial> + const next = emptySubjectPromptMemory() + for (const mode of Object.keys(next) as SubjectReconstructionMode[]) { + next[mode] = Array.isArray(parsed[mode]) ? parsed[mode]!.filter(Boolean).slice(0, SUBJECT_PROMPT_MEMORY_LIMIT) : [] + } + return next + } catch { + return emptySubjectPromptMemory() + } +} + +function saveSubjectPromptMemory(memory: Record) { + if (typeof window === "undefined") return + try { + window.localStorage.setItem(SUBJECT_PROMPT_MEMORY_KEY, JSON.stringify(memory)) + } catch { + /* localStorage may be unavailable */ + } +} + +function loadSubjectImageModelPreference(): SubjectImageModelPreference { + if (typeof window === "undefined") return "auto" + const raw = window.localStorage.getItem(SUBJECT_MODEL_MEMORY_KEY) + return SUBJECT_IMAGE_MODEL_OPTIONS.some((item) => item.value === raw) ? raw as SubjectImageModelPreference : "auto" +} + +function saveSubjectImageModelPreference(value: SubjectImageModelPreference) { + if (typeof window === "undefined") return + try { + window.localStorage.setItem(SUBJECT_MODEL_MEMORY_KEY, value) + } catch { + /* localStorage may be unavailable */ + } +} + +function subjectPromptChipsFromText(text: string): string[] { + const normalized = text.replace(/[,。;;、\n]/g, ",").replace(/\s+/g, " ").trim() + const rawParts = normalized.split(",").map((item) => item.trim()).filter(Boolean) + const chips: string[] = [] + const add = (value: string) => { + const clean = value.replace(/^需要|^保持|^统一|^加上|^加入|^改成|^不要变/g, "").trim() + if (clean.length < 2 || clean.length > 22) return + if (!chips.includes(clean)) chips.push(clean) + } + for (const part of rawParts) { + add(part) + const matches = part.match(/(不要[^,,。;;、]{1,12}|同一套?[^,,。;;、]{1,10}|统一[^,,。;;、]{1,10}|白色[^,,。;;、]{1,10}|黑色[^,,。;;、]{1,10}|运动[^,,。;;、]{1,10}|亚洲|欧美|女性|男性|年轻|中年|短发|长发|马尾|背心|T恤|瑜伽服|运动装|商业广告感|高级感|科技感|可爱|极简)/g) + matches?.forEach(add) + } + return chips.slice(0, 14) +} + +function mergeSubjectPromptMemory(current: string[], text: string) { + const chips = subjectPromptChipsFromText(text) + return [...chips, ...current.filter((item) => !chips.includes(item))].slice(0, SUBJECT_PROMPT_MEMORY_LIMIT) +} + +function appendSubjectPromptChip(text: string, chip: string) { + const trimmed = text.trim() + if (!trimmed) return chip + if (trimmed.includes(chip)) return trimmed + return `${trimmed},${chip}` +} + function formatSeconds(raw?: number) { if (!raw || Number.isNaN(raw)) return "0.0s" return `${raw.toFixed(1)}s` @@ -1091,8 +1172,11 @@ function buildSimilarSubjectPrompt( const base = [ "Create a new similar but non-identical information-feed ad subject from the selected reference frames.", "Treat all selected frames as evidence for ONE same subject, not multiple different subjects.", - "Lock one consistent character bible before generating: same gender presentation, age range, body proportions, head shape, material, silhouette, commercial style, and visual identity across the full multi-view set.", + "Default casting rule: inherit the reference frames' broad gender presentation, regional/ethnic appearance category, skin-tone family, body-proportion category, and role energy unless the user explicitly overrides them.", + "Lock one consistent character bible before generating: same newly designed person or character, same gender presentation, age range, body proportions, face design, hair design, skin tone, material, silhouette, commercial style, and visual identity across the full multi-view set.", + "Lock one wardrobe bible before generating: same garment type, same color palette, same neckline, same sleeve or strap structure, same fabric/material, same fit, and same visible accessories across every view.", "If the user direction asks to change gender, age, or style, apply that single change uniformly to every view; never mix male/female, young/old, or multiple style identities inside one set.", + "Never change outfit between views. Do not switch clothing category from front to side to back.", "Keep the pose vocabulary, camera-readability, creator-ad energy, and commercial clarity, but do not copy the exact source identity, face, watermark, captions, platform UI, or pixels.", "This is for SKG neck-and-shoulder wearable massage device videos: keep neck, collarbone, shoulders, side neck, upper back, shoulder blades, and product placement area clean and visible.", "Output high-definition assets suitable for downstream video generation.", @@ -3203,9 +3287,11 @@ function SourceSubjectPipeline({ const [activeDropMode, setActiveDropMode] = useState(null) const [conversionFrameIndicesByMode, setConversionFrameIndicesByMode] = useState>(() => ({ ...EMPTY_RECONSTRUCTION_FRAME_MAP })) const [reconstructionDirections, setReconstructionDirections] = useState>(() => ({ ...DEFAULT_RECONSTRUCTION_DIRECTIONS })) + const [subjectImageModelPreference, setSubjectImageModelPreference] = useState(() => loadSubjectImageModelPreference()) + const [promptMemoryByMode, setPromptMemoryByMode] = useState>(() => loadSubjectPromptMemory()) const [cartoonStyle, setCartoonStyle] = useState("3d_animation") const [cartoonStyleOpen, setCartoonStyleOpen] = useState(false) - const [subjectBusyFor, setSubjectBusyFor] = useState<{ jobId: string; jobLabel: string; mode: SubjectReconstructionMode; viewCount: number; sourceCount: number; profileLabel: string } | null>(null) + const [subjectBusyFor, setSubjectBusyFor] = useState<{ jobId: string; jobLabel: string; mode: SubjectReconstructionMode; viewCount: number; sourceCount: number; profileLabel: string; modelLabel: string } | null>(null) const [subjectAssetBusy, setSubjectAssetBusy] = useState(null) const [expandedSubjectPackKey, setExpandedSubjectPackKey] = useState(null) const [lastSubjectProfile, setLastSubjectProfile] = useState(null) @@ -3305,6 +3391,14 @@ function SourceSubjectPipeline({ setExpandedSubjectPackKey(null) }, [job.id]) + useEffect(() => { + saveSubjectImageModelPreference(subjectImageModelPreference) + }, [subjectImageModelPreference]) + + useEffect(() => { + saveSubjectPromptMemory(promptMemoryByMode) + }, [promptMemoryByMode]) + useEffect(() => { if (expandedSubjectPackKey && !subjectAssetPacks.some((pack) => pack.key === expandedSubjectPackKey)) { setExpandedSubjectPackKey(null) @@ -3327,6 +3421,28 @@ function SourceSubjectPipeline({ return resolved } + const rememberPromptForMode = (mode: SubjectReconstructionMode, text = reconstructionDirections[mode]) => { + setPromptMemoryByMode((current) => ({ + ...current, + [mode]: mergeSubjectPromptMemory(current[mode] || [], text), + })) + } + + const applyPromptChip = (mode: SubjectReconstructionMode, chip: string) => { + setReconstructionDirections((current) => ({ + ...current, + [mode]: appendSubjectPromptChip(current[mode], chip), + })) + setPromptMemoryByMode((current) => ({ + ...current, + [mode]: [chip, ...(current[mode] || []).filter((item) => item !== chip)].slice(0, SUBJECT_PROMPT_MEMORY_LIMIT), + })) + } + + const subjectModelLabel = (value: SubjectImageModelPreference) => { + return SUBJECT_IMAGE_MODEL_OPTIONS.find((item) => item.value === value)?.label ?? "自动" + } + const generateSubjectPack = async (mode: SubjectReconstructionMode, sourceIndices = conversionFrameIndicesByMode[mode]) => { if (subjectBusyFor) { toast.warning("主体套图正在生成中,完成后再重生。") @@ -3354,6 +3470,7 @@ function SourceSubjectPipeline({ : buildSubjectProfileForRequest() const subjectStyle = reconstructionSubjectStyle(mode) const userDirection = buildReconstructionDirection(mode, reconstructionDirections[mode], cartoonStyle) + rememberPromptForMode(mode, reconstructionDirections[mode]) const modeName = reconstructionElementName(mode) setSubjectBusyFor({ jobId: requestJobId, @@ -3362,6 +3479,7 @@ function SourceSubjectPipeline({ viewCount: selectedSubjectViews.length, sourceCount: sourceFrames.length, profileLabel: requestProfile?.summary ?? "按自主描述", + modelLabel: subjectModelLabel(subjectImageModelPreference), }) try { let workingJob = job @@ -3391,6 +3509,7 @@ function SourceSubjectPipeline({ views: selectedSubjectViews, subject_profile: requestProfile?.payload ?? null, prompt: buildSimilarSubjectPrompt(subjectStyle, userDirection, null, requestProfile), + image_model_preference: subjectImageModelPreference, replace_views: false, pack_label: `${reconstructionModeConfig(mode).label} ${new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false })}`, pack_mode: mode, @@ -3454,6 +3573,7 @@ function SourceSubjectPipeline({ ? null : lastSubjectProfile ?? buildSubjectProfileForRequest() const subjectStyle = reconstructionSubjectStyle(mode) + rememberPromptForMode(mode, reconstructionDirections[mode]) const updated = await generateSubjectAssets(job.id, frame.index, element.id, { subject_kind: "living", subject_style: subjectStyle, @@ -3469,6 +3589,7 @@ function SourceSubjectPipeline({ null, requestProfile, ), + image_model_preference: subjectImageModelPreference, replace_views: true, pack_id: asset.pack_id ?? "", pack_label: asset.pack_label ?? "", @@ -3601,6 +3722,30 @@ function SourceSubjectPipeline({
+
+
+ 生图模型 + 只影响转换层主体套图 +
+
+ {SUBJECT_IMAGE_MODEL_OPTIONS.map((option) => ( + + ))} +
+
先拖入 1-3 张参考帧到对应方向,放好后再点击生成;系统只做参考重构,不复制原人、原脸或原画面。
@@ -3608,6 +3753,9 @@ function SourceSubjectPipeline({ {RECONSTRUCTION_MODES.map((modeConfig) => { const mode = modeConfig.value const modeFrames = conversionFramesByMode[mode] + const promptChips = [...subjectPromptChipsFromText(reconstructionDirections[mode]), ...(promptMemoryByMode[mode] || [])] + .filter((chip, index, list) => chip && list.indexOf(chip) === index) + .slice(0, 10) const dropActive = activeDropMode === mode const canGenerate = mode === "custom" ? Boolean(reconstructionDirections.custom.trim() || modeFrames.length) @@ -3715,10 +3863,26 @@ function SourceSubjectPipeline({