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

This commit is contained in:
GouJ
2026-09-03 19:38:57 +08:00
parent 771b66cafc
commit 13bb6dc83b
47 changed files with 2244 additions and 260 deletions
@@ -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"