import { computed, ref, watch } from 'vue' import { usePolling } from '../../composables/usePolling' import { visualStyleApi } from '../visual-style' import { subjectImagesApi } from '../subject-images/api' import { hasRunningImages } from '../subject-images/model' import { runOperation } from '../workflows/operations' import { useProjectMutationGuard } from '../workflows/useProjectMutationGuard' import { subjectIdentityApi } from './api' import { canBeAnchor, getIdentitySession, groupIdentitySubjects, mergeCastingSubjects } from './model' import type { GenerateCastingCandidateInput, GenerateIdentityImageInput, IdentityImage, SaveIdentityInput, SubjectIdentity } from './types' /** 单主体保存响应必须与提交时固定的正式 ID 一致。 */ function assertIdentity(value: SubjectIdentity, id: string) { if (!value || value.subjectId !== id || value.images.some(image => image.identityId !== value.id)) throw new Error('后端未返回匹配的主体身份,请刷新核对。') } /** 主体目录、项目风格和当前身份分开查询,未创建 Identity 不误报为空图库错误。 */ export function useSubjectIdentity() { const { projectId, operation, blocked: projectBlocked } = useProjectMutationGuard() const selectedId = ref('') const editorRevision = ref(0) const dirty = ref(false) const concurrency = ref(3) const candidateLimit = ref(3) const force = ref(false) const catalog = usePolling(projectId, async (id, signal) => { const forms = await subjectImagesApi.listForms(id, signal) if ( forms.some( form => form.subject.projectId !== id || form.images.some(image => image.subjectFormId !== form.id) ) ) throw new Error('主体目录与当前项目不匹配,请刷新后重试。') return { subjects: groupIdentitySubjects(forms), running: forms.some(form => hasRunningImages(form.images)) } }) const styleQuery = usePolling(projectId, async (id, signal) => { const style = await visualStyleApi.get(id, signal) if (style && style.projectId !== id) throw new Error('视觉风格与当前项目不匹配。') return { style } }) const castingQuery = usePolling(projectId, async (id, signal) => { const readiness = await subjectIdentityApi.castingReadiness(id, signal) return readiness }) const subjects = computed(() => mergeCastingSubjects(catalog.data.value?.subjects ?? [], castingQuery.data.value?.items ?? [], projectId.value) ) const subject = computed(() => subjects.value.find(item => item.id === selectedId.value)) const selectionKey = computed(() => subject.value?.id ?? '') const detail = usePolling(selectionKey, async (id, signal) => { if (!id) return null const identity = await subjectIdentityApi.get(id, signal) if (!identity) return { identity: null, images: [] as IdentityImage[] } assertIdentity(identity, id) const images = await subjectIdentityApi.listImages(id, signal) if (images.some(image => image.identityId !== identity.id)) throw new Error('身份图片与当前主体不匹配。') return { identity, images } }) const identity = computed(() => detail.data.value?.identity ?? null) const images = computed(() => detail.data.value?.images ?? []) const castingItem = computed(() => castingQuery.data.value?.items?.find(item => item.subjectId === subject.value?.id) ) const session = computed(() => getIdentitySession(projectId.value)) const blocked = computed( () => projectBlocked.value || !!catalog.error.value || !catalog.data.value || !!catalog.data.value.running || !!styleQuery.error.value || !styleQuery.data.value ) const detailBlocked = computed( () => blocked.value || !subject.value || !detail.data.value || !!detail.error.value || hasRunningImages(images.value) ) const hasStyle = computed(() => !!styleQuery.data.value?.style) const canGenerateText = computed( () => !detailBlocked.value && hasStyle.value && !identity.value?.isLocked && !dirty.value ) const canGenerateImage = computed( () => !detailBlocked.value && hasStyle.value && !!identity.value?.generationPrompt?.trim() && !dirty.value ) const canGenerateCandidate = computed( () => canGenerateImage.value && subject.value?.module === 'character' && !!castingQuery.data.value && !castingQuery.error.value ) const batchValid = computed(() => Number.isSafeInteger(concurrency.value) && concurrency.value > 0) const candidateLimitValid = computed(() => Number.isSafeInteger(candidateLimit.value) && candidateLimit.value > 0) const castingBlocked = computed( () => blocked.value || !!castingQuery.error.value || !castingQuery.data.value || dirty.value ) watch(subjects, rows => { if (!rows.some(item => item.id === selectedId.value)) selectedId.value = rows[0]?.id ?? '' }) watch(selectionKey, () => { dirty.value = false editorRevision.value++ }) /** 长请求跨主体切换后只更新原项目的操作回执,不用旧响应覆盖新主体。 */ async function writeIdentity(label: string, action: (id: string) => Promise) { if (detailBlocked.value || !subject.value) return const id = subject.value.id const project = projectId.value const ok = await runOperation(project, label, async () => { const result = await action(id) assertIdentity(result, id) if (selectionKey.value === id && projectId.value === project) { // 图片的 isAnchor 只能从专用查询获取,保存文本时暂时保留已有图片列表。 detail.data.value = { identity: result, images: images.value } editorRevision.value++ dirty.value = false } }) if (ok) await Promise.all([detail.refresh(), castingQuery.refresh()]) } /** 人工保存可修改已锁定身份;锁定只阻止自动重生成。 */ function save(input: SaveIdentityInput) { return writeIdentity('保存主体身份', id => subjectIdentityApi.save(id, input)) } /** 单主体 AI 生成与已存在文本的覆盖均需要页面确认。 */ function generate() { if (!canGenerateText.value) return const overwrite = !!identity.value return writeIdentity('AI 生成主体身份', id => subjectIdentityApi.generate(id, overwrite)) } /** 批量仅生成身份文本,已锁定项由后端跳过,回执保留部分失败。 */ async function generateProject(charactersOnly = false) { if (blocked.value || !hasStyle.value || !batchValid.value || dirty.value || !subjects.value.length) return const id = projectId.value const target = getIdentitySession(id) const input = { concurrency: concurrency.value, force: force.value } target.receipt = null const title = charactersOnly ? '批量生成角色身份文本' : '批量生成全部主体身份文本' await runOperation(id, title, async () => { target.receipt = { kind: 'identities', title, result: charactersOnly ? await subjectIdentityApi.generateCharacters(id, input) : await subjectIdentityApi.generateProject(id, input) } }) await Promise.all([detail.refresh(), castingQuery.refresh()]) } /** 小批量只为 missing_anchor 的 Character 生成候选,不自动选择演员。 */ async function generateCastingCandidates() { if ( castingBlocked.value || !hasStyle.value || !batchValid.value || !candidateLimitValid.value || !castingQuery.data.value?.missingAnchor ) return const id = projectId.value const target = getIdentitySession(id) const title = '批量生成角色选角候选' target.receipt = null await runOperation(id, title, async () => { target.receipt = { kind: 'casting', title, result: await subjectIdentityApi.generateCastingCandidates(id, { provider: 'seedream', limit: candidateLimit.value, concurrency: concurrency.value }) } }) await Promise.all([detail.refresh(), castingQuery.refresh()]) } /** 生图不修改文字锁定;即使有自定义 Prompt,也需已有身份 Prompt 与项目风格。 */ async function generateImage(input: GenerateIdentityImageInput) { if (!canGenerateImage.value || !subject.value || !identity.value) return if ( input.referenceImageId && !images.value.some( image => image.id === input.referenceImageId && image.status === 'completed' && image.imageUrl ) ) return const id = subject.value.id const identityId = identity.value.id await runOperation(projectId.value, `生成 ${subject.value.name} 身份参考图`, async () => { const image = await subjectIdentityApi.generateImage(id, input) if (!image || image.identityId !== identityId || image.status !== 'completed' || !image.imageUrl) throw new Error(image?.error || '后端未返回已完成的身份图片,请先刷新核对,不要立即重复生图。') }) await detail.refresh() } /** 单角色候选图固定为独立 primary,不复用旧 Anchor,生成成功后仍需人工确认选角。 */ async function generateCastingCandidate(input: GenerateCastingCandidateInput) { if (!canGenerateCandidate.value || !subject.value || !identity.value) return const id = subject.value.id const identityId = identity.value.id await runOperation(projectId.value, `生成 ${subject.value.name} 选角候选`, async () => { const image = await subjectIdentityApi.generateCastingCandidate(id, input) if (!image || image.identityId !== identityId || image.status !== 'completed' || !image.imageUrl) throw new Error(image?.error || '后端未返回已完成的选角候选,请刷新核对。') }) await Promise.all([detail.refresh(), castingQuery.refresh()]) } /** 候选切换母版只允许成功的 primary 图,不能将辅助视角直接升级为母版。 */ async function setAnchor(imageId: string) { const image = images.value.find(item => item.id === imageId) if (detailBlocked.value || !subject.value || !image || !canBeAnchor(image) || image.isAnchor) return const id = subject.value.id const identityId = image.identityId await runOperation(projectId.value, '切换主体身份母版', async () => { const result = await subjectIdentityApi.setAnchor(id, imageId) if ( !result || result.id !== imageId || result.identityId !== identityId || !result.enabled || !canBeAnchor(result) ) throw new Error('接口未确认身份母版切换,请刷新核对。') }) await detail.refresh() } /** Character 确认选角必须原子切换 Anchor 并锁定 Identity。 */ async function confirmCasting(imageId: string) { const image = images.value.find(item => item.id === imageId) if ( detailBlocked.value || subject.value?.module !== 'character' || !!castingQuery.error.value || !identity.value || !image || !canBeAnchor(image) ) return const subjectId = subject.value.id const identityId = identity.value.id await runOperation(projectId.value, `确认 ${subject.value.name} 角色选角`, async () => { const result = await subjectIdentityApi.confirmCasting(subjectId, imageId) if ( !result || result.identity.id !== identityId || !result.identity.isLocked || result.anchor.id !== imageId || !result.anchor.enabled ) throw new Error('接口未确认演员母版与身份锁定,请刷新核对。') }) await Promise.all([detail.refresh(), castingQuery.refresh()]) } return { projectId, operation, selectedId, subject, subjects, catalog, castingQuery, styleQuery, detail, identity, images, castingItem, session, concurrency, candidateLimit, force, editorRevision, dirty, blocked, castingBlocked, detailBlocked, hasStyle, canGenerateText, canGenerateImage, canGenerateCandidate, batchValid, candidateLimitValid, save, generate, generateProject, generateCastingCandidates, generateImage, generateCastingCandidate, setAnchor, confirmCasting } }