Compare commits

...
7 Commits
Author SHA1 Message Date
th b18e66c28d Add export environment plan 2026-06-07 13:24:34 +02:00
th f469ab7444 Clarify export runtime copy 2026-06-07 12:10:35 +02:00
th 8d296c963e Update development doc references 2026-06-07 11:10:05 +02:00
th 00ee563311 Add Electron E2E harness launcher 2026-06-07 11:09:39 +02:00
th fa8c52b239 Split renderer bundles by feature 2026-06-07 11:08:41 +02:00
th 7bf18636d6 Fix desktop packaging artifact build 2026-06-07 11:08:15 +02:00
th ba58ebb48d Bump version to 1.3.1 2026-06-07 11:06:50 +02:00
19 changed files with 531 additions and 68 deletions

No files matched your search

+6 -4
View File
@@ -60,13 +60,15 @@ same Vite-built app from `dist/` plus the compiled Electron entry points in
```bash
npm install
npm run desktop:dev # start Vite, then launch Electron against localhost
npm run desktop:pack # build an unpacked desktop app into release/
npm run desktop:dir # build an unpacked desktop app into release/
npm run desktop:pack # build platform package artifacts into release/
npm run desktop:build # build platform installer/package artifacts into release/
```
`desktop:pack` is the quickest compile check because it produces an unpacked
app without creating an installer. `desktop:build` uses `electron-builder` and
targets AppImage on Linux, NSIS on Windows, and DMG on macOS.
`desktop:dir` is the quickest compile check because it produces an unpacked app
without creating an installer. `desktop:pack` and `desktop:build` use
`electron-builder` and target AppImage on Linux, NSIS on Windows, and DMG on
macOS.
Desktop MP4 export runs inside Electron and uses ffmpeg from
`resources/bin/<platform>/` in packaged builds. The build scripts run
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

