feat: 同步质量校验修复并精简工作台操作
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user