feat: add subject profile controls
This commit is contained in:
49
api/main.py
49
api/main.py
@@ -3948,6 +3948,20 @@ class GenerateSceneAssetReq(BaseModel):
|
|||||||
product_images: list[dict] = Field(default_factory=list)
|
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):
|
class GenerateSubjectAssetsReq(BaseModel):
|
||||||
subject_kind: SubjectKind = "object"
|
subject_kind: SubjectKind = "object"
|
||||||
background: AssetBackground = "white"
|
background: AssetBackground = "white"
|
||||||
@@ -3959,10 +3973,43 @@ class GenerateSubjectAssetsReq(BaseModel):
|
|||||||
subject_template_id: str = ""
|
subject_template_id: str = ""
|
||||||
subject_style: Literal["transparent_human", "source_actor"] = "transparent_human"
|
subject_style: Literal["transparent_human", "source_actor"] = "transparent_human"
|
||||||
reconstruction_mode: Literal["same", "similar"] = "same"
|
reconstruction_mode: Literal["same", "similar"] = "same"
|
||||||
|
subject_profile: SubjectProfilePreference | None = None
|
||||||
prompt: str = ""
|
prompt: str = ""
|
||||||
replace_views: bool = False
|
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):
|
class UpdateProductRefsReq(BaseModel):
|
||||||
items: list[dict] = Field(default_factory=list)
|
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 = req.prompt.strip()
|
||||||
prompt_extra_clause = f"User direction: {prompt_extra[:1200]} " if prompt_extra else ""
|
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_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. "
|
"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. "
|
"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
|
+ neck_product_clause
|
||||||
+ canvas_clause
|
+ canvas_clause
|
||||||
+ prompt_extra_clause
|
+ prompt_extra_clause
|
||||||
|
+ subject_profile_clause
|
||||||
+ actor_style_clause
|
+ actor_style_clause
|
||||||
+ framing_clause
|
+ framing_clause
|
||||||
+ f"Create a high-definition standalone asset on a solid {bg_phrase} background. "
|
+ f"Create a high-definition standalone asset on a solid {bg_phrase} background. "
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -24,6 +24,7 @@ import {
|
|||||||
type StoryboardScriptRewriteSegment,
|
type StoryboardScriptRewriteSegment,
|
||||||
type StoryboardScene,
|
type StoryboardScene,
|
||||||
type SubjectAsset,
|
type SubjectAsset,
|
||||||
|
type SubjectProfilePreference,
|
||||||
type SubjectKind,
|
type SubjectKind,
|
||||||
addElement,
|
addElement,
|
||||||
analyzeJob,
|
analyzeJob,
|
||||||
@@ -121,6 +122,18 @@ type SubjectPlanningRef = ImageRef & { view: string; roleHint: string }
|
|||||||
type SubjectStyleMode = "transparent_human" | "source_actor"
|
type SubjectStyleMode = "transparent_human" | "source_actor"
|
||||||
type SubjectMode = "template" | "source_similar"
|
type SubjectMode = "template" | "source_similar"
|
||||||
type SubjectViewMode = "all" | "common" | "custom"
|
type SubjectViewMode = "all" | "common" | "custom"
|
||||||
|
type SubjectProfileMode = "random" | "manual"
|
||||||
|
type SubjectProfileFieldKey = "gender" | "age" | "wardrobe" | "region_ethnicity" | "skin_tone" | "body" | "hair" | "mood"
|
||||||
|
type SubjectProfileDraft = Record<SubjectProfileFieldKey, string>
|
||||||
|
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<StoryboardScene["visual_mode"]>
|
type StoryboardVisualMode = NonNullable<StoryboardScene["visual_mode"]>
|
||||||
type RowPlanPatch = Partial<Pick<AudioStoryboardRow, "visualMode" | "needsProduct" | "needsSubject" | "subjectDescription" | "visualPlan" | "firstFramePlan" | "lastFramePlan" | "productIntegration" | "productPlacement">>
|
type RowPlanPatch = Partial<Pick<AudioStoryboardRow, "visualMode" | "needsProduct" | "needsSubject" | "subjectDescription" | "visualPlan" | "firstFramePlan" | "lastFramePlan" | "productIntegration" | "productPlacement">>
|
||||||
type WorkflowStepId = "input" | "source" | "audio" | "visual" | "subject" | "product" | "script" | "scene" | "video"
|
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_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 = {
|
type ModelTraceSpec = {
|
||||||
title: string
|
title: string
|
||||||
model: string
|
model: string
|
||||||
@@ -230,6 +350,61 @@ function shortId(id?: string | null) {
|
|||||||
return id ? id.slice(0, 8) : "-"
|
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) {
|
function formatSeconds(raw?: number) {
|
||||||
if (!raw || Number.isNaN(raw)) return "0.0s"
|
if (!raw || Number.isNaN(raw)) return "0.0s"
|
||||||
return `${raw.toFixed(1)}s`
|
return `${raw.toFixed(1)}s`
|
||||||
@@ -500,7 +675,12 @@ function findSimilarActorSource(preferredFrames: KeyFrame[], allFrames: KeyFrame
|
|||||||
|
|
||||||
type SubjectTemplatePromptSource = { name: string; sourceLabel: string } | null
|
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 = [
|
const base = [
|
||||||
"Create a new similar but non-identical information-feed ad subject from the selected reference frames.",
|
"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.",
|
"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.",
|
"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") {
|
if (subjectStyle === "transparent_human") {
|
||||||
base.push(
|
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.",
|
"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: [
|
chain: [
|
||||||
`视觉 brief:${modelValue(models?.vision)} 把关键帧/模板图转成非身份化文字 brief;失败时继续用用户方向和模板文字`,
|
`视觉 brief:${modelValue(models?.vision)} 把关键帧/模板图转成非身份化文字 brief;失败时继续用用户方向和模板文字`,
|
||||||
`主体类型:${subjectStyle === "transparent_human" ? "透明/半透明皮肤包裹可见白色骨架" : "普通商业广告真人"}`,
|
`主体类型:${subjectStyle === "transparent_human" ? "透明/半透明皮肤包裹可见白色骨架" : "普通商业广告真人"}`,
|
||||||
|
"主体设定:前端把随机组合或手动选择的性别、年龄、着装、地域人种、肤色、体型、发型和气质锁定为结构化 profile",
|
||||||
`图像生成:${subjectImageModelChain(models)} 走 /images/generations 逐张文字生图;当前 similar 模式不上传原帧或模板图作为 image-edit 参考`,
|
`图像生成:${subjectImageModelChain(models)} 走 /images/generations 逐张文字生图;当前 similar 模式不上传原帧或模板图作为 image-edit 参考`,
|
||||||
"身份锁定:整套图必须是同一个主体,性别表现、年龄段、体型、材质和风格保持一致",
|
"身份锁定:整套图必须是同一个主体,性别表现、年龄段、体型、材质和风格保持一致",
|
||||||
],
|
],
|
||||||
@@ -2147,13 +2334,17 @@ function SourceReferenceBuildPanel({
|
|||||||
onJobUpdate: (job: Job) => void
|
onJobUpdate: (job: Job) => void
|
||||||
runtimeModels?: RuntimeModels
|
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<string | null>(null)
|
const [subjectAssetBusy, setSubjectAssetBusy] = useState<string | null>(null)
|
||||||
const [subjectMode, setSubjectMode] = useState<SubjectMode>("source_similar")
|
const [subjectMode, setSubjectMode] = useState<SubjectMode>("source_similar")
|
||||||
const [subjectStyle, setSubjectStyle] = useState<SubjectStyleMode>("transparent_human")
|
const [subjectStyle, setSubjectStyle] = useState<SubjectStyleMode>("transparent_human")
|
||||||
const [subjectViewMode, setSubjectViewMode] = useState<SubjectViewMode>("all")
|
const [subjectViewMode, setSubjectViewMode] = useState<SubjectViewMode>("all")
|
||||||
const [customSubjectViews, setCustomSubjectViews] = useState<string[]>(COMMON_SUBJECT_VIEW_VALUES)
|
const [customSubjectViews, setCustomSubjectViews] = useState<string[]>(COMMON_SUBJECT_VIEW_VALUES)
|
||||||
const [subjectDirection, setSubjectDirection] = useState("")
|
const [subjectDirection, setSubjectDirection] = useState("")
|
||||||
|
const [subjectProfileMode, setSubjectProfileMode] = useState<SubjectProfileMode>("random")
|
||||||
|
const [subjectProfileDraft, setSubjectProfileDraft] = useState<SubjectProfileDraft>({ ...DEFAULT_SUBJECT_PROFILE_DRAFT })
|
||||||
|
const [randomProfileDraft, setRandomProfileDraft] = useState<SubjectProfileDraft>(() => randomSubjectProfileDraft())
|
||||||
|
const [lastSubjectProfile, setLastSubjectProfile] = useState<ResolvedSubjectProfile | null>(null)
|
||||||
const [characterLibrary, setCharacterLibrary] = useState<CharacterLibraryItem[]>([])
|
const [characterLibrary, setCharacterLibrary] = useState<CharacterLibraryItem[]>([])
|
||||||
const [selectedCharacterId, setSelectedCharacterId] = useState("")
|
const [selectedCharacterId, setSelectedCharacterId] = useState("")
|
||||||
const [subjectTemplateLibrary, setSubjectTemplateLibrary] = useState<SubjectTemplateItem[]>([])
|
const [subjectTemplateLibrary, setSubjectTemplateLibrary] = useState<SubjectTemplateItem[]>([])
|
||||||
@@ -2193,6 +2384,11 @@ function SourceReferenceBuildPanel({
|
|||||||
if (subjectViewMode === "custom") return customSubjectViews.length ? customSubjectViews : COMMON_SUBJECT_VIEW_VALUES
|
if (subjectViewMode === "custom") return customSubjectViews.length ? customSubjectViews : COMMON_SUBJECT_VIEW_VALUES
|
||||||
return SUBJECT_ASSET_VIEWS.map((view) => view.value)
|
return SUBJECT_ASSET_VIEWS.map((view) => view.value)
|
||||||
}, [customSubjectViews, subjectViewMode])
|
}, [customSubjectViews, subjectViewMode])
|
||||||
|
const subjectProfilePreview = useMemo(() => {
|
||||||
|
return subjectProfileMode === "random"
|
||||||
|
? resolveSubjectProfile("random", randomProfileDraft)
|
||||||
|
: resolveSubjectProfile("manual", subjectProfileDraft)
|
||||||
|
}, [randomProfileDraft, subjectProfileDraft, subjectProfileMode])
|
||||||
const visibleActorAssets = useMemo(() => {
|
const visibleActorAssets = useMemo(() => {
|
||||||
const latestByView = new Map<string, SubjectAsset>()
|
const latestByView = new Map<string, SubjectAsset>()
|
||||||
for (const asset of actorAssets) {
|
for (const asset of actorAssets) {
|
||||||
@@ -2228,6 +2424,19 @@ function SourceReferenceBuildPanel({
|
|||||||
? `用模板生成 ${selectedSubjectViews.length} 张主体视图`
|
? `用模板生成 ${selectedSubjectViews.length} 张主体视图`
|
||||||
: `从源视频创新生成 ${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) => {
|
const loadSubjectTemplateLibrary = async (silent = false) => {
|
||||||
setTemplateLibraryBusy(true)
|
setTemplateLibraryBusy(true)
|
||||||
try {
|
try {
|
||||||
@@ -2256,6 +2465,7 @@ function SourceReferenceBuildPanel({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTemplateDraftName("")
|
setTemplateDraftName("")
|
||||||
setTemplateDraftNote("")
|
setTemplateDraftNote("")
|
||||||
|
setLastSubjectProfile(null)
|
||||||
}, [job.id])
|
}, [job.id])
|
||||||
|
|
||||||
const generateSimilarActor = async () => {
|
const generateSimilarActor = async () => {
|
||||||
@@ -2270,7 +2480,13 @@ function SourceReferenceBuildPanel({
|
|||||||
const baseFrame = subjectReferenceFrames[0]
|
const baseFrame = subjectReferenceFrames[0]
|
||||||
if (!baseFrame) return
|
if (!baseFrame) return
|
||||||
const requestJobId = job.id
|
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 {
|
try {
|
||||||
let workingJob = job
|
let workingJob = job
|
||||||
let workingFrame = workingJob.frames.find((frame) => frame.index === baseFrame.index) ?? baseFrame
|
let workingFrame = workingJob.frames.find((frame) => frame.index === baseFrame.index) ?? baseFrame
|
||||||
@@ -2303,7 +2519,8 @@ function SourceReferenceBuildPanel({
|
|||||||
views: selectedSubjectViews,
|
views: selectedSubjectViews,
|
||||||
character_id: subjectMode === "template" ? selectedCharacterId : "",
|
character_id: subjectMode === "template" ? selectedCharacterId : "",
|
||||||
subject_template_id: subjectMode === "template" ? selectedSubjectTemplateId : "",
|
subject_template_id: subjectMode === "template" ? selectedSubjectTemplateId : "",
|
||||||
prompt: buildSimilarSubjectPrompt(subjectStyle, subjectDirection, selectedTemplatePrompt),
|
subject_profile: requestProfile.payload,
|
||||||
|
prompt: buildSimilarSubjectPrompt(subjectStyle, subjectDirection, selectedTemplatePrompt, requestProfile),
|
||||||
replace_views: true,
|
replace_views: true,
|
||||||
})
|
})
|
||||||
onJobUpdate(updated)
|
onJobUpdate(updated)
|
||||||
@@ -2322,6 +2539,11 @@ function SourceReferenceBuildPanel({
|
|||||||
if (!actorSource) return
|
if (!actorSource) return
|
||||||
setSubjectAssetBusy(`regen:${asset.id}`)
|
setSubjectAssetBusy(`regen:${asset.id}`)
|
||||||
try {
|
try {
|
||||||
|
const requestProfile = lastSubjectProfile ?? resolveSubjectProfile(
|
||||||
|
subjectProfileMode,
|
||||||
|
subjectProfileMode === "random" ? randomProfileDraft : subjectProfileDraft,
|
||||||
|
{ randomizeRandomValues: subjectProfileMode === "manual" },
|
||||||
|
)
|
||||||
const sourceIndices = asset.source_frame_indices?.length
|
const sourceIndices = asset.source_frame_indices?.length
|
||||||
? asset.source_frame_indices
|
? asset.source_frame_indices
|
||||||
: subjectReferenceFrames.slice(0, 12).map((frame) => frame.index)
|
: subjectReferenceFrames.slice(0, 12).map((frame) => frame.index)
|
||||||
@@ -2335,7 +2557,8 @@ function SourceReferenceBuildPanel({
|
|||||||
views: [asset.view],
|
views: [asset.view],
|
||||||
character_id: subjectMode === "template" ? selectedCharacterId : "",
|
character_id: subjectMode === "template" ? selectedCharacterId : "",
|
||||||
subject_template_id: subjectMode === "template" ? selectedSubjectTemplateId : "",
|
subject_template_id: subjectMode === "template" ? selectedSubjectTemplateId : "",
|
||||||
prompt: buildSimilarSubjectPrompt(subjectStyle, subjectDirection, selectedTemplatePrompt),
|
subject_profile: requestProfile.payload,
|
||||||
|
prompt: buildSimilarSubjectPrompt(subjectStyle, subjectDirection, selectedTemplatePrompt, requestProfile),
|
||||||
replace_views: true,
|
replace_views: true,
|
||||||
})
|
})
|
||||||
onJobUpdate(updated)
|
onJobUpdate(updated)
|
||||||
@@ -2562,6 +2785,7 @@ function SourceReferenceBuildPanel({
|
|||||||
{subjectBusyFor ? (
|
{subjectBusyFor ? (
|
||||||
<div className="mb-2 rounded-md border border-cyan-200/20 bg-cyan-300/[0.07] px-2.5 py-2 text-[10px] leading-snug text-cyan-50/70">
|
<div className="mb-2 rounded-md border border-cyan-200/20 bg-cyan-300/[0.07] px-2.5 py-2 text-[10px] leading-snug text-cyan-50/70">
|
||||||
正在为素材 {subjectBusyFor.jobLabel} 生成 {subjectBusyFor.viewCount} 张主体视图;本次请求已锁定素材、参考帧、模式和视图数量,切换其他模块不会改变生成目标,完成后会回写到该素材。
|
正在为素材 {subjectBusyFor.jobLabel} 生成 {subjectBusyFor.viewCount} 张主体视图;本次请求已锁定素材、参考帧、模式和视图数量,切换其他模块不会改变生成目标,完成后会回写到该素材。
|
||||||
|
<span className="mt-1 block text-cyan-50/58">主体设定:{subjectBusyFor.profileLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -2599,6 +2823,75 @@ function SourceReferenceBuildPanel({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<div className="mb-2 rounded-md border border-white/10 bg-black/24 p-2">
|
||||||
|
<div className="mb-2 flex flex-wrap items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-[10.5px] font-semibold text-white/70">主体设定</div>
|
||||||
|
<div className="mt-0.5 max-w-3xl text-[9.5px] leading-snug text-white/34">
|
||||||
|
默认随机组合,点击生成时会锁定一套性别、年龄、着装、地域人种、肤色、体型、发型和气质,保证整组视图是同一个人设。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
|
<div className="flex rounded-md border border-white/10 bg-black/28 p-0.5">
|
||||||
|
{[
|
||||||
|
{ value: "random" as const, label: "随机组合" },
|
||||||
|
{ value: "manual" as const, label: "手动指定" },
|
||||||
|
].map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSubjectProfileMode(item.value)}
|
||||||
|
className={`h-7 rounded px-2 text-[10px] font-semibold transition ${
|
||||||
|
subjectProfileMode === item.value ? "bg-white text-black" : "text-white/45 hover:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{subjectProfileMode === "random" ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRandomProfileDraft(randomSubjectProfileDraft())}
|
||||||
|
className="inline-flex h-8 items-center gap-1 rounded-md border border-white/10 bg-white/[0.045] px-2 text-[10px] font-semibold text-white/56 transition hover:border-cyan-300/35 hover:text-cyan-100"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-3 w-3" />
|
||||||
|
换一组
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded border border-cyan-200/16 bg-cyan-300/[0.055] px-2 py-1.5 text-[9.5px] leading-snug text-cyan-50/64">
|
||||||
|
{subjectProfileMode === "random" ? "当前随机预览:" : "当前手动设定:"}{subjectProfilePreview.summary}
|
||||||
|
{lastSubjectProfile ? (
|
||||||
|
<span className="mt-1 block text-cyan-50/44">上次生成锁定:{lastSubjectProfile.summary}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{subjectProfileMode === "manual" ? (
|
||||||
|
<div className="mt-2 grid gap-1.5 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{SUBJECT_PROFILE_CATEGORIES.map((category) => (
|
||||||
|
<label key={category.key} className="min-w-0">
|
||||||
|
<span className="mb-1 block text-[9px] font-semibold text-white/40">{category.label}</span>
|
||||||
|
<select
|
||||||
|
value={subjectProfileDraft[category.key]}
|
||||||
|
onChange={(event) => {
|
||||||
|
const value = event.target.value
|
||||||
|
setSubjectProfileDraft((current) => ({ ...current, [category.key]: value }))
|
||||||
|
}}
|
||||||
|
className="h-8 w-full rounded-md border border-white/10 bg-black/45 px-2 text-[10.5px] text-white outline-none transition focus:border-cyan-300/50"
|
||||||
|
>
|
||||||
|
{category.options.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>{option.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-2 xl:grid-cols-[auto_auto_minmax(220px,1fr)_auto] xl:items-start">
|
<div className="grid gap-2 xl:grid-cols-[auto_auto_minmax(220px,1fr)_auto] xl:items-start">
|
||||||
<div className="flex rounded-md border border-white/10 bg-black/28 p-0.5">
|
<div className="flex rounded-md border border-white/10 bg-black/28 p-0.5">
|
||||||
{[
|
{[
|
||||||
|
|||||||
@@ -522,6 +522,20 @@ export interface SubjectAsset {
|
|||||||
created_at: number
|
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 {
|
export interface ProductLibraryItem {
|
||||||
id: string
|
id: string
|
||||||
handle: string
|
handle: string
|
||||||
@@ -1190,6 +1204,7 @@ export async function generateSubjectAssets(
|
|||||||
subject_template_id?: string
|
subject_template_id?: string
|
||||||
subject_style?: "transparent_human" | "source_actor"
|
subject_style?: "transparent_human" | "source_actor"
|
||||||
reconstruction_mode?: "same" | "similar"
|
reconstruction_mode?: "same" | "similar"
|
||||||
|
subject_profile?: SubjectProfilePreference | null
|
||||||
prompt?: string
|
prompt?: string
|
||||||
replace_views?: boolean
|
replace_views?: boolean
|
||||||
} = {},
|
} = {},
|
||||||
@@ -1208,6 +1223,7 @@ export async function generateSubjectAssets(
|
|||||||
subject_template_id: body.subject_template_id ?? "",
|
subject_template_id: body.subject_template_id ?? "",
|
||||||
subject_style: body.subject_style ?? "transparent_human",
|
subject_style: body.subject_style ?? "transparent_human",
|
||||||
reconstruction_mode: body.reconstruction_mode ?? "same",
|
reconstruction_mode: body.reconstruction_mode ?? "same",
|
||||||
|
subject_profile: body.subject_profile ?? null,
|
||||||
prompt: body.prompt ?? "",
|
prompt: body.prompt ?? "",
|
||||||
replace_views: body.replace_views ?? false,
|
replace_views: body.replace_views ?? false,
|
||||||
}),
|
}),
|
||||||
|
|||||||
Reference in New Issue
Block a user