Files
aigen/components/VideoUpscaleControl.vue
T

70 lines
5.8 KiB
Vue
Raw 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>
<div v-if="clipId" class="space-y-2">
<button type="button" class="rounded-2xl border border-amber-300/40 px-4 py-2 text-sm text-amber-100 disabled:opacity-50" :disabled="busy || loading || !state" @click="openDialog">Upscale</button>
<NuxtLink v-if="state?.source.upscaledFromClipId" :to="`/?clip=${state.source.upscaledFromClipId}`" class="block text-xs text-amber-200 underline">Upscaled copy · Open original</NuxtLink>
<div v-if="state?.job" class="max-w-sm text-xs text-zinc-400" aria-live="polite">
<span class="capitalize">Upscale {{ state.job.status }}</span>
<span v-if="state.job.width"> · {{ state.job.width }} × {{ state.job.height }}</span>
<span v-if="state.job.elapsedMs"> · {{ Math.round(state.job.elapsedMs / 1000) }}s</span>
<progress v-if="busy" class="mt-1 block h-1 w-full accent-amber-400" max="100" :value="state.job.progress || 0" />
<p v-if="state.job.error" class="text-red-300">{{ state.job.error }}</p>
<p v-else-if="busy">{{ state.job.message }}</p>
<NuxtLink v-if="state.job.outputClipId" :to="`/?clip=${state.job.outputClipId}`" class="text-amber-200 underline">Open upscaled clip</NuxtLink>
</div>
<p v-if="error && !dialogOpen" role="alert" class="text-xs text-red-300">{{ error }}</p>
<dialog ref="dialog" class="w-[min(92vw,28rem)] rounded-3xl border border-white/15 bg-zinc-950 p-6 text-zinc-100 backdrop:bg-black/70" @close="dialogOpen = false">
<form @submit.prevent="submit">
<h2 class="text-xl font-semibold">Upscale video</h2>
<p class="mt-1 text-sm text-zinc-400">Process locally on your GPU. The original stays in your library.</p>
<div class="my-5 grid grid-cols-2 gap-4">
<label class="text-sm">Scale<select v-model="form.scale" class="mt-1 w-full rounded-xl bg-zinc-800 p-2"><option :value="2">2x</option><option :value="4">4x</option></select></label>
<label class="text-sm">Target<select v-model="form.target" class="mt-1 w-full rounded-xl bg-zinc-800 p-2"><option value="preserve">Preserve aspect</option><option value="1080p">Fit 1080p</option><option value="4k">Fit 4K</option></select></label>
<label class="text-sm">FPS<select v-model="form.fps" class="mt-1 w-full rounded-xl bg-zinc-800 p-2"><option value="keep">Keep original</option><option v-for="fps in [48, 50, 60]" :key="fps" :value="fps">{{ fps }}</option></select></label>
<label class="text-sm">Enhance<select v-model="form.enhance" class="mt-1 w-full rounded-xl bg-zinc-800 p-2"><option value="faithful">Faithful</option><option value="sharpen">Slight sharpen</option></select></label>
</div>
<p class="text-sm">{{ resolution }} <span class="text-zinc-500">estimated · original aspect</span></p>
<p class="mt-1 break-all text-xs text-zinc-400">{{ outputName }}</p>
<p class="mt-2 text-xs text-zinc-500">First use downloads the local tools and weights. Fit uses a 1920 / 3840 pixel long edge, with no cropping or padding.</p>
<p v-if="error" role="alert" class="mt-3 text-sm text-red-300">{{ error }}</p>
<div class="mt-5 flex justify-end gap-3">
<button type="button" class="rounded-full border border-white/15 px-4 py-2" @click="dialog?.close()">Close</button>
<button type="submit" class="rounded-full bg-amber-400 px-4 py-2 font-semibold text-zinc-950 disabled:opacity-50" :disabled="busy || submitting">{{ submitting ? 'Queuing…' : 'Upscale' }}</button>
</div>
</form>
</dialog>
</div>
</template>
<script setup lang="ts">
import { upscaleOptions, upscaleDimensions, upscaleName } from '~/shared/video-upscale.mjs'
const props = defineProps<{ clipId: string; generationBusy?: boolean }>()
const emit = defineEmits<{ complete: [] }>()
const dialog = ref<HTMLDialogElement>(), dialogOpen = ref(false), state = ref<any>(null), error = ref(''), loading = ref(true), submitting = ref(false)
const form = reactive(upscaleOptions())
const busy = computed(() => props.generationBusy || state.value?.busy)
const outputName = computed(() => upscaleName(state.value?.source.stem || 'video', form.scale))
const resolution = computed(() => { try { const size = upscaleDimensions(state.value.source.width, state.value.source.height, form); return `${size.width} × ${size.height}` } catch { return 'Resolution checked before processing' } })
let timer: ReturnType<typeof setTimeout> | undefined, disposed = false
async function refresh() {
const id = props.clipId
if (!id) return
try {
const result = await $fetch<any>(`/api/library/clips/${encodeURIComponent(id)}/upscale`)
if (disposed || id !== props.clipId) return
if (state.value?.job?.status !== 'done' && result.job?.status === 'done') emit('complete')
state.value = result; error.value = ''
} catch (e: any) { if (id === props.clipId) error.value = e?.data?.statusMessage || 'Could not check local upscale status.' }
finally { if (id === props.clipId) loading.value = false }
}
function openDialog() { error.value = ''; dialog.value?.showModal(); dialogOpen.value = true }
async function submit() {
if (busy.value || submitting.value) return
submitting.value = true; error.value = ''
try { await $fetch(`/api/library/clips/${encodeURIComponent(props.clipId)}/upscale`, { method: 'POST', body: { ...form } }); await refresh(); dialog.value?.close() }
catch (e: any) { error.value = e?.data?.statusMessage || 'Could not queue upscale.' }
finally { submitting.value = false }
}
watch(() => props.clipId, () => { state.value = null; loading.value = true; Object.assign(form, upscaleOptions()); dialog.value?.close(); void refresh() })
onMounted(() => { const poll = async () => { await refresh(); if (!disposed) timer = setTimeout(poll, state.value?.busy ? 2000 : 10000) }; void poll() })
onBeforeUnmount(() => { disposed = true; clearTimeout(timer) })
</script>