138 lines
12 KiB
Vue
138 lines
12 KiB
Vue
<template>
|
||
<section class="hd-studio">
|
||
<header class="hd-header">
|
||
<div><h2>高清制作 <span>MiniMax H3-VDN-H3</span></h2>
|
||
<p>依据原分镜与视频参考画面重新生成,动作细节可能变化。原版始终保留,选定版本后再拼接导出。</p></div>
|
||
<button class="btn" :disabled="loading" @click="load">刷新</button>
|
||
</header>
|
||
<p v-if="error" class="hd-error" role="alert">{{ error }}</p>
|
||
<p v-if="notice" class="hd-notice" role="status">{{ notice }}</p>
|
||
<p v-if="!configured && !loading" class="hd-error">请在 AI 服务中启用 MiniMax H3-VDN-H3 高清模型。</p>
|
||
<div v-if="!items.length && !loading" class="hd-empty">先完成分镜与视频制作,再到这里制作高清版。</div>
|
||
<div class="hd-workspace" v-else>
|
||
<nav class="hd-shots" aria-label="选择分镜">
|
||
<button v-for="item in items" :key="item.id" :class="{ active: selectedId === item.id }" @click="selectedId = item.id">
|
||
<strong>分镜 {{ item.order }} · {{ item.title || '未命名' }}</strong>
|
||
<span>{{ item.busy ? '制作中' : item.original ? `${item.versions.filter(v => v.status === 'completed').length} 个高清版本` : '等待原视频' }}</span>
|
||
</button>
|
||
</nav>
|
||
<div v-if="selected" class="hd-detail">
|
||
<div class="hd-controls">
|
||
<label>高清模型 <select v-model="configId" aria-label="高清模型" :disabled="submitting"><option v-for="c in configs" :key="c.id" :value="c.id">{{ c.name }}</option></select></label>
|
||
<label>当前镜头显卡 <select v-model="gpu" aria-label="高清生成显卡" :disabled="submitting"><option value="">跟随模型({{ configGpuLabel }})</option><option value="auto">自动分配</option><option value="gpu1">GPU1 · ComfyUI 8189</option><option value="gpu2">GPU2 · ComfyUI 8188</option><option value="gpu3">GPU3 · ComfyUI 8191</option></select></label>
|
||
<label>目标分辨率 <select v-model="resolution"><option value="2K">2K · 2560 × 1440</option><option value="4K">4K · 3840 × 2160</option></select></label>
|
||
<button class="btn btn-primary" :disabled="!configured || !selected.original || submitting" @click="generate">{{ submitting ? '正在处理,请稍候…' : selected.busy ? `取消并换卡重新生成 ${resolution}` : `生成 ${resolution} 高清版` }}</button>
|
||
</div>
|
||
<p class="hd-hint">以上选择仅用于当前镜头。{{ selected.activeTask ? routingLabel(selected.activeTask) : '尚未提交高清任务' }}</p>
|
||
<TaskProgress :task="selected.activeTask" />
|
||
<p class="hd-hint">支持横屏 16:9 与竖屏 9:16,竖屏自动交换宽高。4K 制作耗时较长。拼接采用所选视频中的最高分辨率;未重制的低清镜头只会缩放,不会增加真实细节。</p>
|
||
<div class="hd-compare">
|
||
<article><div class="hd-card-head"><h3>原版</h3><span v-if="selected.current === selected.original">当前用于导出</span></div>
|
||
<video v-if="selected.original" :key="selected.original" :src="media(selected.original)" controls preload="metadata" />
|
||
<div v-else class="hd-empty">该分镜还没有完成的视频</div>
|
||
<button v-if="selected.original" class="btn" :disabled="selected.busy || applying || selected.current === selected.original" @click="chooseOriginal">使用原版导出</button>
|
||
</article>
|
||
<article><div class="hd-card-head"><h3>{{ preview?.resolution || '高清版' }}</h3><span v-if="preview?.url && selected.current === preview.url">当前用于导出</span></div>
|
||
<video v-if="preview?.url" :key="preview.url" :src="media(preview.url)" :poster="poster(preview.url)" controls preload="metadata" />
|
||
<div v-else class="hd-empty">{{ selected.busy ? '高清任务已提交,完成后显示在这里' : '生成后可在这里与原版对比' }}</div>
|
||
<button v-if="preview?.url" class="btn btn-primary" :disabled="selected.busy || applying || selected.current === preview.url" @click="choose(preview.id)">使用此高清版导出</button>
|
||
</article>
|
||
</div>
|
||
<div class="hd-history"><h3>高清版本记录</h3>
|
||
<p v-if="!selected.versions.length" class="hd-hint">尚未生成高清版本</p>
|
||
<button v-for="version in [...selected.versions].reverse()" :key="version.id" :class="{ active: preview?.id === version.id }" @click="previewId = version.id">
|
||
<strong>{{ version.resolution }} · {{ routingLabel(version) }} · #{{ version.id }}</strong><span>{{ statusLabel(version.status) }} · {{ new Date(version.createdAt).toLocaleString('zh-CN') }}</span>
|
||
<small v-if="version.error">{{ version.error }}</small>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { api } from '~/composables/useApi'
|
||
interface Routing { gpu: string; port: number | null; legacy: boolean }
|
||
interface Version { id: number; status: string; resolution: string; gpu?: string; routing?: Routing; url: string | null; error: string | null; createdAt: string }
|
||
interface Shot { id: number; order: number; title: string; original: string | null; current: string | null; busy: boolean; busyTaskId?: number; activeTask?: { id: number; model: string; configId: number; gpu?: string; routing?: Routing }; versions: Version[] }
|
||
const props = defineProps<{ episodeId: number }>()
|
||
const emit = defineEmits<{ selected: [] }>()
|
||
const items = ref<Shot[]>([])
|
||
const configured = ref(false)
|
||
const choices = ref<Record<number, { configId?: number; gpu?: string; resolution?: string }>>({})
|
||
function chooseSettings(patch: { configId?: number; gpu?: string; resolution?: string }) {
|
||
if (selectedId.value) choices.value = { ...choices.value, [selectedId.value]: { ...choices.value[selectedId.value], ...patch } }
|
||
}
|
||
const gpu = computed({ get: () => selectedId.value ? choices.value[selectedId.value]?.gpu ?? selected.value?.activeTask?.gpu ?? '' : '', set: gpu => chooseSettings({ gpu }) })
|
||
const configs = ref<{ id: number; name: string; gpu: string }[]>([])
|
||
const configId = computed({ get: () => (selectedId.value ? choices.value[selectedId.value]?.configId : undefined) || configs.value.find(c => c.id === selected.value?.activeTask?.configId)?.id || configs.value[0]?.id, set: configId => chooseSettings({ configId, gpu: '' }) })
|
||
const configGpuLabel = computed(() => { const g = configs.value.find(c => c.id === configId.value)?.gpu || 'auto'; return g === 'auto' ? '自动分配' : g.toUpperCase() })
|
||
const selectedId = ref<number>()
|
||
const previewId = ref<number>()
|
||
const resolution = computed({ get: () => selectedId.value ? choices.value[selectedId.value]?.resolution || '2K' : '2K', set: resolution => chooseSettings({ resolution }) })
|
||
const error = ref('')
|
||
const notice = ref('')
|
||
const loading = ref(false)
|
||
const submitting = ref(false)
|
||
const applying = ref(false)
|
||
const selected = computed(() => items.value.find(s => s.id === selectedId.value))
|
||
const preview = computed(() => selected.value?.versions.find(v => v.id === previewId.value) || [...(selected.value?.versions || [])].reverse().find(v => v.status === 'completed'))
|
||
const media = (url: string) => url.startsWith('static/') ? `/${url}` : url
|
||
const poster = (url: string) => media(url).replace(/\.[^./]+$/, '_poster.jpg')
|
||
const statusLabel = (s: string) => ({ completed: '已完成', processing: '制作中', failed: '失败' }[s] || s)
|
||
const routingLabel = (task: { routing?: Routing; model?: string }) => `${task.routing ? `${task.routing.gpu.toUpperCase()} · ${task.routing.legacy ? '旧节点' : `ComfyUI ${task.routing.port}`}` : '等待节点确认'} · ${task.model || 'MiniMax-H3-VDN-H3'}`
|
||
async function load() {
|
||
if (!props.episodeId || loading.value) return
|
||
loading.value = true
|
||
try {
|
||
const data = await api.get<{ configured: boolean; configs: typeof configs.value; items: Shot[] }>(`/hd/episodes/${props.episodeId}`)
|
||
items.value = data.items.sort((a, b) => a.order - b.order)
|
||
configured.value = data.configured
|
||
configs.value = data.configs
|
||
if (!configs.value.some(c => c.id === configId.value)) configId.value = configs.value[0]?.id
|
||
if (!items.value.some(s => s.id === selectedId.value)) selectedId.value = items.value.find(s => s.original)?.id || items.value[0]?.id
|
||
} catch (e) { error.value = e instanceof Error ? e.message : '读取高清任务失败' }
|
||
finally { loading.value = false }
|
||
}
|
||
async function generate() {
|
||
if (!selected.value || submitting.value) return
|
||
submitting.value = true; error.value = ''; notice.value = ''
|
||
try {
|
||
await api.post(`/hd/storyboards/${selected.value.id}`, { resolution: resolution.value, config_id: configId.value,
|
||
gpu: gpu.value || undefined, replace_task_id: selected.value.busy ? selected.value.busyTaskId : undefined })
|
||
notice.value = '高清任务已提交。原视频保持不变,完成后可选择用于导出的版本。'
|
||
await load()
|
||
} catch (e) { error.value = e instanceof Error ? e.message : '提交失败' }
|
||
finally { submitting.value = false }
|
||
}
|
||
async function selectVersion(body: { original?: boolean; task_id?: number }) {
|
||
if (!selected.value) return
|
||
applying.value = true; error.value = ''
|
||
try { await api.post(`/hd/storyboards/${selected.value.id}/select`, body); await load(); emit('selected'); notice.value = '导出版本已切换,请在拼接导出页重新拼接成片。' }
|
||
catch (e) { error.value = e instanceof Error ? e.message : '切换失败' }
|
||
finally { applying.value = false }
|
||
}
|
||
const chooseOriginal = () => selectVersion({ original: true })
|
||
const choose = (id: number) => selectVersion({ task_id: id })
|
||
let timer: ReturnType<typeof setInterval> | undefined
|
||
onMounted(() => { load(); timer = setInterval(load, 10000) })
|
||
onUnmounted(() => clearInterval(timer))
|
||
watch(() => props.episodeId, () => { items.value = []; selectedId.value = undefined; previewId.value = undefined; load() })
|
||
</script>
|
||
|
||
<style scoped>
|
||
.hd-studio{padding:24px;overflow:auto;height:100%;color:var(--text-1)}
|
||
.hd-header,.hd-controls,.hd-card-head{display:flex;align-items:center;justify-content:space-between;gap:16px}
|
||
h2{font-size:20px;margin:0}h2 span{font-size:12px;color:var(--accent-text);margin-left:12px}h3{font-size:14px;margin:0}
|
||
.hd-header p,.hd-hint{font-size:12px;line-height:1.7;color:var(--text-3)}
|
||
.hd-workspace{display:grid;grid-template-columns:210px minmax(0,1fr);gap:24px;margin-top:24px}
|
||
.hd-shots,.hd-history{display:flex;flex-direction:column;gap:8px}.hd-shots button,.hd-history button{background:var(--surface-1);color:var(--text-1);border:1px solid var(--border);border-radius:10px;padding:14px;text-align:left;cursor:pointer}
|
||
.hd-shots button.active,.hd-history button.active{border-color:var(--accent);background:var(--sel-bg)}
|
||
.hd-shots span,.hd-history span,.hd-history small{display:block;margin-top:6px;font-size:12px;color:var(--text-3)}
|
||
.hd-controls{justify-content:flex-start;flex-wrap:wrap}.hd-controls label{font-size:12px}.hd-controls select{margin-left:8px;padding:8px;border:1px solid var(--border);border-radius:6px;background:var(--surface-1);color:var(--text-1)}
|
||
.hd-compare{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin:20px 0}.hd-compare article{padding:16px;border:1px solid var(--border);border-radius:12px;min-width:0}.hd-card-head{margin-bottom:14px}.hd-card-head span{font-size:11px;color:var(--accent-text)}
|
||
video{width:100%;height:300px;object-fit:contain;background:#111;border-radius:8px;margin-bottom:14px}.hd-empty{padding:45px 15px;text-align:center;color:var(--text-3);font-size:13px;line-height:1.8}
|
||
.hd-error{color:var(--warning);font-size:13px}.hd-notice{color:var(--success);font-size:13px}.hd-history h3{margin:8px 0}
|
||
@media(max-width:1050px){.hd-workspace{grid-template-columns:1fr}.hd-shots{flex-direction:row;overflow:auto}.hd-shots button{min-width:160px}.hd-compare{grid-template-columns:1fr}}
|
||
</style>
|