Files
short-drama-agent-front/src/features/production/components/ShotProductionAssets.vue
T

604 lines
29 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import ProviderInputInspector from './ProviderInputInspector.vue'
import { NAlert, NButton, NCollapse, NCollapseItem, NTag } from 'naive-ui'
import { computed, ref, watch } from 'vue'
import { ExternalLink, ImagePlus, RefreshCw } from '@lucide/vue'
import { AssetImage, StatusBadge } from '../../../components/ui'
import { useQuery } from '../../../composables/useQuery'
import { mediaAssetUrl } from '../../../lib/assets'
import { errorMessage } from '../../../lib/http'
import { formatDate } from '../../../lib/format'
import type { DesignedShot } from '../../storyboard/types'
import { storyboardApi } from '../../storyboard/api'
import ConfirmAction from '../../workflows/ConfirmAction.vue'
import { getOperation, runOperation } from '../../workflows/operations'
import { productionApi } from '../api'
import { isActiveVideo, issueLabel, primaryKeyframe, primaryVideo, productionStatusLabel } from '../model'
import type {
GenerateKeyframeInput,
KeyframeReadinessItem,
KeyframeSpec,
PromptReadinessItem,
VideoGenerationSpec,
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 { sessionVideoValidation } from '../quality'
/** 单镜头生产面板只负责正式资产,不修改上游导演设计与即时状态。 */
const props = defineProps<{
projectId: string
shot: DesignedShot
promptReadiness?: PromptReadinessItem
keyframeReadiness?: KeyframeReadinessItem
videoReadiness?: VideoReadinessItem
disabled: boolean
}>()
const emit = defineEmits<{ changed: [] }>()
const key = computed(() => props.shot.shotId)
/** 单镜头资产仅在进入、切换镜头或显式刷新时读取。 */
const query = useQuery(key, async (shotId, signal) => {
const [keyframes, videos] = await Promise.all([
productionApi.listKeyframes(shotId, signal),
productionApi.listVideos(shotId, signal)
])
if (keyframes.some(item => item.shotId !== shotId) || videos.some(item => item.shotId !== shotId))
throw new Error('资产记录与当前镜头不匹配,请刷新后重试。')
return { keyframes, videos }
})
const keyframes = computed(() => query.data.value?.keyframes ?? [])
const videos = computed(() => query.data.value?.videos ?? [])
const currentKeyframe = computed(() => primaryKeyframe(keyframes.value))
const currentVideo = computed(() => primaryVideo(videos.value))
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 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)
const specError = ref('')
const keyframeSpec = ref<KeyframeSpec | null>(null)
const videoSpec = ref<VideoGenerationSpec | null>(null)
const canGeneratePrompt = computed(
() => !assetBlocked.value && !!props.promptReadiness && props.promptReadiness.issues.length === 0
)
const canGenerateKeyframe = computed(
() =>
!assetBlocked.value &&
!keyframes.value.some(item => ['pending', 'generating'].includes(item.status)) &&
!!props.keyframeReadiness &&
props.keyframeReadiness.issues.length === 0
)
const canGenerateVideo = computed(
() =>
!assetBlocked.value && !activeVideo.value && !!props.videoReadiness && props.videoReadiness.issues.length === 0
)
const keyframeStale = computed(() => !!props.keyframeReadiness?.primaryKeyframeStale)
const videoStale = computed(() => !!props.videoReadiness?.primaryVideoStale)
watch(key, () => {
qualityOpen.value = false
qualityTarget.value = null
specsOpen.value = false
confirmingKeyframe.value = ''
confirmingVideo.value = ''
keyframeSpec.value = null
videoSpec.value = null
specError.value = ''
})
/** 只返回通过协议和 /storage 限制的视频地址。 */
function safeVideoUrl(value: string | null): string | null {
return value ? mediaAssetUrl(value) : null
}
/** 重新读取单镜头资产,并通知父页刷新项目就绪状态。 */
async function refreshAll() {
await query.refresh()
emit('changed')
}
/** 单镜头提示词根据现有状态决定是否 force 覆盖。 */
async function generatePrompt() {
if (!canGeneratePrompt.value) return
const shotId = props.shot.shotId
const overwrite = props.promptReadiness?.status === 'skipped'
await runOperation(props.projectId, `生成镜头 ${props.shot.shotNo} 视频提示词`, async () => {
const result = await storyboardApi.generatePrompt(shotId, overwrite)
if (!result || result.shotId !== shotId || !result.videoPrompt) throw new Error('接口未返回有效视频提示词。')
})
await refreshAll()
}
/** 调用图片模型生成单镜头首帧;成功后重新读取候选和主图。 */
async function generateKeyframe(input: GenerateKeyframeInput) {
if (!canGenerateKeyframe.value) return
const shotId = props.shot.shotId
await runOperation(props.projectId, `生成镜头 ${props.shot.shotNo} 首帧`, async () => {
const result = await productionApi.generateKeyframe(shotId, input)
if (!result || result.shotId !== shotId) throw new Error('接口未确认首帧生成结果。')
})
await refreshAll()
}
/** 切换主首帧不调用模型,但会改变后续视频生成输入。 */
async function setPrimaryKeyframe() {
const keyframeId = confirmingKeyframe.value
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 () => {
const result = await productionApi.setPrimaryKeyframe(shotId, keyframeId)
if (!result || result.id !== keyframeId || !result.isPrimary || result.status !== 'completed')
throw new Error('接口未确认主首帧切换。')
})
await refreshAll()
}
/** 创建单镜头严格质量候选;后端完成生成后自动校验、有限修复并按结果晋升。 */
async function generateVideo() {
if (!canGenerateVideo.value) return
const shotId = props.shot.shotId
await runOperation(props.projectId, `提交镜头 ${props.shot.shotNo} 视频质量任务`, async () => {
const result = await productionApi.generateQualityVideo(shotId, {
maxRepairAttempts: 2,
allowedTexts: []
})
if (!result || result.shotId !== shotId || !result.candidate || !isActiveVideo(result.candidate))
throw new Error('接口未返回有效的视频质量候选任务。')
})
await refreshAll()
}
/** 手动向 Provider 查询活动视频状态,终态任务不重复查询。 */
async function refreshActiveVideo() {
const video = activeVideo.value
if (!video || assetBlocked.value) return
await runOperation(props.projectId, `刷新镜头 ${props.shot.shotNo} 视频状态`, async () => {
const result = await productionApi.refreshVideo(video.id)
if (!result || result.id !== video.id) throw new Error('接口未返回当前视频任务状态。')
})
await refreshAll()
}
/** 切换主视频只接受已完成并具有持久化地址的候选。 */
async function setPrimaryVideo() {
const videoId = confirmingVideo.value
if (assetBlocked.value || !videoId) return
const video = videos.value.find(item => item.id === videoId)
if (
!video ||
video.status !== 'completed' ||
!video.videoUrl ||
sessionVideoValidation(props.projectId, video.shotId, video.id)?.passed !== true
)
return
const shotId = props.shot.shotId
confirmingVideo.value = ''
await runOperation(props.projectId, `设置镜头 ${props.shot.shotNo} 主视频`, async () => {
const result = await productionApi.setPrimaryVideo(shotId, videoId)
if (!result || result.id !== videoId || !result.isPrimary || result.status !== 'completed')
throw new Error('接口未确认主视频切换。')
})
await refreshAll()
}
/** 显式检查两份确定性生成规格;某一阶段未就绪时保留另一份可读结果。 */
async function inspectSpecs() {
specsOpen.value = true
inspecting.value = true
specError.value = ''
const shotId = props.shot.shotId
const [keyframeResult, videoResult] = await Promise.allSettled([
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]
.filter(result => result.status === 'rejected')
.map(result => errorMessage(result.reason))
specError.value = [...new Set(errors)].join(' ')
inspecting.value = false
}
</script>
<template>
<section class="production-assets">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h4 class="text-sm font-semibold">镜头生产资产</h4>
</div>
<div class="flex gap-3">
<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>
<div class="production-stage mt-5">
<div class="production-stage-heading">
<div>
<h5 class="text-sm font-medium">01 · 视频提示词</h5>
</div>
<StatusBadge
v-if="promptReadiness"
:status="promptReadiness.status"
:label="productionStatusLabel(promptReadiness.status)"
/>
</div>
<ul v-if="promptReadiness?.issues.length" class="mt-3 space-y-2 text-xs text-danger">
<li v-for="issue in promptReadiness.issues" :key="issue.code">
{{ issueLabel(issue.code) }}{{ issue.reason }}
</li>
</ul>
<div class="mt-4">
<ConfirmAction
:label="promptReadiness?.status === 'skipped' ? '重新生成本镜提示词' : '生成本镜提示词'"
:disabled="!canGeneratePrompt"
acknowledgement
:description="
promptReadiness?.status === 'skipped'
? '依据当前 GenerationSpec 与参考图重写本镜提示词。旧提示词会被覆盖,已有首帧和视频不会自动更新。'
: '依据当前 GenerationSpec 与参考图生成本镜视频提示词。'
"
@confirm="generatePrompt"
/>
</div>
</div>
<div class="production-stage">
<div class="production-stage-heading">
<div>
<h5 class="text-sm font-medium">02 · 首帧</h5>
</div>
<StatusBadge
v-if="keyframeReadiness"
:status="keyframeReadiness.status"
:label="keyframeStale ? '主首帧已过期' : productionStatusLabel(keyframeReadiness.status)"
/>
</div>
<NAlert v-if="keyframeStale" role="status" type="info" :show-icon="false" class="mt-3 text-xs">
当前主首帧使用的主体参考资产已经变化旧图仍保留但不会继续进入视频生成请重新生成主首帧
</NAlert>
<ul v-if="keyframeReadiness?.issues.length" class="mt-3 space-y-2 text-xs text-danger">
<li v-for="issue in keyframeReadiness.issues" :key="issue.code">
{{ issueLabel(issue.code) }}{{ issue.reason }}
</li>
</ul>
<div v-if="keyframes.length" class="keyframe-grid mt-4">
<article v-for="item in keyframes" :key="item.id" class="keyframe-card">
<AssetImage
:src="item.status === 'completed' ? item.imageUrl : null"
:alt="`镜头 ${shot.shotNo} 首帧`"
preview
:empty-text="item.status === 'failed' ? '首帧生成失败' : '首帧生成中'"
/>
<div class="p-3">
<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
>
</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.source !== 'provider_variant' &&
!item.isPrimary
"
:disabled="assetBlocked"
@click="confirmingKeyframe = item.id"
text
size="small"
class="mt-2"
>
设为主首帧
</NButton>
<NAlert
v-if="confirmingKeyframe === item.id"
type="info"
:show-icon="false"
class="mt-2 text-xs"
><p>确认让后续视频使用此首帧?已有视频不会自动重生成。</p>
<div class="mt-2 flex gap-3">
<NButton @click="setPrimaryKeyframe" type="primary">确认切换</NButton>
<NButton @click="confirmingKeyframe = ''" text size="small">取消</NButton>
</div></NAlert
>
</div>
</article>
</div>
<p v-else class="mt-4 text-xs text-muted">尚无首帧记录。</p>
<NButton :disabled="!canGenerateKeyframe" @click="keyframeOpen = true" class="mt-4"
><ImagePlus :size="14" />{{
keyframeStale ? '重新生成主首帧' : currentKeyframe ? '再生成一张候选' : '生成首帧'
}}</NButton
>
</div>
<div class="production-stage">
<div class="production-stage-heading">
<div>
<h5 class="text-sm font-medium">03 · 视频</h5>
</div>
<StatusBadge
v-if="videoReadiness"
:status="videoReadiness.status"
:label="videoStale ? '主视频已过期' : productionStatusLabel(videoReadiness.status)"
/>
</div>
<NAlert v-if="videoStale" role="status" type="info" :show-icon="false" class="mt-3 text-xs">
当前主视频使用的主首帧或主体参考图已经变化。旧视频仍保留;重新生成完成后,新视频会自动接替过期主视频。
</NAlert>
<ul v-if="videoReadiness?.issues.length" class="mt-3 space-y-2 text-xs text-danger">
<li v-for="issue in videoReadiness.issues" :key="issue.code">
{{ issueLabel(issue.code) }}{{ issue.reason }}
</li>
</ul>
<div v-if="videos.length" class="mt-4 space-y-3">
<article v-for="item in videos" :key="item.id" class="video-record">
<video
v-if="item.status === 'completed' && safeVideoUrl(item.videoUrl)"
:src="safeVideoUrl(item.videoUrl) || undefined"
controls
preload="metadata"
class="production-video"
></video>
<div class="min-w-0 flex-1 p-4">
<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.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
>
</div>
<NAlert v-if="item.error" role="alert" type="error" :show-icon="false" class="mt-3">{{
item.error
}}</NAlert>
<p class="mt-3 break-all text-[10px] text-muted">
{{ 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="sessionVideoValidation(projectId, item.shotId, item.id)">
·
{{
sessionVideoValidation(projectId, item.shotId, item.id)?.passed
? '曾通过'
: '未通过'
}}</template
></NButton
>
<a
v-if="safeVideoUrl(item.videoUrl)"
:href="safeVideoUrl(item.videoUrl) || undefined"
target="_blank"
rel="noopener noreferrer"
class="text-button"
>打开视频<ExternalLink :size="12"
/></a>
<NButton
v-if="
item.status === 'completed' &&
item.videoUrl &&
!item.isPrimary &&
sessionVideoValidation(projectId, item.shotId, item.id)?.passed === true
"
:disabled="assetBlocked"
@click="confirmingVideo = item.id"
text
size="small"
>
设为主视频
</NButton>
</div>
<NAlert v-if="confirmingVideo === item.id" type="info" :show-icon="false" class="mt-3 text-xs"
><p>确认把此候选设为当前镜头主视频?旧成片仍会保留。</p>
<div class="mt-2 flex gap-3">
<NButton @click="setPrimaryVideo" type="primary">确认切换</NButton>
<NButton @click="confirmingVideo = ''" text size="small">取消</NButton>
</div></NAlert
>
</div>
</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="
videoStale ? '重新生成质量视频' : currentVideo ? '再生成一个质量候选' : '提交本镜视频质量任务'
"
:disabled="!canGenerateVideo"
acknowledgement
:description="
videoStale
? '使用当前视频提示词、主首帧和主体参考图启动严格质量流水线;最多自动修复 2 次,通过后才接替当前过期主视频。'
: currentVideo
? '新增一个严格质量候选;后端会自动校验并最多修复 2 次,通过后才替换主视频。'
: '启动严格视频质量流水线;任务在后台生成、校验并有限修复,通过后才晋升主视频。'
"
primary
@confirm="generateVideo"
/>
<NButton v-if="activeVideo" :disabled="assetBlocked" @click="refreshActiveVideo"
><RefreshCw :size="14" />刷新任务状态
</NButton>
</div>
</div>
<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" />
<ProviderInputInspector :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">
业务主首帧 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
></NCollapse
>
</AppDialog>
<QualityDialog
v-model:open="qualityOpen"
:project-id="projectId"
:target="qualityTarget"
:disabled="assetBlocked"
@changed="refreshAll"
/>
<KeyframeDialog
v-model:open="keyframeOpen"
:shot-id="shot.shotId"
:shot-title="shot.title"
:keyframes="keyframes"
:replace-primary="keyframeStale"
:disabled="!canGenerateKeyframe"
@generate="generateKeyframe"
/>
</section>
</template>
<style>
/* 本组件专属布局与 Naive 内部结构覆盖。 */
@reference "../../../styles/styles.css";
.production-stage {
@apply py-5 px-0;
}
.production-stage:first-of-type {
@apply border-t-0;
}
.production-stage-heading {
@apply flex items-start justify-between gap-4;
}
.keyframe-grid {
@apply grid grid-cols-[repeat(3,_minmax(0,_1fr))] gap-3;
}
.keyframe-card {
@apply overflow-hidden rounded-none bg-(--app-subtle);
}
.keyframe-card .asset-image {
@apply aspect-video rounded-none;
}
.video-record {
@apply flex overflow-hidden rounded-none bg-(--app-subtle);
}
.production-video {
@apply w-[min(44%,390px)] min-h-[170px] bg-ink object-contain;
}
@media (max-width: 760px) {
.keyframe-grid {
@apply grid-cols-[repeat(2,_minmax(0,_1fr))];
}
.video-record {
@apply block;
}
.production-video {
@apply w-full;
}
}
.production-video {
@apply bg-[#080808];
}
</style>