diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c9e8ef18..41947974a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,6 @@ jobs: node-version: 22 cache: npm - run: npm ci - - run: npm run test - run: npm run test:browser:install - run: npm run test:browser diff --git a/PR_DESCRIPTION.md b/PR_DESCRIPTION.md new file mode 100644 index 000000000..ab4029fb7 --- /dev/null +++ b/PR_DESCRIPTION.md @@ -0,0 +1,70 @@ +# Pull Request Template + +## Description + +This PR adds two major features: + +1. **Recording background color compositing** — when recording a transparent window (e.g. iPhone Mirroring, iOS Simulator), the transparent areas are now filled with a user-chosen solid color via an off-screen canvas pipeline, instead of encoding as black. + +2. **Improved AI zoom suggestions for mobile recordings** — the "Suggest Zooms" wand now generates a zoom region for every tap/click during recording, not just long cursor dwells. Click timestamps and short cursor pauses (120ms+) are both used as candidates, and suggestions no longer block each other so all interactions appear on the timeline. + +## Motivation + +**Background compositing**: When recording a transparent Electron window (e.g. a mirrored iPhone in a frameless window), H.264/VP8 video codecs have no alpha channel — transparent areas encode as solid black. Users had no way to control or replace that background color. + +**AI zoom suggestions**: The previous dwell-detection algorithm required 450ms+ of cursor stillness, missing rapid navigation taps on mobile recordings. Suggestions were also silently dropped when they overlapped each other, causing most mobile interactions to be skipped entirely. + +## Type of Change +- [x] New Feature +- [x] Bug Fix +- [ ] Refactor / Code Cleanup +- [ ] Documentation Update +- [ ] Other (please specify) + +## Related Issue(s) + + +## Screenshots / Video + + + +## Testing + +**Background color picker:** +1. Launch a transparent window recording (iPhone Mirroring or iOS Simulator) +2. Before starting recording, click the colored circle in the HUD bar +3. Select a color from the presets or the custom color wheel +4. Record and export — the background should be the chosen color instead of black +5. Select the transparent (checkerboard) option — **known bug**: transparent areas still export as black because video codecs do not support alpha. This option is preserved in the UI for future codec support but does not currently work. + +**AI zoom suggestions (mobile):** +1. Record an iPhone Mirroring session with several quick tap navigations (~1s apart) +2. Open the recording in the editor +3. Click the magic wand "Suggest Zooms" button on the zoom timeline row +4. A zoom region should appear for each tap, even if they are adjacent or overlapping +5. Pre-existing manually-created zoom regions are respected and will not be overwritten + +## Known Bug + +**Transparent background does not work** — selecting the checkerboard/transparent swatch sets `captureBackgroundColor` to `null`, which bypasses canvas compositing and passes the raw video track to `MediaRecorder`. Because H.264 (and VP8/VP9) have no alpha channel, any transparent pixels in the source window are encoded as black. The transparent option is present in the UI but produces the same black result as having no compositing. A fix would require a codec that supports alpha (e.g. HEVC with alpha, or ProRes 4444), which is not currently supported by `MediaRecorder` in Chromium/Electron. + +## Checklist +- [x] I have performed a self-review of my code. +- [ ] I have added any necessary screenshots or videos. +- [ ] I have linked related issue(s) and updated the changelog if applicable. + +--- + +## Files Changed + +| File | Change | +|------|--------| +| `src/hooks/useScreenRecorder.ts` | Canvas compositing pipeline; `captureBackgroundColor` state | +| `src/components/launch/LaunchWindow.tsx` | HUD color picker (presets, transparent swatch, custom wheel) | +| `src/components/video-editor/VideoPlayback.tsx` | Write clamped auto-focus position back to `smoothedAutoFocusRef` | +| `src/components/video-editor/timeline/zoomSuggestionUtils.ts` | `detectClickCandidates()`; lowered `MIN_DWELL_DURATION_MS` 450→120ms; per-candidate `durationMs`/`startOffsetMs` | +| `src/components/video-editor/timeline/TimelineEditor.tsx` | Merge click+dwell candidates; removed inter-suggestion blocking; pass `cursorClickTimestamps` prop | +| `src/components/video-editor/VideoEditor.tsx` | Pass `cursorClickTimestamps` to `TimelineEditor` | + +--- +*Thank you for contributing!* diff --git a/docs/tests/writing-tests.md b/docs/tests/writing-tests.md deleted file mode 100644 index 09ede7ed6..000000000 --- a/docs/tests/writing-tests.md +++ /dev/null @@ -1,149 +0,0 @@ -# Writing Tests - -This project uses [Vitest](https://vitest.dev/) for both unit/integration tests and browser tests. There are two separate configs — each targets a different set of files. - -## Unit tests - -**Config:** `vitest.config.ts` -**Runs in:** jsdom (simulated DOM, no real browser) -**File pattern:** `src/**/*.test.ts` — anything that does **not** end in `.browser.test.ts` -**CI command:** `npm run test` - -Use unit tests for pure logic, utility functions, data transformations, and anything that doesn't need real browser APIs (Canvas, WebCodecs, MediaRecorder, etc.). - -### File placement - -Co-locate the test file next to the source file, or put it in a `__tests__/` folder in the same directory. - -``` -src/lib/compositeLayout.ts -src/lib/compositeLayout.test.ts # co-located - -src/i18n/__tests__/tutorialHelpTranslations.test.ts # grouped -``` - -### Example - -```ts -import { describe, expect, it } from "vitest"; -import { computeCompositeLayout } from "./compositeLayout"; - -describe("computeCompositeLayout", () => { - it("anchors the overlay in the lower-right corner", () => { - const layout = computeCompositeLayout({ - canvasSize: { width: 1920, height: 1080 }, - screenSize: { width: 1920, height: 1080 }, - webcamSize: { width: 1280, height: 720 }, - }); - - expect(layout).not.toBeNull(); - expect(layout!.webcamRect!.x).toBeGreaterThan(1920 / 2); - expect(layout!.webcamRect!.y).toBeGreaterThan(1080 / 2); - }); -}); -``` - -### Path aliases - -The `@/` alias resolves to `src/`. Use it for imports that would otherwise need long relative paths. - -```ts -import { SUPPORTED_LOCALES } from "@/i18n/config"; -``` - -### Running locally - -```bash -npm run test # run once -npm run test:watch # watch mode -``` - ---- - -## Browser tests - -**Config:** `vitest.browser.config.ts` -**Runs in:** real Chromium via Playwright (headless) -**File pattern:** `src/**/*.browser.test.ts` -**CI commands:** `npm run test:browser:install` then `npm run test:browser` - -Use browser tests when the code under test depends on real browser APIs that jsdom doesn't implement: `VideoDecoder`, `VideoEncoder`, `MediaRecorder`, `OffscreenCanvas`, `WebGL`, etc. - -### File placement - -Name the file `.browser.test.ts` and place it next to the source file. - -``` -src/lib/exporter/videoExporter.ts -src/lib/exporter/videoExporter.browser.test.ts -``` - -### Loading fixture assets - -Static assets (video files, images) live in `tests/fixtures/`. Import them with Vite's `?url` suffix so Vite serves them through the dev server. - -```ts -import sampleVideoUrl from "../../../tests/fixtures/sample.webm?url"; -``` - -### Example - -```ts -import { describe, expect, it } from "vitest"; -import sampleVideoUrl from "../../../tests/fixtures/sample.webm?url"; -import { VideoExporter } from "./videoExporter"; - -describe("VideoExporter (real browser)", () => { - it("exports a valid MP4 blob from a real video", async () => { - const exporter = new VideoExporter({ - videoUrl: sampleVideoUrl, - width: 320, - height: 180, - frameRate: 15, - bitrate: 1_000_000, - wallpaper: "#1a1a2e", - zoomRegions: [], - showShadow: false, - shadowIntensity: 0, - showBlur: false, - cropRegion: { x: 0, y: 0, width: 1, height: 1 }, - }); - - const result = await exporter.export(); - - expect(result.success, result.error).toBe(true); - expect(result.blob).toBeInstanceOf(Blob); - }); -}); -``` - -### Timeouts - -Browser tests have a default timeout of 120 seconds per test and 30 seconds per hook (set in `vitest.browser.config.ts`). Export operations are slow — prefer small fixture dimensions (320×180) and low bitrates to keep tests fast. - -### Running locally - -First install the browser (one-time): - -```bash -npm run test:browser:install -``` - -Then run the tests: - -```bash -npm run test:browser -``` - ---- - -## Choosing the right type - -| Situation | Use | -|---|---| -| Pure function / data transformation | Unit test | -| i18n key coverage | Unit test | -| React hook logic (no real browser APIs) | Unit test | -| `VideoDecoder` / `VideoEncoder` / `MediaRecorder` | Browser test | -| `OffscreenCanvas` / WebGL / Pixi.js rendering | Browser test | -| File export producing a real `Blob` | Browser test | diff --git a/package-lock.json b/package-lock.json index afe209130..23c7662dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -188,7 +188,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -397,7 +396,6 @@ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } @@ -721,7 +719,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -770,7 +767,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -1202,6 +1198,7 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, + "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -1223,6 +1220,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -1239,6 +1237,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -1253,6 +1252,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">= 10.0.0" } @@ -1962,6 +1962,7 @@ "resolved": "https://registry.npmjs.org/@pixi/color/-/color-7.4.3.tgz", "integrity": "sha512-a6R+bXKeXMDcRmjYQoBIK+v2EYqxSX49wcjAY579EYM/WrFKS98nSees6lqVUcLKrcQh2DT9srJHX7XMny3voQ==", "license": "MIT", + "peer": true, "dependencies": { "@pixi/colord": "^2.9.6" } @@ -1976,7 +1977,8 @@ "version": "7.4.3", "resolved": "https://registry.npmjs.org/@pixi/constants/-/constants-7.4.3.tgz", "integrity": "sha512-QGmwJUNQy/vVEHzL6VGQvnwawLZ1wceZMI8HwJAT4/I2uAzbBeFDdmCS8WsTpSWLZjF/DszDc1D8BFp4pVJ5UQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@pixi/core": { "version": "7.4.3", @@ -2003,7 +2005,8 @@ "version": "7.4.3", "resolved": "https://registry.npmjs.org/@pixi/extensions/-/extensions-7.4.3.tgz", "integrity": "sha512-FhoiYkHQEDYHUE7wXhqfsTRz6KxLXjuMbSiAwnLb9uG1vAgp6q6qd6HEsf4X30YaZbLFY8a4KY6hFZWjF+4Fdw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@pixi/filter-drop-shadow": { "version": "5.2.0", @@ -2030,19 +2033,22 @@ "version": "7.4.3", "resolved": "https://registry.npmjs.org/@pixi/math/-/math-7.4.3.tgz", "integrity": "sha512-/uJOVhR2DOZ+zgdI6Bs/CwcXT4bNRKsS+TqX3ekRIxPCwaLra+Qdm7aDxT5cTToDzdxbKL5+rwiLu3Y1egILDw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@pixi/runner": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@pixi/runner/-/runner-7.4.3.tgz", "integrity": "sha512-TJyfp7y23u5vvRAyYhVSa7ytq0PdKSvPLXu4G3meoFh1oxTLHH6g/RIzLuxUAThPG2z7ftthuW3qWq6dRV+dhw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@pixi/settings": { "version": "7.4.3", "resolved": "https://registry.npmjs.org/@pixi/settings/-/settings-7.4.3.tgz", "integrity": "sha512-SmGK8smc0PxRB9nr0UJioEtE9hl4gvj9OedCvZx3bxBwA3omA5BmP3CyhQfN8XJ29+o2OUL01r3zAPVol4l4lA==", "license": "MIT", + "peer": true, "dependencies": { "@pixi/constants": "7.4.3", "@types/css-font-loading-module": "^0.0.12", @@ -2054,6 +2060,7 @@ "resolved": "https://registry.npmjs.org/@pixi/ticker/-/ticker-7.4.3.tgz", "integrity": "sha512-tHsAD0iOUb6QSGGw+c8cyRBvxsq/NlfzIFBZLEHhWZ+Bx4a0MmXup6I/yJDGmyPCYE+ctCcAfY13wKAzdiVFgQ==", "license": "MIT", + "peer": true, "dependencies": { "@pixi/extensions": "7.4.3", "@pixi/settings": "7.4.3", @@ -2065,6 +2072,7 @@ "resolved": "https://registry.npmjs.org/@pixi/utils/-/utils-7.4.3.tgz", "integrity": "sha512-NO3Y9HAn2UKS1YdxffqsPp+kDpVm8XWvkZcS/E+rBzY9VTLnNOI7cawSRm+dacdET3a8Jad3aDKEDZ0HmAqAFA==", "license": "MIT", + "peer": true, "dependencies": { "@pixi/color": "7.4.3", "@pixi/constants": "7.4.3", @@ -3643,7 +3651,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -3718,7 +3727,8 @@ "version": "0.0.12", "resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.12.tgz", "integrity": "sha512-x2tZZYkSxXqWvTDgveSynfjq/T2HyiZHXb00j/+gy19yp70PHCizM48XFdjBCWH7eHBD0R5i/pw9yMBP/BH5uA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/debug": { "version": "4.1.13", @@ -3756,7 +3766,8 @@ "version": "2.1.4", "resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-2.1.4.tgz", "integrity": "sha512-qp3m9PPz4gULB9MhjGID7wpo3gJ4bTGXm7ltNDsmOvsPduTeHp8wSW9YckBj3mljeOh4F0m2z/0JKAALRKbmLQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/estree": { "version": "1.0.8", @@ -3855,7 +3866,6 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -3867,7 +3877,6 @@ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -4149,7 +4158,6 @@ "integrity": "sha512-CWy0lBQJq97nionyJJdnaU4961IXTl43a7UCu5nHy51IoKxAt6PVIJLo+76rVl7KOOgcWHNkG4kbJu/pW7knvA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/browser": "4.1.5", "@vitest/mocker": "4.1.5", @@ -4342,7 +4350,6 @@ "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -4868,7 +4875,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -5050,6 +5056,7 @@ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "license": "MIT", + "peer": true, "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" @@ -5400,7 +5407,8 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/cross-spawn": { "version": "7.0.6", @@ -5690,7 +5698,6 @@ "integrity": "sha512-glMJgnTreo8CFINujtAhCgN96QAqApDMZ8Vl1r8f0QT8QprvC1UCltV4CcWj20YoIyLZx6IUskaJZ0NV8fokcg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", @@ -5783,7 +5790,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dotenv": { "version": "16.6.1", @@ -5832,7 +5840,8 @@ "version": "2.2.4", "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==", - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/ejs": { "version": "3.1.10", @@ -6015,6 +6024,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -6035,6 +6045,7 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -6277,7 +6288,8 @@ "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/expect-type": { "version": "1.3.0", @@ -7689,6 +7701,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -7908,6 +7921,7 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -8221,6 +8235,7 @@ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.4" }, @@ -8429,7 +8444,6 @@ "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.18.1.tgz", "integrity": "sha512-6LUPWYgulZhp/w4kam2XHXB0QedISZIqrJbRdHLLQ3csn5a38uzKxAp6B5j6s89QFYaIJbg95kvgTRcbgpO1ow==", "license": "MIT", - "peer": true, "workspaces": [ "examples", "playground" @@ -8475,7 +8489,6 @@ "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "playwright-core": "1.59.1" }, @@ -8546,7 +8559,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -8691,6 +8703,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -8708,6 +8721,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -8718,6 +8732,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -8733,6 +8748,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -8846,6 +8862,7 @@ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", "license": "BSD-3-Clause", + "peer": true, "dependencies": { "side-channel": "^1.1.0" }, @@ -8904,7 +8921,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -8917,7 +8933,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -8954,7 +8969,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-refresh": { "version": "0.18.0", @@ -9275,6 +9291,7 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -9481,6 +9498,7 @@ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "license": "MIT", + "peer": true, "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", @@ -9500,6 +9518,7 @@ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", + "peer": true, "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" @@ -9516,6 +9535,7 @@ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "license": "MIT", + "peer": true, "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -9534,6 +9554,7 @@ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "license": "MIT", + "peer": true, "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", @@ -9874,7 +9895,6 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", "license": "MIT", - "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -9958,6 +9978,7 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -10358,6 +10379,7 @@ "resolved": "https://registry.npmjs.org/url/-/url-0.11.4.tgz", "integrity": "sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==", "license": "MIT", + "peer": true, "dependencies": { "punycode": "^1.4.1", "qs": "^6.12.3" @@ -10370,7 +10392,8 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/use-callback-ref": { "version": "1.3.3", @@ -10463,7 +10486,6 @@ "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -10553,8 +10575,7 @@ "resolved": "https://registry.npmjs.org/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz", "integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/vite/node_modules/fsevents": { "version": "2.3.3", @@ -10577,7 +10598,6 @@ "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.1.5", "@vitest/mocker": "4.1.5", diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index bffbd9c9a..7c2d263a1 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -28,6 +28,8 @@ import { requestCameraAccess } from "../../lib/requestCameraAccess"; import { formatTimePadded } from "../../utils/timeUtils"; import { AudioLevelMeter } from "../ui/audio-level-meter"; import { Button } from "../ui/button"; +import Colorful from "@uiw/react-color-colorful"; +import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; import { Tooltip } from "../ui/tooltip"; import styles from "./LaunchWindow.module.css"; @@ -106,8 +108,14 @@ export function LaunchWindow() { setWebcamEnabled, webcamDeviceId, setWebcamDeviceId, + captureBackgroundColor, + setCaptureBackgroundColor, } = useScreenRecorder(); + const [isBgPickerOpen, setIsBgPickerOpen] = useState(false); + const [showCustomWheel, setShowCustomWheel] = useState(false); + const BG_PRESETS = ["#000000", "#ffffff", "#1e90ff", "#00b140"] as const; + const showMicControls = microphoneEnabled && !recording; const showWebcamControls = webcamEnabled && !recording; @@ -541,6 +549,129 @@ export function LaunchWindow() { + {/* Background color for compositing */} + {!recording && ( + + +
+ +
+
+ +
Recording Background
+
+ {/* Transparent swatch */} + + ); + })()} +
+ + {captureBackgroundColor === null && ( +

+ Transparent areas will still export as black — video codecs don't support alpha. +

+ )} + + {/* Inline color wheel */} + {showCustomWheel && ( +
+ setCaptureBackgroundColor(c.hex)} + disableAlpha={true} + style={{ borderRadius: "8px", width: "220px" }} + /> +
+ )} +
+
+ )} + {/* Record/Stop group */} - ); - })} +
+
+ {t("zoom.size")} + + {selectedZoomScale != null ? `${Math.round(selectedZoomScale * 100)}%` : "—"} + +
+ onZoomScaleChange?.(v)} + onValueCommit={() => onZoomScaleCommit?.()} + disabled={!zoomEnabled} + className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" + /> +
+ {t("zoom.less")} + {t("zoom.more")} +
+ {zoomEnabled && ( +
+
{t("zoom.shape")}
+ +
+ )} {!zoomEnabled && (

{t("zoom.selectRegion")}

)} @@ -689,7 +716,6 @@ export function SettingsPanel({ )} - {zoomEnabled && ( + ))} + + + + + {onAspectRatioChange && ( + + +
+ + {t("canvas.title")} +
+
+ + + +
+ )} + - - +
+ +
+ + { + pushState({ deviceFrame: frame }); + }, + [pushState], + ); + const effectiveCursorHighlight = useMemo( () => (isMac ? cursorHighlight : { ...cursorHighlight, onlyOnClicks: false }), [cursorHighlight, isMac], @@ -814,6 +826,17 @@ export default function VideoEditor() { [selectedZoomId, pushState], ); + const handleZoomScaleChange = useCallback( + (id: string, scale: number) => { + updateState((prev) => ({ + zoomRegions: prev.zoomRegions.map((region) => + region.id === id ? { ...region, customScale: scale } : region, + ), + })); + }, + [updateState], + ); + const handleZoomFocusModeChange = useCallback( (focusMode: ZoomFocusMode) => { if (!selectedZoomId) return; @@ -855,6 +878,20 @@ export default function VideoEditor() { [selectedZoomId, pushState], ); + const handleZoomAspectRatioChange = useCallback( + (ratio: AspectRatio) => { + if (!selectedZoomId) return; + pushState((prev) => ({ + zoomRegions: prev.zoomRegions.map((z) => + z.id === selectedZoomId + ? { ...z, zoomAspectRatio: ratio === "native" ? undefined : ratio } + : z, + ), + })); + }, + [selectedZoomId, pushState], + ); + const handleTrimDelete = useCallback( (id: string) => { pushState((prev) => ({ @@ -1427,6 +1464,7 @@ export default function VideoEditor() { cursorTelemetry, cursorClickTimestamps, cursorHighlight: effectiveCursorHighlight, + deviceFrame: deviceFrame ?? "none", onProgress: (progress: ExportProgress) => { setExportProgress(progress); }, @@ -1569,6 +1607,7 @@ export default function VideoEditor() { cursorTelemetry, cursorClickTimestamps, cursorHighlight: effectiveCursorHighlight, + deviceFrame: deviceFrame ?? "none", onProgress: (progress: ExportProgress) => { setExportProgress(progress); }, @@ -1654,6 +1693,7 @@ export default function VideoEditor() { cursorTelemetry, cursorClickTimestamps, effectiveCursorHighlight, + deviceFrame, t, ], ); @@ -1926,6 +1966,7 @@ export default function VideoEditor() { cursorTelemetry={cursorTelemetry} cursorHighlight={effectiveCursorHighlight} cursorClickTimestamps={cursorClickTimestamps} + deviceFrame={deviceFrame} /> @@ -1958,6 +1999,7 @@ export default function VideoEditor() { currentTime={currentTime} onSeek={handleSeek} cursorTelemetry={cursorTelemetry} + cursorClickTimestamps={cursorClickTimestamps} zoomRegions={zoomRegions} onZoomAdded={handleZoomAdded} onZoomSuggested={handleZoomSuggested} @@ -2018,6 +2060,19 @@ export default function VideoEditor() { selectedZoomId ? zoomRegions.find((z) => z.id === selectedZoomId)?.depth : null } onZoomDepthChange={(depth) => selectedZoomId && handleZoomDepthChange(depth)} + selectedZoomScale={ + selectedZoomId + ? getZoomScale(zoomRegions.find((z) => z.id === selectedZoomId) ?? { depth: DEFAULT_ZOOM_DEPTH }) + : null + } + onZoomScaleChange={(scale) => selectedZoomId && handleZoomScaleChange(selectedZoomId, scale)} + onZoomScaleCommit={commitState} + selectedZoomAspectRatio={ + selectedZoomId + ? (zoomRegions.find((z) => z.id === selectedZoomId)?.zoomAspectRatio ?? "native") + : null + } + onZoomAspectRatioChange={handleZoomAspectRatioChange} selectedZoomFocusMode={ selectedZoomId ? (zoomRegions.find((z) => z.id === selectedZoomId)?.focusMode ?? "manual") @@ -2052,6 +2107,16 @@ export default function VideoEditor() { cropRegion={cropRegion} onCropChange={(r) => pushState({ cropRegion: r })} aspectRatio={aspectRatio} + onAspectRatioChange={(ar) => + pushState({ + aspectRatio: ar, + webcamLayoutPreset: + (isPortraitAspectRatio(ar) && webcamLayoutPreset === "dual-frame") || + (!isPortraitAspectRatio(ar) && webcamLayoutPreset === "vertical-stack") + ? "picture-in-picture" + : webcamLayoutPreset, + }) + } hasWebcam={Boolean(webcamVideoPath)} webcamLayoutPreset={webcamLayoutPreset} onWebcamLayoutPresetChange={(preset) => @@ -2114,6 +2179,8 @@ export default function VideoEditor() { unsavedExport={unsavedExport} onSaveUnsavedExport={handleSaveUnsavedExport} onSaveDiagnostic={handleSaveDiagnostic} + deviceFrame={deviceFrame} + onDeviceFrameChange={handleDeviceFrameChange} /> diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index ee52bd9f6..2c6733bbf 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -30,21 +30,22 @@ import { getCssClipPath } from "@/lib/webcamMaskShapes"; import { type AspectRatio, formatAspectRatioForCSS, + getAspectRatioValue, getNativeAspectRatioValue, } from "@/utils/aspectRatioUtils"; import { AnnotationOverlay } from "./AnnotationOverlay"; +import { DeviceFrameOverlay } from "./DeviceFrameOverlay"; import { type AnnotationRegion, type BlurData, computeRotation3DContainScale, DEFAULT_ROTATION_3D, + getZoomScale, isRotation3DIdentity, lerpRotation3D, rotation3DPerspective, type SpeedRegion, type TrimRegion, - ZOOM_DEPTH_SCALES, - type ZoomDepth, type ZoomFocus, type ZoomRegion, } from "./types"; @@ -67,7 +68,6 @@ import { DEFAULT_CURSOR_HIGHLIGHT, drawCursorHighlightGraphics, } from "./videoPlayback/cursorHighlight"; -import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focusUtils"; import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils"; import { clamp01 } from "./videoPlayback/mathUtils"; import { updateOverlayIndicator } from "./videoPlayback/overlayUtils"; @@ -127,6 +127,7 @@ interface VideoPlaybackProps { cursorTelemetry?: import("./types").CursorTelemetryPoint[]; cursorHighlight?: CursorHighlightConfig; cursorClickTimestamps?: number[]; + deviceFrame?: import("@/lib/deviceFrames").DeviceFrameType; } export interface VideoPlaybackRef { @@ -187,6 +188,7 @@ const VideoPlayback = forwardRef( cursorTelemetry = [], cursorHighlight = DEFAULT_CURSOR_HIGHLIGHT, cursorClickTimestamps = [], + deviceFrame = "none" as import("@/lib/deviceFrames").DeviceFrameType, }, ref, ) => { @@ -201,6 +203,7 @@ const VideoPlayback = forwardRef( const [pixiReady, setPixiReady] = useState(false); const [videoReady, setVideoReady] = useState(false); const [overlaySize, setOverlaySize] = useState({ width: 800, height: 600 }); + const [stageSize, setStageSize] = useState({ width: 0, height: 0 }); const [overlayElement, setOverlayElement] = useState(null); const overlayRef = useRef(null); @@ -258,8 +261,16 @@ const VideoPlayback = forwardRef( const smoothedAutoFocusRef = useRef(null); const prevTargetProgressRef = useRef(0); - const clampFocusToStage = useCallback((focus: ZoomFocus, depth: ZoomDepth) => { - return clampFocusToStageUtil(focus, depth, stageSizeRef.current); + // Track outer wrapper size for the device frame overlay + useEffect(() => { + const el = outerWrapperRef.current; + if (!el) return; + const ro = new ResizeObserver(([entry]) => { + const { width, height } = entry.contentRect; + setStageSize({ width, height }); + }); + ro.observe(el); + return () => ro.disconnect(); }, []); const updateOverlayForRegion = useCallback( @@ -442,7 +453,41 @@ const VideoPlayback = forwardRef( cx: clamp01(localX / stageWidth), cy: clamp01(localY / stageHeight), }; - const clampedFocus = clampFocusToStage(unclampedFocus, region.depth); + const zoomScale = getZoomScale(region); + const zoomAR = region.zoomAspectRatio + ? getAspectRatioValue(region.zoomAspectRatio) + : stageWidth / stageHeight; + const canvasAR = stageWidth / stageHeight; + let indW: number, indH: number; + if (Math.abs(zoomAR - canvasAR) < 0.01) { + indW = stageWidth / zoomScale; + indH = stageHeight / zoomScale; + } else if (zoomAR < canvasAR) { + indH = stageHeight / zoomScale; + indW = indH * zoomAR; + } else { + indW = stageWidth / zoomScale; + indH = indW / zoomAR; + } + const hfx = indW / (2 * stageWidth); + const hfy = indH / (2 * stageHeight); + + // Clamp focus center to the actual video content rect so the indicator + // can't drag into letterbox/background areas outside the recording. + const boX = baseOffsetRef.current?.x ?? 0; + const boY = baseOffsetRef.current?.y ?? 0; + const vW = videoSizeRef.current?.width ?? stageWidth; + const vH = videoSizeRef.current?.height ?? stageHeight; + const bs = baseScaleRef.current ?? 1; + const contentMinX = boX / stageWidth; + const contentMaxX = (boX + vW * bs) / stageWidth; + const contentMinY = boY / stageHeight; + const contentMaxY = (boY + vH * bs) / stageHeight; + + const clampedFocus: ZoomFocus = { + cx: Math.max(contentMinX + hfx, Math.min(contentMaxX - hfx, unclampedFocus.cx)), + cy: Math.max(contentMinY + hfy, Math.min(contentMaxY - hfy, unclampedFocus.cy)), + }; onZoomFocusChange(region.id, clampedFocus); updateOverlayForRegion({ ...region, focus: clampedFocus }, clampedFocus); @@ -951,7 +996,7 @@ const VideoPlayback = forwardRef( const shouldShowUnzoomedView = hasSelectedZoom && !isPlayingRef.current; if (region && strength > 0 && !shouldShowUnzoomedView) { - const zoomScale = blendedScale ?? ZOOM_DEPTH_SCALES[region.depth]; + const zoomScale = blendedScale ?? getZoomScale(region); const regionFocus = region.focus; targetScaleFactor = zoomScale; @@ -999,6 +1044,45 @@ const VideoPlayback = forwardRef( } prevTargetProgressRef.current = targetProgress; + // Clamp auto-focus targetFocus to video content rect so the zoom + // indicator never drifts into letterbox areas outside the recording. + if (region.focusMode === "auto" && !transition) { + const mask = baseMaskRef.current; + const stageW = stageSizeRef.current.width; + const stageH = stageSizeRef.current.height; + if (mask.width > 0 && mask.height > 0 && stageW > 0 && stageH > 0) { + const zoomScaleLocal = targetScaleFactor; + const zoomAR = region.zoomAspectRatio + ? getAspectRatioValue(region.zoomAspectRatio) + : stageW / stageH; + const canvasAR = stageW / stageH; + let indW: number, indH: number; + if (Math.abs(zoomAR - canvasAR) < 0.01) { + indW = stageW / zoomScaleLocal; + indH = stageH / zoomScaleLocal; + } else if (zoomAR < canvasAR) { + indH = stageH / zoomScaleLocal; + indW = indH * zoomAR; + } else { + indW = stageW / zoomScaleLocal; + indH = indW / zoomAR; + } + const hfx = indW / (2 * stageW); + const hfy = indH / (2 * stageH); + const contentMinX = mask.x / stageW; + const contentMaxX = (mask.x + mask.width) / stageW; + const contentMinY = mask.y / stageH; + const contentMaxY = (mask.y + mask.height) / stageH; + targetFocus = { + cx: Math.max(contentMinX + hfx, Math.min(contentMaxX - hfx, targetFocus.cx)), + cy: Math.max(contentMinY + hfy, Math.min(contentMaxY - hfy, targetFocus.cy)), + }; + // Anchor next tick's smoothing to the clamped position so the smooth + // path never starts outside the content rect. + smoothedAutoFocusRef.current = targetFocus; + } + } + // Handle connected zoom transitions (pan between adjacent zoom regions) if (transition) { const startTransform = computeZoomTransform({ @@ -1216,6 +1300,7 @@ const VideoPlayback = forwardRef( const resolvedWallpaper = useMemo(() => { const source = wallpaper || DEFAULT_WALLPAPER; const classified = classifyWallpaper(source); + if (classified.kind === "transparent") return null; if (classified.kind !== "image") return classified.value; try { return resolveImageWallpaperUrl(classified.path); @@ -1311,9 +1396,11 @@ const VideoPlayback = forwardRef( resolvedWallpaper.startsWith("/") || resolvedWallpaper.startsWith("data:")), ); - const backgroundStyle = isImageUrl - ? { backgroundImage: `url(${resolvedWallpaper || ""})` } - : { background: resolvedWallpaper || "" }; + const backgroundStyle: React.CSSProperties = resolvedWallpaper + ? isImageUrl + ? { backgroundImage: `url(${resolvedWallpaper})` } + : { background: resolvedWallpaper } + : {}; return (
(
)} + {/* Device frame overlay — rendered outside the 3D transform container so it stays flat */} + {deviceFrame && deviceFrame !== "none" && ( + + )}