From be06caaa9fd6cc716b65ffb6495209a86ea93ea8 Mon Sep 17 00:00:00 2001
From: GouJ
Date: Tue, 22 Sep 2026 19:56:12 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E5=90=8C=E6=AD=A5=E6=9C=80=E6=96=B0?=
=?UTF-8?q?=E7=94=9F=E5=9B=BE=E4=B8=8E=E8=BA=AB=E4=BB=BD=E8=83=BD=E5=8A=9B?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
docs/api-contract-sync.md | 7 ++
docs/backend-coverage.md | 12 ++
src/features/production/api.ts | 4 +
.../production/components/KeyframeDialog.vue | 72 +++++++++++-
src/features/production/types.ts | 47 ++++++++
.../subject-identity/SubjectIdentityPage.vue | 79 ++++++++++++-
src/features/subject-identity/api.ts | 13 ++-
src/features/subject-identity/types.ts | 23 ++++
.../subject-identity/useSubjectIdentity.ts | 110 ++++++++++++------
.../subject-images/SubjectImagesPage.vue | 16 +++
src/features/subject-images/api.ts | 6 +-
.../components/GenerateImageDialog.vue | 56 ++++++++-
src/features/subject-images/types.ts | 20 ++++
.../subject-images/useSubjectImages.ts | 61 ++++++----
tests/features/production/production.test.ts | 55 +++++++++
tests/features/subject-images/images.test.ts | 35 ++++++
tests/features/subject-images/prompts.test.ts | 8 ++
tests/lib/http.test.ts | 28 ++++-
18 files changed, 583 insertions(+), 69 deletions(-)
diff --git a/docs/api-contract-sync.md b/docs/api-contract-sync.md
index de4a0d4..5406faf 100644
--- a/docs/api-contract-sync.md
+++ b/docs/api-contract-sync.md
@@ -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。
diff --git a/docs/backend-coverage.md b/docs/backend-coverage.md
index 52aebbc..72b57e8 100644
--- a/docs/backend-coverage.md
+++ b/docs/backend-coverage.md
@@ -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` 已由现有资产库页面使用,无需新增重复页面。
+
+预览结果只用于提交前核对,不作为持久化任务或质量通过证明;真正生成仍需费用确认,并以刷新后的正式资产记录为准。
diff --git a/src/features/production/api.ts b/src/features/production/api.ts
index 408b6ee..045869f 100644
--- a/src/features/production/api.ts
+++ b/src/features/production/api.ts
@@ -6,6 +6,7 @@ import type {
GenerateQualityVideosInput,
KeyframeBatchResult,
KeyframeReadiness,
+ KeyframeGenerationPreview,
KeyframeSpec,
ProjectVideoStatus,
PromptReadiness,
@@ -106,6 +107,9 @@ export const productionApi = {
request(`${shotPath(shotId)}/keyframes`, { signal }),
keyframeSpec: (shotId: string, signal?: AbortSignal) =>
request(`${shotPath(shotId)}/keyframe-spec`, { signal }),
+ /** 预览最终参考图规划与 Provider Prompt,不创建首帧任务。 */
+ previewKeyframe: (shotId: string) =>
+ request(`${shotPath(shotId)}/keyframe/preview`, { method: 'POST', body: {} }),
generateKeyframe: (shotId: string, input: GenerateKeyframeInput) =>
request(`${shotPath(shotId)}/keyframe`, {
method: 'POST',
diff --git a/src/features/production/components/KeyframeDialog.vue b/src/features/production/components/KeyframeDialog.vue
index c5915c8..9047663 100644
--- a/src/features/production/components/KeyframeDialog.vue
+++ b/src/features/production/components/KeyframeDialog.vue
@@ -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('')
const height = ref('')
const setPrimary = ref(false)
const confirmed = ref(false)
+const preview = ref(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
+ }
+}
@@ -66,6 +91,49 @@ const rules = sizeRules(() => formModel.value)
当前主首帧使用的主体参考资产已经变化。新图成功后应设为主首帧,旧图会继续保留为历史候选。
+
+ 预览实际生成输入
+ 只读取参考图规划与最终 Prompt,不调用图片模型。
+
+ {{ previewError }}
+
+
+ {{ preview.provider }} / {{ preview.model }}
+
+ 已选 {{ preview.referencePlan.selected.length }} /
+ {{ preview.referencePlan.totalCandidates }} 张
+
+
+ 省略 {{ preview.referencePlan.omitted.length }} 张
+
+
+
+ -
+ {{ item.subjectRef }} · {{ item.role }} ·
{{ item.imageId }}
+
+
+
+
+ {{ preview.providerPrompt }}
+
+
+
+ -
+ {{ item.subjectRef }} · {{ item.role }} ·
{{ item.imageId }}
+
+
+
+
+ {{
+ JSON.stringify(preview.generationConfig, null, 2)
+ }}
+
+
+
+ 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 {
/** 当前目标视频模型的授权素材,不拼接模型私有地址。 */
diff --git a/src/features/subject-identity/SubjectIdentityPage.vue b/src/features/subject-identity/SubjectIdentityPage.vue
index dcbab8f..a1ca4d4 100644
--- a/src/features/subject-identity/SubjectIdentityPage.vue
+++ b/src/features/subject-identity/SubjectIdentityPage.vue
@@ -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
/>
+
+
+
+
场景与道具稳定母版
+
+ 为已有身份提示词但缺少有效母版的场景或道具小批量生图。成功后后端会自动设为
+ Anchor 并锁定 Identity。
+
+
+
+
+
+
+
+
高级:全部主体身份文本
@@ -350,6 +393,16 @@ const rules = { concurrency: integerRule('并发数'), candidateLimit: integerRu
同时处理 Character、Scene 和
Prop;角色选角建议优先使用上方专用入口。已锁定身份始终跳过。
+
-
+
共 {{ session.receipt.result.totalCharacters }} 个角色 · 缺候选
{{ session.receipt.result.missingAnchor }} · 本批目标
{{ session.receipt.result.targetCount }} · 已生成
{{ session.receipt.result.generated }} · 失败
{{ session.receipt.result.failed }}
+
+ 共 {{ 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 }}
+
+ generateProject: (projectId: string, input: { force: boolean; concurrency: number; module?: IdentityModule }) =>
request(`/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(
+ `/projects/${encodeURIComponent(projectId)}/subject-identities/anchors/generate`,
+ { method: 'POST', body: input, timeoutMs: 0 }
+ ),
listImages: (subjectId: string, signal?: AbortSignal) =>
request(`${identityPath(subjectId)}/images`, { signal }),
generateImage: (subjectId: string, input: GenerateIdentityImageInput) =>
diff --git a/src/features/subject-identity/types.ts b/src/features/subject-identity/types.ts
index 54fb93a..1946f0b 100644
--- a/src/features/subject-identity/types.ts
+++ b/src/features/subject-identity/types.ts
@@ -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 }
diff --git a/src/features/subject-identity/useSubjectIdentity.ts b/src/features/subject-identity/useSubjectIdentity.ts
index a450445..ca15bd1 100644
--- a/src/features/subject-identity/useSubjectIdentity.ts
+++ b/src/features/subject-identity/useSubjectIdentity.ts
@@ -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,
diff --git a/src/features/subject-images/SubjectImagesPage.vue b/src/features/subject-images/SubjectImagesPage.vue
index 1c9ad36..eb8e0b3 100644
--- a/src/features/subject-images/SubjectImagesPage.vue
+++ b/src/features/subject-images/SubjectImagesPage.vue
@@ -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"
/>
+
+ 批处理范围
+
+ 同时应用于批量提示词与批量生图。
+
diff --git a/src/features/subject-images/api.ts b/src/features/subject-images/api.ts
index 5a9ced0..f66ef99 100644
--- a/src/features/subject-images/api.ts
+++ b/src/features/subject-images/api.ts
@@ -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(`/projects/${encodeURIComponent(projectId)}/subject-forms`, { signal }),
listImages: (formId: string, signal?: AbortSignal) => request(formPath(formId), { signal }),
+ /** 解析正式 Prompt、参考图顺序与后端生成配置,不创建图片任务。 */
+ preview: (formId: string) =>
+ request(`${formPath(formId)}/preview`, { method: 'POST', body: {} }),
generate: (formId: string, input: GenerateFormImageInput) =>
request(formPath(formId), { method: 'POST', body: input, timeoutMs: 0 }),
generateProject: (projectId: string, input: GenerateProjectImagesInput) =>
diff --git a/src/features/subject-images/components/GenerateImageDialog.vue b/src/features/subject-images/components/GenerateImageDialog.vue
index e39eac9..b7c7902 100644
--- a/src/features/subject-images/components/GenerateImageDialog.vue
+++ b/src/features/subject-images/components/GenerateImageDialog.vue
@@ -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('')
const height = ref('')
const setPrimary = ref(false)
const acknowledged = ref(false)
+const preview = ref(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) }
人物、场景、道具均会引用已锁定身份的当前母版,保持身份、空间骨架或物件结构;身份未锁定或没有母版时不会继承该图片。更换母版不会自动更新已有形态图。
身份母版与此处的“形态主参考图”是两种不同用途的图片。
+
+ 预览实际生成输入
+ 只读取后端编译结果,不调用图片模型。
+
+
{{ previewError }}
+
+
+ {{ preview.provider }} / {{ preview.model }}
+ {{ preview.references.length }} 张参考图
+
+
+ -
+ 图 {{ item.imageNumber }} · {{ item.role }} ·
{{ item.imageId }}
+
+
+
本次没有图片参考。
+
+
+ {{ preview.providerPrompt }}
+
+
+ {{
+ JSON.stringify(preview.generationConfig, null, 2)
+ }}
+
+
+
& {
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
+ references: Array<{
+ imageNumber: number
+ role: string
+ imageId: string
+ imageUrl: string
+ }>
+ providerPrompt: string
}
/** 长请求的回执按项目隔离,在会话内切换页面后仍可查看。 */
diff --git a/src/features/subject-images/useSubjectImages.ts b/src/features/subject-images/useSubjectImages.ts
index 3d9cdd5..e14b97a 100644
--- a/src/features/subject-images/useSubjectImages.ts
+++ b/src/features/subject-images/useSubjectImages.ts
@@ -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('')
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,
diff --git a/tests/features/production/production.test.ts b/tests/features/production/production.test.ts
index 4f7c5f7..1fd8dc3 100644
--- a/tests/features/production/production.test.ts
+++ b/tests/features/production/production.test.ts
@@ -140,4 +140,59 @@ describe('镜头生产数据契约', () => {
await flushPromises()
expect(wrapper.emitted('generate')?.at(-1)).toEqual([{ setPrimary: false }])
})
+
+ it('首帧生成前可预览后端最终选图规划和 Prompt', async () => {
+ const fetcher = vi.fn().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()
+ })
})
diff --git a/tests/features/subject-images/images.test.ts b/tests/features/subject-images/images.test.ts
index 8407ad7..413e988 100644
--- a/tests/features/subject-images/images.test.ts
+++ b/tests/features/subject-images/images.test.ts
@@ -70,6 +70,41 @@ describe('形态图片选择与操作', () => {
expect(document.body.textContent).toContain('母版')
expect(document.body.textContent).not.toContain('道具形态生图暂不自动引用')
})
+ it('按需预览正式形态的实际参考图和 Provider Prompt,不触发生图事件', async () => {
+ const fetcher = vi.fn().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(
diff --git a/tests/features/subject-images/prompts.test.ts b/tests/features/subject-images/prompts.test.ts
index 1c60c9d..510efdc 100644
--- a/tests/features/subject-images/prompts.test.ts
+++ b/tests/features/subject-images/prompts.test.ts
@@ -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]) {
diff --git a/tests/lib/http.test.ts b/tests/lib/http.test.ts
index c13327c..e059c4e 100644
--- a/tests/lib/http.test.ts
+++ b/tests/lib/http.test.ts
@@ -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().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().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