142 lines
5.7 KiB
Vue
142 lines
5.7 KiB
Vue
<script setup lang="ts">
|
||
import { onScopeDispose, ref, watch } from 'vue'
|
||
import { NAlert, NButton, NCollapse, NCollapseItem, NProgress, NSpin } from 'naive-ui'
|
||
import { mediaAssetUrl } from '../../../lib/assets'
|
||
import { errorMessage } from '../../../lib/http'
|
||
import { productionApi } from '../api'
|
||
import type { EpisodeAssemblyResult, ProjectAssemblyReadiness } from '../types'
|
||
|
||
const props = defineProps<{ projectId: string }>()
|
||
const readiness = ref<ProjectAssemblyReadiness | null>(null)
|
||
const loading = ref(false)
|
||
const assembling = ref('')
|
||
const error = ref('')
|
||
const results = ref<Record<string, EpisodeAssemblyResult>>({})
|
||
let controller: AbortController | null = null
|
||
|
||
async function refresh() {
|
||
controller?.abort()
|
||
controller = new AbortController()
|
||
loading.value = true
|
||
error.value = ''
|
||
try {
|
||
readiness.value = await productionApi.assemblyReadiness(props.projectId, controller.signal)
|
||
} catch (cause) {
|
||
if (!controller.signal.aborted) error.value = errorMessage(cause)
|
||
} finally {
|
||
if (!controller.signal.aborted) loading.value = false
|
||
}
|
||
}
|
||
|
||
async function assemble(episodeId: string) {
|
||
if (assembling.value) return
|
||
assembling.value = episodeId
|
||
error.value = ''
|
||
try {
|
||
const result = await productionApi.assembleEpisode(episodeId)
|
||
results.value = { ...results.value, [episodeId]: result }
|
||
await refresh()
|
||
} catch (cause) {
|
||
error.value = errorMessage(cause)
|
||
} finally {
|
||
assembling.value = ''
|
||
}
|
||
}
|
||
|
||
function resultUrl(episodeId: string) {
|
||
const result = results.value[episodeId]
|
||
return result ? mediaAssetUrl(result.videoUrl) : null
|
||
}
|
||
|
||
watch(
|
||
() => props.projectId,
|
||
() => {
|
||
results.value = {}
|
||
void refresh()
|
||
},
|
||
{ immediate: true }
|
||
)
|
||
onScopeDispose(() => controller?.abort())
|
||
</script>
|
||
|
||
<template>
|
||
<NCollapse class="production-tool-section">
|
||
<NCollapseItem name="episode-assembly" title="单集成片拼接">
|
||
<NSpin :show="loading">
|
||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||
<p class="text-xs text-muted">
|
||
按 Beat 和 Shot 顺序拼接当前有效主视频。V1 只输出无声视频,不包含对白、配乐、字幕和转场。
|
||
</p>
|
||
<NButton size="small" :disabled="loading" @click="refresh">刷新就绪状态</NButton>
|
||
</div>
|
||
<NAlert v-if="error" type="error" :show-icon="false" class="mt-3">{{ error }}</NAlert>
|
||
<p v-if="readiness" class="mt-3 text-xs">
|
||
已就绪 {{ readiness.ready }}/{{ readiness.total }} 集 · 阻塞 {{ readiness.blocked }} 集
|
||
</p>
|
||
<div v-if="readiness?.items.length" class="assembly-list mt-3">
|
||
<article v-for="item in readiness.items" :key="item.episodeId" class="assembly-row">
|
||
<div class="min-w-0">
|
||
<h4 class="truncate text-sm font-semibold">
|
||
第 {{ item.episodeNo }} 集 · {{ item.title }}
|
||
</h4>
|
||
<p class="mt-1 text-xs text-muted">
|
||
主视频 {{ item.readyShots }}/{{ item.totalShots }} · {{ item.totalDurationSeconds }} 秒
|
||
</p>
|
||
<NProgress
|
||
class="mt-2"
|
||
type="line"
|
||
:show-indicator="false"
|
||
:percentage="
|
||
item.totalShots ? Math.round((item.readyShots / item.totalShots) * 100) : 0
|
||
"
|
||
:status="item.ready ? 'success' : 'default'"
|
||
/>
|
||
<p
|
||
v-for="(issue, index) in item.issues.slice(0, 3)"
|
||
:key="index"
|
||
class="mt-1 text-xs text-muted"
|
||
>
|
||
<span v-if="issue.shotNo">镜头 {{ issue.shotNo }}:</span>{{ issue.reason }}
|
||
</p>
|
||
</div>
|
||
<div class="flex shrink-0 flex-wrap items-center gap-2">
|
||
<NButton
|
||
size="small"
|
||
:disabled="!item.ready || !!assembling"
|
||
:loading="assembling === item.episodeId"
|
||
@click="assemble(item.episodeId)"
|
||
>生成无声成片</NButton
|
||
>
|
||
<NButton
|
||
v-if="resultUrl(item.episodeId)"
|
||
tag="a"
|
||
size="small"
|
||
:href="resultUrl(item.episodeId) || undefined"
|
||
target="_blank"
|
||
rel="noopener"
|
||
>查看成片</NButton
|
||
>
|
||
</div>
|
||
</article>
|
||
</div>
|
||
<p v-else-if="readiness && !loading" class="mt-3 text-xs text-muted">当前项目还没有可拼接的剧集。</p>
|
||
</NSpin>
|
||
</NCollapseItem>
|
||
</NCollapse>
|
||
</template>
|
||
|
||
<style scoped>
|
||
@reference "../../../styles/styles.css";
|
||
.assembly-list {
|
||
@apply grid gap-2;
|
||
}
|
||
.assembly-row {
|
||
@apply grid grid-cols-[minmax(0,_1fr)_auto] items-center gap-4 p-4 bg-(--app-subtle);
|
||
}
|
||
@media (max-width: 760px) {
|
||
.assembly-row {
|
||
@apply grid-cols-[minmax(0,_1fr)];
|
||
}
|
||
}
|
||
</style>
|