feat: 同步角色选角与首帧身份约束

This commit is contained in:
GouJ
2026-08-31 19:14:58 +08:00
parent aeb3ce2986
commit 53917268d3
20 changed files with 940 additions and 112 deletions
+67 -8
View File
@@ -337,7 +337,7 @@ describe('视觉风格与主体身份工作区', () => {
expect(wrapper!.text()).toContain('2 个形态')
expect(fetcher.mock.calls.some(([url]) => String(url).endsWith('/subjects/subject-db-1/identity'))).toBe(true)
expect(fetcher.mock.calls.some(([url]) => String(url).endsWith('/identity/images'))).toBe(false)
expect(button('生成身份参考图').disabled).toBe(true)
expect(button('生成选角候选').disabled).toBe(true)
expect(button('AI 生成身份').disabled).toBe(false)
})
@@ -353,7 +353,7 @@ describe('视觉风格与主体身份工作区', () => {
await mountAssets('identity')
expect(button('AI 重新生成身份').disabled).toBe(true)
expect(button('保存主体身份').disabled).toBe(false)
button('生成身份参考图').click()
button('生成辅助身份图').click()
await flushPromises()
expect(button('确认生成身份图').disabled).toBe(true)
document.querySelector<HTMLInputElement>('#identity-image-cost')!.click()
@@ -362,7 +362,7 @@ describe('视觉风格与主体身份工作区', () => {
await flushPromises()
const post = fetcher.mock.calls.find(([, init]) => init?.method === 'POST')!
expect(post[0]).toBe('/api/subjects/subject-db-1/identity/images')
expect(JSON.parse(post[1]!.body as string)).toEqual({ provider: 'seedream', viewType: 'primary' })
expect(JSON.parse(post[1]!.body as string)).toEqual({ provider: 'seedream', viewType: 'front' })
})
it('无项目风格时禁止生成身份和图片,但仍可人工保存身份', async () => {
@@ -377,7 +377,7 @@ describe('视觉风格与主体身份工作区', () => {
)
await mountAssets('identity')
expect(button('AI 重新生成身份').disabled).toBe(true)
expect(button('生成身份参考图').disabled).toBe(true)
expect(button('生成选角候选').disabled).toBe(true)
expect(button('保存主体身份').disabled).toBe(false)
})
@@ -399,19 +399,21 @@ describe('视觉风格与主体身份工作区', () => {
})
vi.stubGlobal('fetch', fetcher)
await mountAssets('identity')
await confirmGeneration('补齐项目身份文本')
await confirmGeneration('补齐全部主体身份文本')
const post = fetcher.mock.calls.find(([, init]) => init?.method === 'POST')!
expect(post[0]).toBe('/api/projects/page-test-project/subject-identities/generate')
expect(JSON.parse(post[1]!.body as string)).toEqual({ force: false, concurrency: 3 })
expect(wrapper!.text()).toContain('部分主体生成失败')
expect(wrapper!.text()).toContain('本次存在部分失败')
expect(wrapper!.text()).toContain('含锁定 1')
expect(wrapper!.text()).toContain('文本模型超时')
})
it('身份候选母版切换需确认,只发送正确 Subject ID 的 anchor PUT', async () => {
let selected = false
const form = formFixture()
form.subject.module = 'scene'
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
if (String(url).endsWith('/subject-forms')) return jsonResponse([formFixture()])
if (String(url).endsWith('/subject-forms')) return jsonResponse([form])
if (String(url).endsWith('/visual-style')) return jsonResponse(styleFixture())
if (init?.method === 'PUT') {
selected = true
@@ -434,6 +436,63 @@ describe('视觉风格与主体身份工作区', () => {
expect(wrapper!.text()).toContain('当前身份母版')
})
it('Character 确认选角原子切换 Anchor 并锁定 Identity,不调用普通母版接口', async () => {
let locked = false
let selected = false
const candidate = identityImageFixture({ id: 'casting-1', enabled: false, isAnchor: false })
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
const path = String(url)
if (path.endsWith('/subject-forms')) return jsonResponse([formFixture()])
if (path.endsWith('/visual-style')) return jsonResponse(styleFixture())
if (path.endsWith('/character-casting/readiness'))
return jsonResponse({
total: 1,
ready: locked ? 1 : 0,
missingIdentity: 0,
missingAnchor: 0,
candidatePending: locked ? 0 : 1,
unlocked: 0,
locked: locked ? 1 : 0,
items: [
{
subjectId: 'subject-db-1',
subjectRef: '@CH0001',
subjectName: '林知夏',
status: locked ? 'ready' : 'candidate_pending',
identityId: 'identity-db-1',
candidateImages: locked ? undefined : [{ id: 'casting-1', imageUrl: candidate.imageUrl }],
anchorImageId: locked ? 'casting-1' : undefined,
anchorImageUrl: locked ? candidate.imageUrl : undefined,
isLocked: locked
}
]
})
if (path.endsWith('/identity/images'))
return jsonResponse([{ ...candidate, enabled: selected, isAnchor: selected }])
if (init?.method === 'PUT' && path.endsWith('/casting')) {
locked = true
selected = true
return jsonResponse({
identity: { ...identityFixture({ isLocked: true }), images: undefined },
anchor: { ...candidate, enabled: true }
})
}
return jsonResponse(identityFixture({ isLocked: locked }))
})
vi.stubGlobal('fetch', fetcher)
await mountAssets('identity')
expect(wrapper!.text()).toContain('等待确认演员')
button('确认选角并锁定').click()
await flushPromises()
button('确认演员选择').click()
await flushPromises()
const put = fetcher.mock.calls.find(([, init]) => init?.method === 'PUT')!
expect(put[0]).toBe('/api/subjects/subject-db-1/identity/images/casting-1/casting')
expect(fetcher.mock.calls.some(([url]) => String(url).endsWith('/anchor'))).toBe(false)
expect(wrapper!.text()).toContain('选角状态:选角已完成')
expect(wrapper!.text()).toContain('演员身份已确认并锁定')
})
it('切换主体会取消旧查询,迟到的身份结果不覆盖当前主体', async () => {
let finish!: (value: Response) => void
const form = formFixture()
@@ -485,7 +544,7 @@ describe('视觉风格与主体身份工作区', () => {
provided.data.value!.project = { ...fixture, status: 'generating' }
await flushPromises()
expect(button('保存主体身份').disabled).toBe(true)
expect(button('生成身份参考图').disabled).toBe(true)
expect(button('生成选角候选').disabled).toBe(true)
})
})