feat: 同步精简接口并统一表单校验
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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: '主首帧已过期'
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user