1.0.1: target size for GIF / APNG / APNG preview
Frame-based exports (GIF, APNG, APNG-preview) accept an optional target output size. ExportDialog exposes it as a single width input above the three image-export rows; height is derived live from the scene's aspect ratio so the user can't squash the output. The preview HTML mirrors the target dims so a downsized export previews at its real pixel size. renderFrames takes targetWidth / targetHeight, sizes its canvas accordingly and scales the ctx once; drawSceneFrame's own save/restore preserves the transform across frames, including the top-level-mask offscreen compositing path. Target width on dialog open is inherited from the current scene.canvas — watched via modelValue so resizing the stage and reopening the dialog picks up the new size (instead of the stale value from whenever ExportDialog first instantiated). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
c2b81b0fb4
commit
cd9cf91ef0
7 files changed
+374
-26
No files matched your search
@@ -1309,6 +1309,100 @@ export function registerAllTests(): void {
|
||||
})
|
||||
})
|
||||
|
||||
suite('export — target size scaling', () => {
|
||||
// The GIF/APNG/APNG-preview pipeline scales to a caller-specified
|
||||
// canvas size while preserving the scene's composition. The
|
||||
// ExportDialog UI derives height from width so aspect stays
|
||||
// locked; this suite verifies the underlying renderer honours
|
||||
// targetWidth/Height directly.
|
||||
|
||||
test('renderFrames scales the canvas to targetWidth/targetHeight', async () => {
|
||||
const { renderFrames } = await import('@/lib/frameExport')
|
||||
const s = makeFreshStore()
|
||||
s.setDuration(0.25)
|
||||
s.project.fps = 4
|
||||
s.scene.canvas.width = 20
|
||||
s.scene.canvas.height = 10
|
||||
let lastWidth = 0
|
||||
let lastHeight = 0
|
||||
await renderFrames(
|
||||
s.scene,
|
||||
({ width, height }) => {
|
||||
lastWidth = width
|
||||
lastHeight = height
|
||||
},
|
||||
{ targetWidth: 200, targetHeight: 100 },
|
||||
)
|
||||
expect(lastWidth).toBe(200)
|
||||
expect(lastHeight).toBe(100)
|
||||
})
|
||||
|
||||
test('renderFrames at target size draws content scaled into the new pixel grid', async () => {
|
||||
// A full-canvas coloured rect should cover the entire target
|
||||
// canvas when we 4x the output. Picks a centre pixel to verify
|
||||
// the fill colour came through (and isn't cleared or clipped).
|
||||
const { renderFrames } = await import('@/lib/frameExport')
|
||||
const s = makeFreshStore()
|
||||
s.setDuration(0.25)
|
||||
s.project.fps = 4
|
||||
s.scene.canvas.width = 10
|
||||
s.scene.canvas.height = 10
|
||||
s.scene.canvas.background = 'transparent'
|
||||
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#ff0000' })
|
||||
const l = s.addLayer(a.id)!
|
||||
s.upsertKeyframe(l.id, 0, { x: 5, y: 5 })
|
||||
let px: Uint8ClampedArray | null = null
|
||||
await renderFrames(
|
||||
s.scene,
|
||||
({ ctx, width, height }) => {
|
||||
px = ctx.getImageData(Math.floor(width / 2), Math.floor(height / 2), 1, 1).data
|
||||
void width
|
||||
void height
|
||||
},
|
||||
{ targetWidth: 40, targetHeight: 40 },
|
||||
)
|
||||
expect(px).toBeTruthy()
|
||||
expect(px![0]).toBeGreaterThan(200) // red
|
||||
expect(px![3]).toBeGreaterThan(200) // alpha
|
||||
})
|
||||
|
||||
test('renderSceneToApng honours targetWidth + targetHeight', async () => {
|
||||
const s = makeFreshStore()
|
||||
s.setDuration(0.25)
|
||||
s.project.fps = 4
|
||||
s.scene.canvas.width = 20
|
||||
s.scene.canvas.height = 10
|
||||
const blob = await renderSceneToApng(s.scene, {
|
||||
targetWidth: 80,
|
||||
targetHeight: 40,
|
||||
})
|
||||
expect(blob.type).toBe('image/png')
|
||||
// Decode the PNG header's IHDR to verify the encoded dimensions
|
||||
// match the target — the Uint8 offset layout is fixed per PNG
|
||||
// spec (magic[8] + length[4] + type[4] + width[4]).
|
||||
const bytes = new Uint8Array(await blob.arrayBuffer())
|
||||
const dv = new DataView(bytes.buffer)
|
||||
expect(dv.getUint32(16)).toBe(80)
|
||||
expect(dv.getUint32(20)).toBe(40)
|
||||
})
|
||||
|
||||
test('APNG preview page reflects targetWidth/Height, not the scene canvas', async () => {
|
||||
// downloadSceneAsApngPreview builds HTML + kicks off a download;
|
||||
// for the test we just reach into the underlying pipeline by
|
||||
// rendering the APNG separately and feeding the same (target)
|
||||
// dimensions to generateApngPreviewHtml. This guards against
|
||||
// anyone wiring the previewer to the scene's size directly
|
||||
// while ignoring the user's target override.
|
||||
const html = generateApngPreviewHtml(
|
||||
'data:image/png;base64,AAAA',
|
||||
640,
|
||||
360,
|
||||
)
|
||||
expect(html).toContain('width: 640px')
|
||||
expect(html).toContain('height: 360px')
|
||||
})
|
||||
})
|
||||
|
||||
suite('ui — ExportDialog', () => {
|
||||
// ExportDialog teleports through Modal so query through document
|
||||
// rather than the handle's host root. Each test snapshots the
|
||||
@@ -1346,6 +1440,86 @@ export function registerAllTests(): void {
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
test('target-size input defaults to the scene canvas and derives height from width', async () => {
|
||||
const s = makeFreshStore()
|
||||
s.scene.canvas.width = 1920
|
||||
s.scene.canvas.height = 1080
|
||||
const baseline = snapshotDialogs()
|
||||
const handle = mountComponent(ExportDialog, { modelValue: true })
|
||||
try {
|
||||
await nextTick()
|
||||
const dialog = dialogsAddedSince(baseline)[0]
|
||||
const input = dialog.querySelector(
|
||||
'.target-size input[type="number"]',
|
||||
) as HTMLInputElement
|
||||
expect(input).toBeTruthy()
|
||||
expect(Number(input.value)).toBe(1920)
|
||||
const derived = dialog.querySelector('.target-size-derived') as HTMLElement
|
||||
expect(derived.textContent).toContain('1080')
|
||||
// Change width to half — derived height must follow aspect
|
||||
// ratio (1080 * 960 / 1920 = 540).
|
||||
input.value = '960'
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await nextTick()
|
||||
expect(derived.textContent).toContain('540')
|
||||
} finally {
|
||||
handle.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
test('re-opening the dialog re-inherits the current document canvas size', async () => {
|
||||
// Regression: the target-width ref was only initialised at
|
||||
// setup() and never refreshed when the user resized the stage
|
||||
// before reopening. Watching modelValue resets it on each open
|
||||
// so the dialog always starts from the current document dims.
|
||||
const s = makeFreshStore()
|
||||
s.scene.canvas.width = 800
|
||||
s.scene.canvas.height = 600
|
||||
const baseline = snapshotDialogs()
|
||||
// Wrapper owns the `open` ref so we can flip it without
|
||||
// remounting — that exercises the watch(modelValue) rather
|
||||
// than a fresh setup().
|
||||
const open = ref(false)
|
||||
const Wrapper = defineComponent({
|
||||
render() {
|
||||
return h(ExportDialog, {
|
||||
modelValue: open.value,
|
||||
'onUpdate:modelValue': (v: boolean) => { open.value = v },
|
||||
})
|
||||
},
|
||||
})
|
||||
const handle = mountComponent(Wrapper, {})
|
||||
try {
|
||||
open.value = true
|
||||
await nextTick()
|
||||
let dialog = dialogsAddedSince(baseline)[0]
|
||||
let input = dialog.querySelector(
|
||||
'.target-size input[type="number"]',
|
||||
) as HTMLInputElement
|
||||
expect(Number(input.value)).toBe(800)
|
||||
// User types something custom, then closes the dialog.
|
||||
input.value = '400'
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await nextTick()
|
||||
open.value = false
|
||||
await nextTick()
|
||||
// Canvas grew in the meantime.
|
||||
s.scene.canvas.width = 1600
|
||||
s.scene.canvas.height = 1200
|
||||
open.value = true
|
||||
await nextTick()
|
||||
dialog = dialogsAddedSince(baseline)[0]
|
||||
input = dialog.querySelector(
|
||||
'.target-size input[type="number"]',
|
||||
) as HTMLInputElement
|
||||
// Must have picked up the new canvas dims, not the custom
|
||||
// 400 the user entered last time.
|
||||
expect(Number(input.value)).toBe(1600)
|
||||
} finally {
|
||||
handle.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
test('clicking APNG preview transitions the dialog to the progress view', async () => {
|
||||
// Tiny scene so the async render stays short. We assert the
|
||||
// synchronous state transition right after the click — the
|
||||
|
||||
Reference in new issue
Block a user