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') }} + +
+