feat: simplify subject reconstruction layer

This commit is contained in:
2026-05-19 20:39:15 +08:00
parent aabddef486
commit 15c6f4d2fc
6 changed files with 479 additions and 227 deletions

View File

@@ -39,7 +39,7 @@
"type" : "web_login" "type" : "web_login"
} }
], ],
"description" : "SKG 信息流广告快速复刻工作台:粘贴 TK 链接或上传视频后点击开始,系统自动下载源视频;下载完成后并行启动音频文案路和视频视觉路。音频路提取原文案\/字幕、中文翻译、讲话人、语速节奏、背景音乐\/环境声\/音效;视觉路自动抽 12 张动作\/节奏参考帧,供生成相似主体、产品素材池、分镜口播和首尾帧审核。当前主流程暂停直接提交视频模型,先保存规划和首尾帧。", "description" : "SKG 信息流广告快速复刻工作台:粘贴 TK 链接或上传视频后点击开始,系统自动下载源视频;下载完成后并行启动音频文案路和视频视觉路。音频路提取原文案\/字幕、中文翻译、讲话人、语速节奏、背景音乐\/环境声\/音效;视觉路自动抽 12 张动作\/节奏参考帧,转换层按真人重构、卡通重构、元素重构、自主描述四个方向生成全新主体 6 视图,再汇合产品素材池、分镜口播和视频候选生成。",
"kind" : "app", "kind" : "app",
"name" : "SKG Marketing Studio \/ SKG 营销内容工作台", "name" : "SKG Marketing Studio \/ SKG 营销内容工作台",
"ownership" : "company", "ownership" : "company",

View File

@@ -11,7 +11,7 @@
- 详见 `CLAUDE.md` 立项决策段 + `.memory/plan.md` 七步管线拆解 - 详见 `CLAUDE.md` 立项决策段 + `.memory/plan.md` 七步管线拆解
- 风格:`04-Dark-Gallery-Ambient`(路径:`~/Projects/research/20260305-网页风格库/04-Dark-Gallery-Ambient.md` - 风格:`04-Dark-Gallery-Ambient`(路径:`~/Projects/research/20260305-网页风格库/04-Dark-Gallery-Ambient.md`
- 第一冲刺:步骤 1-4下载 / 拆轨 / 关键帧 / ASR+翻译) - 第一冲刺:步骤 1-4下载 / 拆轨 / 关键帧 / ASR+翻译)
- 当前产品方向2026-05-19 再确认):信息流广告快速复刻默认进入“三字段候选生成”工作流。主界面为“左侧素材输入列 + 右侧信息流复刻工作表”。用户粘贴 TK 链接或上传视频后点击“开始分析”,系统自动下载源视频;下载完成后并行启动两条路:音频文案路提取原音频文案/字幕,并分析讲话人、语速节奏、背景音乐/环境声/音效;视频视觉路自动抽取参考帧。源视频工作区右侧主体链路是“参考帧池 → 转换层 → 主体元素”:参考帧池竖向排列,用户拖 1-2 张关键帧到转换层,转换层按参考创新生成新的主体套图,主体元素区展示后续分镜可用的主体图;旧下方“相似主体 / 主体模板库”不再作为主路径。波形下方的画面胶片只是临时预览,点击只跳转原视频时间点,双击或拖进参考帧池才正式加入关键帧,已加入的胶片直接显示“已添加”。产品图上传后独立形成产品资产包,自动识别视角/结构/比例并补缺角度。分镜工作台按逐句时间轴默认只露“文案 / 场景一句话 / 人物+产品+动作”,产品素材池、批量控制、三字段、视频候选和高级区都必须可折叠;视频候选无内容时默认不占大面积,有候选时默认只显示迷你缩略条,展开后才显示 4-grid。单条默认生成 4 个视频候选,顶部支持整片批量生成候选;首尾帧、视觉规划、产品出现方式和旧 6 字段保留在“高级”抽屉与后端 quick-plan 自动展开中,不能再作为客户默认闸门。 - 当前产品方向2026-05-19 再确认):信息流广告快速复刻默认进入“三字段候选生成”工作流。主界面为“左侧素材输入列 + 右侧信息流复刻工作表”。用户粘贴 TK 链接或上传视频后点击“开始分析”,系统自动下载源视频;下载完成后并行启动两条路:音频文案路提取原音频文案/字幕,并分析讲话人、语速节奏、背景音乐/环境声/音效;视频视觉路自动抽取参考帧。源视频工作区右侧主体链路是“参考帧池 → 转换层 → 主体元素”:参考帧池竖向排列;转换层只保留真人重构、卡通重构、元素重构、自主描述四个入口,每个入口最多拖入 3 张参考帧,拖入后立即按该方向生成全新 6 视图主体,右侧主体元素区按重构类型分组展示;这四类都属于参考重构,不抠图、不复制原人、不复刻原画面。旧下方“相似主体 / 主体模板库”不再作为主路径。波形下方的画面胶片只是临时预览,点击只跳转原视频时间点,双击或拖进参考帧池才正式加入关键帧,已加入的胶片直接显示“已添加”。产品图上传后独立形成产品资产包,自动识别视角/结构/比例并补缺角度。分镜工作台按逐句时间轴默认只露“文案 / 场景一句话 / 人物+产品+动作”,产品素材池、批量控制、三字段、视频候选和高级区都必须可折叠;视频候选无内容时默认不占大面积,有候选时默认只显示迷你缩略条,展开后才显示 4-grid。单条默认生成 4 个视频候选,顶部支持整片批量生成候选;首尾帧、视觉规划、产品出现方式和旧 6 字段保留在“高级”抽屉与后端 quick-plan 自动展开中,不能再作为客户默认闸门。
## 部署事实 ## 部署事实
- 平台VPS `76.13.31.179`Ubuntu 24.04 / Docker Compose / Coolify Traefik - 平台VPS `76.13.31.179`Ubuntu 24.04 / Docker Compose / Coolify Traefik

View File

