Overhaul Studio controls and add image batch from one still.

Video and Image pickers, thumbwheels, aspect icons, and a Scale to MP paddle; extra prompts default to batch so each run starts from the original image.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-08-30 23:16:19 -05:00
co-authored by Cursor
parent 8df265cbf3
commit 755aaeede8
17 changed files with 791 additions and 233 deletions
+158
View File
@@ -0,0 +1,158 @@
<template>
<div class="w-[6.5rem] min-w-0" :class="disabled ? 'opacity-50' : ''">
<div v-if="label" class="mb-1.5 text-[10px] font-medium uppercase tracking-wider text-zinc-500">{{ label }}</div>
<div class="overflow-hidden rounded-2xl border border-white/10 bg-zinc-950">
<div class="border-b border-white/5">
<SegmentDigits :value="display" />
</div>
<div
ref="root"
class="thumbwheel relative w-full select-none"
:class="[
compact ? 'h-28' : 'h-36',
disabled ? 'pointer-events-none' : 'cursor-ns-resize'
]"
role="slider"
:aria-label="ariaLabel || label || 'Value'"
:aria-valuemin="min"
:aria-valuemax="max"
:aria-valuenow="model"
:aria-disabled="disabled"
tabindex="0"
@pointerdown="onPointerDown"
@wheel.prevent="onWheel"
@keydown="onKey"
>
<div
class="thumbwheel-drum pointer-events-none absolute inset-y-1 left-1 right-8 overflow-hidden rounded-xl"
:style="{ backgroundPositionY: `${drumShift}px` }"
/>
<div class="pointer-events-none absolute inset-y-2 right-2.5 w-2.5 rounded-full bg-zinc-900 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.08)]">
<span
class="absolute left-0 right-0 h-0.5 rounded-full bg-amber-300 shadow-[0_0_10px_rgba(251,191,36,0.85)]"
:style="{ top: `calc(${100 - pct}% - 1px)` }"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const props = withDefaults(defineProps<{
min: number
max: number
step?: number
fineStep?: number
coarseStep?: number
label?: string
ariaLabel?: string
disabled?: boolean
compact?: boolean
digits?: number
}>(), {
step: 0.1,
fineStep: 0.1,
coarseStep: 1,
label: '',
ariaLabel: '',
disabled: false,
compact: false,
digits: 1
})
const model = defineModel<number>({ required: true })
const root = ref<HTMLElement | null>(null)
const span = computed(() => Math.max(props.max - props.min, 0.0001))
const pct = computed(() => ((clamp(model.value) - props.min) / span.value) * 100)
const display = computed(() => format(model.value))
const drumShift = computed(() => Math.round((pct.value / 100) * 72))
function clamp(value: number) {
if (!Number.isFinite(value)) return props.min
const step = props.step || 0.1
const snapped = Math.round(value / step) * step
const places = step < 0.1 ? 2 : 1
const rounded = Number(snapped.toFixed(places))
return Math.min(props.max, Math.max(props.min, rounded))
}
function format(value: number) {
const n = clamp(value)
if (props.digits === 0) return String(Math.round(n))
return n.toFixed(props.digits).replace(/\.0+$/, '').replace(/(\.\d*[1-9])0+$/, '$1')
}
function nudge(delta: number) {
model.value = clamp(model.value + delta)
}
function onWheel(event: WheelEvent) {
if (props.disabled) return
const step = event.shiftKey ? props.coarseStep : props.fineStep
nudge(event.deltaY < 0 ? step : -step)
}
function onKey(event: KeyboardEvent) {
if (props.disabled) return
const step = event.shiftKey ? props.coarseStep : props.fineStep
if (event.key === 'ArrowUp' || event.key === 'ArrowRight') {
event.preventDefault()
nudge(step)
} else if (event.key === 'ArrowDown' || event.key === 'ArrowLeft') {
event.preventDefault()
nudge(-step)
} else if (event.key === 'Home') {
event.preventDefault()
model.value = props.min
} else if (event.key === 'End') {
event.preventDefault()
model.value = props.max
}
}
function onPointerDown(event: PointerEvent) {
if (props.disabled || event.button !== 0) return
const el = root.value
if (!el) return
event.preventDefault()
el.setPointerCapture(event.pointerId)
const height = el.clientHeight || 1
const startY = event.clientY
const startValue = model.value
const onMove = (move: PointerEvent) => {
const delta = startY - move.clientY
model.value = clamp(startValue + (delta / height) * span.value)
}
const onUp = () => {
el.removeEventListener('pointermove', onMove)
el.removeEventListener('pointerup', onUp)
el.removeEventListener('pointercancel', onUp)
}
el.addEventListener('pointermove', onMove)
el.addEventListener('pointerup', onUp)
el.addEventListener('pointercancel', onUp)
}
</script>
<style scoped>
.thumbwheel {
touch-action: none;
}
.thumbwheel-drum {
background-image:
repeating-linear-gradient(
to bottom,
rgba(82, 82, 91, 0.95) 0 2px,
rgba(24, 24, 27, 1) 2px 7px,
rgba(63, 63, 70, 0.9) 7px 9px,
rgba(9, 9, 11, 1) 9px 14px
);
background-size: 100% 14px;
box-shadow:
inset 0 18px 20px rgba(0, 0, 0, 0.55),
inset 0 -18px 20px rgba(0, 0, 0, 0.55),
inset 12px 0 16px rgba(255, 255, 255, 0.04);
}
</style>