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:
2026-08-26 00:38:50 -05:00
parent 78087fbf5b
commit f4d8e1fb70
4 changed files with 131 additions and 144 deletions
+16 -139
View File
@@ -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"
placeholder="Motion prompt for this extension…"
/>
<div class="mt-3">
<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>
<DurationField v-model="item.duration" class="mt-3" label="Duration" />
</div>
<button
type="button"
@@ -251,37 +231,11 @@
</select>
<p class="mt-1 text-[11px] text-zinc-500">{{ frameCount }} frames · duration × FPS</p>
</label>
<div class="text-sm">
<span class="mb-1 block text-zinc-400">Duration</span>
<div class="relative">
<input
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>
<DurationField
v-model="duration"
label="Duration"
hint="Type any length, use the slider, or drag up/down in the box."
/>
</div>
<label class="text-sm">
<span class="mb-1 block text-zinc-400">Seed</span>
@@ -452,27 +406,11 @@
placeholder="Continue the motion from this frame…"
/>
</label>
<div>
<p class="mb-2 text-xs uppercase tracking-wider text-zinc-500">Extension duration</p>
<div class="grid grid-cols-2 gap-2">
<button
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>
<DurationField
v-model="extendDuration"
label="Extension duration"
hint="Type any length, use the slider, or drag up/down in the box."
/>
<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"
@@ -703,7 +641,7 @@ interface RetryDraft {
interface QueuedExtension {
id: string
prompt: string
duration: 3 | 5
duration: number
}
const PRESETS: Record<Exclude<AspectId, 'auto'>, { width: number; height: number }> = {
@@ -743,17 +681,7 @@ const cfgTouched = ref(false)
const fps = ref(24)
const samplerName = ref('res_multistep')
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 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 withSound = ref(true)
const hideThumbnail = ref(false)
@@ -1007,32 +935,13 @@ watch(autoplayEnabled, (value) => {
}
})
watch(duration, (value) => {
if (!durationScrubbing.value) durationDraft.value = formatDuration(value)
})
watch(turbo, (on) => {
if (!cfgTouched.value) cfg.value = on ? CFG_TURBO : CFG_QUALITY
})
function clampDuration(value: number) {
if (!Number.isFinite(value)) return 5
return Math.min(DURATION_MAX, Math.max(DURATION_MIN, 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)
return Math.min(120, Math.max(0.5, Math.round(value * 10) / 10))
}
function clampCfg(value: number) {
@@ -1077,37 +986,6 @@ function restoreAdvanced(item: { turbo: boolean; aspect?: string; width?: number
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) {
const id = crypto.randomUUID()
toasts.value.push({ id, message })
@@ -1292,11 +1170,10 @@ async function loadDraft(draft: RetryDraft) {
withSound.value = draft.sound !== false
if (draft.hideInput && showPrivacyToggles.value) hideInputPreview.value = true
duration.value = clampDuration(Number(draft.duration))
durationDraft.value = formatDuration(duration.value)
extensionQueue.value = (draft.extensions || []).slice(0, MAX_EXTENSIONS).map(item => ({
id: crypto.randomUUID(),
prompt: String(item.prompt || ''),
duration: item.duration === 3 ? 3 : 5 as 3 | 5
duration: clampDuration(Number(item.duration))
}))
try {
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
const clip = clips.value.find(entry => entry.id === id)
extendPrompt.value = clip?.prompt || prompt.value
extendDuration.value = 5
extendDuration.value = clampDuration(duration.value)
extendOpen.value = true
lastFrameLoading.value = true
lastFrameUrl.value = ''
@@ -1562,7 +1439,7 @@ function queueExtension() {
extensionQueue.value.push({
id: crypto.randomUUID(),
prompt: prompt.value.trim(),
duration: 5
duration: duration.value
})
}