From 92e9f1619f52271a167e491db8a71562b0d8c6e9 Mon Sep 17 00:00:00 2001 From: th Date: Fri, 24 Apr 2026 20:50:21 +0200 Subject: [PATCH] 1.1.0: .ruw project bundle + per-export target FPS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- package.json | 2 +- src/App.vue | 19 +-- src/components/ExportDialog.vue | 105 +++++++++++++-- src/devtools/tests.ts | 210 ++++++++++++++++++++++++++++++ src/i18n/messages.ts | 6 + src/lib/frameExport.ts | 12 +- src/lib/projectFile.ts | 6 +- src/lib/ruwFile.ts | 218 ++++++++++++++++++++++++++++++++ src/lib/zipUtil.ts | 185 +++++++++++++++++++++++++++ src/version.ts | 2 +- 10 files changed, 735 insertions(+), 30 deletions(-) create mode 100644 src/lib/ruwFile.ts create mode 100644 src/lib/zipUtil.ts diff --git a/package.json b/package.json index bc4f2ba..4171770 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ruwio", "private": true, - "version": "1.0.2", + "version": "1.1.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src/App.vue b/src/App.vue index b645b2b..300f516 100644 --- a/src/App.vue +++ b/src/App.vue @@ -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 diff --git a/src/components/ExportDialog.vue b/src/components/ExportDialog.vue index 990ae00..fffc157 100644 --- a/src/components/ExportDialog.vue +++ b/src/components/ExportDialog.vue @@ -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) { + + +
+
{{ t('export.targetFps') }}
+ + {{ t('export.targetFpsHint') }} + +
+