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