@@ -532,7 +532,7 @@ class SubjectTemplateItem(BaseModel):
source_job_id: str = "" source_job_id: str = ""
source_frame_idx: int = -1 source_frame_idx: int = -1
source_element_id: str = "" source_element_id: str = ""
subject_style: Literal["transparent_human", "source_actor"] = "transparent_human" subject_style: Literal["transparent_human", "source_actor", "cartoon_subject"] = "transparent_human"
primary_image: str = "" primary_image: str = ""
images: list[SubjectTemplateImage] = Field(default_factory=list) images: list[SubjectTemplateImage] = Field(default_factory=list)
created_at: float = 0.0 created_at: float = 0.0
@@ -599,7 +599,7 @@ class AssetLibraryItem(BaseModel):
is_official: bool = False is_official: bool = False
prompt_brief: str = "" prompt_brief: str = ""
prompt_brief_zh: str = "" prompt_brief_zh: str = ""
subject_style: Literal["transparent_human", "source_actor"] = "transparent_human" subject_style: Literal["transparent_human", "source_actor", "cartoon_subject"] = "transparent_human"
product_type: str = "" product_type: str = ""
views: list[AssetLibraryImage] = Field(default_factory=list) views: list[AssetLibraryImage] = Field(default_factory=list)
images: list[AssetLibraryImage] = Field(default_factory=list) images: list[AssetLibraryImage] = Field(default_factory=list)
@@ -619,7 +619,7 @@ class AssetLibraryPatchReq(BaseModel):
source_job_id: str | None = None source_job_id: str | None = None
prompt_brief: str | None = None prompt_brief: str | None = None
prompt_brief_zh: str | None = None prompt_brief_zh: str | None = None
subject_style: Literal["transparent_human", "source_actor"] | None = None subject_style: Literal["transparent_human", "source_actor", "cartoon_subject"] | None = None
product_type: str | None = None product_type: str | None = None
asset_role: str | None = None asset_role: str | None = None
aspect_ratio: str | None = None aspect_ratio: str | None = None
@@ -4788,7 +4788,7 @@ class GenerateSubjectAssetsReq(BaseModel):
views: list[str] | None = None views: list[str] | None = None
character_id: str = "" character_id: str = ""
subject_template_id: str = "" subject_template_id: str = ""
subject_style: Literal["transparent_human", "source_actor"] = "transparent_human" subject_style: Literal["transparent_human", "source_actor", "cartoon_subject"] = "transparent_human"
reconstruction_mode: Literal["same", "similar"] = "same" reconstruction_mode: Literal["same", "similar"] = "same"
subject_profile: SubjectProfilePreference | None = None subject_profile: SubjectProfilePreference | None = None
prompt: str = "" prompt: str = ""
@@ -5340,7 +5340,12 @@ def generate_subject_assets(job_id: str, idx: int, element_id: str, req: Generat
target = (el.name_en or el.name_zh).strip() target = (el.name_en or el.name_zh).strip()
bg_phrase = "pure white" if req.background == "white" else "pure black" bg_phrase = "pure white" if req.background == "white" else "pure black"
similar_actor = req.subject_kind == "living" and req.subject_style == "source_actor" and req.reconstruction_mode == "similar" similar_actor = req.subject_kind == "living" and req.subject_style == "source_actor" and req.reconstruction_mode == "similar"
kind_phrase = "human actor or living character" if req.subject_kind == "living" else "object or product-like subject" cartoon_subject = req.subject_kind == "living" and req.subject_style == "cartoon_subject"
kind_phrase = (
"original stylized cartoon or illustrative living character"
if cartoon_subject else
"human actor or living character" if req.subject_kind == "living" else "object or product-like subject"
)
transparent_character_clause = ( transparent_character_clause = (
TRANSPARENT_HUMAN_POSITIVE_PROMPT TRANSPARENT_HUMAN_POSITIVE_PROMPT
+ " The generated living character must be a friendly transparent humanoid with transparent or translucent outer body and clean white skeleton visible inside the same body. " + " The generated living character must be a friendly transparent humanoid with transparent or translucent outer body and clean white skeleton visible inside the same body. "
@@ -5357,6 +5362,14 @@ def generate_subject_assets(job_id: str, idx: int, element_id: str, req: Generat
if similar_actor if similar_actor
else "" else ""
) )
cartoon_style_clause = (
"Generate an original stylized cartoon or illustrated advertising character, not a photoreal person and not a copied likeness. "
"Use the source brief only for broad role, pose logic, mood, body proportion category, neck-and-shoulder readability, and commercial energy. "
"Change the face, exact silhouette, clothing details, marks, logos, watermarks, captions, and any identifiable source-video features. "
"Keep one consistent cartoon design system, proportions, materials, color language, and character identity across all requested views. "
if cartoon_subject
else ""
)
identity_clause = ( identity_clause = (
"Create a similar but non-identical original subject: match the performance role, silhouette category, styling direction, camera-readability, and commercial mood, while changing exact identity and unique personal features. " "Create a similar but non-identical original subject: match the performance role, silhouette category, styling direction, camera-readability, and commercial mood, while changing exact identity and unique personal features. "
if req.reconstruction_mode == "similar" if req.reconstruction_mode == "similar"
@@ -5431,6 +5444,7 @@ def generate_subject_assets(job_id: str, idx: int, element_id: str, req: Generat
+ prompt_extra_clause + prompt_extra_clause
+ subject_profile_clause + subject_profile_clause
+ actor_style_clause + actor_style_clause
+ cartoon_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. "
"No extra objects, no props, no additional products, no background elements, no original scene fragments, no shadows from the original scene, no text, no watermark, no UI. " "No extra objects, no props, no additional products, no background elements, no original scene fragments, no shadows from the original scene, no text, no watermark, no UI. "
@@ -6340,7 +6354,7 @@ class SaveSubjectTemplateReq(BaseModel):
frame_idx: int frame_idx: int
element_id: str element_id: str
asset_ids: list[str] = Field(default_factory=list) asset_ids: list[str] = Field(default_factory=list)
subject_style: Literal["transparent_human", "source_actor"] = "transparent_human" subject_style: Literal["transparent_human", "source_actor", "cartoon_subject"] = "transparent_human"
@app.get("/product-library/skg", response_model=list[ProductLibraryItem]) @app.get("/product-library/skg", response_model=list[ProductLibraryItem])

File diff suppressed because one or more lines are too long

View File

@@ -196,10 +196,12 @@ type AudioStoryboardRow = {
type ProductRefItem = ProductRefStateItem type ProductRefItem = ProductRefStateItem
type SubjectPlanningRef = ImageRef & { view: string; roleHint: string; consensusBrief?: string } type SubjectPlanningRef = ImageRef & { view: string; roleHint: string; consensusBrief?: string }
type SubjectStyleMode = "transparent_human" | "source_actor" type SubjectStyleMode = "transparent_human" | "source_actor" | "cartoon_subject"
type SubjectMode = "template" | "source_similar" type SubjectMode = "template" | "source_similar"
type SubjectViewMode = "all" | "common" | "custom" type SubjectViewMode = "all" | "common" | "custom"
type SubjectPipelineViewMode = "all" | "common" type SubjectPipelineViewMode = "all" | "common"
type SubjectReconstructionMode = "realistic" | "cartoon" | "elements" | "custom"
type CartoonReconstructionStyle = "3d_animation" | "designer_toy" | "japanese_clean" | "american_illustration" | "clay_toy" | "flat_minimal"
type SubjectProfileMode = "random" | "manual" type SubjectProfileMode = "random" | "manual"
type SubjectProfileFieldKey = "gender" | "age" | "wardrobe" | "region_ethnicity" | "skin_tone" | "body" | "hair" | "mood" type SubjectProfileFieldKey = "gender" | "age" | "wardrobe" | "region_ethnicity" | "skin_tone" | "body" | "hair" | "mood"
type SubjectProfileDraft = Record<SubjectProfileFieldKey, string> type SubjectProfileDraft = Record<SubjectProfileFieldKey, string>
@@ -252,6 +254,54 @@ const SUBJECT_VIEW_ORDER = [
] ]
const COMMON_SUBJECT_VIEW_VALUES = ["front", "three_quarter_left", "three_quarter_right", "bust_front"] const COMMON_SUBJECT_VIEW_VALUES = ["front", "three_quarter_left", "three_quarter_right", "bust_front"]
const RECONSTRUCTION_SUBJECT_VIEW_VALUES = ["front", "three_quarter_left", "left", "back", "right", "three_quarter_right"]
const RECONSTRUCTION_FRAME_LIMIT = 3
const EMPTY_RECONSTRUCTION_FRAME_MAP: Record<SubjectReconstructionMode, number[]> = {
realistic: [],
cartoon: [],
elements: [],
custom: [],
}
const DEFAULT_RECONSTRUCTION_DIRECTIONS: Record<SubjectReconstructionMode, string> = {
realistic: "",
cartoon: "",
elements: "",
custom: "",
}
const CARTOON_RECONSTRUCTION_STYLES: Array<{ value: CartoonReconstructionStyle; label: string; prompt: string }> = [
{ value: "3d_animation", label: "3D动画", prompt: "premium 3D animated character, clean commercial toy-like rendering, friendly wellness-ad appeal" },
{ value: "designer_toy", label: "潮玩公仔", prompt: "designer art toy character, collectible figurine proportions, polished playful commercial styling" },
{ value: "japanese_clean", label: "日系清爽", prompt: "clean Japanese animation-inspired character, gentle colors, fresh wellness lifestyle advertising feel" },
{ value: "american_illustration", label: "美式插画", prompt: "American editorial advertising illustration character, confident shapes, expressive but polished" },
{ value: "clay_toy", label: "黏土玩具", prompt: "soft clay toy character, tactile handmade material, charming rounded shapes, clean studio look" },
{ value: "flat_minimal", label: "极简扁平", prompt: "minimal flat vector-like character, simple geometric shapes, restrained premium health-tech palette" },
]
const RECONSTRUCTION_MODES: Array<{ value: SubjectReconstructionMode; label: string; subtitle: string; placeholder: string }> = [
{
value: "realistic",
label: "真人重构",
subtitle: "参考非身份化人物特点,生成全新真人 6 视图",
placeholder: "如:更年轻、亚洲女性、运动感、不要像原人",
},
{
value: "cartoon",
label: "卡通重构",
subtitle: "选择风格,把参考转成全新卡通主体 6 视图",
placeholder: "如:更可爱、科技感强、保留肩颈线条",
},
{
value: "elements",
label: "元素重构",
subtitle: "参考姿态、色块和镜头语言,生成差异化主体",
placeholder: "如:保留运动气质,去掉原服装和原脸",
},
{
value: "custom",
label: "自主描述",
subtitle: "可不依赖参考帧,直接按描述生成主体 6 视图",
placeholder: "如30岁亚洲女性白色运动背心高级健康科技广告质感",
},
]
const SUBJECT_ASSET_SIZE = "2048" as const const SUBJECT_ASSET_SIZE = "2048" as const
@@ -905,12 +955,18 @@ function isSimilarActorElement(element: KeyElement) {
const en = (element.name_en || "").toLowerCase() const en = (element.name_en || "").toLowerCase()
const combined = `${zh} ${en}`.toLowerCase() const combined = `${zh} ${en}`.toLowerCase()
const zhSimilarSubject = zh.includes("相似") && (zh.includes("主体") || zh.includes("主角") || zh.includes("人物")) const zhSimilarSubject = zh.includes("相似") && (zh.includes("主体") || zh.includes("主角") || zh.includes("人物"))
const zhReconstructionSubject = zh.includes("重构") && (zh.includes("主体") || zh.includes("主角") || zh.includes("人物"))
const enSimilarSubject = en.includes("similar") && (en.includes("subject") || en.includes("actor") || en.includes("humanoid") || en.includes("character")) const enSimilarSubject = en.includes("similar") && (en.includes("subject") || en.includes("actor") || en.includes("humanoid") || en.includes("character"))
const enReconstructionSubject = en.includes("reconstruction") && (en.includes("subject") || en.includes("actor") || en.includes("character"))
return ( return (
zhSimilarSubject zhSimilarSubject
|| zhReconstructionSubject
|| enSimilarSubject || enSimilarSubject
|| enReconstructionSubject
|| combined.includes("相似主角") || combined.includes("相似主角")
|| combined.includes("相似主体") || combined.includes("相似主体")
|| combined.includes("重构主体")
|| combined.includes("reconstructed subject")
|| combined.includes("similar ad actor") || combined.includes("similar ad actor")
|| combined.includes("similar actor") || combined.includes("similar actor")
|| combined.includes("similar subject") || combined.includes("similar subject")
@@ -941,6 +997,77 @@ function findSimilarActorSource(preferredFrames: KeyFrame[], allFrames: KeyFrame
type SubjectTemplatePromptSource = { name: string; sourceLabel: string } | null type SubjectTemplatePromptSource = { name: string; sourceLabel: string } | null
function reconstructionModeConfig(mode: SubjectReconstructionMode) {
return RECONSTRUCTION_MODES.find((item) => item.value === mode) ?? RECONSTRUCTION_MODES[0]
}
function cartoonStyleConfig(style: CartoonReconstructionStyle) {
return CARTOON_RECONSTRUCTION_STYLES.find((item) => item.value === style) ?? CARTOON_RECONSTRUCTION_STYLES[0]
}
function reconstructionModeFromElement(element: KeyElement): SubjectReconstructionMode | null {
const text = `${element.name_zh || ""} ${element.name_en || ""}`.toLowerCase()
if (text.includes("真人重构") || text.includes("realistic reconstruction")) return "realistic"
if (text.includes("卡通重构") || text.includes("cartoon reconstruction")) return "cartoon"
if (text.includes("元素重构") || text.includes("element reconstruction")) return "elements"
if (text.includes("自主描述") || text.includes("custom description")) return "custom"
return null
}
function reconstructionElementName(mode: SubjectReconstructionMode) {
const config = reconstructionModeConfig(mode)
return {
zh: `${config.label}主体`,
en: `${mode} reconstruction subject`,
}
}
function reconstructionSubjectStyle(mode: SubjectReconstructionMode): SubjectStyleMode {
return mode === "cartoon" ? "cartoon_subject" : "source_actor"
}
function buildReconstructionDirection(
mode: SubjectReconstructionMode,
direction: string,
cartoonStyle: CartoonReconstructionStyle,
) {
const trimmed = direction.trim()
const style = cartoonStyleConfig(cartoonStyle)
const common = [
"Legal-safe reference reconstruction: use selected reference frames only as non-identifying creative evidence.",
"Do not copy the original person, face, biometric identity, unique likeness, watermark, platform UI, captions, exact outfit, exact background, exact composition, or source pixels.",
`Generate exactly ${RECONSTRUCTION_SUBJECT_VIEW_VALUES.length} separate views of one newly designed subject.`,
"Keep the neck, collarbone, shoulders, upper back, and side neck clean and usable for SKG neck-and-shoulder product placement.",
]
if (mode === "realistic") {
common.push(
"Direction mode: realistic human reconstruction.",
"Create a new believable commercial ad actor inspired by broad non-identifying traits from the references: role, body-proportion category, gesture vocabulary, wardrobe category, health-ad energy, and camera readability.",
"Change the exact identity and personal features clearly enough that this is a new actor, not the source person.",
)
} else if (mode === "cartoon") {
common.push(
"Direction mode: cartoon reconstruction.",
`Cartoon style: ${style.label}; ${style.prompt}.`,
"Transform broad pose, emotion, body-readability, and ad energy into a fully original stylized character, not a realistic human and not a traced version of the source.",
)
} else if (mode === "elements") {
common.push(
"Direction mode: element reconstruction.",
"Extract only abstract visual logic: pose grammar, silhouette category, color-block relationship, camera angle, motion feeling, and wellness-ad atmosphere.",
"Create a clearly different new subject with different identity, wardrobe details, face, styling, and visual design while keeping the useful advertising logic.",
)
} else {
common.push(
"Direction mode: autonomous description.",
"Use the user's written description as the primary subject bible. Reference frames are optional secondary mood evidence only; if they conflict with the text, follow the text.",
"Create a fully original subject from the description without depending on source identity.",
)
}
if (trimmed) common.push(`User written direction to understand and apply: ${trimmed}`)
return common.join(" ")
}
function buildSimilarSubjectPrompt( function buildSimilarSubjectPrompt(
subjectStyle: SubjectStyleMode, subjectStyle: SubjectStyleMode,
direction: string, direction: string,
@@ -974,6 +1101,11 @@ function buildSimilarSubjectPrompt(
"Keep transparent skin, visible spine, rib cage, pelvis, arm bones, leg bones, and a friendly non-horror wellness advertising look consistent in every view.", "Keep transparent skin, visible spine, rib cage, pelvis, arm bones, leg bones, and a friendly non-horror wellness advertising look consistent in every view.",
"Do not generate a normal opaque human, skeleton-only character, medical anatomy, organs, blood, gore, surgery, hospital, or horror imagery.", "Do not generate a normal opaque human, skeleton-only character, medical anatomy, organs, blood, gore, surgery, hospital, or horror imagery.",
) )
} else if (subjectStyle === "cartoon_subject") {
base.push(
"The subject must be an original stylized cartoon or illustrative character, not a photorealistic person and not a transparent skeleton character.",
"Keep the same stylized character identity, proportions, palette, material language, and commercial wellness-ad personality consistent in every view.",
)
} else { } else {
base.push( base.push(
"The subject must be a normal believable commercial ad actor, not a transparent or skeleton character.", "The subject must be a normal believable commercial ad actor, not a transparent or skeleton character.",
@@ -1049,12 +1181,17 @@ function productModelTrace(models?: RuntimeModels): ModelTraceSpec {
} }
function similarSubjectModelTrace(models: RuntimeModels | undefined, subjectStyle: SubjectStyleMode): ModelTraceSpec { function similarSubjectModelTrace(models: RuntimeModels | undefined, subjectStyle: SubjectStyleMode): ModelTraceSpec {
const typeLabel = subjectStyle === "transparent_human"
? "透明/半透明皮肤包裹可见白色骨架"
: subjectStyle === "cartoon_subject"
? "原创卡通/插画/潮玩主体"
: "普通商业广告真人"
return { return {
title: subjectStyle === "transparent_human" ? "相似透明骨架主体" : "相似普通真人主体", title: subjectStyle === "transparent_human" ? "相似透明骨架主体" : subjectStyle === "cartoon_subject" ? "卡通重构主体" : "相似普通真人主体",
model: modelList([models?.vision, models?.subject_image]), model: modelList([models?.vision, models?.subject_image]),
chain: [ chain: [
`视觉 brief${modelValue(models?.vision)} 把关键帧/模板图转成非身份化文字 brief失败时继续用用户方向和模板文字`, `视觉 brief${modelValue(models?.vision)} 把关键帧/模板图转成非身份化文字 brief失败时继续用用户方向和模板文字`,
`主体类型:${subjectStyle === "transparent_human" ? "透明/半透明皮肤包裹可见白色骨架" : "普通商业广告真人"}`, `主体类型:${typeLabel}`,
"主体设定:前端把随机组合或手动选择的性别、年龄、着装、地域人种、肤色、体型、发型和气质锁定为结构化 profile", "主体设定:前端把随机组合或手动选择的性别、年龄、着装、地域人种、肤色、体型、发型和气质锁定为结构化 profile",
`图像生成:${subjectImageModelChain(models)} 走 /images/generations 逐张文字生图;当前 similar 模式不上传原帧或模板图作为 image-edit 参考`, `图像生成:${subjectImageModelChain(models)} 走 /images/generations 逐张文字生图;当前 similar 模式不上传原帧或模板图作为 image-edit 参考`,
"身份锁定:整套图必须是同一个主体,性别表现、年龄段、体型、材质和风格保持一致", "身份锁定:整套图必须是同一个主体,性别表现、年龄段、体型、材质和风格保持一致",
@@ -3012,50 +3149,77 @@ function SourceSubjectPipeline({
onDropFilmstripFrame?: (time: number) => void onDropFilmstripFrame?: (time: number) => void
}) { }) {
const [referenceDropActive, setReferenceDropActive] = useState(false) const [referenceDropActive, setReferenceDropActive] = useState(false)
const [conversionDropActive, setConversionDropActive] = useState(false) const [activeDropMode, setActiveDropMode] = useState<SubjectReconstructionMode | null>(null)
const [conversionFrameIndices, setConversionFrameIndices] = useState<number[]>([]) const [conversionFrameIndicesByMode, setConversionFrameIndicesByMode] = useState<Record<SubjectReconstructionMode, number[]>>(() => ({ ...EMPTY_RECONSTRUCTION_FRAME_MAP }))
const [subjectStyle, setSubjectStyle] = useState<SubjectStyleMode>("transparent_human") const [reconstructionDirections, setReconstructionDirections] = useState<Record<SubjectReconstructionMode, string>>(() => ({ ...DEFAULT_RECONSTRUCTION_DIRECTIONS }))
const [subjectViewMode, setSubjectViewMode] = useState<SubjectPipelineViewMode>("all") const [cartoonStyle, setCartoonStyle] = useState<CartoonReconstructionStyle>("3d_animation")
const [subjectDirection, setSubjectDirection] = useState("") const [cartoonStyleOpen, setCartoonStyleOpen] = useState(false)
const [subjectBusyFor, setSubjectBusyFor] = useState<{ jobId: string; jobLabel: string; viewCount: number; sourceCount: number; profileLabel: string } | null>(null) const [subjectBusyFor, setSubjectBusyFor] = useState<{ jobId: string; jobLabel: string; mode: SubjectReconstructionMode; viewCount: number; sourceCount: number; profileLabel: string } | null>(null)
const [subjectAssetBusy, setSubjectAssetBusy] = useState<string | null>(null) const [subjectAssetBusy, setSubjectAssetBusy] = useState<string | null>(null)
const [lastSubjectProfile, setLastSubjectProfile] = useState<ResolvedSubjectProfile | null>(null) const [lastSubjectProfile, setLastSubjectProfile] = useState<ResolvedSubjectProfile | null>(null)
const subjectBusy = !!subjectBusyFor const subjectBusy = !!subjectBusyFor
const selectedSubjectViews = subjectViewMode === "common" const selectedSubjectViews = RECONSTRUCTION_SUBJECT_VIEW_VALUES
? COMMON_SUBJECT_VIEW_VALUES const conversionFramesByMode = useMemo(() => {
: SUBJECT_ASSET_VIEWS.map((view) => view.value) const next = {} as Record<SubjectReconstructionMode, KeyFrame[]>
const conversionFrames = useMemo( for (const config of RECONSTRUCTION_MODES) {
() => conversionFrameIndices next[config.value] = conversionFrameIndicesByMode[config.value]
.map((index) => frames.find((frame) => frame.index === index)) .map((index) => frames.find((frame) => frame.index === index))
.filter((frame): frame is KeyFrame => !!frame), .filter((frame): frame is KeyFrame => !!frame)
[conversionFrameIndices, frames],
)
const actorSource = useMemo(
() => findSimilarActorSource(conversionFrames.length ? conversionFrames : frames, frames),
[conversionFrames, frames],
)
const visibleActorAssets = useMemo(() => {
const latestByView = new Map<string, SubjectAsset>()
for (const asset of actorSource?.element.subject_assets ?? []) {
const current = latestByView.get(asset.view)
if (!current || (asset.created_at || 0) >= (current.created_at || 0)) latestByView.set(asset.view, asset)
} }
return [...latestByView.values()].sort((a, b) => { return next
const ai = SUBJECT_VIEW_ORDER.indexOf(a.view) }, [conversionFrameIndicesByMode, frames])
const bi = SUBJECT_VIEW_ORDER.indexOf(b.view) const allConversionFrameIndices = useMemo(
() => new Set(Object.values(conversionFrameIndicesByMode).flat()),
[conversionFrameIndicesByMode],
)
const actorSources = useMemo(() => {
const items: Array<{ frame: KeyFrame; element: KeyElement; mode: SubjectReconstructionMode }> = []
for (const frame of frames) {
for (const element of frame.elements || []) {
const mode = reconstructionModeFromElement(element) ?? (isSimilarActorElement(element) ? "realistic" : null)
if (mode && element.subject_assets?.length) items.push({ frame, element, mode })
}
}
return items
}, [frames])
const visibleActorAssets = useMemo(() => {
const items: Array<{ frame: KeyFrame; element: KeyElement; mode: SubjectReconstructionMode; asset: SubjectAsset }> = []
for (const source of actorSources) {
const latestByView = new Map<string, SubjectAsset>()
for (const asset of source.element.subject_assets ?? []) {
const current = latestByView.get(asset.view)
if (!current || (asset.created_at || 0) >= (current.created_at || 0)) latestByView.set(asset.view, asset)
}
for (const asset of latestByView.values()) items.push({ ...source, asset })
}
return items.sort((a, b) => {
const mi = RECONSTRUCTION_MODES.findIndex((item) => item.value === a.mode)
const mj = RECONSTRUCTION_MODES.findIndex((item) => item.value === b.mode)
if (mi !== mj) return mi - mj
const ai = SUBJECT_VIEW_ORDER.indexOf(a.asset.view)
const bi = SUBJECT_VIEW_ORDER.indexOf(b.asset.view)
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi) return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi)
}) })
}, [actorSource]) }, [actorSources])
useEffect(() => { useEffect(() => {
setConversionFrameIndices([]) setConversionFrameIndicesByMode({ ...EMPTY_RECONSTRUCTION_FRAME_MAP })
setReconstructionDirections({ ...DEFAULT_RECONSTRUCTION_DIRECTIONS })
setLastSubjectProfile(null) setLastSubjectProfile(null)
setSubjectBusyFor(null) setSubjectBusyFor(null)
setSubjectAssetBusy(null) setSubjectAssetBusy(null)
setActiveDropMode(null)
setCartoonStyleOpen(false)
}, [job.id]) }, [job.id])
useEffect(() => { useEffect(() => {
setConversionFrameIndices((current) => current.filter((index) => frames.some((frame) => frame.index === index))) setConversionFrameIndicesByMode((current) => {
const next = {} as Record<SubjectReconstructionMode, number[]>
for (const config of RECONSTRUCTION_MODES) {
next[config.value] = current[config.value].filter((index) => frames.some((frame) => frame.index === index))
}
return next
})
}, [frames]) }, [frames])
const buildSubjectProfileForRequest = () => { const buildSubjectProfileForRequest = () => {
@@ -3064,7 +3228,7 @@ function SourceSubjectPipeline({
return resolved return resolved
} }
const generateSubjectPack = async (sourceIndices = conversionFrameIndices) => { const generateSubjectPack = async (mode: SubjectReconstructionMode, sourceIndices = conversionFrameIndicesByMode[mode]) => {
if (subjectBusyFor) { if (subjectBusyFor) {
toast.warning("主体套图正在生成中,完成后再重生。") toast.warning("主体套图正在生成中,完成后再重生。")
return return
@@ -3072,34 +3236,44 @@ function SourceSubjectPipeline({
const sourceFrames = sourceIndices const sourceFrames = sourceIndices
.map((index) => frames.find((frame) => frame.index === index)) .map((index) => frames.find((frame) => frame.index === index))
.filter((frame): frame is KeyFrame => !!frame) .filter((frame): frame is KeyFrame => !!frame)
if (!sourceFrames.length) { if (!sourceFrames.length && mode !== "custom") {
toast.warning("先把参考帧拖到转换层。") toast.warning(`先把参考帧拖到${reconstructionModeConfig(mode).label}`)
return
}
const baseFrame = sourceFrames[0] ?? frames[0]
if (!baseFrame) {
toast.warning("先完成抽帧,或从胶片加入至少一张参考帧。")
return return
} }
const baseFrame = sourceFrames[0]
const requestJobId = job.id const requestJobId = job.id
const requestProfile = buildSubjectProfileForRequest() const requestProfile = mode === "custom" && reconstructionDirections.custom.trim()
? null
: buildSubjectProfileForRequest()
const subjectStyle = reconstructionSubjectStyle(mode)
const userDirection = buildReconstructionDirection(mode, reconstructionDirections[mode], cartoonStyle)
const modeName = reconstructionElementName(mode)
setSubjectBusyFor({ setSubjectBusyFor({
jobId: requestJobId, jobId: requestJobId,
jobLabel: shortId(requestJobId), jobLabel: shortId(requestJobId),
mode,
viewCount: selectedSubjectViews.length, viewCount: selectedSubjectViews.length,
sourceCount: sourceFrames.length, sourceCount: sourceFrames.length,
profileLabel: requestProfile.summary, 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
let element = workingFrame.elements?.find(isSimilarActorElement) let element = workingFrame.elements?.find((item) => reconstructionModeFromElement(item) === mode)
if (!element) { if (!element) {
workingJob = await addElement(requestJobId, baseFrame.index, { workingJob = await addElement(requestJobId, baseFrame.index, {
name_zh: subjectStyle === "transparent_human" ? "参考创新透明骨架主体" : "参考创新广告主角", name_zh: modeName.zh,
name_en: subjectStyle === "transparent_human" ? "reference inspired transparent skeleton humanoid subject" : "reference inspired ad actor", name_en: modeName.en,
position: "generated from conversion layer reference frames", position: `${reconstructionModeConfig(mode).label} · generated from conversion layer references`,
source: "manual", source: "manual",
}) })
onJobUpdate(workingJob) onJobUpdate(workingJob)
workingFrame = workingJob.frames.find((frame) => frame.index === baseFrame.index) ?? workingFrame workingFrame = workingJob.frames.find((frame) => frame.index === baseFrame.index) ?? workingFrame
element = workingFrame.elements?.find(isSimilarActorElement) element = workingFrame.elements?.find((item) => reconstructionModeFromElement(item) === mode)
?? workingFrame.elements?.[workingFrame.elements.length - 1] ?? workingFrame.elements?.[workingFrame.elements.length - 1]
} }
if (!element) throw new Error("subject element missing") if (!element) throw new Error("subject element missing")
@@ -3110,64 +3284,81 @@ function SourceSubjectPipeline({
reconstruction_mode: "similar", reconstruction_mode: "similar",
background: "white", background: "white",
size: SUBJECT_ASSET_SIZE, size: SUBJECT_ASSET_SIZE,
source_frame_indices: sourceFrames.slice(0, 8).map((frame) => frame.index), source_frame_indices: sourceFrames.slice(0, RECONSTRUCTION_FRAME_LIMIT).map((frame) => frame.index),
views: selectedSubjectViews, views: selectedSubjectViews,
subject_profile: requestProfile.payload, subject_profile: requestProfile?.payload ?? null,
prompt: buildSimilarSubjectPrompt(subjectStyle, subjectDirection, null, requestProfile), prompt: buildSimilarSubjectPrompt(subjectStyle, userDirection, null, requestProfile),
replace_views: true, replace_views: true,
}) })
onJobUpdate(updated) onJobUpdate(updated)
toast.success(`主体套图已生成:${selectedSubjectViews.length}`) toast.success(`${reconstructionModeConfig(mode).label}已生成:${selectedSubjectViews.length}`)
} catch (e) { } catch (e) {
try { try {
onJobUpdate(await getJob(requestJobId)) onJobUpdate(await getJob(requestJobId))
} catch { /* keep original error visible */ } } catch { /* keep original error visible */ }
toast.error("主体套图生成失败:" + (e instanceof Error ? e.message : String(e))) toast.error(`${reconstructionModeConfig(mode).label}生成失败:` + (e instanceof Error ? e.message : String(e)))
} finally { } finally {
setSubjectBusyFor(null) setSubjectBusyFor(null)
} }
} }
const addConversionFrame = (frame: KeyFrame) => { const addConversionFrame = (mode: SubjectReconstructionMode, frame: KeyFrame) => {
const existed = conversionFrameIndices.includes(frame.index) const current = conversionFrameIndicesByMode[mode]
const existed = current.includes(frame.index)
const next = existed const next = existed
? conversionFrameIndices ? current
: [...conversionFrameIndices, frame.index].slice(0, 6) : current.length >= RECONSTRUCTION_FRAME_LIMIT
setConversionFrameIndices(next) ? [...current.slice(1), frame.index]
: [...current, frame.index]
setConversionFrameIndicesByMode((state) => ({ ...state, [mode]: next }))
if (existed) { if (existed) {
toast.info("这张参考帧已经在转换层。") toast.info(`这张参考帧已经在${reconstructionModeConfig(mode).label}里。`)
return return
} }
toast.info(`已加入转换层:${frame.timestamp.toFixed(1)}s开始生成主体套图。`) if (current.length >= RECONSTRUCTION_FRAME_LIMIT) {
void generateSubjectPack(next) toast.warning(`${reconstructionModeConfig(mode).label}最多保留 ${RECONSTRUCTION_FRAME_LIMIT} 张参考帧,已替换为最近拖入的组合。`)
}
toast.info(`已加入${reconstructionModeConfig(mode).label}${frame.timestamp.toFixed(1)}s开始生成 6 视图。`)
void generateSubjectPack(mode, next)
} }
const removeConversionFrame = (frameIndex: number) => { const removeConversionFrame = (mode: SubjectReconstructionMode, frameIndex: number) => {
setConversionFrameIndices((current) => current.filter((index) => index !== frameIndex)) setConversionFrameIndicesByMode((state) => ({
...state,
[mode]: state[mode].filter((index) => index !== frameIndex),
}))
} }
const regenerateSubjectAsset = async (asset: SubjectAsset) => { const regenerateSubjectAsset = async (item: { frame: KeyFrame; element: KeyElement; mode: SubjectReconstructionMode; asset: SubjectAsset }) => {
if (!actorSource) return const { frame, element, mode, asset } = item
const sourceIndices = asset.source_frame_indices?.length const sourceIndices = asset.source_frame_indices?.length
? asset.source_frame_indices ? asset.source_frame_indices
: conversionFrames.map((frame) => frame.index) : conversionFrameIndicesByMode[mode]
if (!sourceIndices.length) { if (!sourceIndices.length && mode !== "custom") {
toast.warning("转换层没有参考帧,不能重生。") toast.warning("转换层没有参考帧,不能重生。")
return return
} }
setSubjectAssetBusy(`regen:${asset.id}`) setSubjectAssetBusy(`regen:${asset.id}`)
try { try {
const requestProfile = lastSubjectProfile ?? buildSubjectProfileForRequest() const requestProfile = mode === "custom" && reconstructionDirections.custom.trim()
const updated = await generateSubjectAssets(job.id, actorSource.frame.index, actorSource.element.id, { ? null
: lastSubjectProfile ?? buildSubjectProfileForRequest()
const subjectStyle = reconstructionSubjectStyle(mode)
const updated = await generateSubjectAssets(job.id, frame.index, element.id, {
subject_kind: "living", subject_kind: "living",
subject_style: subjectStyle, subject_style: subjectStyle,
reconstruction_mode: "similar", reconstruction_mode: "similar",
background: asset.background || "white", background: asset.background || "white",
size: SUBJECT_ASSET_SIZE, size: SUBJECT_ASSET_SIZE,
source_frame_indices: sourceIndices, source_frame_indices: sourceIndices.slice(0, RECONSTRUCTION_FRAME_LIMIT),
views: [asset.view], views: [asset.view],
subject_profile: requestProfile.payload, subject_profile: requestProfile?.payload ?? null,
prompt: buildSimilarSubjectPrompt(subjectStyle, subjectDirection, null, requestProfile), prompt: buildSimilarSubjectPrompt(
subjectStyle,
buildReconstructionDirection(mode, reconstructionDirections[mode], cartoonStyle),
null,
requestProfile,
),
replace_views: true, replace_views: true,
}) })
onJobUpdate(updated) onJobUpdate(updated)
@@ -3179,11 +3370,11 @@ function SourceSubjectPipeline({
} }
} }
const deleteActorAsset = async (asset: SubjectAsset) => { const deleteActorAsset = async (item: { frame: KeyFrame; element: KeyElement; asset: SubjectAsset }) => {
if (!actorSource) return const { frame, element, asset } = item
setSubjectAssetBusy(`delete:${asset.id}`) setSubjectAssetBusy(`delete:${asset.id}`)
try { try {
const updated = await deleteSubjectAsset(job.id, actorSource.frame.index, actorSource.element.id, asset.id) const updated = await deleteSubjectAsset(job.id, frame.index, element.id, asset.id)
onJobUpdate(updated) onJobUpdate(updated)
toast.success("主体元素已删除") toast.success("主体元素已删除")
} catch (e) { } catch (e) {
@@ -3269,11 +3460,11 @@ function SourceSubjectPipeline({
previewPlacement="left" previewPlacement="left"
previewMaxWidth={320} previewMaxWidth={320}
previewClassName="p-2" previewClassName="p-2"
selected={selected || conversionFrameIndices.includes(frame.index)} selected={selected || allConversionFrameIndices.has(frame.index)}
title={`${selected ? "已选 · 点击取消" : "点击选择"} · 拖到转换层生成主体套图`} title={`${selected ? "已选 · 点击取消" : "点击选择"} · 拖到转换层生成主体套图`}
onClick={() => onToggleFrame(frame.index)} onClick={() => onToggleFrame(frame.index)}
topLeft={<span className="rounded bg-black/72 px-1 font-mono text-[9px] text-white/70">{String(index + 1).padStart(2, "0")}</span>} topLeft={<span className="rounded bg-black/72 px-1 font-mono text-[9px] text-white/70">{String(index + 1).padStart(2, "0")}</span>}
topRight={<span className="rounded-full bg-black/72 p-0.5">{conversionFrameIndices.includes(frame.index) ? <Sparkles className="h-3 w-3 text-[#f5d98e]" /> : selected ? <Check className="h-3 w-3 text-emerald-200" /> : <Circle className="h-3 w-3 text-white/50" />}</span>} topRight={<span className="rounded-full bg-black/72 p-0.5">{allConversionFrameIndices.has(frame.index) ? <Sparkles className="h-3 w-3 text-[#f5d98e]" /> : selected ? <Check className="h-3 w-3 text-emerald-200" /> : <Circle className="h-3 w-3 text-white/50" />}</span>}
onDelete={onDeleteFrame ? () => onDeleteFrame(frame.index) : undefined} onDelete={onDeleteFrame ? () => onDeleteFrame(frame.index) : undefined}
deleting={deletingFrame === frame.index} deleting={deletingFrame === frame.index}
deleteLabel={`删除参考帧 ${index + 1}`} deleteLabel={`删除参考帧 ${index + 1}`}
@@ -3293,119 +3484,138 @@ function SourceSubjectPipeline({
<div className="min-w-0"> <div className="min-w-0">
<div className="mb-2 flex items-center justify-between gap-2"> <div className="mb-2 flex items-center justify-between gap-2">
<SectionTitle icon={<Wand2 className="h-4 w-4" />} title="转换层" /> <SectionTitle icon={<Wand2 className="h-4 w-4" />} title="转换层" />
<ModelTrace trace={similarSubjectModelTrace(runtimeModels, subjectStyle)} compact /> <ModelTrace trace={similarSubjectModelTrace(runtimeModels, subjectBusyFor?.mode === "cartoon" ? "cartoon_subject" : "source_actor")} compact />
</div> </div>
<div <div className="max-h-[520px] min-h-[410px] overflow-y-auto rounded-md border border-white/10 bg-black/32 p-2 2xl:max-h-[600px] 2xl:min-h-[500px]">
className={`min-h-[410px] rounded-md border p-2 transition 2xl:min-h-[500px] ${
conversionDropActive ? "border-[#d6b36a]/80 bg-[#d6b36a]/12 ring-1 ring-[#d6b36a]/45" : "border-white/10 bg-black/32"
}`}
onDragEnter={(event) => {
if (!Array.from(event.dataTransfer.types).includes(SOURCE_KEYFRAME_DRAG_TYPE)) return
event.preventDefault()
setConversionDropActive(true)
}}
onDragOver={(event) => {
if (!Array.from(event.dataTransfer.types).includes(SOURCE_KEYFRAME_DRAG_TYPE)) return
event.preventDefault()
event.dataTransfer.dropEffect = "copy"
}}
onDragLeave={(event) => {
const next = event.relatedTarget as Node | null
if (next && event.currentTarget.contains(next)) return
setConversionDropActive(false)
}}
onDrop={(event) => {
event.preventDefault()
setConversionDropActive(false)
const frameIndex = Number(event.dataTransfer.getData(SOURCE_KEYFRAME_DRAG_TYPE))
const frame = frames.find((item) => item.index === frameIndex)
if (frame) addConversionFrame(frame)
}}
>
<div className="mb-2 rounded-md border border-[#d6b36a]/18 bg-[#d6b36a]/[0.06] px-2.5 py-2 text-[10px] leading-snug text-white/62"> <div className="mb-2 rounded-md border border-[#d6b36a]/18 bg-[#d6b36a]/[0.06] px-2.5 py-2 text-[10px] leading-snug text-white/62">
1-2 1-3
</div> </div>
<div className="mb-2 flex max-h-[224px] flex-col gap-1.5 overflow-y-auto pr-0.5 2xl:max-h-[286px]">
{conversionFrames.map((frame, index) => (
<div key={frame.index} className="relative">
<MediaAssetTile
src={effectiveFrameUrl(job.id, frame)}
alt={`转换参考 ${index + 1}`}
label={`转换参考 ${index + 1}`}
meta={`${frame.timestamp.toFixed(1)}s`}
className="mx-auto aspect-[9/16] w-[72px] 2xl:w-[80px]"
objectFit="contain"
disablePreview
topLeft={<span className="rounded bg-black/72 px-1 font-mono text-[9px] text-white/70">{String(index + 1).padStart(2, "0")}</span>}
/>
<button
type="button"
onClick={() => removeConversionFrame(frame.index)}
className="absolute right-1 top-1 z-20 inline-flex h-5 w-5 items-center justify-center rounded-full border border-rose-100/35 bg-black/78 text-rose-100 transition hover:border-rose-100/70 hover:bg-rose-500/25"
aria-label="移出转换层"
title="移出转换层"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
))}
{!conversionFrames.length ? (
<div className="flex h-28 items-center justify-center rounded border border-dashed border-white/12 px-3 text-center text-[10.5px] leading-snug text-white/34">
</div>
) : null}
</div>
<div className="space-y-2"> <div className="space-y-2">
<div className="grid grid-cols-2 gap-1"> {RECONSTRUCTION_MODES.map((modeConfig) => {
{[ const mode = modeConfig.value
{ value: "transparent_human" as const, label: "透明骨架" }, const modeFrames = conversionFramesByMode[mode]
{ value: "source_actor" as const, label: "真人" }, const dropActive = activeDropMode === mode
].map((item) => ( const canGenerate = mode === "custom"
<button ? Boolean(reconstructionDirections.custom.trim() || modeFrames.length)
key={item.value} : modeFrames.length > 0
type="button" return (
onClick={() => setSubjectStyle(item.value)} <div
className={`h-8 rounded-md border px-2 text-[10.5px] font-semibold transition ${ key={mode}
subjectStyle === item.value ? "border-white bg-white text-black" : "border-white/10 bg-black/24 text-white/45 hover:text-white" className={`rounded-md border p-2 transition ${
dropActive ? "border-[#d6b36a]/80 bg-[#d6b36a]/12 ring-1 ring-[#d6b36a]/45" : "border-white/10 bg-black/24"
}`} }`}
onDragEnter={(event) => {
if (!Array.from(event.dataTransfer.types).includes(SOURCE_KEYFRAME_DRAG_TYPE)) return
event.preventDefault()
setActiveDropMode(mode)
}}
onDragOver={(event) => {
if (!Array.from(event.dataTransfer.types).includes(SOURCE_KEYFRAME_DRAG_TYPE)) return
event.preventDefault()
event.dataTransfer.dropEffect = "copy"
}}
onDragLeave={(event) => {
const next = event.relatedTarget as Node | null
if (next && event.currentTarget.contains(next)) return
setActiveDropMode(null)
}}
onDrop={(event) => {
event.preventDefault()
setActiveDropMode(null)
const frameIndex = Number(event.dataTransfer.getData(SOURCE_KEYFRAME_DRAG_TYPE))
const frame = frames.find((item) => item.index === frameIndex)
if (frame) addConversionFrame(mode, frame)
}}
> >
{item.label} <div className="flex items-start justify-between gap-2">
</button> <div className="min-w-0">
))} <div className="text-[11px] font-semibold text-white">{modeConfig.label}</div>
</div> <div className="mt-0.5 text-[9.5px] leading-snug text-white/42">{modeConfig.subtitle}</div>
<div className="grid grid-cols-2 gap-1"> </div>
{[ <span className="shrink-0 rounded-full border border-white/10 bg-black/35 px-1.5 py-0.5 font-mono text-[9px] text-white/42">
{ value: "all" as const, label: `完整 ${SUBJECT_ASSET_VIEWS.length}` }, {modeFrames.length}/{RECONSTRUCTION_FRAME_LIMIT}
{ value: "common" as const, label: `常用 ${COMMON_SUBJECT_VIEW_VALUES.length}` }, </span>
].map((item) => ( </div>
<button <div className="mt-2 flex min-h-[56px] items-center gap-1 overflow-x-auto pb-0.5">
key={item.value} {modeFrames.map((frame, index) => (
type="button" <div key={frame.index} className="relative shrink-0">
onClick={() => setSubjectViewMode(item.value)} <MediaAssetTile
className={`h-8 rounded-md border px-2 text-[10.5px] font-semibold transition ${ src={effectiveFrameUrl(job.id, frame)}
subjectViewMode === item.value ? "border-white bg-white text-black" : "border-white/10 bg-black/24 text-white/45 hover:text-white" alt={`${modeConfig.label}参考 ${index + 1}`}
}`} label={String(index + 1).padStart(2, "0")}
> meta={`${frame.timestamp.toFixed(1)}s`}
{item.label} className="aspect-[9/16] w-[34px] 2xl:w-[38px]"
</button> objectFit="contain"
))} disablePreview
</div> topLeft={<span className="rounded bg-black/72 px-0.5 font-mono text-[8px] text-white/70">{String(index + 1).padStart(2, "0")}</span>}
<input />
value={subjectDirection} <button
onChange={(event) => setSubjectDirection(event.target.value)} type="button"
placeholder="统一方向:更年轻 / 更高级 / 运动感" onClick={() => removeConversionFrame(mode, frame.index)}
className="h-9 w-full rounded-md border border-white/10 bg-black/35 px-2.5 text-[11px] text-white outline-none placeholder:text-white/28 focus:border-cyan-300/50" className="absolute -right-1 -top-1 z-20 inline-flex h-4 w-4 items-center justify-center rounded-full border border-rose-100/35 bg-black/82 text-rose-100 transition hover:border-rose-100/70 hover:bg-rose-500/25"
/> aria-label="移出转换层参考"
<button title="移出转换层参考"
type="button" >
onClick={() => void generateSubjectPack()} <Trash2 className="h-2.5 w-2.5" />
disabled={!conversionFrames.length || subjectBusy || !selectedSubjectViews.length} </button>
className="skg-primary-action inline-flex h-9 w-full items-center justify-center gap-1 px-3 text-[11px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40" </div>
> ))}
{subjectBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />} {!modeFrames.length ? (
{subjectBusyFor ? `生成中 · ${subjectBusyFor.sourceCount} 参考` : `生成 ${selectedSubjectViews.length} 张主体套图`} <div className="flex min-h-[48px] flex-1 items-center justify-center rounded border border-dashed border-white/12 px-2 text-center text-[10px] leading-snug text-white/34">
</button> {mode === "custom" ? "可只写描述,也可拖入参考。" : "把参考帧拖到这里。"}
</div>
) : null}
</div>
{mode === "cartoon" ? (
<div className="mt-2">
<button
type="button"
onClick={() => setCartoonStyleOpen((open) => !open)}
className="inline-flex h-7 w-full items-center justify-between rounded-md border border-white/10 bg-black/28 px-2 text-[10px] font-semibold text-white/58 transition hover:border-white/20 hover:text-white"
>
<span>{cartoonStyleConfig(cartoonStyle).label}</span>
<ChevronDown className={`h-3.5 w-3.5 transition ${cartoonStyleOpen ? "rotate-180" : ""}`} />
</button>
{cartoonStyleOpen ? (
<div className="mt-1 grid grid-cols-2 gap-1">
{CARTOON_RECONSTRUCTION_STYLES.map((style) => (
<button
key={style.value}
type="button"
onClick={() => {
setCartoonStyle(style.value)
setCartoonStyleOpen(false)
}}
className={`h-7 rounded-md border px-1.5 text-[9.5px] font-semibold transition ${
cartoonStyle === style.value ? "border-white bg-white text-black" : "border-white/10 bg-black/24 text-white/45 hover:text-white"
}`}
>
{style.label}
</button>
))}
</div>
) : null}
</div>
) : null}
<textarea
value={reconstructionDirections[mode]}
onChange={(event) => setReconstructionDirections((current) => ({ ...current, [mode]: event.target.value }))}
placeholder={modeConfig.placeholder}
rows={2}
className="mt-2 min-h-[48px] w-full resize-none rounded-md border border-white/10 bg-black/35 px-2.5 py-2 text-[10.5px] leading-snug text-white outline-none placeholder:text-white/28 focus:border-cyan-300/50"
/>
<button
type="button"
onClick={() => void generateSubjectPack(mode)}
disabled={subjectBusy || !canGenerate}
className="skg-primary-action mt-2 inline-flex h-8 w-full items-center justify-center gap-1 px-3 text-[10.5px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40"
>
{subjectBusyFor?.mode === mode ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
{subjectBusyFor?.mode === mode ? `生成中 · ${subjectBusyFor.sourceCount || "描述"} 参考` : `生成${modeConfig.label} 6视图`}
</button>
</div>
)
})}
{lastSubjectProfile ? ( {lastSubjectProfile ? (
<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/62"> <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/62">
{lastSubjectProfile.summary} {lastSubjectProfile.summary}
@@ -3425,39 +3635,54 @@ function SourceSubjectPipeline({
<div className="min-h-[410px] rounded-md border border-white/10 bg-black/32 p-2 2xl:min-h-[500px]"> <div className="min-h-[410px] rounded-md border border-white/10 bg-black/32 p-2 2xl:min-h-[500px]">
{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.viewCount} {subjectBusyFor.sourceCount} {reconstructionModeConfig(subjectBusyFor.mode).label} {subjectBusyFor.viewCount} {subjectBusyFor.sourceCount || "自主描述"}
<span className="mt-1 block text-cyan-50/58">{subjectBusyFor.profileLabel}</span> <span className="mt-1 block text-cyan-50/58">{subjectBusyFor.profileLabel}</span>
</div> </div>
) : null} ) : null}
{visibleActorAssets.length ? ( {visibleActorAssets.length ? (
<div className="grid grid-cols-[repeat(auto-fill,minmax(76px,1fr))] gap-2 2xl:grid-cols-[repeat(auto-fill,minmax(88px,1fr))]"> <div className="space-y-3">
{visibleActorAssets.map((asset) => { {RECONSTRUCTION_MODES.map((modeConfig) => {
const busyMode = subjectAssetBusy?.endsWith(asset.id) ? subjectAssetBusy.split(":")[0] : "" const items = visibleActorAssets.filter((item) => item.mode === modeConfig.value)
if (!items.length) return null
return ( return (
<MediaAssetTile <div key={modeConfig.value} className="space-y-1.5">
key={asset.id} <div className="flex items-center justify-between gap-2 text-[10px] text-white/44">
src={subjectAssetUrl(job, asset)} <span>{modeConfig.label}</span>
href={subjectAssetUrl(job, asset)} <span>{items.length} </span>
alt={asset.label || asset.view} </div>
label={asset.label || subjectViewLabel(asset.view)} <div className="grid grid-cols-[repeat(auto-fill,minmax(76px,1fr))] gap-2 2xl:grid-cols-[repeat(auto-fill,minmax(88px,1fr))]">
meta={asset.width && asset.height ? `${asset.width}x${asset.height}` : undefined} {items.map((item) => {
className="aspect-[9/16] bg-white" const { asset } = item
objectFit="contain" const busyMode = subjectAssetBusy?.endsWith(asset.id) ? subjectAssetBusy.split(":")[0] : ""
title={asset.label || subjectViewLabel(asset.view)} return (
actions={[{ <MediaAssetTile
key: "regen", key={asset.id}
label: "重新生成这一张", src={subjectAssetUrl(job, asset)}
icon: <RefreshCw className="h-3 w-3" />, href={subjectAssetUrl(job, asset)}
tone: "cyan", alt={asset.label || asset.view}
busy: busyMode === "regen", label={asset.label || subjectViewLabel(asset.view)}
disabled: !!subjectAssetBusy || subjectBusy, meta={asset.width && asset.height ? `${asset.width}x${asset.height}` : undefined}
onClick: () => void regenerateSubjectAsset(asset), className="aspect-[9/16] bg-white"
}]} objectFit="contain"
onDelete={() => void deleteActorAsset(asset)} title={asset.label || subjectViewLabel(asset.view)}
deleting={busyMode === "delete"} actions={[{
deleteDisabled={!!subjectAssetBusy || subjectBusy} key: "regen",
deleteLabel="删除这一张" label: "重新生成这一张",
/> icon: <RefreshCw className="h-3 w-3" />,
tone: "cyan",
busy: busyMode === "regen",
disabled: !!subjectAssetBusy || subjectBusy,
onClick: () => void regenerateSubjectAsset(item),
}]}
onDelete={() => void deleteActorAsset(item)}
deleting={busyMode === "delete"}
deleteDisabled={!!subjectAssetBusy || subjectBusy}
deleteLabel="删除这一张"
/>
)
})}
</div>
</div>
) )
})} })}
</div> </div>

View File

@@ -128,7 +128,7 @@ export interface AssetLibraryItem {
is_official?: boolean is_official?: boolean
prompt_brief?: string prompt_brief?: string
prompt_brief_zh?: string prompt_brief_zh?: string
subject_style?: "transparent_human" | "source_actor" subject_style?: "transparent_human" | "source_actor" | "cartoon_subject"
product_type?: string product_type?: string
views?: AssetLibraryImage[] views?: AssetLibraryImage[]
images?: AssetLibraryImage[] images?: AssetLibraryImage[]
@@ -488,7 +488,7 @@ export async function saveSubjectTemplate(
frame_idx: number frame_idx: number
element_id: string element_id: string
asset_ids: string[] asset_ids: string[]
subject_style?: "transparent_human" | "source_actor" subject_style?: "transparent_human" | "source_actor" | "cartoon_subject"
}, },
): Promise<SubjectTemplateItem> { ): Promise<SubjectTemplateItem> {
const res = await fetch(`${API_BASE}/jobs/${jobId}/subject-templates`, { const res = await fetch(`${API_BASE}/jobs/${jobId}/subject-templates`, {
@@ -838,7 +838,7 @@ export interface SubjectTemplateItem {
source_job_id: string source_job_id: string
source_frame_idx: number source_frame_idx: number
source_element_id: string source_element_id: string
subject_style: "transparent_human" | "source_actor" subject_style: "transparent_human" | "source_actor" | "cartoon_subject"
primary_image: string primary_image: string
images: SubjectTemplateImage[] images: SubjectTemplateImage[]
created_at: number created_at: number
@@ -1495,7 +1495,7 @@ export async function generateSubjectAssets(
views?: string[] views?: string[]
character_id?: string character_id?: string
subject_template_id?: string subject_template_id?: string
subject_style?: "transparent_human" | "source_actor" subject_style?: "transparent_human" | "source_actor" | "cartoon_subject"
reconstruction_mode?: "same" | "similar" reconstruction_mode?: "same" | "similar"
subject_profile?: SubjectProfilePreference | null subject_profile?: SubjectProfilePreference | null
prompt?: string prompt?: string