604 lines
29 KiB
Vue
604 lines
29 KiB
Vue
<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>
|