From 48d4002cbd6d0ffb53ca0bfaff170643af814a02 Mon Sep 17 00:00:00 2001 From: kang Date: Mon, 18 May 2026 15:59:56 +0800 Subject: [PATCH] feat: connect subject template library --- api/main.py | 192 ++++++++++++++++++- docs/source-analysis.html | 20 +- web/components/ad-recreation-board.tsx | 252 +++++++++++++++++++++---- web/lib/api.ts | 77 ++++++++ 4 files changed, 503 insertions(+), 38 deletions(-) diff --git a/api/main.py b/api/main.py index 7c3b3cd..54409f4 100644 --- a/api/main.py +++ b/api/main.py @@ -38,6 +38,10 @@ CHARACTER_LIBRARY_DIR = Path( os.getenv("CHARACTER_LIBRARY_DIR", Path(__file__).resolve().parent / "character_library" / "skg-characters") ).resolve() CHARACTER_LIBRARY_MANIFEST = CHARACTER_LIBRARY_DIR / "manifest.json" +SUBJECT_TEMPLATE_DIR = Path(os.getenv("SUBJECT_TEMPLATE_DIR", JOBS_DIR / "_subject_templates")).resolve() +SUBJECT_TEMPLATE_IMAGE_DIR = SUBJECT_TEMPLATE_DIR / "images" +SUBJECT_TEMPLATE_MANIFEST = SUBJECT_TEMPLATE_DIR / "manifest.json" +SUBJECT_TEMPLATE_IMAGE_DIR.mkdir(parents=True, exist_ok=True) LLM_BASE_URL = os.getenv("LLM_BASE_URL", "").strip() LLM_API_KEY = os.getenv("LLM_API_KEY", "").strip() @@ -452,6 +456,38 @@ class CharacterLibraryItem(BaseModel): images: list[CharacterLibraryImage] = Field(default_factory=list) +class SubjectTemplateImage(BaseModel): + id: str + view: str + label: str = "" + filename: str + url: str = "" + width: int = 0 + height: int = 0 + background: AssetBackground = "white" + quality: AssetQuality = "hd" + size: AssetSize = "source" + source_asset_id: str = "" + source_frame_indices: list[int] = Field(default_factory=list) + created_at: float = 0.0 + + +class SubjectTemplateItem(BaseModel): + id: str + name: str + description: str = "" + note: str = "" + source: Literal["database"] = "database" + source_job_id: str = "" + source_frame_idx: int = -1 + source_element_id: str = "" + subject_style: Literal["transparent_human", "source_actor"] = "transparent_human" + primary_image: str = "" + images: list[SubjectTemplateImage] = Field(default_factory=list) + created_at: float = 0.0 + updated_at: float = 0.0 + + class ProductFusionRegion(BaseModel): x: float = 0 y: float = 0 @@ -802,6 +838,50 @@ def character_library_file(filename: str) -> Path: return p +def load_subject_template_items() -> list[SubjectTemplateItem]: + if not SUBJECT_TEMPLATE_MANIFEST.exists(): + return [] + try: + data = json.loads(SUBJECT_TEMPLATE_MANIFEST.read_text(encoding="utf-8")) + items: list[SubjectTemplateItem] = [] + for raw in data.get("templates", []): + item = SubjectTemplateItem(**raw) + for image in item.images: + image.url = f"/subject-templates/images/{image.filename}" + items.append(item) + items.sort(key=lambda item: item.updated_at or item.created_at, reverse=True) + return items + except Exception as e: + raise HTTPException(500, f"subject template manifest invalid: {e}") + + +def save_subject_template_items(items: list[SubjectTemplateItem]) -> None: + SUBJECT_TEMPLATE_MANIFEST.parent.mkdir(parents=True, exist_ok=True) + SUBJECT_TEMPLATE_MANIFEST.write_text( + json.dumps({"templates": [item.model_dump() for item in items]}, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + +def find_subject_template_item(template_id: str) -> SubjectTemplateItem: + template_id = template_id.strip() + for item in load_subject_template_items(): + if item.id == template_id: + return item + raise HTTPException(404, "subject template not found") + + +def subject_template_image_file(filename: str) -> Path: + p = (SUBJECT_TEMPLATE_IMAGE_DIR / filename).resolve() + try: + p.relative_to(SUBJECT_TEMPLATE_IMAGE_DIR) + except ValueError: + raise HTTPException(400, "invalid subject template image path") + if not p.exists(): + raise HTTPException(404, "subject template image missing") + return p + + def storyboard_ref_url(job_id: str, ref: dict | None) -> str: if not ref: return "" @@ -3783,6 +3863,7 @@ class GenerateSubjectAssetsReq(BaseModel): source_frame_indices: list[int] | None = None views: list[str] | None = None character_id: str = "" + subject_template_id: str = "" subject_style: Literal["transparent_human", "source_actor"] = "transparent_human" reconstruction_mode: Literal["same", "similar"] = "same" prompt: str = "" @@ -4230,8 +4311,21 @@ def generate_subject_assets(job_id: str, idx: int, element_id: str, req: Generat character_reference_paths: list[Path] = [] character_reference_clause = "" character_label = "" + subject_template_id = (req.subject_template_id or "").strip() character_id = (req.character_id or "").strip() - if character_id: + if subject_template_id: + template = find_subject_template_item(subject_template_id) + character_label = template.name + for image in template.images[:10]: + character_reference_paths.append(subject_template_image_file(image.filename)) + character_reference_clause = ( + f"Selected reusable subject template from database: {template.name}. " + "Use these saved generated subject views as a high-quality creative direction and identity bible only; " + "do not copy pixels, file artifacts, exact pose, labels, or accidental defects. " + "Create a new innovative variation that keeps the same broad subject type, transparent wellness character language, " + "camera readability, shoulder/neck product compatibility, and commercial role. " + ) + elif character_id: character = find_character_library_item(character_id) character_label = character.name for image in character.images[:7]: @@ -4854,6 +4948,15 @@ class AnalyzeProductViewsReq(BaseModel): refs: list[dict] = Field(default_factory=list) +class SaveSubjectTemplateReq(BaseModel): + name: str + note: str = "" + frame_idx: int + element_id: str + asset_ids: list[str] = Field(default_factory=list) + subject_style: Literal["transparent_human", "source_actor"] = "transparent_human" + + @app.get("/product-library/skg", response_model=list[ProductLibraryItem]) def list_skg_product_library() -> list[ProductLibraryItem]: """内置 SKG 白底产品图库。来源是本地筛选后的产品图 manifest。""" @@ -4882,6 +4985,93 @@ def get_skg_character_library_image(filename: str): return FileResponse(p, media_type=media_type) +@app.get("/subject-templates", response_model=list[SubjectTemplateItem]) +def list_subject_templates() -> list[SubjectTemplateItem]: + """数据库化主体模板库。保存后的相似主体可被后续任务复用为创意参考。""" + return load_subject_template_items() + + +@app.get("/subject-templates/images/{filename:path}") +def get_subject_template_image(filename: str): + p = subject_template_image_file(filename) + return FileResponse(p, media_type="image/jpeg") + + +@app.post("/jobs/{job_id}/subject-templates", response_model=SubjectTemplateItem) +def save_subject_template(job_id: str, req: SaveSubjectTemplateReq) -> SubjectTemplateItem: + """把当前 job 里已确认的相似主体视图复制到主体模板库。""" + import time as _time + job = JOBS.get(job_id) + if not job: + raise HTTPException(404, "job not found") + name = req.name.strip() + if not name: + raise HTTPException(400, "template name required") + frame = _find_frame(job, req.frame_idx) + element = next((e for e in frame.elements if e.id == req.element_id), None) + if not element: + raise HTTPException(404, "element not found") + + requested_ids = [x.strip() for x in req.asset_ids if x.strip()] + selected_assets = [asset for asset in (element.subject_assets or []) if not requested_ids or asset.id in requested_ids] + if requested_ids: + selected_assets.sort(key=lambda asset: requested_ids.index(asset.id) if asset.id in requested_ids else 999) + else: + selected_assets.sort(key=lambda asset: asset.created_at, reverse=True) + if not selected_assets: + raise HTTPException(400, "no subject assets to save") + + template_id = f"subject-template-{uuid.uuid4().hex[:10]}" + template_dir = SUBJECT_TEMPLATE_IMAGE_DIR / template_id + template_dir.mkdir(parents=True, exist_ok=True) + now = _time.time() + images: list[SubjectTemplateImage] = [] + for asset in selected_assets: + src = job_dir(job_id) / "assets" / f"{asset.id}.jpg" + if not src.exists(): + continue + image_id = f"{asset.view}_{uuid.uuid4().hex[:8]}" + filename = f"{template_id}/{image_id}.jpg" + dst = SUBJECT_TEMPLATE_IMAGE_DIR / filename + shutil.copy2(src, dst) + images.append(SubjectTemplateImage( + id=image_id, + view=asset.view, + label=asset.label or asset.view, + filename=filename, + url=f"/subject-templates/images/{filename}", + width=asset.width, + height=asset.height, + background=asset.background, + quality=asset.quality, + size=asset.size, + source_asset_id=asset.id, + source_frame_indices=asset.source_frame_indices, + created_at=asset.created_at or now, + )) + if not images: + raise HTTPException(404, "subject asset files missing") + + primary = next((image.id for image in images if image.view == "front"), images[0].id) + item = SubjectTemplateItem( + id=template_id, + name=name, + description=req.note.strip(), + note=req.note.strip(), + source_job_id=job_id, + source_frame_idx=frame.index, + source_element_id=element.id, + subject_style=req.subject_style, + primary_image=primary, + images=images, + created_at=now, + updated_at=now, + ) + items = [item] + [existing for existing in load_subject_template_items() if existing.id != item.id] + save_subject_template_items(items) + return item + + def normalize_product_asset_image(src: Path, out: Path) -> dict: original_bytes = src.stat().st_size if src.exists() else 0 actions: list[str] = [] diff --git a/docs/source-analysis.html b/docs/source-analysis.html index bbe7d51..04535f2 100644 --- a/docs/source-analysis.html +++ b/docs/source-analysis.html @@ -594,6 +594,7 @@ web/app/globals.css全局主题变量、登录页视觉样式、ReactFlow 样式引用,以及本地开发态 nextjs-portal 遮挡隐藏规则。 web/app/page.tsx产品工作台主状态:jobs、activeJobId、生成任务状态;主渲染为全屏素材输入列 + 信息流广告复刻工作表;“开始分析”会把 job 放入并行素材分析队列,下载完成后触发 triggerTranscribe 解析音频,并触发 analyzeJob 自动抽 12 张参考帧,形成“音频文案路 + 视频视觉路”同步推进;底部吸附音频条不再从主界面渲染。 web/components/ad-recreation-board.tsx信息流广告复刻工作表:顶部由 buildWorkflowSteps 统一生成 01-09 流程顺序、状态和判定依据,WorkflowOrderBar 展示完整顺序,WorkflowStepBadge / PipelineLane / 分镜列标题共用同一套编号。左侧素材输入只负责链接/上传和任务切换,不再重复放横版原视频预览;右侧顶部用“音频文案、抽帧参考、相似主体、产品素材池”四个状态条显示后台并行进度。源视频工作区展示视频下载状态和默认折叠的文案依据。音频解析结果改成默认折叠的辅助信息,展开后同一行看讲话人/节奏/背景音;主工作区左侧放大为按 9:16 显示的竖版原视频播放器,播放器内覆盖“当前点抽帧”,按当前播放秒数手动补参考帧;右侧上方是音频波形 / 切点参考,下方左侧是参考帧池,右侧是逐句时间轴;下一行只保留“相似主体 / 主体模板”。音频波形用参考图式的连续灰色包络显示响度、停顿和密集爆点,顶部同时显示当前播放秒数、总时长和鼠标指针停点秒数。视频播放时通过 requestAnimationFrame 平滑驱动波形播放线,同时同步高亮并滚动当前句;点击音频波形或字幕行会跳转原视频时间。逐句时间轴左侧参考帧池的主入口是“自动抽帧 12 张”,一键按动作峰值目标重新抽取 12 张源视频参考帧,优先抓手势、表情变化、节奏点和镜头变化;缩略图按竖版完整比例显示不裁切并用更多列紧凑铺开,点选状态直接叠在参考帧池缩略图上,鼠标停留会通过固定浮层放大展示完整帧。“生成 10 张高清图”放在下方相似主体白底视图区,不和抽参考按钮平齐;如果用户没有勾选帧,默认把全部关键帧作为主体参考,勾选后只传已选帧;生成区可在“透明骨架 / 普通真人”之间切换,可选择桌面导入的 5 套内置形象作为创意方向,并可填写统一主体方向,例如年轻女性、更运动、更高级。关键帧和相似主体白底视图都用更小的竖版缩略图密排;白底视图只展示每个 view 的最新一张,缩略图上提供“重新生成这一张”和“删除这一张”,单张重生会用 replace_views=true 替换同一视角。前端调用 generateSubjectAssets 时按主体类型传 subject_style=transparent_humansource_actor,按需传 character_id,并使用 reconstruction_mode=similar;后端会把关键帧和内置形象视为同一个主体的创意证据,并锁定同一性别表现、年龄段、体型、材质、风格和视觉身份,同时生成全身多视角 + 肩颈正/左右近景 + 后颈肩背特写,避免整套图出现男女性别、老少年龄或样式混杂。音频结果下方是信息流复刻分镜工作台:顶部产品参考区是“同一产品素材池”,不限量上传产品图,不做不同产品身份判断;上传原图推荐长边 1200-2000px、短边至少 600px,但后端会统一生成最长边 1600px、JPEG 92 的 AI 工作副本,并回显尺寸、自动转换和风险标注;上传后按“套在脖子上的 U 形肩颈按摩仪”进行同一产品批量识别,左/右按佩戴者身体左右、上/下按佩戴方向,额外标注内外侧、开口方向、局部结构点、背景类型、用途标签、生成风险和备注,用户只检查备注,鼠标悬停通过固定浮层显示大图预览,能盖过滚动容器和分镜框架;缺视角补图失败时保留重试入口。脚本区在分镜行上方提供“作者想法”和“整片改写”,每行新口播文案可直接编辑并可单段 AI 改写,分镜时间和原内容列压缩为窄摘要列,新口播列进一步收窄,把横向空间留给画面规划和首尾帧。每条音频分镜纵向排列,行内从左到右串起原内容、新口播文案、画面规划/产品融入和历史候选视频槽;画面规划区先选择镜头类型(人物/情绪、人物+产品、产品特写、场景过渡),再用人物/产品开关、首帧规划、尾帧规划和产品出现方式决定这一条到底需不需要产品图或相似主体参考。当前主流程暂停直接调用视频模型,不再提供“生成本条 · Seedance”或“一键提交全部”视频入口;行内新增“首尾帧闸门”,分别显示/生成首帧和尾帧,旧 keyframe 类型首尾帧会被忽略,只认真正的 asset 首尾帧。生成首尾帧时调用 generateSceneAsset,先按人物描述、镜头类型、首尾状态和产品佩戴需求,从相似主体 6/10 视图里自动挑选最多 5 张最相关主体视角,再传入 subject_images 和该行自动挑选的产品图 product_images;关键帧只作为前置主体重构证据和行数据承载位置,不再作为后续视频首尾帧参考。视频候选槽只展示历史候选和待生成占位,按钮改为“保存本条规划 / 保存全部规划”。只有该行勾选“产品”时,首尾帧生成才会从产品素材池按分镜角色、视角优先级、用途标签、置信度和风险自动挑选最多 6 张相关产品图;未勾选产品时不会把产品图提交给首尾帧/后续生视频模型。只有该行勾选“人物”时,才会传按需筛选后的相似主体参考图;否则 prompt 会明确禁止强行添加主角式透明骨架人,后端也不会再给产品特写强加透明骨架人约束。ModelTrace 会在音频解析、产品识别/补图、相似主体高清视图包、脚本改写等入口旁直接展示模型名;所有生图入口都显示并使用 gpt-image-2,没有其他图片模型 fallback;点击后用固定浮层展示模型链路、输入输出和回退逻辑。旧分镜卡、抽帧控制和视频生成组件仍保留在文件里,但当前主路径不渲染。 + SourceReferenceBuildPanel“相似主体 / 主体模板”当前承担主体资产生成和主体模板复用的前端入口:面板先分成“主体模板库”和“本次生成 / 入库草稿”。模板库优先读取 GET /subject-templates 数据库模板,并保留 GET /character-library/skg 的内置形象作为策划初始模板;入库草稿显示本次来源、生成数量、模板命名和备注,点击保存会调用 saveSubjectTemplate 把当前主体视图复制到主体模板库。选择数据库模板后,后续 generateSubjectAssets 会传 subject_template_id,让后端以已保存模板视图作为新主体参考。 web/components/media-asset-tile.tsx项目内媒体素材缩略图基底组件:图片、视频、抽帧、产品图、相似主体图、首尾帧和视频候选默认从这里获得统一交互。组件负责缩略图显示、顶层固定浮层 hover 放大、删除按钮、重新生成等操作按钮、忙碌遮罩和图片/视频共用预览,避免每个新板块重复手写不同的媒体交互。 web/app/login/page.tsx生产登录页:访问账号/访问密钥表单、保持登录、错误/成功状态;当前只在原版 Digital Oasis 动态背景上叠加一个组合登录框,桌面端左侧是动态角色,右侧是图标化登录表单;面板左上角展示官网 SKG 字标和中文“营销内容工作台”系统标识。 web/app/login/layout.tsx登录路由专属 layout:覆盖全站默认网页标题和描述为空,避免 /login 继承工作台 metadata 后在页面源码里继续出现登录界面文字以外的文案。 @@ -633,7 +634,7 @@ web/app/page.tsx -> 信息流广告复刻工作表:web/components/ad-recreation-board.tsx -> 开始分析:创建/激活 job → 下载完成后并行触发视频视觉路 analyzeJob 与音频文案路 triggerTranscribe -> WorkflowOrderBar:01 素材输入 → 02 源视频下载 → 03 音频文案 → 04 抽帧参考 → 05 相似主体 → 06 产品素材池 → 07 分镜文案 → 08 画面首尾帧 → 09 视频候选;每步从 buildWorkflowSteps 取判定依据和状态 - -> 左侧素材输入列 + 右侧 03-06 状态条 + 默认折叠的文案依据 + 源视频工作区(音频解析结果默认折叠,竖版 9:16 原视频播放器放大并内置当前点抽帧,右侧上方连续响度波形显示当前/总时长/指针停点,右侧下方左侧是参考帧池,右侧是逐句时间轴联动滚动;参考帧池缩略图自身显示是否已选,不再单独重复显示已选关键帧;下方只保留相似主体 / 主体模板和相似主体高清视图包;不勾选帧则默认用全部帧,勾选后只用已选帧,可叠加 5 套内置形象) + -> 左侧素材输入列 + 右侧 03-06 状态条 + 默认折叠的文案依据 + 源视频工作区(音频解析结果默认折叠,竖版 9:16 原视频播放器放大并内置当前点抽帧,右侧上方连续响度波形显示当前/总时长/指针停点,右侧下方左侧是参考帧池,右侧是逐句时间轴联动滚动;参考帧池缩略图自身显示是否已选,不再单独重复显示已选关键帧;下方只保留相似主体 / 主体模板和相似主体高清视图包;不勾选帧则默认用全部帧,勾选后只用已选帧,可叠加 5 套内置形象;主体模板区分为模板库与本次生成 / 入库草稿,数据库接口未完成前只允许命名和备注,不提交保存) -> 信息流复刻分镜工作台:06 同一产品素材池不限量上传 → 自动识别视角 / 背景 / 用途 / 风险 → 人工检查备注 → 07 逐句时间轴 / 原内容 / 新口播文案 → 08 画面规划与产品融入(镜头类型、人物描述、人物/产品开关、首帧、尾帧、产品出现方式)→ 首尾帧闸门:按需求选择主体视角 + 产品素材生成首帧/尾帧 → 保存规划 → 09 历史候选视频槽(当前不直接批量提交视频) -> 底部音频条:不再渲染,音频结果集中到右侧工作表 -> 旧节点/深度素材面板:web/components/nodes/index.tsx、web/components/lightbox.tsx、web/components/storyboard-workbench.tsx(底层保留,当前不作为主入口) @@ -657,7 +658,7 @@ api/main.py
你看到的区域音频解析结果表
主要源码AudioIntakePanel / SourceReferenceBuildPanel in web/components/ad-recreation-board.tsx;关键帧和相似主体缩略图复用 MediaAssetTile;后端复用 triggerTranscribeAudioScriptanalyzeJobaddManualFramedeleteFramegenerateSubjectAssets
-
适合怎么描述“竖版原视频尺寸、播放器内当前播放点手动抽帧、自动抽帧 12 张入口、关键帧删除、相似主体高清视图包、内置形象选择、透明骨架/普通真人主体类型、连续响度波形、逐句时间轴滚动、高亮和跳转联动还需要怎么调整”。
+
适合怎么描述“竖版原视频尺寸、播放器内当前播放点手动抽帧、自动抽帧 12 张入口、关键帧删除、相似主体高清视图包、主体模板库、生成主体命名入库草稿、透明骨架/普通真人主体类型、连续响度波形、逐句时间轴滚动、高亮和跳转联动还需要怎么调整”。
你看到的区域信息流复刻分镜工作台
@@ -905,7 +906,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,如果用户手动选择了关键帧则只传已选帧,也可传 character_id 选择 5 套内置透明骨架形象之一。当前源视频工作区支持 subject_style=transparent_humansource_actor 两种相似主体:透明骨架人会保持透明/半透明皮肤包裹可见白色骨架,普通真人会保持正常广告演员方向。两种模式都使用 reconstruction_mode=similar,后端最多把 10 张参考图作为独立 image[] 提交给 gpt-image-2,生成默认 10 张 2048 高清白底图:正面、左右 45、左右侧、背面、肩颈正/左右近景、后颈肩背特写。Prompt 明确这是肩颈按摩设备视频素材,要求脖颈、锁骨、肩线、上背和肩胛区域清晰无遮挡;内置形象只作为创意方向,不照抄。后端强制使用 gpt-image-2,不再接受前端或环境变量切到其他图片模型,也不做图片模型 fallback;后端会加身份锁定约束,统一性别表现、年龄段、体型、材质、风格和视觉身份,避免整套图混成不同人物。如果参考帧是竖屏,prompt 会明确要求竖版 9:16 风格画布,落盘也按源帧纵横比归一化。前端白底视图缩略图和关键帧一样,鼠标停留会用顶层浮层放大预览,点击仍打开原图;后端每个 view 单独调用一次生图,并明确禁止多视图拼图、contact sheet、多主体、多面板、标签或对比排版,保证一个视角一张照片。图片调用走统一 ai_http_client,可用 AI_HTTP_PROXY / IMAGE_HTTP_PROXY 处理本地 launchd 代理继承问题;网络/DNS 失败返回 503 并提示检查代理配置。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,如果用户手动选择了关键帧则只传已选帧,也可传 character_id 选择 5 套内置透明骨架形象之一,或传 subject_template_id 使用已保存的主体模板库视图。当前源视频工作区支持 subject_style=transparent_humansource_actor 两种相似主体:透明骨架人会保持透明/半透明皮肤包裹可见白色骨架,普通真人会保持正常广告演员方向。两种模式都使用 reconstruction_mode=similar,后端最多把 10 张参考图作为独立 image[] 提交给 gpt-image-2,生成默认 10 张 2048 高清白底图:正面、左右 45、左右侧、背面、肩颈正/左右近景、后颈肩背特写。Prompt 明确这是肩颈按摩设备视频素材,要求脖颈、锁骨、肩线、上背和肩胛区域清晰无遮挡;内置形象和数据库模板都只作为创意方向,不照抄。后端强制使用 gpt-image-2,不再接受前端或环境变量切到其他图片模型,也不做图片模型 fallback;后端会加身份锁定约束,统一性别表现、年龄段、体型、材质、风格和视觉身份,避免整套图混成不同人物。如果参考帧是竖屏,prompt 会明确要求竖版 9:16 风格画布,落盘也按源帧纵横比归一化。前端白底视图缩略图和关键帧一样,鼠标停留会用顶层浮层放大预览,点击仍打开原图;后端每个 view 单独调用一次生图,并明确禁止多视图拼图、contact sheet、多主体、多面板、标签或对比排版,保证一个视角一张照片。图片调用走统一 ai_http_client,可用 AI_HTTP_PROXY / IMAGE_HTTP_PROXY 处理本地 launchd 代理继承问题;网络/DNS 失败返回 503 并提示检查代理配置。replace_views=true 时会替换同一视角旧图;删除接口会移除对应 subject asset 记录并删除本地 jpg 文件。 首尾帧资产POST /frames/{idx}/scene-assetgenerateSceneAsset同一接口兼容旧场景图和新首尾帧;当前信息流复刻流程传 asset_role=first_frame/last_framesubject_imagesproduct_images。后端优先把相似主体白底视图与产品素材拼成 asset contact sheet 给 gpt-image-2 做图像编辑,关键帧只作为行数据承载位置。生成结果保存在 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 持久化。 @@ -913,6 +914,7 @@ ProductRefStateItem { 产品视角识别POST /jobs/{id}/assets/product-views/analyzeanalyzeProductViews读取同一产品素材池,按批次把多张图一次性提交给 PRODUCT_VIEW_MODEL=gpt-image-2 做视角标注,不限制只看前 6 张;识别对象被固定为套在脖子上的 U 形肩颈按摩仪。返回 viewbackgrounduse_tagsorientationlandmarks、中文备注、生成风险和置信度;orientation 明确佩戴者左/右、上/下、内外侧和开口方向对应图中哪边,避免把图片左右误当产品左右。前端不再要求用户手动选择视角,也不做不同产品身份判断。 产品缺角度补图POST /jobs/{id}/assets/product-anglegenerateProductAngleAsset用当前同一产品素材池作为参考,通过 gpt-image-2 自动补全缺失视角,输出新的 ImageRef(kind="asset")。前端不再固定传第一张图,而是按目标视角给已上传/已标注参考图打分,优先选择真实上传图、目标相邻视角、侧厚/触点/底部对应用途标签和低风险高置信图,最多传 6 张;后端通过 /images/edits multipart 的多张 image[] 直接提交给 gpt-image-2,不再把参考图拼成一张板,降低模型误解成拼图/多产品的概率。Prompt 会约束白底产品图、左右非对称、厚度、内侧触点和肩颈真实佩戴比例,并禁止输出拼图/多产品;遇到上游 429 / saturated 会按退避节奏重试,最终仍失败时返回 503 和可读提示;遇到 DNS / ConnectError 也返回 503,并提示配置 AI_HTTP_PROXY / IMAGE_HTTP_PROXY。 角色库GET /character-library/skglistCharacterLibrary读取内置 5 个透明骨架人角色 manifest,每个角色含正面、左右 45 度、侧面、背面、半身近景和背部特写 7 张参考图。 + 主体模板库GET /subject-templates
GET /subject-templates/images/{filename}
POST /jobs/{id}/subject-templateslistSubjectTemplates
subjectTemplateImageUrl
saveSubjectTemplate数据库化可复用主体库。前端左侧模板库优先展示这里保存的主体模板;右侧“本次生成 / 入库草稿”会把当前 job 的相似主体白底视图按名称、备注、主体类型、原 job/frame/element 和 asset 列表复制到 JOBS_DIR/_subject_templates,以后生成相似主体时通过 subject_template_id 作为参考图来源。 角色图入库到 jobPOST /jobs/{id}/assets/character-librarycopyCharacterLibraryAssets把所选角色的 7 张参考图复制为当前 job asset,返回 subject_images,产品融合生成视频时作为人物身份参考图提交。 产品融合引导图POST /jobs/{id}/product-fusion/guidecreateProductFusionGuide旧流程兼容接口:读取产品图和白底人物图,按 product_region 合成位置引导图。当前内置角色 + 产品 + 描述流程不再主动调用它。 产品融合描述词POST /jobs/{id}/product-fusion/descriptionsgenerateProductFusionDescriptions兼容接口:可生成产品融合动作描述库。当前前端默认直接用本地 36 条镜头语言模板预填 6 行镜头,并通过“换一组”按钮按 6 条一组轮换。 @@ -1021,6 +1023,18 @@ ProductRefStateItem {

变更记录

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

+
+
+

2026-05-18 · 相似主体区接入主体模板数据库

+ UI + Workflow +
+
+

问题:“相似主体 / 主体模板”之前把内置模板选择和本次生成结果混在同一块里;后端主体数据库可用后,前端需要真的支持“已有模板可复用、新生成主体可人工命名后入库”。

+

改动:SourceReferenceBuildPanel 拆成“主体模板库”和“本次生成 / 入库草稿”两块:左侧同时读取 GET /subject-templates 数据库模板和 GET /character-library/skg 内置形象;右侧可填写模板名称、备注,并通过 POST /jobs/{id}/subject-templates 把本次生成主体视图保存入库。generateSubjectAssets 新增 subject_template_id,可用数据库模板继续生成新的相似主体。

+

影响:后续主体复用流程应理解为:先从数据库模板库选已有形象或用源视频关键帧生成新主体;新主体只有在人工确认、命名和备注后,才进入主体模板库供其他二创任务复用。

+
+

2026-05-18 · 去除参考帧池右侧已选重复栏

diff --git a/web/components/ad-recreation-board.tsx b/web/components/ad-recreation-board.tsx index 00cb6a0..1d92c54 100644 --- a/web/components/ad-recreation-board.tsx +++ b/web/components/ad-recreation-board.tsx @@ -14,6 +14,7 @@ import { type GeneratedVideo, type ImageRef, type CharacterLibraryItem, + type SubjectTemplateItem, type Job, type KeyElement, type KeyFrame, @@ -39,11 +40,14 @@ import { getRuntimeHealth, hasCutout, listCharacterLibrary, + listSubjectTemplates, representativeCutoutUrl, resolveImageRefUrl, rewriteStoryboardScript, + saveSubjectTemplate, saveProductRefs, sourceAudioUrl, + subjectTemplateImageUrl, updateStoryboard, uploadStoryboardAsset, videoUrl, @@ -485,7 +489,9 @@ function findSimilarActorSource(preferredFrames: KeyFrame[], allFrames: KeyFrame return null } -function buildSimilarSubjectPrompt(subjectStyle: SubjectStyleMode, direction: string, selectedCharacter?: CharacterLibraryItem | null) { +type SubjectTemplatePromptSource = { name: string; sourceLabel: string } | null + +function buildSimilarSubjectPrompt(subjectStyle: SubjectStyleMode, direction: string, selectedTemplate?: SubjectTemplatePromptSource) { 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.", @@ -495,10 +501,10 @@ function buildSimilarSubjectPrompt(subjectStyle: SubjectStyleMode, direction: st "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.", ] - if (selectedCharacter) { + if (selectedTemplate) { base.push( - `Built-in creative character selected: ${selectedCharacter.name}.`, - "Use the built-in images as planned creative direction only; generate an innovative variation, not a duplicate of that character pack.", + `Creative subject template selected: ${selectedTemplate.name} (${selectedTemplate.sourceLabel}).`, + "Use the template images as planned creative direction only; generate an innovative variation, not a duplicate of that subject pack.", ) } if (subjectStyle === "transparent_human") { @@ -523,7 +529,7 @@ function subjectAssetUrl(job: Job, asset: SubjectAsset) { return apiAssetUrl(asset.url) || resolveImageRefUrl(job.id, { kind: "asset", frame_idx: 0, element_id: asset.id }) } -function characterPreviewImage(character?: CharacterLibraryItem | null) { +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) ?? character.images.find((image) => image.view === "front") @@ -2100,6 +2106,12 @@ function SourceReferenceBuildPanel({ const [subjectDirection, setSubjectDirection] = useState("") const [characterLibrary, setCharacterLibrary] = useState([]) const [selectedCharacterId, setSelectedCharacterId] = useState("") + const [subjectTemplateLibrary, setSubjectTemplateLibrary] = useState([]) + const [selectedSubjectTemplateId, setSelectedSubjectTemplateId] = useState("") + const [templateLibraryBusy, setTemplateLibraryBusy] = useState(false) + const [templateSaveBusy, setTemplateSaveBusy] = useState(false) + const [templateDraftName, setTemplateDraftName] = useState("") + const [templateDraftNote, setTemplateDraftNote] = useState("") const frames = useMemo(() => [...job.frames].sort((a, b) => a.timestamp - b.timestamp), [job.frames]) const selectedReferenceFrames = useMemo( () => frames.filter((frame) => selectedFrames.has(frame.index)), @@ -2117,6 +2129,15 @@ function SourceReferenceBuildPanel({ () => characterLibrary.find((character) => character.id === selectedCharacterId) ?? null, [characterLibrary, selectedCharacterId], ) + const selectedSubjectTemplate = useMemo( + () => subjectTemplateLibrary.find((template) => template.id === selectedSubjectTemplateId) ?? null, + [subjectTemplateLibrary, selectedSubjectTemplateId], + ) + const selectedTemplatePrompt = selectedSubjectTemplate + ? { name: selectedSubjectTemplate.name, sourceLabel: "数据库主体模板" } + : selectedCharacter + ? { name: selectedCharacter.name, sourceLabel: "内置策划形象" } + : null const visibleActorAssets = useMemo(() => { const latestByView = new Map() for (const asset of actorAssets) { @@ -2136,19 +2157,47 @@ function SourceReferenceBuildPanel({ : frames.length ? `默认使用全部 ${frames.length} 张参考帧` : "待抽帧" + const templateSaveHint = visibleActorAssets.length + ? templateDraftName.trim() + ? "保存后会进入左侧主体模板库,后续任务可直接复用" + : "先给这套主体命名,再保存到主体模板库" + : "先生成本次主体视图,再决定是否入库" + const templateSourceLabel = selectedSubjectTemplate + ? `${selectedSubjectTemplate.name} · 数据库模板` + : selectedCharacter + ? `${selectedCharacter.name} · 模板参考` + : "源视频关键帧 · 相似创新" + + const loadSubjectTemplateLibrary = async (silent = false) => { + setTemplateLibraryBusy(true) + try { + const items = await listSubjectTemplates() + setSubjectTemplateLibrary(items) + } catch (e) { + if (!silent) toast.error("主体模板库读取失败:" + (e instanceof Error ? e.message : String(e))) + } finally { + setTemplateLibraryBusy(false) + } + } useEffect(() => { let cancelled = false - listCharacterLibrary() - .then((items) => { - if (!cancelled) setCharacterLibrary(items) - }) - .catch((e) => { - if (!cancelled) toast.error("内置形象读取失败:" + (e instanceof Error ? e.message : String(e))) + Promise.allSettled([listCharacterLibrary(), listSubjectTemplates()]) + .then(([characters, templates]) => { + if (cancelled) return + if (characters.status === "fulfilled") setCharacterLibrary(characters.value) + else toast.error("内置形象读取失败:" + (characters.reason instanceof Error ? characters.reason.message : String(characters.reason))) + if (templates.status === "fulfilled") setSubjectTemplateLibrary(templates.value) + else toast.error("主体模板库读取失败:" + (templates.reason instanceof Error ? templates.reason.message : String(templates.reason))) }) return () => { cancelled = true } }, []) + useEffect(() => { + setTemplateDraftName("") + setTemplateDraftNote("") + }, [job.id]) + const generateSimilarActor = async () => { if (!frames.length) { toast.warning("请先自动抽帧 12 张,或在原版视频上手动补帧。") @@ -2163,11 +2212,11 @@ function SourceReferenceBuildPanel({ let element = workingFrame.elements?.find(isSimilarActorElement) if (!element) { workingJob = await addElement(job.id, baseFrame.index, { - name_zh: selectedCharacter - ? `相似透明骨架主体 · ${selectedCharacter.name}` + name_zh: selectedTemplatePrompt + ? `相似透明骨架主体 · ${selectedTemplatePrompt.name}` : subjectStyle === "transparent_human" ? "相似透明骨架主体" : "相似广告主角", - name_en: selectedCharacter - ? `similar innovative transparent skeleton humanoid subject based on ${selectedCharacter.name}` + name_en: selectedTemplatePrompt + ? `similar innovative transparent skeleton humanoid subject based on ${selectedTemplatePrompt.name}` : subjectStyle === "transparent_human" ? "similar transparent skeleton humanoid subject" : "similar ad actor", position: "source-video main subject selected from global keyframes", source: "manual", @@ -2188,7 +2237,8 @@ function SourceReferenceBuildPanel({ source_frame_indices: subjectReferenceFrames.slice(0, 12).map((frame) => frame.index), views: SUBJECT_ASSET_VIEWS.map((view) => view.value), character_id: selectedCharacterId, - prompt: buildSimilarSubjectPrompt(subjectStyle, subjectDirection, selectedCharacter), + subject_template_id: selectedSubjectTemplateId, + prompt: buildSimilarSubjectPrompt(subjectStyle, subjectDirection, selectedTemplatePrompt), replace_views: true, }) onJobUpdate(updated) @@ -2216,7 +2266,8 @@ function SourceReferenceBuildPanel({ source_frame_indices: sourceIndices, views: [asset.view], character_id: selectedCharacterId, - prompt: buildSimilarSubjectPrompt(subjectStyle, subjectDirection, selectedCharacter), + subject_template_id: selectedSubjectTemplateId, + prompt: buildSimilarSubjectPrompt(subjectStyle, subjectDirection, selectedTemplatePrompt), replace_views: true, }) onJobUpdate(updated) @@ -2242,6 +2293,39 @@ function SourceReferenceBuildPanel({ } } + const saveGeneratedSubjectTemplate = async () => { + if (!actorSource || !visibleActorAssets.length) { + toast.warning("请先生成相似主体视图。") + return + } + const name = templateDraftName.trim() + if (!name) { + toast.warning("请先给这套主体模板命名。") + return + } + setTemplateSaveBusy(true) + try { + const item = await saveSubjectTemplate(job.id, { + name, + note: templateDraftNote.trim(), + frame_idx: actorSource.frame.index, + element_id: actorSource.element.id, + asset_ids: visibleActorAssets.map((asset) => asset.id), + subject_style: subjectStyle, + }) + setSubjectTemplateLibrary((items) => [item, ...items.filter((template) => template.id !== item.id)]) + setSelectedSubjectTemplateId(item.id) + setSelectedCharacterId("") + setTemplateDraftName("") + setTemplateDraftNote("") + toast.success("已保存到主体模板库") + } catch (e) { + toast.error("保存主体模板失败:" + (e instanceof Error ? e.message : String(e))) + } finally { + setTemplateSaveBusy(false) + } + } + return (
@@ -2253,31 +2337,63 @@ function SourceReferenceBuildPanel({
-
-
-
- 相似主体白底视图 - -
- 内置形象只做创意参考,不照抄 -
- -
-
- 内置形象选择 - {selectedCharacter ? `${selectedCharacter.name} · ${selectedCharacter.images.length} 张参考` : "源视频主角相似创新"} +
+
+
+
+
主体模板库
+
数据库模板优先复用;内置形象只作为初始策划模板。
+
+
+ {subjectTemplateLibrary.map((template) => { + const preview = characterPreviewImage(template) + const active = selectedSubjectTemplateId === template.id + return ( + + ) + })} {characterLibrary.map((character) => { const preview = characterPreviewImage(character) const active = selectedCharacterId === character.id @@ -2287,6 +2403,7 @@ function SourceReferenceBuildPanel({ type="button" onClick={() => { setSelectedCharacterId(character.id) + setSelectedSubjectTemplateId("") setSubjectStyle("transparent_human") }} className={`group flex min-h-[58px] items-center gap-1.5 rounded-md border px-1.5 py-1 text-left transition ${ @@ -2298,13 +2415,26 @@ function SourceReferenceBuildPanel({ {character.name} - 7 图参考 + 内置 · 7 图 ) })}
- {selectedCharacter?.images?.length ? ( + {!subjectTemplateLibrary.length ? ( +
+ 数据库暂未保存主体。生成满意的相似主体后,在右侧命名并保存,后续会出现在这里。 +
+ ) : null} + {selectedSubjectTemplate?.images?.length ? ( +
+ {selectedSubjectTemplate.images.slice(0, 10).map((image) => ( +
+ {image.label} +
+ ))} +
+ ) : selectedCharacter?.images?.length ? (
{selectedCharacter.images.slice(0, 7).map((image) => (
@@ -2315,6 +2445,60 @@ function SourceReferenceBuildPanel({ ) : null}
+
+
+
+
+ 本次生成 / 入库草稿 + +
+
{templateSourceLabel} · {visibleActorAssets.length}/{SUBJECT_ASSET_VIEWS.length} 张
+
+ + {visibleActorAssets.length ? "可命名待入库" : "未生成"} + +
+
+ setTemplateDraftName(event.target.value)} + placeholder="模板命名:如透明骨架女性 01" + className="h-7 rounded-md border border-white/10 bg-black/35 px-2 text-[10.5px] text-white outline-none placeholder:text-white/28 focus:border-cyan-300/50" + /> +