Add local queued video upscale with Real-ESRGAN and RIFE

This commit is contained in:
Towsty
2026-09-08 17:55:02 -05:00
parent 4c97980646
commit 1e0cff9dea
24 changed files with 747 additions and 16 deletions
+8
View File
@@ -20,6 +20,13 @@
<p v-else-if="loading" class="mt-4 text-sm text-zinc-400">Loading job…</p>
<div v-if="job" class="mt-5 space-y-4">
<div v-if="job.payload.upscale" class="text-sm text-zinc-300">
<p>Local video upscale · {{ job.payload.upscale.scale }}x · {{ job.payload.upscale.target }} · FPS {{ job.payload.upscale.fps }} · {{ job.payload.upscale.enhance }}</p>
<p class="mt-2">View progress and the result on the source clip. Cancel this queue item to choose different settings.</p>
<NuxtLink :to="`/?clip=${job.payload.upscale.sourceId}`" class="text-amber-200 underline" @click="close">Open source clip</NuxtLink>
<p v-if="job.lastError" class="text-red-300">{{ job.lastError }}</p>
</div>
<template v-else>
<div v-if="thumbUrl" class="overflow-hidden rounded-2xl border border-white/10 bg-black/40">
<img :src="thumbUrl" alt="" class="max-h-56 w-full object-contain">
</div>
@@ -162,6 +169,7 @@
<button v-if="editable" type="button" class="rounded-full bg-amber-400 px-3 py-1.5 text-xs font-semibold text-zinc-950 disabled:opacity-50" :disabled="busy" @click="saveChanges">Save changes</button>
<button type="button" class="rounded-full border border-sky-300/40 px-3 py-1.5 text-xs text-sky-100 hover:border-sky-200" @click="loadAway">{{ job.kind === 'music' ? 'Load into Music' : 'Load into Studio' }}</button>
</div>
</template>
</div>
</div>
</div>
+69
View File
@@ -0,0 +1,69 @@
<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>