fix: isolate subject reference generation

This commit is contained in:
2026-05-20 11:39:33 +08:00
parent e64bf40267
commit 7acbfd5214
3 changed files with 160 additions and 57 deletions

View File

@@ -653,10 +653,14 @@ function emptySubjectPromptMemory(): Record<SubjectReconstructionMode, string[]>
return { realistic: [], cartoon: [], elements: [], custom: [] }
}
function loadSubjectPromptMemory(): Record<SubjectReconstructionMode, string[]> {
function subjectScopedStorageKey(baseKey: string, jobId: string) {
return `${baseKey}:${jobId}`
}
function loadSubjectPromptMemory(jobId: string): 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 parsed = JSON.parse(window.localStorage.getItem(subjectScopedStorageKey(SUBJECT_PROMPT_MEMORY_KEY, jobId)) || "{}") 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) : []
@@ -667,25 +671,25 @@ function loadSubjectPromptMemory(): Record<SubjectReconstructionMode, string[]>
}
}
function saveSubjectPromptMemory(memory: Record<SubjectReconstructionMode, string[]>) {
function saveSubjectPromptMemory(jobId: string, memory: Record<SubjectReconstructionMode, string[]>) {
if (typeof window === "undefined") return
try {
window.localStorage.setItem(SUBJECT_PROMPT_MEMORY_KEY, JSON.stringify(memory))
window.localStorage.setItem(subjectScopedStorageKey(SUBJECT_PROMPT_MEMORY_KEY, jobId), JSON.stringify(memory))
} catch {
/* localStorage may be unavailable */
}
}
function loadSubjectImageModelPreference(): SubjectImageModelPreference {
function loadSubjectImageModelPreference(jobId: string): SubjectImageModelPreference {
if (typeof window === "undefined") return "auto"
const raw = window.localStorage.getItem(SUBJECT_MODEL_MEMORY_KEY)
const raw = window.localStorage.getItem(subjectScopedStorageKey(SUBJECT_MODEL_MEMORY_KEY, jobId))
return SUBJECT_IMAGE_MODEL_OPTIONS.some((item) => item.value === raw) ? raw as SubjectImageModelPreference : "auto"
}
function saveSubjectImageModelPreference(value: SubjectImageModelPreference) {
function saveSubjectImageModelPreference(jobId: string, value: SubjectImageModelPreference) {
if (typeof window === "undefined") return
try {
window.localStorage.setItem(SUBJECT_MODEL_MEMORY_KEY, value)
window.localStorage.setItem(subjectScopedStorageKey(SUBJECT_MODEL_MEMORY_KEY, jobId), value)
} catch {
/* localStorage may be unavailable */
}
@@ -1216,6 +1220,24 @@ function buildSimilarSubjectPrompt(
return base.join(" ")
}
function buildSourceLockedSubjectPrompt(subjectStyle: SubjectStyleMode) {
const base = [
"Source-locked subject replication from the selected reference frames.",
"Use the attached reference frame(s) as the primary visual source for the same visible subject: preserve gender presentation, regional/ethnic appearance category, skin-tone family, body proportions, hair length/color/silhouette, face structure impression, wardrobe category, outfit colors, fit, and commercial role as closely as the model allows.",
"Generate separate clean white-background multi-view assets of that same source subject, removing only source video background, platform UI, captions, watermarks, compression artifacts, and accidental occlusions.",
"Do not invent a different actor, different ethnicity, different gender, different body type, different hair design, or different outfit when the reference evidence is visible.",
"If multiple frames are supplied, treat them as evidence for one same subject and build one locked subject bible before rendering every view.",
"Keep the neck, collarbone, shoulders, upper back, and side neck clean and usable for SKG neck-and-shoulder product placement.",
]
if (subjectStyle === "cartoon_subject") {
base.push("If a cartoon style is requested, convert the same visible source subject into one consistent stylized character while preserving the reference's main appearance and outfit cues.")
} else {
base.push("The subject must remain a believable normal commercial ad actor, not a transparent or skeleton character.")
}
base.push("Output high-definition assets; each image is one requested view of the same unified subject.")
return base.join(" ")
}
function subjectAssetUrl(job: Job, asset: SubjectAsset) {
if (!asset.url && asset.status && asset.status !== "completed") return ""
return apiAssetUrl(asset.url) || resolveImageRefUrl(job.id, { kind: "asset", frame_idx: 0, element_id: asset.id })
@@ -3287,8 +3309,8 @@ 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 [subjectImageModelPreference, setSubjectImageModelPreference] = useState<SubjectImageModelPreference>(() => loadSubjectImageModelPreference(job.id))
const [promptMemoryByMode, setPromptMemoryByMode] = useState<Record<SubjectReconstructionMode, string[]>>(() => loadSubjectPromptMemory(job.id))
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; modelLabel: string } | null>(null)
@@ -3383,6 +3405,8 @@ function SourceSubjectPipeline({
useEffect(() => {
setConversionFrameIndicesByMode({ ...EMPTY_RECONSTRUCTION_FRAME_MAP })
setReconstructionDirections({ ...DEFAULT_RECONSTRUCTION_DIRECTIONS })
setSubjectImageModelPreference(loadSubjectImageModelPreference(job.id))
setPromptMemoryByMode(loadSubjectPromptMemory(job.id))
setLastSubjectProfile(null)
setSubjectBusyFor(null)
setSubjectAssetBusy(null)
@@ -3392,12 +3416,12 @@ function SourceSubjectPipeline({
}, [job.id])
useEffect(() => {
saveSubjectImageModelPreference(subjectImageModelPreference)
}, [subjectImageModelPreference])
saveSubjectImageModelPreference(job.id, subjectImageModelPreference)
}, [job.id, subjectImageModelPreference])
useEffect(() => {
saveSubjectPromptMemory(promptMemoryByMode)
}, [promptMemoryByMode])
saveSubjectPromptMemory(job.id, promptMemoryByMode)
}, [job.id, promptMemoryByMode])
useEffect(() => {
if (expandedSubjectPackKey && !subjectAssetPacks.some((pack) => pack.key === expandedSubjectPackKey)) {
@@ -3455,17 +3479,23 @@ function SourceSubjectPipeline({
const sourceFrames = sourceIndices
.map((index) => frames.find((frame) => frame.index === index))
.filter((frame): frame is KeyFrame => !!frame)
const rawDirection = reconstructionDirections[mode].trim()
const sourceLockedReplication = mode === "custom" && !rawDirection
if (!sourceFrames.length && mode !== "custom") {
toast.warning(`先把参考帧拖到${reconstructionModeConfig(mode).label}`)
return
}
if (!sourceFrames.length && sourceLockedReplication) {
toast.warning("自主描述没有文字时,需要先拖入参考帧用于形象复刻。")
return
}
const baseFrame = sourceFrames[0] ?? frames[0]
if (!baseFrame) {
toast.warning("先完成抽帧,或从胶片加入至少一张参考帧。")
return
}
const requestJobId = job.id
const requestProfile = mode === "custom" && reconstructionDirections.custom.trim()
const requestProfile = sourceLockedReplication || (mode === "custom" && rawDirection)
? null
: buildSubjectProfileForRequest()
const subjectStyle = reconstructionSubjectStyle(mode)
@@ -3502,13 +3532,15 @@ function SourceSubjectPipeline({
const updated = await generateSubjectAssets(requestJobId, baseFrame.index, element.id, {
subject_kind: "living",
subject_style: subjectStyle,
reconstruction_mode: "similar",
reconstruction_mode: sourceLockedReplication ? "same" : "similar",
background: "white",
size: SUBJECT_ASSET_SIZE,
source_frame_indices: sourceFrames.slice(0, RECONSTRUCTION_FRAME_LIMIT).map((frame) => frame.index),
views: selectedSubjectViews,
subject_profile: requestProfile?.payload ?? null,
prompt: buildSimilarSubjectPrompt(subjectStyle, userDirection, null, requestProfile),
prompt: sourceLockedReplication
? buildSourceLockedSubjectPrompt(subjectStyle)
: 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 })}`,
@@ -3563,13 +3595,19 @@ function SourceSubjectPipeline({
const sourceIndices = asset.source_frame_indices?.length
? asset.source_frame_indices
: conversionFrameIndicesByMode[mode]
const rawDirection = reconstructionDirections[mode].trim()
const sourceLockedReplication = mode === "custom" && !rawDirection
if (!sourceIndices.length && mode !== "custom") {
toast.warning("转换层没有参考帧,不能重生。")
return
}
if (!sourceIndices.length && sourceLockedReplication) {
toast.warning("自主描述没有文字时,需要参考帧才能复刻重生。")
return
}
setSubjectAssetBusy(`regen:${asset.id}`)
try {
const requestProfile = mode === "custom" && reconstructionDirections.custom.trim()
const requestProfile = sourceLockedReplication || (mode === "custom" && rawDirection)
? null
: lastSubjectProfile ?? buildSubjectProfileForRequest()
const subjectStyle = reconstructionSubjectStyle(mode)
@@ -3577,18 +3615,20 @@ function SourceSubjectPipeline({
const updated = await generateSubjectAssets(job.id, frame.index, element.id, {
subject_kind: "living",
subject_style: subjectStyle,
reconstruction_mode: "similar",
reconstruction_mode: sourceLockedReplication ? "same" : "similar",
background: asset.background || "white",
size: SUBJECT_ASSET_SIZE,
source_frame_indices: sourceIndices.slice(0, RECONSTRUCTION_FRAME_LIMIT),
views: [asset.view],
subject_profile: requestProfile?.payload ?? null,
prompt: buildSimilarSubjectPrompt(
subjectStyle,
buildReconstructionDirection(mode, reconstructionDirections[mode], cartoonStyle),
null,
requestProfile,
),
prompt: sourceLockedReplication
? buildSourceLockedSubjectPrompt(subjectStyle)
: buildSimilarSubjectPrompt(
subjectStyle,
buildReconstructionDirection(mode, reconstructionDirections[mode], cartoonStyle),
null,
requestProfile,
),
image_model_preference: subjectImageModelPreference,
replace_views: true,
pack_id: asset.pack_id ?? "",