Let queued and player extensions use the full duration slider.
Stop clamping extensions to 3s or 5s so each segment can use the same length control as generate.
This commit is contained in:
@@ -0,0 +1,110 @@
|
|||||||
|
<template>
|
||||||
|
<div class="text-sm">
|
||||||
|
<span v-if="label" class="mb-1 block text-zinc-400">{{ label }}</span>
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
v-model="draft"
|
||||||
|
type="text"
|
||||||
|
inputmode="decimal"
|
||||||
|
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 pr-8 cursor-ns-resize outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:cursor-text focus:ring-2"
|
||||||
|
:class="{ 'select-none caret-transparent': scrubbing }"
|
||||||
|
placeholder="seconds"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
@blur="commit"
|
||||||
|
@keydown.enter.prevent="commit"
|
||||||
|
@pointerdown="onPointerDown"
|
||||||
|
>
|
||||||
|
<span class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-xs text-zinc-500">s</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
class="aigen-slider mt-3 w-full"
|
||||||
|
type="range"
|
||||||
|
:min="SLIDER_MIN"
|
||||||
|
:max="SLIDER_MAX"
|
||||||
|
:step="0.1"
|
||||||
|
:value="slider"
|
||||||
|
:style="{ background: `linear-gradient(to right, #fbbf24 ${sliderPct}%, #27272a ${sliderPct}%)` }"
|
||||||
|
:aria-label="label || 'Duration in seconds'"
|
||||||
|
@input="onSlider"
|
||||||
|
>
|
||||||
|
<p v-if="hint" class="mt-1 text-[11px] text-zinc-500">{{ hint }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const MIN = 0.5
|
||||||
|
const MAX = 120
|
||||||
|
const SLIDER_MIN = 1
|
||||||
|
const SLIDER_MAX = 30
|
||||||
|
|
||||||
|
withDefaults(defineProps<{
|
||||||
|
label?: string
|
||||||
|
hint?: string
|
||||||
|
}>(), {
|
||||||
|
label: '',
|
||||||
|
hint: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const model = defineModel<number>({ required: true })
|
||||||
|
const draft = ref(formatDuration(model.value))
|
||||||
|
const scrubbing = ref(false)
|
||||||
|
|
||||||
|
const slider = computed(() => Math.min(SLIDER_MAX, Math.max(SLIDER_MIN, model.value)))
|
||||||
|
const sliderPct = computed(() => ((slider.value - SLIDER_MIN) / (SLIDER_MAX - SLIDER_MIN)) * 100)
|
||||||
|
|
||||||
|
watch(model, (value) => {
|
||||||
|
if (!scrubbing.value) draft.value = formatDuration(value)
|
||||||
|
})
|
||||||
|
|
||||||
|
function clampDuration(value: number) {
|
||||||
|
if (!Number.isFinite(value)) return 5
|
||||||
|
return Math.min(MAX, Math.max(MIN, Math.round(value * 10) / 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(value: number) {
|
||||||
|
return Number.isInteger(value) ? String(value) : value.toFixed(1).replace(/\.0$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function commit() {
|
||||||
|
const parsed = Number.parseFloat(draft.value.replace(',', '.').trim())
|
||||||
|
model.value = clampDuration(parsed)
|
||||||
|
draft.value = formatDuration(model.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSlider(event: Event) {
|
||||||
|
model.value = clampDuration(Number((event.target as HTMLInputElement).value))
|
||||||
|
draft.value = formatDuration(model.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerDown(event: PointerEvent) {
|
||||||
|
if (event.button !== 0) return
|
||||||
|
const input = event.currentTarget as HTMLInputElement
|
||||||
|
const startY = event.clientY
|
||||||
|
const startValue = model.value
|
||||||
|
let moved = false
|
||||||
|
const onMove = (move: PointerEvent) => {
|
||||||
|
const delta = startY - move.clientY
|
||||||
|
if (!moved && Math.abs(delta) < 5) return
|
||||||
|
if (!moved) {
|
||||||
|
moved = true
|
||||||
|
scrubbing.value = true
|
||||||
|
input.setPointerCapture(move.pointerId)
|
||||||
|
input.blur()
|
||||||
|
}
|
||||||
|
move.preventDefault()
|
||||||
|
const next = clampDuration(startValue + delta / 18)
|
||||||
|
model.value = next
|
||||||
|
draft.value = formatDuration(next)
|
||||||
|
}
|
||||||
|
const onUp = () => {
|
||||||
|
input.removeEventListener('pointermove', onMove)
|
||||||
|
input.removeEventListener('pointerup', onUp)
|
||||||
|
input.removeEventListener('pointercancel', onUp)
|
||||||
|
scrubbing.value = false
|
||||||
|
}
|
||||||
|
input.addEventListener('pointermove', onMove)
|
||||||
|
input.addEventListener('pointerup', onUp)
|
||||||
|
input.addEventListener('pointercancel', onUp)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
+16
-139
@@ -90,27 +90,7 @@
|
|||||||
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 text-sm outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:ring-2"
|
||||||
placeholder="Motion prompt for this extension…"
|
placeholder="Motion prompt for this extension…"
|
||||||
/>
|
/>
|
||||||
<div class="mt-3">
|
<DurationField v-model="item.duration" class="mt-3" label="Duration" />
|
||||||
<p class="mb-2 text-xs uppercase tracking-wider text-zinc-500">Duration</p>
|
|
||||||
<div class="grid grid-cols-2 gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-xl border px-3 py-2 text-sm"
|
|
||||||
:class="item.duration === 3 ? 'border-amber-300 bg-amber-400/10' : 'border-white/10'"
|
|
||||||
@click="item.duration = 3"
|
|
||||||
>
|
|
||||||
3s
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-xl border px-3 py-2 text-sm"
|
|
||||||
:class="item.duration === 5 ? 'border-amber-300 bg-amber-400/10' : 'border-white/10'"
|
|
||||||
@click="item.duration = 5"
|
|
||||||
>
|
|
||||||
5s
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -251,37 +231,11 @@
|
|||||||
</select>
|
</select>
|
||||||
<p class="mt-1 text-[11px] text-zinc-500">{{ frameCount }} frames · duration × FPS</p>
|
<p class="mt-1 text-[11px] text-zinc-500">{{ frameCount }} frames · duration × FPS</p>
|
||||||
</label>
|
</label>
|
||||||
<div class="text-sm">
|
<DurationField
|
||||||
<span class="mb-1 block text-zinc-400">Duration</span>
|
v-model="duration"
|
||||||
<div class="relative">
|
label="Duration"
|
||||||
<input
|
hint="Type any length, use the slider, or drag up/down in the box."
|
||||||
v-model="durationDraft"
|
/>
|
||||||
type="text"
|
|
||||||
inputmode="decimal"
|
|
||||||
class="w-full rounded-xl border border-white/10 bg-zinc-950 px-3 py-2 pr-8 cursor-ns-resize outline-none ring-amber-300/40 placeholder:text-zinc-600 focus:cursor-text focus:ring-2"
|
|
||||||
:class="{ 'select-none caret-transparent': durationScrubbing }"
|
|
||||||
placeholder="seconds"
|
|
||||||
autocomplete="off"
|
|
||||||
spellcheck="false"
|
|
||||||
@blur="commitDuration"
|
|
||||||
@keydown.enter.prevent="commitDuration"
|
|
||||||
@pointerdown="onDurationPointerDown"
|
|
||||||
>
|
|
||||||
<span class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-xs text-zinc-500">s</span>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
class="aigen-slider mt-3 w-full"
|
|
||||||
type="range"
|
|
||||||
:min="DURATION_SLIDER_MIN"
|
|
||||||
:max="DURATION_SLIDER_MAX"
|
|
||||||
:step="0.1"
|
|
||||||
:value="durationSlider"
|
|
||||||
:style="{ background: `linear-gradient(to right, #fbbf24 ${durationSliderPct}%, #27272a ${durationSliderPct}%)` }"
|
|
||||||
aria-label="Duration in seconds"
|
|
||||||
@input="onDurationSlider"
|
|
||||||
>
|
|
||||||
<p class="mt-1 text-[11px] text-zinc-500">Type any length, use the slider, or drag up/down in the box.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<label class="text-sm">
|
<label class="text-sm">
|
||||||
<span class="mb-1 block text-zinc-400">Seed</span>
|
<span class="mb-1 block text-zinc-400">Seed</span>
|
||||||
@@ -452,27 +406,11 @@
|
|||||||
placeholder="Continue the motion from this frame…"
|
placeholder="Continue the motion from this frame…"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<div>
|
<DurationField
|
||||||
<p class="mb-2 text-xs uppercase tracking-wider text-zinc-500">Extension duration</p>
|
v-model="extendDuration"
|
||||||
<div class="grid grid-cols-2 gap-2">
|
label="Extension duration"
|
||||||
<button
|
hint="Type any length, use the slider, or drag up/down in the box."
|
||||||
type="button"
|
/>
|
||||||
class="rounded-xl border px-3 py-2 text-sm"
|
|
||||||
:class="extendDuration === 3 ? 'border-amber-300 bg-amber-400/10' : 'border-white/10'"
|
|
||||||
@click="extendDuration = 3"
|
|
||||||
>
|
|
||||||
3s
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-xl border px-3 py-2 text-sm"
|
|
||||||
:class="extendDuration === 5 ? 'border-amber-300 bg-amber-400/10' : 'border-white/10'"
|
|
||||||
@click="extendDuration = 5"
|
|
||||||
>
|
|
||||||
5s
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="w-full rounded-2xl bg-amber-400 px-4 py-2.5 text-sm font-semibold text-zinc-950 transition hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
|
class="w-full rounded-2xl bg-amber-400 px-4 py-2.5 text-sm font-semibold text-zinc-950 transition hover:bg-amber-300 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
@@ -703,7 +641,7 @@ interface RetryDraft {
|
|||||||
interface QueuedExtension {
|
interface QueuedExtension {
|
||||||
id: string
|
id: string
|
||||||
prompt: string
|
prompt: string
|
||||||
duration: 3 | 5
|
duration: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const PRESETS: Record<Exclude<AspectId, 'auto'>, { width: number; height: number }> = {
|
const PRESETS: Record<Exclude<AspectId, 'auto'>, { width: number; height: number }> = {
|
||||||
@@ -743,17 +681,7 @@ const cfgTouched = ref(false)
|
|||||||
const fps = ref(24)
|
const fps = ref(24)
|
||||||
const samplerName = ref('res_multistep')
|
const samplerName = ref('res_multistep')
|
||||||
const scheduler = ref('simple')
|
const scheduler = ref('simple')
|
||||||
const DURATION_MIN = 0.5
|
|
||||||
const DURATION_MAX = 120
|
|
||||||
const DURATION_SLIDER_MIN = 1
|
|
||||||
const DURATION_SLIDER_MAX = 30
|
|
||||||
const duration = ref(5)
|
const duration = ref(5)
|
||||||
const durationDraft = ref('5')
|
|
||||||
const durationScrubbing = ref(false)
|
|
||||||
const durationSlider = computed(() => Math.min(DURATION_SLIDER_MAX, Math.max(DURATION_SLIDER_MIN, duration.value)))
|
|
||||||
const durationSliderPct = computed(() => {
|
|
||||||
return ((durationSlider.value - DURATION_SLIDER_MIN) / (DURATION_SLIDER_MAX - DURATION_SLIDER_MIN)) * 100
|
|
||||||
})
|
|
||||||
const seedInput = ref('random')
|
const seedInput = ref('random')
|
||||||
const withSound = ref(true)
|
const withSound = ref(true)
|
||||||
const hideThumbnail = ref(false)
|
const hideThumbnail = ref(false)
|
||||||
@@ -1007,32 +935,13 @@ watch(autoplayEnabled, (value) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(duration, (value) => {
|
|
||||||
if (!durationScrubbing.value) durationDraft.value = formatDuration(value)
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(turbo, (on) => {
|
watch(turbo, (on) => {
|
||||||
if (!cfgTouched.value) cfg.value = on ? CFG_TURBO : CFG_QUALITY
|
if (!cfgTouched.value) cfg.value = on ? CFG_TURBO : CFG_QUALITY
|
||||||
})
|
})
|
||||||
|
|
||||||
function clampDuration(value: number) {
|
function clampDuration(value: number) {
|
||||||
if (!Number.isFinite(value)) return 5
|
if (!Number.isFinite(value)) return 5
|
||||||
return Math.min(DURATION_MAX, Math.max(DURATION_MIN, Math.round(value * 10) / 10))
|
return Math.min(120, Math.max(0.5, Math.round(value * 10) / 10))
|
||||||
}
|
|
||||||
|
|
||||||
function formatDuration(value: number) {
|
|
||||||
return Number.isInteger(value) ? String(value) : value.toFixed(1).replace(/\.0$/, '')
|
|
||||||
}
|
|
||||||
|
|
||||||
function commitDuration() {
|
|
||||||
const parsed = Number.parseFloat(durationDraft.value.replace(',', '.').trim())
|
|
||||||
duration.value = clampDuration(parsed)
|
|
||||||
durationDraft.value = formatDuration(duration.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDurationSlider(event: Event) {
|
|
||||||
duration.value = clampDuration(Number((event.target as HTMLInputElement).value))
|
|
||||||
durationDraft.value = formatDuration(duration.value)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clampCfg(value: number) {
|
function clampCfg(value: number) {
|
||||||
@@ -1077,37 +986,6 @@ function restoreAdvanced(item: { turbo: boolean; aspect?: string; width?: number
|
|||||||
scheduler.value = restoreScheduler(item.scheduler)
|
scheduler.value = restoreScheduler(item.scheduler)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDurationPointerDown(event: PointerEvent) {
|
|
||||||
if (event.button !== 0) return
|
|
||||||
const input = event.currentTarget as HTMLInputElement
|
|
||||||
const startY = event.clientY
|
|
||||||
const startValue = duration.value
|
|
||||||
let moved = false
|
|
||||||
const onMove = (move: PointerEvent) => {
|
|
||||||
const delta = startY - move.clientY
|
|
||||||
if (!moved && Math.abs(delta) < 5) return
|
|
||||||
if (!moved) {
|
|
||||||
moved = true
|
|
||||||
durationScrubbing.value = true
|
|
||||||
input.setPointerCapture(move.pointerId)
|
|
||||||
input.blur()
|
|
||||||
}
|
|
||||||
move.preventDefault()
|
|
||||||
const next = clampDuration(startValue + delta / 18)
|
|
||||||
duration.value = next
|
|
||||||
durationDraft.value = formatDuration(next)
|
|
||||||
}
|
|
||||||
const onUp = () => {
|
|
||||||
input.removeEventListener('pointermove', onMove)
|
|
||||||
input.removeEventListener('pointerup', onUp)
|
|
||||||
input.removeEventListener('pointercancel', onUp)
|
|
||||||
durationScrubbing.value = false
|
|
||||||
}
|
|
||||||
input.addEventListener('pointermove', onMove)
|
|
||||||
input.addEventListener('pointerup', onUp)
|
|
||||||
input.addEventListener('pointercancel', onUp)
|
|
||||||
}
|
|
||||||
|
|
||||||
function toast(message: string) {
|
function toast(message: string) {
|
||||||
const id = crypto.randomUUID()
|
const id = crypto.randomUUID()
|
||||||
toasts.value.push({ id, message })
|
toasts.value.push({ id, message })
|
||||||
@@ -1292,11 +1170,10 @@ async function loadDraft(draft: RetryDraft) {
|
|||||||
withSound.value = draft.sound !== false
|
withSound.value = draft.sound !== false
|
||||||
if (draft.hideInput && showPrivacyToggles.value) hideInputPreview.value = true
|
if (draft.hideInput && showPrivacyToggles.value) hideInputPreview.value = true
|
||||||
duration.value = clampDuration(Number(draft.duration))
|
duration.value = clampDuration(Number(draft.duration))
|
||||||
durationDraft.value = formatDuration(duration.value)
|
|
||||||
extensionQueue.value = (draft.extensions || []).slice(0, MAX_EXTENSIONS).map(item => ({
|
extensionQueue.value = (draft.extensions || []).slice(0, MAX_EXTENSIONS).map(item => ({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
prompt: String(item.prompt || ''),
|
prompt: String(item.prompt || ''),
|
||||||
duration: item.duration === 3 ? 3 : 5 as 3 | 5
|
duration: clampDuration(Number(item.duration))
|
||||||
}))
|
}))
|
||||||
try {
|
try {
|
||||||
const blob = await $fetch<Blob>(`/api/library/drafts/${draft.id}/still`, { responseType: 'blob' })
|
const blob = await $fetch<Blob>(`/api/library/drafts/${draft.id}/still`, { responseType: 'blob' })
|
||||||
@@ -1384,7 +1261,7 @@ async function openExtend(item?: LibraryClip) {
|
|||||||
currentClipId.value = id
|
currentClipId.value = id
|
||||||
const clip = clips.value.find(entry => entry.id === id)
|
const clip = clips.value.find(entry => entry.id === id)
|
||||||
extendPrompt.value = clip?.prompt || prompt.value
|
extendPrompt.value = clip?.prompt || prompt.value
|
||||||
extendDuration.value = 5
|
extendDuration.value = clampDuration(duration.value)
|
||||||
extendOpen.value = true
|
extendOpen.value = true
|
||||||
lastFrameLoading.value = true
|
lastFrameLoading.value = true
|
||||||
lastFrameUrl.value = ''
|
lastFrameUrl.value = ''
|
||||||
@@ -1562,7 +1439,7 @@ function queueExtension() {
|
|||||||
extensionQueue.value.push({
|
extensionQueue.value.push({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
prompt: prompt.value.trim(),
|
prompt: prompt.value.trim(),
|
||||||
duration: 5
|
duration: duration.value
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ const SCHEDULERS = new Set(['simple', 'ddim_uniform', 'sgm_uniform'])
|
|||||||
|
|
||||||
function parseExtendDuration(raw: unknown) {
|
function parseExtendDuration(raw: unknown) {
|
||||||
const seconds = Number(raw)
|
const seconds = Number(raw)
|
||||||
return seconds === 3 || seconds === 5 ? seconds : 5
|
if (!Number.isFinite(seconds)) return 5
|
||||||
|
return Math.min(120, Math.max(0.5, Math.round(seconds * 10) / 10))
|
||||||
}
|
}
|
||||||
|
|
||||||
function frameLength(seconds: number, fps: number) {
|
function frameLength(seconds: number, fps: number) {
|
||||||
|
|||||||
@@ -8,15 +8,14 @@ function isComfyBusyTimeout(error: unknown) {
|
|||||||
return text.includes('still busy after 3 minutes')
|
return text.includes('still busy after 3 minutes')
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseDuration(raw: string) {
|
function parseDuration(raw: unknown) {
|
||||||
const seconds = Number(raw)
|
const seconds = Number(raw)
|
||||||
if (!Number.isFinite(seconds)) return 5
|
if (!Number.isFinite(seconds)) return 5
|
||||||
return Math.min(120, Math.max(0.5, seconds))
|
return Math.min(120, Math.max(0.5, Math.round(seconds * 10) / 10))
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseExtendDuration(raw: unknown) {
|
function parseExtendDuration(raw: unknown) {
|
||||||
const seconds = Number(raw)
|
return parseDuration(raw)
|
||||||
return seconds === 3 || seconds === 5 ? seconds : 5
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseExtensions(raw: string | undefined) {
|
function parseExtensions(raw: string | undefined) {
|
||||||
|
|||||||
Reference in New Issue
Block a user