Compare commits

...
18 Commits
Author SHA1 Message Date
th 7093e1f6d2 Update fullscreen preview and camera output 2026-06-08 18:15:27 +02:00
th 9b314ad817 Unify switch controls 2026-06-08 10:57:00 +02:00
th b9a4b53109 Add camera resize and keyframe controls 2026-06-08 00:33:57 +02:00
th 3a93d6ea5d Add camera frontend tests 2026-06-07 23:58:05 +02:00
th 23a66c6fdf Add output camera framing 2026-06-07 23:12:46 +02:00
th 616ced17ce Enable frontend tests in Electron 2026-06-07 15:58:18 +02:00
th b18e66c28d Add export environment plan 2026-06-07 13:24:34 +02:00
th f469ab7444 Clarify export runtime copy 2026-06-07 12:10:35 +02:00
th 8d296c963e Update development doc references 2026-06-07 11:10:05 +02:00
th 00ee563311 Add Electron E2E harness launcher 2026-06-07 11:09:39 +02:00
th fa8c52b239 Split renderer bundles by feature 2026-06-07 11:08:41 +02:00
th 7bf18636d6 Fix desktop packaging artifact build 2026-06-07 11:08:15 +02:00
th ba58ebb48d Bump version to 1.3.1 2026-06-07 11:06:50 +02:00
th 196aa2e8ba rename docs 2026-06-07 10:46:34 +02:00
thandClaude Opus 4.8 518d43855b 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>
2026-06-06 20:41:25 +02:00
th 46f9f32aef Add security review and remediation plan 2026-06-05 20:07:19 +02:00
th b536e8cf73 Bump version to 1.2.15 2026-06-05 20:07:05 +02:00
th fae55e3f13 Harden render server and import boundaries 2026-06-05 20:05:32 +02:00
79 changed files with 10124 additions and 461 deletions

No files matched your search

+5
View File
@@ -20,3 +20,8 @@ release
/ruwio.png
.codex
misc/
# Locally staged ffmpeg binaries. Release-managed binaries can be force-added.
/bin/linux/ffmpeg
/bin/darwin/ffmpeg
/bin/win32/ffmpeg.exe
+56 -4
View File
@@ -60,13 +60,65 @@ same Vite-built app from `dist/` plus the compiled Electron entry points in
```bash
npm install
npm run desktop:dev # start Vite, then launch Electron against localhost
npm run desktop:pack # build an unpacked desktop app into release/
npm run desktop:dir # build an unpacked desktop app into release/
npm run desktop:pack # build platform package artifacts into release/
npm run desktop:build # build platform installer/package artifacts into release/
```
`desktop:pack` is the quickest compile check because it produces an unpacked
app without creating an installer. `desktop:build` uses `electron-builder` and
targets AppImage on Linux, NSIS on Windows, and DMG on macOS.
`desktop:dir` is the quickest compile check because it produces an unpacked app
without creating an installer. `desktop:pack` and `desktop:build` use
`electron-builder` and target AppImage on Linux, NSIS on Windows, and DMG on
macOS.
Desktop MP4 export runs inside Electron and uses ffmpeg from
`resources/bin/<platform>/` in packaged builds. The build scripts run
`npm run stage:ffmpeg -- --optional`, which copies `FFMPEG_PATH` or `ffmpeg` on
`PATH` into `bin/<platform>/` when available. For release artifacts, replace
that local copy with redistributable static ffmpeg binaries for Linux, Windows,
and macOS.
If no bundled binary is present, MP4 export still falls back to `FFMPEG_PATH`
or system `ffmpeg` and reports a clear error when neither can be executed.
Before shipping bundled ffmpeg, document the binary source, version, configured
codecs, and GPL/LGPL obligations for that build.
## MP4 render server
MP4 export is handled by the optional render server in `server/`. It binds to
`127.0.0.1:6000` by default and requires a token before it will accept render
jobs.
```bash
cd server
npm ci
HOST=127.0.0.1 PORT=6000 RENDER_TOKEN="choose-a-long-random-token" npm start
```
Point the app at the server with matching Vite env values:
```bash
VITE_RENDER_SERVER=http://localhost:6000 VITE_RENDER_TOKEN="choose-a-long-random-token" npm run dev
```
The server exposes `HOST=0.0.0.0` only when explicitly requested. If you expose
it beyond localhost, set `RENDER_ALLOWED_ORIGINS` to the exact app origins that
should be allowed. Chromium sandboxing stays enabled by default; use
`CHROME_NO_SANDBOX=1` only for a deployment environment that cannot run the
sandbox.
The included `docker-compose.yml` keeps the published host port on
`127.0.0.1:6000` and requires `RENDER_TOKEN` in the environment before it will
start.
Render-server checks:
```bash
npm run test:render-server # health/auth/validation route smoke
npm run test:render-server:e2e # renders a tiny MP4 and verifies it with ffprobe
```
The E2E check requires Chromium, ffmpeg, and ffprobe. Set
`PUPPETEER_EXECUTABLE_PATH` when Chromium is not at `/usr/bin/chromium`.
## Stack
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

