diff --git a/EXTENSIONS.md b/EXTENSIONS.md index b0c59b94..cc2b3e4c 100644 --- a/EXTENSIONS.md +++ b/EXTENSIONS.md @@ -289,3 +289,4 @@ Use `parentSection` to nest your panel inside an existing area such as `cursor` ## Examples - `extension-examples/webadderall.more-wallpapers` shows a user-installable wallpaper bundle that registers 180 packaged wallpapers through `registerWallpaper()`. +- `extension-examples/willytop8.click-ripple` shows a cursor-effect extension that renders animated ripple, pulse, and burst effects at every click using `registerCursorEffect()`. diff --git a/extension-examples/willytop8.click-ripple/.gitignore b/extension-examples/willytop8.click-ripple/.gitignore new file mode 100644 index 00000000..3c459389 --- /dev/null +++ b/extension-examples/willytop8.click-ripple/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +*.log +.DS_Store diff --git a/extension-examples/willytop8.click-ripple/LICENSE b/extension-examples/willytop8.click-ripple/LICENSE new file mode 100644 index 00000000..f1b73583 --- /dev/null +++ b/extension-examples/willytop8.click-ripple/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 William Vest + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/extension-examples/willytop8.click-ripple/README.md b/extension-examples/willytop8.click-ripple/README.md new file mode 100644 index 00000000..5e47a272 --- /dev/null +++ b/extension-examples/willytop8.click-ripple/README.md @@ -0,0 +1,87 @@ +# Recordly Click Ripple + +Animated cursor click effects — ripple, pulse, or burst — rendered at every click. Visible in the editor preview and baked into the exported video. + +## Install + +### From the Recordly Marketplace + +Search for **Click Ripple** at [marketplace.recordly.dev/extensions](https://marketplace.recordly.dev/extensions) and click **Install**. + +### From a release zip + +1. Download the latest zip from the [Releases](https://github.com/willytop8/recordly-click-ripple/releases/latest) page. +2. In Recordly, go to **Extensions → Open Directory**. Your file manager opens the user extensions folder. +3. Unzip the archive into that folder so that a `recordly-click-ripple/` directory appears there with `recordly-extension.json` at its root. +4. Restart Recordly. +5. Open the Extensions panel and confirm **Click Ripple** shows as **active**. + +### From source + +```bash +git clone https://github.com/willytop8/recordly-click-ripple +cd recordly-click-ripple +npm install +npm run build +``` + +Copy the folder into Recordly's extensions directory (step 2 above) and restart. + +## Settings + +Configure under **Settings → Cursor → Click Effects**. All settings update live. + +| Setting | Default | Range | +|---|---|---| +| Enable | on | toggle | +| Style | Ripple | Ripple / Pulse / Burst | +| Color | `#FFFFFF` | color picker | +| Size | 1.0 | 0.5–2.5 | +| Duration (ms) | 600 | 200–1500 | +| Line thickness | 2 | 1–8 | +| Distinct right-click style | on | toggle | + +Size scales relative to the scene area, not the canvas, so effects look consistent across different export resolutions and padding settings. + +## Styles + +**Ripple** — two concentric rings that expand and fade out. The default; draws attention without dominating the frame. + +**Pulse** — a filled circle that scales up and fades. More visible than Ripple; good when you need clicks to read clearly in fast-paced content. + +**Burst** — eight radial lines extending from the click point. The most prominent; useful in short-form demos where every click needs to land immediately. + +When **Distinct right-click style** is on, right-clicks render with dashed lines so viewers can tell them apart from left-clicks at a glance. + +## Building from source + +```bash +npm install +npm run build # one-off build +npm run watch # rebuild on save +``` + +Output goes to `dist/index.js`. The manifest `main` field points there. + +## How it works + +The extension registers a cursor effect via `registerCursorEffect`. Each click triggers a per-frame callback that draws the current animation frame onto Recordly's canvas and returns `true` until the duration elapses, then `false`. The same callback runs in both the editor preview and the export pipeline — what you see is what you get. + +## Permissions + +- `cursor` — to register click effects. +- `ui` — to register the settings panel. + +No audio, timeline, file asset, or export access. + +## License + +MIT — see [LICENSE](./LICENSE). + +## Contributing + +Issues and pull requests welcome at [github.com/willytop8/recordly-click-ripple](https://github.com/willytop8/recordly-click-ripple). The extension API is documented in [EXTENSIONS.md](https://github.com/webadderallorg/Recordly/blob/main/EXTENSIONS.md). + +## Credits + +Built for [Recordly](https://www.recordly.dev) by [@webadderallorg](https://github.com/webadderallorg). diff --git a/extension-examples/willytop8.click-ripple/build.mjs b/extension-examples/willytop8.click-ripple/build.mjs new file mode 100644 index 00000000..100407d4 --- /dev/null +++ b/extension-examples/willytop8.click-ripple/build.mjs @@ -0,0 +1,43 @@ +import { build, context } from "esbuild"; +import { copyFileSync, existsSync } from "fs"; +import { homedir } from "os"; +import { join } from "path"; + +const INSTALLED = join( + homedir(), + "Library/Application Support/Recordly/extensions/recordly-click-ripple/dist/index.js", +); + +function deployToInstalled() { + if (existsSync(INSTALLED)) { + copyFileSync("dist/index.js", INSTALLED); + console.log("Deployed → " + INSTALLED); + } +} + +const config = { + entryPoints: ["src/index.ts"], + outfile: "dist/index.js", + bundle: true, + format: "esm", + target: "es2022", + platform: "browser", + minify: false, + sourcemap: false, +}; + +if (process.argv.includes("--watch")) { + const ctx = await context({ + ...config, + plugins: [{ + name: "deploy-on-rebuild", + setup(b) { b.onEnd(() => deployToInstalled()); }, + }], + }); + await ctx.watch(); + console.log("Watching src/ — Ctrl-C to stop"); +} else { + await build(config); + console.log("Built dist/index.js"); + deployToInstalled(); +} diff --git a/extension-examples/willytop8.click-ripple/dist/index.js b/extension-examples/willytop8.click-ripple/dist/index.js new file mode 100644 index 00000000..81c24251 --- /dev/null +++ b/extension-examples/willytop8.click-ripple/dist/index.js @@ -0,0 +1,127 @@ +// src/index.ts +function activate(api) { + api.registerSettingsPanel({ + id: "click-ripple-settings", + label: "Click Effects", + icon: "sparkles", + parentSection: "cursor", + fields: [ + { id: "enabled", label: "Enable click effects", type: "toggle", defaultValue: true }, + { + id: "style", + label: "Style", + type: "select", + defaultValue: "ripple", + options: [ + { label: "Ripple (concentric rings)", value: "ripple" }, + { label: "Pulse (filled fade)", value: "pulse" }, + { label: "Burst (radial spokes)", value: "burst" } + ] + }, + { id: "color", label: "Color", type: "color", defaultValue: "#FFFFFF" }, + { id: "size", label: "Size", type: "slider", defaultValue: 1, min: 0.5, max: 2.5, step: 0.1 }, + { id: "durationMs", label: "Duration (ms)", type: "slider", defaultValue: 600, min: 200, max: 1500, step: 50 }, + { id: "thickness", label: "Line thickness", type: "slider", defaultValue: 2, min: 1, max: 8, step: 1 }, + { id: "differentiateRightClick", label: "Distinct right-click style", type: "toggle", defaultValue: true } + ] + }); + api.registerCursorEffect((ctx) => { + const enabled = api.getSetting("enabled") ?? true; + if (!enabled) return false; + const style = api.getSetting("style") ?? "ripple"; + const color = api.getSetting("color") ?? "#FFFFFF"; + const size = api.getSetting("size") ?? 1; + const durationMs = api.getSetting("durationMs") ?? 600; + const thickness = api.getSetting("thickness") ?? 2; + const differentiateRC = api.getSetting("differentiateRightClick") ?? true; + if (ctx.elapsedMs >= durationMs) return false; + const progress = ctx.elapsedMs / durationMs; + const isRightClick = ctx.interactionType === "right-click"; + const distinct = isRightClick && differentiateRC; + const t = ctx.sceneTransform; + let x = ctx.cx * ctx.width; + let y = ctx.cy * ctx.height; + if (t != null && Number.isFinite(t.scale) && t.scale !== 0) { + x = (x - t.x) / t.scale; + y = (y - t.y) / t.scale; + } + const sceneWidth = ctx.videoLayout?.maskRect.width ?? ctx.width; + const baseRadius = sceneWidth * 0.04 * size; + switch (style) { + case "ripple": + drawRipple(ctx.ctx, x, y, progress, baseRadius, color, thickness, distinct); + break; + case "pulse": + drawPulse(ctx.ctx, x, y, progress, baseRadius, color, distinct); + break; + case "burst": + drawBurst(ctx.ctx, x, y, progress, baseRadius, color, thickness, distinct); + break; + } + return true; + }); + api.log("Click Ripple activated"); +} +function deactivate() { +} +function drawRipple(ctx, x, y, p, base, color, thick, distinct) { + const easeOut = (t) => 1 - Math.pow(1 - t, 3); + if (p > 0.15) { + const op = (p - 0.15) / 0.85; + drawRing(ctx, x, y, base * 1.4 * easeOut(op), color, thick, 1 - op, distinct); + } + drawRing(ctx, x, y, base * easeOut(p), color, thick, 1 - p, distinct); +} +function drawRing(ctx, x, y, r, color, thick, alpha, dashed) { + ctx.save(); + ctx.globalAlpha = Math.max(0, Math.min(1, alpha)); + ctx.strokeStyle = color; + ctx.lineWidth = thick; + if (dashed) ctx.setLineDash([thick * 2, thick * 2]); + ctx.beginPath(); + ctx.arc(x, y, r, 0, Math.PI * 2); + ctx.stroke(); + ctx.restore(); +} +function drawPulse(ctx, x, y, p, base, color, distinct) { + const easeOut = (t) => 1 - Math.pow(1 - t, 2); + const r = base * 0.9 * easeOut(p); + const alpha = (1 - p) * 0.5; + ctx.save(); + ctx.globalAlpha = alpha; + ctx.beginPath(); + ctx.arc(x, y, r, 0, Math.PI * 2); + if (distinct) { + ctx.strokeStyle = color; + ctx.lineWidth = 3; + ctx.stroke(); + } else { + ctx.fillStyle = color; + ctx.fill(); + } + ctx.restore(); +} +function drawBurst(ctx, x, y, p, base, color, thick, distinct) { + const easeOut = (t) => 1 - Math.pow(1 - t, 2); + const SPOKES = 8; + const innerR = base * 0.3 * easeOut(p); + const outerR = base * 1.1 * easeOut(p); + ctx.save(); + ctx.globalAlpha = 1 - p; + ctx.strokeStyle = color; + ctx.lineWidth = thick; + ctx.lineCap = "round"; + if (distinct) ctx.setLineDash([thick * 1.5, thick * 1.5]); + for (let i = 0; i < SPOKES; i++) { + const angle = i / SPOKES * Math.PI * 2; + ctx.beginPath(); + ctx.moveTo(x + Math.cos(angle) * innerR, y + Math.sin(angle) * innerR); + ctx.lineTo(x + Math.cos(angle) * outerR, y + Math.sin(angle) * outerR); + ctx.stroke(); + } + ctx.restore(); +} +export { + activate, + deactivate +}; diff --git a/extension-examples/willytop8.click-ripple/package-lock.json b/extension-examples/willytop8.click-ripple/package-lock.json new file mode 100644 index 00000000..16891f0f --- /dev/null +++ b/extension-examples/willytop8.click-ripple/package-lock.json @@ -0,0 +1,514 @@ +{ + "name": "recordly-click-ripple", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "recordly-click-ripple", + "version": "1.0.0", + "devDependencies": { + "esbuild": "^0.27.0", + "typescript": "^5.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/extension-examples/willytop8.click-ripple/package.json b/extension-examples/willytop8.click-ripple/package.json new file mode 100644 index 00000000..e318ae49 --- /dev/null +++ b/extension-examples/willytop8.click-ripple/package.json @@ -0,0 +1,14 @@ +{ + "name": "recordly-click-ripple", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "node build.mjs", + "watch": "node build.mjs --watch" + }, + "devDependencies": { + "esbuild": "^0.27.0", + "typescript": "^5.9.0" + } +} diff --git a/extension-examples/willytop8.click-ripple/recordly-extension.json b/extension-examples/willytop8.click-ripple/recordly-extension.json new file mode 100644 index 00000000..a919f920 --- /dev/null +++ b/extension-examples/willytop8.click-ripple/recordly-extension.json @@ -0,0 +1,19 @@ +{ + "id": "dev.williamjvest.click-ripple", + "name": "Click Ripple", + "version": "1.0.0", + "description": "Animated ripple, pulse, and burst effects rendered at every cursor click.", + "author": "William Vest", + "homepage": "https://github.com/willytop8/recordly-click-ripple", + "license": "MIT", + "engine": "1.2.0", + "icon": "icon.png", + "screenshots": [ + "screenshots/ripple-style.png", + "screenshots/pulse-style.png", + "screenshots/burst-style.png", + "screenshots/settings-panel.png" + ], + "main": "dist/index.js", + "permissions": ["cursor", "ui"] +} diff --git a/extension-examples/willytop8.click-ripple/screenshots/burst-style.png b/extension-examples/willytop8.click-ripple/screenshots/burst-style.png new file mode 100644 index 00000000..eade8349 Binary files /dev/null and b/extension-examples/willytop8.click-ripple/screenshots/burst-style.png differ diff --git a/extension-examples/willytop8.click-ripple/screenshots/pulse-style.png b/extension-examples/willytop8.click-ripple/screenshots/pulse-style.png new file mode 100644 index 00000000..961f59fb Binary files /dev/null and b/extension-examples/willytop8.click-ripple/screenshots/pulse-style.png differ diff --git a/extension-examples/willytop8.click-ripple/screenshots/ripple-style.png b/extension-examples/willytop8.click-ripple/screenshots/ripple-style.png new file mode 100644 index 00000000..a7d2d9bb Binary files /dev/null and b/extension-examples/willytop8.click-ripple/screenshots/ripple-style.png differ diff --git a/extension-examples/willytop8.click-ripple/screenshots/settings-panel.png b/extension-examples/willytop8.click-ripple/screenshots/settings-panel.png new file mode 100644 index 00000000..24b0d1c4 Binary files /dev/null and b/extension-examples/willytop8.click-ripple/screenshots/settings-panel.png differ diff --git a/extension-examples/willytop8.click-ripple/src/index.ts b/extension-examples/willytop8.click-ripple/src/index.ts new file mode 100644 index 00000000..91842f65 --- /dev/null +++ b/extension-examples/willytop8.click-ripple/src/index.ts @@ -0,0 +1,188 @@ +import type { RecordlyExtensionAPI, CursorEffectContext } from "./recordly-types"; + +type Style = "ripple" | "pulse" | "burst"; + +export function activate(api: RecordlyExtensionAPI) { + api.registerSettingsPanel({ + id: "click-ripple-settings", + label: "Click Effects", + icon: "sparkles", + parentSection: "cursor", + fields: [ + { id: "enabled", label: "Enable click effects", type: "toggle", defaultValue: true }, + { + id: "style", + label: "Style", + type: "select", + defaultValue: "ripple", + options: [ + { label: "Ripple (concentric rings)", value: "ripple" }, + { label: "Pulse (filled fade)", value: "pulse" }, + { label: "Burst (radial spokes)", value: "burst" }, + ], + }, + { id: "color", label: "Color", type: "color", defaultValue: "#FFFFFF" }, + { id: "size", label: "Size", type: "slider", defaultValue: 1.0, min: 0.5, max: 2.5, step: 0.1 }, + { id: "durationMs", label: "Duration (ms)", type: "slider", defaultValue: 600, min: 200, max: 1500, step: 50 }, + { id: "thickness", label: "Line thickness", type: "slider", defaultValue: 2, min: 1, max: 8, step: 1 }, + { id: "differentiateRightClick", label: "Distinct right-click style", type: "toggle", defaultValue: true }, + ], + }); + + api.registerCursorEffect((ctx: CursorEffectContext): boolean => { + const enabled = (api.getSetting("enabled") as boolean) ?? true; + if (!enabled) return false; + + const style = (api.getSetting("style") as Style) ?? "ripple"; + const color = (api.getSetting("color") as string) ?? "#FFFFFF"; + const size = (api.getSetting("size") as number) ?? 1.0; + const durationMs = (api.getSetting("durationMs") as number) ?? 600; + const thickness = (api.getSetting("thickness") as number) ?? 2; + const differentiateRC = (api.getSetting("differentiateRightClick") as boolean) ?? true; + + if (ctx.elapsedMs >= durationMs) return false; + + const progress = ctx.elapsedMs / durationMs; + const isRightClick = ctx.interactionType === "right-click"; + const distinct = isRightClick && differentiateRC; + + // Inverse-transform so we draw at canvas-pixel coords, not scene-local coords. + // The canvas context already has applyCanvasSceneTransform applied (translate+scale). + // Guard against a degenerate scale (0 or NaN) to avoid Infinity/NaN coordinates. + const t = ctx.sceneTransform; + let x = ctx.cx * ctx.width; + let y = ctx.cy * ctx.height; + if (t != null && Number.isFinite(t.scale) && t.scale !== 0) { + x = (x - t.x) / t.scale; + y = (y - t.y) / t.scale; + } + + // Scale relative to the SCENE, not the canvas. + const sceneWidth = ctx.videoLayout?.maskRect.width ?? ctx.width; + const baseRadius = sceneWidth * 0.04 * size; + + switch (style) { + case "ripple": + drawRipple(ctx.ctx, x, y, progress, baseRadius, color, thickness, distinct); + break; + case "pulse": + drawPulse(ctx.ctx, x, y, progress, baseRadius, color, distinct); + break; + case "burst": + drawBurst(ctx.ctx, x, y, progress, baseRadius, color, thickness, distinct); + break; + } + + return true; + }); + + api.log("Click Ripple activated"); +} + +export function deactivate() { + // No-op. Recordly disposes registrations automatically. +} + +// --------------------------------------------------------------------------- +// Style: Ripple +// --------------------------------------------------------------------------- + +function drawRipple( + ctx: CanvasRenderingContext2D, + x: number, y: number, + p: number, base: number, + color: string, thick: number, + distinct: boolean, +) { + const easeOut = (t: number) => 1 - Math.pow(1 - t, 3); + + // Outer ring (delayed entrance) + if (p > 0.15) { + const op = (p - 0.15) / 0.85; + drawRing(ctx, x, y, base * 1.4 * easeOut(op), color, thick, 1 - op, distinct); + } + + // Inner ring + drawRing(ctx, x, y, base * easeOut(p), color, thick, 1 - p, distinct); +} + +function drawRing( + ctx: CanvasRenderingContext2D, + x: number, y: number, + r: number, color: string, + thick: number, alpha: number, + dashed: boolean, +) { + ctx.save(); + ctx.globalAlpha = Math.max(0, Math.min(1, alpha)); + ctx.strokeStyle = color; + ctx.lineWidth = thick; + if (dashed) ctx.setLineDash([thick * 2, thick * 2]); + ctx.beginPath(); + ctx.arc(x, y, r, 0, Math.PI * 2); + ctx.stroke(); + ctx.restore(); +} + +// --------------------------------------------------------------------------- +// Style: Pulse +// --------------------------------------------------------------------------- + +function drawPulse( + ctx: CanvasRenderingContext2D, + x: number, y: number, + p: number, base: number, + color: string, + distinct: boolean, +) { + const easeOut = (t: number) => 1 - Math.pow(1 - t, 2); + const r = base * 0.9 * easeOut(p); + const alpha = (1 - p) * 0.5; + + ctx.save(); + ctx.globalAlpha = alpha; + ctx.beginPath(); + ctx.arc(x, y, r, 0, Math.PI * 2); + if (distinct) { + ctx.strokeStyle = color; + ctx.lineWidth = 3; + ctx.stroke(); + } else { + ctx.fillStyle = color; + ctx.fill(); + } + ctx.restore(); +} + +// --------------------------------------------------------------------------- +// Style: Burst +// --------------------------------------------------------------------------- + +function drawBurst( + ctx: CanvasRenderingContext2D, + x: number, y: number, + p: number, base: number, + color: string, thick: number, + distinct: boolean, +) { + const easeOut = (t: number) => 1 - Math.pow(1 - t, 2); + const SPOKES = 8; + const innerR = base * 0.3 * easeOut(p); + const outerR = base * 1.1 * easeOut(p); + + ctx.save(); + ctx.globalAlpha = 1 - p; + ctx.strokeStyle = color; + ctx.lineWidth = thick; + ctx.lineCap = "round"; + if (distinct) ctx.setLineDash([thick * 1.5, thick * 1.5]); + + for (let i = 0; i < SPOKES; i++) { + const angle = (i / SPOKES) * Math.PI * 2; + ctx.beginPath(); + ctx.moveTo(x + Math.cos(angle) * innerR, y + Math.sin(angle) * innerR); + ctx.lineTo(x + Math.cos(angle) * outerR, y + Math.sin(angle) * outerR); + ctx.stroke(); + } + ctx.restore(); +} diff --git a/extension-examples/willytop8.click-ripple/src/recordly-types.d.ts b/extension-examples/willytop8.click-ripple/src/recordly-types.d.ts new file mode 100644 index 00000000..2cfabc99 --- /dev/null +++ b/extension-examples/willytop8.click-ripple/src/recordly-types.d.ts @@ -0,0 +1,357 @@ +/** + * Recordly Extension API types — copied verbatim from + * Recordly/src/lib/extensions/types.ts for standalone extension use. + * Do not modify; regenerate from source when the API changes. + */ + +export interface ContributedCursorStyle { + id: string; + label: string; + /** Path to cursor image relative to extension root */ + defaultImage: string; + /** Optional click state image */ + clickImage?: string; + /** Hotspot offset from top-left (normalized 0-1) */ + hotspot?: { x: number; y: number }; +} + +export interface ContributedSound { + id: string; + label: string; + /** Sound category */ + category: "click" | "transition" | "ambient" | "notification"; + /** Path to audio file relative to extension root */ + file: string; + /** Duration in ms (auto-detected if omitted) */ + durationMs?: number; +} + +export interface ContributedWallpaper { + id: string; + label: string; + /** Path to image/video file relative to extension root */ + file: string; + /** Thumbnail for the picker */ + thumbnail?: string; + /** Whether this is a video wallpaper */ + isVideo?: boolean; +} + +export interface ContributedWebcamFrame { + id: string; + label: string; + /** Path to frame overlay image (PNG with transparency) */ + file: string; + thumbnail?: string; +} + +export interface ContributedFrame { + id: string; + label: string; + /** Category for grouping in the picker */ + category: "browser" | "laptop" | "phone" | "tablet" | "desktop" | "custom"; + /** Path to frame overlay image (PNG or SVG with transparency) relative to extension root */ + file?: string; + /** Alternative: a data URL (e.g. from Canvas.toDataURL) for runtime-generated frames */ + dataUrl?: string; + /** Thumbnail for the picker */ + thumbnail?: string; + /** + * Insets defining where the screen content sits inside the frame image, + * as fractions (0-1) of the frame image dimensions. + * { top, right, bottom, left } + */ + screenInsets: { top: number; right: number; bottom: number; left: number }; + /** Whether the frame has a dark or light appearance (for wallpaper matching) */ + appearance?: "light" | "dark"; + /** + * Resolution-independent draw function. Called at the target dimensions + * to draw the frame chrome, leaving the screen area transparent. + * Preferred over file/dataUrl — avoids bitmap scaling artifacts. + */ + draw?: (ctx: CanvasRenderingContext2D, width: number, height: number) => void; +} + +/** Context passed to render hooks each frame */ +export interface RenderHookContext { + /** Output canvas width */ + width: number; + /** Output canvas height */ + height: number; + /** Current playback time in ms */ + timeMs: number; + /** Total video duration in ms */ + durationMs: number; + /** Current cursor position (normalized 0-1, null if no cursor) */ + cursor: { cx: number; cy: number; interactionType?: string } | null; + /** Current smoothed cursor state and trailing path (normalized 0-1) */ + smoothedCursor?: { + cx: number; + cy: number; + trail: Array<{ cx: number; cy: number }>; + } | null; + /** The 2D rendering context to draw on */ + ctx: CanvasRenderingContext2D; + /** Current video content layout (position & size inside the canvas) */ + videoLayout?: { + /** Position & size of the masked video content area (in canvas pixels) */ + maskRect: { x: number; y: number; width: number; height: number }; + /** Border radius applied to the video (in canvas pixels) */ + borderRadius: number; + /** Padding around the video (in canvas pixels). Can be a number (global) or an object with individual sides. */ + padding: number | { top: number; right: number; bottom: number; left: number }; + }; + /** Current zoom state */ + zoom?: { + /** 1 = no zoom, >1 = zoomed in */ + scale: number; + /** Normalized focus point (0-1) */ + focusX: number; + focusY: number; + /** 0 = idle, 1 = fully zoomed in */ + progress: number; + }; + /** Current scene transform (motion animation offset & scale). */ + sceneTransform?: { + scale: number; + x: number; + y: number; + }; + /** Current shadow settings */ + shadow?: { + enabled: boolean; + intensity: number; + }; + + getPixelColor(x: number, y: number): { r: number; g: number; b: number; a: number }; + getAverageSceneColor(): { r: number; g: number; b: number; a: number }; + getEdgeAverageColor(edgeWidth?: number): { r: number; g: number; b: number; a: number }; + getDominantColors( + count?: number, + ): Array<{ r: number; g: number; b: number; frequency: number }>; +} + +/** Render hook phases — extensions draw in the registered phase */ +export type RenderHookPhase = + | "background" + | "post-video" + | "post-zoom" + | "post-cursor" + | "post-webcam" + | "post-annotations" + | "final"; + +export type RenderHookFn = (ctx: RenderHookContext) => void; + +export interface CursorEffectContext { + /** Current time in ms */ + timeMs: number; + /** Cursor position (normalized 0-1) */ + cx: number; + cy: number; + /** Interaction type that triggered this effect */ + interactionType: "click" | "double-click" | "right-click" | "mouseup"; + /** Canvas dimensions */ + width: number; + height: number; + /** 2D context to draw the effect */ + ctx: CanvasRenderingContext2D; + /** Milliseconds since the interaction occurred */ + elapsedMs: number; + /** Current zoom state (same as RenderHookContext.zoom) */ + zoom?: { + scale: number; + focusX: number; + focusY: number; + progress: number; + }; + /** Current scene transform applied to the canvas */ + sceneTransform?: { + scale: number; + x: number; + y: number; + }; + /** Video content layout inside the canvas */ + videoLayout?: { + maskRect: { x: number; y: number; width: number; height: number }; + borderRadius: number; + padding: number | { top: number; right: number; bottom: number; left: number }; + }; +} + +export type CursorEffectFn = (ctx: CursorEffectContext) => boolean; // return false to stop animation + +export type ExtensionEventType = + | "playback:timeupdate" + | "playback:play" + | "playback:pause" + | "cursor:click" + | "cursor:move" + | "timeline:region-added" + | "timeline:region-removed" + | "export:start" + | "export:frame" + | "export:complete"; + +export interface ExtensionEvent { + type: ExtensionEventType; + timeMs?: number; + data?: unknown; +} + +export type ExtensionEventHandler = (event: ExtensionEvent) => void; + +export interface ExtensionSettingField { + id: string; + label: string; + type: "toggle" | "slider" | "select" | "color" | "text"; + defaultValue: unknown; + /** For sliders */ + min?: number; + max?: number; + step?: number; + /** For select */ + options?: { label: string; value: string }[]; +} + +export interface ExtensionSettingsPanel { + /** Unique panel ID */ + id: string; + /** Display label in settings */ + label: string; + /** Icon name (lucide icon) */ + icon?: string; + /** If set, renders inside this existing section (e.g. 'cursor', 'scene'). + * Otherwise, creates a new standalone section. */ + parentSection?: string; + /** Setting fields */ + fields: ExtensionSettingField[]; +} + +export interface RecordlyExtensionAPI { + /** Register a render hook at a specific pipeline phase */ + registerRenderHook(phase: RenderHookPhase, hook: RenderHookFn): () => void; + + /** Register a cursor click effect */ + registerCursorEffect(effect: CursorEffectFn): () => void; + + /** Register a device frame (browser chrome, laptop bezel, etc.) */ + registerFrame(frame: ContributedFrame): () => void; + + /** Register a wallpaper/background image or video */ + registerWallpaper(wallpaper: ContributedWallpaper): () => void; + + /** Register a cursor style pack */ + registerCursorStyle(cursorStyle: ContributedCursorStyle): () => void; + + /** Listen to extension events */ + on(event: ExtensionEventType, handler: ExtensionEventHandler): () => void; + + /** Register a settings panel for this extension */ + registerSettingsPanel(panel: ExtensionSettingsPanel): () => void; + + /** Get the current value of an extension setting */ + getSetting(settingId: string): unknown; + + /** Set an extension setting value */ + setSetting(settingId: string, value: unknown): void; + + /** Resolve an asset path relative to the extension root */ + resolveAsset(relativePath: string): string; + + /** + * Play a sound from a bundled audio file (relative to extension root). + * Returns a stop function to cancel playback early. + * Optional volume (0-1, default 1). + */ + playSound(relativePath: string, options?: { volume?: number }): () => void; + + /** Log a message (visible in dev tools, prefixed with extension ID) */ + log(message: string, ...args: unknown[]): void; + + /** Get video info (resolution, duration, fps) */ + getVideoInfo(): { + width: number; + height: number; + durationMs: number; + fps: number; + } | null; + + /** Get the current video content layout (mask rect, padding, etc.) */ + getVideoLayout(): { + maskRect: { x: number; y: number; width: number; height: number }; + canvasWidth: number; + canvasHeight: number; + borderRadius: number; + padding: number | { top: number; right: number; bottom: number; left: number }; + } | null; + + getCursorAt(timeMs: number): { + cx: number; + cy: number; + timeMs: number; + interactionType?: string; + pressure?: number; + } | null; + + getSmoothedCursor(): { + cx: number; + cy: number; + timeMs: number; + trail: Array<{ cx: number; cy: number }>; + } | null; + + getZoomState(): { + scale: number; + focusX: number; + focusY: number; + progress: number; + } | null; + + getShadowConfig(): { + enabled: boolean; + intensity: number; + }; + + getKeystrokesInRange( + startMs: number, + endMs: number, + ): Array<{ + timeMs: number; + key: string; + modifiers: string[]; + }>; + + getAspectRatio(): number | null; + getActiveFrame(): string | null; + isExtensionActive(extensionId: string): boolean; + + getPlaybackState(): { + currentTimeMs: number; + durationMs: number; + isPlaying: boolean; + } | null; + + getCanvasDimensions(): { width: number; height: number } | null; + + onSettingChange(callback: (settingId: string, value: unknown) => void): () => void; + getAllSettings(): Record; +} + +export interface FrameInstance { + /** Unique id: extensionId + '/' + frame.id */ + id: string; + /** Extension that contributed this frame */ + extensionId: string; + label: string; + category: ContributedFrame["category"]; + /** Resolved absolute file:// URL to the frame overlay (PNG, SVG, or data URL) */ + filePath: string; + /** Resolved absolute file:// URL to the thumbnail (or filePath if absent) */ + thumbnailPath: string; + /** Screen insets (fraction 0-1 of frame image) */ + screenInsets: { top: number; right: number; bottom: number; left: number }; + appearance?: "light" | "dark"; + /** Resolution-independent draw function (if provided by the extension) */ + draw?: (ctx: CanvasRenderingContext2D, width: number, height: number) => void; +} diff --git a/extension-examples/willytop8.click-ripple/tsconfig.json b/extension-examples/willytop8.click-ripple/tsconfig.json new file mode 100644 index 00000000..8ba4db34 --- /dev/null +++ b/extension-examples/willytop8.click-ripple/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "isolatedModules": true, + "noEmit": true + }, + "include": ["src/**/*"] +}