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