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