Compare commits

...
2 Commits
Author SHA1 Message Date
th 70cf131c47 Make kaleido pivot keyframable 2026-06-05 14:31:31 +02:00
th c646f66e8f Add Electron desktop shell 2026-06-05 14:11:44 +02:00
18 changed files with 4504 additions and 83 deletions

No files matched your search

+2
View File
@@ -1,7 +1,9 @@
node_modules
dist
dist-electron
dist-ssr
dist-archive
release
*.local
.vite
+228
View File
@@ -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.
+137
View File
@@ -0,0 +1,137 @@
import { app, BrowserWindow, Menu, shell } from 'electron'
import type { BrowserWindow as BrowserWindowInstance, Event, MenuItemConstructorOptions } from 'electron'
import path from 'node:path'
const isDev = !app.isPackaged
const devServerUrl = process.env.VITE_DEV_SERVER_URL ?? 'http://localhost:5200'
let mainWindow: BrowserWindowInstance | null = null
function createMainWindow(): void {
mainWindow = new BrowserWindow({
width: 1280,
height: 860,
minWidth: 1024,
minHeight: 700,
title: 'RUWIO',
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 (url.startsWith('https://') || url.startsWith('http://')) {
void shell.openExternal(url)
}
return { action: 'deny' }
})
mainWindow.webContents.on('will-navigate', (event: Event, url: string) => {
const allowedUrl = isDev ? devServerUrl : `file://${path.join(app.getAppPath(), 'dist', 'index.html')}`
if (!url.startsWith(allowedUrl)) {
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', enabled: false },
{ label: 'Open...', accelerator: 'CmdOrCtrl+O', enabled: false },
{ type: 'separator' },
{ label: 'Save', accelerator: 'CmdOrCtrl+S', enabled: false },
{ label: 'Save As...', accelerator: 'CmdOrCtrl+Shift+S', enabled: false },
{ type: 'separator' },
{ label: 'Export...', accelerator: 'CmdOrCtrl+E', enabled: false },
{ type: 'separator' },
process.platform === 'darwin' ? { role: 'close' } : { role: 'quit' },
],
},
{
label: 'Edit',
submenu: [
{ role: 'undo' },
{ role: 'redo' },
{ type: 'separator' },
{ role: 'cut' },
{ role: 'copy' },
{ role: 'paste' },
{ role: 'selectAll' },
],
},
{
label: 'View',
submenu: [
{ role: 'reload' },
{ role: 'forceReload' },
{ role: 'toggleDevTools' },
{ type: 'separator' },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
{ role: 'zoomOut' },
{ type: 'separator' },
{ role: 'togglefullscreen' },
],
},
{
label: 'Window',
submenu: [
{ role: 'minimize' },
{ role: 'zoom' },
],
},
]
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(() => {
createMenu()
createMainWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createMainWindow()
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})
+10
View File
@@ -0,0 +1,10 @@
import { contextBridge } from 'electron'
contextBridge.exposeInMainWorld('ruwioDesktop', {
platform: process.platform,
versions: {
electron: process.versions.electron,
chrome: process.versions.chrome,
node: process.versions.node,
},
})
+3782 -3
View File
File diff suppressed because it is too large. Load diff
+39 -1
View File
@@ -3,9 +3,14 @@
"private": true,
"version": "1.2.13",
"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 && electron-builder",
"desktop:pack": "npm run build && tsc -p tsconfig.electron.json && electron-builder --dir",
"preview": "vite preview",
"type-check": "vue-tsc --noEmit",
"pack": "bash scripts/pack.sh"
@@ -22,8 +27,41 @@
"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",
"directories": {
"output": "release"
},
"files": [
"dist/**/*",
"dist-electron/**/*",
"package.json"
],
"linux": {
"target": [
"AppImage"
],
"category": "Graphics"
},
"win": {
"target": [
"nsis"
]
},
"mac": {
"target": [
"dmg"
],
"category": "public.app-category.graphics-design"
}
}
}
+33 -26
View File
@@ -561,36 +561,35 @@ function kaleidoIndicesForLayer(layer: Layer): number[] {
}
/** Augments a layer's Konva config with the kaleido per-copy
* position + rotation. Pivot defaults to the layer's own (x, y),
* but a per-layer override moves the orbit centre elsewhere on the
* canvas — same arithmetic as render.ts/exportPlayer use, so all
* three render paths land identical pixels. Non-source copies are
* 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 sx = typeof cfg.x === 'number' ? (cfg.x as number) : 0
const sy = typeof cfg.y === 'number' ? (cfg.y as number) : 0
const srot = typeof cfg.rotation === 'number' ? (cfg.rotation as number) : 0
// Live-pivot drag: when the user drags the on-stage handle for
// this layer, route through liveKaleidoPivot so the orbit follows
// the pointer in real time.
const live = liveKaleidoPivot.value
const pivotX = live && live.instanceId === layer.id ? live.x : (layer.kaleidoPivotX ?? sx)
const pivotY = live && live.instanceId === layer.id ? live.y : (layer.kaleidoPivotY ?? sy)
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 = sx - pivotX
const dy = sy - pivotY
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: srot + angle,
rotation: props.rotation + angle,
listening: false,
draggable: false,
} as T
@@ -1683,8 +1682,14 @@ const kaleidoPivotHandle = computed<KaleidoPivotEntity | null>(() => {
if (asset?.kind === 'button') return null
const sampled = sampleLayer(layer, store.playhead)
const live = liveKaleidoPivot.value
const x = live && live.instanceId === lyId ? live.x : (layer.kaleidoPivotX ?? sampled.x)
const y = live && live.instanceId === lyId ? live.y : (layer.kaleidoPivotY ?? sampled.y)
// 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
@@ -1752,9 +1757,11 @@ function onKaleidoPivotPointerUp() {
kaleidoPivotY: liveKaleidoPivot.value.y,
})
} else {
store.setLayerKaleidoPivot(pivotDragEntity.layerId, {
x: liveKaleidoPivot.value.x,
y: liveKaleidoPivot.value.y,
// 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,
})
}
}
@@ -4496,7 +4503,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
>
<v-group
v-if="entry.item.asset.kind === 'svg' && svgAssetFor(entry.item.asset)"
:config="withK(buttonLayerGroupConfig(entry.item.layer, entry.item.props), entry.item.layer, ki, kaleidoCountFor(entry.item.layer))"
: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"
@@ -4581,7 +4588,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
</v-group>
<v-rect
v-else-if="entry.item.asset.kind === 'rect'"
:config="withK(rectConfig(entry.item.layer, entry.item.asset, entry.item.props), entry.item.layer, ki, kaleidoCountFor(entry.item.layer))"
: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"
@@ -4589,7 +4596,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
/>
<v-circle
v-else-if="entry.item.asset.kind === 'circle'"
:config="withK(circleConfig(entry.item.layer, entry.item.asset, entry.item.props), entry.item.layer, ki, kaleidoCountFor(entry.item.layer))"
: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"
@@ -4597,7 +4604,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
/>
<v-text
v-else-if="entry.item.asset.kind === 'text'"
:config="withK(textConfig(entry.item.layer, entry.item.asset, entry.item.props), entry.item.layer, ki, kaleidoCountFor(entry.item.layer))"
: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"
@@ -4605,7 +4612,7 @@ function onResizeHandlePointerUp(e: PointerEvent) {
/>
<v-line
v-else-if="entry.item.asset.kind === 'polygon'"
:config="withK(polygonConfig(entry.item.layer, entry.item.asset, entry.item.props), entry.item.layer, ki, kaleidoCountFor(entry.item.layer))"
: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"
@@ -4613,7 +4620,7 @@ 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="withK(imageConfig(entry.item.layer, entry.item.asset, entry.item.props)!, entry.item.layer, ki, kaleidoCountFor(entry.item.layer))"
: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"
+43 -5
View File
@@ -164,12 +164,15 @@ function isActive(id: string): boolean {
<div v-if="customFonts.length > 0" class="font-popover-section">
<div class="section-title">Your fonts</div>
<button
<div
v-for="f in customFonts"
:key="f.id"
type="button"
class="font-option"
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)"
@@ -180,6 +183,7 @@ function isActive(id: string): boolean {
: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"
@@ -188,7 +192,7 @@ function isActive(id: string): boolean {
>
<PhTrash :size="12" />
</button>
</button>
</div>
</div>
<div class="font-popover-divider"></div>
@@ -327,6 +331,38 @@ function isActive(id: string): boolean {
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;
}
@@ -352,10 +388,12 @@ function isActive(id: string): boolean {
border-radius: var(--radius-sm);
color: var(--text-dim);
cursor: pointer;
margin-right: 4px;
opacity: 0;
}
.font-popover .font-option:hover .delete-custom {
.font-popover .custom-font-row:hover .delete-custom,
.font-popover .delete-custom:focus-visible {
opacity: 1;
}
+17 -3
View File
@@ -225,17 +225,25 @@ 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(sampled.value?.x ?? 0)
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(sampled.value?.y ?? 0)
return Math.round(s?.y ?? 0)
})
function setKaleidoOn(on: boolean) {
@@ -243,9 +251,15 @@ function setKaleidoOn(on: boolean) {
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
store.setLayerKaleidoPivot(selected.value.id, patch)
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)
+111 -12
View File
@@ -15554,7 +15554,7 @@ export function registerAllTests(): void {
expect(src).toContain('clipKaleidoPivot')
expect(src).toContain('clipArmLocalT')
// Runtime version got bumped during the kaleido + wiggle work.
expect(src).toContain("'33'")
expect(src).toContain("'34'")
})
test('exported runtime layerActiveAt is unaffected by kaleido fields', async () => {
@@ -15932,7 +15932,7 @@ export function registerAllTests(): void {
expect(src).toContain('function wiggleSeeds(')
// Runtime version got bumped through the wiggle work and the
// later layer-kaleido-pivot landing.
expect(src).toContain("'33'")
expect(src).toContain("'34'")
})
})
@@ -16207,24 +16207,39 @@ export function registerAllTests(): void {
suite('layer kaleido pivot — separate orbit centre', () => {
test('layerArmTransform: defaults to sampled position, custom pivot rotates around override', async () => {
const { layerArmTransform } = await import('@/lib/kaleido')
const baseProps = (x: number, y: number, rot = 0) => ({
x, y, rotation: rot, scaleX: 1, scaleY: 1, opacity: 1,
}) as unknown as import('@/types/scene').AnimatableProps
// Layer at (100, 100), no custom pivot → arm 1 of 4 rotates
// 90° around (100, 100): same result as no-pivot baseline.
const layer = { id: 'L', kaleido: true, kaleidoCount: 4 } as unknown as import('@/types/scene').Layer
const a = layerArmTransform(layer, 100, 100, 0, 1, 4)
const a = layerArmTransform(layer, baseProps(100, 100), 1, 4)
// dx=dy=0 → arm 1 stays at pivot.
expect(a.x).toBeCloseTo(100, 5)
expect(a.y).toBeCloseTo(100, 5)
expect(a.rotation).toBeCloseTo(90, 5)
// Custom pivot at (50, 50) shifts the orbit centre. Layer at
// (100, 100), arm 1 of 4 (90°) around (50, 50):
// dx=50, dy=50 → rotated by 90° → dx'=-50, dy'=50 → world (0, 100)
// Static legacy override at (50, 50): same rotation, layer at
// (100, 100). Arm 1 (90°): dx=50, dy=50 → after R(90°): (-50, 50)
// → world (0, 100).
const layerWithPivot = {
id: 'L', kaleido: true, kaleidoCount: 4, kaleidoPivotX: 50, kaleidoPivotY: 50,
} as unknown as import('@/types/scene').Layer
const b = layerArmTransform(layerWithPivot, 100, 100, 0, 1, 4)
const b = layerArmTransform(layerWithPivot, baseProps(100, 100), 1, 4)
expect(b.x).toBeCloseTo(0, 5)
expect(b.y).toBeCloseTo(100, 5)
expect(b.rotation).toBeCloseTo(90, 5)
// Keyframed pivot via props takes precedence over the legacy
// layer-level override. Pivot via props (200, 200), layer
// override (50, 50) — props wins. dx=−100, dy=−100 → R(90°): (100, −100)
// → world (300, 100).
const c = layerArmTransform(
layerWithPivot,
{ ...baseProps(100, 100), kaleidoPivotX: 200, kaleidoPivotY: 200 } as import('@/types/scene').AnimatableProps,
1,
4,
)
expect(c.x).toBeCloseTo(300, 5)
expect(c.y).toBeCloseTo(100, 5)
})
test('store.setLayerKaleidoPivot: patch + null clear', () => {
@@ -16322,14 +16337,20 @@ export function registerAllTests(): void {
expect(pivotInputs.length).toBe(2)
expect(Number(pivotInputs[0].value)).toBe(50)
expect(Number(pivotInputs[1].value)).toBe(50)
// Type a new pivot X via @input.
// Type a new pivot X via @input — pivot writes go through the
// keyframe pipeline now (same path as Transform X/Y).
pivotInputs[0].value = '120'
pivotInputs[0].dispatchEvent(new Event('input'))
await nextTick()
const stored = s.scene.layers.find((l) => l.id === layer.id)!
expect(stored.kaleidoPivotX).toBe(120)
// Y stays at the sampled default until the user touches it.
expect(stored.kaleidoPivotY === undefined).toBe(true)
// Find the upserted keyframe at the playhead.
const kf = stored.keyframes.find((k) => Math.abs(k.time - s.playhead) < 1e-3)
expect(!!kf).toBe(true)
expect(kf!.props.kaleidoPivotX).toBe(120)
// Y stays unset on the kf since the user only touched X.
expect(kf!.props.kaleidoPivotY === undefined).toBe(true)
// The legacy top-level field is untouched (still undefined).
expect(stored.kaleidoPivotX === undefined).toBe(true)
} finally {
handle.unmount()
}
@@ -16361,11 +16382,89 @@ export function registerAllTests(): void {
}
})
test('layer kaleido pivot interpolates between keyframes', async () => {
// Keyframe pivot at (0, 0) at t=0 and (200, 200) at t=1.
// At t=0.5 the sampled pivot should land at (100, 100). Verify
// by checking arm 1 (90° of 4) of a layer at world (50, 50):
// dx=50-100=-50, dy=50-100=-50 → R(90°): dx'=50, dy'=-50
// → world (150, 50).
const { sampleLayer } = await import('@/lib/interpolate')
const { layerArmTransform } = await import('@/lib/kaleido')
const layer = {
id: 'L',
name: 'L',
assetId: 'a',
visible: true,
base: { x: 50, y: 50, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
keyframes: [
{
id: 'k1', time: 0, easing: 'linear',
props: { x: 50, y: 50, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1, kaleidoPivotX: 0, kaleidoPivotY: 0 },
},
{
id: 'k2', time: 1, easing: 'linear',
props: { x: 50, y: 50, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1, kaleidoPivotX: 200, kaleidoPivotY: 200 },
},
],
kaleido: true,
kaleidoCount: 4,
} as unknown as import('@/types/scene').Layer
const props = sampleLayer(layer, 0.5)
expect(props.kaleidoPivotX).toBeCloseTo(100, 5)
expect(props.kaleidoPivotY).toBeCloseTo(100, 5)
const tf = layerArmTransform(layer, props, 1, 4)
expect(tf.x).toBeCloseTo(150, 5)
expect(tf.y).toBeCloseTo(50, 5)
})
test('layer kaleido pivot: one-sided override lerps from layer position, not snap', async () => {
// Regression: when only ONE of two adjacent keyframes sets
// kaleidoPivotX/Y, the older lerp logic snapped the entire
// interval to the defined value (because optional props
// default to "carry through"). Pivot should instead lerp from
// the layer's own (x, y) on the unset side to the explicit
// value on the other — so adding a single late kf produces a
// smooth orbit-centre transition, not an instant jump.
const { sampleLayer } = await import('@/lib/interpolate')
const layer = {
id: 'L',
name: 'L',
assetId: 'a',
visible: true,
base: { x: 50, y: 50, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
keyframes: [
{
id: 'k1', time: 0, easing: 'linear',
// No kaleidoPivotX/Y — layer's own (50, 50) is the implicit start.
props: { x: 50, y: 50, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
},
{
id: 'k2', time: 1, easing: 'linear',
props: { x: 50, y: 50, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1, kaleidoPivotX: 250, kaleidoPivotY: 250 },
},
],
kaleido: true,
kaleidoCount: 4,
} as unknown as import('@/types/scene').Layer
// At t=0 → pivot starts at the layer's own position (50, 50).
const start = sampleLayer(layer, 0)
expect(start.kaleidoPivotX).toBeCloseTo(50, 5)
expect(start.kaleidoPivotY).toBeCloseTo(50, 5)
// At t=0.5 → halfway between (50, 50) and (250, 250) = (150, 150).
const mid = sampleLayer(layer, 0.5)
expect(mid.kaleidoPivotX).toBeCloseTo(150, 5)
expect(mid.kaleidoPivotY).toBeCloseTo(150, 5)
// At t=1 → fully at the explicit kf2 value.
const end = sampleLayer(layer, 1)
expect(end.kaleidoPivotX).toBeCloseTo(250, 5)
expect(end.kaleidoPivotY).toBeCloseTo(250, 5)
})
test('exported runtime engine carries layerArmTf helper', () => {
const src = generateRuntimeScript()
expect(src).toContain('function layerArmTf(')
// Bumped on this work.
expect(src).toContain("'33'")
expect(src).toContain("'34'")
})
})
}
+27 -12
View File
@@ -11,7 +11,7 @@ function isProject(p: ExportPayload): p is Project {
return Array.isArray((p as Partial<Project>).scenes)
}
const RUNTIME_VERSION = '33'
const RUNTIME_VERSION = '34'
/** Materialize the engine source. The font map is rebuilt on every
* call so user-uploaded custom fonts (which live in localStorage
@@ -107,6 +107,17 @@ const ENGINE_TEMPLATE = `(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'.
@@ -736,20 +747,24 @@ const ENGINE_TEMPLATE = `(function(){
return n<2?1:n;
}
function kalAngle(i,n){ return n<=1?0:(i*360)/n; }
// Mirror of layerArmTransform in src/lib/kaleido.ts. Defaults the
// pivot to the layer's sampled (x, y); a per-layer override can
// shift it elsewhere on the canvas.
function layerArmTf(layer, sx, sy, srot, i, n){
if(i===0||n<=1) return {x:sx,y:sy,rotation:srot};
var px=(layer.kaleidoPivotX==null?sx:layer.kaleidoPivotX);
var py=(layer.kaleidoPivotY==null?sy:layer.kaleidoPivotY);
// 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=sx-px, dy=sy-py;
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: srot+ang
rotation: props.rotation+ang
};
}
function clipKaleidoPivot(inst){
@@ -833,7 +848,7 @@ const ENGINE_TEMPLATE = `(function(){
var p=sample(layer,time);
var kn=kalCount(layer);
for(var ki=0;ki<kn;ki++){
var tfm=layerArmTf(layer, p.x, p.y, p.rotation, ki, kn);
var tfm=layerArmTf(layer, p, ki, kn);
ctxm.save();
ctxm.translate(tfm.x,tfm.y);
ctxm.rotate(tfm.rotation*Math.PI/180);
@@ -932,7 +947,7 @@ const ENGINE_TEMPLATE = `(function(){
// 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.x, p.y, p.rotation, ki, kn);
var tfL=layerArmTf(layer, p, ki, kn);
ctx.save();
ctx.globalAlpha=ctx.globalAlpha*p.opacity;
ctx.translate(tfL.x,tfL.y);
+16
View File
@@ -69,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]
+21 -18
View File
@@ -4,7 +4,7 @@
// (lib/exportPlayer.ts engine source — by mirroring the formulas in
// JS) all derive identical geometry from the same definitions.
import type { Layer, ClipInstance } from '@/types/scene'
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
@@ -107,42 +107,45 @@ export function layerKaleidoCount(layer: Layer): number {
return kaleidoCountFor(layer)
}
/** Effective pivot for a layer. Defaults to the layer's sampled
* position; an explicit `kaleidoPivotX/Y` overrides per-axis. */
/** 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,
sampledX: number,
sampledY: number,
props: AnimatableProps,
): { x: number; y: number } {
return {
x: layer.kaleidoPivotX ?? sampledX,
y: layer.kaleidoPivotY ?? sampledY,
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. */
* 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,
sampledX: number,
sampledY: number,
sampledRotation: number,
props: AnimatableProps,
index: number,
count: number,
): { x: number; y: number; rotation: number } {
if (index === 0 || count <= 1) {
return { x: sampledX, y: sampledY, rotation: sampledRotation }
return { x: props.x, y: props.y, rotation: props.rotation }
}
const { x: px, y: py } = layerKaleidoPivot(layer, sampledX, sampledY)
const { x: px, y: py } = layerKaleidoPivot(layer, props)
const angle = kaleidoAngleDegrees(index, count)
const a = (angle * Math.PI) / 180
const dx = sampledX - px
const dy = sampledY - py
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: sampledRotation + angle,
rotation: props.rotation + angle,
}
}
+2 -2
View File
@@ -164,7 +164,7 @@ function drawLayers(
// 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.x, p.y, p.rotation, kf, kCount)
const tf = layerArmTransform(layer, p, kf, kCount)
ctx.save()
ctx.globalAlpha *= p.opacity
ctx.translate(tf.x, tf.y)
@@ -361,7 +361,7 @@ function drawMaskShape(
// N rotated shapes.
const kCount = layerKaleidoCount(layer)
for (let kf = 0; kf < kCount; kf++) {
const tf = layerArmTransform(layer, p.x, p.y, p.rotation, kf, kCount)
const tf = layerArmTransform(layer, p, kf, kCount)
ctx.save()
ctx.translate(tf.x, tf.y)
ctx.rotate((tf.rotation * Math.PI) / 180)
+1 -1
View File
@@ -22,7 +22,7 @@ app.mount('#app')
// In Chrome/Edge, an installed PWA runs in standalone display mode,
// which removes the top-edge "exit fullscreen" indicator that tabs
// show when the Fullscreen API is active.
if ('serviceWorker' in navigator) {
if ('serviceWorker' in navigator && !window.ruwioDesktop) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {
/* registration failed — app still works without PWA install */
+14
View File
@@ -0,0 +1,14 @@
export {}
declare global {
interface Window {
ruwioDesktop?: {
platform: 'aix' | 'android' | 'darwin' | 'freebsd' | 'haiku' | 'linux' | 'openbsd' | 'sunos' | 'win32' | 'cygwin' | 'netbsd'
versions: {
electron?: string
chrome?: string
node?: string
}
}
}
}
+6
View File
@@ -29,6 +29,12 @@ export interface AnimatableProps {
points?: Point[]
stroke?: string
strokeWidth?: number
/** Kaleidoscope pivot — keyframable like x/y. When unset on every
* reachable kf, falls back to the layer's static
* `Layer.kaleidoPivotX/Y` (legacy field), then to (x, y). Lets the
* pivot itself dance through the scene. */
kaleidoPivotX?: number
kaleidoPivotY?: number
}
export interface Keyframe {
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node", "electron"],
"outDir": "dist-electron",
"rootDir": "electron",
"sourceMap": false
},
"include": ["electron/**/*.cts"]
}