1.1.0: .ruw project bundle + per-export target FPS
- New .ruw file format: a STORE-only zip wrapping project.json + an assets/ folder. packProject extracts every data-URL asset (images, audio, background video) into its own file and rewrites the in-JSON src fields to relative paths; unpackProject rehydrates back to data URLs on read. Zero dependencies — we ship a minimal in-house zip codec. - ExportDialog surfaces the new format as a top-row "RUWIO project (.ruw)" option that round-trips through the welcome-dialog file picker. Legacy .json imports still work via byte-sniffing the PK header in readAnyProjectFile. Dead App.vue `exportProjectFile` helper removed. - Frame-based exports (GIF, APNG, APNG preview) take an optional targetFps override so a 60fps project doesn't balloon the file — APNG / GIF size scales ~linearly with frame count. The ExportDialog grows a second input next to target size; defaults inherit the scene's fps and reset on each dialog open. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
647f09b287
commit
92e9f1619f
10 files changed
+735
-30
No files matched your search
@@ -1316,6 +1316,42 @@ export function registerAllTests(): void {
|
||||
// locked; this suite verifies the underlying renderer honours
|
||||
// targetWidth/Height directly.
|
||||
|
||||
test('renderFrames emits targetFps frames per second, not scene.fps', async () => {
|
||||
// Scene is set to 60fps; the export asks for 15. Over a half-
|
||||
// second range we expect ~8 frames (15 × 0.5) instead of 30.
|
||||
const { renderFrames } = await import('@/lib/frameExport')
|
||||
const s = makeFreshStore()
|
||||
s.setDuration(0.5)
|
||||
s.project.fps = 60
|
||||
s.scene.canvas.width = 8
|
||||
s.scene.canvas.height = 8
|
||||
let callCount = 0
|
||||
await renderFrames(
|
||||
s.scene,
|
||||
() => { callCount += 1 },
|
||||
{ targetFps: 15 },
|
||||
)
|
||||
expect(callCount).toBe(Math.max(1, Math.round(0.5 * 15)))
|
||||
})
|
||||
|
||||
test('renderFrames delayMs reflects the override fps', async () => {
|
||||
// GIF / APNG encoders read per-frame delay off the ctx. At
|
||||
// 10fps the delay must be 100ms, regardless of scene.fps.
|
||||
const { renderFrames } = await import('@/lib/frameExport')
|
||||
const s = makeFreshStore()
|
||||
s.setDuration(0.3)
|
||||
s.project.fps = 60
|
||||
s.scene.canvas.width = 4
|
||||
s.scene.canvas.height = 4
|
||||
let lastDelay = 0
|
||||
await renderFrames(
|
||||
s.scene,
|
||||
({ delayMs }) => { lastDelay = delayMs },
|
||||
{ targetFps: 10 },
|
||||
)
|
||||
expect(lastDelay).toBeCloseTo(100, 6)
|
||||
})
|
||||
|
||||
test('renderFrames scales the canvas to targetWidth/targetHeight', async () => {
|
||||
const { renderFrames } = await import('@/lib/frameExport')
|
||||
const s = makeFreshStore()
|
||||
@@ -1467,6 +1503,36 @@ export function registerAllTests(): void {
|
||||
}
|
||||
})
|
||||
|
||||
test('target-fps input defaults to the scene fps + hint label renders', async () => {
|
||||
const s = makeFreshStore()
|
||||
s.project.fps = 60
|
||||
const baseline = snapshotDialogs()
|
||||
const handle = mountComponent(ExportDialog, { modelValue: true })
|
||||
try {
|
||||
await nextTick()
|
||||
const dialog = dialogsAddedSince(baseline)[0]
|
||||
// Two `.target-size` rows now: size, fps. The fps row is
|
||||
// the one that contains the 'fps' tag.
|
||||
const rows = Array.from(
|
||||
dialog.querySelectorAll('.target-size'),
|
||||
) as HTMLElement[]
|
||||
const fpsRow = rows.find((r) => r.textContent?.includes('fps'))
|
||||
expect(fpsRow).toBeTruthy()
|
||||
const input = fpsRow!.querySelector(
|
||||
'input[type="number"]',
|
||||
) as HTMLInputElement
|
||||
expect(Number(input.value)).toBe(60)
|
||||
// Bumping the scene fps → input stays until dialog reopens.
|
||||
input.value = '24'
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await nextTick()
|
||||
expect(Number(input.value)).toBe(24)
|
||||
void s
|
||||
} 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
|
||||
@@ -1733,6 +1799,150 @@ export function registerAllTests(): void {
|
||||
})
|
||||
})
|
||||
|
||||
suite('ruw bundle — pack / unpack roundtrip', () => {
|
||||
// .ruw is a zip holding project.json + assets/<name>.<ext>. The
|
||||
// packer extracts every data-URL asset into its own file, the
|
||||
// unpacker inflates them back into data URLs so the rest of the
|
||||
// app sees the same project shape it sent in. Zero dependencies
|
||||
// — we roll the STORE-only zip codec in-house.
|
||||
test('zipStore + unzipStore round-trip preserves every byte', async () => {
|
||||
const { zipStore, unzipStore } = await import('@/lib/zipUtil')
|
||||
const a = new TextEncoder().encode('hello world')
|
||||
const b = new Uint8Array([0, 1, 2, 3, 255, 254, 253])
|
||||
const zip = zipStore([
|
||||
{ name: 'a.txt', data: a },
|
||||
{ name: 'b.bin', data: b },
|
||||
])
|
||||
const out = unzipStore(zip)
|
||||
expect(out.length).toBe(2)
|
||||
expect(out[0].name).toBe('a.txt')
|
||||
expect(new TextDecoder().decode(out[0].data)).toBe('hello world')
|
||||
expect(out[1].name).toBe('b.bin')
|
||||
expect(out[1].data.length).toBe(b.length)
|
||||
for (let i = 0; i < b.length; i++) expect(out[1].data[i]).toBe(b[i])
|
||||
})
|
||||
|
||||
test('looksLikeZip detects the PK header', async () => {
|
||||
const { looksLikeZip } = await import('@/lib/zipUtil')
|
||||
expect(looksLikeZip(new Uint8Array([0x50, 0x4b, 0x03, 0x04, 0]))).toBe(true)
|
||||
expect(looksLikeZip(new Uint8Array([0x7b]))).toBe(false) // '{'
|
||||
expect(looksLikeZip(new Uint8Array([]))).toBe(false)
|
||||
})
|
||||
|
||||
test('packProject extracts image + audio data URLs into assets/', async () => {
|
||||
const { packProject } = await import('@/lib/ruwFile')
|
||||
const { unzipStore } = await import('@/lib/zipUtil')
|
||||
const s = makeFreshStore()
|
||||
s.addAsset({
|
||||
kind: 'image',
|
||||
name: 'pixel',
|
||||
src: 'data:image/png;base64,iVBORw0KGgo=',
|
||||
width: 1,
|
||||
height: 1,
|
||||
})
|
||||
s.addAudioAsset({
|
||||
name: 'beep',
|
||||
src: 'data:audio/mp3;base64,U29tZUJ5dGVz',
|
||||
duration: 1,
|
||||
})
|
||||
const bundle = packProject(s.project)
|
||||
const entries = unzipStore(bundle)
|
||||
const names = entries.map((e) => e.name).sort()
|
||||
// Manifest + project.json + two assets.
|
||||
expect(names).toContain('manifest.json')
|
||||
expect(names).toContain('project.json')
|
||||
const assetEntries = entries.filter((e) => e.name.startsWith('assets/'))
|
||||
expect(assetEntries.length).toBe(2)
|
||||
// Extensions follow the MIME: .png and .mp3.
|
||||
expect(assetEntries.some((e) => e.name.endsWith('.png'))).toBe(true)
|
||||
expect(assetEntries.some((e) => e.name.endsWith('.mp3'))).toBe(true)
|
||||
})
|
||||
|
||||
test('pack + unpack round-trip rebuilds the data-URL srcs', async () => {
|
||||
const { packProject, unpackProject } = await import('@/lib/ruwFile')
|
||||
const s = makeFreshStore()
|
||||
// Image chosen so the MIME round-trips (image/png stays image/
|
||||
// png). Audio uses the canonical audio/mpeg so the test is
|
||||
// unambiguous — image/mp3 → image/mpeg is a valid lossless
|
||||
// label normalisation but not a round-trip equality.
|
||||
const imageSrc = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEX///+nxBvIAAAAC0lEQVQI12NgAAIAAAUAAeImBZsAAAAASUVORK5CYII='
|
||||
const audioPayload = 'SGVsbG9BdWRpbw=='
|
||||
const audioSrc = `data:audio/mpeg;base64,${audioPayload}`
|
||||
s.addAsset({
|
||||
kind: 'image',
|
||||
name: 'pixel',
|
||||
src: imageSrc,
|
||||
width: 1,
|
||||
height: 1,
|
||||
})
|
||||
s.addAudioAsset({ name: 'beep', src: audioSrc, duration: 0.5 })
|
||||
const bundle = packProject(s.project)
|
||||
const parsed = unpackProject(bundle)
|
||||
const img = parsed.assets.find((a) => a.name === 'pixel')! as {
|
||||
kind: 'image'; src: string
|
||||
}
|
||||
const aud = parsed.assets.find((a) => a.name === 'beep')! as {
|
||||
kind: 'audio'; src: string
|
||||
}
|
||||
// PNG round-trips identically (MIME matches canonical form).
|
||||
expect(img.src).toBe(imageSrc)
|
||||
// MP3 payload (base64) survives even though the label may have
|
||||
// been normalised — assert on the base64 body so the test
|
||||
// doesn't care about audio/mp3 vs audio/mpeg labelling.
|
||||
expect(aud.src.endsWith(audioPayload)).toBe(true)
|
||||
expect(aud.src.startsWith('data:audio/')).toBe(true)
|
||||
})
|
||||
|
||||
test('external URLs are left alone in both directions', async () => {
|
||||
const { packProject, unpackProject } = await import('@/lib/ruwFile')
|
||||
const { unzipStore } = await import('@/lib/zipUtil')
|
||||
const s = makeFreshStore()
|
||||
s.addAsset({
|
||||
kind: 'image', name: 'remote',
|
||||
src: 'https://example.com/pic.png',
|
||||
width: 1, height: 1,
|
||||
})
|
||||
const bundle = packProject(s.project)
|
||||
const entries = unzipStore(bundle)
|
||||
// No assets/ entry created for external URLs.
|
||||
expect(entries.some((e) => e.name.startsWith('assets/'))).toBe(false)
|
||||
const parsed = unpackProject(bundle)
|
||||
const img = parsed.assets.find((a) => a.name === 'remote')! as {
|
||||
src: string
|
||||
}
|
||||
expect(img.src).toBe('https://example.com/pic.png')
|
||||
})
|
||||
|
||||
test('readAnyProjectFile sniffs .ruw vs legacy .json', async () => {
|
||||
const { packProject, readAnyProjectFile } = await import('@/lib/ruwFile')
|
||||
const s = makeFreshStore()
|
||||
s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||||
// .ruw path
|
||||
const ruwBytes = packProject(s.project)
|
||||
const ruwFile = new File([ruwBytes], 'test.ruw', { type: 'application/zip' })
|
||||
const fromRuw = await readAnyProjectFile(ruwFile)
|
||||
expect(fromRuw.assets.some((a) => a.name === 'R')).toBe(true)
|
||||
// .json path (legacy)
|
||||
const jsonText = serializeProject(s.project)
|
||||
const jsonFile = new File([jsonText], 'test.json', { type: 'application/json' })
|
||||
const fromJson = await readAnyProjectFile(jsonFile)
|
||||
expect(fromJson.assets.some((a) => a.name === 'R')).toBe(true)
|
||||
})
|
||||
|
||||
test('pack preserves editor state via the embedded project.json', async () => {
|
||||
const { packProject, unpackProject } = await import('@/lib/ruwFile')
|
||||
const s = makeFreshStore()
|
||||
s.editor.canvasZoom = 2.5
|
||||
s.editor.centerCrosshair = true
|
||||
// Mirror the App.vue save path — projectForSave bundles editor.
|
||||
const payload = JSON.parse(JSON.stringify(s.projectForSave))
|
||||
const bundle = packProject(payload)
|
||||
const parsed = unpackProject(bundle)
|
||||
expect(parsed.editor?.canvasZoom).toBe(2.5)
|
||||
expect(parsed.editor?.centerCrosshair).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
suite('editor state — save / load roundtrip', () => {
|
||||
// The store's editor block carries canvas zoom/pan, overlay
|
||||
// toggles and timeline display settings. It rides along with the
|
||||
|
||||
Reference in new issue
Block a user