feat: 同步最新生图与身份能力
This commit is contained in:
@@ -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。
|
||||
|
||||
@@ -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` 已由现有资产库页面使用,无需新增重复页面。
|
||||
|
||||
预览结果只用于提交前核对,不作为持久化任务或质量通过证明;真正生成仍需费用确认,并以刷新后的正式资产记录为准。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
同时处理 Character、Scene 和
|
||||
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"
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/** 长请求的回执按项目隔离,在会话内切换页面后仍可查看。 */
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user