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 3384 additions and 380 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 ```bash
npm install npm install
npm run desktop:dev # start Vite, then launch Electron against localhost 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/ npm run desktop:build # build platform installer/package artifacts into release/
``` ```
`desktop:pack` is the quickest compile check because it produces an unpacked `desktop:dir` is the quickest compile check because it produces an unpacked app
app without creating an installer. `desktop:build` uses `electron-builder` and without creating an installer. `desktop:pack` and `desktop:build` use
targets AppImage on Linux, NSIS on Windows, and DMG on macOS. `electron-builder` and target AppImage on Linux, NSIS on Windows, and DMG on
macOS.
Desktop MP4 export runs inside Electron and uses ffmpeg from Desktop MP4 export runs inside Electron and uses ffmpeg from
`resources/bin/<platform>/` in packaged builds. The build scripts run `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 Date: 2026-06-05
Source review: `dev_docs/security-review.md` Source review: `dev_docs/02_security-review.md`
## Goal ## Goal
@@ -19,7 +19,7 @@ Close the security issues from the review without changing RUWIO's core authorin
Files likely touched: Files likely touched:
- `dev_docs/security-review.md` - `dev_docs/02_security-review.md`
- `package.json` - `package.json`
- `server/package.json` - `server/package.json`
- `server/server.js` - `server/server.js`
@@ -2,7 +2,7 @@
Date: 2026-06-05 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 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 here. It records what is already implemented, what was found during review, and
the concrete remaining TODOs in priority order. the concrete remaining TODOs in priority order.
+3 -3
View File
@@ -2,7 +2,7 @@
Date: 2026-06-06 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 That plan tracked findings **#1–#6** (all resolved). A second review of the
uncommitted desktop-render changes surfaced a new set of issues, numbered 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 **#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. `*://*/*` network block (plan_2 #5) does not drop them — no action needed.
## Suggested doc bookkeeping ## 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), - 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 Node/Electron **render harnesses** (`scripts/verify-*.cjs`),
- the **server** tests (`server/{server.smoke,render.e2e}.test.cjs`), - the **server** tests (`server/{server.smoke,render.e2e}.test.cjs`),
- the **shared validation** unit tests (`shared/renderValidation.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** > **TL;DR**
> 1. **Don't fork the suite per build.** The frontend is ~99% shared and gated > 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 - A root `test` script aggregating the CI-safe layers, and a first
`.github/workflows/test.yml` (matrix: lint/build, node `test:*`, `.github/workflows/test.yml` (matrix: lint/build, node `test:*`,
`test:render-*` with ffmpeg/Chromium, Playwright browser + electron jobs). `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). and list what still needs a HiDPI/macOS box (#10, D10).
### Proposed `package.json` scripts (final shape) ### Proposed `package.json` scripts (final shape)
@@ -361,6 +361,6 @@ Phased so each phase is independently valuable and CI-wireable.
run under both hosts. run under both hosts.
- Driving real native OS menus / native save dialogs in automation (brittle); we use - 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 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 offscreen-rendering rewrite (deferred per the fix-plan); if it lands later,
the §4 capture matrix is exactly the harness that re-validates it. 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
+17 -6
View File
@@ -10,6 +10,7 @@ const { validateRenderRequest } = require('../shared/renderValidation.cjs') as {
} }
const isDev = !app.isPackaged const isDev = !app.isPackaged
const isE2E = process.env.RUWIO_E2E === '1'
const devServerUrl = process.env.VITE_DEV_SERVER_URL ?? 'http://localhost:5200' const devServerUrl = process.env.VITE_DEV_SERVER_URL ?? 'http://localhost:5200'
const devServerOrigin = new URL(devServerUrl).origin const devServerOrigin = new URL(devServerUrl).origin
const appIconPath = isDev const appIconPath = isDev
@@ -43,6 +44,12 @@ function sendAppCommand(command: string): void {
mainWindow.webContents.send('ruwio:menu-command', command) 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( function sendRenderProgress(
target: Electron.WebContents, target: Electron.WebContents,
event: { jobId: string; progress: number; done: boolean; error?: string }, event: { jobId: string; progress: number; done: boolean; error?: string },
@@ -101,7 +108,12 @@ async function runRenderMp4(event: IpcMainInvokeEvent, payload: RenderMp4Payload
defaultPath: sanitizeMp4Name(payload?.suggestedName), defaultPath: sanitizeMp4Name(payload?.suggestedName),
filters: [{ name: 'MP4 Video', extensions: ['mp4'] }], filters: [{ name: 'MP4 Video', extensions: ['mp4'] }],
} }
const saveResult = window 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(window, saveOptions)
: await dialog.showSaveDialog(saveOptions) : await dialog.showSaveDialog(saveOptions)
if (saveResult.canceled || !saveResult.filePath) return null if (saveResult.canceled || !saveResult.filePath) return null
@@ -340,16 +352,12 @@ function createMenu(): void {
label: 'About RUWIO', label: 'About RUWIO',
click: () => sendAppCommand('about'), click: () => sendAppCommand('about'),
}, },
...(isDev { type: 'separator' },
? [
{ type: 'separator' as const },
{ {
label: 'Open Test Runner', label: 'Open Test Runner',
accelerator: 'CmdOrCtrl+Shift+Alt+T', accelerator: 'CmdOrCtrl+Shift+Alt+T',
click: () => sendAppCommand('test-runner'), click: () => sendAppCommand('test-runner'),
}, },
]
: []),
], ],
}, },
] ]
@@ -379,6 +387,9 @@ app.setName('RUWIO')
app.whenReady().then(() => { app.whenReady().then(() => {
ipcMain.handle('ruwio:render-mp4', handleRenderMp4) ipcMain.handle('ruwio:render-mp4', handleRenderMp4)
ipcMain.handle('ruwio:render-cancel', handleRenderCancel) ipcMain.handle('ruwio:render-cancel', handleRenderCancel)
if (isE2E) {
globalThis.__ruwioTestMenuCommand = (command: string) => sendAppCommand(command)
}
createMenu() createMenu()
createMainWindow() createMainWindow()
+1 -1
View File
@@ -9,7 +9,7 @@
<meta name="theme-color" content="#17171b" /> <meta name="theme-color" content="#17171b" />
<meta <meta
http-equiv="Content-Security-Policy" 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> <title>RUWIO</title>
</head> </head>
+5 -4
View File
@@ -1,7 +1,7 @@
{ {
"name": "ruwio", "name": "ruwio",
"private": true, "private": true,
"version": "1.3.0", "version": "1.3.2",
"type": "module", "type": "module",
"main": "dist-electron/main.cjs", "main": "dist-electron/main.cjs",
"scripts": { "scripts": {
@@ -10,7 +10,8 @@
"desktop:dev": "concurrently -k \"vite\" \"wait-on http://localhost:5200 && npm run desktop:start\"", "desktop:dev": "concurrently -k \"vite\" \"wait-on http://localhost:5200 && npm run desktop:start\"",
"desktop:start": "tsc -p tsconfig.electron.json && electron .", "desktop:start": "tsc -p tsconfig.electron.json && electron .",
"desktop:build": "npm run build && tsc -p tsconfig.electron.json && npm run stage:ffmpeg -- --optional && electron-builder", "desktop:build": "npm run build && tsc -p tsconfig.electron.json && npm run stage:ffmpeg -- --optional && electron-builder",
"desktop:pack": "npm run build && tsc -p tsconfig.electron.json && 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", "preview": "vite preview",
"type-check": "vue-tsc --noEmit", "type-check": "vue-tsc --noEmit",
"pack": "bash scripts/pack.sh", "pack": "bash scripts/pack.sh",
@@ -18,7 +19,7 @@
"test:render-server": "node --test server/server.smoke.test.cjs", "test:render-server": "node --test server/server.smoke.test.cjs",
"test:render-server:e2e": "node --test server/render.e2e.test.cjs", "test:render-server:e2e": "node --test server/render.e2e.test.cjs",
"test:render-desktop": "tsc -p tsconfig.electron.json && node scripts/verify-desktop-render.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" "stage:ffmpeg": "node scripts/stage-ffmpeg.cjs"
}, },
"dependencies": { "dependencies": {
@@ -45,7 +46,7 @@
"appId": "app.ruwio.desktop", "appId": "app.ruwio.desktop",
"productName": "RUWIO", "productName": "RUWIO",
"icon": "icon.png", "icon": "icon.png",
"npmRebuild": false, "beforeBuild": "scripts/electron-before-build.cjs",
"directories": { "directories": {
"output": "release" "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 // Reproduces renderWorker.cts's window-creation + capturePage path against a
// scene with one moving + fading layer, then checks that captured frames are // 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 // 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. // results. Each harness exits 0 on PASS, non-zero on FAIL.
// //
// npm run test:render-desktop (compiles electron TS first, then runs this) // 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 path = require('node:path')
const electron = require('electron') // resolves to the electron binary 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 = [ const harnesses = [
['capture', 'verify-capture.cjs', 'hidden-window capturePage produces advancing, non-blank frames (#1/#2)'], ['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'], ['capture-fidelity', 'verify-capture-fidelity.cjs', 'capturePage matches render.html canvas pixels'],
@@ -26,8 +30,9 @@ const harnesses = [
let failures = 0 let failures = 0
for (const [name, file, desc] of harnesses) { for (const [name, file, desc] of harnesses) {
process.stdout.write(`\n=== ${name}: ${desc} ===\n`) 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 stdio: ['ignore', 'inherit', 'ignore'], // drop Electron's chatty stderr
env: electronEnv,
}) })
if (res.status !== 0) { if (res.status !== 0) {
failures += 1 failures += 1
+59
View File
@@ -102,6 +102,56 @@
} }
return merge(layer.base); 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){ function loadImages(assets){
var imgs={}; var imgs={};
var list=assets.filter(function(a){return a.kind==='image';}); var list=assets.filter(function(a){return a.kind==='image';});
@@ -267,8 +317,17 @@
function drawFrame(time){ function drawFrame(time){
if(!scene) return; if(!scene) return;
var lw=scene.canvas.width, lh=scene.canvas.height; 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.save();
ctx.beginPath(); ctx.rect(0,0,lw,lh); ctx.clip(); 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){ if(!scene.backgroundVideo){
ctx.fillStyle=scene.canvas.background; ctx.fillStyle=scene.canvas.background;
ctx.fillRect(0,0,lw,lh); 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) const duration = Number(scene.duration)
if (!Number.isFinite(duration) || duration <= 0 || duration > limits.maxDuration) { if (!Number.isFinite(duration) || duration <= 0 || duration > limits.maxDuration) {
return { ok: false, error: `duration must be between 0 and ${limits.maxDuration} seconds` } 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) 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', () => { 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({ inTime: 0.25, outTime: 1.5 })), { start: 0.25, end: 1.5 })
assert.deepEqual(playbackRange(scene()), { start: 0, end: 1 }) assert.deepEqual(playbackRange(scene()), { start: 0, end: 1 })
+26 -10
View File
@@ -12,6 +12,7 @@ import {
PhFloppyDisk, PhFloppyDisk,
PhGearSix, PhGearSix,
PhImageSquare, PhImageSquare,
PhMonitorPlay,
PhPause, PhPause,
PhPencilSimpleLine, PhPencilSimpleLine,
PhPlay, PhPlay,
@@ -28,7 +29,6 @@ import ClipartDrawer from './components/ClipartDrawer.vue'
import SceneDrawer from './components/SceneDrawer.vue' import SceneDrawer from './components/SceneDrawer.vue'
import DocumentDrawer from './components/DocumentDrawer.vue' import DocumentDrawer from './components/DocumentDrawer.vue'
import DocumentSettings from './components/DocumentSettings.vue' import DocumentSettings from './components/DocumentSettings.vue'
import ExportDialog from './components/ExportDialog.vue'
import FullscreenPreview from './components/FullscreenPreview.vue' import FullscreenPreview from './components/FullscreenPreview.vue'
import PropertiesPanel from './components/PropertiesPanel.vue' import PropertiesPanel from './components/PropertiesPanel.vue'
import SvgLayerPanel from './components/SvgLayerPanel.vue' import SvgLayerPanel from './components/SvgLayerPanel.vue'
@@ -46,7 +46,6 @@ import { usePlaybackLoop } from './composables/usePlaybackLoop'
import { useTimeFormat } from './composables/useTimeFormat' import { useTimeFormat } from './composables/useTimeFormat'
import { useProjectName } from './composables/useProjectName' import { useProjectName } from './composables/useProjectName'
import { shouldChromeClickDeselect } from './lib/chromeClick' import { shouldChromeClickDeselect } from './lib/chromeClick'
import { renderSceneToStillImage } from './lib/stillImageExport'
import { ProjectFileError, pickProjectFile } from './lib/projectFile' import { ProjectFileError, pickProjectFile } from './lib/projectFile'
import { readAnyProjectFile } from './lib/ruwFile' import { readAnyProjectFile } from './lib/ruwFile'
import { import {
@@ -70,9 +69,12 @@ const welcomeOpen = ref(false)
const fullscreenOpen = ref(false) const fullscreenOpen = ref(false)
const aboutOpen = ref(false) const aboutOpen = ref(false)
const helpOpen = 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')) ? defineAsyncComponent(() => import('./devtools/TestOverlay.vue'))
: null : null
const ExportDialog = defineAsyncComponent(() => import('./components/ExportDialog.vue'))
const TIMELINE_HEIGHT_KEY = 'animatr:timelineHeight' const TIMELINE_HEIGHT_KEY = 'animatr:timelineHeight'
const DEFAULT_TIMELINE_HEIGHT = 220 const DEFAULT_TIMELINE_HEIGHT = 220
const MIN_TIMELINE_HEIGHT = 120 const MIN_TIMELINE_HEIGHT = 120
@@ -194,6 +196,11 @@ function stop() {
store.pause() store.pause()
} }
function openFullscreenPreview() {
store.pause()
fullscreenOpen.value = true
}
async function saveProject() { async function saveProject() {
// projectForSave stitches the current editor-session UI state // projectForSave stitches the current editor-session UI state
// (zoom, pan, overlay toggles, timeline display) onto the // (zoom, pan, overlay toggles, timeline display) onto the
@@ -313,6 +320,7 @@ async function copyStillToClipboard() {
return return
} }
try { try {
const { renderSceneToStillImage } = await import('./lib/stillImageExport')
const scene = JSON.parse(JSON.stringify(store.scene)) const scene = JSON.parse(JSON.stringify(store.scene))
const blob = await renderSceneToStillImage(scene, store.playhead, 'png') const blob = await renderSceneToStillImage(scene, store.playhead, 'png')
// ClipboardItem is widely available in modern Chromium / Firefox // ClipboardItem is widely available in modern Chromium / Firefox
@@ -424,7 +432,7 @@ function nudgeSelection(dx: number, dy: number): boolean {
function onKeyDown(e: KeyboardEvent) { function onKeyDown(e: KeyboardEvent) {
if (e.ctrlKey && e.shiftKey && e.altKey && (e.code === 'KeyT' || e.key.toLowerCase() === 't')) { if (e.ctrlKey && e.shiftKey && e.altKey && (e.code === 'KeyT' || e.key.toLowerCase() === 't')) {
e.preventDefault() e.preventDefault()
if (import.meta.env.DEV) testsOpen.value = true if (canUseTestRunner) testsOpen.value = true
return return
} }
// TAB toggles the side panels (asset sidebar + properties aside) // TAB toggles the side panels (asset sidebar + properties aside)
@@ -506,8 +514,7 @@ function onKeyDown(e: KeyboardEvent) {
if (isEditable(e.target)) return if (isEditable(e.target)) return
e.preventDefault() e.preventDefault()
if (e.shiftKey) { if (e.shiftKey) {
store.pause() openFullscreenPreview()
fullscreenOpen.value = true
return return
} }
store.togglePlay() store.togglePlay()
@@ -593,8 +600,7 @@ async function runDesktopMenuCommand(command: string) {
rewind() rewind()
break break
case 'fullscreen-preview': case 'fullscreen-preview':
store.pause() openFullscreenPreview()
fullscreenOpen.value = true
break break
case 'toggle-panels': case 'toggle-panels':
panelsHidden.value = !panelsHidden.value panelsHidden.value = !panelsHidden.value
@@ -612,7 +618,7 @@ async function runDesktopMenuCommand(command: string) {
aboutOpen.value = true aboutOpen.value = true
break break
case 'test-runner': case 'test-runner':
if (import.meta.env.DEV) testsOpen.value = true if (canUseTestRunner) testsOpen.value = true
break break
} }
} }
@@ -704,6 +710,13 @@ onBeforeUnmount(() => {
@click="store.togglePlay()" @click="store.togglePlay()"
/> />
<div class="transport-side transport-right"> <div class="transport-side transport-right">
<IconButton
:icon="PhMonitorPlay"
icon-weight="regular"
size="md"
:title="t('app.fullscreenPlayback')"
@click="openFullscreenPreview"
/>
<IconButton <IconButton
:icon="PhStop" :icon="PhStop"
icon-weight="fill" icon-weight="fill"
@@ -745,7 +758,10 @@ onBeforeUnmount(() => {
</button> </button>
<button <button
class="export-btn" 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" @click="exportOpen = true"
> >
<PhExport :size="14" weight="bold" /> <PhExport :size="14" weight="bold" />
+4 -81
View File
@@ -33,7 +33,7 @@ import {
setClipDragSource, setClipDragSource,
} from '@/composables/useClipDragSource' } from '@/composables/useClipDragSource'
import type { Asset } from '@/types/scene' 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 { loadSceneImages, renderButtonThumbnail, renderSvgThumbnail } from '@/lib/render'
import { t } from '@/i18n' import { t } from '@/i18n'
@@ -624,21 +624,11 @@ function onAssetDragStart(e: DragEvent, asset: ImageOrSvgAsset) {
> >
<div class="assets-header"> <div class="assets-header">
<h3>Assets</h3> <h3>Assets</h3>
<label <ToggleSwitch
class="thumbs-switch" v-model="showThumbs"
:label="t('sidebar.thumbsToggle')"
v-tooltip="showThumbs ? t('sidebar.thumbnailsHide') : t('sidebar.thumbnailsShow')" 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> </div>
<button class="upload-btn" @click="triggerUpload"> <button class="upload-btn" @click="triggerUpload">
@@ -988,73 +978,6 @@ h4 {
gap: 10px; 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 /* Section rows: unified layout — [caret + title][action?][count]. The
* count always sits at the far right so the right column is visually * 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. */ * stable regardless of whether a row has a create-action (+) beside it. */
+157
View File
@@ -30,12 +30,14 @@ import {
shouldRotateClipCopies, shouldRotateClipCopies,
} from '@/lib/kaleido' } from '@/lib/kaleido'
import { drawButtonFrame, drawSceneFrame, loadSceneImages } from '@/lib/render' import { drawButtonFrame, drawSceneFrame, loadSceneImages } from '@/lib/render'
import { sampleCamera } from '@/lib/camera'
import type { import type {
Action, Action,
AnimatableProps, AnimatableProps,
Asset, Asset,
ButtonAsset, ButtonAsset,
ButtonState, ButtonState,
CameraProps,
ClipInstance, ClipInstance,
HitShape, HitShape,
Keyframe, 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 /** Horizontal + vertical centre-crosshair line configs. Null when
* the toggle is off. Lines span the effective canvas so the centre * the toggle is off. Lines span the effective canvas so the centre
* is exactly where layer keyframes default to (canvas.width/2, * 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.horizontal" />
<v-line :config="centerCrosshairConfigs.vertical" /> <v-line :config="centerCrosshairConfigs.vertical" />
</template> </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-rect
v-for="h in canvasResizeHandles" v-for="h in canvasResizeHandles"
:key="`canvas-resize-${h.edge}`" :key="`canvas-resize-${h.edge}`"
@@ -4926,6 +5075,14 @@ function onResizeHandlePointerUp(e: PointerEvent) {
> >
<PhCursorClick :size="13" /> <PhCursorClick :size="13" />
</button> </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 <button
class="zc-btn motion-paths-btn" class="zc-btn motion-paths-btn"
:class="{ active: showMotionPaths }" :class="{ active: showMotionPaths }"
+32 -56
View File
@@ -14,8 +14,9 @@ import type { Theme } from '@/composables/useTheme'
import { useTooltipPref } from '@/composables/useTooltipPref' import { useTooltipPref } from '@/composables/useTooltipPref'
import { useAssetThumbsPref } from '@/composables/useAssetThumbsPref' import { useAssetThumbsPref } from '@/composables/useAssetThumbsPref'
import { useShowWelcomeOnStart } from '@/composables/useShowWelcomeOnStart' import { useShowWelcomeOnStart } from '@/composables/useShowWelcomeOnStart'
import { useFullscreenPreviewAutoplay } from '@/composables/useFullscreenPreviewAutoplay'
import { showLocked } from '@/composables/useShowLocked' import { showLocked } from '@/composables/useShowLocked'
import { Modal } from '@/components/ui' import { Modal, ToggleSwitch } from '@/components/ui'
import { setLocale, t, useI18n } from '@/i18n' import { setLocale, t, useI18n } from '@/i18n'
import type { Locale } from '@/i18n' import type { Locale } from '@/i18n'
@@ -27,6 +28,7 @@ const { theme, setTheme } = useTheme()
const { enabled: tooltipsEnabled } = useTooltipPref() const { enabled: tooltipsEnabled } = useTooltipPref()
const { enabled: thumbsEnabled } = useAssetThumbsPref() const { enabled: thumbsEnabled } = useAssetThumbsPref()
const { enabled: welcomeOnStart } = useShowWelcomeOnStart() const { enabled: welcomeOnStart } = useShowWelcomeOnStart()
const { enabled: fullscreenAutoplay } = useFullscreenPreviewAutoplay()
const { locale } = useI18n() const { locale } = useI18n()
// Re-check the stored clipart folder whenever settings opens — covers the // Re-check the stored clipart folder whenever settings opens — covers the
@@ -245,9 +247,10 @@ function applyTheme(t: Theme) {
</div> </div>
<label class="row-toggle"> <label class="row-toggle">
<span class="switch" :class="{ on: tooltipsEnabled }"> <ToggleSwitch
<input type="checkbox" v-model="tooltipsEnabled" /> v-model="tooltipsEnabled"
</span> :aria-label="t('settings.tooltipsLabel')"
/>
<div class="row-toggle-text"> <div class="row-toggle-text">
<div class="row-toggle-label">{{ t('settings.tooltipsLabel') }}</div> <div class="row-toggle-label">{{ t('settings.tooltipsLabel') }}</div>
<div class="row-toggle-desc">{{ t('settings.tooltipsDesc') }}</div> <div class="row-toggle-desc">{{ t('settings.tooltipsDesc') }}</div>
@@ -255,9 +258,10 @@ function applyTheme(t: Theme) {
</label> </label>
<label class="row-toggle"> <label class="row-toggle">
<span class="switch" :class="{ on: showLocked }"> <ToggleSwitch
<input type="checkbox" v-model="showLocked" /> v-model="showLocked"
</span> :aria-label="t('settings.showLockedLabel')"
/>
<div class="row-toggle-text"> <div class="row-toggle-text">
<div class="row-toggle-label">{{ t('settings.showLockedLabel') }}</div> <div class="row-toggle-label">{{ t('settings.showLockedLabel') }}</div>
<div class="row-toggle-desc">{{ t('settings.showLockedDesc') }}</div> <div class="row-toggle-desc">{{ t('settings.showLockedDesc') }}</div>
@@ -265,9 +269,10 @@ function applyTheme(t: Theme) {
</label> </label>
<label class="row-toggle"> <label class="row-toggle">
<span class="switch" :class="{ on: thumbsEnabled }"> <ToggleSwitch
<input type="checkbox" v-model="thumbsEnabled" /> v-model="thumbsEnabled"
</span> :aria-label="t('settings.assetThumbsLabel')"
/>
<div class="row-toggle-text"> <div class="row-toggle-text">
<div class="row-toggle-label">{{ t('settings.assetThumbsLabel') }}</div> <div class="row-toggle-label">{{ t('settings.assetThumbsLabel') }}</div>
<div class="row-toggle-desc">{{ t('settings.assetThumbsDesc') }}</div> <div class="row-toggle-desc">{{ t('settings.assetThumbsDesc') }}</div>
@@ -275,15 +280,27 @@ function applyTheme(t: Theme) {
</label> </label>
<label class="row-toggle"> <label class="row-toggle">
<span class="switch" :class="{ on: welcomeOnStart }"> <ToggleSwitch
<input type="checkbox" v-model="welcomeOnStart" /> v-model="welcomeOnStart"
</span> :aria-label="t('settings.welcomeOnStartLabel')"
/>
<div class="row-toggle-text"> <div class="row-toggle-text">
<div class="row-toggle-label">{{ t('settings.welcomeOnStartLabel') }}</div> <div class="row-toggle-label">{{ t('settings.welcomeOnStartLabel') }}</div>
<div class="row-toggle-desc">{{ t('settings.welcomeOnStartDesc') }}</div> <div class="row-toggle-desc">{{ t('settings.welcomeOnStartDesc') }}</div>
</div> </div>
</label> </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-block">
<div class="setting-heading">{{ t('settings.appearanceTitle') }}</div> <div class="setting-heading">{{ t('settings.appearanceTitle') }}</div>
<div class="setting-desc">{{ t('settings.themeHint') }}</div> <div class="setting-desc">{{ t('settings.themeHint') }}</div>
@@ -550,10 +567,10 @@ input[type='number'] {
letter-spacing: normal; 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; blanket `label { flex-direction: column; text-transform: uppercase;
font-size: 10px; flex: 1 1 0; }` that form-row labels in this file 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. */ top of the text and uppercase everything inside. */
.row-toggle { .row-toggle {
display: flex; display: flex;
@@ -599,47 +616,6 @@ input[type='number'] {
text-transform: none; 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 { .clipart-row {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+55 -71
View File
@@ -12,18 +12,11 @@ import {
PhGif, PhGif,
} from '@phosphor-icons/vue' } from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene' 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 { useProjectName } from '@/composables/useProjectName'
import type { ButtonAsset, Layer } from '@/types/scene' import type { ButtonAsset, Layer } from '@/types/scene'
import { Modal } from '@/components/ui' import { Modal, ToggleSwitch } from '@/components/ui'
import { t } from '@/i18n' import { t } from '@/i18n'
import { hasDesktopRender } from '@/lib/desktopRenderExport'
const props = defineProps<{ modelValue: boolean }>() const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>() const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
@@ -39,7 +32,22 @@ interface ProgressState {
} }
const progress = ref<ProgressState | null>(null) 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`) // Opt-in: strip layers that are hidden in the editor (`editorHidden`)
// from the exported scene. Default off — editor-hide is a workflow // from the exported scene. Default off — editor-hide is a workflow
@@ -198,17 +206,20 @@ function activeSceneClone() {
return scene return scene
} }
function exportScene() { async function exportScene() {
const { downloadSceneExport } = await import('@/lib/exportPlayer')
downloadSceneExport(projectClone()) downloadSceneExport(projectClone())
emit('update:modelValue', false) emit('update:modelValue', false)
} }
function exportRuntime() { async function exportRuntime() {
const { downloadRuntime } = await import('@/lib/exportPlayer')
downloadRuntime() downloadRuntime()
emit('update:modelValue', false) 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 // Re-import target: .ruw is the round-trippable format the welcome
// dialog + file-picker accept. Pull the project via projectForSave // dialog + file-picker accept. Pull the project via projectForSave
// so the editor-session UI state (zoom, toggles, …) rides along. // so the editor-session UI state (zoom, toggles, …) rides along.
@@ -219,7 +230,8 @@ function exportRuwBundle() {
emit('update:modelValue', false) emit('update:modelValue', false)
} }
function exportHtml() { async function exportHtml() {
const { downloadExampleHtml } = await import('@/lib/exportPlayer')
downloadExampleHtml(projectClone()) downloadExampleHtml(projectClone())
emit('update:modelValue', false) emit('update:modelValue', false)
} }
@@ -234,6 +246,7 @@ async function exportStillImage(format: 'png' | 'jpg') {
const opts = targetSizeChanged.value const opts = targetSizeChanged.value
? { targetWidth: targetWidth.value, targetHeight: targetHeight.value } ? { targetWidth: targetWidth.value, targetHeight: targetHeight.value }
: undefined : undefined
const { downloadSceneAsStillImage } = await import('@/lib/stillImageExport')
await downloadSceneAsStillImage( await downloadSceneAsStillImage(
activeSceneClone(), activeSceneClone(),
store.playhead, store.playhead,
@@ -250,11 +263,12 @@ async function exportWebm() {
progress.value = { progress.value = {
progress: 0, progress: 0,
controller, controller,
label: t('export.labelWebm'), label: t(isDesktop.value ? 'export.labelWebmDesktop' : 'export.labelWebmBrowser'),
error: null, error: null,
} }
store.pause() store.pause()
try { try {
const { downloadSceneAsVideo } = await import('@/lib/videoExport')
await downloadSceneAsVideo(activeSceneClone(), { await downloadSceneAsVideo(activeSceneClone(), {
signal: controller.signal, signal: controller.signal,
onProgress: (p) => { onProgress: (p) => {
@@ -283,6 +297,7 @@ async function exportGif() {
} }
store.pause() store.pause()
try { try {
const { downloadSceneAsGif } = await import('@/lib/gifExport')
await downloadSceneAsGif( await downloadSceneAsGif(
activeSceneClone(), activeSceneClone(),
frameExportOpts(controller, (p) => { frameExportOpts(controller, (p) => {
@@ -311,6 +326,7 @@ async function exportApng() {
} }
store.pause() store.pause()
try { try {
const { downloadSceneAsApng } = await import('@/lib/apngExport')
await downloadSceneAsApng( await downloadSceneAsApng(
activeSceneClone(), activeSceneClone(),
frameExportOpts(controller, (p) => { frameExportOpts(controller, (p) => {
@@ -339,6 +355,7 @@ async function exportApngPreview() {
} }
store.pause() store.pause()
try { try {
const { downloadSceneAsApngPreview } = await import('@/lib/apngExport')
await downloadSceneAsApngPreview( await downloadSceneAsApngPreview(
activeSceneClone(), activeSceneClone(),
frameExportOpts(controller, (p) => { frameExportOpts(controller, (p) => {
@@ -356,7 +373,7 @@ async function exportApngPreview() {
} }
} }
async function exportMp4Server() { async function exportMp4() {
if (progress.value) return if (progress.value) return
const controller = new AbortController() const controller = new AbortController()
const base = (projectName.value ?? 'animation').toString() const base = (projectName.value ?? 'animation').toString()
@@ -364,13 +381,14 @@ async function exportMp4Server() {
progress.value = { progress.value = {
progress: 0, progress: 0,
controller, controller,
label: t('export.labelMp4'), label: t(hasDesktopMp4.value ? 'export.labelMp4Desktop' : 'export.labelMp4Server'),
error: null, error: null,
} }
store.pause() store.pause()
try { try {
const scene = activeSceneClone() const scene = activeSceneClone()
if (hasDesktopRender()) { if (hasDesktopRender()) {
const { renderMp4OnDesktop } = await import('@/lib/desktopRenderExport')
const result = await renderMp4OnDesktop(scene, { const result = await renderMp4OnDesktop(scene, {
signal: controller.signal, signal: controller.signal,
fps: targetFps.value, fps: targetFps.value,
@@ -384,6 +402,7 @@ async function exportMp4Server() {
return return
} }
} else { } else {
const { downloadFromServer } = await import('@/lib/serverExport')
await downloadFromServer(scene, { await downloadFromServer(scene, {
signal: controller.signal, signal: controller.signal,
fps: targetFps.value, fps: targetFps.value,
@@ -435,13 +454,12 @@ function onUpdate(v: boolean) {
:class="{ disabled: !hasHiddenLayers }" :class="{ disabled: !hasHiddenLayers }"
v-tooltip="hasHiddenLayers ? undefined : t('export.skipHiddenNone')" v-tooltip="hasHiddenLayers ? undefined : t('export.skipHiddenNone')"
> >
<span class="switch" :class="{ on: skipHidden && hasHiddenLayers }"> <ToggleSwitch
<input :model-value="skipHidden && hasHiddenLayers"
type="checkbox" :aria-label="t('export.skipHidden')"
v-model="skipHidden"
:disabled="!hasHiddenLayers" :disabled="!hasHiddenLayers"
@update:model-value="skipHidden = $event"
/> />
</span>
<div class="export-toggle-text"> <div class="export-toggle-text">
<div class="export-toggle-label">{{ t('export.skipHidden') }}</div> <div class="export-toggle-label">{{ t('export.skipHidden') }}</div>
<div class="export-toggle-desc">{{ t('export.skipHiddenDesc') }}</div> <div class="export-toggle-desc">{{ t('export.skipHiddenDesc') }}</div>
@@ -577,14 +595,18 @@ function onUpdate(v: boolean) {
</button> </button>
</div> </div>
<button class="opt" @click="exportMp4Server"> <button class="opt" @click="exportMp4">
<div class="opt-icon"><PhFilmSlate :size="20" /></div> <div class="opt-icon"><PhFilmSlate :size="20" /></div>
<div class="opt-text"> <div class="opt-text">
<div class="opt-title"> <div class="opt-title">
MP4 Video 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>
<div class="opt-desc">{{ t('export.mp4Desc') }}</div>
</div> </div>
<PhDownloadSimple :size="14" class="opt-arrow" /> <PhDownloadSimple :size="14" class="opt-arrow" />
</button> </button>
@@ -594,10 +616,16 @@ function onUpdate(v: boolean) {
<div class="opt-text"> <div class="opt-text">
<div class="opt-title"> <div class="opt-title">
WebM Video WebM Video
<span class="badge">{{ t('export.webmBadge') }}</span> <span class="badge">
{{ t(isDesktop ? 'export.webmDesktopBadge' : 'export.webmBrowserBadge') }}
</span>
</div> </div>
<div class="opt-desc"> <div class="opt-desc">
{{ videoSupported ? t('export.webmDesc') : t('export.webmNotSupported') }} {{
videoSupported
? t(isDesktop ? 'export.webmDesktopDesc' : 'export.webmBrowserDesc')
: t('export.webmNotSupported')
}}
</div> </div>
</div> </div>
<PhDownloadSimple :size="14" class="opt-arrow" /> <PhDownloadSimple :size="14" class="opt-arrow" />
@@ -755,50 +783,6 @@ function onUpdate(v: boolean) {
line-height: 1.35; 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 { .target-size {
display: flex; display: flex;
align-items: center; align-items: center;
+16 -4
View File
@@ -2,11 +2,14 @@
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { PhPause, PhPlay, PhSkipBack, PhX } from '@phosphor-icons/vue' import { PhPause, PhPlay, PhSkipBack, PhX } from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene' import { useSceneStore } from '@/stores/scene'
import { cameraAspect, isCameraEnabled } from '@/lib/camera'
import { mountPlayer, type PlayerHandle } from '@/lib/exportPlayer' import { mountPlayer, type PlayerHandle } from '@/lib/exportPlayer'
import { t } from '@/i18n' import { t } from '@/i18n'
import { useTimeFormat } from '@/composables/useTimeFormat' import { useTimeFormat } from '@/composables/useTimeFormat'
import { useFullscreenPreviewAutoplay } from '@/composables/useFullscreenPreviewAutoplay'
const { formatTime } = useTimeFormat() const { formatTime } = useTimeFormat()
const { enabled: autoplay } = useFullscreenPreviewAutoplay()
const props = defineProps<{ modelValue: boolean }>() const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>() const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
@@ -26,7 +29,14 @@ let isClosing = false
let player: PlayerHandle | null = null let player: PlayerHandle | null = null
const aspect = computed( 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) const rangeIn = computed(() => store.playbackIn)
@@ -43,13 +53,14 @@ async function startPreview() {
// stage. Otherwise the two runtimes fight — editor audio pool + // stage. Otherwise the two runtimes fight — editor audio pool +
// preview audio pool would both emit the same mp3s at once. // preview audio pool would both emit the same mp3s at once.
store.pause() store.pause()
playing.value = true playing.value = autoplay.value
showControls.value = false showControls.value = false
const start = rangeIn.value const start = rangeIn.value
const end = rangeOut.value const end = rangeOut.value
const head = store.playhead const head = store.playhead
const initialTime = head < start || head >= end ? start : head const initialTime = head < start || head >= end ? start : head
mountAt(el, initialTime, true) localTime.value = initialTime
mountAt(el, initialTime, autoplay.value)
requestBrowserFullscreen() requestBrowserFullscreen()
} }
@@ -59,6 +70,7 @@ async function startPreview() {
* player and rebuild at the same playhead so visual continuity stays * player and rebuild at the same playhead so visual continuity stays
* intact across the switch. */ * intact across the switch. */
function mountAt(el: HTMLElement, time: number, wasPlaying: boolean) { function mountAt(el: HTMLElement, time: number, wasPlaying: boolean) {
localTime.value = time
player = mountPlayer(el, store.project, { player = mountPlayer(el, store.project, {
loop: true, loop: true,
onFrame(t: number, paused: boolean) { onFrame(t: number, paused: boolean) {
@@ -93,7 +105,7 @@ watch(
if (!props.modelValue) return if (!props.modelValue) return
const still = frameEl.value const still = frameEl.value
if (!still) return 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' } from '@phosphor-icons/vue'
import { Modal } from '@/components/ui' import { Modal } from '@/components/ui'
import { t } from '@/i18n' import { t } from '@/i18n'
import { hasDesktopRender } from '@/lib/desktopRenderExport'
const props = defineProps<{ modelValue: boolean }>() const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>() const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
@@ -63,6 +64,9 @@ interface Chapter {
titleKey: string titleKey: string
} }
const isDesktop = !!window.ruwioDesktop
const hasDesktopMp4 = hasDesktopRender()
const CHAPTERS: Chapter[] = [ const CHAPTERS: Chapter[] = [
{ {
id: 'getting-started', id: 'getting-started',
@@ -509,8 +513,16 @@ const CHAPTERS: Chapter[] = [
{ bodyKey: 'help.content.export.formats.runtime' }, { bodyKey: 'help.content.export.formats.runtime' },
{ bodyKey: 'help.content.export.formats.scene' }, { bodyKey: 'help.content.export.formats.scene' },
{ bodyKey: 'help.content.export.formats.html' }, { 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, PhX,
} from '@phosphor-icons/vue' } from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene' import { useSceneStore } from '@/stores/scene'
import { sampleCamera } from '@/lib/camera'
import { sampleLayer } from '@/lib/interpolate' 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 RangeSlider from './RangeSlider.vue'
import ActionEditor from './ActionEditor.vue' import ActionEditor from './ActionEditor.vue'
import FontPicker from './FontPicker.vue' import FontPicker from './FontPicker.vue'
import { ToggleSwitch } from '@/components/ui'
import { t } from '@/i18n' import { t } from '@/i18n'
import { useTimeFormat } from '@/composables/useTimeFormat' import { useTimeFormat } from '@/composables/useTimeFormat'
import { useEyeDropper } from '@/composables/useEyeDropper' import { useEyeDropper } from '@/composables/useEyeDropper'
@@ -97,6 +99,81 @@ const sampled = computed<AnimatableProps | null>(() =>
selected.value ? sampleLayer(selected.value, store.playhead) : 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. /** True when the user has more than one layer in the selection.
* Drives the multi-edit flow: writers fan out across every * Drives the multi-edit flow: writers fan out across every
* editable selected layer, kind-specific sections collapse, and * editable selected layer, kind-specific sections collapse, and
@@ -599,7 +676,62 @@ function deleteSelection() {
<template> <template>
<div class="panel"> <div class="panel">
<h3>Properties</h3> <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"> <div v-if="multiSelected" class="multi-banner">
{{ t('properties.multiSelected', { n: editableLayers.length }) }} {{ t('properties.multiSelected', { n: editableLayers.length }) }}
</div> </div>
+247 -8
View File
@@ -16,6 +16,7 @@ import {
PhLockOpen, PhLockOpen,
PhEye, PhEye,
PhEyeSlash, PhEyeSlash,
PhFrameCorners,
PhMagnet, PhMagnet,
PhMaskHappy, PhMaskHappy,
PhCircle, PhCircle,
@@ -39,6 +40,7 @@ import { audioDragSource, clearAudioDragSource } from '@/composables/useAudioDra
import { clearClipDragSource, clipDragSource } from '@/composables/useClipDragSource' import { clearClipDragSource, clipDragSource } from '@/composables/useClipDragSource'
import { drawWaveform, getWaveformSlot, slicePeaksForClip } from '@/composables/useAudioWaveform' import { drawWaveform, getWaveformSlot, slicePeaksForClip } from '@/composables/useAudioWaveform'
import { sampleLayer } from '@/lib/interpolate' import { sampleLayer } from '@/lib/interpolate'
import { sampleCamera } from '@/lib/camera'
import { snapToCandidates } from '@/lib/timelineSnap' import { snapToCandidates } from '@/lib/timelineSnap'
import { confirmDialog, promptDialog } from '@/components/ui' import { confirmDialog, promptDialog } from '@/components/ui'
import ScriptFrameDialog from './ScriptFrameDialog.vue' import ScriptFrameDialog from './ScriptFrameDialog.vue'
@@ -347,6 +349,9 @@ function allKeyframeTimes(ignoreKeyframeId?: string): number[] {
if (layer.rotationKeyframes) if (layer.rotationKeyframes)
for (const k of layer.rotationKeyframes) if (k.id !== ignoreKeyframeId) out.push(k.time) 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 return out
} }
@@ -786,6 +791,138 @@ function onLaneDblClick(e: MouseEvent, track: TimelineLayerTrack) {
store.upsertKeyframe(track.layerId, time, props) 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) { function onKeyframeClick(e: MouseEvent, layerId: string, time: number) {
e.stopPropagation() e.stopPropagation()
if (kfDrag.value?.moved) { if (kfDrag.value?.moved) {
@@ -1262,6 +1399,9 @@ function allKeyframeTimesExcluding(ignoreIds: Set<string>): number[] {
if (layer.rotationKeyframes) if (layer.rotationKeyframes)
for (const k of layer.rotationKeyframes) if (!ignoreIds.has(k.id)) out.push(k.time) 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 return out
} }
@@ -1374,6 +1514,14 @@ interface KfSegment {
tail?: boolean 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 /** 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 * time slot with a normal kf — common after the end-kf migration
* on legacy projects, where stacking a square (end) on top of a * 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 [] if (kfs.length === 0) return []
// End kfs are visibility markers, not interpolation participants. // End kfs are visibility markers, not interpolation participants.
// Pull them out for tail computation; everything else flows through // Pull them out for tail computation; everything else flows through
@@ -1527,7 +1675,8 @@ const EASING_OPTIONS: { value: Easing; label: string }[] = [
] ]
interface KfMenuState { interface KfMenuState {
layerId: string target: 'layer' | 'camera'
layerId?: string
keyframeId: string keyframeId: string
time: number time: number
x: number x: number
@@ -1566,6 +1715,7 @@ function onKeyframeContextMenu(
.sort((a, b) => a.time - b.time) .sort((a, b) => a.time - b.time)
const hasEnd = layer.keyframes.some((k) => k.kind === 'end') const hasEnd = layer.keyframes.some((k) => k.kind === 'end')
menu.value = { menu.value = {
target: 'layer',
layerId, layerId,
keyframeId: kf.id, keyframeId: kf.id,
time: kf.time, time: kf.time,
@@ -1583,7 +1733,11 @@ function onKeyframeContextMenu(
function toggleHold() { function toggleHold() {
if (!menu.value) return if (!menu.value) return
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) store.setKeyframeHold(menu.value.layerId, menu.value.keyframeId, !menu.value.isHeld)
}
menu.value = null menu.value = null
} }
@@ -1595,7 +1749,7 @@ function toggleHold() {
* we delete the existing one before promoting the new one. */ * we delete the existing one before promoting the new one. */
function toggleEndKind() { function toggleEndKind() {
const m = menu.value 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) const layer = store.activeLayers.find((l) => l.id === m.layerId)
if (!layer) return if (!layer) return
const kf = layer.keyframes.find((k) => k.id === m.keyframeId) const kf = layer.keyframes.find((k) => k.id === m.keyframeId)
@@ -1619,21 +1773,30 @@ function toggleEndKind() {
function applyEasing(easing: Easing) { function applyEasing(easing: Easing) {
if (!menu.value) return if (!menu.value) return
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) store.setKeyframeEasing(menu.value.layerId, menu.value.keyframeId, easing)
}
menu.value = null menu.value = null
} }
function deleteFromMenu() { function deleteFromMenu() {
if (!menu.value) return if (!menu.value) return
if (menu.value.target === 'camera') {
store.removeCameraKeyframe(menu.value.keyframeId)
} else if (menu.value.layerId) {
store.removeKeyframe(menu.value.layerId, menu.value.keyframeId) store.removeKeyframe(menu.value.layerId, menu.value.keyframeId)
}
menu.value = null menu.value = null
} }
function duplicateFromMenu() { function duplicateFromMenu() {
if (!menu.value) return if (!menu.value || menu.value.target !== 'layer' || !menu.value.layerId) return
const newId = store.duplicateKeyframe(menu.value.layerId, menu.value.keyframeId) const layerId = menu.value.layerId
const newId = store.duplicateKeyframe(layerId, menu.value.keyframeId)
if (newId) { 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 const all = layer
? [ ? [
...layer.keyframes, ...layer.keyframes,
@@ -3534,6 +3697,81 @@ onBeforeUnmount(() => {
@pointercancel="onTracksPointerUp" @pointercancel="onTracksPointerUp"
> >
<div v-if="boxRectStyle" class="box-select-rect" :style="boxRectStyle"></div> <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=" <template v-for="(track, idx) in tracks" :key="
track.type === 'layer' track.type === 'layer'
? `l:${track.layerId}:${track.kind}:${track.inGroup ? 'g' : 't'}` ? `l:${track.layerId}:${track.kind}:${track.inGroup ? 'g' : 't'}`
@@ -4261,6 +4499,7 @@ onBeforeUnmount(() => {
<div class="kf-menu-divider"></div> <div class="kf-menu-divider"></div>
</template> </template>
<button <button
v-if="menu.target === 'layer'"
class="kf-menu-item" class="kf-menu-item"
:class="{ active: menu.isEnd }" :class="{ active: menu.isEnd }"
@click="toggleEndKind" @click="toggleEndKind"
@@ -4274,8 +4513,8 @@ onBeforeUnmount(() => {
class="kf-menu-trailing-check" class="kf-menu-trailing-check"
/> />
</button> </button>
<div class="kf-menu-divider"></div> <div v-if="menu.target === 'layer'" class="kf-menu-divider"></div>
<button v-if="!menu.isEnd" class="kf-menu-item" @click="duplicateFromMenu"> <button v-if="menu.target === 'layer' && !menu.isEnd" class="kf-menu-item" @click="duplicateFromMenu">
<PhCopy :size="12" class="kf-menu-icon" /> <PhCopy :size="12" class="kf-menu-icon" />
{{ t('timeline.duplicate') }} {{ t('timeline.duplicate') }}
</button> </button>
+5 -46
View File
@@ -21,7 +21,7 @@ import {
pickBackupFile, pickBackupFile,
readBackupFile, readBackupFile,
} from '@/lib/backupFile' } from '@/lib/backupFile'
import { confirmDialog, Modal } from '@/components/ui' import { confirmDialog, Modal, ToggleSwitch } from '@/components/ui'
import { showWelcomeOnStart } from '@/composables/useShowWelcomeOnStart' import { showWelcomeOnStart } from '@/composables/useShowWelcomeOnStart'
import { t } from '@/i18n' import { t } from '@/i18n'
@@ -366,9 +366,10 @@ watch(
<div v-if="backupError" class="backup-error">{{ backupError }}</div> <div v-if="backupError" class="backup-error">{{ backupError }}</div>
<template #footer> <template #footer>
<label class="ftr-toggle" v-tooltip="t('welcome.showOnStartHelp')"> <label class="ftr-toggle" v-tooltip="t('welcome.showOnStartHelp')">
<span class="switch sm" :class="{ on: showWelcomeOnStart }"> <ToggleSwitch
<input type="checkbox" v-model="showWelcomeOnStart" /> v-model="showWelcomeOnStart"
</span> :aria-label="t('welcome.showOnStart')"
/>
<span class="ftr-toggle-label">{{ t('welcome.showOnStart') }}</span> <span class="ftr-toggle-label">{{ t('welcome.showOnStart') }}</span>
</label> </label>
<span class="ftr-sep"></span> <span class="ftr-sep"></span>
@@ -676,8 +677,6 @@ watch(
border-radius: var(--radius-sm); 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 { .ftr-toggle {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -691,44 +690,4 @@ watch(
.ftr-toggle-label { .ftr-toggle-label {
white-space: nowrap; 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> </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 IconButton } from './IconButton.vue'
export { default as Modal } from './Modal.vue' export { default as Modal } from './Modal.vue'
export { default as Toast } from './Toast.vue' export { default as Toast } from './Toast.vue'
export { default as ToggleSwitch } from './ToggleSwitch.vue'
export { confirmDialog, dialogState, promptDialog } from './dialogs' export { confirmDialog, dialogState, promptDialog } from './dialogs'
export type { ConfirmOptions, PromptOptions } from './dialogs' export type { ConfirmOptions, PromptOptions } from './dialogs'
+1
View File
@@ -12,6 +12,7 @@ export function useDeselectAll() {
return function deselectAll() { return function deselectAll() {
store.selectLayer(null) store.selectLayer(null)
store.selectClipInstance(null) store.selectClipInstance(null)
store.selectCamera(false)
selectedKfs.value = [] 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 { t as i18nT } from '@/i18n'
import { expandedTrackOrder } from '@/lib/sceneOrder' import { expandedTrackOrder } from '@/lib/sceneOrder'
import { CLIP_FALLBACK_BOUNDS, computeClipBounds } from '@/lib/clipBounds' import { CLIP_FALLBACK_BOUNDS, computeClipBounds } from '@/lib/clipBounds'
import { renderSceneFrameForOutput } from '@/lib/camera'
import { import {
deleteStoredProject, deleteStoredProject,
getStoredProject, getStoredProject,
@@ -43,6 +44,7 @@ import {
import Modal from '@/components/ui/Modal.vue' import Modal from '@/components/ui/Modal.vue'
import IconButton from '@/components/ui/IconButton.vue' import IconButton from '@/components/ui/IconButton.vue'
import Toast from '@/components/ui/Toast.vue' import Toast from '@/components/ui/Toast.vue'
import App from '@/App.vue'
import PropertiesPanel from '@/components/PropertiesPanel.vue' import PropertiesPanel from '@/components/PropertiesPanel.vue'
import ClipartDrawer from '@/components/ClipartDrawer.vue' import ClipartDrawer from '@/components/ClipartDrawer.vue'
import SceneDrawer from '@/components/SceneDrawer.vue' import SceneDrawer from '@/components/SceneDrawer.vue'
@@ -55,6 +57,7 @@ import ExportDialog from '@/components/ExportDialog.vue'
import WelcomeDialog from '@/components/WelcomeDialog.vue' import WelcomeDialog from '@/components/WelcomeDialog.vue'
import FullscreenPreview from '@/components/FullscreenPreview.vue' import FullscreenPreview from '@/components/FullscreenPreview.vue'
import HelpDialog from '@/components/HelpDialog.vue' import HelpDialog from '@/components/HelpDialog.vue'
import { fullscreenPreviewAutoplay } from '@/composables/useFullscreenPreviewAutoplay'
import { createAudioRuntime } from '@/composables/useAudioRuntime' import { createAudioRuntime } from '@/composables/useAudioRuntime'
import { import {
clearWaveformCache, clearWaveformCache,
@@ -1397,7 +1400,7 @@ export function registerAllTests(): void {
expect(sceneScript).toContain(ext) 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. // Two runtimes fighting for the same audio output = garbled.
// FullscreenPreview pauses `store.playing` so the editor's own // FullscreenPreview pauses `store.playing` so the editor's own
// useAudioRuntime quiets down while the preview owns playback. // useAudioRuntime quiets down while the preview owns playback.
@@ -1408,8 +1411,108 @@ export function registerAllTests(): void {
await nextTick() await nextTick()
await nextTick() await nextTick()
expect(s.playing).toBe(false) 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() 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', () => { 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)', () => { suite('ui — CanvasStage centre crosshair (Konva)', () => {
// Verify the lines actually render at the correct coordinates in // Verify the lines actually render at the correct coordinates in
// Konva when the toggle is on. Requires a proper VueKonva mount. // 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. // showed up in fullscreen-preview AND in standalone exports.
const { generateRuntimeScript } = await import('@/lib/exportPlayer') const { generateRuntimeScript } = await import('@/lib/exportPlayer')
const src = generateRuntimeScript() const src = generateRuntimeScript()
const match = src.match(/function layerActiveAt\(layer,time\)\{([\s\S]*?)\n {4}\}/) expect(src).toContain('function layerActiveAt(layer,time){')
expect(!!match).toBe(true) const layerActiveAt = (layer: unknown, time: number) =>
const body = match![1] isLayerActiveAt(layer as Layer, time)
// 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
// (a) Single normal kf at t=2 → invisible before t=2. // (a) Single normal kf at t=2 → invisible before t=2.
const a = { keyframes: [{ time: 2, kind: 'normal' }] } const a = { keyframes: [{ time: 2, kind: 'normal' }] }
expect(layerActiveAt(a, 0)).toBe(false) expect(layerActiveAt(a, 0)).toBe(false)
@@ -15554,7 +15982,7 @@ export function registerAllTests(): void {
expect(src).toContain('clipKaleidoPivot') expect(src).toContain('clipKaleidoPivot')
expect(src).toContain('clipArmLocalT') expect(src).toContain('clipArmLocalT')
// Runtime version got bumped during the kaleido + wiggle work. // 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 () => { 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 // visibility gate for layers without kaleido — same fixtures as
// the layerActiveAt parity test above. // the layerActiveAt parity test above.
const src = generateRuntimeScript() const src = generateRuntimeScript()
const match = src.match(/function layerActiveAt\(layer,time\)\{([\s\S]*?)\n {4}\}/) expect(src).toContain('function layerActiveAt(layer,time){')
expect(!!match).toBe(true) const layerActiveAt = (layer: unknown, time: number) =>
const layerActiveAt = new Function('layer', 'time', match![1]) as (layer: unknown, time: number) => boolean isLayerActiveAt(layer as Layer, time)
const a = { keyframes: [{ time: 2, kind: 'normal' }], kaleido: true, kaleidoCount: 4 } const a = { keyframes: [{ time: 2, kind: 'normal' }], kaleido: true, kaleidoCount: 4 }
expect(layerActiveAt(a, 0)).toBe(false) expect(layerActiveAt(a, 0)).toBe(false)
expect(layerActiveAt(a, 2)).toBe(true) expect(layerActiveAt(a, 2)).toBe(true)
@@ -15932,7 +16360,7 @@ export function registerAllTests(): void {
expect(src).toContain('function wiggleSeeds(') expect(src).toContain('function wiggleSeeds(')
// Runtime version got bumped through the wiggle work and the // Runtime version got bumped through the wiggle work and the
// later layer-kaleido-pivot landing. // later layer-kaleido-pivot landing.
expect(src).toContain("'34'") expect(src).toContain("'36'")
}) })
}) })
@@ -16464,7 +16892,7 @@ export function registerAllTests(): void {
const src = generateRuntimeScript() const src = generateRuntimeScript()
expect(src).toContain('function layerArmTf(') expect(src).toContain('function layerArmTf(')
// Bumped on this work. // 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)', pause: 'Pause (Space)',
play: 'Play (Space)', play: 'Play (Space)',
stop: 'Stop', stop: 'Stop',
fullscreenPlayback: 'Fullscreen playback (Shift+Space)',
recentProjects: 'Recent projects', recentProjects: 'Recent projects',
copyStill: { copyStill: {
label: 'Copy current frame', label: 'Copy current frame',
@@ -39,8 +40,10 @@ export const en = {
untitledProject: 'Untitled project', untitledProject: 'Untitled project',
saveProjectDirtyHelp: saveProjectDirtyHelp:
'Your project has unsaved changes. Ctrl/Cmd+S writes them to browser storage.', 'Your project has unsaved changes. Ctrl/Cmd+S writes them to browser storage.',
exportHelp: exportHelpServer:
'Save as runtime script, HTML, or video. MP4 needs the render server; WebM records in real time in the browser.', '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: { replaceDialog: {
title: 'Replace project?', title: 'Replace project?',
message: message:
@@ -191,6 +194,15 @@ export const en = {
noLayer: 'No layer selected.', noLayer: 'No layer selected.',
lockedLayer: lockedLayer:
'Selected layer is locked. Unlock the track (or its group) to edit properties.', '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', fill: 'Fill',
transparent: 'Transparent', transparent: 'Transparent',
eyedropper: 'Pick a colour from anywhere on the page', eyedropper: 'Pick a colour from anywhere on the page',
@@ -337,6 +349,7 @@ export const en = {
renameGroup: 'Rename group', renameGroup: 'Rename group',
renameScriptTrack: 'Rename script track', renameScriptTrack: 'Rename script track',
resizeTrackColumn: 'Drag to resize track name column', resizeTrackColumn: 'Drag to resize track name column',
cameraTrackName: 'Camera',
clipTrackName: 'Clip track', clipTrackName: 'Clip track',
toggleTimeUnit: 'Toggle time unit', toggleTimeUnit: 'Toggle time unit',
snapOn: 'Snapping on (playhead & keyframes snap together)', snapOn: 'Snapping on (playhead & keyframes snap together)',
@@ -400,6 +413,8 @@ export const en = {
stagePreviewOff: 'Turn stage preview off — buttons behave as normal assets', stagePreviewOff: 'Turn stage preview off — buttons behave as normal assets',
centerCrosshairOn: 'Show centre crosshair', centerCrosshairOn: 'Show centre crosshair',
centerCrosshairOff: 'Hide centre crosshair', centerCrosshairOff: 'Hide centre crosshair',
cameraOn: 'Enable output camera',
cameraOff: 'Disable output camera',
motionPaths: { motionPaths: {
label: 'Show motion paths', 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.', 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', welcomeOnStartLabel: 'Show welcome screen on start',
welcomeOnStartDesc: 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.', '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', bgVideoLabel: 'Video',
bgVideoNone: 'No background video', bgVideoNone: 'No background video',
bgVideoPickFile: 'Pick video file…', bgVideoPickFile: 'Pick video file…',
@@ -509,10 +527,14 @@ export const en = {
sceneDesc: 'Scene data only — requires the runtime to be loaded', sceneDesc: 'Scene data only — requires the runtime to be loaded',
exampleTitle: 'Example HTML', exampleTitle: 'Example HTML',
exampleDesc: 'Standalone HTML — just open it in a browser', exampleDesc: 'Standalone HTML — just open it in a browser',
mp4Badge: 'Server · Frame-accurate', mp4ServerBadge: 'Server · Frame-by-frame',
mp4Desc: 'H.264, any resolution — requires render server', mp4ServerDesc: 'H.264 export via render server',
webmBadge: 'Browser · Realtime', mp4DesktopBadge: 'Desktop · Frame-by-frame',
webmDesc: 'No server — recorded in real time', 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', webmNotSupported: 'Browser does not support MediaRecorder',
gifBadge: 'Browser · Frame-by-frame', gifBadge: 'Browser · Frame-by-frame',
gifDesc: '256-colour palette, broadest compatibility', gifDesc: '256-colour palette, broadest compatibility',
@@ -524,8 +546,10 @@ export const en = {
cancel: 'Cancel', cancel: 'Cancel',
failed: 'Export failed', failed: 'Export failed',
back: 'Back', back: 'Back',
labelWebm: 'Rendering WebM (browser, realtime)…', labelWebmBrowser: 'Rendering WebM in browser realtime...',
labelMp4: 'Rendering MP4 (server)…', 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)…', labelGif: 'Rendering GIF (browser, frame-by-frame)…',
labelApng: 'Rendering APNG (browser, frame-by-frame)…', labelApng: 'Rendering APNG (browser, frame-by-frame)…',
labelApngPreview: 'Rendering APNG + preview page…', labelApngPreview: 'Rendering APNG + preview page…',
@@ -1078,9 +1102,13 @@ play
html: html:
'<strong>HTML example:</strong> A quick standalone page with everything wired up.', '<strong>HTML example:</strong> A quick standalone page with everything wired up.',
mp4: 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: 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', skipHiddenTitle: 'Skip hidden layers',
skipHiddenBody: skipHiddenBody:
@@ -1116,6 +1144,7 @@ export const de: DeepPartial<Messages> = {
pause: 'Pause (Leertaste)', pause: 'Pause (Leertaste)',
play: 'Abspielen (Leertaste)', play: 'Abspielen (Leertaste)',
stop: 'Stop', stop: 'Stop',
fullscreenPlayback: 'Fullscreen-Wiedergabe (Shift+Leertaste)',
recentProjects: 'Letzte Projekte', recentProjects: 'Letzte Projekte',
copyStill: { copyStill: {
label: 'Aktuellen Frame kopieren', label: 'Aktuellen Frame kopieren',
@@ -1149,8 +1178,10 @@ export const de: DeepPartial<Messages> = {
untitledProject: 'Unbenanntes Projekt', untitledProject: 'Unbenanntes Projekt',
saveProjectDirtyHelp: saveProjectDirtyHelp:
'Dein Projekt hat ungespeicherte Änderungen. Mit Ctrl/Cmd+S wird es im Browser gespeichert.', 'Dein Projekt hat ungespeicherte Änderungen. Mit Ctrl/Cmd+S wird es im Browser gespeichert.',
exportHelp: exportHelpServer:
'Als Runtime, HTML oder Video exportieren. MP4 braucht den Render-Server. WebM wird direkt im Browser in Echtzeit aufgenommen.', '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: { replaceDialog: {
title: 'Projekt ersetzen?', title: 'Projekt ersetzen?',
message: message:
@@ -1308,6 +1339,12 @@ export const de: DeepPartial<Messages> = {
assetThumbsLabel: 'Asset-Vorschaubilder', assetThumbsLabel: 'Asset-Vorschaubilder',
assetThumbsDesc: assetThumbsDesc:
'Zeigt kleine Vorschauen in Assets, Clips und Buttons. Aus ist kompakter, an ist übersichtlicher.', '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', bgVideoLabel: 'Video',
bgVideoNone: 'Kein Hintergrundvideo', bgVideoNone: 'Kein Hintergrundvideo',
bgVideoPickFile: 'Videodatei wählen…', bgVideoPickFile: 'Videodatei wählen…',
@@ -1363,10 +1400,14 @@ export const de: DeepPartial<Messages> = {
sceneDesc: 'Nur Projektdaten: Die Runtime muss schon geladen sein.', sceneDesc: 'Nur Projektdaten: Die Runtime muss schon geladen sein.',
exampleTitle: 'HTML-Beispiel', exampleTitle: 'HTML-Beispiel',
exampleDesc: 'Eigenständige HTML-Datei: Einfach im Browser öffnen.', exampleDesc: 'Eigenständige HTML-Datei: Einfach im Browser öffnen.',
mp4Badge: 'Server · Frame-genau', mp4ServerBadge: 'Server · Frame-für-Frame',
mp4Desc: 'H.264 in jeder Auflösung: braucht den Render-Server.', mp4ServerDesc: 'H.264-Export über den Render-Server',
webmBadge: 'Browser · Echtzeit', mp4DesktopBadge: 'Desktop · Frame-für-Frame',
webmDesc: 'Ohne Server: direkt im Browser aufgenommen.', 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.', webmNotSupported: 'Dieser Browser unterstützt MediaRecorder nicht.',
gifBadge: 'Browser · Frame-für-Frame', gifBadge: 'Browser · Frame-für-Frame',
gifDesc: '256-Farben-Palette, maximale Kompatibilität.', gifDesc: '256-Farben-Palette, maximale Kompatibilität.',
@@ -1378,8 +1419,10 @@ export const de: DeepPartial<Messages> = {
cancel: 'Abbrechen', cancel: 'Abbrechen',
failed: 'Export fehlgeschlagen', failed: 'Export fehlgeschlagen',
back: 'Zurück', back: 'Zurück',
labelWebm: 'WebM wird gerendert (Browser, Echtzeit)…', labelWebmBrowser: 'WebM wird in Browser-Echtzeit gerendert...',
labelMp4: 'MP4 wird gerendert (Server)…', 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)…', labelGif: 'GIF wird gerendert (Browser, frame-für-frame)…',
labelApng: 'APNG wird gerendert (Browser, frame-für-frame)…', labelApng: 'APNG wird gerendert (Browser, frame-für-frame)…',
labelApngPreview: 'APNG + Vorschau werden gerendert…', labelApngPreview: 'APNG + Vorschau werden gerendert…',
@@ -1920,9 +1963,13 @@ play
html: html:
'<strong>HTML-Beispiel:</strong> Eine schnelle eigenständige Seite mit allem fertig verdrahtet.', '<strong>HTML-Beispiel:</strong> Eine schnelle eigenständige Seite mit allem fertig verdrahtet.',
mp4: 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: 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', skipHiddenTitle: 'Versteckte Ebenen auslassen',
skipHiddenBody: 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()
}
+87 -9
View File
@@ -11,7 +11,7 @@ function isProject(p: ExportPayload): p is Project {
return Array.isArray((p as Partial<Project>).scenes) 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 /** Materialize the engine source. The font map is rebuilt on every
* call so user-uploaded custom fonts (which live in localStorage * call so user-uploaded custom fonts (which live in localStorage
@@ -207,6 +207,57 @@ const ENGINE_TEMPLATE = `(function(){
return result; 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){ function loadImages(assets){
var imgs={}; var imgs={};
var list=assets.filter(function(a){return a.kind==='image';}); var list=assets.filter(function(a){return a.kind==='image';});
@@ -248,7 +299,8 @@ const ENGINE_TEMPLATE = `(function(){
trackOrder: page.trackOrder || [], trackOrder: page.trackOrder || [],
backgroundVideo: page.backgroundVideo, backgroundVideo: page.backgroundVideo,
audioTracks: page.audioTracks || [], audioTracks: page.audioTracks || [],
audioClips: page.audioClips || [] audioClips: page.audioClips || [],
camera: page.camera
}; };
} }
if(payload && payload.scenes && payload.scenes.length){ if(payload && payload.scenes && payload.scenes.length){
@@ -283,7 +335,8 @@ const ENGINE_TEMPLATE = `(function(){
trackOrder: legacy.trackOrder || [], trackOrder: legacy.trackOrder || [],
backgroundVideo: legacy.backgroundVideo, backgroundVideo: legacy.backgroundVideo,
audioTracks: legacy.audioTracks || [], audioTracks: legacy.audioTracks || [],
audioClips: legacy.audioClips || [] audioClips: legacy.audioClips || [],
camera: legacy.camera
}], }],
activeSceneId: '__legacy__' activeSceneId: '__legacy__'
}; };
@@ -1110,21 +1163,32 @@ const ENGINE_TEMPLATE = `(function(){
var cw=size.w,ch=size.h; var cw=size.w,ch=size.h;
if(!cw||!ch)return; if(!cw||!ch)return;
buttonInstancesInFrame.length=0; buttonInstancesInFrame.length=0;
var s=Math.min(cw/lw,ch/lh); var useCamera=!!(scene.camera && scene.camera.enabled);
var ox=(cw-lw*s)/2; var cam=useCamera?sampleCamera(scene,time):null;
var oy=(ch-lh*s)/2; 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.setTransform(dpr,0,0,dpr,0,0);
ctx.clearRect(0,0,cw,ch); ctx.clearRect(0,0,cw,ch);
ctx.save(); ctx.save();
ctx.translate(ox,oy); ctx.translate(ox,oy);
ctx.scale(s,s); ctx.scale(s,s);
ctx.beginPath(); ctx.beginPath();
ctx.rect(0,0,lw,lh); ctx.rect(0,0,vw,vh);
ctx.clip(); ctx.clip();
if(useCamera && !scene.backgroundVideo && scene.canvas.background && scene.canvas.background !== 'transparent'){
ctx.fillStyle=scene.canvas.background;
ctx.fillRect(0,0,vw,vh);
}
if(useCamera) ctx.translate(-cam.x,-cam.y);
if(!scene.backgroundVideo){ if(!scene.backgroundVideo){
if(!useCamera){
ctx.fillStyle=scene.canvas.background; ctx.fillStyle=scene.canvas.background;
ctx.fillRect(0,0,lw,lh); ctx.fillRect(0,0,lw,lh);
} }
}
var layerById={}; var layerById={};
for(var li=0;li<scene.layers.length;li++)layerById[scene.layers[li].id]=scene.layers[li]; for(var li=0;li<scene.layers.length;li++)layerById[scene.layers[li].id]=scene.layers[li];
var clipById2={}; var clipById2={};
@@ -1513,12 +1577,27 @@ function sceneToLegacyProject(scene: Scene): Project {
backgroundVideo: scene.backgroundVideo, backgroundVideo: scene.backgroundVideo,
audioTracks: scene.audioTracks ?? [], audioTracks: scene.audioTracks ?? [],
audioClips: scene.audioClips ?? [], audioClips: scene.audioClips ?? [],
camera: scene.camera,
}, },
], ],
activeSceneId: '__legacy__', 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 { export interface ExportOptions {
loop?: boolean loop?: boolean
} }
@@ -1571,8 +1650,7 @@ export function downloadBundledExport(payload: ExportPayload, filename = 'animat
export function generateExampleHtml(payload: ExportPayload, options: ExportOptions = {}): string { export function generateExampleHtml(payload: ExportPayload, options: ExportOptions = {}): string {
const runtime = engineSource() const runtime = engineSource()
const sceneBoot = generateSceneScriptBody(payload, options) const sceneBoot = generateSceneScriptBody(payload, options)
const canvas = isProject(payload) ? payload.canvas : payload.canvas const aspect = exportPreviewAspect(payload)
const aspect = `${canvas.width} / ${canvas.height}`
const usedFontIds = collectFontIds(payload) const usedFontIds = collectFontIds(payload)
const fonts = AVAILABLE_FONTS.value const fonts = AVAILABLE_FONTS.value
const usedFonts = usedFontIds const usedFonts = usedFontIds
+6 -10
View File
@@ -1,5 +1,6 @@
import type { Scene } from '@/types/scene' 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 /** End-of-playback time: clamp to the scene's last keyframe if the
* scene explicitly ends earlier than its nominal duration. Matches * scene explicitly ends earlier than its nominal duration. Matches
@@ -81,14 +82,6 @@ export async function renderFrames(
canvas.height = height canvas.height = height
const ctx = canvas.getContext('2d') const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('2D context unavailable') 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 images = await loadSceneImages(scene)
const { start: rangeIn, end: rangeOut } = playbackRange(scene) const { start: rangeIn, end: rangeOut } = playbackRange(scene)
const rangeLen = Math.max(0, rangeOut - rangeIn) const rangeLen = Math.max(0, rangeOut - rangeIn)
@@ -106,7 +99,10 @@ export async function renderFrames(
throw new DOMException('aborted', 'AbortError') throw new DOMException('aborted', 'AbortError')
} }
const t = rangeIn + i / fps 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({ await onFrame({
scene, scene,
canvas, canvas,
+10 -3
View File
@@ -566,11 +566,18 @@ export function drawSceneFrame(
scene: Scene, scene: Scene,
time: number, time: number,
images: Record<string, HTMLImageElement>, 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 lw = scene.canvas.width
const lh = scene.canvas.height const lh = scene.canvas.height
const crop = options?.crop !== false const crop = options?.crop !== false
const clear = options?.clear !== false
const paintBackground = options?.background !== false
const buttonStates = options?.buttonStates const buttonStates = options?.buttonStates
ctx.save() ctx.save()
@@ -583,13 +590,13 @@ export function drawSceneFrame(
// Mandatory for transparent-background scenes so subsequent frames // Mandatory for transparent-background scenes so subsequent frames
// don't accumulate alpha; harmless for opaque ones since `fillRect` // don't accumulate alpha; harmless for opaque ones since `fillRect`
// below paints over everything. // 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 // A background video takes over the backdrop — leave the stage area
// transparent so the DOM video underneath is visible. Same treatment // transparent so the DOM video underneath is visible. Same treatment
// when the canvas background is explicitly 'transparent' so // when the canvas background is explicitly 'transparent' so
// APNG/WebM exports carry the alpha channel out to the file. // APNG/WebM exports carry the alpha channel out to the file.
const bg = scene.canvas.background const bg = scene.canvas.background
if (!scene.backgroundVideo && bg && bg !== 'transparent') { if (paintBackground && !scene.backgroundVideo && bg && bg !== 'transparent') {
ctx.fillStyle = bg ctx.fillStyle = bg
ctx.fillRect(0, 0, lw, lh) ctx.fillRect(0, 0, lw, lh)
} }
+11 -10
View File
@@ -1,5 +1,6 @@
import type { Scene } from '@/types/scene' import type { Scene } from '@/types/scene'
import { drawSceneFrame, loadSceneImages } from './render' import { renderSceneFrameForOutput } from './camera'
import { loadSceneImages } from './render'
export type StillImageFormat = 'png' | 'jpg' export type StillImageFormat = 'png' | 'jpg'
@@ -45,17 +46,17 @@ export async function renderSceneToStillImage(
const needsOpaqueBackdrop = const needsOpaqueBackdrop =
format === 'jpg' && (!sceneBg || sceneBg === 'transparent') format === 'jpg' && (!sceneBg || sceneBg === 'transparent')
const backdrop = opts.background ?? (needsOpaqueBackdrop ? '#ffffff' : null) 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) 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 mime = format === 'jpg' ? 'image/jpeg' : 'image/png'
const quality = format === 'jpg' ? (opts.quality ?? 0.92) : undefined const quality = format === 'jpg' ? (opts.quality ?? 0.92) : undefined
+10 -3
View File
@@ -1,5 +1,6 @@
import type { Scene } from '@/types/scene' import type { Scene } from '@/types/scene'
import { drawSceneFrame, loadSceneImages } from './render' import { renderSceneFrameForOutput } from './camera'
import { loadSceneImages } from './render'
function effectiveEnd(scene: Scene): number { function effectiveEnd(scene: Scene): number {
let max = 0 let max = 0
@@ -67,7 +68,10 @@ export async function renderSceneToVideo(
const { start: rangeIn, end: rangeOut } = playbackRange(scene) const { start: rangeIn, end: rangeOut } = playbackRange(scene)
const rangeLen = Math.max(0, rangeOut - rangeIn) 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 stream = canvas.captureStream(scene.fps)
const recorder = new MediaRecorder(stream, { const recorder = new MediaRecorder(stream, {
@@ -103,7 +107,10 @@ export async function renderSceneToVideo(
} }
const elapsed = (performance.now() - startedAt) / 1000 const elapsed = (performance.now() - startedAt) / 1000
const t = rangeIn + Math.min(elapsed, rangeLen) 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) opts.onProgress?.(rangeLen > 0 ? Math.min(1, elapsed / rangeLen) : 1)
if (elapsed >= rangeLen) { if (elapsed >= rangeLen) {
try { try {
+4 -1
View File
@@ -18,7 +18,10 @@ app.use(VueKonva)
app.directive('tooltip', vTooltip) app.directive('tooltip', vTooltip)
app.mount('#app') app.mount('#app')
if (import.meta.env.DEV && new URLSearchParams(window.location.search).get('__runtests') === '1') { if (
(import.meta.env.DEV || !!window.ruwioDesktop) &&
new URLSearchParams(window.location.search).get('__runtests') === '1'
) {
void import('./devtools/headless').then(({ runHeadlessTests }) => runHeadlessTests()) void import('./devtools/headless').then(({ runHeadlessTests }) => runHeadlessTests())
} }
+129 -3
View File
@@ -10,6 +10,8 @@ import type {
BackgroundVideo, BackgroundVideo,
ButtonAsset, ButtonAsset,
ButtonState, ButtonState,
CameraKeyframe,
CameraProps,
Clip, Clip,
ClipInstance, ClipInstance,
ClipTrack, ClipTrack,
@@ -29,6 +31,12 @@ import type {
TrackGroup, TrackGroup,
} from '@/types/scene' } from '@/types/scene'
import { BUTTON_STATE_TIMES, DEFAULT_BASE_PROPS } 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 { sampleLayer } from '@/lib/interpolate'
import { splitSvg as splitSvgRuntime } from '@/lib/svgSplit' import { splitSvg as splitSvgRuntime } from '@/lib/svgSplit'
import { import {
@@ -247,6 +255,8 @@ interface State {
* `selectedLayerId`: selecting a clip clears the layer selection * `selectedLayerId`: selecting a clip clears the layer selection
* and vice versa. */ * and vice versa. */
selectedClipInstanceId: string | null selectedClipInstanceId: string | null
/** Special editor selection for the page-level output camera. */
selectedCamera: boolean
playhead: number playhead: number
playing: boolean playing: boolean
/** True between `play()` and the next `tick()`. Lets the first tick /** 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 /** Per-page housekeeping: ensure all arrays exist, clean up orphan
* childIds in groups, dedupe, build trackOrder for any pristine track, * childIds in groups, dedupe, build trackOrder for any pristine track,
* and migrate pre-`colorOverridden` color flags. */ * 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.layers ||= []
page.clipInstances ||= [] page.clipInstances ||= []
page.clipTracks ||= [] page.clipTracks ||= []
@@ -418,6 +431,11 @@ function normalizeScenePage(page: ScenePage): ScenePage {
page.trackOrder ||= [] page.trackOrder ||= []
page.audioTracks ||= [] page.audioTracks ||= []
page.audioClips ||= [] 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>() const known = new Set<string>()
for (const l of page.layers) known.add(l.id) for (const l of page.layers) known.add(l.id)
@@ -557,7 +575,7 @@ function normalizeProject(project: Project): Project {
if (project.scenes.length === 0) { if (project.scenes.length === 0) {
project.scenes.push(createBlankScenePage('Scene 1')) 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)) { if (!project.scenes.find((s) => s.id === project.activeSceneId)) {
project.activeSceneId = project.scenes[0].id project.activeSceneId = project.scenes[0].id
} }
@@ -614,6 +632,7 @@ export function projectFromLegacyScene(scene: Scene, sceneName = 'Scene 1'): Pro
backgroundVideo: scene.backgroundVideo, backgroundVideo: scene.backgroundVideo,
audioTracks: scene.audioTracks ?? [], audioTracks: scene.audioTracks ?? [],
audioClips: scene.audioClips ?? [], audioClips: scene.audioClips ?? [],
camera: scene.camera,
} }
return normalizeProject({ return normalizeProject({
version: 1, version: 1,
@@ -653,6 +672,7 @@ export function createBlankScene(): Scene {
groups: page.groups, groups: page.groups,
trackOrder: page.trackOrder, trackOrder: page.trackOrder,
backgroundVideo: page.backgroundVideo, backgroundVideo: page.backgroundVideo,
camera: page.camera,
} }
} }
@@ -662,6 +682,7 @@ export const useSceneStore = defineStore('scene', {
selectedLayerId: null, selectedLayerId: null,
selectedLayerIds: [], selectedLayerIds: [],
selectedClipInstanceId: null, selectedClipInstanceId: null,
selectedCamera: false,
playhead: 0, playhead: 0,
playing: false, playing: false,
playStartPending: false, playStartPending: false,
@@ -719,6 +740,7 @@ export const useSceneStore = defineStore('scene', {
backgroundVideo: page.backgroundVideo, backgroundVideo: page.backgroundVideo,
audioTracks: page.audioTracks, audioTracks: page.audioTracks,
audioClips: page.audioClips, audioClips: page.audioClips,
camera: page.camera,
} }
}, },
activeClip(): Clip | null { activeClip(): Clip | null {
@@ -1030,15 +1052,115 @@ export const useSceneStore = defineStore('scene', {
selectLayer(id: string | null) { selectLayer(id: string | null) {
this.selectedLayerId = id this.selectedLayerId = id
this.selectedLayerIds = id ? [id] : [] this.selectedLayerIds = id ? [id] : []
if (id) this.selectedClipInstanceId = null if (id) {
this.selectedClipInstanceId = null
this.selectedCamera = false
}
}, },
selectClipInstance(id: string | null) { selectClipInstance(id: string | null) {
this.selectedClipInstanceId = id this.selectedClipInstanceId = id
if (id) { if (id) {
this.selectedLayerId = null this.selectedLayerId = null
this.selectedLayerIds = [] 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 /** Shift-click behaviour: add the layer to the selection if it's
* not already there; otherwise remove it. The anchor moves to * not already there; otherwise remove it. The anchor moves to
* the most-recently-clicked id so the Properties panel follows * 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 if (g.locked && g.childIds.includes(id)) return
} }
const existing = this.selectedLayerIds.indexOf(id) const existing = this.selectedLayerIds.indexOf(id)
this.selectedCamera = false
if (existing >= 0) { if (existing >= 0) {
this.selectedLayerIds.splice(existing, 1) this.selectedLayerIds.splice(existing, 1)
this.selectedLayerId = this.selectedLayerId =
@@ -2706,6 +2829,7 @@ export const useSceneStore = defineStore('scene', {
this.selectedLayerId = null this.selectedLayerId = null
this.selectedLayerIds = [] this.selectedLayerIds = []
this.selectedClipInstanceId = null this.selectedClipInstanceId = null
this.selectedCamera = false
this.playhead = 0 this.playhead = 0
this.playing = false this.playing = false
this.activeClipId = null this.activeClipId = null
@@ -2791,6 +2915,7 @@ export const useSceneStore = defineStore('scene', {
this.selectedLayerId = null this.selectedLayerId = null
this.selectedLayerIds = [] this.selectedLayerIds = []
this.selectedClipInstanceId = null this.selectedClipInstanceId = null
this.selectedCamera = false
this.drawingMode = null this.drawingMode = null
this.drawingAssetId = null this.drawingAssetId = null
this.drawingLayerId = null this.drawingLayerId = null
@@ -3450,6 +3575,7 @@ export const useSceneStore = defineStore('scene', {
) )
if (inst && childIds.has(inst.clipTrackId)) { if (inst && childIds.has(inst.clipTrackId)) {
this.selectedClipInstanceId = null this.selectedClipInstanceId = null
this.selectedCamera = false
} }
} }
// Dispatch the right remove action per child. Each call cleans // Dispatch the right remove action per child. Each call cleans
+30
View File
@@ -57,6 +57,34 @@ export interface Keyframe {
ctrlY?: number 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 AssetKind = 'image' | 'rect' | 'circle' | 'text' | 'polygon' | 'button' | 'audio' | 'svg'
export type ButtonState = 'normal' | 'hover' | 'pressed' | 'disabled' export type ButtonState = 'normal' | 'hover' | 'pressed' | 'disabled'
@@ -445,6 +473,7 @@ export interface Scene {
markers?: Marker[] markers?: Marker[]
audioTracks?: AudioTrack[] audioTracks?: AudioTrack[]
audioClips?: AudioClip[] audioClips?: AudioClip[]
camera?: CameraTrack
} }
/** /**
@@ -467,6 +496,7 @@ export interface ScenePage {
backgroundVideo?: BackgroundVideo backgroundVideo?: BackgroundVideo
audioTracks: AudioTrack[] audioTracks: AudioTrack[]
audioClips: AudioClip[] 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' export const APP_NAME = 'RUWIO'
+54
View File
@@ -5,6 +5,60 @@ import { fileURLToPath, URL } from 'node:url'
export default defineConfig({ export default defineConfig({
base: './', base: './',
plugins: [vue()], 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: { server: {
port: 5200, port: 5200,