feat: 同步镜头首帧与视频生产工作区
This commit is contained in:
@@ -0,0 +1,412 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ExternalLink, FileSearch, ImagePlus, RefreshCw } from '@lucide/vue'
|
||||
import { AssetImage, StatusBadge } from '../../../components/ui'
|
||||
import { usePolling } from '../../../composables/usePolling'
|
||||
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'
|
||||
|
||||
/** 单镜头生产面板只负责正式资产,不修改上游导演设计与即时状态。 */
|
||||
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 = usePolling(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 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 && !!props.keyframeReadiness && props.keyframeReadiness.issues.length === 0
|
||||
)
|
||||
const canGenerateVideo = computed(
|
||||
() =>
|
||||
!assetBlocked.value && !activeVideo.value && !!props.videoReadiness && props.videoReadiness.issues.length === 0
|
||||
)
|
||||
|
||||
watch(key, () => {
|
||||
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.id !== shotId || !result.videoPrompt) throw new Error('接口未返回有效视频提示词。')
|
||||
})
|
||||
await refreshAll()
|
||||
}
|
||||
|
||||
/** 调用 Seedream 生成单镜头首帧;成功后重新读取候选和主图。 */
|
||||
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) 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()
|
||||
}
|
||||
|
||||
/** 创建单镜头 Seedance 视频任务;任务完成由后台轮询确认。 */
|
||||
async function generateVideo() {
|
||||
if (!canGenerateVideo.value) return
|
||||
const shotId = props.shot.shotId
|
||||
await runOperation(props.projectId, `提交镜头 ${props.shot.shotNo} 视频任务`, async () => {
|
||||
const result = await productionApi.generateVideo(shotId)
|
||||
if (!result || result.shotId !== shotId || !isActiveVideo(result))
|
||||
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 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() {
|
||||
inspecting.value = true
|
||||
specError.value = ''
|
||||
const shotId = props.shot.shotId
|
||||
const [keyframeResult, videoResult] = await Promise.allSettled([
|
||||
productionApi.keyframeSpec(shotId),
|
||||
productionApi.videoGenerationSpec(shotId)
|
||||
])
|
||||
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>
|
||||
<p class="mt-1 text-[11px] text-muted">记录每次候选与失败,主资产决定下一阶段实际输入。</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button class="text-button" :disabled="inspecting" @click="inspectSpecs">
|
||||
<FileSearch :size="13" />{{ inspecting ? '检查中…' : '检查生成规格' }}
|
||||
</button>
|
||||
<button class="text-button" :disabled="query.loading.value" @click="refreshAll">
|
||||
<RefreshCw :size="13" :class="{ 'animate-spin': query.loading.value }" />刷新资产
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="query.error.value" class="alert alert-error mt-4" role="alert">
|
||||
{{ query.error.value }} 当前保留上次成功读取的资产,操作已暂停。
|
||||
</p>
|
||||
<p v-if="specError" class="alert alert-error mt-4" role="alert">{{ specError }}</p>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
<p class="eyebrow">02 · KEYFRAME</p>
|
||||
<h5 class="mt-2 text-sm font-medium">主首帧与候选图</h5>
|
||||
</div>
|
||||
<StatusBadge
|
||||
v-if="keyframeReadiness"
|
||||
:status="keyframeReadiness.status"
|
||||
:label="productionStatusLabel(keyframeReadiness.status)"
|
||||
/>
|
||||
</div>
|
||||
<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} 首帧`"
|
||||
: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)" />
|
||||
<span v-if="item.isPrimary" class="tag">主首帧</span>
|
||||
<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>
|
||||
<button
|
||||
v-if="item.status === 'completed' && item.imageUrl && !item.isPrimary"
|
||||
class="text-button mt-2"
|
||||
:disabled="assetBlocked"
|
||||
@click="confirmingKeyframe = item.id"
|
||||
>
|
||||
设为主首帧
|
||||
</button>
|
||||
<div v-if="confirmingKeyframe === item.id" class="alert mt-2 text-xs">
|
||||
<p>确认让后续视频使用此首帧?已有视频不会自动重生成。</p>
|
||||
<div class="mt-2 flex gap-3">
|
||||
<button class="button button-primary" @click="setPrimaryKeyframe">确认切换</button>
|
||||
<button class="text-button" @click="confirmingKeyframe = ''">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<p v-else class="mt-4 text-xs text-muted">尚无首帧记录。</p>
|
||||
<button class="button button-secondary mt-4" :disabled="!canGenerateKeyframe" @click="keyframeOpen = true">
|
||||
<ImagePlus :size="14" />{{ currentKeyframe ? '再生成一张候选' : '生成首帧' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
<StatusBadge
|
||||
v-if="videoReadiness"
|
||||
:status="videoReadiness.status"
|
||||
:label="productionStatusLabel(videoReadiness.status)"
|
||||
/>
|
||||
</div>
|
||||
<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)" />
|
||||
<span v-if="item.isPrimary" class="tag">主视频</span>
|
||||
<span class="text-[10px] text-muted"
|
||||
>{{ item.durationSeconds || shot.durationSeconds || '—' }}s</span
|
||||
>
|
||||
</div>
|
||||
<p v-if="item.error" class="alert alert-error mt-3" role="alert">{{ item.error }}</p>
|
||||
<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">
|
||||
<a
|
||||
v-if="safeVideoUrl(item.videoUrl)"
|
||||
:href="safeVideoUrl(item.videoUrl) || undefined"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-button"
|
||||
>打开视频<ExternalLink :size="12"
|
||||
/></a>
|
||||
<button
|
||||
v-if="item.status === 'completed' && item.videoUrl && !item.isPrimary"
|
||||
class="text-button"
|
||||
:disabled="assetBlocked"
|
||||
@click="confirmingVideo = item.id"
|
||||
>
|
||||
设为主视频
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="confirmingVideo === item.id" class="alert mt-3 text-xs">
|
||||
<p>确认把此候选设为当前镜头主视频?旧成片仍会保留。</p>
|
||||
<div class="mt-2 flex gap-3">
|
||||
<button class="button button-primary" @click="setPrimaryVideo">确认切换</button>
|
||||
<button class="text-button" @click="confirmingVideo = ''">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<p v-else class="mt-4 text-xs text-muted">尚无视频任务记录。</p>
|
||||
<div class="mt-4 flex flex-wrap gap-3">
|
||||
<ConfirmAction
|
||||
:label="currentVideo ? '再生成一个视频候选' : '提交本镜视频任务'"
|
||||
:disabled="!canGenerateVideo"
|
||||
acknowledgement
|
||||
:description="
|
||||
currentVideo
|
||||
? '使用当前视频提示词、主首帧和主体参考图新增一个 Seedance 视频候选,不自动替换主视频。'
|
||||
: '使用当前视频提示词、主首帧和主体参考图创建 Seedance 任务;任务会在后台继续运行。'
|
||||
"
|
||||
primary
|
||||
@confirm="generateVideo"
|
||||
/>
|
||||
<button
|
||||
v-if="activeVideo"
|
||||
class="button button-secondary"
|
||||
:disabled="assetBlocked"
|
||||
@click="refreshActiveVideo"
|
||||
>
|
||||
<RefreshCw :size="14" />刷新任务状态
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details v-if="keyframeSpec || videoSpec" class="mt-5 border-t border-line pt-4 text-xs">
|
||||
<summary class="cursor-pointer font-medium">本镜确定性生成规格</summary>
|
||||
<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>
|
||||
</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>
|
||||
</details>
|
||||
<KeyframeDialog
|
||||
v-model:open="keyframeOpen"
|
||||
:shot-id="shot.shotId"
|
||||
:shot-title="shot.title"
|
||||
:keyframes="keyframes"
|
||||
:disabled="!canGenerateKeyframe"
|
||||
@generate="generateKeyframe"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
Reference in New Issue
Block a user