diff --git a/api/main.py b/api/main.py
index 0e0b234..b1a0215 100644
--- a/api/main.py
+++ b/api/main.py
@@ -3948,6 +3948,20 @@ class GenerateSceneAssetReq(BaseModel):
product_images: list[dict] = Field(default_factory=list)
+class SubjectProfilePreference(BaseModel):
+ mode: Literal["random", "manual"] = "random"
+ gender: str = ""
+ age: str = ""
+ wardrobe: str = ""
+ region_ethnicity: str = ""
+ skin_tone: str = ""
+ body: str = ""
+ hair: str = ""
+ mood: str = ""
+ resolved_summary: str = ""
+ prompt_summary: str = ""
+
+
class GenerateSubjectAssetsReq(BaseModel):
subject_kind: SubjectKind = "object"
background: AssetBackground = "white"
@@ -3959,10 +3973,43 @@ class GenerateSubjectAssetsReq(BaseModel):
subject_template_id: str = ""
subject_style: Literal["transparent_human", "source_actor"] = "transparent_human"
reconstruction_mode: Literal["same", "similar"] = "same"
+ subject_profile: SubjectProfilePreference | None = None
prompt: str = ""
replace_views: bool = False
+def _subject_profile_prompt_clause(profile: SubjectProfilePreference | None) -> str:
+ if not profile:
+ return ""
+ prompt_summary = (profile.prompt_summary or "").strip()
+ resolved_summary = (profile.resolved_summary or "").strip()
+ if prompt_summary:
+ body = prompt_summary[:1400]
+ else:
+ parts = [
+ ("gender presentation", profile.gender),
+ ("age range", profile.age),
+ ("wardrobe style", profile.wardrobe),
+ ("regional/ethnic appearance cues", profile.region_ethnicity),
+ ("skin tone", profile.skin_tone),
+ ("body proportion", profile.body),
+ ("hair style", profile.hair),
+ ("commercial mood", profile.mood),
+ ]
+ body = "; ".join(f"{name}: {value.strip()}" for name, value in parts if value and value.strip())[:1400]
+ if not body and not resolved_summary:
+ return ""
+ mode = "random-composed" if profile.mode == "random" else "manually selected"
+ resolved = f" UI summary: {resolved_summary[:700]}." if resolved_summary else ""
+ return (
+ f"Structured subject casting profile ({mode}, locked for this request): {body}. "
+ "This profile overrides ambiguous source/template traits for gender presentation, age range, wardrobe, regional/ethnic appearance cues, skin tone, body proportion, hair, and commercial mood. "
+ "Apply the same profile uniformly to every requested view; do not mix different genders, ages, skin tones, wardrobes, or character identities inside the pack."
+ + resolved
+ + " "
+ )
+
+
class UpdateProductRefsReq(BaseModel):
items: list[dict] = Field(default_factory=list)
@@ -4490,6 +4537,7 @@ def generate_subject_assets(job_id: str, idx: int, element_id: str, req: Generat
)
prompt_extra = req.prompt.strip()
prompt_extra_clause = f"User direction: {prompt_extra[:1200]} " if prompt_extra else ""
+ subject_profile_clause = _subject_profile_prompt_clause(req.subject_profile)
identity_lock_clause = (
"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. "
@@ -4554,6 +4602,7 @@ def generate_subject_assets(job_id: str, idx: int, element_id: str, req: Generat
+ neck_product_clause
+ canvas_clause
+ prompt_extra_clause
+ + subject_profile_clause
+ actor_style_clause
+ framing_clause
+ f"Create a high-definition standalone asset on a solid {bg_phrase} background. "
diff --git a/docs/source-analysis.html b/docs/source-analysis.html
index c4a0084..5d38c7c 100644
--- a/docs/source-analysis.html
+++ b/docs/source-analysis.html
@@ -595,7 +595,7 @@
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_human 或 source_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;点击后用固定浮层展示模型链路、输入输出和回退逻辑。旧分镜卡、抽帧控制和视频生成组件仍保留在文件里,但当前主路径不渲染。 |
AdRecreationBoard 主题切换 | 顶部指标区左侧有“明亮/暗色”按钮,使用 Sun / Moon 图标切换 skg-board-theme--light 类名,并把选择写入 localStorage["skg-board-theme"]。暗色仍是默认模式;明亮模式只改变工作台外观,不改变任务、素材、分镜、模型调用或接口数据。 |
- SourceReferenceBuildPanel | “相似主体 / 主体模板”当前承担主体资产生成和主体模板复用的前端入口:顶部用 radio 区分“用模板生成”和“不用模板(从源视频关键帧创新)”,源视频相似 不再作为模板卡混进网格。模板库把 GET /subject-templates 数据库模板和 GET /character-library/skg 内置形象合并成 120px 竖排卡片,选中态统一用 cyan;当选择“不用模板”时模板网格会收起,避免把生成按钮和结果缩略图挤到折叠区域之外。保存为主体模板的名称、备注和按钮固定在模板区底部一行。下方“生成主体视图”独立显示模型链路,支持透明骨架/真人、全部 10 / 常用 4 / 自定义视图;已有生成结果会优先显示在生成区标题下方,再显示控制项,避免用户生成后还要继续向下找图。主体缩略图放大为可单张重生、删除和 hover 放大的媒体卡;生成中会显示本次请求锁定的素材 ID,切换其他模块不会改变已经提交的生成目标。前端仍传 reconstruction_mode=similar,后端先用 VISION_MODEL 把关键帧/模板图转成非身份化文字 brief;如果 brief 失败,则继续用用户方向、模板文字或内置形象 brief。最终主体图只走 gpt-image-2 的 /images/generations 文字生图,不再把原帧或模板图作为强 image-edit 锚点。 |
+ SourceReferenceBuildPanel | “相似主体 / 主体模板”当前承担主体资产生成和主体模板复用的前端入口:顶部用 radio 区分“用模板生成”和“不用模板(从源视频关键帧创新)”,源视频相似 不再作为模板卡混进网格。模板库把 GET /subject-templates 数据库模板和 GET /character-library/skg 内置形象合并成 120px 竖排卡片,选中态统一用 cyan;当选择“不用模板”时模板网格会收起,避免把生成按钮和结果缩略图挤到折叠区域之外。保存为主体模板的名称、备注和按钮固定在模板区底部一行。下方“生成主体视图”独立显示模型链路,支持透明骨架/真人、全部 10 / 常用 4 / 自定义视图;同时新增“主体设定”,默认随机组合性别表现、年龄段、着装风格、地域人种、肤色、体型比例、发型和气质场景,也可切到手动指定。随机组合会在点击生成时解析成一套固定 profile 并传给后端 subject_profile,整包视图共用同一人设,不会一张男一张女或一张年轻一张银发。已有生成结果会优先显示在生成区标题下方,再显示控制项,避免用户生成后还要继续向下找图。主体缩略图放大为可单张重生、删除和 hover 放大的媒体卡;生成中会显示本次请求锁定的素材 ID 和主体设定,切换其他模块不会改变已经提交的生成目标。前端仍传 reconstruction_mode=similar,后端先用 VISION_MODEL 把关键帧/模板图转成非身份化文字 brief;如果 brief 失败,则继续用用户方向、模板文字、内置形象 brief 和结构化主体设定。最终主体图只走 gpt-image-2 的 /images/generations 文字生图,不再把原帧或模板图作为强 image-edit 锚点。 |
web/components/media-asset-tile.tsx | 项目内媒体素材缩略图基底组件:图片、视频、抽帧、产品图、相似主体图、首尾帧和视频候选默认从这里获得统一交互。组件负责缩略图显示、顶层固定浮层 hover 放大、删除按钮、重新生成等操作按钮、忙碌遮罩和图片/视频共用预览,避免每个新板块重复手写不同的媒体交互。 |
web/app/login/page.tsx | 生产登录页:访问账号/访问密钥表单、保持登录、错误/成功状态;当前只在原版 Digital Oasis 动态背景上叠加一个组合登录框,桌面端左侧是动态角色,右侧是图标化登录表单;面板左上角展示官网 SKG 字标和中文“营销内容工作台”系统标识。 |
web/app/login/layout.tsx | 登录路由专属 layout:覆盖全站默认网页标题和描述为空,避免 /login 继承工作台 metadata 后在页面源码里继续出现登录界面文字以外的文案。 |
@@ -1033,6 +1033,19 @@ ProductRefStateItem {
变更记录
这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。
+
+
+ 2026-05-18 · 主体生成加入随机/手动人设控制
+ UI
+ Prompt
+ API
+
+
+
问题:“生成主体视图”只有自由文本方向,用户想控制男女老少、着装、地域人种、肤色等人设时必须手写,而且随机生成容易在多张视图里漂移。
+
改动:SourceReferenceBuildPanel 新增“主体设定”控件:默认随机组合,也可手动指定性别表现、年龄段、着装风格、地域人种、肤色、体型比例、发型和气质场景。点击生成时前端把随机项解析成一套固定 profile,传入 generateSubjectAssets 的 subject_profile;后端 GenerateSubjectAssetsReq 新增 SubjectProfilePreference,并把这套设定写入 gpt-image-2 prompt,要求整包视图统一使用同一人设。
+
影响:用户可以快速得到有差异但稳定的人物设定;随机模式仍省操作,手动模式可精确控制 casting,后续生成首尾帧时也更容易从主体库里挑到明确类型的人物。
+
+
2026-05-18 · 相似主体生成结果可见性修复
diff --git a/web/components/ad-recreation-board.tsx b/web/components/ad-recreation-board.tsx
index b6d81ee..b66b276 100644
--- a/web/components/ad-recreation-board.tsx
+++ b/web/components/ad-recreation-board.tsx
@@ -24,6 +24,7 @@ import {
type StoryboardScriptRewriteSegment,
type StoryboardScene,
type SubjectAsset,
+ type SubjectProfilePreference,
type SubjectKind,
addElement,
analyzeJob,
@@ -121,6 +122,18 @@ type SubjectPlanningRef = ImageRef & { view: string; roleHint: string }
type SubjectStyleMode = "transparent_human" | "source_actor"
type SubjectMode = "template" | "source_similar"
type SubjectViewMode = "all" | "common" | "custom"
+type SubjectProfileMode = "random" | "manual"
+type SubjectProfileFieldKey = "gender" | "age" | "wardrobe" | "region_ethnicity" | "skin_tone" | "body" | "hair" | "mood"
+type SubjectProfileDraft = Record
+type SubjectProfileOption = { value: string; label: string; prompt: string }
+type SubjectProfileCategory = { key: SubjectProfileFieldKey; label: string; options: SubjectProfileOption[] }
+type ResolvedSubjectProfile = {
+ mode: SubjectProfileMode
+ values: SubjectProfileDraft
+ summary: string
+ promptSummary: string
+ payload: SubjectProfilePreference
+}
type StoryboardVisualMode = NonNullable
type RowPlanPatch = Partial>
type WorkflowStepId = "input" | "source" | "audio" | "visual" | "subject" | "product" | "script" | "scene" | "video"
@@ -164,6 +177,113 @@ const COMMON_SUBJECT_VIEW_VALUES = ["front", "three_quarter_left", "three_quarte
const SUBJECT_ASSET_SIZE = "2048" as const
+const SUBJECT_PROFILE_CATEGORIES: SubjectProfileCategory[] = [
+ {
+ key: "gender",
+ label: "性别表现",
+ options: [
+ { value: "random", label: "随机", prompt: "gender presentation selected randomly for this request" },
+ { value: "female", label: "女性", prompt: "female-presenting commercial ad subject" },
+ { value: "male", label: "男性", prompt: "male-presenting commercial ad subject" },
+ { value: "neutral", label: "中性", prompt: "androgynous or gender-neutral commercial ad subject" },
+ ],
+ },
+ {
+ key: "age",
+ label: "年龄段",
+ options: [
+ { value: "random", label: "随机", prompt: "age range selected randomly for this request" },
+ { value: "young_adult", label: "年轻成人", prompt: "young adult, fresh short-video creator energy" },
+ { value: "adult", label: "成熟成人", prompt: "adult, polished and reliable commercial look" },
+ { value: "middle_aged", label: "中年", prompt: "middle-aged adult, credible wellness and work-stress context" },
+ { value: "senior", label: "银发", prompt: "active senior adult, friendly wellness lifestyle context" },
+ ],
+ },
+ {
+ key: "wardrobe",
+ label: "着装风格",
+ options: [
+ { value: "random", label: "随机", prompt: "wardrobe style selected randomly for this request" },
+ { value: "athleisure", label: "运动休闲", prompt: "clean athleisure outfit with visible neck and shoulders" },
+ { value: "office", label: "通勤职场", prompt: "simple office-casual outfit, clean neckline, no bulky collar" },
+ { value: "home_loungewear", label: "居家舒适", prompt: "soft home loungewear, relaxed wellness ad mood" },
+ { value: "premium_minimal", label: "高级极简", prompt: "premium minimal styling, refined neutral clothing, clear shoulder line" },
+ { value: "street_casual", label: "街头日常", prompt: "modern street-casual styling, creator-ad friendly" },
+ { value: "wellness", label: "健康护理", prompt: "wellness-care styling, clean and professional but not medical" },
+ ],
+ },
+ {
+ key: "region_ethnicity",
+ label: "地域人种",
+ options: [
+ { value: "random", label: "随机", prompt: "regional and ethnic appearance selected randomly for this request" },
+ { value: "east_asian", label: "东亚", prompt: "East Asian appearance cues, contemporary commercial styling" },
+ { value: "southeast_asian", label: "东南亚", prompt: "Southeast Asian appearance cues, warm creator-ad styling" },
+ { value: "south_asian", label: "南亚", prompt: "South Asian appearance cues, clear commercial readability" },
+ { value: "black", label: "黑人/非洲裔", prompt: "Black or African-diaspora appearance cues, polished ad styling" },
+ { value: "white", label: "白人/欧美", prompt: "White or European appearance cues, contemporary lifestyle ad styling" },
+ { value: "latino", label: "拉丁裔", prompt: "Latino appearance cues, energetic lifestyle ad styling" },
+ { value: "middle_eastern", label: "中东", prompt: "Middle Eastern appearance cues, premium lifestyle ad styling" },
+ { value: "mixed_global", label: "混合国际化", prompt: "mixed or globally ambiguous appearance cues, international ad campaign feel" },
+ ],
+ },
+ {
+ key: "skin_tone",
+ label: "肤色",
+ options: [
+ { value: "random", label: "随机", prompt: "skin tone selected randomly for this request" },
+ { value: "fair", label: "白皙", prompt: "fair skin tone" },
+ { value: "light", label: "浅肤色", prompt: "light skin tone" },
+ { value: "medium", label: "中等肤色", prompt: "medium skin tone" },
+ { value: "tan", label: "小麦/棕肤", prompt: "tan or warm brown skin tone" },
+ { value: "deep", label: "深肤色", prompt: "deep skin tone" },
+ ],
+ },
+ {
+ key: "body",
+ label: "体型比例",
+ options: [
+ { value: "random", label: "随机", prompt: "body proportion selected randomly for this request" },
+ { value: "slim", label: "偏瘦", prompt: "slim body proportion with clear neck and shoulder silhouette" },
+ { value: "average", label: "自然匀称", prompt: "average natural body proportion, believable short-video creator" },
+ { value: "athletic", label: "运动型", prompt: "athletic body proportion, wellness and mobility context" },
+ { value: "soft", label: "亲和微胖", prompt: "soft approachable body proportion, friendly lifestyle realism" },
+ { value: "broad_shoulder", label: "肩颈明显", prompt: "slightly broader shoulder line, useful for neck-and-shoulder product placement" },
+ ],
+ },
+ {
+ key: "hair",
+ label: "发型",
+ options: [
+ { value: "random", label: "随机", prompt: "hair style selected randomly for this request" },
+ { value: "short", label: "短发", prompt: "short tidy hair that does not cover the neck" },
+ { value: "shoulder_length", label: "齐肩发", prompt: "shoulder-length hair kept away from the neck placement area" },
+ { value: "ponytail", label: "马尾/束发", prompt: "ponytail or tied-back hair, neck and shoulders clearly visible" },
+ { value: "curly", label: "卷发", prompt: "curly hair controlled away from shoulder product placement area" },
+ { value: "buzz", label: "极短发", prompt: "very short hair, clean neck silhouette" },
+ { value: "business_neat", label: "利落商务", prompt: "neat business hairstyle, polished creator-ad look" },
+ ],
+ },
+ {
+ key: "mood",
+ label: "气质场景",
+ options: [
+ { value: "random", label: "随机", prompt: "commercial mood selected randomly for this request" },
+ { value: "energetic", label: "开场钩子", prompt: "energetic short-video hook performance" },
+ { value: "premium_calm", label: "高级克制", prompt: "premium calm product-ad presence" },
+ { value: "friendly_creator", label: "亲和达人", prompt: "friendly creator speaking-to-camera energy" },
+ { value: "wellness_pro", label: "健康专业", prompt: "wellness professional credibility without medical or hospital cues" },
+ { value: "urban_commute", label: "通勤疲惫", prompt: "urban commute and office fatigue context" },
+ { value: "home_relax", label: "居家放松", prompt: "home relaxation and stress-relief context" },
+ ],
+ },
+]
+
+const DEFAULT_SUBJECT_PROFILE_DRAFT: SubjectProfileDraft = SUBJECT_PROFILE_CATEGORIES.reduce((acc, category) => {
+ acc[category.key] = "random"
+ return acc
+}, {} as SubjectProfileDraft)
+
type ModelTraceSpec = {
title: string
model: string
@@ -230,6 +350,61 @@ function shortId(id?: string | null) {
return id ? id.slice(0, 8) : "-"
}
+function subjectProfileOption(category: SubjectProfileCategory, value: string) {
+ return category.options.find((option) => option.value === value) ?? category.options[0]
+}
+
+function randomSubjectProfileDraft(): SubjectProfileDraft {
+ return SUBJECT_PROFILE_CATEGORIES.reduce((acc, category) => {
+ const concrete = category.options.filter((option) => option.value !== "random")
+ const picked = concrete[Math.floor(Math.random() * concrete.length)] ?? category.options[0]
+ acc[category.key] = picked.value
+ return acc
+ }, {} as SubjectProfileDraft)
+}
+
+function resolveSubjectProfile(
+ mode: SubjectProfileMode,
+ draft: SubjectProfileDraft,
+ options: { randomizeRandomValues?: boolean } = {},
+): ResolvedSubjectProfile {
+ const values = { ...DEFAULT_SUBJECT_PROFILE_DRAFT }
+ const labelParts: string[] = []
+ const promptParts: string[] = []
+ for (const category of SUBJECT_PROFILE_CATEGORIES) {
+ const rawValue = draft[category.key] || "random"
+ let option = subjectProfileOption(category, rawValue)
+ if (option.value === "random" && options.randomizeRandomValues) {
+ const concrete = category.options.filter((item) => item.value !== "random")
+ option = concrete[Math.floor(Math.random() * concrete.length)] ?? option
+ }
+ values[category.key] = option.value
+ labelParts.push(`${category.label}:${option.label}`)
+ promptParts.push(`${category.label}: ${option.prompt}`)
+ }
+ const summary = labelParts.join(" / ")
+ const promptSummary = promptParts.join("; ")
+ return {
+ mode,
+ values,
+ summary,
+ promptSummary,
+ payload: {
+ mode,
+ gender: subjectProfileOption(SUBJECT_PROFILE_CATEGORIES[0], values.gender).label,
+ age: subjectProfileOption(SUBJECT_PROFILE_CATEGORIES[1], values.age).label,
+ wardrobe: subjectProfileOption(SUBJECT_PROFILE_CATEGORIES[2], values.wardrobe).label,
+ region_ethnicity: subjectProfileOption(SUBJECT_PROFILE_CATEGORIES[3], values.region_ethnicity).label,
+ skin_tone: subjectProfileOption(SUBJECT_PROFILE_CATEGORIES[4], values.skin_tone).label,
+ body: subjectProfileOption(SUBJECT_PROFILE_CATEGORIES[5], values.body).label,
+ hair: subjectProfileOption(SUBJECT_PROFILE_CATEGORIES[6], values.hair).label,
+ mood: subjectProfileOption(SUBJECT_PROFILE_CATEGORIES[7], values.mood).label,
+ resolved_summary: summary,
+ prompt_summary: promptSummary,
+ },
+ }
+}
+
function formatSeconds(raw?: number) {
if (!raw || Number.isNaN(raw)) return "0.0s"
return `${raw.toFixed(1)}s`
@@ -500,7 +675,12 @@ function findSimilarActorSource(preferredFrames: KeyFrame[], allFrames: KeyFrame
type SubjectTemplatePromptSource = { name: string; sourceLabel: string } | null
-function buildSimilarSubjectPrompt(subjectStyle: SubjectStyleMode, direction: string, selectedTemplate?: SubjectTemplatePromptSource) {
+function buildSimilarSubjectPrompt(
+ subjectStyle: SubjectStyleMode,
+ direction: string,
+ selectedTemplate?: SubjectTemplatePromptSource,
+ subjectProfile?: ResolvedSubjectProfile | null,
+) {
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.",
@@ -516,6 +696,12 @@ function buildSimilarSubjectPrompt(subjectStyle: SubjectStyleMode, direction: st
"Use the template images as planned creative direction only; generate an innovative variation, not a duplicate of that subject pack.",
)
}
+ if (subjectProfile?.promptSummary) {
+ base.push(
+ `Locked subject casting and styling profile for this request: ${subjectProfile.promptSummary}.`,
+ "Apply this one profile uniformly to every generated view; do not randomize gender, age, region, skin tone, body type, hair, wardrobe, or mood differently between views.",
+ )
+ }
if (subjectStyle === "transparent_human") {
base.push(
"The subject must be a transparent humanoid: transparent or translucent skin/body shell wrapping a clean visible white skeleton inside the same body.",
@@ -600,6 +786,7 @@ function similarSubjectModelTrace(models: RuntimeModels | undefined, subjectStyl
chain: [
`视觉 brief:${modelValue(models?.vision)} 把关键帧/模板图转成非身份化文字 brief;失败时继续用用户方向和模板文字`,
`主体类型:${subjectStyle === "transparent_human" ? "透明/半透明皮肤包裹可见白色骨架" : "普通商业广告真人"}`,
+ "主体设定:前端把随机组合或手动选择的性别、年龄、着装、地域人种、肤色、体型、发型和气质锁定为结构化 profile",
`图像生成:${subjectImageModelChain(models)} 走 /images/generations 逐张文字生图;当前 similar 模式不上传原帧或模板图作为 image-edit 参考`,
"身份锁定:整套图必须是同一个主体,性别表现、年龄段、体型、材质和风格保持一致",
],
@@ -2147,13 +2334,17 @@ function SourceReferenceBuildPanel({
onJobUpdate: (job: Job) => void
runtimeModels?: RuntimeModels
}) {
- const [subjectBusyFor, setSubjectBusyFor] = useState<{ jobId: string; jobLabel: string; viewCount: number } | null>(null)
+ const [subjectBusyFor, setSubjectBusyFor] = useState<{ jobId: string; jobLabel: string; viewCount: number; profileLabel: string } | null>(null)
const [subjectAssetBusy, setSubjectAssetBusy] = useState(null)
const [subjectMode, setSubjectMode] = useState("source_similar")
const [subjectStyle, setSubjectStyle] = useState("transparent_human")
const [subjectViewMode, setSubjectViewMode] = useState("all")
const [customSubjectViews, setCustomSubjectViews] = useState(COMMON_SUBJECT_VIEW_VALUES)
const [subjectDirection, setSubjectDirection] = useState("")
+ const [subjectProfileMode, setSubjectProfileMode] = useState("random")
+ const [subjectProfileDraft, setSubjectProfileDraft] = useState({ ...DEFAULT_SUBJECT_PROFILE_DRAFT })
+ const [randomProfileDraft, setRandomProfileDraft] = useState(() => randomSubjectProfileDraft())
+ const [lastSubjectProfile, setLastSubjectProfile] = useState(null)
const [characterLibrary, setCharacterLibrary] = useState([])
const [selectedCharacterId, setSelectedCharacterId] = useState("")
const [subjectTemplateLibrary, setSubjectTemplateLibrary] = useState([])
@@ -2193,6 +2384,11 @@ function SourceReferenceBuildPanel({
if (subjectViewMode === "custom") return customSubjectViews.length ? customSubjectViews : COMMON_SUBJECT_VIEW_VALUES
return SUBJECT_ASSET_VIEWS.map((view) => view.value)
}, [customSubjectViews, subjectViewMode])
+ const subjectProfilePreview = useMemo(() => {
+ return subjectProfileMode === "random"
+ ? resolveSubjectProfile("random", randomProfileDraft)
+ : resolveSubjectProfile("manual", subjectProfileDraft)
+ }, [randomProfileDraft, subjectProfileDraft, subjectProfileMode])
const visibleActorAssets = useMemo(() => {
const latestByView = new Map()
for (const asset of actorAssets) {
@@ -2228,6 +2424,19 @@ function SourceReferenceBuildPanel({
? `用模板生成 ${selectedSubjectViews.length} 张主体视图`
: `从源视频创新生成 ${selectedSubjectViews.length} 张主体视图`
+ const buildSubjectProfileForRequest = () => {
+ if (subjectProfileMode === "random") {
+ const randomized = randomSubjectProfileDraft()
+ setRandomProfileDraft(randomized)
+ const resolved = resolveSubjectProfile("random", randomized)
+ setLastSubjectProfile(resolved)
+ return resolved
+ }
+ const resolved = resolveSubjectProfile("manual", subjectProfileDraft, { randomizeRandomValues: true })
+ setLastSubjectProfile(resolved)
+ return resolved
+ }
+
const loadSubjectTemplateLibrary = async (silent = false) => {
setTemplateLibraryBusy(true)
try {
@@ -2256,6 +2465,7 @@ function SourceReferenceBuildPanel({
useEffect(() => {
setTemplateDraftName("")
setTemplateDraftNote("")
+ setLastSubjectProfile(null)
}, [job.id])
const generateSimilarActor = async () => {
@@ -2270,7 +2480,13 @@ function SourceReferenceBuildPanel({
const baseFrame = subjectReferenceFrames[0]
if (!baseFrame) return
const requestJobId = job.id
- setSubjectBusyFor({ jobId: requestJobId, jobLabel: shortId(requestJobId), viewCount: selectedSubjectViews.length })
+ const requestProfile = buildSubjectProfileForRequest()
+ setSubjectBusyFor({
+ jobId: requestJobId,
+ jobLabel: shortId(requestJobId),
+ viewCount: selectedSubjectViews.length,
+ profileLabel: requestProfile.summary,
+ })
try {
let workingJob = job
let workingFrame = workingJob.frames.find((frame) => frame.index === baseFrame.index) ?? baseFrame
@@ -2303,7 +2519,8 @@ function SourceReferenceBuildPanel({
views: selectedSubjectViews,
character_id: subjectMode === "template" ? selectedCharacterId : "",
subject_template_id: subjectMode === "template" ? selectedSubjectTemplateId : "",
- prompt: buildSimilarSubjectPrompt(subjectStyle, subjectDirection, selectedTemplatePrompt),
+ subject_profile: requestProfile.payload,
+ prompt: buildSimilarSubjectPrompt(subjectStyle, subjectDirection, selectedTemplatePrompt, requestProfile),
replace_views: true,
})
onJobUpdate(updated)
@@ -2322,6 +2539,11 @@ function SourceReferenceBuildPanel({
if (!actorSource) return
setSubjectAssetBusy(`regen:${asset.id}`)
try {
+ const requestProfile = lastSubjectProfile ?? resolveSubjectProfile(
+ subjectProfileMode,
+ subjectProfileMode === "random" ? randomProfileDraft : subjectProfileDraft,
+ { randomizeRandomValues: subjectProfileMode === "manual" },
+ )
const sourceIndices = asset.source_frame_indices?.length
? asset.source_frame_indices
: subjectReferenceFrames.slice(0, 12).map((frame) => frame.index)
@@ -2335,7 +2557,8 @@ function SourceReferenceBuildPanel({
views: [asset.view],
character_id: subjectMode === "template" ? selectedCharacterId : "",
subject_template_id: subjectMode === "template" ? selectedSubjectTemplateId : "",
- prompt: buildSimilarSubjectPrompt(subjectStyle, subjectDirection, selectedTemplatePrompt),
+ subject_profile: requestProfile.payload,
+ prompt: buildSimilarSubjectPrompt(subjectStyle, subjectDirection, selectedTemplatePrompt, requestProfile),
replace_views: true,
})
onJobUpdate(updated)
@@ -2562,6 +2785,7 @@ function SourceReferenceBuildPanel({
{subjectBusyFor ? (
正在为素材 {subjectBusyFor.jobLabel} 生成 {subjectBusyFor.viewCount} 张主体视图;本次请求已锁定素材、参考帧、模式和视图数量,切换其他模块不会改变生成目标,完成后会回写到该素材。
+ 主体设定:{subjectBusyFor.profileLabel}
) : null}
@@ -2599,6 +2823,75 @@ function SourceReferenceBuildPanel({
) : null}
+
+
+
+
主体设定
+
+ 默认随机组合,点击生成时会锁定一套性别、年龄、着装、地域人种、肤色、体型、发型和气质,保证整组视图是同一个人设。
+
+
+
+
+ {[
+ { value: "random" as const, label: "随机组合" },
+ { value: "manual" as const, label: "手动指定" },
+ ].map((item) => (
+
+ ))}
+
+ {subjectProfileMode === "random" ? (
+
+ ) : null}
+
+
+
+
+ {subjectProfileMode === "random" ? "当前随机预览:" : "当前手动设定:"}{subjectProfilePreview.summary}
+ {lastSubjectProfile ? (
+ 上次生成锁定:{lastSubjectProfile.summary}
+ ) : null}
+
+
+ {subjectProfileMode === "manual" ? (
+
+ {SUBJECT_PROFILE_CATEGORIES.map((category) => (
+
+ ))}
+
+ ) : null}
+
+
{[
diff --git a/web/lib/api.ts b/web/lib/api.ts
index d2386c9..ac77beb 100644
--- a/web/lib/api.ts
+++ b/web/lib/api.ts
@@ -522,6 +522,20 @@ export interface SubjectAsset {
created_at: number
}
+export interface SubjectProfilePreference {
+ mode?: "random" | "manual"
+ gender?: string
+ age?: string
+ wardrobe?: string
+ region_ethnicity?: string
+ skin_tone?: string
+ body?: string
+ hair?: string
+ mood?: string
+ resolved_summary?: string
+ prompt_summary?: string
+}
+
export interface ProductLibraryItem {
id: string
handle: string
@@ -1190,6 +1204,7 @@ export async function generateSubjectAssets(
subject_template_id?: string
subject_style?: "transparent_human" | "source_actor"
reconstruction_mode?: "same" | "similar"
+ subject_profile?: SubjectProfilePreference | null
prompt?: string
replace_views?: boolean
} = {},
@@ -1208,6 +1223,7 @@ export async function generateSubjectAssets(
subject_template_id: body.subject_template_id ?? "",
subject_style: body.subject_style ?? "transparent_human",
reconstruction_mode: body.reconstruction_mode ?? "same",
+ subject_profile: body.subject_profile ?? null,
prompt: body.prompt ?? "",
replace_views: body.replace_views ?? false,
}),