Add automatic aspect matching with explicit ratio overrides.

Keep video output on the still by default, and only force 16:9, 9:16, or 1:1 when a preset is selected.
This commit is contained in:
2026-08-25 17:55:40 -05:00
parent 34e6810c14
commit a9bf4f6a95
3 changed files with 167 additions and 25 deletions
+65 -12
View File
@@ -60,7 +60,7 @@
<div> <div>
<p class="mb-2 text-sm font-medium text-zinc-300">Aspect ratio / quality</p> <p class="mb-2 text-sm font-medium text-zinc-300">Aspect ratio / quality</p>
<div class="grid grid-cols-3 gap-2"> <div class="grid grid-cols-2 gap-2 lg:grid-cols-4">
<button <button
v-for="option in aspects" v-for="option in aspects"
:key="option.id" :key="option.id"
@@ -70,9 +70,10 @@
@click="aspect = option.id" @click="aspect = option.id"
> >
<span class="block font-semibold">{{ option.label }}</span> <span class="block font-semibold">{{ option.label }}</span>
<span class="text-xs text-zinc-400">{{ option.hint }}</span> <span class="text-xs text-zinc-400">{{ option.id === 'auto' ? autoHint : option.hint }}</span>
</button> </button>
</div> </div>
<p class="mt-2 text-xs text-zinc-500">Output {{ outputSize.width }} × {{ outputSize.height }}{{ aspect === 'auto' ? ' · matching the still' : ' · override' }}</p>
</div> </div>
<details class="rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3"> <details class="rounded-2xl border border-white/10 bg-zinc-950/40 px-4 py-3">
@@ -140,13 +141,14 @@
<video <video
v-if="videoUrl" v-if="videoUrl"
:src="videoUrl" :src="videoUrl"
class="aspect-video w-full bg-black" class="w-full bg-black"
:style="{ aspectRatio: `${outputSize.width} / ${outputSize.height}` }"
controls controls
autoplay autoplay
loop loop
playsinline playsinline
/> />
<div v-else class="flex aspect-video items-center justify-center text-sm text-zinc-500"> <div v-else class="flex items-center justify-center text-sm text-zinc-500" :style="{ aspectRatio: `${outputSize.width} / ${outputSize.height}` }">
Generated MP4 appears here Generated MP4 appears here
</div> </div>
</div> </div>
@@ -193,9 +195,12 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
type AspectId = 'auto' | '16:9' | '9:16' | '1:1'
interface HistoryItem { interface HistoryItem {
id: string id: string
prompt: string prompt: string
aspect?: AspectId
width: number width: number
height: number height: number
steps: number steps: number
@@ -206,18 +211,27 @@ interface HistoryItem {
createdAt: number createdAt: number
} }
const PRESETS: Record<Exclude<AspectId, 'auto'>, { width: number; height: number }> = {
'16:9': { width: 1344, height: 768 },
'9:16': { width: 768, height: 1344 },
'1:1': { width: 1024, height: 1024 }
}
const aspects = [ const aspects = [
{ id: '16:9', label: '16:9 Landscape', hint: '1344 × 768 · 0.98 MP' }, { id: 'auto' as const, label: 'Automatic', hint: 'Match the still' },
{ id: '9:16', label: '9:16 Portrait', hint: '768 × 1344 · 0.98 MP' }, { id: '16:9' as const, label: '16:9 Landscape', hint: '1344 × 768 · 0.98 MP' },
{ id: '1:1', label: '1:1 Square', hint: '1024 × 1024' } { id: '9:16' as const, label: '9:16 Portrait', hint: '768 × 1344 · 0.98 MP' },
] as const { id: '1:1' as const, label: '1:1 Square', hint: '1024 × 1024' }
]
const fileInput = ref<HTMLInputElement | null>(null) const fileInput = ref<HTMLInputElement | null>(null)
const file = ref<File | null>(null) const file = ref<File | null>(null)
const preview = ref('') const preview = ref('')
const dragging = ref(false) const dragging = ref(false)
const prompt = ref('') const prompt = ref('')
const aspect = ref<'16:9' | '9:16' | '1:1'>('16:9') const aspect = ref<AspectId>('auto')
const imageWidth = ref(0)
const imageHeight = ref(0)
const turbo = ref(true) const turbo = ref(true)
const duration = ref(5) const duration = ref(5)
const seedInput = ref('random') const seedInput = ref('random')
@@ -244,6 +258,36 @@ const elapsedLabel = computed(() => {
return `${mm}:${ss}` return `${mm}:${ss}`
}) })
function snap32(value: number) {
return Math.max(32, Math.round(value / 32) * 32)
}
function autoResolution(srcWidth: number, srcHeight: number) {
const ratio = srcWidth / Math.max(srcHeight, 1)
let width: number
let height: number
if (ratio >= 1) {
height = 768
width = Math.min(1344, height * ratio)
} else {
width = 768
height = Math.min(1344, width / ratio)
}
return { width: snap32(width), height: snap32(height) }
}
const outputSize = computed(() => {
if (aspect.value !== 'auto') return PRESETS[aspect.value]
if (imageWidth.value && imageHeight.value) return autoResolution(imageWidth.value, imageHeight.value)
return { width: 1344, height: 768 }
})
const autoHint = computed(() => {
if (!imageWidth.value || !imageHeight.value) return 'Match the still'
const size = autoResolution(imageWidth.value, imageHeight.value)
return `${size.width} × ${size.height} · from still`
})
onMounted(async () => { onMounted(async () => {
history.value = JSON.parse(localStorage.getItem('aigen-history') || '[]') history.value = JSON.parse(localStorage.getItem('aigen-history') || '[]')
const [health, me] = await Promise.all([ const [health, me] = await Promise.all([
@@ -271,12 +315,20 @@ function toast(message: string) {
function resetImage() { function resetImage() {
file.value = null file.value = null
preview.value = '' preview.value = ''
imageWidth.value = 0
imageHeight.value = 0
if (fileInput.value) fileInput.value.value = '' if (fileInput.value) fileInput.value.value = ''
} }
function readFile(next: File) { function readFile(next: File) {
file.value = next file.value = next
preview.value = URL.createObjectURL(next) preview.value = URL.createObjectURL(next)
const img = new Image()
img.onload = () => {
imageWidth.value = img.naturalWidth
imageHeight.value = img.naturalHeight
}
img.src = preview.value
} }
function onFile(event: Event) { function onFile(event: Event) {
@@ -313,9 +365,7 @@ function rerun(item: HistoryItem) {
prompt.value = item.prompt prompt.value = item.prompt
turbo.value = item.turbo turbo.value = item.turbo
seedInput.value = String(item.seed) seedInput.value = String(item.seed)
if (item.width === 768) aspect.value = '9:16' aspect.value = item.aspect || 'auto'
else if (item.width === 1024) aspect.value = '1:1'
else aspect.value = '16:9'
toast('Settings restored. Drop the same still if it is not already loaded.') toast('Settings restored. Drop the same still if it is not already loaded.')
} }
@@ -331,6 +381,8 @@ async function generate() {
body.append('image', file.value) body.append('image', file.value)
body.append('prompt', prompt.value.trim()) body.append('prompt', prompt.value.trim())
body.append('aspect', aspect.value) body.append('aspect', aspect.value)
body.append('width', String(outputSize.value.width))
body.append('height', String(outputSize.value.height))
body.append('turbo', String(turbo.value)) body.append('turbo', String(turbo.value))
body.append('steps', String(turbo.value ? 8 : 20)) body.append('steps', String(turbo.value ? 8 : 20))
body.append('duration', String(duration.value)) body.append('duration', String(duration.value))
@@ -369,6 +421,7 @@ function listen(id: string, meta: { seed: number; width: number; height: number;
history.value.unshift({ history.value.unshift({
id, id,
prompt: prompt.value.trim(), prompt: prompt.value.trim(),
aspect: aspect.value,
width: meta.width, width: meta.width,
height: meta.height, height: meta.height,
steps: meta.steps, steps: meta.steps,
+2 -13
View File
@@ -1,13 +1,3 @@
const ASPECTS: Record<string, { width: number; height: number }> = {
'16:9': { width: 1344, height: 768 },
'9:16': { width: 768, height: 1344 },
'1:1': { width: 1024, height: 1024 }
}
function snap32(value: number) {
return Math.max(32, Math.round(value / 32) * 32)
}
function frameLength(seconds: number) { function frameLength(seconds: number) {
const raw = Math.max(5, Math.round(seconds * 24)) const raw = Math.max(5, Math.round(seconds * 24))
return raw + (5 - (raw % 17) + 17) % 17 return raw + (5 - (raw % 17) + 17) % 17
@@ -37,9 +27,7 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 400, statusMessage: 'A motion prompt is required' }) throw createError({ statusCode: 400, statusMessage: 'A motion prompt is required' })
} }
const aspect = ASPECTS[fields.aspect] || ASPECTS['16:9'] const { width, height } = resolveOutputSize(fields.aspect, image.data, Number(fields.width), Number(fields.height))
const width = snap32(Number(fields.width || aspect.width))
const height = snap32(Number(fields.height || aspect.height))
const turbo = fields.turbo !== 'false' const turbo = fields.turbo !== 'false'
const steps = Number(fields.steps || (turbo ? 8 : 20)) const steps = Number(fields.steps || (turbo ? 8 : 20))
const seed = fields.seed && fields.seed !== 'random' const seed = fields.seed && fields.seed !== 'random'
@@ -64,6 +52,7 @@ export default defineEventHandler(async (event) => {
seed, seed,
width, width,
height, height,
aspect: fields.aspect || 'auto',
steps, steps,
turbo turbo
} }
+100
View File
@@ -0,0 +1,100 @@
const SHORT_EDGE = 768
const LONG_EDGE = 1344
export const ASPECT_PRESETS: Record<string, { width: number; height: number }> = {
'16:9': { width: 1344, height: 768 },
'9:16': { width: 768, height: 1344 },
'1:1': { width: 1024, height: 1024 }
}
export function snap32(value: number) {
return Math.max(32, Math.round(value / 32) * 32)
}
/** Fit the source still to H3's native 768 short-edge canvas, 32px grid, 1344 long-edge cap. */
export function autoResolution(srcWidth: number, srcHeight: number) {
const ratio = srcWidth / Math.max(srcHeight, 1)
let width: number
let height: number
if (ratio >= 1) {
height = SHORT_EDGE
width = Math.min(LONG_EDGE, height * ratio)
} else {
width = SHORT_EDGE
height = Math.min(LONG_EDGE, width / ratio)
}
width = snap32(width)
height = snap32(height)
if (width > LONG_EDGE) width = LONG_EDGE
if (height > LONG_EDGE) height = LONG_EDGE
return { width: Math.max(32, width), height: Math.max(32, height) }
}
export function imageDimensions(buf: Buffer) {
if (buf.length < 24) return null
if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) {
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) }
}
if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46) {
return { width: buf.readUInt16LE(6), height: buf.readUInt16LE(8) }
}
if (buf[0] === 0x42 && buf[1] === 0x4d && buf.length >= 26) {
return { width: buf.readInt32LE(18), height: Math.abs(buf.readInt32LE(22)) }
}
if (buf.toString('ascii', 0, 4) === 'RIFF' && buf.toString('ascii', 8, 12) === 'WEBP') {
return webpSize(buf)
}
if (buf[0] === 0xff && buf[1] === 0xd8) {
return jpegSize(buf)
}
return null
}
function webpSize(buf: Buffer) {
const kind = buf.toString('ascii', 12, 16)
if (kind === 'VP8X' && buf.length >= 30) {
const width = 1 + buf[24] + (buf[25] << 8) + (buf[26] << 16)
const height = 1 + buf[27] + (buf[28] << 8) + (buf[29] << 16)
return { width, height }
}
if (kind === 'VP8 ' && buf.length >= 30) {
return { width: buf.readUInt16LE(26) & 0x3fff, height: buf.readUInt16LE(28) & 0x3fff }
}
if (kind === 'VP8L' && buf.length >= 25) {
const bits = buf.readUInt32LE(21)
return { width: (bits & 0x3fff) + 1, height: ((bits >> 14) & 0x3fff) + 1 }
}
return null
}
function jpegSize(buf: Buffer) {
let offset = 2
while (offset + 9 < buf.length) {
if (buf[offset] !== 0xff) return null
const marker = buf[offset + 1]
if (marker === 0xd8 || marker === 0xd9) {
offset += 2
continue
}
const length = buf.readUInt16BE(offset + 2)
if (marker >= 0xc0 && marker <= 0xc3) {
return { width: buf.readUInt16BE(offset + 7), height: buf.readUInt16BE(offset + 5) }
}
offset += 2 + length
}
return null
}
export function resolveOutputSize(aspect: string | undefined, image: Buffer, fallbackWidth?: number, fallbackHeight?: number) {
if (aspect && ASPECT_PRESETS[aspect]) {
return ASPECT_PRESETS[aspect]
}
const fromFields = Number(fallbackWidth) && Number(fallbackHeight)
? { width: snap32(Number(fallbackWidth)), height: snap32(Number(fallbackHeight)) }
: null
const size = imageDimensions(image)
if (size?.width && size?.height) {
return autoResolution(size.width, size.height)
}
return fromFields || ASPECT_PRESETS['16:9']
}