Compare commits
7
Commits
196aa2e8ba
...
b18e66c28d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b18e66c28d | ||
|
|
f469ab7444 | ||
|
|
8d296c963e | ||
|
|
00ee563311 | ||
|
|
fa8c52b239 | ||
|
|
7bf18636d6 | ||
|
|
ba58ebb48d |
No files matched your search
@@ -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,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.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
@@ -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
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
module.exports = async function beforeBuild() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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
@@ -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" />
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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'
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in new issue
Block a user