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:
thandClaude Opus 4.7 committed 2026-04-24 20:50:21 +02:00
1 parent 647f09b287
commit 92e9f1619f
10 files changed
+735 -30

No files matched your search

+210
View File
@@ -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