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

+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "ruwio",
"private": true,
"version": "1.0.2",
"version": "1.1.0",
"type": "module",
"scripts": {
"dev": "vite",
+5 -14
View File
@@ -41,12 +41,8 @@ import { useAudioRuntime } from './composables/useAudioRuntime'
import { useDocumentDrawer } from './composables/useDocumentDrawer'
import { usePlaybackLoop } from './composables/usePlaybackLoop'
import { useProjectName } from './composables/useProjectName'
import {
ProjectFileError,
downloadProject,
pickProjectFile,
readProjectFile,
} from './lib/projectFile'
import { ProjectFileError, pickProjectFile } from './lib/projectFile'
import { readAnyProjectFile } from './lib/ruwFile'
import {
getLastOpenedId,
listRecentProjects,
@@ -209,13 +205,6 @@ function openProjectNameDrawer() {
documentDrawer.toggle()
}
function exportProjectFile() {
const project = JSON.parse(JSON.stringify(store.projectForSave))
const base = currentProjectName.value ?? 'ruwio'
const safe = base.replace(/[^a-z0-9äöüß_-]+/gi, '-').replace(/^-+|-+$/g, '') || 'ruwio'
downloadProject(project, `${safe}.json`)
}
async function importFromFile() {
const file = await pickProjectFile()
if (!file) return
@@ -230,7 +219,9 @@ async function importFromFile() {
if (!ok) return
}
try {
const project = await readProjectFile(file)
// Reader auto-detects ruw (zip) vs legacy json based on the
// leading bytes, so both formats flow through the same code path.
const project = await readAnyProjectFile(file)
store.loadProject(project)
history.resetTo(project)
currentProjectId.value = null
+93 -12
View File
@@ -4,6 +4,7 @@ import {
PhCode,
PhDownloadSimple,
PhEngine,
PhFileArchive,
PhFileHtml,
PhFileImage,
PhFilmSlate,
@@ -15,6 +16,8 @@ import { downloadSceneAsVideo, pickMimeType } from '@/lib/videoExport'
import { downloadSceneAsGif } from '@/lib/gifExport'
import { downloadSceneAsApng, downloadSceneAsApngPreview } from '@/lib/apngExport'
import { downloadFromServer } from '@/lib/serverExport'
import { downloadRuwFile } from '@/lib/ruwFile'
import { useProjectName } from '@/composables/useProjectName'
import type { ButtonAsset, Layer } from '@/types/scene'
import { Modal } from '@/components/ui'
import { t } from '@/i18n'
@@ -23,6 +26,7 @@ const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const store = useSceneStore()
const { projectName } = useProjectName()
interface ProgressState {
progress: number
@@ -62,28 +66,53 @@ function resetTargetSize() {
targetWidth.value = Math.max(1, Math.round(store.scene.canvas.width))
}
// Target capture frame rate. Scene default is often 60 for smooth
// playback in-editor, but 60fps APNG / GIF bloats the file for no
// perceptual gain — let the user dial it down per export without
// touching the project's own fps.
const targetFps = ref(Math.max(1, Math.round(store.scene.fps || 30)))
const targetFpsChanged = computed(
() => targetFps.value !== Math.max(1, Math.round(store.scene.fps || 30)),
)
function resetTargetFps() {
targetFps.value = Math.max(1, Math.round(store.scene.fps || 30))
}
// Every time the dialog re-opens, inherit the CURRENT document
// canvas size. Without this, a user who resized the stage after
// first opening the dialog would keep seeing the stale value from
// whenever ExportDialog originally instantiated.
// canvas size + fps. Without this, a user who resized the stage or
// switched project fps after first opening the dialog would keep
// seeing the stale values.
watch(
() => props.modelValue,
(open) => {
if (open) resetTargetSize()
if (open) {
resetTargetSize()
resetTargetFps()
}
},
)
/** Shared render options for GIF / APNG / APNG-preview. Only sends
* target dimensions when they actually differ from the scene's own
* size so a same-size export keeps the old no-scaling code path. */
* the overrides that actually differ from the scene so same-size
* / same-fps exports keep the old no-override code paths. */
function frameExportOpts(controller: AbortController, onProg: (p: number) => void) {
const base = { signal: controller.signal, onProgress: onProg }
if (!targetSizeChanged.value) return base
return {
...base,
targetWidth: targetWidth.value,
targetHeight: targetHeight.value,
const base: {
signal: AbortSignal
onProgress: (p: number) => void
targetWidth?: number
targetHeight?: number
targetFps?: number
} = { signal: controller.signal, onProgress: onProg }
if (targetSizeChanged.value) {
base.targetWidth = targetWidth.value
base.targetHeight = targetHeight.value
}
if (targetFpsChanged.value) {
base.targetFps = targetFps.value
}
return base
}
const hasHiddenLayers = computed(() => {
@@ -176,6 +205,17 @@ function exportRuntime() {
emit('update:modelValue', false)
}
function exportRuwBundle() {
// Re-import target: .ruw is the round-trippable format the welcome
// dialog + file-picker accept. Pull the project via projectForSave
// so the editor-session UI state (zoom, toggles, …) rides along.
const project = JSON.parse(JSON.stringify(store.projectForSave))
const base = (projectName.value ?? 'ruwio').toString()
const safe = base.replace(/[^a-z0-9äöüß_-]+/gi, '-').replace(/^-+|-+$/g, '') || 'ruwio'
downloadRuwFile(project, `${safe}.ruw`)
emit('update:modelValue', false)
}
function exportHtml() {
downloadExampleHtml(projectClone())
emit('update:modelValue', false)
@@ -366,6 +406,18 @@ function onUpdate(v: boolean) {
</div>
</label>
<button class="opt" @click="exportRuwBundle">
<div class="opt-icon"><PhFileArchive :size="20" /></div>
<div class="opt-text">
<div class="opt-title">
{{ t('export.ruwTitle') }}
<span class="badge accent">{{ t('export.ruwBadge') }}</span>
</div>
<div class="opt-desc">{{ t('export.ruwDesc') }}</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
<button class="opt" @click="exportRuntime">
<div class="opt-icon"><PhEngine :size="20" /></div>
<div class="opt-text">
@@ -452,6 +504,29 @@ function onUpdate(v: boolean) {
</button>
</div>
<div class="target-size">
<div class="target-size-label">{{ t('export.targetFps') }}</div>
<label class="target-size-input">
<input
type="number"
min="1"
step="1"
:value="targetFps"
@input="targetFps = Math.max(1, Math.round(Number(($event.target as HTMLInputElement).value) || 1))"
/>
<span class="target-size-tag">fps</span>
</label>
<span class="target-size-hint">{{ t('export.targetFpsHint') }}</span>
<button
v-if="targetFpsChanged"
class="target-size-reset"
v-tooltip="t('export.targetFpsReset')"
@click="resetTargetFps"
>
{{ t('export.targetSizeResetLabel') }}
</button>
</div>
<button class="opt" @click="exportGif">
<div class="opt-icon"><PhGif :size="20" /></div>
<div class="opt-text">
@@ -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;
+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
+6
View File
@@ -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: {
+11 -1
View File
@@ -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
+5 -1
View File
@@ -101,7 +101,11 @@ export function pickProjectFile(): Promise<File | null> {
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)
+218
View File
@@ -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/<name>` paths.
* assets/ — one file per extracted data URL, named
* `asset-NNN.<ext>` 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<string, string>()
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<string, Uint8Array>()
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 <input type="file">. */
export async function readRuwFile(file: File): Promise<Project> {
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<Project> {
const buf = new Uint8Array(await file.arrayBuffer())
if (looksLikeZip(buf)) return unpackProject(buf)
return parseProject(new TextDecoder().decode(buf))
}
+185
View File
@@ -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
)
}
+1 -1
View File
@@ -1,2 +1,2 @@
export const APP_VERSION = '1.0.2'
export const APP_VERSION = '1.1.0'
export const APP_NAME = 'RUWIO'