Files

138 lines
12 KiB
Vue
Raw Permalink 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.
<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>