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
@@ -75,6 +75,12 @@ const videoRunning = computed(
(query.data.value?.videoStatus.queued ?? 0) +
(query.data.value?.videoStatus.running ?? 0)
)
const identityBlocked = computed(
() =>
(query.data.value?.keyframes.missingIdentity ?? 0) +
(query.data.value?.keyframes.missingIdentityAnchor ?? 0) +
(query.data.value?.keyframes.identityUnlocked ?? 0)
)
/** 父页查询包含整个项目的就绪统计,单镜头变更后立即刷新。 */
function refreshProject() {
@@ -149,6 +155,11 @@ function refreshProject() {
<p v-if="videoRunning" class="alert mt-4" role="status">
当前有 {{ videoRunning }} 个视频任务等待或生成中后端正在轮询 Provider同一镜头不会重复提交活动任务
</p>
<p v-if="identityBlocked" class="alert mt-4">
当前有 {{ identityBlocked }} 项角色身份前置问题Character 必须生成
Identity确认演员母版并锁定后首帧才能就绪
<RouterLink :to="`/projects/${id}/subject-identity`" class="text-button ml-2">前往角色选角 </RouterLink>
</p>
<ProductionReceipt v-if="session.receipt" :receipt="session.receipt" />
<div v-if="query.data.value" class="production-pipeline mt-5">
@@ -191,6 +202,11 @@ function refreshProject() {
<dd>{{ stage.data?.blocked ?? 0 }}</dd>
</div>
</dl>
<p v-if="stage.key === 'keyframes' && identityBlocked" class="mt-3 text-[11px] leading-5 text-danger">
身份缺失 {{ query.data.value?.keyframes.missingIdentity ?? 0 }} · 母版缺失
{{ query.data.value?.keyframes.missingIdentityAnchor ?? 0 }} · 未锁定
{{ query.data.value?.keyframes.identityUnlocked ?? 0 }}
</p>
<ConfirmAction
class="mt-4"
:label="stage.action"
@@ -387,6 +387,16 @@ async function inspectSpecs() {
<div v-if="keyframeSpec">
<h5 class="font-medium">首帧 Prompt · {{ keyframeSpec.references.length }} 张参考图</h5>
<p class="mt-3 whitespace-pre-wrap leading-6 text-muted">{{ keyframeSpec.prompt }}</p>
<ul v-if="keyframeSpec.references.length" class="mt-3 space-y-1 text-[11px] text-muted">
<li
v-for="reference in keyframeSpec.references"
:key="`${reference.role}:${reference.imageId}`"
>
{{ reference.subjectRef }} ·
{{ reference.role === 'identity-anchor' ? '演员身份母版' : '形态主图' }} ·
<code>{{ reference.imageId }}</code>
</li>
</ul>
</div>
<div v-if="videoSpec">
<h5 class="font-medium">视频 Prompt · {{ videoSpec.durationSeconds }}s</h5>
+3
View File
@@ -43,6 +43,9 @@ export function issueLabel(code: ProductionIssueCode): string {
invalid_generation_spec: '生成规格不完整',
missing_visual_style: '缺少视觉风格',
missing_reference: '缺少主体参考图',
missing_identity: '缺少角色身份',
missing_identity_anchor: '缺少演员母版',
identity_unlocked: '演员身份未锁定',
missing_prompt: '缺少视频提示词',
missing_keyframe: '缺少主首帧'
}
@@ -51,6 +51,8 @@ describe('镜头生产数据契约', () => {
it('就绪问题和异步任务状态提供中文标签', () => {
expect(issueLabel('missing_keyframe')).toBe('缺少主首帧')
expect(issueLabel('invalid_generation_spec')).toBe('生成规格不完整')
expect(issueLabel('missing_identity_anchor')).toBe('缺少演员母版')
expect(issueLabel('identity_unlocked')).toBe('演员身份未锁定')
expect(productionStatusLabel('in_progress')).toBe('任务进行中')
expect(productionStatusLabel('unknown')).toBe('unknown')
})
+18 -2
View File
@@ -6,6 +6,9 @@ export type ProductionIssueCode =
| 'invalid_generation_spec'
| 'missing_visual_style'
| 'missing_reference'
| 'missing_identity'
| 'missing_identity_anchor'
| 'identity_unlocked'
| 'missing_prompt'
| 'missing_keyframe'
@@ -37,12 +40,17 @@ export interface PromptReadiness {
/** 镜头首帧就绪检查的逐镜结果。 */
export interface KeyframeReadinessItem extends PromptReadinessItem {
episodeNo: number
beatNo: number
primaryKeyframeId?: string | null
}
/** 镜头首帧项目级就绪汇总。 */
export interface KeyframeReadiness extends PromptReadiness {
missingVisualStyle: number
missingIdentity: number
missingIdentityAnchor: number
identityUnlocked: number
items: KeyframeReadinessItem[]
}
@@ -111,7 +119,8 @@ export interface KeyframeReference {
subjectId: string
subjectRef: string
module: string
subjectFormId: string
role: 'identity-anchor' | 'form-primary'
subjectFormId?: string
imageId: string
imageUrl: string
providerImageUrl?: string
@@ -129,9 +138,16 @@ export interface KeyframeSpec {
}
/** 视频生成规格中的主体参考图。 */
export interface VideoReference extends KeyframeReference {
export interface VideoReference {
subjectId: string
subjectRef: string
subjectName: string
module: string
subjectFormId: string
subjectFormName: string
imageId: string
imageUrl: string
providerImageUrl?: string
}
/** 后端实际提交 Seedance 前编译的完整视频生成规格。 */
@@ -174,8 +174,8 @@ function exportResult(kind: 'spec' | 'prompt') {
</button>
</div>
<p class="mt-2 text-xs leading-6 text-muted">
后端合并主体形态摄影设计与即时状态编译为 GenerationSpec不调用模型需要本镜头已有 Direction
VisualState
后端合并主体稳定身份具体形态摄影设计与即时状态编译为 GenerationSpec不调用模型需要本镜头已有
Direction VisualState
</p>
<p v-if="errors.spec" class="alert alert-error mt-3" role="alert">{{ errors.spec }}</p>
<template v-if="spec"
@@ -190,8 +190,8 @@ function exportResult(kind: 'spec' | 'prompt') {
<section class="border-t border-line pt-6">
<h3 class="text-sm font-semibold">视频提示词</h3>
<p class="mt-2 text-xs leading-6 text-muted">
已有提示词直接读取没有则调用模型生成当前后端仍使用镜头描述和参考图生成 Prompt尚未接入上方
GenerationSpec此操作不会生成视频
已有提示词直接复用没有则依据 GenerationSpec 与参考图调用模型生成此处只处理
Prompt首帧和实际视频任务请到镜头生产
</p>
<div class="mt-4 flex flex-wrap items-center gap-4">
<ConfirmAction
@@ -6,6 +6,8 @@ export interface ShotGenerationSubjectSpec {
subjectRef: string
subjectName: string
module: string
/** 跨形态稳定身份;Character 首帧会据此校验选角前置条件。 */
identityDescription: string
subjectFormId: string
subjectFormName: string
appearancePrompt: string
@@ -1,14 +1,16 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { AssetImage, EmptyState } from '../../components/ui'
import { UserCheck } from '@lucide/vue'
import { AssetImage, EmptyState, StatusBadge } from '../../components/ui'
import { downloadText } from '../../lib/format'
import ConfirmAction from '../workflows/ConfirmAction.vue'
import { currentAnchor } from './model'
import { castingStatusLabel, currentAnchor } from './model'
import { useSubjectIdentity } from './useSubjectIdentity'
import IdentityEditor from './components/IdentityEditor.vue'
import IdentityGallery from './components/IdentityGallery.vue'
import IdentityImageDialog from './components/IdentityImageDialog.vue'
import CastingCandidateDialog from './components/CastingCandidateDialog.vue'
/** 主体身份工作区按正式主体聚合,不把同一主体的多个 Form 当成不同身份。 */
const route = useRoute()
@@ -19,32 +21,46 @@ const {
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
} = useSubjectIdentity()
const search = ref('')
const module = ref('all')
const imageOpen = ref(false)
const castingOpen = ref(false)
const pendingSelection = ref('')
const anchor = computed(() => currentAnchor(images.value))
const casting = computed(() => {
const value = castingQuery.data.value
return value && Array.isArray(value.items) ? value : null
})
const filtered = computed(() =>
subjects.value.filter(
item =>
@@ -66,6 +82,7 @@ watch(
)
watch(selectedId, () => {
imageOpen.value = false
castingOpen.value = false
pendingSelection.value = ''
})
@@ -87,6 +104,17 @@ function discardAndSelect() {
function exportReceipt() {
downloadText('subject-identities-receipt.json', JSON.stringify(session.value.receipt, null, 2), 'application/json')
}
/** 目录刷新同时读取正式形态和 Character Casting,避免无形态角色状态滞后。 */
function refreshDirectory() {
void Promise.all([catalog.refresh(), castingQuery.refresh()])
}
/** Character 使用确认选角事务;Scene / Prop 保留普通母版切换。 */
function chooseAnchor(imageId: string) {
if (subject.value?.module === 'character') void confirmCasting(imageId)
else void setAnchor(imageId)
}
</script>
<template>
@@ -101,10 +129,16 @@ function exportReceipt() {
>
</div>
<p class="alert mt-5 text-xs">
人物与场景形态图在有身份母版时自动引用分别保持人物身份与空间结构没有母版仍可按原逻辑生图道具暂不自动引用旧图不会因母版切换而自动更新
Character 现在需要依次完成身份文本选角候选确认演员并锁定
Identity之后首帧才能就绪场景和道具仍使用普通身份母版流程旧形态图首帧与视频不会因母版切换自动更新
</p>
<p v-if="catalog.error.value || styleQuery.error.value" class="alert alert-error mt-4" role="alert">
{{ catalog.error.value || styleQuery.error.value }} 当前操作已暂停请刷新核对后端
<p
v-if="catalog.error.value || styleQuery.error.value || castingQuery.error.value"
class="alert alert-error mt-4"
role="alert"
>
{{ catalog.error.value || styleQuery.error.value || castingQuery.error.value }}
当前相关操作已暂停请刷新核对后端
</p>
<p v-if="styleQuery.data.value && !hasStyle" class="alert mt-4">
先创建项目视觉风格才能 AI 生成身份或身份图片<RouterLink
@@ -113,55 +147,141 @@ function exportReceipt() {
>设置视觉风格 </RouterLink
>
</p>
<details class="panel my-5 p-5">
<summary class="cursor-pointer text-sm font-medium">项目批量生成身份文本</summary>
<div class="mt-4 flex flex-wrap items-end gap-4">
<label class="w-28"
><span class="field-label">并发数</span
><input
<section v-if="casting" class="panel my-5 p-5" aria-label="角色选角流程">
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="flex items-start gap-3">
<span class="production-step"><UserCheck :size="17" /></span>
<div>
<h3 class="text-sm font-semibold">角色选角</h3>
<p class="mt-2 text-xs leading-6 text-muted">
只处理 Character候选图不会自动成为演员确认选择时才会同时切换 Anchor 并锁定 Identity
</p>
</div>
</div>
<span class="font-mono text-xs text-muted">{{ casting.ready }} / {{ casting.total }} 已完成</span>
</div>
<dl class="casting-stats mt-4">
<div>
<dt>缺身份文本</dt>
<dd>{{ casting.missingIdentity }}</dd>
</div>
<div>
<dt>缺候选图</dt>
<dd>{{ casting.missingAnchor }}</dd>
</div>
<div>
<dt>待确认演员</dt>
<dd>{{ casting.candidatePending }}</dd>
</div>
<div>
<dt>母版未锁定</dt>
<dd>{{ casting.unlocked }}</dd>
</div>
<div>
<dt>选角完成</dt>
<dd>{{ casting.ready }}</dd>
</div>
</dl>
<div class="mt-5 flex flex-wrap items-end gap-4 border-t border-line pt-4">
<label class="w-28">
<span class="field-label">并发数</span>
<input
v-model.number="concurrency"
type="number"
min="1"
step="1"
class="input"
:disabled="operation.pending"
aria-label="身份批量并发"
/></label>
<label class="flex gap-2 pb-2 text-xs"
><input v-model="force" type="checkbox" :disabled="operation.pending" />覆盖未锁定的已有身份</label
>
/>
</label>
<label class="w-28">
<span class="field-label">本批上限</span>
<input
v-model.number="candidateLimit"
type="number"
min="1"
step="1"
class="input"
:disabled="operation.pending"
/>
</label>
<label class="flex gap-2 pb-2 text-xs">
<input v-model="force" type="checkbox" :disabled="operation.pending" />覆盖未锁定身份文本
</label>
<ConfirmAction
:label="force ? '重生成未锁定身份' : '补齐项目身份文本'"
:disabled="blocked || !hasStyle || !batchValid || dirty || !subjects.length"
:label="force ? '重生成角色身份文本' : '补齐角色身份文本'"
:disabled="blocked || !hasStyle || !batchValid || dirty || !casting.total"
acknowledgement
description="只生成身份描述与提示词,不生成图片。操作面向整个项目,不受筛选影响;已锁定身份始终跳过覆盖文本不会自动更新旧身份图与形态图。"
@confirm="generateProject"
description="只生成 Character Identity 文本,不生成图片。已锁定角色始终跳过覆盖文本不会自动更新已有候选图。"
@confirm="generateProject(true)"
/>
<ConfirmAction
label="生成选角候选"
:disabled="
castingBlocked || !hasStyle || !batchValid || !candidateLimitValid || !casting.missingAnchor
"
acknowledgement
description="最多按本批上限为缺少 Anchor 的 Character 调用 Seedream,各生成一张停用的 primary 候选。已有候选、已有母版或已完成选角的角色不会重复处理。"
@confirm="generateCastingCandidates"
/>
</div>
<p v-if="!batchValid" class="mt-2 text-xs text-danger">并发必须是正整数</p>
<p class="mt-3 text-xs text-muted">
后端没有全项目身份查询接口左侧从正式形态目录归并主体点击后读取其身份无形态主体暂不在目录展示但后端批量会处理项目全部主体
<p v-if="!batchValid || !candidateLimitValid" class="mt-3 text-xs text-danger">
并发数与本批上限都必须是正整数
</p>
<div class="casting-list mt-5">
<button
v-for="item in casting.items"
:key="item.subjectId"
class="casting-item"
:class="{ selected: selectedId === item.subjectId }"
@click="select(item.subjectId)"
>
<span class="min-w-0">
<span class="block truncate text-xs font-medium">{{ item.subjectName }}</span>
<span class="mt-1 block font-mono text-[10px] text-muted">{{ item.subjectRef }}</span>
</span>
<StatusBadge :status="item.status" :label="castingStatusLabel(item.status)" />
</button>
</div>
</section>
<details class="panel mb-5 p-5">
<summary class="cursor-pointer text-sm font-medium">高级全部主体身份文本</summary>
<p class="mt-3 text-xs leading-6 text-muted">
同时处理 CharacterScene Prop角色选角建议优先使用上方专用入口已锁定身份始终跳过
</p>
<ConfirmAction
class="mt-4"
:label="force ? '重生成全部未锁定身份' : '补齐全部主体身份文本'"
:disabled="blocked || !hasStyle || !batchValid || dirty || !subjects.length"
acknowledgement
description="生成全项目主体身份描述与提示词,不生成图片。覆盖不会自动更新旧身份图、形态图、首帧或视频。"
@confirm="generateProject(false)"
/>
</details>
<section v-if="session.receipt" class="panel mb-5 p-5" aria-label="身份文本批量回执">
<div class="flex justify-between gap-3">
<h3 class="text-sm font-medium">身份文本批量回执</h3>
<h3 class="text-sm font-medium">{{ session.receipt.title }} · 本次回执</h3>
<button class="text-button" @click="exportReceipt">导出诊断</button>
</div>
<p class="mt-3 text-xs text-muted">
{{ session.receipt.total }} 个主体 · 目标 {{ session.receipt.targetCount }} · 生成
{{ session.receipt.generated }} · 跳过 {{ session.receipt.skipped }}含锁定
{{ session.receipt.skippedLocked }}· 失败 {{ session.receipt.failed }}
<p v-if="session.receipt.kind === 'identities'" class="mt-3 text-xs text-muted">
{{ session.receipt.result.total }} 个主体 · 目标 {{ session.receipt.result.targetCount }} · 生成
{{ session.receipt.result.generated }} · 跳过 {{ session.receipt.result.skipped }}含锁定
{{ session.receipt.result.skippedLocked }}· 失败 {{ session.receipt.result.failed }}
</p>
<p v-if="session.receipt.failed" class="alert alert-error mt-3" role="alert">
部分主体生成失败已成功结果保留请检查失败原因再补齐或单独生成
<p v-else class="mt-3 text-xs text-muted">
{{ session.receipt.result.totalCharacters }} 个角色 · 缺候选
{{ session.receipt.result.missingAnchor }} · 本批目标 {{ session.receipt.result.targetCount }} · 已生成
{{ session.receipt.result.generated }} · 失败 {{ session.receipt.result.failed }}
</p>
<p v-if="session.receipt.result.failed" class="alert alert-error mt-3" role="alert">
本次存在部分失败成功结果不会回滚请检查逐角色原因后再重试
</p>
<ul class="mt-3 space-y-2 text-xs text-danger">
<li v-for="failure in session.receipt.failures" :key="failure.subjectId">
<li v-for="failure in session.receipt.result.failures" :key="failure.subjectId">
{{ failure.subjectRef }} · {{ failure.subjectId }}{{ failure.error }}
</li>
</ul>
<p class="mt-3 text-[11px] text-muted">回执仅保留于当前浏览器会话不代表图片已生成</p>
<p class="mt-3 text-[11px] text-muted">回执仅保留于当前浏览器会话最终状态以刷新后的正式记录为准</p>
</section>
<div v-if="subjects.length" class="identity-workspace">
<aside class="panel min-w-0 p-4">
@@ -186,12 +306,23 @@ function exportReceipt() {
>
<span class="block font-medium">{{ item.name }}</span
><span class="mt-1 block text-[11px] text-muted"
>{{ item.ref }} · {{ item.forms.length }} 个形态</span
>{{ item.ref }} · {{ item.forms.length }} 个形态<span
v-if="casting?.items.find(row => row.subjectId === item.id)"
>
·
{{
castingStatusLabel(casting.items.find(row => row.subjectId === item.id)!.status)
}}</span
></span
>
</button>
<p v-if="!filtered.length" class="py-6 text-xs text-muted">没有匹配主体请调整筛选</p>
</div>
<button class="text-button mt-4" :disabled="catalog.loading.value" @click="catalog.refresh">
<button
class="text-button mt-4"
:disabled="catalog.loading.value || castingQuery.loading.value"
@click="refreshDirectory"
>
刷新主体目录
</button>
</aside>
@@ -210,13 +341,18 @@ function exportReceipt() {
{{ subject.name }} <code class="subject-ref ml-2">{{ subject.ref }}</code>
</h3>
<p class="mt-2 text-xs text-muted">
{{
identity
? identity.isLocked
? '身份文本已锁定'
: '身份文本未锁定'
: '尚未创建身份'
}}
<template v-if="subject.module === 'character' && castingItem">
选角状态{{ castingStatusLabel(castingItem.status) }}
</template>
<template v-else>
{{
identity
? identity.isLocked
? '身份文本已锁定'
: '身份文本未锁定'
: '尚未创建身份'
}}
</template>
</p>
</div>
<button class="text-button" :disabled="detail.loading.value" @click="detail.refresh">
@@ -238,11 +374,19 @@ function exportReceipt() {
description="调用文本模型生成稳定身份描述和提示词。旧图片不会自动重生成;已锁定身份需先解锁并保存。"
@confirm="generate"
/><button
v-if="subject.module === 'character'"
class="button button-secondary"
:disabled="!canGenerateCandidate"
@click="castingOpen = true"
>
生成选角候选</button
><button
v-if="subject.module !== 'character' || !!anchor"
class="button button-secondary"
:disabled="!canGenerateImage"
@click="imageOpen = true"
>
生成身份参考图
{{ subject.module === 'character' ? '生成辅助身份图' : '生成身份参考图' }}
</button>
</div>
<p v-if="dirty" class="mt-3 text-xs text-muted">请先保存身份修改再使用 AI 或生成图片</p>
@@ -264,9 +408,13 @@ function exportReceipt() {
<p class="text-sm font-medium">当前身份母版</p>
<p class="mt-2 text-xs text-muted">
{{
subject.module === 'prop'
? '道具形态生图暂不自动引用此母版。'
: '后续人物/场景形态图会继承此图的身份或空间结构。'
subject.module === 'character'
? identity?.isLocked
? '演员身份已确认并锁定,可用于形态图与镜头首帧。'
: '已有演员母版但 Identity 尚未锁定,镜头首帧仍会被阻塞。'
: subject.module === 'prop'
? '道具形态生图暂不自动引用此母版。'
: '后续场景形态图会继承此图的空间结构。'
}}
</p>
</div>
@@ -275,8 +423,10 @@ function exportReceipt() {
:key="selectedId"
:images="images"
:subject-name="subject.name"
:disabled="detailBlocked"
@anchor="setAnchor"
:module="subject.module"
:identity-locked="!!identity?.isLocked"
:disabled="detailBlocked || (subject.module === 'character' && !!castingQuery.error.value)"
@anchor="chooseAnchor"
/>
</template>
<div class="mt-5 border-t border-line pt-5">
@@ -286,6 +436,9 @@ function exportReceipt() {
>{{ form.name }}{{ form.isDefault ? ' · 默认' : '' }}</span
>
</div>
<p v-if="!subject.forms.length" class="mt-3 text-xs text-muted">
此角色尚无正式形态但可以先完成 Identity 与选角
</p>
<RouterLink
:to="{ path: `/projects/${projectId}/subject-images`, query: { subjectRef: subject.ref } }"
class="text-button mt-3"
@@ -309,9 +462,18 @@ function exportReceipt() {
v-model:open="imageOpen"
:subject-id="subject.id"
:subject-name="subject.name"
:module="subject.module"
:images="images"
:disabled="!canGenerateImage"
@generate="generateImage"
/>
<CastingCandidateDialog
v-if="subject?.module === 'character'"
v-model:open="castingOpen"
:subject-id="subject.id"
:subject-name="subject.name"
:disabled="!canGenerateCandidate"
@generate="generateCastingCandidate"
/>
</section>
</template>
+30
View File
@@ -1,5 +1,10 @@
import { request } from '../../lib/http'
import type {
CastingBatchResult,
CharacterCastingReadiness,
ConfirmCastingResult,
GenerateCastingCandidateInput,
GenerateCastingCandidatesInput,
GenerateIdentityImageInput,
IdentityBatchResult,
IdentityImage,
@@ -30,6 +35,27 @@ export const subjectIdentityApi = {
body: input,
timeoutMs: 0
}),
generateCharacters: (projectId: string, input: { force: boolean; concurrency: number }) =>
request<IdentityBatchResult>(`/projects/${encodeURIComponent(projectId)}/character-identities/generate`, {
method: 'POST',
body: input,
timeoutMs: 0
}),
castingReadiness: (projectId: string, signal?: AbortSignal) =>
request<CharacterCastingReadiness>(`/projects/${encodeURIComponent(projectId)}/character-casting/readiness`, {
signal
}),
generateCastingCandidate: (subjectId: string, input: GenerateCastingCandidateInput) =>
request<IdentityImage>(`${identityPath(subjectId)}/casting-candidates`, {
method: 'POST',
body: input,
timeoutMs: 0
}),
generateCastingCandidates: (projectId: string, input: GenerateCastingCandidatesInput) =>
request<CastingBatchResult>(
`/projects/${encodeURIComponent(projectId)}/character-casting/candidates/generate`,
{ method: 'POST', body: input, timeoutMs: 0 }
),
listImages: (subjectId: string, signal?: AbortSignal) =>
request<IdentityImage[]>(`${identityPath(subjectId)}/images`, { signal }),
generateImage: (subjectId: string, input: GenerateIdentityImageInput) =>
@@ -37,5 +63,9 @@ export const subjectIdentityApi = {
setAnchor: (subjectId: string, imageId: string) =>
request<IdentityImage>(`${identityPath(subjectId)}/images/${encodeURIComponent(imageId)}/anchor`, {
method: 'PUT'
}),
confirmCasting: (subjectId: string, imageId: string) =>
request<ConfirmCastingResult>(`${identityPath(subjectId)}/images/${encodeURIComponent(imageId)}/casting`, {
method: 'PUT'
})
}
@@ -0,0 +1,84 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { AppDialog } from '../../../components/ui'
import { validImageSize } from '../../subject-images/model'
import type { GenerateCastingCandidateInput } from '../types'
/** Character 选角候选表单固定使用 primary,且不会自动选择演员。 */
const props = defineProps<{ subjectId: string; subjectName: string; disabled: boolean }>()
const open = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{ generate: [input: GenerateCastingCandidateInput] }>()
const prompt = ref('')
const width = ref<number | ''>('')
const height = ref<number | ''>('')
const acknowledged = ref(false)
const valid = computed(() => validImageSize(width.value, height.value))
/** 每次切换角色或重新打开都清空自定义覆盖与费用确认。 */
function reset() {
prompt.value = ''
width.value = ''
height.value = ''
acknowledged.value = false
}
watch([() => props.subjectId, open], reset)
/** 只发送选角候选接口接受的字段,不携带普通身份图视角参数。 */
function submit() {
if (props.disabled || !valid.value || !acknowledged.value) return
emit('generate', {
provider: 'seedream',
...(prompt.value.trim() ? { prompt: prompt.value.trim() } : {}),
...(width.value !== '' && height.value !== '' ? { width: width.value, height: height.value } : {})
})
open.value = false
}
</script>
<template>
<AppDialog v-model:open="open" title="生成角色选角候选" :description="`${subjectName} · 候选不会自动成为身份母版`">
<form class="mt-5 space-y-4" @submit.prevent="submit">
<p class="alert text-xs">
后端会依据角色 Identity项目视觉风格和默认选角约束生成一张 primary
候选生成完成后仍需人工点击确认选角
</p>
<label class="block">
<span class="field-label">覆盖本次完整提示词可选</span>
<textarea
id="casting-candidate-prompt"
v-model="prompt"
class="input min-h-24"
placeholder="建议留空,使用后端编译的稳定身份和选角约束"
/>
</label>
<p v-if="prompt.trim()" class="text-xs text-danger">
自定义文本会替换后端完整提示词请自行包含人物身份基础服装和中性参考图要求
</p>
<div class="grid grid-cols-2 gap-4">
<label
><span class="field-label">宽度px</span
><input v-model.number="width" class="input" type="number" min="1" placeholder="后端默认"
/></label>
<label
><span class="field-label">高度px</span
><input v-model.number="height" class="input" type="number" min="1" placeholder="后端默认"
/></label>
</div>
<p v-if="!valid" class="text-xs text-danger">宽高需要同时留空或同时填写正整数</p>
<label class="flex items-start gap-2 text-xs leading-6">
<input
id="casting-candidate-cost"
v-model="acknowledged"
type="checkbox"
class="mt-1.5 accent-accent"
/>我已确认调用 Seedream 可能产生费用且当前没有同项目生成任务
</label>
<div class="dialog-footer">
<button type="button" class="button button-secondary" @click="open = false">取消</button>
<button type="submit" class="button button-primary" :disabled="disabled || !valid || !acknowledged">
确认生成候选
</button>
</div>
</form>
</AppDialog>
</template>
@@ -61,12 +61,11 @@ function save() {
这里保存身份事实背景视角和参考图约束由后端另行编译不是最终整段生图 Prompt
</p>
<label class="flex items-start gap-2 text-xs leading-6"
><input
id="identity-lock"
v-model="form.isLocked"
type="checkbox"
class="mt-1.5 accent-accent"
/>确认并锁定身份文本锁定后仍可人工保存生成图片及切换母版</label
><input id="identity-lock" v-model="form.isLocked" type="checkbox" class="mt-1.5 accent-accent" />{{
module === 'character'
? '确认并锁定演员身份。Character 必须同时具有身份母版并锁定 Identity,首帧才能进入就绪状态。'
: '确认并锁定身份文本。锁定后仍可人工保存、生成图片及切换母版。'
}}</label
>
<div class="flex flex-wrap items-center justify-between gap-3">
<span class="text-xs text-muted">{{
@@ -7,7 +7,13 @@ import { canBeAnchor, currentAnchor, identityViewLabels, readImageProvenance } f
import type { IdentityImage } from '../types'
/** 身份图库区分权威母版、primary 候选和辅助视图,保留后端原始状态。 */
const props = defineProps<{ images: IdentityImage[]; subjectName: string; disabled: boolean }>()
const props = defineProps<{
images: IdentityImage[]
subjectName: string
module: string
identityLocked: boolean
disabled: boolean
}>()
const emit = defineEmits<{ anchor: [imageId: string] }>()
const selectedId = ref('')
const confirming = ref(false)
@@ -15,8 +21,13 @@ const anchor = computed(() => currentAnchor(props.images))
const selected = computed(
() => props.images.find(image => image.id === selectedId.value) ?? anchor.value ?? props.images[0]
)
const character = computed(() => props.module === 'character')
const canSelect = computed(
() => !props.disabled && !!selected.value && canBeAnchor(selected.value) && !selected.value.isAnchor
() =>
!props.disabled &&
!!selected.value &&
canBeAnchor(selected.value) &&
(!selected.value.isAnchor || (character.value && !props.identityLocked))
)
const imageUrl = computed(() => referenceImageUrl(selected.value?.imageUrl ?? ''))
watch(
@@ -76,12 +87,12 @@ function chooseAnchor() {
<p v-if="selected.error" class="alert alert-error mt-3" role="alert">{{ selected.error }}</p>
<div class="mt-3 flex flex-wrap items-center gap-3">
<button
v-if="!selected.isAnchor"
v-if="!selected.isAnchor || (character && !identityLocked)"
class="button button-secondary"
:disabled="!canSelect"
@click="confirming = true"
>
设为身份母版</button
{{ character ? '确认选角并锁定' : '设为身份母版' }}</button
><span class="text-[11px] text-muted"
>{{ selected.provider }} · {{ selected.model }} · {{ formatDate(selected.createdAt) }}</span
>
@@ -91,12 +102,15 @@ function chooseAnchor() {
</p>
<div v-if="confirming" class="alert mt-3">
<p class="text-xs">
确认切换身份母版 primary
母版将停用并保留后续人物场景形态生图会引用新母版道具暂不自动引用此操作不调用模型
{{
character
? '确认选择这张图片作为正式演员?后端会在同一事务中切换身份母版并锁定 Identity,完成后首帧才具备稳定人物身份。'
: '确认切换身份母版?旧 primary 母版将停用并保留。后续场景形态生图会引用新母版。此操作不调用模型。'
}}
</p>
<div class="mt-3 flex gap-3">
<button class="button button-primary" :disabled="!canSelect" @click="chooseAnchor">
确认切换身份母版</button
{{ character ? '确认演员选择' : '确认切换身份母版' }}</button
><button class="text-button" @click="confirming = false">取消</button>
</div>
</div>
@@ -6,7 +6,13 @@ import { currentAnchor, identityViewLabels } from '../model'
import type { GenerateIdentityImageInput, IdentityImage, IdentityViewType } from '../types'
/** 身份图生成配置;默认自动引用母版,不提供后端不存在的“忽略母版”开关。 */
const props = defineProps<{ subjectId: string; subjectName: string; images: IdentityImage[]; disabled: boolean }>()
const props = defineProps<{
subjectId: string
subjectName: string
module?: string
images: IdentityImage[]
disabled: boolean
}>()
const open = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{ generate: [input: GenerateIdentityImageInput] }>()
const viewType = ref<IdentityViewType>('primary')
@@ -16,6 +22,9 @@ const width = ref<number | ''>('')
const height = ref<number | ''>('')
const acknowledged = ref(false)
const anchor = computed(() => currentAnchor(props.images))
const allowedViews = computed(() =>
Object.entries(identityViewLabels).filter(([value]) => props.module !== 'character' || value !== 'primary')
)
const references = computed(() => props.images.filter(image => image.status === 'completed' && image.imageUrl))
const valid = computed(
() =>
@@ -25,13 +34,14 @@ const valid = computed(
watch(
() => [open.value, props.subjectId],
() => {
viewType.value = 'primary'
viewType.value = props.module === 'character' ? 'front' : 'primary'
referenceImageId.value = ''
prompt.value = ''
width.value = ''
height.value = ''
acknowledged.value = false
}
},
{ immediate: true }
)
/** 提交显式的 Seedream 参数;省略空参考 ID 以保留后端自动选择逻辑。 */
@@ -58,7 +68,7 @@ function submit() {
<label class="block"
><span class="field-label">参考视角</span
><select id="identity-view" v-model="viewType" class="input">
<option v-for="(label, value) in identityViewLabels" :key="value" :value="value">
<option v-for="[value, label] in allowedViews" :key="value" :value="value">
{{ label }}{{ value }}
</option>
</select></label
+115 -3
View File
@@ -1,8 +1,17 @@
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import IdentityImageDialog from './components/IdentityImageDialog.vue'
import IdentityGallery from './components/IdentityGallery.vue'
import { canBeAnchor, currentAnchor, groupIdentitySubjects, readImageProvenance } from './model'
import CastingCandidateDialog from './components/CastingCandidateDialog.vue'
import { subjectIdentityApi } from './api'
import {
canBeAnchor,
castingStatusLabel,
currentAnchor,
groupIdentitySubjects,
mergeCastingSubjects,
readImageProvenance
} from './model'
import { identityImageFixture } from './testing/fixtures'
import { formFixture } from '../subject-images/testing/fixtures'
@@ -11,6 +20,7 @@ afterEach(() => {
wrapper?.unmount()
wrapper = undefined
document.body.innerHTML = ''
vi.unstubAllGlobals()
})
/** 从真实 Reka Portal 内获取确认按钮。 */
@@ -48,6 +58,29 @@ describe('身份图与母版契约', () => {
})
})
it('选角就绪结果可以补入尚无形态的角色目录', () => {
const rows = mergeCastingSubjects(
groupIdentitySubjects([formFixture()]),
[
{
subjectId: 'character-without-form',
subjectRef: '@CH0002',
subjectName: '陆川',
status: 'missing_identity',
isLocked: false
}
],
'project-1'
)
expect(rows).toHaveLength(2)
expect(rows.find(item => item.id === 'character-without-form')).toMatchObject({
projectId: 'project-1',
module: 'character',
forms: []
})
expect(castingStatusLabel('candidate_pending')).toBe('等待确认演员')
})
it('辅助视角传递明确参考图、成对尺寸和本次 Prompt,不传形态生图字段', async () => {
wrapper = mount(IdentityImageDialog, {
attachTo: document.body,
@@ -107,18 +140,97 @@ describe('身份图与母版契约', () => {
expect(document.querySelector<HTMLInputElement>('#identity-image-cost')!.checked).toBe(false)
})
it('Character 普通身份图入口只提供辅助视角,primary 必须走选角候选接口', async () => {
wrapper = mount(IdentityImageDialog, {
attachTo: document.body,
props: {
open: true,
subjectId: 's1',
subjectName: '林夏',
module: 'character',
images: [identityImageFixture()],
disabled: false
}
})
await flushPromises()
const options = [...document.querySelectorAll<HTMLOptionElement>('#identity-view option')]
expect(options.map(item => item.value)).toEqual(['front', 'three-quarter', 'full-body'])
expect(document.querySelector<HTMLSelectElement>('#identity-view')!.value).toBe('front')
})
it('辅助图不能切换母版,提示词按纯文本展示', async () => {
wrapper = mount(IdentityGallery, {
attachTo: document.body,
props: {
subjectName: '林夏',
module: 'character',
identityLocked: false,
disabled: false,
images: [identityImageFixture({ viewType: 'front', isAnchor: false })]
}
})
expect(button('设为身份母版').disabled).toBe(true)
expect(button('确认选角并锁定').disabled).toBe(true)
expect(wrapper.text()).toContain('<script>不执行</script>')
expect(wrapper.find('script').exists()).toBe(false)
expect(wrapper.emitted('anchor')).toBeUndefined()
})
it('角色已有母版但未锁定时仍可确认选角,确认文案不冒充普通母版切换', async () => {
wrapper = mount(IdentityGallery, {
attachTo: document.body,
props: {
subjectName: '林夏',
module: 'character',
identityLocked: false,
disabled: false,
images: [identityImageFixture({ isAnchor: true })]
}
})
button('确认选角并锁定').click()
await flushPromises()
expect(document.body.textContent).toContain('同一事务中切换身份母版并锁定 Identity')
button('确认演员选择').click()
expect(wrapper.emitted('anchor')).toEqual([['identity-image-1']])
})
it('选角候选固定发送 Seedream,不携带普通身份图视角字段', async () => {
wrapper = mount(CastingCandidateDialog, {
attachTo: document.body,
props: { open: true, subjectId: 'subject-1', subjectName: '林夏', disabled: false }
})
await flushPromises()
document.querySelector<HTMLInputElement>('#casting-candidate-cost')!.click()
await flushPromises()
button('确认生成候选').click()
expect(wrapper.emitted('generate')).toEqual([[{ provider: 'seedream' }]])
})
it('角色选角接口区分批量身份、候选生图与确认事务', async () => {
const fetcher = vi.fn<typeof fetch>().mockImplementation(
async () =>
new Response(
JSON.stringify({
data: {
total: 0,
targetCount: 0,
generated: 0,
skipped: 0,
skippedLocked: 0,
failed: 0,
failures: []
}
})
)
)
vi.stubGlobal('fetch', fetcher)
await subjectIdentityApi.generateCharacters('project/1', { force: false, concurrency: 2 })
await subjectIdentityApi.generateCastingCandidate('subject/1', { provider: 'seedream' })
await subjectIdentityApi.confirmCasting('subject/1', 'image/1')
expect(fetcher.mock.calls.map(([url]) => url)).toEqual([
'/api/projects/project%2F1/character-identities/generate',
'/api/subjects/subject%2F1/identity/casting-candidates',
'/api/subjects/subject%2F1/identity/images/image%2F1/casting'
])
expect(fetcher.mock.calls.map(([, init]) => init?.method)).toEqual(['POST', 'POST', 'PUT'])
})
})
+43 -3
View File
@@ -1,6 +1,12 @@
import { reactive } from 'vue'
import type { SubjectFormAsset } from '../subject-images/types'
import type { IdentityBatchResult, IdentityImage, IdentitySubject } from './types'
import type {
CharacterCastingItem,
CharacterCastingStatus,
IdentityImage,
IdentityReceipt,
IdentitySubject
} from './types'
/** 视角名称保持与后端四种固定值对应。 */
export const identityViewLabels = {
@@ -22,6 +28,40 @@ export function groupIdentitySubjects(forms: SubjectFormAsset[]): IdentitySubjec
return [...subjects.values()]
}
/** 将选角就绪接口中的无形态 Character 补入主体目录,确保选角早于 Form 生图也可操作。 */
export function mergeCastingSubjects(
subjects: IdentitySubject[],
casting: CharacterCastingItem[],
projectId: string
): IdentitySubject[] {
const rows = new Map(subjects.map(subject => [subject.id, subject]))
for (const item of casting) {
if (!rows.has(item.subjectId)) {
rows.set(item.subjectId, {
id: item.subjectId,
projectId,
ref: item.subjectRef,
name: item.subjectName,
module: 'character',
forms: []
})
}
}
return [...rows.values()].toSorted((a, b) => a.ref.localeCompare(b.ref))
}
/** 角色选角状态中文标签。 */
export function castingStatusLabel(status: CharacterCastingStatus): string {
const labels: Record<CharacterCastingStatus, string> = {
missing_identity: '缺少身份文本',
missing_anchor: '缺少候选图',
candidate_pending: '等待确认演员',
unlocked: '演员尚未锁定',
ready: '选角已完成'
}
return labels[status]
}
/** 只有成功且有图片的 primary 候选可成为母版;停用候选也允许重新启用。 */
export function canBeAnchor(image: IdentityImage): boolean {
return image.viewType === 'primary' && image.status === 'completed' && !!image.imageUrl
@@ -52,9 +92,9 @@ export function readImageProvenance(rawJson?: string | null): {
}
/** 批量回执按项目保存在当前会话,切页不丢失,刷新浏览器后不伪造恢复。 */
const sessions = reactive<Record<string, { receipt: IdentityBatchResult | null }>>({})
const sessions = reactive<Record<string, { receipt: IdentityReceipt | null }>>({})
/** 取得指定项目的身份文本批量回执。 */
/** 取得指定项目的身份文本或选角候选批量回执。 */
export function getIdentitySession(projectId: string) {
return (sessions[projectId] ??= { receipt: null })
}
+87 -2
View File
@@ -36,12 +36,12 @@ export interface SubjectIdentity {
updatedAt: string
}
/** 从正式形态目录归并主体,不使用 checkpoint 的候选 ID。 */
/** 从正式形态与 Character Casting 归并主体,不使用 checkpoint 的候选 ID。 */
export type IdentitySubject = SubjectFormAsset['subject'] & {
forms: SubjectFormAsset[]
}
/** 人工保存身份;锁定只保护 AI 文本重生成,不限制人工保存或生图。 */
/** 人工保存身份;Character 锁定同时是确认演员后的首帧前置条件。 */
export type SaveIdentityInput = Partial<Pick<SubjectIdentity, 'description' | 'generationPrompt' | 'isLocked'>>
/** 单张身份生图;空 referenceImageId 应省略,由后端自动引用当前母版。 */
@@ -54,6 +54,14 @@ export interface GenerateIdentityImageInput {
height?: number
}
/** Character 选角候选固定生成 primary,且不会自动成为 Anchor。 */
export interface GenerateCastingCandidateInput {
provider: 'seedream'
prompt?: string
width?: number
height?: number
}
/** 身份文本批量生成回执,不代表已生成参考图。 */
export interface IdentityBatchResult {
total: number
@@ -64,3 +72,80 @@ export interface IdentityBatchResult {
failed: number
failures: { subjectId: string; subjectRef: string; error: string }[]
}
/** Character 的正式选角阶段状态。 */
export type CharacterCastingStatus = 'missing_identity' | 'missing_anchor' | 'candidate_pending' | 'unlocked' | 'ready'
/** 就绪接口返回的轻量候选图。 */
export interface CastingCandidateImage {
id: string
imageUrl: string
createdAt: string
}
/** 单个 Character 的选角就绪记录。 */
export interface CharacterCastingItem {
subjectId: string
subjectRef: string
subjectName: string
status: CharacterCastingStatus
identityId?: string
anchorImageId?: string
anchorImageUrl?: string
candidateImages?: CastingCandidateImage[]
isLocked: boolean
}
/** 项目角色选角汇总;locked 与 ready 语义不同,锁定但无 Anchor 仍不可生产首帧。 */
export interface CharacterCastingReadiness {
total: number
ready: number
missingIdentity: number
missingAnchor: number
candidatePending: number
unlocked: number
locked: number
items: CharacterCastingItem[]
}
/** 小批量生成选角候选的配置。 */
export interface GenerateCastingCandidatesInput {
provider: 'seedream'
limit: number
concurrency: number
width?: number
height?: number
}
/** 选角候选批量结果中的逐主体记录。 */
export interface CastingCandidateResult {
subjectId: string
subjectRef: string
subjectName: string
success: boolean
imageId?: string
imageUrl?: string | null
error?: string
}
/** 小批量选角候选生图回执。 */
export interface CastingBatchResult {
totalCharacters: number
missingAnchor: number
targetCount: number
generated: number
failed: number
failures: { subjectId: string; subjectRef: string; subjectName: string; error: string }[]
results: CastingCandidateResult[]
}
/** 确认选角会在同一事务中切换 Anchor 并锁定 Identity。 */
export interface ConfirmCastingResult {
identity: Omit<SubjectIdentity, 'images'>
anchor: IdentityImage
}
/** 主体身份页的批量操作回执。 */
export type IdentityReceipt =
| { kind: 'identities'; title: string; result: IdentityBatchResult }
| { kind: 'casting'; title: string; result: CastingBatchResult }
@@ -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
}
}
+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)
})
})