feat: 同步精简接口并统一表单校验

This commit is contained in:
GJ
2026-09-10 22:34:47 +08:00
parent 82d7bde631
commit 2eea0ea72e
66 changed files with 1611 additions and 1100 deletions
+86 -44
View File
@@ -1,4 +1,7 @@
<script setup lang="ts">
import AppForm from '../../components/ui/AppForm.vue'
import { NFormItem } from 'naive-ui'
import { integerRule, sizeRules } from '../../lib/form-rules'
import WorkspacePage from '../../components/ui/WorkspacePage.vue'
import WorkspaceTools from '../../components/ui/WorkspaceTools.vue'
import {
@@ -164,6 +167,21 @@ watch(
if (receipt) toolsOpen.value = true
}
)
const batchModel = computed(() => ({ concurrency: concurrency.value }))
const batchRules = { concurrency: integerRule('批量并发') }
const keyframeModel = computed(() => ({
keyframeLimit: keyframeLimit.value,
keyframeWidth: keyframeWidth.value,
keyframeHeight: keyframeHeight.value
}))
const dimensionRules = sizeRules(() => ({ width: keyframeWidth.value, height: keyframeHeight.value }))
const keyframeRules = {
keyframeLimit: integerRule('本批上限', 1, true),
keyframeWidth: dimensionRules.width,
keyframeHeight: dimensionRules.height
}
const videoModel = computed(() => ({ videoLimit: videoLimit.value, videoRepairAttempts: videoRepairAttempts.value }))
const videoRules = { videoLimit: integerRule('本批镜头上限'), videoRepairAttempts: integerRule('每镜最多自动修复') }
</script>
<template>
@@ -219,25 +237,31 @@ watch(
</RouterLink>
</div>
<section class="mt-3" aria-label="项目生产配置">
<div class="production-controls">
<label>
<span class="field-label">批量并发</span>
<NInputNumber
<AppForm
:model="batchModel"
:rules="batchRules"
:disabled="operation.pending"
validate-on-change
>
<div class="form-controls production-controls">
<NFormItem path="concurrency" label="批量并发">
<NInputNumber
:input-props="{ 'aria-label': '批量并发' }"
:disabled="operation.pending"
:value="typeof concurrency === 'number' ? concurrency : null"
@update:value="concurrency = $event ?? 0"
:min="1"
:step="1"
></NInputNumber>
</NFormItem>
<NCheckbox
v-model:checked="force"
:disabled="operation.pending"
:value="typeof concurrency === 'number' ? concurrency : null"
@update:value="concurrency = $event ?? 0"
:min="1"
:step="1"
></NInputNumber>
<span v-if="!batchValid" class="mt-1 block text-xs text-danger">请输入正整数</span>
</label>
<NCheckbox
v-model:checked="force"
:disabled="operation.pending"
class="control-row-checkbox text-xs"
>覆盖模式重写提示词新增图视频候选
</NCheckbox>
</div>
class="control-row-checkbox text-xs"
>覆盖模式重写提示词新增图视频候选
</NCheckbox>
</div>
</AppForm>
<DetailDisclosure title="处理范围与覆盖规则" class="mt-3"
><p class="text-xs leading-6 text-muted">
提示词与视频就绪检查面向全项目视频实际提交受本批镜头上限控制并使用严格质量流水线首帧默认补当前剧集存在过期主首帧时优先更新全项目过期项覆盖模式处理全项目过期首帧成功后会自动接替旧主图其余新增候选图片模型视频模型与视觉校验均可能产生费用
@@ -393,63 +417,69 @@ watch(
:primary="stage.key === 'videos'"
@confirm="run(stage.key)"
/>
<div v-if="stage.key === 'keyframes'" class="mt-4 grid gap-3">
<label
><span class="field-label">本批上限可选</span
<AppForm
v-if="stage.key === 'keyframes'"
:model="keyframeModel"
:rules="keyframeRules"
:disabled="operation.pending"
validate-on-change
class="mt-4 grid gap-3"
>
<NFormItem path="keyframeLimit" label="本批上限(可选)"
><NInputNumber
:input-props="{ 'aria-label': '本批上限可选' }"
:value="keyframeLimit === '' ? null : keyframeLimit"
@update:value="keyframeLimit = $event ?? ''"
:min="1"
:precision="0"
:disabled="operation.pending"
placeholder="留空不限制"
/></label>
/></NFormItem>
<div class="grid grid-cols-2 gap-3">
<label
><span class="field-label">宽度像素</span
<NFormItem path="keyframeWidth" label="宽度(像素)"
><NInputNumber
:input-props="{ 'aria-label': '宽度像素' }"
:value="keyframeWidth === '' ? null : keyframeWidth"
@update:value="keyframeWidth = $event ?? ''"
:min="1"
:disabled="operation.pending"
placeholder="默认" /></label
><label
><span class="field-label">高度像素</span
placeholder="默认" /></NFormItem
><NFormItem path="keyframeHeight" label="高度(像素)"
><NInputNumber
:input-props="{ 'aria-label': '高度像素' }"
:value="keyframeHeight === '' ? null : keyframeHeight"
@update:value="keyframeHeight = $event ?? ''"
:min="1"
:disabled="operation.pending"
placeholder="默认"
/></label>
/></NFormItem>
</div>
<p v-if="!keyframeValid" class="text-xs text-danger">
上限须为正整数宽高须同时留空或填写正整数
</p>
</div>
<div v-if="stage.key === 'videos'" class="mt-4 grid gap-3">
<label
><span class="field-label">本批镜头上限</span
</AppForm>
<AppForm
v-if="stage.key === 'videos'"
:model="videoModel"
:rules="videoRules"
:disabled="operation.pending"
validate-on-change
class="mt-4 grid gap-3"
>
<NFormItem path="videoLimit" label="本批镜头上限"
><NInputNumber
v-model:value="videoLimit"
:min="1"
:precision="0"
:disabled="operation.pending"
:input-props="{ 'aria-label': '视频质量任务镜头上限' }"
/></label>
<label
><span class="field-label">每镜最多自动修复</span
/></NFormItem>
<NFormItem path="videoRepairAttempts" label="每镜最多自动修复"
><NInputNumber
v-model:value="videoRepairAttempts"
:min="1"
:precision="0"
:disabled="operation.pending"
:input-props="{ 'aria-label': '视频自动修复次数' }"
/></label>
<p v-if="!videoQualityValid" class="text-xs text-danger">
本批上限和自动修复次数都必须是正整数
</p>
</div>
/></NFormItem>
</AppForm>
</article>
</div>
<div v-if="query.data.value" class="panel mt-4 p-5">
@@ -466,7 +496,7 @@ watch(
blocked || !batchValid || !query.data.value.videoStatus.failed
"
acknowledgement
description="为当前最近一次状态为失败的镜头重新创建视频模型任务。成功镜头和进行中的镜头不会处理。"
description="仅重新提交后端判定可重试的失败视频;不可重试项会跳过,并在回执中显示数量。"
@confirm="run('retry-videos')"
/>
</div>
@@ -591,6 +621,18 @@ watch(
</div>
<p v-if="shot.description" class="mt-3 text-sm leading-7">{{ shot.description }}</p>
<p class="mt-3 break-all font-mono text-[10px] text-muted">Shot ID · {{ shot.shotId }}</p>
<NAlert
v-if="videoStatusItem?.status === 'failed'"
type="error"
:show-icon="false"
class="mt-3"
>
{{ videoStatusItem.error || '最近一次视频任务失败' }}
<p v-if="videoStatusItem.retryable === false" class="mt-2">
此失败不支持普通重试,请先处理失败原因。
</p>
<p v-else-if="videoStatusItem.retryable === true" class="mt-2">后端允许重试此任务。</p>
</NAlert>
<StaleKeyframeNotice
v-if="selectedKeyframeStale"
:key="shot.shotId"
@@ -1,4 +1,7 @@
<script setup lang="ts">
import AppForm from '../../../components/ui/AppForm.vue'
import { NFormItem } from 'naive-ui'
import { sizeRules } from '../../../lib/form-rules'
import { NAlert, NButton, NCheckbox, NInputNumber } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { AppDialog } from '../../../components/ui'
@@ -42,6 +45,8 @@ function submit() {
open.value = false
reset()
}
const formModel = computed(() => ({ width: width.value, height: height.value }))
const rules = sizeRules(() => formModel.value)
</script>
<template>
@@ -50,43 +55,51 @@ function submit() {
:title="`${replacePrimary ? '重新生成主首帧' : '生成首帧'} · ${shotTitle}`"
description="依据视觉风格、GenerationSpec 和主体主参考图编译首帧提示词,并调用 图片模型。"
>
<NAlert v-if="replacePrimary" type="info" :show-icon="false" class="mt-5 text-xs">
当前主首帧使用的主体参考资产已经变化新图成功后应设为主首帧旧图会继续保留为历史候选
</NAlert>
<div class="mt-5 grid grid-cols-2 gap-4">
<label
><span class="field-label">宽度可选</span
><NInputNumber
:value="typeof width === 'number' ? width : null"
@update:value="width = $event ?? ''"
:min="1"
></NInputNumber
></label>
<label
><span class="field-label">高度可选</span
><NInputNumber
:value="typeof height === 'number' ? height : null"
@update:value="height = $event ?? ''"
:min="1"
></NInputNumber
></label>
</div>
<p v-if="!sizeValid" class="mt-2 text-xs text-danger">宽高需要同时留空或同时填写正整数</p>
<NCheckbox v-model:checked="setPrimary" class="mt-5 flex items-start gap-3 text-sm leading-6"
>生成成功后设为当前主首帧
</NCheckbox>
<NAlert v-if="hasPrimary && setPrimary" type="info" :show-icon="false" class="mt-3 text-xs">
当前已有主首帧新图成功后将成为视频生成使用的首帧旧图仍保留为候选
</NAlert>
<NCheckbox
id="confirm-keyframe-cost"
v-model:checked="confirmed"
class="mt-5 flex items-start gap-3 text-sm leading-6"
>我已确认调用图片模型可能产生费用且当前没有相同镜头的生图任务
</NCheckbox>
<div class="dialog-footer mt-6">
<NButton @click="open = false">取消</NButton>
<NButton :disabled="!canSubmit" @click="submit" type="primary">确认生成首帧</NButton>
</div>
<AppForm
:model="formModel"
:rules="rules"
:disabled="disabled"
:reset-key="`${open}:${shotId}`"
validate-on-change
@submit="submit"
>
<NAlert v-if="replacePrimary" type="info" :show-icon="false" class="mt-5 text-xs">
当前主首帧使用的主体参考资产已经变化新图成功后应设为主首帧旧图会继续保留为历史候选
</NAlert>
<div class="mt-5 grid grid-cols-2 gap-4">
<NFormItem path="width" label="宽度(可选)"
><NInputNumber
:input-props="{ 'aria-label': '宽度可选' }"
:value="typeof width === 'number' ? width : null"
@update:value="width = $event ?? ''"
:min="1"
></NInputNumber
></NFormItem>
<NFormItem path="height" label="高度(可选)"
><NInputNumber
:input-props="{ 'aria-label': '高度可选' }"
:value="typeof height === 'number' ? height : null"
@update:value="height = $event ?? ''"
:min="1"
></NInputNumber
></NFormItem>
</div>
<NCheckbox v-model:checked="setPrimary" class="mt-5 flex items-start gap-3 text-sm leading-6"
>生成成功后设为当前主首帧
</NCheckbox>
<NAlert v-if="hasPrimary && setPrimary" type="info" :show-icon="false" class="mt-3 text-xs">
当前已有主首帧新图成功后将成为视频生成使用的首帧旧图仍保留为候选
</NAlert>
<NCheckbox
id="confirm-keyframe-cost"
v-model:checked="confirmed"
class="mt-5 flex items-start gap-3 text-sm leading-6"
>我已确认调用图片模型可能产生费用且当前没有相同镜头的生图任务
</NCheckbox>
<div class="dialog-footer mt-6">
<NButton @click="open = false">取消</NButton>
<NButton :disabled="disabled || !confirmed" attr-type="submit" type="primary">确认生成首帧</NButton>
</div>
</AppForm>
</AppDialog>
</template>
@@ -40,7 +40,8 @@ function exportReceipt() {
{{ receipt.result.failed }}
</p>
<p v-else class="mt-3 text-xs text-muted">
检测到 {{ receipt.result.totalFailed }} 个失败镜头 · 已重新提交 {{ receipt.result.retried }} · 提交失败
检测到 {{ receipt.result.totalFailed }} 个失败镜头 · 可重试 {{ receipt.result.retryable }} · 跳过
{{ receipt.result.skipped }} · 已重新提交 {{ receipt.result.retried }} · 提交失败
{{ receipt.result.failed }}
</p>
<NAlert
@@ -1,4 +1,7 @@
<script setup lang="ts">
import AppForm from '../../../components/ui/AppForm.vue'
import { NFormItem } from 'naive-ui'
import { integerRule, sizeRules } from '../../../lib/form-rules'
import { computed, ref, watch } from 'vue'
import {
NAlert,
@@ -12,7 +15,7 @@ import {
NRadioGroup
} from 'naive-ui'
import { AppDialog } from '../../../components/ui'
import { allowedTextLines, savedVideoValidation, validQualityInput } from '../quality'
import { allowedTextLines, validQualityInput } from '../quality'
import type { QualityAction, QualityInput, QualityTarget } from '../quality.types'
import { useQuality } from '../useQuality'
import QualityResult from './QualityResult.vue'
@@ -23,7 +26,6 @@ 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] }>()
@@ -75,7 +77,7 @@ const maxImages = computed(() =>
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 saved = computed(() => (props.target?.kind === 'video' ? (session.value.videoValidation ?? null) : null))
const repairAvailable = computed(
() =>
!videoRepair.value ||
@@ -109,6 +111,21 @@ async function submit() {
acknowledged.value = false
await run(action.value, input.value)
}
const formModel = computed(() => ({
attempts: attempts.value,
concurrency: concurrency.value,
limit: limit.value,
width: width.value,
height: height.value,
texts: texts.value
}))
const rules = computed(() => ({
attempts: integerRule('修复次数', videoRepair.value ? 1 : 0),
concurrency: integerRule('批量并发'),
limit: integerRule('本批镜头上限'),
...sizeRules(() => formModel.value)
}))
const optionsExpanded = ref<string[]>([])
</script>
<template>
@@ -118,126 +135,141 @@ async function submit() {
description="查看质量结果,或按需发起新的视觉检查。"
wide
>
<NRadioGroup
v-if="target && target.kind !== 'batch'"
v-model:value="action"
<AppForm
:model="formModel"
:rules="rules"
:disabled="operation.pending"
class="mt-4"
aria-label="素材质量操作"
:reset-key="`${open}:${key}:${action}`"
validate-on-change
@invalid="optionsExpanded = ['options']"
@submit="submit"
>
<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"
<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">
<NFormItem path="attempts" :label="videoRepair ? '修复链次数上限' : '每镜最多修复'"
><NInputNumber
v-model:value="attempts"
: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
:min="videoRepair ? 1 : 0"
:step="1"
:input-props="{ 'aria-label': '每镜最多修复次数' }"
/></NFormItem>
<NFormItem v-if="target?.kind === 'batch'" path="limit" label="本批镜头上限"
><NInputNumber
v-model:value="limit"
:disabled="operation.pending"
:min="1"
:step="1"
:input-props="{ 'aria-label': '质量生成镜头上限' }"
/></NFormItem>
</div>
<NCheckbox
v-if="target?.kind === 'batch'"
v-model:checked="force"
:disabled="operation.pending"
class="mt-4"
>重做本集已有有效首帧通过后替换主图</NCheckbox
>
<template v-else-if="valid"
>本次最多调用 {{ maxImages }} 次生图{{ maxValidations }} 次视觉校验均可能产生费用</template
<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
>
<template v-if="generating && !videoRepair"
>修复质量生成通过后可能替换主首帧失败候选仍会保留已有视频不会自动更新</template
<NCollapse
v-if="!videoRepair"
v-model:expanded-names="optionsExpanded"
display-directive="show"
class="mt-4"
>
<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
<NCollapseItem name="options" title="更多参数与模型限制">
<div class="quality-fields">
<NFormItem v-if="target?.kind === 'batch'" path="concurrency" label="批量并发"
><NInputNumber
v-model:value="concurrency"
:disabled="operation.pending"
:min="1"
:step="1"
:input-props="{ 'aria-label': '质量生成并发' }"
/></NFormItem>
<NFormItem v-if="generating" path="width" label="宽度(可选)"
><NInputNumber
v-model:value="width"
:disabled="operation.pending"
:min="1"
:input-props="{ 'aria-label': '质量生成宽度' }"
/></NFormItem>
<NFormItem v-if="generating" path="height" label="高度(可选)"
><NInputNumber
v-model:value="height"
:disabled="operation.pending"
:min="1"
:input-props="{ 'aria-label': '质量生成高度' }"
/></NFormItem>
</div>
<NFormItem class="mt-4 block" path="texts" label="额外允许的画面文字(一行一项,可留空)"
><NInput
v-model:value="texts"
:disabled="operation.pending"
type="textarea"
:autosize="{ minRows: 2, maxRows: 5 }"
:input-props="{ 'aria-label': '额外允许的画面文字' }"
/></NFormItem>
<ModelCapabilities :shot-id="target && target.kind !== 'batch' ? target.shotId : undefined" />
</NCollapseItem>
</NCollapse>
<NAlert type="warning" :show-icon="false" class="mt-4">
<template v-if="videoRepair"
>本次最多提交 1
个视频生成任务可能产生费用生成完成不会切换主视频需再次付费复检通过后才会晋升</template
>
<template v-else-if="target?.kind === 'video'"
>本次提交一次视频校验请求后端可能调用多个视觉模型并产生费用</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>
<div class="dialog-footer mt-4">
<NButton @click="open = false">关闭</NButton>
<NButton
type="primary"
:loading="operation.pending"
:disabled="blocked || !acknowledged || !repairAvailable"
attr-type="submit"
>{{ label }}</NButton
>
</div>
</AppForm>
<p v-if="operation.pending" role="status" class="mt-3 text-xs text-muted">
{{ operation.label }}进行中请勿重复提交
</p>
@@ -246,7 +278,7 @@ async function submit() {
}}</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 ? '通过' : '未通过'}`"
><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 }}
@@ -254,8 +286,8 @@ async function submit() {
<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 v-if="target?.kind !== 'batch'" class="mt-3 text-xs text-muted">
校验回执保留在当前会话可导出素材列表不提供历史质量结果刷新浏览器后需重新校验
</p>
</AppDialog>
</template>
@@ -22,6 +22,16 @@ const issues = computed(() => {
? (value.result.attempts.at(-1)?.issues ?? [])
: value.result.issues
})
const videoChecks = computed(() => {
if (props.receipt.kind !== 'video') return []
const result = props.receipt.result
return [
{ label: '运动真实性', result: result.motionRealism },
{ label: '空间交互', result: result.spatialInteraction },
{ label: '物理结构', result: result.physicalStructure },
{ label: '物理连接', result: result.physicalJoint }
].filter(item => item.result)
})
/** 导出只使用后端实际回执,便于关闭页面前留存。 */
function exportResult() {
downloadText(
@@ -119,6 +129,14 @@ function exportResult() {
: '未发现'
}}
</p>
<div v-for="check in videoChecks" :key="check.label" class="mt-2 text-xs">
{{ check.label }}<template v-if="check.result?.enabled"
>{{
check.result.applicable === false ? '不适用' : check.result.passed ? '通过' : '未通过'
}}
· {{ check.result.summary }}</template
><template v-else>未执行</template>
</div>
<div
v-for="subject in receipt.result.subjects"
:key="subject.subjectRef"
@@ -27,7 +27,7 @@ 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, videoQualityInfo, videoRepairInfo } from '../quality'
import { sessionVideoValidation } from '../quality'
/** 单镜头生产面板只负责正式资产,不修改上游导演设计与即时状态。 */
const props = defineProps<{
@@ -64,10 +64,6 @@ const assetBlocked = computed(() => props.disabled || operation.value.pending ||
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)
/** 从实际候选打开质量面板,保留单镜头主操作,不默认展开全部校验参数。 */
@@ -132,7 +128,7 @@ async function generatePrompt() {
const overwrite = props.promptReadiness?.status === 'skipped'
await runOperation(props.projectId, `生成镜头 ${props.shot.shotNo} 视频提示词`, async () => {
const result = await storyboardApi.generatePrompt(shotId, overwrite)
if (!result || result.id !== shotId || !result.videoPrompt) throw new Error('接口未返回有效视频提示词。')
if (!result || result.shotId !== shotId || !result.videoPrompt) throw new Error('接口未返回有效视频提示词。')
})
await refreshAll()
}
@@ -151,7 +147,12 @@ async function generateKeyframe(input: GenerateKeyframeInput) {
/** 切换主首帧不调用模型,但会改变后续视频生成输入。 */
async function setPrimaryKeyframe() {
const keyframeId = confirmingKeyframe.value
if (assetBlocked.value || !keyframeId) return
if (
assetBlocked.value ||
!keyframeId ||
keyframes.value.find(item => item.id === keyframeId)?.source === 'provider_variant'
)
return
const shotId = props.shot.shotId
confirmingKeyframe.value = ''
await runOperation(props.projectId, `设置镜头 ${props.shot.shotNo} 主首帧`, async () => {
@@ -195,8 +196,9 @@ async function setPrimaryVideo() {
const video = videos.value.find(item => item.id === videoId)
if (
!video ||
((videoRepairInfo(video.rawJson) || videoQualityInfo(video.rawJson)) &&
savedVideoValidation(video.rawJson)?.passed !== true)
video.status !== 'completed' ||
!video.videoUrl ||
sessionVideoValidation(props.projectId, video.shotId, video.id)?.passed !== true
)
return
const shotId = props.shot.shotId
@@ -322,6 +324,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="item.source === 'provider_variant'" size="small" :bordered="false"
>模型输入变体</NTag
>
<NTag v-if="item.isPrimary && keyframeStale" size="small" :bordered="false">已过期</NTag>
<span class="text-[10px] text-muted"
>{{ item.width || '—' }} × {{ item.height || '—' }}</span
@@ -338,7 +343,12 @@ async function inspectSpecs() {
>质量检查</NButton
>
<NButton
v-if="item.status === 'completed' && item.imageUrl && !item.isPrimary"
v-if="
item.status === 'completed' &&
item.imageUrl &&
item.source !== 'provider_variant' &&
!item.isPrimary
"
:disabled="assetBlocked"
@click="confirmingKeyframe = item.id"
text
@@ -401,22 +411,7 @@ 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-else-if="videoQualityInfo(item.rawJson) && !item.isPrimary"
size="small"
:bordered="false"
>质量候选 ·
{{
savedVideoValidation(item.rawJson)
? savedVideoValidation(item.rawJson)?.passed
? '已通过'
: '未通过'
: '自动校验中'
}}</NTag
>
<NTag v-if="!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
@@ -434,8 +429,13 @@ async function inspectSpecs() {
text
size="small"
@click="openQuality('video', item.id)"
>质量检查<template v-if="savedVideoValidation(item.rawJson)">
· {{ savedVideoValidation(item.rawJson)?.passed ? '曾通过' : '未通过' }}</template
>质量检查<template v-if="sessionVideoValidation(projectId, item.shotId, item.id)">
·
{{
sessionVideoValidation(projectId, item.shotId, item.id)?.passed
? '曾通过'
: '未通过'
}}</template
></NButton
>
<a
@@ -451,8 +451,7 @@ async function inspectSpecs() {
item.status === 'completed' &&
item.videoUrl &&
!item.isPrimary &&
(!(videoRepairInfo(item.rawJson) || videoQualityInfo(item.rawJson)) ||
savedVideoValidation(item.rawJson)?.passed === true)
sessionVideoValidation(projectId, item.shotId, item.id)?.passed === true
"
:disabled="assetBlocked"
@click="confirmingVideo = item.id"
@@ -473,6 +472,9 @@ async function inspectSpecs() {
</article>
</div>
<p v-else class="mt-4 text-xs text-muted">尚无视频任务记录。</p>
<p v-if="videos.some(item => !item.isPrimary)" class="mt-3 text-xs text-muted">
候选视频的历史质量结果未公开;手动设为主视频前需在本会话完成视觉校验。自动质量任务的晋升结果以刷新后的主视频标记为准。
</p>
<div class="mt-4 flex flex-wrap gap-3">
<ConfirmAction
:label="
@@ -525,7 +527,15 @@ async function inspectSpecs() {
Negative prompt: {{ videoSpec.negativePrompt }}
</p>
<p class="mt-3 break-all font-mono text-[10px] text-muted">
Keyframe ID · {{ videoSpec.keyframe.id }}
业务主首帧 ID · {{ videoSpec.keyframe.id }}
</p>
<p v-if="videoSpec.providerInputKeyframe" class="mt-2 break-all text-[10px] text-muted">
模型输入首帧 ID · {{ videoSpec.providerInputKeyframe.id }}
{{
videoSpec.providerInputKeyframe.source === 'provider_variant'
? '(专用变体)'
: '(业务主首帧)'
}}
</p>
</div>
</div></NCollapseItem
@@ -537,7 +547,6 @@ async function inspectSpecs() {
:project-id="projectId"
:target="qualityTarget"
:disabled="assetBlocked"
:saved-raw-json="qualityRawJson"
@changed="refreshAll"
/>
<KeyframeDialog
+5 -1
View File
@@ -24,7 +24,9 @@ export function validOptionalSize(width: number | '', height: number | ''): bool
/** 返回当前成功主首帧;异常的重复主图只取最新列表中的第一条。 */
export function primaryKeyframe(items: ShotKeyframe[]): ShotKeyframe | undefined {
return items.find(item => item.isPrimary && item.status === 'completed' && item.imageUrl)
return items.find(
item => item.isPrimary && item.source !== 'provider_variant' && item.status === 'completed' && item.imageUrl
)
}
/** 返回当前成功主视频。 */
@@ -47,6 +49,8 @@ export function issueLabel(code: ProductionIssueCode): string {
missing_identity_anchor: '缺少演员母版',
identity_unlocked: '演员身份未锁定',
missing_prompt: '缺少视频提示词',
stale_prompt: '视频提示词需要更新',
provider_input_recovery: '视频模型输入需要处理',
missing_keyframe: '缺少主首帧',
stale_keyframe: '主首帧已过期'
}
+7 -48
View File
@@ -1,10 +1,12 @@
import { reactive } from 'vue'
import { validOptionalSize } from './model'
import type { KeyframeReadiness } from './types'
import type { QualityInput, QualityReceipt, QualityTarget, VisualValidation } from './quality.types'
import type { QualityInput, QualityReceipt, QualityTarget, VisualValidation, VideoValidation } from './quality.types'
/** 未返回的操作不显示上次成功结论;网络失败不会自动重发。 */
const sessions = reactive<Record<string, { receipt: QualityReceipt | null; error: string }>>({})
const sessions = reactive<
Record<string, { receipt: QualityReceipt | null; error: string; videoValidation?: VideoValidation }>
>({})
/** 正式 ID 与目标类型共同隔离会话结果,批量回执按剧集隔离。 */
export function qualityKey(projectId: string, target: QualityTarget | null) {
@@ -83,50 +85,7 @@ export function isVisualValidation(value: unknown): value is VisualValidation {
)
}
/** 读取视频已保存的抽样校验,不为查看历史再次调用视觉模型。 */
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
}
}
/** 识别严格质量候选及其自动修复配置,防止未通过候选被手动绕过校验设为主视频。 */
export function videoQualityInfo(
rawJson?: string | null
): { allowedTexts: string[]; maxRepairAttempts: number } | null {
try {
const snapshot: unknown = JSON.parse(rawJson || '{}')
if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot)) return null
const record = snapshot as Record<string, unknown>
if (record.qualityCandidate !== true) return null
const pipeline = record.qualityPipeline
if (!pipeline || typeof pipeline !== 'object' || Array.isArray(pipeline)) return null
const config = pipeline as Record<string, unknown>
const maxRepairAttempts = config.maxRepairAttempts
if (typeof maxRepairAttempts !== 'number' || !Number.isSafeInteger(maxRepairAttempts) || maxRepairAttempts <= 0)
return null
return {
allowedTexts: Array.isArray(config.allowedTexts)
? config.allowedTexts.filter((item): item is string => typeof item === 'string')
: [],
maxRepairAttempts
}
} catch {
return null
}
/** 公开视频 DTO 不含质量历史,只使用本会话真实收到的校验回执。 */
export function sessionVideoValidation(projectId: string, shotId: string, assetId: string): VideoValidation | null {
return qualitySession(qualityKey(projectId, { kind: 'video', shotId, assetId, title: '' })).videoValidation ?? null
}
+13 -1
View File
@@ -40,7 +40,7 @@ export interface KeyframeValidation extends VisualValidation {
isPrimary: boolean
}
/** 视频校验基于三点抽样帧;修复候选通过后后端会自动晋升为主视频。 */
/** 视频校验包括抽样帧及按需专项检查;修复候选通过后后端会自动晋升为主视频。 */
export interface VideoValidation extends VisualValidation {
shotId: string
videoId: string
@@ -49,6 +49,13 @@ export interface VideoValidation extends VisualValidation {
validatedAt: string
allowedTexts: string[]
sampleFrames: { label: string; timeSeconds: number }[]
motionSampleFrames?: { label: string; timeSeconds: number }[]
physicalDetailFrames?: { label: string; timeSeconds: number; imageLabel: string }[]
propLocator?: { enabled: boolean; [key: string]: unknown }
motionRealism?: VideoValidationCheck
spatialInteraction?: VideoValidationCheck
physicalStructure?: VideoValidationCheck
physicalJoint?: VideoValidationCheck
}
/** 视频修复只创建一个异步候选任务,不自动等待视频或再次执行视觉校验。 */
@@ -148,3 +155,8 @@ export type QualityReceipt =
| { kind: 'video-repair'; result: VideoRepair }
| { kind: 'repair'; result: KeyframeRepair }
| { kind: 'batch'; result: QualityBatchResult }
/** 视频专项检查保留完整回执;缺少前置材料时后端会明确停用,不视为通过。 */
export type VideoValidationCheck =
| { enabled: false; reason: string }
| { enabled: true; passed: boolean; summary: string; applicable?: boolean; [key: string]: unknown }
@@ -8,13 +8,11 @@ export function keyframeFixture(patch: Partial<ShotKeyframe> = {}): ShotKeyframe
source: 'generated',
provider: 'seedream',
model: 'seedream-test',
prompt: '首帧提示词',
imageUrl: '/storage/keyframes/keyframe-1.png',
width: 1920,
height: 1080,
status: 'completed',
isPrimary: true,
providerTaskId: null,
error: null,
createdAt: '2026-08-31T00:00:00.000Z',
updatedAt: '2026-08-31T00:00:00.000Z',
@@ -29,9 +27,6 @@ export function videoFixture(patch: Partial<ShotVideo> = {}): ShotVideo {
shotId: 'shot-1',
provider: 'seedance',
model: 'seedance-test',
providerTaskId: 'provider-task-1',
prompt: '视频提示词',
negativePrompt: null,
status: 'completed',
videoUrl: '/storage/videos/video-1.mp4',
durationSeconds: 5,
+29 -9
View File
@@ -10,6 +10,8 @@ export type ProductionIssueCode =
| 'missing_identity_anchor'
| 'identity_unlocked'
| 'missing_prompt'
| 'stale_prompt'
| 'provider_input_recovery'
| 'missing_keyframe'
| 'stale_keyframe'
@@ -25,6 +27,7 @@ export interface PromptReadinessItem {
shotId: string
shotNo: number
status: Exclude<ProductionReadinessStatus, 'in_progress'>
stalePrompt?: boolean
issues: ProductionIssue[]
}
@@ -68,6 +71,7 @@ export interface VideoReadinessItem {
activeVideoId?: string | null
/** 当前主视频已基于旧主首帧或旧主体参考图生成。 */
primaryVideoStale?: boolean
providerInputRecovery?: ProviderInputRecoveryStatus | null
}
/** 视频任务项目级就绪汇总。 */
@@ -80,6 +84,8 @@ export interface VideoReadiness {
missingPrompt: number
missingKeyframe: number
staleKeyframe: number
stalePrompt?: number
providerInputRecovery?: number
stalePrimaryVideo: number
missingReference: number
items: VideoReadinessItem[]
@@ -89,16 +95,14 @@ export interface VideoReadiness {
export interface ShotKeyframe {
id: string
shotId: string
source: string
source: string | null
provider: string | null
model: string | null
prompt: string | null
imageUrl: string | null
width: number | null
height: number | null
status: 'pending' | 'generating' | 'completed' | 'failed'
isPrimary: boolean
providerTaskId: string | null
error: string | null
createdAt: string
updatedAt: string
@@ -110,16 +114,11 @@ export interface ShotVideo {
shotId: string
provider: string
model: string
providerTaskId: string | null
prompt: string
negativePrompt: string | null
status: 'pending' | 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'
videoUrl: string | null
durationSeconds: number | null
isPrimary: boolean
error: string | null
/** 后端保存的抽样视觉校验与生成快照,按需读取,不直接展示整段原始数据。 */
rawJson?: string | null
createdAt: string
updatedAt: string
}
@@ -170,7 +169,12 @@ export interface VideoGenerationSpec {
durationSeconds: number
videoPrompt: string
negativePrompt?: string
keyframe: { id: string; imageUrl: string; width?: number; height?: number }
keyframe: VideoInputKeyframe
providerInputKeyframe: VideoInputKeyframe & {
source: 'business_primary' | 'provider_variant'
targetProvider?: string | null
variantReason?: string | null
}
references: VideoReference[]
}
@@ -255,6 +259,9 @@ export interface ProjectVideoStatusItem {
shotNo: number
status: ShotVideo['status'] | 'not_started'
videoId: string | null
failureCategory?: 'content_safety' | 'invalid_request' | 'rate_limit' | 'transient' | 'unknown' | null
retryable?: boolean | null
providerInputRecovery?: ProviderInputRecoveryStatus | null
videoUrl: string | null
error: string | null
}
@@ -275,6 +282,8 @@ export interface ProjectVideoStatus {
/** 失败视频批量重试回执。 */
export interface RetryVideosResult {
totalFailed: number
retryable: number
skipped: number
retried: number
failed: number
failures: { shotId: string; error: string }[]
@@ -315,3 +324,14 @@ export interface PipelineBatchResult {
skipped: number
failed: number
}
/** 视频模型最终输入和业务主首帧共用的公开图片规格。 */
export interface VideoInputKeyframe {
id: string
imageUrl: string
width?: number | null
height?: number | null
}
/** 后端公开的模型输入恢复状态,不能当作普通重试状态。 */
export type ProviderInputRecoveryStatus = 'not_required' | 'required' | 'variant_ready' | 'exhausted'
@@ -2,9 +2,9 @@ import { computed, onScopeDispose, ref, watch } from 'vue'
import { projectsApi } from '../projects/api'
import { useProjectContext } from '../projects/context'
import { subjectImagesApi } from '../subject-images/api'
import { hasRunningImages, isPrimaryIdentityStale, primaryImage } from '../subject-images/model'
import { hasRunningImages, primaryImage } from '../subject-images/model'
import { getOperation, runOperation } from '../workflows/operations'
import { workflowCheckpoints } from '../workflows/selectors'
import { hasRunningWorkflow } from '../workflows/selectors'
import { errorMessage } from '../../lib/http'
import { productionApi } from './api'
import { getProductionSession } from './model'
@@ -33,16 +33,10 @@ export async function checkPipeline(projectId: string) {
)
issues.push('各阶段镜头列表不一致,可能正在重新拆解,请刷新后重试。')
if (project.status !== 'completed') issues.push('剧本尚未完成,请先完成剧本创作。')
if (
project.tasks.some(task => ['pending', 'queued', 'running', 'generating'].includes(task.status)) ||
[...new Set(checkpoints.map(item => item.workflowName))].some(
name => workflowCheckpoints(checkpoints, name).at(-1)?.state.workflowExecution?.status === 'running'
)
)
issues.push('项目仍有后台任务或工作流进行中。')
if (hasRunningWorkflow(checkpoints)) issues.push('项目仍有后台任务或工作流进行中。')
if (!forms.length) issues.push('尚无正式形态,请先完成拆解。')
if (forms.some(form => hasRunningImages(form.images))) issues.push('形态图片仍在生成中。')
if (forms.some(form => !primaryImage(form.images)?.imageUrl || isPrimaryIdentityStale(form)))
if (forms.some(form => !primaryImage(form.images)?.imageUrl))
issues.push('请先补齐并确认所有形态主图,更新过期母版引用,再生成首帧。')
if (
!keyframes.total ||
+12 -11
View File
@@ -1,5 +1,6 @@
import { computed, onScopeDispose } from 'vue'
import { projectsApi } from '../projects/api'
import { hasRunningWorkflow } from '../workflows/selectors'
import { getOperation, runOperation } from '../workflows/operations'
import { productionApi } from './api'
import { qualityApi } from './quality-api'
@@ -9,8 +10,7 @@ import {
qualitySession,
qualityTargets,
validQualityInput,
savedVideoValidation,
videoRepairInfo
sessionVideoValidation
} from './quality'
import type { QualityAction, QualityInput, QualityReceipt, QualityTarget, RepairAttempt } from './quality.types'
@@ -58,6 +58,7 @@ export function useQuality(
const receiptTarget = qualitySession(originalKey)
receiptTarget.receipt = null
receiptTarget.error = ''
if (target.kind === 'video' && action === 'validate') delete receiptTarget.videoValidation
const stillCurrent = () => !disposed && key.value === originalKey && visible()
const ok = await runOperation(
projectId,
@@ -69,14 +70,14 @@ export function useQuality(
: '首帧自动修复'
: '视觉质量校验',
async () => {
const [project, readiness] = await Promise.all([
const [project, readiness, checkpoints] = await Promise.all([
projectsApi.detail(projectId),
productionApi.keyframeReadiness(projectId, target.kind === 'batch' ? input.force : true)
productionApi.keyframeReadiness(projectId, target.kind === 'batch' ? input.force : true),
projectsApi.checkpoints(projectId)
])
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 (hasRunningWorkflow(checkpoints)) throw new Error('项目还有后台任务,请结束后再执行质量检查。')
if (
!Array.isArray(readiness.items) ||
readiness.items.length !== readiness.total ||
@@ -142,12 +143,10 @@ export function useQuality(
)
throw new Error('视频未完成、缺少视频地址或有效时长,不能执行抽样校验。')
if (action === 'repair') {
if (savedVideoValidation(video.rawJson)?.passed !== false)
if (sessionVideoValidation(projectId, target.shotId, target.assetId)?.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,
@@ -179,13 +178,15 @@ export function useQuality(
!isVisualValidation(result) ||
result.shotId !== target.shotId ||
result.videoId !== target.assetId ||
!Array.isArray(result.sampleFrames)
!Array.isArray(result.sampleFrames) ||
typeof result.isPrimary !== 'boolean'
)
throw new Error('视频校验结果与目标不匹配或不完整,请核对后端记录。')
receiptTarget.videoValidation = result
receipt = {
kind: 'video',
result,
promoted: !!videoRepairInfo(video.rawJson) && result.passed && result.isPrimary
promoted: !video.isPrimary && result.passed && result.isPrimary
}
}
} else {