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:
1 parent
46f9f32aef
commit
518d43855b
41 files changed
+3902
-81
No files matched your search
@@ -20,3 +20,8 @@ release
|
|||||||
/ruwio.png
|
/ruwio.png
|
||||||
.codex
|
.codex
|
||||||
misc/
|
misc/
|
||||||
|
|
||||||
|
# Locally staged ffmpeg binaries. Release-managed binaries can be force-added.
|
||||||
|
/bin/linux/ffmpeg
|
||||||
|
/bin/darwin/ffmpeg
|
||||||
|
/bin/win32/ffmpeg.exe
|
||||||
@@ -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
|
app without creating an installer. `desktop:build` uses `electron-builder` and
|
||||||
targets AppImage on Linux, NSIS on Windows, and DMG on macOS.
|
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 render server
|
||||||
|
|
||||||
MP4 export is handled by the optional render server in `server/`. It binds to
|
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
|
`127.0.0.1:6000` and requires `RENDER_TOKEN` in the environment before it will
|
||||||
start.
|
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
|
## Stack
|
||||||
|
|
||||||
- Vue 3 (`<script setup>`) + Pinia state
|
- Vue 3 (`<script setup>`) + Pinia state
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -1,7 +1,13 @@
|
|||||||
import { app, BrowserWindow, Menu, shell } from 'electron'
|
import { app, BrowserWindow, dialog, ipcMain, Menu, screen, shell } from 'electron'
|
||||||
import type { BrowserWindow as BrowserWindowInstance, Event, MenuItemConstructorOptions } from 'electron'
|
import type { BrowserWindow as BrowserWindowInstance, Event, IpcMainInvokeEvent, MenuItemConstructorOptions } from 'electron'
|
||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
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 isDev = !app.isPackaged
|
||||||
const devServerUrl = process.env.VITE_DEV_SERVER_URL ?? 'http://localhost:5200'
|
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')
|
: path.join(process.resourcesPath, 'build', 'icon.png')
|
||||||
|
|
||||||
let mainWindow: BrowserWindowInstance | null = null
|
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 {
|
function sendAppCommand(command: string): void {
|
||||||
if (!mainWindow || mainWindow.isDestroyed()) return
|
if (!mainWindow || mainWindow.isDestroyed()) return
|
||||||
mainWindow.webContents.send('ruwio:menu-command', command)
|
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 {
|
function parseUrl(raw: string): URL | null {
|
||||||
try {
|
try {
|
||||||
return new URL(raw)
|
return new URL(raw)
|
||||||
@@ -256,6 +377,8 @@ function createMenu(): void {
|
|||||||
app.setName('RUWIO')
|
app.setName('RUWIO')
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
|
ipcMain.handle('ruwio:render-mp4', handleRenderMp4)
|
||||||
|
ipcMain.handle('ruwio:render-cancel', handleRenderCancel)
|
||||||
createMenu()
|
createMenu()
|
||||||
createMainWindow()
|
createMainWindow()
|
||||||
|
|
||||||
|
|||||||
@@ -14,4 +14,33 @@ contextBridge.exposeInMainWorld('ruwioDesktop', {
|
|||||||
ipcRenderer.removeListener('ruwio:menu-command', listener)
|
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)
|
||||||
|
},
|
||||||
})
|
})
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ruwio",
|
"name": "ruwio",
|
||||||
"version": "1.2.13",
|
"version": "1.2.15",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ruwio",
|
"name": "ruwio",
|
||||||
"version": "1.2.13",
|
"version": "1.2.15",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@phosphor-icons/vue": "^2.2.1",
|
"@phosphor-icons/vue": "^2.2.1",
|
||||||
"gifenc": "^1.0.3",
|
"gifenc": "^1.0.3",
|
||||||
|
|||||||
+23
-4
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ruwio",
|
"name": "ruwio",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.2.15",
|
"version": "1.3.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist-electron/main.cjs",
|
"main": "dist-electron/main.cjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -9,11 +9,17 @@
|
|||||||
"build": "vue-tsc --noEmit && vite build",
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
"desktop:dev": "concurrently -k \"vite\" \"wait-on http://localhost:5200 && npm run desktop:start\"",
|
"desktop:dev": "concurrently -k \"vite\" \"wait-on http://localhost:5200 && npm run desktop:start\"",
|
||||||
"desktop:start": "tsc -p tsconfig.electron.json && electron .",
|
"desktop:start": "tsc -p tsconfig.electron.json && electron .",
|
||||||
"desktop:build": "npm run build && tsc -p tsconfig.electron.json && electron-builder",
|
"desktop:build": "npm run build && tsc -p tsconfig.electron.json && npm run stage:ffmpeg -- --optional && electron-builder",
|
||||||
"desktop:pack": "npm run build && tsc -p tsconfig.electron.json && electron-builder --dir",
|
"desktop:pack": "npm run build && tsc -p tsconfig.electron.json && npm run stage:ffmpeg -- --optional && electron-builder --dir",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"type-check": "vue-tsc --noEmit",
|
"type-check": "vue-tsc --noEmit",
|
||||||
"pack": "bash scripts/pack.sh"
|
"pack": "bash scripts/pack.sh",
|
||||||
|
"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": {
|
"dependencies": {
|
||||||
"@phosphor-icons/vue": "^2.2.1",
|
"@phosphor-icons/vue": "^2.2.1",
|
||||||
@@ -39,18 +45,31 @@
|
|||||||
"appId": "app.ruwio.desktop",
|
"appId": "app.ruwio.desktop",
|
||||||
"productName": "RUWIO",
|
"productName": "RUWIO",
|
||||||
"icon": "icon.png",
|
"icon": "icon.png",
|
||||||
|
"npmRebuild": false,
|
||||||
"directories": {
|
"directories": {
|
||||||
"output": "release"
|
"output": "release"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist/**/*",
|
"dist/**/*",
|
||||||
"dist-electron/**/*",
|
"dist-electron/**/*",
|
||||||
|
"shared/renderValidation.cjs",
|
||||||
"package.json"
|
"package.json"
|
||||||
],
|
],
|
||||||
"extraResources": [
|
"extraResources": [
|
||||||
{
|
{
|
||||||
"from": "build/icon.png",
|
"from": "build/icon.png",
|
||||||
"to": "build/icon.png"
|
"to": "build/icon.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "server/render.html",
|
||||||
|
"to": "server/render.html"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "bin",
|
||||||
|
"to": "bin",
|
||||||
|
"filter": [
|
||||||
|
"**/*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"linux": {
|
"linux": {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
}),
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
}),
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
}),
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
|
}),
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
}),
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
}),
|
||||||
|
)
|
||||||
@@ -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
@@ -6,7 +6,9 @@
|
|||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js",
|
"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": {
|
"dependencies": {
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
|
|||||||
@@ -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
@@ -386,7 +386,20 @@
|
|||||||
return true;
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+16
-67
@@ -6,6 +6,7 @@ import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
|
|||||||
import { spawn } from 'node:child_process'
|
import { spawn } from 'node:child_process'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import puppeteer from 'puppeteer-core'
|
import puppeteer from 'puppeteer-core'
|
||||||
|
import renderValidation from '../shared/renderValidation.cjs'
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url)
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
const __dirname = path.dirname(__filename)
|
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_LAYERS = readIntEnv('RENDER_MAX_LAYERS', 500, 1, 100000)
|
||||||
const MAX_KEYFRAMES = readIntEnv('RENDER_MAX_KEYFRAMES', 5000, 1, 1000000)
|
const MAX_KEYFRAMES = readIntEnv('RENDER_MAX_KEYFRAMES', 5000, 1, 1000000)
|
||||||
const MAX_ASSETS = readIntEnv('RENDER_MAX_ASSETS', 500, 1, 100000)
|
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()
|
const app = express()
|
||||||
app.disable('x-powered-by')
|
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) {
|
async function runJob(job, scene, options) {
|
||||||
const { start: rangeIn } = playbackRange(scene)
|
const { start: rangeIn } = playbackRange(scene)
|
||||||
const { fps, duration, totalFrames, crf, preset } = options
|
const { fps, duration, totalFrames, crf, preset } = options
|
||||||
@@ -260,6 +253,9 @@ async function runJob(job, scene, options) {
|
|||||||
'-i', '-',
|
'-i', '-',
|
||||||
'-c:v', 'libx264',
|
'-c:v', 'libx264',
|
||||||
'-pix_fmt', 'yuv420p',
|
'-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,
|
'-preset', preset,
|
||||||
'-crf', crf,
|
'-crf', crf,
|
||||||
'-movflags', '+faststart',
|
'-movflags', '+faststart',
|
||||||
@@ -327,54 +323,7 @@ async function cleanupJob(jobId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function validateRenderRequest(scene, options) {
|
function validateRenderRequest(scene, options) {
|
||||||
if (!scene || typeof scene !== 'object') return { ok: false, error: 'invalid scene' }
|
return validateSharedRenderRequest(scene, options, RENDER_LIMITS)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function chromeArgs() {
|
function chromeArgs() {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
})
|
||||||
@@ -16,6 +16,7 @@ import { downloadExampleHtml, downloadRuntime, downloadSceneExport } from '@/lib
|
|||||||
import { downloadSceneAsVideo, pickMimeType } from '@/lib/videoExport'
|
import { downloadSceneAsVideo, pickMimeType } from '@/lib/videoExport'
|
||||||
import { downloadSceneAsGif } from '@/lib/gifExport'
|
import { downloadSceneAsGif } from '@/lib/gifExport'
|
||||||
import { downloadSceneAsApng, downloadSceneAsApngPreview } from '@/lib/apngExport'
|
import { downloadSceneAsApng, downloadSceneAsApngPreview } from '@/lib/apngExport'
|
||||||
|
import { hasDesktopRender, renderMp4OnDesktop } from '@/lib/desktopRenderExport'
|
||||||
import { downloadFromServer } from '@/lib/serverExport'
|
import { downloadFromServer } from '@/lib/serverExport'
|
||||||
import { downloadRuwFile } from '@/lib/ruwFile'
|
import { downloadRuwFile } from '@/lib/ruwFile'
|
||||||
import { downloadSceneAsStillImage } from '@/lib/stillImageExport'
|
import { downloadSceneAsStillImage } from '@/lib/stillImageExport'
|
||||||
@@ -358,6 +359,8 @@ async function exportApngPreview() {
|
|||||||
async function exportMp4Server() {
|
async function exportMp4Server() {
|
||||||
if (progress.value) return
|
if (progress.value) return
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
|
const base = (projectName.value ?? 'animation').toString()
|
||||||
|
const safe = base.replace(/[^a-z0-9äöüß_-]+/gi, '-').replace(/^-+|-+$/g, '') || 'animation'
|
||||||
progress.value = {
|
progress.value = {
|
||||||
progress: 0,
|
progress: 0,
|
||||||
controller,
|
controller,
|
||||||
@@ -366,12 +369,29 @@ async function exportMp4Server() {
|
|||||||
}
|
}
|
||||||
store.pause()
|
store.pause()
|
||||||
try {
|
try {
|
||||||
await downloadFromServer(activeSceneClone(), {
|
const scene = activeSceneClone()
|
||||||
|
if (hasDesktopRender()) {
|
||||||
|
const result = await renderMp4OnDesktop(scene, {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
|
fps: targetFps.value,
|
||||||
|
suggestedName: `${safe}.mp4`,
|
||||||
onProgress: (p) => {
|
onProgress: (p) => {
|
||||||
if (progress.value) progress.value.progress = 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
|
progress.value = null
|
||||||
emit('update:modelValue', false)
|
emit('update:modelValue', false)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -133,6 +133,7 @@ export interface RunOptions {
|
|||||||
onTestStart?: (suite: string, name: string) => void
|
onTestStart?: (suite: string, name: string) => void
|
||||||
onResult?: (result: TestResult) => void
|
onResult?: (result: TestResult) => void
|
||||||
interTestDelayMs?: number
|
interTestDelayMs?: number
|
||||||
|
suiteFilter?: (suite: string) => boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runAll(
|
export async function runAll(
|
||||||
@@ -141,11 +142,12 @@ export async function runAll(
|
|||||||
): Promise<TestResult[]> {
|
): Promise<TestResult[]> {
|
||||||
suites.length = 0
|
suites.length = 0
|
||||||
register()
|
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[] = []
|
const results: TestResult[] = []
|
||||||
let done = 0
|
let done = 0
|
||||||
const delay = Math.max(0, opts.interTestDelayMs ?? 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) {
|
for (const t of s.tests) {
|
||||||
opts.onTestStart?.(s.name, t.name)
|
opts.onTestStart?.(s.name, t.name)
|
||||||
await new Promise((r) => requestAnimationFrame(() => r(null)))
|
await new Promise((r) => requestAnimationFrame(() => r(null)))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,10 @@ app.use(VueKonva)
|
|||||||
app.directive('tooltip', vTooltip)
|
app.directive('tooltip', vTooltip)
|
||||||
app.mount('#app')
|
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.
|
// Service worker exists solely to make the app installable as a PWA.
|
||||||
// In Chrome/Edge, an installed PWA runs in standalone display mode,
|
// In Chrome/Edge, an installed PWA runs in standalone display mode,
|
||||||
// which removes the top-edge "exit fullscreen" indicator that tabs
|
// which removes the top-edge "exit fullscreen" indicator that tabs
|
||||||
|
|||||||
Vendored
+16
@@ -1,3 +1,5 @@
|
|||||||
|
import type { Scene } from './scene'
|
||||||
|
|
||||||
export {}
|
export {}
|
||||||
|
|
||||||
type RuwioDesktopMenuCommand =
|
type RuwioDesktopMenuCommand =
|
||||||
@@ -33,6 +35,20 @@ declare global {
|
|||||||
onMenuCommand?: (
|
onMenuCommand?: (
|
||||||
callback: (command: RuwioDesktopMenuCommand) => void,
|
callback: (command: RuwioDesktopMenuCommand) => void,
|
||||||
) => () => 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
@@ -1,2 +1,2 @@
|
|||||||
export const APP_VERSION = '1.2.15'
|
export const APP_VERSION = '1.3.0'
|
||||||
export const APP_NAME = 'RUWIO'
|
export const APP_NAME = 'RUWIO'
|
||||||
+33
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
Reference in new issue
Block a user