Compare commits

...
13 Commits
Author SHA1 Message Date
th 7093e1f6d2 Update fullscreen preview and camera output 2026-06-08 18:15:27 +02:00
th 9b314ad817 Unify switch controls 2026-06-08 10:57:00 +02:00
th b9a4b53109 Add camera resize and keyframe controls 2026-06-08 00:33:57 +02:00
th 3a93d6ea5d Add camera frontend tests 2026-06-07 23:58:05 +02:00
th 23a66c6fdf Add output camera framing 2026-06-07 23:12:46 +02:00
th 616ced17ce Enable frontend tests in Electron 2026-06-07 15:58:18 +02:00
th b18e66c28d Add export environment plan 2026-06-07 13:24:34 +02:00
th f469ab7444 Clarify export runtime copy 2026-06-07 12:10:35 +02:00
th 8d296c963e Update development doc references 2026-06-07 11:10:05 +02:00
th 00ee563311 Add Electron E2E harness launcher 2026-06-07 11:09:39 +02:00
th fa8c52b239 Split renderer bundles by feature 2026-06-07 11:08:41 +02:00
th 7bf18636d6 Fix desktop packaging artifact build 2026-06-07 11:08:15 +02:00
th ba58ebb48d Bump version to 1.3.1 2026-06-07 11:06:50 +02:00
45 changed files with 3399 additions and 395 deletions

No files matched your search

+6 -4
View File
@@ -60,13 +60,15 @@ same Vite-built app from `dist/` plus the compiled Electron entry points in
```bash
npm install
npm run desktop:dev # start Vite, then launch Electron against localhost
npm run desktop:pack # build an unpacked desktop app into release/
npm run desktop:dir # build an unpacked desktop app into release/
npm run desktop:pack # build platform package artifacts into release/
npm run desktop:build # build platform installer/package artifacts into release/
```
`desktop:pack` is the quickest compile check because it produces an unpacked
app without creating an installer. `desktop:build` uses `electron-builder` and
targets AppImage on Linux, NSIS on Windows, and DMG on macOS.
`desktop:dir` is the quickest compile check because it produces an unpacked app
without creating an installer. `desktop:pack` and `desktop:build` use
`electron-builder` and target AppImage on Linux, NSIS on Windows, and DMG on
macOS.
Desktop MP4 export runs inside Electron and uses ffmpeg from
`resources/bin/<platform>/` in packaged builds. The build scripts run
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

