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
@@ -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>