+2 -2
View File
@@ -2,7 +2,7 @@
Date: 2026-06-05
Source review: `dev_docs/security-review.md`
Source review: `dev_docs/02_security-review.md`
## Goal
@@ -19,7 +19,7 @@ Close the security issues from the review without changing RUWIO's core authorin
Files likely touched:
- `dev_docs/security-review.md`
- `dev_docs/02_security-review.md`
- `package.json`
- `server/package.json`
- `server/server.js`
@@ -2,7 +2,7 @@
Date: 2026-06-05
This document supersedes `electron-rendering-integration-plan.md` for ongoing
This document supersedes `04_electron-rendering-integration-plan.md` for ongoing
work. It is self-contained: you do not need the original plan to continue from
here. It records what is already implemented, what was found during review, and
the concrete remaining TODOs in priority order.
+3 -3
View File
@@ -2,7 +2,7 @@
Date: 2026-06-06
This document is a follow-on to `electron-rendering-integration-plan_2.md`.
This document is a follow-on to `05_electron-rendering-integration-plan_2.md`.
That plan tracked findings **#1–#6** (all resolved). A second review of the
uncommitted desktop-render changes surfaced a new set of issues, numbered
**#7–#13** here to avoid collision. Two of them (#7, #8) are user-facing data
@@ -351,6 +351,6 @@ and/or a desktop check asserting a clean result instead of an ffmpeg crash.
`*://*/*` network block (plan_2 #5) does not drop them — no action needed.
## Suggested doc bookkeeping
When these land, update `electron-rendering-integration-plan_2.md`:
When these land, update `05_electron-rendering-integration-plan_2.md`:
- correct the "Partial output file is removed on failure/cancel" note (#7),
- and `electron-rendering-manual-checks.md` with the new verify scripts.
- and `06_electron-rendering-manual-checks.md` with the new verify scripts.
@@ -12,7 +12,7 @@ It builds on what already exists — do not re-invent these:
- the Node/Electron **render harnesses** (`scripts/verify-*.cjs`),
- the **server** tests (`server/{server.smoke,render.e2e}.test.cjs`),
- the **shared validation** unit tests (`shared/renderValidation.test.cjs`),
- the manual checklist (`electron-rendering-manual-checks.md`).
- the manual checklist (`06_electron-rendering-manual-checks.md`).
> **TL;DR**
> 1. **Don't fork the suite per build.** The frontend is ~99% shared and gated
@@ -321,7 +321,7 @@ Phased so each phase is independently valuable and CI-wireable.
- A root `test` script aggregating the CI-safe layers, and a first
`.github/workflows/test.yml` (matrix: lint/build, node `test:*`,
`test:render-*` with ffmpeg/Chromium, Playwright browser + electron jobs).
- Update `electron-rendering-manual-checks.md` to point at the new automated checks
- Update `06_electron-rendering-manual-checks.md` to point at the new automated checks
and list what still needs a HiDPI/macOS box (#10, D10).
### Proposed `package.json` scripts (final shape)
@@ -361,6 +361,6 @@ Phased so each phase is independently valuable and CI-wireable.
run under both hosts.
- Driving real native OS menus / native save dialogs in automation (brittle); we use
the IPC menu-fire hook and the dialog stub instead, and keep one **manual** pass in
`electron-rendering-manual-checks.md` for true native behaviour and macOS roles (D10).
`06_electron-rendering-manual-checks.md` for true native behaviour and macOS roles (D10).
- The offscreen-rendering rewrite (deferred per the fix-plan); if it lands later,
the §4 capture matrix is exactly the harness that re-validates it.
+293
View File
@@ -0,0 +1,293 @@
# Export Environment Copy Plan
Date: 2026-06-07
## Goal
Make the export dialog copy describe what actually happens in each runtime:
- **Browser/PWA:** MP4 uses the render server; WebM records through browser
`MediaRecorder`.
- **Electron desktop:** MP4 uses the desktop render bridge and bundled ffmpeg;
WebM still records through the Chromium `MediaRecorder` runtime inside
Electron.
The current labels are accurate for the browser build, but misleading inside
Electron:
- `SERVER - FRAME-ACCURATE` on MP4, even when Electron is rendering locally.
- `BROWSER - REALTIME` on WebM, even when the app is running as Electron.
## Current Code
Primary files:
- `src/components/ExportDialog.vue`
- `src/i18n/messages.ts`
- `src/lib/desktopRenderExport.ts`
- `src/types/desktop.d.ts`
Runtime detection already exists as an exported helper in
`desktopRenderExport.ts`:
```ts
export function hasDesktopRender(): boolean {
return !!window.ruwioDesktop?.renderMp4
}
```
This is the right signal for MP4 copy because `ExportDialog.vue` routes MP4
through `renderMp4OnDesktop()` when `hasDesktopRender()` returns true, otherwise
it calls `downloadFromServer()`. The MP4 copy should reuse this exact function
rather than reimplementing the predicate, so the text can never drift from the
route (see Implementation Plan step 1).
`window.ruwioDesktop` is injected by the Electron preload before any app module
loads and never changes during a session, so a runtime check is stable at
module-init time — no reactivity is required to read it correctly.
## Proposed Copy Matrix
### MP4
Browser/PWA:
- Badge: `Server · Frame-by-frame`
- Description: `H.264 export via render server`
- Progress: `Rendering MP4 with render server...`
Electron:
- Badge: `Desktop · Frame-by-frame`
- Description: `H.264 export via bundled ffmpeg`
- Progress: `Rendering MP4 in desktop app...`
Rationale: both MP4 paths render deterministic frames through `server/render.html`.
The key difference is where rendering happens: HTTP render server vs Electron
desktop worker plus ffmpeg.
Avoid `Frame-accurate` in the short badge. It sounds like a stronger quality
claim than the dialog needs, and it obscures the real user-facing distinction:
server vs desktop. The help text can explain frame-by-frame rendering if needed.
Badge style: store badges in title case with a `·` middot separator, matching
the existing sibling badges (`gifBadge: 'Browser · Frame-by-frame'`,
`apngBadge: 'Browser · Lossless'`). The `.badge` CSS already applies
`text-transform: uppercase`, so do **not** write the strings in all-caps or
switch to a `-` hyphen separator — that would render MP4/WebM inconsistently
next to GIF/APNG in the same column.
### WebM
Browser/PWA:
- Badge: `Browser · Realtime`
- Description: `Recorded in real time with MediaRecorder`
- Progress: `Rendering WebM in browser realtime...`
Electron:
- Badge: `Chromium · Realtime`
- Description: `Recorded in real time by Electron's Chromium runtime`
- Progress: `Rendering WebM in Chromium realtime...`
Rationale: the implementation does not become a desktop ffmpeg pipeline in
Electron. It still uses `MediaRecorder` from the renderer process. Calling that
`browser` in the desktop app is imprecise, but `Chromium` is technically true
and short enough for the badge.
## Implementation Plan
1. Add environment booleans in `ExportDialog.vue`.
```ts
import { hasDesktopRender } from '@/lib/desktopRenderExport'
const isDesktop = computed(() => !!window.ruwioDesktop)
const hasDesktopMp4 = computed(() => hasDesktopRender())
```
Use `hasDesktopMp4` for MP4 text. It wraps `hasDesktopRender()` — the *same*
function the MP4 route already calls in `exportMp4Server()` — so the copy
decision and the actual route share one predicate and can never diverge. Do
not reimplement `!!window.ruwioDesktop?.renderMp4` inline.
Use `isDesktop` for WebM text because WebM is still available through renderer
Chromium even if MP4 desktop rendering is unavailable; there is no existing
helper for that distinction, so a local boolean is fine.
2. Rename `exportMp4Server()` to `exportMp4()`.
The current name is now misleading because the function already branches:
- Desktop: `renderMp4OnDesktop(scene, ...)`
- Browser/server: `downloadFromServer(scene, ...)`
3. Replace static MP4 bindings.
Current:
```vue
{{ t('export.mp4Badge') }}
{{ t('export.mp4Desc') }}
```
Proposed:
```vue
{{ t(hasDesktopMp4 ? 'export.mp4DesktopBadge' : 'export.mp4ServerBadge') }}
{{ t(hasDesktopMp4 ? 'export.mp4DesktopDesc' : 'export.mp4ServerDesc') }}
```
4. Replace static WebM bindings.
Current:
```vue
{{ t('export.webmBadge') }}
{{ videoSupported ? t('export.webmDesc') : t('export.webmNotSupported') }}
```
Proposed:
```vue
{{ t(isDesktop ? 'export.webmDesktopBadge' : 'export.webmBrowserBadge') }}
{{
videoSupported
? t(isDesktop ? 'export.webmDesktopDesc' : 'export.webmBrowserDesc')
: t('export.webmNotSupported')
}}
```
5. Split progress labels.
MP4 currently uses:
```ts
label: t('export.labelMp4')
```
Proposed:
```ts
label: t(hasDesktopMp4.value ? 'export.labelMp4Desktop' : 'export.labelMp4Server')
```
WebM currently uses:
```ts
label: t('export.labelWebm')
```
Proposed:
```ts
label: t(isDesktop.value ? 'export.labelWebmDesktop' : 'export.labelWebmBrowser')
```
6. Update `src/i18n/messages.ts` in both locales.
English keys to add:
```ts
mp4ServerBadge: 'Server · Frame-by-frame',
mp4ServerDesc: 'H.264 export via render server',
mp4DesktopBadge: 'Desktop · Frame-by-frame',
mp4DesktopDesc: 'H.264 export via bundled ffmpeg',
webmBrowserBadge: 'Browser · Realtime',
webmBrowserDesc: 'Recorded in real time with MediaRecorder',
webmDesktopBadge: 'Chromium · Realtime',
webmDesktopDesc: "Recorded in real time by Electron's Chromium runtime",
labelMp4Server: 'Rendering MP4 with render server...',
labelMp4Desktop: 'Rendering MP4 in desktop app...',
labelWebmBrowser: 'Rendering WebM in browser realtime...',
labelWebmDesktop: 'Rendering WebM in Chromium realtime...',
```
Badges stay title case with a `·` separator; the `.badge` CSS uppercases
them at render time (see the MP4 copy-matrix note above). German keys should
mirror the meaning rather than preserve the old wording.
7. Fix the export-button help string `exportHelp` in `messages.ts`.
This is separate from the help dialog and is easy to miss. Both locales
currently hardcode the misleading claim:
- `messages.ts:43` (en): "...MP4 needs the render server; WebM records in
real time in the browser."
- `messages.ts:1153` (de): "...MP4 braucht den Render-Server. WebM wird
direkt im Browser in Echtzeit aufgenommen."
Add runtime-branched variants (e.g. `exportHelpServer` / `exportHelpDesktop`)
and select with the same `isDesktop` signal wherever `t('...exportHelp')` is
consumed. This is exactly the "globally visible text saying MP4 always needs
the render server" that must not be left in place.
8. Update help-dialog format text in `messages.ts`, and branch it now.
The strings live at `help.content.export.formats.mp4` / `.webm`
(`messages.ts:1081/1083` en, `:1923/1925` de) and `exportHelp` (handled in
step 7). Browser help should describe:
- MP4 as render-server frame-by-frame export.
- WebM as browser `MediaRecorder` realtime recording.
Electron help should describe:
- MP4 as bundled-ffmpeg desktop frame-by-frame export.
- WebM as Chromium `MediaRecorder` realtime recording.
Wiring the branch is cheaper than the old draft assumed. `HelpDialog.vue`
defines `CHAPTERS: Chapter[]` as a module-level `const` with static
`bodyKey` strings, but `window.ruwioDesktop` is set at preload before that
module loads and never changes, so a plain ternary on the `bodyKey` resolves
correctly at module-init time without making `CHAPTERS` reactive:
```ts
{ bodyKey: window.ruwioDesktop?.renderMp4
? 'help.content.export.formats.mp4Desktop'
: 'help.content.export.formats.mp4' },
{ bodyKey: window.ruwioDesktop
? 'help.content.export.formats.webmDesktop'
: 'help.content.export.formats.webm' },
```
Add the `*Desktop` keys in both locales and wire the branch in the same
change. Do not defer this and leave the help dialog asserting MP4 always
needs the render server.
## Verification
Browser/Vite:
- Open the export dialog.
- MP4 badge renders `SERVER · FRAME-BY-FRAME` (stored title case, uppercased by CSS).
- MP4 description mentions the render server.
- WebM badge renders `BROWSER · REALTIME`.
- WebM description mentions `MediaRecorder`.
- Export-button help (`exportHelp`) and help dialog describe MP4 as a
render-server export.
Electron:
- Open the export dialog.
- MP4 badge renders `DESKTOP · FRAME-BY-FRAME`.
- MP4 description mentions bundled ffmpeg.
- WebM badge renders `CHROMIUM · REALTIME`.
- WebM description mentions Electron Chromium.
- Export-button help (`exportHelp`) and help dialog describe MP4 as a
bundled-ffmpeg desktop export, not the render server.
Build checks:
```bash
npm run build
npm run desktop:dir
```
Optional automated coverage:
- Add a small in-app devtools test that stubs/removes `window.ruwioDesktop` and
verifies the selected i18n keys or rendered badge text. `src/devtools/headless.ts`
already reads `!!window.ruwioDesktop` (line 19) and is the natural home for it.
- Add an Electron harness assertion that export dialog MP4 copy is desktop copy.
+16 -1
View File
@@ -10,6 +10,7 @@ const { validateRenderRequest } = require('../shared/renderValidation.cjs') as {
}
const isDev = !app.isPackaged
const isE2E = process.env.RUWIO_E2E === '1'
const devServerUrl = process.env.VITE_DEV_SERVER_URL ?? 'http://localhost:5200'
const devServerOrigin = new URL(devServerUrl).origin
const appIconPath = isDev
@@ -43,6 +44,12 @@ function sendAppCommand(command: string): void {
mainWindow.webContents.send('ruwio:menu-command', command)
}
declare global {
// Playwright's Electron runner can evaluate in the main process and call this
// env-gated hook without expanding the preload bridge surface.
var __ruwioTestMenuCommand: ((command: string) => void) | undefined
}
function sendRenderProgress(
target: Electron.WebContents,
event: { jobId: string; progress: number; done: boolean; error?: string },
@@ -101,7 +108,12 @@ async function runRenderMp4(event: IpcMainInvokeEvent, payload: RenderMp4Payload
defaultPath: sanitizeMp4Name(payload?.suggestedName),
filters: [{ name: 'MP4 Video', extensions: ['mp4'] }],
}
const saveResult = window
const e2eSavePath = isE2E
? process.env.RUWIO_E2E_SAVE_PATH || path.join(app.getPath('temp'), saveOptions.defaultPath)
: ''
const saveResult = e2eSavePath
? { canceled: false, filePath: e2eSavePath }
: window
? await dialog.showSaveDialog(window, saveOptions)
: await dialog.showSaveDialog(saveOptions)
if (saveResult.canceled || !saveResult.filePath) return null
@@ -379,6 +391,9 @@ app.setName('RUWIO')
app.whenReady().then(() => {
ipcMain.handle('ruwio:render-mp4', handleRenderMp4)
ipcMain.handle('ruwio:render-cancel', handleRenderCancel)
if (isE2E) {
globalThis.__ruwioTestMenuCommand = (command: string) => sendAppCommand(command)
}
createMenu()
createMainWindow()
+5 -4
View File
@@ -1,7 +1,7 @@
{
"name": "ruwio",
"private": true,
"version": "1.3.0",
"version": "1.3.2",
"type": "module",
"main": "dist-electron/main.cjs",
"scripts": {
@@ -10,7 +10,8 @@
"desktop:dev": "concurrently -k \"vite\" \"wait-on http://localhost:5200 && npm run desktop:start\"",
"desktop:start": "tsc -p tsconfig.electron.json && electron .",
"desktop:build": "npm run build && tsc -p tsconfig.electron.json && npm run stage:ffmpeg -- --optional && electron-builder",
"desktop:pack": "npm run build && tsc -p tsconfig.electron.json && npm run stage:ffmpeg -- --optional && electron-builder --dir",
"desktop:pack": "npm run build && tsc -p tsconfig.electron.json && npm run stage:ffmpeg -- --optional && electron-builder",
"desktop:dir": "npm run build && tsc -p tsconfig.electron.json && npm run stage:ffmpeg -- --optional && electron-builder --dir",
"preview": "vite preview",
"type-check": "vue-tsc --noEmit",
"pack": "bash scripts/pack.sh",
@@ -18,7 +19,7 @@
"test:render-server": "node --test server/server.smoke.test.cjs",
"test:render-server:e2e": "node --test server/render.e2e.test.cjs",
"test:render-desktop": "tsc -p tsconfig.electron.json && node scripts/verify-desktop-render.cjs",
"test:capture-fidelity": "tsc -p tsconfig.electron.json && electron scripts/verify-capture-fidelity.cjs",
"test:capture-fidelity": "tsc -p tsconfig.electron.json && node scripts/run-electron-harness.cjs scripts/verify-capture-fidelity.cjs",
"stage:ffmpeg": "node scripts/stage-ffmpeg.cjs"
},
"dependencies": {
@@ -45,7 +46,7 @@
"appId": "app.ruwio.desktop",
"productName": "RUWIO",
"icon": "icon.png",
"npmRebuild": false,
"beforeBuild": "scripts/electron-before-build.cjs",
"directories": {
"output": "release"
},
+3
View File
@@ -0,0 +1,3 @@
module.exports = async function beforeBuild() {
return false
}
+21
View File
@@ -0,0 +1,21 @@
const { spawnSync } = require('node:child_process')
const electron = require('electron')
const args = process.argv.slice(2)
if (!args.length) {
console.error('usage: node scripts/run-electron-harness.cjs <script> [args...]')
process.exit(2)
}
const env = { ...process.env }
delete env.ELECTRON_RUN_AS_NODE
env.ELECTRON_DISABLE_SANDBOX = '1'
const res = spawnSync(electron, ['--no-sandbox', ...args], {
stdio: 'inherit',
env,
argv0: electron,
})
process.exit(res.status ?? 1)
+1 -1
View File
@@ -1,4 +1,4 @@
// Phase A verification harness (electron-rendering-integration-plan_2.md, finding #1/#2).
// Phase A verification harness (05_electron-rendering-integration-plan_2.md, finding #1/#2).
//
// Reproduces renderWorker.cts's window-creation + capturePage path against a
// scene with one moving + fading layer, then checks that captured frames are
+7 -2
View File
@@ -1,5 +1,5 @@
// Runs the desktop-render verification harnesses (Phase A/B of
// electron-rendering-integration-plan_2.md) under Electron and aggregates the
// 05_electron-rendering-integration-plan_2.md) under Electron and aggregates the
// results. Each harness exits 0 on PASS, non-zero on FAIL.
//
// npm run test:render-desktop (compiles electron TS first, then runs this)
@@ -11,6 +11,10 @@ const { spawnSync } = require('node:child_process')
const path = require('node:path')
const electron = require('electron') // resolves to the electron binary path
const electronEnv = { ...process.env }
delete electronEnv.ELECTRON_RUN_AS_NODE
electronEnv.ELECTRON_DISABLE_SANDBOX = '1'
const harnesses = [
['capture', 'verify-capture.cjs', 'hidden-window capturePage produces advancing, non-blank frames (#1/#2)'],
['capture-fidelity', 'verify-capture-fidelity.cjs', 'capturePage matches render.html canvas pixels'],
@@ -26,8 +30,9 @@ const harnesses = [
let failures = 0
for (const [name, file, desc] of harnesses) {
process.stdout.write(`\n=== ${name}: ${desc} ===\n`)
const res = spawnSync(electron, [path.join(__dirname, file)], {
const res = spawnSync(electron, ['--no-sandbox', path.join(__dirname, file)], {
stdio: ['ignore', 'inherit', 'ignore'], // drop Electron's chatty stderr
env: electronEnv,
})
if (res.status !== 0) {
failures += 1
+7 -3
View File
@@ -28,7 +28,6 @@ import ClipartDrawer from './components/ClipartDrawer.vue'
import SceneDrawer from './components/SceneDrawer.vue'
import DocumentDrawer from './components/DocumentDrawer.vue'
import DocumentSettings from './components/DocumentSettings.vue'
import ExportDialog from './components/ExportDialog.vue'
import FullscreenPreview from './components/FullscreenPreview.vue'
import PropertiesPanel from './components/PropertiesPanel.vue'
import SvgLayerPanel from './components/SvgLayerPanel.vue'
@@ -46,7 +45,6 @@ import { usePlaybackLoop } from './composables/usePlaybackLoop'
import { useTimeFormat } from './composables/useTimeFormat'
import { useProjectName } from './composables/useProjectName'
import { shouldChromeClickDeselect } from './lib/chromeClick'
import { renderSceneToStillImage } from './lib/stillImageExport'
import { ProjectFileError, pickProjectFile } from './lib/projectFile'
import { readAnyProjectFile } from './lib/ruwFile'
import {
@@ -70,9 +68,11 @@ const welcomeOpen = ref(false)
const fullscreenOpen = ref(false)
const aboutOpen = ref(false)
const helpOpen = ref(false)
const isDesktop = computed(() => !!window.ruwioDesktop)
const TestOverlay = import.meta.env.DEV
? defineAsyncComponent(() => import('./devtools/TestOverlay.vue'))
: null
const ExportDialog = defineAsyncComponent(() => import('./components/ExportDialog.vue'))
const TIMELINE_HEIGHT_KEY = 'animatr:timelineHeight'
const DEFAULT_TIMELINE_HEIGHT = 220
const MIN_TIMELINE_HEIGHT = 120
@@ -313,6 +313,7 @@ async function copyStillToClipboard() {
return
}
try {
const { renderSceneToStillImage } = await import('./lib/stillImageExport')
const scene = JSON.parse(JSON.stringify(store.scene))
const blob = await renderSceneToStillImage(scene, store.playhead, 'png')
// ClipboardItem is widely available in modern Chromium / Firefox
@@ -745,7 +746,10 @@ onBeforeUnmount(() => {
</button>
<button
class="export-btn"
v-tooltip="{ label: t('app.export'), help: t('app.exportHelp') }"
v-tooltip="{
label: t('app.export'),
help: t(isDesktop ? 'app.exportHelpDesktop' : 'app.exportHelpServer'),
}"
@click="exportOpen = true"
>
<PhExport :size="14" weight="bold" />
+50 -21
View File
@@ -12,18 +12,11 @@ import {
PhGif,
} from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { downloadExampleHtml, downloadRuntime, downloadSceneExport } from '@/lib/exportPlayer'
import { downloadSceneAsVideo, pickMimeType } from '@/lib/videoExport'
import { downloadSceneAsGif } from '@/lib/gifExport'
import { downloadSceneAsApng, downloadSceneAsApngPreview } from '@/lib/apngExport'
import { hasDesktopRender, renderMp4OnDesktop } from '@/lib/desktopRenderExport'
import { downloadFromServer } from '@/lib/serverExport'
import { downloadRuwFile } from '@/lib/ruwFile'
import { downloadSceneAsStillImage } from '@/lib/stillImageExport'
import { useProjectName } from '@/composables/useProjectName'
import type { ButtonAsset, Layer } from '@/types/scene'
import { Modal } from '@/components/ui'
import { t } from '@/i18n'
import { hasDesktopRender } from '@/lib/desktopRenderExport'
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
@@ -39,7 +32,22 @@ interface ProgressState {
}
const progress = ref<ProgressState | null>(null)
const videoSupported = pickMimeType() !== null
const VIDEO_MIME_CANDIDATES = [
'video/webm;codecs=vp9',
'video/webm;codecs=vp8',
'video/webm',
'video/mp4;codecs=h264',
'video/mp4',
]
function canRecordVideo(): boolean {
if (typeof MediaRecorder === 'undefined') return false
return VIDEO_MIME_CANDIDATES.some((mime) => MediaRecorder.isTypeSupported?.(mime))
}
const videoSupported = canRecordVideo()
const isDesktop = computed(() => !!window.ruwioDesktop)
const hasDesktopMp4 = computed(() => hasDesktopRender())
// Opt-in: strip layers that are hidden in the editor (`editorHidden`)
// from the exported scene. Default off — editor-hide is a workflow
@@ -198,17 +206,20 @@ function activeSceneClone() {
return scene
}
function exportScene() {
async function exportScene() {
const { downloadSceneExport } = await import('@/lib/exportPlayer')
downloadSceneExport(projectClone())
emit('update:modelValue', false)
}
function exportRuntime() {
async function exportRuntime() {
const { downloadRuntime } = await import('@/lib/exportPlayer')
downloadRuntime()
emit('update:modelValue', false)
}
function exportRuwBundle() {
async function exportRuwBundle() {
const { downloadRuwFile } = await import('@/lib/ruwFile')
// 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.
@@ -219,7 +230,8 @@ function exportRuwBundle() {
emit('update:modelValue', false)
}
function exportHtml() {
async function exportHtml() {
const { downloadExampleHtml } = await import('@/lib/exportPlayer')
downloadExampleHtml(projectClone())
emit('update:modelValue', false)
}
@@ -234,6 +246,7 @@ async function exportStillImage(format: 'png' | 'jpg') {
const opts = targetSizeChanged.value
? { targetWidth: targetWidth.value, targetHeight: targetHeight.value }
: undefined
const { downloadSceneAsStillImage } = await import('@/lib/stillImageExport')
await downloadSceneAsStillImage(
activeSceneClone(),
store.playhead,
@@ -250,11 +263,12 @@ async function exportWebm() {
progress.value = {
progress: 0,
controller,
label: t('export.labelWebm'),
label: t(isDesktop.value ? 'export.labelWebmDesktop' : 'export.labelWebmBrowser'),
error: null,
}
store.pause()
try {
const { downloadSceneAsVideo } = await import('@/lib/videoExport')
await downloadSceneAsVideo(activeSceneClone(), {
signal: controller.signal,
onProgress: (p) => {
@@ -283,6 +297,7 @@ async function exportGif() {
}
store.pause()
try {
const { downloadSceneAsGif } = await import('@/lib/gifExport')
await downloadSceneAsGif(
activeSceneClone(),
frameExportOpts(controller, (p) => {
@@ -311,6 +326,7 @@ async function exportApng() {
}
store.pause()
try {
const { downloadSceneAsApng } = await import('@/lib/apngExport')
await downloadSceneAsApng(
activeSceneClone(),
frameExportOpts(controller, (p) => {
@@ -339,6 +355,7 @@ async function exportApngPreview() {
}
store.pause()
try {
const { downloadSceneAsApngPreview } = await import('@/lib/apngExport')
await downloadSceneAsApngPreview(
activeSceneClone(),
frameExportOpts(controller, (p) => {
@@ -356,7 +373,7 @@ async function exportApngPreview() {
}
}
async function exportMp4Server() {
async function exportMp4() {
if (progress.value) return
const controller = new AbortController()
const base = (projectName.value ?? 'animation').toString()
@@ -364,13 +381,14 @@ async function exportMp4Server() {
progress.value = {
progress: 0,
controller,
label: t('export.labelMp4'),
label: t(hasDesktopMp4.value ? 'export.labelMp4Desktop' : 'export.labelMp4Server'),
error: null,
}
store.pause()
try {
const scene = activeSceneClone()
if (hasDesktopRender()) {
const { renderMp4OnDesktop } = await import('@/lib/desktopRenderExport')
const result = await renderMp4OnDesktop(scene, {
signal: controller.signal,
fps: targetFps.value,
@@ -384,6 +402,7 @@ async function exportMp4Server() {
return
}
} else {
const { downloadFromServer } = await import('@/lib/serverExport')
await downloadFromServer(scene, {
signal: controller.signal,
fps: targetFps.value,
@@ -577,14 +596,18 @@ function onUpdate(v: boolean) {
</button>
</div>
<button class="opt" @click="exportMp4Server">
<button class="opt" @click="exportMp4">
<div class="opt-icon"><PhFilmSlate :size="20" /></div>
<div class="opt-text">
<div class="opt-title">
MP4 Video
<span class="badge accent">{{ t('export.mp4Badge') }}</span>
<span class="badge accent">
{{ t(hasDesktopMp4 ? 'export.mp4DesktopBadge' : 'export.mp4ServerBadge') }}
</span>
</div>
<div class="opt-desc">
{{ t(hasDesktopMp4 ? 'export.mp4DesktopDesc' : 'export.mp4ServerDesc') }}
</div>
<div class="opt-desc">{{ t('export.mp4Desc') }}</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
@@ -594,10 +617,16 @@ function onUpdate(v: boolean) {
<div class="opt-text">
<div class="opt-title">
WebM Video
<span class="badge">{{ t('export.webmBadge') }}</span>
<span class="badge">
{{ t(isDesktop ? 'export.webmDesktopBadge' : 'export.webmBrowserBadge') }}
</span>
</div>
<div class="opt-desc">
{{ videoSupported ? t('export.webmDesc') : t('export.webmNotSupported') }}
{{
videoSupported
? t(isDesktop ? 'export.webmDesktopDesc' : 'export.webmBrowserDesc')
: t('export.webmNotSupported')
}}
</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
+14 -2
View File
@@ -17,6 +17,7 @@ import {
} from '@phosphor-icons/vue'
import { Modal } from '@/components/ui'
import { t } from '@/i18n'
import { hasDesktopRender } from '@/lib/desktopRenderExport'
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
@@ -63,6 +64,9 @@ interface Chapter {
titleKey: string
}
const isDesktop = !!window.ruwioDesktop
const hasDesktopMp4 = hasDesktopRender()
const CHAPTERS: Chapter[] = [
{
id: 'getting-started',
@@ -509,8 +513,16 @@ const CHAPTERS: Chapter[] = [
{ bodyKey: 'help.content.export.formats.runtime' },
{ bodyKey: 'help.content.export.formats.scene' },
{ bodyKey: 'help.content.export.formats.html' },
{ bodyKey: 'help.content.export.formats.mp4' },
{ bodyKey: 'help.content.export.formats.webm' },
{
bodyKey: hasDesktopMp4
? 'help.content.export.formats.mp4Desktop'
: 'help.content.export.formats.mp4',
},
{
bodyKey: isDesktop
? 'help.content.export.formats.webmDesktop'
: 'help.content.export.formats.webm',
},
],
},
{
+44 -20
View File
@@ -39,8 +39,10 @@ export const en = {
untitledProject: 'Untitled project',
saveProjectDirtyHelp:
'Your project has unsaved changes. Ctrl/Cmd+S writes them to browser storage.',
exportHelp:
'Save as runtime script, HTML, or video. MP4 needs the render server; WebM records in real time in the browser.',
exportHelpServer:
'Save as runtime script, HTML, or video. MP4 uses the render server; WebM records in real time with MediaRecorder.',
exportHelpDesktop:
"Save as runtime script, HTML, or video. MP4 renders in the desktop app with bundled ffmpeg; WebM records in Electron's Chromium runtime.",
replaceDialog: {
title: 'Replace project?',
message:
@@ -509,10 +511,14 @@ export const en = {
sceneDesc: 'Scene data only — requires the runtime to be loaded',
exampleTitle: 'Example HTML',
exampleDesc: 'Standalone HTML — just open it in a browser',
mp4Badge: 'Server · Frame-accurate',
mp4Desc: 'H.264, any resolution — requires render server',
webmBadge: 'Browser · Realtime',
webmDesc: 'No server — recorded in real time',
mp4ServerBadge: 'Server · Frame-by-frame',
mp4ServerDesc: 'H.264 export via render server',
mp4DesktopBadge: 'Desktop · Frame-by-frame',
mp4DesktopDesc: 'H.264 export via bundled ffmpeg',
webmBrowserBadge: 'Browser · Realtime',
webmBrowserDesc: 'Recorded in real time with MediaRecorder',
webmDesktopBadge: 'Chromium · Realtime',
webmDesktopDesc: "Recorded in real time by Electron's Chromium runtime",
webmNotSupported: 'Browser does not support MediaRecorder',
gifBadge: 'Browser · Frame-by-frame',
gifDesc: '256-colour palette, broadest compatibility',
@@ -524,8 +530,10 @@ export const en = {
cancel: 'Cancel',
failed: 'Export failed',
back: 'Back',
labelWebm: 'Rendering WebM (browser, realtime)…',
labelMp4: 'Rendering MP4 (server)…',
labelWebmBrowser: 'Rendering WebM in browser realtime...',
labelWebmDesktop: 'Rendering WebM in Chromium realtime...',
labelMp4Server: 'Rendering MP4 with render server...',
labelMp4Desktop: 'Rendering MP4 in desktop app...',
labelGif: 'Rendering GIF (browser, frame-by-frame)…',
labelApng: 'Rendering APNG (browser, frame-by-frame)…',
labelApngPreview: 'Rendering APNG + preview page…',
@@ -1078,9 +1086,13 @@ play
html:
'<strong>HTML example:</strong> A quick standalone page with everything wired up.',
mp4:
'<strong>MP4 video:</strong> Frame-accurate server render. This needs the render server configured in Settings.',
'<strong>MP4 video:</strong> Frame-by-frame H.264 export via the render server.',
mp4Desktop:
'<strong>MP4 video:</strong> Frame-by-frame H.264 export in the desktop app with bundled ffmpeg.',
webm:
'<strong>WebM video:</strong> Browser-side realtime recording. This depends on <code>MediaRecorder</code> support.',
'<strong>WebM video:</strong> Browser realtime recording with <code>MediaRecorder</code>.',
webmDesktop:
"<strong>WebM video:</strong> Realtime recording with <code>MediaRecorder</code> in Electron's Chromium runtime.",
},
skipHiddenTitle: 'Skip hidden layers',
skipHiddenBody:
@@ -1149,8 +1161,10 @@ export const de: DeepPartial<Messages> = {
untitledProject: 'Unbenanntes Projekt',
saveProjectDirtyHelp:
'Dein Projekt hat ungespeicherte Änderungen. Mit Ctrl/Cmd+S wird es im Browser gespeichert.',
exportHelp:
'Als Runtime, HTML oder Video exportieren. MP4 braucht den Render-Server. WebM wird direkt im Browser in Echtzeit aufgenommen.',
exportHelpServer:
'Als Runtime, HTML oder Video exportieren. MP4 nutzt den Render-Server. WebM wird in Echtzeit mit MediaRecorder aufgenommen.',
exportHelpDesktop:
'Als Runtime, HTML oder Video exportieren. MP4 rendert in der Desktop-App mit gebündeltem ffmpeg. WebM wird in Electrons Chromium-Runtime aufgenommen.',
replaceDialog: {
title: 'Projekt ersetzen?',
message:
@@ -1363,10 +1377,14 @@ export const de: DeepPartial<Messages> = {
sceneDesc: 'Nur Projektdaten: Die Runtime muss schon geladen sein.',
exampleTitle: 'HTML-Beispiel',
exampleDesc: 'Eigenständige HTML-Datei: Einfach im Browser öffnen.',
mp4Badge: 'Server · Frame-genau',
mp4Desc: 'H.264 in jeder Auflösung: braucht den Render-Server.',
webmBadge: 'Browser · Echtzeit',
webmDesc: 'Ohne Server: direkt im Browser aufgenommen.',
mp4ServerBadge: 'Server · Frame-für-Frame',
mp4ServerDesc: 'H.264-Export über den Render-Server',
mp4DesktopBadge: 'Desktop · Frame-für-Frame',
mp4DesktopDesc: 'H.264-Export über gebündeltes ffmpeg',
webmBrowserBadge: 'Browser · Echtzeit',
webmBrowserDesc: 'Echtzeit-Aufnahme mit MediaRecorder',
webmDesktopBadge: 'Chromium · Echtzeit',
webmDesktopDesc: 'Echtzeit-Aufnahme durch Electrons Chromium-Runtime',
webmNotSupported: 'Dieser Browser unterstützt MediaRecorder nicht.',
gifBadge: 'Browser · Frame-für-Frame',
gifDesc: '256-Farben-Palette, maximale Kompatibilität.',
@@ -1378,8 +1396,10 @@ export const de: DeepPartial<Messages> = {
cancel: 'Abbrechen',
failed: 'Export fehlgeschlagen',
back: 'Zurück',
labelWebm: 'WebM wird gerendert (Browser, Echtzeit)…',
labelMp4: 'MP4 wird gerendert (Server)…',
labelWebmBrowser: 'WebM wird in Browser-Echtzeit gerendert...',
labelWebmDesktop: 'WebM wird in Chromium-Echtzeit gerendert...',
labelMp4Server: 'MP4 wird mit dem Render-Server gerendert...',
labelMp4Desktop: 'MP4 wird in der Desktop-App gerendert...',
labelGif: 'GIF wird gerendert (Browser, frame-für-frame)…',
labelApng: 'APNG wird gerendert (Browser, frame-für-frame)…',
labelApngPreview: 'APNG + Vorschau werden gerendert…',
@@ -1920,9 +1940,13 @@ play
html:
'<strong>HTML-Beispiel:</strong> Eine schnelle eigenständige Seite mit allem fertig verdrahtet.',
mp4:
'<strong>MP4-Video:</strong> Frame-genauer Server-Render. Dafür muss der Render-Server in den Einstellungen konfiguriert sein.',
'<strong>MP4-Video:</strong> Frame-für-Frame-H.264-Export über den Render-Server.',
mp4Desktop:
'<strong>MP4-Video:</strong> Frame-für-Frame-H.264-Export in der Desktop-App mit gebündeltem ffmpeg.',
webm:
'<strong>WebM-Video:</strong> Browser-seitige Echtzeit-Aufnahme. Das hängt von <code>MediaRecorder</code> ab.',
'<strong>WebM-Video:</strong> Echtzeit-Aufnahme im Browser mit <code>MediaRecorder</code>.',
webmDesktop:
'<strong>WebM-Video:</strong> Echtzeit-Aufnahme mit <code>MediaRecorder</code> in Electrons Chromium-Runtime.',
},
skipHiddenTitle: 'Versteckte Ebenen auslassen',
skipHiddenBody:
+1 -1
View File
@@ -1,2 +1,2 @@
export const APP_VERSION = '1.3.0'
export const APP_VERSION = '1.3.2'
export const APP_NAME = 'RUWIO'
+54
View File
@@ -5,6 +5,60 @@ import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
base: './',
plugins: [vue()],
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('/src/i18n/')) return 'i18n'
if (
id.includes('/src/components/CanvasStage.vue') ||
id.includes('/src/components/AssetSidebar.vue') ||
id.includes('/src/components/PropertiesPanel.vue') ||
id.includes('/src/components/ActionEditor.vue') ||
id.includes('/src/components/FontPicker.vue') ||
id.includes('/src/components/RangeSlider.vue') ||
id.includes('/src/components/SvgLayerPanel.vue') ||
id.includes('/src/stores/scene.ts') ||
id.includes('/src/types/scene.ts') ||
id.includes('/src/lib/render.ts') ||
id.includes('/src/lib/interpolate.ts') ||
id.includes('/src/lib/wiggle.ts') ||
id.includes('/src/lib/kaleido.ts') ||
id.includes('/src/lib/clipBounds.ts') ||
id.includes('/src/lib/actions.ts') ||
id.includes('/src/lib/sceneOrder.ts') ||
id.includes('/src/composables/useButtonRuntime.ts')
) {
return 'editor'
}
if (
id.includes('/src/components/Timeline.vue') ||
id.includes('/src/lib/timelineSnap.ts') ||
id.includes('/src/composables/useAudioWaveform.ts')
) {
return 'timeline'
}
if (!id.includes('/node_modules/')) return undefined
if (id.includes('/@phosphor-icons/vue/')) return 'icons'
if (id.includes('/konva/') || id.includes('/vue-konva/')) return 'canvas'
if (
id.includes('/gifenc/') ||
id.includes('/upng-js/')
) {
return 'encoders'
}
if (
id.includes('/vue/') ||
id.includes('/@vue/') ||
id.includes('/pinia/')
) {
return 'framework'
}
return 'vendor'
},
},
},
},
server: {
port: 5200,