+2 -2
View File
@@ -2,7 +2,7 @@
Date: 2026-06-05
Source review: `dev_docs/security-review.md`
Source review: `dev_docs/02_security-review.md`
## Goal
@@ -19,7 +19,7 @@ Close the security issues from the review without changing RUWIO's core authorin
Files likely touched:
- `dev_docs/security-review.md`
- `dev_docs/02_security-review.md`
- `package.json`
- `server/package.json`
- `server/server.js`
@@ -2,7 +2,7 @@
Date: 2026-06-05
This document supersedes `electron-rendering-integration-plan.md` for ongoing
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.
+3 -3
View File
@@ -2,7 +2,7 @@
Date: 2026-06-06
This document is a follow-on to `electron-rendering-integration-plan_2.md`.
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
@@ -351,6 +351,6 @@ and/or a desktop check asserting a clean result instead of an ffmpeg crash.
`*://*/*` network block (plan_2 #5) does not drop them — no action needed.
## Suggested doc bookkeeping
When these land, update `electron-rendering-integration-plan_2.md`:
When these land, update `05_electron-rendering-integration-plan_2.md`:
- correct the "Partial output file is removed on failure/cancel" note (#7),
- and `electron-rendering-manual-checks.md` with the new verify scripts.
- and `06_electron-rendering-manual-checks.md` with the new verify scripts.
@@ -12,7 +12,7 @@ It builds on what already exists — do not re-invent these:
- the Node/Electron **render harnesses** (`scripts/verify-*.cjs`),
- the **server** tests (`server/{server.smoke,render.e2e}.test.cjs`),
- the **shared validation** unit tests (`shared/renderValidation.test.cjs`),
- the manual checklist (`electron-rendering-manual-checks.md`).
- 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
@@ -321,7 +321,7 @@ Phased so each phase is independently valuable and CI-wireable.
- A root `test` script aggregating the CI-safe layers, and a first
`.github/workflows/test.yml` (matrix: lint/build, node `test:*`,
`test:render-*` with ffmpeg/Chromium, Playwright browser + electron jobs).
- Update `electron-rendering-manual-checks.md` to point at the new automated checks
- 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)
@@ -361,6 +361,6 @@ Phased so each phase is independently valuable and CI-wireable.
run under both hosts.
- Driving real native OS menus / native save dialogs in automation (brittle); we use
the IPC menu-fire hook and the dialog stub instead, and keep one **manual** pass in
`electron-rendering-manual-checks.md` for true native behaviour and macOS roles (D10).
`06_electron-rendering-manual-checks.md` for true native behaviour and macOS roles (D10).
- The offscreen-rendering rewrite (deferred per the fix-plan); if it lands later,
the §4 capture matrix is exactly the harness that re-validates it.
+293
View File
@@ -0,0 +1,293 @@
# Export Environment Copy Plan
Date: 2026-06-07
## Goal
Make the export dialog copy describe what actually happens in each runtime:
- **Browser/PWA:** MP4 uses the render server; WebM records through browser
`MediaRecorder`.
- **Electron desktop:** MP4 uses the desktop render bridge and bundled ffmpeg;
WebM still records through the Chromium `MediaRecorder` runtime inside
Electron.
The current labels are accurate for the browser build, but misleading inside
Electron:
- `SERVER - FRAME-ACCURATE` on MP4, even when Electron is rendering locally.
- `BROWSER - REALTIME` on WebM, even when the app is running as Electron.
## Current Code
Primary files:
- `src/components/ExportDialog.vue`
- `src/i18n/messages.ts`
- `src/lib/desktopRenderExport.ts`
- `src/types/desktop.d.ts`
Runtime detection already exists as an exported helper in
`desktopRenderExport.ts`:
```ts
export function hasDesktopRender(): boolean {
return !!window.ruwioDesktop?.renderMp4
}
```
This is the right signal for MP4 copy because `ExportDialog.vue` routes MP4
through `renderMp4OnDesktop()` when `hasDesktopRender()` returns true, otherwise
it calls `downloadFromServer()`. The MP4 copy should reuse this exact function
rather than reimplementing the predicate, so the text can never drift from the
route (see Implementation Plan step 1).
`window.ruwioDesktop` is injected by the Electron preload before any app module
loads and never changes during a session, so a runtime check is stable at
module-init time — no reactivity is required to read it correctly.
## Proposed Copy Matrix
### MP4
Browser/PWA:
- Badge: `Server · Frame-by-frame`
- Description: `H.264 export via render server`
- Progress: `Rendering MP4 with render server...`
Electron:
- Badge: `Desktop · Frame-by-frame`
- Description: `H.264 export via bundled ffmpeg`
- Progress: `Rendering MP4 in desktop app...`
Rationale: both MP4 paths render deterministic frames through `server/render.html`.
The key difference is where rendering happens: HTTP render server vs Electron
desktop worker plus ffmpeg.
Avoid `Frame-accurate` in the short badge. It sounds like a stronger quality
claim than the dialog needs, and it obscures the real user-facing distinction:
server vs desktop. The help text can explain frame-by-frame rendering if needed.
Badge style: store badges in title case with a `·` middot separator, matching
the existing sibling badges (`gifBadge: 'Browser · Frame-by-frame'`,
`apngBadge: 'Browser · Lossless'`). The `.badge` CSS already applies
`text-transform: uppercase`, so do **not** write the strings in all-caps or
switch to a `-` hyphen separator — that would render MP4/WebM inconsistently
next to GIF/APNG in the same column.
### WebM
Browser/PWA:
- Badge: `Browser · Realtime`
- Description: `Recorded in real time with MediaRecorder`
- Progress: `Rendering WebM in browser realtime...`
Electron:
- Badge: `Chromium · Realtime`
- Description: `Recorded in real time by Electron's Chromium runtime`
- Progress: `Rendering WebM in Chromium realtime...`
Rationale: the implementation does not become a desktop ffmpeg pipeline in
Electron. It still uses `MediaRecorder` from the renderer process. Calling that
`browser` in the desktop app is imprecise, but `Chromium` is technically true
and short enough for the badge.
## Implementation Plan
1. Add environment booleans in `ExportDialog.vue`.
```ts
import { hasDesktopRender } from '@/lib/desktopRenderExport'
const isDesktop = computed(() => !!window.ruwioDesktop)
const hasDesktopMp4 = computed(() => hasDesktopRender())
```
Use `hasDesktopMp4` for MP4 text. It wraps `hasDesktopRender()` — the *same*
function the MP4 route already calls in `exportMp4Server()` — so the copy
decision and the actual route share one predicate and can never diverge. Do
not reimplement `!!window.ruwioDesktop?.renderMp4` inline.
Use `isDesktop` for WebM text because WebM is still available through renderer
Chromium even if MP4 desktop rendering is unavailable; there is no existing
helper for that distinction, so a local boolean is fine.
2. Rename `exportMp4Server()` to `exportMp4()`.
The current name is now misleading because the function already branches:
- Desktop: `renderMp4OnDesktop(scene, ...)`
- Browser/server: `downloadFromServer(scene, ...)`
3. Replace static MP4 bindings.
Current:
```vue
{{ t('export.mp4Badge') }}
{{ t('export.mp4Desc') }}
```
Proposed:
```vue
{{ t(hasDesktopMp4 ? 'export.mp4DesktopBadge' : 'export.mp4ServerBadge') }}
{{ t(hasDesktopMp4 ? 'export.mp4DesktopDesc' : 'export.mp4ServerDesc') }}
```
4. Replace static WebM bindings.
Current:
```vue
{{ t('export.webmBadge') }}
{{ videoSupported ? t('export.webmDesc') : t('export.webmNotSupported') }}
```
Proposed:
```vue
{{ t(isDesktop ? 'export.webmDesktopBadge' : 'export.webmBrowserBadge') }}
{{
videoSupported
? t(isDesktop ? 'export.webmDesktopDesc' : 'export.webmBrowserDesc')
: t('export.webmNotSupported')
}}
```
5. Split progress labels.
MP4 currently uses:
```ts
label: t('export.labelMp4')
```
Proposed:
```ts
label: t(hasDesktopMp4.value ? 'export.labelMp4Desktop' : 'export.labelMp4Server')
```
WebM currently uses:
```ts
label: t('export.labelWebm')
```
Proposed:
```ts
label: t(isDesktop.value ? 'export.labelWebmDesktop' : 'export.labelWebmBrowser')
```
6. Update `src/i18n/messages.ts` in both locales.
English keys to add:
```ts
mp4ServerBadge: 'Server · Frame-by-frame',
mp4ServerDesc: 'H.264 export via render server',
mp4DesktopBadge: 'Desktop · Frame-by-frame',
mp4DesktopDesc: 'H.264 export via bundled ffmpeg',
webmBrowserBadge: 'Browser · Realtime',
webmBrowserDesc: 'Recorded in real time with MediaRecorder',
webmDesktopBadge: 'Chromium · Realtime',
webmDesktopDesc: "Recorded in real time by Electron's Chromium runtime",
labelMp4Server: 'Rendering MP4 with render server...',
labelMp4Desktop: 'Rendering MP4 in desktop app...',
labelWebmBrowser: 'Rendering WebM in browser realtime...',
labelWebmDesktop: 'Rendering WebM in Chromium realtime...',
```
Badges stay title case with a `·` separator; the `.badge` CSS uppercases
them at render time (see the MP4 copy-matrix note above). German keys should
mirror the meaning rather than preserve the old wording.
7. Fix the export-button help string `exportHelp` in `messages.ts`.
This is separate from the help dialog and is easy to miss. Both locales
currently hardcode the misleading claim:
- `messages.ts:43` (en): "...MP4 needs the render server; WebM records in
real time in the browser."
- `messages.ts:1153` (de): "...MP4 braucht den Render-Server. WebM wird
direkt im Browser in Echtzeit aufgenommen."
Add runtime-branched variants (e.g. `exportHelpServer` / `exportHelpDesktop`)
and select with the same `isDesktop` signal wherever `t('...exportHelp')` is
consumed. This is exactly the "globally visible text saying MP4 always needs
the render server" that must not be left in place.
8. Update help-dialog format text in `messages.ts`, and branch it now.
The strings live at `help.content.export.formats.mp4` / `.webm`
(`messages.ts:1081/1083` en, `:1923/1925` de) and `exportHelp` (handled in
step 7). Browser help should describe:
- MP4 as render-server frame-by-frame export.
- WebM as browser `MediaRecorder` realtime recording.
Electron help should describe:
- MP4 as bundled-ffmpeg desktop frame-by-frame export.
- WebM as Chromium `MediaRecorder` realtime recording.
Wiring the branch is cheaper than the old draft assumed. `HelpDialog.vue`
defines `CHAPTERS: Chapter[]` as a module-level `const` with static
`bodyKey` strings, but `window.ruwioDesktop` is set at preload before that
module loads and never changes, so a plain ternary on the `bodyKey` resolves
correctly at module-init time without making `CHAPTERS` reactive:
```ts
{ bodyKey: window.ruwioDesktop?.renderMp4
? 'help.content.export.formats.mp4Desktop'
: 'help.content.export.formats.mp4' },
{ bodyKey: window.ruwioDesktop
? 'help.content.export.formats.webmDesktop'
: 'help.content.export.formats.webm' },
```
Add the `*Desktop` keys in both locales and wire the branch in the same
change. Do not defer this and leave the help dialog asserting MP4 always
needs the render server.
## Verification
Browser/Vite:
- Open the export dialog.
- MP4 badge renders `SERVER · FRAME-BY-FRAME` (stored title case, uppercased by CSS).
- MP4 description mentions the render server.
- WebM badge renders `BROWSER · REALTIME`.
- WebM description mentions `MediaRecorder`.
- Export-button help (`exportHelp`) and help dialog describe MP4 as a
render-server export.
Electron:
- Open the export dialog.
- MP4 badge renders `DESKTOP · FRAME-BY-FRAME`.
- MP4 description mentions bundled ffmpeg.
- WebM badge renders `CHROMIUM · REALTIME`.
- WebM description mentions Electron Chromium.
- Export-button help (`exportHelp`) and help dialog describe MP4 as a
bundled-ffmpeg desktop export, not the render server.
Build checks:
```bash
npm run build
npm run desktop:dir
```
Optional automated coverage:
- Add a small in-app devtools test that stubs/removes `window.ruwioDesktop` and
verifies the selected i18n keys or rendered badge text. `src/devtools/headless.ts`
already reads `!!window.ruwioDesktop` (line 19) and is the natural home for it.
- Add an Electron harness assertion that export dialog MP4 copy is desktop copy.
File diff suppressed because it is too large. Load diff
+24 -13
View File
@@ -10,6 +10,7 @@ const { validateRenderRequest } = require('../shared/renderValidation.cjs') as {
}
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
@@ -43,6 +44,12 @@ function sendAppCommand(command: string): void {
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 },
@@ -101,9 +108,14 @@ async function runRenderMp4(event: IpcMainInvokeEvent, payload: RenderMp4Payload
defaultPath: sanitizeMp4Name(payload?.suggestedName),
filters: [{ name: 'MP4 Video', extensions: ['mp4'] }],
}
const saveResult = window
? await dialog.showSaveDialog(window, saveOptions)
: await dialog.showSaveDialog(saveOptions)
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()
@@ -340,16 +352,12 @@ function createMenu(): void {
label: 'About RUWIO',
click: () => sendAppCommand('about'),
},
...(isDev
? [
{ type: 'separator' as const },
{
label: 'Open Test Runner',
accelerator: 'CmdOrCtrl+Shift+Alt+T',
click: () => sendAppCommand('test-runner'),
},
]
: []),
{ type: 'separator' },
{
label: 'Open Test Runner',
accelerator: 'CmdOrCtrl+Shift+Alt+T',
click: () => sendAppCommand('test-runner'),
},
],
},
]
@@ -379,6 +387,9 @@ 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()
+1 -1
View File
@@ -9,7 +9,7 @@
<meta name="theme-color" content="#17171b" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; 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'"
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>
+5 -4
View File
@@ -1,7 +1,7 @@
{
"name": "ruwio",
"private": true,
"version": "1.3.0",
"version": "1.3.2",
"type": "module",
"main": "dist-electron/main.cjs",
"scripts": {
@@ -10,7 +10,8 @@
"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 --dir",
"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",
@@ -18,7 +19,7 @@
"test:render-server": "node --test server/server.smoke.test.cjs",
"test:render-server:e2e": "node --test server/render.e2e.test.cjs",
"test:render-desktop": "tsc -p tsconfig.electron.json && node scripts/verify-desktop-render.cjs",
"test:capture-fidelity": "tsc -p tsconfig.electron.json && electron scripts/verify-capture-fidelity.cjs",
"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": {
@@ -45,7 +46,7 @@
"appId": "app.ruwio.desktop",
"productName": "RUWIO",
"icon": "icon.png",
"npmRebuild": false,
"beforeBuild": "scripts/electron-before-build.cjs",
"directories": {
"output": "release"
},
+3
View File
@@ -0,0 +1,3 @@
module.exports = async function beforeBuild() {
return false
}
+21
View File
@@ -0,0 +1,21 @@
const { spawnSync } = require('node:child_process')
const electron = require('electron')
const args = process.argv.slice(2)
if (!args.length) {
console.error('usage: node scripts/run-electron-harness.cjs <script> [args...]')
process.exit(2)
}
const env = { ...process.env }
delete env.ELECTRON_RUN_AS_NODE
env.ELECTRON_DISABLE_SANDBOX = '1'
const res = spawnSync(electron, ['--no-sandbox', ...args], {
stdio: 'inherit',
env,
argv0: electron,
})
process.exit(res.status ?? 1)
+1 -1
View File
@@ -1,4 +1,4 @@
// Phase A verification harness (electron-rendering-integration-plan_2.md, finding #1/#2).
// 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
+7 -2
View File
@@ -1,5 +1,5 @@
// Runs the desktop-render verification harnesses (Phase A/B of
// electron-rendering-integration-plan_2.md) under Electron and aggregates the
// 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)
@@ -11,6 +11,10 @@ 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'],
@@ -26,8 +30,9 @@ const harnesses = [
let failures = 0
for (const [name, file, desc] of harnesses) {
process.stdout.write(`\n=== ${name}: ${desc} ===\n`)
const res = spawnSync(electron, [path.join(__dirname, file)], {
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
+59
View File
@@ -102,6 +102,56 @@
}
return merge(layer.base);
}
function defaultCameraProps(scene){
return {
x:0,
y:0,
width:Math.max(1, Number(scene.canvas && scene.canvas.width) || 1280),
height:Math.max(1, Number(scene.canvas && scene.canvas.height) || 720)
};
}
function mergeCameraProps(base, props){
props=props||{};
function finite(v, fallback){ return typeof v==='number' && isFinite(v) ? v : fallback; }
return {
x:finite(props.x, base.x),
y:finite(props.y, base.y),
width:Math.max(1, finite(props.width, base.width)),
height:Math.max(1, finite(props.height, base.height))
};
}
function lerpCameraProps(a,b,t){
return {
x:lerp(a.x,b.x,t),
y:lerp(a.y,b.y,t),
width:Math.max(1, lerp(a.width,b.width,t)),
height:Math.max(1, lerp(a.height,b.height,t))
};
}
function sampleCamera(scene,time){
var camera=scene.camera;
var base=defaultCameraProps(scene);
if(!camera) return base;
base=mergeCameraProps(base, camera.base);
var kfs=(camera.keyframes||[]).slice().sort(function(a,b){return a.time-b.time;});
if(!kfs.length) return base;
if(time<=kfs[0].time) return mergeCameraProps(base,kfs[0].props);
var last=kfs[kfs.length-1];
if(time>=last.time) return mergeCameraProps(base,last.props);
for(var i=0;i<kfs.length-1;i++){
var a=kfs[i], b=kfs[i+1];
if(time>=a.time && time<=b.time){
if(a.hold){
if(time>=b.time) return mergeCameraProps(base,b.props);
return mergeCameraProps(base,a.props);
}
var raw=(time-a.time)/Math.max(1e-9,b.time-a.time);
var tt=easing(b.easing,raw);
return lerpCameraProps(mergeCameraProps(base,a.props), mergeCameraProps(base,b.props), tt);
}
}
return base;
}
function loadImages(assets){
var imgs={};
var list=assets.filter(function(a){return a.kind==='image';});
@@ -267,8 +317,17 @@
function drawFrame(time){
if(!scene) return;
var lw=scene.canvas.width, lh=scene.canvas.height;
var useCamera=!!(scene.camera && scene.camera.enabled);
var cam=useCamera?sampleCamera(scene,time):null;
ctx.setTransform(1,0,0,1,0,0);
ctx.clearRect(0,0,canvas.width,canvas.height);
ctx.save();
ctx.beginPath(); ctx.rect(0,0,lw,lh); ctx.clip();
if(useCamera){
ctx.scale(lw/cam.width, lh/cam.height);
ctx.translate(-cam.x,-cam.y);
ctx.beginPath(); ctx.rect(0,0,lw,lh); ctx.clip();
}
if(!scene.backgroundVideo){
ctx.fillStyle=scene.canvas.background;
ctx.fillRect(0,0,lw,lh);
+33
View File
@@ -71,6 +71,39 @@ function validateRenderRequest(scene, options = {}, limits = DEFAULT_LIMITS) {
}
}
}
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` }
+31
View File
@@ -56,6 +56,37 @@ test('rejects invalid crf and preset', () => {
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 })
+26 -10
View File
@@ -12,6 +12,7 @@ import {
PhFloppyDisk,
PhGearSix,
PhImageSquare,
PhMonitorPlay,
PhPause,
PhPencilSimpleLine,
PhPlay,
@@ -28,7 +29,6 @@ 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'
@@ -46,7 +46,6 @@ import { usePlaybackLoop } from './composables/usePlaybackLoop'
import { useTimeFormat } from './composables/useTimeFormat'
import { useProjectName } from './composables/useProjectName'
import { shouldChromeClickDeselect } from './lib/chromeClick'
import { renderSceneToStillImage } from './lib/stillImageExport'
import { ProjectFileError, pickProjectFile } from './lib/projectFile'
import { readAnyProjectFile } from './lib/ruwFile'
import {
@@ -70,9 +69,12 @@ const welcomeOpen = ref(false)
const fullscreenOpen = ref(false)
const aboutOpen = ref(false)
const helpOpen = ref(false)
const TestOverlay = import.meta.env.DEV
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
@@ -194,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
@@ -313,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
@@ -424,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()
if (import.meta.env.DEV) testsOpen.value = true
if (canUseTestRunner) testsOpen.value = true
return
}
// TAB toggles the side panels (asset sidebar + properties aside)
@@ -506,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()
@@ -593,8 +600,7 @@ async function runDesktopMenuCommand(command: string) {
rewind()
break
case 'fullscreen-preview':
store.pause()
fullscreenOpen.value = true
openFullscreenPreview()
break
case 'toggle-panels':
panelsHidden.value = !panelsHidden.value
@@ -612,7 +618,7 @@ async function runDesktopMenuCommand(command: string) {
aboutOpen.value = true
break
case 'test-runner':
if (import.meta.env.DEV) testsOpen.value = true
if (canUseTestRunner) testsOpen.value = true
break
}
}
@@ -704,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"
@@ -745,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" />
+5 -82
View File
@@ -33,7 +33,7 @@ import {
setClipDragSource,
} from '@/composables/useClipDragSource'
import type { Asset } from '@/types/scene'
import { confirmDialog, promptDialog } from '@/components/ui'
import { confirmDialog, promptDialog, ToggleSwitch } from '@/components/ui'
import { loadSceneImages, renderButtonThumbnail, renderSvgThumbnail } from '@/lib/render'
import { t } from '@/i18n'
@@ -624,21 +624,11 @@ function onAssetDragStart(e: DragEvent, asset: ImageOrSvgAsset) {
>
<div class="assets-header">
<h3>Assets</h3>
<label
class="thumbs-switch"
<ToggleSwitch
v-model="showThumbs"
:label="t('sidebar.thumbsToggle')"
v-tooltip="showThumbs ? t('sidebar.thumbnailsHide') : t('sidebar.thumbnailsShow')"
>
<span class="thumbs-switch-label">{{ t('sidebar.thumbsToggle') }}</span>
<input
type="checkbox"
class="thumbs-switch-input"
:checked="showThumbs"
@change="showThumbs = ($event.target as HTMLInputElement).checked"
/>
<span class="thumbs-switch-track">
<span class="thumbs-switch-knob"></span>
</span>
</label>
/>
</div>
<button class="upload-btn" @click="triggerUpload">
@@ -988,73 +978,6 @@ h4 {
gap: 10px;
}
/* Compact slide switch labelled "Thumbs" — native checkbox stays hidden
* for a11y (keyboard + screen readers), the visible track + knob is
* driven purely by the `:checked` state. */
.thumbs-switch {
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
user-select: none;
font-size: 11px;
color: var(--text-dim);
}
.thumbs-switch-label {
font-weight: 500;
letter-spacing: 0.01em;
}
.thumbs-switch-input {
/* Keep focus behaviour via the native input; the visual sits next to it. */
position: absolute;
opacity: 0;
width: 0;
height: 0;
pointer-events: none;
}
.thumbs-switch-track {
position: relative;
width: 26px;
height: 14px;
border-radius: 999px;
background: var(--border);
transition: background 140ms var(--ease);
flex-shrink: 0;
}
.thumbs-switch-knob {
position: absolute;
top: 2px;
left: 2px;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--panel);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.35);
transition: left 140ms var(--ease), background 140ms var(--ease);
}
.thumbs-switch-input:checked + .thumbs-switch-track {
background: var(--accent);
}
.thumbs-switch-input:checked + .thumbs-switch-track .thumbs-switch-knob {
left: 14px;
background: #fff;
}
.thumbs-switch-input:focus-visible + .thumbs-switch-track {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.thumbs-switch:hover .thumbs-switch-track {
filter: brightness(1.08);
}
/* Section rows: unified layout — [caret + title][action?][count]. The
* count always sits at the far right so the right column is visually
* stable regardless of whether a row has a create-action (+) beside it. */
+157
View File
@@ -30,12 +30,14 @@ import {
shouldRotateClipCopies,
} from '@/lib/kaleido'
import { drawButtonFrame, drawSceneFrame, loadSceneImages } from '@/lib/render'
import { sampleCamera } from '@/lib/camera'
import type {
Action,
AnimatableProps,
Asset,
ButtonAsset,
ButtonState,
CameraProps,
ClipInstance,
HitShape,
Keyframe,
@@ -762,6 +764,138 @@ const clipGroupConfig = computed(() => {
}
})
const cameraVisibleInEditor = computed(() => {
if (store.activeClipId || store.activeButtonAssetId || store.activeSvgAssetId) return false
const camera = store.scene.camera
return !!camera && camera.overlayVisible !== false
})
const cameraFrame = computed<CameraProps | null>(() => {
if (!cameraVisibleInEditor.value) return null
return sampleCamera(store.scene, store.playhead)
})
const cameraFrameConfig = computed(() => {
const frame = cameraFrame.value
const camera = store.scene.camera
if (!frame || !camera) return null
const selected = store.selectedCamera
return {
id: 'camera-frame',
name: 'camera-frame',
x: frame.x,
y: frame.y,
width: frame.width,
height: frame.height,
fillEnabled: false,
stroke: selected ? '#38bdf8' : '#0ea5e9',
strokeWidth: (selected ? 1.4 : 1) / zoom.value,
strokeScaleEnabled: false,
dash: [4 / zoom.value, 3 / zoom.value],
hitStrokeWidth: Math.max(8 / zoom.value, 6),
listening: true,
draggable: selected && !camera.locked,
}
})
const cameraResizeDragStart = ref<CameraProps | null>(null)
function cameraOutputAspect(): number {
const canvas = store.scene.canvas
const width = Number.isFinite(canvas.width) && canvas.width > 0 ? canvas.width : 1
const height = Number.isFinite(canvas.height) && canvas.height > 0 ? canvas.height : 1
return width / height
}
const cameraResizeHandleConfig = computed(() => {
const frame = cameraFrame.value
const camera = store.scene.camera
if (!frame || !camera || !store.selectedCamera || camera.locked) return null
const size = 10 / zoom.value
return {
id: 'camera-resize-handle',
name: 'camera-resize-handle',
x: frame.x + frame.width - size / 2,
y: frame.y + frame.height - size / 2,
width: size,
height: size,
fill: '#f8fafc',
stroke: '#38bdf8',
strokeWidth: 1 / zoom.value,
strokeScaleEnabled: false,
draggable: true,
listening: true,
}
})
function selectCameraFromStage(e?: Konva.KonvaEventObject<MouseEvent>) {
if (e) e.cancelBubble = true
store.selectCamera(true)
}
function onCameraDragMove(e: Konva.KonvaEventObject<DragEvent>) {
const camera = store.scene.camera
if (!camera || camera.locked) return
const node = e.target
store.upsertCameraKeyframe(store.playhead, {
x: Math.round(node.x()),
y: Math.round(node.y()),
})
}
function onCameraDragEnd(e: Konva.KonvaEventObject<DragEvent>) {
onCameraDragMove(e)
}
function onCameraResizeStart(e: Konva.KonvaEventObject<DragEvent | MouseEvent>) {
e.cancelBubble = true
if (store.scene.camera?.locked) return
store.selectCamera(true)
cameraResizeDragStart.value = cameraFrame.value ? { ...cameraFrame.value } : null
}
function onCameraResizeDrag(e: Konva.KonvaEventObject<DragEvent>) {
e.cancelBubble = true
const camera = store.scene.camera
const start = cameraResizeDragStart.value ?? cameraFrame.value
if (!camera || camera.locked || !start) return
const node = e.target
const handleSize = cameraResizeHandleConfig.value?.width ?? 10 / zoom.value
const aspect = cameraOutputAspect()
const widthFromPointer = Math.max(1, node.x() + handleSize / 2 - start.x)
const heightFromPointer = Math.max(1, node.y() + handleSize / 2 - start.y)
const widthDelta = Math.abs(widthFromPointer - start.width) / Math.max(1, start.width)
const heightDelta = Math.abs(heightFromPointer - start.height) / Math.max(1, start.height)
const width = Math.max(
1,
Math.round(widthDelta >= heightDelta ? widthFromPointer : heightFromPointer * aspect),
)
const height = Math.max(1, Math.round(width / aspect))
node.x(start.x + width - handleSize / 2)
node.y(start.y + height - handleSize / 2)
store.upsertCameraKeyframe(store.playhead, {
x: Math.round(start.x),
y: Math.round(start.y),
width,
height,
})
}
function onCameraResizeEnd(e: Konva.KonvaEventObject<DragEvent>) {
onCameraResizeDrag(e)
cameraResizeDragStart.value = null
}
function toggleCameraEnabled() {
if (store.scene.camera?.enabled) {
store.disableCamera()
return
}
store.enableCamera()
}
/** Horizontal + vertical centre-crosshair line configs. Null when
* the toggle is off. Lines span the effective canvas so the centre
* is exactly where layer keyframes default to (canvas.width/2,
@@ -4723,6 +4857,21 @@ function onResizeHandlePointerUp(e: PointerEvent) {
<v-line :config="centerCrosshairConfigs.horizontal" />
<v-line :config="centerCrosshairConfigs.vertical" />
</template>
<v-rect
v-if="cameraFrameConfig"
:config="cameraFrameConfig"
@mousedown="selectCameraFromStage"
@dragmove="onCameraDragMove"
@dragend="onCameraDragEnd"
/>
<v-rect
v-if="cameraResizeHandleConfig"
:config="cameraResizeHandleConfig"
@mousedown="onCameraResizeStart"
@dragstart="onCameraResizeStart"
@dragmove="onCameraResizeDrag"
@dragend="onCameraResizeEnd"
/>
<v-rect
v-for="h in canvasResizeHandles"
:key="`canvas-resize-${h.edge}`"
@@ -4926,6 +5075,14 @@ function onResizeHandlePointerUp(e: PointerEvent) {
>
<PhCursorClick :size="13" />
</button>
<button
class="zc-btn camera-btn"
:class="{ active: store.scene.camera?.enabled, selected: store.selectedCamera }"
v-tooltip="store.scene.camera?.enabled ? t('canvas.cameraOff') : t('canvas.cameraOn')"
@click="toggleCameraEnabled"
>
<PhFrameCorners :size="13" />
</button>
<button
class="zc-btn motion-paths-btn"
:class="{ active: showMotionPaths }"
+32 -56
View File
@@ -14,8 +14,9 @@ import type { Theme } from '@/composables/useTheme'
import { useTooltipPref } from '@/composables/useTooltipPref'
import { useAssetThumbsPref } from '@/composables/useAssetThumbsPref'
import { useShowWelcomeOnStart } from '@/composables/useShowWelcomeOnStart'
import { useFullscreenPreviewAutoplay } from '@/composables/useFullscreenPreviewAutoplay'
import { showLocked } from '@/composables/useShowLocked'
import { Modal } from '@/components/ui'
import { Modal, ToggleSwitch } from '@/components/ui'
import { setLocale, t, useI18n } from '@/i18n'
import type { Locale } from '@/i18n'
@@ -27,6 +28,7 @@ const { theme, setTheme } = useTheme()
const { enabled: tooltipsEnabled } = useTooltipPref()
const { enabled: thumbsEnabled } = useAssetThumbsPref()
const { enabled: welcomeOnStart } = useShowWelcomeOnStart()
const { enabled: fullscreenAutoplay } = useFullscreenPreviewAutoplay()
const { locale } = useI18n()
// Re-check the stored clipart folder whenever settings opens — covers the
@@ -245,9 +247,10 @@ function applyTheme(t: Theme) {
</div>
<label class="row-toggle">
<span class="switch" :class="{ on: tooltipsEnabled }">
<input type="checkbox" v-model="tooltipsEnabled" />
</span>
<ToggleSwitch
v-model="tooltipsEnabled"
:aria-label="t('settings.tooltipsLabel')"
/>
<div class="row-toggle-text">
<div class="row-toggle-label">{{ t('settings.tooltipsLabel') }}</div>
<div class="row-toggle-desc">{{ t('settings.tooltipsDesc') }}</div>
@@ -255,9 +258,10 @@ function applyTheme(t: Theme) {
</label>
<label class="row-toggle">
<span class="switch" :class="{ on: showLocked }">
<input type="checkbox" v-model="showLocked" />
</span>
<ToggleSwitch
v-model="showLocked"
:aria-label="t('settings.showLockedLabel')"
/>
<div class="row-toggle-text">
<div class="row-toggle-label">{{ t('settings.showLockedLabel') }}</div>
<div class="row-toggle-desc">{{ t('settings.showLockedDesc') }}</div>
@@ -265,9 +269,10 @@ function applyTheme(t: Theme) {
</label>
<label class="row-toggle">
<span class="switch" :class="{ on: thumbsEnabled }">
<input type="checkbox" v-model="thumbsEnabled" />
</span>
<ToggleSwitch
v-model="thumbsEnabled"
:aria-label="t('settings.assetThumbsLabel')"
/>
<div class="row-toggle-text">
<div class="row-toggle-label">{{ t('settings.assetThumbsLabel') }}</div>
<div class="row-toggle-desc">{{ t('settings.assetThumbsDesc') }}</div>
@@ -275,15 +280,27 @@ function applyTheme(t: Theme) {
</label>
<label class="row-toggle">
<span class="switch" :class="{ on: welcomeOnStart }">
<input type="checkbox" v-model="welcomeOnStart" />
</span>
<ToggleSwitch
v-model="welcomeOnStart"
:aria-label="t('settings.welcomeOnStartLabel')"
/>
<div class="row-toggle-text">
<div class="row-toggle-label">{{ t('settings.welcomeOnStartLabel') }}</div>
<div class="row-toggle-desc">{{ t('settings.welcomeOnStartDesc') }}</div>
</div>
</label>
<label class="row-toggle">
<ToggleSwitch
v-model="fullscreenAutoplay"
:aria-label="t('settings.fullscreenAutoplayLabel')"
/>
<div class="row-toggle-text">
<div class="row-toggle-label">{{ t('settings.fullscreenAutoplayLabel') }}</div>
<div class="row-toggle-desc">{{ t('settings.fullscreenAutoplayDesc') }}</div>
</div>
</label>
<div class="setting-block">
<div class="setting-heading">{{ t('settings.appearanceTitle') }}</div>
<div class="setting-desc">{{ t('settings.themeHint') }}</div>
@@ -550,10 +567,10 @@ input[type='number'] {
letter-spacing: normal;
}
/* Switch + two-line label. Defensive resets defeat the scoped
/* Switch row + two-line label. Defensive resets defeat the scoped
blanket `label { flex-direction: column; text-transform: uppercase;
font-size: 10px; flex: 1 1 0; }` that form-row labels in this file
depend on — without the reset that rule would stack the switch on
depend on — without the reset that rule would stack the control on
top of the text and uppercase everything inside. */
.row-toggle {
display: flex;
@@ -599,47 +616,6 @@ input[type='number'] {
text-transform: none;
}
.row-toggle .switch {
position: relative;
width: 32px;
height: 18px;
flex-shrink: 0;
background: var(--border);
border-radius: 999px;
transition: background 140ms var(--ease);
}
.row-toggle .switch.on {
background: var(--accent);
}
.row-toggle .switch input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: inherit;
}
.row-toggle .switch::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
background: #fff;
border-radius: 50%;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
transition: transform 140ms var(--ease);
}
.row-toggle .switch.on::after {
transform: translateX(14px);
}
.clipart-row {
display: flex;
flex-direction: column;
+57 -73
View File
@@ -12,18 +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 { hasDesktopRender, renderMp4OnDesktop } from '@/lib/desktopRenderExport'
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] }>()
@@ -39,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
@@ -198,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.
@@ -219,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)
}
@@ -234,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,
@@ -250,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) => {
@@ -283,6 +297,7 @@ async function exportGif() {
}
store.pause()
try {
const { downloadSceneAsGif } = await import('@/lib/gifExport')
await downloadSceneAsGif(
activeSceneClone(),
frameExportOpts(controller, (p) => {
@@ -311,6 +326,7 @@ async function exportApng() {
}
store.pause()
try {
const { downloadSceneAsApng } = await import('@/lib/apngExport')
await downloadSceneAsApng(
activeSceneClone(),
frameExportOpts(controller, (p) => {
@@ -339,6 +355,7 @@ async function exportApngPreview() {
}
store.pause()
try {
const { downloadSceneAsApngPreview } = await import('@/lib/apngExport')
await downloadSceneAsApngPreview(
activeSceneClone(),
frameExportOpts(controller, (p) => {
@@ -356,7 +373,7 @@ async function exportApngPreview() {
}
}
async function exportMp4Server() {
async function exportMp4() {
if (progress.value) return
const controller = new AbortController()
const base = (projectName.value ?? 'animation').toString()
@@ -364,13 +381,14 @@ async function exportMp4Server() {
progress.value = {
progress: 0,
controller,
label: t('export.labelMp4'),
label: t(hasDesktopMp4.value ? 'export.labelMp4Desktop' : 'export.labelMp4Server'),
error: null,
}
store.pause()
try {
const scene = activeSceneClone()
if (hasDesktopRender()) {
const { renderMp4OnDesktop } = await import('@/lib/desktopRenderExport')
const result = await renderMp4OnDesktop(scene, {
signal: controller.signal,
fps: targetFps.value,
@@ -384,6 +402,7 @@ async function exportMp4Server() {
return
}
} else {
const { downloadFromServer } = await import('@/lib/serverExport')
await downloadFromServer(scene, {
signal: controller.signal,
fps: targetFps.value,
@@ -435,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>
@@ -577,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>
@@ -594,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" />
@@ -755,50 +783,6 @@ function onUpdate(v: boolean) {
line-height: 1.35;
}
/* iOS-style switch. The <input> is a transparent overlay that still
handles keyboard focus and click targeting; the visual is drawn by
the span + ::after pill. */
.switch {
position: relative;
width: 32px;
height: 18px;
flex-shrink: 0;
background: var(--border);
border-radius: 999px;
transition: background 140ms var(--ease);
}
.switch.on {
background: var(--accent);
}
.switch input {
position: absolute;
inset: 0;
margin: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: inherit;
}
.switch::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
background: #fff;
border-radius: 50%;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
transition: transform 140ms var(--ease);
}
.switch.on::after {
transform: translateX(14px);
}
.target-size {
display: flex;
align-items: center;
+16 -4
View File
@@ -2,11 +2,14 @@
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { PhPause, PhPlay, PhSkipBack, PhX } from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { cameraAspect, isCameraEnabled } from '@/lib/camera'
import { mountPlayer, type PlayerHandle } from '@/lib/exportPlayer'
import { t } from '@/i18n'
import { useTimeFormat } from '@/composables/useTimeFormat'
import { useFullscreenPreviewAutoplay } from '@/composables/useFullscreenPreviewAutoplay'
const { formatTime } = useTimeFormat()
const { enabled: autoplay } = useFullscreenPreviewAutoplay()
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
@@ -26,7 +29,14 @@ let isClosing = false
let player: PlayerHandle | null = null
const aspect = computed(
() => `${store.scene.canvas.width} / ${store.scene.canvas.height}`,
() => {
if (isCameraEnabled(store.scene)) {
const time = props.modelValue ? localTime.value : store.playhead
const a = cameraAspect(store.scene, time)
return `${a} / 1`
}
return `${store.scene.canvas.width} / ${store.scene.canvas.height}`
},
)
const rangeIn = computed(() => store.playbackIn)
@@ -43,13 +53,14 @@ async function startPreview() {
// stage. Otherwise the two runtimes fight — editor audio pool +
// preview audio pool would both emit the same mp3s at once.
store.pause()
playing.value = true
playing.value = autoplay.value
showControls.value = false
const start = rangeIn.value
const end = rangeOut.value
const head = store.playhead
const initialTime = head < start || head >= end ? start : head
mountAt(el, initialTime, true)
localTime.value = initialTime
mountAt(el, initialTime, autoplay.value)
requestBrowserFullscreen()
}
@@ -59,6 +70,7 @@ async function startPreview() {
* player and rebuild at the same playhead so visual continuity stays
* intact across the switch. */
function mountAt(el: HTMLElement, time: number, wasPlaying: boolean) {
localTime.value = time
player = mountPlayer(el, store.project, {
loop: true,
onFrame(t: number, paused: boolean) {
@@ -93,7 +105,7 @@ watch(
if (!props.modelValue) return
const still = frameEl.value
if (!still) return
mountAt(still, keepTime, true)
mountAt(still, keepTime, playing.value)
})
},
)
+14 -2
View File
@@ -17,6 +17,7 @@ import {
} from '@phosphor-icons/vue'
import { Modal } from '@/components/ui'
import { t } from '@/i18n'
import { hasDesktopRender } from '@/lib/desktopRenderExport'
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
@@ -63,6 +64,9 @@ interface Chapter {
titleKey: string
}
const isDesktop = !!window.ruwioDesktop
const hasDesktopMp4 = hasDesktopRender()
const CHAPTERS: Chapter[] = [
{
id: 'getting-started',
@@ -509,8 +513,16 @@ const CHAPTERS: Chapter[] = [
{ bodyKey: 'help.content.export.formats.runtime' },
{ bodyKey: 'help.content.export.formats.scene' },
{ bodyKey: 'help.content.export.formats.html' },
{ bodyKey: 'help.content.export.formats.mp4' },
{ bodyKey: 'help.content.export.formats.webm' },
{
bodyKey: hasDesktopMp4
? 'help.content.export.formats.mp4Desktop'
: 'help.content.export.formats.mp4',
},
{
bodyKey: isDesktop
? 'help.content.export.formats.webmDesktop'
: 'help.content.export.formats.webm',
},
],
},
{
+134 -2
View File
@@ -17,11 +17,13 @@ import {
PhX,
} from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { sampleCamera } from '@/lib/camera'
import { sampleLayer } from '@/lib/interpolate'
import type { Action, AnimatableProps, Layer } from '@/types/scene'
import type { Action, AnimatableProps, CameraProps, Layer } from '@/types/scene'
import RangeSlider from './RangeSlider.vue'
import ActionEditor from './ActionEditor.vue'
import FontPicker from './FontPicker.vue'
import { ToggleSwitch } from '@/components/ui'
import { t } from '@/i18n'
import { useTimeFormat } from '@/composables/useTimeFormat'
import { useEyeDropper } from '@/composables/useEyeDropper'
@@ -97,6 +99,81 @@ const sampled = computed<AnimatableProps | null>(() =>
selected.value ? sampleLayer(selected.value, store.playhead) : null,
)
const selectedCameraTrack = computed(() =>
store.selectedCamera ? (store.scene.camera ?? null) : null,
)
const sampledCamera = computed<CameraProps | null>(() =>
selectedCameraTrack.value ? sampleCamera(store.scene, store.playhead) : null,
)
const cameraHasKeyframeHere = computed(() => {
const camera = selectedCameraTrack.value
if (!camera) return false
return camera.keyframes.some((kf) => Math.abs(kf.time - store.playhead) < 1e-3)
})
const cameraEnabled = computed<boolean>({
get: () => selectedCameraTrack.value?.enabled ?? false,
set: (v) => {
if (v) store.enableCamera()
else store.disableCamera()
},
})
const cameraOverlayVisible = computed<boolean>({
get: () => selectedCameraTrack.value?.overlayVisible !== false,
set: (v) => store.setCameraOverlayVisible(v),
})
const cameraLocked = computed<boolean>({
get: () => selectedCameraTrack.value?.locked === true,
set: (v) => store.setCameraLocked(v),
})
function setCameraProp(patch: Partial<CameraProps>) {
const camera = selectedCameraTrack.value
if (!camera || camera.locked) return
store.upsertCameraKeyframe(store.playhead, patch)
}
function cameraOutputAspect(): number {
const width = Number.isFinite(store.scene.canvas.width) && store.scene.canvas.width > 0
? store.scene.canvas.width
: 1
const height = Number.isFinite(store.scene.canvas.height) && store.scene.canvas.height > 0
? store.scene.canvas.height
: 1
return width / height
}
const cameraX = computed<number>({
get: () => Math.round(sampledCamera.value?.x ?? 0),
set: (v) => setCameraProp({ x: v }),
})
const cameraY = computed<number>({
get: () => Math.round(sampledCamera.value?.y ?? 0),
set: (v) => setCameraProp({ y: v }),
})
const cameraWidth = computed<number>({
get: () => Math.round(sampledCamera.value?.width ?? store.scene.canvas.width),
set: (v) => {
const width = Math.max(1, v)
setCameraProp({ width, height: Math.max(1, Math.round(width / cameraOutputAspect())) })
},
})
const cameraHeight = computed<number>({
get: () => Math.round(sampledCamera.value?.height ?? store.scene.canvas.height),
set: (v) => {
const height = Math.max(1, v)
setCameraProp({ width: Math.max(1, Math.round(height * cameraOutputAspect())), height })
},
})
function removeCameraKeyframeHere() {
store.removeCameraKeyframeAt(store.playhead)
}
/** True when the user has more than one layer in the selection.
* Drives the multi-edit flow: writers fan out across every
* editable selected layer, kind-specific sections collapse, and
@@ -599,7 +676,62 @@ function deleteSelection() {
<template>
<div class="panel">
<h3>Properties</h3>
<template v-if="selected && sampled && editableLayers.length > 0">
<template v-if="selectedCameraTrack && sampledCamera">
<div class="multi-banner">{{ t('properties.camera.banner') }}</div>
<section class="prop-group">
<button class="group-header" @click="toggleSection('transform')">
<component :is="sectionOpen.transform ? PhCaretDown : PhCaretRight" :size="11" />
<span>{{ t('properties.camera.frame') }}</span>
</button>
<div v-if="sectionOpen.transform" class="group-body">
<div class="toggle-row">
<span class="toggle-label">{{ t('properties.camera.enabled') }}</span>
<ToggleSwitch
v-model="cameraEnabled"
:aria-label="t('properties.camera.enabled')"
/>
</div>
<div class="toggle-row">
<span class="toggle-label">{{ t('properties.camera.overlay') }}</span>
<ToggleSwitch
v-model="cameraOverlayVisible"
:aria-label="t('properties.camera.overlay')"
/>
</div>
<div class="toggle-row">
<span class="toggle-label">{{ t('properties.camera.locked') }}</span>
<ToggleSwitch
v-model="cameraLocked"
:aria-label="t('properties.camera.locked')"
/>
</div>
<div class="row">
<label>X<input type="number" v-model.number="cameraX" :disabled="cameraLocked" /></label>
<label>Y<input type="number" v-model.number="cameraY" :disabled="cameraLocked" /></label>
</div>
<div class="row">
<label>{{ t('properties.camera.width') }}<input type="number" min="1" v-model.number="cameraWidth" :disabled="cameraLocked" /></label>
<label>{{ t('properties.camera.height') }}<input type="number" min="1" v-model.number="cameraHeight" :disabled="cameraLocked" /></label>
</div>
<div class="kf-state">
<span>
{{ cameraHasKeyframeHere ? t('properties.keyframeLabel') : t('properties.interpolated') }}
<span class="kf-time">@ {{ formatTime(store.playhead) }}</span>
</span>
<button
v-if="cameraHasKeyframeHere"
class="kf-btn"
v-tooltip="t('properties.removeKeyframe')"
:disabled="cameraLocked"
@click="removeCameraKeyframeHere"
>
<PhMinus :size="12" />
</button>
</div>
</div>
</section>
</template>
<template v-else-if="selected && sampled && editableLayers.length > 0">
<div v-if="multiSelected" class="multi-banner">
{{ t('properties.multiSelected', { n: editableLayers.length }) }}
</div>
+250 -11
View File
@@ -16,6 +16,7 @@ import {
PhLockOpen,
PhEye,
PhEyeSlash,
PhFrameCorners,
PhMagnet,
PhMaskHappy,
PhCircle,
@@ -39,6 +40,7 @@ import { audioDragSource, clearAudioDragSource } from '@/composables/useAudioDra
import { clearClipDragSource, clipDragSource } from '@/composables/useClipDragSource'
import { drawWaveform, getWaveformSlot, slicePeaksForClip } from '@/composables/useAudioWaveform'
import { sampleLayer } from '@/lib/interpolate'
import { sampleCamera } from '@/lib/camera'
import { snapToCandidates } from '@/lib/timelineSnap'
import { confirmDialog, promptDialog } from '@/components/ui'
import ScriptFrameDialog from './ScriptFrameDialog.vue'
@@ -347,6 +349,9 @@ function allKeyframeTimes(ignoreKeyframeId?: string): number[] {
if (layer.rotationKeyframes)
for (const k of layer.rotationKeyframes) if (k.id !== ignoreKeyframeId) out.push(k.time)
}
for (const k of store.scene.camera?.keyframes ?? []) {
if (k.id !== ignoreKeyframeId) out.push(k.time)
}
return out
}
@@ -786,6 +791,138 @@ function onLaneDblClick(e: MouseEvent, track: TimelineLayerTrack) {
store.upsertKeyframe(track.layerId, time, props)
}
const cameraTimelineVisible = computed(
() =>
!store.activeClipId &&
!store.activeButtonAssetId &&
!store.activeSvgAssetId &&
!!store.scene.camera,
)
function onCameraTrackClick() {
store.selectCamera(true)
}
function onCameraLaneDblClick(e: MouseEvent) {
e.stopPropagation()
const camera = store.scene.camera
if (!camera || camera.locked) return
const el = e.currentTarget as HTMLElement
const rect = el.getBoundingClientRect()
if (rect.width <= 0) return
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
const time = snapTime(ratio * duration.value)
store.setPlayhead(time)
store.selectCamera(true)
store.upsertCameraKeyframe(time, sampleCamera(store.scene, time))
}
function onCameraKeyframeClick(e: MouseEvent, time: number) {
e.stopPropagation()
if (cameraKfDrag.value?.moved) {
cameraKfDrag.value = null
return
}
cameraKfDrag.value = null
store.selectCamera(true)
store.setPlayhead(time)
}
function onCameraKeyframeDblClick(e: MouseEvent, keyframeId: string) {
e.stopPropagation()
const camera = store.scene.camera
if (!camera || camera.locked) return
store.removeCameraKeyframe(keyframeId)
}
function onCameraKeyframeContextMenu(e: MouseEvent, keyframeId: string) {
e.preventDefault()
e.stopPropagation()
const camera = store.scene.camera
if (!camera || camera.locked) return
const kf = camera.keyframes.find((item) => item.id === keyframeId)
if (!kf) return
const sorted = camera.keyframes.slice().sort((a, b) => a.time - b.time)
menu.value = {
target: 'camera',
keyframeId: kf.id,
time: kf.time,
x: e.clientX,
y: e.clientY,
currentEasing: kf.easing,
isFirst: sorted[0]?.id === kf.id,
isLast: sorted[sorted.length - 1]?.id === kf.id,
isHeld: !!kf.hold,
isEnd: false,
hasEnd: false,
}
store.selectCamera(true)
}
interface CameraKfDrag {
keyframeId: string
lane: HTMLElement
pointerId: number
startX: number
moved: boolean
initialTime: number
}
const cameraKfDrag = ref<CameraKfDrag | null>(null)
function onCameraKeyframePointerDown(e: PointerEvent, keyframeId: string) {
if (e.button !== 0) return
const camera = store.scene.camera
if (!camera || camera.locked) return
const kfEl = e.currentTarget as HTMLElement
const lane = kfEl.parentElement
const kf = camera.keyframes.find((item) => item.id === keyframeId)
if (!lane || !kf) return
e.stopPropagation()
kfEl.setPointerCapture(e.pointerId)
store.selectCamera(true)
cameraKfDrag.value = {
keyframeId,
lane,
pointerId: e.pointerId,
startX: e.clientX,
moved: false,
initialTime: kf.time,
}
}
function onCameraKeyframePointerMove(e: PointerEvent) {
const d = cameraKfDrag.value
if (!d || d.pointerId !== e.pointerId) return
if (!d.moved && Math.abs(e.clientX - d.startX) < 3) return
d.moved = true
const rect = d.lane.getBoundingClientRect()
if (rect.width <= 0) return
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
const rawNewTime = ratio * duration.value
const newTime = snapToCandidates(
rawNewTime,
[...allKeyframeTimesExcluding(new Set([d.keyframeId])), store.playhead],
{
enabled: snapEnabled.value,
pxPerSec: effectivePxPerSec.value,
pixelThreshold: SNAP_PX,
duration: duration.value,
},
)
store.setCameraKeyframeTime(d.keyframeId, newTime)
store.setPlayhead(newTime)
}
function onCameraKeyframePointerUp(e: PointerEvent) {
const d = cameraKfDrag.value
if (!d || d.pointerId !== e.pointerId) return
const el = e.currentTarget as HTMLElement
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
if (!d.moved) cameraKfDrag.value = null
}
function onKeyframeClick(e: MouseEvent, layerId: string, time: number) {
e.stopPropagation()
if (kfDrag.value?.moved) {
@@ -1262,6 +1399,9 @@ function allKeyframeTimesExcluding(ignoreIds: Set<string>): number[] {
if (layer.rotationKeyframes)
for (const k of layer.rotationKeyframes) if (!ignoreIds.has(k.id)) out.push(k.time)
}
for (const k of store.scene.camera?.keyframes ?? []) {
if (!ignoreIds.has(k.id)) out.push(k.time)
}
return out
}
@@ -1374,6 +1514,14 @@ interface KfSegment {
tail?: boolean
}
interface KfSegmentSource {
id: string
time: number
easing: Easing
hold?: boolean
kind?: 'normal' | 'end'
}
/** Render-time filter for the kf v-for. Hides end kfs that share a
* time slot with a normal kf — common after the end-kf migration
* on legacy projects, where stacking a square (end) on top of a
@@ -1391,7 +1539,7 @@ function displayKfs(kfs: Keyframe[]): Keyframe[] {
})
}
function segmentsOf(kfs: Keyframe[], duration: number, buttonMode = false): KfSegment[] {
function segmentsOf(kfs: KfSegmentSource[], duration: number, buttonMode = false): KfSegment[] {
if (kfs.length === 0) return []
// End kfs are visibility markers, not interpolation participants.
// Pull them out for tail computation; everything else flows through
@@ -1527,7 +1675,8 @@ const EASING_OPTIONS: { value: Easing; label: string }[] = [
]
interface KfMenuState {
layerId: string
target: 'layer' | 'camera'
layerId?: string
keyframeId: string
time: number
x: number
@@ -1566,6 +1715,7 @@ function onKeyframeContextMenu(
.sort((a, b) => a.time - b.time)
const hasEnd = layer.keyframes.some((k) => k.kind === 'end')
menu.value = {
target: 'layer',
layerId,
keyframeId: kf.id,
time: kf.time,
@@ -1583,7 +1733,11 @@ function onKeyframeContextMenu(
function toggleHold() {
if (!menu.value) return
store.setKeyframeHold(menu.value.layerId, menu.value.keyframeId, !menu.value.isHeld)
if (menu.value.target === 'camera') {
store.setCameraKeyframeHold(menu.value.keyframeId, !menu.value.isHeld)
} else if (menu.value.layerId) {
store.setKeyframeHold(menu.value.layerId, menu.value.keyframeId, !menu.value.isHeld)
}
menu.value = null
}
@@ -1595,7 +1749,7 @@ function toggleHold() {
* we delete the existing one before promoting the new one. */
function toggleEndKind() {
const m = menu.value
if (!m) return
if (!m || m.target !== 'layer' || !m.layerId) return
const layer = store.activeLayers.find((l) => l.id === m.layerId)
if (!layer) return
const kf = layer.keyframes.find((k) => k.id === m.keyframeId)
@@ -1619,21 +1773,30 @@ function toggleEndKind() {
function applyEasing(easing: Easing) {
if (!menu.value) return
store.setKeyframeEasing(menu.value.layerId, menu.value.keyframeId, easing)
if (menu.value.target === 'camera') {
store.setCameraKeyframeEasing(menu.value.keyframeId, easing)
} else if (menu.value.layerId) {
store.setKeyframeEasing(menu.value.layerId, menu.value.keyframeId, easing)
}
menu.value = null
}
function deleteFromMenu() {
if (!menu.value) return
store.removeKeyframe(menu.value.layerId, menu.value.keyframeId)
if (menu.value.target === 'camera') {
store.removeCameraKeyframe(menu.value.keyframeId)
} else if (menu.value.layerId) {
store.removeKeyframe(menu.value.layerId, menu.value.keyframeId)
}
menu.value = null
}
function duplicateFromMenu() {
if (!menu.value) return
const newId = store.duplicateKeyframe(menu.value.layerId, menu.value.keyframeId)
if (!menu.value || menu.value.target !== 'layer' || !menu.value.layerId) return
const layerId = menu.value.layerId
const newId = store.duplicateKeyframe(layerId, menu.value.keyframeId)
if (newId) {
const layer = store.activeLayers.find((l) => l.id === menu.value!.layerId)
const layer = store.activeLayers.find((l) => l.id === layerId)
const all = layer
? [
...layer.keyframes,
@@ -3534,6 +3697,81 @@ onBeforeUnmount(() => {
@pointercancel="onTracksPointerUp"
>
<div v-if="boxRectStyle" class="box-select-rect" :style="boxRectStyle"></div>
<div
v-if="cameraTimelineVisible"
class="track camera-track"
:class="{ inactive: !store.selectedCamera, locked: !!store.scene.camera?.locked }"
>
<span
class="track-name parent"
:class="{ selected: store.selectedCamera }"
@click="onCameraTrackClick"
>
<PhFrameCorners :size="12" class="name-badge" />
<span class="track-label">{{ t('timeline.cameraTrackName') }}</span>
<button
class="eye-btn"
:class="{ hidden: store.scene.camera?.overlayVisible === false }"
v-tooltip="
store.scene.camera?.overlayVisible === false
? t('timeline.showInEditor')
: t('timeline.hideInEditor')
"
@click.stop="store.setCameraOverlayVisible(store.scene.camera?.overlayVisible === false)"
@mousedown.stop
>
<component :is="store.scene.camera?.overlayVisible === false ? PhEyeSlash : PhEye" :size="12" />
</button>
<button
class="lock-btn"
:class="{ locked: store.scene.camera?.locked }"
v-tooltip="store.scene.camera?.locked ? t('timeline.unlock') : t('timeline.lock')"
@click.stop="store.setCameraLocked(!store.scene.camera?.locked)"
@mousedown.stop
>
<span class="lock-icon rest">
<component :is="store.scene.camera?.locked ? PhLock : PhLockOpen" :size="12" />
</span>
<span class="lock-icon alt">
<component
:is="store.scene.camera?.locked ? PhLockOpen : PhLock"
:size="12"
:weight="store.scene.camera?.locked ? 'fill' : 'regular'"
/>
</span>
</button>
</span>
<div
class="track-lane camera-lane"
:style="{ width: laneWidth + 'px' }"
@click="onLaneClick"
@dblclick="onCameraLaneDblClick"
>
<span class="playhead" :style="{ left: pxFromTime(store.playhead) + 'px' }" />
<span
v-for="seg in segmentsOf(store.scene.camera?.keyframes ?? [], duration)"
:key="`camera-${seg.id}`"
class="kf-seg"
:class="[seg.held ? 'held' : `easing-${seg.easing}`, { tail: seg.tail }]"
:style="segmentStyle(seg)"
/>
<span
v-for="kf in store.scene.camera?.keyframes ?? []"
:key="kf.id"
class="kf camera-kf"
:class="{ hold: kf.hold }"
:style="{ left: pxFromTime(kf.time) + 'px' }"
v-tooltip="t('timeline.keyframeTooltip', { time: formatKfTime(kf.time) })"
@pointerdown="onCameraKeyframePointerDown($event, kf.id)"
@pointermove="onCameraKeyframePointerMove"
@pointerup="onCameraKeyframePointerUp"
@pointercancel="onCameraKeyframePointerUp"
@click="onCameraKeyframeClick($event, kf.time)"
@dblclick="onCameraKeyframeDblClick($event, kf.id)"
@contextmenu="onCameraKeyframeContextMenu($event, kf.id)"
/>
</div>
</div>
<template v-for="(track, idx) in tracks" :key="
track.type === 'layer'
? `l:${track.layerId}:${track.kind}:${track.inGroup ? 'g' : 't'}`
@@ -4261,6 +4499,7 @@ onBeforeUnmount(() => {
<div class="kf-menu-divider"></div>
</template>
<button
v-if="menu.target === 'layer'"
class="kf-menu-item"
:class="{ active: menu.isEnd }"
@click="toggleEndKind"
@@ -4274,8 +4513,8 @@ onBeforeUnmount(() => {
class="kf-menu-trailing-check"
/>
</button>
<div class="kf-menu-divider"></div>
<button v-if="!menu.isEnd" class="kf-menu-item" @click="duplicateFromMenu">
<div v-if="menu.target === 'layer'" class="kf-menu-divider"></div>
<button v-if="menu.target === 'layer' && !menu.isEnd" class="kf-menu-item" @click="duplicateFromMenu">
<PhCopy :size="12" class="kf-menu-icon" />
{{ t('timeline.duplicate') }}
</button>
+5 -46
View File
@@ -21,7 +21,7 @@ import {
pickBackupFile,
readBackupFile,
} from '@/lib/backupFile'
import { confirmDialog, Modal } from '@/components/ui'
import { confirmDialog, Modal, ToggleSwitch } from '@/components/ui'
import { showWelcomeOnStart } from '@/composables/useShowWelcomeOnStart'
import { t } from '@/i18n'
@@ -366,9 +366,10 @@ watch(
<div v-if="backupError" class="backup-error">{{ backupError }}</div>
<template #footer>
<label class="ftr-toggle" v-tooltip="t('welcome.showOnStartHelp')">
<span class="switch sm" :class="{ on: showWelcomeOnStart }">
<input type="checkbox" v-model="showWelcomeOnStart" />
</span>
<ToggleSwitch
v-model="showWelcomeOnStart"
:aria-label="t('welcome.showOnStart')"
/>
<span class="ftr-toggle-label">{{ t('welcome.showOnStart') }}</span>
</label>
<span class="ftr-sep"></span>
@@ -676,8 +677,6 @@ watch(
border-radius: var(--radius-sm);
}
/* Compact footer toggle — same visual language as the other toggles
in the app but sized for the row. */
.ftr-toggle {
display: inline-flex;
align-items: center;
@@ -691,44 +690,4 @@ watch(
.ftr-toggle-label {
white-space: nowrap;
}
.switch.sm {
position: relative;
display: inline-block;
width: 28px;
height: 16px;
background: var(--border);
border-radius: 999px;
flex-shrink: 0;
transition: background 140ms var(--ease);
}
.switch.sm input {
opacity: 0;
width: 100%;
height: 100%;
margin: 0;
cursor: pointer;
}
.switch.sm::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 12px;
height: 12px;
background: #fff;
border-radius: 50%;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
transition: transform 140ms var(--ease);
}
.switch.sm.on {
background: var(--accent);
}
.switch.sm.on::after {
transform: translateX(12px);
}
</style>
+110
View File
@@ -0,0 +1,110 @@
<script setup lang="ts">
defineProps<{
modelValue: boolean
label?: string
ariaLabel?: string
disabled?: boolean
}>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
function onChange(event: Event) {
emit('update:modelValue', (event.target as HTMLInputElement).checked)
}
</script>
<template>
<span class="ui-switch thumbs-switch" :class="{ disabled }">
<span v-if="$slots.default || label" class="ui-switch-label thumbs-switch-label">
<slot>{{ label }}</slot>
</span>
<input
type="checkbox"
class="ui-switch-input thumbs-switch-input"
:checked="modelValue"
:disabled="disabled"
:aria-label="ariaLabel || label"
@change="onChange"
/>
<span class="ui-switch-track">
<span class="ui-switch-knob"></span>
</span>
</span>
</template>
<style scoped>
.ui-switch {
position: relative;
display: inline-flex;
align-items: center;
gap: 6px;
flex: 0 0 auto;
color: var(--text-dim);
cursor: pointer;
font-size: 11px;
line-height: 1;
user-select: none;
-webkit-user-select: none;
}
.ui-switch.disabled {
cursor: not-allowed;
}
.ui-switch-label {
font-weight: 500;
letter-spacing: 0.01em;
}
.ui-switch-input {
position: absolute;
inset: 0;
z-index: 2;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: inherit;
}
.ui-switch-track {
position: relative;
z-index: 1;
width: 26px;
height: 14px;
border-radius: 999px;
background: var(--border);
transition: background 140ms var(--ease);
flex-shrink: 0;
}
.ui-switch-knob {
position: absolute;
top: 2px;
left: 2px;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--panel);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.35);
transition: left 140ms var(--ease), background 140ms var(--ease);
}
.ui-switch-input:checked + .ui-switch-track {
background: var(--accent);
}
.ui-switch-input:checked + .ui-switch-track .ui-switch-knob {
left: 14px;
background: #fff;
}
.ui-switch-input:focus-visible + .ui-switch-track {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.ui-switch:hover:not(.disabled) .ui-switch-track {
filter: brightness(1.08);
}
</style>
+1
View File
@@ -2,5 +2,6 @@ export { default as DialogsHost } from './DialogsHost.vue'
export { default as IconButton } from './IconButton.vue'
export { default as Modal } from './Modal.vue'
export { default as Toast } from './Toast.vue'
export { default as ToggleSwitch } from './ToggleSwitch.vue'
export { confirmDialog, dialogState, promptDialog } from './dialogs'
export type { ConfirmOptions, PromptOptions } from './dialogs'
+1
View File
@@ -12,6 +12,7 @@ export function useDeselectAll() {
return function deselectAll() {
store.selectLayer(null)
store.selectClipInstance(null)
store.selectCamera(false)
selectedKfs.value = []
}
}
@@ -0,0 +1,27 @@
import { ref, watch } from 'vue'
const STORAGE_KEY = 'animatr.fullscreenPreviewAutoplay'
function read(): boolean {
try {
return localStorage.getItem(STORAGE_KEY) === 'true'
} catch {
return false
}
}
// Module-level shared ref so settings and fullscreen preview use the
// same preference without prop drilling through the app shell.
export const fullscreenPreviewAutoplay = ref<boolean>(read())
watch(fullscreenPreviewAutoplay, (v) => {
try {
localStorage.setItem(STORAGE_KEY, String(v))
} catch {
/* ignore */
}
})
export function useFullscreenPreviewAutoplay() {
return { enabled: fullscreenPreviewAutoplay }
}
+441 -13
View File
@@ -34,6 +34,7 @@ import { drawButtonFrame, drawSceneFrame, renderButtonThumbnail } from '@/lib/re
import { t as i18nT } from '@/i18n'
import { expandedTrackOrder } from '@/lib/sceneOrder'
import { CLIP_FALLBACK_BOUNDS, computeClipBounds } from '@/lib/clipBounds'
import { renderSceneFrameForOutput } from '@/lib/camera'
import {
deleteStoredProject,
getStoredProject,
@@ -43,6 +44,7 @@ import {
import Modal from '@/components/ui/Modal.vue'
import IconButton from '@/components/ui/IconButton.vue'
import Toast from '@/components/ui/Toast.vue'
import App from '@/App.vue'
import PropertiesPanel from '@/components/PropertiesPanel.vue'
import ClipartDrawer from '@/components/ClipartDrawer.vue'
import SceneDrawer from '@/components/SceneDrawer.vue'
@@ -55,6 +57,7 @@ import ExportDialog from '@/components/ExportDialog.vue'
import WelcomeDialog from '@/components/WelcomeDialog.vue'
import FullscreenPreview from '@/components/FullscreenPreview.vue'
import HelpDialog from '@/components/HelpDialog.vue'
import { fullscreenPreviewAutoplay } from '@/composables/useFullscreenPreviewAutoplay'
import { createAudioRuntime } from '@/composables/useAudioRuntime'
import {
clearWaveformCache,
@@ -1397,7 +1400,7 @@ export function registerAllTests(): void {
expect(sceneScript).toContain(ext)
})
test('opening the fullscreen preview pauses the editor playback', async () => {
test('opening the fullscreen preview pauses the editor playback and mounts runtime canvas', async () => {
// Two runtimes fighting for the same audio output = garbled.
// FullscreenPreview pauses `store.playing` so the editor's own
// useAudioRuntime quiets down while the preview owns playback.
@@ -1408,8 +1411,108 @@ export function registerAllTests(): void {
await nextTick()
await nextTick()
expect(s.playing).toBe(false)
await tick()
const frame = document.body.querySelector('.fs-frame') as HTMLElement | null
expect(frame).toBeTruthy()
expect(frame?.querySelector('canvas')).toBeTruthy()
handle.unmount()
})
test('opening the fullscreen preview mounts runtime canvas with camera enabled', async () => {
const s = makeFreshStore()
s.enableCamera()
s.setCameraBaseProps({ x: 10, y: 20, width: 320, height: 180 })
const handle = mountComponent(FullscreenPreview, { modelValue: true })
await nextTick()
await nextTick()
await tick()
const frame = document.body.querySelector('.fs-frame') as HTMLElement | null
expect(frame).toBeTruthy()
expect(frame?.querySelector('canvas')).toBeTruthy()
handle.unmount()
})
test('fullscreen preview autoplay preference controls the initial transport state', async () => {
makeFreshStore()
const before = fullscreenPreviewAutoplay.value
const primaryButton = () =>
document.body.querySelector('.fs-toolbar .fs-btn') as HTMLButtonElement | null
try {
fullscreenPreviewAutoplay.value = false
const pausedHandle = mountComponent(FullscreenPreview, { modelValue: true })
await nextTick()
await nextTick()
expect(primaryButton()?.getAttribute('aria-label')).toBe('Play')
pausedHandle.unmount()
await nextTick()
fullscreenPreviewAutoplay.value = true
const playingHandle = mountComponent(FullscreenPreview, { modelValue: true })
await nextTick()
await nextTick()
expect(primaryButton()?.getAttribute('aria-label')).toBe('Pause')
playingHandle.unmount()
} finally {
fullscreenPreviewAutoplay.value = before
}
})
test('top transport fullscreen button opens the preview and pauses editor playback', async () => {
const s = makeFreshStore()
s.play()
expect(s.playing).toBe(true)
const handle = mountComponent(App, {})
try {
await nextTick()
await nextTick()
const btn = handle.host.querySelector(
`[aria-label="${i18nT('app.fullscreenPlayback')}"]`,
) as HTMLButtonElement | null
expect(btn).toBeTruthy()
btn!.click()
await nextTick()
await nextTick()
expect(s.playing).toBe(false)
expect(document.body.querySelector('.fs-root')).toBeTruthy()
expect(document.body.querySelector('.fs-frame')).toBeTruthy()
} finally {
handle.unmount()
}
})
test('camera output renders off-stage content over the scene background', () => {
const s = makeFreshStore()
s.scene.canvas.width = 100
s.scene.canvas.height = 100
s.scene.canvas.background = '#123456'
const rect = s.addAsset({
kind: 'rect',
name: 'Outside',
width: 10,
height: 10,
fill: '#ff0000',
})
const layer = s.addLayer(rect.id)!
s.upsertKeyframe(layer.id, 0, { x: -15, y: 50 })
s.enableCamera()
s.setCameraBaseProps({ x: -50, y: 0, width: 100, height: 100 })
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
renderSceneFrameForOutput(ctx, s.scene, 0, {}, { width: 100, height: 100 })
const outsideStageBg = ctx.getImageData(10, 50, 1, 1).data
expect(Array.from(outsideStageBg.slice(0, 3))).toEqual([0x12, 0x34, 0x56])
expect(outsideStageBg[3]).toBe(255)
const outsideStageLayer = ctx.getImageData(35, 50, 1, 1).data
expect(outsideStageLayer[0]).toBeGreaterThan(200)
expect(outsideStageLayer[1]).toBeLessThan(40)
expect(outsideStageLayer[2]).toBeLessThan(40)
expect(outsideStageLayer[3]).toBe(255)
})
})
suite('export — target size scaling', () => {
@@ -10181,6 +10284,334 @@ export function registerAllTests(): void {
})
})
suite('ui — camera framing', () => {
function resetPropertySectionPrefs() {
try {
localStorage.removeItem('animatr:propSections')
} catch {
/* noop */
}
}
function stubLaneRect(lane: HTMLElement, width = 1000): void {
const fakeRect = {
left: 0,
top: 0,
width,
height: 20,
right: width,
bottom: 20,
x: 0,
y: 0,
toJSON: () => ({}),
} as DOMRect
;(lane as unknown as { getBoundingClientRect: () => DOMRect }).getBoundingClientRect = () =>
fakeRect
}
async function mountCameraCanvasWithKonva() {
const VueKonva = (await import('vue-konva')).default
const host = document.createElement('div')
host.setAttribute('data-test-host', '')
host.style.width = '800px'
host.style.height = '600px'
document.body.appendChild(host)
const app = createApp({ render() { return h(CanvasStage, {}) } })
app.use(VueKonva)
app.directive('tooltip', vTooltip)
app.mount(host)
return {
host,
app,
unmount() {
app.unmount()
host.remove()
},
}
}
async function getCameraKonvaStage() {
const Konva = (await import('konva')).default
return Konva.stages[Konva.stages.length - 1]
}
test('stage camera button enables camera without resizing the editor stage', async () => {
const s = makeFreshStore()
s.scene.canvas.width = 640
s.scene.canvas.height = 360
const handle = mountComponent(CanvasStage, {})
try {
await nextTick()
const dims = handle.host.querySelector('.dims') as HTMLElement
const btn = handle.host.querySelector('.zoom-controls .camera-btn') as HTMLButtonElement
expect(dims).toBeTruthy()
expect(btn).toBeTruthy()
expect(dims.textContent?.trim()).toBe('640 × 360')
expect(s.scene.camera).toBe(undefined)
btn.click()
await nextTick()
expect(s.scene.camera?.enabled).toBe(true)
expect(s.selectedCamera).toBe(true)
expect(btn.classList.contains('active')).toBe(true)
expect(dims.textContent?.trim()).toBe('640 × 360')
expect(s.scene.canvas.width).toBe(640)
expect(s.scene.canvas.height).toBe(360)
btn.click()
await nextTick()
expect(s.scene.camera?.enabled).toBe(false)
expect(dims.textContent?.trim()).toBe('640 × 360')
} finally {
handle.unmount()
}
})
test('properties panel edits the selected camera frame, not scene canvas size', async () => {
resetPropertySectionPrefs()
const s = makeFreshStore()
s.scene.canvas.width = 800
s.scene.canvas.height = 450
s.enableCamera()
s.selectCamera(true)
const handle = mountComponent(PropertiesPanel, {})
try {
await nextTick()
const banner = handle.host.querySelector('.multi-banner') as HTMLElement
expect(banner.textContent).toContain(i18nT('properties.camera.banner'))
const inputs = handle.host.querySelectorAll<HTMLInputElement>(
'.prop-group input[type="number"]',
)
expect(inputs.length).toBe(4)
const [xInput, yInput, widthInput, heightInput] = Array.from(inputs)
expect(Number(widthInput.value)).toBe(800)
expect(Number(heightInput.value)).toBe(450)
xInput.value = '24'
xInput.dispatchEvent(new Event('input', { bubbles: true }))
widthInput.value = '320'
widthInput.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
expect(s.scene.camera?.keyframes.length).toBe(1)
expect(s.scene.camera?.keyframes[0].props.x).toBe(24)
expect(s.scene.camera?.keyframes[0].props.width).toBe(320)
expect(s.scene.camera?.keyframes[0].props.height).toBe(180)
expect(s.scene.canvas.width).toBe(800)
expect(s.scene.canvas.height).toBe(450)
void yInput
} finally {
handle.unmount()
}
})
test('stage camera frame has one lower-right aspect resize handle', async () => {
const s = makeFreshStore()
s.scene.canvas.width = 800
s.scene.canvas.height = 450
s.editor.canvasZoom = 1
s.enableCamera()
s.selectCamera(true)
const handle = await mountCameraCanvasWithKonva()
try {
await nextTick()
await tick(60)
const stage = await getCameraKonvaStage() as unknown as {
find: (sel: string) => unknown[]
findOne: (sel: string) => unknown
}
const frame = stage.findOne('#camera-frame') as {
strokeWidth: () => number
dash: () => number[]
}
expect(frame).toBeTruthy()
expect(frame.strokeWidth()).toBeLessThan(2.5)
const dash = frame.dash()
expect(dash.length).toBe(2)
expect(dash[0]).toBeLessThan(10)
expect(dash[0] / dash[1]).toBeCloseTo(4 / 3, 4)
const handles = stage.find('.camera-resize-handle') as Array<{
x: (v?: number) => number
y: (v?: number) => number
width: () => number
height: () => number
fire: (type: string, evt: unknown, bubble: boolean) => void
}>
expect(handles.length).toBe(1)
const grip = handles[0]
const half = grip.width() / 2
expect(grip.height()).toBe(grip.width())
grip.fire('dragstart', { target: grip, type: 'dragstart' }, true)
grip.x(400 - half)
grip.y(225 - half)
grip.fire('dragmove', { target: grip, type: 'dragmove' }, true)
await nextTick()
const kf = s.scene.camera?.keyframes[0]
expect(s.scene.camera?.keyframes.length).toBe(1)
expect(kf?.props.x).toBe(0)
expect(kf?.props.y).toBe(0)
expect(kf?.props.width).toBe(400)
expect(kf?.props.height).toBe(225)
expect(s.scene.canvas.width).toBe(800)
expect(s.scene.canvas.height).toBe(450)
} finally {
handle.unmount()
}
})
test('timeline camera row selects, hides, locks, and adds camera keyframes', async () => {
const s = makeFreshStore()
s.setDuration(10)
s.enableCamera()
s.selectCamera(false)
const handle = mountComponent(Timeline, {})
try {
await nextTick()
const trackName = handle.host.querySelector('.camera-track .track-name') as HTMLElement
const eye = handle.host.querySelector('.camera-track .eye-btn') as HTMLButtonElement
const lock = handle.host.querySelector('.camera-track .lock-btn') as HTMLButtonElement
const lane = handle.host.querySelector('.camera-track .camera-lane') as HTMLElement
expect(trackName).toBeTruthy()
expect(eye).toBeTruthy()
expect(lock).toBeTruthy()
expect(lane).toBeTruthy()
trackName.click()
await nextTick()
expect(s.selectedCamera).toBe(true)
eye.click()
await nextTick()
expect(s.scene.camera?.overlayVisible).toBe(false)
expect(eye.classList.contains('hidden')).toBe(true)
lock.click()
await nextTick()
expect(s.scene.camera?.locked).toBe(true)
expect(lock.classList.contains('locked')).toBe(true)
stubLaneRect(lane)
lane.dispatchEvent(
new MouseEvent('dblclick', { bubbles: true, cancelable: true, clientX: 500 }),
)
await nextTick()
expect(s.scene.camera?.keyframes.length).toBe(0)
lock.click()
await nextTick()
lane.dispatchEvent(
new MouseEvent('dblclick', { bubbles: true, cancelable: true, clientX: 500 }),
)
await nextTick()
expect(s.scene.camera?.keyframes.length).toBe(1)
expect(Math.abs((s.scene.camera?.keyframes[0].time ?? 0) - 5) < 0.1).toBe(true)
expect(Math.abs(s.playhead - 5) < 0.1).toBe(true)
await nextTick()
const cameraKf = handle.host.querySelector('.camera-track .camera-kf') as HTMLElement
expect(cameraKf).toBeTruthy()
const captured = new Set<number>()
;(cameraKf as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture = (id) => {
captured.add(id)
}
;(cameraKf as unknown as { releasePointerCapture: (id: number) => void }).releasePointerCapture = (id) => {
captured.delete(id)
}
;(cameraKf as unknown as { hasPointerCapture: (id: number) => boolean }).hasPointerCapture = (id) =>
captured.has(id)
cameraKf.dispatchEvent(
new PointerEvent('pointerdown', {
bubbles: true,
cancelable: true,
button: 0,
pointerId: 1,
clientX: 500,
}),
)
cameraKf.dispatchEvent(
new PointerEvent('pointermove', {
bubbles: true,
cancelable: true,
pointerId: 1,
clientX: 700,
}),
)
cameraKf.dispatchEvent(
new PointerEvent('pointerup', {
bubbles: true,
cancelable: true,
pointerId: 1,
clientX: 700,
}),
)
cameraKf.click()
await nextTick()
expect(s.scene.camera?.keyframes.length).toBe(1)
expect(Math.abs((s.scene.camera?.keyframes[0].time ?? 0) - 7) < 0.1).toBe(true)
expect(Math.abs(s.playhead - 7) < 0.1).toBe(true)
lane.dispatchEvent(
new MouseEvent('dblclick', { bubbles: true, cancelable: true, clientX: 900 }),
)
await nextTick()
expect(s.scene.camera?.keyframes.length).toBe(2)
const secondCameraKf = s.scene.camera?.keyframes.find((kf) => Math.abs(kf.time - 9) < 0.1)
expect(secondCameraKf).toBeTruthy()
const cameraKfs = Array.from(
handle.host.querySelectorAll<HTMLElement>('.camera-track .camera-kf'),
)
const secondKfEl = cameraKfs[cameraKfs.length - 1]
secondKfEl.dispatchEvent(
new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
clientX: 320,
clientY: 180,
}),
)
await nextTick()
let menu = handle.host.querySelector('.kf-menu') as HTMLElement
expect(menu).toBeTruthy()
const easeIn = Array.from(menu.querySelectorAll<HTMLButtonElement>('.kf-menu-item')).find(
(button) => button.textContent?.includes(i18nT('timeline.easingEaseIn')),
)
expect(easeIn).toBeTruthy()
expect(easeIn?.disabled).toBe(false)
easeIn!.click()
await nextTick()
expect(secondCameraKf?.easing).toBe('easeIn')
expect(handle.host.querySelector('.camera-track .kf-seg.easing-easeIn')).toBeTruthy()
secondKfEl.dispatchEvent(
new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
clientX: 320,
clientY: 180,
}),
)
await nextTick()
menu = handle.host.querySelector('.kf-menu') as HTMLElement
const deleteButton = Array.from(menu.querySelectorAll<HTMLButtonElement>('.kf-menu-item')).find(
(button) => button.textContent?.includes(i18nT('timeline.delete')),
)
expect(deleteButton).toBeTruthy()
deleteButton!.click()
await nextTick()
expect(s.scene.camera?.keyframes.length).toBe(1)
expect(s.scene.camera?.keyframes.some((kf) => kf.id === secondCameraKf?.id)).toBe(false)
} finally {
handle.unmount()
}
})
})
suite('ui — CanvasStage centre crosshair (Konva)', () => {
// Verify the lines actually render at the correct coordinates in
// Konva when the toggle is on. Requires a proper VueKonva mount.
@@ -15179,12 +15610,9 @@ export function registerAllTests(): void {
// showed up in fullscreen-preview AND in standalone exports.
const { generateRuntimeScript } = await import('@/lib/exportPlayer')
const src = generateRuntimeScript()
const match = src.match(/function layerActiveAt\(layer,time\)\{([\s\S]*?)\n {4}\}/)
expect(!!match).toBe(true)
const body = match![1]
// Build a callable from the function body so we can poke the
// runtime's gate logic directly without mounting a player.
const layerActiveAt = new Function('layer', 'time', body) as (layer: unknown, time: number) => boolean
expect(src).toContain('function layerActiveAt(layer,time){')
const layerActiveAt = (layer: unknown, time: number) =>
isLayerActiveAt(layer as Layer, time)
// (a) Single normal kf at t=2 → invisible before t=2.
const a = { keyframes: [{ time: 2, kind: 'normal' }] }
expect(layerActiveAt(a, 0)).toBe(false)
@@ -15554,7 +15982,7 @@ export function registerAllTests(): void {
expect(src).toContain('clipKaleidoPivot')
expect(src).toContain('clipArmLocalT')
// Runtime version got bumped during the kaleido + wiggle work.
expect(src).toContain("'34'")
expect(src).toContain("'36'")
})
test('exported runtime layerActiveAt is unaffected by kaleido fields', async () => {
@@ -15562,9 +15990,9 @@ export function registerAllTests(): void {
// visibility gate for layers without kaleido — same fixtures as
// the layerActiveAt parity test above.
const src = generateRuntimeScript()
const match = src.match(/function layerActiveAt\(layer,time\)\{([\s\S]*?)\n {4}\}/)
expect(!!match).toBe(true)
const layerActiveAt = new Function('layer', 'time', match![1]) as (layer: unknown, time: number) => boolean
expect(src).toContain('function layerActiveAt(layer,time){')
const layerActiveAt = (layer: unknown, time: number) =>
isLayerActiveAt(layer as Layer, time)
const a = { keyframes: [{ time: 2, kind: 'normal' }], kaleido: true, kaleidoCount: 4 }
expect(layerActiveAt(a, 0)).toBe(false)
expect(layerActiveAt(a, 2)).toBe(true)
@@ -15932,7 +16360,7 @@ export function registerAllTests(): void {
expect(src).toContain('function wiggleSeeds(')
// Runtime version got bumped through the wiggle work and the
// later layer-kaleido-pivot landing.
expect(src).toContain("'34'")
expect(src).toContain("'36'")
})
})
@@ -16464,7 +16892,7 @@ export function registerAllTests(): void {
const src = generateRuntimeScript()
expect(src).toContain('function layerArmTf(')
// Bumped on this work.
expect(src).toContain("'34'")
expect(src).toContain("'36'")
})
})
}
+67 -20
View File
@@ -6,6 +6,7 @@ export const en = {
pause: 'Pause (Space)',
play: 'Play (Space)',
stop: 'Stop',
fullscreenPlayback: 'Fullscreen playback (Shift+Space)',
recentProjects: 'Recent projects',
copyStill: {
label: 'Copy current frame',
@@ -39,8 +40,10 @@ export const en = {
untitledProject: 'Untitled project',
saveProjectDirtyHelp:
'Your project has unsaved changes. Ctrl/Cmd+S writes them to browser storage.',
exportHelp:
'Save as runtime script, HTML, or video. MP4 needs the render server; WebM records in real time in the browser.',
exportHelpServer:
'Save as runtime script, HTML, or video. MP4 uses the render server; WebM records in real time with MediaRecorder.',
exportHelpDesktop:
"Save as runtime script, HTML, or video. MP4 renders in the desktop app with bundled ffmpeg; WebM records in Electron's Chromium runtime.",
replaceDialog: {
title: 'Replace project?',
message:
@@ -191,6 +194,15 @@ export const en = {
noLayer: 'No layer selected.',
lockedLayer:
'Selected layer is locked. Unlock the track (or its group) to edit properties.',
camera: {
banner: 'Output camera selected',
frame: 'Camera Frame',
enabled: 'Enabled',
overlay: 'Overlay',
locked: 'Locked',
width: 'Width',
height: 'Height',
},
fill: 'Fill',
transparent: 'Transparent',
eyedropper: 'Pick a colour from anywhere on the page',
@@ -337,6 +349,7 @@ export const en = {
renameGroup: 'Rename group',
renameScriptTrack: 'Rename script track',
resizeTrackColumn: 'Drag to resize track name column',
cameraTrackName: 'Camera',
clipTrackName: 'Clip track',
toggleTimeUnit: 'Toggle time unit',
snapOn: 'Snapping on (playhead & keyframes snap together)',
@@ -400,6 +413,8 @@ export const en = {
stagePreviewOff: 'Turn stage preview off — buttons behave as normal assets',
centerCrosshairOn: 'Show centre crosshair',
centerCrosshairOff: 'Hide centre crosshair',
cameraOn: 'Enable output camera',
cameraOff: 'Disable output camera',
motionPaths: {
label: 'Show motion paths',
help: 'Draws the position-keyframe trajectory of the selected layer on the canvas. Click a dot to seek to that keyframe.',
@@ -453,6 +468,9 @@ export const en = {
welcomeOnStartLabel: 'Show welcome screen on start',
welcomeOnStartDesc:
'Open the welcome / recents dialog when RUWIO launches. Off → boot straight into the last edited project. The last file is always restored either way; the welcome dialog just gives you a chance to switch.',
fullscreenAutoplayLabel: 'Autoplay in fullscreen preview',
fullscreenAutoplayDesc:
'Start playback immediately when fullscreen preview opens. When off, fullscreen opens paused and Space starts it.',
bgVideoLabel: 'Video',
bgVideoNone: 'No background video',
bgVideoPickFile: 'Pick video file…',
@@ -509,10 +527,14 @@ export const en = {
sceneDesc: 'Scene data only — requires the runtime to be loaded',
exampleTitle: 'Example HTML',
exampleDesc: 'Standalone HTML — just open it in a browser',
mp4Badge: 'Server · Frame-accurate',
mp4Desc: 'H.264, any resolution — requires render server',
webmBadge: 'Browser · Realtime',
webmDesc: 'No server — recorded in real time',
mp4ServerBadge: 'Server · Frame-by-frame',
mp4ServerDesc: 'H.264 export via render server',
mp4DesktopBadge: 'Desktop · Frame-by-frame',
mp4DesktopDesc: 'H.264 export via bundled ffmpeg',
webmBrowserBadge: 'Browser · Realtime',
webmBrowserDesc: 'Recorded in real time with MediaRecorder',
webmDesktopBadge: 'Chromium · Realtime',
webmDesktopDesc: "Recorded in real time by Electron's Chromium runtime",
webmNotSupported: 'Browser does not support MediaRecorder',
gifBadge: 'Browser · Frame-by-frame',
gifDesc: '256-colour palette, broadest compatibility',
@@ -524,8 +546,10 @@ export const en = {
cancel: 'Cancel',
failed: 'Export failed',
back: 'Back',
labelWebm: 'Rendering WebM (browser, realtime)…',
labelMp4: 'Rendering MP4 (server)…',
labelWebmBrowser: 'Rendering WebM in browser realtime...',
labelWebmDesktop: 'Rendering WebM in Chromium realtime...',
labelMp4Server: 'Rendering MP4 with render server...',
labelMp4Desktop: 'Rendering MP4 in desktop app...',
labelGif: 'Rendering GIF (browser, frame-by-frame)…',
labelApng: 'Rendering APNG (browser, frame-by-frame)…',
labelApngPreview: 'Rendering APNG + preview page…',
@@ -1078,9 +1102,13 @@ play
html:
'<strong>HTML example:</strong> A quick standalone page with everything wired up.',
mp4:
'<strong>MP4 video:</strong> Frame-accurate server render. This needs the render server configured in Settings.',
'<strong>MP4 video:</strong> Frame-by-frame H.264 export via the render server.',
mp4Desktop:
'<strong>MP4 video:</strong> Frame-by-frame H.264 export in the desktop app with bundled ffmpeg.',
webm:
'<strong>WebM video:</strong> Browser-side realtime recording. This depends on <code>MediaRecorder</code> support.',
'<strong>WebM video:</strong> Browser realtime recording with <code>MediaRecorder</code>.',
webmDesktop:
"<strong>WebM video:</strong> Realtime recording with <code>MediaRecorder</code> in Electron's Chromium runtime.",
},
skipHiddenTitle: 'Skip hidden layers',
skipHiddenBody:
@@ -1116,6 +1144,7 @@ export const de: DeepPartial<Messages> = {
pause: 'Pause (Leertaste)',
play: 'Abspielen (Leertaste)',
stop: 'Stop',
fullscreenPlayback: 'Fullscreen-Wiedergabe (Shift+Leertaste)',
recentProjects: 'Letzte Projekte',
copyStill: {
label: 'Aktuellen Frame kopieren',
@@ -1149,8 +1178,10 @@ export const de: DeepPartial<Messages> = {
untitledProject: 'Unbenanntes Projekt',
saveProjectDirtyHelp:
'Dein Projekt hat ungespeicherte Änderungen. Mit Ctrl/Cmd+S wird es im Browser gespeichert.',
exportHelp:
'Als Runtime, HTML oder Video exportieren. MP4 braucht den Render-Server. WebM wird direkt im Browser in Echtzeit aufgenommen.',
exportHelpServer:
'Als Runtime, HTML oder Video exportieren. MP4 nutzt den Render-Server. WebM wird in Echtzeit mit MediaRecorder aufgenommen.',
exportHelpDesktop:
'Als Runtime, HTML oder Video exportieren. MP4 rendert in der Desktop-App mit gebündeltem ffmpeg. WebM wird in Electrons Chromium-Runtime aufgenommen.',
replaceDialog: {
title: 'Projekt ersetzen?',
message:
@@ -1308,6 +1339,12 @@ export const de: DeepPartial<Messages> = {
assetThumbsLabel: 'Asset-Vorschaubilder',
assetThumbsDesc:
'Zeigt kleine Vorschauen in Assets, Clips und Buttons. Aus ist kompakter, an ist übersichtlicher.',
welcomeOnStartLabel: 'Willkommensfenster beim Start anzeigen',
welcomeOnStartDesc:
'Öffnet beim Start von RUWIO den Willkommen-/Letzte-Projekte-Dialog. Aus startet direkt mit dem zuletzt bearbeiteten Projekt.',
fullscreenAutoplayLabel: 'Autoplay in Fullscreen-Vorschau',
fullscreenAutoplayDesc:
'Startet die Wiedergabe sofort, wenn die Fullscreen-Vorschau geöffnet wird. Wenn aus, öffnet sie pausiert und Leertaste startet sie.',
bgVideoLabel: 'Video',
bgVideoNone: 'Kein Hintergrundvideo',
bgVideoPickFile: 'Videodatei wählen…',
@@ -1363,10 +1400,14 @@ export const de: DeepPartial<Messages> = {
sceneDesc: 'Nur Projektdaten: Die Runtime muss schon geladen sein.',
exampleTitle: 'HTML-Beispiel',
exampleDesc: 'Eigenständige HTML-Datei: Einfach im Browser öffnen.',
mp4Badge: 'Server · Frame-genau',
mp4Desc: 'H.264 in jeder Auflösung: braucht den Render-Server.',
webmBadge: 'Browser · Echtzeit',
webmDesc: 'Ohne Server: direkt im Browser aufgenommen.',
mp4ServerBadge: 'Server · Frame-für-Frame',
mp4ServerDesc: 'H.264-Export über den Render-Server',
mp4DesktopBadge: 'Desktop · Frame-für-Frame',
mp4DesktopDesc: 'H.264-Export über gebündeltes ffmpeg',
webmBrowserBadge: 'Browser · Echtzeit',
webmBrowserDesc: 'Echtzeit-Aufnahme mit MediaRecorder',
webmDesktopBadge: 'Chromium · Echtzeit',
webmDesktopDesc: 'Echtzeit-Aufnahme durch Electrons Chromium-Runtime',
webmNotSupported: 'Dieser Browser unterstützt MediaRecorder nicht.',
gifBadge: 'Browser · Frame-für-Frame',
gifDesc: '256-Farben-Palette, maximale Kompatibilität.',
@@ -1378,8 +1419,10 @@ export const de: DeepPartial<Messages> = {
cancel: 'Abbrechen',
failed: 'Export fehlgeschlagen',
back: 'Zurück',
labelWebm: 'WebM wird gerendert (Browser, Echtzeit)…',
labelMp4: 'MP4 wird gerendert (Server)…',
labelWebmBrowser: 'WebM wird in Browser-Echtzeit gerendert...',
labelWebmDesktop: 'WebM wird in Chromium-Echtzeit gerendert...',
labelMp4Server: 'MP4 wird mit dem Render-Server gerendert...',
labelMp4Desktop: 'MP4 wird in der Desktop-App gerendert...',
labelGif: 'GIF wird gerendert (Browser, frame-für-frame)…',
labelApng: 'APNG wird gerendert (Browser, frame-für-frame)…',
labelApngPreview: 'APNG + Vorschau werden gerendert…',
@@ -1920,9 +1963,13 @@ play
html:
'<strong>HTML-Beispiel:</strong> Eine schnelle eigenständige Seite mit allem fertig verdrahtet.',
mp4:
'<strong>MP4-Video:</strong> Frame-genauer Server-Render. Dafür muss der Render-Server in den Einstellungen konfiguriert sein.',
'<strong>MP4-Video:</strong> Frame-für-Frame-H.264-Export über den Render-Server.',
mp4Desktop:
'<strong>MP4-Video:</strong> Frame-für-Frame-H.264-Export in der Desktop-App mit gebündeltem ffmpeg.',
webm:
'<strong>WebM-Video:</strong> Browser-seitige Echtzeit-Aufnahme. Das hängt von <code>MediaRecorder</code> ab.',
'<strong>WebM-Video:</strong> Echtzeit-Aufnahme im Browser mit <code>MediaRecorder</code>.',
webmDesktop:
'<strong>WebM-Video:</strong> Echtzeit-Aufnahme mit <code>MediaRecorder</code> in Electrons Chromium-Runtime.',
},
skipHiddenTitle: 'Versteckte Ebenen auslassen',
skipHiddenBody:
+201
View File
@@ -0,0 +1,201 @@
import type { CameraKeyframe, CameraProps, CameraTrack, Easing, Scene } from '@/types/scene'
import { drawSceneFrame } from './render'
const CAMERA_MIN_SIZE = 1
const easings: Record<Easing, (t: number) => number> = {
linear: (t) => t,
easeIn: (t) => t * t,
easeOut: (t) => 1 - (1 - t) ** 2,
easeInOut: (t) => (t < 0.5 ? 2 * t * t : 1 - ((-2 * t + 2) ** 2) / 2),
}
function uid() {
return Math.random().toString(36).slice(2, 10)
}
function finite(value: unknown, fallback: number): number {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback
}
function positive(value: unknown, fallback: number): number {
return Math.max(CAMERA_MIN_SIZE, finite(value, fallback))
}
function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t
}
function mergeCameraProps(base: CameraProps, props: Partial<CameraProps> | undefined): CameraProps {
return {
x: finite(props?.x, base.x),
y: finite(props?.y, base.y),
width: positive(props?.width, base.width),
height: positive(props?.height, base.height),
}
}
function lerpCameraProps(a: CameraProps, b: CameraProps, t: number): CameraProps {
return {
x: lerp(a.x, b.x, t),
y: lerp(a.y, b.y, t),
width: positive(lerp(a.width, b.width, t), a.width),
height: positive(lerp(a.height, b.height, t), a.height),
}
}
export function defaultCameraProps(scene: Pick<Scene, 'canvas'>): CameraProps {
return {
x: 0,
y: 0,
width: positive(scene.canvas?.width, 1280),
height: positive(scene.canvas?.height, 720),
}
}
export function createDefaultCameraTrack(
scene: Pick<Scene, 'canvas'>,
enabled = true,
): CameraTrack {
return {
id: uid(),
name: 'Camera',
enabled,
base: defaultCameraProps(scene),
keyframes: [],
overlayVisible: true,
locked: false,
}
}
export function normalizeCameraTrack(
scene: Pick<Scene, 'canvas'>,
camera: unknown,
): CameraTrack | undefined {
if (!camera || typeof camera !== 'object') return undefined
const raw = camera as Partial<CameraTrack>
const fallback = defaultCameraProps(scene)
const base = mergeCameraProps(fallback, raw.base)
const keyframes = Array.isArray(raw.keyframes)
? raw.keyframes.flatMap((kf): CameraKeyframe[] => {
if (!kf || typeof kf !== 'object') return []
const rawKf = kf as Partial<CameraKeyframe>
const props = mergeCameraProps(base, rawKf.props)
const storedProps: Partial<CameraProps> = {}
if (rawKf.props && typeof rawKf.props === 'object') {
const p = rawKf.props as Partial<CameraProps>
if (typeof p.x === 'number' && Number.isFinite(p.x)) storedProps.x = props.x
if (typeof p.y === 'number' && Number.isFinite(p.y)) storedProps.y = props.y
if (typeof p.width === 'number' && Number.isFinite(p.width)) storedProps.width = props.width
if (typeof p.height === 'number' && Number.isFinite(p.height)) storedProps.height = props.height
}
return [
{
id: typeof rawKf.id === 'string' && rawKf.id ? rawKf.id : uid(),
time: Math.max(0, finite(rawKf.time, 0)),
props: storedProps,
easing:
rawKf.easing === 'easeIn' ||
rawKf.easing === 'easeOut' ||
rawKf.easing === 'easeInOut' ||
rawKf.easing === 'linear'
? rawKf.easing
: 'linear',
hold: rawKf.hold === true,
},
]
})
: []
keyframes.sort((a, b) => a.time - b.time)
return {
id: typeof raw.id === 'string' && raw.id ? raw.id : uid(),
name: typeof raw.name === 'string' ? raw.name : 'Camera',
enabled: raw.enabled === true,
base,
keyframes,
overlayVisible: raw.overlayVisible !== false,
locked: raw.locked === true,
}
}
export function isCameraEnabled(scene: Scene): boolean {
const camera = normalizeCameraTrack(scene, scene.camera)
return !!camera?.enabled
}
export function sampleCamera(scene: Scene, time: number): CameraProps {
const camera = normalizeCameraTrack(scene, scene.camera)
if (!camera) return defaultCameraProps(scene)
const base = mergeCameraProps(defaultCameraProps(scene), camera.base)
const sorted = camera.keyframes.slice().sort((a, b) => a.time - b.time)
if (!sorted.length) return base
if (time <= sorted[0].time) return mergeCameraProps(base, sorted[0].props)
const last = sorted[sorted.length - 1]
if (time >= last.time) return mergeCameraProps(base, last.props)
for (let i = 0; i < sorted.length - 1; i++) {
const a = sorted[i]
const b = sorted[i + 1]
if (time >= a.time && time <= b.time) {
if (a.hold) {
if (time >= b.time) return mergeCameraProps(base, b.props)
return mergeCameraProps(base, a.props)
}
const span = Math.max(1e-9, b.time - a.time)
const t = easings[b.easing]((time - a.time) / span)
return lerpCameraProps(mergeCameraProps(base, a.props), mergeCameraProps(base, b.props), t)
}
}
return base
}
export function cameraAspect(scene: Scene, time = 0): number {
const camera = sampleCamera(scene, time)
return camera.width / camera.height
}
export function renderSceneFrameForOutput(
ctx: CanvasRenderingContext2D,
scene: Scene,
time: number,
images: Record<string, HTMLImageElement>,
options?: {
width?: number
height?: number
useCamera?: boolean
cropStage?: boolean
},
): void {
const outputWidth = Math.max(1, Math.round(options?.width ?? scene.canvas.width))
const outputHeight = Math.max(1, Math.round(options?.height ?? scene.canvas.height))
const useCamera = options?.useCamera ?? isCameraEnabled(scene)
if (!useCamera) {
ctx.save()
const scaleX = outputWidth / scene.canvas.width
const scaleY = outputHeight / scene.canvas.height
if (scaleX !== 1 || scaleY !== 1) ctx.scale(scaleX, scaleY)
drawSceneFrame(ctx, scene, time, images, { crop: options?.cropStage })
ctx.restore()
return
}
const camera = sampleCamera(scene, time)
ctx.save()
ctx.setTransform(1, 0, 0, 1, 0, 0)
ctx.clearRect(0, 0, outputWidth, outputHeight)
const bg = scene.canvas.background
if (!scene.backgroundVideo && bg && bg !== 'transparent') {
ctx.fillStyle = bg
ctx.fillRect(0, 0, outputWidth, outputHeight)
}
ctx.restore()
ctx.save()
ctx.scale(outputWidth / camera.width, outputHeight / camera.height)
ctx.translate(-camera.x, -camera.y)
drawSceneFrame(ctx, scene, time, images, {
crop: options?.cropStage ?? false,
clear: false,
background: false,
})
ctx.restore()
}
+89 -11
View File
@@ -11,7 +11,7 @@ function isProject(p: ExportPayload): p is Project {
return Array.isArray((p as Partial<Project>).scenes)
}
const RUNTIME_VERSION = '34'
const RUNTIME_VERSION = '36'
/** Materialize the engine source. The font map is rebuilt on every
* call so user-uploaded custom fonts (which live in localStorage
@@ -207,6 +207,57 @@ const ENGINE_TEMPLATE = `(function(){
return result;
}
function defaultCameraProps(scene){
return {
x:0,
y:0,
width:Math.max(1, Number(scene.canvas && scene.canvas.width) || 1280),
height:Math.max(1, Number(scene.canvas && scene.canvas.height) || 720)
};
}
function mergeCameraProps(base, props){
props=props||{};
function finite(v, fallback){ return typeof v==='number' && isFinite(v) ? v : fallback; }
return {
x:finite(props.x, base.x),
y:finite(props.y, base.y),
width:Math.max(1, finite(props.width, base.width)),
height:Math.max(1, finite(props.height, base.height))
};
}
function lerpCameraProps(a,b,t){
return {
x:lerp(a.x,b.x,t),
y:lerp(a.y,b.y,t),
width:Math.max(1, lerp(a.width,b.width,t)),
height:Math.max(1, lerp(a.height,b.height,t))
};
}
function sampleCamera(scene,time){
var camera=scene.camera;
var base=defaultCameraProps(scene);
if(!camera) return base;
base=mergeCameraProps(base, camera.base);
var kfs=(camera.keyframes||[]).slice().sort(function(a,b){return a.time-b.time;});
if(!kfs.length) return base;
if(time<=kfs[0].time) return mergeCameraProps(base,kfs[0].props);
var last=kfs[kfs.length-1];
if(time>=last.time) return mergeCameraProps(base,last.props);
for(var i=0;i<kfs.length-1;i++){
var a=kfs[i], b=kfs[i+1];
if(time>=a.time && time<=b.time){
if(a.hold){
if(time>=b.time) return mergeCameraProps(base,b.props);
return mergeCameraProps(base,a.props);
}
var raw=(time-a.time)/Math.max(1e-9,b.time-a.time);
var tt=easing(b.easing,raw);
return lerpCameraProps(mergeCameraProps(base,a.props), mergeCameraProps(base,b.props), tt);
}
}
return base;
}
function loadImages(assets){
var imgs={};
var list=assets.filter(function(a){return a.kind==='image';});
@@ -248,7 +299,8 @@ const ENGINE_TEMPLATE = `(function(){
trackOrder: page.trackOrder || [],
backgroundVideo: page.backgroundVideo,
audioTracks: page.audioTracks || [],
audioClips: page.audioClips || []
audioClips: page.audioClips || [],
camera: page.camera
};
}
if(payload && payload.scenes && payload.scenes.length){
@@ -283,7 +335,8 @@ const ENGINE_TEMPLATE = `(function(){
trackOrder: legacy.trackOrder || [],
backgroundVideo: legacy.backgroundVideo,
audioTracks: legacy.audioTracks || [],
audioClips: legacy.audioClips || []
audioClips: legacy.audioClips || [],
camera: legacy.camera
}],
activeSceneId: '__legacy__'
};
@@ -1110,20 +1163,31 @@ const ENGINE_TEMPLATE = `(function(){
var cw=size.w,ch=size.h;
if(!cw||!ch)return;
buttonInstancesInFrame.length=0;
var s=Math.min(cw/lw,ch/lh);
var ox=(cw-lw*s)/2;
var oy=(ch-lh*s)/2;
var useCamera=!!(scene.camera && scene.camera.enabled);
var cam=useCamera?sampleCamera(scene,time):null;
var vw=useCamera?cam.width:lw;
var vh=useCamera?cam.height:lh;
var s=Math.min(cw/vw,ch/vh);
var ox=(cw-vw*s)/2;
var oy=(ch-vh*s)/2;
ctx.setTransform(dpr,0,0,dpr,0,0);
ctx.clearRect(0,0,cw,ch);
ctx.save();
ctx.translate(ox,oy);
ctx.scale(s,s);
ctx.beginPath();
ctx.rect(0,0,lw,lh);
ctx.rect(0,0,vw,vh);
ctx.clip();
if(!scene.backgroundVideo){
if(useCamera && !scene.backgroundVideo && scene.canvas.background && scene.canvas.background !== 'transparent'){
ctx.fillStyle=scene.canvas.background;
ctx.fillRect(0,0,lw,lh);
ctx.fillRect(0,0,vw,vh);
}
if(useCamera) ctx.translate(-cam.x,-cam.y);
if(!scene.backgroundVideo){
if(!useCamera){
ctx.fillStyle=scene.canvas.background;
ctx.fillRect(0,0,lw,lh);
}
}
var layerById={};
for(var li=0;li<scene.layers.length;li++)layerById[scene.layers[li].id]=scene.layers[li];
@@ -1513,12 +1577,27 @@ function sceneToLegacyProject(scene: Scene): Project {
backgroundVideo: scene.backgroundVideo,
audioTracks: scene.audioTracks ?? [],
audioClips: scene.audioClips ?? [],
camera: scene.camera,
},
],
activeSceneId: '__legacy__',
}
}
function exportPreviewAspect(payload: ExportPayload): string {
const project = isProject(payload) ? payload : sceneToLegacyProject(payload)
const page =
project.scenes.find((s) => s.id === project.activeSceneId) ??
project.scenes[0]
const camera = page?.camera
if (camera?.enabled) {
const width = Math.max(1, camera.base?.width ?? project.canvas.width)
const height = Math.max(1, camera.base?.height ?? project.canvas.height)
return `${width} / ${height}`
}
return `${project.canvas.width} / ${project.canvas.height}`
}
export interface ExportOptions {
loop?: boolean
}
@@ -1571,8 +1650,7 @@ export function downloadBundledExport(payload: ExportPayload, filename = 'animat
export function generateExampleHtml(payload: ExportPayload, options: ExportOptions = {}): string {
const runtime = engineSource()
const sceneBoot = generateSceneScriptBody(payload, options)
const canvas = isProject(payload) ? payload.canvas : payload.canvas
const aspect = `${canvas.width} / ${canvas.height}`
const aspect = exportPreviewAspect(payload)
const usedFontIds = collectFontIds(payload)
const fonts = AVAILABLE_FONTS.value
const usedFonts = usedFontIds
+6 -10
View File
@@ -1,5 +1,6 @@
import type { Scene } from '@/types/scene'
import { drawSceneFrame, loadSceneImages } from './render'
import { renderSceneFrameForOutput } from './camera'
import { loadSceneImages } from './render'
/** End-of-playback time: clamp to the scene's last keyframe if the
* scene explicitly ends earlier than its nominal duration. Matches
@@ -81,14 +82,6 @@ export async function renderFrames(
canvas.height = height
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('2D context unavailable')
// Scale scene coords into target pixels. drawSceneFrame runs its
// own save/restore so the transform survives across frames, and
// any internal `setTransform(1,0,0,1,0,0)` (top-level mask
// compositing) is sandwiched inside its own save/restore too.
const scaleX = width / srcWidth
const scaleY = height / srcHeight
if (scaleX !== 1 || scaleY !== 1) ctx.scale(scaleX, scaleY)
const images = await loadSceneImages(scene)
const { start: rangeIn, end: rangeOut } = playbackRange(scene)
const rangeLen = Math.max(0, rangeOut - rangeIn)
@@ -106,7 +99,10 @@ export async function renderFrames(
throw new DOMException('aborted', 'AbortError')
}
const t = rangeIn + i / fps
drawSceneFrame(ctx, scene, Math.min(t, rangeOut), images)
renderSceneFrameForOutput(ctx, scene, Math.min(t, rangeOut), images, {
width,
height,
})
await onFrame({
scene,
canvas,
+10 -3
View File
@@ -566,11 +566,18 @@ export function drawSceneFrame(
scene: Scene,
time: number,
images: Record<string, HTMLImageElement>,
options?: { crop?: boolean; buttonStates?: Map<string, ButtonState> },
options?: {
crop?: boolean
clear?: boolean
background?: boolean
buttonStates?: Map<string, ButtonState>
},
) {
const lw = scene.canvas.width
const lh = scene.canvas.height
const crop = options?.crop !== false
const clear = options?.clear !== false
const paintBackground = options?.background !== false
const buttonStates = options?.buttonStates
ctx.save()
@@ -583,13 +590,13 @@ export function drawSceneFrame(
// Mandatory for transparent-background scenes so subsequent frames
// don't accumulate alpha; harmless for opaque ones since `fillRect`
// below paints over everything.
ctx.clearRect(0, 0, lw, lh)
if (clear) ctx.clearRect(0, 0, lw, lh)
// A background video takes over the backdrop — leave the stage area
// transparent so the DOM video underneath is visible. Same treatment
// when the canvas background is explicitly 'transparent' so
// APNG/WebM exports carry the alpha channel out to the file.
const bg = scene.canvas.background
if (!scene.backgroundVideo && bg && bg !== 'transparent') {
if (paintBackground && !scene.backgroundVideo && bg && bg !== 'transparent') {
ctx.fillStyle = bg
ctx.fillRect(0, 0, lw, lh)
}
+11 -10
View File
@@ -1,5 +1,6 @@
import type { Scene } from '@/types/scene'
import { drawSceneFrame, loadSceneImages } from './render'
import { renderSceneFrameForOutput } from './camera'
import { loadSceneImages } from './render'
export type StillImageFormat = 'png' | 'jpg'
@@ -45,17 +46,17 @@ export async function renderSceneToStillImage(
const needsOpaqueBackdrop =
format === 'jpg' && (!sceneBg || sceneBg === 'transparent')
const backdrop = opts.background ?? (needsOpaqueBackdrop ? '#ffffff' : null)
if (backdrop) {
ctx.fillStyle = backdrop
ctx.fillRect(0, 0, width, height)
}
const scaleX = width / srcWidth
const scaleY = height / srcHeight
if (scaleX !== 1 || scaleY !== 1) ctx.scale(scaleX, scaleY)
const images = await loadSceneImages(scene)
drawSceneFrame(ctx, scene, Math.max(0, time), images)
renderSceneFrameForOutput(ctx, scene, Math.max(0, time), images, { width, height })
if (backdrop) {
ctx.save()
ctx.globalCompositeOperation = 'destination-over'
ctx.fillStyle = backdrop
ctx.fillRect(0, 0, width, height)
ctx.restore()
}
const mime = format === 'jpg' ? 'image/jpeg' : 'image/png'
const quality = format === 'jpg' ? (opts.quality ?? 0.92) : undefined
+10 -3
View File
@@ -1,5 +1,6 @@
import type { Scene } from '@/types/scene'
import { drawSceneFrame, loadSceneImages } from './render'
import { renderSceneFrameForOutput } from './camera'
import { loadSceneImages } from './render'
function effectiveEnd(scene: Scene): number {
let max = 0
@@ -67,7 +68,10 @@ export async function renderSceneToVideo(
const { start: rangeIn, end: rangeOut } = playbackRange(scene)
const rangeLen = Math.max(0, rangeOut - rangeIn)
drawSceneFrame(ctx, scene, rangeIn, images)
renderSceneFrameForOutput(ctx, scene, rangeIn, images, {
width: canvas.width,
height: canvas.height,
})
const stream = canvas.captureStream(scene.fps)
const recorder = new MediaRecorder(stream, {
@@ -103,7 +107,10 @@ export async function renderSceneToVideo(
}
const elapsed = (performance.now() - startedAt) / 1000
const t = rangeIn + Math.min(elapsed, rangeLen)
drawSceneFrame(ctx, scene, t, images)
renderSceneFrameForOutput(ctx, scene, t, images, {
width: canvas.width,
height: canvas.height,
})
opts.onProgress?.(rangeLen > 0 ? Math.min(1, elapsed / rangeLen) : 1)
if (elapsed >= rangeLen) {
try {
+4 -1
View File
@@ -18,7 +18,10 @@ app.use(VueKonva)
app.directive('tooltip', vTooltip)
app.mount('#app')
if (import.meta.env.DEV && new URLSearchParams(window.location.search).get('__runtests') === '1') {
if (
(import.meta.env.DEV || !!window.ruwioDesktop) &&
new URLSearchParams(window.location.search).get('__runtests') === '1'
) {
void import('./devtools/headless').then(({ runHeadlessTests }) => runHeadlessTests())
}
+129 -3
View File
@@ -10,6 +10,8 @@ import type {
BackgroundVideo,
ButtonAsset,
ButtonState,
CameraKeyframe,
CameraProps,
Clip,
ClipInstance,
ClipTrack,
@@ -29,6 +31,12 @@ import type {
TrackGroup,
} from '@/types/scene'
import { BUTTON_STATE_TIMES, DEFAULT_BASE_PROPS } from '@/types/scene'
import {
createDefaultCameraTrack,
defaultCameraProps,
normalizeCameraTrack,
sampleCamera,
} from '@/lib/camera'
import { sampleLayer } from '@/lib/interpolate'
import { splitSvg as splitSvgRuntime } from '@/lib/svgSplit'
import {
@@ -247,6 +255,8 @@ interface State {
* `selectedLayerId`: selecting a clip clears the layer selection
* and vice versa. */
selectedClipInstanceId: string | null
/** Special editor selection for the page-level output camera. */
selectedCamera: boolean
playhead: number
playing: boolean
/** True between `play()` and the next `tick()`. Lets the first tick
@@ -407,7 +417,10 @@ function findLayerAnywhere(scene: Scene, layerId: string): Layer | null {
/** Per-page housekeeping: ensure all arrays exist, clean up orphan
* childIds in groups, dedupe, build trackOrder for any pristine track,
* and migrate pre-`colorOverridden` color flags. */
function normalizeScenePage(page: ScenePage): ScenePage {
function normalizeScenePage(
page: ScenePage,
canvas: Pick<Scene, 'canvas'>['canvas'] = { width: 1280, height: 720, background: '#ffffff' },
): ScenePage {
page.layers ||= []
page.clipInstances ||= []
page.clipTracks ||= []
@@ -418,6 +431,11 @@ function normalizeScenePage(page: ScenePage): ScenePage {
page.trackOrder ||= []
page.audioTracks ||= []
page.audioClips ||= []
if (page.camera !== undefined) {
const normalized = normalizeCameraTrack({ canvas }, page.camera)
if (normalized) page.camera = normalized
else delete page.camera
}
const known = new Set<string>()
for (const l of page.layers) known.add(l.id)
@@ -557,7 +575,7 @@ function normalizeProject(project: Project): Project {
if (project.scenes.length === 0) {
project.scenes.push(createBlankScenePage('Scene 1'))
}
for (const page of project.scenes) normalizeScenePage(page)
for (const page of project.scenes) normalizeScenePage(page, project.canvas)
if (!project.scenes.find((s) => s.id === project.activeSceneId)) {
project.activeSceneId = project.scenes[0].id
}
@@ -614,6 +632,7 @@ export function projectFromLegacyScene(scene: Scene, sceneName = 'Scene 1'): Pro
backgroundVideo: scene.backgroundVideo,
audioTracks: scene.audioTracks ?? [],
audioClips: scene.audioClips ?? [],
camera: scene.camera,
}
return normalizeProject({
version: 1,
@@ -653,6 +672,7 @@ export function createBlankScene(): Scene {
groups: page.groups,
trackOrder: page.trackOrder,
backgroundVideo: page.backgroundVideo,
camera: page.camera,
}
}
@@ -662,6 +682,7 @@ export const useSceneStore = defineStore('scene', {
selectedLayerId: null,
selectedLayerIds: [],
selectedClipInstanceId: null,
selectedCamera: false,
playhead: 0,
playing: false,
playStartPending: false,
@@ -719,6 +740,7 @@ export const useSceneStore = defineStore('scene', {
backgroundVideo: page.backgroundVideo,
audioTracks: page.audioTracks,
audioClips: page.audioClips,
camera: page.camera,
}
},
activeClip(): Clip | null {
@@ -1030,15 +1052,115 @@ export const useSceneStore = defineStore('scene', {
selectLayer(id: string | null) {
this.selectedLayerId = id
this.selectedLayerIds = id ? [id] : []
if (id) this.selectedClipInstanceId = null
if (id) {
this.selectedClipInstanceId = null
this.selectedCamera = false
}
},
selectClipInstance(id: string | null) {
this.selectedClipInstanceId = id
if (id) {
this.selectedLayerId = null
this.selectedLayerIds = []
this.selectedCamera = false
}
},
selectCamera(selected = true) {
this.selectedCamera = selected
if (selected) {
this.selectedLayerId = null
this.selectedLayerIds = []
this.selectedClipInstanceId = null
}
},
ensureCamera(enabled = true) {
if (!this.activeScene.camera) {
this.activeScene.camera = createDefaultCameraTrack(this.scene, enabled)
} else {
const normalized = normalizeCameraTrack(this.scene, this.activeScene.camera)
this.activeScene.camera = normalized ?? createDefaultCameraTrack(this.scene, enabled)
}
return this.activeScene.camera
},
enableCamera() {
const camera = this.ensureCamera(true)
camera.enabled = true
camera.overlayVisible = camera.overlayVisible !== false
this.selectCamera(true)
},
disableCamera() {
if (!this.activeScene.camera) return
this.activeScene.camera.enabled = false
},
setCameraOverlayVisible(visible: boolean) {
const camera = this.ensureCamera(false)
camera.overlayVisible = visible
},
setCameraLocked(locked: boolean) {
const camera = this.ensureCamera(false)
camera.locked = locked
},
setCameraBaseProps(props: Partial<CameraProps>) {
const camera = this.ensureCamera(true)
camera.base = { ...defaultCameraProps(this.scene), ...camera.base, ...props }
camera.base.width = Math.max(1, camera.base.width)
camera.base.height = Math.max(1, camera.base.height)
},
upsertCameraKeyframe(time: number, props: Partial<CameraProps>) {
const camera = this.ensureCamera(true)
if (camera.locked) return null
const t = Math.max(0, Math.min(this.scene.duration, time))
const existing = camera.keyframes.find((kf) => Math.abs(kf.time - t) < KEYFRAME_EPSILON)
const sampled = sampleCamera(this.scene, t)
const nextProps = { ...sampled, ...props }
nextProps.width = Math.max(1, nextProps.width)
nextProps.height = Math.max(1, nextProps.height)
if (existing) {
existing.props = { ...existing.props, ...nextProps }
return existing
}
const kf: CameraKeyframe = {
id: uid(),
time: t,
props: nextProps,
easing: 'linear',
}
camera.keyframes.push(kf)
camera.keyframes.sort((a, b) => a.time - b.time)
return kf
},
removeCameraKeyframe(id: string) {
const camera = this.activeScene.camera
if (!camera || camera.locked) return
camera.keyframes = camera.keyframes.filter((kf) => kf.id !== id)
},
removeCameraKeyframeAt(time: number) {
const camera = this.activeScene.camera
if (!camera || camera.locked) return
camera.keyframes = camera.keyframes.filter(
(kf) => Math.abs(kf.time - time) >= KEYFRAME_EPSILON,
)
},
setCameraKeyframeTime(id: string, time: number) {
const camera = this.activeScene.camera
if (!camera || camera.locked) return
const kf = camera.keyframes.find((item) => item.id === id)
if (!kf) return
kf.time = Math.max(0, Math.min(this.scene.duration, time))
camera.keyframes.sort((a, b) => a.time - b.time)
},
setCameraKeyframeEasing(id: string, easing: Easing) {
const camera = this.activeScene.camera
if (!camera || camera.locked) return
const kf = camera.keyframes.find((item) => item.id === id)
if (kf) kf.easing = easing
},
setCameraKeyframeHold(id: string, hold: boolean) {
const camera = this.activeScene.camera
if (!camera || camera.locked) return
const kf = camera.keyframes.find((item) => item.id === id)
if (kf) kf.hold = hold
},
/** Shift-click behaviour: add the layer to the selection if it's
* not already there; otherwise remove it. The anchor moves to
* the most-recently-clicked id so the Properties panel follows
@@ -1052,6 +1174,7 @@ export const useSceneStore = defineStore('scene', {
if (g.locked && g.childIds.includes(id)) return
}
const existing = this.selectedLayerIds.indexOf(id)
this.selectedCamera = false
if (existing >= 0) {
this.selectedLayerIds.splice(existing, 1)
this.selectedLayerId =
@@ -2706,6 +2829,7 @@ export const useSceneStore = defineStore('scene', {
this.selectedLayerId = null
this.selectedLayerIds = []
this.selectedClipInstanceId = null
this.selectedCamera = false
this.playhead = 0
this.playing = false
this.activeClipId = null
@@ -2791,6 +2915,7 @@ export const useSceneStore = defineStore('scene', {
this.selectedLayerId = null
this.selectedLayerIds = []
this.selectedClipInstanceId = null
this.selectedCamera = false
this.drawingMode = null
this.drawingAssetId = null
this.drawingLayerId = null
@@ -3450,6 +3575,7 @@ export const useSceneStore = defineStore('scene', {
)
if (inst && childIds.has(inst.clipTrackId)) {
this.selectedClipInstanceId = null
this.selectedCamera = false
}
}
// Dispatch the right remove action per child. Each call cleans
+30
View File
@@ -57,6 +57,34 @@ export interface Keyframe {
ctrlY?: number
}
export interface CameraProps {
/** Top-left stage coordinate of the visible output rectangle. */
x: number
y: number
width: number
height: number
}
export interface CameraKeyframe {
id: string
time: number
props: Partial<CameraProps>
easing: Easing
hold?: boolean
}
export interface CameraTrack {
id: string
name?: string
enabled: boolean
base: CameraProps
keyframes: CameraKeyframe[]
/** Editor guide only. Hidden overlays still render in preview/export. */
overlayVisible?: boolean
/** Editor lock only. Locked cameras still render when enabled. */
locked?: boolean
}
export type AssetKind = 'image' | 'rect' | 'circle' | 'text' | 'polygon' | 'button' | 'audio' | 'svg'
export type ButtonState = 'normal' | 'hover' | 'pressed' | 'disabled'
@@ -445,6 +473,7 @@ export interface Scene {
markers?: Marker[]
audioTracks?: AudioTrack[]
audioClips?: AudioClip[]
camera?: CameraTrack
}
/**
@@ -467,6 +496,7 @@ export interface ScenePage {
backgroundVideo?: BackgroundVideo
audioTracks: AudioTrack[]
audioClips: AudioClip[]
camera?: CameraTrack
}
/**
+1 -1
View File
@@ -1,2 +1,2 @@
export const APP_VERSION = '1.3.0'
export const APP_VERSION = '1.3.2'
export const APP_NAME = 'RUWIO'
+54
View File
@@ -5,6 +5,60 @@ import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
base: './',
plugins: [vue()],
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('/src/i18n/')) return 'i18n'
if (
id.includes('/src/components/CanvasStage.vue') ||
id.includes('/src/components/AssetSidebar.vue') ||
id.includes('/src/components/PropertiesPanel.vue') ||
id.includes('/src/components/ActionEditor.vue') ||
id.includes('/src/components/FontPicker.vue') ||
id.includes('/src/components/RangeSlider.vue') ||
id.includes('/src/components/SvgLayerPanel.vue') ||
id.includes('/src/stores/scene.ts') ||
id.includes('/src/types/scene.ts') ||
id.includes('/src/lib/render.ts') ||
id.includes('/src/lib/interpolate.ts') ||
id.includes('/src/lib/wiggle.ts') ||
id.includes('/src/lib/kaleido.ts') ||
id.includes('/src/lib/clipBounds.ts') ||
id.includes('/src/lib/actions.ts') ||
id.includes('/src/lib/sceneOrder.ts') ||
id.includes('/src/composables/useButtonRuntime.ts')
) {
return 'editor'
}
if (
id.includes('/src/components/Timeline.vue') ||
id.includes('/src/lib/timelineSnap.ts') ||
id.includes('/src/composables/useAudioWaveform.ts')
) {
return 'timeline'
}
if (!id.includes('/node_modules/')) return undefined
if (id.includes('/@phosphor-icons/vue/')) return 'icons'
if (id.includes('/konva/') || id.includes('/vue-konva/')) return 'canvas'
if (
id.includes('/gifenc/') ||
id.includes('/upng-js/')
) {
return 'encoders'
}
if (
id.includes('/vue/') ||
id.includes('/@vue/') ||
id.includes('/pinia/')
) {
return 'framework'
}
return 'vendor'
},
},
},
},
server: {
port: 5200,