Compare commits
37
Commits
c17e2e8a9f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7093e1f6d2 | ||
|
|
9b314ad817 | ||
|
|
b9a4b53109 | ||
|
|
3a93d6ea5d | ||
|
|
23a66c6fdf | ||
|
|
616ced17ce | ||
|
|
b18e66c28d | ||
|
|
f469ab7444 | ||
|
|
8d296c963e | ||
|
|
00ee563311 | ||
|
|
fa8c52b239 | ||
|
|
7bf18636d6 | ||
|
|
ba58ebb48d | ||
|
|
196aa2e8ba | ||
|
|
518d43855b | ||
|
|
46f9f32aef | ||
|
|
b536e8cf73 | ||
|
|
fae55e3f13 | ||
|
|
5cc373cf6b | ||
|
|
32cc3ecec3 | ||
|
|
70cf131c47 | ||
|
|
c646f66e8f | ||
|
|
42c5f68182 | ||
|
|
b88d174bcb | ||
|
|
367dd68e49 | ||
|
|
e47a982492 | ||
|
|
ceaf1db434 | ||
|
|
184842b2e0 | ||
|
|
a2fdd1684a | ||
|
|
9e25cf155a | ||
|
|
7d81a50e74 | ||
|
|
80e1510b53 | ||
|
|
27d6a47b72 | ||
|
|
8c4f744afc | ||
|
|
4c2b7c7c35 | ||
|
|
b32b8a6a3d | ||
|
|
127812d3bf |
No files matched your search
@@ -1,7 +1,9 @@
|
||||
node_modules
|
||||
dist
|
||||
dist-electron
|
||||
dist-ssr
|
||||
dist-archive
|
||||
release
|
||||
*.local
|
||||
.vite
|
||||
|
||||
@@ -16,3 +18,10 @@ dist-archive
|
||||
/logo.xcf
|
||||
/logo_v2.xcf
|
||||
/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
|
||||
@@ -0,0 +1,154 @@
|
||||
<p align="center">
|
||||
<img src="src/assets/logo.png" alt="RUWIO" width="320" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>A timeline-based animation editor that runs in your browser.</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="src/assets/ruwio_screenshot.png" alt="RUWIO editor screenshot" width="100%" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
RUWIO is a browser-native authoring tool for short interactive animations.
|
||||
It pairs a Konva-driven canvas with a keyframe timeline, exports a
|
||||
self-contained HTML/JS bundle, and ships everything as a Vue 3 single-page
|
||||
app you can serve from any static host.
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Keyframes everywhere** — animate position, rotation, scale, fill, stroke,
|
||||
shadow, and shape-specific properties. Optional separated position /
|
||||
rotation tracks. End keyframes terminate a layer cleanly.
|
||||
- **Effects**
|
||||
- **Kaleidoscope** — radial repeats of a layer or a clip instance,
|
||||
with per-arm time offset for time-pinwheel effects.
|
||||
- **Wiggle** — deterministic procedural shake (position + rotation).
|
||||
- **Masks** — destination-in / destination-out cascade, scoped to
|
||||
track groups, clips, and SVG assets.
|
||||
- **Asset kinds** — rect, circle, polygon (with vertex / bezier editing),
|
||||
freeform path, text (14 bundled Google fonts + custom .woff2 / .ttf
|
||||
upload), images, audio, button-with-states, nested SVG assets, and
|
||||
reusable clips with their own internal timeline.
|
||||
- **Scenes** — multiple pages per project; jumpToScene / jumpTo / jumpToFrame
|
||||
scripted actions; markers; per-scene background video and audio tracks.
|
||||
- **Render parity** — the canvas2d offline renderer, the Konva live stage,
|
||||
and the exported runtime all share the same sampling math. What you see
|
||||
on stage is what gets exported.
|
||||
- **Eyedropper** colour picking, **motion-path** overlay, global
|
||||
**seconds / frames** display toggle, custom font upload to
|
||||
localStorage — the polish kit.
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # start the editor at http://localhost:5200
|
||||
npm run type-check # vue-tsc strict pass
|
||||
npm run build # production bundle to dist/
|
||||
npm run pack # zip dist/ into dist-archive/
|
||||
```
|
||||
|
||||
## Electron desktop build
|
||||
|
||||
RUWIO can also run as an Electron desktop app. The desktop shell uses the
|
||||
same Vite-built app from `dist/` plus the compiled Electron entry points in
|
||||
`dist-electron/`.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run desktop:dev # start Vite, then launch Electron against localhost
|
||||
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: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
|
||||
|
||||
- Vue 3 (`<script setup>`) + Pinia state
|
||||
- Konva for the live canvas (vue-konva bindings)
|
||||
- Vite for dev / build
|
||||
- TypeScript everywhere; tests live in `src/devtools/tests.ts`
|
||||
and run inside the app via the in-browser test overlay
|
||||
- `gifenc`, `upng-js` for image-sequence exports
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
src/
|
||||
components/ Vue SFCs (CanvasStage, Timeline, PropertiesPanel, …)
|
||||
composables/ Reusable hooks (theme, time-format, eyedropper, …)
|
||||
lib/ Pure logic: render.ts, exportPlayer.ts, interpolate.ts,
|
||||
wiggle.ts, kaleido.ts, ruwFile.ts (zip pack/unpack), …
|
||||
stores/ Pinia stores
|
||||
types/ Scene + project type definitions
|
||||
i18n/ English + German translations
|
||||
devtools/ In-app test runner + tests
|
||||
public/
|
||||
fonts/ Bundled .woff2 files
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
The test runner lives inside the app. Open the editor (`npm run dev`)
|
||||
and hit `Ctrl+Shift+Alt+T` to toggle the overlay; from there you can
|
||||
run individual suites or the whole pile. The runner, all suites and
|
||||
the components they exercise are plain TypeScript / Vue, so the tests
|
||||
run the real code path — no separate harness.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
@@ -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.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 191 KiB |
@@ -0,0 +1,228 @@
|
||||
# Electron Desktop Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Package RUWIO as a desktop app while keeping the existing Vue/Vite renderer as the primary application. The Electron layer should start small: host the built app, expose native filesystem dialogs, and later absorb the current server-side MP4 render workflow so desktop users do not need a separate render server.
|
||||
|
||||
## Current App Shape
|
||||
|
||||
- The UI is a Vue 3 + Vite single-page app in `src/`.
|
||||
- The production web build is produced by `npm run build` into `dist/`.
|
||||
- Project save/load is currently browser-driven:
|
||||
- JSON project export/import lives in `src/lib/projectFile.ts`.
|
||||
- `.ruw` bundle export/import lives in `src/lib/ruwFile.ts`.
|
||||
- User-triggered file downloads use `Blob`, object URLs, and anchor clicks.
|
||||
- Still/GIF/APNG style exports are implemented client-side.
|
||||
- MP4 rendering is currently split out into `server/server.js`, using Express, Puppeteer Core, Chromium, and ffmpeg.
|
||||
- The app already has PWA assets and static-host assumptions, which are compatible with an Electron renderer if paths are handled carefully.
|
||||
|
||||
## Recommended Architecture
|
||||
|
||||
Use a standard Electron split:
|
||||
|
||||
- `electron/main.ts`
|
||||
- Creates the main `BrowserWindow`.
|
||||
- Loads the Vite dev server during development.
|
||||
- Loads `dist/index.html` in packaged builds.
|
||||
- Owns native dialogs, app menu, recent files, and privileged filesystem/process work.
|
||||
- `electron/preload.ts`
|
||||
- Exposes a small typed API on `window.ruwioDesktop`.
|
||||
- Uses `contextBridge` and IPC.
|
||||
- Does not expose Node primitives directly.
|
||||
- Vue renderer
|
||||
- Remains browser-compatible.
|
||||
- Detects the desktop API when available.
|
||||
- Falls back to current browser file picker/download behavior on the web.
|
||||
|
||||
Keep Electron as an optional target rather than replacing the browser build. The same app should continue to run with `npm run dev`, `npm run build`, and static hosting.
|
||||
|
||||
## Package Additions
|
||||
|
||||
Add these development dependencies:
|
||||
|
||||
- `electron`
|
||||
- `electron-builder` or `electron-forge`
|
||||
- `concurrently` for local dev convenience
|
||||
- `wait-on` if the Electron dev command waits for Vite
|
||||
|
||||
Recommended scripts:
|
||||
|
||||
```json
|
||||
{
|
||||
"dev": "vite",
|
||||
"desktop:dev": "concurrently \"vite\" \"wait-on http://localhost:5200 && electron .\"",
|
||||
"desktop:build": "npm run build && electron-builder",
|
||||
"desktop:pack": "npm run build && electron-builder --dir"
|
||||
}
|
||||
```
|
||||
|
||||
The exact script shape can be adjusted once the Electron entry files exist.
|
||||
|
||||
## Phase 1: Minimal Desktop Shell
|
||||
|
||||
1. Add Electron source files under `electron/`.
|
||||
2. Configure TypeScript output for the Electron main/preload files.
|
||||
3. Create a secure `BrowserWindow`:
|
||||
- `contextIsolation: true`
|
||||
- `nodeIntegration: false`
|
||||
- `sandbox: true` if preload needs stay small enough to support it
|
||||
4. Load `http://localhost:5200` in development.
|
||||
5. Load the built `dist/index.html` in production.
|
||||
6. Set app name, icon, minimum window size, and basic lifecycle handling for macOS/Windows/Linux.
|
||||
7. Add a basic app menu with New/Open/Save/Save As/Export/Quit placeholders.
|
||||
|
||||
Acceptance check:
|
||||
|
||||
- `npm run desktop:dev` opens the existing app.
|
||||
- `npm run desktop:pack` creates an unpacked app.
|
||||
- The normal browser dev/build flows still work.
|
||||
|
||||
## Phase 2: Desktop File API
|
||||
|
||||
Add IPC-backed file operations instead of immediately rewriting the app's save/load model.
|
||||
|
||||
Expose a preload API like:
|
||||
|
||||
```ts
|
||||
window.ruwioDesktop = {
|
||||
openProject(): Promise<{ name: string; bytes: Uint8Array } | null>,
|
||||
saveProject(options: { suggestedName: string; bytes: Uint8Array }): Promise<{ path: string } | null>,
|
||||
saveJson(options: { suggestedName: string; text: string }): Promise<{ path: string } | null>,
|
||||
exportBlob(options: { suggestedName: string; bytes: Uint8Array }): Promise<{ path: string } | null>
|
||||
}
|
||||
```
|
||||
|
||||
Renderer changes:
|
||||
|
||||
- Add a small desktop adapter module, for example `src/lib/desktopBridge.ts`.
|
||||
- In `downloadProject` and `downloadRuwFile`, use the desktop bridge when present and fall back to browser downloads otherwise.
|
||||
- For loading, keep the current `<input type="file">` path as the web fallback and add desktop open dialog support for Electron.
|
||||
- Preserve existing `.ruw` bundle logic. The desktop API should move bytes to and from disk, not duplicate parsing/packing rules.
|
||||
|
||||
Acceptance check:
|
||||
|
||||
- Open `.ruw` and legacy `.json` projects from a native dialog.
|
||||
- Save `.ruw` projects through a native save dialog.
|
||||
- Browser import/export behavior remains unchanged.
|
||||
|
||||
## Phase 3: Native Menus And App State
|
||||
|
||||
Wire menu actions to renderer commands through IPC.
|
||||
|
||||
Planned menu commands:
|
||||
|
||||
- New Project
|
||||
- Open
|
||||
- Save
|
||||
- Save As
|
||||
- Export HTML/JS bundle
|
||||
- Export PNG/GIF/APNG/MP4 as available
|
||||
- Undo/Redo if the existing history store can expose commands
|
||||
- Toggle fullscreen/preview/test overlay where appropriate
|
||||
|
||||
Implementation notes:
|
||||
|
||||
- The main process should not mutate project state directly.
|
||||
- The renderer should own project state and respond to menu events.
|
||||
- Track the current file path in the desktop bridge or a small Pinia/composable integration.
|
||||
- Add a dirty-state signal so the main process can confirm before closing a window with unsaved work.
|
||||
|
||||
Acceptance check:
|
||||
|
||||
- Menu shortcuts trigger the same code paths as toolbar buttons.
|
||||
- Closing the app warns when there are unsaved changes.
|
||||
- Recent files can be added after the open/save path is stable.
|
||||
|
||||
## Phase 4: Desktop MP4 Rendering
|
||||
|
||||
Replace the external render server requirement for desktop builds.
|
||||
|
||||
Preferred approach:
|
||||
|
||||
1. Move the core render job orchestration out of `server/server.js` into a reusable Node module.
|
||||
2. Keep the existing Express server as a thin wrapper around that module for web/server deployments.
|
||||
3. Call the same render module from Electron main via IPC.
|
||||
4. Stream progress back to the renderer.
|
||||
5. Save the resulting MP4 through a native save dialog.
|
||||
|
||||
Rendering dependencies:
|
||||
|
||||
- Bundle or locate Chromium. Electron already ships Chromium, but Puppeteer against Electron's browser runtime needs careful validation. The safer first version is to keep Puppeteer or Playwright with an explicit executable path.
|
||||
- Bundle or locate ffmpeg. For a consumer desktop app, prefer an embedded ffmpeg binary per platform or document a clear install requirement until bundling is solved.
|
||||
- Keep temporary frames/pipes in the OS temp directory, as the current server does.
|
||||
|
||||
Acceptance check:
|
||||
|
||||
- MP4 export works offline in the packaged desktop app.
|
||||
- Progress, cancel, and failure states match the current server export UX.
|
||||
- The existing `server/` deployment still runs.
|
||||
|
||||
## Phase 5: Packaging And Release
|
||||
|
||||
Configure packaging metadata:
|
||||
|
||||
- App id, product name, copyright.
|
||||
- Icons for macOS, Windows, and Linux.
|
||||
- File associations for `.ruw`.
|
||||
- Build targets:
|
||||
- macOS: `.dmg` or `.zip`
|
||||
- Windows: `nsis`
|
||||
- Linux: `AppImage` first, optionally `.deb`
|
||||
- Include `dist/`, Electron compiled files, static assets, and any render binaries.
|
||||
- Exclude source maps and development-only files unless needed for debugging.
|
||||
|
||||
Later release tasks:
|
||||
|
||||
- Code signing and notarization for macOS.
|
||||
- Windows signing.
|
||||
- Auto-update channel if RUWIO will ship outside app stores.
|
||||
- CI builds for all target platforms.
|
||||
|
||||
Acceptance check:
|
||||
|
||||
- A clean checkout can produce a packaged desktop artifact.
|
||||
- The artifact launches without a local dev server.
|
||||
- File associations open `.ruw` projects when double-clicked.
|
||||
|
||||
## Security Rules
|
||||
|
||||
- Keep Node access out of the Vue renderer.
|
||||
- Use `contextBridge` with a narrow allowlist.
|
||||
- Validate IPC payloads in the main process.
|
||||
- Do not pass arbitrary file paths from renderer to main without checks.
|
||||
- Restrict navigation to the app origin in development and local files in production.
|
||||
- Disable or guard `window.open`.
|
||||
- Keep the render process isolated from arbitrary project content where possible, especially if imported SVG/HTML assets can contain script-like payloads.
|
||||
|
||||
## Risk Areas
|
||||
|
||||
- Asset paths under `file://` may behave differently than Vite dev paths. Test fonts, icons, manifest assets, imported images, audio, and exported runtime files in packaged builds.
|
||||
- Browser download code is synchronous from the user's perspective, while native save dialogs are async and cancellable. UI states should handle cancellation cleanly.
|
||||
- MP4 export is the largest unknown because Chromium and ffmpeg packaging varies by platform.
|
||||
- Large `.ruw` files may put pressure on IPC if sent as huge arrays. If this becomes a problem, switch to main-process file handles or temporary files.
|
||||
- Existing in-browser tests need an Electron smoke layer because packaged-app failures may not appear in Vite.
|
||||
|
||||
## Suggested Implementation Order
|
||||
|
||||
1. Add Electron shell and dev/build scripts.
|
||||
2. Package the current app without native integrations.
|
||||
3. Add preload typings and a tiny `desktopBridge`.
|
||||
4. Move project open/save to native dialogs.
|
||||
5. Add native export save paths for existing browser-generated blobs.
|
||||
6. Add app menu and unsaved-change handling.
|
||||
7. Refactor `server/server.js` render logic into a reusable Node module.
|
||||
8. Add desktop MP4 export through Electron main.
|
||||
9. Configure packaging icons, file associations, and platform targets.
|
||||
10. Add CI/package smoke checks.
|
||||
|
||||
## First Pull Request Scope
|
||||
|
||||
Keep the first PR intentionally small:
|
||||
|
||||
- Electron main/preload files.
|
||||
- Desktop dev and pack scripts.
|
||||
- Production loading of `dist/index.html`.
|
||||
- Basic secure window configuration.
|
||||
- No changes to project save/load or rendering behavior yet.
|
||||
|
||||
That gives RUWIO a launchable desktop shell before any app behavior changes, making the rest of the migration easier to test in small increments.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
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,
|
||||
height: 860,
|
||||
minWidth: 1024,
|
||||
minHeight: 700,
|
||||
title: 'RUWIO',
|
||||
icon: appIconPath,
|
||||
show: false,
|
||||
backgroundColor: '#14171f',
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.cjs'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
},
|
||||
})
|
||||
|
||||
mainWindow.once('ready-to-show', () => {
|
||||
mainWindow?.show()
|
||||
})
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }: { url: string }) => {
|
||||
if (isAllowedExternalUrl(url)) {
|
||||
void shell.openExternal(url)
|
||||
}
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
mainWindow.webContents.on('will-navigate', (event: Event, url: string) => {
|
||||
if (!isAllowedAppNavigation(url)) {
|
||||
event.preventDefault()
|
||||
}
|
||||
})
|
||||
|
||||
if (isDev) {
|
||||
void mainWindow.loadURL(devServerUrl)
|
||||
mainWindow.webContents.openDevTools({ mode: 'detach' })
|
||||
} else {
|
||||
void mainWindow.loadFile(path.join(app.getAppPath(), 'dist', 'index.html'))
|
||||
}
|
||||
}
|
||||
|
||||
function createMenu(): void {
|
||||
const template: MenuItemConstructorOptions[] = [
|
||||
{
|
||||
label: 'File',
|
||||
submenu: [
|
||||
{
|
||||
label: 'New Project',
|
||||
accelerator: 'CmdOrCtrl+N',
|
||||
click: () => sendAppCommand('new-project'),
|
||||
},
|
||||
{
|
||||
label: 'Open...',
|
||||
accelerator: 'CmdOrCtrl+O',
|
||||
click: () => sendAppCommand('open-project'),
|
||||
},
|
||||
{
|
||||
label: 'Recent Projects',
|
||||
accelerator: 'CmdOrCtrl+Shift+O',
|
||||
click: () => sendAppCommand('recent-projects'),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Save',
|
||||
accelerator: 'CmdOrCtrl+S',
|
||||
click: () => sendAppCommand('save-project'),
|
||||
},
|
||||
{
|
||||
label: 'Save As...',
|
||||
accelerator: 'CmdOrCtrl+Shift+S',
|
||||
click: () => sendAppCommand('save-project-as'),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Export...',
|
||||
accelerator: 'CmdOrCtrl+E',
|
||||
click: () => sendAppCommand('export-project'),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
process.platform === 'darwin' ? { role: 'close' } : { role: 'quit' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Edit',
|
||||
submenu: [
|
||||
{
|
||||
label: 'Undo',
|
||||
accelerator: 'CmdOrCtrl+Z',
|
||||
click: () => sendAppCommand('undo'),
|
||||
},
|
||||
{
|
||||
label: 'Redo',
|
||||
accelerator: 'CmdOrCtrl+Shift+Z',
|
||||
click: () => sendAppCommand('redo'),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ role: 'cut' },
|
||||
{ role: 'copy' },
|
||||
{ role: 'paste' },
|
||||
{ role: 'selectAll' },
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Copy Current Frame',
|
||||
accelerator: 'CmdOrCtrl+Alt+C',
|
||||
click: () => sendAppCommand('copy-current-frame'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'View',
|
||||
submenu: [
|
||||
{ role: 'reload' },
|
||||
{ role: 'forceReload' },
|
||||
{ role: 'toggleDevTools' },
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Play / Pause',
|
||||
accelerator: 'Space',
|
||||
click: () => sendAppCommand('toggle-playback'),
|
||||
},
|
||||
{
|
||||
label: 'Stop',
|
||||
accelerator: 'Escape',
|
||||
click: () => sendAppCommand('stop-playback'),
|
||||
},
|
||||
{
|
||||
label: 'Jump to Start',
|
||||
accelerator: 'R',
|
||||
click: () => sendAppCommand('rewind'),
|
||||
},
|
||||
{
|
||||
label: 'Fullscreen Preview',
|
||||
accelerator: 'Shift+Space',
|
||||
click: () => sendAppCommand('fullscreen-preview'),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Toggle Side Panels',
|
||||
accelerator: 'Tab',
|
||||
click: () => sendAppCommand('toggle-panels'),
|
||||
},
|
||||
{
|
||||
label: 'Media Library',
|
||||
accelerator: 'CmdOrCtrl+M',
|
||||
click: () => sendAppCommand('media-library'),
|
||||
},
|
||||
{
|
||||
label: 'Document Settings',
|
||||
accelerator: 'CmdOrCtrl+,',
|
||||
click: () => sendAppCommand('document-settings'),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{ role: 'resetZoom' },
|
||||
{ role: 'zoomIn' },
|
||||
{ role: 'zoomOut' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'togglefullscreen' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Window',
|
||||
submenu: [
|
||||
{ role: 'minimize' },
|
||||
{ role: 'zoom' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Help',
|
||||
submenu: [
|
||||
{
|
||||
label: 'RUWIO Help',
|
||||
accelerator: 'F1',
|
||||
click: () => sendAppCommand('help'),
|
||||
},
|
||||
{
|
||||
label: 'About RUWIO',
|
||||
click: () => sendAppCommand('about'),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: 'Open Test Runner',
|
||||
accelerator: 'CmdOrCtrl+Shift+Alt+T',
|
||||
click: () => sendAppCommand('test-runner'),
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
template.unshift({
|
||||
label: app.name,
|
||||
submenu: [
|
||||
{ role: 'about' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'services' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'hide' },
|
||||
{ role: 'hideOthers' },
|
||||
{ role: 'unhide' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'quit' },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
Menu.setApplicationMenu(Menu.buildFromTemplate(template))
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createMainWindow()
|
||||
})
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') app.quit()
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
|
||||
contextBridge.exposeInMainWorld('ruwioDesktop', {
|
||||
platform: process.platform,
|
||||
versions: {
|
||||
electron: process.versions.electron,
|
||||
chrome: process.versions.chrome,
|
||||
node: process.versions.node,
|
||||
},
|
||||
onMenuCommand(callback: (command: string) => void) {
|
||||
const listener = (_event: Electron.IpcRendererEvent, command: string) => callback(command)
|
||||
ipcRenderer.on('ruwio:menu-command', listener)
|
||||
return () => {
|
||||
ipcRenderer.removeListener('ruwio:menu-command', listener)
|
||||
}
|
||||
},
|
||||
renderMp4(options: {
|
||||
scene: unknown
|
||||
fps?: number
|
||||
crf?: number
|
||||
preset?: string
|
||||
suggestedName?: string
|
||||
}) {
|
||||
return ipcRenderer.invoke('ruwio:render-mp4', options)
|
||||
},
|
||||
onRenderProgress(callback: (event: {
|
||||
jobId: string
|
||||
progress: number
|
||||
done: boolean
|
||||
error?: string
|
||||
}) => void) {
|
||||
const listener = (_event: Electron.IpcRendererEvent, payload: {
|
||||
jobId: string
|
||||
progress: number
|
||||
done: boolean
|
||||
error?: string
|
||||
}) => callback(payload)
|
||||
ipcRenderer.on('ruwio:render-progress', listener)
|
||||
return () => {
|
||||
ipcRenderer.removeListener('ruwio:render-progress', listener)
|
||||
}
|
||||
},
|
||||
cancelRender(jobId: string) {
|
||||
return ipcRenderer.invoke('ruwio:render-cancel', jobId)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,309 @@
|
||||
import { BrowserWindow, app } from 'electron'
|
||||
import type { Rectangle } from 'electron'
|
||||
import { spawn } from 'node:child_process'
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { missingFfmpegMessage, resolveFfmpegPath } from './ffmpegPath.cjs'
|
||||
|
||||
const validation = require('../shared/renderValidation.cjs') as {
|
||||
playbackRange(scene: RenderScene): { start: number; end: number }
|
||||
}
|
||||
|
||||
// Inactivity watchdog: abort a job if no frame makes progress for this long.
|
||||
// The timer is re-armed on every onProgress, so steady renders never trip it;
|
||||
// only a wedged executeJavaScript/capturePage does. Mirrors the standalone
|
||||
// server's RENDER_JOB_TIMEOUT_MS safety net (finding #3).
|
||||
function readTimeoutMs(): number {
|
||||
const raw = Number(process.env.RUWIO_RENDER_TIMEOUT_MS)
|
||||
return Number.isFinite(raw) && raw >= 1000 ? raw : 2 * 60 * 1000
|
||||
}
|
||||
const RENDER_INACTIVITY_TIMEOUT_MS = readTimeoutMs()
|
||||
|
||||
// Isolated, in-memory session for the hidden render window so its webRequest
|
||||
// filter can't affect the main app session (finding #5).
|
||||
const RENDER_PARTITION = 'ruwio-render'
|
||||
|
||||
function abortReason(signal: AbortSignal): Error {
|
||||
return signal.reason instanceof Error ? signal.reason : new Error('aborted')
|
||||
}
|
||||
|
||||
// Reject as soon as `signal` aborts, otherwise settle with `promise`. Lets a
|
||||
// timeout/cancel interrupt an await that would otherwise hang (finding #3).
|
||||
function raceAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
||||
if (signal.aborted) return Promise.reject(abortReason(signal))
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const onAbort = () => reject(abortReason(signal))
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
promise.then(
|
||||
(value) => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
resolve(value)
|
||||
},
|
||||
(err) => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
reject(err)
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export interface RenderScene {
|
||||
canvas: { width: number; height: number }
|
||||
duration: number
|
||||
layers: Array<{ keyframes: Array<{ time: number }> }>
|
||||
assets?: unknown[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface RenderOptions {
|
||||
fps: number
|
||||
duration: number
|
||||
totalFrames: number
|
||||
crf: string
|
||||
preset: string
|
||||
}
|
||||
|
||||
export interface RenderJobInput {
|
||||
jobId: string
|
||||
scene: RenderScene
|
||||
options: RenderOptions
|
||||
outputPath: string
|
||||
signal: AbortSignal
|
||||
onProgress: (progress: number) => void
|
||||
}
|
||||
|
||||
export async function renderMp4Job(input: RenderJobInput): Promise<void> {
|
||||
const renderWindow = createRenderWindow(input.scene)
|
||||
const ffmpegPath = resolveFfmpegPath()
|
||||
let ffmpegErr = ''
|
||||
let settled = false
|
||||
let started = false
|
||||
|
||||
// Render into a sibling temp file and promote it on success, so a cancelled
|
||||
// or failed render never truncates a pre-existing file the user chose to
|
||||
// overwrite (finding #7). Same directory => rename stays on one filesystem.
|
||||
const finalPath = input.outputPath
|
||||
const tmpPath = path.join(
|
||||
path.dirname(finalPath),
|
||||
`.${path.basename(finalPath)}.${input.jobId}.partial`,
|
||||
)
|
||||
|
||||
// Combined abort: trips on user cancel (input.signal) or the inactivity
|
||||
// watchdog. Used to kill ffmpeg and to interrupt per-frame awaits.
|
||||
const jobAbort = new AbortController()
|
||||
const onExternalAbort = () => jobAbort.abort(new Error('aborted'))
|
||||
if (input.signal.aborted) onExternalAbort()
|
||||
else input.signal.addEventListener('abort', onExternalAbort, { once: true })
|
||||
|
||||
let watchdog: ReturnType<typeof setTimeout> | undefined
|
||||
const armWatchdog = () => {
|
||||
if (watchdog) clearTimeout(watchdog)
|
||||
watchdog = setTimeout(
|
||||
() => jobAbort.abort(new Error('render timed out')),
|
||||
RENDER_INACTIVITY_TIMEOUT_MS,
|
||||
)
|
||||
}
|
||||
|
||||
const ffmpeg = spawn(ffmpegPath, [
|
||||
'-y',
|
||||
'-f', 'image2pipe',
|
||||
'-framerate', String(input.options.fps),
|
||||
'-i', '-',
|
||||
'-c:v', 'libx264',
|
||||
'-pix_fmt', 'yuv420p',
|
||||
// libx264 + yuv420p require even dimensions, but validation allows any
|
||||
// canvas in 1..4096 — an odd width/height would otherwise make ffmpeg exit
|
||||
// with a confusing "height not divisible by 2" error. Pad up to the next
|
||||
// even size (≤1px edge column/row) so odd canvases encode (finding #13).
|
||||
'-vf', 'pad=ceil(iw/2)*2:ceil(ih/2)*2',
|
||||
'-preset', input.options.preset,
|
||||
'-crf', input.options.crf,
|
||||
'-movflags', '+faststart',
|
||||
// Force the muxer explicitly: the temp filename has no .mp4 extension, and
|
||||
// the final path may lack one too, so don't rely on extension inference.
|
||||
'-f', 'mp4',
|
||||
tmpPath,
|
||||
])
|
||||
|
||||
// If ffmpeg exits early (encode error, disk full, ...), the next stdin write
|
||||
// emits an async 'error' (EPIPE). Without a listener that becomes an
|
||||
// uncaughtException and crashes the app; the real failure still surfaces via
|
||||
// the 'exit'/ffmpegDone path below (finding #8).
|
||||
ffmpeg.stdin.on('error', () => {
|
||||
/* noop: surfaced via ffmpegDone */
|
||||
})
|
||||
|
||||
const abort = () => {
|
||||
try {
|
||||
ffmpeg.kill('SIGKILL')
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
jobAbort.signal.addEventListener('abort', abort, { once: true })
|
||||
|
||||
const ffmpegReady = new Promise<void>((resolve, reject) => {
|
||||
ffmpeg.once('spawn', () => resolve())
|
||||
ffmpeg.once('error', (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === 'ENOENT') reject(new Error(missingFfmpegMessage()))
|
||||
else reject(err)
|
||||
})
|
||||
})
|
||||
|
||||
const ffmpegDone = new Promise<void>((resolve, reject) => {
|
||||
ffmpeg.stderr.on('data', (d: Buffer) => {
|
||||
ffmpegErr += d.toString()
|
||||
})
|
||||
ffmpeg.on('spawn', () => {
|
||||
started = true
|
||||
})
|
||||
ffmpeg.on('error', (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === 'ENOENT') reject(new Error(missingFfmpegMessage()))
|
||||
else reject(err)
|
||||
})
|
||||
ffmpeg.on('exit', (code) => {
|
||||
settled = true
|
||||
if (code === 0) resolve()
|
||||
else if (jobAbort.signal.aborted) reject(abortReason(jobAbort.signal))
|
||||
else if (!started) reject(new Error(missingFfmpegMessage()))
|
||||
else reject(new Error(`ffmpeg exited with code ${code}: ${ffmpegErr.slice(-500)}`))
|
||||
})
|
||||
})
|
||||
// ffmpeg can fail mid-render, so ffmpegDone may reject long before the
|
||||
// `await ffmpegDone` after the loop attaches a handler. Keep a handler on it
|
||||
// from creation so that gap can't trip Node's unhandled-rejection process
|
||||
// termination; real handling still flows through `await ffmpegDone` (#8).
|
||||
void ffmpegDone.catch(() => {})
|
||||
|
||||
const signal = jobAbort.signal
|
||||
try {
|
||||
armWatchdog()
|
||||
await raceAbort(ffmpegReady, signal)
|
||||
await raceAbort(loadRenderPage(renderWindow), signal)
|
||||
await raceAbort(
|
||||
renderWindow.webContents.executeJavaScript(
|
||||
`window.__animatrInit(${JSON.stringify(input.scene)})`,
|
||||
true,
|
||||
),
|
||||
signal,
|
||||
)
|
||||
|
||||
const { start: rangeIn } = validation.playbackRange(input.scene)
|
||||
const captureRect: Rectangle = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: input.scene.canvas.width,
|
||||
height: input.scene.canvas.height,
|
||||
}
|
||||
|
||||
for (let i = 0; i < input.options.totalFrames; i += 1) {
|
||||
if (signal.aborted) throw abortReason(signal)
|
||||
// ffmpeg already exited (e.g. encode error): stop feeding it so the real
|
||||
// error surfaces via `await ffmpegDone` below instead of an EPIPE (#8).
|
||||
if (settled) break
|
||||
const time = rangeIn + Math.min(i / input.options.fps, input.options.duration)
|
||||
await raceAbort(
|
||||
renderWindow.webContents.executeJavaScript(`window.__animatrSeek(${time})`, true),
|
||||
signal,
|
||||
)
|
||||
const frame = await raceAbort(renderWindow.webContents.capturePage(captureRect), signal)
|
||||
// capturePage returns a NativeImage at the display's device scale factor,
|
||||
// so on a HiDPI screen the bitmap is N× the requested canvas and the MP4
|
||||
// comes out at the wrong resolution. Normalize every frame back to the
|
||||
// canvas dimensions — the standalone server pins deviceScaleFactor:1 for
|
||||
// the same reason. No-op on scale-factor-1 displays (finding #10).
|
||||
const frameSize = frame.getSize()
|
||||
const png = (frameSize.width === captureRect.width && frameSize.height === captureRect.height
|
||||
? frame
|
||||
: frame.resize({ width: captureRect.width, height: captureRect.height })
|
||||
).toPNG()
|
||||
if (!ffmpeg.stdin.write(png)) {
|
||||
// Wait for backpressure to clear — but don't hang forever if ffmpeg has
|
||||
// already died (no 'drain' will ever fire) or the job is aborted. Racing
|
||||
// ffmpegDone surfaces an early ffmpeg exit here instead of wedging (#8).
|
||||
await raceAbort(
|
||||
Promise.race([
|
||||
new Promise<void>((resolve) => ffmpeg.stdin.once('drain', resolve)),
|
||||
ffmpegDone,
|
||||
]),
|
||||
signal,
|
||||
)
|
||||
}
|
||||
input.onProgress((i + 1) / input.options.totalFrames)
|
||||
armWatchdog()
|
||||
}
|
||||
|
||||
ffmpeg.stdin.end()
|
||||
await ffmpegDone
|
||||
// Render succeeded and tmpPath is complete: promote it into place. Try a
|
||||
// plain rename first (atomic replace on POSIX); Windows won't overwrite an
|
||||
// existing destination, so fall back to remove-then-rename there (#7).
|
||||
try {
|
||||
await fs.rename(tmpPath, finalPath)
|
||||
} catch {
|
||||
await fs.rm(finalPath, { force: true })
|
||||
await fs.rename(tmpPath, finalPath)
|
||||
}
|
||||
} catch (err) {
|
||||
if (!settled) abort()
|
||||
try {
|
||||
ffmpeg.stdin.destroy()
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
// Only ever remove our temp file, never the user's chosen target (#7).
|
||||
await fs.rm(tmpPath, { force: true }).catch(() => {})
|
||||
await ffmpegDone.catch(() => {})
|
||||
throw err
|
||||
} finally {
|
||||
if (watchdog) clearTimeout(watchdog)
|
||||
input.signal.removeEventListener('abort', onExternalAbort)
|
||||
jobAbort.signal.removeEventListener('abort', abort)
|
||||
if (!renderWindow.isDestroyed()) renderWindow.close()
|
||||
}
|
||||
}
|
||||
|
||||
function createRenderWindow(scene: RenderScene): BrowserWindow {
|
||||
const win = new BrowserWindow({
|
||||
width: scene.canvas.width,
|
||||
height: scene.canvas.height,
|
||||
show: false,
|
||||
frame: false,
|
||||
// Opaque white background so transparent regions (e.g. backgroundVideo
|
||||
// scenes) flatten onto white under yuv420p — matching the standalone
|
||||
// server's Puppeteer screenshot (omitBackground:false), not black (#4).
|
||||
transparent: false,
|
||||
backgroundColor: '#ffffff',
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
partition: RENDER_PARTITION,
|
||||
// The window is never shown, so Chromium would throttle/pause rAF and the
|
||||
// per-frame paint settle in __animatrSeek would hit its 1s fallback every
|
||||
// frame (~1fps renders). Keep timers/rAF running while hidden (finding #12).
|
||||
backgroundThrottling: false,
|
||||
},
|
||||
})
|
||||
|
||||
// Defense-in-depth: the render page loads only local (file:/data:/blob:)
|
||||
// assets, so block any networked request the captured project might carry
|
||||
// (finding #5). The url filter only matches http/https, leaving local
|
||||
// schemes untouched. Scoped to this window's isolated session partition.
|
||||
win.webContents.session.webRequest.onBeforeRequest(
|
||||
{ urls: ['*://*/*'] },
|
||||
(_details, callback) => callback({ cancel: true }),
|
||||
)
|
||||
|
||||
win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
|
||||
win.webContents.on('will-navigate', (event) => event.preventDefault())
|
||||
return win
|
||||
}
|
||||
|
||||
async function loadRenderPage(win: BrowserWindow): Promise<void> {
|
||||
const renderHtml = app.isPackaged
|
||||
? path.join(process.resourcesPath, 'server', 'render.html')
|
||||
: path.join(process.cwd(), 'server', 'render.html')
|
||||
await win.loadURL(pathToFileURL(renderHtml).toString())
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 174 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
@@ -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>
|
||||
|
||||
Generated
+3782
-3
File diff suppressed because it is too large.
Load diff
+68
-3
@@ -1,14 +1,26 @@
|
||||
{
|
||||
"name": "ruwio",
|
||||
"private": true,
|
||||
"version": "1.2.2",
|
||||
"version": "1.3.2",
|
||||
"type": "module",
|
||||
"main": "dist-electron/main.cjs",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"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 && 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",
|
||||
@@ -22,8 +34,61 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.2",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"concurrently": "^9.2.1",
|
||||
"electron": "^42.3.3",
|
||||
"electron-builder": "^26.8.1",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^6.0.3",
|
||||
"vue-tsc": "^2.1.10"
|
||||
"vue-tsc": "^2.1.10",
|
||||
"wait-on": "^8.0.5"
|
||||
},
|
||||
"build": {
|
||||
"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": {
|
||||
"target": [
|
||||
"AppImage"
|
||||
],
|
||||
"category": "Graphics"
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
"nsis"
|
||||
]
|
||||
},
|
||||
"mac": {
|
||||
"target": [
|
||||
"dmg"
|
||||
],
|
||||
"category": "public.app-category.graphics-design"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
# Plan — SVG asset authoring view
|
||||
|
||||
## Concept
|
||||
A new asset kind `svg` (sibling to `button`) with its own sub-editor scope. Created from a "+" button under the Images section. The sub-editor reuses the canvas + shape primitives but has no time axis: each sub-layer is a single static frame.
|
||||
|
||||
## 1. Data model — `src/types/scene.ts`
|
||||
- Extend `AssetKind` union with `'svg'`.
|
||||
- Add `SvgAsset { id; kind: 'svg'; name; canvas: { width; height; background? }; layers: Layer[] }`.
|
||||
- Add to the `Asset` discriminated union.
|
||||
- Layers reuse the existing `Layer` type — single keyframe at `t=0` is all the renderer/sampler needs.
|
||||
|
||||
## 2. Store scope — `src/stores/scene.ts`
|
||||
- New state: `activeSvgAssetId: string | null`.
|
||||
- Actions (mirroring the button pair):
|
||||
- `addSvgAsset(name, canvas)` → creates asset, returns it.
|
||||
- `enterSvgAssetEdit(assetId)` → sets `activeSvgAssetId`, clears layer / clip / button selections, parks playhead at 0.
|
||||
- `exitSvgAssetEdit()` → nulls scope + selection.
|
||||
- The existing `addLayer(assetId)` and `addAsset(...)` already write into the active scope (button / clip / main); extend the same scope-resolver so `activeSvgAssetId` is recognised. That means `addSampleRect / addSampleCircle / addSampleText / addSamplePolygon / startFreeformDrawing` all work unchanged inside the SVG scope.
|
||||
- Block keyframe-creating paths inside SVG scope (`upsertKeyframe`, the "+ keyframe" button, arrow-nudge keyframe writes) — silently bake into the single frame at `t=0` instead of inserting a new one. Cheaper and cleaner than scrubbing them out post-hoc.
|
||||
|
||||
## 3. AssetSidebar — `src/components/AssetSidebar.vue`
|
||||
- Add a `section-action` "+" button to the Images section header (same pattern as Clips/Buttons section).
|
||||
- Handler: prompt for name (and maybe canvas size — default `300×300`) → `addSvgAsset(...)` → `enterSvgAssetEdit(asset.id)`.
|
||||
- SVG assets show up in the Images section list as their own row (icon + name + click-to-edit + drag-to-stage to place a layer that references the asset). Pattern matches Buttons section.
|
||||
|
||||
## 4. Sub-editor wiring — `src/App.vue`
|
||||
- Scope footer: add a third branch alongside `activeClipId` / `activeButtonAssetId`. SvgAsset name + back button → `exitSvgAssetEdit()`.
|
||||
- **Layout swap** (the "left side replaces library" bit): when `activeSvgAssetId` is truthy:
|
||||
- The `.sidebar` slot renders a new `SvgLayerPanel` instead of `AssetSidebar`.
|
||||
- The `.timeline` footer area is collapsed / hidden (toggle a `panels-no-timeline` class on `.app` that flips `--timeline-height` to 0 and skips rendering Timeline). Same machinery the TAB-panels feature uses, so the visual transition is consistent.
|
||||
- Header transport (play/pause/rewind/playhead) stays but should be disabled in SVG scope — there's nothing to play. Easiest: gate them on `!activeSvgAssetId`.
|
||||
|
||||
## 5. New component — `src/components/SvgLayerPanel.vue`
|
||||
A purpose-built layer list. Cannot extract from Timeline — its track-name column is too coupled to the time axis. Surface area:
|
||||
- One row per layer in `asset.layers`, top = front.
|
||||
- Per row: type icon, inline-rename name, eye toggle (`editorHidden`), lock toggle, delete.
|
||||
- Drag-to-reorder calling `moveLayer`.
|
||||
- Top toolbar with the same shape-creation buttons currently in AssetSidebar (rect / circle / text / polygon / freeform). Reuse the existing `addSample*` handlers — they already obey active scope once step 2 lands.
|
||||
- Selection state: existing `selectedLayerIds` works as-is.
|
||||
|
||||
## 6. CanvasStage — `src/components/CanvasStage.vue`
|
||||
- Already drives off `activeButtonAssetId` for the button stage. Add a parallel branch for `activeSvgAssetId` that resolves the source layers + canvas size from the SVG asset.
|
||||
- Skip the playhead animation: sample layers at `t=0` regardless of `store.playhead`. (Or: lock playhead to 0 in SVG scope so existing sampling Just Works.)
|
||||
- Disable / hide UI not relevant to authoring statics: keyframe overlays, in/out brackets, scrub guidance.
|
||||
|
||||
## 7. Rendering the SVG asset back in the main scene — `src/lib/render.ts`
|
||||
When a layer references an `SvgAsset`, draw its sub-layers via the existing layer/group draw path at `t=0`, scaled into the asset's `canvas` rect (same idea as `drawButtonFrame`). New helper `drawSvgFrame(ctx, asset, scene, images)` — small, reuses `drawMaskedGroup` like the button code does.
|
||||
|
||||
## 8. Persistence
|
||||
No work. `SvgAsset.layers` is just `Layer[]`; goes through `projectForSave` → JSON → `.ruw` round-trip transparently. The only gate is the `AssetKind` union — once that's extended, save/load + zip/unzip + scene-clone paths all continue to work unchanged.
|
||||
|
||||
## 9. i18n
|
||||
Strings: section-action button label/tooltip, scope footer label, layer-panel toolbar tooltips, the new-svg prompt copy. EN + DE.
|
||||
|
||||
## 10. Tests (devtools/tests.ts)
|
||||
- Store: `addSvgAsset` shape, `enterSvgAssetEdit` flips scope + clears selection, `exitSvgAssetEdit` reverses it, `addLayer` inside SVG scope appends to `asset.layers`.
|
||||
- Sidebar: "+" button under Images creates an asset and enters edit mode.
|
||||
- App: while `activeSvgAssetId`, sidebar shows the layer panel and the timeline area is hidden.
|
||||
- Render: a scene containing a layer referencing an SVG asset draws the asset's child shapes (snapshot via canvas pixel sample, like the existing button rendering tests).
|
||||
- Round-trip: save + reload preserves `kind: 'svg'` and the nested `layers`.
|
||||
|
||||
## 11. Decisions (confirmed)
|
||||
- **Canvas size on creation**: inherit from current scene; user can resize later via the existing resize button.
|
||||
- **Background**: transparent.
|
||||
- **Placement after creation**: drop user straight into the sub-editor.
|
||||
- **Existing keyframe data**: strip on entry — SVG sub-layers carry a single t=0 keyframe.
|
||||
|
||||
Implementation order: 1–2 (types + store) → 3 + 4 (sidebar entry + scope swap) → 5 (layer panel) → 6 (canvas stage scope) → 7 (main-scene render) → 8 (i18n) → 9 (tests).
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,3 @@
|
||||
module.exports = async function beforeBuild() {
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
const { spawnSync } = require('node:child_process')
|
||||
|
||||
const electron = require('electron')
|
||||
const args = process.argv.slice(2)
|
||||
|
||||
if (!args.length) {
|
||||
console.error('usage: node scripts/run-electron-harness.cjs <script> [args...]')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
const env = { ...process.env }
|
||||
delete env.ELECTRON_RUN_AS_NODE
|
||||
env.ELECTRON_DISABLE_SANDBOX = '1'
|
||||
|
||||
const res = spawnSync(electron, ['--no-sandbox', ...args], {
|
||||
stdio: 'inherit',
|
||||
env,
|
||||
argv0: electron,
|
||||
})
|
||||
|
||||
process.exit(res.status ?? 1)
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const { copyFileSync, chmodSync, existsSync, mkdirSync, statSync } = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { spawnSync } = require('node:child_process')
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const optional = args.includes('--optional')
|
||||
const sourceIndex = args.indexOf('--source')
|
||||
const explicitSource = sourceIndex >= 0 ? args[sourceIndex + 1] : ''
|
||||
|
||||
const root = path.resolve(__dirname, '..')
|
||||
const exe = process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'
|
||||
const targetDir = path.join(root, 'bin', process.platform)
|
||||
const target = path.join(targetDir, exe)
|
||||
|
||||
try {
|
||||
mkdirSync(targetDir, { recursive: true })
|
||||
|
||||
if (existsSync(target) && statSync(target).isFile()) {
|
||||
chmodIfNeeded(target)
|
||||
console.log(`ffmpeg already staged at ${path.relative(root, target)}`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const source = explicitSource || process.env.FFMPEG_PATH || findOnPath()
|
||||
if (!source) {
|
||||
finishWithoutBinary('ffmpeg was not found. Set FFMPEG_PATH or pass --source /path/to/ffmpeg.')
|
||||
}
|
||||
|
||||
const resolvedSource = path.resolve(source)
|
||||
if (!existsSync(resolvedSource) || !statSync(resolvedSource).isFile()) {
|
||||
finishWithoutBinary(`ffmpeg source is not a file: ${source}`)
|
||||
}
|
||||
|
||||
copyFileSync(resolvedSource, target)
|
||||
chmodIfNeeded(target)
|
||||
console.log(`staged ffmpeg from ${resolvedSource} to ${path.relative(root, target)}`)
|
||||
} catch (err) {
|
||||
if (optional) {
|
||||
console.warn(`warning: could not stage ffmpeg: ${err.message}`)
|
||||
process.exit(0)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
function findOnPath() {
|
||||
const command = process.platform === 'win32' ? 'where' : 'which'
|
||||
const result = spawnSync(command, [exe], { encoding: 'utf8' })
|
||||
if (result.status !== 0) return ''
|
||||
return result.stdout.split(/\r?\n/).map((line) => line.trim()).find(Boolean) || ''
|
||||
}
|
||||
|
||||
function chmodIfNeeded(file) {
|
||||
if (process.platform !== 'win32') chmodSync(file, 0o755)
|
||||
}
|
||||
|
||||
function finishWithoutBinary(message) {
|
||||
if (optional) {
|
||||
console.warn(`warning: ${message}`)
|
||||
process.exit(0)
|
||||
}
|
||||
console.error(message)
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// Phase B verification of the render-window network block (finding #5):
|
||||
// - a local data: image asset still loads and draws (block must not break it)
|
||||
// - an http: image asset is blocked and skipped without hanging the render
|
||||
//
|
||||
// Uses the REAL worker window (createRenderWindow) by loading render.html in a
|
||||
// window built the same way, so it exercises the actual webRequest filter.
|
||||
//
|
||||
// Run: node_modules/.bin/electron scripts/verify-assets.cjs
|
||||
|
||||
const { app, BrowserWindow } = require('electron')
|
||||
const path = require('node:path')
|
||||
const { pathToFileURL } = require('node:url')
|
||||
|
||||
const RENDER_PARTITION = 'ruwio-render'
|
||||
|
||||
// 40x40 green square as an inline SVG data URL (no network needed).
|
||||
const greenSvg =
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40'%3E%3Crect width='40' height='40' fill='%2300ff00'/%3E%3C/svg%3E"
|
||||
|
||||
function makeScene(src) {
|
||||
return {
|
||||
canvas: { width: 160, height: 120, background: '#202030' },
|
||||
duration: 1,
|
||||
assets: [{ id: 'img1', kind: 'image', width: 40, height: 40, src }],
|
||||
layers: [
|
||||
{
|
||||
id: 'l1',
|
||||
assetId: 'img1',
|
||||
visible: true,
|
||||
base: { x: 80, y: 60, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
|
||||
keyframes: [],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function createRenderWindow(scene) {
|
||||
const win = new BrowserWindow({
|
||||
width: scene.canvas.width,
|
||||
height: scene.canvas.height,
|
||||
show: false,
|
||||
frame: false,
|
||||
transparent: false,
|
||||
backgroundColor: '#ffffff',
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
partition: RENDER_PARTITION,
|
||||
},
|
||||
})
|
||||
win.webContents.session.webRequest.onBeforeRequest({ urls: ['*://*/*'] }, (_d, cb) =>
|
||||
cb({ cancel: true }),
|
||||
)
|
||||
win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
|
||||
win.webContents.on('will-navigate', (e) => e.preventDefault())
|
||||
return win
|
||||
}
|
||||
|
||||
function countGreen(image) {
|
||||
const bmp = image.getBitmap() // BGRA
|
||||
let green = 0
|
||||
for (let i = 0; i < bmp.length; i += 4) {
|
||||
if (bmp[i] < 120 && bmp[i + 1] > 150 && bmp[i + 2] < 120) green++
|
||||
}
|
||||
return green
|
||||
}
|
||||
|
||||
async function renderOnce(src) {
|
||||
const scene = makeScene(src)
|
||||
const win = createRenderWindow(scene)
|
||||
const renderHtml = path.join(process.cwd(), 'server', 'render.html')
|
||||
await win.loadURL(pathToFileURL(renderHtml).toString())
|
||||
// Bound by Promise.race so a network-blocked asset that mis-handles can't hang.
|
||||
const initDone = win.webContents.executeJavaScript(
|
||||
`window.__animatrInit(${JSON.stringify(scene)})`,
|
||||
true,
|
||||
)
|
||||
const timedOut = await Promise.race([
|
||||
initDone.then(() => false),
|
||||
new Promise((r) => setTimeout(() => r(true), 5000)),
|
||||
])
|
||||
await win.webContents.executeJavaScript('window.__animatrSeek(0)', true)
|
||||
const img = await win.webContents.capturePage({ x: 0, y: 0, width: 160, height: 120 })
|
||||
const green = countGreen(img)
|
||||
if (!win.isDestroyed()) win.close()
|
||||
return { green, timedOut }
|
||||
}
|
||||
|
||||
app.on('window-all-closed', () => {})
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
const local = await renderOnce(greenSvg)
|
||||
const remote = await renderOnce('http://10.255.255.1/never.png') // unroutable; must not hang
|
||||
|
||||
const notes = []
|
||||
let pass = true
|
||||
// ~1600 green px expected for a 40x40 image; allow for AA.
|
||||
if (local.green < 1000) { pass = false; notes.push(`local data: image did not draw (green=${local.green})`) }
|
||||
if (local.timedOut) { pass = false; notes.push('local image init timed out') }
|
||||
if (remote.green > 50) { pass = false; notes.push(`remote image drew despite block (green=${remote.green})`) }
|
||||
if (remote.timedOut) { pass = false; notes.push('remote (blocked) image init hung past 5s') }
|
||||
|
||||
console.log(`local: green=${local.green} timedOut=${local.timedOut}`)
|
||||
console.log(`remote(blocked): green=${remote.green} timedOut=${remote.timedOut}`)
|
||||
console.log(notes.length ? 'NOTES:\n ' + notes.join('\n ') : 'NOTES: none')
|
||||
console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL')
|
||||
app.exit(pass ? 0 : 1)
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
// Phase B verification of transparent-region flattening (finding #4): a scene
|
||||
// with backgroundVideo set makes render.html skip the background fill, leaving
|
||||
// transparent areas. With the opaque-white render window those areas must
|
||||
// capture as white (~255), matching the server's omitBackground:false, not
|
||||
// black. Builds the window exactly like renderWorker.createRenderWindow.
|
||||
//
|
||||
// Run: node_modules/.bin/electron scripts/verify-bg.cjs
|
||||
|
||||
const { app, BrowserWindow } = require('electron')
|
||||
const path = require('node:path')
|
||||
const { pathToFileURL } = require('node:url')
|
||||
|
||||
const RENDER_PARTITION = 'ruwio-render'
|
||||
|
||||
// backgroundVideo present -> render.html does NOT fill canvas.background.
|
||||
const scene = {
|
||||
canvas: { width: 160, height: 120, background: '#202030' },
|
||||
backgroundVideo: { src: 'data:video/mp4,', duration: 1 },
|
||||
duration: 1,
|
||||
assets: [{ id: 'a1', kind: 'rect', width: 20, height: 20, fill: '#ff0000' }],
|
||||
layers: [
|
||||
{
|
||||
id: 'l1',
|
||||
assetId: 'a1',
|
||||
visible: true,
|
||||
base: { x: 30, y: 30, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
|
||||
keyframes: [],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
function createRenderWindow() {
|
||||
const win = new BrowserWindow({
|
||||
width: scene.canvas.width,
|
||||
height: scene.canvas.height,
|
||||
show: false,
|
||||
frame: false,
|
||||
transparent: false,
|
||||
backgroundColor: '#ffffff',
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
partition: RENDER_PARTITION,
|
||||
},
|
||||
})
|
||||
win.webContents.session.webRequest.onBeforeRequest({ urls: ['*://*/*'] }, (_d, cb) =>
|
||||
cb({ cancel: true }),
|
||||
)
|
||||
return win
|
||||
}
|
||||
|
||||
app.on('window-all-closed', () => {})
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
const win = createRenderWindow()
|
||||
const renderHtml = path.join(process.cwd(), 'server', 'render.html')
|
||||
await win.loadURL(pathToFileURL(renderHtml).toString())
|
||||
await win.webContents.executeJavaScript(`window.__animatrInit(${JSON.stringify(scene)})`, true)
|
||||
await win.webContents.executeJavaScript('window.__animatrSeek(0)', true)
|
||||
const img = await win.webContents.capturePage({ x: 0, y: 0, width: 160, height: 120 })
|
||||
const bmp = img.getBitmap() // BGRA
|
||||
|
||||
// Sample a corner pixel (far from the red rect at 30,30): index 0 == (0,0).
|
||||
const corner = { b: bmp[0], g: bmp[1], r: bmp[2], a: bmp[3] }
|
||||
let white = 0
|
||||
let black = 0
|
||||
for (let i = 0; i < bmp.length; i += 4) {
|
||||
if (bmp[i] > 240 && bmp[i + 1] > 240 && bmp[i + 2] > 240) white++
|
||||
if (bmp[i] < 15 && bmp[i + 1] < 15 && bmp[i + 2] < 15) black++
|
||||
}
|
||||
|
||||
const notes = []
|
||||
let pass = true
|
||||
if (!(corner.r > 240 && corner.g > 240 && corner.b > 240)) {
|
||||
pass = false
|
||||
notes.push(`corner is not white: rgb(${corner.r},${corner.g},${corner.b})`)
|
||||
}
|
||||
if (black > 100) {
|
||||
pass = false
|
||||
notes.push(`${black} near-black px present (transparent flattened to black?)`)
|
||||
}
|
||||
|
||||
console.log(`corner=rgb(${corner.r},${corner.g},${corner.b}) whitePx=${white} blackPx=${black}`)
|
||||
console.log(notes.length ? 'NOTES:\n ' + notes.join('\n ') : 'NOTES: none')
|
||||
console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL')
|
||||
if (!win.isDestroyed()) win.close()
|
||||
app.exit(pass ? 0 : 1)
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
// Phase A cancel verification: abort renderMp4Job mid-export, confirm it
|
||||
// rejects, leaves no partial output file, and the ffmpeg child is gone.
|
||||
//
|
||||
// Run: npx tsc -p tsconfig.electron.json && node_modules/.bin/electron scripts/verify-cancel.cjs
|
||||
|
||||
const { app } = require('electron')
|
||||
const path = require('node:path')
|
||||
const os = require('node:os')
|
||||
const fs = require('node:fs')
|
||||
const { renderMp4Job } = require('../dist-electron/renderWorker.cjs')
|
||||
|
||||
const fps = 24
|
||||
const duration = 6 // long enough that we can cancel before it finishes
|
||||
const scene = {
|
||||
canvas: { width: 480, height: 360, background: '#202030' },
|
||||
duration,
|
||||
assets: [{ id: 'a1', kind: 'rect', width: 60, height: 60, fill: '#40c0ff' }],
|
||||
layers: [
|
||||
{
|
||||
id: 'l1',
|
||||
assetId: 'a1',
|
||||
visible: true,
|
||||
base: { x: 40, y: 180, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
|
||||
keyframes: [
|
||||
{ time: 0, props: { x: 40 } },
|
||||
{ time: 6, props: { x: 420 } },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
const options = { fps, duration, totalFrames: fps * duration + 1, crf: '20', preset: 'ultrafast' }
|
||||
|
||||
async function main() {
|
||||
const outPath = path.join(os.tmpdir(), 'ruwio-verify-cancel.mp4')
|
||||
fs.rmSync(outPath, { force: true })
|
||||
|
||||
const controller = new AbortController()
|
||||
let progressAtCancel = null
|
||||
|
||||
const job = renderMp4Job({
|
||||
jobId: 'cancel',
|
||||
scene,
|
||||
options,
|
||||
outputPath: outPath,
|
||||
signal: controller.signal,
|
||||
onProgress: (p) => {
|
||||
// Cancel once we've made real progress but are nowhere near done.
|
||||
if (progressAtCancel === null && p > 0.1) {
|
||||
progressAtCancel = p
|
||||
controller.abort()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
let rejected = false
|
||||
let message = ''
|
||||
try {
|
||||
await job
|
||||
} catch (err) {
|
||||
rejected = true
|
||||
message = err.message
|
||||
}
|
||||
|
||||
// Give the OS a moment to reap the killed ffmpeg + finish fs.rm cleanup.
|
||||
await new Promise((r) => setTimeout(r, 300))
|
||||
|
||||
const notes = []
|
||||
let pass = true
|
||||
if (progressAtCancel === null) {
|
||||
pass = false
|
||||
notes.push('never reached a progress point to cancel at (job too fast)')
|
||||
}
|
||||
if (!rejected) {
|
||||
pass = false
|
||||
notes.push('job resolved instead of rejecting after abort')
|
||||
} else if (!/abort/i.test(message)) {
|
||||
notes.push(`rejected, but message was: ${message}`)
|
||||
}
|
||||
if (fs.existsSync(outPath)) {
|
||||
pass = false
|
||||
notes.push(`partial output left behind: ${outPath} (${fs.statSync(outPath).size}B)`)
|
||||
}
|
||||
|
||||
console.log(`progressAtCancel=${progressAtCancel} rejected=${rejected} message="${message}"`)
|
||||
console.log(`output exists after cancel: ${fs.existsSync(outPath)}`)
|
||||
console.log(notes.length ? 'NOTES:\n ' + notes.join('\n ') : 'NOTES: none')
|
||||
console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL')
|
||||
app.exit(pass ? 0 : 1)
|
||||
}
|
||||
|
||||
// Suppress Electron's default "quit when all windows close": renderMp4Job
|
||||
// closes its render window, and the auto-quit would race our result printing.
|
||||
app.on('window-all-closed', () => {})
|
||||
|
||||
app.whenReady().then(() =>
|
||||
main().catch((err) => {
|
||||
console.error('HARNESS ERROR:', err)
|
||||
app.exit(2)
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,120 @@
|
||||
// Compares Electron capturePage output against render.html's own canvas pixels
|
||||
// for deterministic fixture scenes. This guards the capture step itself:
|
||||
// wrong scale-factor normalization, stale frames, and clipping show up here.
|
||||
|
||||
const { app, BrowserWindow, nativeImage } = require('electron')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const { pathToFileURL } = require('node:url')
|
||||
const { diffBgra } = require('../test/lib/pixelDiff.cjs')
|
||||
|
||||
const fixtures = [
|
||||
{
|
||||
file: path.join(process.cwd(), 'test', 'fixtures', 'scenes', 'capture-basic.json'),
|
||||
times: [0, 0.5, 1],
|
||||
},
|
||||
]
|
||||
|
||||
function createRenderWindow(scene) {
|
||||
return new BrowserWindow({
|
||||
width: scene.canvas.width,
|
||||
height: scene.canvas.height,
|
||||
show: false,
|
||||
frame: false,
|
||||
transparent: false,
|
||||
backgroundColor: '#ffffff',
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
backgroundThrottling: false,
|
||||
partition: 'ruwio-capture-fidelity',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let pass = true
|
||||
const notes = []
|
||||
const renderHtml = path.join(process.cwd(), 'server', 'render.html')
|
||||
|
||||
for (const fixture of fixtures) {
|
||||
const scene = JSON.parse(fs.readFileSync(fixture.file, 'utf8'))
|
||||
const win = createRenderWindow(scene)
|
||||
win.webContents.session.webRequest.onBeforeRequest(
|
||||
{ urls: ['*://*/*'] },
|
||||
(_details, callback) => callback({ cancel: true }),
|
||||
)
|
||||
win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
|
||||
win.webContents.on('will-navigate', (event) => event.preventDefault())
|
||||
|
||||
try {
|
||||
await win.loadURL(pathToFileURL(renderHtml).toString())
|
||||
await win.webContents.executeJavaScript(`window.__animatrInit(${JSON.stringify(scene)})`, true)
|
||||
|
||||
for (const t of fixture.times) {
|
||||
await win.webContents.executeJavaScript(`window.__animatrSeek(${t})`, true)
|
||||
const capture = await win.webContents.capturePage({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: scene.canvas.width,
|
||||
height: scene.canvas.height,
|
||||
})
|
||||
const canvasDataUrl = await win.webContents.executeJavaScript(
|
||||
`document.querySelector('canvas').toDataURL('image/png')`,
|
||||
true,
|
||||
)
|
||||
const expected = nativeImage.createFromDataURL(canvasDataUrl)
|
||||
const captureSize = capture.getSize()
|
||||
const expectedSize = expected.getSize()
|
||||
if (captureSize.width !== scene.canvas.width || captureSize.height !== scene.canvas.height) {
|
||||
pass = false
|
||||
notes.push(`${path.basename(fixture.file)} t=${t}: capture size ${captureSize.width}x${captureSize.height}`)
|
||||
continue
|
||||
}
|
||||
if (expectedSize.width !== scene.canvas.width || expectedSize.height !== scene.canvas.height) {
|
||||
pass = false
|
||||
notes.push(`${path.basename(fixture.file)} t=${t}: canvas size ${expectedSize.width}x${expectedSize.height}`)
|
||||
continue
|
||||
}
|
||||
const diff = diffBgra(capture.getBitmap(), expected.getBitmap(), {
|
||||
// capturePage composites the hidden window, which introduces
|
||||
// sub-perceptual AA/rounding noise (~maxDelta 6/255 on shape edges)
|
||||
// versus the raw canvas toDataURL. Tolerate that; genuine render
|
||||
// drift (wrong color, missing or stale shape, off-by-one frame)
|
||||
// produces much larger deltas over a contiguous region and still
|
||||
// trips the ratio gate. The explicit size checks above catch #10/#11.
|
||||
perChannelTolerance: 8,
|
||||
maxMismatchedRatio: 0.02,
|
||||
})
|
||||
console.log(
|
||||
`${path.basename(fixture.file)} t=${t}: mismatch=${diff.mismatchedPixels}/${diff.pixels} ` +
|
||||
`(${(diff.mismatchedRatio * 100).toFixed(4)}%) maxDelta=${diff.maxDelta} meanDelta=${diff.meanDelta.toFixed(3)}`,
|
||||
)
|
||||
if (!diff.ok) {
|
||||
pass = false
|
||||
notes.push(
|
||||
`${path.basename(fixture.file)} t=${t}: diff exceeds tolerance ` +
|
||||
`${diff.mismatchedPixels}/${diff.pixels}, maxDelta=${diff.maxDelta}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (!win.isDestroyed()) win.close()
|
||||
}
|
||||
}
|
||||
|
||||
console.log(notes.length ? 'NOTES:\n ' + notes.join('\n ') : 'NOTES: none')
|
||||
console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL')
|
||||
app.exit(pass ? 0 : 1)
|
||||
}
|
||||
|
||||
app.on('window-all-closed', () => {})
|
||||
app.commandLine.appendSwitch('disable-gpu')
|
||||
|
||||
app.whenReady().then(() =>
|
||||
main().catch((err) => {
|
||||
console.error('HARNESS ERROR:', err)
|
||||
app.exit(2)
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,138 @@
|
||||
// Phase A verification harness (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)
|
||||
}),
|
||||
)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,125 @@
|
||||
// Phase A end-to-end verification: drives the REAL compiled renderMp4Job
|
||||
// (dist-electron/renderWorker.cjs) through ffmpeg and ffprobes the result.
|
||||
//
|
||||
// Run: npx tsc -p tsconfig.electron.json && node_modules/.bin/electron scripts/verify-export.cjs
|
||||
|
||||
const { app } = require('electron')
|
||||
const path = require('node:path')
|
||||
const os = require('node:os')
|
||||
const fs = require('node:fs')
|
||||
const { execFileSync } = require('node:child_process')
|
||||
const { renderMp4Job } = require('../dist-electron/renderWorker.cjs')
|
||||
|
||||
const fps = 12
|
||||
const duration = 2
|
||||
const scene = {
|
||||
canvas: { width: 320, height: 240, background: '#202030' },
|
||||
duration,
|
||||
assets: [{ id: 'a1', kind: 'rect', width: 40, height: 40, fill: '#ff4040' }],
|
||||
layers: [
|
||||
{
|
||||
id: 'l1',
|
||||
assetId: 'a1',
|
||||
visible: true,
|
||||
base: { x: 20, y: 100, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
|
||||
keyframes: [
|
||||
{ time: 0, props: { x: 20 } },
|
||||
{ time: 2, props: { x: 260 } },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
const options = {
|
||||
fps,
|
||||
duration,
|
||||
totalFrames: fps * duration + 1,
|
||||
crf: '20',
|
||||
preset: 'ultrafast',
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const outPath = path.join(os.tmpdir(), 'ruwio-verify-export.mp4')
|
||||
fs.rmSync(outPath, { force: true })
|
||||
|
||||
const controller = new AbortController()
|
||||
let lastProgress = 0
|
||||
const startedAt = Date.now()
|
||||
await renderMp4Job({
|
||||
jobId: 'verify',
|
||||
scene,
|
||||
options,
|
||||
outputPath: outPath,
|
||||
signal: controller.signal,
|
||||
onProgress: (p) => {
|
||||
lastProgress = p
|
||||
},
|
||||
})
|
||||
const elapsedMs = Date.now() - startedAt
|
||||
const msPerFrame = elapsedMs / options.totalFrames
|
||||
|
||||
const notes = []
|
||||
let pass = true
|
||||
if (!fs.existsSync(outPath)) {
|
||||
pass = false
|
||||
notes.push('output file missing')
|
||||
} else {
|
||||
const sizeKb = (fs.statSync(outPath).size / 1024).toFixed(1)
|
||||
const probe = JSON.parse(
|
||||
execFileSync('ffprobe', [
|
||||
'-v', 'error',
|
||||
'-select_streams', 'v:0',
|
||||
'-show_entries', 'stream=codec_name,width,height,nb_read_frames',
|
||||
'-count_frames',
|
||||
'-of', 'json',
|
||||
outPath,
|
||||
]).toString(),
|
||||
)
|
||||
const s = probe.streams[0]
|
||||
console.log(`output: ${outPath} (${sizeKb} KB)`)
|
||||
console.log(`codec=${s.codec_name} ${s.width}x${s.height} frames=${s.nb_read_frames} progress=${lastProgress}`)
|
||||
console.log(`elapsed=${elapsedMs}ms (${msPerFrame.toFixed(1)}ms/frame)`)
|
||||
if (s.codec_name !== 'h264') { pass = false; notes.push(`codec ${s.codec_name} != h264`) }
|
||||
if (s.width !== 320 || s.height !== 240) { pass = false; notes.push(`wrong dims ${s.width}x${s.height}`) }
|
||||
if (Number(s.nb_read_frames) !== options.totalFrames) {
|
||||
pass = false
|
||||
notes.push(`frames ${s.nb_read_frames} != expected ${options.totalFrames}`)
|
||||
}
|
||||
if (lastProgress !== 1) { pass = false; notes.push(`final progress ${lastProgress} != 1`) }
|
||||
const maxMsPerFrame = Number(process.env.RUWIO_VERIFY_MAX_MS_PER_FRAME || 800)
|
||||
if (msPerFrame > maxMsPerFrame) {
|
||||
pass = false
|
||||
notes.push(
|
||||
`render throughput ${msPerFrame.toFixed(1)}ms/frame exceeds ${maxMsPerFrame}ms/frame ` +
|
||||
'(hidden render window may be throttled)',
|
||||
)
|
||||
}
|
||||
|
||||
// Decode first & last frame, confirm the rect moved (motion preserved).
|
||||
const f0 = path.join(os.tmpdir(), 'ruwio-f0.png')
|
||||
const f1 = path.join(os.tmpdir(), 'ruwio-flast.png')
|
||||
execFileSync('ffmpeg', ['-y', '-v', 'error', '-i', outPath, '-vf', 'select=eq(n\\,0)', '-frames:v', '1', f0])
|
||||
execFileSync('ffmpeg', ['-y', '-v', 'error', '-sseof', '-0.1', '-i', outPath, '-frames:v', '1', f1])
|
||||
const sz0 = fs.statSync(f0).size
|
||||
const sz1 = fs.statSync(f1).size
|
||||
const b0 = fs.readFileSync(f0)
|
||||
const b1 = fs.readFileSync(f1)
|
||||
const same = b0.length === b1.length && b0.equals(b1)
|
||||
console.log(`first frame ${sz0}B, last frame ${sz1}B, identical=${same}`)
|
||||
if (same) { pass = false; notes.push('first and last frame identical (no motion)') }
|
||||
}
|
||||
|
||||
console.log(notes.length ? 'NOTES:\n ' + notes.join('\n ') : 'NOTES: none')
|
||||
console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL')
|
||||
app.exit(pass ? 0 : 1)
|
||||
}
|
||||
|
||||
// renderMp4Job closes its render window; suppress the default auto-quit so it
|
||||
// doesn't race result printing.
|
||||
app.on('window-all-closed', () => {})
|
||||
|
||||
app.whenReady().then(() =>
|
||||
main().catch((err) => {
|
||||
console.error('HARNESS ERROR:', err)
|
||||
app.exit(2)
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,66 @@
|
||||
// Regression for odd canvas dimensions (#13): yuv420p MP4 export pads to even
|
||||
// dimensions instead of failing with "height not divisible by 2".
|
||||
|
||||
const { app } = require('electron')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const { execFileSync } = require('node:child_process')
|
||||
const { renderMp4Job } = require('../dist-electron/renderWorker.cjs')
|
||||
|
||||
const scene = {
|
||||
canvas: { width: 101, height: 101, background: '#111827' },
|
||||
duration: 0.5,
|
||||
assets: [{ id: 'rect', kind: 'rect', width: 45, height: 35, fill: '#f97316' }],
|
||||
layers: [
|
||||
{
|
||||
id: 'layer-rect',
|
||||
assetId: 'rect',
|
||||
visible: true,
|
||||
base: { x: 50, y: 50, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
|
||||
keyframes: [
|
||||
{ time: 0, props: { x: 38 } },
|
||||
{ time: 0.5, props: { x: 62 } },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const outPath = path.join(os.tmpdir(), 'ruwio-verify-odd-dims.mp4')
|
||||
fs.rmSync(outPath, { force: true })
|
||||
await renderMp4Job({
|
||||
jobId: 'verify-odd-dims',
|
||||
scene,
|
||||
options: { fps: 4, duration: 0.5, totalFrames: 3, crf: '28', preset: 'ultrafast' },
|
||||
outputPath: outPath,
|
||||
signal: new AbortController().signal,
|
||||
onProgress: () => {},
|
||||
})
|
||||
|
||||
const probe = JSON.parse(execFileSync('ffprobe', [
|
||||
'-v', 'error',
|
||||
'-select_streams', 'v:0',
|
||||
'-show_entries', 'stream=codec_name,width,height,nb_read_frames',
|
||||
'-count_frames',
|
||||
'-of', 'json',
|
||||
outPath,
|
||||
]).toString())
|
||||
const stream = probe.streams[0]
|
||||
const pass =
|
||||
stream.codec_name === 'h264' &&
|
||||
stream.width === 102 &&
|
||||
stream.height === 102 &&
|
||||
Number(stream.nb_read_frames) === 3
|
||||
console.log(`codec=${stream.codec_name} dimensions=${stream.width}x${stream.height} frames=${stream.nb_read_frames}`)
|
||||
console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL')
|
||||
app.exit(pass ? 0 : 1)
|
||||
}
|
||||
|
||||
app.on('window-all-closed', () => {})
|
||||
app.whenReady().then(() =>
|
||||
main().catch((err) => {
|
||||
console.error('HARNESS ERROR:', err)
|
||||
app.exit(2)
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,58 @@
|
||||
// Regression for capture resolution normalization (#10). On scale-factor-1
|
||||
// displays this is a no-op, while HiDPI displays catch capturePage inflation.
|
||||
|
||||
const { app } = require('electron')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const { execFileSync } = require('node:child_process')
|
||||
const { renderMp4Job } = require('../dist-electron/renderWorker.cjs')
|
||||
|
||||
const scene = {
|
||||
canvas: { width: 320, height: 240, background: '#ffffff' },
|
||||
duration: 0.25,
|
||||
assets: [{ id: 'rect', kind: 'rect', width: 80, height: 60, fill: '#2563eb' }],
|
||||
layers: [
|
||||
{
|
||||
id: 'layer-rect',
|
||||
assetId: 'rect',
|
||||
visible: true,
|
||||
base: { x: 160, y: 120, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
|
||||
keyframes: [{ time: 0, props: {} }],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const outPath = path.join(os.tmpdir(), 'ruwio-verify-resolution.mp4')
|
||||
fs.rmSync(outPath, { force: true })
|
||||
await renderMp4Job({
|
||||
jobId: 'verify-resolution',
|
||||
scene,
|
||||
options: { fps: 2, duration: 0.25, totalFrames: 1, crf: '28', preset: 'ultrafast' },
|
||||
outputPath: outPath,
|
||||
signal: new AbortController().signal,
|
||||
onProgress: () => {},
|
||||
})
|
||||
|
||||
const probe = JSON.parse(execFileSync('ffprobe', [
|
||||
'-v', 'error',
|
||||
'-select_streams', 'v:0',
|
||||
'-show_entries', 'stream=width,height',
|
||||
'-of', 'json',
|
||||
outPath,
|
||||
]).toString())
|
||||
const stream = probe.streams[0]
|
||||
const pass = stream.width === scene.canvas.width && stream.height === scene.canvas.height
|
||||
console.log(`ffprobe dimensions: ${stream.width}x${stream.height}`)
|
||||
console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL')
|
||||
app.exit(pass ? 0 : 1)
|
||||
}
|
||||
|
||||
app.on('window-all-closed', () => {})
|
||||
app.whenReady().then(() =>
|
||||
main().catch((err) => {
|
||||
console.error('HARNESS ERROR:', err)
|
||||
app.exit(2)
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,88 @@
|
||||
// Phase B verification of the inactivity watchdog (finding #3): point the
|
||||
// worker at a render page whose __animatrSeek never resolves, with a short
|
||||
// RUWIO_RENDER_TIMEOUT_MS, and confirm the job aborts with "render timed out"
|
||||
// and leaves no partial file.
|
||||
//
|
||||
// Run: npx tsc -p tsconfig.electron.json && node_modules/.bin/electron scripts/verify-timeout.cjs
|
||||
|
||||
// Must be set BEFORE requiring the worker — the timeout is read at module load.
|
||||
process.env.RUWIO_RENDER_TIMEOUT_MS = '2000'
|
||||
|
||||
const { app } = require('electron')
|
||||
const path = require('node:path')
|
||||
const os = require('node:os')
|
||||
const fs = require('node:fs')
|
||||
|
||||
// Build a temp project dir with a server/render.html that hangs on seek, then
|
||||
// chdir into it so the worker's loadRenderPage (process.cwd()/server/render.html)
|
||||
// picks it up.
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruwio-timeout-'))
|
||||
fs.mkdirSync(path.join(tmpDir, 'server'))
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, 'server', 'render.html'),
|
||||
`<!doctype html><html><body><script>
|
||||
window.__animatrInit = function(){ return Promise.resolve(true) }
|
||||
window.__animatrSeek = function(){ return new Promise(function(){}) } // never resolves
|
||||
</script></body></html>`,
|
||||
)
|
||||
process.chdir(tmpDir)
|
||||
|
||||
const { renderMp4Job } = require('../dist-electron/renderWorker.cjs')
|
||||
|
||||
const fps = 12
|
||||
const duration = 2
|
||||
const scene = {
|
||||
canvas: { width: 160, height: 120, background: '#202030' },
|
||||
duration,
|
||||
assets: [{ id: 'a1', kind: 'rect', width: 20, height: 20, fill: '#ff4040' }],
|
||||
layers: [
|
||||
{
|
||||
id: 'l1',
|
||||
assetId: 'a1',
|
||||
visible: true,
|
||||
base: { x: 20, y: 60, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
|
||||
keyframes: [{ time: 0, props: { x: 20 } }, { time: 2, props: { x: 140 } }],
|
||||
},
|
||||
],
|
||||
}
|
||||
const options = { fps, duration, totalFrames: fps * duration + 1, crf: '20', preset: 'ultrafast' }
|
||||
|
||||
app.on('window-all-closed', () => {})
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
const outPath = path.join(tmpDir, 'out.mp4')
|
||||
const controller = new AbortController()
|
||||
const startedAt = process.hrtime.bigint()
|
||||
|
||||
let rejected = false
|
||||
let message = ''
|
||||
try {
|
||||
await renderMp4Job({
|
||||
jobId: 'timeout',
|
||||
scene,
|
||||
options,
|
||||
outputPath: outPath,
|
||||
signal: controller.signal,
|
||||
onProgress: () => {},
|
||||
})
|
||||
} catch (err) {
|
||||
rejected = true
|
||||
message = err.message
|
||||
}
|
||||
const elapsedMs = Number(process.hrtime.bigint() - startedAt) / 1e6
|
||||
await new Promise((r) => setTimeout(r, 200))
|
||||
|
||||
const notes = []
|
||||
let pass = true
|
||||
if (!rejected) { pass = false; notes.push('job did not reject') }
|
||||
if (!/timed out/i.test(message)) { pass = false; notes.push(`message was "${message}", expected "render timed out"`) }
|
||||
if (elapsedMs < 1500 || elapsedMs > 8000) { pass = false; notes.push(`elapsed ${elapsedMs.toFixed(0)}ms outside [1500,8000] (timeout=2000)`) }
|
||||
if (fs.existsSync(outPath)) { pass = false; notes.push('partial output left behind') }
|
||||
|
||||
console.log(`rejected=${rejected} message="${message}" elapsed=${elapsedMs.toFixed(0)}ms outExists=${fs.existsSync(outPath)}`)
|
||||
console.log(notes.length ? 'NOTES:\n ' + notes.join('\n ') : 'NOTES: none')
|
||||
console.log(pass ? 'RESULT: PASS' : 'RESULT: FAIL')
|
||||
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true })
|
||||
app.exit(pass ? 0 : 1)
|
||||
})
|
||||
+7
-2
@@ -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"]
|
||||
Generated
+2007
File diff suppressed because it is too large.
Load diff
+3
-2
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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.')
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const { spawn } = require('node:child_process')
|
||||
const net = require('node:net')
|
||||
const { test } = require('node:test')
|
||||
|
||||
const HOST = '127.0.0.1'
|
||||
const TOKEN = 'server-smoke-token'
|
||||
|
||||
test('render server health, auth, and validation routes work', async (t) => {
|
||||
const port = await freePort()
|
||||
const server = spawn(process.execPath, ['server.js'], {
|
||||
cwd: __dirname,
|
||||
env: {
|
||||
...process.env,
|
||||
HOST,
|
||||
PORT: String(port),
|
||||
RENDER_TOKEN: TOKEN,
|
||||
RENDER_MAX_BODY: '1mb',
|
||||
RENDER_RATE_MAX: '100',
|
||||
PUPPETEER_EXECUTABLE_PATH: '/not-used-by-smoke-test',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
|
||||
let output = ''
|
||||
server.stdout.on('data', (chunk) => {
|
||||
output += chunk.toString()
|
||||
})
|
||||
server.stderr.on('data', (chunk) => {
|
||||
output += chunk.toString()
|
||||
})
|
||||
|
||||
t.after(async () => {
|
||||
if (server.exitCode === null) {
|
||||
server.kill('SIGTERM')
|
||||
await waitForExit(server)
|
||||
}
|
||||
})
|
||||
|
||||
await waitForServer(server, port, () => output)
|
||||
|
||||
const base = `http://${HOST}:${port}`
|
||||
|
||||
const health = await fetch(`${base}/health`)
|
||||
assert.equal(health.status, 200)
|
||||
assert.deepEqual(await health.json(), { ok: true })
|
||||
|
||||
const missingToken = await postRender(base, null, invalidRenderBody())
|
||||
assert.equal(missingToken.status, 401)
|
||||
assert.equal((await missingToken.json()).error, 'invalid render token')
|
||||
|
||||
const badToken = await postRender(base, 'wrong-token', invalidRenderBody())
|
||||
assert.equal(badToken.status, 401)
|
||||
assert.equal((await badToken.json()).error, 'invalid render token')
|
||||
|
||||
const invalidRender = await postRender(base, TOKEN, invalidRenderBody())
|
||||
assert.equal(invalidRender.status, 400)
|
||||
assert.match((await invalidRender.json()).error, /canvas must be/)
|
||||
|
||||
const missingJobEvents = await fetch(`${base}/render/does-not-exist/events?token=bad`)
|
||||
assert.equal(missingJobEvents.status, 404)
|
||||
|
||||
const missingJobFile = await fetch(`${base}/render/does-not-exist/file?token=bad`)
|
||||
assert.equal(missingJobFile.status, 404)
|
||||
|
||||
const missingJobDelete = await fetch(`${base}/render/does-not-exist?token=bad`, { method: 'DELETE' })
|
||||
assert.equal(missingJobDelete.status, 404)
|
||||
})
|
||||
|
||||
function invalidRenderBody() {
|
||||
return {
|
||||
scene: {
|
||||
canvas: { width: 0, height: 100 },
|
||||
duration: 1,
|
||||
fps: 30,
|
||||
layers: [],
|
||||
assets: [],
|
||||
},
|
||||
options: { fps: 30 },
|
||||
}
|
||||
}
|
||||
|
||||
function postRender(base, token, body) {
|
||||
const headers = { 'Content-Type': 'application/json' }
|
||||
if (token) headers.Authorization = `Bearer ${token}`
|
||||
return fetch(`${base}/render`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
function freePort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer()
|
||||
server.once('error', reject)
|
||||
server.listen(0, HOST, () => {
|
||||
const address = server.address()
|
||||
server.close(() => {
|
||||
if (!address || typeof address === 'string') reject(new Error('could not allocate test port'))
|
||||
else resolve(address.port)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForServer(server, port, output) {
|
||||
const deadline = Date.now() + 5000
|
||||
let lastError = null
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (server.exitCode !== null) {
|
||||
throw new Error(`server exited early with code ${server.exitCode}\n${output()}`)
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`http://${HOST}:${port}/health`)
|
||||
if (res.ok) return
|
||||
} catch (err) {
|
||||
lastError = err
|
||||
}
|
||||
await delay(50)
|
||||
}
|
||||
|
||||
throw new Error(`server did not start: ${lastError?.message || 'timeout'}\n${output()}`)
|
||||
}
|
||||
|
||||
function waitForExit(child) {
|
||||
return new Promise((resolve) => {
|
||||
child.once('exit', () => resolve())
|
||||
setTimeout(() => {
|
||||
if (child.exitCode === null) child.kill('SIGKILL')
|
||||
resolve()
|
||||
}, 1000).unref()
|
||||
})
|
||||
}
|
||||
|
||||
function delay(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
@@ -0,0 +1,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,
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
+112
-10
@@ -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'
|
||||
@@ -44,9 +43,9 @@ import { useHistory } from './composables/useHistory'
|
||||
import { useAudioRuntime } from './composables/useAudioRuntime'
|
||||
import { useDocumentDrawer } from './composables/useDocumentDrawer'
|
||||
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 {
|
||||
@@ -70,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
|
||||
@@ -165,6 +170,7 @@ const isDirty = ref(false)
|
||||
usePlaybackLoop()
|
||||
useAudioRuntime()
|
||||
const store = useSceneStore()
|
||||
const { formatTime } = useTimeFormat()
|
||||
const clipart = useClipartStore()
|
||||
const history = useHistory()
|
||||
const deselectAll = useDeselectAll()
|
||||
@@ -190,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
|
||||
@@ -218,6 +229,12 @@ async function saveProject() {
|
||||
await markClean()
|
||||
}
|
||||
|
||||
async function saveProjectAs() {
|
||||
currentProjectId.value = null
|
||||
currentProjectName.value = null
|
||||
await saveProject()
|
||||
}
|
||||
|
||||
function openProjectNameDrawer() {
|
||||
// Clicking the project name in the topbar toggles the document
|
||||
// drawer — edits the name inline alongside the rest of the document
|
||||
@@ -303,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
|
||||
@@ -414,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)
|
||||
@@ -496,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()
|
||||
@@ -544,12 +561,79 @@ function onChromeClick(e: MouseEvent) {
|
||||
deselectAll()
|
||||
}
|
||||
|
||||
async function runDesktopMenuCommand(command: string) {
|
||||
switch (command) {
|
||||
case 'new-project':
|
||||
await startNewProject()
|
||||
break
|
||||
case 'open-project':
|
||||
await importFromFile()
|
||||
break
|
||||
case 'recent-projects':
|
||||
welcomeOpen.value = true
|
||||
break
|
||||
case 'save-project':
|
||||
await saveProject()
|
||||
break
|
||||
case 'save-project-as':
|
||||
await saveProjectAs()
|
||||
break
|
||||
case 'export-project':
|
||||
exportOpen.value = true
|
||||
break
|
||||
case 'undo':
|
||||
history.undo()
|
||||
break
|
||||
case 'redo':
|
||||
history.redo()
|
||||
break
|
||||
case 'copy-current-frame':
|
||||
await copyStillToClipboard()
|
||||
break
|
||||
case 'toggle-playback':
|
||||
store.togglePlay()
|
||||
break
|
||||
case 'stop-playback':
|
||||
stop()
|
||||
break
|
||||
case 'rewind':
|
||||
rewind()
|
||||
break
|
||||
case 'fullscreen-preview':
|
||||
openFullscreenPreview()
|
||||
break
|
||||
case 'toggle-panels':
|
||||
panelsHidden.value = !panelsHidden.value
|
||||
break
|
||||
case 'media-library':
|
||||
clipart.toggle()
|
||||
break
|
||||
case 'document-settings':
|
||||
settingsOpen.value = true
|
||||
break
|
||||
case 'help':
|
||||
helpOpen.value = true
|
||||
break
|
||||
case 'about':
|
||||
aboutOpen.value = true
|
||||
break
|
||||
case 'test-runner':
|
||||
if (canUseTestRunner) testsOpen.value = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
let removeDesktopMenuHandler: (() => void) | null = null
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
document.addEventListener('contextmenu', onDocumentContextMenu)
|
||||
document.addEventListener('paste', onPaste)
|
||||
document.addEventListener('click', onChromeClick)
|
||||
window.addEventListener('resize', onWindowResize)
|
||||
removeDesktopMenuHandler = window.ruwioDesktop?.onMenuCommand?.((command) => {
|
||||
void runDesktopMenuCommand(command)
|
||||
}) ?? null
|
||||
void clipart.initialize()
|
||||
bootWelcome()
|
||||
})
|
||||
@@ -559,6 +643,8 @@ onBeforeUnmount(() => {
|
||||
document.removeEventListener('paste', onPaste)
|
||||
document.removeEventListener('click', onChromeClick)
|
||||
window.removeEventListener('resize', onWindowResize)
|
||||
removeDesktopMenuHandler?.()
|
||||
removeDesktopMenuHandler = null
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -624,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"
|
||||
@@ -631,7 +724,7 @@ onBeforeUnmount(() => {
|
||||
:title="t('app.stop')"
|
||||
@click="stop"
|
||||
/>
|
||||
<span class="time">{{ store.playhead.toFixed(2) }}s / {{ store.scene.duration.toFixed(2) }}s</span>
|
||||
<span class="time">{{ formatTime(store.playhead) }} / {{ formatTime(store.scene.duration) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
@@ -665,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" />
|
||||
@@ -725,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" />
|
||||
@@ -928,6 +1024,12 @@ onBeforeUnmount(() => {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.icon-btn.active {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
/* Unsaved-changes indicator on the save button. A small accent dot
|
||||
pinned to the top-right of the icon, visible at a glance. `position:
|
||||
relative` on the button lets the dot anchor inside it. */
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 147 KiB |
@@ -9,6 +9,9 @@ import {
|
||||
import { useSceneStore } from '@/stores/scene'
|
||||
import { t } from '@/i18n'
|
||||
import type { Action } from '@/types/scene'
|
||||
import { useTimeFormat } from '@/composables/useTimeFormat'
|
||||
|
||||
const { timeToInputValue, inputValueToSeconds, inputStep, unitLabel } = useTimeFormat()
|
||||
|
||||
const props = defineProps<{ actions: Action[] }>()
|
||||
const emit = defineEmits<{ 'update:actions': [value: Action[]] }>()
|
||||
@@ -154,13 +157,13 @@ function onTypeChange(index: number, newType: Action['type']) {
|
||||
</div>
|
||||
<div v-if="a.type === 'jumpTo'" class="params">
|
||||
<label>
|
||||
{{ t('actions.param.time') }}
|
||||
{{ t('actions.param.time') }} ({{ unitLabel }})
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
:step="inputStep"
|
||||
min="0"
|
||||
:value="a.time"
|
||||
@input="setAt(i, { type: 'jumpTo', time: Number(($event.target as HTMLInputElement).value) })"
|
||||
:value="timeToInputValue(a.time)"
|
||||
@input="setAt(i, { type: 'jumpTo', time: inputValueToSeconds(Number(($event.target as HTMLInputElement).value)) })"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
@@ -363,6 +366,11 @@ function onTypeChange(index: number, newType: Action['type']) {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.params input[type='number'] {
|
||||
width: 96px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.params textarea {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 11px;
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from '@phosphor-icons/vue'
|
||||
import { useSceneStore } from '@/stores/scene'
|
||||
import { useAssetThumbsPref } from '@/composables/useAssetThumbsPref'
|
||||
import { useTimeFormat } from '@/composables/useTimeFormat'
|
||||
import { importAudioFile, importImageFile, isAudioFile } from '@/lib/fileLoader'
|
||||
import {
|
||||
clearAudioDragSource,
|
||||
@@ -32,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'
|
||||
|
||||
@@ -42,6 +43,7 @@ const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const uploadError = ref<string | null>(null)
|
||||
|
||||
const { enabled: showThumbs } = useAssetThumbsPref()
|
||||
const { formatTime } = useTimeFormat()
|
||||
|
||||
/** Per-section collapsed state, persisted so the user's layout
|
||||
* survives reloads. Each key is false (expanded) by default —
|
||||
@@ -140,7 +142,7 @@ async function addSampleText() {
|
||||
name: text.length > 24 ? text.slice(0, 24) + '…' : text,
|
||||
text,
|
||||
fontSize: 48,
|
||||
fill: '#f5d76e',
|
||||
fill: '#000000',
|
||||
})
|
||||
store.addLayer(asset.id)
|
||||
}
|
||||
@@ -622,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">
|
||||
@@ -782,7 +774,7 @@ function onAssetDragStart(e: DragEvent, asset: ImageOrSvgAsset) {
|
||||
<div class="clip-meta">
|
||||
<span class="clip-name" v-tooltip="clip.name">{{ clip.name }}</span>
|
||||
<span class="clip-sub">
|
||||
{{ t('sidebar.clipLayerCount', { n: clip.layers.length }) }} · {{ clip.duration.toFixed(1) }}s
|
||||
{{ t('sidebar.clipLayerCount', { n: clip.layers.length }) }} · {{ formatTime(clip.duration, { short: true, decimals: 1 }) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="clip-actions">
|
||||
@@ -882,7 +874,7 @@ function onAssetDragStart(e: DragEvent, asset: ImageOrSvgAsset) {
|
||||
</div>
|
||||
<div class="clip-meta">
|
||||
<span class="clip-name" v-tooltip="asset.name">{{ asset.name }}</span>
|
||||
<span class="clip-sub">{{ asset.duration.toFixed(2) }}s</span>
|
||||
<span class="clip-sub">{{ formatTime(asset.duration) }}</span>
|
||||
</div>
|
||||
<div class="clip-actions">
|
||||
<button
|
||||
@@ -986,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. */
|
||||
|
||||
+623
-49
@@ -9,6 +9,7 @@ import {
|
||||
PhCursorClick,
|
||||
PhFrameCorners,
|
||||
PhMaskHappy,
|
||||
PhPath,
|
||||
PhPencilSimple,
|
||||
PhPolygon,
|
||||
PhSlidersHorizontal,
|
||||
@@ -20,13 +21,23 @@ import { isButtonLayerActiveAt, isLayerActiveAt, sampleLayer } from '@/lib/inter
|
||||
import { importImageFile, loadImage } from '@/lib/fileLoader'
|
||||
import { useClipartStore } from '@/stores/clipart'
|
||||
import { useDocumentDrawer } from '@/composables/useDocumentDrawer'
|
||||
import { fontCssFamily, fontsReady } from '@/lib/fonts'
|
||||
import {
|
||||
clipArmLocalTime,
|
||||
clipArmTranslate,
|
||||
kaleidoAngleDegrees,
|
||||
kaleidoCountFor,
|
||||
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,
|
||||
@@ -41,6 +52,7 @@ import { t } from '@/i18n'
|
||||
import { useDeselectAll } from '@/composables/useDeselectAll'
|
||||
import { useKfSelection } from '@/composables/useKfSelection'
|
||||
import { showLocked } from '@/composables/useShowLocked'
|
||||
import { showMotionPaths } from '@/composables/useShowMotionPaths'
|
||||
import { expandedTrackOrder } from '@/lib/sceneOrder'
|
||||
import { CLIP_FALLBACK_BOUNDS, computeClipBounds, type Rect } from '@/lib/clipBounds'
|
||||
|
||||
@@ -540,6 +552,51 @@ function buttonChildConfig<T extends Record<string, unknown>>(cfg: T): T {
|
||||
return { ...cfg, draggable: false, listening: false }
|
||||
}
|
||||
|
||||
/** Returns [0..N-1] when kaleido is on, else [0]. The Konva templates
|
||||
* iterate this and call `withK(cfg, ki, n)` to add the per-copy
|
||||
* rotation. For ki>0 the copy is non-listening so only the source
|
||||
* receives clicks/drag/transform events. */
|
||||
function kaleidoIndicesForLayer(layer: Layer): number[] {
|
||||
const n = kaleidoCountFor(layer)
|
||||
if (n <= 1) return [0]
|
||||
return Array.from({ length: n }, (_, i) => i)
|
||||
}
|
||||
|
||||
/** Augments a layer's Konva config with the kaleido per-copy
|
||||
* position + rotation. Pivot lookup matches render.ts: live drag
|
||||
* override → keyframed `props.kaleidoPivotX/Y` → static layer
|
||||
* override → layer's own (x, y). Non-source copies are
|
||||
* listening:false to keep events on the source. */
|
||||
function withK<T extends Record<string, unknown>>(
|
||||
cfg: T,
|
||||
layer: Layer,
|
||||
props: AnimatableProps,
|
||||
ki: number,
|
||||
n: number,
|
||||
): T {
|
||||
if (n <= 1 || ki === 0) return cfg
|
||||
const live = liveKaleidoPivot.value
|
||||
const liveOverride = live && live.instanceId === layer.id
|
||||
const pivotX = liveOverride
|
||||
? live.x
|
||||
: props.kaleidoPivotX ?? layer.kaleidoPivotX ?? props.x
|
||||
const pivotY = liveOverride
|
||||
? live.y
|
||||
: props.kaleidoPivotY ?? layer.kaleidoPivotY ?? props.y
|
||||
const angle = kaleidoAngleDegrees(ki, n)
|
||||
const a = (angle * Math.PI) / 180
|
||||
const dx = props.x - pivotX
|
||||
const dy = props.y - pivotY
|
||||
return {
|
||||
...cfg,
|
||||
x: pivotX + dx * Math.cos(a) - dy * Math.sin(a),
|
||||
y: pivotY + dx * Math.sin(a) + dy * Math.cos(a),
|
||||
rotation: props.rotation + angle,
|
||||
listening: false,
|
||||
draggable: false,
|
||||
} as T
|
||||
}
|
||||
|
||||
/** SVG-asset child wrapper. Same listening/drag config as button
|
||||
* children, but also pins `strokeScaleEnabled: false`. svgStackForAsset
|
||||
* pre-multiplies each child's strokeWidth by the outer placement
|
||||
@@ -707,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,
|
||||
@@ -906,6 +1095,10 @@ interface RenderedClipInstance {
|
||||
* no measurable layers (template falls back to a placeholder). */
|
||||
bounds: Rect | null
|
||||
items: RenderedClipLayer[]
|
||||
/** Kaleido arm index (0 = source). Static (non-kaleido) instances
|
||||
* always emit a single bundle with armIndex 0, armCount 1. */
|
||||
armIndex: number
|
||||
armCount: number
|
||||
}
|
||||
|
||||
type StackItem =
|
||||
@@ -1051,39 +1244,50 @@ const stackItems = computed<StackItem[]>(() => {
|
||||
if (!clip) continue
|
||||
const trimStart = inst.trimStart ?? 0
|
||||
const trimEnd = inst.trimEnd ?? 0
|
||||
const visible = Math.max(0, clip.duration - trimStart - trimEnd)
|
||||
const visibleDur = Math.max(0, clip.duration - trimStart - trimEnd)
|
||||
const offsetIntoBlock = store.playhead - inst.startTime
|
||||
if (offsetIntoBlock < 0 || offsetIntoBlock > visible) continue
|
||||
const localTime = trimStart + offsetIntoBlock
|
||||
// Bundle the clip's layers under a single clip-instance entry
|
||||
// so the template can wrap them in a draggable Konva group that
|
||||
// carries the instance's x / y / scale transform.
|
||||
const items: RenderedClipLayer[] = []
|
||||
for (let k = clip.layers.length - 1; k >= 0; k--) {
|
||||
const cl = clip.layers[k]
|
||||
if (!cl.visible) continue
|
||||
if (!isLayerActiveAt(cl, localTime)) continue
|
||||
const ca = assetMap.value.get(cl.assetId)
|
||||
if (!ca) continue
|
||||
items.push({
|
||||
instanceId: inst.id,
|
||||
layer: cl,
|
||||
asset: ca,
|
||||
props: sampleLayer(cl, localTime),
|
||||
// For kaleido instances delayed arms can outlive the source's
|
||||
// window; widen the early-out so they survive into the loop.
|
||||
const armCount = kaleidoCountFor(inst)
|
||||
const offsetSec = inst.kaleidoOffset ?? 0
|
||||
const maxArmDelay = armCount > 1 && offsetSec !== 0
|
||||
? Math.max(0, (armCount - 1) * offsetSec)
|
||||
: 0
|
||||
if (offsetIntoBlock < 0 || offsetIntoBlock > visibleDur + maxArmDelay) continue
|
||||
const baseLocal = trimStart + offsetIntoBlock
|
||||
const bounds = computeClipBounds(clip, assetMap.value)
|
||||
// Bundle each kaleido arm separately. Each arm samples its own
|
||||
// localT so a delayed arm shows the right clip frame for *its*
|
||||
// playback offset (not the source's). Static (non-kaleido)
|
||||
// instances run this loop once with armIndex 0.
|
||||
for (let armIndex = 0; armIndex < armCount; armIndex++) {
|
||||
const localT = clipArmLocalTime(inst, baseLocal, armIndex, visibleDur)
|
||||
if (localT === null) continue
|
||||
const items: RenderedClipLayer[] = []
|
||||
for (let k = clip.layers.length - 1; k >= 0; k--) {
|
||||
const cl = clip.layers[k]
|
||||
if (!cl.visible) continue
|
||||
if (!isLayerActiveAt(cl, localT)) continue
|
||||
const ca = assetMap.value.get(cl.assetId)
|
||||
if (!ca) continue
|
||||
items.push({
|
||||
instanceId: inst.id,
|
||||
layer: cl,
|
||||
asset: ca,
|
||||
props: sampleLayer(cl, localT),
|
||||
})
|
||||
}
|
||||
out.push({
|
||||
kind: 'clipInstance',
|
||||
bundle: {
|
||||
instance: inst,
|
||||
bounds,
|
||||
items,
|
||||
armIndex,
|
||||
armCount,
|
||||
},
|
||||
})
|
||||
}
|
||||
// Always push the bundle when the instance is in time-range,
|
||||
// even if no items are renderable at this playhead: the frame
|
||||
// rect still needs to render so the clip can be selected (and
|
||||
// the transformer framed) on empty frames.
|
||||
out.push({
|
||||
kind: 'clipInstance',
|
||||
bundle: {
|
||||
instance: inst,
|
||||
bounds: computeClipBounds(clip, assetMap.value),
|
||||
items,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
@@ -1303,11 +1507,17 @@ function circleConfig(
|
||||
function textConfig(layer: Layer, asset: Extract<Asset, { kind: 'text' }>, props: AnimatableProps) {
|
||||
const fill = props.fill ?? asset.fill
|
||||
const base = baseNodeConfig(layer, props)
|
||||
// Read fontsReady so this config recomputes once bundled fonts
|
||||
// resolve — otherwise Konva caches a measurement made against the
|
||||
// fallback family and the text is rendered too narrow.
|
||||
void fontsReady.value
|
||||
const family = fontCssFamily(asset.fontFamily)
|
||||
if (isLayerGhostLocked(layer)) {
|
||||
return {
|
||||
...base,
|
||||
text: asset.text,
|
||||
fontSize: asset.fontSize,
|
||||
fontFamily: family,
|
||||
offsetX: layer.offsetX ?? 0,
|
||||
offsetY: layer.offsetY ?? 0,
|
||||
fill: undefined,
|
||||
@@ -1320,6 +1530,7 @@ function textConfig(layer: Layer, asset: Extract<Asset, { kind: 'text' }>, props
|
||||
...base,
|
||||
text: asset.text,
|
||||
fontSize: asset.fontSize,
|
||||
fontFamily: family,
|
||||
offsetX: layer.offsetX ?? 0,
|
||||
offsetY: layer.offsetY ?? 0,
|
||||
...(layer.isMask ? {} : strokeCfg(props, asset)),
|
||||
@@ -1508,6 +1719,191 @@ function clipInstanceGroupConfig(inst: ClipInstance) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Per-arm Konva Group config for a kaleido'd clip instance. ki=0 is
|
||||
* the source (events go here), ki>0 are decorative non-listening
|
||||
* copies. Spatial layout mirrors lib/kaleido.ts: rotateCopies=true
|
||||
* rotates the entire local space around the pivot (contents follow);
|
||||
* rotateCopies=false places the instance translated around the pivot
|
||||
* without rotating its contents. */
|
||||
function clipInstanceKaleidoGroupConfig(inst: ClipInstance, ki: number) {
|
||||
const base = clipInstanceGroupConfig(inst)
|
||||
const n = kaleidoCountFor(inst)
|
||||
if (n <= 1 || ki === 0) return base
|
||||
// Read the pivot through `effectiveKaleidoPivot` so a live drag
|
||||
// overrides the stored value — keeps every arm orbiting around
|
||||
// the cursor in real time during the drag.
|
||||
const { x: px, y: py } = effectiveKaleidoPivot(inst)
|
||||
if (shouldRotateClipCopies(inst)) {
|
||||
// Rotate the source's local space around the pivot. Konva doesn't
|
||||
// expose a "rotate around an arbitrary point" knob in a single
|
||||
// node, but we can replicate it by translating to the pivot,
|
||||
// rotating, and translating back — folded into x / y / rotation.
|
||||
const a = (kaleidoAngleDegrees(ki, n) * Math.PI) / 180
|
||||
const dx = (inst.x ?? 0) - px
|
||||
const dy = (inst.y ?? 0) - py
|
||||
const rotatedX = px + dx * Math.cos(a) - dy * Math.sin(a)
|
||||
const rotatedY = py + dx * Math.sin(a) + dy * Math.cos(a)
|
||||
return {
|
||||
...base,
|
||||
x: rotatedX,
|
||||
y: rotatedY,
|
||||
rotation: (inst.rotation ?? 0) + kaleidoAngleDegrees(ki, n),
|
||||
listening: false,
|
||||
draggable: false,
|
||||
}
|
||||
}
|
||||
// rotateCopies = false: position around the pivot but keep the
|
||||
// source orientation. Compute the rotated translate inline so we
|
||||
// pick up the live pivot too.
|
||||
const a = (kaleidoAngleDegrees(ki, n) * Math.PI) / 180
|
||||
const ix = inst.x ?? 0
|
||||
const iy = inst.y ?? 0
|
||||
const dx = ix - px
|
||||
const dy = iy - py
|
||||
return {
|
||||
...base,
|
||||
x: px + dx * Math.cos(a) - dy * Math.sin(a),
|
||||
y: py + dx * Math.sin(a) + dy * Math.cos(a),
|
||||
listening: false,
|
||||
draggable: false,
|
||||
}
|
||||
}
|
||||
|
||||
/** Live (mid-drag) override for the kaleido pivot. Pinia mutations
|
||||
* alone don't propagate fast enough through vue-konva while Konva's
|
||||
* drag loop is active — the kaleido arms only reposition at mouse-
|
||||
* up. Mirroring the `liveLayerPoses` pattern: a local Vue ref is
|
||||
* written on every dragmove and consumed by the per-arm config
|
||||
* helper so the orbit follows the pointer in real time, then
|
||||
* committed to the store on dragend. */
|
||||
const liveKaleidoPivot = ref<{ instanceId: string; x: number; y: number } | null>(null)
|
||||
|
||||
function effectiveKaleidoPivot(inst: ClipInstance): { x: number; y: number } {
|
||||
const live = liveKaleidoPivot.value
|
||||
if (live && live.instanceId === inst.id) return { x: live.x, y: live.y }
|
||||
return {
|
||||
x: inst.kaleidoPivotX ?? (inst.x ?? 0),
|
||||
y: inst.kaleidoPivotY ?? (inst.y ?? 0),
|
||||
}
|
||||
}
|
||||
|
||||
/** Visible pivot handle for a kaleido'd clip instance OR layer.
|
||||
* Appears at the pivot world position whenever the entity is
|
||||
* selected and has kaleido on. Drag is wired manually via window-
|
||||
* level pointer events — Konva's `draggable: true` doesn't fire
|
||||
* dragmove reliably for shapes nested inside the clipFunc'd canvas
|
||||
* group here, so we drive position-from-pointer ourselves. */
|
||||
type KaleidoPivotEntity =
|
||||
| { kind: 'clip'; instanceId: string; x: number; y: number }
|
||||
| { kind: 'layer'; layerId: string; x: number; y: number }
|
||||
|
||||
const kaleidoPivotHandle = computed<KaleidoPivotEntity | null>(() => {
|
||||
// Clip instance takes precedence (more specific selection).
|
||||
const ciId = store.selectedClipInstanceId
|
||||
if (ciId) {
|
||||
const inst = store.scene.clipInstances?.find((c) => c.id === ciId)
|
||||
if (!inst || !inst.kaleido) return null
|
||||
const { x, y } = effectiveKaleidoPivot(inst)
|
||||
return { kind: 'clip', instanceId: inst.id, x, y }
|
||||
}
|
||||
const lyId = store.selectedLayerId
|
||||
if (lyId) {
|
||||
const layer = store.activeLayers.find((l) => l.id === lyId)
|
||||
if (!layer || !layer.kaleido) return null
|
||||
// Skip button-asset layers — kaleido is disabled for them and
|
||||
// a stray pivot handle would be confusing.
|
||||
const asset = assetMap.value.get(layer.assetId)
|
||||
if (asset?.kind === 'button') return null
|
||||
const sampled = sampleLayer(layer, store.playhead)
|
||||
const live = liveKaleidoPivot.value
|
||||
// Pivot lookup matches render.ts: live drag → keyframed prop →
|
||||
// legacy static layer field → sampled layer position.
|
||||
const x = live && live.instanceId === lyId
|
||||
? live.x
|
||||
: sampled.kaleidoPivotX ?? layer.kaleidoPivotX ?? sampled.x
|
||||
const y = live && live.instanceId === lyId
|
||||
? live.y
|
||||
: sampled.kaleidoPivotY ?? layer.kaleidoPivotY ?? sampled.y
|
||||
return { kind: 'layer', layerId: layer.id, x, y }
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const kaleidoPivotHandleConfig = computed(() => {
|
||||
const h = kaleidoPivotHandle.value
|
||||
if (!h) return null
|
||||
return {
|
||||
x: h.x,
|
||||
y: h.y,
|
||||
radius: 9 / zoom.value,
|
||||
stroke: '#ff7a59',
|
||||
strokeWidth: 2 / zoom.value,
|
||||
strokeScaleEnabled: false,
|
||||
fill: 'rgba(255, 122, 89, 0.18)',
|
||||
name: 'kaleido-pivot-handle',
|
||||
}
|
||||
})
|
||||
|
||||
let pivotDragEntity: KaleidoPivotEntity | null = null
|
||||
let pivotDragKonvaLayer: Konva.Layer | null = null
|
||||
|
||||
function clientToCanvas(clientX: number, clientY: number) {
|
||||
const el = container.value
|
||||
if (!el) return null
|
||||
const rect = el.getBoundingClientRect()
|
||||
const sx = clientX - rect.left
|
||||
const sy = clientY - rect.top
|
||||
return {
|
||||
x: (sx - viewX.value) / zoom.value,
|
||||
y: (sy - viewY.value) / zoom.value,
|
||||
}
|
||||
}
|
||||
|
||||
function onKaleidoPivotMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
|
||||
e.cancelBubble = true
|
||||
const h = kaleidoPivotHandle.value
|
||||
if (!h) return
|
||||
pivotDragEntity = h
|
||||
pivotDragKonvaLayer = e.target.getLayer()
|
||||
window.addEventListener('pointermove', onKaleidoPivotPointerMove)
|
||||
window.addEventListener('pointerup', onKaleidoPivotPointerUp, { once: true })
|
||||
}
|
||||
|
||||
function onKaleidoPivotPointerMove(e: PointerEvent) {
|
||||
if (!pivotDragEntity) return
|
||||
const c = clientToCanvas(e.clientX, e.clientY)
|
||||
if (!c) return
|
||||
// The live ref is keyed by entityId — both clip-instance ids and
|
||||
// layer ids are universally-unique uids, so a single map is fine.
|
||||
const id = pivotDragEntity.kind === 'clip'
|
||||
? pivotDragEntity.instanceId
|
||||
: pivotDragEntity.layerId
|
||||
liveKaleidoPivot.value = { instanceId: id, x: c.x, y: c.y }
|
||||
pivotDragKonvaLayer?.batchDraw()
|
||||
}
|
||||
|
||||
function onKaleidoPivotPointerUp() {
|
||||
window.removeEventListener('pointermove', onKaleidoPivotPointerMove)
|
||||
if (pivotDragEntity && liveKaleidoPivot.value) {
|
||||
if (pivotDragEntity.kind === 'clip') {
|
||||
store.setClipInstanceKaleido(pivotDragEntity.instanceId, {
|
||||
kaleidoPivotX: liveKaleidoPivot.value.x,
|
||||
kaleidoPivotY: liveKaleidoPivot.value.y,
|
||||
})
|
||||
} else {
|
||||
// Layer pivot is keyframable. Upsert at the current playhead
|
||||
// so dropping multiple kfs animates the orbit centre.
|
||||
store.upsertKeyframe(pivotDragEntity.layerId, store.playhead, {
|
||||
kaleidoPivotX: liveKaleidoPivot.value.x,
|
||||
kaleidoPivotY: liveKaleidoPivot.value.y,
|
||||
})
|
||||
}
|
||||
}
|
||||
liveKaleidoPivot.value = null
|
||||
pivotDragEntity = null
|
||||
pivotDragKonvaLayer = null
|
||||
}
|
||||
|
||||
/** Invisible frame rect sized to the clip's union bbox. Sits as the
|
||||
* first child of the ci-group so:
|
||||
* - the group's client rect is dominated by this stable rectangle,
|
||||
@@ -1611,12 +2007,14 @@ function clipCircleConfig(item: RenderedClipLayer) {
|
||||
}
|
||||
|
||||
function clipTextConfig(item: RenderedClipLayer) {
|
||||
void fontsReady.value
|
||||
const asset = item.asset as Extract<Asset, { kind: 'text' }>
|
||||
const fill = item.props.fill ?? asset.fill
|
||||
const cfg: Record<string, unknown> = {
|
||||
...clipBaseConfig(item.instanceId, item.layer, item.props),
|
||||
text: asset.text,
|
||||
fontSize: asset.fontSize,
|
||||
fontFamily: fontCssFamily(asset.fontFamily),
|
||||
offsetX: item.layer.offsetX ?? 0,
|
||||
offsetY: item.layer.offsetY ?? 0,
|
||||
...strokeCfg(item.props, asset),
|
||||
@@ -1757,10 +2155,10 @@ interface VertexHandle {
|
||||
}
|
||||
|
||||
const polygonVertexHandles = computed<VertexHandle[]>(() => {
|
||||
// Vertex handles only apply to a SOLO polygon selection — in a
|
||||
// mixed multi-selection the shared transformer bounding box owns
|
||||
// scale / rotate, and per-vertex handles would visually fight it.
|
||||
if (store.selectedLayerIds.length > 1) return []
|
||||
// Vertex handles only apply to a SOLO polygon selection in
|
||||
// *vertex-edit mode* (entered via double-click). Level-1
|
||||
// selection of a polygon shows the regular transformer instead.
|
||||
if (!polygonInVertexEdit.value) return []
|
||||
const id = store.selectedLayerId
|
||||
if (!id) return []
|
||||
const layer = store.activeLayers.find((l) => l.id === id)
|
||||
@@ -2085,10 +2483,26 @@ const selectedPolygonLayer = computed(() => {
|
||||
return { layer, asset }
|
||||
})
|
||||
|
||||
/** Layer id of the polygon currently in vertex-edit mode. A
|
||||
* polygon's first selection lands at "level 1" — the regular
|
||||
* transformer (size + rotation) attaches like any other shape.
|
||||
* Double-click promotes the polygon to "level 2" / vertex edit:
|
||||
* per-vertex handles + tangent handles + edge-insert dbl-clicks
|
||||
* light up. Cleared on selection change, scope change, or Escape. */
|
||||
const polygonVertexEditId = ref<string | null>(null)
|
||||
|
||||
const polygonInVertexEdit = computed(() => {
|
||||
if (store.selectedLayerIds.length > 1) return false
|
||||
const id = store.selectedLayerId
|
||||
if (!id) return false
|
||||
return polygonVertexEditId.value === id
|
||||
})
|
||||
|
||||
watch(
|
||||
() => store.selectedLayerId,
|
||||
() => {
|
||||
selectedVertexIndices.value = new Set()
|
||||
polygonVertexEditId.value = null
|
||||
closeVertexMenu()
|
||||
},
|
||||
)
|
||||
@@ -2313,8 +2727,10 @@ function attachTransformer(retriesLeft = 3) {
|
||||
|
||||
// Multi-select: wrap every selected layer node in one transformer
|
||||
// so the bounding box + scale / rotate handles span the group.
|
||||
// A SOLE polygon selection still bypasses the transformer because
|
||||
// a single polygon is edited via vertex handles; mixed selections
|
||||
// A SOLE polygon selection bypasses the transformer ONLY when
|
||||
// the user has promoted it to vertex-edit mode (double-click);
|
||||
// in level-1 / single-click selection the polygon gets the
|
||||
// regular transformer like any other shape. Mixed selections
|
||||
// that happen to include a polygon fold it into the shared bbox.
|
||||
const ids = store.selectedLayerIds.length
|
||||
? store.selectedLayerIds
|
||||
@@ -2333,7 +2749,13 @@ function attachTransformer(retriesLeft = 3) {
|
||||
const layer = store.activeLayers.find((l) => l.id === id)
|
||||
if (!layer) continue
|
||||
const asset = assetMap.value.get(layer.assetId)
|
||||
if (asset?.kind === 'polygon' && single) continue
|
||||
if (
|
||||
asset?.kind === 'polygon' &&
|
||||
single &&
|
||||
polygonInVertexEdit.value
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const node = stage.findOne<Konva.Node>(`#${id}`)
|
||||
if (!node) {
|
||||
anyMissing = true
|
||||
@@ -2359,6 +2781,7 @@ watch(
|
||||
store.selectedClipInstanceId,
|
||||
stackItems.value,
|
||||
store.playhead,
|
||||
polygonVertexEditId.value,
|
||||
] as const,
|
||||
async () => {
|
||||
await nextTick()
|
||||
@@ -2537,6 +2960,89 @@ const animationPath = computed<PathOverlay | null>(() => {
|
||||
}
|
||||
})
|
||||
|
||||
/** Motion-path overlay for the currently-selected layer (toggle-
|
||||
* driven, off by default). Renders the layer's position-keyframe
|
||||
* path even when no keyframe is explicitly selected — handy for
|
||||
* visualizing the trajectory while tweaking other props. Skipped
|
||||
* when `animationPath` is already producing one (the kf-selection
|
||||
* view wins, since it offers richer interaction). */
|
||||
interface MotionPathOverlay {
|
||||
layerId: string
|
||||
segments: { points: number[] }[]
|
||||
dots: { keyframeId: string; time: number; x: number; y: number }[]
|
||||
}
|
||||
|
||||
const motionPathOverlay = computed<MotionPathOverlay | null>(() => {
|
||||
if (!showMotionPaths.value) return null
|
||||
if (animationPath.value) return null
|
||||
const id = store.selectedLayerId
|
||||
if (!id) return null
|
||||
const layer = store.activeLayers.find((l) => l.id === id)
|
||||
if (!layer) return null
|
||||
const arr = layer.separated ? layer.positionKeyframes : layer.keyframes
|
||||
if (!arr || arr.length < 2) return null
|
||||
const sorted = [...arr]
|
||||
.filter((k) => k.kind !== 'end')
|
||||
.sort((a, b) => a.time - b.time)
|
||||
if (sorted.length < 2) return null
|
||||
const fps = store.scene.fps || 60
|
||||
const segments: { points: number[] }[] = []
|
||||
for (let i = 0; i < sorted.length - 1; i++) {
|
||||
const flat = sampleSegment(layer, sorted[i].time, sorted[i + 1].time, fps)
|
||||
if (flat.length >= 4) segments.push({ points: flat })
|
||||
}
|
||||
if (!segments.length) return null
|
||||
const dots = sorted.map((k) => {
|
||||
const s = sampleLayer(layer, k.time)
|
||||
return { keyframeId: k.id, time: k.time, x: s.x, y: s.y }
|
||||
})
|
||||
return { layerId: layer.id, segments, dots }
|
||||
})
|
||||
|
||||
const motionPathLines = computed(() => {
|
||||
const path = motionPathOverlay.value
|
||||
if (!path) return []
|
||||
return path.segments.map((seg, i) => ({
|
||||
key: `mp-${i}`,
|
||||
config: {
|
||||
points: seg.points,
|
||||
stroke: '#a47bff',
|
||||
strokeWidth: 1.25 / zoom.value,
|
||||
strokeScaleEnabled: false,
|
||||
listening: false,
|
||||
lineCap: 'round',
|
||||
lineJoin: 'round',
|
||||
opacity: 0.65,
|
||||
dash: [5 / zoom.value, 4 / zoom.value],
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
const motionPathDots = computed(() => {
|
||||
const path = motionPathOverlay.value
|
||||
if (!path) return []
|
||||
return path.dots.map((d) => ({
|
||||
key: `mp-dot-${d.keyframeId}`,
|
||||
config: {
|
||||
x: d.x,
|
||||
y: d.y,
|
||||
radius: 4 / zoom.value,
|
||||
fill: '#a47bff',
|
||||
stroke: '#ffffff',
|
||||
strokeWidth: 1.25 / zoom.value,
|
||||
strokeScaleEnabled: false,
|
||||
name: 'motion-path-dot',
|
||||
},
|
||||
time: d.time,
|
||||
}))
|
||||
})
|
||||
|
||||
function onMotionPathDotClick(time: number) {
|
||||
// Click a dot → seek the playhead to that keyframe's time. Cheap
|
||||
// navigation aid for layers with many keyframes.
|
||||
store.setPlayhead(time)
|
||||
}
|
||||
|
||||
const animationGhostSegments = computed(() => {
|
||||
const path = animationPath.value
|
||||
if (!path) return []
|
||||
@@ -2700,7 +3206,9 @@ function handleStageMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
|
||||
// selection — inside a multi-select the box always targets
|
||||
// layers so new picks add to the existing group.
|
||||
const mode: 'vertex' | 'layer' =
|
||||
selectedPolygonLayer.value && store.selectedLayerIds.length <= 1
|
||||
selectedPolygonLayer.value &&
|
||||
store.selectedLayerIds.length <= 1 &&
|
||||
polygonInVertexEdit.value
|
||||
? 'vertex'
|
||||
: 'layer'
|
||||
boxSelect.value = {
|
||||
@@ -2763,6 +3271,8 @@ function handleStageMouseDown(e: Konva.KonvaEventObject<MouseEvent>) {
|
||||
if (target.name?.() === 'vertex-handle' || target.findAncestor?.('.vertex-handle')) return
|
||||
if (target.name?.() === 'tangent-handle') return
|
||||
if (target.name?.() === 'path-frame-dot') return
|
||||
if (target.name?.() === 'motion-path-dot') return
|
||||
if (target.name?.() === 'kaleido-pivot-handle') return
|
||||
if (vertexMenu.value) vertexMenu.value = null
|
||||
if (target === target.getStage()) {
|
||||
deselectAll()
|
||||
@@ -2894,6 +3404,16 @@ function onContainerDblClick(e: MouseEvent) {
|
||||
return
|
||||
}
|
||||
if (asset.kind !== 'polygon') return
|
||||
// First dbl-click on a level-1-selected polygon → enter
|
||||
// vertex-edit mode (level 2). The transformer detaches in the
|
||||
// attachTransformer watcher, vertex handles light up. Subsequent
|
||||
// dbl-clicks (already in vertex-edit mode) fall through to the
|
||||
// edge-insert / open-polygon resume gestures below. SVG-edit
|
||||
// scope skips this beat since polygons are always level-2 there.
|
||||
if (!polygonInVertexEdit.value) {
|
||||
polygonVertexEditId.value = layer.id
|
||||
return
|
||||
}
|
||||
const sampled = sampleLayer(layer, store.playhead)
|
||||
const pts = sampled.points ?? asset.points
|
||||
if (pts.length < 1) return
|
||||
@@ -4111,9 +4631,13 @@ function onResizeHandlePointerUp(e: PointerEvent) {
|
||||
"
|
||||
>
|
||||
<template v-if="entry.kind === 'layer'">
|
||||
<template
|
||||
v-for="ki in kaleidoIndicesForLayer(entry.item.layer)"
|
||||
:key="`${entry.item.layer.id}:k${ki}`"
|
||||
>
|
||||
<v-group
|
||||
v-if="entry.item.asset.kind === 'svg' && svgAssetFor(entry.item.asset)"
|
||||
:config="buttonLayerGroupConfig(entry.item.layer, entry.item.props)"
|
||||
:config="withK(buttonLayerGroupConfig(entry.item.layer, entry.item.props), entry.item.layer, entry.item.props, ki, kaleidoCountFor(entry.item.layer))"
|
||||
@dragmove="handleDragMove"
|
||||
@dragend="handleDragEnd"
|
||||
@transform="handleTransform"
|
||||
@@ -4157,6 +4681,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
|
||||
<v-group
|
||||
v-else-if="entry.item.asset.kind === 'button' && buttonAssetFor(entry.item.asset)"
|
||||
:config="buttonLayerGroupConfig(entry.item.layer, entry.item.props)"
|
||||
v-show="ki === 0"
|
||||
@mouseenter="onButtonEnter(entry.item.layer)"
|
||||
@mouseleave="onButtonLeave(entry.item.layer)"
|
||||
@mousedown="onButtonDown(entry.item.layer)"
|
||||
@@ -4197,7 +4722,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
|
||||
</v-group>
|
||||
<v-rect
|
||||
v-else-if="entry.item.asset.kind === 'rect'"
|
||||
:config="rectConfig(entry.item.layer, entry.item.asset, entry.item.props)"
|
||||
:config="withK(rectConfig(entry.item.layer, entry.item.asset, entry.item.props), entry.item.layer, entry.item.props, ki, kaleidoCountFor(entry.item.layer))"
|
||||
@dragmove="handleDragMove"
|
||||
@dragend="handleDragEnd"
|
||||
@transform="handleTransform"
|
||||
@@ -4205,7 +4730,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
|
||||
/>
|
||||
<v-circle
|
||||
v-else-if="entry.item.asset.kind === 'circle'"
|
||||
:config="circleConfig(entry.item.layer, entry.item.asset, entry.item.props)"
|
||||
:config="withK(circleConfig(entry.item.layer, entry.item.asset, entry.item.props), entry.item.layer, entry.item.props, ki, kaleidoCountFor(entry.item.layer))"
|
||||
@dragmove="handleDragMove"
|
||||
@dragend="handleDragEnd"
|
||||
@transform="handleTransform"
|
||||
@@ -4213,7 +4738,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
|
||||
/>
|
||||
<v-text
|
||||
v-else-if="entry.item.asset.kind === 'text'"
|
||||
:config="textConfig(entry.item.layer, entry.item.asset, entry.item.props)"
|
||||
:config="withK(textConfig(entry.item.layer, entry.item.asset, entry.item.props), entry.item.layer, entry.item.props, ki, kaleidoCountFor(entry.item.layer))"
|
||||
@dragmove="handleDragMove"
|
||||
@dragend="handleDragEnd"
|
||||
@transform="handleTransform"
|
||||
@@ -4221,7 +4746,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
|
||||
/>
|
||||
<v-line
|
||||
v-else-if="entry.item.asset.kind === 'polygon'"
|
||||
:config="polygonConfig(entry.item.layer, entry.item.asset, entry.item.props)"
|
||||
:config="withK(polygonConfig(entry.item.layer, entry.item.asset, entry.item.props), entry.item.layer, entry.item.props, ki, kaleidoCountFor(entry.item.layer))"
|
||||
@dragmove="handleDragMove"
|
||||
@dragend="handleDragEnd"
|
||||
@transform="handleTransform"
|
||||
@@ -4229,21 +4754,23 @@ function onResizeHandlePointerUp(e: PointerEvent) {
|
||||
/>
|
||||
<v-image
|
||||
v-else-if="entry.item.asset.kind === 'image' && imageConfig(entry.item.layer, entry.item.asset, entry.item.props)"
|
||||
:config="imageConfig(entry.item.layer, entry.item.asset, entry.item.props)!"
|
||||
:config="withK(imageConfig(entry.item.layer, entry.item.asset, entry.item.props)!, entry.item.layer, entry.item.props, ki, kaleidoCountFor(entry.item.layer))"
|
||||
@dragmove="handleDragMove"
|
||||
@dragend="handleDragEnd"
|
||||
@transform="handleTransform"
|
||||
@transformend="handleTransformEnd"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<v-group
|
||||
:config="clipInstanceGroupConfig(entry.bundle.instance)"
|
||||
@mousedown="store.selectClipInstance(entry.bundle.instance.id)"
|
||||
@dragend="onClipInstanceDragEnd($event, entry.bundle.instance.id)"
|
||||
@transformend="onClipInstanceTransformEnd($event, entry.bundle.instance.id)"
|
||||
:config="clipInstanceKaleidoGroupConfig(entry.bundle.instance, entry.bundle.armIndex)"
|
||||
@mousedown="entry.bundle.armIndex === 0 && store.selectClipInstance(entry.bundle.instance.id)"
|
||||
@dragend="entry.bundle.armIndex === 0 && onClipInstanceDragEnd($event, entry.bundle.instance.id)"
|
||||
@transformend="entry.bundle.armIndex === 0 && onClipInstanceTransformEnd($event, entry.bundle.instance.id)"
|
||||
>
|
||||
<v-rect
|
||||
v-if="entry.bundle.armIndex === 0"
|
||||
:config="clipFrameConfig(
|
||||
entry.bundle.instance.id,
|
||||
entry.bundle.bounds,
|
||||
@@ -4251,7 +4778,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
|
||||
/>
|
||||
<template
|
||||
v-for="sub in entry.bundle.items"
|
||||
:key="`ci:${entry.bundle.instance.id}:${sub.layer.id}`"
|
||||
:key="`ci:${entry.bundle.instance.id}:${sub.layer.id}:k${entry.bundle.armIndex}`"
|
||||
>
|
||||
<v-rect
|
||||
v-if="sub.asset.kind === 'rect'"
|
||||
@@ -4330,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}`"
|
||||
@@ -4419,6 +4961,22 @@ function onResizeHandlePointerUp(e: PointerEvent) {
|
||||
@dragmove="(e: Konva.KonvaEventObject<DragEvent>) => onPathDotDragMove(e, d)"
|
||||
@dragend="(e: Konva.KonvaEventObject<DragEvent>) => onPathDotDragMove(e, d)"
|
||||
/>
|
||||
<v-line
|
||||
v-for="line in motionPathLines"
|
||||
:key="line.key"
|
||||
:config="line.config"
|
||||
/>
|
||||
<v-circle
|
||||
v-for="d in motionPathDots"
|
||||
:key="d.key"
|
||||
:config="d.config"
|
||||
@mousedown.stop="onMotionPathDotClick(d.time)"
|
||||
/>
|
||||
<v-circle
|
||||
v-if="kaleidoPivotHandleConfig"
|
||||
:config="kaleidoPivotHandleConfig"
|
||||
@mousedown="onKaleidoPivotMouseDown"
|
||||
/>
|
||||
<v-line v-if="drawingPreviewConfig" :config="drawingPreviewConfig" />
|
||||
<v-circle
|
||||
v-for="d in drawingDotsConfig"
|
||||
@@ -4517,6 +5075,22 @@ 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 }"
|
||||
v-tooltip="{ label: t('canvas.motionPaths.label'), help: t('canvas.motionPaths.help') }"
|
||||
@click="showMotionPaths = !showMotionPaths"
|
||||
>
|
||||
<PhPath :size="13" :weight="showMotionPaths ? 'fill' : 'regular'" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="dims">{{ effectiveCanvas.width }} × {{ effectiveCanvas.height }}</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useSceneStore } from '@/stores/scene'
|
||||
import { confirmDialog, promptDialog, dialogState } from '@/components/ui'
|
||||
import { useDocumentDrawer } from '@/composables/useDocumentDrawer'
|
||||
import { useProjectName } from '@/composables/useProjectName'
|
||||
import { useTimeFormat } from '@/composables/useTimeFormat'
|
||||
import {
|
||||
parsePresetInput,
|
||||
presetLabel,
|
||||
@@ -17,6 +18,7 @@ const store = useSceneStore()
|
||||
const { open } = useDocumentDrawer()
|
||||
const { projectName, renameProject } = useProjectName()
|
||||
const { presets, addPreset, removePreset } = useCanvasPresets()
|
||||
const { mode: timeMode, timeToInputValue, inputValueToSeconds, inputStep, unitLabel } = useTimeFormat()
|
||||
|
||||
const rootEl = ref<HTMLElement | null>(null)
|
||||
const nameInput = ref<HTMLInputElement | null>(null)
|
||||
@@ -272,12 +274,11 @@ const bgTransparent = computed({
|
||||
},
|
||||
})
|
||||
|
||||
// Duration input follows the global seconds/frames toggle. Storage
|
||||
// stays in seconds; conversion happens here at the UI boundary.
|
||||
const duration = computed({
|
||||
// Round display to 2 decimals so a frame-aligned duration like
|
||||
// 4.733333… shows as 4.73 in the input, matching the header
|
||||
// format. Setter passes the user-typed value through unchanged.
|
||||
get: () => Math.round(store.scene.duration * 100) / 100,
|
||||
set: (v: number) => store.setDuration(v),
|
||||
get: () => timeToInputValue(store.scene.duration),
|
||||
set: (v: number) => store.setDuration(inputValueToSeconds(v)),
|
||||
})
|
||||
|
||||
const fps = computed({
|
||||
@@ -364,8 +365,13 @@ async function onRemovePreset(p: CanvasPreset) {
|
||||
<input type="number" min="1" step="1" v-model.number="height" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>{{ t('settings.duration') }}</span>
|
||||
<input type="number" min="0.1" step="0.1" v-model.number="duration" />
|
||||
<span>{{ t('settings.duration') }} ({{ unitLabel }})</span>
|
||||
<input
|
||||
type="number"
|
||||
:min="timeMode === 'frames' ? 1 : 0.1"
|
||||
:step="inputStep"
|
||||
v-model.number="duration"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>{{ t('settings.fps') }}</span>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { PhCaretDown, PhCheck, PhTrash, PhUploadSimple } from '@phosphor-icons/vue'
|
||||
import {
|
||||
AVAILABLE_FONTS,
|
||||
BUILTIN_FONTS,
|
||||
fontDef,
|
||||
removeCustomFont,
|
||||
uploadCustomFont,
|
||||
} from '@/lib/fonts'
|
||||
|
||||
const props = defineProps<{
|
||||
// Empty string = system-sans (no override). Mirrors the storage
|
||||
// shape: TextAsset.fontFamily is undefined for the default.
|
||||
modelValue: string
|
||||
}>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
|
||||
const open = ref(false)
|
||||
const trigger = ref<HTMLButtonElement | null>(null)
|
||||
const popover = ref<HTMLDivElement | null>(null)
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const errorMsg = ref<string | null>(null)
|
||||
|
||||
const selected = computed(() => fontDef(props.modelValue || undefined))
|
||||
|
||||
const customFonts = computed(() => AVAILABLE_FONTS.value.filter(f => f.custom))
|
||||
|
||||
function toggle() {
|
||||
open.value = !open.value
|
||||
}
|
||||
|
||||
function pick(id: string) {
|
||||
// Storage convention: 'system-sans' clears the override.
|
||||
emit('update:modelValue', id === 'system-sans' ? '' : id)
|
||||
open.value = false
|
||||
trigger.value?.focus()
|
||||
}
|
||||
|
||||
function onUploadClick() {
|
||||
errorMsg.value = null
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
async function onFileChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
// Reset value so picking the same file twice still triggers change.
|
||||
input.value = ''
|
||||
if (!file) return
|
||||
try {
|
||||
const newFont = await uploadCustomFont(file)
|
||||
// Auto-select the freshly uploaded font.
|
||||
emit('update:modelValue', newFont.id)
|
||||
} catch (err) {
|
||||
errorMsg.value = err instanceof Error ? err.message : 'Upload failed.'
|
||||
}
|
||||
}
|
||||
|
||||
function onDelete(id: string, e: MouseEvent) {
|
||||
e.stopPropagation()
|
||||
// If the currently selected font is the one being deleted, fall
|
||||
// back to system sans — otherwise the asset is left pointing at a
|
||||
// dead id and silently renders in the fallback family.
|
||||
if (props.modelValue === id) emit('update:modelValue', '')
|
||||
removeCustomFont(id)
|
||||
}
|
||||
|
||||
function onWindowPointerDown(e: PointerEvent) {
|
||||
if (!open.value) return
|
||||
const t = e.target as Node | null
|
||||
if (!t) return
|
||||
if (trigger.value?.contains(t)) return
|
||||
if (popover.value?.contains(t)) return
|
||||
open.value = false
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (!open.value) return
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
open.value = false
|
||||
trigger.value?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('pointerdown', onWindowPointerDown, true)
|
||||
window.addEventListener('keydown', onKeydown)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('pointerdown', onWindowPointerDown, true)
|
||||
window.removeEventListener('keydown', onKeydown)
|
||||
})
|
||||
|
||||
const popStyle = ref<Record<string, string>>({})
|
||||
watch(open, async (isOpen) => {
|
||||
if (!isOpen) return
|
||||
await Promise.resolve()
|
||||
const t = trigger.value
|
||||
if (!t) return
|
||||
const r = t.getBoundingClientRect()
|
||||
popStyle.value = {
|
||||
left: `${r.left}px`,
|
||||
top: `${r.bottom + 4}px`,
|
||||
minWidth: `${r.width}px`,
|
||||
}
|
||||
})
|
||||
|
||||
function isActive(id: string): boolean {
|
||||
return (props.modelValue || 'system-sans') === id
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="font-picker">
|
||||
<button
|
||||
ref="trigger"
|
||||
type="button"
|
||||
class="trigger"
|
||||
:class="{ open }"
|
||||
:aria-expanded="open"
|
||||
@click="toggle"
|
||||
>
|
||||
<span class="label" :style="{ fontFamily: selected.family }">{{ selected.label }}</span>
|
||||
<PhCaretDown :size="12" class="caret" />
|
||||
</button>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept=".woff2,.woff,.ttf,.otf,font/woff2,font/woff,font/ttf,font/otf"
|
||||
class="hidden-file-input"
|
||||
@change="onFileChange"
|
||||
/>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="open"
|
||||
ref="popover"
|
||||
class="font-popover"
|
||||
:style="popStyle"
|
||||
role="listbox"
|
||||
>
|
||||
<div class="font-popover-section">
|
||||
<button
|
||||
v-for="f in BUILTIN_FONTS"
|
||||
:key="f.id"
|
||||
type="button"
|
||||
class="font-option"
|
||||
:class="{ active: isActive(f.id) }"
|
||||
role="option"
|
||||
:aria-selected="isActive(f.id)"
|
||||
@click="pick(f.id)"
|
||||
>
|
||||
<PhCheck
|
||||
:size="12"
|
||||
class="check"
|
||||
:style="{ visibility: isActive(f.id) ? 'visible' : 'hidden' }"
|
||||
/>
|
||||
<span class="option-label" :style="{ fontFamily: f.family }">{{ f.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="customFonts.length > 0" class="font-popover-divider"></div>
|
||||
|
||||
<div v-if="customFonts.length > 0" class="font-popover-section">
|
||||
<div class="section-title">Your fonts</div>
|
||||
<div
|
||||
v-for="f in customFonts"
|
||||
:key="f.id"
|
||||
class="custom-font-row"
|
||||
:class="{ active: isActive(f.id) }"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="font-option custom-font-select"
|
||||
role="option"
|
||||
:aria-selected="isActive(f.id)"
|
||||
@click="pick(f.id)"
|
||||
>
|
||||
<PhCheck
|
||||
:size="12"
|
||||
class="check"
|
||||
:style="{ visibility: isActive(f.id) ? 'visible' : 'hidden' }"
|
||||
/>
|
||||
<span class="option-label" :style="{ fontFamily: f.family }">{{ f.label }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="delete-custom"
|
||||
title="Remove this font"
|
||||
@click="onDelete(f.id, $event)"
|
||||
>
|
||||
<PhTrash :size="12" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="font-popover-divider"></div>
|
||||
|
||||
<button type="button" class="font-option upload-btn" @click="onUploadClick">
|
||||
<PhUploadSimple :size="12" class="check" />
|
||||
<span class="option-label">Upload font…</span>
|
||||
</button>
|
||||
<div v-if="errorMsg" class="upload-error">{{ errorMsg }}</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.font-picker {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.trigger:hover {
|
||||
border-color: var(--border-strong, var(--border));
|
||||
}
|
||||
|
||||
.trigger.open,
|
||||
.trigger:focus-visible {
|
||||
border-color: var(--accent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.trigger .label {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.trigger .caret {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-dim);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.trigger.open .caret {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.hidden-file-input {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* Popover is teleported to <body>, so the styles need to be global. */
|
||||
.font-popover {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.font-popover-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.font-popover-divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 4px 2px;
|
||||
}
|
||||
|
||||
.font-popover .section-title {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-dim);
|
||||
padding: 4px 8px 2px;
|
||||
}
|
||||
|
||||
.font-popover .font-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
padding: 6px 8px;
|
||||
font-size: 13px;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.font-popover .font-option:hover {
|
||||
background: var(--panel-hover, var(--panel-alt));
|
||||
}
|
||||
|
||||
.font-popover .font-option.active {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.font-popover .custom-font-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.font-popover .custom-font-row:hover {
|
||||
background: var(--panel-hover, var(--panel-alt));
|
||||
}
|
||||
|
||||
.font-popover .custom-font-row.active {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.font-popover .custom-font-select {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
color: inherit;
|
||||
padding: 6px 0 6px 8px;
|
||||
}
|
||||
|
||||
.font-popover .custom-font-select:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.font-popover .check {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.font-popover .option-label {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.font-popover .delete-custom {
|
||||
flex-shrink: 0;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
margin-right: 4px;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.font-popover .custom-font-row:hover .delete-custom,
|
||||
.font-popover .delete-custom:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.font-popover .delete-custom:hover {
|
||||
color: var(--danger, #ff6b6b);
|
||||
border-color: var(--danger, #ff6b6b);
|
||||
}
|
||||
|
||||
.font-popover .upload-btn {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.font-popover .upload-btn:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.font-popover .upload-error {
|
||||
font-size: 11px;
|
||||
color: var(--danger, #ff6b6b);
|
||||
padding: 4px 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -2,8 +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] }>()
|
||||
@@ -23,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)
|
||||
@@ -40,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()
|
||||
}
|
||||
|
||||
@@ -56,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) {
|
||||
@@ -90,7 +105,7 @@ watch(
|
||||
if (!props.modelValue) return
|
||||
const still = frameEl.value
|
||||
if (!still) return
|
||||
mountAt(still, keepTime, true)
|
||||
mountAt(still, keepTime, playing.value)
|
||||
})
|
||||
},
|
||||
)
|
||||
@@ -240,7 +255,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
const timeLabel = computed(() => {
|
||||
const end = rangeOut.value || 0
|
||||
return `${localTime.value.toFixed(2)}s / ${end.toFixed(2)}s`
|
||||
return `${formatTime(localTime.value)} / ${formatTime(end)}`
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -6,7 +6,10 @@ import {
|
||||
PhDiamond,
|
||||
PhEye,
|
||||
PhEyeSlash,
|
||||
PhEyedropper,
|
||||
PhFlower,
|
||||
PhMaskHappy,
|
||||
PhWaveSine,
|
||||
PhMinus,
|
||||
PhPolygon,
|
||||
PhPlus,
|
||||
@@ -14,15 +17,36 @@ 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'
|
||||
|
||||
const { formatTime, timeToInputValue, inputValueToSeconds, inputStep, unitLabel } = useTimeFormat()
|
||||
const { supported: eyeDropperSupported, pick: pickColor } = useEyeDropper()
|
||||
|
||||
async function pickFillColor() {
|
||||
const c = await pickColor()
|
||||
if (c) fillColor.value = c
|
||||
}
|
||||
async function pickStrokeColor() {
|
||||
const c = await pickColor()
|
||||
if (c) strokeColor.value = c
|
||||
}
|
||||
async function pickShadowColor() {
|
||||
const c = await pickColor()
|
||||
if (c) shadowColorValue.value = c
|
||||
}
|
||||
|
||||
const SECTION_STORAGE_KEY = 'animatr:propSections'
|
||||
|
||||
type SectionKey = 'transform' | 'text' | 'polygon' | 'appearance' | 'shadow' | 'mask' | 'button'
|
||||
type SectionKey = 'transform' | 'text' | 'polygon' | 'appearance' | 'shadow' | 'mask' | 'button' | 'effects'
|
||||
|
||||
const defaultOpen: Record<SectionKey, boolean> = {
|
||||
transform: true,
|
||||
@@ -32,6 +56,7 @@ const defaultOpen: Record<SectionKey, boolean> = {
|
||||
shadow: false,
|
||||
mask: false,
|
||||
button: true,
|
||||
effects: false,
|
||||
}
|
||||
|
||||
function readStoredOpen(): Record<SectionKey, boolean> {
|
||||
@@ -74,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
|
||||
@@ -137,6 +237,17 @@ const textAsset = computed(() => {
|
||||
return a && a.kind === 'text' ? a : null
|
||||
})
|
||||
|
||||
// Empty string represents the system-sans default. Stored as
|
||||
// `undefined` on the asset so the model field round-trips cleanly
|
||||
// through JSON without leaking a bogus 'system-sans' id.
|
||||
const textFontModel = computed({
|
||||
get: () => textAsset.value?.fontFamily ?? '',
|
||||
set: (id: string) => {
|
||||
if (!textAsset.value) return
|
||||
textAsset.value.fontFamily = id || undefined
|
||||
},
|
||||
})
|
||||
|
||||
const polygonAsset = computed(() => {
|
||||
const a = selectedAsset.value
|
||||
return a && a.kind === 'polygon' ? a : null
|
||||
@@ -147,6 +258,128 @@ const buttonAsset = computed(() => {
|
||||
return a && a.kind === 'button' ? a : null
|
||||
})
|
||||
|
||||
// Kaleido is allowed on any non-button layer. Buttons are excluded
|
||||
// because radial copies multiply hit areas and break click routing.
|
||||
const canKaleido = computed(() => {
|
||||
const a = selectedAsset.value
|
||||
if (!a) return false
|
||||
return a.kind !== 'button'
|
||||
})
|
||||
|
||||
const selectedClipInstance = computed(() => {
|
||||
const id = store.selectedClipInstanceId
|
||||
if (!id) return null
|
||||
return store.scene.clipInstances?.find((c) => c.id === id) ?? null
|
||||
})
|
||||
|
||||
const ciKaleidoOn = computed(() => !!selectedClipInstance.value?.kaleido)
|
||||
const ciKaleidoCount = computed(() => selectedClipInstance.value?.kaleidoCount ?? 6)
|
||||
// Offset is stored as seconds (the renderer's unit) but presented in
|
||||
// whichever unit the user picked globally (Timeline ruler toggle) —
|
||||
// frames or seconds. Conversion at the UI boundary via useTimeFormat.
|
||||
const ciKaleidoOffsetInput = computed(() =>
|
||||
timeToInputValue(selectedClipInstance.value?.kaleidoOffset ?? 0),
|
||||
)
|
||||
const ciPivotX = computed(() =>
|
||||
Math.round(selectedClipInstance.value?.kaleidoPivotX ?? selectedClipInstance.value?.x ?? 0),
|
||||
)
|
||||
const ciPivotY = computed(() =>
|
||||
Math.round(selectedClipInstance.value?.kaleidoPivotY ?? selectedClipInstance.value?.y ?? 0),
|
||||
)
|
||||
const ciRotateCopies = computed(() => selectedClipInstance.value?.kaleidoRotateCopies !== false)
|
||||
|
||||
function setCiKaleido(patch: Parameters<typeof store.setClipInstanceKaleido>[1]) {
|
||||
const inst = selectedClipInstance.value
|
||||
if (!inst) return
|
||||
store.setClipInstanceKaleido(inst.id, patch)
|
||||
}
|
||||
|
||||
function setCiKaleidoOffset(value: number) {
|
||||
setCiKaleido({ kaleidoOffset: inputValueToSeconds(value) })
|
||||
}
|
||||
|
||||
const kaleidoOn = computed(() => !!selected.value?.kaleido)
|
||||
|
||||
const kaleidoCountValue = computed(() => selected.value?.kaleidoCount ?? 6)
|
||||
|
||||
/** Display value for the layer's kaleido pivot inputs. Reads the
|
||||
* sampled keyframe value first (so a moving pivot animates the
|
||||
* number too), then the layer's static legacy override, then the
|
||||
* layer's own (x, y). Rounded to int for compact display. */
|
||||
const kaleidoPivotX = computed(() => {
|
||||
const l = selected.value
|
||||
if (!l) return 0
|
||||
const s = sampled.value
|
||||
if (s?.kaleidoPivotX !== undefined) return Math.round(s.kaleidoPivotX)
|
||||
if (l.kaleidoPivotX !== undefined) return Math.round(l.kaleidoPivotX)
|
||||
return Math.round(s?.x ?? 0)
|
||||
})
|
||||
const kaleidoPivotY = computed(() => {
|
||||
const l = selected.value
|
||||
if (!l) return 0
|
||||
const s = sampled.value
|
||||
if (s?.kaleidoPivotY !== undefined) return Math.round(s.kaleidoPivotY)
|
||||
if (l.kaleidoPivotY !== undefined) return Math.round(l.kaleidoPivotY)
|
||||
return Math.round(s?.y ?? 0)
|
||||
})
|
||||
|
||||
function setKaleidoOn(on: boolean) {
|
||||
if (!selected.value) return
|
||||
store.setLayerKaleido(selected.value.id, on)
|
||||
}
|
||||
|
||||
/** Pivot writes go through the keyframe pipeline — same flow as
|
||||
* Transform X/Y. Animators can drop multiple kfs and the orbit
|
||||
* centre interpolates between them. */
|
||||
function setKaleidoPivot(patch: { x?: number; y?: number }) {
|
||||
if (!selected.value) return
|
||||
const props: Partial<import('@/types/scene').AnimatableProps> = {}
|
||||
if (patch.x !== undefined) props.kaleidoPivotX = patch.x
|
||||
if (patch.y !== undefined) props.kaleidoPivotY = patch.y
|
||||
store.upsertKeyframe(selected.value.id, store.playhead, props)
|
||||
}
|
||||
|
||||
const wiggleOn = computed(() => !!selected.value?.wiggle)
|
||||
const wiggleAmplitude = computed<number>({
|
||||
get: () => selected.value?.wiggle?.amplitude ?? 8,
|
||||
set: (v: number) => {
|
||||
if (!selected.value) return
|
||||
store.setLayerWiggle(selected.value.id, { amplitude: v })
|
||||
},
|
||||
})
|
||||
const wiggleRotation = computed<number>({
|
||||
get: () => selected.value?.wiggle?.rotation ?? 2,
|
||||
set: (v: number) => {
|
||||
if (!selected.value) return
|
||||
store.setLayerWiggle(selected.value.id, { rotation: v })
|
||||
},
|
||||
})
|
||||
const wiggleFrequency = computed<number>({
|
||||
get: () => selected.value?.wiggle?.frequency ?? 2,
|
||||
set: (v: number) => {
|
||||
if (!selected.value) return
|
||||
store.setLayerWiggle(selected.value.id, { frequency: v })
|
||||
},
|
||||
})
|
||||
|
||||
function setWiggleOn(on: boolean) {
|
||||
if (!selected.value) return
|
||||
if (on) {
|
||||
// Seed with the pleasant defaults animators tend to start with;
|
||||
// they can dial it down from there.
|
||||
store.setLayerWiggle(selected.value.id, { amplitude: 8, rotation: 2, frequency: 2 })
|
||||
} else {
|
||||
store.setLayerWiggle(selected.value.id, null)
|
||||
}
|
||||
}
|
||||
|
||||
function onKaleidoCountInput(e: Event) {
|
||||
if (!selected.value) return
|
||||
const v = Number((e.target as HTMLInputElement).value)
|
||||
if (!Number.isFinite(v)) return
|
||||
store.setLayerKaleidoCount(selected.value.id, v)
|
||||
}
|
||||
|
||||
const polygonPointCount = computed<number>({
|
||||
get: () => polygonAsset.value?.points.length ?? 0,
|
||||
set: (v: number) => {
|
||||
@@ -443,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>
|
||||
@@ -453,7 +741,7 @@ function deleteSelection() {
|
||||
<PhDiamond :size="11" :weight="hasKeyframeHere ? 'fill' : 'regular'" class="kf-dot" />
|
||||
<span class="kf-label">
|
||||
{{ hasKeyframeHere ? t('properties.keyframeLabel') : t('properties.interpolated') }}
|
||||
<span class="kf-time">@ {{ store.playhead.toFixed(2) }}s</span>
|
||||
<span class="kf-time">@ {{ formatTime(store.playhead) }}</span>
|
||||
</span>
|
||||
<button v-if="hasKeyframeHere" class="kf-btn" v-tooltip="t('properties.removeKeyframe')" @click="removeKeyframeHere">
|
||||
<PhX :size="12" weight="bold" />
|
||||
@@ -484,8 +772,12 @@ function deleteSelection() {
|
||||
<span>{{ t('properties.sections.text') }}</span>
|
||||
</button>
|
||||
<div v-if="sectionOpen.text" class="group-body">
|
||||
<label>Text<input type="text" v-model="textAsset.text" /></label>
|
||||
<label>Font Size<input type="number" min="1" v-model.number="textAsset.fontSize" /></label>
|
||||
<label>{{ t('properties.text.text') }}<input type="text" v-model="textAsset.text" /></label>
|
||||
<label>{{ t('properties.text.fontSize') }}<input type="number" min="1" v-model.number="textAsset.fontSize" /></label>
|
||||
<label>
|
||||
{{ t('properties.text.font') }}
|
||||
<FontPicker v-model="textFontModel" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -532,6 +824,16 @@ function deleteSelection() {
|
||||
@input="fillColor = ($event.target as HTMLInputElement).value"
|
||||
/>
|
||||
<span class="color-hex">{{ fillTransparent ? '—' : fillColor.toUpperCase() }}</span>
|
||||
<button
|
||||
v-if="eyeDropperSupported"
|
||||
type="button"
|
||||
class="icon-toggle eyedropper-btn"
|
||||
:disabled="fillTransparent"
|
||||
v-tooltip="t('properties.eyedropper')"
|
||||
@click="pickFillColor"
|
||||
>
|
||||
<PhEyedropper :size="13" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-toggle alpha-toggle-btn"
|
||||
@@ -554,6 +856,16 @@ function deleteSelection() {
|
||||
@input="strokeColor = ($event.target as HTMLInputElement).value"
|
||||
/>
|
||||
<span class="color-hex">{{ strokeTransparent ? '—' : strokeColor.toUpperCase() }}</span>
|
||||
<button
|
||||
v-if="eyeDropperSupported"
|
||||
type="button"
|
||||
class="icon-toggle eyedropper-btn"
|
||||
:disabled="strokeTransparent"
|
||||
v-tooltip="t('properties.eyedropper')"
|
||||
@click="pickStrokeColor"
|
||||
>
|
||||
<PhEyedropper :size="13" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-toggle alpha-toggle-btn"
|
||||
@@ -572,7 +884,7 @@ function deleteSelection() {
|
||||
:min="0"
|
||||
:max="32"
|
||||
:step="0.5"
|
||||
:format="(v: number) => `${v}px`"
|
||||
:format="(v: number) => `${Math.round(v * 2) / 2}px`"
|
||||
/>
|
||||
</label>
|
||||
<label class="slider-label">
|
||||
@@ -608,6 +920,15 @@ function deleteSelection() {
|
||||
@input="shadowColorValue = ($event.target as HTMLInputElement).value"
|
||||
/>
|
||||
<span class="color-hex">{{ shadowColorValue.toUpperCase() }}</span>
|
||||
<button
|
||||
v-if="eyeDropperSupported"
|
||||
type="button"
|
||||
class="icon-toggle eyedropper-btn"
|
||||
v-tooltip="t('properties.eyedropper')"
|
||||
@click="pickShadowColor"
|
||||
>
|
||||
<PhEyedropper :size="13" />
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
<label class="slider-label">
|
||||
@@ -698,6 +1019,107 @@ function deleteSelection() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="canKaleido && !multiSelected" class="prop-group">
|
||||
<button class="group-header" @click="toggleSection('effects')">
|
||||
<component :is="sectionOpen.effects ? PhCaretDown : PhCaretRight" :size="11" />
|
||||
<span>{{ t('properties.sections.effects') }}</span>
|
||||
</button>
|
||||
<div v-if="sectionOpen.effects" class="group-body">
|
||||
<!-- Wiggle -->
|
||||
<div class="effect-block">
|
||||
<div class="toggle-row">
|
||||
<span class="toggle-label">{{ t('properties.wiggle.toggle') }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-toggle"
|
||||
:class="{ active: wiggleOn }"
|
||||
v-tooltip="t('properties.wiggle.toggle')"
|
||||
@click="setWiggleOn(!wiggleOn)"
|
||||
>
|
||||
<PhWaveSine :size="14" :weight="wiggleOn ? 'fill' : 'regular'" />
|
||||
</button>
|
||||
</div>
|
||||
<label v-if="wiggleOn" class="slider-label">
|
||||
{{ t('properties.wiggle.amplitude') }}
|
||||
<RangeSlider
|
||||
v-model="wiggleAmplitude"
|
||||
:min="0"
|
||||
:max="64"
|
||||
:step="1"
|
||||
:format="(v: number) => `${Math.round(v)}px`"
|
||||
/>
|
||||
</label>
|
||||
<label v-if="wiggleOn" class="slider-label">
|
||||
{{ t('properties.wiggle.rotation') }}
|
||||
<RangeSlider
|
||||
v-model="wiggleRotation"
|
||||
:min="0"
|
||||
:max="45"
|
||||
:step="0.5"
|
||||
:format="(v: number) => `${Math.round(v * 2) / 2}°`"
|
||||
/>
|
||||
</label>
|
||||
<label v-if="wiggleOn" class="slider-label">
|
||||
{{ t('properties.wiggle.frequency') }}
|
||||
<RangeSlider
|
||||
v-model="wiggleFrequency"
|
||||
:min="0"
|
||||
:max="10"
|
||||
:step="0.1"
|
||||
:format="(v: number) => `${Math.round(v * 10) / 10} Hz`"
|
||||
/>
|
||||
</label>
|
||||
<p v-if="wiggleOn" class="hint">{{ t('properties.wiggle.hint') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Kaleidoscope -->
|
||||
<div class="effect-block">
|
||||
<div class="toggle-row">
|
||||
<span class="toggle-label">{{ t('properties.kaleido.toggle') }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-toggle"
|
||||
:class="{ active: kaleidoOn }"
|
||||
v-tooltip="t('properties.kaleido.toggle')"
|
||||
@click="setKaleidoOn(!kaleidoOn)"
|
||||
>
|
||||
<PhFlower :size="14" :weight="kaleidoOn ? 'fill' : 'regular'" />
|
||||
</button>
|
||||
</div>
|
||||
<label v-if="kaleidoOn">
|
||||
{{ t('properties.kaleido.repeat') }}
|
||||
<input
|
||||
type="number"
|
||||
min="2"
|
||||
max="64"
|
||||
step="1"
|
||||
:value="kaleidoCountValue"
|
||||
@input="onKaleidoCountInput($event)"
|
||||
/>
|
||||
</label>
|
||||
<div v-if="kaleidoOn" class="row">
|
||||
<label>
|
||||
{{ t('properties.kaleido.pivotX') }}
|
||||
<input
|
||||
type="number"
|
||||
:value="kaleidoPivotX"
|
||||
@input="setKaleidoPivot({ x: Number(($event.target as HTMLInputElement).value) })"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{{ t('properties.kaleido.pivotY') }}
|
||||
<input
|
||||
type="number"
|
||||
:value="kaleidoPivotY"
|
||||
@input="setKaleidoPivot({ y: Number(($event.target as HTMLInputElement).value) })"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="kaleidoOn" class="hint">{{ t('properties.kaleido.hint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="buttonAsset && !multiSelected" class="prop-group">
|
||||
<button class="group-header" @click="toggleSection('button')">
|
||||
<component :is="sectionOpen.button ? PhCaretDown : PhCaretRight" :size="11" />
|
||||
@@ -720,6 +1142,79 @@ function deleteSelection() {
|
||||
}}
|
||||
</button>
|
||||
</template>
|
||||
<template v-else-if="selectedClipInstance">
|
||||
<div class="multi-banner">{{ t('properties.clipInstance.banner') }}</div>
|
||||
<section class="prop-group">
|
||||
<button class="group-header" @click="toggleSection('effects')">
|
||||
<component :is="sectionOpen.effects ? PhCaretDown : PhCaretRight" :size="11" />
|
||||
<span>{{ t('properties.sections.effects') }}</span>
|
||||
</button>
|
||||
<div v-if="sectionOpen.effects" class="group-body">
|
||||
<div class="toggle-row">
|
||||
<span class="toggle-label">{{ t('properties.kaleido.toggle') }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-toggle"
|
||||
:class="{ active: ciKaleidoOn }"
|
||||
v-tooltip="t('properties.kaleido.toggle')"
|
||||
@click="setCiKaleido({ kaleido: !ciKaleidoOn })"
|
||||
>
|
||||
<PhFlower :size="14" :weight="ciKaleidoOn ? 'fill' : 'regular'" />
|
||||
</button>
|
||||
</div>
|
||||
<label v-if="ciKaleidoOn">
|
||||
{{ t('properties.kaleido.repeat') }}
|
||||
<input
|
||||
type="number"
|
||||
min="2"
|
||||
max="64"
|
||||
step="1"
|
||||
:value="ciKaleidoCount"
|
||||
@input="setCiKaleido({ kaleidoCount: Number(($event.target as HTMLInputElement).value) })"
|
||||
/>
|
||||
</label>
|
||||
<label v-if="ciKaleidoOn">
|
||||
{{ t('properties.kaleido.offset') }} ({{ unitLabel }})
|
||||
<input
|
||||
type="number"
|
||||
:step="inputStep"
|
||||
:value="ciKaleidoOffsetInput"
|
||||
@input="setCiKaleidoOffset(Number(($event.target as HTMLInputElement).value))"
|
||||
/>
|
||||
</label>
|
||||
<div v-if="ciKaleidoOn" class="row">
|
||||
<label>
|
||||
{{ t('properties.kaleido.pivotX') }}
|
||||
<input
|
||||
type="number"
|
||||
:value="ciPivotX"
|
||||
@input="setCiKaleido({ kaleidoPivotX: Number(($event.target as HTMLInputElement).value) })"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{{ t('properties.kaleido.pivotY') }}
|
||||
<input
|
||||
type="number"
|
||||
:value="ciPivotY"
|
||||
@input="setCiKaleido({ kaleidoPivotY: Number(($event.target as HTMLInputElement).value) })"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="ciKaleidoOn" class="toggle-row">
|
||||
<span class="toggle-label">{{ t('properties.kaleido.rotateCopies') }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="icon-toggle"
|
||||
:class="{ active: ciRotateCopies }"
|
||||
@click="setCiKaleido({ kaleidoRotateCopies: !ciRotateCopies })"
|
||||
>
|
||||
<PhFlower :size="14" :weight="ciRotateCopies ? 'fill' : 'regular'" />
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="ciKaleidoOn" class="hint">{{ t('properties.kaleido.hintClip') }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
<p v-else-if="selected && editableLayers.length === 0" class="hint">
|
||||
{{ t('properties.lockedLayer') }}
|
||||
</p>
|
||||
@@ -766,6 +1261,20 @@ input {
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
/* Number inputs hold short numeric values. Capping the width keeps
|
||||
them from stretching to the panel's full ~260px and aligns the
|
||||
panel visually with the more compact slider controls. Inputs
|
||||
inside a `.row` (two side-by-side labels) still split that row
|
||||
evenly because their label parent is `flex: 1 1 0`. */
|
||||
input[type='number'] {
|
||||
max-width: 96px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.row input[type='number'] {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -818,6 +1327,21 @@ input {
|
||||
padding: 0 2px 10px;
|
||||
}
|
||||
|
||||
/* Sub-block inside the Effects section. The thin top border visually
|
||||
separates Wiggle from Kaleidoscope without forcing a second
|
||||
collapsible header — they share one section but each toggles
|
||||
independently. */
|
||||
.effect-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.effect-block + .effect-block {
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -6,6 +6,9 @@ import { t } from '@/i18n'
|
||||
import { Modal } from '@/components/ui'
|
||||
import ActionEditor from './ActionEditor.vue'
|
||||
import type { Action } from '@/types/scene'
|
||||
import { useTimeFormat } from '@/composables/useTimeFormat'
|
||||
|
||||
const { timeToInputValue, inputValueToSeconds, inputStep, unitLabel } = useTimeFormat()
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
@@ -73,13 +76,13 @@ function removeFrame() {
|
||||
<div v-if="frame" class="body">
|
||||
<div class="row">
|
||||
<label>
|
||||
{{ t('scriptFrameDialog.time') }}
|
||||
{{ t('scriptFrameDialog.time') }} ({{ unitLabel }})
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
:step="inputStep"
|
||||
min="0"
|
||||
:value="localTime"
|
||||
@input="localTime = Number(($event.target as HTMLInputElement).value)"
|
||||
:value="timeToInputValue(localTime)"
|
||||
@input="localTime = inputValueToSeconds(Number(($event.target as HTMLInputElement).value))"
|
||||
/>
|
||||
</label>
|
||||
<label class="grow">
|
||||
@@ -144,6 +147,11 @@ function removeFrame() {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.row input[type='number'] {
|
||||
width: 96px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -77,7 +77,7 @@ async function addText() {
|
||||
name: text.length > 24 ? text.slice(0, 24) + '…' : text,
|
||||
text,
|
||||
fontSize: 48,
|
||||
fill: '#f5d76e',
|
||||
fill: '#000000',
|
||||
})
|
||||
store.addLayer(asset.id)
|
||||
}
|
||||
@@ -301,6 +301,7 @@ function onDragEnd() {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -409,6 +410,10 @@ function onDragEnd() {
|
||||
.row-btn {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
/* Override the global `button { padding: 5px 10px }` reset —
|
||||
with box-sizing:border-box that padding eats the entire 22×22
|
||||
box and clips the icon out of view ("icons missing"). */
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
+301
-16
@@ -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,
|
||||
@@ -2169,7 +2332,7 @@ function audioClipBlockStyle(clip: AudioClip) {
|
||||
function audioClipTooltip(clip: AudioClip): string {
|
||||
const asset = audioAssetById.value.get(clip.audioAssetId)
|
||||
if (!asset) return ''
|
||||
return `${asset.name} · ${audibleDurationOf(clip).toFixed(2)}s`
|
||||
return `${asset.name} · ${formatKfTime(audibleDurationOf(clip))}`
|
||||
}
|
||||
|
||||
/** Resolved peaks for a clip's asset — undefined while the decoder
|
||||
@@ -2664,10 +2827,10 @@ function onScriptLaneClick(e: MouseEvent, trackId: string) {
|
||||
|
||||
function trimTooltip(ci: ClipInstance): string {
|
||||
const name = clipById.value.get(ci.clipId)?.name ?? '?'
|
||||
const dur = visibleDurationOf(ci).toFixed(2)
|
||||
const dur = formatKfTime(visibleDurationOf(ci))
|
||||
const trims: string[] = []
|
||||
if ((ci.trimStart ?? 0) > 0) trims.push(t('timeline.trimStart', { value: (ci.trimStart ?? 0).toFixed(2) }))
|
||||
if ((ci.trimEnd ?? 0) > 0) trims.push(t('timeline.trimEnd', { value: (ci.trimEnd ?? 0).toFixed(2) }))
|
||||
if ((ci.trimStart ?? 0) > 0) trims.push(t('timeline.trimStart', { value: formatKfTime(ci.trimStart ?? 0) }))
|
||||
if ((ci.trimEnd ?? 0) > 0) trims.push(t('timeline.trimEnd', { value: formatKfTime(ci.trimEnd ?? 0) }))
|
||||
const trimsStr = trims.length ? ` · ${trims.join(' · ')}` : ''
|
||||
return t('timeline.trimTooltip', { name, duration: dur, trims: trimsStr })
|
||||
}
|
||||
@@ -2787,10 +2950,56 @@ const trackMenuRef = ref<HTMLDivElement | null>(null)
|
||||
|
||||
function onLayerTrackNameClick(e: MouseEvent, track: TimelineLayerTrack) {
|
||||
if (isTrackEffectivelyLocked(track)) return
|
||||
if (e.shiftKey && track.isParent) {
|
||||
if (!track.isParent) {
|
||||
// Sub-tracks (separated position / rotation lanes) can't carry
|
||||
// their own selection — just route to the parent layer.
|
||||
store.selectLayer(track.layerId)
|
||||
return
|
||||
}
|
||||
// Ctrl / Cmd → toggle this layer in/out of the selection.
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
store.toggleLayerSelection(track.layerId)
|
||||
return
|
||||
}
|
||||
// Shift → range select: every parent-layer track between the
|
||||
// current anchor (selectedLayerId) and the clicked one in the
|
||||
// visible timeline order joins the selection.
|
||||
if (e.shiftKey) {
|
||||
const anchorId = store.selectedLayerId
|
||||
if (!anchorId || anchorId === track.layerId) {
|
||||
store.selectLayer(track.layerId)
|
||||
return
|
||||
}
|
||||
const layerOrder: string[] = []
|
||||
for (const tr of tracks.value) {
|
||||
if (tr.type === 'layer' && tr.isParent) layerOrder.push(tr.layerId)
|
||||
}
|
||||
const a = layerOrder.indexOf(anchorId)
|
||||
const b = layerOrder.indexOf(track.layerId)
|
||||
if (a < 0 || b < 0) {
|
||||
store.selectLayer(track.layerId)
|
||||
return
|
||||
}
|
||||
const [from, to] = a <= b ? [a, b] : [b, a]
|
||||
const range = layerOrder.slice(from, to + 1)
|
||||
// Skip layers the user can't actually act on (locked or inside
|
||||
// a locked parent group). selectLayer / toggle paths skip these
|
||||
// too, so the range stays consistent with the rest of the UX.
|
||||
const usable = range.filter((id) => {
|
||||
const layer = store.activeLayers.find((l) => l.id === id)
|
||||
if (!layer || layer.locked) return false
|
||||
if (lockedGroupChildIds.value.has(id)) return false
|
||||
return true
|
||||
})
|
||||
if (!usable.length) return
|
||||
store.selectedLayerIds = usable
|
||||
// Anchor moves to the most-recently clicked id so the next
|
||||
// shift-click extends from there. Matches typical file-manager
|
||||
// shift-select behaviour.
|
||||
store.selectedLayerId = track.layerId
|
||||
store.selectedClipInstanceId = null
|
||||
return
|
||||
}
|
||||
store.selectLayer(track.layerId)
|
||||
}
|
||||
|
||||
@@ -3488,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'}`
|
||||
@@ -4215,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"
|
||||
@@ -4228,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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
@@ -12,6 +12,7 @@ export function useDeselectAll() {
|
||||
return function deselectAll() {
|
||||
store.selectLayer(null)
|
||||
store.selectClipInstance(null)
|
||||
store.selectCamera(false)
|
||||
selectedKfs.value = []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Wraps the browser's EyeDropper API (Chromium 95+). Provides a
|
||||
// reactive `supported` flag plus a `pick()` helper that resolves to
|
||||
// the chosen sRGBHex string (or null on cancel/abort). Callers
|
||||
// should hide the pipette button when `supported` is false to keep
|
||||
// Safari + Firefox UX clean.
|
||||
|
||||
import { ref } from 'vue'
|
||||
|
||||
interface EyeDropperResult {
|
||||
sRGBHex: string
|
||||
}
|
||||
|
||||
interface EyeDropperConstructor {
|
||||
new (): { open(opts?: { signal?: AbortSignal }): Promise<EyeDropperResult> }
|
||||
}
|
||||
|
||||
function detectSupport(): boolean {
|
||||
return (
|
||||
typeof window !== 'undefined' &&
|
||||
typeof (window as unknown as { EyeDropper?: unknown }).EyeDropper === 'function'
|
||||
)
|
||||
}
|
||||
|
||||
export function useEyeDropper() {
|
||||
// Re-detect on every call so a test (or a polyfill that loads
|
||||
// late) can see updates without juggling Vite's module cache.
|
||||
const supported = ref(detectSupport())
|
||||
async function pick(): Promise<string | null> {
|
||||
if (!detectSupport()) return null
|
||||
const Ctor = (window as unknown as { EyeDropper: EyeDropperConstructor }).EyeDropper
|
||||
try {
|
||||
const result = await new Ctor().open()
|
||||
return result.sRGBHex
|
||||
} catch {
|
||||
// User pressed Esc / dismissed the picker — treat as cancel.
|
||||
return null
|
||||
}
|
||||
}
|
||||
return { supported, pick }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const KEY = 'animatr:showMotionPaths'
|
||||
|
||||
function read(): boolean {
|
||||
try {
|
||||
const v = localStorage.getItem(KEY)
|
||||
return v === null ? false : v === '1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Module-level shared ref. When true, the canvas paints the
|
||||
* position-keyframe path of the currently-selected layer (or
|
||||
* layers) as a dashed polyline with a dot per keyframe — even
|
||||
* when no keyframe is explicitly selected. Defaults to off so it
|
||||
* doesn't clutter the canvas for users who never wanted it. */
|
||||
export const showMotionPaths = ref(read())
|
||||
|
||||
watch(showMotionPaths, (v) => {
|
||||
try {
|
||||
localStorage.setItem(KEY, v ? '1' : '0')
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
// Single source of truth for "how do we format / parse a time?".
|
||||
// The Timeline ruler already exposes a seconds/frames toggle backed
|
||||
// by `store.editor.timelineDisplayMode`. This composable surfaces
|
||||
// that mode globally so headers, dialogs, properties, tooltips and
|
||||
// every other time display follows the user's choice — keeping them
|
||||
// in sync with the ruler instead of forcing the user to think in two
|
||||
// units at once.
|
||||
//
|
||||
// Storage stays in seconds everywhere (renderer + .ruw files).
|
||||
// Conversion happens at the UI boundary using `scene.fps`.
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { useSceneStore } from '@/stores/scene'
|
||||
|
||||
export type TimeDisplayMode = 'seconds' | 'frames'
|
||||
|
||||
export function useTimeFormat() {
|
||||
const store = useSceneStore()
|
||||
const mode = computed<TimeDisplayMode>(() => store.editor.timelineDisplayMode)
|
||||
const fps = computed(() => (store.scene.fps > 0 ? store.scene.fps : 60))
|
||||
|
||||
/** seconds → frames (rounded to int). */
|
||||
const secondsToFrames = (s: number): number => Math.round(s * fps.value)
|
||||
|
||||
/** frames → seconds. */
|
||||
const framesToSeconds = (f: number): number => f / fps.value
|
||||
|
||||
/** Format a time for display. In frames mode emits e.g. `"42 fr"`,
|
||||
* in seconds mode `"1.42s"`. Pass `{ short: true }` to drop the
|
||||
* trailing zeros for ruler-style ticks ("1s" instead of "1.00s"). */
|
||||
function formatTime(
|
||||
seconds: number,
|
||||
opts: { short?: boolean; decimals?: number } = {},
|
||||
): string {
|
||||
if (mode.value === 'frames') return `${secondsToFrames(seconds)} fr`
|
||||
const decimals = opts.decimals ?? 2
|
||||
if (opts.short) {
|
||||
const fixed = seconds.toFixed(decimals)
|
||||
return `${fixed.replace(/0+$/, '').replace(/\.$/, '')}s`
|
||||
}
|
||||
return `${seconds.toFixed(decimals)}s`
|
||||
}
|
||||
|
||||
/** Number suitable to plug into an `<input type="number" :value>`.
|
||||
* In frames mode returns an integer (frames). In seconds mode
|
||||
* returns a 2-decimal-rounded number so the input doesn't show
|
||||
* e.g. `1.4181818181`. */
|
||||
function timeToInputValue(seconds: number): number {
|
||||
if (mode.value === 'frames') return secondsToFrames(seconds)
|
||||
return Math.round(seconds * 100) / 100
|
||||
}
|
||||
|
||||
/** Inverse of `timeToInputValue` — read back what the user typed
|
||||
* into a time input and convert to seconds for storage. */
|
||||
function inputValueToSeconds(value: number): number {
|
||||
if (!Number.isFinite(value)) return 0
|
||||
if (mode.value === 'frames') return framesToSeconds(Math.round(value))
|
||||
return value
|
||||
}
|
||||
|
||||
/** Sensible step + decimal placement for an `<input type="number">`
|
||||
* bound to a time. Frames mode: step 1, integer. Seconds: 0.01. */
|
||||
const inputStep = computed(() => (mode.value === 'frames' ? '1' : '0.01'))
|
||||
|
||||
/** Unit suffix for labels (`"frames"` or `"seconds"`). */
|
||||
const unitLabel = computed(() => (mode.value === 'frames' ? 'frames' : 'seconds'))
|
||||
|
||||
return {
|
||||
mode,
|
||||
fps,
|
||||
secondsToFrames,
|
||||
framesToSeconds,
|
||||
formatTime,
|
||||
timeToInputValue,
|
||||
inputValueToSeconds,
|
||||
inputStep,
|
||||
unitLabel,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useI18n } from '@/i18n'
|
||||
import { useSceneStore } from '@/stores/scene'
|
||||
import { runAll, type TestResult } from './testRunner'
|
||||
import { registerAllTests } from './tests'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__ruwioTestResults?: TestResult[]
|
||||
}
|
||||
}
|
||||
|
||||
function shouldRunSuite(name: string, desktop: boolean): boolean {
|
||||
if (name.startsWith('desktop:')) return desktop
|
||||
if (name.startsWith('browser:')) return !desktop
|
||||
return true
|
||||
}
|
||||
|
||||
export async function runHeadlessTests(): Promise<TestResult[]> {
|
||||
const desktop = !!window.ruwioDesktop
|
||||
const store = useSceneStore()
|
||||
const { locale } = useI18n()
|
||||
const snapshot = JSON.parse(JSON.stringify(store.$state))
|
||||
const previousLocale = locale.value
|
||||
|
||||
document.body.setAttribute('data-tests-done', 'running')
|
||||
document.body.setAttribute('data-test-host', desktop ? 'electron' : 'browser')
|
||||
locale.value = 'en'
|
||||
|
||||
try {
|
||||
const results = await runAll(registerAllTests, {
|
||||
interTestDelayMs: 0,
|
||||
suiteFilter: (name) => shouldRunSuite(name, desktop),
|
||||
})
|
||||
window.__ruwioTestResults = results
|
||||
document.body.setAttribute('data-tests-done', results.some((r) => !r.ok) ? 'fail' : 'pass')
|
||||
return results
|
||||
} catch (err) {
|
||||
const result: TestResult = {
|
||||
suite: 'headless',
|
||||
name: 'runAll',
|
||||
ok: false,
|
||||
error: (err as Error).message || String(err),
|
||||
stack: (err as Error).stack,
|
||||
durationMs: 0,
|
||||
}
|
||||
window.__ruwioTestResults = [result]
|
||||
document.body.setAttribute('data-tests-done', 'fail')
|
||||
return [result]
|
||||
} finally {
|
||||
store.$state = snapshot
|
||||
locale.value = previousLocale
|
||||
}
|
||||
}
|
||||
@@ -133,6 +133,7 @@ export interface RunOptions {
|
||||
onTestStart?: (suite: string, name: string) => void
|
||||
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)))
|
||||
|
||||
+2597
-13
File diff suppressed because it is too large.
Load diff
+98
-20
@@ -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,8 +194,18 @@ 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',
|
||||
opacity: 'Opacity',
|
||||
edgeBlur: 'Edge blur',
|
||||
sections: {
|
||||
@@ -201,6 +214,32 @@ export const en = {
|
||||
polygon: 'Polygon',
|
||||
appearance: 'Appearance',
|
||||
button: 'On Click',
|
||||
effects: 'Effects',
|
||||
},
|
||||
text: {
|
||||
text: 'Text',
|
||||
fontSize: 'Font Size',
|
||||
font: 'Font',
|
||||
},
|
||||
kaleido: {
|
||||
toggle: 'Kaleido',
|
||||
repeat: 'Repeat',
|
||||
offset: 'Start offset',
|
||||
rotateCopies: 'Rotate copies outward',
|
||||
pivotX: 'Pivot X',
|
||||
pivotY: 'Pivot Y',
|
||||
hint: 'Renders the layer N times around its pivot.',
|
||||
hintClip: 'Renders the clip N times around the pivot. Each copy starts at a different point in the clip when "Start offset" is set.',
|
||||
},
|
||||
clipInstance: {
|
||||
banner: 'Clip instance — only kaleidoscope is editable here. Use the canvas to move/resize.',
|
||||
},
|
||||
wiggle: {
|
||||
toggle: 'Wiggle',
|
||||
amplitude: 'Position amplitude',
|
||||
rotation: 'Rotation amplitude',
|
||||
frequency: 'Frequency',
|
||||
hint: 'Adds procedural shake on top of the keyframe sample. Deterministic per layer — same time always gives the same wobble.',
|
||||
},
|
||||
polygonPoints: 'Points',
|
||||
polygonPointsHint: 'Changing the point count rewrites all keyframes; new points are inserted at the midpoint of the last edge.',
|
||||
@@ -310,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)',
|
||||
@@ -373,6 +413,12 @@ 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.',
|
||||
},
|
||||
vertexCorner: 'Corner',
|
||||
vertexCurve: 'Curve (smooth)',
|
||||
vertexSplitCurve: 'Split curve',
|
||||
@@ -422,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…',
|
||||
@@ -478,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',
|
||||
@@ -493,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…',
|
||||
@@ -1047,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:
|
||||
@@ -1085,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',
|
||||
@@ -1118,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:
|
||||
@@ -1277,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…',
|
||||
@@ -1332,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.',
|
||||
@@ -1347,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…',
|
||||
@@ -1889,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:
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -22,9 +22,12 @@ export interface Rect {
|
||||
* depends on font metrics — so we fall back to the asset's declared
|
||||
* width/height when present and a 40×40 placeholder otherwise. */
|
||||
function assetFootprint(asset: Asset): { w: number; h: number } {
|
||||
if (asset.kind === 'rect' || asset.kind === 'image' || asset.kind === 'button') {
|
||||
if (asset.kind === 'rect' || asset.kind === 'image') {
|
||||
return { w: asset.width, h: asset.height }
|
||||
}
|
||||
if (asset.kind === 'button' || asset.kind === 'svg') {
|
||||
return { w: asset.canvas.width, h: asset.canvas.height }
|
||||
}
|
||||
if (asset.kind === 'circle') {
|
||||
const d = Math.max(1, asset.radius * 2)
|
||||
return { w: d, h: d }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+373
-115
@@ -1,4 +1,5 @@
|
||||
import type { Project, Scene } from '@/types/scene'
|
||||
import type { Asset, Project, Scene } from '@/types/scene'
|
||||
import { AVAILABLE_FONTS, fontFaceCss, fontFamilyMapSnapshot, fontPrimary, SYSTEM_SANS } from './fonts'
|
||||
|
||||
/** Input accepted by every export helper below. A Project exports all
|
||||
* its scenes (jumpToScene works in the bundle); a single Scene is
|
||||
@@ -10,10 +11,22 @@ function isProject(p: ExportPayload): p is Project {
|
||||
return Array.isArray((p as Partial<Project>).scenes)
|
||||
}
|
||||
|
||||
const RUNTIME_VERSION = '27'
|
||||
const RUNTIME_VERSION = '36'
|
||||
|
||||
const ENGINE_SOURCE = `(function(){
|
||||
/** Materialize the engine source. The font map is rebuilt on every
|
||||
* call so user-uploaded custom fonts (which live in localStorage
|
||||
* and are dynamic) make it into the exported runtime. */
|
||||
function engineSource(): string {
|
||||
const fontMapJson = JSON.stringify(fontFamilyMapSnapshot())
|
||||
return ENGINE_TEMPLATE.replace('__FONT_FAMILY_MAP__', fontMapJson)
|
||||
}
|
||||
|
||||
const ENGINE_TEMPLATE = `(function(){
|
||||
if (window.AnimatrPlayer && window.AnimatrPlayer.__v === '${RUNTIME_VERSION}') return;
|
||||
var FONT_FAMILIES = __FONT_FAMILY_MAP__;
|
||||
function fontFam(asset){
|
||||
return (asset && asset.fontFamily && FONT_FAMILIES[asset.fontFamily]) || FONT_FAMILIES['system-sans'] || 'sans-serif';
|
||||
}
|
||||
|
||||
function easing(name, t){
|
||||
if(name==='easeIn')return t*t;
|
||||
@@ -94,6 +107,17 @@ const ENGINE_SOURCE = `(function(){
|
||||
if(a.shadowColor && b.shadowColor) out.shadowColor=lerpColor(a.shadowColor,b.shadowColor,t);
|
||||
else if(b.shadowColor) out.shadowColor=b.shadowColor;
|
||||
else if(a.shadowColor) out.shadowColor=a.shadowColor;
|
||||
// Kaleido pivot: mirror src/lib/interpolate.ts. A one-sided
|
||||
// override lerps from the layer's own (x, y) on the unset side
|
||||
// to the explicit value on the other.
|
||||
var apx=a.kaleidoPivotX, bpx=b.kaleidoPivotX;
|
||||
if(apx!=null || bpx!=null){
|
||||
out.kaleidoPivotX = lerp(apx!=null?apx:a.x, bpx!=null?bpx:b.x, t);
|
||||
}
|
||||
var apy=a.kaleidoPivotY, bpy=b.kaleidoPivotY;
|
||||
if(apy!=null || bpy!=null){
|
||||
out.kaleidoPivotY = lerp(apy!=null?apy:a.y, bpy!=null?bpy:b.y, t);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// Interpolate a single keyframe track against 'base' at 'time'.
|
||||
@@ -127,21 +151,111 @@ const ENGINE_SOURCE = `(function(){
|
||||
}
|
||||
return merge(base);
|
||||
}
|
||||
// Mirror of src/lib/wiggle.ts — keep in lockstep so editor and
|
||||
// exported runtime render an identical wobble at the same time.
|
||||
function wiggleHash32(s){
|
||||
var h=5381;
|
||||
for(var i=0;i<s.length;i++){ h=((h<<5)+h+s.charCodeAt(i))|0; }
|
||||
return h>>>0;
|
||||
}
|
||||
function wiggleSeeds(id){
|
||||
var h=wiggleHash32(id);
|
||||
return [(h^0x9e3779b9)>>>0, (h^0x85ebca6b)>>>0, (h^0xc2b2ae35)>>>0];
|
||||
}
|
||||
function wiggleNoise(seed, t){
|
||||
var a=Math.sin(seed*12.9898+t*78.233);
|
||||
var b=Math.sin(seed*4.31+t*11.71);
|
||||
var c=Math.sin(seed*7.71+t*23.5);
|
||||
return (a+b+c)/3;
|
||||
}
|
||||
function applyWiggle(props, layer, time){
|
||||
var w=layer.wiggle;
|
||||
if(!w) return props;
|
||||
var amp=(w.amplitude==null?8:w.amplitude);
|
||||
var rot=(w.rotation==null?2:w.rotation);
|
||||
var freq=(w.frequency==null?2:w.frequency);
|
||||
if(amp<=0 && rot<=0) return props;
|
||||
if(freq<=0) return props;
|
||||
var seeds=wiggleSeeds(layer.id);
|
||||
var tt=time*freq;
|
||||
var out={};
|
||||
for(var k in props) out[k]=props[k];
|
||||
out.x=props.x+amp*wiggleNoise(seeds[0],tt);
|
||||
out.y=props.y+amp*wiggleNoise(seeds[1],tt);
|
||||
out.rotation=props.rotation+rot*wiggleNoise(seeds[2],tt);
|
||||
return out;
|
||||
}
|
||||
|
||||
function sample(layer,time){
|
||||
var main=sampleKeyframeSet(layer.keyframes, layer.base, time);
|
||||
if(!layer.separated) return main;
|
||||
// Separated mode: position and rotation come from their own tracks
|
||||
// if present, merged back over the main-track values. This matches
|
||||
// the editor's sampleLayer() so animations on either side of the
|
||||
// Separate-toggle play identically.
|
||||
var pos = layer.positionKeyframes ? sampleKeyframeSet(layer.positionKeyframes, layer.base, time) : main;
|
||||
var rot = layer.rotationKeyframes ? sampleKeyframeSet(layer.rotationKeyframes, layer.base, time) : main;
|
||||
var outSep={};
|
||||
for(var k in main) outSep[k]=main[k];
|
||||
outSep.x=pos.x;
|
||||
outSep.y=pos.y;
|
||||
outSep.rotation=rot.rotation;
|
||||
return outSep;
|
||||
var result;
|
||||
if(!layer.separated){
|
||||
result=main;
|
||||
} else {
|
||||
// Separated mode: position and rotation come from their own
|
||||
// tracks if present, merged back over the main-track values.
|
||||
// Matches the editor's sampleLayer().
|
||||
var pos = layer.positionKeyframes ? sampleKeyframeSet(layer.positionKeyframes, layer.base, time) : main;
|
||||
var rot = layer.rotationKeyframes ? sampleKeyframeSet(layer.rotationKeyframes, layer.base, time) : main;
|
||||
result={};
|
||||
for(var k in main) result[k]=main[k];
|
||||
result.x=pos.x;
|
||||
result.y=pos.y;
|
||||
result.rotation=rot.rotation;
|
||||
}
|
||||
if(layer.wiggle) return applyWiggle(result, layer, time);
|
||||
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){
|
||||
@@ -185,7 +299,8 @@ const ENGINE_SOURCE = `(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){
|
||||
@@ -220,7 +335,8 @@ const ENGINE_SOURCE = `(function(){
|
||||
trackOrder: legacy.trackOrder || [],
|
||||
backgroundVideo: legacy.backgroundVideo,
|
||||
audioTracks: legacy.audioTracks || [],
|
||||
audioClips: legacy.audioClips || []
|
||||
audioClips: legacy.audioClips || [],
|
||||
camera: legacy.camera
|
||||
}],
|
||||
activeSceneId: '__legacy__'
|
||||
};
|
||||
@@ -676,6 +792,105 @@ const ENGINE_SOURCE = `(function(){
|
||||
}
|
||||
if(closed) tctx.closePath();
|
||||
}
|
||||
// Kaleidoscope: number of radial copies for an entity (Layer or
|
||||
// ClipInstance). 1 = no kaleido. Mirrors lib/kaleido.ts.
|
||||
function kalCount(e){
|
||||
if(!e || !e.kaleido) return 1;
|
||||
var n=Math.floor(e.kaleidoCount||0);
|
||||
return n<2?1:n;
|
||||
}
|
||||
function kalAngle(i,n){ return n<=1?0:(i*360)/n; }
|
||||
// Mirror of layerArmTransform in src/lib/kaleido.ts. Pivot lookup:
|
||||
// sampled props (keyframed) → layer's static legacy override →
|
||||
// layer's own sampled (x, y).
|
||||
function layerArmTf(layer, props, i, n){
|
||||
if(i===0||n<=1) return {x:props.x,y:props.y,rotation:props.rotation};
|
||||
var ppx=(props.kaleidoPivotX==null?null:props.kaleidoPivotX);
|
||||
var ppy=(props.kaleidoPivotY==null?null:props.kaleidoPivotY);
|
||||
var lpx=(layer.kaleidoPivotX==null?null:layer.kaleidoPivotX);
|
||||
var lpy=(layer.kaleidoPivotY==null?null:layer.kaleidoPivotY);
|
||||
var px=(ppx!=null?ppx:(lpx!=null?lpx:props.x));
|
||||
var py=(ppy!=null?ppy:(lpy!=null?lpy:props.y));
|
||||
var ang=kalAngle(i,n);
|
||||
var a=ang*Math.PI/180;
|
||||
var dx=props.x-px, dy=props.y-py;
|
||||
return {
|
||||
x: px+dx*Math.cos(a)-dy*Math.sin(a),
|
||||
y: py+dx*Math.sin(a)+dy*Math.cos(a),
|
||||
rotation: props.rotation+ang
|
||||
};
|
||||
}
|
||||
function clipKaleidoPivot(inst){
|
||||
var ix=inst.x||0, iy=inst.y||0;
|
||||
return {
|
||||
x:(inst.kaleidoPivotX==null?ix:inst.kaleidoPivotX),
|
||||
y:(inst.kaleidoPivotY==null?iy:inst.kaleidoPivotY)
|
||||
};
|
||||
}
|
||||
function clipArmLocalT(inst, baseLocal, i, trimmedDur){
|
||||
if(trimmedDur<=0) return null;
|
||||
var off=inst.kaleidoOffset||0;
|
||||
var trimS=inst.trimStart||0;
|
||||
var offsetIntoBlock=baseLocal-trimS;
|
||||
// Arm 0 (and the no-shift case) → source's own time. Arms i>0
|
||||
// with a non-zero offset start later.
|
||||
var shifted=(i===0||off===0)?offsetIntoBlock:(offsetIntoBlock-i*off);
|
||||
var EPS=1e-9;
|
||||
if(shifted < -EPS || shifted > trimmedDur+EPS) return null;
|
||||
if(shifted<0) shifted=0;
|
||||
if(shifted>trimmedDur) shifted=trimmedDur;
|
||||
return trimS+shifted;
|
||||
}
|
||||
function clipArmTrans(inst, i, n){
|
||||
var ix=inst.x||0, iy=inst.y||0;
|
||||
if(i===0||n<=1) return {x:ix,y:iy};
|
||||
var rot=inst.kaleidoRotateCopies!==false;
|
||||
if(rot) return {x:ix,y:iy};
|
||||
var pv=clipKaleidoPivot(inst);
|
||||
var a=kalAngle(i,n)*Math.PI/180;
|
||||
var dx=ix-pv.x, dy=iy-pv.y;
|
||||
return {
|
||||
x:pv.x+dx*Math.cos(a)-dy*Math.sin(a),
|
||||
y:pv.y+dx*Math.sin(a)+dy*Math.cos(a)
|
||||
};
|
||||
}
|
||||
function drawClipInstance(inst, clip, time){
|
||||
var trimS=inst.trimStart||0;
|
||||
var trimE=inst.trimEnd||0;
|
||||
var visibleClip=Math.max(0,clip.duration-trimS-trimE);
|
||||
var offsetIntoBlock=time-inst.startTime;
|
||||
var isx=(inst.scaleX==null?1:inst.scaleX);
|
||||
var isy=(inst.scaleY==null?1:inst.scaleY);
|
||||
var irot=inst.rotation||0;
|
||||
var n=kalCount(inst);
|
||||
var rotateCopies=inst.kaleidoRotateCopies!==false;
|
||||
var pv=clipKaleidoPivot(inst);
|
||||
var baseLocal=trimS+offsetIntoBlock;
|
||||
// Delayed arms can outlive the source's own window — keep
|
||||
// rendering until the LAST arm has finished playing.
|
||||
var offSec=inst.kaleidoOffset||0;
|
||||
var maxArmDelay=(n>1 && offSec!==0)?Math.max(0,(n-1)*offSec):0;
|
||||
if(offsetIntoBlock<0||offsetIntoBlock>visibleClip+maxArmDelay) return;
|
||||
for(var i=0;i<n;i++){
|
||||
var ka=kalAngle(i,n);
|
||||
var lt=clipArmLocalT(inst, baseLocal, i, visibleClip);
|
||||
if(lt===null) continue;
|
||||
var tr=clipArmTrans(inst, i, n);
|
||||
ctx.save();
|
||||
if(n>1 && i>0 && rotateCopies){
|
||||
ctx.translate(pv.x,pv.y);
|
||||
ctx.rotate(ka*Math.PI/180);
|
||||
ctx.translate(-pv.x,-pv.y);
|
||||
}
|
||||
ctx.translate(tr.x,tr.y);
|
||||
if(irot!==0) ctx.rotate(irot*Math.PI/180);
|
||||
ctx.scale(isx,isy);
|
||||
for(var li=clip.layers.length-1;li>=0;li--){
|
||||
drawLayer(clip.layers[li], lt);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
function drawMaskShape(ctxm,layer,time,buttonMode){
|
||||
var asset=assetMap[layer.assetId];
|
||||
if(!asset) return;
|
||||
@@ -684,58 +899,72 @@ const ENGINE_SOURCE = `(function(){
|
||||
if(!buttonLayerActiveAt(layer,time)) return;
|
||||
} else if(!layerActiveAt(layer,time)) return;
|
||||
var p=sample(layer,time);
|
||||
ctxm.save();
|
||||
ctxm.translate(p.x,p.y);
|
||||
ctxm.rotate(p.rotation*Math.PI/180);
|
||||
ctxm.scale(p.scaleX,p.scaleY);
|
||||
ctxm.fillStyle='#000000';
|
||||
if(asset.kind==='rect'){
|
||||
var mrOff=layer.offsetX!=null?layer.offsetX:asset.width/2;
|
||||
var mrOffY=layer.offsetY!=null?layer.offsetY:asset.height/2;
|
||||
ctxm.fillRect(-mrOff,-mrOffY,asset.width,asset.height);
|
||||
} else if(asset.kind==='circle'){
|
||||
var mcOff=layer.offsetX!=null?layer.offsetX:0;
|
||||
var mcOffY=layer.offsetY!=null?layer.offsetY:0;
|
||||
ctxm.beginPath();
|
||||
ctxm.arc(-mcOff,-mcOffY,asset.radius,0,Math.PI*2);
|
||||
ctxm.fill();
|
||||
} else if(asset.kind==='text'){
|
||||
var mtOff=layer.offsetX!=null?layer.offsetX:0;
|
||||
var mtOffY=layer.offsetY!=null?layer.offsetY:0;
|
||||
ctxm.font=asset.fontSize+'px sans-serif';
|
||||
ctxm.textBaseline='top';
|
||||
ctxm.fillText(asset.text,-mtOff,-mtOffY);
|
||||
} else {
|
||||
var mpOffX=layer.offsetX!=null?layer.offsetX:0;
|
||||
var mpOffY=layer.offsetY!=null?layer.offsetY:0;
|
||||
var mpPts=p.points||asset.points||[];
|
||||
var mpClosed=asset.closed!==false;
|
||||
if(mpClosed && mpPts.length>=3){
|
||||
tracePolyPath(ctxm,mpPts,asset.points,mpOffX,mpOffY,true);
|
||||
var kn=kalCount(layer);
|
||||
for(var ki=0;ki<kn;ki++){
|
||||
var tfm=layerArmTf(layer, p, ki, kn);
|
||||
ctxm.save();
|
||||
ctxm.translate(tfm.x,tfm.y);
|
||||
ctxm.rotate(tfm.rotation*Math.PI/180);
|
||||
ctxm.scale(p.scaleX,p.scaleY);
|
||||
ctxm.fillStyle='#000000';
|
||||
if(asset.kind==='rect'){
|
||||
var mrOff=layer.offsetX!=null?layer.offsetX:asset.width/2;
|
||||
var mrOffY=layer.offsetY!=null?layer.offsetY:asset.height/2;
|
||||
ctxm.fillRect(-mrOff,-mrOffY,asset.width,asset.height);
|
||||
} else if(asset.kind==='circle'){
|
||||
var mcOff=layer.offsetX!=null?layer.offsetX:0;
|
||||
var mcOffY=layer.offsetY!=null?layer.offsetY:0;
|
||||
ctxm.beginPath();
|
||||
ctxm.arc(-mcOff,-mcOffY,asset.radius,0,Math.PI*2);
|
||||
ctxm.fill();
|
||||
} else if(asset.kind==='text'){
|
||||
var mtOff=layer.offsetX!=null?layer.offsetX:0;
|
||||
var mtOffY=layer.offsetY!=null?layer.offsetY:0;
|
||||
ctxm.font=asset.fontSize+'px '+fontFam(asset);
|
||||
ctxm.textBaseline='top';
|
||||
ctxm.fillText(asset.text,-mtOff,-mtOffY);
|
||||
} else {
|
||||
var mpOffX=layer.offsetX!=null?layer.offsetX:0;
|
||||
var mpOffY=layer.offsetY!=null?layer.offsetY:0;
|
||||
var mpPts=p.points||asset.points||[];
|
||||
var mpClosed=asset.closed!==false;
|
||||
if(mpClosed && mpPts.length>=3){
|
||||
tracePolyPath(ctxm,mpPts,asset.points,mpOffX,mpOffY,true);
|
||||
ctxm.fill();
|
||||
}
|
||||
}
|
||||
ctxm.restore();
|
||||
}
|
||||
ctxm.restore();
|
||||
}
|
||||
// Mirror of isLayerActiveAt + layerActiveRange in interpolate.ts.
|
||||
// A layer is "alive" from its earliest NORMAL kf onward, ending
|
||||
// either at the earliest 'end' kf or at +Infinity when none exists.
|
||||
// Layers without any normal kf are static (always visible).
|
||||
// End kfs only live on .keyframes (the main track).
|
||||
function layerActiveAt(layer,time){
|
||||
var main=layer.keyframes||[];
|
||||
var pos=layer.positionKeyframes||[];
|
||||
var rot=layer.rotationKeyframes||[];
|
||||
var total=main.length+pos.length+rot.length;
|
||||
if(total<=1) return true;
|
||||
var mn=Infinity, mx=-Infinity, lastHold=false;
|
||||
function scan(arr){
|
||||
var foundNormal=false, mn=Infinity;
|
||||
function scanNormal(arr){
|
||||
for(var i=0;i<arr.length;i++){
|
||||
var k=arr[i], t=k.time;
|
||||
if(t<mn) mn=t;
|
||||
if(t>mx){ mx=t; lastHold=!!k.hold; }
|
||||
else if(t===mx && k.hold){ lastHold=true; }
|
||||
var k=arr[i];
|
||||
if(k.kind==='end') continue;
|
||||
foundNormal=true;
|
||||
if(k.time<mn) mn=k.time;
|
||||
}
|
||||
}
|
||||
scan(main); scan(pos); scan(rot);
|
||||
if(lastHold) mx=Infinity;
|
||||
scanNormal(main); scanNormal(pos); scanNormal(rot);
|
||||
if(!foundNormal) return true;
|
||||
var endTime=null;
|
||||
for(var ei=0;ei<main.length;ei++){
|
||||
var ek=main[ei];
|
||||
if(ek.kind!=='end') continue;
|
||||
if(endTime===null || ek.time<endTime) endTime=ek.time;
|
||||
}
|
||||
var mx=(endTime===null)?Infinity:endTime;
|
||||
var EPS=1e-6;
|
||||
return time>=mn-EPS && time<=mx+EPS;
|
||||
return time>=mn-EPS && time<mx-EPS;
|
||||
}
|
||||
// Button-asset visibility: a still-frame (hold) kf shows ONLY on
|
||||
// its own state frame, not on the state frames that follow. Mirrors
|
||||
@@ -767,10 +996,15 @@ const ENGINE_SOURCE = `(function(){
|
||||
if(!buttonLayerActiveAt(layer,time)) return;
|
||||
} else if(!layerActiveAt(layer,time)) return;
|
||||
var p=sample(layer,time);
|
||||
// Kaleido off for button-asset layers — multiplying their hit
|
||||
// areas would break click routing.
|
||||
var kn=(asset.kind==='button')?1:kalCount(layer);
|
||||
for(var ki=0;ki<kn;ki++){
|
||||
var tfL=layerArmTf(layer, p, ki, kn);
|
||||
ctx.save();
|
||||
ctx.globalAlpha=ctx.globalAlpha*p.opacity;
|
||||
ctx.translate(p.x,p.y);
|
||||
ctx.rotate(p.rotation*Math.PI/180);
|
||||
ctx.translate(tfL.x,tfL.y);
|
||||
ctx.rotate(tfL.rotation*Math.PI/180);
|
||||
ctx.scale(p.scaleX,p.scaleY);
|
||||
var sOp=p.shadowOpacity||0;
|
||||
var sBl=p.shadowBlur||0;
|
||||
@@ -827,7 +1061,7 @@ const ENGINE_SOURCE = `(function(){
|
||||
}else if(asset.kind==='text'){
|
||||
var tOffX=layer.offsetX!=null?layer.offsetX:0;
|
||||
var tOffY=layer.offsetY!=null?layer.offsetY:0;
|
||||
ctx.font=asset.fontSize+'px sans-serif';
|
||||
ctx.font=asset.fontSize+'px '+fontFam(asset);
|
||||
ctx.textBaseline='top';
|
||||
var tFill=p.fill||asset.fill;
|
||||
if(tFill && tFill!=='transparent'){
|
||||
@@ -922,26 +1156,38 @@ const ENGINE_SOURCE = `(function(){
|
||||
ctx.shadowOffsetX=0;
|
||||
ctx.shadowOffsetY=0;
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
function draw(time){
|
||||
var lw=scene.canvas.width,lh=scene.canvas.height;
|
||||
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];
|
||||
@@ -990,27 +1236,7 @@ const ENGINE_SOURCE = `(function(){
|
||||
var inst2=tInsts[ti];
|
||||
var clip2=clipById2[inst2.clipId];
|
||||
if(!clip2)continue;
|
||||
var tS=inst2.trimStart||0;
|
||||
var tE=inst2.trimEnd||0;
|
||||
var visibleClip=Math.max(0,clip2.duration-tS-tE);
|
||||
var offset=time-inst2.startTime;
|
||||
if(offset<0||offset>visibleClip)continue;
|
||||
var localT=tS+offset;
|
||||
var ix2=inst2.x||0, iy2=inst2.y||0;
|
||||
var isx2=(inst2.scaleX==null?1:inst2.scaleX);
|
||||
var isy2=(inst2.scaleY==null?1:inst2.scaleY);
|
||||
var irot2=inst2.rotation||0;
|
||||
var needsXf=ix2!==0||iy2!==0||isx2!==1||isy2!==1||irot2!==0;
|
||||
if(needsXf){
|
||||
ctx.save();
|
||||
ctx.translate(ix2,iy2);
|
||||
if(irot2!==0) ctx.rotate(irot2*Math.PI/180);
|
||||
ctx.scale(isx2,isy2);
|
||||
}
|
||||
for(var cl2=clip2.layers.length-1;cl2>=0;cl2--){
|
||||
drawLayer(clip2.layers[cl2],localT);
|
||||
}
|
||||
if(needsXf) ctx.restore();
|
||||
drawClipInstance(inst2, clip2, time);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -1038,27 +1264,7 @@ const ENGINE_SOURCE = `(function(){
|
||||
var inst4=insts3[ti3];
|
||||
var clip4=clipById2[inst4.clipId];
|
||||
if(!clip4) continue;
|
||||
var tS4=inst4.trimStart||0;
|
||||
var tE4=inst4.trimEnd||0;
|
||||
var vis4=Math.max(0,clip4.duration-tS4-tE4);
|
||||
var off4=time-inst4.startTime;
|
||||
if(off4<0||off4>vis4) continue;
|
||||
var lt4=tS4+off4;
|
||||
var ix4=inst4.x||0, iy4=inst4.y||0;
|
||||
var isx4=(inst4.scaleX==null?1:inst4.scaleX);
|
||||
var isy4=(inst4.scaleY==null?1:inst4.scaleY);
|
||||
var irot4=inst4.rotation||0;
|
||||
var needsXf4=ix4!==0||iy4!==0||isx4!==1||isy4!==1||irot4!==0;
|
||||
if(needsXf4){
|
||||
ctx.save();
|
||||
ctx.translate(ix4,iy4);
|
||||
if(irot4!==0) ctx.rotate(irot4*Math.PI/180);
|
||||
ctx.scale(isx4,isy4);
|
||||
}
|
||||
for(var cl4=clip4.layers.length-1;cl4>=0;cl4--){
|
||||
drawLayer(clip4.layers[cl4],lt4);
|
||||
}
|
||||
if(needsXf4) ctx.restore();
|
||||
drawClipInstance(inst4, clip4, time);
|
||||
}
|
||||
};
|
||||
if(!gMasks.length){
|
||||
@@ -1256,7 +1462,7 @@ let cachedApi: any = null
|
||||
function ensureEngine() {
|
||||
if (cachedApi) return
|
||||
// eslint-disable-next-line no-eval
|
||||
;(0, eval)(ENGINE_SOURCE)
|
||||
;(0, eval)(engineSource())
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
cachedApi = (window as any).AnimatrPlayer
|
||||
}
|
||||
@@ -1330,6 +1536,22 @@ function jsonForExport(payload: ExportPayload): string {
|
||||
return JSON.stringify(out)
|
||||
}
|
||||
|
||||
function collectFontIds(payload: ExportPayload): string[] {
|
||||
const ids = new Set<string>()
|
||||
const assets: readonly Asset[] = payload.assets ?? []
|
||||
for (const a of assets) {
|
||||
if (a.kind === 'text' && a.fontFamily) ids.add(a.fontFamily)
|
||||
}
|
||||
return Array.from(ids)
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
}
|
||||
|
||||
function sceneToLegacyProject(scene: Scene): Project {
|
||||
return {
|
||||
version: 1,
|
||||
@@ -1355,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
|
||||
}
|
||||
@@ -1368,7 +1605,7 @@ export interface ExportOptions {
|
||||
export function generateRuntimeScript(): string {
|
||||
return (
|
||||
'/* RUWIO runtime v' + RUNTIME_VERSION + ' — generated ' + new Date().toISOString() + ' */\n' +
|
||||
ENGINE_SOURCE
|
||||
engineSource()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1386,7 +1623,7 @@ export function generateSceneScript(payload: ExportPayload, options: ExportOptio
|
||||
export function generateBundledScript(payload: ExportPayload, options: ExportOptions = {}): string {
|
||||
return (
|
||||
'/* RUWIO bundled export — generated ' + new Date().toISOString() + ' */\n' +
|
||||
ENGINE_SOURCE +
|
||||
engineSource() +
|
||||
'\n' +
|
||||
generateSceneScriptBody(payload, options)
|
||||
)
|
||||
@@ -1411,10 +1648,29 @@ export function downloadBundledExport(payload: ExportPayload, filename = 'animat
|
||||
}
|
||||
|
||||
export function generateExampleHtml(payload: ExportPayload, options: ExportOptions = {}): string {
|
||||
const runtime = ENGINE_SOURCE
|
||||
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
|
||||
.map(id => fonts.find(f => f.id === id))
|
||||
.filter((f): f is typeof fonts[number] => !!f && !!f.file)
|
||||
const builtinUsed = usedFonts.filter(f => !f.custom)
|
||||
const customUsed = usedFonts.filter(f => f.custom)
|
||||
const fontHint = usedFonts.length === 0 ? '' : `
|
||||
<div class="usage">
|
||||
<h2>Fonts used in this animation</h2>
|
||||
<p style="margin-bottom: 8px;">This preview uses ${SYSTEM_SANS.label} for missing fonts. To render with the correct typefaces, host the font files alongside this HTML and add the @font-face rules below to your CSS:</p>
|
||||
<ul style="margin: 0 0 12px 18px; padding: 0; font-size: 12px; color: #6e6e73;">
|
||||
${builtinUsed.map(f => ` <li>${fontPrimary(f.id)} — <code>${f.file.replace(/^\//, '')}</code></li>`).join('\n')}${
|
||||
customUsed.length > 0
|
||||
? '\n' + customUsed.map(f => ` <li>${escapeHtml(f.label)} — <em>locally uploaded; the @font-face below has the file inlined as a data URL</em></li>`).join('\n')
|
||||
: ''
|
||||
}
|
||||
</ul>
|
||||
<pre>${escapeHtml(fontFaceCss(usedFontIds))}</pre>
|
||||
</div>`
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -1489,6 +1745,8 @@ ${sceneBoot}
|
||||
</script>
|
||||
</div>
|
||||
|
||||
${fontHint}
|
||||
|
||||
<div class="usage">
|
||||
<h2>Embed on your own page</h2>
|
||||
<pre><!-- once per page -->
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
// Curated set of Google Fonts shipped with the editor as local
|
||||
// .woff2 files (in /public/fonts), plus user-uploaded fonts kept in
|
||||
// localStorage as data-URLs. Adding a built-in needs three things:
|
||||
// (1) drop the .woff2 next to the others, (2) add an entry to
|
||||
// BUILTIN_FONTS, (3) the example-HTML hint picks it up automatically.
|
||||
// Custom (user-uploaded) fonts flow through uploadCustomFont().
|
||||
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
/** Reactive flag that flips to true once all bundled fonts have
|
||||
* resolved. Read it inside a computed/watch on the canvas to force
|
||||
* a re-render after fonts arrive — otherwise Konva's first paint
|
||||
* would use the fallback family and never refresh. */
|
||||
export const fontsReady = ref(false)
|
||||
|
||||
export interface FontDef {
|
||||
/** Stable id stored on TextAsset.fontFamily — never rename. */
|
||||
id: string
|
||||
/** Human-readable label shown in the picker. */
|
||||
label: string
|
||||
/** CSS font-family string (with fallback) used at render time. */
|
||||
family: string
|
||||
/** Path to the .woff2 (built-ins) or data-URL (custom). Empty for
|
||||
* the system-sans fallback. */
|
||||
file: string
|
||||
/** True for user-uploaded fonts. Built-ins are false/undefined. */
|
||||
custom?: boolean
|
||||
}
|
||||
|
||||
export const SYSTEM_SANS: FontDef = {
|
||||
id: 'system-sans',
|
||||
label: 'System Sans',
|
||||
family:
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||
file: '',
|
||||
}
|
||||
|
||||
export const BUILTIN_FONTS: FontDef[] = [
|
||||
SYSTEM_SANS,
|
||||
{ id: 'inter', label: 'Inter', family: '"Inter", sans-serif', file: '/fonts/Inter-Regular.woff2' },
|
||||
{ id: 'roboto', label: 'Roboto', family: '"Roboto", sans-serif', file: '/fonts/Roboto-Regular.woff2' },
|
||||
{ id: 'montserrat', label: 'Montserrat', family: '"Montserrat", sans-serif', file: '/fonts/Montserrat-Regular.woff2' },
|
||||
{ id: 'nunito', label: 'Nunito', family: '"Nunito", sans-serif', file: '/fonts/Nunito-Regular.woff2' },
|
||||
{ id: 'figtree', label: 'Figtree', family: '"Figtree", sans-serif', file: '/fonts/Figtree-Regular.woff2' },
|
||||
{ id: 'gabarito', label: 'Gabarito', family: '"Gabarito", sans-serif', file: '/fonts/Gabarito-Regular.woff2' },
|
||||
{ id: 'lora', label: 'Lora', family: '"Lora", serif', file: '/fonts/Lora-Regular.woff2' },
|
||||
{ id: 'playfair', label: 'Playfair Display', family: '"Playfair Display", serif', file: '/fonts/PlayfairDisplay-Regular.woff2' },
|
||||
{ id: 'archivo-black', label: 'Archivo Black', family: '"Archivo Black", sans-serif', file: '/fonts/ArchivoBlack-Regular.woff2' },
|
||||
{ id: 'bebas', label: 'Bebas Neue', family: '"Bebas Neue", sans-serif', file: '/fonts/BebasNeue-Regular.woff2' },
|
||||
{ id: 'press-start', label: 'Press Start 2P', family: '"Press Start 2P", monospace', file: '/fonts/PressStart2P-Regular.woff2' },
|
||||
{ id: 'source-code', label: 'Source Code Pro', family: '"Source Code Pro", monospace', file: '/fonts/SourceCodePro-Regular.woff2' },
|
||||
{ id: 'marker', label: 'Permanent Marker', family: '"Permanent Marker", cursive', file: '/fonts/PermanentMarker-Regular.woff2' },
|
||||
]
|
||||
|
||||
// ----- Custom (user-uploaded) fonts -----------------------------------------
|
||||
|
||||
const CUSTOM_FONTS_KEY = 'animatr:customFonts'
|
||||
const CUSTOM_ID_PREFIX = 'custom-'
|
||||
|
||||
interface CustomFontStored {
|
||||
id: string
|
||||
label: string
|
||||
/** data:font/woff2;base64,... — also accepts ttf / otf data URLs. */
|
||||
dataUrl: string
|
||||
}
|
||||
|
||||
interface CustomFontsBlob {
|
||||
version: 1
|
||||
fonts: CustomFontStored[]
|
||||
}
|
||||
|
||||
function readCustomFontsFromStorage(): CustomFontStored[] {
|
||||
if (typeof localStorage === 'undefined') return []
|
||||
try {
|
||||
const raw = localStorage.getItem(CUSTOM_FONTS_KEY)
|
||||
if (!raw) return []
|
||||
const parsed = JSON.parse(raw) as CustomFontsBlob
|
||||
if (parsed?.version !== 1 || !Array.isArray(parsed.fonts)) return []
|
||||
return parsed.fonts.filter(f => f && typeof f.id === 'string' && typeof f.dataUrl === 'string')
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function persistCustomFonts(fonts: CustomFontStored[]): void {
|
||||
if (typeof localStorage === 'undefined') return
|
||||
const blob: CustomFontsBlob = { version: 1, fonts }
|
||||
localStorage.setItem(CUSTOM_FONTS_KEY, JSON.stringify(blob))
|
||||
}
|
||||
|
||||
const customFontsState = ref<CustomFontStored[]>(readCustomFontsFromStorage())
|
||||
|
||||
function customFamilyName(id: string): string {
|
||||
// Sanitize so the id can be embedded in a font-family identifier
|
||||
// without escaping. Custom ids only ever come from cryptoId() so
|
||||
// they're already safe, but we belt-and-suspenders for safety.
|
||||
return `RUWIO Custom ${id.replace(/[^a-zA-Z0-9_-]/g, '')}`
|
||||
}
|
||||
|
||||
function customToDef(c: CustomFontStored): FontDef {
|
||||
const fam = customFamilyName(c.id)
|
||||
return {
|
||||
id: c.id,
|
||||
label: c.label,
|
||||
family: `"${fam}", sans-serif`,
|
||||
file: c.dataUrl,
|
||||
custom: true,
|
||||
}
|
||||
}
|
||||
|
||||
/** Reactive list of all available fonts: built-ins + user-uploaded. */
|
||||
export const AVAILABLE_FONTS = computed<FontDef[]>(() => {
|
||||
return [...BUILTIN_FONTS, ...customFontsState.value.map(customToDef)]
|
||||
})
|
||||
|
||||
function fontByIdSnapshot(id: string): FontDef | undefined {
|
||||
for (const f of BUILTIN_FONTS) if (f.id === id) return f
|
||||
const c = customFontsState.value.find(x => x.id === id)
|
||||
return c ? customToDef(c) : undefined
|
||||
}
|
||||
|
||||
export function fontDef(id: string | undefined | null): FontDef {
|
||||
if (!id) return SYSTEM_SANS
|
||||
return fontByIdSnapshot(id) ?? SYSTEM_SANS
|
||||
}
|
||||
|
||||
export function fontCssFamily(id: string | undefined | null): string {
|
||||
return fontDef(id).family
|
||||
}
|
||||
|
||||
/** Primary CSS family name only (no fallback list). Needed for
|
||||
* ctx.font shorthand and FontFace registration. */
|
||||
export function fontPrimary(id: string | undefined | null): string {
|
||||
const f = fontDef(id).family
|
||||
const head = f.split(',', 1)[0]?.trim() ?? f
|
||||
return head.replace(/^"(.*)"$/, '$1')
|
||||
}
|
||||
|
||||
let loadPromise: Promise<void> | null = null
|
||||
|
||||
function registerFontFace(family: string, src: string, format?: string): Promise<void> {
|
||||
if (typeof document === 'undefined' || !document.fonts) return Promise.resolve()
|
||||
const srcSpec = format ? `url(${src}) format('${format}')` : `url(${src})`
|
||||
const face = new FontFace(family, srcSpec, {
|
||||
style: 'normal',
|
||||
weight: '400',
|
||||
display: 'swap',
|
||||
})
|
||||
return face.load().then(loaded => {
|
||||
document.fonts.add(loaded)
|
||||
})
|
||||
}
|
||||
|
||||
export function loadFonts(): Promise<void> {
|
||||
if (loadPromise) return loadPromise
|
||||
if (typeof document === 'undefined' || !document.fonts) {
|
||||
loadPromise = Promise.resolve()
|
||||
return loadPromise
|
||||
}
|
||||
const builtinTasks = BUILTIN_FONTS
|
||||
.filter(f => f.file)
|
||||
.map(f =>
|
||||
registerFontFace(fontPrimary(f.id), f.file, 'woff2').catch(() => {
|
||||
// Best-effort. Failure to load a single font shouldn't block
|
||||
// the editor — text falls back to the next family in the list.
|
||||
}),
|
||||
)
|
||||
const customTasks = customFontsState.value.map(c =>
|
||||
registerFontFace(customFamilyName(c.id), c.dataUrl).catch(() => {}),
|
||||
)
|
||||
loadPromise = Promise.all([...builtinTasks, ...customTasks]).then(() => {
|
||||
fontsReady.value = true
|
||||
})
|
||||
return loadPromise
|
||||
}
|
||||
|
||||
// ----- Upload / remove API --------------------------------------------------
|
||||
|
||||
function cryptoId(): string {
|
||||
if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID().replace(/-/g, '')
|
||||
return 'x' + Math.random().toString(36).slice(2) + Date.now().toString(36)
|
||||
}
|
||||
|
||||
function dataUrlFor(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(String(reader.result))
|
||||
reader.onerror = () => reject(reader.error ?? new Error('FileReader failed'))
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
const ACCEPTED_EXT = /\.(woff2?|ttf|otf)$/i
|
||||
|
||||
export async function uploadCustomFont(file: File): Promise<FontDef> {
|
||||
if (!ACCEPTED_EXT.test(file.name)) {
|
||||
throw new Error('Unsupported font format. Use .woff2, .woff, .ttf, or .otf.')
|
||||
}
|
||||
const dataUrl = await dataUrlFor(file)
|
||||
const id = `${CUSTOM_ID_PREFIX}${cryptoId()}`
|
||||
const label = file.name.replace(ACCEPTED_EXT, '')
|
||||
// Register with FontFace before persisting so a corrupt file fails
|
||||
// loud instead of poisoning localStorage.
|
||||
await registerFontFace(customFamilyName(id), dataUrl)
|
||||
const next: CustomFontStored = { id, label, dataUrl }
|
||||
const all = [...customFontsState.value, next]
|
||||
try {
|
||||
persistCustomFonts(all)
|
||||
} catch (err) {
|
||||
// Quota: report a friendly message but keep the live registration
|
||||
// (the font still works for this session, it just won't survive
|
||||
// a reload).
|
||||
throw new Error(
|
||||
'Could not save font to local storage — likely quota full. The font is loaded for this session only.',
|
||||
)
|
||||
}
|
||||
customFontsState.value = all
|
||||
return customToDef(next)
|
||||
}
|
||||
|
||||
export function removeCustomFont(id: string): void {
|
||||
const all = customFontsState.value.filter(f => f.id !== id)
|
||||
if (all.length === customFontsState.value.length) return
|
||||
persistCustomFonts(all)
|
||||
customFontsState.value = all
|
||||
// Drop the FontFace too so a freshly added font with the same id
|
||||
// isn't shadowed (in practice the id is random so this is defensive).
|
||||
if (typeof document !== 'undefined' && document.fonts) {
|
||||
const fam = customFamilyName(id)
|
||||
const faces = Array.from(document.fonts as unknown as Iterable<FontFace>)
|
||||
for (const f of faces) if (f.family === fam) document.fonts.delete(f)
|
||||
}
|
||||
}
|
||||
|
||||
export function isCustomFontId(id: string | undefined | null): boolean {
|
||||
return !!id && id.startsWith(CUSTOM_ID_PREFIX)
|
||||
}
|
||||
|
||||
/** Snapshot of {id → CSS family} used by the export player engine.
|
||||
* Built fresh per export so user-uploaded fonts get included. */
|
||||
export function fontFamilyMapSnapshot(): Record<string, string> {
|
||||
const out: Record<string, string> = {}
|
||||
for (const f of BUILTIN_FONTS) out[f.id] = f.family
|
||||
for (const c of customFontsState.value) out[c.id] = customToDef(c).family
|
||||
return out
|
||||
}
|
||||
|
||||
/** @font-face block users can paste into their own CSS when bundling
|
||||
* the example HTML — used by the export preview hint. Custom fonts
|
||||
* are emitted with the data-URL inlined so the snippet is self-
|
||||
* contained even though the export itself doesn't bundle them. */
|
||||
export function fontFaceCss(ids: string[]): string {
|
||||
const seen = new Set<string>()
|
||||
const out: string[] = []
|
||||
for (const id of ids) {
|
||||
if (seen.has(id)) continue
|
||||
seen.add(id)
|
||||
const f = fontByIdSnapshot(id)
|
||||
if (!f || !f.file) continue
|
||||
if (f.custom) {
|
||||
out.push(
|
||||
`@font-face {\n font-family: '${customFamilyName(f.id)}';\n src: url('${f.file}');\n font-weight: 400;\n font-style: normal;\n font-display: swap;\n}`,
|
||||
)
|
||||
} else {
|
||||
out.push(
|
||||
`@font-face {\n font-family: '${fontPrimary(f.id)}';\n src: url('.${f.file}') format('woff2');\n font-weight: 400;\n font-style: normal;\n font-display: swap;\n}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
return out.join('\n')
|
||||
}
|
||||
+6
-10
@@ -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,
|
||||
|
||||
+37
-13
@@ -1,4 +1,5 @@
|
||||
import type { AnimatableProps, Easing, Keyframe, Layer } from '@/types/scene'
|
||||
import { applyWiggle } from './wiggle'
|
||||
|
||||
const easings: Record<Easing, (t: number) => number> = {
|
||||
linear: (t) => t,
|
||||
@@ -68,6 +69,22 @@ export function lerpProps(a: AnimatableProps, b: AnimatableProps, t: number): An
|
||||
if (a.strokeWidth !== undefined && b.strokeWidth !== undefined) out.strokeWidth = lerp(a.strokeWidth, b.strokeWidth, t)
|
||||
else if (b.strokeWidth !== undefined) out.strokeWidth = b.strokeWidth
|
||||
else if (a.strokeWidth !== undefined) out.strokeWidth = a.strokeWidth
|
||||
// Kaleido pivot defaults to the layer's own (x, y) when unset on a
|
||||
// keyframe — so a one-sided override (only b has kaleidoPivotX, a
|
||||
// doesn't) lerps from a.x → b.kaleidoPivotX instead of snapping to
|
||||
// b's value at t=0+. Same on the reverse direction.
|
||||
{
|
||||
const ax = a.kaleidoPivotX
|
||||
const bx = b.kaleidoPivotX
|
||||
if (ax !== undefined || bx !== undefined) {
|
||||
out.kaleidoPivotX = lerp(ax ?? a.x, bx ?? b.x, t)
|
||||
}
|
||||
const ay = a.kaleidoPivotY
|
||||
const by = b.kaleidoPivotY
|
||||
if (ay !== undefined || by !== undefined) {
|
||||
out.kaleidoPivotY = lerp(ay ?? a.y, by ?? b.y, t)
|
||||
}
|
||||
}
|
||||
if (a.points && b.points && a.points.length === b.points.length) {
|
||||
out.points = a.points.map((pa, i) => {
|
||||
const pb = b.points![i]
|
||||
@@ -220,18 +237,25 @@ export function isButtonLayerActiveAt(layer: Layer, time: number): boolean {
|
||||
|
||||
export function sampleLayer(layer: Layer, time: number): AnimatableProps {
|
||||
const main = sampleKeyframeSet(layer.keyframes, layer.base, time)
|
||||
if (!layer.separated) return main
|
||||
|
||||
const posSource = layer.positionKeyframes
|
||||
const rotSource = layer.rotationKeyframes
|
||||
|
||||
const pos = posSource ? sampleKeyframeSet(posSource, layer.base, time) : main
|
||||
const rot = rotSource ? sampleKeyframeSet(rotSource, layer.base, time) : main
|
||||
|
||||
return {
|
||||
...main,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
rotation: rot.rotation,
|
||||
let result: AnimatableProps
|
||||
if (!layer.separated) {
|
||||
result = main
|
||||
} else {
|
||||
const posSource = layer.positionKeyframes
|
||||
const rotSource = layer.rotationKeyframes
|
||||
const pos = posSource ? sampleKeyframeSet(posSource, layer.base, time) : main
|
||||
const rot = rotSource ? sampleKeyframeSet(rotSource, layer.base, time) : main
|
||||
result = {
|
||||
...main,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
rotation: rot.rotation,
|
||||
}
|
||||
}
|
||||
// Wiggle stacks on top of the keyframe sample — every renderer goes
|
||||
// through this function so the modifier lights up everywhere
|
||||
// (canvas2d, Konva stage, button-state, clip-internal). The export
|
||||
// engine mirrors the same math in JS.
|
||||
if (layer.wiggle) return applyWiggle(result, layer, time)
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// Math helpers for the kaleidoscope feature. Kept as pure data so
|
||||
// the canvas2d offline renderer (lib/render.ts), the Konva live
|
||||
// stage (components/CanvasStage.vue), AND the exported runtime
|
||||
// (lib/exportPlayer.ts engine source — by mirroring the formulas in
|
||||
// JS) all derive identical geometry from the same definitions.
|
||||
|
||||
import type { AnimatableProps, Layer, ClipInstance } from '@/types/scene'
|
||||
|
||||
/** Number of radial copies to render. Returns 1 when kaleido is off
|
||||
* or the count is below the floor — the caller should treat 1 as
|
||||
* "no kaleido, just render once unchanged". */
|
||||
export function kaleidoCountFor(entity: { kaleido?: boolean; kaleidoCount?: number }): number {
|
||||
if (!entity.kaleido) return 1
|
||||
const n = Math.floor(entity.kaleidoCount ?? 0)
|
||||
if (n < 2) return 1
|
||||
return n
|
||||
}
|
||||
|
||||
/** Per-copy radial angle in degrees. Index 0 → 0° (the source). */
|
||||
export function kaleidoAngleDegrees(index: number, count: number): number {
|
||||
if (count <= 1) return 0
|
||||
return (index * 360) / count
|
||||
}
|
||||
|
||||
/** ClipInstance kaleido pivot in world space. Defaults to the
|
||||
* instance's own position when no explicit pivot is set. */
|
||||
export function clipKaleidoPivot(inst: ClipInstance): { x: number; y: number } {
|
||||
const ix = inst.x ?? 0
|
||||
const iy = inst.y ?? 0
|
||||
return {
|
||||
x: inst.kaleidoPivotX ?? ix,
|
||||
y: inst.kaleidoPivotY ?? iy,
|
||||
}
|
||||
}
|
||||
|
||||
/** Per-arm local clip time (seconds). Returns `null` when the arm is
|
||||
* outside the clip's playable range — i.e. the arm hasn't started
|
||||
* yet (its delayed start is still in the future) or it has already
|
||||
* finished. Callers must skip arms where this returns null.
|
||||
*
|
||||
* Semantics: arm `i` is delayed by `i * kaleidoOffset` seconds
|
||||
* relative to the source. So with offset = 0.5s and four arms, at
|
||||
* scene time `t` arm 0 shows the source frame, arm 1 shows the
|
||||
* frame from 0.5s earlier in the clip's playback (equivalent to
|
||||
* "arm 1 starts 0.5s after arm 0"), arm 2 from 1s earlier, etc.
|
||||
* Before its delayed start the arm is hidden; after the clip's end
|
||||
* it stays gone.
|
||||
*/
|
||||
export function clipArmLocalTime(
|
||||
inst: ClipInstance,
|
||||
baseLocal: number,
|
||||
index: number,
|
||||
trimmedDuration: number,
|
||||
): number | null {
|
||||
if (trimmedDuration <= 0) return null
|
||||
const offset = inst.kaleidoOffset ?? 0
|
||||
const trimStart = inst.trimStart ?? 0
|
||||
const offsetIntoBlock = baseLocal - trimStart
|
||||
// Arm 0 (and the no-shift case) just uses the source's own clip-
|
||||
// local time; arms i>0 with a non-zero offset are delayed.
|
||||
const shifted = (index === 0 || offset === 0)
|
||||
? offsetIntoBlock
|
||||
: offsetIntoBlock - index * offset
|
||||
const EPS = 1e-9
|
||||
if (shifted < -EPS || shifted > trimmedDuration + EPS) return null
|
||||
return trimStart + Math.max(0, Math.min(trimmedDuration, shifted))
|
||||
}
|
||||
|
||||
/**
|
||||
* For rotateCopies=false: compute the rotated *position* of the
|
||||
* instance's anchor around the kaleido pivot. The caller then
|
||||
* translates by this rotated position (instead of the original
|
||||
* (ix, iy)) so the contents land in a circle around the pivot
|
||||
* without rotating themselves.
|
||||
*/
|
||||
export function clipArmTranslate(
|
||||
inst: ClipInstance,
|
||||
index: number,
|
||||
count: number,
|
||||
): { x: number; y: number } {
|
||||
const ix = inst.x ?? 0
|
||||
const iy = inst.y ?? 0
|
||||
if (index === 0 || count <= 1) return { x: ix, y: iy }
|
||||
const rotateCopies = inst.kaleidoRotateCopies !== false
|
||||
if (rotateCopies) return { x: ix, y: iy }
|
||||
const { x: px, y: py } = clipKaleidoPivot(inst)
|
||||
const a = (kaleidoAngleDegrees(index, count) * Math.PI) / 180
|
||||
const dx = ix - px
|
||||
const dy = iy - py
|
||||
return {
|
||||
x: px + dx * Math.cos(a) - dy * Math.sin(a),
|
||||
y: py + dx * Math.sin(a) + dy * Math.cos(a),
|
||||
}
|
||||
}
|
||||
|
||||
/** Bool helper: should the per-arm transform also rotate the
|
||||
* contents (true) or only translate them around the pivot (false)?
|
||||
* Default true mirrors the natural kaleidoscope look. Layers don't
|
||||
* expose a toggle — they always rotate copies. */
|
||||
export function shouldRotateClipCopies(inst: ClipInstance): boolean {
|
||||
return inst.kaleidoRotateCopies !== false
|
||||
}
|
||||
|
||||
/** Convenience wrapper for the layer side, where the math is
|
||||
* simpler (contents always rotate with the copy). */
|
||||
export function layerKaleidoCount(layer: Layer): number {
|
||||
return kaleidoCountFor(layer)
|
||||
}
|
||||
|
||||
/** Effective pivot for a layer. Lookup chain:
|
||||
* 1. `props.kaleidoPivotX/Y` — keyframed value (lets the pivot
|
||||
* animate)
|
||||
* 2. `Layer.kaleidoPivotX/Y` — legacy static override (kept for
|
||||
* back-compat with projects saved before the feature went
|
||||
* keyframable)
|
||||
* 3. layer's sampled (x, y) — default
|
||||
*/
|
||||
export function layerKaleidoPivot(
|
||||
layer: Layer,
|
||||
props: AnimatableProps,
|
||||
): { x: number; y: number } {
|
||||
return {
|
||||
x: props.kaleidoPivotX ?? layer.kaleidoPivotX ?? props.x,
|
||||
y: props.kaleidoPivotY ?? layer.kaleidoPivotY ?? props.y,
|
||||
}
|
||||
}
|
||||
|
||||
/** World-space (x, y, rotation) for arm `index` of a kaleido'd
|
||||
* layer. The source (index 0) is unchanged. Higher indices rotate
|
||||
* around the pivot — both their position AND their orientation,
|
||||
* since layer kaleido always rotates copies. */
|
||||
export function layerArmTransform(
|
||||
layer: Layer,
|
||||
props: AnimatableProps,
|
||||
index: number,
|
||||
count: number,
|
||||
): { x: number; y: number; rotation: number } {
|
||||
if (index === 0 || count <= 1) {
|
||||
return { x: props.x, y: props.y, rotation: props.rotation }
|
||||
}
|
||||
const { x: px, y: py } = layerKaleidoPivot(layer, props)
|
||||
const angle = kaleidoAngleDegrees(index, count)
|
||||
const a = (angle * Math.PI) / 180
|
||||
const dx = props.x - px
|
||||
const dy = props.y - py
|
||||
return {
|
||||
x: px + dx * Math.cos(a) - dy * Math.sin(a),
|
||||
y: py + dx * Math.sin(a) + dy * Math.cos(a),
|
||||
rotation: props.rotation + angle,
|
||||
}
|
||||
}
|
||||
+191
-112
@@ -1,6 +1,17 @@
|
||||
import type { AnimatableProps, Asset, ButtonAsset, ButtonState, Layer, Scene, SvgAsset } from '@/types/scene'
|
||||
import { BUTTON_STATE_TIMES } from '@/types/scene'
|
||||
import { isButtonLayerActiveAt, isLayerActiveAt, sampleLayer } from './interpolate'
|
||||
import { fontCssFamily } from './fonts'
|
||||
import {
|
||||
clipArmLocalTime,
|
||||
clipArmTranslate,
|
||||
clipKaleidoPivot,
|
||||
kaleidoAngleDegrees,
|
||||
kaleidoCountFor,
|
||||
layerArmTransform,
|
||||
layerKaleidoCount,
|
||||
shouldRotateClipCopies,
|
||||
} from './kaleido'
|
||||
|
||||
export async function loadSceneImages(scene: Scene): Promise<Record<string, HTMLImageElement>> {
|
||||
const result: Record<string, HTMLImageElement> = {}
|
||||
@@ -149,11 +160,16 @@ function drawLayers(
|
||||
if (!asset) continue
|
||||
const p = sampleLayer(layer, time)
|
||||
|
||||
ctx.save()
|
||||
ctx.globalAlpha *= p.opacity
|
||||
ctx.translate(p.x, p.y)
|
||||
ctx.rotate((p.rotation * Math.PI) / 180)
|
||||
ctx.scale(p.scaleX, p.scaleY)
|
||||
// Kaleido is disabled on button-asset layers (button assets render
|
||||
// their own internal layers and click routing must stay 1:1).
|
||||
const kCount = asset.kind === 'button' ? 1 : layerKaleidoCount(layer)
|
||||
for (let kf = 0; kf < kCount; kf++) {
|
||||
const tf = layerArmTransform(layer, p, kf, kCount)
|
||||
ctx.save()
|
||||
ctx.globalAlpha *= p.opacity
|
||||
ctx.translate(tf.x, tf.y)
|
||||
ctx.rotate((tf.rotation * Math.PI) / 180)
|
||||
ctx.scale(p.scaleX, p.scaleY)
|
||||
|
||||
const shadow = computeShadow(p)
|
||||
applyShadow(ctx, shadow)
|
||||
@@ -202,7 +218,7 @@ function drawLayers(
|
||||
const offX = layer.offsetX ?? 0
|
||||
const offY = layer.offsetY ?? 0
|
||||
const fill = p.fill ?? asset.fill
|
||||
ctx.font = `${asset.fontSize}px sans-serif`
|
||||
ctx.font = `${asset.fontSize}px ${fontCssFamily(asset.fontFamily)}`
|
||||
ctx.textBaseline = 'top'
|
||||
if (fill && fill !== 'transparent') {
|
||||
ctx.fillStyle = fill
|
||||
@@ -260,8 +276,63 @@ function drawLayers(
|
||||
drawMaskedGroup(ctx, asset.layers, assetMap, images, 0, undefined, true)
|
||||
}
|
||||
|
||||
clearShadow(ctx)
|
||||
ctx.restore()
|
||||
clearShadow(ctx)
|
||||
ctx.restore()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Renders one clip instance — including kaleidoscope replication
|
||||
* when configured. Shared by the top-level scene loop and the
|
||||
* in-group child-drawing path so both honour the same kaleido math. */
|
||||
function renderClipInstance(
|
||||
target: CanvasRenderingContext2D,
|
||||
inst: import('@/types/scene').ClipInstance,
|
||||
clip: import('@/types/scene').Clip,
|
||||
time: number,
|
||||
assetMap: Map<string, Asset>,
|
||||
images: Record<string, HTMLImageElement>,
|
||||
) {
|
||||
const trimStart = inst.trimStart ?? 0
|
||||
const trimEnd = inst.trimEnd ?? 0
|
||||
const visible = Math.max(0, clip.duration - trimStart - trimEnd)
|
||||
const offsetIntoBlock = time - inst.startTime
|
||||
const isx = inst.scaleX ?? 1
|
||||
const isy = inst.scaleY ?? 1
|
||||
const irot = inst.rotation ?? 0
|
||||
|
||||
const kCount = kaleidoCountFor(inst)
|
||||
const rotateCopies = shouldRotateClipCopies(inst)
|
||||
const pivot = clipKaleidoPivot(inst)
|
||||
const baseLocal = trimStart + offsetIntoBlock
|
||||
|
||||
// With kaleido + offset, delayed arms can still be valid after the
|
||||
// source's window has ended (they started later, so they finish
|
||||
// later). The early-out for the source's window only applies when
|
||||
// there's no delay-staggered arm to render.
|
||||
const offsetSec = inst.kaleidoOffset ?? 0
|
||||
const maxArmDelay = kCount > 1 && offsetSec !== 0 ? Math.max(0, (kCount - 1) * offsetSec) : 0
|
||||
if (offsetIntoBlock < 0 || offsetIntoBlock > visible + maxArmDelay) return
|
||||
|
||||
for (let kf = 0; kf < kCount; kf++) {
|
||||
const kAngle = kaleidoAngleDegrees(kf, kCount)
|
||||
const localT = clipArmLocalTime(inst, baseLocal, kf, visible)
|
||||
if (localT === null) continue
|
||||
const trans = clipArmTranslate(inst, kf, kCount)
|
||||
|
||||
target.save()
|
||||
if (kCount > 1 && kf > 0 && rotateCopies) {
|
||||
// Rotate the entire local space around the pivot — contents
|
||||
// follow the rotation, giving the natural kaleido look.
|
||||
target.translate(pivot.x, pivot.y)
|
||||
target.rotate((kAngle * Math.PI) / 180)
|
||||
target.translate(-pivot.x, -pivot.y)
|
||||
}
|
||||
target.translate(trans.x, trans.y)
|
||||
if (irot !== 0) target.rotate((irot * Math.PI) / 180)
|
||||
target.scale(isx, isy)
|
||||
drawMaskedGroup(target, clip.layers, assetMap, images, localT)
|
||||
target.restore()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,38 +356,45 @@ function drawMaskShape(
|
||||
if (!isButtonLayerActiveAt(layer, time)) return
|
||||
} else if (!isLayerActiveAt(layer, time)) return
|
||||
const p = sampleLayer(layer, time)
|
||||
ctx.save()
|
||||
ctx.translate(p.x, p.y)
|
||||
ctx.rotate((p.rotation * Math.PI) / 180)
|
||||
ctx.scale(p.scaleX, p.scaleY)
|
||||
ctx.fillStyle = '#000000'
|
||||
if (asset.kind === 'rect') {
|
||||
const offX = layer.offsetX ?? asset.width / 2
|
||||
const offY = layer.offsetY ?? asset.height / 2
|
||||
ctx.fillRect(-offX, -offY, asset.width, asset.height)
|
||||
} else if (asset.kind === 'circle') {
|
||||
const offX = layer.offsetX ?? 0
|
||||
const offY = layer.offsetY ?? 0
|
||||
ctx.beginPath()
|
||||
ctx.arc(-offX, -offY, asset.radius, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
} else if (asset.kind === 'text') {
|
||||
const offX = layer.offsetX ?? 0
|
||||
const offY = layer.offsetY ?? 0
|
||||
ctx.font = `${asset.fontSize}px sans-serif`
|
||||
ctx.textBaseline = 'top'
|
||||
ctx.fillText(asset.text, -offX, -offY)
|
||||
} else {
|
||||
const offX = layer.offsetX ?? 0
|
||||
const offY = layer.offsetY ?? 0
|
||||
const pts = p.points ?? asset.points
|
||||
const closed = asset.closed !== false
|
||||
if (closed && pts.length >= 3) {
|
||||
tracePolygonPath(ctx, pts, asset.points, offX, offY, true)
|
||||
// Mask layers honour kaleido — every radial copy contributes to the
|
||||
// composite mask buffer, so the cumulative mask is the union of the
|
||||
// N rotated shapes.
|
||||
const kCount = layerKaleidoCount(layer)
|
||||
for (let kf = 0; kf < kCount; kf++) {
|
||||
const tf = layerArmTransform(layer, p, kf, kCount)
|
||||
ctx.save()
|
||||
ctx.translate(tf.x, tf.y)
|
||||
ctx.rotate((tf.rotation * Math.PI) / 180)
|
||||
ctx.scale(p.scaleX, p.scaleY)
|
||||
ctx.fillStyle = '#000000'
|
||||
if (asset.kind === 'rect') {
|
||||
const offX = layer.offsetX ?? asset.width / 2
|
||||
const offY = layer.offsetY ?? asset.height / 2
|
||||
ctx.fillRect(-offX, -offY, asset.width, asset.height)
|
||||
} else if (asset.kind === 'circle') {
|
||||
const offX = layer.offsetX ?? 0
|
||||
const offY = layer.offsetY ?? 0
|
||||
ctx.beginPath()
|
||||
ctx.arc(-offX, -offY, asset.radius, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
} else if (asset.kind === 'text') {
|
||||
const offX = layer.offsetX ?? 0
|
||||
const offY = layer.offsetY ?? 0
|
||||
ctx.font = `${asset.fontSize}px ${fontCssFamily(asset.fontFamily)}`
|
||||
ctx.textBaseline = 'top'
|
||||
ctx.fillText(asset.text, -offX, -offY)
|
||||
} else {
|
||||
const offX = layer.offsetX ?? 0
|
||||
const offY = layer.offsetY ?? 0
|
||||
const pts = p.points ?? asset.points
|
||||
const closed = asset.closed !== false
|
||||
if (closed && pts.length >= 3) {
|
||||
tracePolygonPath(ctx, pts, asset.points, offX, offY, true)
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
/** Render the contents of a scene-level TrackGroup. Unlike
|
||||
@@ -343,22 +421,27 @@ function drawTrackGroup(
|
||||
images: Record<string, HTMLImageElement>,
|
||||
buttonStates?: Map<string, ButtonState>,
|
||||
): void {
|
||||
// Partition into masks vs. renderable ids (layer ids + clip-track
|
||||
// ids). Editor-hidden clip tracks drop out here so the cascade
|
||||
// matches the Konva stage.
|
||||
const masks: Layer[] = []
|
||||
const renderIds: string[] = []
|
||||
// Walk the child list bottom-up so the mask cascade follows
|
||||
// stack order: a mask masks layers below it but not layers
|
||||
// above it. Items are kept as their resolved kind (layer or
|
||||
// clip-track id) plus a flag for masks; the loop below applies
|
||||
// each item to the offscreen buffer in z-order.
|
||||
type Item =
|
||||
| { kind: 'mask'; layer: Layer }
|
||||
| { kind: 'layer'; id: string }
|
||||
| { kind: 'clip'; id: string }
|
||||
const items: Item[] = []
|
||||
for (let k = childIds.length - 1; k >= 0; k--) {
|
||||
const cid = childIds[k]
|
||||
const layer = layerById.get(cid)
|
||||
if (layer) {
|
||||
if (layer.isMask) masks.push(layer)
|
||||
else renderIds.push(cid)
|
||||
if (layer.isMask) items.push({ kind: 'mask', layer })
|
||||
else items.push({ kind: 'layer', id: cid })
|
||||
continue
|
||||
}
|
||||
if (clipTrackById.get(cid)?.editorHidden) continue
|
||||
if (instancesByTrack.has(cid)) {
|
||||
renderIds.push(cid)
|
||||
items.push({ kind: 'clip', id: cid })
|
||||
}
|
||||
// Script / audio tracks don't paint on the canvas — skip silently.
|
||||
}
|
||||
@@ -373,31 +456,15 @@ function drawTrackGroup(
|
||||
for (const inst of instances) {
|
||||
const clip = clipById.get(inst.clipId)
|
||||
if (!clip) continue
|
||||
const trimStart = inst.trimStart ?? 0
|
||||
const trimEnd = inst.trimEnd ?? 0
|
||||
const visible = Math.max(0, clip.duration - trimStart - trimEnd)
|
||||
const offsetIntoBlock = time - inst.startTime
|
||||
if (offsetIntoBlock < 0 || offsetIntoBlock > visible) continue
|
||||
const ix = inst.x ?? 0
|
||||
const iy = inst.y ?? 0
|
||||
const isx = inst.scaleX ?? 1
|
||||
const isy = inst.scaleY ?? 1
|
||||
const irot = inst.rotation ?? 0
|
||||
const needsTransform =
|
||||
ix !== 0 || iy !== 0 || isx !== 1 || isy !== 1 || irot !== 0
|
||||
if (needsTransform) {
|
||||
target.save()
|
||||
target.translate(ix, iy)
|
||||
if (irot !== 0) target.rotate((irot * Math.PI) / 180)
|
||||
target.scale(isx, isy)
|
||||
}
|
||||
drawLayers(target, clip.layers, assetMap, images, trimStart + offsetIntoBlock)
|
||||
if (needsTransform) target.restore()
|
||||
renderClipInstance(target, inst, clip, time, assetMap, images)
|
||||
}
|
||||
}
|
||||
|
||||
if (masks.length === 0) {
|
||||
for (const id of renderIds) drawChild(ctx, id)
|
||||
const hasMask = items.some((it) => it.kind === 'mask')
|
||||
if (!hasMask) {
|
||||
for (const it of items) {
|
||||
if (it.kind === 'layer' || it.kind === 'clip') drawChild(ctx, it.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -406,19 +473,26 @@ function drawTrackGroup(
|
||||
off.height = ctx.canvas.height
|
||||
const offCtx = off.getContext('2d')
|
||||
if (!offCtx) {
|
||||
for (const id of renderIds) drawChild(ctx, id)
|
||||
for (const it of items) {
|
||||
if (it.kind === 'layer' || it.kind === 'clip') drawChild(ctx, it.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
const m = ctx.getTransform()
|
||||
offCtx.setTransform(m.a, m.b, m.c, m.d, m.e, m.f)
|
||||
for (const id of renderIds) drawChild(offCtx, id)
|
||||
for (const mask of masks) {
|
||||
if (!isLayerActiveAt(mask, time)) continue
|
||||
offCtx.save()
|
||||
offCtx.globalCompositeOperation =
|
||||
mask.maskMode === 'negative' ? 'destination-out' : 'destination-in'
|
||||
drawMaskShape(offCtx, mask, assetMap, time)
|
||||
offCtx.restore()
|
||||
// Incremental cascade: render content / apply mask in z-order.
|
||||
for (const it of items) {
|
||||
if (it.kind === 'mask') {
|
||||
const mask = it.layer
|
||||
if (!isLayerActiveAt(mask, time)) continue
|
||||
offCtx.save()
|
||||
offCtx.globalCompositeOperation =
|
||||
mask.maskMode === 'negative' ? 'destination-out' : 'destination-in'
|
||||
drawMaskShape(offCtx, mask, assetMap, time)
|
||||
offCtx.restore()
|
||||
continue
|
||||
}
|
||||
drawChild(offCtx, it.id)
|
||||
}
|
||||
ctx.save()
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0)
|
||||
@@ -435,9 +509,8 @@ function drawMaskedGroup(
|
||||
buttonStates?: Map<string, ButtonState>,
|
||||
buttonMode?: boolean,
|
||||
) {
|
||||
const masks = children.filter((l) => l.isMask)
|
||||
const content = children.filter((l) => !l.isMask)
|
||||
if (masks.length === 0) {
|
||||
const hasMask = children.some((l) => l.isMask)
|
||||
if (!hasMask) {
|
||||
drawLayers(ctx, children, assetMap, images, time, buttonStates, buttonMode)
|
||||
return
|
||||
}
|
||||
@@ -449,20 +522,38 @@ function drawMaskedGroup(
|
||||
off.height = h
|
||||
const offCtx = off.getContext('2d')
|
||||
if (!offCtx) {
|
||||
drawLayers(ctx, content, assetMap, images, time, buttonStates, buttonMode)
|
||||
drawLayers(
|
||||
ctx,
|
||||
children.filter((l) => !l.isMask),
|
||||
assetMap, images, time, buttonStates, buttonMode,
|
||||
)
|
||||
return
|
||||
}
|
||||
const m = ctx.getTransform()
|
||||
offCtx.setTransform(m.a, m.b, m.c, m.d, m.e, m.f)
|
||||
drawLayers(offCtx, content, assetMap, images, time, buttonStates, buttonMode)
|
||||
for (const mask of masks) {
|
||||
|
||||
// Bottom-up traversal so masks only affect what was drawn below
|
||||
// them in the z-order — matches the top-level drawSceneFrame
|
||||
// cascade. A mask placed in the middle of the stack masks just
|
||||
// the layers underneath; layers above the mask render on top
|
||||
// unmasked. Was previously "collect all masks, apply to all
|
||||
// content" which applied a mid-stack mask to layers above it too.
|
||||
for (let i = children.length - 1; i >= 0; i--) {
|
||||
const child = children[i]
|
||||
if (!child.visible) continue
|
||||
if (child.editorHidden) continue
|
||||
if (buttonMode) {
|
||||
if (!isButtonLayerActiveAt(mask, time)) continue
|
||||
} else if (!isLayerActiveAt(mask, time)) continue
|
||||
offCtx.save()
|
||||
offCtx.globalCompositeOperation = mask.maskMode === 'negative' ? 'destination-out' : 'destination-in'
|
||||
drawMaskShape(offCtx, mask, assetMap, time, buttonMode)
|
||||
offCtx.restore()
|
||||
if (!isButtonLayerActiveAt(child, time)) continue
|
||||
} else if (!isLayerActiveAt(child, time)) continue
|
||||
if (child.isMask) {
|
||||
offCtx.save()
|
||||
offCtx.globalCompositeOperation =
|
||||
child.maskMode === 'negative' ? 'destination-out' : 'destination-in'
|
||||
drawMaskShape(offCtx, child, assetMap, time, buttonMode)
|
||||
offCtx.restore()
|
||||
} else {
|
||||
drawLayers(offCtx, [child], assetMap, images, time, buttonStates, buttonMode)
|
||||
}
|
||||
}
|
||||
ctx.save()
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0)
|
||||
@@ -475,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()
|
||||
@@ -492,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)
|
||||
}
|
||||
@@ -508,7 +606,7 @@ export function drawSceneFrame(
|
||||
|
||||
const layerById = new Map<string, Layer>()
|
||||
for (const l of scene.layers) layerById.set(l.id, l)
|
||||
const instancesByTrack = new Map<string, typeof scene.clipInstances>()
|
||||
const instancesByTrack = new Map<string, NonNullable<typeof scene.clipInstances>>()
|
||||
for (const ci of scene.clipInstances ?? []) {
|
||||
const arr = instancesByTrack.get(ci.clipTrackId) ?? []
|
||||
arr.push(ci)
|
||||
@@ -577,26 +675,7 @@ export function drawSceneFrame(
|
||||
for (const inst of instances) {
|
||||
const clip = clipById.get(inst.clipId)
|
||||
if (!clip) continue
|
||||
const trimStart = inst.trimStart ?? 0
|
||||
const trimEnd = inst.trimEnd ?? 0
|
||||
const visible = Math.max(0, clip.duration - trimStart - trimEnd)
|
||||
const offsetIntoBlock = time - inst.startTime
|
||||
if (offsetIntoBlock < 0 || offsetIntoBlock > visible) continue
|
||||
const ix = inst.x ?? 0
|
||||
const iy = inst.y ?? 0
|
||||
const isx = inst.scaleX ?? 1
|
||||
const isy = inst.scaleY ?? 1
|
||||
const irot = inst.rotation ?? 0
|
||||
const needsTransform =
|
||||
ix !== 0 || iy !== 0 || isx !== 1 || isy !== 1 || irot !== 0
|
||||
if (needsTransform) {
|
||||
drawCtx.save()
|
||||
drawCtx.translate(ix, iy)
|
||||
if (irot !== 0) drawCtx.rotate((irot * Math.PI) / 180)
|
||||
drawCtx.scale(isx, isy)
|
||||
}
|
||||
drawLayers(drawCtx, clip.layers, assetMap, images, trimStart + offsetIntoBlock)
|
||||
if (needsTransform) drawCtx.restore()
|
||||
renderClipInstance(drawCtx, inst, clip, time, assetMap, images)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
+4
-1
@@ -119,7 +119,10 @@ function visitSrcs(project: Project, visit: (h: SrcHolder) => string): void {
|
||||
}
|
||||
for (const page of project.scenes) {
|
||||
if (page.backgroundVideo?.src) {
|
||||
const replaced = visit({ src: page.backgroundVideo.src, name: page.backgroundVideo.name })
|
||||
// BackgroundVideo has no `name` field — synthesise one from the
|
||||
// page so the extracted file gets a readable filename in the
|
||||
// zip (e.g. "scene-bg-page1.mp4" instead of a uuid).
|
||||
const replaced = visit({ src: page.backgroundVideo.src, name: `scene-bg-${page.id}` })
|
||||
if (replaced !== page.backgroundVideo.src) page.backgroundVideo.src = replaced
|
||||
}
|
||||
}
|
||||
|
||||
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user