181 lines
7.4 KiB
TypeScript
181 lines
7.4 KiB
TypeScript
import { computed, ref } from 'vue'
|
|
import { usePolling } from '../../composables/usePolling'
|
|
import { ApiError } from '../../lib/http'
|
|
import { useProjectContext } from '../projects/context'
|
|
import { getOperation, runOperation } from '../workflows/operations'
|
|
import { workflowCheckpoints } from '../workflows/selectors'
|
|
import { subjectImagesApi } from './api'
|
|
import { getImageSession, hasRunningImages, isPrimaryIdentityStale } from './model'
|
|
import type { GenerateFormImageInput, SubjectFormAsset } from './types'
|
|
|
|
/** 形态图库统一读取数据库记录并管理单图/批量长请求,不调用生产流程。 */
|
|
export function useSubjectImages() {
|
|
const context = useProjectContext()
|
|
const id = computed(() => context.project.value?.id ?? '')
|
|
const concurrency = ref(2)
|
|
const force = ref(false)
|
|
const query = usePolling(id, async (projectId, signal) => {
|
|
if (!projectId) return []
|
|
try {
|
|
const forms = await subjectImagesApi.listForms(projectId, signal)
|
|
if (
|
|
forms.some(
|
|
form =>
|
|
form.subject.projectId !== projectId ||
|
|
form.images.some(image => image.subjectFormId !== form.id)
|
|
)
|
|
)
|
|
throw new Error('形态图库返回了不匹配的项目或图片,请刷新后重试。')
|
|
return forms
|
|
} catch (error) {
|
|
if (error instanceof ApiError && error.status === 404)
|
|
throw new Error(
|
|
'后端尚未提供形态图库查询,请更新后端 dev 并重启服务(GET /projects/:id/subject-forms)。',
|
|
{ cause: error }
|
|
)
|
|
throw error
|
|
}
|
|
})
|
|
const forms = computed(() => query.data.value ?? [])
|
|
const staleForms = computed(() => forms.value.filter(form => isPrimaryIdentityStale(form)))
|
|
const operation = computed(() => getOperation(id.value))
|
|
const session = computed(() => getImageSession(id.value))
|
|
const running = computed(() => forms.value.some(form => hasRunningImages(form.images)))
|
|
const breakdownRunning = computed(
|
|
() =>
|
|
workflowCheckpoints(context.checkpoints.value, 'breakdown').at(-1)?.state.workflowExecution?.status ===
|
|
'running'
|
|
)
|
|
const blocked = computed(
|
|
() =>
|
|
operation.value.pending ||
|
|
!!context.error.value ||
|
|
!!query.error.value ||
|
|
query.data.value === null ||
|
|
running.value ||
|
|
breakdownRunning.value ||
|
|
context.project.value?.status === 'generating'
|
|
)
|
|
const batchValid = computed(() => Number.isSafeInteger(concurrency.value) && concurrency.value > 0)
|
|
|
|
/** 捕获正式 Form ID,生成后重新读取图片;刷新失败也不自动重发有费用的请求。 */
|
|
async function generate(formId: string, input: GenerateFormImageInput) {
|
|
if (blocked.value) return
|
|
const form = forms.value.find(item => item.id === formId)
|
|
if (!form) return
|
|
const projectId = id.value
|
|
await runOperation(projectId, `生成 ${form.subject.name} · ${form.name} 图片`, async () => {
|
|
const image = await subjectImagesApi.generate(formId, input)
|
|
if (!image || image.subjectFormId !== formId || image.status !== 'completed' || !image.imageUrl)
|
|
throw new Error(image?.error || '接口未返回已完成的图片,请先刷新图片记录核对后端状态。')
|
|
})
|
|
await query.refresh()
|
|
}
|
|
|
|
/** 批量始终面向整个项目,不受页面筛选影响;force 仅新增候选图,不替换已有主图。 */
|
|
async function generateProject() {
|
|
if (blocked.value || !batchValid.value || !forms.value.length) return
|
|
const projectId = id.value
|
|
const input = { provider: 'seedream' as const, concurrency: concurrency.value, force: force.value }
|
|
const target = getImageSession(projectId)
|
|
target.receipt = null
|
|
await runOperation(projectId, '批量生成项目形态图片', async () => {
|
|
target.receipt = {
|
|
title: input.force ? '为全项目新增候选图' : '补齐项目主参考图',
|
|
result: await subjectImagesApi.generateProject(projectId, input)
|
|
}
|
|
})
|
|
await query.refresh()
|
|
}
|
|
|
|
/**
|
|
* 只刷新 Identity Anchor 已变化的 Character Form。
|
|
* 用户已经明确确认“刷新过期形态”,因此成功的新图直接切换为当前 Primary;旧图仍保留为历史记录。
|
|
*/
|
|
async function generateStale() {
|
|
if (blocked.value || !batchValid.value || !staleForms.value.length) return
|
|
const projectId = id.value
|
|
const targets = [...staleForms.value]
|
|
const receipt = getImageSession(projectId)
|
|
receipt.receipt = null
|
|
|
|
await runOperation(projectId, '刷新过期人物形态图', async () => {
|
|
const results = await runWithConcurrency(targets, concurrency.value, async form => {
|
|
try {
|
|
const image = await subjectImagesApi.generate(form.id, {
|
|
provider: 'seedream',
|
|
setPrimary: true
|
|
})
|
|
if (!image || image.subjectFormId !== form.id || image.status !== 'completed' || !image.imageUrl) {
|
|
throw new Error(image?.error || '接口未返回已完成的主参考图')
|
|
}
|
|
return { subjectFormId: form.id, success: true as const }
|
|
} catch (error) {
|
|
return {
|
|
subjectFormId: form.id,
|
|
success: false as const,
|
|
error: error instanceof Error ? error.message : String(error)
|
|
}
|
|
}
|
|
})
|
|
const failures = results
|
|
.filter(item => !item.success)
|
|
.map(item => ({ subjectFormId: item.subjectFormId, error: item.error }))
|
|
|
|
receipt.receipt = {
|
|
title: '刷新过期人物形态图',
|
|
result: {
|
|
total: forms.value.length,
|
|
targetCount: targets.length,
|
|
generated: results.filter(item => item.success).length,
|
|
skipped: forms.value.length - targets.length,
|
|
failed: failures.length,
|
|
failures
|
|
}
|
|
}
|
|
})
|
|
await query.refresh()
|
|
}
|
|
|
|
return {
|
|
id,
|
|
forms,
|
|
staleForms,
|
|
query,
|
|
operation,
|
|
session,
|
|
concurrency,
|
|
force,
|
|
blocked,
|
|
running,
|
|
batchValid,
|
|
generate,
|
|
generateProject,
|
|
generateStale
|
|
}
|
|
}
|
|
|
|
/** 使用固定并发数执行需要付费的单形态生图请求。 */
|
|
async function runWithConcurrency<TResult>(
|
|
items: SubjectFormAsset[],
|
|
concurrency: number,
|
|
handler: (item: SubjectFormAsset) => Promise<TResult>
|
|
): Promise<TResult[]> {
|
|
const results: TResult[] = []
|
|
let currentIndex = 0
|
|
|
|
async function worker() {
|
|
while (currentIndex < items.length) {
|
|
const index = currentIndex
|
|
currentIndex += 1
|
|
const item = items[index]
|
|
if (!item) continue
|
|
results[index] = await handler(item)
|
|
}
|
|
}
|
|
|
|
const workers = Array.from({ length: Math.min(concurrency, items.length) }, () => worker())
|
|
await Promise.all(workers)
|
|
return results
|
|
}
|