@@ -667,6 +742,12 @@ function onUpdate(v: boolean) {
justify-content: flex-end;
}
+.target-size-hint {
+ margin-left: 4px;
+ color: var(--text-dim);
+ font-size: 11px;
+}
+
.target-size-reset {
margin-left: auto;
padding: 4px 8px;
diff --git a/src/devtools/tests.ts b/src/devtools/tests.ts
index 6319899..86a387a 100644
--- a/src/devtools/tests.ts
+++ b/src/devtools/tests.ts
@@ -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/
.. 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
diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts
index 94abf5b..5578d76 100644
--- a/src/i18n/messages.ts
+++ b/src/i18n/messages.ts
@@ -454,6 +454,12 @@ export const en = {
targetSize: 'Target size',
targetSizeReset: 'Reset to scene canvas size',
targetSizeResetLabel: 'Reset',
+ targetFps: 'Target FPS',
+ targetFpsHint: 'Lower → smaller file',
+ targetFpsReset: 'Reset to scene FPS',
+ ruwTitle: 'RUWIO project (.ruw)',
+ ruwBadge: 'Reimportable',
+ ruwDesc: 'Zipped bundle — project JSON + every image / audio asset as a separate file. Drop it onto the welcome screen to reopen.',
},
tests: {
diff --git a/src/lib/frameExport.ts b/src/lib/frameExport.ts
index 3bb290f..b923f9d 100644
--- a/src/lib/frameExport.ts
+++ b/src/lib/frameExport.ts
@@ -37,6 +37,11 @@ export interface FrameExportOptions {
/** Output canvas height in pixels. Defaults to the scene canvas
* height. */
targetHeight?: number
+ /** Frames-per-second to sample + encode at. Defaults to
+ * `scene.fps`. Lower values shrink the file dramatically (GIF /
+ * APNG size scales ~linearly with frame count) while staying
+ * perceptually smooth for most editor content. */
+ targetFps?: number
}
export interface FrameRenderContext {
@@ -87,7 +92,12 @@ export async function renderFrames(
const images = await loadSceneImages(scene)
const { start: rangeIn, end: rangeOut } = playbackRange(scene)
const rangeLen = Math.max(0, rangeOut - rangeIn)
- const fps = Math.max(1, Math.round(scene.fps || 30))
+ // Target FPS overrides the scene's capture rate — crucial for
+ // APNG / GIF where each frame is its own encoded image and the
+ // default 60 fps project produces files 2–3× larger than anyone
+ // needs for a motion graphic.
+ const requestedFps = opts.targetFps ?? scene.fps
+ const fps = Math.max(1, Math.round(requestedFps || 30))
const frameCount = Math.max(1, Math.round(rangeLen * fps))
const delayMs = 1000 / fps
diff --git a/src/lib/projectFile.ts b/src/lib/projectFile.ts
index cd6953d..68e9f83 100644
--- a/src/lib/projectFile.ts
+++ b/src/lib/projectFile.ts
@@ -101,7 +101,11 @@ export function pickProjectFile(): Promise {
return new Promise((resolve) => {
const input = document.createElement('input')
input.type = 'file'
- input.accept = '.json,application/json'
+ // Accept the new .ruw bundle + legacy .json. Filtering by
+ // extension is forgiving — some platforms map .ruw to
+ // application/zip, others to the generic octet-stream, so we
+ // don't constrain by MIME.
+ input.accept = '.ruw,.json,application/json,application/zip'
input.onchange = () => {
const f = input.files?.[0]
resolve(f ?? null)
diff --git a/src/lib/ruwFile.ts b/src/lib/ruwFile.ts
new file mode 100644
index 0000000..9eb2dd7
--- /dev/null
+++ b/src/lib/ruwFile.ts
@@ -0,0 +1,218 @@
+/**
+ * .ruw bundle format — a zip archive wrapping one project plus its
+ * binary assets as individual files. Everything the browser keeps in
+ * the running project as data URLs gets extracted into `assets/*`
+ * and the in-file JSON carries relative paths instead. That keeps
+ * the on-disk size tight (no base64 overhead on images / audio),
+ * leaves the project JSON human-readable, and lets other tools peek
+ * at the assets without decoding anything.
+ *
+ * Bundle layout:
+ * manifest.json — `{ magic: 'ruwio', version: 1 }`
+ * project.json — serialized Project, same schema as the legacy
+ * .json export but with data-URL asset `src`
+ * fields replaced by `assets/` paths.
+ * assets/ — one file per extracted data URL, named
+ * `asset-NNN.` derived from the MIME type.
+ */
+
+import type { Project } from '@/types/scene'
+import { looksLikeZip, unzipStore, zipStore, type ZipEntry } from './zipUtil'
+import { parseProject, serializeProject } from './projectFile'
+
+const RUW_VERSION = 1
+const ASSET_DIR = 'assets/'
+
+interface Parsed {
+ mime: string
+ bytes: Uint8Array
+}
+
+/** Pull the raw bytes + MIME type out of a data URL. Returns null on
+ * anything that doesn't start with `data:` (http URLs, already-
+ * extracted asset paths, …) so callers can leave those untouched. */
+function parseDataUrl(src: string): Parsed | null {
+ if (!src.startsWith('data:')) return null
+ const commaAt = src.indexOf(',')
+ if (commaAt < 0) return null
+ const header = src.slice(5, commaAt)
+ const body = src.slice(commaAt + 1)
+ const isBase64 = /;base64$/i.test(header)
+ const mime = (isBase64 ? header.slice(0, header.length - 7) : header) || 'application/octet-stream'
+ if (isBase64) {
+ const bin = atob(body)
+ const bytes = new Uint8Array(bin.length)
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
+ return { mime, bytes }
+ }
+ // Percent-encoded text (mostly SVG).
+ const text = decodeURIComponent(body)
+ return { mime, bytes: new TextEncoder().encode(text) }
+}
+
+function extForMime(mime: string): string {
+ const m = mime.toLowerCase()
+ if (m.includes('svg')) return '.svg'
+ if (m === 'image/png') return '.png'
+ if (m === 'image/jpeg' || m === 'image/jpg') return '.jpg'
+ if (m === 'image/gif') return '.gif'
+ if (m === 'image/webp') return '.webp'
+ if (m === 'image/avif') return '.avif'
+ if (m === 'audio/mpeg' || m === 'audio/mp3') return '.mp3'
+ if (m === 'audio/wav' || m === 'audio/x-wav' || m === 'audio/wave') return '.wav'
+ if (m === 'audio/ogg' || m === 'audio/opus') return '.ogg'
+ if (m === 'audio/mp4' || m === 'audio/m4a' || m === 'audio/x-m4a') return '.m4a'
+ if (m === 'audio/flac') return '.flac'
+ if (m.startsWith('video/webm')) return '.webm'
+ if (m.startsWith('video/mp4')) return '.mp4'
+ if (m.startsWith('video/quicktime')) return '.mov'
+ return '.bin'
+}
+
+function mimeForExt(ext: string): string {
+ switch (ext.toLowerCase()) {
+ case '.svg': return 'image/svg+xml'
+ case '.png': return 'image/png'
+ case '.jpg': case '.jpeg': return 'image/jpeg'
+ case '.gif': return 'image/gif'
+ case '.webp': return 'image/webp'
+ case '.avif': return 'image/avif'
+ case '.mp3': return 'audio/mpeg'
+ case '.wav': return 'audio/wav'
+ case '.ogg': return 'audio/ogg'
+ case '.m4a': return 'audio/mp4'
+ case '.flac': return 'audio/flac'
+ case '.webm': return 'video/webm'
+ case '.mp4': return 'video/mp4'
+ case '.mov': return 'video/quicktime'
+ default: return 'application/octet-stream'
+ }
+}
+
+/** Binary → data URL. SVG is percent-encoded (mirrors how the rest
+ * of the app stores inline SVG); everything else base64'd. */
+function bytesToDataUrl(mime: string, bytes: Uint8Array): string {
+ if (/svg/i.test(mime)) {
+ const text = new TextDecoder().decode(bytes)
+ const encoded = encodeURIComponent(text).replace(
+ /%(20|22|3D)/g,
+ (_, c) => ({ '20': ' ', '22': '"', '3D': '=' })[c as '20' | '22' | '3D'] ?? _,
+ )
+ return `data:${mime},${encoded}`
+ }
+ let bin = ''
+ for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i])
+ return `data:${mime};base64,${btoa(bin)}`
+}
+
+type SrcHolder = { src: string; name?: string }
+
+/** Walk every place the project stores a `src` data URL. Each visited
+ * holder is passed to `visit`, which returns the new (possibly
+ * unchanged) src string. */
+function visitSrcs(project: Project, visit: (h: SrcHolder) => string): void {
+ for (const a of project.assets) {
+ if ((a.kind === 'image' || a.kind === 'audio') && typeof a.src === 'string') {
+ const replaced = visit({ src: a.src, name: a.name })
+ if (replaced !== a.src) (a as unknown as { src: string }).src = replaced
+ }
+ }
+ for (const page of project.scenes) {
+ if (page.backgroundVideo?.src) {
+ const replaced = visit({ src: page.backgroundVideo.src, name: page.backgroundVideo.name })
+ if (replaced !== page.backgroundVideo.src) page.backgroundVideo.src = replaced
+ }
+ }
+}
+
+/** Produce a ZIP byte stream holding the project + each data-URL
+ * asset pulled out into its own file. External URLs are left as-is
+ * (we don't want to download the world at save time). */
+export function packProject(project: Project): Uint8Array {
+ const clone = JSON.parse(JSON.stringify(project)) as Project
+ const extracted = new Map()
+ const entries: ZipEntry[] = []
+ let idx = 0
+ visitSrcs(clone, ({ src }) => {
+ const cached = extracted.get(src)
+ if (cached) return cached
+ const parsed = parseDataUrl(src)
+ if (!parsed) return src
+ idx += 1
+ const name = `${ASSET_DIR}asset-${String(idx).padStart(3, '0')}${extForMime(parsed.mime)}`
+ entries.push({ name, data: parsed.bytes })
+ extracted.set(src, name)
+ return name
+ })
+ entries.push({
+ name: 'manifest.json',
+ data: new TextEncoder().encode(
+ JSON.stringify({ magic: 'ruwio', version: RUW_VERSION }) + '\n',
+ ),
+ })
+ entries.push({
+ name: 'project.json',
+ data: new TextEncoder().encode(serializeProject(clone)),
+ })
+ return zipStore(entries)
+}
+
+/** Reverse of `packProject`: parse the zip, inflate asset paths back
+ * into data URLs so the rest of the app (which expects data-URL
+ * `src` fields) keeps working without any further plumbing. */
+export function unpackProject(bytes: Uint8Array): Project {
+ if (!looksLikeZip(bytes)) {
+ throw new Error('Not a RUWIO bundle (missing ZIP signature).')
+ }
+ const entries = unzipStore(bytes)
+ const byName = new Map()
+ for (const e of entries) byName.set(e.name, e.data)
+ const projectJson = byName.get('project.json')
+ if (!projectJson) {
+ throw new Error('RUWIO bundle is missing project.json.')
+ }
+ const project = parseProject(new TextDecoder().decode(projectJson))
+ visitSrcs(project, ({ src }) => {
+ if (!src.startsWith(ASSET_DIR)) return src
+ const data = byName.get(src)
+ if (!data) return src
+ const extAt = src.lastIndexOf('.')
+ const ext = extAt >= 0 ? src.slice(extAt) : ''
+ return bytesToDataUrl(mimeForExt(ext), data)
+ })
+ return project
+}
+
+/** Trigger a browser download of the .ruw bundle. */
+export function downloadRuwFile(project: Project, filename = 'projekt.ruw'): void {
+ const bytes = packProject(project)
+ // The underlying buffer might be a SharedArrayBuffer-backed typed
+ // array in some environments; copy into a plain Uint8Array so Blob
+ // owns a straight ArrayBuffer slice.
+ const copy = new Uint8Array(bytes.byteLength)
+ copy.set(bytes)
+ const blob = new Blob([copy], { type: 'application/zip' })
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement('a')
+ a.href = url
+ a.download = filename
+ document.body.appendChild(a)
+ a.click()
+ document.body.removeChild(a)
+ setTimeout(() => URL.revokeObjectURL(url), 1000)
+}
+
+/** Parse a .ruw file the user picked through . */
+export async function readRuwFile(file: File): Promise {
+ const buf = new Uint8Array(await file.arrayBuffer())
+ return unpackProject(buf)
+}
+
+/** Accept either a .ruw (zip) or legacy .json project and return the
+ * Project. Chooses the path by sniffing the first four bytes — .ruw
+ * starts with the PK local-header signature, .json with `{`. */
+export async function readAnyProjectFile(file: File): Promise {
+ const buf = new Uint8Array(await file.arrayBuffer())
+ if (looksLikeZip(buf)) return unpackProject(buf)
+ return parseProject(new TextDecoder().decode(buf))
+}
diff --git a/src/lib/zipUtil.ts b/src/lib/zipUtil.ts
new file mode 100644
index 0000000..cd3293a
--- /dev/null
+++ b/src/lib/zipUtil.ts
@@ -0,0 +1,185 @@
+/**
+ * Minimal in-house ZIP reader/writer (STORE method only — no
+ * compression). Keeps the project .ruw bundle format dependency-free.
+ * Deflate adds a CPU cost without meaningful savings: project.json is
+ * smallish and the asset payloads (PNG, JPEG, MP3, …) are already
+ * compressed by their native formats. Store is fast, safe, and any
+ * real unzip tool opens the output.
+ */
+
+export interface ZipEntry {
+ /** Path inside the archive. Use forward slashes. */
+ name: string
+ data: Uint8Array
+}
+
+const SIG_LOCAL = 0x04034b50
+const SIG_CENTRAL = 0x02014b50
+const SIG_EOCD = 0x06054b50
+const VERSION_NEEDED = 20
+
+const CRC_TABLE = (() => {
+ const t = new Uint32Array(256)
+ for (let i = 0; i < 256; i++) {
+ let c = i
+ for (let k = 0; k < 8; k++) {
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
+ }
+ t[i] = c >>> 0
+ }
+ return t
+})()
+
+function crc32(data: Uint8Array): number {
+ let c = 0xffffffff
+ for (let i = 0; i < data.length; i++) {
+ c = CRC_TABLE[(c ^ data[i]) & 0xff]! ^ (c >>> 8)
+ }
+ return (c ^ 0xffffffff) >>> 0
+}
+
+/** Pack a list of file entries into a flat STORE zip byte stream. */
+export function zipStore(entries: ZipEntry[]): Uint8Array {
+ const enc = new TextEncoder()
+ const localChunks: Uint8Array[] = []
+ const centralChunks: Uint8Array[] = []
+ let offset = 0
+ for (const entry of entries) {
+ const nameBytes = enc.encode(entry.name)
+ const data = entry.data
+ const crc = crc32(data)
+ // Local file header.
+ const local = new Uint8Array(30 + nameBytes.length)
+ const ldv = new DataView(local.buffer)
+ ldv.setUint32(0, SIG_LOCAL, true)
+ ldv.setUint16(4, VERSION_NEEDED, true)
+ ldv.setUint16(6, 0, true)
+ ldv.setUint16(8, 0, true) // compression = STORE
+ ldv.setUint16(10, 0, true)
+ ldv.setUint16(12, 0, true)
+ ldv.setUint32(14, crc, true)
+ ldv.setUint32(18, data.length, true)
+ ldv.setUint32(22, data.length, true)
+ ldv.setUint16(26, nameBytes.length, true)
+ ldv.setUint16(28, 0, true)
+ local.set(nameBytes, 30)
+ localChunks.push(local)
+ localChunks.push(data)
+
+ // Central directory entry pointing back to the local header.
+ const cdh = new Uint8Array(46 + nameBytes.length)
+ const cdv = new DataView(cdh.buffer)
+ cdv.setUint32(0, SIG_CENTRAL, true)
+ cdv.setUint16(4, VERSION_NEEDED, true)
+ cdv.setUint16(6, VERSION_NEEDED, true)
+ cdv.setUint16(8, 0, true)
+ cdv.setUint16(10, 0, true)
+ cdv.setUint16(12, 0, true)
+ cdv.setUint16(14, 0, true)
+ cdv.setUint32(16, crc, true)
+ cdv.setUint32(20, data.length, true)
+ cdv.setUint32(24, data.length, true)
+ cdv.setUint16(28, nameBytes.length, true)
+ cdv.setUint16(30, 0, true)
+ cdv.setUint16(32, 0, true)
+ cdv.setUint16(34, 0, true)
+ cdv.setUint16(36, 0, true)
+ cdv.setUint32(38, 0, true)
+ cdv.setUint32(42, offset, true)
+ cdh.set(nameBytes, 46)
+ centralChunks.push(cdh)
+
+ offset += local.length + data.length
+ }
+
+ let centralSize = 0
+ for (const c of centralChunks) centralSize += c.length
+
+ const eocd = new Uint8Array(22)
+ const edv = new DataView(eocd.buffer)
+ edv.setUint32(0, SIG_EOCD, true)
+ edv.setUint16(4, 0, true)
+ edv.setUint16(6, 0, true)
+ edv.setUint16(8, entries.length, true)
+ edv.setUint16(10, entries.length, true)
+ edv.setUint32(12, centralSize, true)
+ edv.setUint32(16, offset, true)
+ edv.setUint16(20, 0, true)
+
+ let total = eocd.length + centralSize
+ for (const c of localChunks) total += c.length
+ const out = new Uint8Array(total)
+ let pos = 0
+ for (const c of localChunks) { out.set(c, pos); pos += c.length }
+ for (const c of centralChunks) { out.set(c, pos); pos += c.length }
+ out.set(eocd, pos)
+ return out
+}
+
+/** Parse a STORE-only zip stream. Throws on bad signatures, truncated
+ * data, or compressed entries (we don't ship a deflate path). */
+export function unzipStore(bytes: Uint8Array): ZipEntry[] {
+ const dec = new TextDecoder()
+ // EOCD is at least 22 bytes and may have up to 65535 bytes of
+ // trailing comment; scan from the end within that window.
+ let eocdOff = -1
+ const minOff = Math.max(0, bytes.length - (22 + 65535))
+ for (let i = bytes.length - 22; i >= minOff; i--) {
+ const dv = new DataView(bytes.buffer, bytes.byteOffset + i, 4)
+ if (dv.getUint32(0, true) === SIG_EOCD) {
+ eocdOff = i
+ break
+ }
+ }
+ if (eocdOff < 0) throw new Error('Not a ZIP archive (EOCD not found).')
+ const edv = new DataView(bytes.buffer, bytes.byteOffset + eocdOff)
+ const total = edv.getUint16(10, true)
+ const centralOff = edv.getUint32(16, true)
+
+ const entries: ZipEntry[] = []
+ let p = centralOff
+ for (let i = 0; i < total; i++) {
+ const cdv = new DataView(bytes.buffer, bytes.byteOffset + p)
+ if (cdv.getUint32(0, true) !== SIG_CENTRAL) {
+ throw new Error('Bad central directory entry.')
+ }
+ const method = cdv.getUint16(10, true)
+ const size = cdv.getUint32(24, true)
+ const nameLen = cdv.getUint16(28, true)
+ const extraLen = cdv.getUint16(30, true)
+ const commentLen = cdv.getUint16(32, true)
+ const localOff = cdv.getUint32(42, true)
+ if (method !== 0) {
+ throw new Error(`Unsupported ZIP compression method ${method} — only STORE is accepted.`)
+ }
+ const nameBytes = bytes.subarray(p + 46, p + 46 + nameLen)
+ const name = dec.decode(nameBytes)
+ // Re-read the local header to skip its variable-length fields
+ // before the file data.
+ const ldv = new DataView(bytes.buffer, bytes.byteOffset + localOff)
+ if (ldv.getUint32(0, true) !== SIG_LOCAL) {
+ throw new Error('Bad local file header.')
+ }
+ const lNameLen = ldv.getUint16(26, true)
+ const lExtraLen = ldv.getUint16(28, true)
+ const dataOff = localOff + 30 + lNameLen + lExtraLen
+ const data = bytes.subarray(dataOff, dataOff + size)
+ // Copy so callers get a standalone Uint8Array they can keep.
+ entries.push({ name, data: new Uint8Array(data) })
+ p += 46 + nameLen + extraLen + commentLen
+ }
+ return entries
+}
+
+/** Magic check — `true` when the first four bytes match the ZIP local
+ * file header signature (PK\x03\x04). Used by the project-file
+ * reader to decide between .ruw (zip) and .json (legacy). */
+export function looksLikeZip(bytes: Uint8Array): boolean {
+ return (
+ bytes.length >= 4 &&
+ bytes[0] === 0x50 &&
+ bytes[1] === 0x4b &&
+ bytes[2] === 0x03 &&
+ bytes[3] === 0x04
+ )
+}
diff --git a/src/version.ts b/src/version.ts
index 5d1531c..fdf097b 100644
--- a/src/version.ts
+++ b/src/version.ts
@@ -1,2 +1,2 @@
-export const APP_VERSION = '1.0.2'
+export const APP_VERSION = '1.1.0'
export const APP_NAME = 'RUWIO'