Fix prop confirmation bug, add Wan 2.7 model, refine multiple UI details, improve prop generation quality and aspect ratio, remove text overlays from Asset Center created images, and optimize prop filtering logic

This commit is contained in:
saturn
2026-04-03 22:36:41 +08:00
parent 854b932e67
commit 78b93331b4
136 changed files with 3393 additions and 875 deletions

View File

@@ -32,10 +32,15 @@ vi.mock('@/lib/llm-client', () => llmMock)
vi.mock('@/lib/llm-observe/internal-stream-context', () => ({
withInternalLLMStreamCallbacks: vi.fn(async (_callbacks: unknown, fn: () => Promise<unknown>) => await fn()),
}))
vi.mock('@/lib/constants', () => ({
getArtStylePrompt: vi.fn(() => 'cinematic style'),
removeLocationPromptSuffix: vi.fn((text: string) => text.replace(' [SUFFIX]', '')),
}))
vi.mock('@/lib/constants', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/constants')>()
return {
...actual,
getArtStylePrompt: vi.fn(() => 'cinematic style'),
removeLocationPromptSuffix: vi.fn((text: string) => text.replace(' [SUFFIX]', '')),
removePropPromptSuffix: vi.fn((text: string) => text),
}
})
vi.mock('@/lib/workers/shared', () => ({ reportTaskProgress: workerMock.reportTaskProgress }))
vi.mock('@/lib/workers/utils', () => ({ assertTaskActive: workerMock.assertTaskActive }))
vi.mock('@/lib/workers/handlers/llm-stream', () => ({
@@ -125,7 +130,8 @@ describe('worker analyze-novel behavior', () => {
props: [
{
name: '金箍棒',
summary: '一根两头包裹金片的黑铁长棍',
summary: '孙悟空随身铁棍法器',
description: '一根黑铁长棍,两端包裹金色金属箍,表面磨损发亮,杆身笔直厚重',
},
],
}))
@@ -194,7 +200,7 @@ describe('worker analyze-novel behavior', () => {
{
locationId: 'prop-new-1',
imageIndex: 0,
description: '一根两头包裹金片的黑铁长棍',
description: '一根黑铁长棍,两端包裹金色金属箍,表面磨损发亮,杆身笔直厚重',
availableSlots: '[]',
},
],

View File

@@ -1,6 +1,6 @@
import type { Job } from 'bullmq'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { CHARACTER_PROMPT_SUFFIX } from '@/lib/constants'
import { CHARACTER_ASSET_IMAGE_RATIO, CHARACTER_PROMPT_SUFFIX, PROP_IMAGE_RATIO, PROP_PROMPT_SUFFIX } from '@/lib/constants'
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
const workersUtilsMock = vi.hoisted(() => ({
@@ -27,7 +27,7 @@ const prismaMock = vi.hoisted(() => ({
}))
const sharedMock = vi.hoisted(() => ({
generateLabeledImageToCos: vi.fn(async () => 'cos/generated-character.png'),
generateCleanImageToStorage: vi.fn(async () => 'cos/generated-character.png'),
parseJsonStringArray: vi.fn(() => []),
}))
@@ -39,7 +39,7 @@ vi.mock('@/lib/workers/handlers/image-task-handler-shared', async () => {
)
return {
...actual,
generateLabeledImageToCos: sharedMock.generateLabeledImageToCos,
generateCleanImageToStorage: sharedMock.generateCleanImageToStorage,
parseJsonStringArray: sharedMock.parseJsonStringArray,
}
})
@@ -93,13 +93,19 @@ describe('asset hub character image prompt suffix regression', () => {
await handleAssetHubImageTask(job)
const generationCall = sharedMock.generateLabeledImageToCos.mock.calls[0] as unknown as [{ prompt?: string }] | undefined
const generationCall = sharedMock.generateCleanImageToStorage.mock.calls[0] as unknown as [{
prompt?: string
options?: { aspectRatio?: string }
label?: string
}] | undefined
const callArg = generationCall?.[0]
const prompt = callArg?.prompt || ''
expect(prompt).toContain('主角,黑发,冷静')
expect(prompt).toContain(CHARACTER_PROMPT_SUFFIX)
expect(countOccurrences(prompt, CHARACTER_PROMPT_SUFFIX)).toBe(1)
expect(callArg?.options).toEqual(expect.objectContaining({ aspectRatio: CHARACTER_ASSET_IMAGE_RATIO }))
expect(callArg?.label).toBeUndefined()
})
it('honors requested count for global location generation', async () => {
@@ -124,11 +130,42 @@ describe('asset hub character image prompt suffix regression', () => {
locationId: 'global-location-1',
imageCount: 1,
})
expect(sharedMock.generateLabeledImageToCos).toHaveBeenCalledTimes(1)
expect(sharedMock.generateCleanImageToStorage).toHaveBeenCalledTimes(1)
expect(prismaMock.globalLocationImage.update).toHaveBeenCalledTimes(1)
expect(prismaMock.globalLocationImage.update).toHaveBeenCalledWith({
where: { id: 'global-location-image-1' },
data: { imageUrl: 'cos/generated-character.png' },
})
})
it('keeps the prop prompt suffix in global prop generation prompts', async () => {
prismaMock.globalLocation.findFirst.mockResolvedValueOnce({
id: 'global-prop-1',
name: 'Silver Cutlery',
images: [
{
id: 'global-prop-image-1',
description: '银质餐具套装,包含刀叉与汤匙,线条简洁,金属冷白光泽',
},
],
})
await handleAssetHubImageTask(buildJob({
type: 'prop',
id: 'global-prop-1',
}))
const generationCall = sharedMock.generateCleanImageToStorage.mock.calls[0] as unknown as [{
prompt?: string
options?: { aspectRatio?: string }
label?: string
}] | undefined
const callArg = generationCall?.[0]
const prompt = callArg?.prompt || ''
expect(prompt).toContain(PROP_PROMPT_SUFFIX)
expect(countOccurrences(prompt, PROP_PROMPT_SUFFIX)).toBe(1)
expect(callArg?.options).toEqual(expect.objectContaining({ aspectRatio: PROP_IMAGE_RATIO }))
expect(callArg?.label).toBeUndefined()
})
})

View File

@@ -25,8 +25,9 @@ const prismaMock = vi.hoisted(() => ({
}))
const sharedMock = vi.hoisted(() => ({
generateLabeledImageToCos: vi.fn<(input: {
generateProjectLabeledImageToStorage: vi.fn<(input: {
prompt: string
label: string
options?: { referenceImages?: string[]; aspectRatio?: string }
}) => Promise<string>>(async () => 'cos/character-generated-0.png'),
}))
@@ -41,7 +42,7 @@ vi.mock('@/lib/workers/handlers/image-task-handler-shared', async () => {
)
return {
...actual,
generateLabeledImageToCos: sharedMock.generateLabeledImageToCos,
generateProjectLabeledImageToStorage: sharedMock.generateProjectLabeledImageToStorage,
}
})
@@ -101,8 +102,9 @@ describe('worker character-image-task-handler behavior', () => {
imageUrl: 'cos/character-generated-0.png',
})
const generationInput = sharedMock.generateLabeledImageToCos.mock.calls[0]?.[0] as {
const generationInput = sharedMock.generateProjectLabeledImageToStorage.mock.calls[0]?.[0] as {
prompt: string
label: string
options?: { referenceImages?: string[]; aspectRatio?: string }
}
const realisticStylePrompt = getArtStylePrompt('realistic', 'zh')
@@ -111,6 +113,7 @@ describe('worker character-image-task-handler behavior', () => {
expect(generationInput.prompt).toContain(realisticStylePrompt)
expect(generationInput.prompt.split(CHARACTER_PROMPT_SUFFIX).length - 1).toBe(1)
expect(generationInput.prompt.split(realisticStylePrompt).length - 1).toBe(1)
expect(generationInput.label).toBe('Hero - 战斗形态')
expect(generationInput.options).toEqual(expect.objectContaining({
referenceImages: ['normalized-primary-ref'],
aspectRatio: '3:2',
@@ -129,7 +132,7 @@ describe('worker character-image-task-handler behavior', () => {
const job = buildJob({ imageIndex: 0, artStyle: 'japanese-anime' })
await handleCharacterImageTask(job)
const generationInput = sharedMock.generateLabeledImageToCos.mock.calls[0]?.[0] as {
const generationInput = sharedMock.generateProjectLabeledImageToStorage.mock.calls[0]?.[0] as {
prompt: string
}
expect(generationInput.prompt).toContain(getArtStylePrompt('japanese-anime', 'zh'))
@@ -143,7 +146,7 @@ describe('worker character-image-task-handler behavior', () => {
})
it('uses requested count for grouped generation and expands imageUrls to requested size', async () => {
sharedMock.generateLabeledImageToCos
sharedMock.generateProjectLabeledImageToStorage
.mockResolvedValueOnce('cos/character-generated-0.png')
.mockResolvedValueOnce('cos/character-generated-1.png')
.mockResolvedValueOnce('cos/character-generated-2.png')
@@ -152,7 +155,7 @@ describe('worker character-image-task-handler behavior', () => {
const result = await handleCharacterImageTask(buildJob({ count: 5 }))
expect(sharedMock.generateLabeledImageToCos).toHaveBeenCalledTimes(5)
expect(sharedMock.generateProjectLabeledImageToStorage).toHaveBeenCalledTimes(5)
expect(result).toEqual({
appearanceId: 'appearance-2',
imageCount: 5,

View File

@@ -1,5 +1,6 @@
import type { Job } from 'bullmq'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LOCATION_IMAGE_RATIO, PROP_IMAGE_RATIO } from '@/lib/constants'
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
const utilsMock = vi.hoisted(() => ({
@@ -118,7 +119,7 @@ describe('worker image-task-handlers-core', () => {
expect.anything(),
expect.objectContaining({
options: expect.objectContaining({
aspectRatio: '1:1',
aspectRatio: LOCATION_IMAGE_RATIO,
resolution: '1536x1024',
referenceImages: ['required-reference-image', 'normalized-reference-image'],
}),
@@ -132,6 +133,34 @@ describe('worker image-task-handlers-core', () => {
expect(updateData.imageUrl).toBe('cos/new-image.png')
})
it('uses the character-matching aspect ratio when modifying project prop images', async () => {
prismaMock.locationImage.findUnique.mockResolvedValueOnce({
id: 'prop-image-1',
locationId: 'prop-1',
imageUrl: 'cos/prop-old.png',
description: 'silver prop',
previousDescription: null,
location: { name: 'Silver Prop' },
})
await handleModifyAssetImageTask(buildJob({
type: 'prop',
locationImageId: 'prop-image-1',
modifyPrompt: 'make it brushed silver',
generationOptions: { resolution: '1536x1024' },
}))
expect(utilsMock.resolveImageSourceFromGeneration).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
options: expect.objectContaining({
aspectRatio: PROP_IMAGE_RATIO,
resolution: '1536x1024',
}),
}),
)
})
it('updates storyboard panel image and keeps candidateImages reset', async () => {
prismaMock.novelPromotionPanel.findUnique.mockResolvedValue({
id: 'panel-1',

View File

@@ -1,6 +1,6 @@
import type { Job } from 'bullmq'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getArtStylePrompt } from '@/lib/constants'
import { LOCATION_IMAGE_RATIO, PROP_IMAGE_RATIO, getArtStylePrompt } from '@/lib/constants'
import { TASK_TYPE, type TaskJobData } from '@/lib/task/types'
const utilsMock = vi.hoisted(() => ({
@@ -20,7 +20,7 @@ const prismaMock = vi.hoisted(() => ({
}))
const sharedMock = vi.hoisted(() => ({
generateLabeledImageToCos: vi.fn(async () => 'cos/location-generated-1.png'),
generateProjectLabeledImageToStorage: vi.fn(async () => 'cos/location-generated-1.png'),
}))
vi.mock('@/lib/workers/utils', () => utilsMock)
@@ -32,7 +32,7 @@ vi.mock('@/lib/workers/handlers/image-task-handler-shared', async () => {
)
return {
...actual,
generateLabeledImageToCos: sharedMock.generateLabeledImageToCos,
generateProjectLabeledImageToStorage: sharedMock.generateProjectLabeledImageToStorage,
}
})
@@ -100,32 +100,32 @@ describe('worker location-image-task-handler behavior', () => {
locationIds: ['location-1'],
})
expect(sharedMock.generateLabeledImageToCos).toHaveBeenCalledWith(
expect(sharedMock.generateProjectLabeledImageToStorage).toHaveBeenCalledWith(
expect.objectContaining({
prompt: expect.stringContaining('雨夜街道'),
label: 'Old Town',
targetId: 'location-image-1',
options: expect.objectContaining({ aspectRatio: '1:1' }),
options: expect.objectContaining({ aspectRatio: LOCATION_IMAGE_RATIO }),
}),
)
expect(sharedMock.generateLabeledImageToCos).toHaveBeenCalledWith(
expect(sharedMock.generateProjectLabeledImageToStorage).toHaveBeenCalledWith(
expect.objectContaining({
prompt: expect.stringContaining('可站位置:'),
}),
)
expect(sharedMock.generateLabeledImageToCos).toHaveBeenCalledWith(
expect(sharedMock.generateProjectLabeledImageToStorage).toHaveBeenCalledWith(
expect.objectContaining({
prompt: expect.stringContaining('街道左侧靠墙的留白位置'),
}),
)
expect(sharedMock.generateLabeledImageToCos).toHaveBeenCalledWith(
expect(sharedMock.generateProjectLabeledImageToStorage).toHaveBeenCalledWith(
expect.objectContaining({
prompt: expect.stringContaining('必须使用宽广完整的场景全景构图'),
}),
)
const generationCall = sharedMock.generateLabeledImageToCos.mock.calls[0] as unknown as [{ prompt: string }] | undefined
const generationCall = sharedMock.generateProjectLabeledImageToStorage.mock.calls[0] as unknown as [{ prompt: string }] | undefined
expect(generationCall).toBeTruthy()
if (!generationCall) throw new Error('expected generateLabeledImageToCos call')
if (!generationCall) throw new Error('expected generateProjectLabeledImageToStorage call')
const generationInput = generationCall[0]
expect(generationInput.prompt.split(animeStylePrompt).length - 1).toBe(1)
@@ -138,7 +138,7 @@ describe('worker location-image-task-handler behavior', () => {
it('payload artStyle overrides project artStyle in prompt', async () => {
await handleLocationImageTask(buildJob({ imageIndex: 0, artStyle: 'realistic' }))
expect(sharedMock.generateLabeledImageToCos).toHaveBeenCalledWith(
expect(sharedMock.generateProjectLabeledImageToStorage).toHaveBeenCalledWith(
expect.objectContaining({
prompt: expect.stringContaining(getArtStylePrompt('realistic', 'zh')),
}),
@@ -169,11 +169,21 @@ describe('worker location-image-task-handler behavior', () => {
updated: 1,
locationIds: ['location-1'],
})
expect(sharedMock.generateLabeledImageToCos).toHaveBeenCalledTimes(1)
expect(sharedMock.generateProjectLabeledImageToStorage).toHaveBeenCalledTimes(1)
expect(prismaMock.locationImage.update).toHaveBeenCalledTimes(1)
expect(prismaMock.locationImage.update).toHaveBeenCalledWith({
where: { id: 'location-image-1' },
data: { imageUrl: 'cos/location-generated-1.png' },
})
})
it('uses the same aspect ratio as character generation for prop images', async () => {
await handleLocationImageTask(buildJob({ type: 'prop', imageIndex: 0 }))
expect(sharedMock.generateProjectLabeledImageToStorage).toHaveBeenCalledWith(
expect.objectContaining({
options: expect.objectContaining({ aspectRatio: PROP_IMAGE_RATIO }),
}),
)
})
})

View File

@@ -1,5 +1,6 @@
import type { Job } from 'bullmq'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PROP_IMAGE_RATIO } from '@/lib/constants'
import { TASK_TYPE, type TaskJobData, type TaskType } from '@/lib/task/types'
const utilsMock = vi.hoisted(() => ({
@@ -27,6 +28,7 @@ const promptMock = vi.hoisted(() => ({
PROMPT_IDS: {
NP_CHARACTER_DESCRIPTION_UPDATE: 'np_character_description_update',
NP_LOCATION_DESCRIPTION_UPDATE: 'np_location_description_update',
NP_PROP_DESCRIPTION_UPDATE: 'np_prop_description_update',
},
buildPrompt: vi.fn(({ promptId }: { promptId: string }) => `${promptId}-prompt`),
}))
@@ -200,6 +202,16 @@ describe('modify image syncs descriptions after edit', () => {
await handleAssetHubModifyTask(job)
expect(aiRuntimeMock.executeAiVisionStep).toHaveBeenCalledTimes(1)
expect(utilsMock.stripLabelBar).not.toHaveBeenCalled()
expect(utilsMock.withLabelBar).not.toHaveBeenCalled()
expect(utilsMock.resolveImageSourceFromGeneration).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
options: expect.objectContaining({
referenceImages: ['https://signed/current-image.png', 'https://ref.example/b.png'],
}),
}),
)
const globalCharacterUpdateCall = prismaMock.globalCharacterAppearance.update.mock.calls.at(-1) as [unknown] | undefined
const updateArg = globalCharacterUpdateCall?.[0]
@@ -246,6 +258,17 @@ describe('modify image syncs descriptions after edit', () => {
await handleAssetHubModifyTask(job)
expect(utilsMock.stripLabelBar).not.toHaveBeenCalled()
expect(utilsMock.withLabelBar).not.toHaveBeenCalled()
expect(utilsMock.resolveImageSourceFromGeneration).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
options: expect.objectContaining({
referenceImages: ['https://signed/current-image.png', 'https://ref.example/location.png'],
}),
}),
)
const globalLocationUpdateCall = prismaMock.globalLocationImage.update.mock.calls.at(-1) as [unknown] | undefined
const updateArg = globalLocationUpdateCall?.[0]
const updateData = getUpdateData(updateArg)
@@ -253,4 +276,60 @@ describe('modify image syncs descriptions after edit', () => {
expect(updateData.description).toBe('VISION_UPDATED_LOCATION')
expect(updateData.imageUrl).toBe('cos/new-global-location-image.png')
})
it('syncs project prop descriptions for pure text edits', async () => {
aiRuntimeMock.executeAiTextStep.mockResolvedValueOnce({ text: '{"prompt":"TEXT_UPDATED_PROP"}' })
const job = buildJob(TASK_TYPE.MODIFY_ASSET_IMAGE, {
type: 'prop',
locationId: 'location-1',
imageIndex: 0,
modifyPrompt: '把表面改成拉丝银色,并增加刻纹',
})
await handleModifyAssetImageTask(job)
const locationUpdateCall = prismaMock.locationImage.update.mock.calls.at(-1) as [unknown] | undefined
const updateData = getUpdateData(locationUpdateCall?.[0])
expect(updateData.previousDescription).toBe('old location description')
expect(updateData.description).toBe('TEXT_UPDATED_PROP')
expect(updateData.imageUrl).toBe('cos/new-image.png')
expect(utilsMock.resolveImageSourceFromGeneration).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
options: expect.objectContaining({
aspectRatio: PROP_IMAGE_RATIO,
}),
}),
)
})
it('syncs asset-hub prop descriptions for reference-image edits', async () => {
utilsMock.uploadImageSourceToCos.mockResolvedValueOnce('cos/new-global-prop-image.png')
aiRuntimeMock.executeAiVisionStep.mockResolvedValueOnce({ text: '{"prompt":"VISION_UPDATED_PROP"}' })
const job = buildJob(TASK_TYPE.ASSET_HUB_MODIFY, {
type: 'prop',
id: 'global-location-1',
imageIndex: 0,
modifyPrompt: '改成磨砂银色餐具,去掉多余反光',
extraImageUrls: ['https://ref.example/prop.png'],
})
await handleAssetHubModifyTask(job)
const globalLocationUpdateCall = prismaMock.globalLocationImage.update.mock.calls.at(-1) as [unknown] | undefined
const updateData = getUpdateData(globalLocationUpdateCall?.[0])
expect(updateData.previousDescription).toBe('global location description')
expect(updateData.description).toBe('VISION_UPDATED_PROP')
expect(updateData.imageUrl).toBe('cos/new-global-prop-image.png')
expect(utilsMock.resolveImageSourceFromGeneration).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
options: expect.objectContaining({
aspectRatio: PROP_IMAGE_RATIO,
}),
}),
)
})
})

View File

@@ -177,6 +177,8 @@ describe('worker reference-to-character', () => {
expect(result).toEqual(expect.objectContaining({ success: true }))
expect(generatorApiMock.generateImage).toHaveBeenCalledTimes(3)
expect(fontsMock.initializeFonts).not.toHaveBeenCalled()
expect(fontsMock.createLabelSVG).not.toHaveBeenCalled()
const { prompt, options } = readGenerateCall(0)
expect(prompt).toContain('冷静黑发角色')
@@ -201,6 +203,8 @@ describe('worker reference-to-character', () => {
expect(result).toEqual(expect.objectContaining({ success: true }))
expect(generatorApiMock.generateImage).toHaveBeenCalledTimes(3)
expect(fontsMock.initializeFonts).not.toHaveBeenCalled()
expect(fontsMock.createLabelSVG).not.toHaveBeenCalled()
const { prompt, options } = readGenerateCall(0)
expect(prompt).toContain('BASE_REFERENCE_PROMPT')
@@ -237,4 +241,20 @@ describe('worker reference-to-character', () => {
expect(cosKeys).toHaveLength(5)
expect(cosKeys?.every((item) => item.startsWith('cos/reference-key-'))).toBe(true)
})
it('adds project label bars only for project reference generation', async () => {
const job = buildJob(
{
referenceImageUrls: ['https://example.com/ref-a.png'],
characterName: 'Hero',
count: 1,
},
TASK_TYPE.REFERENCE_TO_CHARACTER,
)
await handleReferenceToCharacterTask(job)
expect(fontsMock.initializeFonts).toHaveBeenCalledTimes(1)
expect(fontsMock.createLabelSVG).toHaveBeenCalledTimes(1)
})
})