Add desktop/server MP4 export pipeline, hardening, and render tests (v1.3.0)

Desktop MP4 export renders frames from a hidden BrowserWindow via
capturePage, pipes PNGs to a bundled ffmpeg (libx264/yuv420p/+faststart),
and writes the file through an atomic temp-file swap. It shares scene
validation (shared/renderValidation.cjs) and the render.html renderer with
the standalone Puppeteer server, which is hardened in parallel.

Render worker & main (electron/):
- abortable render loop with an inactivity watchdog and cancel plumbing
- atomic temp-file + rename so a cancel/failure never truncates a
  pre-existing target (#7); stdin EPIPE / early-exit / backpressure-hang
  handled (#8)
- HiDPI frames normalized to canvas size (#10); odd dimensions padded to
  even (#13, also server); backgroundThrottling disabled (#12)
- reject canvases larger than the display (#11); single-render guard;
  isolated render session that blocks network requests (#5)

Tests:
- Electron harnesses (scripts/verify-*.cjs) for capture, export, cancel,
  timeout, assets, background, capture-fidelity, resolution, odd-dims,
  aggregated by verify-desktop-render.cjs
- server smoke + e2e and shared validation unit tests
- headless hook (src/devtools/headless.ts) to run the in-app suite under
  browser/electron hosts; pixel-diff util + capture fixtures (test/)
- dev_docs plans: integration, fixes (#7-#13), and the e2e/capture strategy

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.8 committed 2026-06-06 20:41:25 +02:00
1 parent 46f9f32aef
commit 518d43855b
41 files changed
+3907 -86

No files matched your search

+5
View File
@@ -20,3 +20,8 @@ release
/ruwio.png
.codex
misc/
# Locally staged ffmpeg binaries. Release-managed binaries can be force-added.
/bin/linux/ffmpeg
/bin/darwin/ffmpeg
/bin/win32/ffmpeg.exe
+22
View File
@@ -68,6 +68,18 @@ npm run desktop:build # build platform installer/package artifacts into release
app without creating an installer. `desktop:build` uses `electron-builder` and
targets AppImage on Linux, NSIS on Windows, and DMG on macOS.
Desktop MP4 export runs inside Electron and uses ffmpeg from
`resources/bin/<platform>/` in packaged builds. The build scripts run
`npm run stage:ffmpeg -- --optional`, which copies `FFMPEG_PATH` or `ffmpeg` on
`PATH` into `bin/<platform>/` when available. For release artifacts, replace
that local copy with redistributable static ffmpeg binaries for Linux, Windows,
and macOS.
If no bundled binary is present, MP4 export still falls back to `FFMPEG_PATH`
or system `ffmpeg` and reports a clear error when neither can be executed.
Before shipping bundled ffmpeg, document the binary source, version, configured
codecs, and GPL/LGPL obligations for that build.
## MP4 render server
MP4 export is handled by the optional render server in `server/`. It binds to
@@ -96,6 +108,16 @@ The included `docker-compose.yml` keeps the published host port on
`127.0.0.1:6000` and requires `RENDER_TOKEN` in the environment before it will
start.
Render-server checks:
```bash
npm run test:render-server # health/auth/validation route smoke
npm run test:render-server:e2e # renders a tiny MP4 and verifies it with ffprobe
```
The E2E check requires Chromium, ffmpeg, and ffprobe. Set
`PUPPETEER_EXECUTABLE_PATH` when Chromium is not at `/usr/bin/chromium`.
## Stack
- Vue 3 (`<script setup>`) + Pinia state
+16
View File
@@ -0,0 +1,16 @@
# ffmpeg release binaries
Desktop MP4 export looks for ffmpeg at:
- `bin/linux/ffmpeg`
- `bin/darwin/ffmpeg`
- `bin/win32/ffmpeg.exe`
These binaries are packaged into Electron resources as `resources/bin/<platform>/`.
Use `npm run stage:ffmpeg -- --source /path/to/ffmpeg` to copy a local binary
into the correct folder, or set `FFMPEG_PATH` when running the app to use an
external ffmpeg without staging it.
Release builds should use a redistributable static ffmpeg build for each target
platform. Record the source URL, version, configured codecs, and license terms
for every binary in this folder before publishing an installer.
@@ -0,0 +1,366 @@
# Electron E2E & Capture Verification — Test Plan
Date: 2026-06-06
This plan defines how we verify (a) the **capture/render fidelity** of the
desktop and server export paths and (b) **all Electron-specific behaviour**, and
answers the open question of **whether the browser build and the Electron build
need separate test coverage**.
It builds on what already exists — do not re-invent these:
- the in-app **Test Runner** (`src/devtools/{testRunner.ts,tests.ts,TestOverlay.vue}`),
- the Node/Electron **render harnesses** (`scripts/verify-*.cjs`),
- the **server** tests (`server/{server.smoke,render.e2e}.test.cjs`),
- the **shared validation** unit tests (`shared/renderValidation.test.cjs`),
- the manual checklist (`electron-rendering-manual-checks.md`).
> **TL;DR**
> 1. **Don't fork the suite per build.** The frontend is ~99% shared and gated
> only on `window.ruwioDesktop`. Test the shared core **once**; cover the small,
> enumerable set of divergences with **seam-targeted** tests.
> 2. **Capture fidelity** is verified with one **golden-scene matrix** run through
> all three renderers (editor `drawSceneFrame`, desktop `capturePage`, server
> `screenshot`). The same matrix doubles as the **render.ts ⇄ render.html
> parity** check — the highest-value new test, because those two renderers are
> hand-duplicated and silently drift.
> 3. **Unlock the ~130 existing in-app suites for CI** by adding one headless
> auto-run hook, then host it under both a browser runner and an Electron
> runner. This is the cheapest large coverage win.
> 4. **Add the missing layer:** an Electron **app-level e2e** that launches the
> real app and drives menu commands / the export→save-dialog→file flow.
---
## 1. The system under test (what actually diverges)
### 1.1 Two renderer implementations (critical)
| Renderer | Code | Used by |
| --- | --- | --- |
| **#1 TS `drawSceneFrame`** | `src/lib/render.ts` | Live editor preview (`CanvasStage.vue` `drawPreview`), **and every client export**: GIF (`gifExport.ts`), APNG (`apngExport.ts`), WebM (`videoExport.ts`), still PNG/JPG (`stillImageExport.ts`), clipboard frame — all via `src/lib/frameExport.ts` `renderFrames()`. |
| **#2 vanilla-JS reimpl** | inlined in `server/render.html` (`drawFrame`/`sample`/`drawLayerNode`/`drawMaskShape`), exposed as `window.__animatrInit` / `window.__animatrSeek` | **Both MP4 backends**: the Puppeteer **server** (`server/server.js`) *and* the Electron **desktop worker** (`electron/renderWorker.cts`). |
These two are **functionally the same renderer maintained twice**. Renderer #1
is heavily covered by in-app pixel tests; Renderer #2 has only a non-blank/motion
fingerprint today. A feature added to `render.ts` (new asset kind, easing,
shadow/blur tweak, mask mode) that is not mirrored into `render.html` produces
**correct previews/GIFs but wrong MP4s** with no failing test. **Parity between
#1 and #2 is the single biggest coverage gap.**
### 1.2 Browser vs Electron — the full divergence set
Detection is **only** `window.ruwioDesktop` presence (set by `electron/preload.cts`).
No user-agent sniffing anywhere. The divergences are:
| # | Divergence | Where | Build | Test owner (proposed) |
| --- | --- | --- | --- | --- |
| D1 | **MP4 backend switch** `hasDesktopRender()` → desktop IPC vs server HTTP | `src/lib/desktopRenderExport.ts:12`, `ExportDialog.vue` (`exportMp4Server`) | both | unit (inject/remove bridge) + each backend's e2e |
| D2 | **Native save dialog** (`dialog.showSaveDialog`); `null` return on cancel | `electron/main.cts` `runRenderMp4` | electron | Electron app e2e (stubbed dialog) |
| D3 | **18 IPC menu commands** via `onMenuCommand` → `runDesktopMenuCommand` | `electron/main.cts` `createMenu`, `src/App.vue`, `src/types/desktop.d.ts` | electron | Electron app e2e |
| D4 | **Service worker** registered only in browser | `src/main.ts` (`!window.ruwioDesktop`) | browser | jsdom/browser unit |
| D5 | **Bundled ffmpeg + capturePage** pipeline | `electron/renderWorker.cts`, `electron/ffmpegPath.cts` | electron | capture/render e2e |
| D6 | **`renderActive` single-job guard** (mirrors server `MAX_ACTIVE_JOBS=1`) | `electron/main.cts` `handleRenderMp4` | electron | Electron app e2e / worker e2e |
| D7 | **Canvas-larger-than-display** hard error (#11) | `electron/main.cts` `runRenderMp4` | electron | Electron e2e / main unit |
| D8 | **Hardened render window** — `show:false`, white bg (#4), `backgroundThrottling:false` (#12), isolated partition, `http(s)` request block (#5) | `electron/renderWorker.cts` `createRenderWindow` | electron | capture/render e2e (extend `verify-assets`/`verify-bg`) |
| D9 | **Navigation/external-link hardening** (`setWindowOpenHandler`, `will-navigate`, `shell.openExternal` https-only) | `electron/main.cts` | electron | Electron app e2e (or main unit) |
| D10 | **`darwin` menu-role branches** | `electron/main.cts` `createMenu` | electron (mac) | smoke / manual |
| D11 | **HiDPI frame normalization** (#10), **odd-dim pad** (#13) | `electron/renderWorker.cts`, `server/server.js` | electron + server | capture/render e2e |
**Conclusion on the user's question:** *Yes, differentiate — but minimally.* The
shared frontend (stores, `drawSceneFrame`, UI components, all non-MP4 exports) is
build-agnostic and is tested **once**. Only D1–D11 need build-specific tests, and
each is a small, named seam. We do **not** maintain two parallel frontend suites.
---
## 2. Current coverage inventory & gaps
### Layer A — in-app Test Runner (`src/devtools/`) — renderer, **manual-only**
- Custom `suite`/`test`/`expect` DSL (`testRunner.ts`), ~130 suites in `tests.ts`,
results UI in `TestOverlay.vue`. Triggered by the dev menu `test-runner` command
(`App.vue`, gated on `import.meta.env.DEV`).
- Runs **in the live renderer** against real Pinia store, Konva stage, and DOM.
- Covers: scene store, interpolation/`sampleLayer`, **pixel-level `drawSceneFrame`**
(asset kinds, masks, groups, clip instances, shadow, blur, crop), client exports
(GIF/APNG magic bytes + `renderFrames` counts/scaling/pixels), serialization
round-trips, and a wide set of UI components + a live-DOM smoke suite.
- **Gap:** no headless invocation, **no exit code**, results only in UI/clipboard
→ invisible to CI. It is the largest existing asset and is currently un-automatable.
### Layer B — Node/Electron harnesses — **CI-shaped (exit codes)**
- `scripts/verify-*.cjs` via `npm run test:render-desktop` (spawns the real
`electron` binary): `verify-capture` (hidden window → `render.html` →
`capturePage` → BGRA fingerprint: non-blank, right size, motion advances),
`verify-export` (real worker → ffmpeg → ffprobe), `verify-cancel`,
`verify-timeout` (watchdog), `verify-assets` (D8 network block), `verify-bg`
(D8 white-flatten).
- `server/render.e2e.test.cjs` (full server lifecycle → SSE → download → ffprobe),
`server/server.smoke.test.cjs` (routes/auth/validation), `shared/renderValidation.test.cjs`.
- **Gaps:** capture is verified for *liveness/motion*, **not fidelity** (no golden
comparison, no breadth across asset kinds for the MP4 path); no render.ts⇄render.html
parity; no throughput (#12) assertion; #10/#11/#13 fixes have no regression test;
no app-level e2e (menus, dialog, `renderActive`); the in-app suite isn't reachable.
### Cross-cutting gaps
- **No CI** (`.github/workflows` absent) — every layer is run by hand.
- **No frontend unit framework** (no vitest/jsdom/test-utils) — only `node:test`
(`.cjs`) and the in-app runner.
---
## 3. Target architecture — the test pyramid
```
┌─────────────────────────────────────────────────────────┐
L3 app │ Electron app e2e (Playwright _electron): launch real app,│ NEW
│ menu commands D3, export→save-dialog→file D2, renderActive│
│ D6, oversize D7, nav-hardening D9, preload bridge shape │
├─────────────────────────────────────────────────────────┤
L2 e2e │ Capture & render e2e (extend scripts/verify-*): │ EXTEND
render │ • golden-scene matrix across 3 renderers (fidelity) │
│ • render.ts ⇄ render.html PARITY (§4) │
│ • #10 resolution / #11 oversize / #12 throughput / #13 │
│ • server render.e2e breadth parity │
├─────────────────────────────────────────────────────────┤
L1 comp │ In-app Test Runner, run HEADLESS under two hosts: │ UNLOCK
/unit │ • browser host (no ruwioDesktop) │
│ • electron host (ruwioDesktop present) → covers D1/D4 │
├─────────────────────────────────────────────────────────┤
L0 unit │ node:test: shared/renderValidation (have), pure helpers, │ KEEP/ADD
│ hasDesktopRender() seam, sanitizeMp4Name, ffmpegPath │
└─────────────────────────────────────────────────────────┘
```
The shared frontend is exercised once at **L1**; build divergences live at **L0
seam tests** and **L2/L3**.
---
## 4. Capture verification — the core design
**Goal:** prove that, for a known scene at a known time, every capture backend
produces the **correct** pixels — not merely a non-blank, advancing frame.
### 4.1 Golden-scene matrix
A small set of deterministic scene JSONs (`test/fixtures/scenes/*.json`) covering
the rendering feature matrix:
- asset kinds: `rect`, `circle`, `text`, `polygon` (open/closed, bezier), `image` (`data:` PNG)
- modifiers: shadow (opacity/blur/distance/angle), blur, stroke, opacity, rotation/scale
- compositing: top-level mask (in/out), grouped mask, multi-layer z-order via `trackOrder`
- clip instances (trim/startTime), background solid vs `backgroundVideo`/transparent
- a few sampled times per scene (e.g. t = 0, mid, end)
Each scene is **renderer-agnostic** (the same object the editor serializes via
`activeSceneClone()` and ships to both backends).
### 4.2 Three capture backends, one fixture set
Run every (scene, time) through:
1. **Editor renderer #1** — `drawSceneFrame(ctx, scene, t, images)` into an
offscreen canvas → `getImageData`. (Runs in the L1 host; no Electron.)
2. **Desktop capture** — hidden `BrowserWindow` → `render.html` →
`__animatrInit`/`__animatrSeek(t)` → `capturePage(rect)` → bitmap.
(Extend `scripts/verify-capture.cjs`.)
3. **Server capture** — Puppeteer page → `render.html` → `__animatrSeek(t)` →
`page.screenshot(clip)`. (Extend `server/render.e2e.test.cjs` or a sibling.)
### 4.3 What we assert
- **Parity (#1 ⇄ #2):** backend-2/3 output must match backend-1 (`drawSceneFrame`)
within a per-pixel tolerance. **This is the render.ts ⇄ render.html drift guard.**
Treat `drawSceneFrame` (the unit-tested TS renderer) as the spec.
- **Self-fidelity of `capturePage`:** compare `capturePage` output to render.html's
own `canvas.toDataURL()` for the same frame → proves the *capture step* is faithful
(no scale-factor inflation #10, no stale/off-by-one frame, no size clipping #11).
- **Golden regression:** optionally commit reference PNGs; diff with tolerance to
catch unintended renderer changes over time.
### 4.4 Determinism hazards (must be controlled or tests flake)
| Hazard | Mitigation |
| --- | --- |
| **Fonts** — `render.html` & `render.ts` use `sans-serif`; glyphs differ per OS | bundle a single test webfont and force it in fixtures; **or** exclude `text` from pixel-exact parity (assert bounding-box/coverage instead); pin CI OS for text goldens |
| **Anti-aliasing / GPU vs software** | run capture with `--disable-gpu`; compare with tolerance (e.g. ≤2/255 per channel, ≤0.1% pixels over threshold) |
| **Device scale factor** | pin to 1 in tests; #10 normalization makes desktop match; server already pins `deviceScaleFactor:1` |
| **Image decode / paint timing** | always `__animatrSeek` (double-rAF settle) before capture; `__animatrInit` resolves after first decode |
| **#10/#11 only manifest off scale-1 / large displays** | mark those assertions; run once on a HiDPI box / large virtual display (note in manual-checks) |
### 4.5 Fold the recent fixes in as regressions
- **#10 resolution:** `verify-resolution.cjs` — capture a scene, assert
`frame.getSize()`/ffprobe dims **exactly equal** the canvas (no-op on scale-1 CI;
real value on HiDPI — note it).
- **#11 oversize:** assert `main.runRenderMp4` rejects a canvas > display with the
clear error (drive via L3 app e2e, or extract the guard to a testable pure fn).
- **#12 throughput:** extend `verify-export.cjs` to time N frames and **fail if
per-frame wall-clock approaches the 1 s `__animatrSeek` fallback** (guards the
`backgroundThrottling` regression).
- **#13 odd dims:** render a 101×101 scene, ffprobe → expect a clean 102×102 MP4
(already proven by a throwaway harness; promote it to `verify-odd-dims.cjs` and add
the server equivalent). Mirror in `shared/renderValidation.test.cjs` if we ever
switch to reject-mode.
---
## 5. Unlocking the in-app Test Runner for CI (cheapest big win)
The ~130 suites already exercise the shared frontend. Make them headless:
1. **Auto-run hook.** On boot, if `location.search` has `?__runtests=1` (or
`import.meta.env.MODE === 'test'`), after mount call
`runAll(registerAllTests)` and publish results:
```ts
// src/devtools/headless.ts (new)
const results = await runAll(registerAllTests, { delayMs: 0 })
;(window as any).__ruwioTestResults = results
document.body.setAttribute('data-tests-done',
results.some(r => !r.ok) ? 'fail' : 'pass')
```
Keep it dev/test-only (tree-shaken from prod as the overlay already is).
2. **Two hosts, one suite:**
- **Browser host:** serve the built/dev app under Playwright (Chromium), open
`?__runtests=1`, wait for `data-tests-done`, read `__ruwioTestResults`, set
exit code. `window.ruwioDesktop` absent → exercises the **browser** branch (D1
server path selection, D4 SW gating present at module load).
- **Electron host:** Playwright `_electron` launches the dev app, same hook,
`page.evaluate(() => window.__ruwioTestResults)`. `window.ruwioDesktop`
present → exercises the **electron** branch.
3. **Tagging divergent suites.** A handful of suites assert build-specific behaviour;
add a convention (e.g. suite name prefix `desktop:` / `browser:`) and let the
headless hook skip the inapplicable set per host. Most suites are build-agnostic
and run under both.
This turns the existing investment into the L1 layer for **both** builds with a
single hook — and is itself the cleanest demonstration of "differentiate by host,
not by duplicating tests."
> Caveat: the runner mutates the live app and pins locale to `en` (it snapshots and
> restores store state). That is fine for a dedicated test page/run; do not point it
> at a user session.
---
## 6. The missing layer — Electron app-level e2e (L3)
Nothing today launches the **real assembled app** and drives the main↔renderer
seam (menus, native dialog, IPC round-trips, window hardening). Proposed:
**Tooling: Playwright `_electron`** (`electron.launch({ args: ['.'] })`), because
it (a) launches the packaged/dev main, (b) hands back the renderer as a `Page` for
UI assertions, (c) can `evaluate` in both main and renderer, and (d) the same
Playwright install serves the L1 browser host. (Alternative: `wdio-electron-service`
— stronger native-API mocking, heavier. Decision in §9.)
**Test-mode hook in `electron/main.cts`** (guarded by `RUWIO_E2E=1`), so native,
human-only surfaces become deterministic:
- **Stub the save dialog:** when `RUWIO_E2E`, `runRenderMp4` uses a fixed temp path
(or honours `RUWIO_E2E_SAVE_PATH`) instead of `dialog.showSaveDialog`. Lets the
full export→encode→file flow run unattended (covers D2, D5).
- **Fire menu commands programmatically:** expose `ipcMain.handle('ruwio:test-menu',
cmd => sendAppCommand(cmd))` (or call the existing `sendAppCommand`) so the e2e can
assert each of the 18 commands routes correctly in `App.vue` (covers D3).
- Keep the hook tiny and entirely behind the env flag.
**L3 scenarios:**
- Each menu command D3 produces its renderer effect (export dialog opens, undo/redo,
play/pause, panel toggle, etc.).
- Export MP4 end-to-end via the stubbed dialog → file exists, plays (ffprobe) → D2/D5.
- Cancel mid-export → no partial at the final path (we already fixed #7; assert it at
app level too).
- Second concurrent export rejected by `renderActive` → D6.
- Oversize canvas → clear error surfaced to the renderer → D7/#11.
- Preload bridge shape: `window.ruwioDesktop` exposes exactly the documented surface
(`platform`, `versions`, `onMenuCommand`, `renderMp4`, `onRenderProgress`,
`cancelRender`) and nothing more → contract test for `preload.cts`/`desktop.d.ts`.
- Navigation hardening D9: `window.open`/in-page nav to a non-allowlisted URL is denied
(can assert via main-process evaluate of the handlers).
---
## 7. Browser-only & seam unit tests (L0)
- **`hasDesktopRender()` switch (D1):** unit test that injects a fake
`window.ruwioDesktop.renderMp4` and asserts MP4 export calls the desktop path;
removes it and asserts the server path. Pure, fast, no Electron.
- **Service-worker gating (D4):** import `src/main.ts` logic under jsdom with/without
`window.ruwioDesktop` and assert `navigator.serviceWorker.register` is called only
in the browser case.
- **`sanitizeMp4Name`, `resolveFfmpegPath`, `playbackRange`, validation limits:** plain
`node:test` (validation already covered; add the others).
These need a tiny **jsdom + node:test** (or vitest) setup — the first frontend unit
harness in the repo. Keep it minimal; the bulk of component coverage stays in L1.
---
## 8. Deliverables & sequencing
Phased so each phase is independently valuable and CI-wireable.
**Phase 1 — Regression-proof the fixes we just shipped (small, high value)**
- `scripts/verify-odd-dims.cjs` (#13) + server odd-dims case; add to `verify-desktop-render.cjs` aggregator.
- `scripts/verify-resolution.cjs` (#10, no-op on scale-1 — documented).
- throughput assertion in `verify-export.cjs` (#12).
- `npm run test:render-desktop` picks these up automatically.
**Phase 2 — Capture fidelity & renderer parity (the core ask)**
- `test/fixtures/scenes/*.json` golden matrix (§4.1).
- `scripts/verify-capture-fidelity.cjs` — desktop `capturePage` vs `drawSceneFrame`
vs render.html `toDataURL`, with the §4.4 tolerance/determinism controls.
- server-side sibling reusing the same fixtures (extend `render.e2e.test.cjs`).
- a shared pixel-diff util (`test/lib/pixelDiff.cjs`).
- **Outcome:** render.ts ⇄ render.html drift is now caught.
**Phase 3 — Unlock the in-app suite headlessly**
- `src/devtools/headless.ts` auto-run hook + boot wiring (§5).
- `test/e2e/inapp.browser.spec.ts` (Playwright Chromium) and
`test/e2e/inapp.electron.spec.ts` (Playwright `_electron`).
- suite tagging convention for build-specific suites.
**Phase 4 — Electron app e2e (L3)**
- `RUWIO_E2E` test-mode hook in `electron/main.cts` (dialog stub + menu-fire IPC).
- `test/e2e/app.electron.spec.ts` covering D2/D3/D5/D6/D7/D9 + preload contract.
**Phase 5 — L0 seam units + CI**
- jsdom/node:test for D1/D4 + pure helpers.
- A root `test` script aggregating the CI-safe layers, and a first
`.github/workflows/test.yml` (matrix: lint/build, node `test:*`,
`test:render-*` with ffmpeg/Chromium, Playwright browser + electron jobs).
- Update `electron-rendering-manual-checks.md` to point at the new automated checks
and list what still needs a HiDPI/macOS box (#10, D10).
### Proposed `package.json` scripts (final shape)
```jsonc
"test": "npm run test:unit && npm run test:render-validation && npm run test:render-server && npm run test:capture-fidelity",
"test:unit": "node --test test/unit/*.test.cjs",
"test:capture-fidelity": "tsc -p tsconfig.electron.json && node scripts/verify-capture-fidelity.cjs",
"test:e2e:inapp": "playwright test test/e2e/inapp.*.spec.ts",
"test:e2e:app": "playwright test test/e2e/app.electron.spec.ts"
// existing: test:render-validation, test:render-server[:e2e], test:render-desktop
```
---
## 9. Open decisions (need a call before building Phases 3–4)
1. **App-e2e tool:** Playwright `_electron` (recommended — one tool for browser +
electron, renderer Page access) vs `wdio-electron-service` (better native-API
mocking) vs a lighter spawn-electron-and-assert-IPC harness (no UI driving).
2. **Golden images:** commit reference PNGs (catches *any* renderer change, but
needs a blessing workflow + pins CI OS for fonts) **or** parity-only (compare the
three live renderers to each other, no committed baselines — less brittle, but
can't catch a change that drifts all three together). Recommendation: **parity-first**,
add goldens later for a curated non-text subset.
3. **Text fidelity:** bundle a test font for deterministic glyphs, or exempt `text`
from pixel-exact parity (structural assertion only). Affects fixture design.
4. **In-app runner in CI:** accept that it mutates a live app on a dedicated test page
(recommended) — or carve a subset that is side-effect-free for faster CI.
5. **New frontend unit harness:** introduce **vitest + jsdom** (richer, watch mode,
Vue ecosystem) vs stay on **node:test + jsdom** (zero new deps). Recommendation:
vitest, since L0/L1 will grow.
---
## 10. Out of scope / explicitly not doing
- Re-testing the shared, build-agnostic frontend twice. It is covered once at L1 and
run under both hosts.
- Driving real native OS menus / native save dialogs in automation (brittle); we use
the IPC menu-fire hook and the dialog stub instead, and keep one **manual** pass in
`electron-rendering-manual-checks.md` for true native behaviour and macOS roles (D10).
- 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.
+356
View File
@@ -0,0 +1,356 @@
# Electron Rendering — Fix Plan (Second-Review Findings #7–#13)
Date: 2026-06-06
This document is a follow-on to `electron-rendering-integration-plan_2.md`.
That plan tracked findings **#1–#6** (all resolved). A second review of the
uncommitted desktop-render changes surfaced a new set of issues, numbered
**#7–#13** here to avoid collision. Two of them (#7, #8) are user-facing data
loss / crash bugs and should be fixed before any GUI/packaging sign-off.
> **Status (2026-06-06): all of #7–#13 are now IMPLEMENTED and verified.**
> #10 and #11 took the interim-mitigation path (per-frame resize back to canvas
> size + fail-fast on canvases larger than the display), not the offscreen
> rewrite; #13 pads odd dimensions up to even on both render paths. All six
> `test:render-desktop` checks pass, as do the server smoke + e2e tests; #7, #8,
> and #13 were additionally verified with throwaway harnesses (overwrite safety;
> early-ffmpeg-exit surfaces an error without crash or hang; a 101×101 canvas
> encodes to a clean 102×102 MP4). See the per-finding "Status" notes below.
> Note: plan_2's "What is already correct" section claims *"Partial output file
> is removed on failure/cancel"* as a safety property. That is precisely the
> mechanism behind finding **#7** — the worker writes ffmpeg output straight to
> the user's chosen path, so "removing the partial file" deletes a pre-existing
> file the user chose to overwrite. The note should be corrected when #7 lands.
## Summary table
| # | Sev | File | One-line |
| --- | --- | --- | --- |
| 7 | CRITICAL | `electron/renderWorker.cts` | Direct-to-target ffmpeg write + `fs.rm` destroys a pre-existing file on cancel/ffmpeg failure (data loss). |
| 8 | HIGH | `electron/renderWorker.cts` | No `error` listener on `ffmpeg.stdin`; an early ffmpeg exit makes the next `write()` throw an uncaught EPIPE that crashes the main process. |
| 9 | HIGH (trivial) | `package.json` | `"packageManager": "traversal@1.0.0"` is a bogus value; breaks corepack-driven tooling. |
| 10 | MEDIUM | `electron/renderWorker.cts` | `capturePage` returns frames at the display scale factor → HiDPI output is N× the requested resolution. |
| 11 | MEDIUM | `electron/renderWorker.cts` | A non-offscreen render window can't be captured larger than the display → canvases > screen are truncated. |
| 12 | MEDIUM (perf) | `electron/renderWorker.cts` + `server/render.html` | Hidden-window rAF is throttled, so `__animatrSeek` hits its 1 s fallback every frame → renders crawl at ~1 fps. |
| 13 | LOW (optional) | `electron/renderWorker.cts` + `server/server.js` | Odd canvas width/height fails `yuv420p`/libx264 (shared latent bug). |
Recommended sequencing: **#9 (1 line)** → **#7, #8 (correctness)** →
**#12 (one line, big perf win)** → decide the **#10/#11 strategy (offscreen vs.
mitigations)** → **#13 (optional hardening)**.
---
## #7 — CRITICAL: desktop export destroys an existing file on cancel/failure
> **Status: IMPLEMENTED.** Worker renders to a sibling
> `.<name>.<jobId>.partial` file and promotes it with `rename` (rm-then-rename
> fallback on Windows) only after `ffmpegDone` resolves; the failure/cancel path
> only ever removes the temp file. One extra change was required: ffmpeg infers
> its muxer from the output extension, and the temp file has none, so the spawn
> now passes an explicit `-f mp4` (this also hardens a final path the user typed
> without a `.mp4` extension). Verified: cancel mid-render leaves a pre-existing
> sentinel file byte-identical with no leftover `.partial`.
### Problem
`renderMp4Job` spawns ffmpeg writing **directly** to the user-chosen path:
```ts
const ffmpeg = spawn(ffmpegPath, [ '-y', ..., input.outputPath ])
```
and on any error/cancel does:
```ts
await fs.rm(input.outputPath, { force: true }).catch(() => {})
```
`ffmpeg -y <outputPath>` opens and **truncates** the target the instant it
starts. If the user chose to overwrite an existing file (the native save dialog
explicitly offers this), then any of:
- user clicks Cancel mid-render,
- the inactivity watchdog fires,
- ffmpeg exits non-zero,
leaves the original file already truncated, and the catch block then deletes it.
**Net result: the user's pre-existing file is gone and there is no replacement.**
The standalone server never has this problem because it renders into a private
`mkdtemp` workDir and only exposes the finished file.
### Fix (render to a sibling temp file, promote on success)
Write to a temp file in the **same directory** as the target (same filesystem ⇒
`rename` is atomic and can't hit `EXDEV`), and only touch the real path once the
render has fully succeeded:
```ts
const finalPath = input.outputPath
const tmpPath = path.join(
path.dirname(finalPath),
`.${path.basename(finalPath)}.${input.jobId}.partial`,
)
// spawn ffmpeg with tmpPath instead of finalPath
// success path, after `await ffmpegDone`:
await fs.rm(finalPath, { force: true }) // Windows rename won't replace an existing dest
await fs.rename(tmpPath, finalPath)
return { path: finalPath }
// catch path:
await fs.rm(tmpPath, { force: true }).catch(() => {}) // NEVER finalPath
```
Notes:
- The `rm(finalPath)` before `rename` only runs after a **successful** render
(tmp file is complete), so the old file is replaced, never lost.
- Keep `'-movflags', '+faststart'` — ffmpeg still finalizes into `tmpPath`.
- Optionally sweep stale `.*.partial` files in the target dir on worker start
(best-effort), to cover hard crashes.
### Verification
- New `scripts/verify-overwrite.cjs`: pre-create a sentinel file at the target
path, start an export to it, cancel mid-render, assert the **sentinel bytes
are unchanged** and no `.partial` file remains.
- Re-run `scripts/verify-cancel.cjs` (already passing) — must stay green.
---
## #8 — HIGH: an early ffmpeg exit crashes/hangs the worker
> **Status: IMPLEMENTED.** The investigation found the EPIPE was only one of
> three failure modes when ffmpeg exits mid-render; all three are now fixed:
> 1. **EPIPE crash** — added `ffmpeg.stdin.on('error', …)` so a write to the
> dead pipe can't become an uncaughtException.
> 2. **Unhandled-rejection crash** — `ffmpegDone` rejects mid-loop, long before
> the post-loop `await` attaches a handler; under Node's default
> `--unhandled-rejections=throw` that terminated the process. Now a no-op
> handler is attached at creation (`void ffmpegDone.catch(() => {})`); real
> handling still flows through `await ffmpegDone`. (This is why the original
> cancel test passed but the early-exit path died — the cancel path attaches
> its handler within Node's grace window, the early-exit path does not.)
> 3. **Backpressure hang** — when ffmpeg is dead, `stdin.write` returns false and
> the old `await stdin.once('drain')` waited forever (no drain ever comes, and
> it wasn't abortable). The wait now races `ffmpegDone` and `raceAbort(signal)`.
>
> Also added `if (settled) break` at the top of the frame loop so a dead ffmpeg
> stops being fed. Verified: a stub ffmpeg that exits non-zero mid-stream makes
> `renderMp4Job` reject with the ffmpeg error, and the process survives and exits
> 0 (no `UNCAUGHT_EXCEPTION` / `UNHANDLED_REJECTION`, no hang).
### Problem
The only error listeners are on the `ffmpeg` ChildProcess (`spawn`/`error`/
`exit`) and `ffmpeg.stderr`. There is **no listener on `ffmpeg.stdin`**. If
ffmpeg exits early (malformed frame, disk full, codec/resource error), the next
loop iteration runs:
```ts
if (!ffmpeg.stdin.write(png)) { ... }
```
Writing to a pipe whose read end is closed emits an **asynchronous** `'error'`
(EPIPE) on the stdin stream. Because it is async, the surrounding `try/catch`
does not catch it; with no `'error'` listener it becomes an
`uncaughtException` and takes down the **entire Electron app** (not just the
export). The standalone server has the same write pattern, but there it only
crashes an isolated, restartable server process — in the desktop app it kills
the user's editor.
### Fix
1. Swallow the stdin error (the real failure is already surfaced via the
`exit`/`ffmpegDone` path):
```ts
ffmpeg.stdin.on('error', () => { /* EPIPE after early exit; surfaced via ffmpegDone */ })
```
2. Stop feeding a dead ffmpeg so the *real* error surfaces promptly. `settled`
is already set in the `exit` handler — check it at the top of the loop:
```ts
for (let i = 0; i < input.options.totalFrames; i += 1) {
if (signal.aborted) throw abortReason(signal)
if (settled) break // ffmpeg already exited; quit feeding it
...
ffmpeg.stdin.write(png)
}
ffmpeg.stdin.end()
await ffmpegDone // rejects with "ffmpeg exited with code N: <stderr>"
```
### Verification
- New `scripts/verify-ffmpeg-early-exit.cjs`: induce an early ffmpeg exit (e.g.
point `FFMPEG_PATH` at a stub that reads a few bytes then `exit(1)`, or kill
the ffmpeg child mid-stream) and assert `renderMp4Job` **rejects** with a
surfaced ffmpeg error and the **process does not crash** (the test harness
itself surviving is the assertion).
---
## #9 — HIGH (trivial): bogus `packageManager` field
> **Status: IMPLEMENTED.** Line removed from `package.json`; valid JSON, no
> references remain.
### Problem
`package.json:6`:
```json
"packageManager": "traversal@1.0.0",
```
`traversal` is not a package manager. It is referenced nowhere and is absent
from `package-lock.json` — almost certainly an accidental insertion. In any
corepack-enabled environment (Node ships corepack; CI or contributors may have
run `corepack enable`), invoking npm/yarn/pnpm reads this field and errors
(`Unsupported package manager 'traversal'`), blocking installs and builds.
### Fix
Delete the line. (If a manager really must be pinned, use a real value like
`"npm@10.x"`.)
### Verification
`npm install` / `npm ci` and `corepack npm -v` succeed in a clean checkout.
---
## #10 / #11 / #12 — Capture fidelity & performance
These three share a root cause: the render window is a **hidden, non-offscreen**
`BrowserWindow`, and `capturePage` is coupled to the physical display
(scale factor and size) and to Chromium's background throttling. The strategic
fix is offscreen rendering (already floated in plan_2 finding #1); pragmatic
per-issue mitigations are listed if offscreen is deferred.
### #10 — MEDIUM: HiDPI doubles the output resolution
> **Status: IMPLEMENTED (interim mitigation).** The frame loop now calls
> `frame.getSize()` and, when the captured `NativeImage` doesn't match the
> requested canvas, `frame.resize({width, height})` back to the canvas dims
> before `toPNG()`. No-op on scale-factor-1 displays. The offscreen "deep fix"
> below was not taken. (Can't be reproduced on this scale-1 box; should be
> sanity-checked once on a HiDPI machine.)
`capturePage(captureRect)` takes a rect in DIPs but returns a `NativeImage` at
the **display's device scale factor**. On a 2× display a 1920×1080 canvas yields
a 3840×2160 PNG, so the MP4 is double the requested resolution and ~4× the size
— diverging from the server, which pins `deviceScaleFactor: 1`. Likely
unnoticed so far because the verify harness runs at scale factor 1.
Interim mitigation (normalize every frame to the requested size):
```ts
const frame = await raceAbort(renderWindow.webContents.capturePage(captureRect), signal)
const { width, height } = frame.getSize()
const png = (width === captureRect.width && height === captureRect.height
? frame
: frame.resize({ width: captureRect.width, height: captureRect.height })
).toPNG()
```
(Do **not** use `app.commandLine.appendSwitch('force-device-scale-factor','1')`
— it is global and would degrade the editor UI on HiDPI.)
### #11 — MEDIUM: canvases larger than the display are truncated
> **Status: IMPLEMENTED (interim mitigation).** `main.cts` now compares the
> normalized canvas against `screen.getPrimaryDisplay().size` before starting and
> throws a clear error ("Canvas WxH is larger than this display …") rather than
> silently truncating. This is desktop-only: the web/server path keeps using a
> headless viewport and still accepts up to 4096². The offscreen "deep fix" below
> (which would let the desktop path render oversize canvases too) was not taken.
A non-offscreen window can't be composited/captured beyond the display bounds,
so a canvas larger than the screen (validation allows up to 4096²) produces a
clipped frame. The server avoids this with a headless Chrome viewport.
Interim mitigation: in `main.cts`, before starting, compare the (normalized)
canvas against `screen.getPrimaryDisplay().workAreaSize` and **fail fast with a
clear error** rather than silently truncating; or document a desktop max-canvas
limit. (The real fix is offscreen.)
### #12 — MEDIUM (perf): hidden-window rAF throttling ⇒ ~1 fps
> **Status: IMPLEMENTED.** `backgroundThrottling: false` added to the render
> window's `webPreferences`. (#10 and #11 remain open; the offscreen-rendering
> "deep fix" below was not taken.)
`__animatrSeek` now resolves after a double `requestAnimationFrame`, with a
`setTimeout(finish, 1000)` anti-hang fallback. But the render window is
`show: false` with **no `backgroundThrottling: false`**, so Chromium throttles/
pauses rAF for the hidden document — the rAFs don't fire promptly and the **1 s
fallback becomes the per-frame path**. A 10 s @ 30 fps render then takes ~5 min.
(The verify harness checks correctness, not throughput, so this slipped through.)
Fix (one line, large win):
```ts
webPreferences: {
...
backgroundThrottling: false, // keep rAF firing while the window is hidden
}
```
### Deep fix (recommended) — offscreen rendering, subsumes #10/#11/#12
Switch the render window to `webPreferences: { offscreen: true }`:
- renders to an in-memory bitmap **independent of the physical display** (fixes
#11 oversize) at a controllable scale (fixes #10 HiDPI),
- isn't background-throttled and pairs with `webContents.setFrameRate(...)`
(fixes #12),
- capture via either the `paint` event / `beginFrameSubscription` or
`capturePage` (still pair with the existing rAF settle to avoid the
off-by-one from plan_2 #2).
Cost/risk: changes the capture mechanism, so the **entire** `test:render-desktop`
suite (all 6 checks) must be re-run, plus new HiDPI + large-canvas checks, plus
a GPU-vs-software-rendering sanity pass on each platform. If offscreen lands,
#10/#11/#12 close together and the interim mitigations above are unnecessary.
### Verification (either path)
- New `scripts/verify-resolution.cjs`: render and `ffprobe` the output; assert
width/height **exactly equal** the canvas dims. (Won't reproduce #10 on a
scale-factor-1 CI box; note that, and run it once on a HiDPI machine.)
- New `scripts/verify-large-canvas.cjs`: render a canvas larger than the test
display; assert full-size, non-truncated frames.
- New throughput assertion (extend `verify-export.cjs`): time N frames and fail
if per-frame wall-clock is near the 1 s fallback — guards the #12 regression.
---
## #13 — LOW (optional): odd canvas dimensions fail `yuv420p`
> **Status: IMPLEMENTED (pad to even).** Both ffmpeg spawns (desktop worker and
> standalone server) now carry `-vf pad=ceil(iw/2)*2:ceil(ih/2)*2`, so an odd
> canvas encodes (e.g. 101×101 → 102×102) instead of crashing with "height not
> divisible by 2". Validation was left permissive (no reject). Verified by a
> throwaway harness rendering a 101×101 scene to a clean 102×102 H.264 MP4, and
> the server e2e test still passes.
### Problem
libx264 with `-pix_fmt yuv420p` requires **even** width/height. Validation
allows any integer 1..4096, so an odd-dimension canvas (e.g. 101×101) makes
ffmpeg exit with a confusing "height not divisible by 2" error. This is shared
with the standalone server (pre-existing), but the new desktop path is a fresh
surface for it. On HiDPI it's accidentally masked because the 2× capture is
always even — fixing #10 (normalize to canvas size) re-exposes it.
### Fix (pick one, apply to both desktop worker and server)
- Pad to even in the ffmpeg args (least surprising, adds ≤1 px):
`'-vf', 'pad=ceil(iw/2)*2:ceil(ih/2)*2'`, **or**
- Reject odd dimensions in `shared/renderValidation.cjs` with a clear message.
### Verification
Add an odd-dimension case (e.g. 101×101) to `shared/renderValidation.test.cjs`
and/or a desktop check asserting a clean result instead of an ffmpeg crash.
---
## Out of scope / already correct (do not redo)
- Validation parity, the abort/watchdog plumbing, the cancel-before-jobId race,
ENOENT friendly message, packaging wiring, and the `__animatrSeek` paint
settle (plan_2 #2) are all sound and unchanged by this plan.
- Image assets are imported as `data:` URLs, so the render window's
`*://*/*` network block (plan_2 #5) does not drop them — no action needed.
## Suggested doc bookkeeping
When these land, update `electron-rendering-integration-plan_2.md`:
- correct the "Partial output file is removed on failure/cancel" note (#7),
- and `electron-rendering-manual-checks.md` with the new verify scripts.
@@ -0,0 +1,291 @@
# Electron Rendering Integration Plan
Date: 2026-06-05
## Verdict
Yes, the MP4 rendering part can be included in the Electron app.
The current render server is already made from desktop-compatible pieces:
- Node orchestration in `server/server.js`
- a browser render surface in `server/render.html`
- frame capture through Chromium
- MP4 encoding through `ffmpeg`
- progress, cancellation, temp files, validation, and cleanup around the job
The main caveat is that the server cannot be bundled as-is and expected to work in a packaged desktop app. It currently assumes:
- `puppeteer-core` is installed in the server package
- Chromium exists at `PUPPETEER_EXECUTABLE_PATH` or `/usr/bin/chromium`
- `ffmpeg` exists on `PATH`
- the Vue app talks to `http://localhost:6000` with `VITE_RENDER_SERVER`
- Electron Builder packages only `dist/`, `dist-electron/`, and root `package.json`
So the feature is feasible, but the right implementation is to move the render job into the Electron main process behind IPC, not to expose an HTTP render server from the desktop app.
## Recommended Architecture
Use Electron as the local render host:
- Vue renderer keeps owning project state and export UI.
- Preload exposes a narrow desktop render API on `window.ruwioDesktop`.
- Electron main validates render requests, starts/cancels jobs, captures frames, runs `ffmpeg`, and returns a saved MP4 path or file bytes.
- The standalone `server/` package remains available for web or self-hosted server deployments.
Preferred render surface:
- Use a hidden/offscreen Electron `BrowserWindow` to load a packaged render page.
- Call `webContents.executeJavaScript` or `webContents.send` to initialize the scene and seek each frame.
- Use `webContents.capturePage()` for PNG frame capture.
- Pipe PNG frames into `ffmpeg` the same way `server/server.js` does today.
This avoids bundling a second Chromium through Puppeteer. Electron already ships Chromium, so the desktop build only needs to solve `ffmpeg` packaging.
Fallback/spike option:
- Start the existing Express render server as a child process bound to `127.0.0.1` with a random token.
- Point the renderer at that local URL.
- This is useful for a quick feasibility spike, but it keeps the localhost server, token plumbing, Puppeteer, and external Chromium dependency in the desktop app. It should not be the final architecture.
## Dependency Strategy
### Chromium
Do not add a separate Chromium binary for the first native implementation. Use Electron's bundled Chromium through a hidden render window.
Only keep Puppeteer for the standalone server unless a spike proves Electron capture cannot match render quality or performance.
### ffmpeg
`ffmpeg` is the real packaged-app dependency.
Implementation options:
1. Bundle per-platform `ffmpeg` binaries as `extraResources`.
2. Use a maintained npm package that supplies platform-specific binaries.
3. Support a user-configured/system `ffmpeg` path as a fallback.
Recommended first production path:
- Add an internal resolver such as `electron/ffmpegPath.cts`.
- In development, prefer `process.env.FFMPEG_PATH`, then `ffmpeg` on `PATH`.
- In packaged builds, prefer `process.resourcesPath/bin/<platform>/ffmpeg`.
- Show a friendly export error if no usable binary is found.
## Implementation Plan
## Phase 1: Extract Shared Render Validation
Goal: keep server and desktop limits identical.
Tasks:
1. Move request validation and playback range helpers out of `server/server.js` into a shared module.
2. Keep the module Node-compatible and browser-safe where practical.
3. Export:
- `validateRenderRequest(scene, options)`
- `playbackRange(scene)`
- render limits and allowed ffmpeg presets
4. Update `server/server.js` to use the shared validator.
5. Add focused tests for invalid dimensions, fps, duration, frame count, layers, keyframes, `crf`, and preset.
Acceptance:
- Existing server behavior is unchanged.
- Desktop rendering can reuse the same validation before launching any render surface or child process.
## Phase 2: Add Desktop Render IPC Contract
Goal: let the Vue app request MP4 rendering without knowing whether the backend is HTTP or Electron.
Tasks:
1. Extend `src/types/desktop.d.ts` with a render API:
```ts
renderMp4(options: {
scene: Scene
fps?: number
crf?: number
preset?: string
suggestedName?: string
}): Promise<{ path: string } | null>
onRenderProgress(callback: (event: {
jobId: string
progress: number
done: boolean
error?: string
}) => void): () => void
cancelRender(jobId: string): Promise<void>
```
2. Add preload wrappers that call `ipcRenderer.invoke` and subscribe to progress events.
3. Add main-process handlers:
- `ruwio:render-mp4`
- `ruwio:render-cancel`
4. Validate every IPC payload in main before starting a job.
5. Keep job IDs and abort controllers in main, not in the Vue renderer.
Acceptance:
- A renderer call can start a fake/stub render and receive progress.
- Cancel reaches the main process.
- Invalid payloads fail before any child process or hidden window is created.
## Phase 3: Build The Electron Render Worker
Goal: replace Puppeteer with Electron's own Chromium for desktop exports.
Tasks:
1. Create an Electron-only render worker module, for example `electron/renderWorker.cts`.
2. Package a render page:
- first pass: reuse `server/render.html` as an extra resource
- later pass: consider generating a render page from the shared browser renderer to reduce drift
3. Create a hidden `BrowserWindow` per job or a serialized reusable worker window:
- `show: false`
- `webPreferences.sandbox: true`
- no Node integration
- restricted navigation
4. Load the render page from a local file URL.
5. Initialize the scene.
6. For each frame:
- seek to the target time
- wait for the render function to settle
- capture the page or canvas bounds
- write PNG bytes to `ffmpeg.stdin`
- emit progress over IPC
7. On cancel or timeout:
- abort the frame loop
- kill `ffmpeg`
- close the worker window
- remove temp files
Acceptance:
- A tiny scene renders to MP4 in `npm run desktop:dev`.
- Progress updates reach `ExportDialog`.
- Cancel stops capture and `ffmpeg`.
- Temp directories are removed on success, failure, and cancel.
## Phase 4: Package ffmpeg
Goal: make MP4 export work in packaged desktop builds.
Tasks:
1. Choose the binary source:
- committed/release-managed binaries under a `vendor/` or `bin/` folder, or
- npm package binaries copied into Electron Builder resources
2. Add Electron Builder `extraResources` entries for:
- Linux ffmpeg
- Windows ffmpeg.exe
- macOS ffmpeg
- render worker HTML/assets
3. Add `ffmpeg` path resolution for dev and packaged builds.
4. Verify executable permissions on macOS/Linux packaged output.
5. Document licensing obligations for the selected `ffmpeg` build.
Acceptance:
- `npm run desktop:pack` includes the render page and ffmpeg.
- The unpacked app exports MP4 without a separately installed render server.
- Missing ffmpeg produces a clear user-facing error.
## Phase 5: Switch ExportDialog To Desktop Rendering
Goal: use native rendering in Electron and keep the current server path for the web app.
Tasks:
1. Add `src/lib/desktopRenderExport.ts`.
2. In `ExportDialog.vue`, route MP4 export as:
- if `window.ruwioDesktop?.renderMp4` exists: use desktop IPC render
- else: use current `downloadFromServer`
3. Reuse current progress UI and cancellation behavior.
4. Save the finished MP4 through a native save dialog from main.
5. Keep `VITE_RENDER_SERVER` support for browser builds and remote/self-hosted workflows.
Acceptance:
- Desktop users do not need `cd server && npm run start`.
- Browser users can still use the external render server.
- The MP4 button reports a helpful error when no server is configured in browser mode.
## Phase 6: Keep Standalone Server Working
Goal: avoid breaking existing server deployments.
Tasks:
1. Keep `server/server.js` as an Express wrapper around shared validation/render helpers where useful.
2. Keep `server/render.html` or replace it only after desktop and server render outputs are compared.
3. Preserve `/health`, `/render`, `/events`, `/file`, and `DELETE` behavior.
4. Add a smoke test or manual check for server MP4 export after shared refactors.
Acceptance:
- `cd server && npm run start` still starts the local render server.
- The Vue app can still render through `VITE_RENDER_SERVER` and `VITE_RENDER_TOKEN`.
## Phase 7: Verification Matrix
Run these before calling the integration complete:
- `npm run build`
- `npm run desktop:pack`
- `npm run test:render-server`
- `npm run test:render-server:e2e`
- desktop dev MP4 export of a small scene
- desktop packaged MP4 export of a small scene
- cancel an MP4 export after progress starts
- export a scene with image assets
- export a scene with text/fonts
- export a scene at non-default fps
- export a scene with hidden layers skipped
- server-mode MP4 export still works
Manual platform checks:
- Linux AppImage
- Windows NSIS build
- macOS DMG build
## Risks And Mitigations
- **ffmpeg packaging varies by platform**: solve with explicit binary resolution and an unpacked-app smoke check before installer work.
- **render drift between `server/render.html` and `src/lib/render.ts`**: keep parity tests, then consider generating the desktop/server render page from shared code.
- **large scenes can freeze the desktop app**: keep render work in main-controlled worker windows, serialize jobs initially, enforce the same render limits as the server.
- **IPC can move large payloads**: scenes are JSON-sized today, but avoid sending frame buffers over IPC; frame capture and `ffmpeg` piping should stay in main.
- **untrusted project assets reach Chromium**: keep the hidden render window sandboxed, restrict navigation, block external network requests unless explicitly allowed, and keep existing project validation work moving.
## Suggested First Pull Request
Keep the first PR small and proof-oriented:
1. Extract shared render validation from `server/server.js`.
2. Add a stubbed desktop render IPC contract.
3. Add `desktopRenderExport.ts` and route the MP4 button to desktop IPC when available.
4. Return a fake progress/done result in development or behind a temporary flag.
That proves the app integration and cancellation shape before dealing with frame capture and ffmpeg packaging.
## Suggested Second Pull Request
Implement the real Electron render worker:
1. Hidden render window.
2. Frame capture loop.
3. `ffmpeg` stdin piping.
4. Temp directory cleanup.
5. Dev-only `ffmpeg` path resolution.
## Suggested Third Pull Request
Make it shippable:
1. Package ffmpeg and render worker assets.
2. Verify `desktop:pack`.
3. Add packaged-app manual test notes.
4. Document ffmpeg licensing and update README desktop export notes.
@@ -0,0 +1,308 @@
# Electron Rendering Integration — Plan 2 (Review Findings + Remaining Work)
Date: 2026-06-05
This document supersedes `electron-rendering-integration-plan.md` for ongoing
work. It is self-contained: you do not need the original plan to continue from
here. It records what is already implemented, what was found during review, and
the concrete remaining TODOs in priority order.
## Goal
Render MP4 inside the Electron desktop app without the standalone HTTP render
server, while keeping that standalone server working for the web/self-hosted
build. Desktop uses Electron's own Chromium (a hidden render window) for frame
capture and a bundled `ffmpeg` for H.264 encoding.
## Architecture (as built)
- **Vue renderer** owns project state and the export UI (`ExportDialog.vue`).
For MP4 it calls `renderMp4OnDesktop()` when `window.ruwioDesktop.renderMp4`
exists, otherwise falls back to the HTTP server path (`downloadFromServer`).
- **Preload** (`electron/preload.cts`) exposes a narrow API on
`window.ruwioDesktop`: `renderMp4`, `onRenderProgress`, `cancelRender`.
- **Main process** (`electron/main.cts`) validates every IPC payload, shows a
native save dialog, owns job IDs + `AbortController`s, and forwards progress
events. IPC channels: `ruwio:render-mp4`, `ruwio:render-cancel`, progress on
`ruwio:render-progress`.
- **Render worker** (`electron/renderWorker.cts`) creates a hidden
`BrowserWindow`, loads the packaged render page, seeks each frame, captures
it, and pipes PNG bytes into `ffmpeg` stdin. Mirrors the server frame loop.
- **Shared validation** (`shared/renderValidation.cjs`) is the single source of
render limits, `playbackRange`, and `validateRenderRequest`, used by both
`server/server.js` and `electron/main.cts`.
- **ffmpeg resolution** (`electron/ffmpegPath.cts`): `FFMPEG_PATH` env →
`process.resourcesPath/bin/<platform>/<ffmpeg>` (packaged) → `ffmpeg` on PATH.
- **Staging script** (`scripts/stage-ffmpeg.cjs`) copies an ffmpeg binary into
`bin/<platform>/` for packaging. Wired into `desktop:build`/`desktop:pack`
via `stage:ffmpeg -- --optional`.
- **Packaging** (`package.json` `build`): `extraResources` ships
`server/render.html` and `bin/`; `files` ships `shared/renderValidation.cjs`.
## Key files
| Concern | File |
| --- | --- |
| Renderer export routing | `src/components/ExportDialog.vue` (`exportMp4Server`) |
| Renderer desktop client | `src/lib/desktopRenderExport.ts` |
| Desktop API types | `src/types/desktop.d.ts` |
| Preload bridge | `electron/preload.cts` |
| Main IPC + save dialog | `electron/main.cts` (`handleRenderMp4`, `handleRenderCancel`) |
| Frame capture + ffmpeg | `electron/renderWorker.cts` |
| ffmpeg path resolver | `electron/ffmpegPath.cts` |
| Shared validation | `shared/renderValidation.cjs` |
| Render page (capture surface) | `server/render.html` (`window.__animatrInit`, `window.__animatrSeek`) |
| Standalone server | `server/server.js` |
| ffmpeg staging | `scripts/stage-ffmpeg.cjs` |
| Desktop render verification | `scripts/verify-*.cjs` + runner `scripts/verify-desktop-render.cjs` (`npm run test:render-desktop`) |
## Current verified state
Last verified: 2026-06-06 (Linux, DISPLAY available, system ffmpeg/ffprobe 7.1.4).
- `tsc -p tsconfig.electron.json` — passes.
- `npm run build` (vue-tsc + vite) — passes.
- `npm run test:render-validation` — 7/7 pass.
- `npm run test:render-server` — pass.
- `npm run test:render-server:e2e` — pass (server still produces a playable
H.264 MP4; confirms the shared `render.html` change below is server-safe).
- `npm run test:render-desktop` — **all 6 desktop checks pass.** New aggregated
Electron harness suite (`scripts/verify-*.cjs`, runner
`scripts/verify-desktop-render.cjs`) that drives the real worker headlessly:
- capture: hidden-window `capturePage` yields advancing, non-blank frames.
- export: the real `renderMp4Job` produces a 25-frame H.264 MP4 with motion.
- cancel: mid-export abort rejects cleanly, no partial file.
- timeout: the inactivity watchdog aborts a wedged render (~2s).
- assets: local `data:` assets load; remote `http:` is blocked, no hang.
- background: transparent regions flatten to white (not black).
- `npm run desktop:pack` — succeeds. Packaged resources verified:
`resources/server/render.html`, executable `resources/bin/linux/ffmpeg`
(runs `-version`), and `app.asar` contains `dist-electron/{main,renderWorker,
ffmpegPath}.cjs` + `shared/renderValidation.cjs`.
Findings #1–#6 are all resolved (see status tags below).
Not yet done:
- A manual GUI desktop export (dev or packaged) through the save dialog. The
worker logic itself is proven end-to-end by `test:render-desktop`; only the
click-through has not been recorded.
- **`bin/linux/ffmpeg` is the dynamically-linked Debian system binary (~358 KB),
not a redistributable static build.** Fine for local dev; NOT release-safe.
- Windows/macOS ffmpeg binaries and licensing notes (Phase C #10–11).
- Platform installer builds (AppImage / NSIS / DMG) and their manual checks.
## Review findings (ordered by severity)
### 1. CRITICAL — `capturePage()` on a non-shown, non-offscreen window — RESOLVED (does not reproduce)
Verified 2026-06-06 via `scripts/verify-capture.cjs`: a hidden, non-offscreen
window's `capturePage()` returns real, non-blank frames whose contents advance
frame-to-frame (linearly-stepping position hash over a moving rect). No
offscreen mode was needed. The remaining staleness risk was finding #2.
Original analysis below.
`renderWorker.cts` creates the render window with `show: false` but **not**
`webPreferences.offscreen: true`, then calls `webContents.capturePage()` once
per frame. Hidden, non-offscreen windows are not composited by Chromium, so
`capturePage()` can return blank or stale frames. This is the single biggest
risk and cannot be confirmed by reading code — it needs a real export of a
moving scene, frame-stepped, to confirm frames actually advance.
Fix direction if it reproduces:
- Switch the render window to `webPreferences.offscreen: true` and drive capture
off the `paint` event (`win.webContents.on('paint', (e, dirty, image) => ...)`
/ `beginFrameSubscription`), **or**
- Show the window positioned off-screen (e.g. far negative coordinates) so the
compositor paints it, then `capturePage`.
Offscreen mode also naturally solves finding #2.
### 2. HIGH — No wait for a compositor paint between seek and capture — RESOLVED (was real; fixed)
Confirmed real and reproducible: without a settle, `capturePage` intermittently
returned the *previous* frame (stale/off-by-one) in ~1/3 of runs. Fixed in
`server/render.html`: `window.__animatrSeek` now returns a Promise that resolves
only after a double `requestAnimationFrame` (with a 1s anti-hang fallback), so
both the desktop worker (`await executeJavaScript`) and the server (Puppeteer
`await evaluate`) wait for a paint before capturing. 6/6 PASS after the fix;
`test:render-server:e2e` confirms the server path is unaffected.
Original analysis below.
`__animatrSeek` runs a synchronous 2D-canvas `drawFrame`, but the worker calls
`capturePage` immediately after `executeJavaScript(...seek)` with no wait for a
paint. This risks an off-by-one frame or tearing. The original plan explicitly
required "wait for the render function to settle."
Fix direction: await a double `requestAnimationFrame` inside the page (or, in
offscreen mode, await the next `paint` event) before capturing.
### 3. MEDIUM — No render timeout in the desktop worker — RESOLVED
Added an inactivity watchdog in `renderWorker.cts`: a combined `AbortController`
(`jobAbort`) trips on user cancel or after `RENDER_INACTIVITY_TIMEOUT_MS`
(default 2 min, override via `RUWIO_RENDER_TIMEOUT_MS`) with no progress. The
timer is re-armed on every `onProgress`, and each per-frame await is wrapped in
`raceAbort(...)` so a wedged `executeJavaScript`/`capturePage` is interrupted,
ffmpeg is killed, and the partial file removed. Verified by
`scripts/verify-timeout.cjs` (aborts ~2s with message "render timed out").
Original analysis below.
`server/server.js` auto-aborts a stuck job via `JOB_TIMEOUT_MS`. The desktop
worker has no watchdog, so a hung `executeJavaScript`/`capturePage` wedges the
export with only manual cancel as escape.
Fix direction: add a timeout that aborts the job's `AbortController` after N
seconds of no progress (reset the timer on each `onProgress`).
### 4. MEDIUM — Transparent window flattens to black under `yuv420p` — RESOLVED
Decision: match the server (flatten onto white). The render window is now
`transparent: false` / `backgroundColor: '#ffffff'`, so transparent regions
(e.g. `backgroundVideo` scenes, where `render.html` skips the bg fill) capture
as white instead of black. Verified by `scripts/verify-bg.cjs` (corner pixel
`rgb(255,255,255)`, zero near-black pixels).
Original analysis below.
The render window is `transparent: true`. Opaque scenes are fine because
`render.html` fills `canvas.background` over the full frame, but any genuinely
transparent region flattens onto **black** (vs the server's Puppeteer path,
which flattens onto white via `omitBackground:false`). Decide this consciously:
either make the render window background opaque, or document MP4 transparency
behavior as black-filled.
### 5. LOW — Outbound network not blocked in the render window — RESOLVED
The render window now uses an isolated session partition (`ruwio-render`) with a
`session.webRequest.onBeforeRequest` filter on `['*://*/*']` that cancels all
http/https requests; local schemes (`file:`/`data:`/`blob:`) are unmatched and
load normally. Verified by `scripts/verify-assets.cjs`: a local `data:` image
draws (1600 px), a remote `http:` asset is blocked (0 px) and does not hang.
Original analysis below.
The render window denies `window.open` and `will-navigate`, but does not block
network requests. A project asset with an `http(s)` `src` will be fetched by the
hidden window. The original plan listed "block external network requests unless
explicitly allowed." Low risk for a local app; close it for defense-in-depth via
`session.webRequest.onBeforeRequest` or a CSP on the render page.
### 6. LOW — Desktop jobs are not serialized in main — RESOLVED
`main.cts` now guards with a module-level `renderActive` flag set at the top of
`handleRenderMp4` (before the save dialog) and reset in a `finally`; a second
concurrent IPC invoke throws "A render is already in progress". Mirrors the
server's `MAX_ACTIVE_JOBS=1`.
Original analysis below.
`handleRenderMp4` will run concurrent jobs; only the `ExportDialog`
`if (progress.value) return` guard prevents it from the UI, and IPC is directly
invokable. The server enforces `MAX_ACTIVE_JOBS`. Add a one-line guard in main
(reject or queue when a job is already active).
## What is already correct (do not re-do)
- Partial output file is removed on failure/cancel; ffmpeg is drained and the
window closed in `finally`.
- Cancel-before-jobId race handled in `desktopRenderExport.ts` via
`pendingAbort`, including the window where main is blocked on the save dialog.
- ffmpeg ENOENT surfaces the friendly `missingFfmpegMessage()` through both the
`spawn`-error and `exit`-`!started` branches.
- Packaging wiring is consistent: render page + `bin/` in `extraResources`,
`shared/renderValidation.cjs` in `files`, resolvers read `process.resourcesPath`.
- Validation parity: both server and main call the same `validateRenderRequest`;
desktop uses frozen `DEFAULT_LIMITS`, server overlays env-configurable limits.
- Filename sanitization (`sanitizeMp4Name`) strips path separators before the
save dialog.
## Remaining TODOs
### Phase A — Prove frame capture works — DONE
Findings #1 and #2 resolved (see status tags above). The acceptance criteria
(moving scene → correct MP4; clean cancel; no partial file) are covered by
`npm run test:render-desktop` (capture/export/cancel checks).
### Phase B — Robustness — DONE
Findings #3, #4, #5, #6 all implemented and verified (status tags above).
### Phase C — Packaging + cross-platform (partly done)
8. `npm run desktop:pack` + packaged-resource verification — DONE for Linux
(see "Current verified state").
9. **TODO (manual GUI):** launch the unpacked app and export MP4 with no
standalone server running. Click-through not yet recorded.
10. **TODO:** stage reviewed *redistributable static* ffmpeg binaries for Linux
(`bin/linux/ffmpeg`, replace the current dynamic system copy), Windows
(`bin/win32/ffmpeg.exe`), and macOS (`bin/darwin/ffmpeg`).
11. **TODO:** record ffmpeg release notes per bundled binary: source URL,
upstream version, platform/arch, static vs dynamic, codecs (esp.
`libx264`), GPL/LGPL obligations, attribution/license files to ship.
### Phase D — Verification matrix
Automated checks — DONE and recorded under "Current verified state"
(`build`, `desktop:pack`, `test:render-validation`, `test:render-server`,
`test:render-server:e2e`, `test:render-desktop`).
**TODO (manual GUI):**
- desktop dev export of a small scene through the save dialog
- desktop packaged export of a small scene
- scenes with: image assets; text/non-default fonts; non-default fps;
hidden layers skipped
- server-mode MP4 export via the browser fallback
- Platform builds: Linux AppImage, Windows NSIS, macOS DMG
## Manual check commands (reference)
Server fallback smoke (needs Chromium, ffmpeg, ffprobe):
```bash
npm run test:render-server
npm run test:render-server:e2e
```
Browser fallback against the standalone server:
```bash
# terminal 1
cd server
HOST=127.0.0.1 PORT=6000 RENDER_TOKEN="choose-a-long-random-token" npm start
# terminal 2
VITE_RENDER_SERVER=http://localhost:6000 VITE_RENDER_TOKEN="choose-a-long-random-token" npm run dev
```
Stage a platform ffmpeg for packaging:
```bash
npm run stage:ffmpeg -- --source /path/to/ffmpeg
```
## Risks carried forward
- **Frame capture on hidden windows** (finding #1) is the gating risk for the
whole desktop path. Resolve Phase A before any packaging work.
- **Render drift** between `server/render.html` and `src/lib/render.ts`: keep
validation/parity in mind; consider generating the render page from shared
code later to avoid divergence.
- **ffmpeg packaging varies by platform**: solve with explicit binary
resolution (done) plus an unpacked-app smoke check before installer work.
@@ -0,0 +1,121 @@
# Electron MP4 Render Manual Checks
Date: 2026-06-05
Use this checklist before treating desktop MP4 export as release-ready.
## Automated Desktop Render Checks
Before the manual checks below, run the headless harness suite (requires a
display, ffmpeg, and ffprobe). It drives the real worker end-to-end and covers
frame capture, MP4 output, cancel, the inactivity watchdog, the render-window
network block, and transparent-region flattening:
```bash
npm run test:render-desktop
```
All six checks should print `RESULT: PASS` and the run ends with
`ALL DESKTOP RENDER CHECKS PASSED`.
## Build Checks
1. Stage a redistributable ffmpeg binary for the target platform:
```bash
npm run stage:ffmpeg -- --source /path/to/ffmpeg
```
2. Build an unpacked app:
```bash
npm run desktop:pack
```
3. Confirm packaged resources exist:
```bash
find release/linux-unpacked/resources -maxdepth 4 -type f | sort | rg 'server/render.html|bin/.*/ffmpeg'
```
4. Confirm Unix executable permissions on Linux/macOS:
```bash
ls -l release/linux-unpacked/resources/bin/linux/ffmpeg
```
5. Confirm the packaged binary runs:
```bash
release/linux-unpacked/resources/bin/linux/ffmpeg -version
```
## App Checks
1. Launch the unpacked app.
2. Create a tiny scene with at least one visible animated layer.
3. Export MP4 at the default FPS and confirm:
- a native save dialog appears
- progress advances in the export dialog
- the final file exists at the selected path
- the file opens in a standard media player
4. Start another MP4 export and cancel after progress starts. Confirm:
- progress stops
- the app remains responsive
- no partial MP4 remains at the selected path
5. Repeat with:
- image assets
- text layers using non-default fonts
- non-default FPS
- layers hidden from render
## Server Fallback Checks
Automated route smoke:
```bash
npm run test:render-server
```
Automated MP4 E2E smoke:
```bash
npm run test:render-server:e2e
```
The E2E smoke starts the standalone server on a temporary localhost port,
submits a tiny scene, waits for SSE completion, downloads the MP4, and verifies
the H.264 stream with `ffprobe`. It requires Chromium, ffmpeg, and ffprobe.
Manual browser fallback:
1. Start the standalone server:
```bash
cd server
HOST=127.0.0.1 PORT=6000 RENDER_TOKEN="choose-a-long-random-token" npm start
```
2. Start the browser app with matching render settings:
```bash
VITE_RENDER_SERVER=http://localhost:6000 VITE_RENDER_TOKEN="choose-a-long-random-token" npm run dev
```
3. Export MP4 from the browser app and confirm the server path still works.
## ffmpeg Release Notes
For every bundled ffmpeg binary, record:
- source URL
- upstream version
- target platform and architecture
- whether the build is static or dynamically linked
- configured codecs, especially `libx264`
- GPL/LGPL license obligations
- required attribution or license files to ship with the app
The local staging script may copy a system ffmpeg. That is useful for package
path verification, but release artifacts should use reviewed redistributable
binaries for each platform.
+30
View File
@@ -0,0 +1,30 @@
import { app } from 'electron'
import fs from 'node:fs'
import path from 'node:path'
export function resolveFfmpegPath(): string {
const envPath = process.env.FFMPEG_PATH?.trim()
if (envPath) return envPath
const exe = process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'
const bundledPath = path.join(process.resourcesPath, 'bin', process.platform, exe)
if (isUsableBinary(bundledPath)) return bundledPath
return 'ffmpeg'
}
export function missingFfmpegMessage(): string {
if (app.isPackaged) {
return 'ffmpeg is not bundled with this RUWIO build, or it cannot be executed. Install ffmpeg or set FFMPEG_PATH to export MP4.'
}
return 'ffmpeg was not found. Install ffmpeg or set FFMPEG_PATH to export MP4.'
}
function isUsableBinary(file: string): boolean {
try {
fs.accessSync(file, process.platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK)
return true
} catch {
return false
}
}
+125 -2
View File
@@ -1,7 +1,13 @@
import { app, BrowserWindow, Menu, shell } from 'electron'
import type { BrowserWindow as BrowserWindowInstance, Event, MenuItemConstructorOptions } from 'electron'
import { app, BrowserWindow, dialog, ipcMain, Menu, screen, shell } from 'electron'
import type { BrowserWindow as BrowserWindowInstance, Event, IpcMainInvokeEvent, MenuItemConstructorOptions } from 'electron'
import { randomUUID } from 'node:crypto'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { renderMp4Job } from './renderWorker.cjs'
const { validateRenderRequest } = require('../shared/renderValidation.cjs') as {
validateRenderRequest(scene: unknown, options: unknown): RenderValidation
}
const isDev = !app.isPackaged
const devServerUrl = process.env.VITE_DEV_SERVER_URL ?? 'http://localhost:5200'
@@ -11,12 +17,127 @@ const appIconPath = isDev
: path.join(process.resourcesPath, 'build', 'icon.png')
let mainWindow: BrowserWindowInstance | null = null
const renderJobs = new Map<string, AbortController>()
// Serialize desktop renders: one active export at a time, mirroring the
// standalone server's MAX_ACTIVE_JOBS=1 (finding #6). The UI also guards this,
// but IPC is directly invokable, so enforce it in main too.
let renderActive = false
interface RenderValidation {
ok: boolean
error?: string
scene?: Parameters<typeof renderMp4Job>[0]['scene']
options?: Parameters<typeof renderMp4Job>[0]['options']
}
interface RenderMp4Payload {
scene?: unknown
fps?: unknown
crf?: unknown
preset?: unknown
suggestedName?: unknown
}
function sendAppCommand(command: string): void {
if (!mainWindow || mainWindow.isDestroyed()) return
mainWindow.webContents.send('ruwio:menu-command', command)
}
function sendRenderProgress(
target: Electron.WebContents,
event: { jobId: string; progress: number; done: boolean; error?: string },
): void {
if (!target.isDestroyed()) target.send('ruwio:render-progress', event)
}
function sanitizeMp4Name(value: unknown): string {
const raw = typeof value === 'string' && value.trim() ? value.trim() : 'animation.mp4'
const noPath = raw.replace(/[\\/]+/g, '-')
const safe = noPath.replace(/[^a-z0-9äöüß._-]+/gi, '-').replace(/^-+|-+$/g, '')
const named = safe || 'animation.mp4'
return named.toLowerCase().endsWith('.mp4') ? named : `${named}.mp4`
}
async function handleRenderMp4(event: IpcMainInvokeEvent, payload: RenderMp4Payload): Promise<{ path: string } | null> {
if (renderActive) {
throw new Error('A render is already in progress')
}
renderActive = true
try {
return await runRenderMp4(event, payload)
} finally {
renderActive = false
}
}
async function runRenderMp4(event: IpcMainInvokeEvent, payload: RenderMp4Payload): Promise<{ path: string } | null> {
const validation = validateRenderRequest(payload?.scene, {
fps: payload?.fps,
crf: payload?.crf,
preset: payload?.preset,
})
if (!validation.ok || !validation.scene || !validation.options) {
throw new Error(validation.error || 'invalid render request')
}
// The desktop path captures a hidden, on-screen BrowserWindow, which Chromium
// can't composite/capture beyond the physical display bounds — a canvas larger
// than the screen would silently yield truncated frames. Fail fast with a clear
// message instead (finding #11). The web/server path uses a headless viewport
// and has no such limit, so this is desktop-only.
const { width: canvasWidth, height: canvasHeight } = validation.scene.canvas
const { width: displayWidth, height: displayHeight } = screen.getPrimaryDisplay().size
if (canvasWidth > displayWidth || canvasHeight > displayHeight) {
throw new Error(
`Canvas ${canvasWidth}×${canvasHeight} is larger than this display ` +
`(${displayWidth}×${displayHeight}). Desktop export can't capture a canvas ` +
`bigger than the screen — reduce the canvas size and try again.`,
)
}
const window = BrowserWindow.fromWebContents(event.sender)
const saveOptions = {
title: 'Export MP4',
defaultPath: sanitizeMp4Name(payload?.suggestedName),
filters: [{ name: 'MP4 Video', extensions: ['mp4'] }],
}
const saveResult = window
? await dialog.showSaveDialog(window, saveOptions)
: await dialog.showSaveDialog(saveOptions)
if (saveResult.canceled || !saveResult.filePath) return null
const jobId = randomUUID()
const controller = new AbortController()
renderJobs.set(jobId, controller)
sendRenderProgress(event.sender, { jobId, progress: 0, done: false })
try {
await renderMp4Job({
jobId,
scene: validation.scene,
options: validation.options,
outputPath: saveResult.filePath,
signal: controller.signal,
onProgress: (progress) => {
sendRenderProgress(event.sender, { jobId, progress, done: false })
},
})
sendRenderProgress(event.sender, { jobId, progress: 1, done: true })
return { path: saveResult.filePath }
} catch (err) {
const message = (err as Error).message || String(err)
sendRenderProgress(event.sender, { jobId, progress: 0, done: true, error: message })
throw err
} finally {
renderJobs.delete(jobId)
}
}
async function handleRenderCancel(_event: IpcMainInvokeEvent, jobId: unknown): Promise<void> {
if (typeof jobId !== 'string' || !jobId) return
renderJobs.get(jobId)?.abort()
}
function parseUrl(raw: string): URL | null {
try {
return new URL(raw)
@@ -256,6 +377,8 @@ function createMenu(): void {
app.setName('RUWIO')
app.whenReady().then(() => {
ipcMain.handle('ruwio:render-mp4', handleRenderMp4)
ipcMain.handle('ruwio:render-cancel', handleRenderCancel)
createMenu()
createMainWindow()
+29
View File
@@ -14,4 +14,33 @@ contextBridge.exposeInMainWorld('ruwioDesktop', {
ipcRenderer.removeListener('ruwio:menu-command', listener)
}
},
renderMp4(options: {
scene: unknown
fps?: number
crf?: number
preset?: string
suggestedName?: string
}) {
return ipcRenderer.invoke('ruwio:render-mp4', options)
},
onRenderProgress(callback: (event: {
jobId: string
progress: number
done: boolean
error?: string
}) => void) {
const listener = (_event: Electron.IpcRendererEvent, payload: {
jobId: string
progress: number
done: boolean
error?: string
}) => callback(payload)
ipcRenderer.on('ruwio:render-progress', listener)
return () => {
ipcRenderer.removeListener('ruwio:render-progress', listener)
}
},
cancelRender(jobId: string) {
return ipcRenderer.invoke('ruwio:render-cancel', jobId)
},
})
+309
View File
@@ -0,0 +1,309 @@
import { BrowserWindow, app } from 'electron'
import type { Rectangle } from 'electron'
import { spawn } from 'node:child_process'
import fs from 'node:fs/promises'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import { missingFfmpegMessage, resolveFfmpegPath } from './ffmpegPath.cjs'
const validation = require('../shared/renderValidation.cjs') as {
playbackRange(scene: RenderScene): { start: number; end: number }
}
// Inactivity watchdog: abort a job if no frame makes progress for this long.
// The timer is re-armed on every onProgress, so steady renders never trip it;
// only a wedged executeJavaScript/capturePage does. Mirrors the standalone
// server's RENDER_JOB_TIMEOUT_MS safety net (finding #3).
function readTimeoutMs(): number {
const raw = Number(process.env.RUWIO_RENDER_TIMEOUT_MS)
return Number.isFinite(raw) && raw >= 1000 ? raw : 2 * 60 * 1000
}
const RENDER_INACTIVITY_TIMEOUT_MS = readTimeoutMs()
// Isolated, in-memory session for the hidden render window so its webRequest
// filter can't affect the main app session (finding #5).
const RENDER_PARTITION = 'ruwio-render'
function abortReason(signal: AbortSignal): Error {
return signal.reason instanceof Error ? signal.reason : new Error('aborted')
}
// Reject as soon as `signal` aborts, otherwise settle with `promise`. Lets a
// timeout/cancel interrupt an await that would otherwise hang (finding #3).
function raceAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
if (signal.aborted) return Promise.reject(abortReason(signal))
return new Promise<T>((resolve, reject) => {
const onAbort = () => reject(abortReason(signal))
signal.addEventListener('abort', onAbort, { once: true })
promise.then(
(value) => {
signal.removeEventListener('abort', onAbort)
resolve(value)
},
(err) => {
signal.removeEventListener('abort', onAbort)
reject(err)
},
)
})
}
export interface RenderScene {
canvas: { width: number; height: number }
duration: number
layers: Array<{ keyframes: Array<{ time: number }> }>
assets?: unknown[]
[key: string]: unknown
}
export interface RenderOptions {
fps: number
duration: number
totalFrames: number
crf: string
preset: string
}
export interface RenderJobInput {
jobId: string
scene: RenderScene
options: RenderOptions
outputPath: string
signal: AbortSignal
onProgress: (progress: number) => void
}
export async function renderMp4Job(input: RenderJobInput): Promise<void> {
const renderWindow = createRenderWindow(input.scene)
const ffmpegPath = resolveFfmpegPath()
let ffmpegErr = ''
let settled = false
let started = false
// Render into a sibling temp file and promote it on success, so a cancelled
// or failed render never truncates a pre-existing file the user chose to
// overwrite (finding #7). Same directory => rename stays on one filesystem.
const finalPath = input.outputPath
const tmpPath = path.join(
path.dirname(finalPath),
`.${path.basename(finalPath)}.${input.jobId}.partial`,
)
// Combined abort: trips on user cancel (input.signal) or the inactivity
// watchdog. Used to kill ffmpeg and to interrupt per-frame awaits.
const jobAbort = new AbortController()
const onExternalAbort = () => jobAbort.abort(new Error('aborted'))
if (input.signal.aborted) onExternalAbort()
else input.signal.addEventListener('abort', onExternalAbort, { once: true })
let watchdog: ReturnType<typeof setTimeout> | undefined
const armWatchdog = () => {
if (watchdog) clearTimeout(watchdog)
watchdog = setTimeout(
() => jobAbort.abort(new Error('render timed out')),
RENDER_INACTIVITY_TIMEOUT_MS,
)
}
const ffmpeg = spawn(ffmpegPath, [
'-y',
'-f', 'image2pipe',
'-framerate', String(input.options.fps),
'-i', '-',
'-c:v', 'libx264',
'-pix_fmt', 'yuv420p',
// libx264 + yuv420p require even dimensions, but validation allows any
// canvas in 1..4096 — an odd width/height would otherwise make ffmpeg exit
// with a confusing "height not divisible by 2" error. Pad up to the next
// even size (≤1px edge column/row) so odd canvases encode (finding #13).
'-vf', 'pad=ceil(iw/2)*2:ceil(ih/2)*2',
'-preset', input.options.preset,
'-crf', input.options.crf,
'-movflags', '+faststart',
// Force the muxer explicitly: the temp filename has no .mp4 extension, and
// the final path may lack one too, so don't rely on extension inference.
'-f', 'mp4',
tmpPath,
])
// If ffmpeg exits early (encode error, disk full, ...), the next stdin write
// emits an async 'error' (EPIPE). Without a listener that becomes an
// uncaughtException and crashes the app; the real failure still surfaces via
// the 'exit'/ffmpegDone path below (finding #8).
ffmpeg.stdin.on('error', () => {
/* noop: surfaced via ffmpegDone */
})
const abort = () => {
try {
ffmpeg.kill('SIGKILL')
} catch {
/* noop */
}
}
jobAbort.signal.addEventListener('abort', abort, { once: true })
const ffmpegReady = new Promise<void>((resolve, reject) => {
ffmpeg.once('spawn', () => resolve())
ffmpeg.once('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'ENOENT') reject(new Error(missingFfmpegMessage()))
else reject(err)
})
})
const ffmpegDone = new Promise<void>((resolve, reject) => {
ffmpeg.stderr.on('data', (d: Buffer) => {
ffmpegErr += d.toString()
})
ffmpeg.on('spawn', () => {
started = true
})
ffmpeg.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'ENOENT') reject(new Error(missingFfmpegMessage()))
else reject(err)
})
ffmpeg.on('exit', (code) => {
settled = true
if (code === 0) resolve()
else if (jobAbort.signal.aborted) reject(abortReason(jobAbort.signal))
else if (!started) reject(new Error(missingFfmpegMessage()))
else reject(new Error(`ffmpeg exited with code ${code}: ${ffmpegErr.slice(-500)}`))
})
})
// ffmpeg can fail mid-render, so ffmpegDone may reject long before the
// `await ffmpegDone` after the loop attaches a handler. Keep a handler on it
// from creation so that gap can't trip Node's unhandled-rejection process
// termination; real handling still flows through `await ffmpegDone` (#8).
void ffmpegDone.catch(() => {})
const signal = jobAbort.signal
try {
armWatchdog()
await raceAbort(ffmpegReady, signal)
await raceAbort(loadRenderPage(renderWindow), signal)
await raceAbort(
renderWindow.webContents.executeJavaScript(
`window.__animatrInit(${JSON.stringify(input.scene)})`,
true,
),
signal,
)
const { start: rangeIn } = validation.playbackRange(input.scene)
const captureRect: Rectangle = {
x: 0,
y: 0,
width: input.scene.canvas.width,
height: input.scene.canvas.height,
}
for (let i = 0; i < input.options.totalFrames; i += 1) {
if (signal.aborted) throw abortReason(signal)
// ffmpeg already exited (e.g. encode error): stop feeding it so the real
// error surfaces via `await ffmpegDone` below instead of an EPIPE (#8).
if (settled) break
const time = rangeIn + Math.min(i / input.options.fps, input.options.duration)
await raceAbort(
renderWindow.webContents.executeJavaScript(`window.__animatrSeek(${time})`, true),
signal,
)
const frame = await raceAbort(renderWindow.webContents.capturePage(captureRect), signal)
// capturePage returns a NativeImage at the display's device scale factor,
// so on a HiDPI screen the bitmap is N× the requested canvas and the MP4
// comes out at the wrong resolution. Normalize every frame back to the
// canvas dimensions — the standalone server pins deviceScaleFactor:1 for
// the same reason. No-op on scale-factor-1 displays (finding #10).
const frameSize = frame.getSize()
const png = (frameSize.width === captureRect.width && frameSize.height === captureRect.height
? frame
: frame.resize({ width: captureRect.width, height: captureRect.height })
).toPNG()
if (!ffmpeg.stdin.write(png)) {
// Wait for backpressure to clear — but don't hang forever if ffmpeg has
// already died (no 'drain' will ever fire) or the job is aborted. Racing
// ffmpegDone surfaces an early ffmpeg exit here instead of wedging (#8).
await raceAbort(
Promise.race([
new Promise<void>((resolve) => ffmpeg.stdin.once('drain', resolve)),
ffmpegDone,
]),
signal,
)
}
input.onProgress((i + 1) / input.options.totalFrames)
armWatchdog()
}
ffmpeg.stdin.end()
await ffmpegDone
// Render succeeded and tmpPath is complete: promote it into place. Try a
// plain rename first (atomic replace on POSIX); Windows won't overwrite an
// existing destination, so fall back to remove-then-rename there (#7).
try {
await fs.rename(tmpPath, finalPath)
} catch {
await fs.rm(finalPath, { force: true })
await fs.rename(tmpPath, finalPath)
}
} catch (err) {
if (!settled) abort()
try {
ffmpeg.stdin.destroy()
} catch {
/* noop */
}
// Only ever remove our temp file, never the user's chosen target (#7).
await fs.rm(tmpPath, { force: true }).catch(() => {})
await ffmpegDone.catch(() => {})
throw err
} finally {
if (watchdog) clearTimeout(watchdog)
input.signal.removeEventListener('abort', onExternalAbort)
jobAbort.signal.removeEventListener('abort', abort)
if (!renderWindow.isDestroyed()) renderWindow.close()
}
}
function createRenderWindow(scene: RenderScene): BrowserWindow {
const win = new BrowserWindow({
width: scene.canvas.width,
height: scene.canvas.height,
show: false,
frame: false,
// Opaque white background so transparent regions (e.g. backgroundVideo
// scenes) flatten onto white under yuv420p — matching the standalone
// server's Puppeteer screenshot (omitBackground:false), not black (#4).
transparent: false,
backgroundColor: '#ffffff',
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
partition: RENDER_PARTITION,
// The window is never shown, so Chromium would throttle/pause rAF and the
// per-frame paint settle in __animatrSeek would hit its 1s fallback every
// frame (~1fps renders). Keep timers/rAF running while hidden (finding #12).
backgroundThrottling: false,
},
})
// Defense-in-depth: the render page loads only local (file:/data:/blob:)
// assets, so block any networked request the captured project might carry
// (finding #5). The url filter only matches http/https, leaving local
// schemes untouched. Scoped to this window's isolated session partition.
win.webContents.session.webRequest.onBeforeRequest(
{ urls: ['*://*/*'] },
(_details, callback) => callback({ cancel: true }),
)
win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
win.webContents.on('will-navigate', (event) => event.preventDefault())
return win
}
async function loadRenderPage(win: BrowserWindow): Promise<void> {
const renderHtml = app.isPackaged
? path.join(process.resourcesPath, 'server', 'render.html')
: path.join(process.cwd(), 'server', 'render.html')
await win.loadURL(pathToFileURL(renderHtml).toString())
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ruwio",
"version": "1.2.13",
"version": "1.2.15",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ruwio",
"version": "1.2.13",
"version": "1.2.15",
"dependencies": {
"@phosphor-icons/vue": "^2.2.1",
"gifenc": "^1.0.3",
+23 -4
View File
@@ -1,7 +1,7 @@
{
"name": "ruwio",
"private": true,
"version": "1.2.15",
"version": "1.3.0",
"type": "module",
"main": "dist-electron/main.cjs",
"scripts": {
@@ -9,11 +9,17 @@
"build": "vue-tsc --noEmit && vite build",
"desktop:dev": "concurrently -k \"vite\" \"wait-on http://localhost:5200 && npm run desktop:start\"",
"desktop:start": "tsc -p tsconfig.electron.json && electron .",
"desktop:build": "npm run build && tsc -p tsconfig.electron.json && electron-builder",
"desktop:pack": "npm run build && tsc -p tsconfig.electron.json && electron-builder --dir",
"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",
"preview": "vite preview",
"type-check": "vue-tsc --noEmit",
"pack": "bash scripts/pack.sh"
"pack": "bash scripts/pack.sh",
"test:render-validation": "node --test shared/renderValidation.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-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",
"stage:ffmpeg": "node scripts/stage-ffmpeg.cjs"
},
"dependencies": {
"@phosphor-icons/vue": "^2.2.1",
@@ -39,18 +45,31 @@
"appId": "app.ruwio.desktop",
"productName": "RUWIO",
"icon": "icon.png",
"npmRebuild": false,
"directories": {
"output": "release"
},
"files": [
"dist/**/*",
"dist-electron/**/*",
"shared/renderValidation.cjs",
"package.json"
],
"extraResources": [
{
"from": "build/icon.png",
"to": "build/icon.png"
},
{
"from": "server/render.html",
"to": "server/render.html"
},
{
"from": "bin",
"to": "bin",
"filter": [
"**/*"
]
}
],
"linux": {
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env node
const { copyFileSync, chmodSync, existsSync, mkdirSync, statSync } = require('node:fs')
const path = require('node:path')
const { spawnSync } = require('node:child_process')
const args = process.argv.slice(2)
const optional = args.includes('--optional')
const sourceIndex = args.indexOf('--source')
const explicitSource = sourceIndex >= 0 ? args[sourceIndex + 1] : ''
const root = path.resolve(__dirname, '..')
const exe = process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'
const targetDir = path.join(root, 'bin', process.platform)
const target = path.join(targetDir, exe)
try {
mkdirSync(targetDir, { recursive: true })
if (existsSync(target) && statSync(target).isFile()) {
chmodIfNeeded(target)
console.log(`ffmpeg already staged at ${path.relative(root, target)}`)
process.exit(0)
}
const source = explicitSource || process.env.FFMPEG_PATH || findOnPath()
if (!source) {
finishWithoutBinary('ffmpeg was not found. Set FFMPEG_PATH or pass --source /path/to/ffmpeg.')
}
const resolvedSource = path.resolve(source)
if (!existsSync(resolvedSource) || !statSync(resolvedSource).isFile()) {
finishWithoutBinary(`ffmpeg source is not a file: ${source}`)
}
copyFileSync(resolvedSource, target)
chmodIfNeeded(target)
console.log(`staged ffmpeg from ${resolvedSource} to ${path.relative(root, target)}`)
} catch (err) {
if (optional) {
console.warn(`warning: could not stage ffmpeg: ${err.message}`)
process.exit(0)
}
throw err
}
function findOnPath() {
const command = process.platform === 'win32' ? 'where' : 'which'
const result = spawnSync(command, [exe], { encoding: 'utf8' })
if (result.status !== 0) return ''
return result.stdout.split(/\r?\n/).map((line) => line.trim()).find(Boolean) || ''
}
function chmodIfNeeded(file) {
if (process.platform !== 'win32') chmodSync(file, 0o755)
}
function finishWithoutBinary(message) {
if (optional) {
console.warn(`warning: ${message}`)
process.exit(0)
}
console.error(message)
process.exit(1)
}
+109
View File
@@ -0,0 +1,109 @@
// Phase B verification of the render-window network block (finding #5):
// - a local data: image asset still loads and draws (block must not break it)
// - an http: image asset is blocked and skipped without hanging the render
//
// Uses the REAL worker window (createRenderWindow) by loading render.html in a
// window built the same way, so it exercises the actual webRequest filter.
//
// Run: node_modules/.bin/electron scripts/verify-assets.cjs
const { app, BrowserWindow } = require('electron')
const path = require('node:path')
const { pathToFileURL } = require('node:url')
const RENDER_PARTITION = 'ruwio-render'
// 40x40 green square as an inline SVG data URL (no network needed).
const greenSvg =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40'%3E%3Crect width='40' height='40' fill='%2300ff00'/%3E%3C/svg%3E"
function makeScene(src) {
return {
canvas: { width: 160, height: 120, background: '#202030' },
duration: 1,
assets: [{ id: 'img1', kind: 'image', width: 40, height: 40, src }],
layers: [
{
id: 'l1',
assetId: 'img1',
visible: true,
base: { x: 80, y: 60, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
keyframes: [],
},
],
}
}
function createRenderWindow(scene) {
const win = new BrowserWindow({
width: scene.canvas.width,
height: scene.canvas.height,
show: false,
frame: false,
transparent: false,
backgroundColor: '#ffffff',
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
partition: RENDER_PARTITION,
},
})
win.webContents.session.webRequest.onBeforeRequest({ urls: ['*://*/*'] }, (_d, cb) =>
cb({ cancel: true }),
)
win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
win.webContents.on('will-navigate', (e) => e.preventDefault())
return win
}
function countGreen(image) {
const bmp = image.getBitmap() // BGRA
let green = 0
for (let i = 0; i < bmp.length; i += 4) {
if (bmp[i] < 120 && bmp[i + 1] > 150 && bmp[i + 2] < 120) green++
}
return green
}
async function renderOnce(src) {
const scene = makeScene(src)
const win = createRenderWindow(scene)
const renderHtml = path.join(process.cwd(), 'server', 'render.html')
await win.loadURL(pathToFileURL(renderHtml).toString())
// Bound by Promise.race so a network-blocked asset that mis-handles can't hang.
const initDone = win.webContents.executeJavaScript(
`window.__animatrInit(${JSON.stringify(scene)})`,
true,
)
const timedOut = await Promise.race([
initDone.then(() => false),
new Promise((r) => setTimeout(() => r(true), 5000)),
])
await win.webContents.executeJavaScript('window.__animatrSeek(0)', true)
const img = await win.webContents.capturePage({ x: 0, y: 0, width: 160, height: 120 })
const green = countGreen(img)
if (!win.isDestroyed()) win.close()
return { green, timedOut }
}
app.on('window-all-closed', () => {})
app.whenReady().then(async () => {
const local = await renderOnce(greenSvg)
const remote = await renderOnce('http://10.255.255.1/never.png') // unroutable; must not hang
const notes = []
let pass = true
// ~1600 green px expected for a 40x40 image; allow for AA.
if (local.green < 1000) { pass = false; notes.push(`local data: image did not draw (green=${local.green})`) }
if (local.timedOut) { pass = false; notes.push('local image init timed out') }
if (remote.green > 50) { pass = false; notes.push(`remote image drew despite block (green=${remote.green})`) }
if (remote.timedOut) { pass = false; notes.push('remote (blocked) image init hung past 5s') }
console.log(`local: green=${local.green} timedOut=${local.timedOut}`)
console.log(`remote(blocked): green=${remote.green} timedOut=${remote.timedOut}`)
console.log(notes.length ? 'NOTES:\n ' + notes.join('\n ') : 'NOTES: none')
console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL')
app.exit(pass ? 0 : 1)
})
+89
View File
@@ -0,0 +1,89 @@
// Phase B verification of transparent-region flattening (finding #4): a scene
// with backgroundVideo set makes render.html skip the background fill, leaving
// transparent areas. With the opaque-white render window those areas must
// capture as white (~255), matching the server's omitBackground:false, not
// black. Builds the window exactly like renderWorker.createRenderWindow.
//
// Run: node_modules/.bin/electron scripts/verify-bg.cjs
const { app, BrowserWindow } = require('electron')
const path = require('node:path')
const { pathToFileURL } = require('node:url')
const RENDER_PARTITION = 'ruwio-render'
// backgroundVideo present -> render.html does NOT fill canvas.background.
const scene = {
canvas: { width: 160, height: 120, background: '#202030' },
backgroundVideo: { src: 'data:video/mp4,', duration: 1 },
duration: 1,
assets: [{ id: 'a1', kind: 'rect', width: 20, height: 20, fill: '#ff0000' }],
layers: [
{
id: 'l1',
assetId: 'a1',
visible: true,
base: { x: 30, y: 30, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
keyframes: [],
},
],
}
function createRenderWindow() {
const win = new BrowserWindow({
width: scene.canvas.width,
height: scene.canvas.height,
show: false,
frame: false,
transparent: false,
backgroundColor: '#ffffff',
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
partition: RENDER_PARTITION,
},
})
win.webContents.session.webRequest.onBeforeRequest({ urls: ['*://*/*'] }, (_d, cb) =>
cb({ cancel: true }),
)
return win
}
app.on('window-all-closed', () => {})
app.whenReady().then(async () => {
const win = createRenderWindow()
const renderHtml = path.join(process.cwd(), 'server', 'render.html')
await win.loadURL(pathToFileURL(renderHtml).toString())
await win.webContents.executeJavaScript(`window.__animatrInit(${JSON.stringify(scene)})`, true)
await win.webContents.executeJavaScript('window.__animatrSeek(0)', true)
const img = await win.webContents.capturePage({ x: 0, y: 0, width: 160, height: 120 })
const bmp = img.getBitmap() // BGRA
// Sample a corner pixel (far from the red rect at 30,30): index 0 == (0,0).
const corner = { b: bmp[0], g: bmp[1], r: bmp[2], a: bmp[3] }
let white = 0
let black = 0
for (let i = 0; i < bmp.length; i += 4) {
if (bmp[i] > 240 && bmp[i + 1] > 240 && bmp[i + 2] > 240) white++
if (bmp[i] < 15 && bmp[i + 1] < 15 && bmp[i + 2] < 15) black++
}
const notes = []
let pass = true
if (!(corner.r > 240 && corner.g > 240 && corner.b > 240)) {
pass = false
notes.push(`corner is not white: rgb(${corner.r},${corner.g},${corner.b})`)
}
if (black > 100) {
pass = false
notes.push(`${black} near-black px present (transparent flattened to black?)`)
}
console.log(`corner=rgb(${corner.r},${corner.g},${corner.b}) whitePx=${white} blackPx=${black}`)
console.log(notes.length ? 'NOTES:\n ' + notes.join('\n ') : 'NOTES: none')
console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL')
if (!win.isDestroyed()) win.close()
app.exit(pass ? 0 : 1)
})
+100
View File
@@ -0,0 +1,100 @@
// Phase A cancel verification: abort renderMp4Job mid-export, confirm it
// rejects, leaves no partial output file, and the ffmpeg child is gone.
//
// Run: npx tsc -p tsconfig.electron.json && node_modules/.bin/electron scripts/verify-cancel.cjs
const { app } = require('electron')
const path = require('node:path')
const os = require('node:os')
const fs = require('node:fs')
const { renderMp4Job } = require('../dist-electron/renderWorker.cjs')
const fps = 24
const duration = 6 // long enough that we can cancel before it finishes
const scene = {
canvas: { width: 480, height: 360, background: '#202030' },
duration,
assets: [{ id: 'a1', kind: 'rect', width: 60, height: 60, fill: '#40c0ff' }],
layers: [
{
id: 'l1',
assetId: 'a1',
visible: true,
base: { x: 40, y: 180, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
keyframes: [
{ time: 0, props: { x: 40 } },
{ time: 6, props: { x: 420 } },
],
},
],
}
const options = { fps, duration, totalFrames: fps * duration + 1, crf: '20', preset: 'ultrafast' }
async function main() {
const outPath = path.join(os.tmpdir(), 'ruwio-verify-cancel.mp4')
fs.rmSync(outPath, { force: true })
const controller = new AbortController()
let progressAtCancel = null
const job = renderMp4Job({
jobId: 'cancel',
scene,
options,
outputPath: outPath,
signal: controller.signal,
onProgress: (p) => {
// Cancel once we've made real progress but are nowhere near done.
if (progressAtCancel === null && p > 0.1) {
progressAtCancel = p
controller.abort()
}
},
})
let rejected = false
let message = ''
try {
await job
} catch (err) {
rejected = true
message = err.message
}
// Give the OS a moment to reap the killed ffmpeg + finish fs.rm cleanup.
await new Promise((r) => setTimeout(r, 300))
const notes = []
let pass = true
if (progressAtCancel === null) {
pass = false
notes.push('never reached a progress point to cancel at (job too fast)')
}
if (!rejected) {
pass = false
notes.push('job resolved instead of rejecting after abort')
} else if (!/abort/i.test(message)) {
notes.push(`rejected, but message was: ${message}`)
}
if (fs.existsSync(outPath)) {
pass = false
notes.push(`partial output left behind: ${outPath} (${fs.statSync(outPath).size}B)`)
}
console.log(`progressAtCancel=${progressAtCancel} rejected=${rejected} message="${message}"`)
console.log(`output exists after cancel: ${fs.existsSync(outPath)}`)
console.log(notes.length ? 'NOTES:\n ' + notes.join('\n ') : 'NOTES: none')
console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL')
app.exit(pass ? 0 : 1)
}
// Suppress Electron's default "quit when all windows close": renderMp4Job
// closes its render window, and the auto-quit would race our result printing.
app.on('window-all-closed', () => {})
app.whenReady().then(() =>
main().catch((err) => {
console.error('HARNESS ERROR:', err)
app.exit(2)
}),
)
+120
View File
@@ -0,0 +1,120 @@
// Compares Electron capturePage output against render.html's own canvas pixels
// for deterministic fixture scenes. This guards the capture step itself:
// wrong scale-factor normalization, stale frames, and clipping show up here.
const { app, BrowserWindow, nativeImage } = require('electron')
const fs = require('node:fs')
const path = require('node:path')
const { pathToFileURL } = require('node:url')
const { diffBgra } = require('../test/lib/pixelDiff.cjs')
const fixtures = [
{
file: path.join(process.cwd(), 'test', 'fixtures', 'scenes', 'capture-basic.json'),
times: [0, 0.5, 1],
},
]
function createRenderWindow(scene) {
return new BrowserWindow({
width: scene.canvas.width,
height: scene.canvas.height,
show: false,
frame: false,
transparent: false,
backgroundColor: '#ffffff',
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
backgroundThrottling: false,
partition: 'ruwio-capture-fidelity',
},
})
}
async function main() {
let pass = true
const notes = []
const renderHtml = path.join(process.cwd(), 'server', 'render.html')
for (const fixture of fixtures) {
const scene = JSON.parse(fs.readFileSync(fixture.file, 'utf8'))
const win = createRenderWindow(scene)
win.webContents.session.webRequest.onBeforeRequest(
{ urls: ['*://*/*'] },
(_details, callback) => callback({ cancel: true }),
)
win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
win.webContents.on('will-navigate', (event) => event.preventDefault())
try {
await win.loadURL(pathToFileURL(renderHtml).toString())
await win.webContents.executeJavaScript(`window.__animatrInit(${JSON.stringify(scene)})`, true)
for (const t of fixture.times) {
await win.webContents.executeJavaScript(`window.__animatrSeek(${t})`, true)
const capture = await win.webContents.capturePage({
x: 0,
y: 0,
width: scene.canvas.width,
height: scene.canvas.height,
})
const canvasDataUrl = await win.webContents.executeJavaScript(
`document.querySelector('canvas').toDataURL('image/png')`,
true,
)
const expected = nativeImage.createFromDataURL(canvasDataUrl)
const captureSize = capture.getSize()
const expectedSize = expected.getSize()
if (captureSize.width !== scene.canvas.width || captureSize.height !== scene.canvas.height) {
pass = false
notes.push(`${path.basename(fixture.file)} t=${t}: capture size ${captureSize.width}x${captureSize.height}`)
continue
}
if (expectedSize.width !== scene.canvas.width || expectedSize.height !== scene.canvas.height) {
pass = false
notes.push(`${path.basename(fixture.file)} t=${t}: canvas size ${expectedSize.width}x${expectedSize.height}`)
continue
}
const diff = diffBgra(capture.getBitmap(), expected.getBitmap(), {
// capturePage composites the hidden window, which introduces
// sub-perceptual AA/rounding noise (~maxDelta 6/255 on shape edges)
// versus the raw canvas toDataURL. Tolerate that; genuine render
// drift (wrong color, missing or stale shape, off-by-one frame)
// produces much larger deltas over a contiguous region and still
// trips the ratio gate. The explicit size checks above catch #10/#11.
perChannelTolerance: 8,
maxMismatchedRatio: 0.02,
})
console.log(
`${path.basename(fixture.file)} t=${t}: mismatch=${diff.mismatchedPixels}/${diff.pixels} ` +
`(${(diff.mismatchedRatio * 100).toFixed(4)}%) maxDelta=${diff.maxDelta} meanDelta=${diff.meanDelta.toFixed(3)}`,
)
if (!diff.ok) {
pass = false
notes.push(
`${path.basename(fixture.file)} t=${t}: diff exceeds tolerance ` +
`${diff.mismatchedPixels}/${diff.pixels}, maxDelta=${diff.maxDelta}`,
)
}
}
} finally {
if (!win.isDestroyed()) win.close()
}
}
console.log(notes.length ? 'NOTES:\n ' + notes.join('\n ') : 'NOTES: none')
console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL')
app.exit(pass ? 0 : 1)
}
app.on('window-all-closed', () => {})
app.commandLine.appendSwitch('disable-gpu')
app.whenReady().then(() =>
main().catch((err) => {
console.error('HARNESS ERROR:', err)
app.exit(2)
}),
)
+138
View File
@@ -0,0 +1,138 @@
// Phase A verification harness (electron-rendering-integration-plan_2.md, finding #1/#2).
//
// Reproduces renderWorker.cts's window-creation + capturePage path against a
// scene with one moving + fading layer, then checks that captured frames are
// (a) non-blank and (b) actually advance over time. This validates or refutes
// "capturePage on a hidden, non-offscreen window returns blank/stale frames".
//
// Run: node_modules/.bin/electron scripts/verify-capture.cjs
// Set OFFSCREEN=1 to exercise the offscreen+paint-settle code path instead.
const { app, BrowserWindow } = require('electron')
const path = require('node:path')
const { pathToFileURL } = require('node:url')
const OFFSCREEN = process.env.OFFSCREEN === '1'
// A 320x240 scene: a red rect slides left->right and fades over 2s on a dark bg.
const scene = {
canvas: { width: 320, height: 240, background: '#202030' },
duration: 2,
assets: [{ id: 'a1', kind: 'rect', width: 40, height: 40, fill: '#ff4040' }],
layers: [
{
id: 'l1',
assetId: 'a1',
visible: true,
base: { x: 20, y: 100, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
keyframes: [
{ time: 0, props: { x: 20 } },
{ time: 2, props: { x: 260 } },
],
},
],
}
function createRenderWindow() {
return new BrowserWindow({
width: scene.canvas.width,
height: scene.canvas.height,
show: false,
frame: false,
transparent: true,
backgroundColor: '#00000000',
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
offscreen: OFFSCREEN,
},
})
}
// Cheap fingerprint of a NativeImage: total pixels, count of non-(near)background
// pixels, and a coarse sum so we can tell frames apart.
function fingerprint(image) {
const bmp = image.getBitmap() // BGRA
let nonBg = 0
let redish = 0
// Position-weighted hash: changes under pure translation (an order-independent
// sum would not), so two frames that differ only in where the rect sits hash
// differently.
let hash = 0
for (let i = 0; i < bmp.length; i += 4) {
const b = bmp[i]
const g = bmp[i + 1]
const r = bmp[i + 2]
const px = i / 4
if (Math.abs(r - 0x20) + Math.abs(g - 0x20) + Math.abs(b - 0x30) > 60) nonBg++
if (r > 150 && g < 120 && b < 120) {
redish++
hash = (hash + px * 2654435761) >>> 0
}
}
return { pixels: bmp.length / 4, nonBg, redish, hash, size: image.getSize() }
}
async function main() {
const win = createRenderWindow()
win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
const renderHtml = path.join(process.cwd(), 'server', 'render.html')
await win.loadURL(pathToFileURL(renderHtml).toString())
await win.webContents.executeJavaScript(`window.__animatrInit(${JSON.stringify(scene)})`, true)
const captureRect = { x: 0, y: 0, width: scene.canvas.width, height: scene.canvas.height }
const times = [0, 0.5, 1, 1.5, 2]
const frames = []
for (const t of times) {
// __animatrSeek now resolves only after a compositor paint settle, so the
// worker just awaits it — no extra settle here (mirrors renderWorker.cts).
await win.webContents.executeJavaScript(`window.__animatrSeek(${t})`, true)
const image = await win.webContents.capturePage(captureRect)
frames.push({ t, ...fingerprint(image) })
}
// Analysis
let pass = true
const notes = []
for (const f of frames) {
if (f.size.width !== scene.canvas.width || f.size.height !== scene.canvas.height) {
pass = false
notes.push(`frame t=${f.t}: wrong size ${f.size.width}x${f.size.height}`)
}
if (f.nonBg < 50) {
pass = false
notes.push(`frame t=${f.t}: looks blank (nonBg=${f.nonBg})`)
}
}
// Frames must differ (motion advanced, not frozen on frame 0).
const hashes = new Set(frames.map((f) => f.hash))
if (hashes.size < frames.length) {
pass = false
notes.push(`frames not all distinct: ${frames.length} captured, ${hashes.size} unique hashes`)
}
console.log('OFFSCREEN =', OFFSCREEN)
console.log('captures:')
for (const f of frames) {
console.log(
` t=${f.t} nonBg=${f.nonBg} redish=${f.redish} hash=${f.hash} size=${f.size.width}x${f.size.height}`,
)
}
console.log(notes.length ? 'NOTES:\n ' + notes.join('\n ') : 'NOTES: none')
console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL')
if (!win.isDestroyed()) win.close()
app.exit(pass ? 0 : 1)
}
app.on('window-all-closed', () => {})
app.whenReady().then(() =>
main().catch((err) => {
console.error('HARNESS ERROR:', err)
app.exit(2)
}),
)
+39
View File
@@ -0,0 +1,39 @@
// Runs the desktop-render verification harnesses (Phase A/B of
// electron-rendering-integration-plan_2.md) under Electron and aggregates the
// results. Each harness exits 0 on PASS, non-zero on FAIL.
//
// npm run test:render-desktop (compiles electron TS first, then runs this)
//
// Requires a display (DISPLAY set), ffmpeg + ffprobe on PATH, and a built
// dist-electron/ (the npm script's `tsc` step handles the latter).
const { spawnSync } = require('node:child_process')
const path = require('node:path')
const electron = require('electron') // resolves to the electron binary path
const harnesses = [
['capture', 'verify-capture.cjs', 'hidden-window capturePage produces advancing, non-blank frames (#1/#2)'],
['capture-fidelity', 'verify-capture-fidelity.cjs', 'capturePage matches render.html canvas pixels'],
['export', 'verify-export.cjs', 'real worker renders a playable H.264 MP4 with motion'],
['resolution', 'verify-resolution.cjs', 'MP4 dimensions match the requested canvas on HiDPI (#10)'],
['odd-dims', 'verify-odd-dims.cjs', 'odd canvas dimensions pad to an even MP4 instead of failing (#13)'],
['cancel', 'verify-cancel.cjs', 'cancel mid-export aborts cleanly, no partial file'],
['timeout', 'verify-timeout.cjs', 'inactivity watchdog aborts a wedged render (#3)'],
['assets', 'verify-assets.cjs', 'local data: assets load; remote http: blocked, no hang (#5)'],
['background', 'verify-bg.cjs', 'transparent regions flatten to white, not black (#4)'],
]
let failures = 0
for (const [name, file, desc] of harnesses) {
process.stdout.write(`\n=== ${name}: ${desc} ===\n`)
const res = spawnSync(electron, [path.join(__dirname, file)], {
stdio: ['ignore', 'inherit', 'ignore'], // drop Electron's chatty stderr
})
if (res.status !== 0) {
failures += 1
process.stdout.write(`*** ${name} FAILED (exit ${res.status})\n`)
}
}
process.stdout.write(`\n${failures === 0 ? 'ALL DESKTOP RENDER CHECKS PASSED' : failures + ' CHECK(S) FAILED'}\n`)
process.exit(failures === 0 ? 0 : 1)
+125
View File
@@ -0,0 +1,125 @@
// Phase A end-to-end verification: drives the REAL compiled renderMp4Job
// (dist-electron/renderWorker.cjs) through ffmpeg and ffprobes the result.
//
// Run: npx tsc -p tsconfig.electron.json && node_modules/.bin/electron scripts/verify-export.cjs
const { app } = require('electron')
const path = require('node:path')
const os = require('node:os')
const fs = require('node:fs')
const { execFileSync } = require('node:child_process')
const { renderMp4Job } = require('../dist-electron/renderWorker.cjs')
const fps = 12
const duration = 2
const scene = {
canvas: { width: 320, height: 240, background: '#202030' },
duration,
assets: [{ id: 'a1', kind: 'rect', width: 40, height: 40, fill: '#ff4040' }],
layers: [
{
id: 'l1',
assetId: 'a1',
visible: true,
base: { x: 20, y: 100, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
keyframes: [
{ time: 0, props: { x: 20 } },
{ time: 2, props: { x: 260 } },
],
},
],
}
const options = {
fps,
duration,
totalFrames: fps * duration + 1,
crf: '20',
preset: 'ultrafast',
}
async function main() {
const outPath = path.join(os.tmpdir(), 'ruwio-verify-export.mp4')
fs.rmSync(outPath, { force: true })
const controller = new AbortController()
let lastProgress = 0
const startedAt = Date.now()
await renderMp4Job({
jobId: 'verify',
scene,
options,
outputPath: outPath,
signal: controller.signal,
onProgress: (p) => {
lastProgress = p
},
})
const elapsedMs = Date.now() - startedAt
const msPerFrame = elapsedMs / options.totalFrames
const notes = []
let pass = true
if (!fs.existsSync(outPath)) {
pass = false
notes.push('output file missing')
} else {
const sizeKb = (fs.statSync(outPath).size / 1024).toFixed(1)
const probe = JSON.parse(
execFileSync('ffprobe', [
'-v', 'error',
'-select_streams', 'v:0',
'-show_entries', 'stream=codec_name,width,height,nb_read_frames',
'-count_frames',
'-of', 'json',
outPath,
]).toString(),
)
const s = probe.streams[0]
console.log(`output: ${outPath} (${sizeKb} KB)`)
console.log(`codec=${s.codec_name} ${s.width}x${s.height} frames=${s.nb_read_frames} progress=${lastProgress}`)
console.log(`elapsed=${elapsedMs}ms (${msPerFrame.toFixed(1)}ms/frame)`)
if (s.codec_name !== 'h264') { pass = false; notes.push(`codec ${s.codec_name} != h264`) }
if (s.width !== 320 || s.height !== 240) { pass = false; notes.push(`wrong dims ${s.width}x${s.height}`) }
if (Number(s.nb_read_frames) !== options.totalFrames) {
pass = false
notes.push(`frames ${s.nb_read_frames} != expected ${options.totalFrames}`)
}
if (lastProgress !== 1) { pass = false; notes.push(`final progress ${lastProgress} != 1`) }
const maxMsPerFrame = Number(process.env.RUWIO_VERIFY_MAX_MS_PER_FRAME || 800)
if (msPerFrame > maxMsPerFrame) {
pass = false
notes.push(
`render throughput ${msPerFrame.toFixed(1)}ms/frame exceeds ${maxMsPerFrame}ms/frame ` +
'(hidden render window may be throttled)',
)
}
// Decode first & last frame, confirm the rect moved (motion preserved).
const f0 = path.join(os.tmpdir(), 'ruwio-f0.png')
const f1 = path.join(os.tmpdir(), 'ruwio-flast.png')
execFileSync('ffmpeg', ['-y', '-v', 'error', '-i', outPath, '-vf', 'select=eq(n\\,0)', '-frames:v', '1', f0])
execFileSync('ffmpeg', ['-y', '-v', 'error', '-sseof', '-0.1', '-i', outPath, '-frames:v', '1', f1])
const sz0 = fs.statSync(f0).size
const sz1 = fs.statSync(f1).size
const b0 = fs.readFileSync(f0)
const b1 = fs.readFileSync(f1)
const same = b0.length === b1.length && b0.equals(b1)
console.log(`first frame ${sz0}B, last frame ${sz1}B, identical=${same}`)
if (same) { pass = false; notes.push('first and last frame identical (no motion)') }
}
console.log(notes.length ? 'NOTES:\n ' + notes.join('\n ') : 'NOTES: none')
console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL')
app.exit(pass ? 0 : 1)
}
// renderMp4Job closes its render window; suppress the default auto-quit so it
// doesn't race result printing.
app.on('window-all-closed', () => {})
app.whenReady().then(() =>
main().catch((err) => {
console.error('HARNESS ERROR:', err)
app.exit(2)
}),
)
+66
View File
@@ -0,0 +1,66 @@
// Regression for odd canvas dimensions (#13): yuv420p MP4 export pads to even
// dimensions instead of failing with "height not divisible by 2".
const { app } = require('electron')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const { execFileSync } = require('node:child_process')
const { renderMp4Job } = require('../dist-electron/renderWorker.cjs')
const scene = {
canvas: { width: 101, height: 101, background: '#111827' },
duration: 0.5,
assets: [{ id: 'rect', kind: 'rect', width: 45, height: 35, fill: '#f97316' }],
layers: [
{
id: 'layer-rect',
assetId: 'rect',
visible: true,
base: { x: 50, y: 50, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
keyframes: [
{ time: 0, props: { x: 38 } },
{ time: 0.5, props: { x: 62 } },
],
},
],
}
async function main() {
const outPath = path.join(os.tmpdir(), 'ruwio-verify-odd-dims.mp4')
fs.rmSync(outPath, { force: true })
await renderMp4Job({
jobId: 'verify-odd-dims',
scene,
options: { fps: 4, duration: 0.5, totalFrames: 3, crf: '28', preset: 'ultrafast' },
outputPath: outPath,
signal: new AbortController().signal,
onProgress: () => {},
})
const probe = JSON.parse(execFileSync('ffprobe', [
'-v', 'error',
'-select_streams', 'v:0',
'-show_entries', 'stream=codec_name,width,height,nb_read_frames',
'-count_frames',
'-of', 'json',
outPath,
]).toString())
const stream = probe.streams[0]
const pass =
stream.codec_name === 'h264' &&
stream.width === 102 &&
stream.height === 102 &&
Number(stream.nb_read_frames) === 3
console.log(`codec=${stream.codec_name} dimensions=${stream.width}x${stream.height} frames=${stream.nb_read_frames}`)
console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL')
app.exit(pass ? 0 : 1)
}
app.on('window-all-closed', () => {})
app.whenReady().then(() =>
main().catch((err) => {
console.error('HARNESS ERROR:', err)
app.exit(2)
}),
)
+58
View File
@@ -0,0 +1,58 @@
// Regression for capture resolution normalization (#10). On scale-factor-1
// displays this is a no-op, while HiDPI displays catch capturePage inflation.
const { app } = require('electron')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const { execFileSync } = require('node:child_process')
const { renderMp4Job } = require('../dist-electron/renderWorker.cjs')
const scene = {
canvas: { width: 320, height: 240, background: '#ffffff' },
duration: 0.25,
assets: [{ id: 'rect', kind: 'rect', width: 80, height: 60, fill: '#2563eb' }],
layers: [
{
id: 'layer-rect',
assetId: 'rect',
visible: true,
base: { x: 160, y: 120, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
keyframes: [{ time: 0, props: {} }],
},
],
}
async function main() {
const outPath = path.join(os.tmpdir(), 'ruwio-verify-resolution.mp4')
fs.rmSync(outPath, { force: true })
await renderMp4Job({
jobId: 'verify-resolution',
scene,
options: { fps: 2, duration: 0.25, totalFrames: 1, crf: '28', preset: 'ultrafast' },
outputPath: outPath,
signal: new AbortController().signal,
onProgress: () => {},
})
const probe = JSON.parse(execFileSync('ffprobe', [
'-v', 'error',
'-select_streams', 'v:0',
'-show_entries', 'stream=width,height',
'-of', 'json',
outPath,
]).toString())
const stream = probe.streams[0]
const pass = stream.width === scene.canvas.width && stream.height === scene.canvas.height
console.log(`ffprobe dimensions: ${stream.width}x${stream.height}`)
console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL')
app.exit(pass ? 0 : 1)
}
app.on('window-all-closed', () => {})
app.whenReady().then(() =>
main().catch((err) => {
console.error('HARNESS ERROR:', err)
app.exit(2)
}),
)
+88
View File
@@ -0,0 +1,88 @@
// Phase B verification of the inactivity watchdog (finding #3): point the
// worker at a render page whose __animatrSeek never resolves, with a short
// RUWIO_RENDER_TIMEOUT_MS, and confirm the job aborts with "render timed out"
// and leaves no partial file.
//
// Run: npx tsc -p tsconfig.electron.json && node_modules/.bin/electron scripts/verify-timeout.cjs
// Must be set BEFORE requiring the worker — the timeout is read at module load.
process.env.RUWIO_RENDER_TIMEOUT_MS = '2000'
const { app } = require('electron')
const path = require('node:path')
const os = require('node:os')
const fs = require('node:fs')
// Build a temp project dir with a server/render.html that hangs on seek, then
// chdir into it so the worker's loadRenderPage (process.cwd()/server/render.html)
// picks it up.
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruwio-timeout-'))
fs.mkdirSync(path.join(tmpDir, 'server'))
fs.writeFileSync(
path.join(tmpDir, 'server', 'render.html'),
`<!doctype html><html><body><script>
window.__animatrInit = function(){ return Promise.resolve(true) }
window.__animatrSeek = function(){ return new Promise(function(){}) } // never resolves
</script></body></html>`,
)
process.chdir(tmpDir)
const { renderMp4Job } = require('../dist-electron/renderWorker.cjs')
const fps = 12
const duration = 2
const scene = {
canvas: { width: 160, height: 120, background: '#202030' },
duration,
assets: [{ id: 'a1', kind: 'rect', width: 20, height: 20, fill: '#ff4040' }],
layers: [
{
id: 'l1',
assetId: 'a1',
visible: true,
base: { x: 20, y: 60, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
keyframes: [{ time: 0, props: { x: 20 } }, { time: 2, props: { x: 140 } }],
},
],
}
const options = { fps, duration, totalFrames: fps * duration + 1, crf: '20', preset: 'ultrafast' }
app.on('window-all-closed', () => {})
app.whenReady().then(async () => {
const outPath = path.join(tmpDir, 'out.mp4')
const controller = new AbortController()
const startedAt = process.hrtime.bigint()
let rejected = false
let message = ''
try {
await renderMp4Job({
jobId: 'timeout',
scene,
options,
outputPath: outPath,
signal: controller.signal,
onProgress: () => {},
})
} catch (err) {
rejected = true
message = err.message
}
const elapsedMs = Number(process.hrtime.bigint() - startedAt) / 1e6
await new Promise((r) => setTimeout(r, 200))
const notes = []
let pass = true
if (!rejected) { pass = false; notes.push('job did not reject') }
if (!/timed out/i.test(message)) { pass = false; notes.push(`message was "${message}", expected "render timed out"`) }
if (elapsedMs < 1500 || elapsedMs > 8000) { pass = false; notes.push(`elapsed ${elapsedMs.toFixed(0)}ms outside [1500,8000] (timeout=2000)`) }
if (fs.existsSync(outPath)) { pass = false; notes.push('partial output left behind') }
console.log(`rejected=${rejected} message="${message}" elapsed=${elapsedMs.toFixed(0)}ms outExists=${fs.existsSync(outPath)}`)
console.log(notes.length ? 'NOTES:\n ' + notes.join('\n ') : 'NOTES: none')
console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL')
fs.rmSync(tmpDir, { recursive: true, force: true })
app.exit(pass ? 0 : 1)
})
+3 -1
View File
@@ -6,7 +6,9 @@
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node server.js"
"dev": "node server.js",
"test:smoke": "node --test server.smoke.test.cjs",
"test:e2e": "node --test render.e2e.test.cjs"
},
"dependencies": {
"express": "^4.21.2",
+310
View File
@@ -0,0 +1,310 @@
const assert = require('node:assert/strict')
const { spawn } = require('node:child_process')
const { mkdtemp, readFile, rm } = require('node:fs/promises')
const net = require('node:net')
const os = require('node:os')
const path = require('node:path')
const { test } = require('node:test')
const HOST = '127.0.0.1'
const TOKEN = 'server-e2e-token'
const CHROME = process.env.PUPPETEER_EXECUTABLE_PATH || '/usr/bin/chromium'
test('render server produces a playable MP4 for a tiny scene', async (t) => {
const ffmpeg = await commandPath('ffmpeg')
const ffprobe = await commandPath('ffprobe')
if (!ffmpeg || !ffprobe || !(await canAccess(CHROME))) {
t.skip('requires ffmpeg, ffprobe, and Chromium')
return
}
const port = await freePort()
const tmp = await mkdtemp(path.join(os.tmpdir(), 'ruwio-render-e2e-'))
const server = spawn(process.execPath, ['server.js'], {
cwd: __dirname,
env: {
...process.env,
HOST,
PORT: String(port),
RENDER_TOKEN: TOKEN,
RENDER_JOB_TTL_MS: '1000',
RENDER_JOB_TIMEOUT_MS: '30000',
PUPPETEER_EXECUTABLE_PATH: CHROME,
CHROME_NO_SANDBOX: '1',
},
stdio: ['ignore', 'pipe', 'pipe'],
})
let output = ''
server.stdout.on('data', (chunk) => {
output += chunk.toString()
})
server.stderr.on('data', (chunk) => {
output += chunk.toString()
})
t.after(async () => {
if (server.exitCode === null) {
server.kill('SIGTERM')
await waitForExit(server)
}
await rm(tmp, { recursive: true, force: true })
})
await waitForServer(server, port, () => output)
const base = `http://${HOST}:${port}`
const start = await fetch(`${base}/render`, {
method: 'POST',
headers: {
Authorization: `Bearer ${TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ scene: tinyScene(), options: { fps: 2, crf: 28, preset: 'ultrafast' } }),
})
const startText = await start.text()
assert.equal(start.status, 200, startText)
const { jobId, accessToken } = JSON.parse(startText)
assert.equal(typeof jobId, 'string')
assert.equal(typeof accessToken, 'string')
const done = await waitForDone(`${base}/render/${jobId}/events?token=${encodeURIComponent(accessToken)}`)
assert.equal(done.done, true)
assert.equal(done.error, null)
assert.equal(done.progress, 1)
assert.equal(done.url, `/render/${jobId}/file?token=${encodeURIComponent(accessToken)}`)
const file = await fetch(`${base}${done.url}`)
assert.equal(file.status, 200)
const outPath = path.join(tmp, 'tiny.mp4')
await require('node:fs/promises').writeFile(outPath, Buffer.from(await file.arrayBuffer()))
const metadata = await ffprobeJson(outPath)
const video = metadata.streams.find((stream) => stream.codec_type === 'video')
assert.ok(video, 'expected a video stream')
assert.equal(video.codec_name, 'h264')
assert.equal(video.width, 64)
assert.equal(video.height, 64)
})
test('render server pads odd canvas dimensions to an even MP4', async (t) => {
const ffmpeg = await commandPath('ffmpeg')
const ffprobe = await commandPath('ffprobe')
if (!ffmpeg || !ffprobe || !(await canAccess(CHROME))) {
t.skip('requires ffmpeg, ffprobe, and Chromium')
return
}
const port = await freePort()
const tmp = await mkdtemp(path.join(os.tmpdir(), 'ruwio-render-e2e-'))
const server = spawn(process.execPath, ['server.js'], {
cwd: __dirname,
env: {
...process.env,
HOST,
PORT: String(port),
RENDER_TOKEN: TOKEN,
RENDER_JOB_TTL_MS: '1000',
RENDER_JOB_TIMEOUT_MS: '30000',
PUPPETEER_EXECUTABLE_PATH: CHROME,
CHROME_NO_SANDBOX: '1',
},
stdio: ['ignore', 'pipe', 'pipe'],
})
let output = ''
server.stdout.on('data', (chunk) => {
output += chunk.toString()
})
server.stderr.on('data', (chunk) => {
output += chunk.toString()
})
t.after(async () => {
if (server.exitCode === null) {
server.kill('SIGTERM')
await waitForExit(server)
}
await rm(tmp, { recursive: true, force: true })
})
await waitForServer(server, port, () => output)
const base = `http://${HOST}:${port}`
const start = await fetch(`${base}/render`, {
method: 'POST',
headers: {
Authorization: `Bearer ${TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ scene: oddScene(), options: { fps: 2, crf: 28, preset: 'ultrafast' } }),
})
const startText = await start.text()
assert.equal(start.status, 200, startText)
const { jobId, accessToken } = JSON.parse(startText)
const done = await waitForDone(`${base}/render/${jobId}/events?token=${encodeURIComponent(accessToken)}`)
assert.equal(done.done, true)
assert.equal(done.error, null)
assert.equal(done.progress, 1)
const file = await fetch(`${base}${done.url}`)
assert.equal(file.status, 200)
const outPath = path.join(tmp, 'odd.mp4')
await require('node:fs/promises').writeFile(outPath, Buffer.from(await file.arrayBuffer()))
const metadata = await ffprobeJson(outPath)
const video = metadata.streams.find((stream) => stream.codec_type === 'video')
assert.ok(video, 'expected a video stream')
assert.equal(video.codec_name, 'h264')
assert.equal(video.width, 102)
assert.equal(video.height, 102)
})
function tinyScene() {
return {
canvas: { width: 64, height: 64, background: '#ffffff' },
duration: 1,
fps: 2,
assets: [
{ id: 'asset-rect', kind: 'rect', name: 'Box', width: 24, height: 24, fill: '#22cc88' },
],
layers: [
{
id: 'layer-rect',
name: 'Box',
assetId: 'asset-rect',
visible: true,
base: { x: 16, y: 32, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
keyframes: [
{ id: 'kf-0', time: 0, easing: 'linear', props: { x: 16 } },
{ id: 'kf-1', time: 0.5, easing: 'linear', props: { x: 48 } },
],
},
],
}
}
function oddScene() {
const s = tinyScene()
s.canvas.width = 101
s.canvas.height = 101
s.layers[0].base.x = 50
s.layers[0].base.y = 50
s.layers[0].keyframes = [
{ id: 'kf-0', time: 0, easing: 'linear', props: { x: 42 } },
{ id: 'kf-1', time: 0.5, easing: 'linear', props: { x: 58 } },
]
return s
}
async function waitForDone(url) {
const res = await fetch(url)
assert.equal(res.status, 200)
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
const deadline = Date.now() + 30000
while (Date.now() < deadline) {
const { value, done } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const messages = buffer.split('\n\n')
buffer = messages.pop() || ''
for (const message of messages) {
const line = message.split('\n').find((entry) => entry.startsWith('data: '))
if (!line) continue
const payload = JSON.parse(line.slice(6))
if (payload.done) return payload
}
}
throw new Error('render did not finish before timeout')
}
function ffprobeJson(file) {
return new Promise((resolve, reject) => {
const child = spawn('ffprobe', [
'-v', 'error',
'-show_streams',
'-of', 'json',
file,
])
let stdout = ''
let stderr = ''
child.stdout.on('data', (chunk) => {
stdout += chunk.toString()
})
child.stderr.on('data', (chunk) => {
stderr += chunk.toString()
})
child.on('error', reject)
child.on('exit', (code) => {
if (code === 0) resolve(JSON.parse(stdout))
else reject(new Error(`ffprobe exited with code ${code}: ${stderr}`))
})
})
}
function commandPath(command) {
return new Promise((resolve) => {
const probe = spawn(process.platform === 'win32' ? 'where' : 'which', [command])
probe.on('error', () => resolve(''))
probe.on('exit', (code) => resolve(code === 0 ? command : ''))
})
}
async function canAccess(file) {
try {
await readFile(file)
return true
} catch {
return false
}
}
function freePort() {
return new Promise((resolve, reject) => {
const server = net.createServer()
server.once('error', reject)
server.listen(0, HOST, () => {
const address = server.address()
server.close(() => {
if (!address || typeof address === 'string') reject(new Error('could not allocate test port'))
else resolve(address.port)
})
})
})
}
async function waitForServer(server, port, output) {
const deadline = Date.now() + 5000
while (Date.now() < deadline) {
if (server.exitCode !== null) {
throw new Error(`server exited early with code ${server.exitCode}\n${output()}`)
}
try {
const res = await fetch(`http://${HOST}:${port}/health`)
if (res.ok) return
} catch {
/* retry */
}
await delay(50)
}
throw new Error(`server did not start\n${output()}`)
}
function waitForExit(child) {
return new Promise((resolve) => {
child.once('exit', () => resolve())
setTimeout(() => {
if (child.exitCode === null) child.kill('SIGKILL')
resolve()
}, 1000).unref()
})
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
+14 -1
View File
@@ -386,7 +386,20 @@
return true;
});
};
window.__animatrSeek=function(time){ drawFrame(time); };
window.__animatrSeek=function(time){
drawFrame(time);
// Resolve only after the compositor has had a chance to paint, so a
// subsequent capturePage()/screenshot() sees THIS frame and not the
// previous one. Without this wait, Electron's capturePage on a hidden
// window intermittently returns the prior frame (stale/off-by-one).
// setTimeout is an anti-hang fallback in case rAF is throttled while hidden.
return new Promise(function(res){
var done=false;
function finish(){ if(!done){ done=true; res(true); } }
requestAnimationFrame(function(){ requestAnimationFrame(finish); });
setTimeout(finish, 1000);
});
};
})();
</script>
</body>
+16 -67
View File
@@ -6,6 +6,7 @@ import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
import { spawn } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import puppeteer from 'puppeteer-core'
import renderValidation from '../shared/renderValidation.cjs'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
@@ -36,6 +37,17 @@ const MAX_FRAMES = readIntEnv('RENDER_MAX_FRAMES', 3600, 1, 250000)
const MAX_LAYERS = readIntEnv('RENDER_MAX_LAYERS', 500, 1, 100000)
const MAX_KEYFRAMES = readIntEnv('RENDER_MAX_KEYFRAMES', 5000, 1, 1000000)
const MAX_ASSETS = readIntEnv('RENDER_MAX_ASSETS', 500, 1, 100000)
const RENDER_LIMITS = {
maxCanvasWidth: MAX_CANVAS_WIDTH,
maxCanvasHeight: MAX_CANVAS_HEIGHT,
maxFps: MAX_FPS,
maxDuration: MAX_DURATION,
maxFrames: MAX_FRAMES,
maxLayers: MAX_LAYERS,
maxKeyframes: MAX_KEYFRAMES,
maxAssets: MAX_ASSETS,
}
const { playbackRange, validateRenderRequest: validateSharedRenderRequest } = renderValidation
const app = express()
app.disable('x-powered-by')
@@ -214,25 +226,6 @@ function notify(job) {
}
}
function effectiveEnd(scene) {
let max = 0
for (const layer of scene.layers) {
for (const kf of layer.keyframes) {
if (kf.time > max) max = kf.time
}
}
if (max <= 0) return scene.duration
return Math.min(max, scene.duration)
}
function playbackRange(scene) {
const autoEnd = effectiveEnd(scene)
const start = Math.max(0, Math.min(scene.duration, scene.inTime == null ? 0 : scene.inTime))
const rawEnd = scene.outTime == null ? autoEnd : scene.outTime
const end = Math.max(start, Math.min(scene.duration, rawEnd))
return { start, end }
}
async function runJob(job, scene, options) {
const { start: rangeIn } = playbackRange(scene)
const { fps, duration, totalFrames, crf, preset } = options
@@ -260,6 +253,9 @@ async function runJob(job, scene, options) {
'-i', '-',
'-c:v', 'libx264',
'-pix_fmt', 'yuv420p',
// libx264 + yuv420p require even dimensions; pad an odd canvas up to the
// next even size so it encodes instead of crashing ffmpeg (finding #13).
'-vf', 'pad=ceil(iw/2)*2:ceil(ih/2)*2',
'-preset', preset,
'-crf', crf,
'-movflags', '+faststart',
@@ -327,54 +323,7 @@ async function cleanupJob(jobId) {
}
function validateRenderRequest(scene, options) {
if (!scene || typeof scene !== 'object') return { ok: false, error: 'invalid scene' }
if (!scene.canvas || typeof scene.canvas !== 'object') return { ok: false, error: 'invalid canvas' }
const width = toBoundedInt(scene.canvas.width, 1, MAX_CANVAS_WIDTH)
const height = toBoundedInt(scene.canvas.height, 1, MAX_CANVAS_HEIGHT)
if (!width || !height) return { ok: false, error: `canvas must be between 1 and ${MAX_CANVAS_WIDTH}x${MAX_CANVAS_HEIGHT}` }
if (!Array.isArray(scene.layers)) return { ok: false, error: 'scene layers must be an array' }
if (scene.layers.length > MAX_LAYERS) return { ok: false, error: `scene has more than ${MAX_LAYERS} layers` }
if (Array.isArray(scene.assets) && scene.assets.length > MAX_ASSETS) return { ok: false, error: `scene has more than ${MAX_ASSETS} assets` }
let keyframes = 0
for (const layer of scene.layers) {
if (!Array.isArray(layer?.keyframes)) return { ok: false, error: 'every layer must have keyframes' }
keyframes += layer.keyframes.length
if (keyframes > MAX_KEYFRAMES) return { ok: false, error: `scene has more than ${MAX_KEYFRAMES} keyframes` }
for (const kf of layer.keyframes) {
if (!Number.isFinite(Number(kf?.time))) return { ok: false, error: 'keyframe times must be finite numbers' }
}
}
const duration = Number(scene.duration)
if (!Number.isFinite(duration) || duration <= 0 || duration > MAX_DURATION) {
return { ok: false, error: `duration must be between 0 and ${MAX_DURATION} seconds` }
}
const fps = toBoundedInt(options.fps || scene.fps || 60, 1, MAX_FPS)
if (!fps) return { ok: false, error: `fps must be between 1 and ${MAX_FPS}` }
const range = playbackRange({ ...scene, canvas: { ...scene.canvas, width, height }, duration })
const requestedDuration = Number(options.duration || Math.max(0, range.end - range.start))
if (!Number.isFinite(requestedDuration) || requestedDuration <= 0 || requestedDuration > MAX_DURATION) {
return { ok: false, error: `render duration must be between 0 and ${MAX_DURATION} seconds` }
}
const totalFrames = Math.max(1, Math.round(requestedDuration * fps))
if (totalFrames > MAX_FRAMES) return { ok: false, error: `render has more than ${MAX_FRAMES} frames` }
const crf = toBoundedInt(options.crf ?? 18, 0, 51)
if (crf == null) return { ok: false, error: 'crf must be between 0 and 51' }
const preset = String(options.preset ?? 'fast')
const allowedPresets = new Set(['ultrafast', 'superfast', 'veryfast', 'faster', 'fast', 'medium', 'slow', 'slower', 'veryslow'])
if (!allowedPresets.has(preset)) return { ok: false, error: 'invalid ffmpeg preset' }
return {
ok: true,
scene: { ...scene, canvas: { ...scene.canvas, width, height }, duration },
options: { fps, duration: requestedDuration, totalFrames, crf: String(crf), preset },
}
}
function toBoundedInt(value, min, max) {
const n = Number(value)
if (!Number.isFinite(n)) return null
const i = Math.round(n)
if (i < min || i > max) return null
return i
return validateSharedRenderRequest(scene, options, RENDER_LIMITS)
}
function chromeArgs() {
+139
View File
@@ -0,0 +1,139 @@
const assert = require('node:assert/strict')
const { spawn } = require('node:child_process')
const net = require('node:net')
const { test } = require('node:test')
const HOST = '127.0.0.1'
const TOKEN = 'server-smoke-token'
test('render server health, auth, and validation routes work', async (t) => {
const port = await freePort()
const server = spawn(process.execPath, ['server.js'], {
cwd: __dirname,
env: {
...process.env,
HOST,
PORT: String(port),
RENDER_TOKEN: TOKEN,
RENDER_MAX_BODY: '1mb',
RENDER_RATE_MAX: '100',
PUPPETEER_EXECUTABLE_PATH: '/not-used-by-smoke-test',
},
stdio: ['ignore', 'pipe', 'pipe'],
})
let output = ''
server.stdout.on('data', (chunk) => {
output += chunk.toString()
})
server.stderr.on('data', (chunk) => {
output += chunk.toString()
})
t.after(async () => {
if (server.exitCode === null) {
server.kill('SIGTERM')
await waitForExit(server)
}
})
await waitForServer(server, port, () => output)
const base = `http://${HOST}:${port}`
const health = await fetch(`${base}/health`)
assert.equal(health.status, 200)
assert.deepEqual(await health.json(), { ok: true })
const missingToken = await postRender(base, null, invalidRenderBody())
assert.equal(missingToken.status, 401)
assert.equal((await missingToken.json()).error, 'invalid render token')
const badToken = await postRender(base, 'wrong-token', invalidRenderBody())
assert.equal(badToken.status, 401)
assert.equal((await badToken.json()).error, 'invalid render token')
const invalidRender = await postRender(base, TOKEN, invalidRenderBody())
assert.equal(invalidRender.status, 400)
assert.match((await invalidRender.json()).error, /canvas must be/)
const missingJobEvents = await fetch(`${base}/render/does-not-exist/events?token=bad`)
assert.equal(missingJobEvents.status, 404)
const missingJobFile = await fetch(`${base}/render/does-not-exist/file?token=bad`)
assert.equal(missingJobFile.status, 404)
const missingJobDelete = await fetch(`${base}/render/does-not-exist?token=bad`, { method: 'DELETE' })
assert.equal(missingJobDelete.status, 404)
})
function invalidRenderBody() {
return {
scene: {
canvas: { width: 0, height: 100 },
duration: 1,
fps: 30,
layers: [],
assets: [],
},
options: { fps: 30 },
}
}
function postRender(base, token, body) {
const headers = { 'Content-Type': 'application/json' }
if (token) headers.Authorization = `Bearer ${token}`
return fetch(`${base}/render`, {
method: 'POST',
headers,
body: JSON.stringify(body),
})
}
function freePort() {
return new Promise((resolve, reject) => {
const server = net.createServer()
server.once('error', reject)
server.listen(0, HOST, () => {
const address = server.address()
server.close(() => {
if (!address || typeof address === 'string') reject(new Error('could not allocate test port'))
else resolve(address.port)
})
})
})
}
async function waitForServer(server, port, output) {
const deadline = Date.now() + 5000
let lastError = null
while (Date.now() < deadline) {
if (server.exitCode !== null) {
throw new Error(`server exited early with code ${server.exitCode}\n${output()}`)
}
try {
const res = await fetch(`http://${HOST}:${port}/health`)
if (res.ok) return
} catch (err) {
lastError = err
}
await delay(50)
}
throw new Error(`server did not start: ${lastError?.message || 'timeout'}\n${output()}`)
}
function waitForExit(child) {
return new Promise((resolve) => {
child.once('exit', () => resolve())
setTimeout(() => {
if (child.exitCode === null) child.kill('SIGKILL')
resolve()
}, 1000).unref()
})
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
+120
View File
@@ -0,0 +1,120 @@
const DEFAULT_LIMITS = Object.freeze({
maxCanvasWidth: 4096,
maxCanvasHeight: 4096,
maxFps: 60,
maxDuration: 60,
maxFrames: 3600,
maxLayers: 500,
maxKeyframes: 5000,
maxAssets: 500,
})
const ALLOWED_FFMPEG_PRESETS = Object.freeze([
'ultrafast',
'superfast',
'veryfast',
'faster',
'fast',
'medium',
'slow',
'slower',
'veryslow',
])
function effectiveEnd(scene) {
let max = 0
for (const layer of scene.layers) {
for (const kf of layer.keyframes) {
if (kf.time > max) max = kf.time
}
}
if (max <= 0) return scene.duration
return Math.min(max, scene.duration)
}
function playbackRange(scene) {
const autoEnd = effectiveEnd(scene)
const start = Math.max(0, Math.min(scene.duration, scene.inTime == null ? 0 : scene.inTime))
const rawEnd = scene.outTime == null ? autoEnd : scene.outTime
const end = Math.max(start, Math.min(scene.duration, rawEnd))
return { start, end }
}
function validateRenderRequest(scene, options = {}, limits = DEFAULT_LIMITS) {
if (!scene || typeof scene !== 'object') return { ok: false, error: 'invalid scene' }
if (!scene.canvas || typeof scene.canvas !== 'object') return { ok: false, error: 'invalid canvas' }
const width = toBoundedInt(scene.canvas.width, 1, limits.maxCanvasWidth)
const height = toBoundedInt(scene.canvas.height, 1, limits.maxCanvasHeight)
if (!width || !height) {
return {
ok: false,
error: `canvas must be between 1 and ${limits.maxCanvasWidth}x${limits.maxCanvasHeight}`,
}
}
if (!Array.isArray(scene.layers)) return { ok: false, error: 'scene layers must be an array' }
if (scene.layers.length > limits.maxLayers) {
return { ok: false, error: `scene has more than ${limits.maxLayers} layers` }
}
if (Array.isArray(scene.assets) && scene.assets.length > limits.maxAssets) {
return { ok: false, error: `scene has more than ${limits.maxAssets} assets` }
}
let keyframes = 0
for (const layer of scene.layers) {
if (!Array.isArray(layer?.keyframes)) return { ok: false, error: 'every layer must have keyframes' }
keyframes += layer.keyframes.length
if (keyframes > limits.maxKeyframes) {
return { ok: false, error: `scene has more than ${limits.maxKeyframes} keyframes` }
}
for (const kf of layer.keyframes) {
if (!Number.isFinite(Number(kf?.time))) {
return { ok: false, error: 'keyframe times must be finite numbers' }
}
}
}
const duration = Number(scene.duration)
if (!Number.isFinite(duration) || duration <= 0 || duration > limits.maxDuration) {
return { ok: false, error: `duration must be between 0 and ${limits.maxDuration} seconds` }
}
const fps = toBoundedInt(options.fps ?? scene.fps ?? 60, 1, limits.maxFps)
if (!fps) return { ok: false, error: `fps must be between 1 and ${limits.maxFps}` }
const normalizedScene = { ...scene, canvas: { ...scene.canvas, width, height }, duration }
const range = playbackRange(normalizedScene)
const requestedDuration = Number(options.duration ?? Math.max(0, range.end - range.start))
if (
!Number.isFinite(requestedDuration) ||
requestedDuration <= 0 ||
requestedDuration > limits.maxDuration
) {
return { ok: false, error: `render duration must be between 0 and ${limits.maxDuration} seconds` }
}
const totalFrames = Math.max(1, Math.round(requestedDuration * fps))
if (totalFrames > limits.maxFrames) {
return { ok: false, error: `render has more than ${limits.maxFrames} frames` }
}
const crf = toBoundedInt(options.crf ?? 18, 0, 51)
if (crf == null) return { ok: false, error: 'crf must be between 0 and 51' }
const preset = String(options.preset ?? 'fast')
if (!ALLOWED_FFMPEG_PRESETS.includes(preset)) {
return { ok: false, error: 'invalid ffmpeg preset' }
}
return {
ok: true,
scene: normalizedScene,
options: { fps, duration: requestedDuration, totalFrames, crf: String(crf), preset },
}
}
function toBoundedInt(value, min, max) {
const n = Number(value)
if (!Number.isFinite(n)) return null
const i = Math.round(n)
if (i < min || i > max) return null
return i
}
module.exports = {
ALLOWED_FFMPEG_PRESETS,
DEFAULT_LIMITS,
playbackRange,
validateRenderRequest,
}
+62
View File
@@ -0,0 +1,62 @@
const test = require('node:test')
const assert = require('node:assert/strict')
const { validateRenderRequest, playbackRange } = require('./renderValidation.cjs')
function scene(overrides = {}) {
return {
canvas: { width: 640, height: 360 },
duration: 2,
fps: 30,
layers: [
{
keyframes: [
{ time: 0, props: {} },
{ time: 1, props: {} },
],
},
],
assets: [],
...overrides,
}
}
test('accepts a normal render request', () => {
const result = validateRenderRequest(scene(), { fps: 30, crf: 18, preset: 'fast' })
assert.equal(result.ok, true)
assert.equal(result.options.totalFrames, 30)
})
test('rejects invalid dimensions', () => {
const result = validateRenderRequest(scene({ canvas: { width: 0, height: 360 } }))
assert.equal(result.ok, false)
assert.match(result.error, /canvas/)
})
test('rejects invalid fps and frame count', () => {
assert.equal(validateRenderRequest(scene(), { fps: 0 }).ok, false)
assert.equal(validateRenderRequest(scene({ duration: 61 }), { fps: 60 }).ok, false)
})
test('rejects invalid duration', () => {
const result = validateRenderRequest(scene({ duration: 0 }))
assert.equal(result.ok, false)
assert.match(result.error, /duration/)
})
test('rejects too many layers and keyframes', () => {
const layers = Array.from({ length: 501 }, () => ({ keyframes: [] }))
assert.equal(validateRenderRequest(scene({ layers })).ok, false)
const keyframes = Array.from({ length: 5001 }, (_, i) => ({ time: i / 60, props: {} }))
assert.equal(validateRenderRequest(scene({ layers: [{ keyframes }] })).ok, false)
})
test('rejects invalid crf and preset', () => {
assert.equal(validateRenderRequest(scene(), { crf: 52 }).ok, false)
assert.equal(validateRenderRequest(scene(), { preset: 'turbo' }).ok, false)
})
test('computes playback range from in/out or last keyframe', () => {
assert.deepEqual(playbackRange(scene({ inTime: 0.25, outTime: 1.5 })), { start: 0.25, end: 1.5 })
assert.deepEqual(playbackRange(scene()), { start: 0, end: 1 })
})
+26 -6
View File
@@ -16,6 +16,7 @@ import { downloadExampleHtml, downloadRuntime, downloadSceneExport } from '@/lib
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'
@@ -358,6 +359,8 @@ async function exportApngPreview() {
async function exportMp4Server() {
if (progress.value) return
const controller = new AbortController()
const base = (projectName.value ?? 'animation').toString()
const safe = base.replace(/[^a-z0-9äöüß_-]+/gi, '-').replace(/^-+|-+$/g, '') || 'animation'
progress.value = {
progress: 0,
controller,
@@ -366,12 +369,29 @@ async function exportMp4Server() {
}
store.pause()
try {
await downloadFromServer(activeSceneClone(), {
signal: controller.signal,
onProgress: (p) => {
if (progress.value) progress.value.progress = p
},
})
const scene = activeSceneClone()
if (hasDesktopRender()) {
const result = await renderMp4OnDesktop(scene, {
signal: controller.signal,
fps: targetFps.value,
suggestedName: `${safe}.mp4`,
onProgress: (p) => {
if (progress.value) progress.value.progress = p
},
})
if (!result) {
progress.value = null
return
}
} else {
await downloadFromServer(scene, {
signal: controller.signal,
fps: targetFps.value,
onProgress: (p) => {
if (progress.value) progress.value.progress = p
},
})
}
progress.value = null
emit('update:modelValue', false)
} catch (err) {
+53
View File
@@ -0,0 +1,53 @@
import { useI18n } from '@/i18n'
import { useSceneStore } from '@/stores/scene'
import { runAll, type TestResult } from './testRunner'
import { registerAllTests } from './tests'
declare global {
interface Window {
__ruwioTestResults?: TestResult[]
}
}
function shouldRunSuite(name: string, desktop: boolean): boolean {
if (name.startsWith('desktop:')) return desktop
if (name.startsWith('browser:')) return !desktop
return true
}
export async function runHeadlessTests(): Promise<TestResult[]> {
const desktop = !!window.ruwioDesktop
const store = useSceneStore()
const { locale } = useI18n()
const snapshot = JSON.parse(JSON.stringify(store.$state))
const previousLocale = locale.value
document.body.setAttribute('data-tests-done', 'running')
document.body.setAttribute('data-test-host', desktop ? 'electron' : 'browser')
locale.value = 'en'
try {
const results = await runAll(registerAllTests, {
interTestDelayMs: 0,
suiteFilter: (name) => shouldRunSuite(name, desktop),
})
window.__ruwioTestResults = results
document.body.setAttribute('data-tests-done', results.some((r) => !r.ok) ? 'fail' : 'pass')
return results
} catch (err) {
const result: TestResult = {
suite: 'headless',
name: 'runAll',
ok: false,
error: (err as Error).message || String(err),
stack: (err as Error).stack,
durationMs: 0,
}
window.__ruwioTestResults = [result]
document.body.setAttribute('data-tests-done', 'fail')
return [result]
} finally {
store.$state = snapshot
locale.value = previousLocale
}
}
+4 -2
View File
@@ -133,6 +133,7 @@ export interface RunOptions {
onTestStart?: (suite: string, name: string) => void
onResult?: (result: TestResult) => void
interTestDelayMs?: number
suiteFilter?: (suite: string) => boolean
}
export async function runAll(
@@ -141,11 +142,12 @@ export async function runAll(
): Promise<TestResult[]> {
suites.length = 0
register()
const total = suites.reduce((a, s) => a + s.tests.length, 0)
const runnableSuites = opts.suiteFilter ? suites.filter((s) => opts.suiteFilter?.(s.name)) : suites
const total = runnableSuites.reduce((a, s) => a + s.tests.length, 0)
const results: TestResult[] = []
let done = 0
const delay = Math.max(0, opts.interTestDelayMs ?? 0)
for (const s of suites) {
for (const s of runnableSuites) {
for (const t of s.tests) {
opts.onTestStart?.(s.name, t.name)
await new Promise((r) => requestAnimationFrame(() => r(null)))
+65
View File
@@ -0,0 +1,65 @@
import type { Scene } from '@/types/scene'
export interface DesktopRenderOptions {
onProgress?: (progress: number) => void
signal?: AbortSignal
fps?: number
crf?: number
preset?: string
suggestedName?: string
}
export function hasDesktopRender(): boolean {
return !!window.ruwioDesktop?.renderMp4
}
export async function renderMp4OnDesktop(
scene: Scene,
opts: DesktopRenderOptions = {},
): Promise<{ path: string } | null> {
const desktop = window.ruwioDesktop
if (!desktop?.renderMp4) throw new Error('Desktop MP4 rendering is not available')
let jobId: string | null = null
let settled = false
let pendingAbort = false
const cleanupProgress = desktop.onRenderProgress?.((event) => {
jobId = event.jobId
if (typeof event.progress === 'number') opts.onProgress?.(event.progress)
if (pendingAbort && !settled && jobId) {
pendingAbort = false
void desktop.cancelRender?.(jobId)
}
})
const abort = () => {
if (settled) return
if (jobId) void desktop.cancelRender?.(jobId)
else pendingAbort = true
}
if (opts.signal) {
if (opts.signal.aborted) {
throw new DOMException('aborted', 'AbortError')
}
opts.signal.addEventListener('abort', abort, { once: true })
}
try {
const result = await desktop.renderMp4({
scene,
fps: opts.fps,
crf: opts.crf,
preset: opts.preset,
suggestedName: opts.suggestedName,
})
return result
} catch (err) {
if (opts.signal?.aborted) throw new DOMException('aborted', 'AbortError')
throw err
} finally {
settled = true
cleanupProgress?.()
opts.signal?.removeEventListener('abort', abort)
}
}
+4
View File
@@ -18,6 +18,10 @@ app.use(VueKonva)
app.directive('tooltip', vTooltip)
app.mount('#app')
if (import.meta.env.DEV && new URLSearchParams(window.location.search).get('__runtests') === '1') {
void import('./devtools/headless').then(({ runHeadlessTests }) => runHeadlessTests())
}
// Service worker exists solely to make the app installable as a PWA.
// In Chrome/Edge, an installed PWA runs in standalone display mode,
// which removes the top-edge "exit fullscreen" indicator that tabs
+16
View File
@@ -1,3 +1,5 @@
import type { Scene } from './scene'
export {}
type RuwioDesktopMenuCommand =
@@ -33,6 +35,20 @@ declare global {
onMenuCommand?: (
callback: (command: RuwioDesktopMenuCommand) => void,
) => () => void
renderMp4?: (options: {
scene: Scene
fps?: number
crf?: number
preset?: string
suggestedName?: string
}) => Promise<{ path: string } | null>
onRenderProgress?: (callback: (event: {
jobId: string
progress: number
done: boolean
error?: string
}) => void) => () => void
cancelRender?: (jobId: string) => Promise<void>
}
}
}
+1 -1
View File
@@ -1,2 +1,2 @@
export const APP_VERSION = '1.2.15'
export const APP_VERSION = '1.3.0'
export const APP_NAME = 'RUWIO'
+33
View File
@@ -0,0 +1,33 @@
{
"canvas": { "width": 160, "height": 120, "background": "#f8fafc" },
"duration": 1,
"assets": [
{ "id": "rect", "kind": "rect", "name": "Rect", "width": 42, "height": 28, "fill": "#ef4444", "stroke": "#111827", "strokeWidth": 2 },
{ "id": "circle", "kind": "circle", "name": "Circle", "radius": 18, "fill": "#22c55e", "stroke": "#0f172a", "strokeWidth": 2 }
],
"layers": [
{
"id": "layer-rect",
"name": "Rect",
"assetId": "rect",
"visible": true,
"base": { "x": 36, "y": 62, "scaleX": 1, "scaleY": 1, "rotation": 0, "opacity": 1 },
"keyframes": [
{ "id": "rect-0", "time": 0, "easing": "linear", "props": { "x": 36, "rotation": 0 } },
{ "id": "rect-1", "time": 1, "easing": "linear", "props": { "x": 112, "rotation": 18 } }
]
},
{
"id": "layer-circle",
"name": "Circle",
"assetId": "circle",
"visible": true,
"base": { "x": 88, "y": 44, "scaleX": 1, "scaleY": 1, "rotation": 0, "opacity": 0.82 },
"keyframes": [
{ "id": "circle-0", "time": 0, "easing": "linear", "props": { "y": 44 } },
{ "id": "circle-1", "time": 1, "easing": "linear", "props": { "y": 78 } }
]
}
],
"trackOrder": ["layer-circle", "layer-rect"]
}
+41
View File
@@ -0,0 +1,41 @@
function diffBgra(a, b, options = {}) {
if (!a || !b || a.length !== b.length) {
throw new Error(`bitmap size mismatch: ${a?.length || 0} != ${b?.length || 0}`)
}
const perChannelTolerance = Number.isFinite(options.perChannelTolerance)
? options.perChannelTolerance
: 2
const maxMismatchedRatio = Number.isFinite(options.maxMismatchedRatio)
? options.maxMismatchedRatio
: 0.001
let mismatchedPixels = 0
let maxDelta = 0
let totalDelta = 0
const pixels = a.length / 4
for (let i = 0; i < a.length; i += 4) {
let pixelMismatch = false
for (let c = 0; c < 4; c += 1) {
const delta = Math.abs(a[i + c] - b[i + c])
maxDelta = Math.max(maxDelta, delta)
totalDelta += delta
if (delta > perChannelTolerance) pixelMismatch = true
}
if (pixelMismatch) mismatchedPixels += 1
}
const mismatchedRatio = pixels ? mismatchedPixels / pixels : 0
return {
ok: mismatchedRatio <= maxMismatchedRatio,
pixels,
mismatchedPixels,
mismatchedRatio,
maxDelta,
meanDelta: pixels ? totalDelta / (pixels * 4) : 0,
perChannelTolerance,
maxMismatchedRatio,
}
}
module.exports = { diffBgra }