feat: 同步质量校验修复并精简工作台操作

This commit is contained in:
GouJ
2026-09-03 19:38:57 +08:00
parent 771b66cafc
commit 13bb6dc83b
47 changed files with 2244 additions and 260 deletions
+37 -25
View File
@@ -17,7 +17,7 @@ import { ArrowLeft, CheckCircle2, CircleAlert, Film, Image, MessageSquareText, R
import { EmptyState, StatusBadge } from '../../components/ui'
import BeatShotDirectory from '../storyboard/components/BeatShotDirectory.vue'
import ConfirmAction from '../workflows/ConfirmAction.vue'
import { issueLabel, productionStatusLabel } from './model'
import { productionStatusLabel } from './model'
import { useProduction } from './useProduction'
import ProductionReceipt from './components/ProductionReceipt.vue'
import ShotProductionAssets from './components/ShotProductionAssets.vue'
@@ -25,6 +25,9 @@ import AdvancedProduction from './components/AdvancedProduction.vue'
import StaleKeyframeNotice from './components/StaleKeyframeNotice.vue'
import { routeLocationKey } from 'vue-router'
import { queryText } from './asset-links'
import QualityDialog from './components/QualityDialog.vue'
import DetailDisclosure from '../../components/ui/DetailDisclosure.vue'
const qualityOpen = ref(false)
/** 镜头生产页将项目批处理与单镜头资产管理放在同一条可核验链路中。 */
const {
@@ -87,7 +90,7 @@ const stages = computed(() => {
key: 'keyframes' as const,
code: '02',
title: '镜头首帧',
description: '合并视觉风格、镜头规格和主体参考图,调用 Seedream 生成。',
description: '合并视觉风格、镜头规格和主体参考图,调用图片模型生成。',
icon: Image,
data: data?.keyframes,
done: data?.keyframes.skipped ?? 0,
@@ -97,7 +100,7 @@ const stages = computed(() => {
key: 'videos' as const,
code: '03',
title: '视频成片',
description: '以主首帧作为第一帧,连同提示词和主体参考图提交 Seedance。',
description: '以主首帧作为第一帧,连同提示词和主体参考图提交视频模型。',
icon: Film,
data: data?.videos,
done: data?.videoStatus.completed ?? 0,
@@ -112,7 +115,6 @@ const stages = computed(() => {
const selectedIssues = computed(() =>
[promptItem.value, keyframeItem.value, videoItem.value].flatMap(item => item?.issues ?? [])
)
const remainingIssues = computed(() => selectedIssues.value.filter(issue => issue.code !== 'stale_keyframe'))
const selectedKeyframeStale = computed(
() =>
!!keyframeItem.value?.primaryKeyframeStale ||
@@ -136,6 +138,11 @@ const staleKeyframes = computed(() => query.data.value?.keyframes.stalePrimaryKe
function refreshProject() {
void query.refresh()
}
/** 从质量回执定位本集正式镜头,关闭弹窗不会触发任何生成请求。 */
function locateQualityShot(shotId: string) {
selectedShot.value = shotId
qualityOpen.value = false
}
/** 新回执自动展开,避免折叠区隐藏部分失败诊断。 */
const toolsOpen = ref(false)
const overview = ref<string[]>(['overview'])
@@ -181,17 +188,20 @@ watch(
></span
>
<div class="toolbar-actions">
<NButton :disabled="blocked || !episodeNo" @click="qualityOpen = true">首帧质量生成</NButton>
<NButton
:disabled="query.loading.value"
@click="query.refresh"
text
size="small"
class="mb-2 ml-auto"
><RefreshCw :size="13" :class="{ 'animate-spin': query.loading.value }" />刷新就绪状态
quaternary
class="icon-button"
aria-label="刷新就绪状态"
title="刷新就绪状态"
><RefreshCw :size="16" :class="{ 'animate-spin': query.loading.value }" />
</NButton>
<WorkspaceTools
v-model:open="toolsOpen"
title="项目生产总览与批量操作"
label="批量生产"
:has-receipt="!!session.receipt || !!session.pipelineReceipt"
>
<div class="flex flex-wrap items-end justify-between gap-4">
@@ -225,18 +235,17 @@ watch(
>覆盖模式重写提示词新增图视频候选
</NCheckbox>
</div>
<p class="mt-3 text-[11px] leading-6 text-muted">
提示词与视频批量操作面向全项目首帧默认补当前剧集存在过期主首帧时优先更新全项目过期项覆盖模式处理全项目过期首帧成功后会自动接替旧主图其余新增候选Seedream
Seedance 均可能产生费用
</p>
<DetailDisclosure title="处理范围与覆盖规则" class="mt-3"
><p class="text-xs leading-6 text-muted">
提示词与视频批量操作面向全项目首帧默认补当前剧集存在过期主首帧时优先更新全项目过期项覆盖模式处理全项目过期首帧成功后会自动接替旧主图其余新增候选图片模型
与视频模型均可能产生费用
</p></DetailDisclosure
>
</section>
<div class="workspace-overview-panel">
<NCollapse v-model:expanded-names="overview"
><NCollapseItem name="overview" title="项目生产总览与批量操作"
><div class="overview-content">
<NAlert type="info" :show-icon="false" class="mt-5 text-xs">
每一步都按后端就绪检查执行覆盖会重写提示词图片和视频通常新增候选但过期首帧即使在覆盖模式也会更新为主图上游资产变化后请先更新过期结果再继续生产
</NAlert>
<NAlert
v-if="videoRunning"
role="status"
@@ -372,8 +381,8 @@ watch(
:description="
stage.key === 'videos'
? staleVideos && !force
? '当前存在过期主视频,非覆盖模式只会为这些镜头创建新的 Seedance 任务;新视频完成后会自动接替过期主视频。'
: '只为当前就绪镜头创建 Seedance 异步任务。创建成功不代表视频已经完成,任务会在后台继续运行。'
? '当前存在过期主视频,非覆盖模式只会为这些镜头创建新的视频模型任务;新视频完成后会自动接替过期主视频。'
: '只为当前就绪镜头创建视频模型异步任务。创建成功不代表视频已经完成,任务会在后台继续运行。'
: stage.key === 'keyframes'
? `处理缺失或过期主首帧。过期首帧成功后自动接替旧主图(覆盖模式也如此),其余已有主图只新增候选。本批上限:${keyframeLimit || '不限制'};尺寸:${keyframeWidth && keyframeHeight ? `${keyframeWidth} × ${keyframeHeight}` : '后端默认'}。`
: '只处理通过视频提示词就绪检查的镜头;覆盖模式会重写已有提示词。'
@@ -431,7 +440,7 @@ watch(
blocked || !batchValid || !query.data.value.videoStatus.failed
"
acknowledgement
description="为当前最近一次状态为失败的镜头重新创建 Seedance 任务。成功镜头和进行中的镜头不会处理。"
description="为当前最近一次状态为失败的镜头重新创建视频模型任务。成功镜头和进行中的镜头不会处理。"
@confirm="run('retry-videos')"
/>
</div>
@@ -576,11 +585,6 @@ watch(
>
主视频已过期:当前主首帧或主体参考图与生成该视频时不一致。旧视频仍保留,但需要重新生成后才能作为当前有效成片。
</NAlert>
<ul v-if="remainingIssues.length" class="alert alert-error mt-4 space-y-1" role="alert">
<li v-for="(issue, index) in remainingIssues" :key="`${issue.code}-${index}`">
{{ issueLabel(issue.code) }}{{ issue.reason }}
</li>
</ul>
</header>
<ShotProductionAssets
:key="shot.shotId"
@@ -604,6 +608,14 @@ watch(
</EmptyState>
<p v-else-if="query.loading.value" class="panel mt-5 p-8 text-sm text-muted" role="status">
正在检查全项目生产就绪状态……
</p></WorkspacePage
>
</p>
<QualityDialog
v-model:open="qualityOpen"
:project-id="id"
:target="episodeNo ? { kind: 'batch', episodeNo, title: `第 ${episodeNo} 集首帧` } : null"
:disabled="blocked"
@changed="refreshProject"
@locate="locateQualityShot"
/>
</WorkspacePage>
</template>
+3 -3
View File
@@ -33,7 +33,7 @@ export const productionApi = {
startPipeline: (projectId: string) =>
request<ProductionPipelineResult>(`${projectPath(projectId)}/production/start`, {
method: 'POST',
body: { imageProvider: 'seedream', videoProvider: 'seedance' },
body: {},
timeoutMs: 0
}),
promptReadiness: (projectId: string, force: boolean, signal?: AbortSignal) =>
@@ -65,7 +65,7 @@ export const productionApi = {
retryVideos: (projectId: string, concurrency: number) =>
request<RetryVideosResult>(`${projectPath(projectId)}/videos/retry-failed`, {
method: 'POST',
body: { provider: 'seedance', concurrency },
body: { concurrency },
timeoutMs: 0
}),
listKeyframes: (shotId: string, signal?: AbortSignal) =>
@@ -89,7 +89,7 @@ export const productionApi = {
generateVideo: (shotId: string) =>
request<ShotVideo | null>(`${shotPath(shotId)}/videos`, {
method: 'POST',
body: { provider: 'seedance' },
body: {},
timeoutMs: 0
}),
refreshVideo: (videoId: string, signal?: AbortSignal) =>
+2 -6
View File
@@ -124,10 +124,7 @@ describe('高级串联生产的安全边界', () => {
await service.start()
expect(posts()).toHaveLength(1)
expect(posts()[0]?.[0]).toBe('/api/projects/capability-test/production/start')
expect(JSON.parse(String(posts()[0]?.[1]?.body))).toEqual({
imageProvider: 'seedream',
videoProvider: 'seedance'
})
expect(JSON.parse(String(posts()[0]?.[1]?.body))).toEqual({})
expect(
fetcher.mock.calls.filter(([url]) => String(url).includes('/keyframes/readiness?force=true'))
).toHaveLength(2)
@@ -250,7 +247,6 @@ describe('首帧批量参数', () => {
service.keyframeHeight.value = 2048
await service.run('keyframes')
expect(JSON.parse(String(posts()[0]?.[1]?.body))).toEqual({
provider: 'seedream',
concurrency: 2,
force: false,
episodeNo: 1,
@@ -269,6 +265,6 @@ describe('首帧批量参数', () => {
service.force.value = true
await flushPromises()
await service.run('keyframes')
expect(JSON.parse(String(posts()[2]?.[1]?.body))).toEqual({ provider: 'seedream', concurrency: 2, force: true })
expect(JSON.parse(String(posts()[2]?.[1]?.body))).toEqual({ concurrency: 2, force: true })
})
})
@@ -36,7 +36,6 @@ watch([() => props.shotId, open], reset, { immediate: true })
function submit() {
if (!canSubmit.value) return
emit('generate', {
provider: 'seedream',
...(width.value === '' ? {} : { width: width.value, height: height.value as number }),
setPrimary: setPrimary.value
})
@@ -49,7 +48,7 @@ function submit() {
<AppDialog
v-model:open="open"
:title="`${replacePrimary ? '重新生成主首帧' : '生成首帧'} · ${shotTitle}`"
description="依据视觉风格、GenerationSpec 和主体主参考图编译首帧提示词,并调用 Seedream。"
description="依据视觉风格、GenerationSpec 和主体主参考图编译首帧提示词,并调用 图片模型。"
>
<NAlert v-if="replacePrimary" type="info" :show-icon="false" class="mt-5 text-xs">
当前主首帧使用的主体参考资产已经变化新图成功后应设为主首帧旧图会继续保留为历史候选
@@ -83,7 +82,7 @@ function submit() {
id="confirm-keyframe-cost"
v-model:checked="confirmed"
class="mt-5 flex items-start gap-3 text-sm leading-6"
>我已确认调用 Seedream 可能产生费用且当前没有相同镜头的生图任务
>我已确认调用图片模型可能产生费用且当前没有相同镜头的生图任务
</NCheckbox>
<div class="dialog-footer mt-6">
<NButton @click="open = false">取消</NButton>
@@ -0,0 +1,76 @@
<script setup lang="ts">
import { onScopeDispose, ref, watch } from 'vue'
import { NAlert, NButton } from 'naive-ui'
import { qualityApi } from '../quality-api'
import type { ImageProviderCapability, ShotProviderCapability } from '../quality.types'
import { errorMessage } from '../../../lib/http'
/** 模型由后端配置;此入口只读公开能力,不提供无效的前端模型切换。 */
const props = defineProps<{ shotId?: string }>()
const providers = ref<ImageProviderCapability[] | null>(null)
const capability = ref<ShotProviderCapability | null>(null)
const error = ref('')
const loading = ref(false)
let revision = 0
onScopeDispose(() => {
revision++
})
watch(
() => props.shotId,
() => {
revision++
providers.value = null
capability.value = null
error.value = ''
loading.value = false
}
)
/** 按需检查,不自动生成图片;空上限表示未声明,不冒充无限制。 */
async function inspect() {
if (loading.value) return
const version = ++revision
const shotId = props.shotId
loading.value = true
error.value = ''
try {
const list = await qualityApi.providers()
if (version !== revision) return
const result = shotId ? await qualityApi.capability(shotId) : null
if (version !== revision) return
if (!Array.isArray(list) || list.some(item => !item.capabilities?.references))
throw new Error('模型能力接口返回不完整。')
providers.value = list
capability.value = result
} catch (cause) {
if (version === revision) error.value = errorMessage(cause)
} finally {
if (version === revision) loading.value = false
}
}
</script>
<template>
<section class="mt-4 text-xs">
<NButton size="small" :loading="loading" :disabled="loading" @click="inspect">查看后端模型能力</NButton>
<NAlert v-if="error" type="error" :show-icon="false" class="mt-3">{{ error }}</NAlert>
<template v-if="providers">
<p v-if="!providers.some(item => item.active)" class="mt-2 text-danger">
没有匹配的启用图片模型请检查后端配置
</p>
<p v-for="item in providers" :key="item.provider" class="mt-2">
{{ item.provider }} · {{ item.active ? '当前启用' : '已注册' }} ·
{{
!item.capabilities.references.supported
? '不支持参考图'
: item.capabilities.references.maxReferenceImages === null
? '参考图上限未声明'
: `最多 ${item.capabilities.references.maxReferenceImages} 张参考图`
}}
</p>
</template>
<NAlert v-if="capability" :type="capability.valid ? 'info' : 'error'" :show-icon="false" class="mt-3">{{
capability.valid
? `当前镜头需要 ${capability.referenceCount} 张参考图符合 ${capability.provider} 的已声明能力`
: capability.message || '当前镜头不满足模型能力限制'
}}</NAlert>
</section>
</template>
@@ -47,7 +47,7 @@ function exportReceipt() {
:show-icon="false"
class="mt-3"
>
视频任务已经创建但尚未代表成片完成后台会继续查询 Seedance以下方任务状态和视频资产为准
视频任务已经创建但尚未代表成片完成后台会继续查询 视频模型以下方任务状态和视频资产为准
</NAlert>
<NAlert v-if="receipt.result.failed" role="alert" type="error" :show-icon="false" class="mt-3">
本次存在部分失败成功提交或生成的结果不会回滚请查看逐镜错误后再重试
@@ -0,0 +1,261 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import {
NAlert,
NButton,
NCheckbox,
NCollapse,
NCollapseItem,
NInput,
NInputNumber,
NRadioButton,
NRadioGroup
} from 'naive-ui'
import { AppDialog } from '../../../components/ui'
import { allowedTextLines, savedVideoValidation, validQualityInput } from '../quality'
import type { QualityAction, QualityInput, QualityTarget } from '../quality.types'
import { useQuality } from '../useQuality'
import QualityResult from './QualityResult.vue'
import ModelCapabilities from './ModelCapabilities.vue'
/** 单素材与当前集质量生成共用一个按需打开的面板,默认小批量且不自动调用模型。 */
const props = defineProps<{
projectId: string
target: QualityTarget | null
disabled: boolean
savedRawJson?: string | null
}>()
const open = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{ changed: []; locate: [shotId: string] }>()
const { key, session, operation, blocked, run } = useQuality(
props,
() => open.value,
() => emit('changed')
)
const action = ref<QualityAction>('validate')
const attempts = ref<number | null>(1)
const concurrency = ref<number | null>(1)
const limit = ref<number | null>(1)
const force = ref(false)
const width = ref<number | null>(null)
const height = ref<number | null>(null)
const texts = ref('')
const acknowledged = ref(false)
const videoRepair = computed(() => props.target?.kind === 'video' && action.value === 'repair')
const input = computed<QualityInput>(() => ({
concurrency: action.value === 'batch' ? (concurrency.value ?? 0) : 1,
limit: action.value === 'batch' ? (limit.value ?? 0) : 1,
maxRepairAttempts: action.value === 'validate' ? 0 : (attempts.value ?? -1),
force: force.value,
episodeNo: props.target?.kind === 'batch' ? props.target.episodeNo : 1,
allowedTexts: allowedTextLines(texts.value),
...(action.value === 'validate' || width.value === null ? {} : { width: width.value }),
...(action.value === 'validate' || height.value === null ? {} : { height: height.value })
}))
const valid = computed(
() => validQualityInput(input.value) && (!videoRepair.value || input.value.maxRepairAttempts > 0)
)
const generating = computed(() => action.value !== 'validate')
const label = computed(() =>
action.value === 'batch'
? '开始质量生成'
: action.value === 'repair'
? videoRepair.value
? '创建视频修复候选'
: '校验并修复'
: '开始视觉校验'
)
const maxImages = computed(() =>
action.value === 'batch'
? input.value.limit * (1 + input.value.maxRepairAttempts)
: action.value === 'repair'
? input.value.maxRepairAttempts
: 0
)
const maxValidations = computed(() =>
action.value === 'batch' ? maxImages.value : action.value === 'repair' ? input.value.maxRepairAttempts + 1 : 1
)
const saved = computed(() => (props.target?.kind === 'video' ? savedVideoValidation(props.savedRawJson) : null))
const repairAvailable = computed(
() =>
!videoRepair.value ||
(session.value.receipt?.kind === 'video'
? session.value.receipt.result.passed === false
: saved.value?.passed === false)
)
/** 目标和费用范围变化均撤销确认;关闭不取消已经发送到后端的长请求。 */
watch(
[key, open],
() => {
action.value = props.target?.kind === 'batch' ? 'batch' : 'validate'
attempts.value = props.target?.kind === 'video' ? 2 : 1
concurrency.value = 1
limit.value = 1
force.value = false
width.value = null
height.value = null
texts.value = ''
acknowledged.value = false
},
{ immediate: true }
)
watch([input, action], () => {
acknowledged.value = false
})
/** 付费入口保留明确确认,不因菜单选择或查看历史自动提交。 */
async function submit() {
if (!acknowledged.value || !valid.value || blocked.value || !repairAvailable.value) return
acknowledged.value = false
await run(action.value, input.value)
}
</script>
<template>
<AppDialog
v-model:open="open"
:title="`质量检查 · ${target?.title ?? ''}`"
description="查看质量结果,或按需发起新的视觉检查。"
wide
>
<NRadioGroup
v-if="target && target.kind !== 'batch'"
v-model:value="action"
:disabled="operation.pending"
class="mt-4"
aria-label="素材质量操作"
>
<NRadioButton value="validate">视觉校验</NRadioButton
><NRadioButton value="repair">{{ target?.kind === 'video' ? '生成修复候选' : '校验并修复' }}</NRadioButton>
</NRadioGroup>
<NAlert v-if="target?.kind === 'video'" type="info" :show-icon="false" class="mt-4"
>视频校验只检查三个抽样帧不含音频口型和完整运动普通候选只校验修复候选复检通过后会自动设为主视频</NAlert
>
<div v-if="generating" class="quality-fields mt-4">
<label
><span class="field-label">{{ videoRepair ? '修复链次数上限' : '每镜最多修复' }}</span
><NInputNumber
v-model:value="attempts"
:disabled="operation.pending"
:min="videoRepair ? 1 : 0"
:step="1"
:input-props="{ 'aria-label': '每镜最多修复次数' }"
/></label>
<label v-if="target?.kind === 'batch'"
><span class="field-label">本批镜头上限</span
><NInputNumber
v-model:value="limit"
:disabled="operation.pending"
:min="1"
:step="1"
:input-props="{ 'aria-label': '质量生成镜头上限' }"
/></label>
</div>
<NCheckbox v-if="target?.kind === 'batch'" v-model:checked="force" :disabled="operation.pending" class="mt-4"
>重做本集已有有效首帧通过后替换主图</NCheckbox
>
<p v-if="target?.kind === 'batch'" class="mt-3 text-xs text-muted">
仅第 {{ target.episodeNo }} 最多
{{ limit ?? '' }} 非重做模式优先处理过期项若全项目无过期项则补缺失首帧
</p>
<p v-if="videoRepair" class="mt-3 text-xs text-muted">
本次仅创建一个修复视频不自动等待或复检完成后请打开新候选的质量检查修复约束沿用上次失败校验不使用额外参数
</p>
<NAlert v-if="!repairAvailable" type="warning" :show-icon="false" class="mt-3"
>请先执行视觉校验只有未通过的视频可以创建修复候选</NAlert
>
<NCollapse v-if="!videoRepair" class="mt-4">
<NCollapseItem name="options" title="更多参数与模型限制">
<div class="quality-fields">
<label v-if="target?.kind === 'batch'"
><span class="field-label">批量并发</span
><NInputNumber
v-model:value="concurrency"
:disabled="operation.pending"
:min="1"
:step="1"
:input-props="{ 'aria-label': '质量生成并发' }"
/></label>
<label v-if="generating"
><span class="field-label">宽度可选</span
><NInputNumber
v-model:value="width"
:disabled="operation.pending"
:min="1"
:input-props="{ 'aria-label': '质量生成宽度' }"
/></label>
<label v-if="generating"
><span class="field-label">高度可选</span
><NInputNumber
v-model:value="height"
:disabled="operation.pending"
:min="1"
:input-props="{ 'aria-label': '质量生成高度' }"
/></label>
</div>
<label class="mt-4 block"
><span class="field-label">额外允许的画面文字一行一项可留空</span
><NInput
v-model:value="texts"
:disabled="operation.pending"
type="textarea"
:autosize="{ minRows: 2, maxRows: 5 }"
:input-props="{ 'aria-label': '额外允许的画面文字' }"
/></label>
<ModelCapabilities :shot-id="target && target.kind !== 'batch' ? target.shotId : undefined" />
</NCollapseItem>
</NCollapse>
<NAlert v-if="!valid" type="error" :show-icon="false" class="mt-4">{{
videoRepair
? '修复链次数上限需为正整数'
: '并发数量和集数需为正整数修复次数需为非负整数宽高同时留空或同时填写正整数'
}}</NAlert>
<NAlert type="warning" :show-icon="false" class="mt-4">
<template v-if="videoRepair"
>本次最多提交 1
个视频生成任务可能产生费用生成完成不会切换主视频需再次付费复检通过后才会晋升</template
>
<template v-else-if="valid"
>本次最多调用 {{ maxImages }} 次生图{{ maxValidations }} 次视觉校验均可能产生费用</template
>
<template v-if="generating && !videoRepair"
>修复质量生成通过后可能替换主首帧失败候选仍会保留已有视频不会自动更新</template
>
<template v-else-if="!generating">{{
target?.kind === 'video' ? '如果当前是修复候选校验通过将替换当前主视频' : '仅校验不修改主资产'
}}</template>
</NAlert>
<NCheckbox v-model:checked="acknowledged" :disabled="operation.pending" class="mt-4"
>我已确认费用处理范围且同项目没有其他生成任务</NCheckbox
>
<div class="dialog-footer mt-4">
<NButton @click="open = false">关闭</NButton>
<NButton
type="primary"
:loading="operation.pending"
:disabled="blocked || !valid || !acknowledged || !repairAvailable"
@click="submit"
>{{ label }}</NButton
>
</div>
<p v-if="operation.pending" role="status" class="mt-3 text-xs text-muted">
{{ operation.label }}进行中关闭面板不会取消后端任务请勿重复提交
</p>
<NAlert v-if="session.error" role="alert" type="error" :show-icon="false" class="mt-4">{{
session.error
}}</NAlert>
<QualityResult v-if="session.receipt" :receipt="session.receipt" @locate="emit('locate', $event)" />
<NCollapse v-else-if="saved" class="mt-4"
><NCollapseItem name="saved" :title="`最近已保存校验 · ${saved.passed ? '通过' : '未通过'}`"
><p>{{ saved.summary }}</p>
<p v-for="(issue, index) in saved.issues" :key="index" class="mt-2 text-danger">
{{ issue.description }}
</p>
<p class="mt-3 text-xs text-muted">此为历史抽样结论不保证当前上游素材仍然一致</p></NCollapseItem
></NCollapse
>
<p v-if="session.receipt && target?.kind !== 'video'" class="mt-3 text-xs text-muted">
回执保留在当前会话可导出后端暂未提供首帧校验历史读取接口
</p>
</AppDialog>
</template>
@@ -0,0 +1,142 @@
<script setup lang="ts">
import { computed } from 'vue'
import { NAlert, NButton, NCollapse, NCollapseItem } from 'naive-ui'
import { downloadText } from '../../../lib/format'
import type { QualityReceipt } from '../quality.types'
/** 摘要与失败直接显示,完整逐轮信息按需展开,不用完成请求冒充质量通过。 */
const props = defineProps<{ receipt: QualityReceipt }>()
const emit = defineEmits<{ locate: [shotId: string] }>()
const passed = computed(() =>
props.receipt.kind === 'batch'
? props.receipt.result.targetCount > 0 && props.receipt.result.passed === props.receipt.result.targetCount
: props.receipt.kind === 'video-repair'
? false
: props.receipt.result.passed
)
const issues = computed(() => {
const value = props.receipt
return value.kind === 'batch' || value.kind === 'video-repair'
? []
: value.kind === 'repair'
? (value.result.attempts.at(-1)?.issues ?? [])
: value.result.issues
})
/** 导出只使用后端实际回执,便于关闭页面前留存。 */
function exportResult() {
downloadText(
`quality-${props.receipt.kind}.json`,
JSON.stringify(props.receipt.result, null, 2),
'application/json'
)
}
</script>
<template>
<section class="quality-result" aria-label="质量检查结果">
<div class="flex flex-wrap items-center justify-between gap-3">
<strong :class="receipt.kind === 'video-repair' ? '' : passed ? 'text-success' : 'text-danger'">{{
receipt.kind === 'video-repair'
? '修复任务已提交'
: passed
? '视觉校验通过'
: receipt.kind === 'batch' && !receipt.result.targetCount
? '未执行镜头'
: '仍需处理'
}}</strong>
<NButton text size="small" @click="exportResult">导出结果</NButton>
</div>
<template v-if="receipt.kind === 'batch'">
<p class="mt-3 text-xs text-muted">
目标 {{ receipt.result.targetCount }} · 通过 {{ receipt.result.passed }} · 修复未通过
{{ receipt.result.repairFailed }} · 调用失败 {{ receipt.result.failed }} · 待后续
{{ Math.max(0, receipt.result.selected - receipt.result.targetCount) }}
</p>
<p class="mt-2 text-xs text-muted">通过的候选已设为主首帧未通过的不会替换原主图</p>
<div v-for="item in receipt.result.results" :key="item.shotId" class="quality-result-row">
<NButton text size="small" @click="emit('locate', item.shotId)">定位镜头</NButton>
<code class="text-xs text-muted">{{ item.shotId }}</code>
<span :class="item.success ? 'text-success' : 'text-danger'">{{
item.success ? '已通过' : item.error || '未通过'
}}</span>
</div>
</template>
<template v-else-if="receipt.kind === 'repair'">
<p class="mt-3 text-sm">
{{
receipt.result.primaryChanged
? '修复通过,新首帧已替换主图。已有视频需重新核对。'
: receipt.result.passed
? '原首帧通过检查,主图未切换。'
: '达到修复上限仍未通过,原主图保持不变。'
}}
</p>
<p class="mt-2 text-xs text-muted">
已重生成 {{ receipt.result.repairAttempts }} 视觉校验 {{ receipt.result.attempts.length }}
</p>
</template>
<template v-else-if="receipt.kind === 'video-repair'">
<p class="mt-3 text-sm">
{{ receipt.result.repairAttempt }} 次修复候选已创建尚未完成质量复检当前主视频未切换
</p>
<p class="mt-2 text-xs text-muted">
候选 ID{{
receipt.result.candidate.id
}}关闭此面板在下方视频记录查看任务进度完成后对新候选执行视觉校验
</p>
</template>
<template v-else>
<p class="mt-3 text-sm">{{ receipt.result.summary || '后端未提供摘要。' }}</p>
<p v-if="receipt.kind === 'video'" class="mt-2 text-xs text-muted">
{{
receipt.promoted ? '修复候选已通过后端已将其设为主视频' : '本次校验未晋升主视频'
}}仅为抽样帧视觉结果不含音频口型或完整运动检测
</p>
<p v-else class="mt-2 text-xs text-muted">只校验不重生成或切换主首帧</p>
</template>
<NAlert
v-for="(issue, index) in issues"
:key="index"
:type="issue.severity === 'error' ? 'error' : 'warning'"
:show-icon="false"
class="mt-3"
>
{{ issue.frameLabel ? `${issue.frameLabel} · ` : '' }}{{ issue.description }}
</NAlert>
<NCollapse class="mt-4">
<NCollapseItem name="details" title="主体检查与执行详情">
<template v-if="receipt.kind === 'keyframe' || receipt.kind === 'video'">
<p class="text-xs">
主体数量{{ receipt.result.subjectCountConsistent ? '一致' : '不一致'
}}<template v-if="receipt.kind === 'video'">
· 时间一致性{{ receipt.result.temporalConsistency ? '通过' : '未通过' }}</template
>
</p>
<p class="mt-2 text-xs">
未授权文字{{
receipt.result.unauthorizedText.detected
? receipt.result.unauthorizedText.texts.join('、') || '已发现'
: '未发现'
}}
</p>
<div
v-for="subject in receipt.result.subjects"
:key="subject.subjectRef"
class="quality-result-row"
>
<code>{{ subject.subjectRef }}</code>
<span
>身份 {{ subject.identityConsistent ? '✓' : '✕' }} · 形态
{{ subject.formConsistent ? '✓' : '✕'
}}<template v-if="subject.stateConsistent !== undefined">
· 状态 {{ subject.stateConsistent ? '' : '' }}</template
></span
>
<p v-for="issue in subject.issues" :key="issue">{{ issue }}</p>
</div>
</template>
<pre class="quality-json">{{ JSON.stringify(receipt.result, null, 2) }}</pre>
</NCollapseItem>
</NCollapse>
</section>
</template>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { NAlert, NButton, NCollapse, NCollapseItem, NTag } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { ExternalLink, FileSearch, ImagePlus, RefreshCw } from '@lucide/vue'
import { ExternalLink, ImagePlus, RefreshCw } from '@lucide/vue'
import { AssetImage, StatusBadge } from '../../../components/ui'
import { usePolling } from '../../../composables/usePolling'
import { mediaAssetUrl } from '../../../lib/assets'
@@ -22,6 +22,12 @@ import type {
VideoReadinessItem
} from '../types'
import KeyframeDialog from './KeyframeDialog.vue'
import QualityDialog from './QualityDialog.vue'
import ActionMenu from '../../../components/ui/ActionMenu.vue'
import { AppDialog } from '../../../components/ui'
import ModelCapabilities from './ModelCapabilities.vue'
import type { QualityTarget } from '../quality.types'
import { savedVideoValidation, videoRepairInfo } from '../quality'
/** 单镜头生产面板只负责正式资产,不修改上游导演设计与即时状态。 */
const props = defineProps<{
@@ -51,6 +57,24 @@ const activeVideo = computed(() => videos.value.find(isActiveVideo))
const operation = computed(() => getOperation(props.projectId))
const assetBlocked = computed(() => props.disabled || operation.value.pending || !!query.error.value)
const keyframeOpen = ref(false)
const qualityOpen = ref(false)
const qualityTarget = ref<QualityTarget | null>(null)
const qualityRawJson = computed(() => {
const target = qualityTarget.value
return target?.kind === 'video' ? (videos.value.find(item => item.id === target.assetId)?.rawJson ?? null) : null
})
const specsOpen = ref(false)
/** 从实际候选打开质量面板,保留单镜头主操作,不默认展开全部校验参数。 */
function openQuality(kind: 'keyframe' | 'video', assetId: string) {
qualityTarget.value = {
kind,
shotId: props.shot.shotId,
assetId,
title: `${props.shot.title} · ${kind === 'keyframe' ? '首帧' : '视频'}`
}
qualityOpen.value = true
}
const confirmingKeyframe = ref('')
const confirmingVideo = ref('')
const inspecting = ref(false)
@@ -61,7 +85,11 @@ const canGeneratePrompt = computed(
() => !assetBlocked.value && !!props.promptReadiness && props.promptReadiness.issues.length === 0
)
const canGenerateKeyframe = computed(
() => !assetBlocked.value && !!props.keyframeReadiness && props.keyframeReadiness.issues.length === 0
() =>
!assetBlocked.value &&
!keyframes.value.some(item => ['pending', 'generating'].includes(item.status)) &&
!!props.keyframeReadiness &&
props.keyframeReadiness.issues.length === 0
)
const canGenerateVideo = computed(
() =>
@@ -71,6 +99,9 @@ const keyframeStale = computed(() => !!props.keyframeReadiness?.primaryKeyframeS
const videoStale = computed(() => !!props.videoReadiness?.primaryVideoStale)
watch(key, () => {
qualityOpen.value = false
qualityTarget.value = null
specsOpen.value = false
confirmingKeyframe.value = ''
confirmingVideo.value = ''
keyframeSpec.value = null
@@ -101,7 +132,7 @@ async function generatePrompt() {
await refreshAll()
}
/** 调用 Seedream 生成单镜头首帧;成功后重新读取候选和主图。 */
/** 调用图片模型生成单镜头首帧;成功后重新读取候选和主图。 */
async function generateKeyframe(input: GenerateKeyframeInput) {
if (!canGenerateKeyframe.value) return
const shotId = props.shot.shotId
@@ -126,7 +157,7 @@ async function setPrimaryKeyframe() {
await refreshAll()
}
/** 创建单镜头 Seedance 视频任务;任务完成由后台轮询确认。 */
/** 创建单镜头视频模型视频任务;任务完成由后台轮询确认。 */
async function generateVideo() {
if (!canGenerateVideo.value) return
const shotId = props.shot.shotId
@@ -153,6 +184,8 @@ async function refreshActiveVideo() {
async function setPrimaryVideo() {
const videoId = confirmingVideo.value
if (assetBlocked.value || !videoId) return
const video = videos.value.find(item => item.id === videoId)
if (!video || (videoRepairInfo(video.rawJson) && savedVideoValidation(video.rawJson)?.passed !== true)) return
const shotId = props.shot.shotId
confirmingVideo.value = ''
await runOperation(props.projectId, `设置镜头 ${props.shot.shotNo} 主视频`, async () => {
@@ -165,6 +198,7 @@ async function setPrimaryVideo() {
/** 显式检查两份确定性生成规格;某一阶段未就绪时保留另一份可读结果。 */
async function inspectSpecs() {
specsOpen.value = true
inspecting.value = true
specError.value = ''
const shotId = props.shot.shotId
@@ -172,6 +206,10 @@ async function inspectSpecs() {
productionApi.keyframeSpec(shotId),
productionApi.videoGenerationSpec(shotId)
])
if (props.shot.shotId !== shotId) {
inspecting.value = false
return
}
keyframeSpec.value = keyframeResult.status === 'fulfilled' ? keyframeResult.value : null
videoSpec.value = videoResult.status === 'fulfilled' ? videoResult.value : null
const errors = [keyframeResult, videoResult]
@@ -187,27 +225,32 @@ async function inspectSpecs() {
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h4 class="text-sm font-semibold">镜头生产资产</h4>
<p class="mt-1 text-[11px] text-muted">记录每次候选与失败主资产决定下一阶段实际输入</p>
</div>
<div class="flex gap-3">
<NButton :disabled="inspecting" @click="inspectSpecs" text size="small"
><FileSearch :size="13" />{{ inspecting ? '检查中…' : '检查生成规格' }}</NButton
>
<NButton :disabled="query.loading.value" @click="refreshAll" text size="small"
><RefreshCw :size="13" :class="{ 'animate-spin': query.loading.value }" />刷新资产
<ActionMenu
label="镜头资产更多操作"
:items="[{ key: 'specs', label: '检查生成规格与模型限制', disabled: inspecting }]"
@select="inspectSpecs"
/>
<NButton
:disabled="query.loading.value"
@click="refreshAll"
quaternary
class="icon-button"
aria-label="刷新资产"
title="刷新资产"
><RefreshCw :size="16" :class="{ 'animate-spin': query.loading.value }" />
</NButton>
</div>
</div>
<NAlert v-if="query.error.value" role="alert" type="error" :show-icon="false" class="mt-4"
>{{ query.error.value }} 当前保留上次成功读取的资产操作已暂停
</NAlert>
<NAlert v-if="specError" role="alert" type="error" :show-icon="false" class="mt-4">{{ specError }}</NAlert>
<div class="production-stage mt-5">
<div class="production-stage-heading">
<div>
<p class="eyebrow">01 · VIDEO PROMPT</p>
<h5 class="mt-2 text-sm font-medium">视频提示词</h5>
<h5 class="text-sm font-medium">01 · 视频提示词</h5>
</div>
<StatusBadge
v-if="promptReadiness"
@@ -238,8 +281,7 @@ async function inspectSpecs() {
<div class="production-stage">
<div class="production-stage-heading">
<div>
<p class="eyebrow">02 · KEYFRAME</p>
<h5 class="mt-2 text-sm font-medium">主首帧与候选图</h5>
<h5 class="text-sm font-medium">02 · 首帧</h5>
</div>
<StatusBadge
v-if="keyframeReadiness"
@@ -274,6 +316,14 @@ async function inspectSpecs() {
</div>
<p v-if="item.error" class="mt-2 line-clamp-3 text-xs text-danger">{{ item.error }}</p>
<p class="mt-2 text-[10px] text-muted">{{ formatDate(item.createdAt) }}</p>
<NButton
v-if="item.status === 'completed' && item.imageUrl"
text
size="small"
class="mt-2 mr-3"
@click="openQuality('keyframe', item.id)"
>质量检查</NButton
>
<NButton
v-if="item.status === 'completed' && item.imageUrl && !item.isPrimary"
:disabled="assetBlocked"
@@ -309,8 +359,7 @@ async function inspectSpecs() {
<div class="production-stage">
<div class="production-stage-heading">
<div>
<p class="eyebrow">03 · VIDEO</p>
<h5 class="mt-2 text-sm font-medium">Seedance 视频任务与成片</h5>
<h5 class="text-sm font-medium">03 · 视频</h5>
</div>
<StatusBadge
v-if="videoReadiness"
@@ -339,6 +388,9 @@ async function inspectSpecs() {
<div class="flex flex-wrap items-center gap-2">
<StatusBadge :status="item.status" :label="productionStatusLabel(item.status)" />
<NTag v-if="item.isPrimary" size="small" :bordered="false">主视频</NTag>
<NTag v-if="videoRepairInfo(item.rawJson) && !item.isPrimary" size="small" :bordered="false"
>修复候选 · 待复检</NTag
>
<NTag v-if="item.isPrimary && videoStale" size="small" :bordered="false">已过期</NTag>
<span class="text-[10px] text-muted"
>{{ item.durationSeconds || shot.durationSeconds || '—' }}s</span
@@ -351,6 +403,15 @@ async function inspectSpecs() {
{{ item.provider }} · {{ item.model }} · {{ formatDate(item.createdAt) }}
</p>
<div class="mt-3 flex flex-wrap gap-3">
<NButton
v-if="item.status === 'completed' && item.videoUrl"
text
size="small"
@click="openQuality('video', item.id)"
>质量检查<template v-if="savedVideoValidation(item.rawJson)">
· {{ savedVideoValidation(item.rawJson)?.passed ? '曾通过' : '未通过' }}</template
></NButton
>
<a
v-if="safeVideoUrl(item.videoUrl)"
:href="safeVideoUrl(item.videoUrl) || undefined"
@@ -360,7 +421,13 @@ async function inspectSpecs() {
>打开视频<ExternalLink :size="12"
/></a>
<NButton
v-if="item.status === 'completed' && item.videoUrl && !item.isPrimary"
v-if="
item.status === 'completed' &&
item.videoUrl &&
!item.isPrimary &&
(!videoRepairInfo(item.rawJson) ||
savedVideoValidation(item.rawJson)?.passed === true)
"
:disabled="assetBlocked"
@click="confirmingVideo = item.id"
text
@@ -387,10 +454,10 @@ async function inspectSpecs() {
acknowledgement
:description="
videoStale
? '使用当前视频提示词、主首帧和主体参考图重新创建 Seedance 任务;完成后会自动接替当前过期主视频。'
? '使用当前视频提示词、主首帧和主体参考图重新创建视频模型任务;完成后会自动接替当前过期主视频。'
: currentVideo
? '使用当前视频提示词、主首帧和主体参考图新增一个 Seedance 视频候选,不自动替换主视频。'
: '使用当前视频提示词、主首帧和主体参考图创建 Seedance 任务;任务会在后台继续运行。'
? '使用当前视频提示词、主首帧和主体参考图新增一个视频模型视频候选,不自动替换主视频。'
: '使用当前视频提示词、主首帧和主体参考图创建视频模型任务;任务会在后台继续运行。'
"
primary
@confirm="generateVideo"
@@ -401,37 +468,50 @@ async function inspectSpecs() {
</div>
</div>
<NCollapse v-if="keyframeSpec || videoSpec" class="mt-5 pt-4 text-xs"
><NCollapseItem name="details"
><template #header>本镜确定性生成规格</template>
<div class="mt-4 grid gap-5 lg:grid-cols-2">
<div v-if="keyframeSpec">
<h5 class="font-medium">首帧 Prompt · {{ keyframeSpec.references.length }} 张参考图</h5>
<p class="mt-3 whitespace-pre-wrap leading-6 text-muted">{{ keyframeSpec.prompt }}</p>
<ul v-if="keyframeSpec.references.length" class="mt-3 space-y-1 text-[11px] text-muted">
<li
v-for="reference in keyframeSpec.references"
:key="`${reference.role}:${reference.imageId}`"
>
{{ reference.subjectRef }} ·
{{ reference.role === 'identity-anchor' ? '演员身份母版' : '形态主图' }} ·
<code>{{ reference.imageId }}</code>
</li>
</ul>
</div>
<div v-if="videoSpec">
<h5 class="font-medium">视频 Prompt · {{ videoSpec.durationSeconds }}s</h5>
<p class="mt-3 whitespace-pre-wrap leading-6 text-muted">{{ videoSpec.videoPrompt }}</p>
<p v-if="videoSpec.negativePrompt" class="mt-3 whitespace-pre-wrap leading-6 text-muted">
Negative prompt: {{ videoSpec.negativePrompt }}
</p>
<p class="mt-3 break-all font-mono text-[10px] text-muted">
Keyframe ID · {{ videoSpec.keyframe.id }}
</p>
</div>
</div></NCollapseItem
></NCollapse
>
<AppDialog v-model:open="specsOpen" title="生成规格与模型限制" description="只读检查,不调用生成模型。" wide>
<NAlert v-if="specError" role="alert" type="error" :show-icon="false" class="mt-4">{{ specError }}</NAlert>
<p v-if="inspecting" role="status" class="mt-4 text-xs text-muted">正在读取生成规格…</p>
<ModelCapabilities :shot-id="shot.shotId" />
<NCollapse v-if="keyframeSpec || videoSpec" class="mt-5 pt-4 text-xs" :default-expanded-names="['details']"
><NCollapseItem name="details"
><template #header>本镜确定性生成规格</template>
<div class="mt-4 grid gap-5 lg:grid-cols-2">
<div v-if="keyframeSpec">
<h5 class="font-medium">首帧 Prompt · {{ keyframeSpec.references.length }} 张参考图</h5>
<p class="mt-3 whitespace-pre-wrap leading-6 text-muted">{{ keyframeSpec.prompt }}</p>
<ul v-if="keyframeSpec.references.length" class="mt-3 space-y-1 text-[11px] text-muted">
<li
v-for="reference in keyframeSpec.references"
:key="`${reference.role}:${reference.imageId}`"
>
{{ reference.subjectRef }} ·
{{ reference.role === 'identity-anchor' ? '演员身份母版' : '形态主图' }} ·
<code>{{ reference.imageId }}</code>
</li>
</ul>
</div>
<div v-if="videoSpec">
<h5 class="font-medium">视频 Prompt · {{ videoSpec.durationSeconds }}s</h5>
<p class="mt-3 whitespace-pre-wrap leading-6 text-muted">{{ videoSpec.videoPrompt }}</p>
<p v-if="videoSpec.negativePrompt" class="mt-3 whitespace-pre-wrap leading-6 text-muted">
Negative prompt: {{ videoSpec.negativePrompt }}
</p>
<p class="mt-3 break-all font-mono text-[10px] text-muted">
Keyframe ID · {{ videoSpec.keyframe.id }}
</p>
</div>
</div></NCollapseItem
></NCollapse
>
</AppDialog>
<QualityDialog
v-model:open="qualityOpen"
:project-id="projectId"
:target="qualityTarget"
:disabled="assetBlocked"
:saved-raw-json="qualityRawJson"
@changed="refreshAll"
/>
<KeyframeDialog
v-model:open="keyframeOpen"
:shot-id="shot.shotId"
+3 -4
View File
@@ -99,11 +99,10 @@ describe('镜头生产数据契约', () => {
})
vi.stubGlobal('fetch', fetcher)
await productionApi.videoReadiness('project/1', true)
await productionApi.generateVideos('project/1', { provider: 'seedance', concurrency: 3, force: true })
await productionApi.generateVideos('project/1', { concurrency: 3, force: true })
expect(fetcher.mock.calls[0]?.[0]).toBe('/api/projects/project%2F1/videos/readiness?force=true')
expect(fetcher.mock.calls[1]?.[0]).toBe('/api/projects/project%2F1/videos/generate')
expect(JSON.parse(String(fetcher.mock.calls[1]?.[1]?.body))).toEqual({
provider: 'seedance',
concurrency: 3,
force: true
})
@@ -120,7 +119,7 @@ describe('镜头生产数据契约', () => {
await flushPromises()
button('确认生成首帧').click()
await flushPromises()
expect(wrapper.emitted('generate')).toEqual([[{ provider: 'seedream', setPrimary: true }]])
expect(wrapper.emitted('generate')).toEqual([[{ setPrimary: true }]])
await wrapper.setProps({ open: false })
await flushPromises()
@@ -129,6 +128,6 @@ describe('镜头生产数据契约', () => {
document.querySelector<HTMLInputElement>('#confirm-keyframe-cost')!.click()
await flushPromises()
button('确认生成首帧').click()
expect(wrapper.emitted('generate')?.at(-1)).toEqual([{ provider: 'seedream', setPrimary: false }])
expect(wrapper.emitted('generate')?.at(-1)).toEqual([{ setPrimary: false }])
})
})
+52
View File
@@ -0,0 +1,52 @@
import { request } from '../../lib/http'
import type {
ImageProviderCapability,
KeyframeRepair,
KeyframeValidation,
QualityBatchResult,
QualityInput,
ShotProviderCapability,
VideoValidation,
VideoRepair
} from './quality.types'
/** 校验会调用视觉模型,POST 不设短超时、不自动重试;能力检查只读。 */
export const qualityApi = {
repairVideo: (shotId: string, videoId: string, maxRepairAttempts: number) =>
request<VideoRepair>(
`/storyboard-shots/${encodeURIComponent(shotId)}/videos/${encodeURIComponent(videoId)}/repair`,
{ method: 'POST', body: { maxRepairAttempts }, timeoutMs: 0 }
),
providers: (signal?: AbortSignal) =>
request<ImageProviderCapability[]>('/image-providers/capabilities', { signal }),
capability: (shotId: string, signal?: AbortSignal) =>
request<ShotProviderCapability>(
`/storyboard-shots/${encodeURIComponent(shotId)}/keyframe-provider-capability`,
{ signal }
),
validateKeyframe: (shotId: string, assetId: string, allowedTexts: string[]) =>
request<KeyframeValidation>(
`/storyboard-shots/${encodeURIComponent(shotId)}/keyframes/${encodeURIComponent(assetId)}/validate`,
{ method: 'POST', body: { allowedTexts }, timeoutMs: 0 }
),
repair: (
shotId: string,
assetId: string,
input: Pick<QualityInput, 'maxRepairAttempts' | 'allowedTexts' | 'width' | 'height'>
) =>
request<KeyframeRepair>(
`/storyboard-shots/${encodeURIComponent(shotId)}/keyframes/${encodeURIComponent(assetId)}/repair`,
{ method: 'POST', body: input, timeoutMs: 0 }
),
validateVideo: (shotId: string, assetId: string, allowedTexts: string[]) =>
request<VideoValidation>(
`/storyboard-shots/${encodeURIComponent(shotId)}/videos/${encodeURIComponent(assetId)}/validate`,
{ method: 'POST', body: { allowedTexts }, timeoutMs: 0 }
),
generate: (projectId: string, input: QualityInput) =>
request<QualityBatchResult>(`/projects/${encodeURIComponent(projectId)}/keyframes/generate-quality`, {
method: 'POST',
body: input,
timeoutMs: 0
})
}
+515
View File
@@ -0,0 +1,515 @@
import { defineComponent, reactive, ref } from 'vue'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { NCheckbox, NInputNumber, NRadioGroup } from 'naive-ui'
import { testProjectContext } from '../../testing/project-context'
import { productionApi } from './api'
import { qualityApi } from './quality-api'
import {
allowedTextLines,
qualityKey,
qualitySession,
qualityTargets,
savedVideoValidation,
videoRepairInfo,
validQualityInput
} from './quality'
import type { KeyframeReadiness } from './types'
import type { QualityInput, QualityTarget } from './quality.types'
import { useQuality } from './useQuality'
import { keyframeFixture, videoFixture } from './testing/fixtures'
import QualityDialog from './components/QualityDialog.vue'
import QualityResult from './components/QualityResult.vue'
import ModelCapabilities from './components/ModelCapabilities.vue'
let wrapper: VueWrapper | undefined
let sequence = 0
afterEach(() => {
wrapper?.unmount()
wrapper = undefined
document.body.innerHTML = ''
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
/** 模拟统一配置后的真实契约,测试绝不调用真实图片或视觉模型。 */
function server() {
const projectId = `quality-test-${sequence++}`
const project = testProjectContext(projectId).project.value!
const validation = {
passed: true,
summary: '身份与造型一致',
subjects: [],
subjectCountConsistent: true,
unauthorizedText: { detected: false, texts: [] },
issues: []
}
const keyframe = keyframeFixture()
const video = videoFixture()
const readiness = {
total: 1,
ready: 1,
skipped: 0,
blocked: 0,
stalePrimaryKeyframe: 0,
missingVisualStyle: 0,
missingIdentity: 0,
missingIdentityAnchor: 0,
identityUnlocked: 0,
invalidGenerationSpec: 0,
missingReference: 0,
items: [{ shotId: 'shot-1', shotNo: 1, beatNo: 1, episodeNo: 1, status: 'ready', issues: [] }]
} as KeyframeReadiness
const capability = {
provider: 'qwen-image',
referenceCount: 3,
maxReferenceImages: 3,
valid: true,
message: '参考图超限'
}
const keyframeResult = {
...validation,
shotId: 'shot-1',
keyframeId: 'keyframe-1',
validationTaskId: 'validation-1',
isPrimary: true,
imageUrl: keyframe.imageUrl!
}
const attempt = {
attempt: 0,
keyframeId: 'keyframe-1',
validationTaskId: 'validation-1',
passed: true,
issues: [],
validationDurationMs: 10
}
const repair = {
shotId: 'shot-1',
initialKeyframeId: 'keyframe-1',
finalKeyframeId: 'keyframe-1',
passed: true,
repaired: false,
primaryChanged: false,
repairAttempts: 0,
maxRepairAttempts: 1,
attempts: [attempt]
}
const batch = {
total: 1,
ready: 1,
selected: 1,
targetCount: 1,
passed: 1,
repairFailed: 0,
failed: 0,
skipped: 0,
blocked: 0,
stalePrimaryKeyframe: 0,
missingReference: 0,
results: [{ shotId: 'shot-1', success: true, status: 'passed' as const }]
}
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, options) => {
const path = String(url)
let data: unknown
if (options?.method === 'POST') {
if (path.endsWith('/generate-quality')) data = batch
else if (path.includes('/videos/') && path.endsWith('/repair'))
data = {
shotId: 'shot-1',
sourceVideoId: 'video-1',
repairAttempt: 1,
maxRepairAttempts: 2,
repairInstructions: ['修复人物漂移'],
allowedTexts: [],
candidate: videoFixture({
id: 'video-repair-1',
status: 'queued',
isPrimary: false,
videoUrl: null,
rawJson: JSON.stringify({ repair: { sourceVideoId: 'video-1', attempt: 1 } })
})
}
else if (path.endsWith('/repair')) data = repair
else if (path.includes('/videos/'))
data = {
...validation,
shotId: 'shot-1',
videoId: 'video-1',
validatedAt: '2026-09-03',
isPrimary: validation.passed && !!videoRepairInfo(video.rawJson),
videoUrl: video.videoUrl,
sampleFrames: [{ label: '中间', timeSeconds: 2 }],
allowedTexts: []
}
else data = keyframeResult
} else if (path.endsWith(`/projects/${projectId}`)) data = project
else if (path.includes('/readiness')) data = readiness
else if (path.endsWith('/keyframes')) data = [keyframe]
else if (path.endsWith('/videos')) data = [video]
else if (path.endsWith('/keyframe-provider-capability')) data = capability
else if (path.endsWith('/image-providers/capabilities'))
data = [
{
provider: 'qwen-image',
active: true,
capabilities: { references: { supported: true, maxReferenceImages: 3 } }
}
]
else throw new Error('意外接口:' + path)
return new Response(JSON.stringify({ data }))
})
vi.stubGlobal('fetch', fetcher)
return {
projectId,
project,
validation,
keyframe,
video,
readiness,
capability,
keyframeResult,
repair,
batch,
fetcher,
posts: () => fetcher.mock.calls.filter(([, options]) => options?.method === 'POST')
}
}
/** 统一默认小批次,零修复和自定义允许文字均可单独覆写。 */
function input(patch: Partial<QualityInput> = {}): QualityInput {
return { concurrency: 1, limit: 1, episodeNo: 1, maxRepairAttempts: 1, force: false, allowedTexts: [], ...patch }
}
/** 通过实际挂载按钮检验费用确认,避免绕过禁用状态。 */
function validationButton() {
return [...document.querySelectorAll<HTMLButtonElement>('button')].find(
item => item.textContent === '开始视觉校验'
)!
}
function setup(target: QualityTarget = { kind: 'keyframe', shotId: 'shot-1', assetId: 'keyframe-1', title: '镜头一' }) {
const data = server()
const props = reactive({ projectId: data.projectId, target: target as QualityTarget | null, disabled: false })
const visible = ref(true)
const changed = vi.fn<() => void>()
let service!: ReturnType<typeof useQuality>
wrapper = mount(
defineComponent({
setup() {
service = useQuality(props, () => visible.value, changed)
return () => null
}
})
)
return { ...data, props, visible, changed, service }
}
describe('视觉质量契约与付费边界', () => {
it('打开面板不发模型请求,确认后仅校验指定首帧', async () => {
const data = setup()
expect(data.fetcher).not.toHaveBeenCalled()
await data.service.run('validate', input({ allowedTexts: ['记忆当铺'] }))
expect(data.posts()).toHaveLength(1)
expect(data.posts()[0]?.[0]).toBe('/api/storyboard-shots/shot-1/keyframes/keyframe-1/validate')
expect(JSON.parse(String(data.posts()[0]?.[1]?.body))).toEqual({ allowedTexts: ['记忆当铺'] })
expect(data.service.session.value.receipt).toMatchObject({ kind: 'keyframe', result: { passed: true } })
})
it.each(['generating', 'failed', 'need_review'] as const)('后端项目状态为 %s 时不发付费请求', async status => {
const data = setup()
data.project.status = status
await data.service.run('validate', input())
expect(data.posts()).toHaveLength(0)
expect(data.service.session.value.error).toContain('剧本未完成')
})
it('目标不属于项目时不查询目标素材,不生成', async () => {
const data = setup({ kind: 'keyframe', shotId: 'foreign-shot', assetId: 'foreign-image', title: '外部' })
await data.service.run('validate', input())
expect(data.posts()).toHaveLength(0)
expect(data.fetcher.mock.calls.some(([url]) => String(url).includes('foreign-shot'))).toBe(false)
})
it('畸形视觉结果不能当作校验通过,也不会自动重试', async () => {
const data = setup()
vi.spyOn(qualityApi, 'validateKeyframe').mockResolvedValueOnce({
...data.keyframeResult,
passed: undefined
} as never)
await data.service.run('validate', input())
expect(data.service.session.value.receipt).toBeNull()
expect(data.service.session.value.error).toContain('不完整')
expect(qualityApi.validateKeyframe).toHaveBeenCalledTimes(1)
})
it('0 次修复保留语义;不会额外调用普通生图或切换主图接口', async () => {
const data = setup()
await data.service.run('repair', input({ maxRepairAttempts: 0 }))
expect(data.posts()).toHaveLength(1)
expect(data.posts()[0]?.[0]).toContain('/repair')
expect(JSON.parse(String(data.posts()[0]?.[1]?.body))).toEqual({ maxRepairAttempts: 0, allowedTexts: [] })
expect(data.fetcher.mock.calls.some(([, options]) => options?.method === 'PUT')).toBe(false)
expect(data.service.session.value.receipt).toMatchObject({ kind: 'repair', result: { primaryChanged: false } })
})
it('模型参考图能力不足时阻止修复与质量批次', async () => {
const data = setup()
data.capability.valid = false
await data.service.run('repair', input())
expect(data.posts()).toHaveLength(0)
expect(data.service.session.value.error).toContain('参考图超限')
data.props.target = { kind: 'batch', title: '第一集', episodeNo: 1 }
await data.service.run('batch', input())
expect(data.posts()).toHaveLength(0)
})
it('视频需完成并有有效时长,校验不自动切换主视频', async () => {
const data = setup({ kind: 'video', shotId: 'shot-1', assetId: 'video-1', title: '视频' })
data.video.durationSeconds = null
await data.service.run('validate', input())
expect(data.posts()).toHaveLength(0)
data.video.durationSeconds = 5
await data.service.run('validate', input())
expect(data.posts()).toHaveLength(1)
expect(data.posts()[0]?.[0]).toBe('/api/storyboard-shots/shot-1/videos/video-1/validate')
expect(data.service.session.value.receipt).toMatchObject({
kind: 'video',
result: { sampleFrames: [{ timeSeconds: 2 }] }
})
expect(data.fetcher.mock.calls.some(([, options]) => options?.method === 'PUT')).toBe(false)
})
it('批量范围、上限、尺寸原样传递,不发送 Provider;部分失败仍显示待处理', async () => {
const data = setup({ kind: 'batch', episodeNo: 1, title: '第一集' })
Object.assign(data.batch, {
passed: 0,
repairFailed: 1,
results: [{ shotId: 'shot-1', success: false, status: 'repair_failed', error: '人物不一致' }]
})
await data.service.run('batch', input({ width: 1536, height: 1024 }))
expect(data.posts()).toHaveLength(1)
expect(JSON.parse(String(data.posts()[0]?.[1]?.body))).toEqual(input({ width: 1536, height: 1024 }))
const receipt = data.service.session.value.receipt!
wrapper!.unmount()
wrapper = mount(QualityResult, { props: { receipt } })
expect(wrapper.text()).toContain('仍需处理')
expect(wrapper.text()).toContain('人物不一致')
await wrapper
.findAll('button')
.find(button => button.text() === '定位镜头')!
.trigger('click')
expect(wrapper.emitted('locate')).toEqual([['shot-1']])
})
it('过期优先级与后端一致,当前集无过期项时不误补其他镜头', async () => {
const data = setup({ kind: 'batch', episodeNo: 1, title: '第一集' })
data.readiness.stalePrimaryKeyframe = 1
expect(qualityTargets(data.readiness, input())).toHaveLength(0)
await data.service.run('batch', input())
expect(data.posts()).toHaveLength(0)
expect(data.service.session.value.error).toContain('切换剧集')
expect(qualityTargets(data.readiness, input({ force: true }))).toHaveLength(1)
})
it('错误输入与未完成素材不提交,已禁用操作也不能绕过', async () => {
const data = setup()
for (const values of [{ limit: 0 }, { concurrency: 1.5 }, { maxRepairAttempts: -1 }, { width: 100 }])
await data.service.run('repair', input(values))
expect(data.fetcher).not.toHaveBeenCalled()
data.keyframe.status = 'generating'
await data.service.run('repair', input())
expect(data.posts()).toHaveLength(0)
data.props.disabled = true
const count = data.fetcher.mock.calls.length
await data.service.run('validate', input())
expect(data.fetcher).toHaveBeenCalledTimes(count)
})
it('预检中关闭或换项目不发 POST;已提交的迟到回执只写回原目标', async () => {
const data = setup()
let finishCheck!: (value: KeyframeReadiness) => void
vi.spyOn(productionApi, 'keyframeReadiness').mockImplementationOnce(
() =>
new Promise(resolve => {
finishCheck = resolve
})
)
const pendingCheck = data.service.run('validate', input())
await flushPromises()
data.visible.value = false
finishCheck(data.readiness)
await pendingCheck
expect(data.posts()).toHaveLength(0)
data.visible.value = true
let finish!: (value: typeof data.keyframeResult) => void
vi.spyOn(qualityApi, 'validateKeyframe').mockImplementationOnce(
() =>
new Promise(resolve => {
finish = resolve
})
)
const originalKey = data.service.key.value
const pending = data.service.run('validate', input())
await flushPromises()
await data.service.run('validate', input())
expect(qualityApi.validateKeyframe).toHaveBeenCalledTimes(1)
data.props.projectId = 'different-project'
finish(data.keyframeResult)
await pending
expect(qualitySession(originalKey).receipt).toMatchObject({ kind: 'keyframe' })
expect(data.service.session.value.receipt).toBeNull()
})
it('API 对正式 ID 编码,视觉与质量长请求只发一次', async () => {
const data = server()
await qualityApi.validateKeyframe('shot/a', 'asset/b', [])
await qualityApi.repair('shot/a', 'asset/b', { maxRepairAttempts: 1 })
await qualityApi.validateVideo('shot/a', 'video/b', [])
expect(data.posts().map(([url]) => url)).toEqual([
'/api/storyboard-shots/shot%2Fa/keyframes/asset%2Fb/validate',
'/api/storyboard-shots/shot%2Fa/keyframes/asset%2Fb/repair',
'/api/storyboard-shots/shot%2Fa/videos/video%2Fb/validate'
])
})
})
describe('质量面板渐进展示', () => {
it('默认仅校验,未确认时不可提交,打开或更改参数不产生费用', async () => {
const data = server()
wrapper = mount(QualityDialog, {
attachTo: document.body,
props: {
projectId: data.projectId,
target: { kind: 'keyframe', shotId: 'shot-1', assetId: 'keyframe-1', title: '测试镜头' },
open: true,
disabled: false
}
})
await flushPromises()
expect(validationButton().disabled).toBe(true)
expect(document.body.textContent).toContain('更多参数与模型限制')
expect(document.querySelector('[aria-label="额外允许的画面文字"]')).toBeNull()
expect(data.fetcher).not.toHaveBeenCalled()
wrapper.findAllComponents(NCheckbox).at(-1)!.vm.$emit('update:checked', true)
await flushPromises()
expect(validationButton().disabled).toBe(false)
validationButton().click()
await flushPromises()
expect(data.posts()).toHaveLength(1)
expect(document.body.textContent).toContain('视觉校验通过')
})
it('批量默认当前集一镜,调整次数撤销费用确认', async () => {
const data = server()
wrapper = mount(QualityDialog, {
attachTo: document.body,
props: {
projectId: data.projectId,
target: { kind: 'batch', episodeNo: 2, title: '第二集' },
open: true,
disabled: false
}
})
await flushPromises()
expect(document.body.textContent).toContain('仅第 2 集,最多 1 镜')
expect(document.body.textContent).toContain('最多调用 2 次生图、2 次视觉校验')
wrapper.findAllComponents(NCheckbox).at(-1)!.vm.$emit('update:checked', true)
await flushPromises()
wrapper.findAllComponents(NInputNumber)[0]!.vm.$emit('update:value', 2)
await flushPromises()
const submit = [...document.querySelectorAll<HTMLButtonElement>('button')].find(
item => item.textContent === '开始质量生成'
)!
expect(submit.disabled).toBe(true)
expect(data.posts()).toHaveLength(0)
})
it('模型能力只读按需查询,明确当前启用模型及参考图上限', async () => {
const data = server()
wrapper = mount(ModelCapabilities, { props: { shotId: 'shot-1' } })
expect(data.fetcher).not.toHaveBeenCalled()
await wrapper.get('button').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('qwen-image · 当前启用')
expect(wrapper.text()).toContain('最多 3 张参考图')
expect(data.posts()).toHaveLength(0)
})
it('视频历史读取不触发视觉模型,畸形历史不会伪造通过', () => {
const data = server()
expect(savedVideoValidation(JSON.stringify({ videoValidation: data.validation }))?.passed).toBe(true)
expect(savedVideoValidation('{broken')).toBeNull()
expect(savedVideoValidation('{"videoValidation":{"passed":true}}')).toBeNull()
expect(allowedTextLines(' 招牌\n\n招牌\n编号 ')).toEqual(['招牌', '编号'])
expect(validQualityInput(input({ maxRepairAttempts: 0 }))).toBe(true)
expect(qualityKey('a', { kind: 'batch', episodeNo: 1, title: '' })).not.toBe(
qualityKey('b', { kind: 'batch', episodeNo: 1, title: '' })
)
expect(data.fetcher).not.toHaveBeenCalled()
})
})
describe('视频修复候选与复检晋升', () => {
it('只允许校验未通过的视频创建一个候选,不自动再次校验', async () => {
const data = setup({ kind: 'video', shotId: 'shot-1', assetId: 'video-1', title: '视频' })
await data.service.run('repair', input({ maxRepairAttempts: 2 }))
expect(data.posts()).toHaveLength(0)
data.video.rawJson = JSON.stringify({ videoValidation: { ...data.validation, passed: false } })
await data.service.run('repair', input({ maxRepairAttempts: 2 }))
expect(data.posts()).toHaveLength(1)
expect(data.posts()[0]?.[0]).toBe('/api/storyboard-shots/shot-1/videos/video-1/repair')
expect(JSON.parse(String(data.posts()[0]?.[1]?.body))).toEqual({ maxRepairAttempts: 2 })
expect(data.service.session.value.receipt).toMatchObject({
kind: 'video-repair',
result: { candidate: { status: 'queued', isPrimary: false } }
})
})
it('链路次数上限不能作为零次修复,已达上限不创建任务', async () => {
const data = setup({ kind: 'video', shotId: 'shot-1', assetId: 'video-1', title: '视频' })
data.video.rawJson = JSON.stringify({
videoValidation: { ...data.validation, passed: false },
repair: { attempt: 2 }
})
await data.service.run('repair', input({ maxRepairAttempts: 0 }))
await data.service.run('repair', input({ maxRepairAttempts: 2 }))
expect(data.posts()).toHaveLength(0)
expect(data.service.session.value.error).toContain('次数上限')
})
it('复检通过的修复候选准确显示自动晋升,前端不再另发主视频 PUT', async () => {
const data = setup({ kind: 'video', shotId: 'shot-1', assetId: 'video-1', title: '修复候选' })
data.video.rawJson = JSON.stringify({ repair: { attempt: 1, sourceVideoId: 'source' } })
data.video.isPrimary = false
await data.service.run('validate', input())
const receipt = data.service.session.value.receipt!
expect(receipt).toMatchObject({ kind: 'video', promoted: true, result: { isPrimary: true } })
expect(data.posts()).toHaveLength(1)
expect(data.fetcher.mock.calls.some(([, options]) => options?.method === 'PUT')).toBe(false)
wrapper!.unmount()
wrapper = mount(QualityResult, { props: { receipt } })
expect(wrapper.text()).toContain('后端已将其设为主视频')
})
it('视频修复面板明确一次一任务及复检后替换,不展示生图次数', async () => {
const data = server()
wrapper = mount(QualityDialog, {
attachTo: document.body,
props: {
projectId: data.projectId,
target: { kind: 'video', shotId: 'shot-1', assetId: 'video-1', title: '视频' },
open: true,
disabled: false,
savedRawJson: JSON.stringify({ videoValidation: { ...data.validation, passed: false } })
}
})
await flushPromises()
expect(document.body.textContent).toContain('修复候选复检通过后会自动设为主视频')
wrapper.getComponent(NRadioGroup).vm.$emit('update:value', 'repair')
await flushPromises()
expect(document.body.textContent).toContain('本次最多提交 1 个视频生成任务')
expect(document.body.textContent).not.toContain('次生图')
expect(document.body.textContent).toContain('修复链次数上限')
expect(data.posts()).toHaveLength(0)
})
})
+106
View File
@@ -0,0 +1,106 @@
import { reactive } from 'vue'
import { validOptionalSize } from './model'
import type { KeyframeReadiness } from './types'
import type { QualityInput, QualityReceipt, QualityTarget, VisualValidation } from './quality.types'
/** 未返回的操作不显示上次成功结论;网络失败不会自动重发。 */
const sessions = reactive<Record<string, { receipt: QualityReceipt | null; error: string }>>({})
/** 正式 ID 与目标类型共同隔离会话结果,批量回执按剧集隔离。 */
export function qualityKey(projectId: string, target: QualityTarget | null) {
return JSON.stringify([
projectId,
target?.kind,
target && ('assetId' in target ? [target.shotId, target.assetId] : target.episodeNo)
])
}
/** 同一目标关闭后重新打开仍能查看本会话的回执。 */
export function qualitySession(key: string) {
return (sessions[key] ??= { receipt: null, error: '' })
}
/** 一行一个允许文字,仅清理空白与重复,不自行添加授权内容。 */
export function allowedTextLines(value: string) {
return [
...new Set(
value
.split('\n')
.map(item => item.trim())
.filter(Boolean)
)
]
}
/** 付费前独立校验整数、尺寸与剧集范围,0 次修复代表只验不修。 */
export function validQualityInput(input: QualityInput) {
return (
[input.concurrency, input.limit, input.episodeNo].every(value => Number.isSafeInteger(value) && value > 0) &&
Number.isSafeInteger(input.maxRepairAttempts) &&
input.maxRepairAttempts >= 0 &&
validOptionalSize(input.width ?? '', input.height ?? '')
)
}
/** 与后端保持同一优先级:全项目有过期项时先筛过期,再按当前集筛选。 */
export function qualityTargets(readiness: KeyframeReadiness, input: QualityInput) {
return readiness.items
.filter(
item =>
item.status === 'ready' &&
(input.force ||
(readiness.stalePrimaryKeyframe > 0
? item.primaryKeyframeStale === true
: !item.primaryKeyframeId)) &&
item.episodeNo === input.episodeNo
)
.slice(0, input.limit)
}
/** 最小响应校验,避免畸形响应或仅 HTTP 200 被显示成视觉通过。 */
export function isVisualValidation(value: unknown): value is VisualValidation {
if (!value || typeof value !== 'object') return false
const item = value as VisualValidation
return (
typeof item.passed === 'boolean' &&
typeof item.summary === 'string' &&
typeof item.subjectCountConsistent === 'boolean' &&
Array.isArray(item.subjects) &&
item.subjects.every(
subject =>
typeof subject.subjectRef === 'string' &&
typeof subject.identityConsistent === 'boolean' &&
typeof subject.formConsistent === 'boolean' &&
Array.isArray(subject.issues) &&
subject.issues.every(issue => typeof issue === 'string')
) &&
Array.isArray(item.issues) &&
item.issues.every(
issue => typeof issue.description === 'string' && ['warning', 'error'].includes(issue.severity)
) &&
typeof item.unauthorizedText?.detected === 'boolean' &&
Array.isArray(item.unauthorizedText.texts)
)
}
/** 读取视频已保存的抽样校验,不为查看历史再次调用视觉模型。 */
export function savedVideoValidation(rawJson?: string | null): VisualValidation | null {
try {
const value: unknown = JSON.parse(rawJson || '{}').videoValidation
return isVisualValidation(value) ? value : null
} catch {
return null
}
}
/** 识别后端修复候选标记,避免未复检候选通过普通主视频按钮绕过校验。 */
export function videoRepairInfo(rawJson?: string | null): { attempt: number } | null {
try {
const repair: unknown = JSON.parse(rawJson || '{}').repair
if (!repair || typeof repair !== 'object' || Array.isArray(repair)) return null
const attempt = (repair as { attempt?: unknown }).attempt
return { attempt: typeof attempt === 'number' && Number.isSafeInteger(attempt) && attempt >= 0 ? attempt : 0 }
} catch {
return null
}
}
+150
View File
@@ -0,0 +1,150 @@
/** 校验入口绑定正式镜头与资产 ID;批量默认只处理当前集。 */
export type QualityTarget =
| { kind: 'keyframe' | 'video'; shotId: string; assetId: string; title: string }
| { kind: 'batch'; episodeNo: number; title: string }
/** 质量操作与普通生成独立,不复用生产面板的覆盖、并发或范围。 */
export type QualityAction = 'validate' | 'repair' | 'batch'
/** 三类校验问题使用同一可读列表,保留后端原始问题类型及抽样帧标记。 */
export interface QualityIssue {
type: string
severity: 'warning' | 'error'
description: string
frameLabel?: string
}
/** 首帧与视频共同的视觉校验结果;通过不等于完整人工验收。 */
export interface VisualValidation {
passed: boolean
summary: string
subjects: {
subjectRef: string
identityConsistent: boolean
formConsistent: boolean
stateConsistent?: boolean
issues: string[]
}[]
subjectCountConsistent: boolean
temporalConsistency?: boolean
unauthorizedText: { detected: boolean; texts: string[] }
issues: QualityIssue[]
}
/** 首帧校验回执;当前后端没有独立读取校验历史的 GET。 */
export interface KeyframeValidation extends VisualValidation {
shotId: string
keyframeId: string
validationTaskId: string
imageUrl: string
isPrimary: boolean
}
/** 视频校验基于三点抽样帧;修复候选通过后后端会自动晋升为主视频。 */
export interface VideoValidation extends VisualValidation {
shotId: string
videoId: string
videoUrl: string
isPrimary: boolean
validatedAt: string
allowedTexts: string[]
sampleFrames: { label: string; timeSeconds: number }[]
}
/** 视频修复只创建一个异步候选任务,不自动等待视频或再次执行视觉校验。 */
export interface VideoRepair {
shotId: string
sourceVideoId: string
repairAttempt: number
maxRepairAttempts: number
repairInstructions: string[]
allowedTexts: string[]
candidate: import('./types').ShotVideo
}
/** 每轮修复的耗时与视觉结论,不用 HTTP 成功代替校验通过。 */
export interface RepairAttempt {
attempt: number
keyframeId: string
validationTaskId: string
passed: boolean
issues: QualityIssue[]
generationDurationMs?: number
validationDurationMs: number
}
/** 单张修复只有新候选校验通过才切换主图,原图直接通过不会晋升。 */
export interface KeyframeRepair {
shotId: string
initialKeyframeId: string
finalKeyframeId: string
passed: boolean
repaired: boolean
primaryChanged: boolean
repairAttempts: number
maxRepairAttempts: number
repairInstructions?: string[]
attempts: RepairAttempt[]
}
/** 项目质量生成逐镜回执,区分调用失败与达到修复上限仍不合格。 */
export interface QualityBatchResult {
total: number
ready: number
selected: number
targetCount: number
passed: number
repairFailed: number
failed: number
skipped: number
blocked: number
stalePrimaryKeyframe: number
missingReference: number
results: {
shotId: string
success: boolean
status: 'passed' | 'repair_failed' | 'failed'
initialKeyframeId?: string
finalKeyframeId?: string
repairAttempts?: number
repaired?: boolean
attempts?: RepairAttempt[]
error?: string
}[]
}
/** 首帧质量生成配置;limit 强制由前端填写,避免无意执行全项目。 */
export interface QualityInput {
concurrency: number
maxRepairAttempts: number
force: boolean
episodeNo: number
limit: number
allowedTexts?: string[]
width?: number
height?: number
}
/** 当前后端启用模型的公开能力;不包含连接地址或密钥。 */
export interface ImageProviderCapability {
provider: string
active: boolean
capabilities: { references: { supported: boolean; maxReferenceImages: number | null } }
}
/** 只读能力检查不切换模型;无法满足参考图数量时阻止生图。 */
export interface ShotProviderCapability {
provider: string
referenceCount: number
maxReferenceImages: number | null
valid: boolean
message?: string
}
/** 会话回执按项目和目标隔离,保留关闭弹窗后的返回结果。 */
export type QualityReceipt =
| { kind: 'keyframe'; result: KeyframeValidation }
| { kind: 'video'; result: VideoValidation; promoted?: boolean }
| { kind: 'video-repair'; result: VideoRepair }
| { kind: 'repair'; result: KeyframeRepair }
| { kind: 'batch'; result: QualityBatchResult }
+3 -4
View File
@@ -118,6 +118,8 @@ export interface ShotVideo {
durationSeconds: number | null
isPrimary: boolean
error: string | null
/** 后端保存的抽样视觉校验与生成快照,按需读取,不直接展示整段原始数据。 */
rawJson?: string | null
createdAt: string
updatedAt: string
}
@@ -158,7 +160,7 @@ export interface VideoReference {
providerImageUrl?: string
}
/** 后端实际提交 Seedance 前编译的完整视频生成规格。 */
/** 后端实际提交 视频模型 前编译的完整视频生成规格。 */
export interface VideoGenerationSpec {
projectId: string
shotId: string
@@ -174,7 +176,6 @@ export interface VideoGenerationSpec {
/** 项目首帧批量生成参数。 */
export interface GenerateKeyframesInput {
provider: 'seedream'
concurrency: number
force: boolean
/** 缺失首帧默认只生成当前剧集,避免开发阶段误跑整个项目。 */
@@ -187,7 +188,6 @@ export interface GenerateKeyframesInput {
/** 单镜头首帧生成参数。 */
export interface GenerateKeyframeInput {
provider: 'seedream'
width?: number
height?: number
setPrimary: boolean
@@ -210,7 +210,6 @@ export interface KeyframeBatchResult {
/** 项目视频批量生成参数。 */
export interface GenerateVideosInput {
provider: 'seedance'
concurrency: number
force: boolean
}
+1 -2
View File
@@ -119,7 +119,6 @@ export function useProduction() {
kind: 'keyframes',
title: labels[command],
result: await productionApi.generateKeyframes(projectId, {
provider: 'seedream',
...input,
// 有 stale 时由后端优先处理全部 stale;否则只补当前剧集,避免误跑整个项目。
...(force.value || staleKeyframes > 0 ? {} : { episodeNo: episodeNo.value }),
@@ -133,7 +132,7 @@ export function useProduction() {
target.receipt = {
kind: 'videos',
title: labels[command],
result: await productionApi.generateVideos(projectId, { provider: 'seedance', ...input })
result: await productionApi.generateVideos(projectId, input)
}
} else {
target.receipt = {
+248
View File
@@ -0,0 +1,248 @@
import { computed, onScopeDispose } from 'vue'
import { projectsApi } from '../projects/api'
import { getOperation, runOperation } from '../workflows/operations'
import { productionApi } from './api'
import { qualityApi } from './quality-api'
import {
isVisualValidation,
qualityKey,
qualitySession,
qualityTargets,
validQualityInput,
savedVideoValidation,
videoRepairInfo
} from './quality'
import type { QualityAction, QualityInput, QualityReceipt, QualityTarget, RepairAttempt } from './quality.types'
/** 校验有限修复回执的每轮结论,避免结构缺失时误报成功。 */
function validAttempts(attempts: RepairAttempt[]) {
return (
Array.isArray(attempts) &&
attempts.length > 0 &&
attempts.every(
item =>
typeof item.keyframeId === 'string' && typeof item.passed === 'boolean' && Array.isArray(item.issues)
)
)
}
/** 统一质量操作:只读预检 → 显式付费请求 → 按原目标保存回执,不自动重试。 */
export function useQuality(
props: { projectId: string; target: QualityTarget | null; disabled: boolean },
visible: () => boolean,
changed: () => void
) {
const key = computed(() => qualityKey(props.projectId, props.target))
const session = computed(() => qualitySession(key.value))
const operation = computed(() => getOperation(props.projectId))
const blocked = computed(() => props.disabled || operation.value.pending || !props.target || !props.projectId)
let disposed = false
onScopeDispose(() => {
disposed = true
})
/** 确认之后重新读取后端状态;打开弹窗和查看结果不会调用此函数。 */
async function run(action: QualityAction, input: QualityInput) {
if (blocked.value || !visible() || !props.target || !validQualityInput(input)) return
const target = { ...props.target }
if (
(target.kind === 'batch') !== (action === 'batch') ||
(target.kind === 'video' && action === 'repair' && input.maxRepairAttempts <= 0)
)
return
if (target.kind === 'batch' && input.episodeNo !== target.episodeNo) return
const projectId = props.projectId
// 固定本次确认的参数,预检期间的表单变化不能扩大已确认的生成范围。
input = { ...input, allowedTexts: input.allowedTexts?.slice() }
const originalKey = key.value
const receiptTarget = qualitySession(originalKey)
receiptTarget.receipt = null
receiptTarget.error = ''
const stillCurrent = () => !disposed && key.value === originalKey && visible()
const ok = await runOperation(
projectId,
action === 'batch'
? '首帧质量生成'
: action === 'repair'
? target.kind === 'video'
? '创建视频修复候选'
: '首帧自动修复'
: '视觉质量校验',
async () => {
const [project, readiness] = await Promise.all([
projectsApi.detail(projectId),
productionApi.keyframeReadiness(projectId, target.kind === 'batch' ? input.force : true)
])
if (project.id !== projectId || project.status !== 'completed')
throw new Error('剧本未完成或项目已变化,未提交质量任务。')
if (project.tasks.some(task => ['pending', 'queued', 'running', 'generating'].includes(task.status)))
throw new Error('项目还有后台任务,请结束后再执行质量检查。')
if (
!Array.isArray(readiness.items) ||
readiness.items.length !== readiness.total ||
new Set(readiness.items.map(item => item.shotId)).size !== readiness.total
)
throw new Error('镜头就绪列表不完整,请刷新。')
let receipt: QualityReceipt
if (target.kind === 'batch') {
const targets = qualityTargets(readiness, input)
if (!targets.length)
throw new Error(
readiness.stalePrimaryKeyframe > 0
? '当前集没有可更新的过期首帧;后端优先处理全项目过期项,请切换剧集。'
: '当前集没有符合条件的镜头;请补齐设计与参考图,或明确选择重做有效首帧。'
)
// 逐镜只读检查已选的小批量;任何模型能力不足都不启动本批付费流水线。
const capabilities = await Promise.all(targets.map(item => qualityApi.capability(item.shotId)))
const invalid = capabilities.find(item => item.valid !== true)
if (invalid) throw new Error(invalid.message || '当前图片模型不支持所需参考图。')
const assets = await Promise.all(targets.map(item => productionApi.listKeyframes(item.shotId)))
if (
assets.some((items, index) =>
items.some(
item =>
item.shotId !== targets[index]?.shotId ||
['pending', 'generating'].includes(item.status)
)
)
)
throw new Error('本批镜头有进行中的生图任务或资产归属异常,请先刷新核对。')
if (!stillCurrent()) throw new Error('已离开原操作目标,未提交质量任务。')
const result = await qualityApi.generate(projectId, input)
if (
!result ||
!Array.isArray(result.results) ||
![result.targetCount, result.passed, result.repairFailed, result.failed, result.selected].every(
value => Number.isSafeInteger(value) && value >= 0
) ||
result.targetCount > input.limit ||
result.results.length !== result.targetCount ||
result.passed + result.repairFailed + result.failed !== result.targetCount ||
result.results.some(
item =>
!readiness.items.some(
shot => shot.shotId === item.shotId && shot.episodeNo === input.episodeNo
) || typeof item.success !== 'boolean'
)
)
throw new Error('质量流水线回执不完整,请先核对资产,不要重复提交。')
receipt = { kind: 'batch', result }
} else {
if (!readiness.items.some(item => item.shotId === target.shotId))
throw new Error('指定镜头不属于当前项目,未提交质量任务。')
if (target.kind === 'video') {
const videos = await productionApi.listVideos(target.shotId)
const video = videos.find(item => item.id === target.assetId && item.shotId === target.shotId)
if (
!video ||
video.status !== 'completed' ||
!video.videoUrl ||
!video.durationSeconds ||
video.durationSeconds <= 0
)
throw new Error('视频未完成、缺少视频地址或有效时长,不能执行抽样校验。')
if (action === 'repair') {
if (savedVideoValidation(video.rawJson)?.passed !== false)
throw new Error('请先完成视频质量校验;只有未通过的视频才能创建修复候选。')
if (videos.some(item => ['pending', 'queued', 'running'].includes(item.status)))
throw new Error('此镜头仍有活动视频任务,请结束后再修复。')
if ((videoRepairInfo(video.rawJson)?.attempt ?? 0) + 1 > input.maxRepairAttempts)
throw new Error('当前视频修复链已达到次数上限,请核对后再调整。')
if (!stillCurrent()) throw new Error('已离开原操作目标,未提交质量任务。')
const result = await qualityApi.repairVideo(
target.shotId,
target.assetId,
input.maxRepairAttempts
)
if (
!result ||
result.shotId !== target.shotId ||
result.sourceVideoId !== target.assetId ||
!result.candidate ||
result.candidate.shotId !== target.shotId ||
result.candidate.id === target.assetId ||
result.candidate.isPrimary ||
!Number.isSafeInteger(result.repairAttempt) ||
result.repairAttempt <= 0 ||
!Array.isArray(result.repairInstructions)
)
throw new Error('视频修复任务回执不完整,请先刷新视频记录,勿重复提交。')
receipt = { kind: 'video-repair', result }
} else {
if (!stillCurrent()) throw new Error('已离开原操作目标,未提交质量任务。')
const result = await qualityApi.validateVideo(
target.shotId,
target.assetId,
input.allowedTexts ?? []
)
if (
!isVisualValidation(result) ||
result.shotId !== target.shotId ||
result.videoId !== target.assetId ||
!Array.isArray(result.sampleFrames)
)
throw new Error('视频校验结果与目标不匹配或不完整,请核对后端记录。')
receipt = {
kind: 'video',
result,
promoted: !!videoRepairInfo(video.rawJson) && result.passed && result.isPrimary
}
}
} else {
const keyframes = await productionApi.listKeyframes(target.shotId)
const image = keyframes.find(
item => item.id === target.assetId && item.shotId === target.shotId
)
if (!image || image.status !== 'completed' || !image.imageUrl)
throw new Error('首帧尚未完成或缺少图片,不能执行质量检查。')
if (keyframes.some(item => ['pending', 'generating'].includes(item.status)))
throw new Error('此镜头仍在生图,请结束后再校验或修复。')
if (action === 'repair' && input.maxRepairAttempts > 0) {
const capability = await qualityApi.capability(target.shotId)
if (capability.valid !== true)
throw new Error(capability.message || '当前图片模型不满足参考图限制。')
}
if (!stillCurrent()) throw new Error('已离开原操作目标,未提交质量任务。')
if (action === 'repair') {
const result = await qualityApi.repair(target.shotId, target.assetId, {
maxRepairAttempts: input.maxRepairAttempts,
allowedTexts: input.allowedTexts,
width: input.width,
height: input.height
})
if (
!result ||
result.shotId !== target.shotId ||
result.initialKeyframeId !== target.assetId ||
typeof result.passed !== 'boolean' ||
typeof result.primaryChanged !== 'boolean' ||
!validAttempts(result.attempts)
)
throw new Error('修复回执不完整,请先核对首帧与主图状态,不要重复生图。')
receipt = { kind: 'repair', result }
} else {
const result = await qualityApi.validateKeyframe(
target.shotId,
target.assetId,
input.allowedTexts ?? []
)
if (
!isVisualValidation(result) ||
result.shotId !== target.shotId ||
result.keyframeId !== target.assetId ||
!result.validationTaskId
)
throw new Error('首帧校验结果不完整或与目标不匹配,请核对后端记录。')
receipt = { kind: 'keyframe', result }
}
}
}
receiptTarget.receipt = receipt
}
)
if (!ok) receiptTarget.error = getOperation(projectId).error
// 已提交请求即使切换页面也归原会话;只刷新仍在查看的原项目。
if (!disposed && props.projectId === projectId) changed()
}
return { key, session, operation, blocked, run }
}