feat: 同步角色选角与首帧身份约束
This commit is contained in:
@@ -6,8 +6,14 @@ import { hasRunningImages } from '../subject-images/model'
|
||||
import { runOperation } from '../workflows/operations'
|
||||
import { useProjectMutationGuard } from '../workflows/useProjectMutationGuard'
|
||||
import { subjectIdentityApi } from './api'
|
||||
import { canBeAnchor, getIdentitySession, groupIdentitySubjects } from './model'
|
||||
import type { GenerateIdentityImageInput, IdentityImage, SaveIdentityInput, SubjectIdentity } from './types'
|
||||
import { canBeAnchor, getIdentitySession, groupIdentitySubjects, mergeCastingSubjects } from './model'
|
||||
import type {
|
||||
GenerateCastingCandidateInput,
|
||||
GenerateIdentityImageInput,
|
||||
IdentityImage,
|
||||
SaveIdentityInput,
|
||||
SubjectIdentity
|
||||
} from './types'
|
||||
|
||||
/** 单主体保存响应必须与提交时固定的正式 ID 一致。 */
|
||||
function assertIdentity(value: SubjectIdentity, id: string) {
|
||||
@@ -22,6 +28,7 @@ export function useSubjectIdentity() {
|
||||
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)
|
||||
@@ -38,7 +45,13 @@ export function useSubjectIdentity() {
|
||||
if (style && style.projectId !== id) throw new Error('视觉风格与当前项目不匹配。')
|
||||
return { style }
|
||||
})
|
||||
const subjects = computed(() => catalog.data.value?.subjects ?? [])
|
||||
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) => {
|
||||
@@ -52,6 +65,9 @@ export function useSubjectIdentity() {
|
||||
})
|
||||
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(
|
||||
() =>
|
||||
@@ -77,7 +93,18 @@ export function useSubjectIdentity() {
|
||||
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 ?? ''
|
||||
@@ -102,7 +129,7 @@ export function useSubjectIdentity() {
|
||||
dirty.value = false
|
||||
}
|
||||
})
|
||||
if (ok) await detail.refresh()
|
||||
if (ok) await Promise.all([detail.refresh(), castingQuery.refresh()])
|
||||
}
|
||||
|
||||
/** 人工保存可修改已锁定身份;锁定只阻止自动重生成。 */
|
||||
@@ -118,16 +145,51 @@ export function useSubjectIdentity() {
|
||||
}
|
||||
|
||||
/** 批量仅生成身份文本,已锁定项由后端跳过,回执保留部分失败。 */
|
||||
async function generateProject() {
|
||||
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
|
||||
await runOperation(id, '批量生成主体身份文本', async () => {
|
||||
target.receipt = await subjectIdentityApi.generateProject(id, input)
|
||||
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 detail.refresh()
|
||||
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 与项目风格。 */
|
||||
@@ -150,6 +212,19 @@ export function useSubjectIdentity() {
|
||||
await detail.refresh()
|
||||
}
|
||||
|
||||
/** 单角色候选图固定为 primary,生成成功后仍需人工确认选角。 */
|
||||
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)
|
||||
@@ -170,6 +245,34 @@ export function useSubjectIdentity() {
|
||||
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,
|
||||
@@ -177,25 +280,34 @@ export function useSubjectIdentity() {
|
||||
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,
|
||||
setAnchor
|
||||
generateCastingCandidate,
|
||||
setAnchor,
|
||||
confirmCasting
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user