+16
View File
@@ -0,0 +1,16 @@
# ffmpeg release binaries
Desktop MP4 export looks for ffmpeg at:
- `bin/linux/ffmpeg`
- `bin/darwin/ffmpeg`
- `bin/win32/ffmpeg.exe`
These binaries are packaged into Electron resources as `resources/bin/<platform>/`.
Use `npm run stage:ffmpeg -- --source /path/to/ffmpeg` to copy a local binary
into the correct folder, or set `FFMPEG_PATH` when running the app to use an
external ffmpeg without staging it.
Release builds should use a redistributable static ffmpeg build for each target
platform. Record the source URL, version, configured codecs, and license terms
for every binary in this folder before publishing an installer.
File renamed without changes.
+174
View File
@@ -0,0 +1,174 @@
# Security Review
Date: 2026-06-05
Scope reviewed:
- Vue/Vite renderer in `src/`
- Electron main/preload in `electron/`
- MP4 render server in `server/`
- Project import/export paths for `.json`, `.ruw`, backups, assets, fonts, and generated HTML exports
- Build/dependency posture at the repository root
## Summary
The desktop shell starts from a good Electron baseline: `contextIsolation: true`, `nodeIntegration: false`, `sandbox: true`, and a narrow preload bridge. I did not find direct Node exposure to the Vue renderer.
The highest-risk area is the standalone MP4 render server. It accepts unauthenticated cross-origin render jobs, large request bodies, and attacker-controlled scene data, then renders it in Chromium with `--no-sandbox` and feeds frames to `ffmpeg`. That combination should be treated as a network-exposed compute/sandbox boundary until it is tightened.
The second broad theme is input hardening. Project files and `.ruw` bundles are structurally checked, but not deeply schema-validated or bounded before the app uses canvas sizes, frame counts, assets, and timelines. A crafted file can likely cause heavy CPU/memory use in both browser and server render paths.
## Findings
### High: Render Server Is Unauthenticated And Cross-Origin
Evidence:
- `server/server.js:19` enables default `cors()`, allowing browser requests from any origin.
- `server/server.js:20` accepts JSON bodies up to `200mb`.
- `server/server.js:27` starts render jobs without authentication, CSRF protection, origin checks, rate limits, or concurrency limits.
- `docker-compose.yml:4-5` publishes port `6000` on the host.
Impact:
Any site reachable by a user's browser, or any network client that can reach the service, can submit expensive render jobs. This can exhaust CPU, memory, disk, Chromium processes, or `ffmpeg` work queues. If the server is ever exposed beyond localhost, it becomes a public unauthenticated render farm.
Recommendations:
- Bind the service to localhost by default, or require an explicit opt-in for network exposure.
- Replace default `cors()` with an allowlist for the app origin.
- Add authentication or a per-session token for `/render`, `/events`, `/file`, and `DELETE`.
- Add request rate limits, maximum active jobs, queue limits, and per-job timeouts.
- Lower the default body limit and reject requests before parsing when possible.
### High: Chromium Renders Untrusted Data With Sandbox Disabled
Evidence:
- `server/server.js:126-134` launches Chromium with `--no-sandbox` and `--disable-setuid-sandbox`.
- `server/server.js:166-167` loads `render.html` and injects caller-provided scene data into the page.
- `server/Dockerfile:1-20` uses the default root user; no non-root `USER` is configured.
Impact:
The server renders attacker-controlled image/audio/video/SVG-like asset data in Chromium. If a browser engine, image decoder, font decoder, media decoder, or canvas path is exploited, disabling the Chromium sandbox and running the container as root increases the blast radius.
Recommendations:
- Run the container as a non-root user.
- Prefer enabling the Chromium sandbox. If the container needs extra setup, document and configure it rather than relying on `--no-sandbox`.
- Treat each render job as untrusted: use process-level timeouts, memory/CPU limits, and container hardening.
- Consider a separate locked-down worker container for renders.
### Medium: Project And Bundle Imports Lack Deep Validation And Bounds
Evidence:
- `src/lib/projectFile.ts:39-67` checks only top-level `magic`, `canvas`, `scenes`, and `assets` shape before returning the imported object as `Project`.
- `src/lib/zipUtil.ts:121-171` parses ZIP entries into memory without explicit caps on entry count, central directory size, total extracted size, or path/name length.
- `server/server.js:115-119` derives `fps`, `duration`, and `totalFrames` directly from request data.
- `server/server.js:135-138` uses request-provided canvas dimensions as the Chromium viewport.
Impact:
A crafted `.json`, `.ruw`, backup, or server request can specify extreme canvas sizes, frame counts, layer/keyframe arrays, base64 assets, font data, or ZIP metadata. The likely outcome is denial of service: browser tab hangs, Electron renderer memory pressure, server CPU exhaustion, or failed/very slow exports.
Recommendations:
- Add a real project schema validator at file and server boundaries.
- Clamp canvas dimensions, duration, fps, layer counts, keyframe counts, asset count, asset byte size, and total project size.
- Add ZIP reader bounds and clearer errors for malformed/truncated archives.
- Normalize imported projects into a safe internal shape instead of trusting arbitrary object fields.
### Medium: Render Server Dependency Installs Are Not Reproducible
Evidence:
- `server/package.json` exists, but no `server/package-lock.json` is present.
- `server/Dockerfile:15-16` copies only `package.json` and runs `npm install --omit=dev`.
- `npm audit --prefix server --omit=dev` cannot run without a lockfile.
Impact:
Server builds may pull different transitive dependency versions over time. That makes vulnerability tracking, incident response, and reproducible releases harder.
Recommendations:
- Commit `server/package-lock.json`.
- Use `npm ci --omit=dev` in the server Dockerfile.
- Add server dependency auditing to CI.
### Medium: Development Test Overlay Ships In The Production Bundle
Evidence:
- `src/App.vue:38` statically imports `./devtools/TestOverlay.vue`.
- `src/App.vue:806` always mounts the component.
- `npm run build` output shows `src/devtools/tests.ts` pulled into the production bundle through static/dynamic import relationships.
Impact:
This increases production attack surface and bundle size. Even if the test runner is intended to open only from a development-only Electron menu item, the code is still shipped to every web and desktop user.
Recommendations:
- Gate the test overlay behind `import.meta.env.DEV`.
- Load it with `defineAsyncComponent` or dynamic import only in development.
- Ensure production builds do not include `src/devtools/tests.ts`.
### Low: Electron Navigation And External Link Policy Could Be Stricter
Evidence:
- `electron/main.cts:40-44` opens any `http://` or `https://` URL externally.
- `electron/main.cts:47-51` uses string `startsWith` checks for navigation allow/deny decisions.
Impact:
This is not a direct Node compromise because the renderer is isolated. Still, if untrusted content ever reaches a clickable/openable path, arbitrary external links can be launched. String-prefix navigation checks are also easier to get wrong than parsed URL origin/path checks.
Recommendations:
- Prefer parsed URL checks over `startsWith`.
- Allow only expected origins in development.
- Consider allowing only `https:` external links, or an explicit domain allowlist for help/about links.
### Low: No Content Security Policy Is Defined
Evidence:
- `index.html` and built `dist/index.html` have no CSP meta tag.
Impact:
Vue's default escaping reduces XSS risk, and Electron disables Node integration. A CSP would still provide defense in depth for the web app, generated asset previews, and Electron renderer by limiting script, media, image, and connection destinations.
Recommendations:
- Add a CSP for the app shell.
- Account for required `data:`/`blob:` sources for user assets and exports.
- Add Electron-specific CSP validation during packaged smoke tests.
## Positive Notes
- Electron uses `contextIsolation`, disables `nodeIntegration`, enables renderer sandboxing, and exposes only a small preload API.
- The preload bridge does not expose arbitrary IPC invocation or Node primitives.
- The Electron app denies `window.open` in-app and prevents most unexpected navigation.
- `.ruw` uses STORE-only ZIP handling, avoiding decompression bombs from deflated entries.
- The root production dependency audit completed with `0 vulnerabilities`.
- A shallow secret scan found no obvious committed `.env`, key, secret, PEM, or certificate files outside dependencies.
## Verification Performed
- `npm run build` passed.
- `npm audit --omit=dev` passed with `0 vulnerabilities`.
- `npm audit --prefix server --omit=dev` could not run because `server/` has no lockfile.
## Suggested Remediation Order
1. Lock down the render server: localhost binding, CORS allowlist, auth/token, rate limits, job limits, and timeouts.
2. Run render workers as non-root and remove Chromium `--no-sandbox` if feasible.
3. Add schema validation and resource bounds for imported projects, `.ruw` bundles, backups, and render requests.
4. Add `server/package-lock.json`, switch Docker to `npm ci --omit=dev`, and audit server dependencies in CI.
5. Remove devtools/test runner code from production bundles.
6. Add CSP and stricter Electron URL parsing/allowlists.
+318
View File
@@ -0,0 +1,318 @@
# Security Remediation Plan
Date: 2026-06-05
Source review: `dev_docs/02_security-review.md`
## Goal
Close the security issues from the review without changing RUWIO's core authoring workflow. The first priority is to make the MP4 render server safe to run on a developer machine by default; the second is to make project and bundle input handling bounded and reproducible.
## Principles
- Secure defaults first: local-only render server, explicit opt-in for network exposure, and bounded work.
- Validate at every trust boundary: imported files, `.ruw` bundles, render requests, Electron IPC, and generated/exported HTML.
- Keep browser and desktop builds working while hardening server-only paths.
- Add tests for security behavior so fixes do not drift quietly.
## Phase 0: Baseline And Guardrails
Files likely touched:
- `dev_docs/02_security-review.md`
- `package.json`
- `server/package.json`
- `server/server.js`
- `src/devtools/tests.ts`
Tasks:
1. Capture a clean baseline with `npm run build`.
2. Add or document a lightweight verification command for render-server checks once server tests exist.
3. Decide the initial production limits so later validation has concrete numbers:
- maximum canvas width and height
- maximum render fps
- maximum render duration and total frames
- maximum layer, clip, keyframe, scene, asset, and point counts
- maximum JSON body, `.json` project, `.ruw` bundle, per-ZIP-entry, and total extracted asset size
4. Record the selected limits in this document or a small constants module.
Acceptance:
- Existing build still passes before remediation starts.
- Limits are explicit instead of scattered magic numbers.
## Phase 1: Lock Down The Render Server
Addresses:
- High: unauthenticated cross-origin render server
- Part of Medium: unbounded render requests
Files likely touched:
- `server/server.js`
- `src/lib/serverExport.ts`
- `src/components/ExportDialog.vue`
- `docker-compose.yml`
- `README.md`
Tasks:
1. Bind the server to `127.0.0.1` by default:
- Add `HOST=127.0.0.1` default in `server/server.js`.
- Use `app.listen(PORT, HOST, ...)`.
- Change Docker Compose publishing from `6000:6000` to `127.0.0.1:6000:6000`.
- Require an explicit `HOST=0.0.0.0` or compose override for LAN exposure.
2. Replace default `cors()` with an allowlist:
- Add `RENDER_ALLOWED_ORIGINS`, defaulting to the Vite dev origin and localhost app origins that are actually needed.
- Reject browser requests with unexpected `Origin` values.
- Keep non-browser/local CLI requests possible when they have no `Origin` and pass auth.
3. Add authentication:
- Add `RENDER_TOKEN`.
- Require `Authorization: Bearer <token>` or `X-RUWIO-Render-Token` for `POST /render`.
- Because `EventSource` cannot send custom headers, have `POST /render` return a random per-job access token, then require that token for `/render/:id/events`, `/render/:id/file`, and `DELETE /render/:id`.
- Add render-server settings in the client if the token must be user-configurable.
4. Add basic abuse controls:
- Lower `express.json` from `200mb` to a documented default, with an environment override.
- Reject requests with a too-large `Content-Length` before JSON parsing.
- Add per-IP request rate limits for `/render`.
- Add `MAX_ACTIVE_JOBS` and `MAX_QUEUED_JOBS`; return `429` or `503` when full.
- Add a per-job timeout that aborts Puppeteer and `ffmpeg`.
5. Improve job cleanup:
- Delete failed and aborted job directories.
- Remove completed job metadata after download TTL expires.
- Ensure `ffmpeg` is killed when Chromium/page work fails.
Acceptance:
- A default server only listens on localhost.
- Cross-origin requests from an unlisted origin fail.
- Requests without the configured token fail.
- A completed job can still be started, watched, downloaded, and deleted by the RUWIO client.
- Concurrent jobs, over-large bodies, and over-long renders are rejected with clear errors.
## Phase 2: Harden Chromium And The Container
Addresses:
- High: Chromium renders untrusted data with sandbox disabled
Files likely touched:
- `server/server.js`
- `server/Dockerfile`
- `docker-compose.yml`
- `README.md`
Tasks:
1. Run the container as a non-root user:
- Create an app user/group in `server/Dockerfile`.
- Ensure `/app` and temporary render directories are writable by that user.
- Set `USER` before `CMD`.
2. Remove unconditional sandbox-disabling Chromium flags:
- Try launching Chromium without `--no-sandbox` and `--disable-setuid-sandbox`.
- Keep an explicit `CHROME_NO_SANDBOX=1` escape hatch only if needed for a known deployment environment.
- Log a loud warning when the escape hatch is used.
3. Add process and container limits:
- Configure Docker Compose memory/CPU/pids limits where supported.
- Add Node-side child-process kill paths for abort, timeout, and client disconnect.
- Consider using a dedicated worker module/process so Express request handling and rendering are isolated.
4. Limit network reachability from render jobs:
- Prefer rendering local/data assets only.
- If external URLs must remain supported, document the SSRF/privacy tradeoff and add an allowlist or opt-in later.
Acceptance:
- The Docker image runs as a non-root user.
- The normal render path works without `--no-sandbox`, or the documented fallback is explicit and visible.
- Aborted/timed-out jobs leave no running Chromium or `ffmpeg` process.
## Phase 3: Add Shared Project Validation And Resource Bounds
Addresses:
- Medium: project and bundle imports lack deep validation and bounds
- Part of High/Medium: unbounded render workload
Files likely touched:
- `src/types/scene.ts`
- new `src/lib/projectLimits.ts`
- new `src/lib/projectValidation.ts`
- `src/lib/projectFile.ts`
- `src/lib/ruwFile.ts`
- `src/lib/zipUtil.ts`
- `server/server.js`
- possibly `server/validation.js` or a shared package if server reuse becomes necessary
Tasks:
1. Add centralized limits:
- Keep numeric bounds in one module.
- Use browser-safe limits for imports and stricter server-safe limits for rendering if needed.
2. Add schema validation at import boundaries:
- Validate `Project`, `Scene`, `Asset`, `Layer`, `Keyframe`, clips, actions, background media, and canvas data.
- Reject non-finite numbers, negative dimensions, invalid colors where practical, oversized strings, and unknown critical shapes.
- Normalize optional arrays and legacy fields into safe defaults.
- Return friendly `ProjectFileError` messages rather than raw parser errors.
3. Add render-request validation:
- Validate `scene` and `options` before creating a job.
- Clamp or reject `fps`, `duration`, `crf`, `preset`, canvas size, and total frame count.
- Compute `totalFrames` only after validation.
4. Harden ZIP parsing:
- Add caps for entry count, archive byte length, central directory range, name length, per-entry size, and total extracted size.
- Check every local/central directory offset and data range before creating `DataView` or `subarray`.
- Reject path traversal and absolute paths even though extraction is in-memory.
- Preserve STORE-only behavior.
5. Add import tests:
- Valid legacy `.json` and `.ruw` still load.
- Malformed JSON reports a friendly error.
- Oversized canvas, extreme fps/duration/keyframes/assets, malformed ZIP offsets, too many entries, and missing assets are rejected.
Acceptance:
- Project import returns a safe internal `Project` shape.
- `.ruw` parsing fails early for oversized or malformed archives.
- Render server rejects pathological render requests before launching Chromium.
- Existing normal projects still open and export.
## Phase 4: Make Server Dependencies Reproducible
Addresses:
- Medium: render server dependency installs are not reproducible
Files likely touched:
- `server/package-lock.json`
- `server/Dockerfile`
- root CI config when one exists
- `README.md`
Tasks:
1. Generate and commit `server/package-lock.json`.
2. Replace `npm install --omit=dev` with `npm ci --omit=dev` in `server/Dockerfile`.
3. Add server audit/documentation:
- `npm audit --prefix server --omit=dev`
- Add the command to local release checks or CI.
4. Rebuild the server image from a clean checkout to verify the lockfile path.
Acceptance:
- `npm audit --prefix server --omit=dev` can run.
- Docker builds use the committed lockfile.
- Repeated builds install identical dependency versions.
## Phase 5: Remove Devtools From Production Bundles
Addresses:
- Medium: development test overlay ships in production
Files likely touched:
- `src/App.vue`
- `src/devtools/TestOverlay.vue`
- `src/devtools/tests.ts`
Tasks:
1. Replace the static `TestOverlay` import with a development-only dynamic import:
- Use `defineAsyncComponent` only when `import.meta.env.DEV` is true.
- Mount the overlay only in development.
2. Guard menu/command paths that open the test runner:
- Keep Electron menu gating already present.
- Ensure renderer-side command handling ignores `test-runner` in production.
3. Verify bundle output:
- Run `npm run build`.
- Confirm production chunks do not include `src/devtools/tests.ts` or the test overlay.
Acceptance:
- Production build output excludes devtools tests.
- Development build still opens the test overlay.
## Phase 6: Tighten Electron Navigation And External Links
Addresses:
- Low: Electron navigation and external link policy could be stricter
Files likely touched:
- `electron/main.cts`
Tasks:
1. Replace string-prefix URL checks with parsed `URL` checks.
2. Define allowed renderer origins:
- Development: exactly the configured Vite origin.
- Production: the packaged `file://` app entry and same-directory app assets.
3. Restrict external opening:
- Prefer `https:` only.
- Add an explicit allowlist for help/about domains if external links are needed.
- Deny `file:`, custom protocols, `javascript:`, and unexpected `http:`.
4. Add small unit or smoke tests if Electron test infrastructure exists; otherwise document manual packaged-app checks.
Acceptance:
- App navigation works in dev and packaged builds.
- Unexpected app navigation is prevented.
- External links are opened only when they match the policy.
## Phase 7: Add Content Security Policy
Addresses:
- Low: no Content Security Policy is defined
Files likely touched:
- `index.html`
- possibly Vite config if separate dev/prod CSP handling is needed
- generated export code only if app-shell CSP breaks previews
Tasks:
1. Add an app-shell CSP meta tag.
2. Start with a policy that supports current app behavior:
- scripts from self
- images/media/fonts from self plus `data:`/`blob:` where required for user assets
- connections to self plus configured render server during development/export
- no object/embed
3. Verify the Electron renderer and browser app do not log CSP violations for normal workflows.
4. Keep generated HTML exports separate if they need a looser asset policy; do not silently weaken the app shell for exports.
Acceptance:
- App loads in browser dev, production preview, and Electron.
- Common import/export/preview workflows work without unexpected CSP violations.
- CSP blocks inline or remote script execution in the app shell.
## Suggested Pull Request Order
1. Render server exposure and authentication.
2. Render server job limits, body limits, and cleanup.
3. Container non-root user and Chromium sandbox work.
4. Shared limits plus render-request validation.
5. Project and `.ruw` import validation.
6. Server lockfile and Docker `npm ci`.
7. Production devtools gating.
8. Electron URL policy.
9. CSP.
## Final Verification Checklist
- `npm run build`
- `npm audit --omit=dev`
- `npm audit --prefix server --omit=dev`
- Render server rejects unauthenticated `/render`.
- Render server rejects disallowed `Origin`.
- Render server rejects oversized request bodies and excessive frame counts.
- Normal MP4 render still completes through the app.
- Malformed `.json` and `.ruw` files fail with friendly errors.
- Production bundle does not contain `src/devtools/tests.ts`.
- Packaged Electron app opens only expected app URLs and approved external links.
@@ -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 `04_electron-rendering-integration-plan.md` for ongoing
work. It is self-contained: you do not need the original plan to continue from
here. It records what is already implemented, what was found during review, and
the concrete remaining TODOs in priority order.
## 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.
+356
View File
@@ -0,0 +1,356 @@
# Electron Rendering — Fix Plan (Second-Review Findings #7–#13)
Date: 2026-06-06
This document is a follow-on to `05_electron-rendering-integration-plan_2.md`.
That plan tracked findings **#1–#6** (all resolved). A second review of the
uncommitted desktop-render changes surfaced a new set of issues, numbered
**#7–#13** here to avoid collision. Two of them (#7, #8) are user-facing data
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 `05_electron-rendering-integration-plan_2.md`:
- correct the "Partial output file is removed on failure/cancel" note (#7),
- and `06_electron-rendering-manual-checks.md` with the new verify scripts.
@@ -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 (`06_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 `06_electron-rendering-manual-checks.md` to point at the new automated checks
and list what still needs a HiDPI/macOS box (#10, D10).
### Proposed `package.json` scripts (final shape)
```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
`06_electron-rendering-manual-checks.md` for true native behaviour and macOS roles (D10).
- The offscreen-rendering rewrite (deferred per the fix-plan); if it lands later,
the §4 capture matrix is exactly the harness that re-validates it.
+293
View File
@@ -0,0 +1,293 @@
# Export Environment Copy Plan
Date: 2026-06-07
## Goal
Make the export dialog copy describe what actually happens in each runtime:
- **Browser/PWA:** MP4 uses the render server; WebM records through browser
`MediaRecorder`.
- **Electron desktop:** MP4 uses the desktop render bridge and bundled ffmpeg;
WebM still records through the Chromium `MediaRecorder` runtime inside
Electron.
The current labels are accurate for the browser build, but misleading inside
Electron:
- `SERVER - FRAME-ACCURATE` on MP4, even when Electron is rendering locally.
- `BROWSER - REALTIME` on WebM, even when the app is running as Electron.
## Current Code
Primary files:
- `src/components/ExportDialog.vue`
- `src/i18n/messages.ts`
- `src/lib/desktopRenderExport.ts`
- `src/types/desktop.d.ts`
Runtime detection already exists as an exported helper in
`desktopRenderExport.ts`:
```ts
export function hasDesktopRender(): boolean {
return !!window.ruwioDesktop?.renderMp4
}
```
This is the right signal for MP4 copy because `ExportDialog.vue` routes MP4
through `renderMp4OnDesktop()` when `hasDesktopRender()` returns true, otherwise
it calls `downloadFromServer()`. The MP4 copy should reuse this exact function
rather than reimplementing the predicate, so the text can never drift from the
route (see Implementation Plan step 1).
`window.ruwioDesktop` is injected by the Electron preload before any app module
loads and never changes during a session, so a runtime check is stable at
module-init time — no reactivity is required to read it correctly.
## Proposed Copy Matrix
### MP4
Browser/PWA:
- Badge: `Server · Frame-by-frame`
- Description: `H.264 export via render server`
- Progress: `Rendering MP4 with render server...`
Electron:
- Badge: `Desktop · Frame-by-frame`
- Description: `H.264 export via bundled ffmpeg`
- Progress: `Rendering MP4 in desktop app...`
Rationale: both MP4 paths render deterministic frames through `server/render.html`.
The key difference is where rendering happens: HTTP render server vs Electron
desktop worker plus ffmpeg.
Avoid `Frame-accurate` in the short badge. It sounds like a stronger quality
claim than the dialog needs, and it obscures the real user-facing distinction:
server vs desktop. The help text can explain frame-by-frame rendering if needed.
Badge style: store badges in title case with a `·` middot separator, matching
the existing sibling badges (`gifBadge: 'Browser · Frame-by-frame'`,
`apngBadge: 'Browser · Lossless'`). The `.badge` CSS already applies
`text-transform: uppercase`, so do **not** write the strings in all-caps or
switch to a `-` hyphen separator — that would render MP4/WebM inconsistently
next to GIF/APNG in the same column.
### WebM
Browser/PWA:
- Badge: `Browser · Realtime`
- Description: `Recorded in real time with MediaRecorder`
- Progress: `Rendering WebM in browser realtime...`
Electron:
- Badge: `Chromium · Realtime`
- Description: `Recorded in real time by Electron's Chromium runtime`
- Progress: `Rendering WebM in Chromium realtime...`
Rationale: the implementation does not become a desktop ffmpeg pipeline in
Electron. It still uses `MediaRecorder` from the renderer process. Calling that
`browser` in the desktop app is imprecise, but `Chromium` is technically true
and short enough for the badge.
## Implementation Plan
1. Add environment booleans in `ExportDialog.vue`.
```ts
import { hasDesktopRender } from '@/lib/desktopRenderExport'
const isDesktop = computed(() => !!window.ruwioDesktop)
const hasDesktopMp4 = computed(() => hasDesktopRender())
```
Use `hasDesktopMp4` for MP4 text. It wraps `hasDesktopRender()` — the *same*
function the MP4 route already calls in `exportMp4Server()` — so the copy
decision and the actual route share one predicate and can never diverge. Do
not reimplement `!!window.ruwioDesktop?.renderMp4` inline.
Use `isDesktop` for WebM text because WebM is still available through renderer
Chromium even if MP4 desktop rendering is unavailable; there is no existing
helper for that distinction, so a local boolean is fine.
2. Rename `exportMp4Server()` to `exportMp4()`.
The current name is now misleading because the function already branches:
- Desktop: `renderMp4OnDesktop(scene, ...)`
- Browser/server: `downloadFromServer(scene, ...)`
3. Replace static MP4 bindings.
Current:
```vue
{{ t('export.mp4Badge') }}
{{ t('export.mp4Desc') }}
```
Proposed:
```vue
{{ t(hasDesktopMp4 ? 'export.mp4DesktopBadge' : 'export.mp4ServerBadge') }}
{{ t(hasDesktopMp4 ? 'export.mp4DesktopDesc' : 'export.mp4ServerDesc') }}
```
4. Replace static WebM bindings.
Current:
```vue
{{ t('export.webmBadge') }}
{{ videoSupported ? t('export.webmDesc') : t('export.webmNotSupported') }}
```
Proposed:
```vue
{{ t(isDesktop ? 'export.webmDesktopBadge' : 'export.webmBrowserBadge') }}
{{
videoSupported
? t(isDesktop ? 'export.webmDesktopDesc' : 'export.webmBrowserDesc')
: t('export.webmNotSupported')
}}
```
5. Split progress labels.
MP4 currently uses:
```ts
label: t('export.labelMp4')
```
Proposed:
```ts
label: t(hasDesktopMp4.value ? 'export.labelMp4Desktop' : 'export.labelMp4Server')
```
WebM currently uses:
```ts
label: t('export.labelWebm')
```
Proposed:
```ts
label: t(isDesktop.value ? 'export.labelWebmDesktop' : 'export.labelWebmBrowser')
```
6. Update `src/i18n/messages.ts` in both locales.
English keys to add:
```ts
mp4ServerBadge: 'Server · Frame-by-frame',
mp4ServerDesc: 'H.264 export via render server',
mp4DesktopBadge: 'Desktop · Frame-by-frame',
mp4DesktopDesc: 'H.264 export via bundled ffmpeg',
webmBrowserBadge: 'Browser · Realtime',
webmBrowserDesc: 'Recorded in real time with MediaRecorder',
webmDesktopBadge: 'Chromium · Realtime',
webmDesktopDesc: "Recorded in real time by Electron's Chromium runtime",
labelMp4Server: 'Rendering MP4 with render server...',
labelMp4Desktop: 'Rendering MP4 in desktop app...',
labelWebmBrowser: 'Rendering WebM in browser realtime...',
labelWebmDesktop: 'Rendering WebM in Chromium realtime...',
```
Badges stay title case with a `·` separator; the `.badge` CSS uppercases
them at render time (see the MP4 copy-matrix note above). German keys should
mirror the meaning rather than preserve the old wording.
7. Fix the export-button help string `exportHelp` in `messages.ts`.
This is separate from the help dialog and is easy to miss. Both locales
currently hardcode the misleading claim:
- `messages.ts:43` (en): "...MP4 needs the render server; WebM records in
real time in the browser."
- `messages.ts:1153` (de): "...MP4 braucht den Render-Server. WebM wird
direkt im Browser in Echtzeit aufgenommen."
Add runtime-branched variants (e.g. `exportHelpServer` / `exportHelpDesktop`)
and select with the same `isDesktop` signal wherever `t('...exportHelp')` is
consumed. This is exactly the "globally visible text saying MP4 always needs
the render server" that must not be left in place.
8. Update help-dialog format text in `messages.ts`, and branch it now.
The strings live at `help.content.export.formats.mp4` / `.webm`
(`messages.ts:1081/1083` en, `:1923/1925` de) and `exportHelp` (handled in
step 7). Browser help should describe:
- MP4 as render-server frame-by-frame export.
- WebM as browser `MediaRecorder` realtime recording.
Electron help should describe:
- MP4 as bundled-ffmpeg desktop frame-by-frame export.
- WebM as Chromium `MediaRecorder` realtime recording.
Wiring the branch is cheaper than the old draft assumed. `HelpDialog.vue`
defines `CHAPTERS: Chapter[]` as a module-level `const` with static
`bodyKey` strings, but `window.ruwioDesktop` is set at preload before that
module loads and never changes, so a plain ternary on the `bodyKey` resolves
correctly at module-init time without making `CHAPTERS` reactive:
```ts
{ bodyKey: window.ruwioDesktop?.renderMp4
? 'help.content.export.formats.mp4Desktop'
: 'help.content.export.formats.mp4' },
{ bodyKey: window.ruwioDesktop
? 'help.content.export.formats.webmDesktop'
: 'help.content.export.formats.webm' },
```
Add the `*Desktop` keys in both locales and wire the branch in the same
change. Do not defer this and leave the help dialog asserting MP4 always
needs the render server.
## Verification
Browser/Vite:
- Open the export dialog.
- MP4 badge renders `SERVER · FRAME-BY-FRAME` (stored title case, uppercased by CSS).
- MP4 description mentions the render server.
- WebM badge renders `BROWSER · REALTIME`.
- WebM description mentions `MediaRecorder`.
- Export-button help (`exportHelp`) and help dialog describe MP4 as a
render-server export.
Electron:
- Open the export dialog.
- MP4 badge renders `DESKTOP · FRAME-BY-FRAME`.
- MP4 description mentions bundled ffmpeg.
- WebM badge renders `CHROMIUM · REALTIME`.
- WebM description mentions Electron Chromium.
- Export-button help (`exportHelp`) and help dialog describe MP4 as a
bundled-ffmpeg desktop export, not the render server.
Build checks:
```bash
npm run build
npm run desktop:dir
```
Optional automated coverage:
- Add a small in-app devtools test that stubs/removes `window.ruwioDesktop` and
verifies the selected i18n keys or rendered badge text. `src/devtools/headless.ts`
already reads `!!window.ruwioDesktop` (line 19) and is the natural home for it.
- Add an Electron harness assertion that export dialog MP4 copy is desktop copy.
File diff suppressed because it is too large. Load diff
+6 -1
View File
@@ -2,9 +2,14 @@ services:
animatr-render:
build: ./server
ports:
- "6000:6000"
- "127.0.0.1:6000:6000"
environment:
- NODE_ENV=production
- PORT=6000
- HOST=0.0.0.0
- RENDER_TOKEN=${RENDER_TOKEN:?set RENDER_TOKEN before starting the render server}
shm_size: "2gb"
pids_limit: 256
mem_limit: 2g
cpus: 2
restart: unless-stopped
+30
View File
@@ -0,0 +1,30 @@
import { app } from 'electron'
import fs from 'node:fs'
import path from 'node:path'
export function resolveFfmpegPath(): string {
const envPath = process.env.FFMPEG_PATH?.trim()
if (envPath) return envPath
const exe = process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'
const bundledPath = path.join(process.resourcesPath, 'bin', process.platform, exe)
if (isUsableBinary(bundledPath)) return bundledPath
return 'ffmpeg'
}
export function missingFfmpegMessage(): string {
if (app.isPackaged) {
return 'ffmpeg is not bundled with this RUWIO build, or it cannot be executed. Install ffmpeg or set FFMPEG_PATH to export MP4.'
}
return 'ffmpeg was not found. Install ffmpeg or set FFMPEG_PATH to export MP4.'
}
function isUsableBinary(file: string): boolean {
try {
fs.accessSync(file, process.platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK)
return true
} catch {
return false
}
}
+172 -15
View File
@@ -1,20 +1,177 @@
import { app, BrowserWindow, Menu, shell } from 'electron'
import type { BrowserWindow as BrowserWindowInstance, Event, MenuItemConstructorOptions } from 'electron'
import { app, BrowserWindow, dialog, ipcMain, Menu, screen, shell } from 'electron'
import type { BrowserWindow as BrowserWindowInstance, Event, IpcMainInvokeEvent, MenuItemConstructorOptions } from 'electron'
import { randomUUID } from 'node:crypto'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { renderMp4Job } from './renderWorker.cjs'
const { validateRenderRequest } = require('../shared/renderValidation.cjs') as {
validateRenderRequest(scene: unknown, options: unknown): RenderValidation
}
const isDev = !app.isPackaged
const isE2E = process.env.RUWIO_E2E === '1'
const devServerUrl = process.env.VITE_DEV_SERVER_URL ?? 'http://localhost:5200'
const devServerOrigin = new URL(devServerUrl).origin
const appIconPath = isDev
? path.join(process.cwd(), 'build', 'icon.png')
: path.join(process.resourcesPath, 'build', 'icon.png')
let mainWindow: BrowserWindowInstance | null = null
const renderJobs = new Map<string, AbortController>()
// Serialize desktop renders: one active export at a time, mirroring the
// standalone server's MAX_ACTIVE_JOBS=1 (finding #6). The UI also guards this,
// but IPC is directly invokable, so enforce it in main too.
let renderActive = false
interface RenderValidation {
ok: boolean
error?: string
scene?: Parameters<typeof renderMp4Job>[0]['scene']
options?: Parameters<typeof renderMp4Job>[0]['options']
}
interface RenderMp4Payload {
scene?: unknown
fps?: unknown
crf?: unknown
preset?: unknown
suggestedName?: unknown
}
function sendAppCommand(command: string): void {
if (!mainWindow || mainWindow.isDestroyed()) return
mainWindow.webContents.send('ruwio:menu-command', command)
}
declare global {
// Playwright's Electron runner can evaluate in the main process and call this
// env-gated hook without expanding the preload bridge surface.
var __ruwioTestMenuCommand: ((command: string) => void) | undefined
}
function sendRenderProgress(
target: Electron.WebContents,
event: { jobId: string; progress: number; done: boolean; error?: string },
): 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 e2eSavePath = isE2E
? process.env.RUWIO_E2E_SAVE_PATH || path.join(app.getPath('temp'), saveOptions.defaultPath)
: ''
const saveResult = e2eSavePath
? { canceled: false, filePath: e2eSavePath }
: window
? await dialog.showSaveDialog(window, saveOptions)
: await dialog.showSaveDialog(saveOptions)
if (saveResult.canceled || !saveResult.filePath) return null
const jobId = randomUUID()
const controller = new AbortController()
renderJobs.set(jobId, controller)
sendRenderProgress(event.sender, { jobId, progress: 0, done: false })
try {
await renderMp4Job({
jobId,
scene: validation.scene,
options: validation.options,
outputPath: saveResult.filePath,
signal: controller.signal,
onProgress: (progress) => {
sendRenderProgress(event.sender, { jobId, progress, done: false })
},
})
sendRenderProgress(event.sender, { jobId, progress: 1, done: true })
return { path: saveResult.filePath }
} catch (err) {
const message = (err as Error).message || String(err)
sendRenderProgress(event.sender, { jobId, progress: 0, done: true, error: message })
throw err
} finally {
renderJobs.delete(jobId)
}
}
async function handleRenderCancel(_event: IpcMainInvokeEvent, jobId: unknown): Promise<void> {
if (typeof jobId !== 'string' || !jobId) return
renderJobs.get(jobId)?.abort()
}
function parseUrl(raw: string): URL | null {
try {
return new URL(raw)
} catch {
return null
}
}
function isAllowedAppNavigation(raw: string): boolean {
const url = parseUrl(raw)
if (!url) return false
if (isDev) return url.origin === devServerOrigin
if (url.protocol !== 'file:') return false
const appEntry = path.join(app.getAppPath(), 'dist', 'index.html')
return path.normalize(fileURLToPath(url)) === path.normalize(appEntry)
}
function isAllowedExternalUrl(raw: string): boolean {
const url = parseUrl(raw)
return url?.protocol === 'https:'
}
function createMainWindow(): void {
mainWindow = new BrowserWindow({
width: 1280,
@@ -38,15 +195,14 @@ function createMainWindow(): void {
})
mainWindow.webContents.setWindowOpenHandler(({ url }: { url: string }) => {
if (url.startsWith('https://') || url.startsWith('http://')) {
if (isAllowedExternalUrl(url)) {
void shell.openExternal(url)
}
return { action: 'deny' }
})
mainWindow.webContents.on('will-navigate', (event: Event, url: string) => {
const allowedUrl = isDev ? devServerUrl : `file://${path.join(app.getAppPath(), 'dist', 'index.html')}`
if (!url.startsWith(allowedUrl)) {
if (!isAllowedAppNavigation(url)) {
event.preventDefault()
}
})
@@ -196,16 +352,12 @@ function createMenu(): void {
label: 'About RUWIO',
click: () => sendAppCommand('about'),
},
...(isDev
? [
{ type: 'separator' as const },
{
label: 'Open Test Runner',
accelerator: 'CmdOrCtrl+Shift+Alt+T',
click: () => sendAppCommand('test-runner'),
},
]
: []),
{ type: 'separator' },
{
label: 'Open Test Runner',
accelerator: 'CmdOrCtrl+Shift+Alt+T',
click: () => sendAppCommand('test-runner'),
},
],
},
]
@@ -233,6 +385,11 @@ function createMenu(): void {
app.setName('RUWIO')
app.whenReady().then(() => {
ipcMain.handle('ruwio:render-mp4', handleRenderMp4)
ipcMain.handle('ruwio:render-cancel', handleRenderCancel)
if (isE2E) {
globalThis.__ruwioTestMenuCommand = (command: string) => sendAppCommand(command)
}
createMenu()
createMainWindow()
+29
View File
@@ -14,4 +14,33 @@ contextBridge.exposeInMainWorld('ruwioDesktop', {
ipcRenderer.removeListener('ruwio:menu-command', listener)
}
},
renderMp4(options: {
scene: unknown
fps?: number
crf?: number
preset?: string
suggestedName?: string
}) {
return ipcRenderer.invoke('ruwio:render-mp4', options)
},
onRenderProgress(callback: (event: {
jobId: string
progress: number
done: boolean
error?: string
}) => void) {
const listener = (_event: Electron.IpcRendererEvent, payload: {
jobId: string
progress: number
done: boolean
error?: string
}) => callback(payload)
ipcRenderer.on('ruwio:render-progress', listener)
return () => {
ipcRenderer.removeListener('ruwio:render-progress', listener)
}
},
cancelRender(jobId: string) {
return ipcRenderer.invoke('ruwio:render-cancel', jobId)
},
})
+309
View File
@@ -0,0 +1,309 @@
import { BrowserWindow, app } from 'electron'
import type { Rectangle } from 'electron'
import { spawn } from 'node:child_process'
import fs from 'node:fs/promises'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import { missingFfmpegMessage, resolveFfmpegPath } from './ffmpegPath.cjs'
const validation = require('../shared/renderValidation.cjs') as {
playbackRange(scene: RenderScene): { start: number; end: number }
}
// Inactivity watchdog: abort a job if no frame makes progress for this long.
// The timer is re-armed on every onProgress, so steady renders never trip it;
// only a wedged executeJavaScript/capturePage does. Mirrors the standalone
// server's RENDER_JOB_TIMEOUT_MS safety net (finding #3).
function readTimeoutMs(): number {
const raw = Number(process.env.RUWIO_RENDER_TIMEOUT_MS)
return Number.isFinite(raw) && raw >= 1000 ? raw : 2 * 60 * 1000
}
const RENDER_INACTIVITY_TIMEOUT_MS = readTimeoutMs()
// Isolated, in-memory session for the hidden render window so its webRequest
// filter can't affect the main app session (finding #5).
const RENDER_PARTITION = 'ruwio-render'
function abortReason(signal: AbortSignal): Error {
return signal.reason instanceof Error ? signal.reason : new Error('aborted')
}
// Reject as soon as `signal` aborts, otherwise settle with `promise`. Lets a
// timeout/cancel interrupt an await that would otherwise hang (finding #3).
function raceAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
if (signal.aborted) return Promise.reject(abortReason(signal))
return new Promise<T>((resolve, reject) => {
const onAbort = () => reject(abortReason(signal))
signal.addEventListener('abort', onAbort, { once: true })
promise.then(
(value) => {
signal.removeEventListener('abort', onAbort)
resolve(value)
},
(err) => {
signal.removeEventListener('abort', onAbort)
reject(err)
},
)
})
}
export interface RenderScene {
canvas: { width: number; height: number }
duration: number
layers: Array<{ keyframes: Array<{ time: number }> }>
assets?: unknown[]
[key: string]: unknown
}
export interface RenderOptions {
fps: number
duration: number
totalFrames: number
crf: string
preset: string
}
export interface RenderJobInput {
jobId: string
scene: RenderScene
options: RenderOptions
outputPath: string
signal: AbortSignal
onProgress: (progress: number) => void
}
export async function renderMp4Job(input: RenderJobInput): Promise<void> {
const renderWindow = createRenderWindow(input.scene)
const ffmpegPath = resolveFfmpegPath()
let ffmpegErr = ''
let settled = false
let started = false
// Render into a sibling temp file and promote it on success, so a cancelled
// or failed render never truncates a pre-existing file the user chose to
// overwrite (finding #7). Same directory => rename stays on one filesystem.
const finalPath = input.outputPath
const tmpPath = path.join(
path.dirname(finalPath),
`.${path.basename(finalPath)}.${input.jobId}.partial`,
)
// Combined abort: trips on user cancel (input.signal) or the inactivity
// watchdog. Used to kill ffmpeg and to interrupt per-frame awaits.
const jobAbort = new AbortController()
const onExternalAbort = () => jobAbort.abort(new Error('aborted'))
if (input.signal.aborted) onExternalAbort()
else input.signal.addEventListener('abort', onExternalAbort, { once: true })
let watchdog: ReturnType<typeof setTimeout> | undefined
const armWatchdog = () => {
if (watchdog) clearTimeout(watchdog)
watchdog = setTimeout(
() => jobAbort.abort(new Error('render timed out')),
RENDER_INACTIVITY_TIMEOUT_MS,
)
}
const ffmpeg = spawn(ffmpegPath, [
'-y',
'-f', 'image2pipe',
'-framerate', String(input.options.fps),
'-i', '-',
'-c:v', 'libx264',
'-pix_fmt', 'yuv420p',
// libx264 + yuv420p require even dimensions, but validation allows any
// canvas in 1..4096 — an odd width/height would otherwise make ffmpeg exit
// with a confusing "height not divisible by 2" error. Pad up to the next
// even size (≤1px edge column/row) so odd canvases encode (finding #13).
'-vf', 'pad=ceil(iw/2)*2:ceil(ih/2)*2',
'-preset', input.options.preset,
'-crf', input.options.crf,
'-movflags', '+faststart',
// Force the muxer explicitly: the temp filename has no .mp4 extension, and
// the final path may lack one too, so don't rely on extension inference.
'-f', 'mp4',
tmpPath,
])
// If ffmpeg exits early (encode error, disk full, ...), the next stdin write
// emits an async 'error' (EPIPE). Without a listener that becomes an
// uncaughtException and crashes the app; the real failure still surfaces via
// the 'exit'/ffmpegDone path below (finding #8).
ffmpeg.stdin.on('error', () => {
/* noop: surfaced via ffmpegDone */
})
const abort = () => {
try {
ffmpeg.kill('SIGKILL')
} catch {
/* noop */
}
}
jobAbort.signal.addEventListener('abort', abort, { once: true })
const ffmpegReady = new Promise<void>((resolve, reject) => {
ffmpeg.once('spawn', () => resolve())
ffmpeg.once('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'ENOENT') reject(new Error(missingFfmpegMessage()))
else reject(err)
})
})
const ffmpegDone = new Promise<void>((resolve, reject) => {
ffmpeg.stderr.on('data', (d: Buffer) => {
ffmpegErr += d.toString()
})
ffmpeg.on('spawn', () => {
started = true
})
ffmpeg.on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'ENOENT') reject(new Error(missingFfmpegMessage()))
else reject(err)
})
ffmpeg.on('exit', (code) => {
settled = true
if (code === 0) resolve()
else if (jobAbort.signal.aborted) reject(abortReason(jobAbort.signal))
else if (!started) reject(new Error(missingFfmpegMessage()))
else reject(new Error(`ffmpeg exited with code ${code}: ${ffmpegErr.slice(-500)}`))
})
})
// ffmpeg can fail mid-render, so ffmpegDone may reject long before the
// `await ffmpegDone` after the loop attaches a handler. Keep a handler on it
// from creation so that gap can't trip Node's unhandled-rejection process
// termination; real handling still flows through `await ffmpegDone` (#8).
void ffmpegDone.catch(() => {})
const signal = jobAbort.signal
try {
armWatchdog()
await raceAbort(ffmpegReady, signal)
await raceAbort(loadRenderPage(renderWindow), signal)
await raceAbort(
renderWindow.webContents.executeJavaScript(
`window.__animatrInit(${JSON.stringify(input.scene)})`,
true,
),
signal,
)
const { start: rangeIn } = validation.playbackRange(input.scene)
const captureRect: Rectangle = {
x: 0,
y: 0,
width: input.scene.canvas.width,
height: input.scene.canvas.height,
}
for (let i = 0; i < input.options.totalFrames; i += 1) {
if (signal.aborted) throw abortReason(signal)
// ffmpeg already exited (e.g. encode error): stop feeding it so the real
// error surfaces via `await ffmpegDone` below instead of an EPIPE (#8).
if (settled) break
const time = rangeIn + Math.min(i / input.options.fps, input.options.duration)
await raceAbort(
renderWindow.webContents.executeJavaScript(`window.__animatrSeek(${time})`, true),
signal,
)
const frame = await raceAbort(renderWindow.webContents.capturePage(captureRect), signal)
// capturePage returns a NativeImage at the display's device scale factor,
// so on a HiDPI screen the bitmap is N× the requested canvas and the MP4
// comes out at the wrong resolution. Normalize every frame back to the
// canvas dimensions — the standalone server pins deviceScaleFactor:1 for
// the same reason. No-op on scale-factor-1 displays (finding #10).
const frameSize = frame.getSize()
const png = (frameSize.width === captureRect.width && frameSize.height === captureRect.height
? frame
: frame.resize({ width: captureRect.width, height: captureRect.height })
).toPNG()
if (!ffmpeg.stdin.write(png)) {
// Wait for backpressure to clear — but don't hang forever if ffmpeg has
// already died (no 'drain' will ever fire) or the job is aborted. Racing
// ffmpegDone surfaces an early ffmpeg exit here instead of wedging (#8).
await raceAbort(
Promise.race([
new Promise<void>((resolve) => ffmpeg.stdin.once('drain', resolve)),
ffmpegDone,
]),
signal,
)
}
input.onProgress((i + 1) / input.options.totalFrames)
armWatchdog()
}
ffmpeg.stdin.end()
await ffmpegDone
// Render succeeded and tmpPath is complete: promote it into place. Try a
// plain rename first (atomic replace on POSIX); Windows won't overwrite an
// existing destination, so fall back to remove-then-rename there (#7).
try {
await fs.rename(tmpPath, finalPath)
} catch {
await fs.rm(finalPath, { force: true })
await fs.rename(tmpPath, finalPath)
}
} catch (err) {
if (!settled) abort()
try {
ffmpeg.stdin.destroy()
} catch {
/* noop */
}
// Only ever remove our temp file, never the user's chosen target (#7).
await fs.rm(tmpPath, { force: true }).catch(() => {})
await ffmpegDone.catch(() => {})
throw err
} finally {
if (watchdog) clearTimeout(watchdog)
input.signal.removeEventListener('abort', onExternalAbort)
jobAbort.signal.removeEventListener('abort', abort)
if (!renderWindow.isDestroyed()) renderWindow.close()
}
}
function createRenderWindow(scene: RenderScene): BrowserWindow {
const win = new BrowserWindow({
width: scene.canvas.width,
height: scene.canvas.height,
show: false,
frame: false,
// Opaque white background so transparent regions (e.g. backgroundVideo
// scenes) flatten onto white under yuv420p — matching the standalone
// server's Puppeteer screenshot (omitBackground:false), not black (#4).
transparent: false,
backgroundColor: '#ffffff',
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
partition: RENDER_PARTITION,
// The window is never shown, so Chromium would throttle/pause rAF and the
// per-frame paint settle in __animatrSeek would hit its 1s fallback every
// frame (~1fps renders). Keep timers/rAF running while hidden (finding #12).
backgroundThrottling: false,
},
})
// Defense-in-depth: the render page loads only local (file:/data:/blob:)
// assets, so block any networked request the captured project might carry
// (finding #5). The url filter only matches http/https, leaving local
// schemes untouched. Scoped to this window's isolated session partition.
win.webContents.session.webRequest.onBeforeRequest(
{ urls: ['*://*/*'] },
(_details, callback) => callback({ cancel: true }),
)
win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
win.webContents.on('will-navigate', (event) => event.preventDefault())
return win
}
async function loadRenderPage(win: BrowserWindow): Promise<void> {
const renderHtml = app.isPackaged
? path.join(process.resourcesPath, 'server', 'render.html')
: path.join(process.cwd(), 'server', 'render.html')
await win.loadURL(pathToFileURL(renderHtml).toString())
}
+4
View File
@@ -7,6 +7,10 @@
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#17171b" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' http://localhost:6000 http://127.0.0.1:6000 ws://localhost:5200 ws://127.0.0.1:5200; object-src 'none'; base-uri 'self'; frame-ancestors 'none'"
/>
<title>RUWIO</title>
</head>
<body>
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ruwio",
"version": "1.2.13",
"version": "1.2.15",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ruwio",
"version": "1.2.13",
"version": "1.2.15",
"dependencies": {
"@phosphor-icons/vue": "^2.2.1",
"gifenc": "^1.0.3",
+24 -4
View File
@@ -1,7 +1,7 @@
{
"name": "ruwio",
"private": true,
"version": "1.2.14",
"version": "1.3.2",
"type": "module",
"main": "dist-electron/main.cjs",
"scripts": {
@@ -9,11 +9,18 @@
"build": "vue-tsc --noEmit && vite build",
"desktop:dev": "concurrently -k \"vite\" \"wait-on http://localhost:5200 && npm run desktop:start\"",
"desktop:start": "tsc -p tsconfig.electron.json && electron .",
"desktop:build": "npm run build && tsc -p tsconfig.electron.json && electron-builder",
"desktop:pack": "npm run build && tsc -p tsconfig.electron.json && electron-builder --dir",
"desktop:build": "npm run build && tsc -p tsconfig.electron.json && npm run stage:ffmpeg -- --optional && electron-builder",
"desktop:pack": "npm run build && tsc -p tsconfig.electron.json && npm run stage:ffmpeg -- --optional && electron-builder",
"desktop:dir": "npm run build && tsc -p tsconfig.electron.json && npm run stage:ffmpeg -- --optional && electron-builder --dir",
"preview": "vite preview",
"type-check": "vue-tsc --noEmit",
"pack": "bash scripts/pack.sh"
"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 && node scripts/run-electron-harness.cjs scripts/verify-capture-fidelity.cjs",
"stage:ffmpeg": "node scripts/stage-ffmpeg.cjs"
},
"dependencies": {
"@phosphor-icons/vue": "^2.2.1",
@@ -39,18 +46,31 @@
"appId": "app.ruwio.desktop",
"productName": "RUWIO",
"icon": "icon.png",
"beforeBuild": "scripts/electron-before-build.cjs",
"directories": {
"output": "release"
},
"files": [
"dist/**/*",
"dist-electron/**/*",
"shared/renderValidation.cjs",
"package.json"
],
"extraResources": [
{
"from": "build/icon.png",
"to": "build/icon.png"
},
{
"from": "server/render.html",
"to": "server/render.html"
},
{
"from": "bin",
"to": "bin",
"filter": [
"**/*"
]
}
],
"linux": {
+3
View File
@@ -0,0 +1,3 @@
module.exports = async function beforeBuild() {
return false
}
+21
View File
@@ -0,0 +1,21 @@
const { spawnSync } = require('node:child_process')
const electron = require('electron')
const args = process.argv.slice(2)
if (!args.length) {
console.error('usage: node scripts/run-electron-harness.cjs <script> [args...]')
process.exit(2)
}
const env = { ...process.env }
delete env.ELECTRON_RUN_AS_NODE
env.ELECTRON_DISABLE_SANDBOX = '1'
const res = spawnSync(electron, ['--no-sandbox', ...args], {
stdio: 'inherit',
env,
argv0: electron,
})
process.exit(res.status ?? 1)
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env node
const { copyFileSync, chmodSync, existsSync, mkdirSync, statSync } = require('node:fs')
const path = require('node:path')
const { spawnSync } = require('node:child_process')
const args = process.argv.slice(2)
const optional = args.includes('--optional')
const sourceIndex = args.indexOf('--source')
const explicitSource = sourceIndex >= 0 ? args[sourceIndex + 1] : ''
const root = path.resolve(__dirname, '..')
const exe = process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'
const targetDir = path.join(root, 'bin', process.platform)
const target = path.join(targetDir, exe)
try {
mkdirSync(targetDir, { recursive: true })
if (existsSync(target) && statSync(target).isFile()) {
chmodIfNeeded(target)
console.log(`ffmpeg already staged at ${path.relative(root, target)}`)
process.exit(0)
}
const source = explicitSource || process.env.FFMPEG_PATH || findOnPath()
if (!source) {
finishWithoutBinary('ffmpeg was not found. Set FFMPEG_PATH or pass --source /path/to/ffmpeg.')
}
const resolvedSource = path.resolve(source)
if (!existsSync(resolvedSource) || !statSync(resolvedSource).isFile()) {
finishWithoutBinary(`ffmpeg source is not a file: ${source}`)
}
copyFileSync(resolvedSource, target)
chmodIfNeeded(target)
console.log(`staged ffmpeg from ${resolvedSource} to ${path.relative(root, target)}`)
} catch (err) {
if (optional) {
console.warn(`warning: could not stage ffmpeg: ${err.message}`)
process.exit(0)
}
throw err
}
function findOnPath() {
const command = process.platform === 'win32' ? 'where' : 'which'
const result = spawnSync(command, [exe], { encoding: 'utf8' })
if (result.status !== 0) return ''
return result.stdout.split(/\r?\n/).map((line) => line.trim()).find(Boolean) || ''
}
function chmodIfNeeded(file) {
if (process.platform !== 'win32') chmodSync(file, 0o755)
}
function finishWithoutBinary(message) {
if (optional) {
console.warn(`warning: ${message}`)
process.exit(0)
}
console.error(message)
process.exit(1)
}
+109
View File
@@ -0,0 +1,109 @@
// Phase B verification of the render-window network block (finding #5):
// - a local data: image asset still loads and draws (block must not break it)
// - an http: image asset is blocked and skipped without hanging the render
//
// Uses the REAL worker window (createRenderWindow) by loading render.html in a
// window built the same way, so it exercises the actual webRequest filter.
//
// Run: node_modules/.bin/electron scripts/verify-assets.cjs
const { app, BrowserWindow } = require('electron')
const path = require('node:path')
const { pathToFileURL } = require('node:url')
const RENDER_PARTITION = 'ruwio-render'
// 40x40 green square as an inline SVG data URL (no network needed).
const greenSvg =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40'%3E%3Crect width='40' height='40' fill='%2300ff00'/%3E%3C/svg%3E"
function makeScene(src) {
return {
canvas: { width: 160, height: 120, background: '#202030' },
duration: 1,
assets: [{ id: 'img1', kind: 'image', width: 40, height: 40, src }],
layers: [
{
id: 'l1',
assetId: 'img1',
visible: true,
base: { x: 80, y: 60, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
keyframes: [],
},
],
}
}
function createRenderWindow(scene) {
const win = new BrowserWindow({
width: scene.canvas.width,
height: scene.canvas.height,
show: false,
frame: false,
transparent: false,
backgroundColor: '#ffffff',
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
partition: RENDER_PARTITION,
},
})
win.webContents.session.webRequest.onBeforeRequest({ urls: ['*://*/*'] }, (_d, cb) =>
cb({ cancel: true }),
)
win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
win.webContents.on('will-navigate', (e) => e.preventDefault())
return win
}
function countGreen(image) {
const bmp = image.getBitmap() // BGRA
let green = 0
for (let i = 0; i < bmp.length; i += 4) {
if (bmp[i] < 120 && bmp[i + 1] > 150 && bmp[i + 2] < 120) green++
}
return green
}
async function renderOnce(src) {
const scene = makeScene(src)
const win = createRenderWindow(scene)
const renderHtml = path.join(process.cwd(), 'server', 'render.html')
await win.loadURL(pathToFileURL(renderHtml).toString())
// Bound by Promise.race so a network-blocked asset that mis-handles can't hang.
const initDone = win.webContents.executeJavaScript(
`window.__animatrInit(${JSON.stringify(scene)})`,
true,
)
const timedOut = await Promise.race([
initDone.then(() => false),
new Promise((r) => setTimeout(() => r(true), 5000)),
])
await win.webContents.executeJavaScript('window.__animatrSeek(0)', true)
const img = await win.webContents.capturePage({ x: 0, y: 0, width: 160, height: 120 })
const green = countGreen(img)
if (!win.isDestroyed()) win.close()
return { green, timedOut }
}
app.on('window-all-closed', () => {})
app.whenReady().then(async () => {
const local = await renderOnce(greenSvg)
const remote = await renderOnce('http://10.255.255.1/never.png') // unroutable; must not hang
const notes = []
let pass = true
// ~1600 green px expected for a 40x40 image; allow for AA.
if (local.green < 1000) { pass = false; notes.push(`local data: image did not draw (green=${local.green})`) }
if (local.timedOut) { pass = false; notes.push('local image init timed out') }
if (remote.green > 50) { pass = false; notes.push(`remote image drew despite block (green=${remote.green})`) }
if (remote.timedOut) { pass = false; notes.push('remote (blocked) image init hung past 5s') }
console.log(`local: green=${local.green} timedOut=${local.timedOut}`)
console.log(`remote(blocked): green=${remote.green} timedOut=${remote.timedOut}`)
console.log(notes.length ? 'NOTES:\n ' + notes.join('\n ') : 'NOTES: none')
console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL')
app.exit(pass ? 0 : 1)
})
+89
View File
@@ -0,0 +1,89 @@
// Phase B verification of transparent-region flattening (finding #4): a scene
// with backgroundVideo set makes render.html skip the background fill, leaving
// transparent areas. With the opaque-white render window those areas must
// capture as white (~255), matching the server's omitBackground:false, not
// black. Builds the window exactly like renderWorker.createRenderWindow.
//
// Run: node_modules/.bin/electron scripts/verify-bg.cjs
const { app, BrowserWindow } = require('electron')
const path = require('node:path')
const { pathToFileURL } = require('node:url')
const RENDER_PARTITION = 'ruwio-render'
// backgroundVideo present -> render.html does NOT fill canvas.background.
const scene = {
canvas: { width: 160, height: 120, background: '#202030' },
backgroundVideo: { src: 'data:video/mp4,', duration: 1 },
duration: 1,
assets: [{ id: 'a1', kind: 'rect', width: 20, height: 20, fill: '#ff0000' }],
layers: [
{
id: 'l1',
assetId: 'a1',
visible: true,
base: { x: 30, y: 30, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
keyframes: [],
},
],
}
function createRenderWindow() {
const win = new BrowserWindow({
width: scene.canvas.width,
height: scene.canvas.height,
show: false,
frame: false,
transparent: false,
backgroundColor: '#ffffff',
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
partition: RENDER_PARTITION,
},
})
win.webContents.session.webRequest.onBeforeRequest({ urls: ['*://*/*'] }, (_d, cb) =>
cb({ cancel: true }),
)
return win
}
app.on('window-all-closed', () => {})
app.whenReady().then(async () => {
const win = createRenderWindow()
const renderHtml = path.join(process.cwd(), 'server', 'render.html')
await win.loadURL(pathToFileURL(renderHtml).toString())
await win.webContents.executeJavaScript(`window.__animatrInit(${JSON.stringify(scene)})`, true)
await win.webContents.executeJavaScript('window.__animatrSeek(0)', true)
const img = await win.webContents.capturePage({ x: 0, y: 0, width: 160, height: 120 })
const bmp = img.getBitmap() // BGRA
// Sample a corner pixel (far from the red rect at 30,30): index 0 == (0,0).
const corner = { b: bmp[0], g: bmp[1], r: bmp[2], a: bmp[3] }
let white = 0
let black = 0
for (let i = 0; i < bmp.length; i += 4) {
if (bmp[i] > 240 && bmp[i + 1] > 240 && bmp[i + 2] > 240) white++
if (bmp[i] < 15 && bmp[i + 1] < 15 && bmp[i + 2] < 15) black++
}
const notes = []
let pass = true
if (!(corner.r > 240 && corner.g > 240 && corner.b > 240)) {
pass = false
notes.push(`corner is not white: rgb(${corner.r},${corner.g},${corner.b})`)
}
if (black > 100) {
pass = false
notes.push(`${black} near-black px present (transparent flattened to black?)`)
}
console.log(`corner=rgb(${corner.r},${corner.g},${corner.b}) whitePx=${white} blackPx=${black}`)
console.log(notes.length ? 'NOTES:\n ' + notes.join('\n ') : 'NOTES: none')
console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL')
if (!win.isDestroyed()) win.close()
app.exit(pass ? 0 : 1)
})
+100
View File
@@ -0,0 +1,100 @@
// Phase A cancel verification: abort renderMp4Job mid-export, confirm it
// rejects, leaves no partial output file, and the ffmpeg child is gone.
//
// Run: npx tsc -p tsconfig.electron.json && node_modules/.bin/electron scripts/verify-cancel.cjs
const { app } = require('electron')
const path = require('node:path')
const os = require('node:os')
const fs = require('node:fs')
const { renderMp4Job } = require('../dist-electron/renderWorker.cjs')
const fps = 24
const duration = 6 // long enough that we can cancel before it finishes
const scene = {
canvas: { width: 480, height: 360, background: '#202030' },
duration,
assets: [{ id: 'a1', kind: 'rect', width: 60, height: 60, fill: '#40c0ff' }],
layers: [
{
id: 'l1',
assetId: 'a1',
visible: true,
base: { x: 40, y: 180, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
keyframes: [
{ time: 0, props: { x: 40 } },
{ time: 6, props: { x: 420 } },
],
},
],
}
const options = { fps, duration, totalFrames: fps * duration + 1, crf: '20', preset: 'ultrafast' }
async function main() {
const outPath = path.join(os.tmpdir(), 'ruwio-verify-cancel.mp4')
fs.rmSync(outPath, { force: true })
const controller = new AbortController()
let progressAtCancel = null
const job = renderMp4Job({
jobId: 'cancel',
scene,
options,
outputPath: outPath,
signal: controller.signal,
onProgress: (p) => {
// Cancel once we've made real progress but are nowhere near done.
if (progressAtCancel === null && p > 0.1) {
progressAtCancel = p
controller.abort()
}
},
})
let rejected = false
let message = ''
try {
await job
} catch (err) {
rejected = true
message = err.message
}
// Give the OS a moment to reap the killed ffmpeg + finish fs.rm cleanup.
await new Promise((r) => setTimeout(r, 300))
const notes = []
let pass = true
if (progressAtCancel === null) {
pass = false
notes.push('never reached a progress point to cancel at (job too fast)')
}
if (!rejected) {
pass = false
notes.push('job resolved instead of rejecting after abort')
} else if (!/abort/i.test(message)) {
notes.push(`rejected, but message was: ${message}`)
}
if (fs.existsSync(outPath)) {
pass = false
notes.push(`partial output left behind: ${outPath} (${fs.statSync(outPath).size}B)`)
}
console.log(`progressAtCancel=${progressAtCancel} rejected=${rejected} message="${message}"`)
console.log(`output exists after cancel: ${fs.existsSync(outPath)}`)
console.log(notes.length ? 'NOTES:\n ' + notes.join('\n ') : 'NOTES: none')
console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL')
app.exit(pass ? 0 : 1)
}
// Suppress Electron's default "quit when all windows close": renderMp4Job
// closes its render window, and the auto-quit would race our result printing.
app.on('window-all-closed', () => {})
app.whenReady().then(() =>
main().catch((err) => {
console.error('HARNESS ERROR:', err)
app.exit(2)
}),
)
+120
View File
@@ -0,0 +1,120 @@
// Compares Electron capturePage output against render.html's own canvas pixels
// for deterministic fixture scenes. This guards the capture step itself:
// wrong scale-factor normalization, stale frames, and clipping show up here.
const { app, BrowserWindow, nativeImage } = require('electron')
const fs = require('node:fs')
const path = require('node:path')
const { pathToFileURL } = require('node:url')
const { diffBgra } = require('../test/lib/pixelDiff.cjs')
const fixtures = [
{
file: path.join(process.cwd(), 'test', 'fixtures', 'scenes', 'capture-basic.json'),
times: [0, 0.5, 1],
},
]
function createRenderWindow(scene) {
return new BrowserWindow({
width: scene.canvas.width,
height: scene.canvas.height,
show: false,
frame: false,
transparent: false,
backgroundColor: '#ffffff',
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
backgroundThrottling: false,
partition: 'ruwio-capture-fidelity',
},
})
}
async function main() {
let pass = true
const notes = []
const renderHtml = path.join(process.cwd(), 'server', 'render.html')
for (const fixture of fixtures) {
const scene = JSON.parse(fs.readFileSync(fixture.file, 'utf8'))
const win = createRenderWindow(scene)
win.webContents.session.webRequest.onBeforeRequest(
{ urls: ['*://*/*'] },
(_details, callback) => callback({ cancel: true }),
)
win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
win.webContents.on('will-navigate', (event) => event.preventDefault())
try {
await win.loadURL(pathToFileURL(renderHtml).toString())
await win.webContents.executeJavaScript(`window.__animatrInit(${JSON.stringify(scene)})`, true)
for (const t of fixture.times) {
await win.webContents.executeJavaScript(`window.__animatrSeek(${t})`, true)
const capture = await win.webContents.capturePage({
x: 0,
y: 0,
width: scene.canvas.width,
height: scene.canvas.height,
})
const canvasDataUrl = await win.webContents.executeJavaScript(
`document.querySelector('canvas').toDataURL('image/png')`,
true,
)
const expected = nativeImage.createFromDataURL(canvasDataUrl)
const captureSize = capture.getSize()
const expectedSize = expected.getSize()
if (captureSize.width !== scene.canvas.width || captureSize.height !== scene.canvas.height) {
pass = false
notes.push(`${path.basename(fixture.file)} t=${t}: capture size ${captureSize.width}x${captureSize.height}`)
continue
}
if (expectedSize.width !== scene.canvas.width || expectedSize.height !== scene.canvas.height) {
pass = false
notes.push(`${path.basename(fixture.file)} t=${t}: canvas size ${expectedSize.width}x${expectedSize.height}`)
continue
}
const diff = diffBgra(capture.getBitmap(), expected.getBitmap(), {
// capturePage composites the hidden window, which introduces
// sub-perceptual AA/rounding noise (~maxDelta 6/255 on shape edges)
// versus the raw canvas toDataURL. Tolerate that; genuine render
// drift (wrong color, missing or stale shape, off-by-one frame)
// produces much larger deltas over a contiguous region and still
// trips the ratio gate. The explicit size checks above catch #10/#11.
perChannelTolerance: 8,
maxMismatchedRatio: 0.02,
})
console.log(
`${path.basename(fixture.file)} t=${t}: mismatch=${diff.mismatchedPixels}/${diff.pixels} ` +
`(${(diff.mismatchedRatio * 100).toFixed(4)}%) maxDelta=${diff.maxDelta} meanDelta=${diff.meanDelta.toFixed(3)}`,
)
if (!diff.ok) {
pass = false
notes.push(
`${path.basename(fixture.file)} t=${t}: diff exceeds tolerance ` +
`${diff.mismatchedPixels}/${diff.pixels}, maxDelta=${diff.maxDelta}`,
)
}
}
} finally {
if (!win.isDestroyed()) win.close()
}
}
console.log(notes.length ? 'NOTES:\n ' + notes.join('\n ') : 'NOTES: none')
console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL')
app.exit(pass ? 0 : 1)
}
app.on('window-all-closed', () => {})
app.commandLine.appendSwitch('disable-gpu')
app.whenReady().then(() =>
main().catch((err) => {
console.error('HARNESS ERROR:', err)
app.exit(2)
}),
)
+138
View File
@@ -0,0 +1,138 @@
// Phase A verification harness (05_electron-rendering-integration-plan_2.md, finding #1/#2).
//
// Reproduces renderWorker.cts's window-creation + capturePage path against a
// scene with one moving + fading layer, then checks that captured frames are
// (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)
}),
)
+44
View File
@@ -0,0 +1,44 @@
// Runs the desktop-render verification harnesses (Phase A/B of
// 05_electron-rendering-integration-plan_2.md) under Electron and aggregates the
// results. Each harness exits 0 on PASS, non-zero on FAIL.
//
// npm run test:render-desktop (compiles electron TS first, then runs this)
//
// 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 electronEnv = { ...process.env }
delete electronEnv.ELECTRON_RUN_AS_NODE
electronEnv.ELECTRON_DISABLE_SANDBOX = '1'
const harnesses = [
['capture', 'verify-capture.cjs', 'hidden-window capturePage produces advancing, non-blank frames (#1/#2)'],
['capture-fidelity', 'verify-capture-fidelity.cjs', 'capturePage matches render.html canvas pixels'],
['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, ['--no-sandbox', path.join(__dirname, file)], {
stdio: ['ignore', 'inherit', 'ignore'], // drop Electron's chatty stderr
env: electronEnv,
})
if (res.status !== 0) {
failures += 1
process.stdout.write(`*** ${name} FAILED (exit ${res.status})\n`)
}
}
process.stdout.write(`\n${failures === 0 ? 'ALL DESKTOP RENDER CHECKS PASSED' : failures + ' CHECK(S) FAILED'}\n`)
process.exit(failures === 0 ? 0 : 1)
+125
View File
@@ -0,0 +1,125 @@
// Phase A end-to-end verification: drives the REAL compiled renderMp4Job
// (dist-electron/renderWorker.cjs) through ffmpeg and ffprobes the result.
//
// Run: npx tsc -p tsconfig.electron.json && node_modules/.bin/electron scripts/verify-export.cjs
const { app } = require('electron')
const path = require('node:path')
const os = require('node:os')
const fs = require('node:fs')
const { execFileSync } = require('node:child_process')
const { renderMp4Job } = require('../dist-electron/renderWorker.cjs')
const fps = 12
const duration = 2
const scene = {
canvas: { width: 320, height: 240, background: '#202030' },
duration,
assets: [{ id: 'a1', kind: 'rect', width: 40, height: 40, fill: '#ff4040' }],
layers: [
{
id: 'l1',
assetId: 'a1',
visible: true,
base: { x: 20, y: 100, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
keyframes: [
{ time: 0, props: { x: 20 } },
{ time: 2, props: { x: 260 } },
],
},
],
}
const options = {
fps,
duration,
totalFrames: fps * duration + 1,
crf: '20',
preset: 'ultrafast',
}
async function main() {
const outPath = path.join(os.tmpdir(), 'ruwio-verify-export.mp4')
fs.rmSync(outPath, { force: true })
const controller = new AbortController()
let lastProgress = 0
const startedAt = Date.now()
await renderMp4Job({
jobId: 'verify',
scene,
options,
outputPath: outPath,
signal: controller.signal,
onProgress: (p) => {
lastProgress = p
},
})
const elapsedMs = Date.now() - startedAt
const msPerFrame = elapsedMs / options.totalFrames
const notes = []
let pass = true
if (!fs.existsSync(outPath)) {
pass = false
notes.push('output file missing')
} else {
const sizeKb = (fs.statSync(outPath).size / 1024).toFixed(1)
const probe = JSON.parse(
execFileSync('ffprobe', [
'-v', 'error',
'-select_streams', 'v:0',
'-show_entries', 'stream=codec_name,width,height,nb_read_frames',
'-count_frames',
'-of', 'json',
outPath,
]).toString(),
)
const s = probe.streams[0]
console.log(`output: ${outPath} (${sizeKb} KB)`)
console.log(`codec=${s.codec_name} ${s.width}x${s.height} frames=${s.nb_read_frames} progress=${lastProgress}`)
console.log(`elapsed=${elapsedMs}ms (${msPerFrame.toFixed(1)}ms/frame)`)
if (s.codec_name !== 'h264') { pass = false; notes.push(`codec ${s.codec_name} != h264`) }
if (s.width !== 320 || s.height !== 240) { pass = false; notes.push(`wrong dims ${s.width}x${s.height}`) }
if (Number(s.nb_read_frames) !== options.totalFrames) {
pass = false
notes.push(`frames ${s.nb_read_frames} != expected ${options.totalFrames}`)
}
if (lastProgress !== 1) { pass = false; notes.push(`final progress ${lastProgress} != 1`) }
const maxMsPerFrame = Number(process.env.RUWIO_VERIFY_MAX_MS_PER_FRAME || 800)
if (msPerFrame > maxMsPerFrame) {
pass = false
notes.push(
`render throughput ${msPerFrame.toFixed(1)}ms/frame exceeds ${maxMsPerFrame}ms/frame ` +
'(hidden render window may be throttled)',
)
}
// Decode first & last frame, confirm the rect moved (motion preserved).
const f0 = path.join(os.tmpdir(), 'ruwio-f0.png')
const f1 = path.join(os.tmpdir(), 'ruwio-flast.png')
execFileSync('ffmpeg', ['-y', '-v', 'error', '-i', outPath, '-vf', 'select=eq(n\\,0)', '-frames:v', '1', f0])
execFileSync('ffmpeg', ['-y', '-v', 'error', '-sseof', '-0.1', '-i', outPath, '-frames:v', '1', f1])
const sz0 = fs.statSync(f0).size
const sz1 = fs.statSync(f1).size
const b0 = fs.readFileSync(f0)
const b1 = fs.readFileSync(f1)
const same = b0.length === b1.length && b0.equals(b1)
console.log(`first frame ${sz0}B, last frame ${sz1}B, identical=${same}`)
if (same) { pass = false; notes.push('first and last frame identical (no motion)') }
}
console.log(notes.length ? 'NOTES:\n ' + notes.join('\n ') : 'NOTES: none')
console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL')
app.exit(pass ? 0 : 1)
}
// renderMp4Job closes its render window; suppress the default auto-quit so it
// doesn't race result printing.
app.on('window-all-closed', () => {})
app.whenReady().then(() =>
main().catch((err) => {
console.error('HARNESS ERROR:', err)
app.exit(2)
}),
)
+66
View File
@@ -0,0 +1,66 @@
// Regression for odd canvas dimensions (#13): yuv420p MP4 export pads to even
// dimensions instead of failing with "height not divisible by 2".
const { app } = require('electron')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const { execFileSync } = require('node:child_process')
const { renderMp4Job } = require('../dist-electron/renderWorker.cjs')
const scene = {
canvas: { width: 101, height: 101, background: '#111827' },
duration: 0.5,
assets: [{ id: 'rect', kind: 'rect', width: 45, height: 35, fill: '#f97316' }],
layers: [
{
id: 'layer-rect',
assetId: 'rect',
visible: true,
base: { x: 50, y: 50, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
keyframes: [
{ time: 0, props: { x: 38 } },
{ time: 0.5, props: { x: 62 } },
],
},
],
}
async function main() {
const outPath = path.join(os.tmpdir(), 'ruwio-verify-odd-dims.mp4')
fs.rmSync(outPath, { force: true })
await renderMp4Job({
jobId: 'verify-odd-dims',
scene,
options: { fps: 4, duration: 0.5, totalFrames: 3, crf: '28', preset: 'ultrafast' },
outputPath: outPath,
signal: new AbortController().signal,
onProgress: () => {},
})
const probe = JSON.parse(execFileSync('ffprobe', [
'-v', 'error',
'-select_streams', 'v:0',
'-show_entries', 'stream=codec_name,width,height,nb_read_frames',
'-count_frames',
'-of', 'json',
outPath,
]).toString())
const stream = probe.streams[0]
const pass =
stream.codec_name === 'h264' &&
stream.width === 102 &&
stream.height === 102 &&
Number(stream.nb_read_frames) === 3
console.log(`codec=${stream.codec_name} dimensions=${stream.width}x${stream.height} frames=${stream.nb_read_frames}`)
console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL')
app.exit(pass ? 0 : 1)
}
app.on('window-all-closed', () => {})
app.whenReady().then(() =>
main().catch((err) => {
console.error('HARNESS ERROR:', err)
app.exit(2)
}),
)
+58
View File
@@ -0,0 +1,58 @@
// Regression for capture resolution normalization (#10). On scale-factor-1
// displays this is a no-op, while HiDPI displays catch capturePage inflation.
const { app } = require('electron')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const { execFileSync } = require('node:child_process')
const { renderMp4Job } = require('../dist-electron/renderWorker.cjs')
const scene = {
canvas: { width: 320, height: 240, background: '#ffffff' },
duration: 0.25,
assets: [{ id: 'rect', kind: 'rect', width: 80, height: 60, fill: '#2563eb' }],
layers: [
{
id: 'layer-rect',
assetId: 'rect',
visible: true,
base: { x: 160, y: 120, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
keyframes: [{ time: 0, props: {} }],
},
],
}
async function main() {
const outPath = path.join(os.tmpdir(), 'ruwio-verify-resolution.mp4')
fs.rmSync(outPath, { force: true })
await renderMp4Job({
jobId: 'verify-resolution',
scene,
options: { fps: 2, duration: 0.25, totalFrames: 1, crf: '28', preset: 'ultrafast' },
outputPath: outPath,
signal: new AbortController().signal,
onProgress: () => {},
})
const probe = JSON.parse(execFileSync('ffprobe', [
'-v', 'error',
'-select_streams', 'v:0',
'-show_entries', 'stream=width,height',
'-of', 'json',
outPath,
]).toString())
const stream = probe.streams[0]
const pass = stream.width === scene.canvas.width && stream.height === scene.canvas.height
console.log(`ffprobe dimensions: ${stream.width}x${stream.height}`)
console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL')
app.exit(pass ? 0 : 1)
}
app.on('window-all-closed', () => {})
app.whenReady().then(() =>
main().catch((err) => {
console.error('HARNESS ERROR:', err)
app.exit(2)
}),
)
+88
View File
@@ -0,0 +1,88 @@
// Phase B verification of the inactivity watchdog (finding #3): point the
// worker at a render page whose __animatrSeek never resolves, with a short
// RUWIO_RENDER_TIMEOUT_MS, and confirm the job aborts with "render timed out"
// and leaves no partial file.
//
// Run: npx tsc -p tsconfig.electron.json && node_modules/.bin/electron scripts/verify-timeout.cjs
// Must be set BEFORE requiring the worker — the timeout is read at module load.
process.env.RUWIO_RENDER_TIMEOUT_MS = '2000'
const { app } = require('electron')
const path = require('node:path')
const os = require('node:os')
const fs = require('node:fs')
// Build a temp project dir with a server/render.html that hangs on seek, then
// chdir into it so the worker's loadRenderPage (process.cwd()/server/render.html)
// picks it up.
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruwio-timeout-'))
fs.mkdirSync(path.join(tmpDir, 'server'))
fs.writeFileSync(
path.join(tmpDir, 'server', 'render.html'),
`<!doctype html><html><body><script>
window.__animatrInit = function(){ return Promise.resolve(true) }
window.__animatrSeek = function(){ return new Promise(function(){}) } // never resolves
</script></body></html>`,
)
process.chdir(tmpDir)
const { renderMp4Job } = require('../dist-electron/renderWorker.cjs')
const fps = 12
const duration = 2
const scene = {
canvas: { width: 160, height: 120, background: '#202030' },
duration,
assets: [{ id: 'a1', kind: 'rect', width: 20, height: 20, fill: '#ff4040' }],
layers: [
{
id: 'l1',
assetId: 'a1',
visible: true,
base: { x: 20, y: 60, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
keyframes: [{ time: 0, props: { x: 20 } }, { time: 2, props: { x: 140 } }],
},
],
}
const options = { fps, duration, totalFrames: fps * duration + 1, crf: '20', preset: 'ultrafast' }
app.on('window-all-closed', () => {})
app.whenReady().then(async () => {
const outPath = path.join(tmpDir, 'out.mp4')
const controller = new AbortController()
const startedAt = process.hrtime.bigint()
let rejected = false
let message = ''
try {
await renderMp4Job({
jobId: 'timeout',
scene,
options,
outputPath: outPath,
signal: controller.signal,
onProgress: () => {},
})
} catch (err) {
rejected = true
message = err.message
}
const elapsedMs = Number(process.hrtime.bigint() - startedAt) / 1e6
await new Promise((r) => setTimeout(r, 200))
const notes = []
let pass = true
if (!rejected) { pass = false; notes.push('job did not reject') }
if (!/timed out/i.test(message)) { pass = false; notes.push(`message was "${message}", expected "render timed out"`) }
if (elapsedMs < 1500 || elapsedMs > 8000) { pass = false; notes.push(`elapsed ${elapsedMs.toFixed(0)}ms outside [1500,8000] (timeout=2000)`) }
if (fs.existsSync(outPath)) { pass = false; notes.push('partial output left behind') }
console.log(`rejected=${rejected} message="${message}" elapsed=${elapsedMs.toFixed(0)}ms outExists=${fs.existsSync(outPath)}`)
console.log(notes.length ? 'NOTES:\n ' + notes.join('\n ') : 'NOTES: none')
console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL')
fs.rmSync(tmpDir, { recursive: true, force: true })
app.exit(pass ? 0 : 1)
})
+7 -2
View File
@@ -10,11 +10,16 @@ RUN apt-get update && apt-get install -y \
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
ENV PORT=6000
ENV HOST=127.0.0.1
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
RUN groupadd -r ruwio && useradd -r -g ruwio -d /app -s /usr/sbin/nologin ruwio \
&& chown -R ruwio:ruwio /app
USER ruwio
EXPOSE 6000
CMD ["node", "server.js"]
+2007
View File
File diff suppressed because it is too large. Load diff
+3 -2
View File
@@ -6,10 +6,11 @@
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node server.js"
"dev": "node server.js",
"test:smoke": "node --test server.smoke.test.cjs",
"test:e2e": "node --test render.e2e.test.cjs"
},
"dependencies": {
"cors": "^2.8.5",
"express": "^4.21.2",
"puppeteer-core": "^23.11.1"
}
+310
View File
@@ -0,0 +1,310 @@
const assert = require('node:assert/strict')
const { spawn } = require('node:child_process')
const { mkdtemp, readFile, rm } = require('node:fs/promises')
const net = require('node:net')
const os = require('node:os')
const path = require('node:path')
const { test } = require('node:test')
const HOST = '127.0.0.1'
const TOKEN = 'server-e2e-token'
const CHROME = process.env.PUPPETEER_EXECUTABLE_PATH || '/usr/bin/chromium'
test('render server produces a playable MP4 for a tiny scene', async (t) => {
const ffmpeg = await commandPath('ffmpeg')
const ffprobe = await commandPath('ffprobe')
if (!ffmpeg || !ffprobe || !(await canAccess(CHROME))) {
t.skip('requires ffmpeg, ffprobe, and Chromium')
return
}
const port = await freePort()
const tmp = await mkdtemp(path.join(os.tmpdir(), 'ruwio-render-e2e-'))
const server = spawn(process.execPath, ['server.js'], {
cwd: __dirname,
env: {
...process.env,
HOST,
PORT: String(port),
RENDER_TOKEN: TOKEN,
RENDER_JOB_TTL_MS: '1000',
RENDER_JOB_TIMEOUT_MS: '30000',
PUPPETEER_EXECUTABLE_PATH: CHROME,
CHROME_NO_SANDBOX: '1',
},
stdio: ['ignore', 'pipe', 'pipe'],
})
let output = ''
server.stdout.on('data', (chunk) => {
output += chunk.toString()
})
server.stderr.on('data', (chunk) => {
output += chunk.toString()
})
t.after(async () => {
if (server.exitCode === null) {
server.kill('SIGTERM')
await waitForExit(server)
}
await rm(tmp, { recursive: true, force: true })
})
await waitForServer(server, port, () => output)
const base = `http://${HOST}:${port}`
const start = await fetch(`${base}/render`, {
method: 'POST',
headers: {
Authorization: `Bearer ${TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ scene: tinyScene(), options: { fps: 2, crf: 28, preset: 'ultrafast' } }),
})
const startText = await start.text()
assert.equal(start.status, 200, startText)
const { jobId, accessToken } = JSON.parse(startText)
assert.equal(typeof jobId, 'string')
assert.equal(typeof accessToken, 'string')
const done = await waitForDone(`${base}/render/${jobId}/events?token=${encodeURIComponent(accessToken)}`)
assert.equal(done.done, true)
assert.equal(done.error, null)
assert.equal(done.progress, 1)
assert.equal(done.url, `/render/${jobId}/file?token=${encodeURIComponent(accessToken)}`)
const file = await fetch(`${base}${done.url}`)
assert.equal(file.status, 200)
const outPath = path.join(tmp, 'tiny.mp4')
await require('node:fs/promises').writeFile(outPath, Buffer.from(await file.arrayBuffer()))
const metadata = await ffprobeJson(outPath)
const video = metadata.streams.find((stream) => stream.codec_type === 'video')
assert.ok(video, 'expected a video stream')
assert.equal(video.codec_name, 'h264')
assert.equal(video.width, 64)
assert.equal(video.height, 64)
})
test('render server pads odd canvas dimensions to an even MP4', async (t) => {
const ffmpeg = await commandPath('ffmpeg')
const ffprobe = await commandPath('ffprobe')
if (!ffmpeg || !ffprobe || !(await canAccess(CHROME))) {
t.skip('requires ffmpeg, ffprobe, and Chromium')
return
}
const port = await freePort()
const tmp = await mkdtemp(path.join(os.tmpdir(), 'ruwio-render-e2e-'))
const server = spawn(process.execPath, ['server.js'], {
cwd: __dirname,
env: {
...process.env,
HOST,
PORT: String(port),
RENDER_TOKEN: TOKEN,
RENDER_JOB_TTL_MS: '1000',
RENDER_JOB_TIMEOUT_MS: '30000',
PUPPETEER_EXECUTABLE_PATH: CHROME,
CHROME_NO_SANDBOX: '1',
},
stdio: ['ignore', 'pipe', 'pipe'],
})
let output = ''
server.stdout.on('data', (chunk) => {
output += chunk.toString()
})
server.stderr.on('data', (chunk) => {
output += chunk.toString()
})
t.after(async () => {
if (server.exitCode === null) {
server.kill('SIGTERM')
await waitForExit(server)
}
await rm(tmp, { recursive: true, force: true })
})
await waitForServer(server, port, () => output)
const base = `http://${HOST}:${port}`
const start = await fetch(`${base}/render`, {
method: 'POST',
headers: {
Authorization: `Bearer ${TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ scene: oddScene(), options: { fps: 2, crf: 28, preset: 'ultrafast' } }),
})
const startText = await start.text()
assert.equal(start.status, 200, startText)
const { jobId, accessToken } = JSON.parse(startText)
const done = await waitForDone(`${base}/render/${jobId}/events?token=${encodeURIComponent(accessToken)}`)
assert.equal(done.done, true)
assert.equal(done.error, null)
assert.equal(done.progress, 1)
const file = await fetch(`${base}${done.url}`)
assert.equal(file.status, 200)
const outPath = path.join(tmp, 'odd.mp4')
await require('node:fs/promises').writeFile(outPath, Buffer.from(await file.arrayBuffer()))
const metadata = await ffprobeJson(outPath)
const video = metadata.streams.find((stream) => stream.codec_type === 'video')
assert.ok(video, 'expected a video stream')
assert.equal(video.codec_name, 'h264')
assert.equal(video.width, 102)
assert.equal(video.height, 102)
})
function tinyScene() {
return {
canvas: { width: 64, height: 64, background: '#ffffff' },
duration: 1,
fps: 2,
assets: [
{ id: 'asset-rect', kind: 'rect', name: 'Box', width: 24, height: 24, fill: '#22cc88' },
],
layers: [
{
id: 'layer-rect',
name: 'Box',
assetId: 'asset-rect',
visible: true,
base: { x: 16, y: 32, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
keyframes: [
{ id: 'kf-0', time: 0, easing: 'linear', props: { x: 16 } },
{ id: 'kf-1', time: 0.5, easing: 'linear', props: { x: 48 } },
],
},
],
}
}
function oddScene() {
const s = tinyScene()
s.canvas.width = 101
s.canvas.height = 101
s.layers[0].base.x = 50
s.layers[0].base.y = 50
s.layers[0].keyframes = [
{ id: 'kf-0', time: 0, easing: 'linear', props: { x: 42 } },
{ id: 'kf-1', time: 0.5, easing: 'linear', props: { x: 58 } },
]
return s
}
async function waitForDone(url) {
const res = await fetch(url)
assert.equal(res.status, 200)
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
const deadline = Date.now() + 30000
while (Date.now() < deadline) {
const { value, done } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const messages = buffer.split('\n\n')
buffer = messages.pop() || ''
for (const message of messages) {
const line = message.split('\n').find((entry) => entry.startsWith('data: '))
if (!line) continue
const payload = JSON.parse(line.slice(6))
if (payload.done) return payload
}
}
throw new Error('render did not finish before timeout')
}
function ffprobeJson(file) {
return new Promise((resolve, reject) => {
const child = spawn('ffprobe', [
'-v', 'error',
'-show_streams',
'-of', 'json',
file,
])
let stdout = ''
let stderr = ''
child.stdout.on('data', (chunk) => {
stdout += chunk.toString()
})
child.stderr.on('data', (chunk) => {
stderr += chunk.toString()
})
child.on('error', reject)
child.on('exit', (code) => {
if (code === 0) resolve(JSON.parse(stdout))
else reject(new Error(`ffprobe exited with code ${code}: ${stderr}`))
})
})
}
function commandPath(command) {
return new Promise((resolve) => {
const probe = spawn(process.platform === 'win32' ? 'where' : 'which', [command])
probe.on('error', () => resolve(''))
probe.on('exit', (code) => resolve(code === 0 ? command : ''))
})
}
async function canAccess(file) {
try {
await readFile(file)
return true
} catch {
return false
}
}
function freePort() {
return new Promise((resolve, reject) => {
const server = net.createServer()
server.once('error', reject)
server.listen(0, HOST, () => {
const address = server.address()
server.close(() => {
if (!address || typeof address === 'string') reject(new Error('could not allocate test port'))
else resolve(address.port)
})
})
})
}
async function waitForServer(server, port, output) {
const deadline = Date.now() + 5000
while (Date.now() < deadline) {
if (server.exitCode !== null) {
throw new Error(`server exited early with code ${server.exitCode}\n${output()}`)
}
try {
const res = await fetch(`http://${HOST}:${port}/health`)
if (res.ok) return
} catch {
/* retry */
}
await delay(50)
}
throw new Error(`server did not start\n${output()}`)
}
function waitForExit(child) {
return new Promise((resolve) => {
child.once('exit', () => resolve())
setTimeout(() => {
if (child.exitCode === null) child.kill('SIGKILL')
resolve()
}, 1000).unref()
})
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
+73 -1
View File
@@ -102,6 +102,56 @@
}
return merge(layer.base);
}
function defaultCameraProps(scene){
return {
x:0,
y:0,
width:Math.max(1, Number(scene.canvas && scene.canvas.width) || 1280),
height:Math.max(1, Number(scene.canvas && scene.canvas.height) || 720)
};
}
function mergeCameraProps(base, props){
props=props||{};
function finite(v, fallback){ return typeof v==='number' && isFinite(v) ? v : fallback; }
return {
x:finite(props.x, base.x),
y:finite(props.y, base.y),
width:Math.max(1, finite(props.width, base.width)),
height:Math.max(1, finite(props.height, base.height))
};
}
function lerpCameraProps(a,b,t){
return {
x:lerp(a.x,b.x,t),
y:lerp(a.y,b.y,t),
width:Math.max(1, lerp(a.width,b.width,t)),
height:Math.max(1, lerp(a.height,b.height,t))
};
}
function sampleCamera(scene,time){
var camera=scene.camera;
var base=defaultCameraProps(scene);
if(!camera) return base;
base=mergeCameraProps(base, camera.base);
var kfs=(camera.keyframes||[]).slice().sort(function(a,b){return a.time-b.time;});
if(!kfs.length) return base;
if(time<=kfs[0].time) return mergeCameraProps(base,kfs[0].props);
var last=kfs[kfs.length-1];
if(time>=last.time) return mergeCameraProps(base,last.props);
for(var i=0;i<kfs.length-1;i++){
var a=kfs[i], b=kfs[i+1];
if(time>=a.time && time<=b.time){
if(a.hold){
if(time>=b.time) return mergeCameraProps(base,b.props);
return mergeCameraProps(base,a.props);
}
var raw=(time-a.time)/Math.max(1e-9,b.time-a.time);
var tt=easing(b.easing,raw);
return lerpCameraProps(mergeCameraProps(base,a.props), mergeCameraProps(base,b.props), tt);
}
}
return base;
}
function loadImages(assets){
var imgs={};
var list=assets.filter(function(a){return a.kind==='image';});
@@ -267,8 +317,17 @@
function drawFrame(time){
if(!scene) return;
var lw=scene.canvas.width, lh=scene.canvas.height;
var useCamera=!!(scene.camera && scene.camera.enabled);
var cam=useCamera?sampleCamera(scene,time):null;
ctx.setTransform(1,0,0,1,0,0);
ctx.clearRect(0,0,canvas.width,canvas.height);
ctx.save();
ctx.beginPath(); ctx.rect(0,0,lw,lh); ctx.clip();
if(useCamera){
ctx.scale(lw/cam.width, lh/cam.height);
ctx.translate(-cam.x,-cam.y);
ctx.beginPath(); ctx.rect(0,0,lw,lh); ctx.clip();
}
if(!scene.backgroundVideo){
ctx.fillStyle=scene.canvas.background;
ctx.fillRect(0,0,lw,lh);
@@ -386,7 +445,20 @@
return true;
});
};
window.__animatrSeek=function(time){ drawFrame(time); };
window.__animatrSeek=function(time){
drawFrame(time);
// Resolve only after the compositor has had a chance to paint, so a
// subsequent capturePage()/screenshot() sees THIS frame and not the
// previous one. Without this wait, Electron's capturePage on a hidden
// window intermittently returns the prior frame (stale/off-by-one).
// setTimeout is an anti-hang fallback in case rAF is throttled while hidden.
return new Promise(function(res){
var done=false;
function finish(){ if(!done){ done=true; res(true); } }
requestAnimationFrame(function(){ requestAnimationFrame(finish); });
setTimeout(finish, 1000);
});
};
})();
</script>
</body>
+208 -52
View File
@@ -1,53 +1,98 @@
import express from 'express'
import cors from 'cors'
import path from 'node:path'
import fs from 'node:fs/promises'
import os from 'node:os'
import { randomUUID } from 'node:crypto'
import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
import { spawn } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import puppeteer from 'puppeteer-core'
import renderValidation from '../shared/renderValidation.cjs'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const PORT = Number(process.env.PORT || 6000)
const HOST = process.env.HOST || '127.0.0.1'
const PORT = readIntEnv('PORT', 6000, 1, 65535)
const CHROME_PATH = process.env.PUPPETEER_EXECUTABLE_PATH || '/usr/bin/chromium'
const RENDER_HTML = path.join(__dirname, 'render.html')
const RENDER_TOKEN = process.env.RENDER_TOKEN || ''
const ALLOWED_ORIGINS = new Set(
(process.env.RENDER_ALLOWED_ORIGINS ||
'http://localhost:5200,http://127.0.0.1:5200,http://localhost:5173,http://127.0.0.1:5173,http://localhost:6000,http://127.0.0.1:6000')
.split(',')
.map((origin) => origin.trim())
.filter(Boolean),
)
const BODY_LIMIT_BYTES = readSizeEnv('RENDER_MAX_BODY', 15 * 1024 * 1024)
const MAX_ACTIVE_JOBS = readIntEnv('MAX_ACTIVE_JOBS', 1, 1, 16)
const JOB_TIMEOUT_MS = readIntEnv('RENDER_JOB_TIMEOUT_MS', 2 * 60 * 1000, 1000, 60 * 60 * 1000)
const JOB_TTL_MS = readIntEnv('RENDER_JOB_TTL_MS', 10 * 60 * 1000, 1000, 24 * 60 * 60 * 1000)
const RATE_LIMIT_WINDOW_MS = readIntEnv('RENDER_RATE_WINDOW_MS', 60 * 1000, 1000, 60 * 60 * 1000)
const RATE_LIMIT_MAX = readIntEnv('RENDER_RATE_MAX', 8, 1, 1000)
const MAX_CANVAS_WIDTH = readIntEnv('RENDER_MAX_CANVAS_WIDTH', 4096, 1, 16384)
const MAX_CANVAS_HEIGHT = readIntEnv('RENDER_MAX_CANVAS_HEIGHT', 4096, 1, 16384)
const MAX_FPS = readIntEnv('RENDER_MAX_FPS', 60, 1, 240)
const MAX_DURATION = readIntEnv('RENDER_MAX_DURATION', 60, 1, 60 * 60)
const MAX_FRAMES = readIntEnv('RENDER_MAX_FRAMES', 3600, 1, 250000)
const MAX_LAYERS = readIntEnv('RENDER_MAX_LAYERS', 500, 1, 100000)
const MAX_KEYFRAMES = readIntEnv('RENDER_MAX_KEYFRAMES', 5000, 1, 1000000)
const MAX_ASSETS = readIntEnv('RENDER_MAX_ASSETS', 500, 1, 100000)
const RENDER_LIMITS = {
maxCanvasWidth: MAX_CANVAS_WIDTH,
maxCanvasHeight: MAX_CANVAS_HEIGHT,
maxFps: MAX_FPS,
maxDuration: MAX_DURATION,
maxFrames: MAX_FRAMES,
maxLayers: MAX_LAYERS,
maxKeyframes: MAX_KEYFRAMES,
maxAssets: MAX_ASSETS,
}
const { playbackRange, validateRenderRequest: validateSharedRenderRequest } = renderValidation
const app = express()
app.use(cors())
app.use(express.json({ limit: '200mb' }))
app.disable('x-powered-by')
app.use(originGuard)
app.use(rejectLargeBodies)
app.use(express.json({ limit: BODY_LIMIT_BYTES }))
/** @type {Map<string, Job>} */
const jobs = new Map()
const rateBuckets = new Map()
app.get('/health', (_req, res) => res.json({ ok: true }))
app.post('/render', (req, res) => {
app.post('/render', requireRenderToken, rateLimitRender, (req, res) => {
if (activeJobCount() >= MAX_ACTIVE_JOBS) {
return res.status(503).json({ error: 'render server is busy' })
}
const { scene, options } = req.body || {}
if (!scene || !scene.canvas || !Array.isArray(scene.layers)) {
return res.status(400).json({ error: 'invalid scene' })
const validation = validateRenderRequest(scene, options || {})
if (!validation.ok) {
return res.status(400).json({ error: validation.error })
}
const job = {
id: randomUUID(),
accessToken: randomToken(),
progress: 0,
done: false,
error: null,
filePath: null,
workDir: null,
subscribers: new Set(),
abort: new AbortController(),
cleanupTimer: null,
}
jobs.set(job.id, job)
res.json({ jobId: job.id })
runJob(job, scene, options || {}).catch((err) => {
res.json({ jobId: job.id, accessToken: job.accessToken })
runJob(job, validation.scene, validation.options).catch((err) => {
job.error = err?.message || String(err)
job.done = true
notify(job)
scheduleCleanup(job, 0)
})
})
app.get('/render/:id/events', (req, res) => {
app.get('/render/:id/events', requireJobToken, (req, res) => {
const job = jobs.get(req.params.id)
if (!job) return res.status(404).end()
res.writeHead(200, {
@@ -60,7 +105,7 @@ app.get('/render/:id/events', (req, res) => {
progress: job.progress,
done: job.done,
error: job.error,
url: job.done && !job.error ? `/render/${job.id}/file` : null,
url: job.done && !job.error ? `/render/${job.id}/file?token=${encodeURIComponent(job.accessToken)}` : null,
}
res.write(`data: ${JSON.stringify(payload)}\n\n`)
}
@@ -73,65 +118,127 @@ app.get('/render/:id/events', (req, res) => {
})
})
app.get('/render/:id/file', (req, res) => {
app.get('/render/:id/file', requireJobToken, (req, res) => {
const job = jobs.get(req.params.id)
if (!job || !job.filePath || job.error) return res.status(404).end()
res.download(job.filePath, 'animation.mp4')
})
app.delete('/render/:id', (req, res) => {
app.delete('/render/:id', requireJobToken, (req, res) => {
const job = jobs.get(req.params.id)
if (!job) return res.status(404).end()
job.abort.abort()
if (job.done) scheduleCleanup(job, 0)
res.json({ ok: true })
})
app.use((err, _req, res, _next) => {
if (err?.type === 'entity.too.large') {
return res.status(413).json({ error: `request body exceeds ${BODY_LIMIT_BYTES} bytes` })
}
if (err instanceof SyntaxError && 'body' in err) {
return res.status(400).json({ error: 'invalid JSON body' })
}
console.error(err)
return res.status(500).json({ error: 'internal server error' })
})
function originGuard(req, res, next) {
const origin = req.get('Origin')
if (origin) {
if (!ALLOWED_ORIGINS.has(origin)) {
return res.status(403).json({ error: 'origin is not allowed' })
}
res.setHeader('Access-Control-Allow-Origin', origin)
res.setHeader('Vary', 'Origin')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-RUWIO-Render-Token')
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,DELETE,OPTIONS')
}
if (req.method === 'OPTIONS') return res.status(204).end()
next()
}
function rejectLargeBodies(req, res, next) {
const len = Number(req.get('Content-Length') || 0)
if (Number.isFinite(len) && len > BODY_LIMIT_BYTES) {
return res.status(413).json({ error: `request body exceeds ${BODY_LIMIT_BYTES} bytes` })
}
next()
}
function requireRenderToken(req, res, next) {
if (!RENDER_TOKEN) {
return res.status(503).json({ error: 'render server requires RENDER_TOKEN to be configured' })
}
const token = bearerToken(req) || req.get('X-RUWIO-Render-Token') || ''
if (!secureEqual(token, RENDER_TOKEN)) return res.status(401).json({ error: 'invalid render token' })
next()
}
function requireJobToken(req, res, next) {
const job = jobs.get(req.params.id)
if (!job) return res.status(404).end()
const token = bearerToken(req) || req.get('X-RUWIO-Render-Token') || String(req.query.token || '')
if (!secureEqual(token, job.accessToken)) return res.status(401).json({ error: 'invalid job token' })
next()
}
function rateLimitRender(req, res, next) {
const ip = req.ip || req.socket.remoteAddress || 'unknown'
const now = Date.now()
const bucket = rateBuckets.get(ip)
if (!bucket || now - bucket.startedAt > RATE_LIMIT_WINDOW_MS) {
rateBuckets.set(ip, { startedAt: now, count: 1 })
return next()
}
bucket.count += 1
if (bucket.count > RATE_LIMIT_MAX) return res.status(429).json({ error: 'too many render requests' })
next()
}
function bearerToken(req) {
const auth = req.get('Authorization') || ''
return auth.toLowerCase().startsWith('bearer ') ? auth.slice(7).trim() : ''
}
function secureEqual(a, b) {
if (!a || !b) return false
const left = Buffer.from(String(a))
const right = Buffer.from(String(b))
return left.length === right.length && timingSafeEqual(left, right)
}
function randomToken() {
return randomBytes(32).toString('base64url')
}
function activeJobCount() {
let count = 0
for (const job of jobs.values()) {
if (!job.done) count += 1
}
return count
}
function notify(job) {
for (const sub of Array.from(job.subscribers)) {
try { sub() } catch { /* noop */ }
}
}
function effectiveEnd(scene) {
let max = 0
for (const layer of scene.layers) {
for (const kf of layer.keyframes) {
if (kf.time > max) max = kf.time
}
}
if (max <= 0) return scene.duration
return Math.min(max, scene.duration)
}
function playbackRange(scene) {
const autoEnd = effectiveEnd(scene)
const start = Math.max(0, Math.min(scene.duration, scene.inTime == null ? 0 : scene.inTime))
const rawEnd = scene.outTime == null ? autoEnd : scene.outTime
const end = Math.max(start, Math.min(scene.duration, rawEnd))
return { start, end }
}
async function runJob(job, scene, options) {
const fps = Number(options.fps || scene.fps || 60)
const { start: rangeIn, end: rangeOut } = playbackRange(scene)
const rangeLen = Math.max(0, rangeOut - rangeIn)
const duration = Number(options.duration || rangeLen)
const totalFrames = Math.max(1, Math.round(duration * fps))
const crf = String(options.crf ?? 18)
const preset = String(options.preset ?? 'fast')
const { start: rangeIn } = playbackRange(scene)
const { fps, duration, totalFrames, crf, preset } = options
const workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'animatr-'))
job.workDir = workDir
const outPath = path.join(workDir, 'out.mp4')
const timeout = setTimeout(() => job.abort.abort(), JOB_TIMEOUT_MS)
const browser = await puppeteer.launch({
executablePath: CHROME_PATH,
headless: 'new',
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--hide-scrollbars',
],
args: chromeArgs(),
defaultViewport: {
width: scene.canvas.width,
height: scene.canvas.height,
@@ -146,6 +253,9 @@ async function runJob(job, scene, options) {
'-i', '-',
'-c:v', 'libx264',
'-pix_fmt', 'yuv420p',
// libx264 + yuv420p require even dimensions; pad an odd canvas up to the
// next even size so it encodes instead of crashing ffmpeg (finding #13).
'-vf', 'pad=ceil(iw/2)*2:ceil(ih/2)*2',
'-preset', preset,
'-crf', crf,
'-movflags', '+faststart',
@@ -190,18 +300,64 @@ async function runJob(job, scene, options) {
job.done = true
notify(job)
setTimeout(() => cleanupJob(job.id, workDir).catch(() => {}), 10 * 60 * 1000)
scheduleCleanup(job, JOB_TTL_MS)
} finally {
clearTimeout(timeout)
if (!job.done) {
try { ffmpeg.kill('SIGKILL') } catch { /* noop */ }
}
await browser.close().catch(() => {})
}
}
async function cleanupJob(jobId, workDir) {
jobs.delete(jobId)
await fs.rm(workDir, { recursive: true, force: true })
function scheduleCleanup(job, delayMs) {
if (job.cleanupTimer) clearTimeout(job.cleanupTimer)
job.cleanupTimer = setTimeout(() => cleanupJob(job.id).catch(() => {}), delayMs)
}
app.listen(PORT, () => {
console.log(`animatr render server listening on :${PORT}`)
async function cleanupJob(jobId) {
const job = jobs.get(jobId)
if (!job) return
jobs.delete(jobId)
if (job.workDir) await fs.rm(job.workDir, { recursive: true, force: true })
}
function validateRenderRequest(scene, options) {
return validateSharedRenderRequest(scene, options, RENDER_LIMITS)
}
function chromeArgs() {
const args = ['--disable-dev-shm-usage', '--hide-scrollbars']
if (process.env.CHROME_NO_SANDBOX === '1') {
console.warn('WARNING: CHROME_NO_SANDBOX=1 disables Chromium sandboxing for render jobs.')
args.push('--no-sandbox', '--disable-setuid-sandbox')
}
return args
}
function readIntEnv(name, fallback, min, max) {
const raw = process.env[name]
if (!raw) return fallback
const n = Number.parseInt(raw, 10)
if (!Number.isFinite(n) || n < min || n > max) {
throw new Error(`${name} must be an integer between ${min} and ${max}`)
}
return n
}
function readSizeEnv(name, fallback) {
const raw = process.env[name]
if (!raw) return fallback
const match = raw.trim().match(/^(\d+)(b|kb|mb)?$/i)
if (!match) throw new Error(`${name} must be a byte size like 1048576 or 15mb`)
const amount = Number.parseInt(match[1], 10)
const unit = (match[2] || 'b').toLowerCase()
const mult = unit === 'mb' ? 1024 * 1024 : unit === 'kb' ? 1024 : 1
return amount * mult
}
app.listen(PORT, HOST, () => {
console.log(`animatr render server listening on ${HOST}:${PORT}`)
console.log(`chromium: ${CHROME_PATH}`)
if (!RENDER_TOKEN) console.warn('WARNING: RENDER_TOKEN is not set; POST /render is disabled.')
})
+139
View File
@@ -0,0 +1,139 @@
const assert = require('node:assert/strict')
const { spawn } = require('node:child_process')
const net = require('node:net')
const { test } = require('node:test')
const HOST = '127.0.0.1'
const TOKEN = 'server-smoke-token'
test('render server health, auth, and validation routes work', async (t) => {
const port = await freePort()
const server = spawn(process.execPath, ['server.js'], {
cwd: __dirname,
env: {
...process.env,
HOST,
PORT: String(port),
RENDER_TOKEN: TOKEN,
RENDER_MAX_BODY: '1mb',
RENDER_RATE_MAX: '100',
PUPPETEER_EXECUTABLE_PATH: '/not-used-by-smoke-test',
},
stdio: ['ignore', 'pipe', 'pipe'],
})
let output = ''
server.stdout.on('data', (chunk) => {
output += chunk.toString()
})
server.stderr.on('data', (chunk) => {
output += chunk.toString()
})
t.after(async () => {
if (server.exitCode === null) {
server.kill('SIGTERM')
await waitForExit(server)
}
})
await waitForServer(server, port, () => output)
const base = `http://${HOST}:${port}`
const health = await fetch(`${base}/health`)
assert.equal(health.status, 200)
assert.deepEqual(await health.json(), { ok: true })
const missingToken = await postRender(base, null, invalidRenderBody())
assert.equal(missingToken.status, 401)
assert.equal((await missingToken.json()).error, 'invalid render token')
const badToken = await postRender(base, 'wrong-token', invalidRenderBody())
assert.equal(badToken.status, 401)
assert.equal((await badToken.json()).error, 'invalid render token')
const invalidRender = await postRender(base, TOKEN, invalidRenderBody())
assert.equal(invalidRender.status, 400)
assert.match((await invalidRender.json()).error, /canvas must be/)
const missingJobEvents = await fetch(`${base}/render/does-not-exist/events?token=bad`)
assert.equal(missingJobEvents.status, 404)
const missingJobFile = await fetch(`${base}/render/does-not-exist/file?token=bad`)
assert.equal(missingJobFile.status, 404)
const missingJobDelete = await fetch(`${base}/render/does-not-exist?token=bad`, { method: 'DELETE' })
assert.equal(missingJobDelete.status, 404)
})
function invalidRenderBody() {
return {
scene: {
canvas: { width: 0, height: 100 },
duration: 1,
fps: 30,
layers: [],
assets: [],
},
options: { fps: 30 },
}
}
function postRender(base, token, body) {
const headers = { 'Content-Type': 'application/json' }
if (token) headers.Authorization = `Bearer ${token}`
return fetch(`${base}/render`, {
method: 'POST',
headers,
body: JSON.stringify(body),
})
}
function freePort() {
return new Promise((resolve, reject) => {
const server = net.createServer()
server.once('error', reject)
server.listen(0, HOST, () => {
const address = server.address()
server.close(() => {
if (!address || typeof address === 'string') reject(new Error('could not allocate test port'))
else resolve(address.port)
})
})
})
}
async function waitForServer(server, port, output) {
const deadline = Date.now() + 5000
let lastError = null
while (Date.now() < deadline) {
if (server.exitCode !== null) {
throw new Error(`server exited early with code ${server.exitCode}\n${output()}`)
}
try {
const res = await fetch(`http://${HOST}:${port}/health`)
if (res.ok) return
} catch (err) {
lastError = err
}
await delay(50)
}
throw new Error(`server did not start: ${lastError?.message || 'timeout'}\n${output()}`)
}
function waitForExit(child) {
return new Promise((resolve) => {
child.once('exit', () => resolve())
setTimeout(() => {
if (child.exitCode === null) child.kill('SIGKILL')
resolve()
}, 1000).unref()
})
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
+153
View File
@@ -0,0 +1,153 @@
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' }
}
}
}
if (scene.camera != null) {
const camera = scene.camera
if (typeof camera !== 'object') return { ok: false, error: 'camera must be an object' }
if (camera.enabled) {
const cw = Number(camera.base?.width)
const ch = Number(camera.base?.height)
if (!Number.isFinite(cw) || cw <= 0 || !Number.isFinite(ch) || ch <= 0) {
return { ok: false, error: 'enabled camera must have positive width and height' }
}
}
if (camera.keyframes != null) {
if (!Array.isArray(camera.keyframes)) {
return { ok: false, error: 'camera keyframes must be an array' }
}
keyframes += camera.keyframes.length
if (keyframes > limits.maxKeyframes) {
return { ok: false, error: `scene has more than ${limits.maxKeyframes} keyframes` }
}
for (const kf of camera.keyframes) {
if (!Number.isFinite(Number(kf?.time))) {
return { ok: false, error: 'keyframe times must be finite numbers' }
}
const w = kf?.props?.width
const h = kf?.props?.height
if (w != null && (!Number.isFinite(Number(w)) || Number(w) <= 0)) {
return { ok: false, error: 'camera keyframe width must be positive' }
}
if (h != null && (!Number.isFinite(Number(h)) || Number(h) <= 0)) {
return { ok: false, error: 'camera keyframe height must be positive' }
}
}
}
}
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,
}
+93
View File
@@ -0,0 +1,93 @@
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('validates enabled camera data', () => {
const valid = validateRenderRequest(scene({
camera: {
enabled: true,
base: { x: 0, y: 0, width: 320, height: 180 },
keyframes: [{ time: 0.5, props: { x: 10 }, easing: 'linear' }],
},
}))
assert.equal(valid.ok, true)
const invalidSize = validateRenderRequest(scene({
camera: {
enabled: true,
base: { x: 0, y: 0, width: 0, height: 180 },
keyframes: [],
},
}))
assert.equal(invalidSize.ok, false)
assert.match(invalidSize.error, /camera/)
const invalidKf = validateRenderRequest(scene({
camera: {
enabled: true,
base: { x: 0, y: 0, width: 320, height: 180 },
keyframes: [{ time: Number.NaN, props: {}, easing: 'linear' }],
},
}))
assert.equal(invalidKf.ok, false)
assert.match(invalidKf.error, /keyframe/)
})
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 })
})
+30 -12
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, defineAsyncComponent, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import {
PhArrowClockwise,
PhArrowCounterClockwise,
@@ -12,6 +12,7 @@ import {
PhFloppyDisk,
PhGearSix,
PhImageSquare,
PhMonitorPlay,
PhPause,
PhPencilSimpleLine,
PhPlay,
@@ -28,14 +29,12 @@ import ClipartDrawer from './components/ClipartDrawer.vue'
import SceneDrawer from './components/SceneDrawer.vue'
import DocumentDrawer from './components/DocumentDrawer.vue'
import DocumentSettings from './components/DocumentSettings.vue'
import ExportDialog from './components/ExportDialog.vue'
import FullscreenPreview from './components/FullscreenPreview.vue'
import PropertiesPanel from './components/PropertiesPanel.vue'
import SvgLayerPanel from './components/SvgLayerPanel.vue'
import Timeline from './components/Timeline.vue'
import TooltipHost from './components/TooltipHost.vue'
import WelcomeDialog from './components/WelcomeDialog.vue'
import TestOverlay from './devtools/TestOverlay.vue'
import ruwioLogo from './assets/logo.png'
import { confirmDialog, DialogsHost, IconButton, promptDialog, Toast } from './components/ui'
import { t } from './i18n'
@@ -47,7 +46,6 @@ import { usePlaybackLoop } from './composables/usePlaybackLoop'
import { useTimeFormat } from './composables/useTimeFormat'
import { useProjectName } from './composables/useProjectName'
import { shouldChromeClickDeselect } from './lib/chromeClick'
import { renderSceneToStillImage } from './lib/stillImageExport'
import { ProjectFileError, pickProjectFile } from './lib/projectFile'
import { readAnyProjectFile } from './lib/ruwFile'
import {
@@ -71,6 +69,12 @@ const welcomeOpen = ref(false)
const fullscreenOpen = ref(false)
const aboutOpen = ref(false)
const helpOpen = ref(false)
const isDesktop = computed(() => !!window.ruwioDesktop)
const canUseTestRunner = import.meta.env.DEV || !!window.ruwioDesktop
const TestOverlay = canUseTestRunner
? defineAsyncComponent(() => import('./devtools/TestOverlay.vue'))
: null
const ExportDialog = defineAsyncComponent(() => import('./components/ExportDialog.vue'))
const TIMELINE_HEIGHT_KEY = 'animatr:timelineHeight'
const DEFAULT_TIMELINE_HEIGHT = 220
const MIN_TIMELINE_HEIGHT = 120
@@ -192,6 +196,11 @@ function stop() {
store.pause()
}
function openFullscreenPreview() {
store.pause()
fullscreenOpen.value = true
}
async function saveProject() {
// projectForSave stitches the current editor-session UI state
// (zoom, pan, overlay toggles, timeline display) onto the
@@ -311,6 +320,7 @@ async function copyStillToClipboard() {
return
}
try {
const { renderSceneToStillImage } = await import('./lib/stillImageExport')
const scene = JSON.parse(JSON.stringify(store.scene))
const blob = await renderSceneToStillImage(scene, store.playhead, 'png')
// ClipboardItem is widely available in modern Chromium / Firefox
@@ -422,7 +432,7 @@ function nudgeSelection(dx: number, dy: number): boolean {
function onKeyDown(e: KeyboardEvent) {
if (e.ctrlKey && e.shiftKey && e.altKey && (e.code === 'KeyT' || e.key.toLowerCase() === 't')) {
e.preventDefault()
testsOpen.value = true
if (canUseTestRunner) testsOpen.value = true
return
}
// TAB toggles the side panels (asset sidebar + properties aside)
@@ -504,8 +514,7 @@ function onKeyDown(e: KeyboardEvent) {
if (isEditable(e.target)) return
e.preventDefault()
if (e.shiftKey) {
store.pause()
fullscreenOpen.value = true
openFullscreenPreview()
return
}
store.togglePlay()
@@ -591,8 +600,7 @@ async function runDesktopMenuCommand(command: string) {
rewind()
break
case 'fullscreen-preview':
store.pause()
fullscreenOpen.value = true
openFullscreenPreview()
break
case 'toggle-panels':
panelsHidden.value = !panelsHidden.value
@@ -610,7 +618,7 @@ async function runDesktopMenuCommand(command: string) {
aboutOpen.value = true
break
case 'test-runner':
testsOpen.value = true
if (canUseTestRunner) testsOpen.value = true
break
}
}
@@ -702,6 +710,13 @@ onBeforeUnmount(() => {
@click="store.togglePlay()"
/>
<div class="transport-side transport-right">
<IconButton
:icon="PhMonitorPlay"
icon-weight="regular"
size="md"
:title="t('app.fullscreenPlayback')"
@click="openFullscreenPreview"
/>
<IconButton
:icon="PhStop"
icon-weight="fill"
@@ -743,7 +758,10 @@ onBeforeUnmount(() => {
</button>
<button
class="export-btn"
v-tooltip="{ label: t('app.export'), help: t('app.exportHelp') }"
v-tooltip="{
label: t('app.export'),
help: t(isDesktop ? 'app.exportHelpDesktop' : 'app.exportHelpServer'),
}"
@click="exportOpen = true"
>
<PhExport :size="14" weight="bold" />
@@ -803,7 +821,7 @@ onBeforeUnmount(() => {
</footer>
<DocumentSettings v-model="settingsOpen" />
<ExportDialog v-model="exportOpen" />
<TestOverlay v-model="testsOpen" />
<component :is="TestOverlay" v-if="TestOverlay" v-model="testsOpen" />
<FullscreenPreview v-model="fullscreenOpen" />
<AboutDialog v-model="aboutOpen" />
<HelpDialog v-model="helpOpen" />
+5 -82
View File
@@ -33,7 +33,7 @@ import {
setClipDragSource,
} from '@/composables/useClipDragSource'
import type { Asset } from '@/types/scene'
import { confirmDialog, promptDialog } from '@/components/ui'
import { confirmDialog, promptDialog, ToggleSwitch } from '@/components/ui'
import { loadSceneImages, renderButtonThumbnail, renderSvgThumbnail } from '@/lib/render'
import { t } from '@/i18n'
@@ -624,21 +624,11 @@ function onAssetDragStart(e: DragEvent, asset: ImageOrSvgAsset) {
>
<div class="assets-header">
<h3>Assets</h3>
<label
class="thumbs-switch"
<ToggleSwitch
v-model="showThumbs"
:label="t('sidebar.thumbsToggle')"
v-tooltip="showThumbs ? t('sidebar.thumbnailsHide') : t('sidebar.thumbnailsShow')"
>
<span class="thumbs-switch-label">{{ t('sidebar.thumbsToggle') }}</span>
<input
type="checkbox"
class="thumbs-switch-input"
:checked="showThumbs"
@change="showThumbs = ($event.target as HTMLInputElement).checked"
/>
<span class="thumbs-switch-track">
<span class="thumbs-switch-knob"></span>
</span>
</label>
/>
</div>
<button class="upload-btn" @click="triggerUpload">
@@ -988,73 +978,6 @@ h4 {
gap: 10px;
}
/* Compact slide switch labelled "Thumbs" — native checkbox stays hidden
* for a11y (keyboard + screen readers), the visible track + knob is
* driven purely by the `:checked` state. */
.thumbs-switch {
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
user-select: none;
font-size: 11px;
color: var(--text-dim);
}
.thumbs-switch-label {
font-weight: 500;
letter-spacing: 0.01em;
}
.thumbs-switch-input {
/* Keep focus behaviour via the native input; the visual sits next to it. */
position: absolute;
opacity: 0;
width: 0;
height: 0;
pointer-events: none;
}
.thumbs-switch-track {
position: relative;
width: 26px;
height: 14px;
border-radius: 999px;
background: var(--border);
transition: background 140ms var(--ease);
flex-shrink: 0;
}
.thumbs-switch-knob {
position: absolute;
top: 2px;
left: 2px;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--panel);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.35);
transition: left 140ms var(--ease), background 140ms var(--ease);
}
.thumbs-switch-input:checked + .thumbs-switch-track {
background: var(--accent);
}
.thumbs-switch-input:checked + .thumbs-switch-track .thumbs-switch-knob {
left: 14px;
background: #fff;
}
.thumbs-switch-input:focus-visible + .thumbs-switch-track {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.thumbs-switch:hover .thumbs-switch-track {
filter: brightness(1.08);
}
/* Section rows: unified layout — [caret + title][action?][count]. The
* count always sits at the far right so the right column is visually
* stable regardless of whether a row has a create-action (+) beside it. */
+157
View File
@@ -30,12 +30,14 @@ import {
shouldRotateClipCopies,
} from '@/lib/kaleido'
import { drawButtonFrame, drawSceneFrame, loadSceneImages } from '@/lib/render'
import { sampleCamera } from '@/lib/camera'
import type {
Action,
AnimatableProps,
Asset,
ButtonAsset,
ButtonState,
CameraProps,
ClipInstance,
HitShape,
Keyframe,
@@ -762,6 +764,138 @@ const clipGroupConfig = computed(() => {
}
})
const cameraVisibleInEditor = computed(() => {
if (store.activeClipId || store.activeButtonAssetId || store.activeSvgAssetId) return false
const camera = store.scene.camera
return !!camera && camera.overlayVisible !== false
})
const cameraFrame = computed<CameraProps | null>(() => {
if (!cameraVisibleInEditor.value) return null
return sampleCamera(store.scene, store.playhead)
})
const cameraFrameConfig = computed(() => {
const frame = cameraFrame.value
const camera = store.scene.camera
if (!frame || !camera) return null
const selected = store.selectedCamera
return {
id: 'camera-frame',
name: 'camera-frame',
x: frame.x,
y: frame.y,
width: frame.width,
height: frame.height,
fillEnabled: false,
stroke: selected ? '#38bdf8' : '#0ea5e9',
strokeWidth: (selected ? 1.4 : 1) / zoom.value,
strokeScaleEnabled: false,
dash: [4 / zoom.value, 3 / zoom.value],
hitStrokeWidth: Math.max(8 / zoom.value, 6),
listening: true,
draggable: selected && !camera.locked,
}
})
const cameraResizeDragStart = ref<CameraProps | null>(null)
function cameraOutputAspect(): number {
const canvas = store.scene.canvas
const width = Number.isFinite(canvas.width) && canvas.width > 0 ? canvas.width : 1
const height = Number.isFinite(canvas.height) && canvas.height > 0 ? canvas.height : 1
return width / height
}
const cameraResizeHandleConfig = computed(() => {
const frame = cameraFrame.value
const camera = store.scene.camera
if (!frame || !camera || !store.selectedCamera || camera.locked) return null
const size = 10 / zoom.value
return {
id: 'camera-resize-handle',
name: 'camera-resize-handle',
x: frame.x + frame.width - size / 2,
y: frame.y + frame.height - size / 2,
width: size,
height: size,
fill: '#f8fafc',
stroke: '#38bdf8',
strokeWidth: 1 / zoom.value,
strokeScaleEnabled: false,
draggable: true,
listening: true,
}
})
function selectCameraFromStage(e?: Konva.KonvaEventObject<MouseEvent>) {
if (e) e.cancelBubble = true
store.selectCamera(true)
}
function onCameraDragMove(e: Konva.KonvaEventObject<DragEvent>) {
const camera = store.scene.camera
if (!camera || camera.locked) return
const node = e.target
store.upsertCameraKeyframe(store.playhead, {
x: Math.round(node.x()),
y: Math.round(node.y()),
})
}
function onCameraDragEnd(e: Konva.KonvaEventObject<DragEvent>) {
onCameraDragMove(e)
}
function onCameraResizeStart(e: Konva.KonvaEventObject<DragEvent | MouseEvent>) {
e.cancelBubble = true
if (store.scene.camera?.locked) return
store.selectCamera(true)
cameraResizeDragStart.value = cameraFrame.value ? { ...cameraFrame.value } : null
}
function onCameraResizeDrag(e: Konva.KonvaEventObject<DragEvent>) {
e.cancelBubble = true
const camera = store.scene.camera
const start = cameraResizeDragStart.value ?? cameraFrame.value
if (!camera || camera.locked || !start) return
const node = e.target
const handleSize = cameraResizeHandleConfig.value?.width ?? 10 / zoom.value
const aspect = cameraOutputAspect()
const widthFromPointer = Math.max(1, node.x() + handleSize / 2 - start.x)
const heightFromPointer = Math.max(1, node.y() + handleSize / 2 - start.y)
const widthDelta = Math.abs(widthFromPointer - start.width) / Math.max(1, start.width)
const heightDelta = Math.abs(heightFromPointer - start.height) / Math.max(1, start.height)
const width = Math.max(
1,
Math.round(widthDelta >= heightDelta ? widthFromPointer : heightFromPointer * aspect),
)
const height = Math.max(1, Math.round(width / aspect))
node.x(start.x + width - handleSize / 2)
node.y(start.y + height - handleSize / 2)
store.upsertCameraKeyframe(store.playhead, {
x: Math.round(start.x),
y: Math.round(start.y),
width,
height,
})
}
function onCameraResizeEnd(e: Konva.KonvaEventObject<DragEvent>) {
onCameraResizeDrag(e)
cameraResizeDragStart.value = null
}
function toggleCameraEnabled() {
if (store.scene.camera?.enabled) {
store.disableCamera()
return
}
store.enableCamera()
}
/** Horizontal + vertical centre-crosshair line configs. Null when
* the toggle is off. Lines span the effective canvas so the centre
* is exactly where layer keyframes default to (canvas.width/2,
@@ -4723,6 +4857,21 @@ function onResizeHandlePointerUp(e: PointerEvent) {
<v-line :config="centerCrosshairConfigs.horizontal" />
<v-line :config="centerCrosshairConfigs.vertical" />
</template>
<v-rect
v-if="cameraFrameConfig"
:config="cameraFrameConfig"
@mousedown="selectCameraFromStage"
@dragmove="onCameraDragMove"
@dragend="onCameraDragEnd"
/>
<v-rect
v-if="cameraResizeHandleConfig"
:config="cameraResizeHandleConfig"
@mousedown="onCameraResizeStart"
@dragstart="onCameraResizeStart"
@dragmove="onCameraResizeDrag"
@dragend="onCameraResizeEnd"
/>
<v-rect
v-for="h in canvasResizeHandles"
:key="`canvas-resize-${h.edge}`"
@@ -4926,6 +5075,14 @@ function onResizeHandlePointerUp(e: PointerEvent) {
>
<PhCursorClick :size="13" />
</button>
<button
class="zc-btn camera-btn"
:class="{ active: store.scene.camera?.enabled, selected: store.selectedCamera }"
v-tooltip="store.scene.camera?.enabled ? t('canvas.cameraOff') : t('canvas.cameraOn')"
@click="toggleCameraEnabled"
>
<PhFrameCorners :size="13" />
</button>
<button
class="zc-btn motion-paths-btn"
:class="{ active: showMotionPaths }"
+32 -56
View File
@@ -14,8 +14,9 @@ import type { Theme } from '@/composables/useTheme'
import { useTooltipPref } from '@/composables/useTooltipPref'
import { useAssetThumbsPref } from '@/composables/useAssetThumbsPref'
import { useShowWelcomeOnStart } from '@/composables/useShowWelcomeOnStart'
import { useFullscreenPreviewAutoplay } from '@/composables/useFullscreenPreviewAutoplay'
import { showLocked } from '@/composables/useShowLocked'
import { Modal } from '@/components/ui'
import { Modal, ToggleSwitch } from '@/components/ui'
import { setLocale, t, useI18n } from '@/i18n'
import type { Locale } from '@/i18n'
@@ -27,6 +28,7 @@ const { theme, setTheme } = useTheme()
const { enabled: tooltipsEnabled } = useTooltipPref()
const { enabled: thumbsEnabled } = useAssetThumbsPref()
const { enabled: welcomeOnStart } = useShowWelcomeOnStart()
const { enabled: fullscreenAutoplay } = useFullscreenPreviewAutoplay()
const { locale } = useI18n()
// Re-check the stored clipart folder whenever settings opens — covers the
@@ -245,9 +247,10 @@ function applyTheme(t: Theme) {
</div>
<label class="row-toggle">
<span class="switch" :class="{ on: tooltipsEnabled }">
<input type="checkbox" v-model="tooltipsEnabled" />
</span>
<ToggleSwitch
v-model="tooltipsEnabled"
:aria-label="t('settings.tooltipsLabel')"
/>
<div class="row-toggle-text">
<div class="row-toggle-label">{{ t('settings.tooltipsLabel') }}</div>
<div class="row-toggle-desc">{{ t('settings.tooltipsDesc') }}</div>
@@ -255,9 +258,10 @@ function applyTheme(t: Theme) {
</label>
<label class="row-toggle">
<span class="switch" :class="{ on: showLocked }">
<input type="checkbox" v-model="showLocked" />
</span>
<ToggleSwitch
v-model="showLocked"
:aria-label="t('settings.showLockedLabel')"
/>
<div class="row-toggle-text">
<div class="row-toggle-label">{{ t('settings.showLockedLabel') }}</div>
<div class="row-toggle-desc">{{ t('settings.showLockedDesc') }}</div>
@@ -265,9 +269,10 @@ function applyTheme(t: Theme) {
</label>
<label class="row-toggle">
<span class="switch" :class="{ on: thumbsEnabled }">
<input type="checkbox" v-model="thumbsEnabled" />
</span>
<ToggleSwitch
v-model="thumbsEnabled"
:aria-label="t('settings.assetThumbsLabel')"
/>
<div class="row-toggle-text">
<div class="row-toggle-label">{{ t('settings.assetThumbsLabel') }}</div>
<div class="row-toggle-desc">{{ t('settings.assetThumbsDesc') }}</div>
@@ -275,15 +280,27 @@ function applyTheme(t: Theme) {
</label>
<label class="row-toggle">
<span class="switch" :class="{ on: welcomeOnStart }">
<input type="checkbox" v-model="welcomeOnStart" />
</span>
<ToggleSwitch
v-model="welcomeOnStart"
:aria-label="t('settings.welcomeOnStartLabel')"
/>
<div class="row-toggle-text">
<div class="row-toggle-label">{{ t('settings.welcomeOnStartLabel') }}</div>
<div class="row-toggle-desc">{{ t('settings.welcomeOnStartDesc') }}</div>
</div>
</label>
<label class="row-toggle">
<ToggleSwitch
v-model="fullscreenAutoplay"
:aria-label="t('settings.fullscreenAutoplayLabel')"
/>
<div class="row-toggle-text">
<div class="row-toggle-label">{{ t('settings.fullscreenAutoplayLabel') }}</div>
<div class="row-toggle-desc">{{ t('settings.fullscreenAutoplayDesc') }}</div>
</div>
</label>
<div class="setting-block">
<div class="setting-heading">{{ t('settings.appearanceTitle') }}</div>
<div class="setting-desc">{{ t('settings.themeHint') }}</div>
@@ -550,10 +567,10 @@ input[type='number'] {
letter-spacing: normal;
}
/* Switch + two-line label. Defensive resets defeat the scoped
/* Switch row + two-line label. Defensive resets defeat the scoped
blanket `label { flex-direction: column; text-transform: uppercase;
font-size: 10px; flex: 1 1 0; }` that form-row labels in this file
depend on — without the reset that rule would stack the switch on
depend on — without the reset that rule would stack the control on
top of the text and uppercase everything inside. */
.row-toggle {
display: flex;
@@ -599,47 +616,6 @@ input[type='number'] {
text-transform: none;
}
.row-toggle .switch {
position: relative;
width: 32px;
height: 18px;
flex-shrink: 0;
background: var(--border);
border-radius: 999px;
transition: background 140ms var(--ease);
}
.row-toggle .switch.on {
background: var(--accent);
}
.row-toggle .switch input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: inherit;
}
.row-toggle .switch::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
background: #fff;
border-radius: 50%;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
transition: transform 140ms var(--ease);
}
.row-toggle .switch.on::after {
transform: translateX(14px);
}
.clipart-row {
display: flex;
flex-direction: column;
+82 -78
View File
@@ -12,17 +12,11 @@ import {
PhGif,
} from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { downloadExampleHtml, downloadRuntime, downloadSceneExport } from '@/lib/exportPlayer'
import { downloadSceneAsVideo, pickMimeType } from '@/lib/videoExport'
import { downloadSceneAsGif } from '@/lib/gifExport'
import { downloadSceneAsApng, downloadSceneAsApngPreview } from '@/lib/apngExport'
import { downloadFromServer } from '@/lib/serverExport'
import { downloadRuwFile } from '@/lib/ruwFile'
import { downloadSceneAsStillImage } from '@/lib/stillImageExport'
import { useProjectName } from '@/composables/useProjectName'
import type { ButtonAsset, Layer } from '@/types/scene'
import { Modal } from '@/components/ui'
import { Modal, ToggleSwitch } from '@/components/ui'
import { t } from '@/i18n'
import { hasDesktopRender } from '@/lib/desktopRenderExport'
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
@@ -38,7 +32,22 @@ interface ProgressState {
}
const progress = ref<ProgressState | null>(null)
const videoSupported = pickMimeType() !== null
const VIDEO_MIME_CANDIDATES = [
'video/webm;codecs=vp9',
'video/webm;codecs=vp8',
'video/webm',
'video/mp4;codecs=h264',
'video/mp4',
]
function canRecordVideo(): boolean {
if (typeof MediaRecorder === 'undefined') return false
return VIDEO_MIME_CANDIDATES.some((mime) => MediaRecorder.isTypeSupported?.(mime))
}
const videoSupported = canRecordVideo()
const isDesktop = computed(() => !!window.ruwioDesktop)
const hasDesktopMp4 = computed(() => hasDesktopRender())
// Opt-in: strip layers that are hidden in the editor (`editorHidden`)
// from the exported scene. Default off — editor-hide is a workflow
@@ -197,17 +206,20 @@ function activeSceneClone() {
return scene
}
function exportScene() {
async function exportScene() {
const { downloadSceneExport } = await import('@/lib/exportPlayer')
downloadSceneExport(projectClone())
emit('update:modelValue', false)
}
function exportRuntime() {
async function exportRuntime() {
const { downloadRuntime } = await import('@/lib/exportPlayer')
downloadRuntime()
emit('update:modelValue', false)
}
function exportRuwBundle() {
async function exportRuwBundle() {
const { downloadRuwFile } = await import('@/lib/ruwFile')
// Re-import target: .ruw is the round-trippable format the welcome
// dialog + file-picker accept. Pull the project via projectForSave
// so the editor-session UI state (zoom, toggles, …) rides along.
@@ -218,7 +230,8 @@ function exportRuwBundle() {
emit('update:modelValue', false)
}
function exportHtml() {
async function exportHtml() {
const { downloadExampleHtml } = await import('@/lib/exportPlayer')
downloadExampleHtml(projectClone())
emit('update:modelValue', false)
}
@@ -233,6 +246,7 @@ async function exportStillImage(format: 'png' | 'jpg') {
const opts = targetSizeChanged.value
? { targetWidth: targetWidth.value, targetHeight: targetHeight.value }
: undefined
const { downloadSceneAsStillImage } = await import('@/lib/stillImageExport')
await downloadSceneAsStillImage(
activeSceneClone(),
store.playhead,
@@ -249,11 +263,12 @@ async function exportWebm() {
progress.value = {
progress: 0,
controller,
label: t('export.labelWebm'),
label: t(isDesktop.value ? 'export.labelWebmDesktop' : 'export.labelWebmBrowser'),
error: null,
}
store.pause()
try {
const { downloadSceneAsVideo } = await import('@/lib/videoExport')
await downloadSceneAsVideo(activeSceneClone(), {
signal: controller.signal,
onProgress: (p) => {
@@ -282,6 +297,7 @@ async function exportGif() {
}
store.pause()
try {
const { downloadSceneAsGif } = await import('@/lib/gifExport')
await downloadSceneAsGif(
activeSceneClone(),
frameExportOpts(controller, (p) => {
@@ -310,6 +326,7 @@ async function exportApng() {
}
store.pause()
try {
const { downloadSceneAsApng } = await import('@/lib/apngExport')
await downloadSceneAsApng(
activeSceneClone(),
frameExportOpts(controller, (p) => {
@@ -338,6 +355,7 @@ async function exportApngPreview() {
}
store.pause()
try {
const { downloadSceneAsApngPreview } = await import('@/lib/apngExport')
await downloadSceneAsApngPreview(
activeSceneClone(),
frameExportOpts(controller, (p) => {
@@ -355,23 +373,44 @@ async function exportApngPreview() {
}
}
async function exportMp4Server() {
async function exportMp4() {
if (progress.value) return
const controller = new AbortController()
const base = (projectName.value ?? 'animation').toString()
const safe = base.replace(/[^a-z0-9äöüß_-]+/gi, '-').replace(/^-+|-+$/g, '') || 'animation'
progress.value = {
progress: 0,
controller,
label: t('export.labelMp4'),
label: t(hasDesktopMp4.value ? 'export.labelMp4Desktop' : 'export.labelMp4Server'),
error: null,
}
store.pause()
try {
await downloadFromServer(activeSceneClone(), {
signal: controller.signal,
onProgress: (p) => {
if (progress.value) progress.value.progress = p
},
})
const scene = activeSceneClone()
if (hasDesktopRender()) {
const { renderMp4OnDesktop } = await import('@/lib/desktopRenderExport')
const result = await renderMp4OnDesktop(scene, {
signal: controller.signal,
fps: targetFps.value,
suggestedName: `${safe}.mp4`,
onProgress: (p) => {
if (progress.value) progress.value.progress = p
},
})
if (!result) {
progress.value = null
return
}
} else {
const { downloadFromServer } = await import('@/lib/serverExport')
await downloadFromServer(scene, {
signal: controller.signal,
fps: targetFps.value,
onProgress: (p) => {
if (progress.value) progress.value.progress = p
},
})
}
progress.value = null
emit('update:modelValue', false)
} catch (err) {
@@ -415,13 +454,12 @@ function onUpdate(v: boolean) {
:class="{ disabled: !hasHiddenLayers }"
v-tooltip="hasHiddenLayers ? undefined : t('export.skipHiddenNone')"
>
<span class="switch" :class="{ on: skipHidden && hasHiddenLayers }">
<input
type="checkbox"
v-model="skipHidden"
:disabled="!hasHiddenLayers"
/>
</span>
<ToggleSwitch
:model-value="skipHidden && hasHiddenLayers"
:aria-label="t('export.skipHidden')"
:disabled="!hasHiddenLayers"
@update:model-value="skipHidden = $event"
/>
<div class="export-toggle-text">
<div class="export-toggle-label">{{ t('export.skipHidden') }}</div>
<div class="export-toggle-desc">{{ t('export.skipHiddenDesc') }}</div>
@@ -557,14 +595,18 @@ function onUpdate(v: boolean) {
</button>
</div>
<button class="opt" @click="exportMp4Server">
<button class="opt" @click="exportMp4">
<div class="opt-icon"><PhFilmSlate :size="20" /></div>
<div class="opt-text">
<div class="opt-title">
MP4 Video
<span class="badge accent">{{ t('export.mp4Badge') }}</span>
<span class="badge accent">
{{ t(hasDesktopMp4 ? 'export.mp4DesktopBadge' : 'export.mp4ServerBadge') }}
</span>
</div>
<div class="opt-desc">
{{ t(hasDesktopMp4 ? 'export.mp4DesktopDesc' : 'export.mp4ServerDesc') }}
</div>
<div class="opt-desc">{{ t('export.mp4Desc') }}</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
@@ -574,10 +616,16 @@ function onUpdate(v: boolean) {
<div class="opt-text">
<div class="opt-title">
WebM Video
<span class="badge">{{ t('export.webmBadge') }}</span>
<span class="badge">
{{ t(isDesktop ? 'export.webmDesktopBadge' : 'export.webmBrowserBadge') }}
</span>
</div>
<div class="opt-desc">
{{ videoSupported ? t('export.webmDesc') : t('export.webmNotSupported') }}
{{
videoSupported
? t(isDesktop ? 'export.webmDesktopDesc' : 'export.webmBrowserDesc')
: t('export.webmNotSupported')
}}
</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
@@ -735,50 +783,6 @@ function onUpdate(v: boolean) {
line-height: 1.35;
}
/* iOS-style switch. The <input> is a transparent overlay that still
handles keyboard focus and click targeting; the visual is drawn by
the span + ::after pill. */
.switch {
position: relative;
width: 32px;
height: 18px;
flex-shrink: 0;
background: var(--border);
border-radius: 999px;
transition: background 140ms var(--ease);
}
.switch.on {
background: var(--accent);
}
.switch input {
position: absolute;
inset: 0;
margin: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: inherit;
}
.switch::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
background: #fff;
border-radius: 50%;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
transition: transform 140ms var(--ease);
}
.switch.on::after {
transform: translateX(14px);
}
.target-size {
display: flex;
align-items: center;
+16 -4
View File
@@ -2,11 +2,14 @@
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { PhPause, PhPlay, PhSkipBack, PhX } from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { cameraAspect, isCameraEnabled } from '@/lib/camera'
import { mountPlayer, type PlayerHandle } from '@/lib/exportPlayer'
import { t } from '@/i18n'
import { useTimeFormat } from '@/composables/useTimeFormat'
import { useFullscreenPreviewAutoplay } from '@/composables/useFullscreenPreviewAutoplay'
const { formatTime } = useTimeFormat()
const { enabled: autoplay } = useFullscreenPreviewAutoplay()
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
@@ -26,7 +29,14 @@ let isClosing = false
let player: PlayerHandle | null = null
const aspect = computed(
() => `${store.scene.canvas.width} / ${store.scene.canvas.height}`,
() => {
if (isCameraEnabled(store.scene)) {
const time = props.modelValue ? localTime.value : store.playhead
const a = cameraAspect(store.scene, time)
return `${a} / 1`
}
return `${store.scene.canvas.width} / ${store.scene.canvas.height}`
},
)
const rangeIn = computed(() => store.playbackIn)
@@ -43,13 +53,14 @@ async function startPreview() {
// stage. Otherwise the two runtimes fight — editor audio pool +
// preview audio pool would both emit the same mp3s at once.
store.pause()
playing.value = true
playing.value = autoplay.value
showControls.value = false
const start = rangeIn.value
const end = rangeOut.value
const head = store.playhead
const initialTime = head < start || head >= end ? start : head
mountAt(el, initialTime, true)
localTime.value = initialTime
mountAt(el, initialTime, autoplay.value)
requestBrowserFullscreen()
}
@@ -59,6 +70,7 @@ async function startPreview() {
* player and rebuild at the same playhead so visual continuity stays
* intact across the switch. */
function mountAt(el: HTMLElement, time: number, wasPlaying: boolean) {
localTime.value = time
player = mountPlayer(el, store.project, {
loop: true,
onFrame(t: number, paused: boolean) {
@@ -93,7 +105,7 @@ watch(
if (!props.modelValue) return
const still = frameEl.value
if (!still) return
mountAt(still, keepTime, true)
mountAt(still, keepTime, playing.value)
})
},
)
+14 -2
View File
@@ -17,6 +17,7 @@ import {
} from '@phosphor-icons/vue'
import { Modal } from '@/components/ui'
import { t } from '@/i18n'
import { hasDesktopRender } from '@/lib/desktopRenderExport'
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
@@ -63,6 +64,9 @@ interface Chapter {
titleKey: string
}
const isDesktop = !!window.ruwioDesktop
const hasDesktopMp4 = hasDesktopRender()
const CHAPTERS: Chapter[] = [
{
id: 'getting-started',
@@ -509,8 +513,16 @@ const CHAPTERS: Chapter[] = [
{ bodyKey: 'help.content.export.formats.runtime' },
{ bodyKey: 'help.content.export.formats.scene' },
{ bodyKey: 'help.content.export.formats.html' },
{ bodyKey: 'help.content.export.formats.mp4' },
{ bodyKey: 'help.content.export.formats.webm' },
{
bodyKey: hasDesktopMp4
? 'help.content.export.formats.mp4Desktop'
: 'help.content.export.formats.mp4',
},
{
bodyKey: isDesktop
? 'help.content.export.formats.webmDesktop'
: 'help.content.export.formats.webm',
},
],
},
{
+134 -2
View File
@@ -17,11 +17,13 @@ import {
PhX,
} from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { sampleCamera } from '@/lib/camera'
import { sampleLayer } from '@/lib/interpolate'
import type { Action, AnimatableProps, Layer } from '@/types/scene'
import type { Action, AnimatableProps, CameraProps, Layer } from '@/types/scene'
import RangeSlider from './RangeSlider.vue'
import ActionEditor from './ActionEditor.vue'
import FontPicker from './FontPicker.vue'
import { ToggleSwitch } from '@/components/ui'
import { t } from '@/i18n'
import { useTimeFormat } from '@/composables/useTimeFormat'
import { useEyeDropper } from '@/composables/useEyeDropper'
@@ -97,6 +99,81 @@ const sampled = computed<AnimatableProps | null>(() =>
selected.value ? sampleLayer(selected.value, store.playhead) : null,
)
const selectedCameraTrack = computed(() =>
store.selectedCamera ? (store.scene.camera ?? null) : null,
)
const sampledCamera = computed<CameraProps | null>(() =>
selectedCameraTrack.value ? sampleCamera(store.scene, store.playhead) : null,
)
const cameraHasKeyframeHere = computed(() => {
const camera = selectedCameraTrack.value
if (!camera) return false
return camera.keyframes.some((kf) => Math.abs(kf.time - store.playhead) < 1e-3)
})
const cameraEnabled = computed<boolean>({
get: () => selectedCameraTrack.value?.enabled ?? false,
set: (v) => {
if (v) store.enableCamera()
else store.disableCamera()
},
})
const cameraOverlayVisible = computed<boolean>({
get: () => selectedCameraTrack.value?.overlayVisible !== false,
set: (v) => store.setCameraOverlayVisible(v),
})
const cameraLocked = computed<boolean>({
get: () => selectedCameraTrack.value?.locked === true,
set: (v) => store.setCameraLocked(v),
})
function setCameraProp(patch: Partial<CameraProps>) {
const camera = selectedCameraTrack.value
if (!camera || camera.locked) return
store.upsertCameraKeyframe(store.playhead, patch)
}
function cameraOutputAspect(): number {
const width = Number.isFinite(store.scene.canvas.width) && store.scene.canvas.width > 0
? store.scene.canvas.width
: 1
const height = Number.isFinite(store.scene.canvas.height) && store.scene.canvas.height > 0
? store.scene.canvas.height
: 1
return width / height
}
const cameraX = computed<number>({
get: () => Math.round(sampledCamera.value?.x ?? 0),
set: (v) => setCameraProp({ x: v }),
})
const cameraY = computed<number>({
get: () => Math.round(sampledCamera.value?.y ?? 0),
set: (v) => setCameraProp({ y: v }),
})
const cameraWidth = computed<number>({
get: () => Math.round(sampledCamera.value?.width ?? store.scene.canvas.width),
set: (v) => {
const width = Math.max(1, v)
setCameraProp({ width, height: Math.max(1, Math.round(width / cameraOutputAspect())) })
},
})
const cameraHeight = computed<number>({
get: () => Math.round(sampledCamera.value?.height ?? store.scene.canvas.height),
set: (v) => {
const height = Math.max(1, v)
setCameraProp({ width: Math.max(1, Math.round(height * cameraOutputAspect())), height })
},
})
function removeCameraKeyframeHere() {
store.removeCameraKeyframeAt(store.playhead)
}
/** True when the user has more than one layer in the selection.
* Drives the multi-edit flow: writers fan out across every
* editable selected layer, kind-specific sections collapse, and
@@ -599,7 +676,62 @@ function deleteSelection() {
<template>
<div class="panel">
<h3>Properties</h3>
<template v-if="selected && sampled && editableLayers.length > 0">
<template v-if="selectedCameraTrack && sampledCamera">
<div class="multi-banner">{{ t('properties.camera.banner') }}</div>
<section class="prop-group">
<button class="group-header" @click="toggleSection('transform')">
<component :is="sectionOpen.transform ? PhCaretDown : PhCaretRight" :size="11" />
<span>{{ t('properties.camera.frame') }}</span>
</button>
<div v-if="sectionOpen.transform" class="group-body">
<div class="toggle-row">
<span class="toggle-label">{{ t('properties.camera.enabled') }}</span>
<ToggleSwitch
v-model="cameraEnabled"
:aria-label="t('properties.camera.enabled')"
/>
</div>
<div class="toggle-row">
<span class="toggle-label">{{ t('properties.camera.overlay') }}</span>
<ToggleSwitch
v-model="cameraOverlayVisible"
:aria-label="t('properties.camera.overlay')"
/>
</div>
<div class="toggle-row">
<span class="toggle-label">{{ t('properties.camera.locked') }}</span>
<ToggleSwitch
v-model="cameraLocked"
:aria-label="t('properties.camera.locked')"
/>
</div>
<div class="row">
<label>X<input type="number" v-model.number="cameraX" :disabled="cameraLocked" /></label>
<label>Y<input type="number" v-model.number="cameraY" :disabled="cameraLocked" /></label>
</div>
<div class="row">
<label>{{ t('properties.camera.width') }}<input type="number" min="1" v-model.number="cameraWidth" :disabled="cameraLocked" /></label>
<label>{{ t('properties.camera.height') }}<input type="number" min="1" v-model.number="cameraHeight" :disabled="cameraLocked" /></label>
</div>
<div class="kf-state">
<span>
{{ cameraHasKeyframeHere ? t('properties.keyframeLabel') : t('properties.interpolated') }}
<span class="kf-time">@ {{ formatTime(store.playhead) }}</span>
</span>
<button
v-if="cameraHasKeyframeHere"
class="kf-btn"
v-tooltip="t('properties.removeKeyframe')"
:disabled="cameraLocked"
@click="removeCameraKeyframeHere"
>
<PhMinus :size="12" />
</button>
</div>
</div>
</section>
</template>
<template v-else-if="selected && sampled && editableLayers.length > 0">
<div v-if="multiSelected" class="multi-banner">
{{ t('properties.multiSelected', { n: editableLayers.length }) }}
</div>
+250 -11
View File
@@ -16,6 +16,7 @@ import {
PhLockOpen,
PhEye,
PhEyeSlash,
PhFrameCorners,
PhMagnet,
PhMaskHappy,
PhCircle,
@@ -39,6 +40,7 @@ import { audioDragSource, clearAudioDragSource } from '@/composables/useAudioDra
import { clearClipDragSource, clipDragSource } from '@/composables/useClipDragSource'
import { drawWaveform, getWaveformSlot, slicePeaksForClip } from '@/composables/useAudioWaveform'
import { sampleLayer } from '@/lib/interpolate'
import { sampleCamera } from '@/lib/camera'
import { snapToCandidates } from '@/lib/timelineSnap'
import { confirmDialog, promptDialog } from '@/components/ui'
import ScriptFrameDialog from './ScriptFrameDialog.vue'
@@ -347,6 +349,9 @@ function allKeyframeTimes(ignoreKeyframeId?: string): number[] {
if (layer.rotationKeyframes)
for (const k of layer.rotationKeyframes) if (k.id !== ignoreKeyframeId) out.push(k.time)
}
for (const k of store.scene.camera?.keyframes ?? []) {
if (k.id !== ignoreKeyframeId) out.push(k.time)
}
return out
}
@@ -786,6 +791,138 @@ function onLaneDblClick(e: MouseEvent, track: TimelineLayerTrack) {
store.upsertKeyframe(track.layerId, time, props)
}
const cameraTimelineVisible = computed(
() =>
!store.activeClipId &&
!store.activeButtonAssetId &&
!store.activeSvgAssetId &&
!!store.scene.camera,
)
function onCameraTrackClick() {
store.selectCamera(true)
}
function onCameraLaneDblClick(e: MouseEvent) {
e.stopPropagation()
const camera = store.scene.camera
if (!camera || camera.locked) return
const el = e.currentTarget as HTMLElement
const rect = el.getBoundingClientRect()
if (rect.width <= 0) return
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
const time = snapTime(ratio * duration.value)
store.setPlayhead(time)
store.selectCamera(true)
store.upsertCameraKeyframe(time, sampleCamera(store.scene, time))
}
function onCameraKeyframeClick(e: MouseEvent, time: number) {
e.stopPropagation()
if (cameraKfDrag.value?.moved) {
cameraKfDrag.value = null
return
}
cameraKfDrag.value = null
store.selectCamera(true)
store.setPlayhead(time)
}
function onCameraKeyframeDblClick(e: MouseEvent, keyframeId: string) {
e.stopPropagation()
const camera = store.scene.camera
if (!camera || camera.locked) return
store.removeCameraKeyframe(keyframeId)
}
function onCameraKeyframeContextMenu(e: MouseEvent, keyframeId: string) {
e.preventDefault()
e.stopPropagation()
const camera = store.scene.camera
if (!camera || camera.locked) return
const kf = camera.keyframes.find((item) => item.id === keyframeId)
if (!kf) return
const sorted = camera.keyframes.slice().sort((a, b) => a.time - b.time)
menu.value = {
target: 'camera',
keyframeId: kf.id,
time: kf.time,
x: e.clientX,
y: e.clientY,
currentEasing: kf.easing,
isFirst: sorted[0]?.id === kf.id,
isLast: sorted[sorted.length - 1]?.id === kf.id,
isHeld: !!kf.hold,
isEnd: false,
hasEnd: false,
}
store.selectCamera(true)
}
interface CameraKfDrag {
keyframeId: string
lane: HTMLElement
pointerId: number
startX: number
moved: boolean
initialTime: number
}
const cameraKfDrag = ref<CameraKfDrag | null>(null)
function onCameraKeyframePointerDown(e: PointerEvent, keyframeId: string) {
if (e.button !== 0) return
const camera = store.scene.camera
if (!camera || camera.locked) return
const kfEl = e.currentTarget as HTMLElement
const lane = kfEl.parentElement
const kf = camera.keyframes.find((item) => item.id === keyframeId)
if (!lane || !kf) return
e.stopPropagation()
kfEl.setPointerCapture(e.pointerId)
store.selectCamera(true)
cameraKfDrag.value = {
keyframeId,
lane,
pointerId: e.pointerId,
startX: e.clientX,
moved: false,
initialTime: kf.time,
}
}
function onCameraKeyframePointerMove(e: PointerEvent) {
const d = cameraKfDrag.value
if (!d || d.pointerId !== e.pointerId) return
if (!d.moved && Math.abs(e.clientX - d.startX) < 3) return
d.moved = true
const rect = d.lane.getBoundingClientRect()
if (rect.width <= 0) return
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
const rawNewTime = ratio * duration.value
const newTime = snapToCandidates(
rawNewTime,
[...allKeyframeTimesExcluding(new Set([d.keyframeId])), store.playhead],
{
enabled: snapEnabled.value,
pxPerSec: effectivePxPerSec.value,
pixelThreshold: SNAP_PX,
duration: duration.value,
},
)
store.setCameraKeyframeTime(d.keyframeId, newTime)
store.setPlayhead(newTime)
}
function onCameraKeyframePointerUp(e: PointerEvent) {
const d = cameraKfDrag.value
if (!d || d.pointerId !== e.pointerId) return
const el = e.currentTarget as HTMLElement
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
if (!d.moved) cameraKfDrag.value = null
}
function onKeyframeClick(e: MouseEvent, layerId: string, time: number) {
e.stopPropagation()
if (kfDrag.value?.moved) {
@@ -1262,6 +1399,9 @@ function allKeyframeTimesExcluding(ignoreIds: Set<string>): number[] {
if (layer.rotationKeyframes)
for (const k of layer.rotationKeyframes) if (!ignoreIds.has(k.id)) out.push(k.time)
}
for (const k of store.scene.camera?.keyframes ?? []) {
if (!ignoreIds.has(k.id)) out.push(k.time)
}
return out
}
@@ -1374,6 +1514,14 @@ interface KfSegment {
tail?: boolean
}
interface KfSegmentSource {
id: string
time: number
easing: Easing
hold?: boolean
kind?: 'normal' | 'end'
}
/** Render-time filter for the kf v-for. Hides end kfs that share a
* time slot with a normal kf — common after the end-kf migration
* on legacy projects, where stacking a square (end) on top of a
@@ -1391,7 +1539,7 @@ function displayKfs(kfs: Keyframe[]): Keyframe[] {
})
}
function segmentsOf(kfs: Keyframe[], duration: number, buttonMode = false): KfSegment[] {
function segmentsOf(kfs: KfSegmentSource[], duration: number, buttonMode = false): KfSegment[] {
if (kfs.length === 0) return []
// End kfs are visibility markers, not interpolation participants.
// Pull them out for tail computation; everything else flows through
@@ -1527,7 +1675,8 @@ const EASING_OPTIONS: { value: Easing; label: string }[] = [
]
interface KfMenuState {
layerId: string
target: 'layer' | 'camera'
layerId?: string
keyframeId: string
time: number
x: number
@@ -1566,6 +1715,7 @@ function onKeyframeContextMenu(
.sort((a, b) => a.time - b.time)
const hasEnd = layer.keyframes.some((k) => k.kind === 'end')
menu.value = {
target: 'layer',
layerId,
keyframeId: kf.id,
time: kf.time,
@@ -1583,7 +1733,11 @@ function onKeyframeContextMenu(
function toggleHold() {
if (!menu.value) return
store.setKeyframeHold(menu.value.layerId, menu.value.keyframeId, !menu.value.isHeld)
if (menu.value.target === 'camera') {
store.setCameraKeyframeHold(menu.value.keyframeId, !menu.value.isHeld)
} else if (menu.value.layerId) {
store.setKeyframeHold(menu.value.layerId, menu.value.keyframeId, !menu.value.isHeld)
}
menu.value = null
}
@@ -1595,7 +1749,7 @@ function toggleHold() {
* we delete the existing one before promoting the new one. */
function toggleEndKind() {
const m = menu.value
if (!m) return
if (!m || m.target !== 'layer' || !m.layerId) return
const layer = store.activeLayers.find((l) => l.id === m.layerId)
if (!layer) return
const kf = layer.keyframes.find((k) => k.id === m.keyframeId)
@@ -1619,21 +1773,30 @@ function toggleEndKind() {
function applyEasing(easing: Easing) {
if (!menu.value) return
store.setKeyframeEasing(menu.value.layerId, menu.value.keyframeId, easing)
if (menu.value.target === 'camera') {
store.setCameraKeyframeEasing(menu.value.keyframeId, easing)
} else if (menu.value.layerId) {
store.setKeyframeEasing(menu.value.layerId, menu.value.keyframeId, easing)
}
menu.value = null
}
function deleteFromMenu() {
if (!menu.value) return
store.removeKeyframe(menu.value.layerId, menu.value.keyframeId)
if (menu.value.target === 'camera') {
store.removeCameraKeyframe(menu.value.keyframeId)
} else if (menu.value.layerId) {
store.removeKeyframe(menu.value.layerId, menu.value.keyframeId)
}
menu.value = null
}
function duplicateFromMenu() {
if (!menu.value) return
const newId = store.duplicateKeyframe(menu.value.layerId, menu.value.keyframeId)
if (!menu.value || menu.value.target !== 'layer' || !menu.value.layerId) return
const layerId = menu.value.layerId
const newId = store.duplicateKeyframe(layerId, menu.value.keyframeId)
if (newId) {
const layer = store.activeLayers.find((l) => l.id === menu.value!.layerId)
const layer = store.activeLayers.find((l) => l.id === layerId)
const all = layer
? [
...layer.keyframes,
@@ -3534,6 +3697,81 @@ onBeforeUnmount(() => {
@pointercancel="onTracksPointerUp"
>
<div v-if="boxRectStyle" class="box-select-rect" :style="boxRectStyle"></div>
<div
v-if="cameraTimelineVisible"
class="track camera-track"
:class="{ inactive: !store.selectedCamera, locked: !!store.scene.camera?.locked }"
>
<span
class="track-name parent"
:class="{ selected: store.selectedCamera }"
@click="onCameraTrackClick"
>
<PhFrameCorners :size="12" class="name-badge" />
<span class="track-label">{{ t('timeline.cameraTrackName') }}</span>
<button
class="eye-btn"
:class="{ hidden: store.scene.camera?.overlayVisible === false }"
v-tooltip="
store.scene.camera?.overlayVisible === false
? t('timeline.showInEditor')
: t('timeline.hideInEditor')
"
@click.stop="store.setCameraOverlayVisible(store.scene.camera?.overlayVisible === false)"
@mousedown.stop
>
<component :is="store.scene.camera?.overlayVisible === false ? PhEyeSlash : PhEye" :size="12" />
</button>
<button
class="lock-btn"
:class="{ locked: store.scene.camera?.locked }"
v-tooltip="store.scene.camera?.locked ? t('timeline.unlock') : t('timeline.lock')"
@click.stop="store.setCameraLocked(!store.scene.camera?.locked)"
@mousedown.stop
>
<span class="lock-icon rest">
<component :is="store.scene.camera?.locked ? PhLock : PhLockOpen" :size="12" />
</span>
<span class="lock-icon alt">
<component
:is="store.scene.camera?.locked ? PhLockOpen : PhLock"
:size="12"
:weight="store.scene.camera?.locked ? 'fill' : 'regular'"
/>
</span>
</button>
</span>
<div
class="track-lane camera-lane"
:style="{ width: laneWidth + 'px' }"
@click="onLaneClick"
@dblclick="onCameraLaneDblClick"
>
<span class="playhead" :style="{ left: pxFromTime(store.playhead) + 'px' }" />
<span
v-for="seg in segmentsOf(store.scene.camera?.keyframes ?? [], duration)"
:key="`camera-${seg.id}`"
class="kf-seg"
:class="[seg.held ? 'held' : `easing-${seg.easing}`, { tail: seg.tail }]"
:style="segmentStyle(seg)"
/>
<span
v-for="kf in store.scene.camera?.keyframes ?? []"
:key="kf.id"
class="kf camera-kf"
:class="{ hold: kf.hold }"
:style="{ left: pxFromTime(kf.time) + 'px' }"
v-tooltip="t('timeline.keyframeTooltip', { time: formatKfTime(kf.time) })"
@pointerdown="onCameraKeyframePointerDown($event, kf.id)"
@pointermove="onCameraKeyframePointerMove"
@pointerup="onCameraKeyframePointerUp"
@pointercancel="onCameraKeyframePointerUp"
@click="onCameraKeyframeClick($event, kf.time)"
@dblclick="onCameraKeyframeDblClick($event, kf.id)"
@contextmenu="onCameraKeyframeContextMenu($event, kf.id)"
/>
</div>
</div>
<template v-for="(track, idx) in tracks" :key="
track.type === 'layer'
? `l:${track.layerId}:${track.kind}:${track.inGroup ? 'g' : 't'}`
@@ -4261,6 +4499,7 @@ onBeforeUnmount(() => {
<div class="kf-menu-divider"></div>
</template>
<button
v-if="menu.target === 'layer'"
class="kf-menu-item"
:class="{ active: menu.isEnd }"
@click="toggleEndKind"
@@ -4274,8 +4513,8 @@ onBeforeUnmount(() => {
class="kf-menu-trailing-check"
/>
</button>
<div class="kf-menu-divider"></div>
<button v-if="!menu.isEnd" class="kf-menu-item" @click="duplicateFromMenu">
<div v-if="menu.target === 'layer'" class="kf-menu-divider"></div>
<button v-if="menu.target === 'layer' && !menu.isEnd" class="kf-menu-item" @click="duplicateFromMenu">
<PhCopy :size="12" class="kf-menu-icon" />
{{ t('timeline.duplicate') }}
</button>
+5 -46
View File
@@ -21,7 +21,7 @@ import {
pickBackupFile,
readBackupFile,
} from '@/lib/backupFile'
import { confirmDialog, Modal } from '@/components/ui'
import { confirmDialog, Modal, ToggleSwitch } from '@/components/ui'
import { showWelcomeOnStart } from '@/composables/useShowWelcomeOnStart'
import { t } from '@/i18n'
@@ -366,9 +366,10 @@ watch(
<div v-if="backupError" class="backup-error">{{ backupError }}</div>
<template #footer>
<label class="ftr-toggle" v-tooltip="t('welcome.showOnStartHelp')">
<span class="switch sm" :class="{ on: showWelcomeOnStart }">
<input type="checkbox" v-model="showWelcomeOnStart" />
</span>
<ToggleSwitch
v-model="showWelcomeOnStart"
:aria-label="t('welcome.showOnStart')"
/>
<span class="ftr-toggle-label">{{ t('welcome.showOnStart') }}</span>
</label>
<span class="ftr-sep"></span>
@@ -676,8 +677,6 @@ watch(
border-radius: var(--radius-sm);
}
/* Compact footer toggle — same visual language as the other toggles
in the app but sized for the row. */
.ftr-toggle {
display: inline-flex;
align-items: center;
@@ -691,44 +690,4 @@ watch(
.ftr-toggle-label {
white-space: nowrap;
}
.switch.sm {
position: relative;
display: inline-block;
width: 28px;
height: 16px;
background: var(--border);
border-radius: 999px;
flex-shrink: 0;
transition: background 140ms var(--ease);
}
.switch.sm input {
opacity: 0;
width: 100%;
height: 100%;
margin: 0;
cursor: pointer;
}
.switch.sm::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 12px;
height: 12px;
background: #fff;
border-radius: 50%;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
transition: transform 140ms var(--ease);
}
.switch.sm.on {
background: var(--accent);
}
.switch.sm.on::after {
transform: translateX(12px);
}
</style>
+110
View File
@@ -0,0 +1,110 @@
<script setup lang="ts">
defineProps<{
modelValue: boolean
label?: string
ariaLabel?: string
disabled?: boolean
}>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
function onChange(event: Event) {
emit('update:modelValue', (event.target as HTMLInputElement).checked)
}
</script>
<template>
<span class="ui-switch thumbs-switch" :class="{ disabled }">
<span v-if="$slots.default || label" class="ui-switch-label thumbs-switch-label">
<slot>{{ label }}</slot>
</span>
<input
type="checkbox"
class="ui-switch-input thumbs-switch-input"
:checked="modelValue"
:disabled="disabled"
:aria-label="ariaLabel || label"
@change="onChange"
/>
<span class="ui-switch-track">
<span class="ui-switch-knob"></span>
</span>
</span>
</template>
<style scoped>
.ui-switch {
position: relative;
display: inline-flex;
align-items: center;
gap: 6px;
flex: 0 0 auto;
color: var(--text-dim);
cursor: pointer;
font-size: 11px;
line-height: 1;
user-select: none;
-webkit-user-select: none;
}
.ui-switch.disabled {
cursor: not-allowed;
}
.ui-switch-label {
font-weight: 500;
letter-spacing: 0.01em;
}
.ui-switch-input {
position: absolute;
inset: 0;
z-index: 2;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: inherit;
}
.ui-switch-track {
position: relative;
z-index: 1;
width: 26px;
height: 14px;
border-radius: 999px;
background: var(--border);
transition: background 140ms var(--ease);
flex-shrink: 0;
}
.ui-switch-knob {
position: absolute;
top: 2px;
left: 2px;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--panel);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.35);
transition: left 140ms var(--ease), background 140ms var(--ease);
}
.ui-switch-input:checked + .ui-switch-track {
background: var(--accent);
}
.ui-switch-input:checked + .ui-switch-track .ui-switch-knob {
left: 14px;
background: #fff;
}
.ui-switch-input:focus-visible + .ui-switch-track {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.ui-switch:hover:not(.disabled) .ui-switch-track {
filter: brightness(1.08);
}
</style>
+1
View File
@@ -2,5 +2,6 @@ export { default as DialogsHost } from './DialogsHost.vue'
export { default as IconButton } from './IconButton.vue'
export { default as Modal } from './Modal.vue'
export { default as Toast } from './Toast.vue'
export { default as ToggleSwitch } from './ToggleSwitch.vue'
export { confirmDialog, dialogState, promptDialog } from './dialogs'
export type { ConfirmOptions, PromptOptions } from './dialogs'
+1
View File
@@ -12,6 +12,7 @@ export function useDeselectAll() {
return function deselectAll() {
store.selectLayer(null)
store.selectClipInstance(null)
store.selectCamera(false)
selectedKfs.value = []
}
}
@@ -0,0 +1,27 @@
import { ref, watch } from 'vue'
const STORAGE_KEY = 'animatr.fullscreenPreviewAutoplay'
function read(): boolean {
try {
return localStorage.getItem(STORAGE_KEY) === 'true'
} catch {
return false
}
}
// Module-level shared ref so settings and fullscreen preview use the
// same preference without prop drilling through the app shell.
export const fullscreenPreviewAutoplay = ref<boolean>(read())
watch(fullscreenPreviewAutoplay, (v) => {
try {
localStorage.setItem(STORAGE_KEY, String(v))
} catch {
/* ignore */
}
})
export function useFullscreenPreviewAutoplay() {
return { enabled: fullscreenPreviewAutoplay }
}
+53
View File
@@ -0,0 +1,53 @@
import { useI18n } from '@/i18n'
import { useSceneStore } from '@/stores/scene'
import { runAll, type TestResult } from './testRunner'
import { registerAllTests } from './tests'
declare global {
interface Window {
__ruwioTestResults?: TestResult[]
}
}
function shouldRunSuite(name: string, desktop: boolean): boolean {
if (name.startsWith('desktop:')) return desktop
if (name.startsWith('browser:')) return !desktop
return true
}
export async function runHeadlessTests(): Promise<TestResult[]> {
const desktop = !!window.ruwioDesktop
const store = useSceneStore()
const { locale } = useI18n()
const snapshot = JSON.parse(JSON.stringify(store.$state))
const previousLocale = locale.value
document.body.setAttribute('data-tests-done', 'running')
document.body.setAttribute('data-test-host', desktop ? 'electron' : 'browser')
locale.value = 'en'
try {
const results = await runAll(registerAllTests, {
interTestDelayMs: 0,
suiteFilter: (name) => shouldRunSuite(name, desktop),
})
window.__ruwioTestResults = results
document.body.setAttribute('data-tests-done', results.some((r) => !r.ok) ? 'fail' : 'pass')
return results
} catch (err) {
const result: TestResult = {
suite: 'headless',
name: 'runAll',
ok: false,
error: (err as Error).message || String(err),
stack: (err as Error).stack,
durationMs: 0,
}
window.__ruwioTestResults = [result]
document.body.setAttribute('data-tests-done', 'fail')
return [result]
} finally {
store.$state = snapshot
locale.value = previousLocale
}
}
+4 -2
View File
@@ -133,6 +133,7 @@ export interface RunOptions {
onTestStart?: (suite: string, name: string) => void
onResult?: (result: TestResult) => void
interTestDelayMs?: number
suiteFilter?: (suite: string) => boolean
}
export async function runAll(
@@ -141,11 +142,12 @@ export async function runAll(
): Promise<TestResult[]> {
suites.length = 0
register()
const total = suites.reduce((a, s) => a + s.tests.length, 0)
const runnableSuites = opts.suiteFilter ? suites.filter((s) => opts.suiteFilter?.(s.name)) : suites
const total = runnableSuites.reduce((a, s) => a + s.tests.length, 0)
const results: TestResult[] = []
let done = 0
const delay = Math.max(0, opts.interTestDelayMs ?? 0)
for (const s of suites) {
for (const s of runnableSuites) {
for (const t of s.tests) {
opts.onTestStart?.(s.name, t.name)
await new Promise((r) => requestAnimationFrame(() => r(null)))
+441 -13
View File
@@ -34,6 +34,7 @@ import { drawButtonFrame, drawSceneFrame, renderButtonThumbnail } from '@/lib/re
import { t as i18nT } from '@/i18n'
import { expandedTrackOrder } from '@/lib/sceneOrder'
import { CLIP_FALLBACK_BOUNDS, computeClipBounds } from '@/lib/clipBounds'
import { renderSceneFrameForOutput } from '@/lib/camera'
import {
deleteStoredProject,
getStoredProject,
@@ -43,6 +44,7 @@ import {
import Modal from '@/components/ui/Modal.vue'
import IconButton from '@/components/ui/IconButton.vue'
import Toast from '@/components/ui/Toast.vue'
import App from '@/App.vue'
import PropertiesPanel from '@/components/PropertiesPanel.vue'
import ClipartDrawer from '@/components/ClipartDrawer.vue'
import SceneDrawer from '@/components/SceneDrawer.vue'
@@ -55,6 +57,7 @@ import ExportDialog from '@/components/ExportDialog.vue'
import WelcomeDialog from '@/components/WelcomeDialog.vue'
import FullscreenPreview from '@/components/FullscreenPreview.vue'
import HelpDialog from '@/components/HelpDialog.vue'
import { fullscreenPreviewAutoplay } from '@/composables/useFullscreenPreviewAutoplay'
import { createAudioRuntime } from '@/composables/useAudioRuntime'
import {
clearWaveformCache,
@@ -1397,7 +1400,7 @@ export function registerAllTests(): void {
expect(sceneScript).toContain(ext)
})
test('opening the fullscreen preview pauses the editor playback', async () => {
test('opening the fullscreen preview pauses the editor playback and mounts runtime canvas', async () => {
// Two runtimes fighting for the same audio output = garbled.
// FullscreenPreview pauses `store.playing` so the editor's own
// useAudioRuntime quiets down while the preview owns playback.
@@ -1408,8 +1411,108 @@ export function registerAllTests(): void {
await nextTick()
await nextTick()
expect(s.playing).toBe(false)
await tick()
const frame = document.body.querySelector('.fs-frame') as HTMLElement | null
expect(frame).toBeTruthy()
expect(frame?.querySelector('canvas')).toBeTruthy()
handle.unmount()
})
test('opening the fullscreen preview mounts runtime canvas with camera enabled', async () => {
const s = makeFreshStore()
s.enableCamera()
s.setCameraBaseProps({ x: 10, y: 20, width: 320, height: 180 })
const handle = mountComponent(FullscreenPreview, { modelValue: true })
await nextTick()
await nextTick()
await tick()
const frame = document.body.querySelector('.fs-frame') as HTMLElement | null
expect(frame).toBeTruthy()
expect(frame?.querySelector('canvas')).toBeTruthy()
handle.unmount()
})
test('fullscreen preview autoplay preference controls the initial transport state', async () => {
makeFreshStore()
const before = fullscreenPreviewAutoplay.value
const primaryButton = () =>
document.body.querySelector('.fs-toolbar .fs-btn') as HTMLButtonElement | null
try {
fullscreenPreviewAutoplay.value = false
const pausedHandle = mountComponent(FullscreenPreview, { modelValue: true })
await nextTick()
await nextTick()
expect(primaryButton()?.getAttribute('aria-label')).toBe('Play')
pausedHandle.unmount()
await nextTick()
fullscreenPreviewAutoplay.value = true
const playingHandle = mountComponent(FullscreenPreview, { modelValue: true })
await nextTick()
await nextTick()
expect(primaryButton()?.getAttribute('aria-label')).toBe('Pause')
playingHandle.unmount()
} finally {
fullscreenPreviewAutoplay.value = before
}
})
test('top transport fullscreen button opens the preview and pauses editor playback', async () => {
const s = makeFreshStore()
s.play()
expect(s.playing).toBe(true)
const handle = mountComponent(App, {})
try {
await nextTick()
await nextTick()
const btn = handle.host.querySelector(
`[aria-label="${i18nT('app.fullscreenPlayback')}"]`,
) as HTMLButtonElement | null
expect(btn).toBeTruthy()
btn!.click()
await nextTick()
await nextTick()
expect(s.playing).toBe(false)
expect(document.body.querySelector('.fs-root')).toBeTruthy()
expect(document.body.querySelector('.fs-frame')).toBeTruthy()
} finally {
handle.unmount()
}
})
test('camera output renders off-stage content over the scene background', () => {
const s = makeFreshStore()
s.scene.canvas.width = 100
s.scene.canvas.height = 100
s.scene.canvas.background = '#123456'
const rect = s.addAsset({
kind: 'rect',
name: 'Outside',
width: 10,
height: 10,
fill: '#ff0000',
})
const layer = s.addLayer(rect.id)!
s.upsertKeyframe(layer.id, 0, { x: -15, y: 50 })
s.enableCamera()
s.setCameraBaseProps({ x: -50, y: 0, width: 100, height: 100 })
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
renderSceneFrameForOutput(ctx, s.scene, 0, {}, { width: 100, height: 100 })
const outsideStageBg = ctx.getImageData(10, 50, 1, 1).data
expect(Array.from(outsideStageBg.slice(0, 3))).toEqual([0x12, 0x34, 0x56])
expect(outsideStageBg[3]).toBe(255)
const outsideStageLayer = ctx.getImageData(35, 50, 1, 1).data
expect(outsideStageLayer[0]).toBeGreaterThan(200)
expect(outsideStageLayer[1]).toBeLessThan(40)
expect(outsideStageLayer[2]).toBeLessThan(40)
expect(outsideStageLayer[3]).toBe(255)
})
})
suite('export — target size scaling', () => {
@@ -10181,6 +10284,334 @@ export function registerAllTests(): void {
})
})
suite('ui — camera framing', () => {
function resetPropertySectionPrefs() {
try {
localStorage.removeItem('animatr:propSections')
} catch {
/* noop */
}
}
function stubLaneRect(lane: HTMLElement, width = 1000): void {
const fakeRect = {
left: 0,
top: 0,
width,
height: 20,
right: width,
bottom: 20,
x: 0,
y: 0,
toJSON: () => ({}),
} as DOMRect
;(lane as unknown as { getBoundingClientRect: () => DOMRect }).getBoundingClientRect = () =>
fakeRect
}
async function mountCameraCanvasWithKonva() {
const VueKonva = (await import('vue-konva')).default
const host = document.createElement('div')
host.setAttribute('data-test-host', '')
host.style.width = '800px'
host.style.height = '600px'
document.body.appendChild(host)
const app = createApp({ render() { return h(CanvasStage, {}) } })
app.use(VueKonva)
app.directive('tooltip', vTooltip)
app.mount(host)
return {
host,
app,
unmount() {
app.unmount()
host.remove()
},
}
}
async function getCameraKonvaStage() {
const Konva = (await import('konva')).default
return Konva.stages[Konva.stages.length - 1]
}
test('stage camera button enables camera without resizing the editor stage', async () => {
const s = makeFreshStore()
s.scene.canvas.width = 640
s.scene.canvas.height = 360
const handle = mountComponent(CanvasStage, {})
try {
await nextTick()
const dims = handle.host.querySelector('.dims') as HTMLElement
const btn = handle.host.querySelector('.zoom-controls .camera-btn') as HTMLButtonElement
expect(dims).toBeTruthy()
expect(btn).toBeTruthy()
expect(dims.textContent?.trim()).toBe('640 × 360')
expect(s.scene.camera).toBe(undefined)
btn.click()
await nextTick()
expect(s.scene.camera?.enabled).toBe(true)
expect(s.selectedCamera).toBe(true)
expect(btn.classList.contains('active')).toBe(true)
expect(dims.textContent?.trim()).toBe('640 × 360')
expect(s.scene.canvas.width).toBe(640)
expect(s.scene.canvas.height).toBe(360)
btn.click()
await nextTick()
expect(s.scene.camera?.enabled).toBe(false)
expect(dims.textContent?.trim()).toBe('640 × 360')
} finally {
handle.unmount()
}
})
test('properties panel edits the selected camera frame, not scene canvas size', async () => {
resetPropertySectionPrefs()
const s = makeFreshStore()
s.scene.canvas.width = 800
s.scene.canvas.height = 450
s.enableCamera()
s.selectCamera(true)
const handle = mountComponent(PropertiesPanel, {})
try {
await nextTick()
const banner = handle.host.querySelector('.multi-banner') as HTMLElement
expect(banner.textContent).toContain(i18nT('properties.camera.banner'))
const inputs = handle.host.querySelectorAll<HTMLInputElement>(
'.prop-group input[type="number"]',
)
expect(inputs.length).toBe(4)
const [xInput, yInput, widthInput, heightInput] = Array.from(inputs)
expect(Number(widthInput.value)).toBe(800)
expect(Number(heightInput.value)).toBe(450)
xInput.value = '24'
xInput.dispatchEvent(new Event('input', { bubbles: true }))
widthInput.value = '320'
widthInput.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
expect(s.scene.camera?.keyframes.length).toBe(1)
expect(s.scene.camera?.keyframes[0].props.x).toBe(24)
expect(s.scene.camera?.keyframes[0].props.width).toBe(320)
expect(s.scene.camera?.keyframes[0].props.height).toBe(180)
expect(s.scene.canvas.width).toBe(800)
expect(s.scene.canvas.height).toBe(450)
void yInput
} finally {
handle.unmount()
}
})
test('stage camera frame has one lower-right aspect resize handle', async () => {
const s = makeFreshStore()
s.scene.canvas.width = 800
s.scene.canvas.height = 450
s.editor.canvasZoom = 1
s.enableCamera()
s.selectCamera(true)
const handle = await mountCameraCanvasWithKonva()
try {
await nextTick()
await tick(60)
const stage = await getCameraKonvaStage() as unknown as {
find: (sel: string) => unknown[]
findOne: (sel: string) => unknown
}
const frame = stage.findOne('#camera-frame') as {
strokeWidth: () => number
dash: () => number[]
}
expect(frame).toBeTruthy()
expect(frame.strokeWidth()).toBeLessThan(2.5)
const dash = frame.dash()
expect(dash.length).toBe(2)
expect(dash[0]).toBeLessThan(10)
expect(dash[0] / dash[1]).toBeCloseTo(4 / 3, 4)
const handles = stage.find('.camera-resize-handle') as Array<{
x: (v?: number) => number
y: (v?: number) => number
width: () => number
height: () => number
fire: (type: string, evt: unknown, bubble: boolean) => void
}>
expect(handles.length).toBe(1)
const grip = handles[0]
const half = grip.width() / 2
expect(grip.height()).toBe(grip.width())
grip.fire('dragstart', { target: grip, type: 'dragstart' }, true)
grip.x(400 - half)
grip.y(225 - half)
grip.fire('dragmove', { target: grip, type: 'dragmove' }, true)
await nextTick()
const kf = s.scene.camera?.keyframes[0]
expect(s.scene.camera?.keyframes.length).toBe(1)
expect(kf?.props.x).toBe(0)
expect(kf?.props.y).toBe(0)
expect(kf?.props.width).toBe(400)
expect(kf?.props.height).toBe(225)
expect(s.scene.canvas.width).toBe(800)
expect(s.scene.canvas.height).toBe(450)
} finally {
handle.unmount()
}
})
test('timeline camera row selects, hides, locks, and adds camera keyframes', async () => {
const s = makeFreshStore()
s.setDuration(10)
s.enableCamera()
s.selectCamera(false)
const handle = mountComponent(Timeline, {})
try {
await nextTick()
const trackName = handle.host.querySelector('.camera-track .track-name') as HTMLElement
const eye = handle.host.querySelector('.camera-track .eye-btn') as HTMLButtonElement
const lock = handle.host.querySelector('.camera-track .lock-btn') as HTMLButtonElement
const lane = handle.host.querySelector('.camera-track .camera-lane') as HTMLElement
expect(trackName).toBeTruthy()
expect(eye).toBeTruthy()
expect(lock).toBeTruthy()
expect(lane).toBeTruthy()
trackName.click()
await nextTick()
expect(s.selectedCamera).toBe(true)
eye.click()
await nextTick()
expect(s.scene.camera?.overlayVisible).toBe(false)
expect(eye.classList.contains('hidden')).toBe(true)
lock.click()
await nextTick()
expect(s.scene.camera?.locked).toBe(true)
expect(lock.classList.contains('locked')).toBe(true)
stubLaneRect(lane)
lane.dispatchEvent(
new MouseEvent('dblclick', { bubbles: true, cancelable: true, clientX: 500 }),
)
await nextTick()
expect(s.scene.camera?.keyframes.length).toBe(0)
lock.click()
await nextTick()
lane.dispatchEvent(
new MouseEvent('dblclick', { bubbles: true, cancelable: true, clientX: 500 }),
)
await nextTick()
expect(s.scene.camera?.keyframes.length).toBe(1)
expect(Math.abs((s.scene.camera?.keyframes[0].time ?? 0) - 5) < 0.1).toBe(true)
expect(Math.abs(s.playhead - 5) < 0.1).toBe(true)
await nextTick()
const cameraKf = handle.host.querySelector('.camera-track .camera-kf') as HTMLElement
expect(cameraKf).toBeTruthy()
const captured = new Set<number>()
;(cameraKf as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture = (id) => {
captured.add(id)
}
;(cameraKf as unknown as { releasePointerCapture: (id: number) => void }).releasePointerCapture = (id) => {
captured.delete(id)
}
;(cameraKf as unknown as { hasPointerCapture: (id: number) => boolean }).hasPointerCapture = (id) =>
captured.has(id)
cameraKf.dispatchEvent(
new PointerEvent('pointerdown', {
bubbles: true,
cancelable: true,
button: 0,
pointerId: 1,
clientX: 500,
}),
)
cameraKf.dispatchEvent(
new PointerEvent('pointermove', {
bubbles: true,
cancelable: true,
pointerId: 1,
clientX: 700,
}),
)
cameraKf.dispatchEvent(
new PointerEvent('pointerup', {
bubbles: true,
cancelable: true,
pointerId: 1,
clientX: 700,
}),
)
cameraKf.click()
await nextTick()
expect(s.scene.camera?.keyframes.length).toBe(1)
expect(Math.abs((s.scene.camera?.keyframes[0].time ?? 0) - 7) < 0.1).toBe(true)
expect(Math.abs(s.playhead - 7) < 0.1).toBe(true)
lane.dispatchEvent(
new MouseEvent('dblclick', { bubbles: true, cancelable: true, clientX: 900 }),
)
await nextTick()
expect(s.scene.camera?.keyframes.length).toBe(2)
const secondCameraKf = s.scene.camera?.keyframes.find((kf) => Math.abs(kf.time - 9) < 0.1)
expect(secondCameraKf).toBeTruthy()
const cameraKfs = Array.from(
handle.host.querySelectorAll<HTMLElement>('.camera-track .camera-kf'),
)
const secondKfEl = cameraKfs[cameraKfs.length - 1]
secondKfEl.dispatchEvent(
new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
clientX: 320,
clientY: 180,
}),
)
await nextTick()
let menu = handle.host.querySelector('.kf-menu') as HTMLElement
expect(menu).toBeTruthy()
const easeIn = Array.from(menu.querySelectorAll<HTMLButtonElement>('.kf-menu-item')).find(
(button) => button.textContent?.includes(i18nT('timeline.easingEaseIn')),
)
expect(easeIn).toBeTruthy()
expect(easeIn?.disabled).toBe(false)
easeIn!.click()
await nextTick()
expect(secondCameraKf?.easing).toBe('easeIn')
expect(handle.host.querySelector('.camera-track .kf-seg.easing-easeIn')).toBeTruthy()
secondKfEl.dispatchEvent(
new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
clientX: 320,
clientY: 180,
}),
)
await nextTick()
menu = handle.host.querySelector('.kf-menu') as HTMLElement
const deleteButton = Array.from(menu.querySelectorAll<HTMLButtonElement>('.kf-menu-item')).find(
(button) => button.textContent?.includes(i18nT('timeline.delete')),
)
expect(deleteButton).toBeTruthy()
deleteButton!.click()
await nextTick()
expect(s.scene.camera?.keyframes.length).toBe(1)
expect(s.scene.camera?.keyframes.some((kf) => kf.id === secondCameraKf?.id)).toBe(false)
} finally {
handle.unmount()
}
})
})
suite('ui — CanvasStage centre crosshair (Konva)', () => {
// Verify the lines actually render at the correct coordinates in
// Konva when the toggle is on. Requires a proper VueKonva mount.
@@ -15179,12 +15610,9 @@ export function registerAllTests(): void {
// showed up in fullscreen-preview AND in standalone exports.
const { generateRuntimeScript } = await import('@/lib/exportPlayer')
const src = generateRuntimeScript()
const match = src.match(/function layerActiveAt\(layer,time\)\{([\s\S]*?)\n {4}\}/)
expect(!!match).toBe(true)
const body = match![1]
// Build a callable from the function body so we can poke the
// runtime's gate logic directly without mounting a player.
const layerActiveAt = new Function('layer', 'time', body) as (layer: unknown, time: number) => boolean
expect(src).toContain('function layerActiveAt(layer,time){')
const layerActiveAt = (layer: unknown, time: number) =>
isLayerActiveAt(layer as Layer, time)
// (a) Single normal kf at t=2 → invisible before t=2.
const a = { keyframes: [{ time: 2, kind: 'normal' }] }
expect(layerActiveAt(a, 0)).toBe(false)
@@ -15554,7 +15982,7 @@ export function registerAllTests(): void {
expect(src).toContain('clipKaleidoPivot')
expect(src).toContain('clipArmLocalT')
// Runtime version got bumped during the kaleido + wiggle work.
expect(src).toContain("'34'")
expect(src).toContain("'36'")
})
test('exported runtime layerActiveAt is unaffected by kaleido fields', async () => {
@@ -15562,9 +15990,9 @@ export function registerAllTests(): void {
// visibility gate for layers without kaleido — same fixtures as
// the layerActiveAt parity test above.
const src = generateRuntimeScript()
const match = src.match(/function layerActiveAt\(layer,time\)\{([\s\S]*?)\n {4}\}/)
expect(!!match).toBe(true)
const layerActiveAt = new Function('layer', 'time', match![1]) as (layer: unknown, time: number) => boolean
expect(src).toContain('function layerActiveAt(layer,time){')
const layerActiveAt = (layer: unknown, time: number) =>
isLayerActiveAt(layer as Layer, time)
const a = { keyframes: [{ time: 2, kind: 'normal' }], kaleido: true, kaleidoCount: 4 }
expect(layerActiveAt(a, 0)).toBe(false)
expect(layerActiveAt(a, 2)).toBe(true)
@@ -15932,7 +16360,7 @@ export function registerAllTests(): void {
expect(src).toContain('function wiggleSeeds(')
// Runtime version got bumped through the wiggle work and the
// later layer-kaleido-pivot landing.
expect(src).toContain("'34'")
expect(src).toContain("'36'")
})
})
@@ -16464,7 +16892,7 @@ export function registerAllTests(): void {
const src = generateRuntimeScript()
expect(src).toContain('function layerArmTf(')
// Bumped on this work.
expect(src).toContain("'34'")
expect(src).toContain("'36'")
})
})
}
+67 -20
View File
@@ -6,6 +6,7 @@ export const en = {
pause: 'Pause (Space)',
play: 'Play (Space)',
stop: 'Stop',
fullscreenPlayback: 'Fullscreen playback (Shift+Space)',
recentProjects: 'Recent projects',
copyStill: {
label: 'Copy current frame',
@@ -39,8 +40,10 @@ export const en = {
untitledProject: 'Untitled project',
saveProjectDirtyHelp:
'Your project has unsaved changes. Ctrl/Cmd+S writes them to browser storage.',
exportHelp:
'Save as runtime script, HTML, or video. MP4 needs the render server; WebM records in real time in the browser.',
exportHelpServer:
'Save as runtime script, HTML, or video. MP4 uses the render server; WebM records in real time with MediaRecorder.',
exportHelpDesktop:
"Save as runtime script, HTML, or video. MP4 renders in the desktop app with bundled ffmpeg; WebM records in Electron's Chromium runtime.",
replaceDialog: {
title: 'Replace project?',
message:
@@ -191,6 +194,15 @@ export const en = {
noLayer: 'No layer selected.',
lockedLayer:
'Selected layer is locked. Unlock the track (or its group) to edit properties.',
camera: {
banner: 'Output camera selected',
frame: 'Camera Frame',
enabled: 'Enabled',
overlay: 'Overlay',
locked: 'Locked',
width: 'Width',
height: 'Height',
},
fill: 'Fill',
transparent: 'Transparent',
eyedropper: 'Pick a colour from anywhere on the page',
@@ -337,6 +349,7 @@ export const en = {
renameGroup: 'Rename group',
renameScriptTrack: 'Rename script track',
resizeTrackColumn: 'Drag to resize track name column',
cameraTrackName: 'Camera',
clipTrackName: 'Clip track',
toggleTimeUnit: 'Toggle time unit',
snapOn: 'Snapping on (playhead & keyframes snap together)',
@@ -400,6 +413,8 @@ export const en = {
stagePreviewOff: 'Turn stage preview off — buttons behave as normal assets',
centerCrosshairOn: 'Show centre crosshair',
centerCrosshairOff: 'Hide centre crosshair',
cameraOn: 'Enable output camera',
cameraOff: 'Disable output camera',
motionPaths: {
label: 'Show motion paths',
help: 'Draws the position-keyframe trajectory of the selected layer on the canvas. Click a dot to seek to that keyframe.',
@@ -453,6 +468,9 @@ export const en = {
welcomeOnStartLabel: 'Show welcome screen on start',
welcomeOnStartDesc:
'Open the welcome / recents dialog when RUWIO launches. Off → boot straight into the last edited project. The last file is always restored either way; the welcome dialog just gives you a chance to switch.',
fullscreenAutoplayLabel: 'Autoplay in fullscreen preview',
fullscreenAutoplayDesc:
'Start playback immediately when fullscreen preview opens. When off, fullscreen opens paused and Space starts it.',
bgVideoLabel: 'Video',
bgVideoNone: 'No background video',
bgVideoPickFile: 'Pick video file…',
@@ -509,10 +527,14 @@ export const en = {
sceneDesc: 'Scene data only — requires the runtime to be loaded',
exampleTitle: 'Example HTML',
exampleDesc: 'Standalone HTML — just open it in a browser',
mp4Badge: 'Server · Frame-accurate',
mp4Desc: 'H.264, any resolution — requires render server',
webmBadge: 'Browser · Realtime',
webmDesc: 'No server — recorded in real time',
mp4ServerBadge: 'Server · Frame-by-frame',
mp4ServerDesc: 'H.264 export via render server',
mp4DesktopBadge: 'Desktop · Frame-by-frame',
mp4DesktopDesc: 'H.264 export via bundled ffmpeg',
webmBrowserBadge: 'Browser · Realtime',
webmBrowserDesc: 'Recorded in real time with MediaRecorder',
webmDesktopBadge: 'Chromium · Realtime',
webmDesktopDesc: "Recorded in real time by Electron's Chromium runtime",
webmNotSupported: 'Browser does not support MediaRecorder',
gifBadge: 'Browser · Frame-by-frame',
gifDesc: '256-colour palette, broadest compatibility',
@@ -524,8 +546,10 @@ export const en = {
cancel: 'Cancel',
failed: 'Export failed',
back: 'Back',
labelWebm: 'Rendering WebM (browser, realtime)…',
labelMp4: 'Rendering MP4 (server)…',
labelWebmBrowser: 'Rendering WebM in browser realtime...',
labelWebmDesktop: 'Rendering WebM in Chromium realtime...',
labelMp4Server: 'Rendering MP4 with render server...',
labelMp4Desktop: 'Rendering MP4 in desktop app...',
labelGif: 'Rendering GIF (browser, frame-by-frame)…',
labelApng: 'Rendering APNG (browser, frame-by-frame)…',
labelApngPreview: 'Rendering APNG + preview page…',
@@ -1078,9 +1102,13 @@ play
html:
'<strong>HTML example:</strong> A quick standalone page with everything wired up.',
mp4:
'<strong>MP4 video:</strong> Frame-accurate server render. This needs the render server configured in Settings.',
'<strong>MP4 video:</strong> Frame-by-frame H.264 export via the render server.',
mp4Desktop:
'<strong>MP4 video:</strong> Frame-by-frame H.264 export in the desktop app with bundled ffmpeg.',
webm:
'<strong>WebM video:</strong> Browser-side realtime recording. This depends on <code>MediaRecorder</code> support.',
'<strong>WebM video:</strong> Browser realtime recording with <code>MediaRecorder</code>.',
webmDesktop:
"<strong>WebM video:</strong> Realtime recording with <code>MediaRecorder</code> in Electron's Chromium runtime.",
},
skipHiddenTitle: 'Skip hidden layers',
skipHiddenBody:
@@ -1116,6 +1144,7 @@ export const de: DeepPartial<Messages> = {
pause: 'Pause (Leertaste)',
play: 'Abspielen (Leertaste)',
stop: 'Stop',
fullscreenPlayback: 'Fullscreen-Wiedergabe (Shift+Leertaste)',
recentProjects: 'Letzte Projekte',
copyStill: {
label: 'Aktuellen Frame kopieren',
@@ -1149,8 +1178,10 @@ export const de: DeepPartial<Messages> = {
untitledProject: 'Unbenanntes Projekt',
saveProjectDirtyHelp:
'Dein Projekt hat ungespeicherte Änderungen. Mit Ctrl/Cmd+S wird es im Browser gespeichert.',
exportHelp:
'Als Runtime, HTML oder Video exportieren. MP4 braucht den Render-Server. WebM wird direkt im Browser in Echtzeit aufgenommen.',
exportHelpServer:
'Als Runtime, HTML oder Video exportieren. MP4 nutzt den Render-Server. WebM wird in Echtzeit mit MediaRecorder aufgenommen.',
exportHelpDesktop:
'Als Runtime, HTML oder Video exportieren. MP4 rendert in der Desktop-App mit gebündeltem ffmpeg. WebM wird in Electrons Chromium-Runtime aufgenommen.',
replaceDialog: {
title: 'Projekt ersetzen?',
message:
@@ -1308,6 +1339,12 @@ export const de: DeepPartial<Messages> = {
assetThumbsLabel: 'Asset-Vorschaubilder',
assetThumbsDesc:
'Zeigt kleine Vorschauen in Assets, Clips und Buttons. Aus ist kompakter, an ist übersichtlicher.',
welcomeOnStartLabel: 'Willkommensfenster beim Start anzeigen',
welcomeOnStartDesc:
'Öffnet beim Start von RUWIO den Willkommen-/Letzte-Projekte-Dialog. Aus startet direkt mit dem zuletzt bearbeiteten Projekt.',
fullscreenAutoplayLabel: 'Autoplay in Fullscreen-Vorschau',
fullscreenAutoplayDesc:
'Startet die Wiedergabe sofort, wenn die Fullscreen-Vorschau geöffnet wird. Wenn aus, öffnet sie pausiert und Leertaste startet sie.',
bgVideoLabel: 'Video',
bgVideoNone: 'Kein Hintergrundvideo',
bgVideoPickFile: 'Videodatei wählen…',
@@ -1363,10 +1400,14 @@ export const de: DeepPartial<Messages> = {
sceneDesc: 'Nur Projektdaten: Die Runtime muss schon geladen sein.',
exampleTitle: 'HTML-Beispiel',
exampleDesc: 'Eigenständige HTML-Datei: Einfach im Browser öffnen.',
mp4Badge: 'Server · Frame-genau',
mp4Desc: 'H.264 in jeder Auflösung: braucht den Render-Server.',
webmBadge: 'Browser · Echtzeit',
webmDesc: 'Ohne Server: direkt im Browser aufgenommen.',
mp4ServerBadge: 'Server · Frame-für-Frame',
mp4ServerDesc: 'H.264-Export über den Render-Server',
mp4DesktopBadge: 'Desktop · Frame-für-Frame',
mp4DesktopDesc: 'H.264-Export über gebündeltes ffmpeg',
webmBrowserBadge: 'Browser · Echtzeit',
webmBrowserDesc: 'Echtzeit-Aufnahme mit MediaRecorder',
webmDesktopBadge: 'Chromium · Echtzeit',
webmDesktopDesc: 'Echtzeit-Aufnahme durch Electrons Chromium-Runtime',
webmNotSupported: 'Dieser Browser unterstützt MediaRecorder nicht.',
gifBadge: 'Browser · Frame-für-Frame',
gifDesc: '256-Farben-Palette, maximale Kompatibilität.',
@@ -1378,8 +1419,10 @@ export const de: DeepPartial<Messages> = {
cancel: 'Abbrechen',
failed: 'Export fehlgeschlagen',
back: 'Zurück',
labelWebm: 'WebM wird gerendert (Browser, Echtzeit)…',
labelMp4: 'MP4 wird gerendert (Server)…',
labelWebmBrowser: 'WebM wird in Browser-Echtzeit gerendert...',
labelWebmDesktop: 'WebM wird in Chromium-Echtzeit gerendert...',
labelMp4Server: 'MP4 wird mit dem Render-Server gerendert...',
labelMp4Desktop: 'MP4 wird in der Desktop-App gerendert...',
labelGif: 'GIF wird gerendert (Browser, frame-für-frame)…',
labelApng: 'APNG wird gerendert (Browser, frame-für-frame)…',
labelApngPreview: 'APNG + Vorschau werden gerendert…',
@@ -1920,9 +1963,13 @@ play
html:
'<strong>HTML-Beispiel:</strong> Eine schnelle eigenständige Seite mit allem fertig verdrahtet.',
mp4:
'<strong>MP4-Video:</strong> Frame-genauer Server-Render. Dafür muss der Render-Server in den Einstellungen konfiguriert sein.',
'<strong>MP4-Video:</strong> Frame-für-Frame-H.264-Export über den Render-Server.',
mp4Desktop:
'<strong>MP4-Video:</strong> Frame-für-Frame-H.264-Export in der Desktop-App mit gebündeltem ffmpeg.',
webm:
'<strong>WebM-Video:</strong> Browser-seitige Echtzeit-Aufnahme. Das hängt von <code>MediaRecorder</code> ab.',
'<strong>WebM-Video:</strong> Echtzeit-Aufnahme im Browser mit <code>MediaRecorder</code>.',
webmDesktop:
'<strong>WebM-Video:</strong> Echtzeit-Aufnahme mit <code>MediaRecorder</code> in Electrons Chromium-Runtime.',
},
skipHiddenTitle: 'Versteckte Ebenen auslassen',
skipHiddenBody:
+201
View File
@@ -0,0 +1,201 @@
import type { CameraKeyframe, CameraProps, CameraTrack, Easing, Scene } from '@/types/scene'
import { drawSceneFrame } from './render'
const CAMERA_MIN_SIZE = 1
const easings: Record<Easing, (t: number) => number> = {
linear: (t) => t,
easeIn: (t) => t * t,
easeOut: (t) => 1 - (1 - t) ** 2,
easeInOut: (t) => (t < 0.5 ? 2 * t * t : 1 - ((-2 * t + 2) ** 2) / 2),
}
function uid() {
return Math.random().toString(36).slice(2, 10)
}
function finite(value: unknown, fallback: number): number {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
}
function positive(value: unknown, fallback: number): number {
return Math.max(CAMERA_MIN_SIZE, finite(value, fallback))
}
function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t
}
function mergeCameraProps(base: CameraProps, props: Partial<CameraProps> | undefined): CameraProps {
return {
x: finite(props?.x, base.x),
y: finite(props?.y, base.y),
width: positive(props?.width, base.width),
height: positive(props?.height, base.height),
}
}
function lerpCameraProps(a: CameraProps, b: CameraProps, t: number): CameraProps {
return {
x: lerp(a.x, b.x, t),
y: lerp(a.y, b.y, t),
width: positive(lerp(a.width, b.width, t), a.width),
height: positive(lerp(a.height, b.height, t), a.height),
}
}
export function defaultCameraProps(scene: Pick<Scene, 'canvas'>): CameraProps {
return {
x: 0,
y: 0,
width: positive(scene.canvas?.width, 1280),
height: positive(scene.canvas?.height, 720),
}
}
export function createDefaultCameraTrack(
scene: Pick<Scene, 'canvas'>,
enabled = true,
): CameraTrack {
return {
id: uid(),
name: 'Camera',
enabled,
base: defaultCameraProps(scene),
keyframes: [],
overlayVisible: true,
locked: false,
}
}
export function normalizeCameraTrack(
scene: Pick<Scene, 'canvas'>,
camera: unknown,
): CameraTrack | undefined {
if (!camera || typeof camera !== 'object') return undefined
const raw = camera as Partial<CameraTrack>
const fallback = defaultCameraProps(scene)
const base = mergeCameraProps(fallback, raw.base)
const keyframes = Array.isArray(raw.keyframes)
? raw.keyframes.flatMap((kf): CameraKeyframe[] => {
if (!kf || typeof kf !== 'object') return []
const rawKf = kf as Partial<CameraKeyframe>
const props = mergeCameraProps(base, rawKf.props)
const storedProps: Partial<CameraProps> = {}
if (rawKf.props && typeof rawKf.props === 'object') {
const p = rawKf.props as Partial<CameraProps>
if (typeof p.x === 'number' && Number.isFinite(p.x)) storedProps.x = props.x
if (typeof p.y === 'number' && Number.isFinite(p.y)) storedProps.y = props.y
if (typeof p.width === 'number' && Number.isFinite(p.width)) storedProps.width = props.width
if (typeof p.height === 'number' && Number.isFinite(p.height)) storedProps.height = props.height
}
return [
{
id: typeof rawKf.id === 'string' && rawKf.id ? rawKf.id : uid(),
time: Math.max(0, finite(rawKf.time, 0)),
props: storedProps,
easing:
rawKf.easing === 'easeIn' ||
rawKf.easing === 'easeOut' ||
rawKf.easing === 'easeInOut' ||
rawKf.easing === 'linear'
? rawKf.easing
: 'linear',
hold: rawKf.hold === true,
},
]
})
: []
keyframes.sort((a, b) => a.time - b.time)
return {
id: typeof raw.id === 'string' && raw.id ? raw.id : uid(),
name: typeof raw.name === 'string' ? raw.name : 'Camera',
enabled: raw.enabled === true,
base,
keyframes,
overlayVisible: raw.overlayVisible !== false,
locked: raw.locked === true,
}
}
export function isCameraEnabled(scene: Scene): boolean {
const camera = normalizeCameraTrack(scene, scene.camera)
return !!camera?.enabled
}
export function sampleCamera(scene: Scene, time: number): CameraProps {
const camera = normalizeCameraTrack(scene, scene.camera)
if (!camera) return defaultCameraProps(scene)
const base = mergeCameraProps(defaultCameraProps(scene), camera.base)
const sorted = camera.keyframes.slice().sort((a, b) => a.time - b.time)
if (!sorted.length) return base
if (time <= sorted[0].time) return mergeCameraProps(base, sorted[0].props)
const last = sorted[sorted.length - 1]
if (time >= last.time) return mergeCameraProps(base, last.props)
for (let i = 0; i < sorted.length - 1; i++) {
const a = sorted[i]
const b = sorted[i + 1]
if (time >= a.time && time <= b.time) {
if (a.hold) {
if (time >= b.time) return mergeCameraProps(base, b.props)
return mergeCameraProps(base, a.props)
}
const span = Math.max(1e-9, b.time - a.time)
const t = easings[b.easing]((time - a.time) / span)
return lerpCameraProps(mergeCameraProps(base, a.props), mergeCameraProps(base, b.props), t)
}
}
return base
}
export function cameraAspect(scene: Scene, time = 0): number {
const camera = sampleCamera(scene, time)
return camera.width / camera.height
}
export function renderSceneFrameForOutput(
ctx: CanvasRenderingContext2D,
scene: Scene,
time: number,
images: Record<string, HTMLImageElement>,
options?: {
width?: number
height?: number
useCamera?: boolean
cropStage?: boolean
},
): void {
const outputWidth = Math.max(1, Math.round(options?.width ?? scene.canvas.width))
const outputHeight = Math.max(1, Math.round(options?.height ?? scene.canvas.height))
const useCamera = options?.useCamera ?? isCameraEnabled(scene)
if (!useCamera) {
ctx.save()
const scaleX = outputWidth / scene.canvas.width
const scaleY = outputHeight / scene.canvas.height
if (scaleX !== 1 || scaleY !== 1) ctx.scale(scaleX, scaleY)
drawSceneFrame(ctx, scene, time, images, { crop: options?.cropStage })
ctx.restore()
return
}
const camera = sampleCamera(scene, time)
ctx.save()
ctx.setTransform(1, 0, 0, 1, 0, 0)
ctx.clearRect(0, 0, outputWidth, outputHeight)
const bg = scene.canvas.background
if (!scene.backgroundVideo && bg && bg !== 'transparent') {
ctx.fillStyle = bg
ctx.fillRect(0, 0, outputWidth, outputHeight)
}
ctx.restore()
ctx.save()
ctx.scale(outputWidth / camera.width, outputHeight / camera.height)
ctx.translate(-camera.x, -camera.y)
drawSceneFrame(ctx, scene, time, images, {
crop: options?.cropStage ?? false,
clear: false,
background: false,
})
ctx.restore()
}
+65
View File
@@ -0,0 +1,65 @@
import type { Scene } from '@/types/scene'
export interface DesktopRenderOptions {
onProgress?: (progress: number) => void
signal?: AbortSignal
fps?: number
crf?: number
preset?: string
suggestedName?: string
}
export function hasDesktopRender(): boolean {
return !!window.ruwioDesktop?.renderMp4
}
export async function renderMp4OnDesktop(
scene: Scene,
opts: DesktopRenderOptions = {},
): Promise<{ path: string } | null> {
const desktop = window.ruwioDesktop
if (!desktop?.renderMp4) throw new Error('Desktop MP4 rendering is not available')
let jobId: string | null = null
let settled = false
let pendingAbort = false
const cleanupProgress = desktop.onRenderProgress?.((event) => {
jobId = event.jobId
if (typeof event.progress === 'number') opts.onProgress?.(event.progress)
if (pendingAbort && !settled && jobId) {
pendingAbort = false
void desktop.cancelRender?.(jobId)
}
})
const abort = () => {
if (settled) return
if (jobId) void desktop.cancelRender?.(jobId)
else pendingAbort = true
}
if (opts.signal) {
if (opts.signal.aborted) {
throw new DOMException('aborted', 'AbortError')
}
opts.signal.addEventListener('abort', abort, { once: true })
}
try {
const result = await desktop.renderMp4({
scene,
fps: opts.fps,
crf: opts.crf,
preset: opts.preset,
suggestedName: opts.suggestedName,
})
return result
} catch (err) {
if (opts.signal?.aborted) throw new DOMException('aborted', 'AbortError')
throw err
} finally {
settled = true
cleanupProgress?.()
opts.signal?.removeEventListener('abort', abort)
}
}
+89 -11
View File
@@ -11,7 +11,7 @@ function isProject(p: ExportPayload): p is Project {
return Array.isArray((p as Partial<Project>).scenes)
}
const RUNTIME_VERSION = '34'
const RUNTIME_VERSION = '36'
/** Materialize the engine source. The font map is rebuilt on every
* call so user-uploaded custom fonts (which live in localStorage
@@ -207,6 +207,57 @@ const ENGINE_TEMPLATE = `(function(){
return result;
}
function defaultCameraProps(scene){
return {
x:0,
y:0,
width:Math.max(1, Number(scene.canvas && scene.canvas.width) || 1280),
height:Math.max(1, Number(scene.canvas && scene.canvas.height) || 720)
};
}
function mergeCameraProps(base, props){
props=props||{};
function finite(v, fallback){ return typeof v==='number' && isFinite(v) ? v : fallback; }
return {
x:finite(props.x, base.x),
y:finite(props.y, base.y),
width:Math.max(1, finite(props.width, base.width)),
height:Math.max(1, finite(props.height, base.height))
};
}
function lerpCameraProps(a,b,t){
return {
x:lerp(a.x,b.x,t),
y:lerp(a.y,b.y,t),
width:Math.max(1, lerp(a.width,b.width,t)),
height:Math.max(1, lerp(a.height,b.height,t))
};
}
function sampleCamera(scene,time){
var camera=scene.camera;
var base=defaultCameraProps(scene);
if(!camera) return base;
base=mergeCameraProps(base, camera.base);
var kfs=(camera.keyframes||[]).slice().sort(function(a,b){return a.time-b.time;});
if(!kfs.length) return base;
if(time<=kfs[0].time) return mergeCameraProps(base,kfs[0].props);
var last=kfs[kfs.length-1];
if(time>=last.time) return mergeCameraProps(base,last.props);
for(var i=0;i<kfs.length-1;i++){
var a=kfs[i], b=kfs[i+1];
if(time>=a.time && time<=b.time){
if(a.hold){
if(time>=b.time) return mergeCameraProps(base,b.props);
return mergeCameraProps(base,a.props);
}
var raw=(time-a.time)/Math.max(1e-9,b.time-a.time);
var tt=easing(b.easing,raw);
return lerpCameraProps(mergeCameraProps(base,a.props), mergeCameraProps(base,b.props), tt);
}
}
return base;
}
function loadImages(assets){
var imgs={};
var list=assets.filter(function(a){return a.kind==='image';});
@@ -248,7 +299,8 @@ const ENGINE_TEMPLATE = `(function(){
trackOrder: page.trackOrder || [],
backgroundVideo: page.backgroundVideo,
audioTracks: page.audioTracks || [],
audioClips: page.audioClips || []
audioClips: page.audioClips || [],
camera: page.camera
};
}
if(payload && payload.scenes && payload.scenes.length){
@@ -283,7 +335,8 @@ const ENGINE_TEMPLATE = `(function(){
trackOrder: legacy.trackOrder || [],
backgroundVideo: legacy.backgroundVideo,
audioTracks: legacy.audioTracks || [],
audioClips: legacy.audioClips || []
audioClips: legacy.audioClips || [],
camera: legacy.camera
}],
activeSceneId: '__legacy__'
};
@@ -1110,20 +1163,31 @@ const ENGINE_TEMPLATE = `(function(){
var cw=size.w,ch=size.h;
if(!cw||!ch)return;
buttonInstancesInFrame.length=0;
var s=Math.min(cw/lw,ch/lh);
var ox=(cw-lw*s)/2;
var oy=(ch-lh*s)/2;
var useCamera=!!(scene.camera && scene.camera.enabled);
var cam=useCamera?sampleCamera(scene,time):null;
var vw=useCamera?cam.width:lw;
var vh=useCamera?cam.height:lh;
var s=Math.min(cw/vw,ch/vh);
var ox=(cw-vw*s)/2;
var oy=(ch-vh*s)/2;
ctx.setTransform(dpr,0,0,dpr,0,0);
ctx.clearRect(0,0,cw,ch);
ctx.save();
ctx.translate(ox,oy);
ctx.scale(s,s);
ctx.beginPath();
ctx.rect(0,0,lw,lh);
ctx.rect(0,0,vw,vh);
ctx.clip();
if(!scene.backgroundVideo){
if(useCamera && !scene.backgroundVideo && scene.canvas.background && scene.canvas.background !== 'transparent'){
ctx.fillStyle=scene.canvas.background;
ctx.fillRect(0,0,lw,lh);
ctx.fillRect(0,0,vw,vh);
}
if(useCamera) ctx.translate(-cam.x,-cam.y);
if(!scene.backgroundVideo){
if(!useCamera){
ctx.fillStyle=scene.canvas.background;
ctx.fillRect(0,0,lw,lh);
}
}
var layerById={};
for(var li=0;li<scene.layers.length;li++)layerById[scene.layers[li].id]=scene.layers[li];
@@ -1513,12 +1577,27 @@ function sceneToLegacyProject(scene: Scene): Project {
backgroundVideo: scene.backgroundVideo,
audioTracks: scene.audioTracks ?? [],
audioClips: scene.audioClips ?? [],
camera: scene.camera,
},
],
activeSceneId: '__legacy__',
}
}
function exportPreviewAspect(payload: ExportPayload): string {
const project = isProject(payload) ? payload : sceneToLegacyProject(payload)
const page =
project.scenes.find((s) => s.id === project.activeSceneId) ??
project.scenes[0]
const camera = page?.camera
if (camera?.enabled) {
const width = Math.max(1, camera.base?.width ?? project.canvas.width)
const height = Math.max(1, camera.base?.height ?? project.canvas.height)
return `${width} / ${height}`
}
return `${project.canvas.width} / ${project.canvas.height}`
}
export interface ExportOptions {
loop?: boolean
}
@@ -1571,8 +1650,7 @@ export function downloadBundledExport(payload: ExportPayload, filename = 'animat
export function generateExampleHtml(payload: ExportPayload, options: ExportOptions = {}): string {
const runtime = engineSource()
const sceneBoot = generateSceneScriptBody(payload, options)
const canvas = isProject(payload) ? payload.canvas : payload.canvas
const aspect = `${canvas.width} / ${canvas.height}`
const aspect = exportPreviewAspect(payload)
const usedFontIds = collectFontIds(payload)
const fonts = AVAILABLE_FONTS.value
const usedFonts = usedFontIds
+6 -10
View File
@@ -1,5 +1,6 @@
import type { Scene } from '@/types/scene'
import { drawSceneFrame, loadSceneImages } from './render'
import { renderSceneFrameForOutput } from './camera'
import { loadSceneImages } from './render'
/** End-of-playback time: clamp to the scene's last keyframe if the
* scene explicitly ends earlier than its nominal duration. Matches
@@ -81,14 +82,6 @@ export async function renderFrames(
canvas.height = height
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('2D context unavailable')
// Scale scene coords into target pixels. drawSceneFrame runs its
// own save/restore so the transform survives across frames, and
// any internal `setTransform(1,0,0,1,0,0)` (top-level mask
// compositing) is sandwiched inside its own save/restore too.
const scaleX = width / srcWidth
const scaleY = height / srcHeight
if (scaleX !== 1 || scaleY !== 1) ctx.scale(scaleX, scaleY)
const images = await loadSceneImages(scene)
const { start: rangeIn, end: rangeOut } = playbackRange(scene)
const rangeLen = Math.max(0, rangeOut - rangeIn)
@@ -106,7 +99,10 @@ export async function renderFrames(
throw new DOMException('aborted', 'AbortError')
}
const t = rangeIn + i / fps
drawSceneFrame(ctx, scene, Math.min(t, rangeOut), images)
renderSceneFrameForOutput(ctx, scene, Math.min(t, rangeOut), images, {
width,
height,
})
await onFrame({
scene,
canvas,
+10 -3
View File
@@ -566,11 +566,18 @@ export function drawSceneFrame(
scene: Scene,
time: number,
images: Record<string, HTMLImageElement>,
options?: { crop?: boolean; buttonStates?: Map<string, ButtonState> },
options?: {
crop?: boolean
clear?: boolean
background?: boolean
buttonStates?: Map<string, ButtonState>
},
) {
const lw = scene.canvas.width
const lh = scene.canvas.height
const crop = options?.crop !== false
const clear = options?.clear !== false
const paintBackground = options?.background !== false
const buttonStates = options?.buttonStates
ctx.save()
@@ -583,13 +590,13 @@ export function drawSceneFrame(
// Mandatory for transparent-background scenes so subsequent frames
// don't accumulate alpha; harmless for opaque ones since `fillRect`
// below paints over everything.
ctx.clearRect(0, 0, lw, lh)
if (clear) ctx.clearRect(0, 0, lw, lh)
// A background video takes over the backdrop — leave the stage area
// transparent so the DOM video underneath is visible. Same treatment
// when the canvas background is explicitly 'transparent' so
// APNG/WebM exports carry the alpha channel out to the file.
const bg = scene.canvas.background
if (!scene.backgroundVideo && bg && bg !== 'transparent') {
if (paintBackground && !scene.backgroundVideo && bg && bg !== 'transparent') {
ctx.fillStyle = bg
ctx.fillRect(0, 0, lw, lh)
}
+10 -5
View File
@@ -2,6 +2,7 @@ import type { Scene } from '@/types/scene'
const DEFAULT_SERVER = 'http://localhost:6000'
const SERVER_URL = (import.meta.env.VITE_RENDER_SERVER as string | undefined) ?? DEFAULT_SERVER
const RENDER_TOKEN = import.meta.env.VITE_RENDER_TOKEN as string | undefined
export interface ServerRenderOptions {
onProgress?: (progress: number) => void
@@ -27,7 +28,10 @@ export async function renderOnServer(
const baseUrl = SERVER_URL
const startRes = await fetch(`${baseUrl}/render`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
...(RENDER_TOKEN ? { Authorization: `Bearer ${RENDER_TOKEN}` } : {}),
},
body: JSON.stringify({
scene,
options: {
@@ -42,18 +46,19 @@ export async function renderOnServer(
const body = await startRes.text().catch(() => '')
throw new Error(`Server start failed (${startRes.status}): ${body.slice(0, 200)}`)
}
const { jobId } = (await startRes.json()) as { jobId: string }
if (!jobId) throw new Error('Server did not return a jobId')
const { jobId, accessToken } = (await startRes.json()) as { jobId: string; accessToken: string }
if (!jobId || !accessToken) throw new Error('Server did not return job credentials')
const downloadUrl = await new Promise<string>((resolve, reject) => {
const es = new EventSource(`${baseUrl}/render/${jobId}/events`)
const jobToken = encodeURIComponent(accessToken)
const es = new EventSource(`${baseUrl}/render/${jobId}/events?token=${jobToken}`)
const onAbort = () => {
try {
es.close()
} catch {
/* noop */
}
fetch(`${baseUrl}/render/${jobId}`, { method: 'DELETE' }).catch(() => {})
fetch(`${baseUrl}/render/${jobId}?token=${jobToken}`, { method: 'DELETE' }).catch(() => {})
reject(new DOMException('aborted', 'AbortError'))
}
if (opts.signal) {
+11 -10
View File
@@ -1,5 +1,6 @@
import type { Scene } from '@/types/scene'
import { drawSceneFrame, loadSceneImages } from './render'
import { renderSceneFrameForOutput } from './camera'
import { loadSceneImages } from './render'
export type StillImageFormat = 'png' | 'jpg'
@@ -45,17 +46,17 @@ export async function renderSceneToStillImage(
const needsOpaqueBackdrop =
format === 'jpg' && (!sceneBg || sceneBg === 'transparent')
const backdrop = opts.background ?? (needsOpaqueBackdrop ? '#ffffff' : null)
if (backdrop) {
ctx.fillStyle = backdrop
ctx.fillRect(0, 0, width, height)
}
const scaleX = width / srcWidth
const scaleY = height / srcHeight
if (scaleX !== 1 || scaleY !== 1) ctx.scale(scaleX, scaleY)
const images = await loadSceneImages(scene)
drawSceneFrame(ctx, scene, Math.max(0, time), images)
renderSceneFrameForOutput(ctx, scene, Math.max(0, time), images, { width, height })
if (backdrop) {
ctx.save()
ctx.globalCompositeOperation = 'destination-over'
ctx.fillStyle = backdrop
ctx.fillRect(0, 0, width, height)
ctx.restore()
}
const mime = format === 'jpg' ? 'image/jpeg' : 'image/png'
const quality = format === 'jpg' ? (opts.quality ?? 0.92) : undefined
+10 -3
View File
@@ -1,5 +1,6 @@
import type { Scene } from '@/types/scene'
import { drawSceneFrame, loadSceneImages } from './render'
import { renderSceneFrameForOutput } from './camera'
import { loadSceneImages } from './render'
function effectiveEnd(scene: Scene): number {
let max = 0
@@ -67,7 +68,10 @@ export async function renderSceneToVideo(
const { start: rangeIn, end: rangeOut } = playbackRange(scene)
const rangeLen = Math.max(0, rangeOut - rangeIn)
drawSceneFrame(ctx, scene, rangeIn, images)
renderSceneFrameForOutput(ctx, scene, rangeIn, images, {
width: canvas.width,
height: canvas.height,
})
const stream = canvas.captureStream(scene.fps)
const recorder = new MediaRecorder(stream, {
@@ -103,7 +107,10 @@ export async function renderSceneToVideo(
}
const elapsed = (performance.now() - startedAt) / 1000
const t = rangeIn + Math.min(elapsed, rangeLen)
drawSceneFrame(ctx, scene, t, images)
renderSceneFrameForOutput(ctx, scene, t, images, {
width: canvas.width,
height: canvas.height,
})
opts.onProgress?.(rangeLen > 0 ? Math.min(1, elapsed / rangeLen) : 1)
if (elapsed >= rangeLen) {
try {
+65 -4
View File
@@ -17,6 +17,11 @@ const SIG_LOCAL = 0x04034b50
const SIG_CENTRAL = 0x02014b50
const SIG_EOCD = 0x06054b50
const VERSION_NEEDED = 20
const MAX_ARCHIVE_BYTES = 256 * 1024 * 1024
const MAX_ENTRIES = 1000
const MAX_NAME_BYTES = 512
const MAX_ENTRY_BYTES = 100 * 1024 * 1024
const MAX_TOTAL_ENTRY_BYTES = 200 * 1024 * 1024
const CRC_TABLE = (() => {
const t = new Uint32Array(256)
@@ -40,12 +45,20 @@ function crc32(data: Uint8Array): number {
/** Pack a list of file entries into a flat STORE zip byte stream. */
export function zipStore(entries: ZipEntry[]): Uint8Array {
if (entries.length > MAX_ENTRIES) throw new Error(`ZIP has more than ${MAX_ENTRIES} entries.`)
const enc = new TextEncoder()
const localChunks: Uint8Array[] = []
const centralChunks: Uint8Array[] = []
let offset = 0
for (const entry of entries) {
validateZipName(entry.name)
if (entry.data.length > MAX_ENTRY_BYTES) {
throw new Error(`ZIP entry ${entry.name} exceeds ${MAX_ENTRY_BYTES} bytes.`)
}
const nameBytes = enc.encode(entry.name)
if (nameBytes.length > MAX_NAME_BYTES) {
throw new Error(`ZIP entry name ${entry.name} is too long.`)
}
const data = entry.data
const crc = crc32(data)
// Local file header.
@@ -119,27 +132,35 @@ export function zipStore(entries: ZipEntry[]): Uint8Array {
/** Parse a STORE-only zip stream. Throws on bad signatures, truncated
* data, or compressed entries (we don't ship a deflate path). */
export function unzipStore(bytes: Uint8Array): ZipEntry[] {
if (bytes.length > MAX_ARCHIVE_BYTES) {
throw new Error(`ZIP archive exceeds ${MAX_ARCHIVE_BYTES} bytes.`)
}
if (bytes.length < 22) throw new Error('Not a ZIP archive (too small).')
const dec = new TextDecoder()
// EOCD is at least 22 bytes and may have up to 65535 bytes of
// trailing comment; scan from the end within that window.
let eocdOff = -1
const minOff = Math.max(0, bytes.length - (22 + 65535))
for (let i = bytes.length - 22; i >= minOff; i--) {
const dv = new DataView(bytes.buffer, bytes.byteOffset + i, 4)
const dv = view(bytes, i, 4)
if (dv.getUint32(0, true) === SIG_EOCD) {
eocdOff = i
break
}
}
if (eocdOff < 0) throw new Error('Not a ZIP archive (EOCD not found).')
const edv = new DataView(bytes.buffer, bytes.byteOffset + eocdOff)
const edv = view(bytes, eocdOff, 22)
const total = edv.getUint16(10, true)
if (total > MAX_ENTRIES) throw new Error(`ZIP has more than ${MAX_ENTRIES} entries.`)
const centralSize = edv.getUint32(12, true)
const centralOff = edv.getUint32(16, true)
ensureRange(bytes, centralOff, centralSize, 'ZIP central directory')
const entries: ZipEntry[] = []
let p = centralOff
let extractedTotal = 0
for (let i = 0; i < total; i++) {
const cdv = new DataView(bytes.buffer, bytes.byteOffset + p)
const cdv = view(bytes, p, 46)
if (cdv.getUint32(0, true) !== SIG_CENTRAL) {
throw new Error('Bad central directory entry.')
}
@@ -149,20 +170,30 @@ export function unzipStore(bytes: Uint8Array): ZipEntry[] {
const extraLen = cdv.getUint16(30, true)
const commentLen = cdv.getUint16(32, true)
const localOff = cdv.getUint32(42, true)
if (nameLen > MAX_NAME_BYTES) throw new Error('ZIP entry name is too long.')
if (size > MAX_ENTRY_BYTES) throw new Error(`ZIP entry exceeds ${MAX_ENTRY_BYTES} bytes.`)
extractedTotal += size
if (extractedTotal > MAX_TOTAL_ENTRY_BYTES) {
throw new Error(`ZIP extracted data exceeds ${MAX_TOTAL_ENTRY_BYTES} bytes.`)
}
if (method !== 0) {
throw new Error(`Unsupported ZIP compression method ${method} — only STORE is accepted.`)
}
ensureRange(bytes, p, 46 + nameLen + extraLen + commentLen, 'ZIP central directory entry')
const nameBytes = bytes.subarray(p + 46, p + 46 + nameLen)
const name = dec.decode(nameBytes)
validateZipName(name)
// Re-read the local header to skip its variable-length fields
// before the file data.
const ldv = new DataView(bytes.buffer, bytes.byteOffset + localOff)
const ldv = view(bytes, localOff, 30)
if (ldv.getUint32(0, true) !== SIG_LOCAL) {
throw new Error('Bad local file header.')
}
const lNameLen = ldv.getUint16(26, true)
const lExtraLen = ldv.getUint16(28, true)
const dataOff = localOff + 30 + lNameLen + lExtraLen
ensureRange(bytes, localOff, 30 + lNameLen + lExtraLen, 'ZIP local file header')
ensureRange(bytes, dataOff, size, 'ZIP entry data')
const data = bytes.subarray(dataOff, dataOff + size)
// Copy so callers get a standalone Uint8Array they can keep.
entries.push({ name, data: new Uint8Array(data) })
@@ -171,6 +202,36 @@ export function unzipStore(bytes: Uint8Array): ZipEntry[] {
return entries
}
function view(bytes: Uint8Array, offset: number, length: number): DataView {
ensureRange(bytes, offset, length, 'ZIP data')
return new DataView(bytes.buffer, bytes.byteOffset + offset, length)
}
function ensureRange(bytes: Uint8Array, offset: number, length: number, label: string): void {
if (
!Number.isSafeInteger(offset) ||
!Number.isSafeInteger(length) ||
offset < 0 ||
length < 0 ||
offset > bytes.length ||
offset + length > bytes.length
) {
throw new Error(`${label} is outside the archive.`)
}
}
function validateZipName(name: string): void {
if (
!name ||
name.startsWith('/') ||
name.startsWith('\\') ||
name.includes('\\') ||
name.split('/').some((part) => part === '..' || part === '')
) {
throw new Error(`Unsafe ZIP entry name: ${name || '(empty)'}.`)
}
}
/** Magic check — `true` when the first four bytes match the ZIP local
* file header signature (PK\x03\x04). Used by the project-file
* reader to decide between .ruw (zip) and .json (legacy). */
+7
View File
@@ -18,6 +18,13 @@ app.use(VueKonva)
app.directive('tooltip', vTooltip)
app.mount('#app')
if (
(import.meta.env.DEV || !!window.ruwioDesktop) &&
new URLSearchParams(window.location.search).get('__runtests') === '1'
) {
void import('./devtools/headless').then(({ runHeadlessTests }) => runHeadlessTests())
}
// Service worker exists solely to make the app installable as a PWA.
// In Chrome/Edge, an installed PWA runs in standalone display mode,
// which removes the top-edge "exit fullscreen" indicator that tabs
+129 -3
View File
@@ -10,6 +10,8 @@ import type {
BackgroundVideo,
ButtonAsset,
ButtonState,
CameraKeyframe,
CameraProps,
Clip,
ClipInstance,
ClipTrack,
@@ -29,6 +31,12 @@ import type {
TrackGroup,
} from '@/types/scene'
import { BUTTON_STATE_TIMES, DEFAULT_BASE_PROPS } from '@/types/scene'
import {
createDefaultCameraTrack,
defaultCameraProps,
normalizeCameraTrack,
sampleCamera,
} from '@/lib/camera'
import { sampleLayer } from '@/lib/interpolate'
import { splitSvg as splitSvgRuntime } from '@/lib/svgSplit'
import {
@@ -247,6 +255,8 @@ interface State {
* `selectedLayerId`: selecting a clip clears the layer selection
* and vice versa. */
selectedClipInstanceId: string | null
/** Special editor selection for the page-level output camera. */
selectedCamera: boolean
playhead: number
playing: boolean
/** True between `play()` and the next `tick()`. Lets the first tick
@@ -407,7 +417,10 @@ function findLayerAnywhere(scene: Scene, layerId: string): Layer | null {
/** Per-page housekeeping: ensure all arrays exist, clean up orphan
* childIds in groups, dedupe, build trackOrder for any pristine track,
* and migrate pre-`colorOverridden` color flags. */
function normalizeScenePage(page: ScenePage): ScenePage {
function normalizeScenePage(
page: ScenePage,
canvas: Pick<Scene, 'canvas'>['canvas'] = { width: 1280, height: 720, background: '#ffffff' },
): ScenePage {
page.layers ||= []
page.clipInstances ||= []
page.clipTracks ||= []
@@ -418,6 +431,11 @@ function normalizeScenePage(page: ScenePage): ScenePage {
page.trackOrder ||= []
page.audioTracks ||= []
page.audioClips ||= []
if (page.camera !== undefined) {
const normalized = normalizeCameraTrack({ canvas }, page.camera)
if (normalized) page.camera = normalized
else delete page.camera
}
const known = new Set<string>()
for (const l of page.layers) known.add(l.id)
@@ -557,7 +575,7 @@ function normalizeProject(project: Project): Project {
if (project.scenes.length === 0) {
project.scenes.push(createBlankScenePage('Scene 1'))
}
for (const page of project.scenes) normalizeScenePage(page)
for (const page of project.scenes) normalizeScenePage(page, project.canvas)
if (!project.scenes.find((s) => s.id === project.activeSceneId)) {
project.activeSceneId = project.scenes[0].id
}
@@ -614,6 +632,7 @@ export function projectFromLegacyScene(scene: Scene, sceneName = 'Scene 1'): Pro
backgroundVideo: scene.backgroundVideo,
audioTracks: scene.audioTracks ?? [],
audioClips: scene.audioClips ?? [],
camera: scene.camera,
}
return normalizeProject({
version: 1,
@@ -653,6 +672,7 @@ export function createBlankScene(): Scene {
groups: page.groups,
trackOrder: page.trackOrder,
backgroundVideo: page.backgroundVideo,
camera: page.camera,
}
}
@@ -662,6 +682,7 @@ export const useSceneStore = defineStore('scene', {
selectedLayerId: null,
selectedLayerIds: [],
selectedClipInstanceId: null,
selectedCamera: false,
playhead: 0,
playing: false,
playStartPending: false,
@@ -719,6 +740,7 @@ export const useSceneStore = defineStore('scene', {
backgroundVideo: page.backgroundVideo,
audioTracks: page.audioTracks,
audioClips: page.audioClips,
camera: page.camera,
}
},
activeClip(): Clip | null {
@@ -1030,15 +1052,115 @@ export const useSceneStore = defineStore('scene', {
selectLayer(id: string | null) {
this.selectedLayerId = id
this.selectedLayerIds = id ? [id] : []
if (id) this.selectedClipInstanceId = null
if (id) {
this.selectedClipInstanceId = null
this.selectedCamera = false
}
},
selectClipInstance(id: string | null) {
this.selectedClipInstanceId = id
if (id) {
this.selectedLayerId = null
this.selectedLayerIds = []
this.selectedCamera = false
}
},
selectCamera(selected = true) {
this.selectedCamera = selected
if (selected) {
this.selectedLayerId = null
this.selectedLayerIds = []
this.selectedClipInstanceId = null
}
},
ensureCamera(enabled = true) {
if (!this.activeScene.camera) {
this.activeScene.camera = createDefaultCameraTrack(this.scene, enabled)
} else {
const normalized = normalizeCameraTrack(this.scene, this.activeScene.camera)
this.activeScene.camera = normalized ?? createDefaultCameraTrack(this.scene, enabled)
}
return this.activeScene.camera
},
enableCamera() {
const camera = this.ensureCamera(true)
camera.enabled = true
camera.overlayVisible = camera.overlayVisible !== false
this.selectCamera(true)
},
disableCamera() {
if (!this.activeScene.camera) return
this.activeScene.camera.enabled = false
},
setCameraOverlayVisible(visible: boolean) {
const camera = this.ensureCamera(false)
camera.overlayVisible = visible
},
setCameraLocked(locked: boolean) {
const camera = this.ensureCamera(false)
camera.locked = locked
},
setCameraBaseProps(props: Partial<CameraProps>) {
const camera = this.ensureCamera(true)
camera.base = { ...defaultCameraProps(this.scene), ...camera.base, ...props }
camera.base.width = Math.max(1, camera.base.width)
camera.base.height = Math.max(1, camera.base.height)
},
upsertCameraKeyframe(time: number, props: Partial<CameraProps>) {
const camera = this.ensureCamera(true)
if (camera.locked) return null
const t = Math.max(0, Math.min(this.scene.duration, time))
const existing = camera.keyframes.find((kf) => Math.abs(kf.time - t) < KEYFRAME_EPSILON)
const sampled = sampleCamera(this.scene, t)
const nextProps = { ...sampled, ...props }
nextProps.width = Math.max(1, nextProps.width)
nextProps.height = Math.max(1, nextProps.height)
if (existing) {
existing.props = { ...existing.props, ...nextProps }
return existing
}
const kf: CameraKeyframe = {
id: uid(),
time: t,
props: nextProps,
easing: 'linear',
}
camera.keyframes.push(kf)
camera.keyframes.sort((a, b) => a.time - b.time)
return kf
},
removeCameraKeyframe(id: string) {
const camera = this.activeScene.camera
if (!camera || camera.locked) return
camera.keyframes = camera.keyframes.filter((kf) => kf.id !== id)
},
removeCameraKeyframeAt(time: number) {
const camera = this.activeScene.camera
if (!camera || camera.locked) return
camera.keyframes = camera.keyframes.filter(
(kf) => Math.abs(kf.time - time) >= KEYFRAME_EPSILON,
)
},
setCameraKeyframeTime(id: string, time: number) {
const camera = this.activeScene.camera
if (!camera || camera.locked) return
const kf = camera.keyframes.find((item) => item.id === id)
if (!kf) return
kf.time = Math.max(0, Math.min(this.scene.duration, time))
camera.keyframes.sort((a, b) => a.time - b.time)
},
setCameraKeyframeEasing(id: string, easing: Easing) {
const camera = this.activeScene.camera
if (!camera || camera.locked) return
const kf = camera.keyframes.find((item) => item.id === id)
if (kf) kf.easing = easing
},
setCameraKeyframeHold(id: string, hold: boolean) {
const camera = this.activeScene.camera
if (!camera || camera.locked) return
const kf = camera.keyframes.find((item) => item.id === id)
if (kf) kf.hold = hold
},
/** Shift-click behaviour: add the layer to the selection if it's
* not already there; otherwise remove it. The anchor moves to
* the most-recently-clicked id so the Properties panel follows
@@ -1052,6 +1174,7 @@ export const useSceneStore = defineStore('scene', {
if (g.locked && g.childIds.includes(id)) return
}
const existing = this.selectedLayerIds.indexOf(id)
this.selectedCamera = false
if (existing >= 0) {
this.selectedLayerIds.splice(existing, 1)
this.selectedLayerId =
@@ -2706,6 +2829,7 @@ export const useSceneStore = defineStore('scene', {
this.selectedLayerId = null
this.selectedLayerIds = []
this.selectedClipInstanceId = null
this.selectedCamera = false
this.playhead = 0
this.playing = false
this.activeClipId = null
@@ -2791,6 +2915,7 @@ export const useSceneStore = defineStore('scene', {
this.selectedLayerId = null
this.selectedLayerIds = []
this.selectedClipInstanceId = null
this.selectedCamera = false
this.drawingMode = null
this.drawingAssetId = null
this.drawingLayerId = null
@@ -3450,6 +3575,7 @@ export const useSceneStore = defineStore('scene', {
)
if (inst && childIds.has(inst.clipTrackId)) {
this.selectedClipInstanceId = null
this.selectedCamera = false
}
}
// Dispatch the right remove action per child. Each call cleans
+16
View File
@@ -1,3 +1,5 @@
import type { Scene } from './scene'
export {}
type RuwioDesktopMenuCommand =
@@ -33,6 +35,20 @@ declare global {
onMenuCommand?: (
callback: (command: RuwioDesktopMenuCommand) => void,
) => () => void
renderMp4?: (options: {
scene: Scene
fps?: number
crf?: number
preset?: string
suggestedName?: string
}) => Promise<{ path: string } | null>
onRenderProgress?: (callback: (event: {
jobId: string
progress: number
done: boolean
error?: string
}) => void) => () => void
cancelRender?: (jobId: string) => Promise<void>
}
}
}
+30
View File
@@ -57,6 +57,34 @@ export interface Keyframe {
ctrlY?: number
}
export interface CameraProps {
/** Top-left stage coordinate of the visible output rectangle. */
x: number
y: number
width: number
height: number
}
export interface CameraKeyframe {
id: string
time: number
props: Partial<CameraProps>
easing: Easing
hold?: boolean
}
export interface CameraTrack {
id: string
name?: string
enabled: boolean
base: CameraProps
keyframes: CameraKeyframe[]
/** Editor guide only. Hidden overlays still render in preview/export. */
overlayVisible?: boolean
/** Editor lock only. Locked cameras still render when enabled. */
locked?: boolean
}
export type AssetKind = 'image' | 'rect' | 'circle' | 'text' | 'polygon' | 'button' | 'audio' | 'svg'
export type ButtonState = 'normal' | 'hover' | 'pressed' | 'disabled'
@@ -445,6 +473,7 @@ export interface Scene {
markers?: Marker[]
audioTracks?: AudioTrack[]
audioClips?: AudioClip[]
camera?: CameraTrack
}
/**
@@ -467,6 +496,7 @@ export interface ScenePage {
backgroundVideo?: BackgroundVideo
audioTracks: AudioTrack[]
audioClips: AudioClip[]
camera?: CameraTrack
}
/**
+1 -1
View File
@@ -1,2 +1,2 @@
export const APP_VERSION = '1.2.14'
export const APP_VERSION = '1.3.2'
export const APP_NAME = 'RUWIO'
+33
View File
@@ -0,0 +1,33 @@
{
"canvas": { "width": 160, "height": 120, "background": "#f8fafc" },
"duration": 1,
"assets": [
{ "id": "rect", "kind": "rect", "name": "Rect", "width": 42, "height": 28, "fill": "#ef4444", "stroke": "#111827", "strokeWidth": 2 },
{ "id": "circle", "kind": "circle", "name": "Circle", "radius": 18, "fill": "#22c55e", "stroke": "#0f172a", "strokeWidth": 2 }
],
"layers": [
{
"id": "layer-rect",
"name": "Rect",
"assetId": "rect",
"visible": true,
"base": { "x": 36, "y": 62, "scaleX": 1, "scaleY": 1, "rotation": 0, "opacity": 1 },
"keyframes": [
{ "id": "rect-0", "time": 0, "easing": "linear", "props": { "x": 36, "rotation": 0 } },
{ "id": "rect-1", "time": 1, "easing": "linear", "props": { "x": 112, "rotation": 18 } }
]
},
{
"id": "layer-circle",
"name": "Circle",
"assetId": "circle",
"visible": true,
"base": { "x": 88, "y": 44, "scaleX": 1, "scaleY": 1, "rotation": 0, "opacity": 0.82 },
"keyframes": [
{ "id": "circle-0", "time": 0, "easing": "linear", "props": { "y": 44 } },
{ "id": "circle-1", "time": 1, "easing": "linear", "props": { "y": 78 } }
]
}
],
"trackOrder": ["layer-circle", "layer-rect"]
}
+41
View File
@@ -0,0 +1,41 @@
function diffBgra(a, b, options = {}) {
if (!a || !b || a.length !== b.length) {
throw new Error(`bitmap size mismatch: ${a?.length || 0} != ${b?.length || 0}`)
}
const perChannelTolerance = Number.isFinite(options.perChannelTolerance)
? options.perChannelTolerance
: 2
const maxMismatchedRatio = Number.isFinite(options.maxMismatchedRatio)
? options.maxMismatchedRatio
: 0.001
let mismatchedPixels = 0
let maxDelta = 0
let totalDelta = 0
const pixels = a.length / 4
for (let i = 0; i < a.length; i += 4) {
let pixelMismatch = false
for (let c = 0; c < 4; c += 1) {
const delta = Math.abs(a[i + c] - b[i + c])
maxDelta = Math.max(maxDelta, delta)
totalDelta += delta
if (delta > perChannelTolerance) pixelMismatch = true
}
if (pixelMismatch) mismatchedPixels += 1
}
const mismatchedRatio = pixels ? mismatchedPixels / pixels : 0
return {
ok: mismatchedRatio <= maxMismatchedRatio,
pixels,
mismatchedPixels,
mismatchedRatio,
maxDelta,
meanDelta: pixels ? totalDelta / (pixels * 4) : 0,
perChannelTolerance,
maxMismatchedRatio,
}
}
module.exports = { diffBgra }
+54
View File
@@ -5,6 +5,60 @@ import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
base: './',
plugins: [vue()],
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('/src/i18n/')) return 'i18n'
if (
id.includes('/src/components/CanvasStage.vue') ||
id.includes('/src/components/AssetSidebar.vue') ||
id.includes('/src/components/PropertiesPanel.vue') ||
id.includes('/src/components/ActionEditor.vue') ||
id.includes('/src/components/FontPicker.vue') ||
id.includes('/src/components/RangeSlider.vue') ||
id.includes('/src/components/SvgLayerPanel.vue') ||
id.includes('/src/stores/scene.ts') ||
id.includes('/src/types/scene.ts') ||
id.includes('/src/lib/render.ts') ||
id.includes('/src/lib/interpolate.ts') ||
id.includes('/src/lib/wiggle.ts') ||
id.includes('/src/lib/kaleido.ts') ||
id.includes('/src/lib/clipBounds.ts') ||
id.includes('/src/lib/actions.ts') ||
id.includes('/src/lib/sceneOrder.ts') ||
id.includes('/src/composables/useButtonRuntime.ts')
) {
return 'editor'
}
if (
id.includes('/src/components/Timeline.vue') ||
id.includes('/src/lib/timelineSnap.ts') ||
id.includes('/src/composables/useAudioWaveform.ts')
) {
return 'timeline'
}
if (!id.includes('/node_modules/')) return undefined
if (id.includes('/@phosphor-icons/vue/')) return 'icons'
if (id.includes('/konva/') || id.includes('/vue-konva/')) return 'canvas'
if (
id.includes('/gifenc/') ||
id.includes('/upng-js/')
) {
return 'encoders'
}
if (
id.includes('/vue/') ||
id.includes('/@vue/') ||
id.includes('/pinia/')
) {
return 'framework'
}
return 'vendor'
},
},
},
},
server: {
port: 5200,