feat: 同步最新生图与身份能力

This commit is contained in:
GouJ
2026-09-22 19:56:12 +08:00
parent 65e8f9c3ba
commit be06caaa9f
18 changed files with 583 additions and 69 deletions
+7
View File
@@ -52,3 +52,10 @@
- 素材公开字段包含 `publicUrl`、类型、MIME、扩展名、大小、分类、元数据和时间;页面只读取 `metadata.originalName` 作为辅助搜索,不依赖内部存储路径。
- 视觉风格新增参考图可发送 `projectAssetId`,后端从所属项目素材解析 `imageUrl`;外部 `imageUrl` 入口继续兼容。资产关联关系不在公开风格图 DTO 中推断。
- 删除仍由后端执行引用保护;前端不先删风格记录,也不将失败响应显示为已删除。
## 2026-09-22 增量同步
- 新增形态图和镜头首帧的 POST 预览接口;请求体默认 `{}`,只读取后端当前生成配置与参考图规划,不产生模型任务。
- 首帧预览按 `referencePlan.selected``omitted` 展示 Provider 上限后的真实输入;不再从通用 `keyframe-spec` 推断最终选图。
- 形态提示词、形态图片及主体身份文本批处理均可发送可选 `module`;省略时保持全项目行为。
- 场景/道具身份母版使用 `/projects/:projectId/subject-identities/anchors/generate`,请求包含 `module``limit``concurrency`;成功项已由后端设为 Anchor 并锁定,不再由前端逐项补 PUT。
+12
View File
@@ -83,3 +83,15 @@
核对后端 dev `d9ca93ef54a4c5fbe3917c504531a45f31cb9966`。新增独立项目资产库页面,接入图片上传、列表筛选、详情预览、名称/分类编辑和删除;上传使用 multipart,前端与后端同时限制 JPEG、PNG、WebP 及单张 20MB。视觉风格页可按需读取资产库并以正式 `projectAssetId` 登记参考图,仍保留外部 URL 兼容入口。后端负责校验资产归属、图片类型和删除引用保护。
启用的视觉风格参考图会由后端编译为形态生图的分类参考职责,身份母版继续承担主体一致性。前端不拼接 Provider Prompt,也不把资产库图片自动设为形态主图;已有形态图不会因资产或风格参考变化被客户端静默替换。
## 2026-09-22 生图预览与主体模块批处理
核对后端 dev `9d927cd3e9d7e01415ad953bf3a0957bc3825050`,补齐以下正式能力:
- 形态生图弹窗接入 POST `/subject-forms/:subjectFormId/images/preview`,展示后端实际 Provider、模型、参考图顺序、生成配置与最终 Prompt;预览不创建生图任务。
- 首帧弹窗接入 POST `/storyboard-shots/:shotId/keyframe/preview`,展示视觉风格、身份母版和形态主图经过去重与 Provider 上限规划后的实际选中/省略结果。
- 形态提示词与形态图片批处理支持 `module=character|scene|prop`,前端提供统一批处理范围选择,不再只能执行全项目全部类型。
- 身份文本批处理支持模块筛选;场景/道具新增稳定母版小批量入口,成功后由后端原子设为 Anchor 并锁定 Identity,前端保留部分失败回执。
- 全局 GET `/assets` 已由现有资产库页面使用,无需新增重复页面。
预览结果只用于提交前核对,不作为持久化任务或质量通过证明;真正生成仍需费用确认,并以刷新后的正式资产记录为准。
+4
View File
@@ -6,6 +6,7 @@ import type {
GenerateQualityVideosInput,
KeyframeBatchResult,
KeyframeReadiness,
KeyframeGenerationPreview,
KeyframeSpec,
ProjectVideoStatus,
PromptReadiness,
@@ -106,6 +107,9 @@ export const productionApi = {
request<ShotKeyframe[]>(`${shotPath(shotId)}/keyframes`, { signal }),
keyframeSpec: (shotId: string, signal?: AbortSignal) =>
request<KeyframeSpec>(`${shotPath(shotId)}/keyframe-spec`, { signal }),
/** 预览最终参考图规划与 Provider Prompt,不创建首帧任务。 */
previewKeyframe: (shotId: string) =>
request<KeyframeGenerationPreview>(`${shotPath(shotId)}/keyframe/preview`, { method: 'POST', body: {} }),
generateKeyframe: (shotId: string, input: GenerateKeyframeInput) =>
request<ShotKeyframe | null>(`${shotPath(shotId)}/keyframe`, {
method: 'POST',
@@ -2,11 +2,13 @@
import AppForm from '../../../components/ui/AppForm.vue'
import { NFormItem } from 'naive-ui'
import { sizeRules } from '../../../lib/form-rules'
import { NAlert, NButton, NCheckbox, NInputNumber } from 'naive-ui'
import { NAlert, NButton, NCheckbox, NCollapse, NCollapseItem, NInputNumber, NTag } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { AppDialog } from '../../../components/ui'
import { errorMessage } from '../../../lib/http'
import { productionApi } from '../api'
import { validOptionalSize } from '../model'
import type { GenerateKeyframeInput, ShotKeyframe } from '../types'
import type { GenerateKeyframeInput, KeyframeGenerationPreview, ShotKeyframe } from '../types'
/** 单镜头首帧表单;普通候选仍由用户明确选择,过期主首帧默认替换为新图。 */
const props = defineProps<{
@@ -22,6 +24,9 @@ const width = ref<number | ''>('')
const height = ref<number | ''>('')
const setPrimary = ref(false)
const confirmed = ref(false)
const preview = ref<KeyframeGenerationPreview | null>(null)
const previewError = ref('')
const previewPending = ref(false)
const hasPrimary = computed(() => props.keyframes.some(item => item.isPrimary && item.status === 'completed'))
const sizeValid = computed(() => validOptionalSize(width.value, height.value))
const canSubmit = computed(() => !props.disabled && sizeValid.value && confirmed.value)
@@ -32,6 +37,9 @@ function reset() {
height.value = ''
setPrimary.value = !!props.replacePrimary || !hasPrimary.value
confirmed.value = false
preview.value = null
previewError.value = ''
previewPending.value = false
}
watch([() => props.shotId, open], reset, { immediate: true })
@@ -47,6 +55,23 @@ function submit() {
}
const formModel = computed(() => ({ width: width.value, height: height.value }))
const rules = sizeRules(() => formModel.value)
/** 读取后端最终参考图选择与 Prompt,不创建首帧候选。 */
async function loadPreview() {
if (!props.shotId || previewPending.value) return
previewPending.value = true
previewError.value = ''
try {
const result = await productionApi.previewKeyframe(props.shotId)
if (!result || result.shotId !== props.shotId) throw new Error('预览结果与当前镜头不匹配。')
preview.value = result
} catch (cause) {
preview.value = null
previewError.value = errorMessage(cause)
} finally {
previewPending.value = false
}
}
</script>
<template>
@@ -66,6 +91,49 @@ const rules = sizeRules(() => formModel.value)
<NAlert v-if="replacePrimary" type="info" :show-icon="false" class="mt-5 text-xs">
当前主首帧使用的主体参考资产已经变化新图成功后应设为主首帧旧图会继续保留为历史候选
</NAlert>
<div class="mt-5 flex flex-wrap items-center gap-3">
<NButton :loading="previewPending" @click="loadPreview">预览实际生成输入</NButton>
<span class="text-xs text-muted">只读取参考图规划与最终 Prompt不调用图片模型</span>
</div>
<NAlert v-if="previewError" type="error" :show-icon="false" class="mt-3">{{ previewError }}</NAlert>
<div v-if="preview" class="panel mt-3 p-4 text-xs leading-6">
<div class="flex flex-wrap items-center gap-2">
<strong>{{ preview.provider }} / {{ preview.model }}</strong>
<NTag size="small" :bordered="false">
已选 {{ preview.referencePlan.selected.length }} /
{{ preview.referencePlan.totalCandidates }}
</NTag>
<NTag v-if="preview.referencePlan.omitted.length" size="small" type="warning" :bordered="false">
省略 {{ preview.referencePlan.omitted.length }}
</NTag>
</div>
<ul v-if="preview.referencePlan.selected.length" class="mt-3 space-y-1 text-muted">
<li v-for="item in preview.referencePlan.selected" :key="`${item.role}:${item.imageId}`">
{{ item.subjectRef }} · {{ item.role }} · <code>{{ item.imageId }}</code>
</li>
</ul>
<NCollapse class="mt-3" :default-expanded-names="['prompt']">
<NCollapseItem name="prompt" title="最终 Provider Prompt">
<p class="mt-3 whitespace-pre-wrap">{{ preview.providerPrompt }}</p>
</NCollapseItem>
<NCollapseItem
v-if="preview.referencePlan.omitted.length"
name="omitted"
title="未进入模型的参考图"
>
<ul class="mt-3 space-y-1 text-muted">
<li v-for="item in preview.referencePlan.omitted" :key="`${item.role}:${item.imageId}`">
{{ item.subjectRef }} · {{ item.role }} · <code>{{ item.imageId }}</code>
</li>
</ul>
</NCollapseItem>
<NCollapseItem name="config" title="生成配置">
<pre class="mt-3 whitespace-pre-wrap">{{
JSON.stringify(preview.generationConfig, null, 2)
}}</pre>
</NCollapseItem>
</NCollapse>
</div>
<div class="mt-5 grid grid-cols-2 gap-4">
<NFormItem path="width" label="宽度(可选)"
><NInputNumber
+47
View File
@@ -146,6 +146,53 @@ export interface KeyframeSpec {
references: KeyframeReference[]
}
export type ImageReferenceRole =
| 'style-overall'
| 'style-character'
| 'style-scene'
| 'style-prop'
| 'identity-anchor'
| 'form-primary'
export interface KeyframePreviewReference {
originalImageNumber: number
subjectRef: string
module: string
role: ImageReferenceRole
imageId: string
imageUrl: string
}
export interface KeyframeReferencePlanItem {
subjectId?: string
subjectRef: string
module: string
role: ImageReferenceRole
imageId: string
subjectFormId?: string
}
/** 首帧生成前的无费用参考图规划与 Provider Prompt 预览。 */
export interface KeyframeGenerationPreview {
shotId: string
episodeNo: number
beatNo: number
shotNo: number
provider: string
model: string
generationConfig: Record<string, unknown>
references: KeyframePreviewReference[]
referencePlan: {
provider: string
maxReferenceImages: number | null
totalCandidates: number
strategy: 'all' | 'subject-coverage' | 'repair-aware'
selected: KeyframeReferencePlanItem[]
omitted: KeyframeReferencePlanItem[]
}
providerPrompt: string
}
/** 视频生成规格中的主体参考图。 */
export interface VideoReference {
/** 当前目标视频模型的授权素材,不拼接模型私有地址。 */
@@ -64,6 +64,7 @@ const {
save,
generate,
generateProject,
generateStableAnchors,
generateCastingCandidates,
generateImage,
generateCastingCandidate,
@@ -72,6 +73,7 @@ const {
} = useSubjectIdentity()
const search = ref('')
const module = ref('all')
const identityBatchModule = ref<'all' | 'character' | 'scene' | 'prop'>('all')
const imageOpen = ref(false)
const castingOpen = ref(false)
const pendingSelection = ref('')
@@ -301,6 +303,7 @@ const rules = { concurrency: integerRule('并发数'), candidateLimit: integerRu
blocked ||
!hasStyle ||
!batchValid ||
!candidateLimitValid ||
dirty ||
!casting.total
"
@@ -343,6 +346,46 @@ const rules = { concurrency: integerRule('并发数'), candidateLimit: integerRu
/></NButton>
</div>
</section>
<section class="panel mb-5 p-5" aria-label="场景与道具身份母版">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h3 class="text-sm font-semibold">场景与道具稳定母版</h3>
<p class="mt-2 text-xs leading-6 text-muted">
为已有身份提示词但缺少有效母版的场景或道具小批量生图成功后后端会自动设为
Anchor 并锁定 Identity
</p>
</div>
<div class="flex flex-wrap gap-3">
<ConfirmAction
label="生成场景母版"
:disabled="
blocked ||
!hasStyle ||
!batchValid ||
!candidateLimitValid ||
dirty ||
!subjects.length
"
acknowledgement
:description="`最多处理 ${candidateLimit} 个场景,生成成功后自动设为身份母版并锁定。可能产生图片模型费用。`"
@confirm="generateStableAnchors('scene')"
/>
<ConfirmAction
label="生成道具母版"
:disabled="
blocked ||
!hasStyle ||
!batchValid ||
dirty ||
!subjects.length
"
acknowledgement
:description="`最多处理 ${candidateLimit} 个道具,生成成功后自动设为身份母版并锁定。可能产生图片模型费用。`"
@confirm="generateStableAnchors('prop')"
/>
</div>
</div>
</section>
<NCollapse class="panel mb-5 p-5" :default-expanded-names="['details']"
><NCollapseItem name="details"
><template #header>高级全部主体身份文本</template>
@@ -350,6 +393,16 @@ const rules = { concurrency: integerRule('并发数'), candidateLimit: integerRu
同时处理 CharacterScene
Prop角色选角建议优先使用上方专用入口已锁定身份始终跳过
</p>
<NSelect
v-model:value="identityBatchModule"
class="mt-4 w-48"
aria-label="身份文本批处理范围"
:options="[
{ label: String('全部类型'), value: 'all' },
{ label: String('仅人物'), value: 'character' },
{ label: String('仅场景'), value: 'scene' },
{ label: String('仅道具'), value: 'prop' }
]" />
<ConfirmAction
class="mt-4"
:label="force ? '重生成全部未锁定身份' : '补齐全部主体身份文本'"
@@ -358,7 +411,17 @@ const rules = { concurrency: integerRule('并发数'), candidateLimit: integerRu
"
acknowledgement
description="生成全项目主体身份描述与提示词,不生成图片。覆盖不会自动更新旧身份图、形态图、首帧或视频。"
@confirm="generateProject(false)" /></NCollapseItem
@confirm="
identityBatchModule === 'character'
? generateProject(true)
: generateProject(
false,
identityBatchModule === 'scene' ||
identityBatchModule === 'prop'
? identityBatchModule
: undefined
)
" /></NCollapseItem
></NCollapse>
<section
v-if="session.receipt"
@@ -382,13 +445,25 @@ const rules = { concurrency: integerRule('并发数'), candidateLimit: integerRu
{{ session.receipt.result.skippedLocked }}· 失败
{{ session.receipt.result.failed }}
</p>
<p v-else class="mt-3 text-xs text-muted">
<p
v-else-if="session.receipt.kind === 'casting'"
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-else class="mt-3 text-xs text-muted">
{{ session.receipt.result.total }} {{
session.receipt.result.module === 'scene' ? '场景' : '道具'
}}
· 符合条件 {{ session.receipt.result.eligibleCount }} · 本批目标
{{ session.receipt.result.targetCount }} · 已生成并锁定
{{ session.receipt.result.generated }} · 失败
{{ session.receipt.result.failed }}
</p>
<NAlert
v-if="session.receipt.result.failed"
role="alert"
+12 -1
View File
@@ -8,7 +8,9 @@ import type {
GenerateIdentityImageInput,
IdentityBatchResult,
IdentityImage,
IdentityModule,
SaveIdentityInput,
StableAnchorBatchResult,
SubjectIdentity
} from './types'
@@ -29,7 +31,7 @@ export const subjectIdentityApi = {
body: { force },
timeoutMs: 0
}),
generateProject: (projectId: string, input: { force: boolean; concurrency: number }) =>
generateProject: (projectId: string, input: { force: boolean; concurrency: number; module?: IdentityModule }) =>
request<IdentityBatchResult>(`/projects/${encodeURIComponent(projectId)}/subject-identities/generate`, {
method: 'POST',
body: input,
@@ -56,6 +58,15 @@ export const subjectIdentityApi = {
`/projects/${encodeURIComponent(projectId)}/character-casting/candidates/generate`,
{ method: 'POST', body: input, timeoutMs: 0 }
),
/** 为场景或道具生成稳定身份母版,并由后端原子设为 Anchor、锁定 Identity。 */
generateStableAnchors: (
projectId: string,
input: { module: 'scene' | 'prop'; limit: number; concurrency: number }
) =>
request<StableAnchorBatchResult>(
`/projects/${encodeURIComponent(projectId)}/subject-identities/anchors/generate`,
{ method: 'POST', body: input, timeoutMs: 0 }
),
listImages: (subjectId: string, signal?: AbortSignal) =>
request<IdentityImage[]>(`${identityPath(subjectId)}/images`, { signal }),
generateImage: (subjectId: string, input: GenerateIdentityImageInput) =>
+23
View File
@@ -67,6 +67,28 @@ export interface IdentityBatchResult {
failures: { subjectId: string; subjectRef: string; error: string }[]
}
export type IdentityModule = 'character' | 'scene' | 'prop'
export interface StableAnchorBatchResult {
module: 'scene' | 'prop'
total: number
eligibleCount: number
targetCount: number
generated: number
failed: number
failures: { subjectId: string; subjectRef: string; subjectName: string; error: string }[]
results: Array<{
subjectId: string
subjectRef: string
subjectName: string
success: boolean
imageId?: string
imageUrl?: string | null
identityLocked?: boolean
error?: string
}>
}
/** Character 的正式选角阶段状态。 */
export type CharacterCastingStatus = 'missing_identity' | 'missing_anchor' | 'candidate_pending' | 'unlocked' | 'ready'
@@ -144,3 +166,4 @@ export interface ConfirmCastingResult {
export type IdentityReceipt =
| { kind: 'identities'; title: string; result: IdentityBatchResult }
| { kind: 'casting'; title: string; result: CastingBatchResult }
| { kind: 'stable-anchors'; title: string; result: StableAnchorBatchResult }
@@ -1,5 +1,5 @@
import { computed, ref, watch } from 'vue'
import { useQuery } from '../../composables/useQuery'
import { usePolling } from '../../composables/usePolling'
import { visualStyleApi } from '../visual-style'
import { subjectImagesApi } from '../subject-images/api'
import { hasRunningImages } from '../subject-images/model'
@@ -30,42 +30,58 @@ export function useSubjectIdentity() {
const candidateLimit = ref(3)
const force = ref(false)
/** 目录与身份已有刷新按钮,查询只在进入、切换和手动刷新时读取。 */
const catalog = useQuery(projectId, async (id, signal) => {
const forms = await subjectImagesApi.listForms(id, signal)
if (
forms.some(
form => form.subject.projectId !== id || form.images.some(image => image.subjectFormId !== form.id)
const catalog = usePolling(
projectId,
async (id, signal) => {
const forms = await subjectImagesApi.listForms(id, signal)
if (
forms.some(
form => form.subject.projectId !== id || form.images.some(image => image.subjectFormId !== form.id)
)
)
)
throw new Error('主体目录与当前项目不匹配,请刷新后重试。')
return {
subjects: groupIdentitySubjects(forms),
running: forms.some(form => hasRunningImages(form.images))
}
})
const styleQuery = useQuery(projectId, async (id, signal) => {
const style = await visualStyleApi.get(id, signal)
if (style && style.projectId !== id) throw new Error('视觉风格与当前项目不匹配。')
return { style }
})
const castingQuery = useQuery(projectId, async (id, signal) => {
const readiness = await subjectIdentityApi.castingReadiness(id, signal)
return readiness
})
throw new Error('主体目录与当前项目不匹配,请刷新后重试。')
return {
subjects: groupIdentitySubjects(forms),
running: forms.some(form => hasRunningImages(form.images))
}
},
false
)
const styleQuery = usePolling(
projectId,
async (id, signal) => {
const style = await visualStyleApi.get(id, signal)
if (style && style.projectId !== id) throw new Error('视觉风格与当前项目不匹配。')
return { style }
},
false
)
const castingQuery = usePolling(
projectId,
async (id, signal) => {
const readiness = await subjectIdentityApi.castingReadiness(id, signal)
return readiness
},
false
)
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 = useQuery(selectionKey, async (id, signal) => {
if (!id) return null
const identity = await subjectIdentityApi.get(id, signal)
if (!identity) return { identity: null, images: [] as IdentityImage[] }
assertIdentity(identity, id)
const images = await subjectIdentityApi.listImages(id, signal)
if (images.some(image => image.identityId !== identity.id)) throw new Error('身份图片与当前主体不匹配。')
return { identity, images }
})
const detail = usePolling(
selectionKey,
async (id, signal) => {
if (!id) return null
const identity = await subjectIdentityApi.get(id, signal)
if (!identity) return { identity: null, images: [] as IdentityImage[] }
assertIdentity(identity, id)
const images = await subjectIdentityApi.listImages(id, signal)
if (images.some(image => image.identityId !== identity.id)) throw new Error('身份图片与当前主体不匹配。')
return { identity, images }
},
false
)
const identity = computed(() => detail.data.value?.identity ?? null)
const images = computed(() => detail.data.value?.images ?? [])
const castingItem = computed(() =>
@@ -148,13 +164,17 @@ export function useSubjectIdentity() {
}
/** 批量仅生成身份文本,已锁定项由后端跳过,回执保留部分失败。 */
async function generateProject(charactersOnly = false) {
async function generateProject(charactersOnly = false, module?: 'scene' | 'prop') {
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 }
const input = { concurrency: concurrency.value, force: force.value, ...(module ? { module } : {}) }
target.receipt = null
const title = charactersOnly ? '批量生成角色身份文本' : '批量生成全部主体身份文本'
const title = charactersOnly
? '批量生成角色身份文本'
: module
? `批量生成${module === 'scene' ? '场景' : '道具'}身份文本`
: '批量生成全部主体身份文本'
await runOperation(id, title, async () => {
target.receipt = {
kind: 'identities',
@@ -167,6 +187,27 @@ export function useSubjectIdentity() {
await Promise.all([detail.refresh(), castingQuery.refresh()])
}
/** 场景/道具身份母版由后端独立生成、设为 Anchor 并锁定,保留逐主体失败回执。 */
async function generateStableAnchors(module: 'scene' | 'prop') {
if (blocked.value || !hasStyle.value || !batchValid.value || !candidateLimitValid.value || dirty.value) return
const id = projectId.value
const target = getIdentitySession(id)
const title = `批量生成${module === 'scene' ? '场景' : '道具'}身份母版`
target.receipt = null
await runOperation(id, title, async () => {
target.receipt = {
kind: 'stable-anchors',
title,
result: await subjectIdentityApi.generateStableAnchors(id, {
module,
limit: candidateLimit.value,
concurrency: concurrency.value
})
}
})
await Promise.all([catalog.refresh(), detail.refresh()])
}
/** 小批量只为 missing_anchor 的 Character 生成候选,不自动选择演员。 */
async function generateCastingCandidates() {
if (
@@ -309,6 +350,7 @@ export function useSubjectIdentity() {
save,
generate,
generateProject,
generateStableAnchors,
generateCastingCandidates,
generateImage,
generateCastingCandidate,
@@ -61,6 +61,7 @@ const {
limit,
promptConcurrency,
promptForce,
batchModule,
generatePrompt,
generatePrompts,
blocked,
@@ -345,6 +346,21 @@ const rules = { concurrency: integerRule('批量并发'), limit: integerRule('
v-model:force="promptForce"
@generate="generatePrompts"
/>
<div class="mt-5 flex flex-wrap items-center gap-3">
<span class="text-xs text-muted">批处理范围</span>
<NSelect
v-model:value="batchModule"
class="w-40"
aria-label="形态批处理范围"
:options="[
{ label: String('全部类型'), value: 'all' },
{ label: String('仅人物'), value: 'character' },
{ label: String('仅场景'), value: 'scene' },
{ label: String('仅道具'), value: 'prop' }
]"
/>
<span class="text-xs text-muted">同时应用于批量提示词与批量生图</span>
</div>
<div class="panel mt-5 p-5">
<div class="flex flex-wrap items-center justify-between gap-4">
<p class="text-sm">
+5 -1
View File
@@ -6,7 +6,8 @@ import type {
FormPromptResult,
ImageBatchResult,
SubjectFormAsset,
SubjectImage
SubjectImage,
SubjectImageGenerationPreview
} from './types'
/** 对正式 SubjectForm 数据库 ID 编码,不接受按名称拼出的路径。 */
@@ -33,6 +34,9 @@ export const subjectImagesApi = {
listForms: (projectId: string, signal?: AbortSignal) =>
request<SubjectFormAsset[]>(`/projects/${encodeURIComponent(projectId)}/subject-forms`, { signal }),
listImages: (formId: string, signal?: AbortSignal) => request<SubjectImage[]>(formPath(formId), { signal }),
/** 解析正式 Prompt、参考图顺序与后端生成配置,不创建图片任务。 */
preview: (formId: string) =>
request<SubjectImageGenerationPreview>(`${formPath(formId)}/preview`, { method: 'POST', body: {} }),
generate: (formId: string, input: GenerateFormImageInput) =>
request<SubjectImage | null>(formPath(formId), { method: 'POST', body: input, timeoutMs: 0 }),
generateProject: (projectId: string, input: GenerateProjectImagesInput) =>
@@ -2,11 +2,13 @@
import AppForm from '../../../components/ui/AppForm.vue'
import { NFormItem } from 'naive-ui'
import { sizeRules } from '../../../lib/form-rules'
import { NAlert, NButton, NCheckbox, NCollapse, NCollapseItem, NInput, NInputNumber } from 'naive-ui'
import { NAlert, NButton, NCheckbox, NCollapse, NCollapseItem, NInput, NInputNumber, NTag } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { AppDialog } from '../../../components/ui'
import { errorMessage } from '../../../lib/http'
import { subjectImagesApi } from '../api'
import { primaryImage, validImageSize } from '../model'
import type { GenerateFormImageInput, SubjectFormAsset } from '../types'
import type { GenerateFormImageInput, SubjectFormAsset, SubjectImageGenerationPreview } from '../types'
/** 单形态生成确认:尺寸与自定义 Prompt 只用于本次请求。 */
const props = defineProps<{ form: SubjectFormAsset | null; disabled: boolean }>()
@@ -17,6 +19,9 @@ const width = ref<number | ''>('')
const height = ref<number | ''>('')
const setPrimary = ref(false)
const acknowledged = ref(false)
const preview = ref<SubjectImageGenerationPreview | null>(null)
const previewError = ref('')
const previewPending = ref(false)
const dimensionsValid = computed(() => validImageSize(width.value, height.value))
/** 每次打开重新建立表单,避免把另一形态的 Prompt 或尺寸带入请求。 */
@@ -27,11 +32,31 @@ watch(
width.value = ''
height.value = ''
acknowledged.value = false
preview.value = null
previewError.value = ''
previewPending.value = false
setPrimary.value = props.form ? !primaryImage(props.form.images) : false
},
{ immediate: true }
)
/** 预览由后端按当前正式配置编译,不提交图片生成任务。 */
async function loadPreview() {
if (!props.form || previewPending.value) return
previewPending.value = true
previewError.value = ''
try {
const result = await subjectImagesApi.preview(props.form.id)
if (!result || result.subjectFormId !== props.form.id) throw new Error('预览结果与当前形态不匹配。')
preview.value = result
} catch (cause) {
preview.value = null
previewError.value = errorMessage(cause)
} finally {
previewPending.value = false
}
}
/** 完成前置确认后只发出一次明确的生图请求,不触发整条生产流程。 */
function submit() {
if (!props.form || props.disabled || !dimensionsValid.value || !acknowledged.value) return
@@ -68,6 +93,33 @@ const rules = { ...sizeRules(() => formModel.value) }
人物场景道具均会引用已锁定身份的当前母版保持身份空间骨架或物件结构身份未锁定或没有母版时不会继承该图片更换母版不会自动更新已有形态图
身份母版与此处的形态主参考图是两种不同用途的图片
</p>
<div class="flex flex-wrap items-center gap-3">
<NButton :loading="previewPending" :disabled="!form" @click="loadPreview">预览实际生成输入</NButton>
<span class="text-xs text-muted">只读取后端编译结果不调用图片模型</span>
</div>
<NAlert v-if="previewError" type="error" :show-icon="false">{{ previewError }}</NAlert>
<div v-if="preview" class="panel p-4 text-xs leading-6">
<div class="flex flex-wrap items-center gap-2">
<strong>{{ preview.provider }} / {{ preview.model }}</strong>
<NTag size="small" :bordered="false">{{ preview.references.length }} 张参考图</NTag>
</div>
<ul v-if="preview.references.length" class="mt-3 space-y-1 text-muted">
<li v-for="item in preview.references" :key="`${item.imageNumber}:${item.imageId}`">
{{ item.imageNumber }} · {{ item.role }} · <code>{{ item.imageId }}</code>
</li>
</ul>
<p v-else class="mt-3 text-muted">本次没有图片参考</p>
<NCollapse class="mt-3" :default-expanded-names="['prompt']">
<NCollapseItem name="prompt" title="最终 Provider Prompt">
<p class="mt-3 whitespace-pre-wrap">{{ preview.providerPrompt }}</p>
</NCollapseItem>
<NCollapseItem name="config" title="生成配置">
<pre class="mt-3 whitespace-pre-wrap">{{
JSON.stringify(preview.generationConfig, null, 2)
}}</pre>
</NCollapseItem>
</NCollapse>
</div>
<NFormItem
class="block"
path="prompt"
+20
View File
@@ -56,6 +56,8 @@ export interface GenerateFormImageInput {
export interface GenerateProjectImagesInput {
concurrency: number
force: boolean
/** 可按主体模块缩小批处理范围;省略时处理全部模块。 */
module?: SubjectModule
/** 仅限制本次真正生图的数量,留空表示不限制。 */
limit?: number
}
@@ -83,6 +85,24 @@ export type FormPromptResult = Omit<SubjectFormAsset, 'subject' | 'images'> & {
export interface GenerateFormPromptsInput {
concurrency: number
force: boolean
module?: SubjectModule
}
export type SubjectModule = 'character' | 'scene' | 'prop'
/** 形态生图前的无费用实际输入预览。 */
export interface SubjectImageGenerationPreview {
subjectFormId: string
provider: string
model: string
generationConfig: Record<string, unknown>
references: Array<{
imageNumber: number
role: string
imageId: string
imageUrl: string
}>
providerPrompt: string
}
/** 长请求的回执按项目隔离,在会话内切换页面后仍可查看。 */
+36 -25
View File
@@ -1,12 +1,12 @@
import { computed, ref } from 'vue'
import { useQuery } from '../../composables/useQuery'
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 } from './model'
import type { GenerateFormImageInput } from './types'
import type { GenerateFormImageInput, SubjectModule } from './types'
/** 形态图库统一读取数据库记录并管理单图/批量长请求,不调用生产流程。 */
export function useSubjectImages() {
@@ -17,28 +17,33 @@ export function useSubjectImages() {
const limit = ref<number | ''>('')
const promptConcurrency = ref(3)
const promptForce = ref(false)
const query = useQuery(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)
const batchModule = ref<'all' | SubjectModule>('all')
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
}
})
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
}
},
false
)
const forms = computed(() => query.data.value ?? [])
const operation = computed(() => getOperation(id.value))
const session = computed(() => getImageSession(id.value))
@@ -87,7 +92,11 @@ export function useSubjectImages() {
async function generatePrompts() {
if (blocked.value || !promptValid.value || !forms.value.length) return
const projectId = id.value
const input = { concurrency: promptConcurrency.value, force: promptForce.value }
const input = {
concurrency: promptConcurrency.value,
force: promptForce.value,
...(batchModule.value === 'all' ? {} : { module: batchModule.value })
}
const target = getImageSession(projectId)
target.promptReceipt = null
await runOperation(projectId, '批量生成形态正式提示词', async () => {
@@ -120,7 +129,8 @@ export function useSubjectImages() {
const input = {
concurrency: concurrency.value,
force: force.value,
...(limit.value === '' ? {} : { limit: limit.value })
...(limit.value === '' ? {} : { limit: limit.value }),
...(batchModule.value === 'all' ? {} : { module: batchModule.value })
}
const target = getImageSession(projectId)
target.receipt = null
@@ -145,6 +155,7 @@ export function useSubjectImages() {
limit,
promptConcurrency,
promptForce,
batchModule,
promptValid,
concurrencyValid,
generatePrompt,
@@ -140,4 +140,59 @@ describe('镜头生产数据契约', () => {
await flushPromises()
expect(wrapper.emitted('generate')?.at(-1)).toEqual([{ setPrimary: false }])
})
it('首帧生成前可预览后端最终选图规划和 Prompt', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(
new Response(
JSON.stringify({
data: {
shotId: 'shot-1',
episodeNo: 1,
beatNo: 1,
shotNo: 1,
provider: 'seedream',
model: 'image-model',
generationConfig: {},
references: [],
referencePlan: {
provider: 'seedream',
maxReferenceImages: 4,
totalCandidates: 2,
strategy: 'subject-coverage',
selected: [
{
subjectRef: '@CH0001',
module: 'character',
role: 'identity-anchor',
imageId: 'anchor-1'
}
],
omitted: [
{
subjectRef: '@SC0001',
module: 'scene',
role: 'form-primary',
imageId: 'scene-1'
}
]
},
providerPrompt: '最终首帧 Prompt'
}
})
)
)
vi.stubGlobal('fetch', fetcher)
wrapper = mount(KeyframeDialog, {
attachTo: document.body,
props: { open: true, shotId: 'shot-1', shotTitle: '开场', keyframes: [], disabled: false }
})
await flushPromises()
button('预览实际生成输入').click()
await flushPromises()
expect(fetcher.mock.calls[0]?.[0]).toBe('/api/storyboard-shots/shot-1/keyframe/preview')
expect(document.body.textContent).toContain('已选 1 / 2 张')
expect(document.body.textContent).toContain('省略 1 张')
expect(document.body.textContent).toContain('最终首帧 Prompt')
expect(wrapper.emitted('generate')).toBeUndefined()
})
})
@@ -70,6 +70,41 @@ describe('形态图片选择与操作', () => {
expect(document.body.textContent).toContain('母版')
expect(document.body.textContent).not.toContain('道具形态生图暂不自动引用')
})
it('按需预览正式形态的实际参考图和 Provider Prompt,不触发生图事件', async () => {
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(
new Response(
JSON.stringify({
data: {
subjectFormId: 'form-db-1',
provider: 'seedream',
model: 'image-model',
generationConfig: { width: 2048 },
references: [
{
imageNumber: 1,
role: 'identity-anchor',
imageId: 'anchor-1',
imageUrl: '/storage/anchor.png'
}
],
providerPrompt: '最终形态 Prompt'
}
})
)
)
vi.stubGlobal('fetch', fetcher)
wrapper = mount(GenerateImageDialog, {
attachTo: document.body,
props: { open: true, form: formFixture(), disabled: false }
})
await flushPromises()
button('预览实际生成输入').click()
await flushPromises()
expect(fetcher.mock.calls[0]?.[0]).toBe('/api/subject-forms/form-db-1/images/preview')
expect(document.body.textContent).toContain('seedream / image-model')
expect(document.body.textContent).toContain('最终形态 Prompt')
expect(wrapper.emitted('generate')).toBeUndefined()
})
it('已有主图优先于新候选图和失败记录,无主图才回退到成功候选图', () => {
const form = formFixture()
form.images.unshift(
@@ -112,6 +112,14 @@ describe('形态正式提示词与批量配置', () => {
expect(service.session.value.promptReceipt?.result.failures[0]?.error).toBe('模型拒绝')
expect(service.session.value.receipt).toBeNull()
})
it('批量提示词和图片可按主体模块缩小范围', async () => {
const { service, posts } = await setup()
service.batchModule.value = 'scene'
await service.generatePrompts()
await service.generateProject()
expect(JSON.parse(String(posts()[0]?.[1]?.body))).toMatchObject({ module: 'scene' })
expect(JSON.parse(String(posts()[1]?.[1]?.body))).toMatchObject({ module: 'scene' })
})
it('图片数量上限可选,非法上限和并发阻止提交', async () => {
const { service, posts } = await setup()
for (const limit of [0, -1, 1.5]) {
+26 -2
View File
@@ -4,6 +4,8 @@ import { projectsApi } from '@/features/projects/api'
import { breakdownApi } from '@/features/breakdown/api'
import { storyboardApi } from '@/features/storyboard/api'
import { subjectImagesApi } from '@/features/subject-images/api'
import { subjectIdentityApi } from '@/features/subject-identity/api'
import { productionApi } from '@/features/production/api'
afterEach(() => {
vi.unstubAllGlobals()
@@ -43,6 +45,29 @@ describe('后端 API 契约', () => {
})
expect(fetcher.mock.calls[4]![1]!.body).toBeUndefined()
})
it('新增预览、模块批处理和稳定母版接口使用正式数据库 ID', async () => {
const fetcher = vi.fn<typeof fetch>().mockImplementation(async () => new Response('{"data":{}}'))
vi.stubGlobal('fetch', fetcher)
await subjectImagesApi.preview('form/id')
await subjectImagesApi.generatePrompts('project/id', { concurrency: 2, force: false, module: 'scene' })
await subjectImagesApi.generateProject('project/id', { concurrency: 1, force: false, module: 'prop' })
await subjectIdentityApi.generateProject('project/id', { concurrency: 2, force: false, module: 'scene' })
await subjectIdentityApi.generateStableAnchors('project/id', { module: 'prop', limit: 3, concurrency: 2 })
await productionApi.previewKeyframe('shot/id')
expect(fetcher.mock.calls.map(([url, init]) => [url, init?.method])).toEqual([
['/api/subject-forms/form%2Fid/images/preview', 'POST'],
['/api/projects/project%2Fid/subject-forms/generation-prompts', 'POST'],
['/api/projects/project%2Fid/subject-images/generate', 'POST'],
['/api/projects/project%2Fid/subject-identities/generate', 'POST'],
['/api/projects/project%2Fid/subject-identities/anchors/generate', 'POST'],
['/api/storyboard-shots/shot%2Fid/keyframe/preview', 'POST']
])
expect(JSON.parse(fetcher.mock.calls[4]![1]!.body as string)).toEqual({
module: 'prop',
limit: 3,
concurrency: 2
})
})
it('分镜只调用正式项目接口,并保持 force 和零次修复参数', async () => {
const fetcher = vi.fn<typeof fetch>().mockImplementation(async () => new Response('{"data":{}}'))
vi.stubGlobal('fetch', fetcher)
@@ -110,12 +135,11 @@ describe('后端 API 契约', () => {
)
vi.stubGlobal('fetch', fetcher)
expect(await projectsApi.list()).toEqual([{ id: 'p1' }])
expect(await projectsApi.create({ title: '测试剧本', topic: '故事', style: '悬疑', episodeCount: 3 })).toEqual({
expect(await projectsApi.create({ topic: '故事', style: '悬疑', episodeCount: 3 })).toEqual({
projectId: 'p2',
status: 'generating'
})
expect(JSON.parse(fetcher.mock.calls[1]![1].body as string)).toEqual({
title: '测试剧本',
topic: '故事',
style: '悬疑',
episodeCount: 3