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 533 additions and 70 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 ```bash
npm install npm install
npm run desktop:dev # start Vite, then launch Electron against localhost 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/ npm run desktop:build # build platform installer/package artifacts into release/
``` ```
`desktop:pack` is the quickest compile check because it produces an unpacked `desktop:dir` is the quickest compile check because it produces an unpacked app
app without creating an installer. `desktop:build` uses `electron-builder` and without creating an installer. `desktop:pack` and `desktop:build` use
targets AppImage on Linux, NSIS on Windows, and DMG on macOS. `electron-builder` and target AppImage on Linux, NSIS on Windows, and DMG on
macOS.
Desktop MP4 export runs inside Electron and uses ffmpeg from Desktop MP4 export runs inside Electron and uses ffmpeg from
`resources/bin/<platform>/` in packaged builds. The build scripts run `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 Date: 2026-06-05
Source review: `dev_docs/security-review.md` Source review: `dev_docs/02_security-review.md`
## Goal ## Goal
@@ -19,7 +19,7 @@ Close the security issues from the review without changing RUWIO's core authorin
Files likely touched: Files likely touched:
- `dev_docs/security-review.md` - `dev_docs/02_security-review.md`
- `package.json` - `package.json`
- `server/package.json` - `server/package.json`
- `server/server.js` - `server/server.js`
@@ -2,7 +2,7 @@
Date: 2026-06-05 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 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 here. It records what is already implemented, what was found during review, and
the concrete remaining TODOs in priority order. the concrete remaining TODOs in priority order.
+3 -3
View File
@@ -2,7 +2,7 @@
Date: 2026-06-06 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 That plan tracked findings **#1–#6** (all resolved). A second review of the
uncommitted desktop-render changes surfaced a new set of issues, numbered 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 **#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. `*://*/*` network block (plan_2 #5) does not drop them — no action needed.
## Suggested doc bookkeeping ## 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), - 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 Node/Electron **render harnesses** (`scripts/verify-*.cjs`),
- the **server** tests (`server/{server.smoke,render.e2e}.test.cjs`), - the **server** tests (`server/{server.smoke,render.e2e}.test.cjs`),
- the **shared validation** unit tests (`shared/renderValidation.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** > **TL;DR**
> 1. **Don't fork the suite per build.** The frontend is ~99% shared and gated > 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 - A root `test` script aggregating the CI-safe layers, and a first
`.github/workflows/test.yml` (matrix: lint/build, node `test:*`, `.github/workflows/test.yml` (matrix: lint/build, node `test:*`,
`test:render-*` with ffmpeg/Chromium, Playwright browser + electron jobs). `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). and list what still needs a HiDPI/macOS box (#10, D10).
### Proposed `package.json` scripts (final shape) ### Proposed `package.json` scripts (final shape)
@@ -361,6 +361,6 @@ Phased so each phase is independently valuable and CI-wireable.
run under both hosts. run under both hosts.
- Driving real native OS menus / native save dialogs in automation (brittle); we use - 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 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 offscreen-rendering rewrite (deferred per the fix-plan); if it lands later,
the §4 capture matrix is exactly the harness that re-validates it. 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.
+18 -3
View File
@@ -10,6 +10,7 @@ const { validateRenderRequest } = require('../shared/renderValidation.cjs') as {
} }
const isDev = !app.isPackaged const isDev = !app.isPackaged
const isE2E = process.env.RUWIO_E2E === '1'
const devServerUrl = process.env.VITE_DEV_SERVER_URL ?? 'http://localhost:5200' const devServerUrl = process.env.VITE_DEV_SERVER_URL ?? 'http://localhost:5200'
const devServerOrigin = new URL(devServerUrl).origin const devServerOrigin = new URL(devServerUrl).origin
const appIconPath = isDev const appIconPath = isDev
@@ -43,6 +44,12 @@ function sendAppCommand(command: string): void {
mainWindow.webContents.send('ruwio:menu-command', command) 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( function sendRenderProgress(
target: Electron.WebContents, target: Electron.WebContents,
event: { jobId: string; progress: number; done: boolean; error?: string }, event: { jobId: string; progress: number; done: boolean; error?: string },
@@ -101,9 +108,14 @@ async function runRenderMp4(event: IpcMainInvokeEvent, payload: RenderMp4Payload
defaultPath: sanitizeMp4Name(payload?.suggestedName), defaultPath: sanitizeMp4Name(payload?.suggestedName),
filters: [{ name: 'MP4 Video', extensions: ['mp4'] }], filters: [{ name: 'MP4 Video', extensions: ['mp4'] }],
} }
const saveResult = window const e2eSavePath = isE2E
? await dialog.showSaveDialog(window, saveOptions) ? process.env.RUWIO_E2E_SAVE_PATH || path.join(app.getPath('temp'), saveOptions.defaultPath)
: await dialog.showSaveDialog(saveOptions) : ''
const saveResult = e2eSavePath
? { canceled: false, filePath: e2eSavePath }
: window
? await dialog.showSaveDialog(window, saveOptions)
: await dialog.showSaveDialog(saveOptions)
if (saveResult.canceled || !saveResult.filePath) return null if (saveResult.canceled || !saveResult.filePath) return null
const jobId = randomUUID() const jobId = randomUUID()
@@ -379,6 +391,9 @@ app.setName('RUWIO')
app.whenReady().then(() => { app.whenReady().then(() => {
ipcMain.handle('ruwio:render-mp4', handleRenderMp4) ipcMain.handle('ruwio:render-mp4', handleRenderMp4)
ipcMain.handle('ruwio:render-cancel', handleRenderCancel) ipcMain.handle('ruwio:render-cancel', handleRenderCancel)
if (isE2E) {
globalThis.__ruwioTestMenuCommand = (command: string) => sendAppCommand(command)
}
createMenu() createMenu()
createMainWindow() createMainWindow()
+5 -4
View File
@@ -1,7 +1,7 @@
{ {
"name": "ruwio", "name": "ruwio",
"private": true, "private": true,
"version": "1.3.0", "version": "1.3.2",
"type": "module", "type": "module",
"main": "dist-electron/main.cjs", "main": "dist-electron/main.cjs",
"scripts": { "scripts": {
@@ -10,7 +10,8 @@
"desktop:dev": "concurrently -k \"vite\" \"wait-on http://localhost:5200 && npm run desktop:start\"", "desktop:dev": "concurrently -k \"vite\" \"wait-on http://localhost:5200 && npm run desktop:start\"",
"desktop:start": "tsc -p tsconfig.electron.json && electron .", "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: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", "preview": "vite preview",
"type-check": "vue-tsc --noEmit", "type-check": "vue-tsc --noEmit",
"pack": "bash scripts/pack.sh", "pack": "bash scripts/pack.sh",
@@ -18,7 +19,7 @@
"test:render-server": "node --test server/server.smoke.test.cjs", "test:render-server": "node --test server/server.smoke.test.cjs",
"test:render-server:e2e": "node --test server/render.e2e.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: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" "stage:ffmpeg": "node scripts/stage-ffmpeg.cjs"
}, },
"dependencies": { "dependencies": {
@@ -45,7 +46,7 @@
"appId": "app.ruwio.desktop", "appId": "app.ruwio.desktop",
"productName": "RUWIO", "productName": "RUWIO",
"icon": "icon.png", "icon": "icon.png",
"npmRebuild": false, "beforeBuild": "scripts/electron-before-build.cjs",
"directories": { "directories": {
"output": "release" "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 // Reproduces renderWorker.cts's window-creation + capturePage path against a
// scene with one moving + fading layer, then checks that captured frames are // 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 // 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. // results. Each harness exits 0 on PASS, non-zero on FAIL.
// //
// npm run test:render-desktop (compiles electron TS first, then runs this) // 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 path = require('node:path')
const electron = require('electron') // resolves to the electron binary 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 = [ const harnesses = [
['capture', 'verify-capture.cjs', 'hidden-window capturePage produces advancing, non-blank frames (#1/#2)'], ['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'], ['capture-fidelity', 'verify-capture-fidelity.cjs', 'capturePage matches render.html canvas pixels'],
@@ -26,8 +30,9 @@ const harnesses = [
let failures = 0 let failures = 0
for (const [name, file, desc] of harnesses) { for (const [name, file, desc] of harnesses) {
process.stdout.write(`\n=== ${name}: ${desc} ===\n`) 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 stdio: ['ignore', 'inherit', 'ignore'], // drop Electron's chatty stderr
env: electronEnv,
}) })
if (res.status !== 0) { if (res.status !== 0) {
failures += 1 failures += 1
+7 -3
View File
@@ -28,7 +28,6 @@ import ClipartDrawer from './components/ClipartDrawer.vue'
import SceneDrawer from './components/SceneDrawer.vue' import SceneDrawer from './components/SceneDrawer.vue'
import DocumentDrawer from './components/DocumentDrawer.vue' import DocumentDrawer from './components/DocumentDrawer.vue'
import DocumentSettings from './components/DocumentSettings.vue' import DocumentSettings from './components/DocumentSettings.vue'
import ExportDialog from './components/ExportDialog.vue'
import FullscreenPreview from './components/FullscreenPreview.vue' import FullscreenPreview from './components/FullscreenPreview.vue'
import PropertiesPanel from './components/PropertiesPanel.vue' import PropertiesPanel from './components/PropertiesPanel.vue'
import SvgLayerPanel from './components/SvgLayerPanel.vue' import SvgLayerPanel from './components/SvgLayerPanel.vue'
@@ -46,7 +45,6 @@ import { usePlaybackLoop } from './composables/usePlaybackLoop'
import { useTimeFormat } from './composables/useTimeFormat' import { useTimeFormat } from './composables/useTimeFormat'
import { useProjectName } from './composables/useProjectName' import { useProjectName } from './composables/useProjectName'
import { shouldChromeClickDeselect } from './lib/chromeClick' import { shouldChromeClickDeselect } from './lib/chromeClick'
import { renderSceneToStillImage } from './lib/stillImageExport'
import { ProjectFileError, pickProjectFile } from './lib/projectFile' import { ProjectFileError, pickProjectFile } from './lib/projectFile'
import { readAnyProjectFile } from './lib/ruwFile' import { readAnyProjectFile } from './lib/ruwFile'
import { import {
@@ -70,9 +68,11 @@ const welcomeOpen = ref(false)
const fullscreenOpen = ref(false) const fullscreenOpen = ref(false)
const aboutOpen = ref(false) const aboutOpen = ref(false)
const helpOpen = ref(false) const helpOpen = ref(false)
const isDesktop = computed(() => !!window.ruwioDesktop)
const TestOverlay = import.meta.env.DEV const TestOverlay = import.meta.env.DEV
? defineAsyncComponent(() => import('./devtools/TestOverlay.vue')) ? defineAsyncComponent(() => import('./devtools/TestOverlay.vue'))
: null : null
const ExportDialog = defineAsyncComponent(() => import('./components/ExportDialog.vue'))
const TIMELINE_HEIGHT_KEY = 'animatr:timelineHeight' const TIMELINE_HEIGHT_KEY = 'animatr:timelineHeight'
const DEFAULT_TIMELINE_HEIGHT = 220 const DEFAULT_TIMELINE_HEIGHT = 220
const MIN_TIMELINE_HEIGHT = 120 const MIN_TIMELINE_HEIGHT = 120
@@ -313,6 +313,7 @@ async function copyStillToClipboard() {
return return
} }
try { try {
const { renderSceneToStillImage } = await import('./lib/stillImageExport')
const scene = JSON.parse(JSON.stringify(store.scene)) const scene = JSON.parse(JSON.stringify(store.scene))
const blob = await renderSceneToStillImage(scene, store.playhead, 'png') const blob = await renderSceneToStillImage(scene, store.playhead, 'png')
// ClipboardItem is widely available in modern Chromium / Firefox // ClipboardItem is widely available in modern Chromium / Firefox
@@ -745,7 +746,10 @@ onBeforeUnmount(() => {
</button> </button>
<button <button
class="export-btn" 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" @click="exportOpen = true"
> >
<PhExport :size="14" weight="bold" /> <PhExport :size="14" weight="bold" />
+50 -21
View File
@@ -12,18 +12,11 @@ import {
PhGif, PhGif,
} from '@phosphor-icons/vue' } from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene' 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 { useProjectName } from '@/composables/useProjectName'
import type { ButtonAsset, Layer } from '@/types/scene' import type { ButtonAsset, Layer } from '@/types/scene'
import { Modal } from '@/components/ui' import { Modal } from '@/components/ui'
import { t } from '@/i18n' import { t } from '@/i18n'
import { hasDesktopRender } from '@/lib/desktopRenderExport'
const props = defineProps<{ modelValue: boolean }>() const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>() const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
@@ -39,7 +32,22 @@ interface ProgressState {
} }
const progress = ref<ProgressState | null>(null) 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`) // Opt-in: strip layers that are hidden in the editor (`editorHidden`)
// from the exported scene. Default off — editor-hide is a workflow // from the exported scene. Default off — editor-hide is a workflow
@@ -198,17 +206,20 @@ function activeSceneClone() {
return scene return scene
} }
function exportScene() { async function exportScene() {
const { downloadSceneExport } = await import('@/lib/exportPlayer')
downloadSceneExport(projectClone()) downloadSceneExport(projectClone())
emit('update:modelValue', false) emit('update:modelValue', false)
} }
function exportRuntime() { async function exportRuntime() {
const { downloadRuntime } = await import('@/lib/exportPlayer')
downloadRuntime() downloadRuntime()
emit('update:modelValue', false) 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 // Re-import target: .ruw is the round-trippable format the welcome
// dialog + file-picker accept. Pull the project via projectForSave // dialog + file-picker accept. Pull the project via projectForSave
// so the editor-session UI state (zoom, toggles, …) rides along. // so the editor-session UI state (zoom, toggles, …) rides along.
@@ -219,7 +230,8 @@ function exportRuwBundle() {
emit('update:modelValue', false) emit('update:modelValue', false)
} }
function exportHtml() { async function exportHtml() {
const { downloadExampleHtml } = await import('@/lib/exportPlayer')
downloadExampleHtml(projectClone()) downloadExampleHtml(projectClone())
emit('update:modelValue', false) emit('update:modelValue', false)
} }
@@ -234,6 +246,7 @@ async function exportStillImage(format: 'png' | 'jpg') {
const opts = targetSizeChanged.value const opts = targetSizeChanged.value
? { targetWidth: targetWidth.value, targetHeight: targetHeight.value } ? { targetWidth: targetWidth.value, targetHeight: targetHeight.value }
: undefined : undefined
const { downloadSceneAsStillImage } = await import('@/lib/stillImageExport')
await downloadSceneAsStillImage( await downloadSceneAsStillImage(
activeSceneClone(), activeSceneClone(),
store.playhead, store.playhead,
@@ -250,11 +263,12 @@ async function exportWebm() {
progress.value = { progress.value = {
progress: 0, progress: 0,
controller, controller,
label: t('export.labelWebm'), label: t(isDesktop.value ? 'export.labelWebmDesktop' : 'export.labelWebmBrowser'),
error: null, error: null,
} }
store.pause() store.pause()
try { try {
const { downloadSceneAsVideo } = await import('@/lib/videoExport')
await downloadSceneAsVideo(activeSceneClone(), { await downloadSceneAsVideo(activeSceneClone(), {
signal: controller.signal, signal: controller.signal,
onProgress: (p) => { onProgress: (p) => {
@@ -283,6 +297,7 @@ async function exportGif() {
} }
store.pause() store.pause()
try { try {
const { downloadSceneAsGif } = await import('@/lib/gifExport')
await downloadSceneAsGif( await downloadSceneAsGif(
activeSceneClone(), activeSceneClone(),
frameExportOpts(controller, (p) => { frameExportOpts(controller, (p) => {
@@ -311,6 +326,7 @@ async function exportApng() {
} }
store.pause() store.pause()
try { try {
const { downloadSceneAsApng } = await import('@/lib/apngExport')
await downloadSceneAsApng( await downloadSceneAsApng(
activeSceneClone(), activeSceneClone(),
frameExportOpts(controller, (p) => { frameExportOpts(controller, (p) => {
@@ -339,6 +355,7 @@ async function exportApngPreview() {
} }
store.pause() store.pause()
try { try {
const { downloadSceneAsApngPreview } = await import('@/lib/apngExport')
await downloadSceneAsApngPreview( await downloadSceneAsApngPreview(
activeSceneClone(), activeSceneClone(),
frameExportOpts(controller, (p) => { frameExportOpts(controller, (p) => {
@@ -356,7 +373,7 @@ async function exportApngPreview() {
} }
} }
async function exportMp4Server() { async function exportMp4() {
if (progress.value) return if (progress.value) return
const controller = new AbortController() const controller = new AbortController()
const base = (projectName.value ?? 'animation').toString() const base = (projectName.value ?? 'animation').toString()
@@ -364,13 +381,14 @@ async function exportMp4Server() {
progress.value = { progress.value = {
progress: 0, progress: 0,
controller, controller,
label: t('export.labelMp4'), label: t(hasDesktopMp4.value ? 'export.labelMp4Desktop' : 'export.labelMp4Server'),
error: null, error: null,
} }
store.pause() store.pause()
try { try {
const scene = activeSceneClone() const scene = activeSceneClone()
if (hasDesktopRender()) { if (hasDesktopRender()) {
const { renderMp4OnDesktop } = await import('@/lib/desktopRenderExport')
const result = await renderMp4OnDesktop(scene, { const result = await renderMp4OnDesktop(scene, {
signal: controller.signal, signal: controller.signal,
fps: targetFps.value, fps: targetFps.value,
@@ -384,6 +402,7 @@ async function exportMp4Server() {
return return
} }
} else { } else {
const { downloadFromServer } = await import('@/lib/serverExport')
await downloadFromServer(scene, { await downloadFromServer(scene, {
signal: controller.signal, signal: controller.signal,
fps: targetFps.value, fps: targetFps.value,
@@ -577,14 +596,18 @@ function onUpdate(v: boolean) {
</button> </button>
</div> </div>
<button class="opt" @click="exportMp4Server"> <button class="opt" @click="exportMp4">
<div class="opt-icon"><PhFilmSlate :size="20" /></div> <div class="opt-icon"><PhFilmSlate :size="20" /></div>
<div class="opt-text"> <div class="opt-text">
<div class="opt-title"> <div class="opt-title">
MP4 Video 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>
<div class="opt-desc">{{ t('export.mp4Desc') }}</div>
</div> </div>
<PhDownloadSimple :size="14" class="opt-arrow" /> <PhDownloadSimple :size="14" class="opt-arrow" />
</button> </button>
@@ -594,10 +617,16 @@ function onUpdate(v: boolean) {
<div class="opt-text"> <div class="opt-text">
<div class="opt-title"> <div class="opt-title">
WebM Video WebM Video
<span class="badge">{{ t('export.webmBadge') }}</span> <span class="badge">
{{ t(isDesktop ? 'export.webmDesktopBadge' : 'export.webmBrowserBadge') }}
</span>
</div> </div>
<div class="opt-desc"> <div class="opt-desc">
{{ videoSupported ? t('export.webmDesc') : t('export.webmNotSupported') }} {{
videoSupported
? t(isDesktop ? 'export.webmDesktopDesc' : 'export.webmBrowserDesc')
: t('export.webmNotSupported')
}}
</div> </div>
</div> </div>
<PhDownloadSimple :size="14" class="opt-arrow" /> <PhDownloadSimple :size="14" class="opt-arrow" />
+14 -2
View File
@@ -17,6 +17,7 @@ import {
} from '@phosphor-icons/vue' } from '@phosphor-icons/vue'
import { Modal } from '@/components/ui' import { Modal } from '@/components/ui'
import { t } from '@/i18n' import { t } from '@/i18n'
import { hasDesktopRender } from '@/lib/desktopRenderExport'
const props = defineProps<{ modelValue: boolean }>() const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>() const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
@@ -63,6 +64,9 @@ interface Chapter {
titleKey: string titleKey: string
} }
const isDesktop = !!window.ruwioDesktop
const hasDesktopMp4 = hasDesktopRender()
const CHAPTERS: Chapter[] = [ const CHAPTERS: Chapter[] = [
{ {
id: 'getting-started', id: 'getting-started',
@@ -509,8 +513,16 @@ const CHAPTERS: Chapter[] = [
{ bodyKey: 'help.content.export.formats.runtime' }, { bodyKey: 'help.content.export.formats.runtime' },
{ bodyKey: 'help.content.export.formats.scene' }, { bodyKey: 'help.content.export.formats.scene' },
{ bodyKey: 'help.content.export.formats.html' }, { 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', untitledProject: 'Untitled project',
saveProjectDirtyHelp: saveProjectDirtyHelp:
'Your project has unsaved changes. Ctrl/Cmd+S writes them to browser storage.', 'Your project has unsaved changes. Ctrl/Cmd+S writes them to browser storage.',
exportHelp: exportHelpServer:
'Save as runtime script, HTML, or video. MP4 needs the render server; WebM records in real time in the browser.', '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: { replaceDialog: {
title: 'Replace project?', title: 'Replace project?',
message: message:
@@ -509,10 +511,14 @@ export const en = {
sceneDesc: 'Scene data only — requires the runtime to be loaded', sceneDesc: 'Scene data only — requires the runtime to be loaded',
exampleTitle: 'Example HTML', exampleTitle: 'Example HTML',
exampleDesc: 'Standalone HTML — just open it in a browser', exampleDesc: 'Standalone HTML — just open it in a browser',
mp4Badge: 'Server · Frame-accurate', mp4ServerBadge: 'Server · Frame-by-frame',
mp4Desc: 'H.264, any resolution — requires render server', mp4ServerDesc: 'H.264 export via render server',
webmBadge: 'Browser · Realtime', mp4DesktopBadge: 'Desktop · Frame-by-frame',
webmDesc: 'No server — recorded in real time', 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', webmNotSupported: 'Browser does not support MediaRecorder',
gifBadge: 'Browser · Frame-by-frame', gifBadge: 'Browser · Frame-by-frame',
gifDesc: '256-colour palette, broadest compatibility', gifDesc: '256-colour palette, broadest compatibility',
@@ -524,8 +530,10 @@ export const en = {
cancel: 'Cancel', cancel: 'Cancel',
failed: 'Export failed', failed: 'Export failed',
back: 'Back', back: 'Back',
labelWebm: 'Rendering WebM (browser, realtime)…', labelWebmBrowser: 'Rendering WebM in browser realtime...',
labelMp4: 'Rendering MP4 (server)…', 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)…', labelGif: 'Rendering GIF (browser, frame-by-frame)…',
labelApng: 'Rendering APNG (browser, frame-by-frame)…', labelApng: 'Rendering APNG (browser, frame-by-frame)…',
labelApngPreview: 'Rendering APNG + preview page…', labelApngPreview: 'Rendering APNG + preview page…',
@@ -1078,9 +1086,13 @@ play
html: html:
'<strong>HTML example:</strong> A quick standalone page with everything wired up.', '<strong>HTML example:</strong> A quick standalone page with everything wired up.',
mp4: 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: 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', skipHiddenTitle: 'Skip hidden layers',
skipHiddenBody: skipHiddenBody:
@@ -1149,8 +1161,10 @@ export const de: DeepPartial<Messages> = {
untitledProject: 'Unbenanntes Projekt', untitledProject: 'Unbenanntes Projekt',
saveProjectDirtyHelp: saveProjectDirtyHelp:
'Dein Projekt hat ungespeicherte Änderungen. Mit Ctrl/Cmd+S wird es im Browser gespeichert.', 'Dein Projekt hat ungespeicherte Änderungen. Mit Ctrl/Cmd+S wird es im Browser gespeichert.',
exportHelp: exportHelpServer:
'Als Runtime, HTML oder Video exportieren. MP4 braucht den Render-Server. WebM wird direkt im Browser in Echtzeit aufgenommen.', '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: { replaceDialog: {
title: 'Projekt ersetzen?', title: 'Projekt ersetzen?',
message: message:
@@ -1363,10 +1377,14 @@ export const de: DeepPartial<Messages> = {
sceneDesc: 'Nur Projektdaten: Die Runtime muss schon geladen sein.', sceneDesc: 'Nur Projektdaten: Die Runtime muss schon geladen sein.',
exampleTitle: 'HTML-Beispiel', exampleTitle: 'HTML-Beispiel',
exampleDesc: 'Eigenständige HTML-Datei: Einfach im Browser öffnen.', exampleDesc: 'Eigenständige HTML-Datei: Einfach im Browser öffnen.',
mp4Badge: 'Server · Frame-genau', mp4ServerBadge: 'Server · Frame-für-Frame',
mp4Desc: 'H.264 in jeder Auflösung: braucht den Render-Server.', mp4ServerDesc: 'H.264-Export über den Render-Server',
webmBadge: 'Browser · Echtzeit', mp4DesktopBadge: 'Desktop · Frame-für-Frame',
webmDesc: 'Ohne Server: direkt im Browser aufgenommen.', 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.', webmNotSupported: 'Dieser Browser unterstützt MediaRecorder nicht.',
gifBadge: 'Browser · Frame-für-Frame', gifBadge: 'Browser · Frame-für-Frame',
gifDesc: '256-Farben-Palette, maximale Kompatibilität.', gifDesc: '256-Farben-Palette, maximale Kompatibilität.',
@@ -1378,8 +1396,10 @@ export const de: DeepPartial<Messages> = {
cancel: 'Abbrechen', cancel: 'Abbrechen',
failed: 'Export fehlgeschlagen', failed: 'Export fehlgeschlagen',
back: 'Zurück', back: 'Zurück',
labelWebm: 'WebM wird gerendert (Browser, Echtzeit)…', labelWebmBrowser: 'WebM wird in Browser-Echtzeit gerendert...',
labelMp4: 'MP4 wird gerendert (Server)…', 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)…', labelGif: 'GIF wird gerendert (Browser, frame-für-frame)…',
labelApng: 'APNG wird gerendert (Browser, frame-für-frame)…', labelApng: 'APNG wird gerendert (Browser, frame-für-frame)…',
labelApngPreview: 'APNG + Vorschau werden gerendert…', labelApngPreview: 'APNG + Vorschau werden gerendert…',
@@ -1920,9 +1940,13 @@ play
html: html:
'<strong>HTML-Beispiel:</strong> Eine schnelle eigenständige Seite mit allem fertig verdrahtet.', '<strong>HTML-Beispiel:</strong> Eine schnelle eigenständige Seite mit allem fertig verdrahtet.',
mp4: 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: 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', skipHiddenTitle: 'Versteckte Ebenen auslassen',
skipHiddenBody: 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' export const APP_NAME = 'RUWIO'
+54
View File
@@ -5,6 +5,60 @@ import { fileURLToPath, URL } from 'node:url'
export default defineConfig({ export default defineConfig({
base: './', base: './',
plugins: [vue()], 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: { server: {
port: 5200, port: 5200,