feat: 同步角色选角与首帧身份约束
This commit is contained in:
@@ -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">
|
||||
同时处理 Character、Scene 和 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>
|
||||
|
||||
Reference in New Issue
Block a user