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