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
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ruwio",
|
||||
"private": true,
|
||||
"version": "1.0.2",
|
||||
"version": "1.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+5
-14
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
@@ -1,2 +1,2 @@
|
||||
export const APP_VERSION = '1.0.2'
|
||||
export const APP_VERSION = '1.1.0'
|
||||
export const APP_NAME = 'RUWIO'
|
||||
Reference in new issue
Block a user