diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index afdc5de..6c41e9e 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -5,7 +5,7 @@ "url": "https://amigoscode.com" }, "metadata": { - "description": "Amigoscode marketplace — content and developer tools for educators and engineers.", + "description": "Amigoscode marketplace \u2014 content and developer tools for educators and engineers.", "version": "1.0.0", "repository": "https://github.com/amigoscode/skills" }, @@ -13,7 +13,7 @@ { "name": "amigoscode", "source": "./", - "description": "Branded explainer videos, educational infographics (HOW X WORKS diagrams), LinkedIn carousels, X-style quote cards, and an autonomous LinkedIn poster." + "description": "Branded explainer videos, animated backend flow diagrams, educational infographics (HOW X WORKS diagrams), LinkedIn carousels, X-style quote cards, and an autonomous LinkedIn poster." } ] } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 7310d4f..023a5f0 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "amigoscode", "version": "1.0.0", - "description": "Amigoscode content tools — branded explainer videos, educational infographics, LinkedIn carousels, X-style quote cards, and an autonomous LinkedIn poster.", + "description": "Amigoscode content tools \u2014 branded explainer videos, animated backend flow diagrams, educational infographics, LinkedIn carousels, X-style quote cards, and an autonomous LinkedIn poster.", "author": { "name": "Amigoscode", "url": "https://amigoscode.com" @@ -23,6 +23,10 @@ "hyperframes", "headshots", "profile-picture", - "portrait" + "portrait", + "flows-diagram", + "diagram", + "animation", + "backend" ] } diff --git a/README.md b/README.md index de22bbf..5e5924c 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ Skills are markdown files that give AI agents specialized knowledge and workflow |-------|-------------| | [carousel](skills/carousel/) | Generate branded LinkedIn carousel slides (1080x1350px PNGs), a combined PDF, beat-synced MP4s, a GIF, and platform... | | [explainer-video](skills/explainer-video/) | Produce a branded Amigoscode vertical explainer VIDEO (1080x1920 MP4) that teaches one backend, Java, data-structures,... | +| [flows-diagram](skills/flows-diagram/) | Produce a branded Amigoscode animated BACKEND-DIAGRAM video: a React/SVG flow diagram (controllers, queues, threads,... | | [headshots](skills/headshots/) | Generate polished LinkedIn-style profile-picture variations from a single reference photo, while keeping the person's... | | [infographic](skills/infographic/) | Generate 'HOW X WORKS' educational infographic diagrams for Amigoscode — hand-drawn style diagrams about backend/Java... | | [linkedin-poster](skills/linkedin-poster/) | Fully autonomous LinkedIn poster. Drives the LinkedIn web composer via Playwright/TypeScript to publish posts... | @@ -37,7 +38,7 @@ Use [npx skills](https://github.com/vercel-labs/skills) to install skills direct npx skills add amigoscode/skills # Install specific skills -npx skills add amigoscode/skills --skill infographic carousel explainer-video x-card linkedin-poster +npx skills add amigoscode/skills --skill infographic carousel explainer-video flows-diagram x-card linkedin-poster # List available skills npx skills add amigoscode/skills --list @@ -91,6 +92,7 @@ Each skill has its own dependencies and one-time setup. See the skill READMEs: - [`skills/infographic/README.md`](./skills/infographic/README.md) — needs a `GEMINI_API_KEY` and `npm install` for the diagram generator and Playwright screenshot step. - [`skills/carousel/README.md`](./skills/carousel/README.md) — needs `npm install` + `npx playwright install chromium`; Python 3 with `librosa` and `ffmpeg` for the beat-synced videos. - [`skills/explainer-video/README.md`](./skills/explainer-video/README.md) — needs an `ELEVEN_LABS` API key (Creator tier for cloned voices), `ffmpeg`/`ffprobe`, and `npx hyperframes` (Whisper transcription + render). +- [`skills/flows-diagram/README.md`](./skills/flows-diagram/README.md) — needs Google Chrome, `ffmpeg`/`ffprobe`, `npm install` (puppeteer-core), and an `ELEVEN_LABS` API key for the voiceover. - [`skills/x-card/README.md`](./skills/x-card/README.md) — needs Python 3 with Pillow (`pip install pillow`). Fonts and badge are bundled. - [`skills/linkedin-poster/README.md`](./skills/linkedin-poster/README.md) — needs `npm install` + `npx playwright install chromium`, then a one-time LinkedIn login that is saved for future runs. @@ -110,6 +112,9 @@ Once installed, just ask your agent to help with content tasks: "Create a LinkedIn carousel about the top 10 Git commands" → Uses carousel skill +"Make a flows diagram for the outbox pattern" +→ Uses flows-diagram skill + "Make an X card: Juniors write code. Seniors delete it." → Uses x-card skill @@ -125,6 +130,7 @@ You can also invoke skills directly: ``` /infographic /carousel +/flows-diagram /x-card /linkedin-poster ``` diff --git a/VERSIONS.md b/VERSIONS.md index 5b2c047..af7af2d 100644 --- a/VERSIONS.md +++ b/VERSIONS.md @@ -11,9 +11,13 @@ Current versions of all skills. Agents can compare against local versions to che | linkedin-poster | 1.0.0 | 2026-06-23 | Fully autonomous LinkedIn poster that publishes immediately or schedules text, carousel, or image posts via Playwright. | | nanobanana | 1.0.0 | 2026-06-27 | Generate and edit images with Google's Gemini 3.1 Flash Image model: text-to-image, single-image edits, style transfer, and multi-image compositing. | | headshots | 1.1.0 | 2026-06-28 | Generate polished LinkedIn-style profile-picture variations from one reference photo, preserving the person's identity. 11 options including a restore-only mode, picked via checkbox; fans out one subagent per style. Self-contained Gemini generator. | +| flows-diagram | 1.0.0 | 2026-07-31 | Animated backend flow diagrams (React/SVG scenes) rendered to a 1440x1440 square MP4, a 960x960 looping GIF, and a 1080x1920 branded reel, plus ElevenLabs voiceover, word-by-word captions, caption.txt, and title.txt. | ## Recent Changes +### 2026-07-31 +- Added `flows-diagram` skill: animated backend/Java/Spring/database diagrams (square MP4 + looping GIF + branded reel) with voiceover and word-by-word captions + ### 2026-06-28 - `headshots`: added a `restore` option (enhance/repair the original photo with no restyle), selectable in the checkbox prompt alongside the styled looks diff --git a/skills/flows-diagram/.env.example b/skills/flows-diagram/.env.example new file mode 100644 index 0000000..a3d8862 --- /dev/null +++ b/skills/flows-diagram/.env.example @@ -0,0 +1,9 @@ +# ElevenLabs API key (voiceover + forced alignment). +# Also read from ~/.env if you keep your keys there. +ELEVEN_LABS= + +# Optional: override the Chrome binary used for rendering. +# CHROME_PATH=/Applications/Google Chrome.app/Contents/MacOS/Google Chrome + +# Optional: override where diagrams are authored and rendered. +# FLOWS_DIR=~/amigoscode-skills/flows diff --git a/skills/flows-diagram/.gitignore b/skills/flows-diagram/.gitignore new file mode 100644 index 0000000..44e671d --- /dev/null +++ b/skills/flows-diagram/.gitignore @@ -0,0 +1,15 @@ +# Secrets +.env + +# Vendored React + Babel, fetched on first render +.cache/ + +# Scratch frames / harness +.work/ + +# Dependencies +node_modules + +# OS / editor cruft +.DS_Store +*.log diff --git a/skills/flows-diagram/LICENSE b/skills/flows-diagram/LICENSE new file mode 100644 index 0000000..d8326ec --- /dev/null +++ b/skills/flows-diagram/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Amigoscode + +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/skills/flows-diagram/README.md b/skills/flows-diagram/README.md new file mode 100644 index 0000000..0e573b2 --- /dev/null +++ b/skills/flows-diagram/README.md @@ -0,0 +1,108 @@ +# flows-diagram + +Turn one backend concept into a **branded animated diagram**: a React/SVG scene animated across +10 seconds and rendered three ways from a single command, then voiced, captioned, and titled. + +Built on headless Chrome (puppeteer-core) + ffmpeg. Deterministic: the timeline is stepped to +exact timestamps, so renders are repeatable. + +## What you get per diagram + +``` +$FLOWS/-flow/ +├── .mp4 ← 1440x1440 square, branded frame +├── .gif ← 960x960 infinite loop +├── -reel.mp4 ← 1080x1920 vertical reel +├── -reel-voice-over.mp4 ← the reel with voiceover + word-by-word captions +├── -vo.mp3 ← ElevenLabs narration +├── -scene.jsx ← the diagram (the only creative file) +├── animations.jsx ← shared timeline engine (copied per project) +├── vo-script.txt ← ~44 word narration script +├── caption.txt ← long-form social post copy +└── title.txt ← short upload title +``` + +## Prerequisites + +- **Google Chrome** or Chromium (set `CHROME_PATH` if it is not in a standard location). +- **ffmpeg** and **ffprobe** on `PATH`. +- **Node.js 18+**, then `npm install` in this folder (pulls `puppeteer-core`). +- **`ELEVEN_LABS` API key** for the voiceover and forced alignment ([elevenlabs.io](https://elevenlabs.io)). + Read from the environment, this folder's `.env`, or `~/.env`. + +```bash +cp .env.example .env # then add your key, or: export ELEVEN_LABS=... +npm install +``` + +React and Babel are fetched into `.cache/` automatically on the first render. + +## Usage + +In Claude Code: + +> "make a flows diagram for the outbox pattern" +> "add a diagram about backpressure" +> "give the retry diagram a voiceover and captions" + +Or drive the pipeline directly: + +```bash +SKILL=~/.claude/skills/flows-diagram +FLOWS=~/amigoscode-skills/flows + +cd "$FLOWS" && mkdir -p outbox-flow +cp "$SKILL/assets/template/animations.jsx" outbox-flow/animations.jsx +cp "$SKILL/assets/template/example-scene.jsx" outbox-flow/outbox-scene.jsx # then edit + +# fast one-frame layout check +MODE=check node "$SKILL/scripts/make.mjs" outbox-flow outbox-scene.jsx outbox \ + "How the Outbox Pattern Works" "DISTRIBUTED TX" "x" + +# full render: square mp4 + looping gif + branded reel +CROP_TOP=150 CROP_BOTTOM=140 node "$SKILL/scripts/make.mjs" outbox-flow outbox-scene.jsx outbox \ + "How the Outbox Pattern Works" "DISTRIBUTED TX" "Write → Outbox → Relay → Broker" +``` + +## Workspace + +Diagrams are authored and rendered **outside** the skill so updating the skill never touches your +work. The workspace resolves in this order: + +1. `FLOWS_DIR` environment variable +2. `workspaceDir` in `~/amigoscode-skills/flows-diagram-config.json` +3. `~/flows` if it exists (the original project location) +4. `~/amigoscode-skills/flows` (created on demand) + +Copy `config.default.json` to `~/amigoscode-skills/flows-diagram-config.json` to change the +workspace, the ElevenLabs voice, or the voice settings. + +## Layout + +``` +flows-diagram/ +├── SKILL.md # agent instructions +├── scripts/ +│ ├── make.mjs # scene -> square mp4 + gif + reel +│ ├── gen-vo.mjs # ElevenLabs TTS +│ ├── vo-align.mjs # forced alignment -> word timings +│ ├── caption-render.mjs # Submagic-style caption overlay frames +│ └── paths.mjs # workspace / Chrome / key / cache resolution +├── assets/ +│ ├── logo-mark.svg # purple mark (top of every frame) +│ ├── amigoscode-logo*.png # wordmarks +│ └── template/ # animations.jsx + example scene to copy +└── references/ + ├── pipeline.md # make.mjs contract, env vars, tuned render matrix + ├── scene-contract.md # how to author a scene + └── voiceover-captions.md # voiceover, captions, caption.txt, title.txt +``` + +## Brand rules + +Purple `#7f56d9` primary, teal `#2FA39B` secondary, red `#E5484D` failure, ink `#20273e` text. +10 seconds, 30fps. No en dashes or em dashes anywhere in titles, captions, or narration. + +## License + +MIT diff --git a/skills/flows-diagram/SKILL.md b/skills/flows-diagram/SKILL.md new file mode 100644 index 0000000..8ae6b0f --- /dev/null +++ b/skills/flows-diagram/SKILL.md @@ -0,0 +1,108 @@ +--- +name: flows-diagram +description: "Produce a branded Amigoscode animated BACKEND-DIAGRAM video: a React/SVG flow diagram (controllers, queues, threads, caches, DB cylinders, arrows, packets) animated over 10s and rendered to a 1440x1440 square MP4, a 960x960 looping GIF, and a 1080x1920 branded reel, then optionally given an ElevenLabs voiceover, word-by-word captions with animated emojis, an infographic-style caption.txt, and an upload title.txt. Use whenever the user wants an animated backend, Java, Spring, database, or concurrency DIAGRAM, flow, or 'how X works' reel, or to add a voiceover, captions, or a title to one of these diagrams. Examples: 'make a flows diagram for the outbox pattern', 'add a diagram about backpressure', 'render the square + reel for saga', 'give the retry diagram a voiceover and captions', 'generate title.txt for these'. This is the SVG-scene pipeline (make.mjs + puppeteer + ffmpeg). It is NOT the HyperFrames explainer-video skill and NOT the CSV reel skill." +license: MIT +--- + +# Flows diagram + +Turn one backend concept into a set of branded animated diagrams. Every diagram is a self +contained React/SVG scene animated across 10 seconds and rendered three ways from one command, +then voiced, captioned, and titled. The voiced, captioned reel is part of the standard +deliverable, not an afterthought. + +Zero en dashes or em dashes anywhere (titles, captions, voiceover). Periods and commas only. + +## Where things live + +- **`$SKILL`** — this skill folder. It carries `scripts/` (the pipeline), `assets/` (brand logos + and the scene template), and `references/`. Never write output here. +- **`$FLOWS`** — the workspace where diagrams are authored and rendered. One folder per diagram + (`-flow/`). Resolution order: `FLOWS_DIR` env var, then `workspaceDir` in + `~/amigoscode-skills/flows-diagram-config.json`, then `~/flows` if it exists, then + `~/amigoscode-skills/flows` (created on demand). + +Set both once per session, then every command below is copy-paste: + +```bash +SKILL=~/.claude/skills/flows-diagram # or the installed plugin path +FLOWS=$(node -e "import('$SKILL/scripts/paths.mjs').then(m=>console.log(m.workspaceDir({create:true})))") +``` + +First render fetches React and Babel into `$SKILL/.cache` automatically. `puppeteer-core` must be +installed once (`cd $SKILL && npm install`); rendering needs system Chrome (`CHROME_PATH` to +override) plus `ffmpeg` and `ffprobe`. + +## The pipeline at a glance + +1. **Scene** — author `$FLOWS/-flow/-scene.jsx` (+ copy `animations.jsx`) from + `$SKILL/assets/template/`. This is the only creative step. See `references/scene-contract.md`. +2. **Render** — `node $SKILL/scripts/make.mjs "" "<EYEBROW>" "<caption>"` + emits the square MP4, the GIF, and the reel. Tune `CROP_TOP`/`CROP_BOTTOM` per diagram so the + diagram is centered between the title and the footer (see below). See `references/pipeline.md`. +3. **Words** — write `caption.txt` (the /infographic post copy) and `title.txt` (the upload + name). See `references/voiceover-captions.md`. +4. **Voiceover + captions** — write `vo-script.txt` (~44 words), then run the voiceover + + Submagic caption pipeline to produce `<base>-reel-voice-over.mp4`. Do this for every diagram + by default (the reel ships voiced and captioned); only skip it if the user explicitly says + they just want the silent base videos. See `references/voiceover-captions.md`. + +## Fast path for a brand new diagram + +```bash +cd "$FLOWS" +mkdir foo-flow +cp "$SKILL/assets/template/animations.jsx" foo-flow/animations.jsx +cp "$SKILL/assets/template/example-scene.jsx" foo-flow/foo-scene.jsx # then edit the diagram +# iterate on layout with the fast one-frame check (writes $FLOWS/.work/foo/foo-check.png): +MODE=check node "$SKILL/scripts/make.mjs" foo-flow foo-scene.jsx foo "How Foo Works" "SUBSYSTEM" "x" +# when the frame looks right, full render (square mp4 + gif + reel): +CROP_TOP=150 CROP_BOTTOM=140 node "$SKILL/scripts/make.mjs" foo-flow foo-scene.jsx foo \ + "How Foo Works" "SUBSYSTEM" "Left → Middle → Right" +``` + +`MODE=check` renders a single mid-timeline frame in ~5s so you can dial in the diagram and the +`CROP_TOP`/`CROP_BOTTOM` padding before committing to a full 30fps render. Always iterate on the +check frame first. + +## Conventions (non negotiable) + +- **Palette**: purple `#7f56d9` primary, teal `#2FA39B` secondary/success, red `#E5484D` + failure. Ink text `#20273e`. The scene template's `C` object already holds these. +- **Layout is locked** in `make.mjs`: purple mark at top, eyebrow + title pinned near the top, + the diagram in the middle, ink wordmark pinned at the bottom. Do not restyle the frame; only + the diagram inside `#stage` changes. +- **Center the diagram between the title and the footer.** This is the look we want: roughly + equal breathing room above the diagram (below the title) and below it (above the wordmark), not + jammed against the title with a big empty gap below. `CROP_TOP`/`CROP_BOTTOM` are how you land + it: a tall diagram fills the body naturally, but a short or single-row diagram needs a *smaller* + `CROP_TOP` so it drops into the vertical middle. Always confirm the balance on the `MODE=check` + frame before the full render. Example: a thin single row centers at `CROP_TOP=95 + CROP_BOTTOM=130`, whereas a tall diagram like a connection pool uses `180/165`. +- **10 seconds, 30fps, k=1** (no breathing zoom). Scenes animate on `useTime()` (0..10). +- **Voiceover ~44 words** so the brand voice lands near 18 to 21 seconds. Longer scripts drift + past 22s. +- **Caption placement for reels is fixed**: `caption-render.mjs ... 1080 1920 30 <dur> 1545 60 1420`. +- **Clean up after every render**: `make.mjs` wipes `$FLOWS/.work/<outBase>` automatically (set + `KEEP_WORK=1` to keep frames for debugging). The voiceover driver deletes its caption frames. + Never leave `.work` or `caps-*` scratch behind; it grows to many GB fast. + +## Scaling to many diagrams (use agents) + +Writing scenes, `caption.txt`, `vo-script.txt`, and `title.txt` is per diagram authoring work +that parallelizes well: dispatch one general-purpose agent per small batch (3 to 4 diagrams) to +write the text files, giving each agent the exact style spec. Do the RENDERING sequentially in a +single resumable driver (a `done_file` the loop skips) because puppeteer/Chrome and ffmpeg +contend when run in parallel. See `references/pipeline.md` for the batch-render pattern and +`references/voiceover-captions.md` for the batch-voiceover driver. + +## References + +- `references/pipeline.md` — the `make.mjs` CLI contract, every env var, the tuned 26-diagram + render matrix (folder / outBase / CROP values / title / eyebrow), and the resumable + batch-render driver. +- `references/scene-contract.md` — how to author a `<name>-scene.jsx`: the helpers, `KCard`/ + `KIcon`/`ICONS`, the DB cylinder, `Defs`, the `SceneRouter`/`window.*` exports, and how motion + is driven by time. +- `references/voiceover-captions.md` — `gen-vo.mjs`, `vo-align.mjs`, `caption-render.mjs`, the + emoji map, the exact ffmpeg composite command, and the `caption.txt` / `title.txt` styles. diff --git a/skills/flows-diagram/assets/amigoscode-logo-ink.png b/skills/flows-diagram/assets/amigoscode-logo-ink.png new file mode 100644 index 0000000..c092c0d Binary files /dev/null and b/skills/flows-diagram/assets/amigoscode-logo-ink.png differ diff --git a/skills/flows-diagram/assets/amigoscode-logo.png b/skills/flows-diagram/assets/amigoscode-logo.png new file mode 100644 index 0000000..9e2c8fc Binary files /dev/null and b/skills/flows-diagram/assets/amigoscode-logo.png differ diff --git a/skills/flows-diagram/assets/logo-mark.svg b/skills/flows-diagram/assets/logo-mark.svg new file mode 100644 index 0000000..f2da994 --- /dev/null +++ b/skills/flows-diagram/assets/logo-mark.svg @@ -0,0 +1,4 @@ +<svg width="472" height="472" viewBox="0 0 472 472" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M471.212 235.606C471.212 300.693 444.819 359.594 402.207 402.207C359.594 444.888 300.693 471.211 235.606 471.211C170.519 471.211 111.617 444.819 69.005 402.207C64.4688 397.671 60.1387 392.997 56.015 388.117C43.2999 373.203 32.4406 356.708 23.7807 338.907C8.52265 307.772 0 272.72 0 235.675C0 105.5 105.501 0 235.675 0C300.762 0 359.663 26.3923 402.276 69.0048C444.957 111.617 471.28 170.519 471.28 235.606H471.212Z" fill="#7F56D9"/> +<path d="M388.737 280.213L347.98 320.969L243.511 216.5C239.112 212.101 231.964 212.101 227.635 216.5L55.9472 388.187C43.2322 373.273 32.3728 356.778 23.7129 338.977L195.813 166.877C217.737 144.952 253.339 144.952 275.333 166.877L388.737 280.281V280.213Z" fill="white"/> +</svg> diff --git a/skills/flows-diagram/assets/template/animations.jsx b/skills/flows-diagram/assets/template/animations.jsx new file mode 100644 index 0000000..7481f21 --- /dev/null +++ b/skills/flows-diagram/assets/template/animations.jsx @@ -0,0 +1,855 @@ +// @ds-adherence-ignore -- omelette starter scaffold (raw elements/hex/px by design) + +/* BEGIN USAGE */ +// animations.jsx — timeline engine. Exports (on window): Stage, Sprite, +// TextSprite, ImageSprite, RectSprite, VideoSprite, PlaybackBar, +// useTime, useTimeline, useSprite, Easing, interpolate, animate, clamp. +// +// <Stage width={1280} height={720} duration={10} background="#f6f4ef"> +// <Sprite start={0} end={3}> +// <TextSprite text="Hello" x={100} y={300} size={72} color="#111" /> +// </Sprite> +// <Sprite start={2} end={8}> +// <ImageSprite src="hero.png" x={200} y={120} width={640} height={360} kenBurns /> +// </Sprite> +// </Stage> +// +// Stage({width,height,duration,background,fps,loop,autoplay}) — auto-scales to +// viewport; scrubber + play/pause + ←/→ seek + space + 0-reset; persists +// playhead. The canvas is an <svg><foreignObject>, export-ready: Share → +// Export → Video (or the PlaybackBar's download button) renders it to .mp4. +// Screenshot tools DOM-rerender (not pixel-capture) and unwrap this wrapper +// so captures should work — but if one comes back black, that's a capture +// artifact, not a render bug; trust the live preview. +// Sprite({start,end,keepMounted}) — mounts children only while playhead is in +// [start,end]. Children read {localTime, progress, duration} via useSprite(). +// useTime() → seconds; useTimeline() → {time,duration,playing,setTime,setPlaying}. +// TextSprite({text,x,y,size,color,font,weight,align,entryDur,exitDur}) — fades/scales in+out. +// ImageSprite({src,x,y,width,height,fit,radius,kenBurns,placeholder}) — same, with optional ken-burns. +// RectSprite({x,y,width,height,color,radius}) — solid box with entry/exit. +// VideoSprite({src,start,end,speed,style}) — looped <video> clip synced to the +// timeline; its audio is mixed into the exported video. +// Easing.{linear,easeIn/Out/InOut Quad/Cubic/Quart/Quint/Expo/Back, …} +// interpolate([t0,t1,…],[v0,v1,…],ease?) → (t)=>v — piecewise tween. +// animate({from,to,start,end,ease}) → (t)=>v — single tween. +// +// Build scenes by composing Sprites inside Stage. Absolutely-position elements. +// +// In a .dc.html project, put your scene in a sibling my-scene.jsx (reading +// {Stage, Sprite, useTime, Easing, …} from window is safe) and mount BOTH: +// <x-import component-from-global-scope="MyScene" +// from="./animations.jsx ./my-scene.jsx"></x-import> +// The two files in from= load in order, so my-scene.jsx can use the globals +// animations.jsx set. +/* END USAGE */ +// ───────────────────────────────────────────────────────────────────────────── + +// ── Easing functions (hand-rolled, Popmotion-style) ───────────────────────── +// All easings take t ∈ [0,1] and return eased t ∈ [0,1] (may overshoot for back/elastic). +const Easing = { + linear: (t) => t, + + // Quad + easeInQuad: (t) => t * t, + easeOutQuad: (t) => t * (2 - t), + easeInOutQuad: (t) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t), + + // Cubic + easeInCubic: (t) => t * t * t, + easeOutCubic: (t) => (--t) * t * t + 1, + easeInOutCubic: (t) => (t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1), + + // Quart + easeInQuart: (t) => t * t * t * t, + easeOutQuart: (t) => 1 - (--t) * t * t * t, + easeInOutQuart: (t) => (t < 0.5 ? 8 * t * t * t * t : 1 - 8 * (--t) * t * t * t), + + // Expo + easeInExpo: (t) => (t === 0 ? 0 : Math.pow(2, 10 * (t - 1))), + easeOutExpo: (t) => (t === 1 ? 1 : 1 - Math.pow(2, -10 * t)), + easeInOutExpo: (t) => { + if (t === 0) return 0; + if (t === 1) return 1; + if (t < 0.5) return 0.5 * Math.pow(2, 20 * t - 10); + return 1 - 0.5 * Math.pow(2, -20 * t + 10); + }, + + // Sine + easeInSine: (t) => 1 - Math.cos((t * Math.PI) / 2), + easeOutSine: (t) => Math.sin((t * Math.PI) / 2), + easeInOutSine: (t) => -(Math.cos(Math.PI * t) - 1) / 2, + + // Back (overshoot) + easeOutBack: (t) => { + const c1 = 1.70158, c3 = c1 + 1; + return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2); + }, + easeInBack: (t) => { + const c1 = 1.70158, c3 = c1 + 1; + return c3 * t * t * t - c1 * t * t; + }, + easeInOutBack: (t) => { + const c1 = 1.70158, c2 = c1 * 1.525; + return t < 0.5 + ? (Math.pow(2 * t, 2) * ((c2 + 1) * 2 * t - c2)) / 2 + : (Math.pow(2 * t - 2, 2) * ((c2 + 1) * (t * 2 - 2) + c2) + 2) / 2; + }, + + // Elastic + easeOutElastic: (t) => { + const c4 = (2 * Math.PI) / 3; + if (t === 0) return 0; + if (t === 1) return 1; + return Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1; + }, +}; + +// ── Core interpolation helpers ────────────────────────────────────────────── + +// Clamp a value to [min, max] +const clamp = (v, min, max) => Math.max(min, Math.min(max, v)); + +// interpolate([0, 0.5, 1], [0, 100, 50], ease?) -> fn(t) +// Popmotion-style: linearly maps t across input keyframes to output values, +// with optional easing per segment (single fn or array of fns). +function interpolate(input, output, ease = Easing.linear) { + return (t) => { + if (t <= input[0]) return output[0]; + if (t >= input[input.length - 1]) return output[output.length - 1]; + for (let i = 0; i < input.length - 1; i++) { + if (t >= input[i] && t <= input[i + 1]) { + const span = input[i + 1] - input[i]; + const local = span === 0 ? 0 : (t - input[i]) / span; + const easeFn = Array.isArray(ease) ? (ease[i] || Easing.linear) : ease; + const eased = easeFn(local); + return output[i] + (output[i + 1] - output[i]) * eased; + } + } + return output[output.length - 1]; + }; +} + +// animate({from, to, start, end, ease})(t) — simpler single-segment tween. +// Returns `from` before `start`, `to` after `end`. +function animate({ from = 0, to = 1, start = 0, end = 1, ease = Easing.easeInOutCubic }) { + return (t) => { + if (t <= start) return from; + if (t >= end) return to; + const local = (t - start) / (end - start); + return from + (to - from) * ease(local); + }; +} + +// ── Timeline context ──────────────────────────────────────────────────────── + +const TimelineContext = React.createContext({ time: 0, duration: 10, playing: false }); + +const useTime = () => React.useContext(TimelineContext).time; +const useTimeline = () => React.useContext(TimelineContext); + +// ── Sprite ────────────────────────────────────────────────────────────────── +// Renders children only when the playhead is inside [start, end]. Provides +// a sub-context with `localTime` (seconds since start) and `progress` (0..1). +// +// <Sprite start={2} end={5}> +// {({ localTime, progress }) => <Thing x={progress * 100} />} +// </Sprite> +// +// Or as a plain wrapper — children can call useSprite() themselves. + +const SpriteContext = React.createContext({ localTime: 0, progress: 0, duration: 0 }); +const useSprite = () => React.useContext(SpriteContext); + +function Sprite({ start = 0, end = Infinity, children, keepMounted = false }) { + const { time } = useTimeline(); + const visible = time >= start && time <= end; + if (!visible && !keepMounted) return null; + + const duration = end - start; + const localTime = Math.max(0, time - start); + const progress = duration > 0 && isFinite(duration) + ? clamp(localTime / duration, 0, 1) + : 0; + + const value = { localTime, progress, duration, visible }; + + return ( + <SpriteContext.Provider value={value}> + {typeof children === 'function' ? children(value) : children} + </SpriteContext.Provider> + ); +} + +// ── Sample sprite components ──────────────────────────────────────────────── + +// TextSprite: fades/slides text in on entry, holds, then fades out on exit. +// Props: text, x, y, size, color, font, entryDur, exitDur, align +function TextSprite({ + text, + x = 0, y = 0, + size = 48, + color = '#111', + font = 'Inter, system-ui, sans-serif', + weight = 600, + entryDur = 0.45, + exitDur = 0.35, + entryEase = Easing.easeOutBack, + exitEase = Easing.easeInCubic, + align = 'left', + letterSpacing = '-0.01em', +}) { + const { localTime, duration } = useSprite(); + const exitStart = Math.max(0, duration - exitDur); + + let opacity = 1; + let ty = 0; + + if (localTime < entryDur) { + const t = entryEase(clamp(localTime / entryDur, 0, 1)); + opacity = t; + ty = (1 - t) * 16; + } else if (localTime > exitStart) { + const t = exitEase(clamp((localTime - exitStart) / exitDur, 0, 1)); + opacity = 1 - t; + ty = -t * 8; + } + + const translateX = align === 'center' ? '-50%' : align === 'right' ? '-100%' : '0'; + + return ( + <div style={{ + position: 'absolute', + left: x, top: y, + transform: `translate(${translateX}, ${ty}px)`, + opacity, + fontFamily: font, + fontSize: size, + fontWeight: weight, + color, + letterSpacing, + whiteSpace: 'pre', + lineHeight: 1.1, + willChange: 'transform, opacity', + }}> + {text} + </div> + ); +} + +// ImageSprite: scales + fades in; optional Ken Burns drift during hold. +function ImageSprite({ + src, + x = 0, y = 0, + width = 400, height = 300, + entryDur = 0.6, + exitDur = 0.4, + kenBurns = false, + kenBurnsScale = 1.08, + radius = 12, + fit = 'cover', + placeholder = null, // {label: string} for striped placeholder +}) { + const { localTime, duration } = useSprite(); + const exitStart = Math.max(0, duration - exitDur); + + let opacity = 1; + let scale = 1; + + if (localTime < entryDur) { + const t = Easing.easeOutCubic(clamp(localTime / entryDur, 0, 1)); + opacity = t; + scale = 0.96 + 0.04 * t; + } else if (localTime > exitStart) { + const t = Easing.easeInCubic(clamp((localTime - exitStart) / exitDur, 0, 1)); + opacity = 1 - t; + scale = (kenBurns ? kenBurnsScale : 1) + 0.02 * t; + } else if (kenBurns) { + const holdSpan = exitStart - entryDur; + const holdT = holdSpan > 0 ? (localTime - entryDur) / holdSpan : 0; + scale = 1 + (kenBurnsScale - 1) * holdT; + } + + const content = placeholder ? ( + <div style={{ + width: '100%', height: '100%', + display: 'flex', alignItems: 'center', justifyContent: 'center', + background: 'repeating-linear-gradient(135deg, #e9e6df 0 10px, #dcd8cf 10px 20px)', + color: '#6b6458', + fontFamily: 'JetBrains Mono, ui-monospace, monospace', + fontSize: 13, + letterSpacing: '0.04em', + textTransform: 'uppercase', + }}> + {placeholder.label || 'image'} + </div> + ) : ( + <img src={src} alt="" style={{ width: '100%', height: '100%', objectFit: fit, display: 'block' }} /> + ); + + return ( + <div style={{ + position: 'absolute', + left: x, top: y, + width, height, + opacity, + transform: `scale(${scale})`, + transformOrigin: 'center', + borderRadius: radius, + overflow: 'hidden', + willChange: 'transform, opacity', + }}> + {content} + </div> + ); +} + +// RectSprite: simple rectangle that animates position/size/color via props. +// Useful demo primitive — takes a `render` fn for per-frame customization. +function RectSprite({ + x = 0, y = 0, + width = 100, height = 100, + color = '#111', + radius = 8, + entryDur = 0.4, + exitDur = 0.3, + render, // optional: (ctx) => style overrides +}) { + const spriteCtx = useSprite(); + const { localTime, duration } = spriteCtx; + const exitStart = Math.max(0, duration - exitDur); + + let opacity = 1; + let scale = 1; + + if (localTime < entryDur) { + const t = Easing.easeOutBack(clamp(localTime / entryDur, 0, 1)); + opacity = clamp(localTime / entryDur, 0, 1); + scale = 0.4 + 0.6 * t; + } else if (localTime > exitStart) { + const t = Easing.easeInQuad(clamp((localTime - exitStart) / exitDur, 0, 1)); + opacity = 1 - t; + scale = 1 - 0.15 * t; + } + + const overrides = render ? render(spriteCtx) : {}; + + return ( + <div style={{ + position: 'absolute', + left: x, top: y, + width, height, + background: color, + borderRadius: radius, + opacity, + transform: `scale(${scale})`, + transformOrigin: 'center', + willChange: 'transform, opacity', + ...overrides, + }} /> + ); +} + + +// ── Font inlining ─────────────────────────────────────────────────────────── +// Copy every @font-face rule from the page into a <style> inside the svg's +// foreignObject, with font URLs rewritten to data: URLs. Makes the svg +// self-describing so serializing it alone (video export fast path) still +// renders with the right fonts. Sets data-om-fonts-inlined on the svg when +// done so the exporter can wait for it. + +function useInlineFontsInto(svgRef) { + React.useEffect(() => { + const svg = svgRef.current; + const host = svg && svg.querySelector('foreignObject > div'); + if (!svg || !host) return; + let cancelled = false; + (async () => { + const rules = []; + for (const ss of document.styleSheets) { + let cssRules; + try { cssRules = ss.cssRules; } catch { + // Cross-origin sheet without crossorigin attr (e.g. the standard + // fonts.googleapis.com <link>) — fetch the CSS text directly and + // regex-extract the @font-face blocks. + if (ss.href) { + try { + const txt = await fetch(ss.href).then(r => { if (!r.ok) throw 0; return r.text(); }); + for (const ff of (txt.match(/@font-face\s*{[^}]*}/g) || [])) + rules.push({ css: ff, base: ss.href }); + } catch {} + } + continue; + } + if (!cssRules) continue; + for (const r of cssRules) { + if (r.type === CSSRule.FONT_FACE_RULE) { + rules.push({ css: r.cssText, base: ss.href || location.href }); + } + } + } + const toDataURL = (url) => fetch(url) + .then(r => { if (!r.ok) throw 0; return r.blob(); }) + .then(b => new Promise(res => { + const fr = new FileReader(); + fr.onload = () => res(fr.result); + fr.onerror = () => res(url); + fr.readAsDataURL(b); + })) + .catch(() => url); + const parts = await Promise.all(rules.map(async ({ css, base }) => { + const re = /url\((['"]?)([^'")]+)\1\)/g; + let out = css, m; + while ((m = re.exec(css))) { + const u = m[2]; + if (u.startsWith('data:')) continue; + let abs; try { abs = new URL(u, base).href; } catch { continue; } + out = out.split(m[0]).join(`url("${await toDataURL(abs)}")`); + } + return out; + })); + if (cancelled || !parts.length) { + svg.setAttribute('data-om-fonts-inlined', 'true'); + return; + } + const style = document.createElement('style'); + style.textContent = parts.join('\n'); + host.insertBefore(style, host.firstChild); + svg.setAttribute('data-om-fonts-inlined', 'true'); + })(); + return () => { cancelled = true; }; + }, []); +} + + +function Stage({ + width = 1280, + height = 720, + duration = 10, + background = '#f6f4ef', + fps = 60, + loop = true, + autoplay = true, + persistKey = 'animstage', + children, +}) { + // Props arrive as strings when Stage is mounted via <x-import> (DC + // projects) — coerce so style={{width}} gets a number React can px-ify. + width = +width || 1280; height = +height || 720; + duration = +duration || 10; fps = +fps || 60; + if (typeof loop === 'string') loop = loop !== 'false'; + if (typeof autoplay === 'string') autoplay = autoplay !== 'false'; + + const [time, setTime] = React.useState(() => { + try { + const v = parseFloat(localStorage.getItem(persistKey + ':t') || '0'); + return isFinite(v) ? clamp(v, 0, duration) : 0; + } catch { return 0; } + }); + const [playing, setPlaying] = React.useState(autoplay); + const [hoverTime, setHoverTime] = React.useState(null); + const [scale, setScale] = React.useState(1); + + const stageRef = React.useRef(null); + const canvasRef = React.useRef(null); + const rafRef = React.useRef(null); + const lastTsRef = React.useRef(null); + + // Persist playhead + React.useEffect(() => { + try { localStorage.setItem(persistKey + ':t', String(time)); } catch {} + }, [time, persistKey]); + + // Auto-scale to fit viewport + React.useEffect(() => { + if (!stageRef.current) return; + const el = stageRef.current; + const measure = () => { + const barH = 44; // playback bar height + const s = Math.min( + el.clientWidth / width, + (el.clientHeight - barH) / height + ); + setScale(Math.max(0.05, s)); + }; + measure(); + const ro = new ResizeObserver(measure); + ro.observe(el); + window.addEventListener('resize', measure); + return () => { + ro.disconnect(); + window.removeEventListener('resize', measure); + }; + }, [width, height]); + + // Animation loop + React.useEffect(() => { + if (!playing) { + lastTsRef.current = null; + return; + } + const step = (ts) => { + if (lastTsRef.current == null) lastTsRef.current = ts; + const dt = (ts - lastTsRef.current) / 1000; + lastTsRef.current = ts; + setTime((t) => { + let next = t + dt; + if (next >= duration) { + if (loop) next = next % duration; + else { next = duration; setPlaying(false); } + } + return next; + }); + rafRef.current = requestAnimationFrame(step); + }; + rafRef.current = requestAnimationFrame(step); + return () => { + if (rafRef.current) cancelAnimationFrame(rafRef.current); + lastTsRef.current = null; + }; + }, [playing, duration, loop]); + + // Keyboard: space = play/pause, ← → = seek + React.useEffect(() => { + const onKey = (e) => { + if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA')) return; + if (e.code === 'Space') { + e.preventDefault(); + setPlaying(p => !p); + } else if (e.code === 'ArrowLeft') { + setTime(t => clamp(t - (e.shiftKey ? 1 : 0.1), 0, duration)); + } else if (e.code === 'ArrowRight') { + setTime(t => clamp(t + (e.shiftKey ? 1 : 0.1), 0, duration)); + } else if (e.key === '0' || e.code === 'Home') { + setTime(0); + } + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [duration]); + + // Video-export protocol: the exporter dispatches this event per frame; + // pause + sync the playhead so the capture sees exactly that timestamp. + React.useEffect(() => { + const el = canvasRef.current; + if (!el) return; + const onSeek = (e) => { + setPlaying(false); + setTime(clamp(e.detail.time, 0, duration)); + }; + el.addEventListener('data-om-seek-to-time-frame', onSeek); + return () => el.removeEventListener('data-om-seek-to-time-frame', onSeek); + }, [duration]); + + // Inline @font-face rules into the svg's foreignObject so the svg is + // self-describing — serializing it alone (for video export) then renders + // with the right fonts. Sets data-om-fonts-inlined once done. + useInlineFontsInto(canvasRef); + + const displayTime = hoverTime != null ? hoverTime : time; + + const ctxValue = React.useMemo( + () => ({ time: displayTime, duration, playing, setTime, setPlaying }), + [displayTime, duration, playing] + ); + + return ( + <div + ref={stageRef} + style={{ + position: 'absolute', inset: 0, + display: 'flex', flexDirection: 'column', + alignItems: 'center', + background: '#0a0a0a', + fontFamily: 'Inter, system-ui, sans-serif', + }} + > + {/* Canvas area — vertically centered in remaining space */} + <div style={{ + flex: 1, + width: '100%', + display: 'flex', alignItems: 'center', justifyContent: 'center', + overflow: 'hidden', + minHeight: 0, + }}> + <svg + ref={canvasRef} + width={width} height={height} + data-om-exportable-video-with-duration-secs={duration} + style={{ + transform: `scale(${scale})`, + transformOrigin: 'center', + flexShrink: 0, + boxShadow: '0 20px 60px rgba(0,0,0,0.4)', + display: 'block', + }} + > + <foreignObject x="0" y="0" width="100%" height="100%"> + <div + xmlns="http://www.w3.org/1999/xhtml" + style={{ + width, height, + background, + position: 'relative', + overflow: 'hidden', + }} + > + <TimelineContext.Provider value={ctxValue}> + {children} + </TimelineContext.Provider> + </div> + </foreignObject> + </svg> + </div> + + {/* Playback bar — stacked below canvas, never overlapping */} + <PlaybackBar + time={displayTime} + actualTime={time} + duration={duration} + playing={playing} + onPlayPause={() => setPlaying(p => !p)} + onReset={() => { setTime(0); }} + onSeek={(t) => setTime(t)} + onHover={(t) => setHoverTime(t)} + /> + </div> + ); +} + +// ── Playback bar ──────────────────────────────────────────────────────────── +// Play/pause, return-to-begin, scrub track, time display. +// Uses fixed-width time fields so layout doesn't thrash. + +function PlaybackBar({ time, duration, playing, onPlayPause, onReset, onSeek, onHover }) { + const trackRef = React.useRef(null); + const [dragging, setDragging] = React.useState(false); + + const timeFromEvent = React.useCallback((e) => { + const rect = trackRef.current.getBoundingClientRect(); + const x = clamp((e.clientX - rect.left) / rect.width, 0, 1); + return x * duration; + }, [duration]); + + const onTrackMove = (e) => { + if (!trackRef.current) return; + const t = timeFromEvent(e); + if (dragging) { + onSeek(t); + } else { + onHover(t); + } + }; + + const onTrackLeave = () => { + if (!dragging) onHover(null); + }; + + const onTrackDown = (e) => { + setDragging(true); + const t = timeFromEvent(e); + onSeek(t); + onHover(null); + }; + + React.useEffect(() => { + if (!dragging) return; + const onUp = () => setDragging(false); + const onMove = (e) => { + if (!trackRef.current) return; + const t = timeFromEvent(e); + onSeek(t); + }; + window.addEventListener('mouseup', onUp); + window.addEventListener('mousemove', onMove); + return () => { + window.removeEventListener('mouseup', onUp); + window.removeEventListener('mousemove', onMove); + }; + }, [dragging, timeFromEvent, onSeek]); + + const pct = duration > 0 ? (time / duration) * 100 : 0; + const fmt = (t) => { + const total = Math.max(0, t); + const m = Math.floor(total / 60); + const s = Math.floor(total % 60); + const cs = Math.floor((total * 100) % 100); + return `${String(m).padStart(1, '0')}:${String(s).padStart(2, '0')}.${String(cs).padStart(2, '0')}`; + }; + + const mono = 'JetBrains Mono, ui-monospace, SFMono-Regular, monospace'; + + return ( + <div data-omelette-chrome style={{ + display: 'flex', alignItems: 'center', gap: 12, + padding: '8px 16px', + background: 'rgba(20,20,20,0.92)', + borderTop: '1px solid rgba(255,255,255,0.08)', + width: '100%', + maxWidth: 680, + alignSelf: 'center', + + borderRadius: 8, + color: '#f6f4ef', + fontFamily: 'Inter, system-ui, sans-serif', + userSelect: 'none', + flexShrink: 0, + }}> + <IconButton onClick={onReset} title="Return to start (0)"> + <svg width="14" height="14" viewBox="0 0 14 14" fill="none"> + <path d="M3 2v10M12 2L5 7l7 5V2z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round"/> + </svg> + </IconButton> + <IconButton onClick={onPlayPause} title="Play/pause (space)"> + {playing ? ( + <svg width="14" height="14" viewBox="0 0 14 14" fill="none"> + <rect x="3" y="2" width="3" height="10" fill="currentColor"/> + <rect x="8" y="2" width="3" height="10" fill="currentColor"/> + </svg> + ) : ( + <svg width="14" height="14" viewBox="0 0 14 14" fill="none"> + <path d="M3 2l9 5-9 5V2z" fill="currentColor"/> + </svg> + )} + </IconButton> + + {/* Current time: fixed width so it doesn't thrash */} + <div style={{ + fontFamily: mono, + fontSize: 12, + fontVariantNumeric: 'tabular-nums', + width: 64, textAlign: 'right', + color: '#f6f4ef', + }}> + {fmt(time)} + </div> + + {/* Scrub track */} + <div + ref={trackRef} + onMouseMove={onTrackMove} + onMouseLeave={onTrackLeave} + onMouseDown={onTrackDown} + style={{ + flex: 1, + height: 22, + position: 'relative', + cursor: 'pointer', + display: 'flex', alignItems: 'center', + }} + > + <div style={{ + position: 'absolute', + left: 0, right: 0, height: 4, + background: 'rgba(255,255,255,0.12)', + borderRadius: 2, + }}/> + <div style={{ + position: 'absolute', + left: 0, width: `${pct}%`, height: 4, + background: 'oklch(72% 0.12 250)', + borderRadius: 2, + }}/> + <div style={{ + position: 'absolute', + left: `${pct}%`, top: '50%', + width: 12, height: 12, + marginLeft: -6, marginTop: -6, + background: '#fff', + borderRadius: 6, + boxShadow: '0 2px 4px rgba(0,0,0,0.4)', + }}/> + </div> + + {/* Duration: fixed width */} + <div style={{ + fontFamily: mono, + fontSize: 12, + fontVariantNumeric: 'tabular-nums', + width: 64, textAlign: 'left', + color: 'rgba(246,244,239,0.55)', + }}> + {fmt(duration)} + </div> + + {typeof VideoEncoder !== 'undefined' && ( + <IconButton + title="Export video" + onClick={() => window.parent.postMessage({ type: 'omelette:request-video-export' }, '*')} + > + <svg width="14" height="14" viewBox="0 0 14 14" fill="none"> + <path d="M7 2v7m0 0L4 6m3 3l3-3M2 12h10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/> + </svg> + </IconButton> + )} + </div> + ); +} + +function IconButton({ children, onClick, title }) { + const [hover, setHover] = React.useState(false); + return ( + <button + onClick={onClick} + title={title} + onMouseEnter={() => setHover(true)} + onMouseLeave={() => setHover(false)} + style={{ + width: 28, height: 28, + display: 'flex', alignItems: 'center', justifyContent: 'center', + background: hover ? 'rgba(255,255,255,0.12)' : 'rgba(255,255,255,0.04)', + border: '1px solid rgba(255,255,255,0.1)', + borderRadius: 6, + color: '#f6f4ef', + cursor: 'pointer', + padding: 0, + transition: 'background 120ms', + }} + > + {children} + </button> + ); +} + + +// ── VideoSprite ───────────────────────────────────────────────────────────── +// Renders a <video> that loops within [start,end] of its source at `speed`, +// kept in sync with the Stage's playhead. Carries the +// data-om-exportable-video-play-* attrs so video export can mix its audio. +// +// <VideoSprite src="clip.mp4" start={2} end={5} speed={1} +// style={{ width: 640, height: 360 }} /> + +function VideoSprite({ src, start = 0, end, speed = 1, style, ...rest }) { + start = +start || 0; speed = +speed || 1; + if (end != null) end = +end || undefined; + const t = useTime(); + const ref = React.useRef(null); + const span = Math.max(0.001, ((end ?? start + 1) - start)); + React.useEffect(() => { + const v = ref.current; + if (!v || v.readyState < 1) return; + const target = start + ((t * speed) % span); + if (Math.abs(v.currentTime - target) > 0.05) v.currentTime = target; + }, [t, start, span, speed]); + return ( + <video + ref={ref} + src={src} + muted playsInline preload="auto" + data-om-exportable-video-play-start={start} + data-om-exportable-video-play-end={end ?? start + span} + data-om-exportable-video-play-speed={speed} + style={{ display: 'block', objectFit: 'cover', ...style }} + {...rest} + /> + ); +} + + +Object.assign(window, { + Easing, interpolate, animate, clamp, + TimelineContext, useTime, useTimeline, + Sprite, SpriteContext, useSprite, + TextSprite, ImageSprite, RectSprite, VideoSprite, + Stage, PlaybackBar, +}); diff --git a/skills/flows-diagram/assets/template/example-scene.jsx b/skills/flows-diagram/assets/template/example-scene.jsx new file mode 100644 index 0000000..6803c3b --- /dev/null +++ b/skills/flows-diagram/assets/template/example-scene.jsx @@ -0,0 +1,308 @@ +// Database replication (primary / replica) — PostgreSQL. +// App writes → PRIMARY only; PRIMARY replicates (WAL stream) → REPLICAS; reads served by REPLICAS. +// Reuses the timeline engine from animations.jsx (window globals). +// Exports window.SceneRouter + window.FlowScene (same shape as kafka-scene.jsx). + +const { Stage, useTime, Easing, clamp } = window; + +const C = { + pu: '#7f56d9', puL: '#9c80e5', puD: '#5a37a6', + ink: '#20273e', sub: '#7B84A0', + pill: '#ffffff', pillEdge: '#E6E3F1', + puTitle: '#7F56D9', puEdge: 'rgba(127,86,217,0.24)', + produce: '#7f56d9', // WRITES (purple) + consume: '#2FA39B', // READS + replication stream (teal) + tealTitle: '#268C85', + boxFill: 'rgba(127,86,217,0.05)', boxEdge: 'rgba(127,86,217,0.16)', + tealFill: 'rgba(47,163,155,0.06)', tealEdge: 'rgba(47,163,155,0.22)', + base: 'rgba(140,110,220,0.40)', + tealBase: 'rgba(47,163,155,0.34)', + cylFill: 'rgba(127,86,217,0.13)', cylTop: '#EEF0FB', +}; + +const clmp = (v, a, b) => Math.max(a, Math.min(b, v)); +const bump = (t, c, w) => Math.exp(-((t - c) / w) * ((t - c) / w)); + +function roundedPath(pts, r) { + if (pts.length < 2) return ''; + let d = `M ${pts[0][0]} ${pts[0][1]}`; + for (let i = 1; i < pts.length - 1; i++) { + const [px, py] = pts[i - 1], [cx, cy] = pts[i], [nx, ny] = pts[i + 1]; + let v1x = cx - px, v1y = cy - py; const l1 = Math.hypot(v1x, v1y) || 1; v1x /= l1; v1y /= l1; + let v2x = nx - cx, v2y = ny - cy; const l2 = Math.hypot(v2x, v2y) || 1; v2x /= l2; v2y /= l2; + const rr = Math.min(r, l1 / 2, l2 / 2); + d += ` L ${cx - v1x * rr} ${cy - v1y * rr} Q ${cx} ${cy} ${cx + v2x * rr} ${cy + v2y * rr}`; + } + const last = pts[pts.length - 1]; + d += ` L ${last[0]} ${last[1]}`; + return d; +} +function polyPoint(pts, s) { + s = clmp(s, 0, 1); + const segs = []; let total = 0; + for (let i = 0; i < pts.length - 1; i++) { + const l = Math.hypot(pts[i + 1][0] - pts[i][0], pts[i + 1][1] - pts[i][1]); + segs.push(l); total += l; + } + let d = s * total; + for (let i = 0; i < segs.length; i++) { + if (d <= segs[i] || i === segs.length - 1) { + const t = segs[i] ? d / segs[i] : 0; + return [pts[i][0] + (pts[i + 1][0] - pts[i][0]) * t, pts[i][1] + (pts[i + 1][1] - pts[i][1]) * t]; + } + d -= segs[i]; + } + return pts[pts.length - 1]; +} + +// ── layout ─────────────────────────────────────────────────────────────────── +const APP = { x: 250, y: 360 }; +const PRI = { x: 640, y: 360, w: 88 }; +const REPL = [ + { id: 'rTop', x: 1130, y: 250, w: 74 }, + { id: 'rMid', x: 1130, y: 460, w: 74 }, + { id: 'rBot', x: 1130, y: 670, w: 74 }, +]; + +const L = { + primaryBox: { x: 520, y: 250, w: 240, h: 224 }, + replicaBox: { x: 1022, y: 196, w: 204, h: 566 }, + routes: { + write: [[350, 360], [552, 360]], + replTop: [[728, 350], [900, 350], [900, 250], [1056, 250]], + replMid: [[728, 360], [900, 360], [900, 460], [1056, 460]], + replBot: [[728, 370], [900, 370], [900, 670], [1056, 670]], + readTop: [[250, 393], [250, 780], [1250, 780], [1250, 250], [1204, 250]], + readMid: [[250, 393], [250, 780], [1250, 780], [1250, 460], [1204, 460]], + readBot: [[250, 393], [250, 780], [1250, 780], [1250, 670], [1204, 670]], + }, +}; + +// choreography over a seamless 10s loop +const FLOWS = (() => { + const f = []; + // writes → primary + for (const s of [0.3, 2.8, 5.3, 7.8]) f.push({ route: 'write', s, e: s + 1.0, kind: 'write', node: 'app', target: 'primary' }); + // continuous replication stream primary → replicas (staggered waves) + const rep = ['replTop', 'replMid', 'replBot']; + const tgt = { replTop: 'rTop', replMid: 'rMid', replBot: 'rBot' }; + for (let i = 0; i < 9; i++) { + const base = 0.5 + i * 0.95; + rep.forEach((r, j) => { + const s = base + j * 0.12; + if (s + 0.85 < 9.6) f.push({ route: r, s, e: s + 0.85, kind: 'repl', node: 'primary', target: tgt[r] }); + }); + } + // reads fan out from app → replicas + const rd = ['readTop', 'readMid', 'readBot']; + const rtgt = { readTop: 'rTop', readMid: 'rMid', readBot: 'rBot' }; + for (const base of [1.2, 3.7, 6.2, 8.1]) { + rd.forEach((r, j) => { + const s = base + j * 0.15; + f.push({ route: r, s, e: s + 1.1, kind: 'read', node: 'app', target: rtgt[r] }); + }); + } + return f; +})(); + +// ── icons ──────────────────────────────────────────────────────────────────── +const ICONS = { + app: <g><rect x="3" y="4" width="18" height="16" rx="2" /><path d="M3 9h18M9 4v16" /></g>, +}; +function KIcon({ type, x, y, color }) { + return ( + <g transform={`translate(${x - 12},${y - 12})`} fill="none" stroke={color} strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"> + {ICONS[type] || null} + </g> + ); +} +function KCard({ x, y, title, sub, icon, glow = 0, accent }) { + const w = 200, h = 66; + const on = glow > 0.14; + const chipX = x - w / 2 + 28, chipY = y; + return ( + <g> + {glow > 0.03 ? ( + <rect x={x - w / 2 - 5} y={y - h / 2 - 5} width={w + 10} height={h + 10} rx="17" + fill="none" stroke={accent} strokeWidth="2.5" opacity={glow * 0.6} /> + ) : null} + <rect x={x - w / 2} y={y - h / 2} width={w} height={h} rx="13" fill="#fff" + stroke={on ? accent : C.pillEdge} strokeWidth={on ? 2 : 1.3} filter="url(#pillShadow)" /> + <rect x={chipX - 16} y={chipY - 16} width="32" height="32" rx="8" fill="rgba(127,86,217,0.10)" /> + <KIcon type={icon} x={chipX} y={chipY} color={accent} /> + <text x={x - w / 2 + 56} y={y - 5} fontFamily="Epilogue, sans-serif" fontSize="16.5" fontWeight="700" fill={C.ink}>{title}</text> + <text x={x - w / 2 + 56} y={y + 15} fontFamily="'JetBrains Mono', monospace" fontSize="11" fontWeight="500" fill={C.sub}>{sub}</text> + </g> + ); +} + +// database cylinder (adapted from request-flow DBNode) +function DBNode({ x, y, w = 80, label, sub, glow = 0, accent }) { + const top = 18; + const on = glow > 0.16; + const edge = on ? accent : C.puEdge; + return ( + <g> + {glow > 0.03 ? <ellipse cx={x} cy={y} rx={w + 16} ry={56} fill={accent} opacity={glow * 0.14} filter="url(#glow)" /> : null} + <path d={`M ${x - w} ${y - 30} V ${y + 30} C ${x - w} ${y + 42} ${x + w} ${y + 42} ${x + w} ${y + 30} V ${y - 30}`} + fill={C.cylFill} stroke={edge} strokeWidth={on ? 2.2 : 1.6} /> + <ellipse cx={x} cy={y} rx={w} ry={top} fill="none" stroke={C.puEdge} strokeWidth="1.4" opacity="0.7" /> + <ellipse cx={x} cy={y + 15} rx={w} ry={top * 0.9} fill="none" stroke={C.puEdge} strokeWidth="1.1" opacity="0.45" /> + <ellipse cx={x} cy={y - 30} rx={w} ry={top} fill={C.cylTop} stroke={edge} strokeWidth={on ? 2 : 1.6} /> + <ellipse cx={x} cy={y - 30} rx={w * 0.6} ry={top * 0.6} fill="none" stroke={C.puEdge} strokeWidth="1.1" opacity="0.5" /> + <text x={x} y={y - 24} textAnchor="middle" fontFamily="Epilogue, sans-serif" fontSize="12.5" fontWeight="800" fill={C.puTitle} letterSpacing="1.5">{label}</text> + <text x={x} y={y + 56} textAnchor="middle" fontFamily="'JetBrains Mono', monospace" fontSize="11" fontWeight="600" fill={C.sub}>{sub}</text> + </g> + ); +} + +function ReplicationDiagram({ accent }) { + const t = useTime(); + const write = accent || C.produce; + const pathRefs = React.useRef({}); + const [lens, setLens] = React.useState({}); + React.useLayoutEffect(() => { + const o = {}; + for (const k in pathRefs.current) { const el = pathRefs.current[k]; if (el) o[k] = el.getTotalLength(); } + setLens(o); + }, []); + + const routeIds = Object.keys(L.routes); + const routeD = React.useMemo(() => { + const o = {}; for (const k of routeIds) o[k] = roundedPath(L.routes[k], 18); return o; + }, []); + + // active streaks + const active = []; + for (const fl of FLOWS) { + if (t < fl.s - 0.05 || t > fl.e + 0.28) continue; + const p = Easing.easeInOutCubic(clmp((t - fl.s) / (fl.e - fl.s), 0, 1)); + let alpha = 1; + if (t > fl.e) alpha = clmp(1 - (t - fl.e) / 0.28, 0, 1); + active.push({ route: fl.route, front01: p, color: fl.kind === 'write' ? write : C.consume, kind: fl.kind, alpha }); + } + + // node glows + const g = { app: 0, primary: 0, rTop: 0, rMid: 0, rBot: 0 }; + for (const fl of FLOWS) { + if (fl.kind === 'write') { + g.app += bump(t, fl.s, 0.32); + g.primary += bump(t, fl.e, 0.34); + } else if (fl.kind === 'repl') { + g.primary += bump(t, fl.s, 0.30) * 0.7; + g[fl.target] += bump(t, fl.e, 0.32); + } else { // read + g.app += bump(t, fl.s, 0.30) * 0.7; + g[fl.target] += bump(t, fl.e, 0.32); + } + } + + const headPoint = (route, front01) => { + const el = pathRefs.current[route]; + if (el && lens[route]) return el.getPointAtLength(clmp(front01, 0, 1) * lens[route]); + const p = polyPoint(L.routes[route], front01); return { x: p[0], y: p[1] }; + }; + + return ( + <g> + {/* section labels */} + <text x={APP.x} y="298" textAnchor="middle" fontFamily="Epilogue, sans-serif" fontSize="14" fontWeight="700" fill={C.puTitle} opacity="0.7" letterSpacing="2">CLIENT</text> + + {/* container zones */} + <rect x={L.primaryBox.x} y={L.primaryBox.y} width={L.primaryBox.w} height={L.primaryBox.h} rx="22" fill={C.boxFill} stroke={C.boxEdge} strokeWidth="1.4" /> + <text x={L.primaryBox.x + L.primaryBox.w / 2} y={L.primaryBox.y - 12} textAnchor="middle" fontFamily="Epilogue, sans-serif" fontSize="13.5" fontWeight="800" fill={C.puTitle} opacity="0.82" letterSpacing="1">PRIMARY · WRITES</text> + + <rect x={L.replicaBox.x} y={L.replicaBox.y} width={L.replicaBox.w} height={L.replicaBox.h} rx="22" fill={C.tealFill} stroke={C.tealEdge} strokeWidth="1.4" /> + <text x={L.replicaBox.x + L.replicaBox.w / 2} y={L.replicaBox.y - 12} textAnchor="middle" fontFamily="Epilogue, sans-serif" fontSize="13.5" fontWeight="800" fill={C.tealTitle} opacity="0.85" letterSpacing="1">REPLICAS · READS</text> + + {/* base connectors */} + {routeIds.map((k) => ( + <path key={k} ref={(el) => (pathRefs.current[k] = el)} d={routeD[k]} + fill="none" stroke={k === 'write' ? C.base : C.tealBase} strokeWidth={k === 'write' ? 4 : 3.5} + strokeLinecap="round" strokeLinejoin="round" + strokeDasharray={k === 'write' ? '2 7' : k.startsWith('read') ? '2 8' : 'none'} + opacity={k.startsWith('read') ? 0.5 : 0.7} /> + ))} + + {/* route labels */} + <text x={451} y={344} textAnchor="middle" fontFamily="'JetBrains Mono', monospace" fontSize="12.5" fontWeight="700" fill={C.produce}>writes</text> + <g> + <text x={868} y={330} textAnchor="middle" fontFamily="'JetBrains Mono', monospace" fontSize="12.5" fontWeight="700" fill={C.tealTitle}>replication</text> + <text x={868} y={346} textAnchor="middle" fontFamily="'JetBrains Mono', monospace" fontSize="10.5" fontWeight="600" fill={C.sub}>WAL stream</text> + </g> + <text x={700} y={766} textAnchor="middle" fontFamily="'JetBrains Mono', monospace" fontSize="12.5" fontWeight="700" fill={C.tealTitle}>reads</text> + + {/* active streaks */} + {active.map((a, i) => { + const Ln = lens[a.route]; + const hp = headPoint(a.route, a.front01); + const dash = a.kind === 'repl' ? 22 : 32; + return ( + <g key={i} opacity={a.alpha}> + {Ln ? ( + <path d={routeD[a.route]} fill="none" stroke={a.color} strokeWidth="5" strokeLinecap="round" + strokeDasharray={`${dash} ${Ln + dash}`} strokeDashoffset={dash - a.front01 * Ln} /> + ) : null} + <circle cx={hp.x} cy={hp.y} r="11" fill={a.color} opacity="0.28" filter="url(#glow)" /> + <circle cx={hp.x} cy={hp.y} r="5.5" fill={a.color} /> + <circle cx={hp.x} cy={hp.y} r="2.4" fill="#fff" opacity="0.95" /> + </g> + ); + })} + + {/* app card */} + <KCard x={APP.x} y={APP.y} title="Spring App" sub="reads + writes" icon="app" glow={g.app} accent={write} /> + + {/* primary database */} + <DBNode x={PRI.x} y={PRI.y} w={PRI.w} label="PRIMARY" sub="leader" glow={g.primary} accent={C.produce} /> + + {/* replica databases */} + {REPL.map((r) => ( + <DBNode key={r.id} x={r.x} y={r.y} w={r.w} label="REPLICA" sub="follower" glow={g[r.id]} accent={C.consume} /> + ))} + </g> + ); +} + +function Defs() { + return ( + <defs> + <radialGradient id="bgGrad" cx="50%" cy="34%" r="80%"> + <stop offset="0%" stopColor="#FCFBFF" /> + <stop offset="100%" stopColor="#F1EEFA" /> + </radialGradient> + <filter id="glow" x="-80%" y="-80%" width="260%" height="260%"> + <feGaussianBlur stdDeviation="5" /> + </filter> + <filter id="pillShadow" x="-40%" y="-60%" width="180%" height="240%"> + <feDropShadow dx="0" dy="3" stdDeviation="4" floodColor="#20273e" floodOpacity="0.14" /> + </filter> + </defs> + ); +} + +function SceneRouter({ variant, accent }) { + const t = useTime(); + const k = 1; + const W = 1440, H = 960; + return ( + <svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} style={{ display: 'block' }}> + <Defs /> + <rect x="0" y="0" width={W} height={H} fill="url(#bgGrad)" /> + <g transform={`translate(${W / 2},${H / 2}) scale(${k}) translate(${-W / 2},${-H / 2})`}> + <ReplicationDiagram accent={accent} /> + </g> + </svg> + ); +} + +function FlowScene({ variant, accent }) { + return ( + <Stage width={1440} height={960} duration={10} loop background="#0e1020"> + <SceneRouter variant={variant} accent={accent} /> + </Stage> + ); +} + +window.FlowScene = FlowScene; +window.SceneRouter = SceneRouter; diff --git a/skills/flows-diagram/config.default.json b/skills/flows-diagram/config.default.json new file mode 100644 index 0000000..e38f43c --- /dev/null +++ b/skills/flows-diagram/config.default.json @@ -0,0 +1,11 @@ +{ + "workspaceDir": "~/amigoscode-skills/flows", + "voiceId": "BtWabtumIemAotTjP5sk", + "voiceModel": "eleven_multilingual_v2", + "voiceSettings": { + "stability": 0.45, + "similarity_boost": 0.8, + "style": 0.0, + "use_speaker_boost": true + } +} diff --git a/skills/flows-diagram/install.sh b/skills/flows-diagram/install.sh new file mode 100755 index 0000000..4c89bb4 --- /dev/null +++ b/skills/flows-diagram/install.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# +# One-line installer for the flows-diagram skill. +# +# curl -fsSL https://raw.githubusercontent.com/amigoscode/skills/main/skills/flows-diagram/install.sh | bash +# +# Override the install location with FLOWS_DIAGRAM_SKILL_DIR. + +set -euo pipefail + +REPO_URL="https://github.com/amigoscode/skills.git" +DEST="${FLOWS_DIAGRAM_SKILL_DIR:-$HOME/.claude/skills/flows-diagram}" +WORKSPACE="${FLOWS_DIR:-$HOME/amigoscode-skills/flows}" + +info() { printf '\033[1;35m==>\033[0m %s\n' "$1"; } +err() { printf '\033[1;31mError:\033[0m %s\n' "$1" >&2; } + +# Preflight: required tools +for cmd in node npm ffmpeg ffprobe; do + if ! command -v "$cmd" >/dev/null 2>&1; then + err "'$cmd' is required but not found. Install it and re-run." + exit 1 + fi +done + +# Chrome is rendered through puppeteer-core, so a real browser must exist +if [ -z "${CHROME_PATH:-}" ]; then + found="" + for c in "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \ + "/Applications/Chromium.app/Contents/MacOS/Chromium" \ + "/usr/bin/google-chrome" "/usr/bin/google-chrome-stable" \ + "/usr/bin/chromium" "/usr/bin/chromium-browser" "/snap/bin/chromium"; do + [ -x "$c" ] && { found="$c"; break; } + done + if [ -z "$found" ]; then + err "Google Chrome not found. Install Chrome, or export CHROME_PATH=/path/to/chrome." + exit 1 + fi + info "Chrome: $found" +fi + +# Node deps (puppeteer-core) +if [ -d "$DEST" ]; then + info "Installing node dependencies" + (cd "$DEST" && npm install --silent --no-audit --no-fund) +fi + +# API key scaffold +if [ ! -f "$DEST/.env" ] && [ -f "$DEST/.env.example" ]; then + cp "$DEST/.env.example" "$DEST/.env" + info "Created .env from .env.example" +fi + +# Workspace for authored diagrams. Prefer an existing legacy ~/flows project. +if [ -d "$HOME/flows" ] && [ -z "${FLOWS_DIR:-}" ]; then + WORKSPACE="$HOME/flows" + info "Using existing workspace at $WORKSPACE" +else + mkdir -p "$WORKSPACE" + info "Workspace: $WORKSPACE" +fi + +echo +info "Skill ready at $DEST" +printf '\033[1;33mNext:\033[0m\n' +printf ' - Add your ElevenLabs key: export ELEVEN_LABS=... (or edit %s/.env)\n' "$DEST" +printf ' - Diagrams are authored in: %s\n' "$WORKSPACE" +printf ' - Then ask Claude Code: "make a flows diagram for the outbox pattern"\n' +echo diff --git a/skills/flows-diagram/package-lock.json b/skills/flows-diagram/package-lock.json new file mode 100644 index 0000000..3cc8c17 --- /dev/null +++ b/skills/flows-diagram/package-lock.json @@ -0,0 +1,927 @@ +{ + "name": "flows-diagram", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "flows-diagram", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "puppeteer-core": "^24.43.1" + } + }, + "node_modules/@puppeteer/browsers": { + "version": "2.13.2", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.13.2.tgz", + "integrity": "sha512-5EUZSUIc37H6aIXyWO0Z4y8NlF8NnjgmqeQgOGiswAU7pY0HOo16ho4+alIWmSfdZnjqBRawMsP3I5YqLSn6kw==", + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.4.3", + "extract-zip": "^2.0.1", + "progress": "^2.0.3", + "proxy-agent": "^6.5.0", + "semver": "^7.7.4", + "tar-fs": "^3.1.1", + "yargs": "^17.7.2" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.1.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz", + "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==", + "license": "MIT", + "optional": true, + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/b4a": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/bare-events": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz", + "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.7.4", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.4.tgz", + "integrity": "sha512-y1kC+ffIx/tPLdTE693uNjHfzTfr+ravR5tvWlMXe25nELbkqV400S71qHDwbkAQ1FVEZobB1NFRzFbCCcyBCQ==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.1.tgz", + "integrity": "sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==", + "license": "Apache-2.0" + }, + "node_modules/bare-stream": { + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.3.tgz", + "integrity": "sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.8.1", + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.6.tgz", + "integrity": "sha512-iQxPClE07hETVpbRoX7JXX3v/ZQViCxe/SYCxylRLzdEx1xJAufPptfiOqR8tqiCtmbtMDANKWszzjLu1PMAZQ==", + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/basic-ftp": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz", + "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/chromium-bidi": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-14.0.0.tgz", + "integrity": "sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw==", + "license": "Apache-2.0", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/devtools-protocol": { + "version": "0.0.1608973", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1608973.tgz", + "integrity": "sha512-Tpm17fxYzt+J7VrGdc1k8YdRqS3YV7se/M6KeemEqvUbq/n7At1rWVuXMxQgpWkdwSdIEKYbU//Bve+Shm4YNQ==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-uri": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ip-address": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz", + "integrity": "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/netmask": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.1.1.tgz", + "integrity": "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "license": "MIT", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "license": "MIT" + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/proxy-agent": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", + "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.6", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.1.0", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/puppeteer-core": { + "version": "24.43.1", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.43.1.tgz", + "integrity": "sha512-T5ScUMAsmhdNbgDR41AGESYeS6V9MSgetkSnVhhW+gXvzC42VesKCn5ld87gAZDJ6vLHL9GkRvY9WtQWSnwFbw==", + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.13.2", + "chromium-bidi": "14.0.0", + "debug": "^4.4.3", + "devtools-protocol": "0.0.1608973", + "typed-query-selector": "^2.12.2", + "webdriver-bidi-protocol": "0.4.1", + "ws": "^8.20.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/streamx": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.0.tgz", + "integrity": "sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar-fs": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.3.tgz", + "integrity": "sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ==", + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", + "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typed-query-selector": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz", + "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT", + "optional": true + }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz", + "integrity": "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==", + "license": "Apache-2.0" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/skills/flows-diagram/package.json b/skills/flows-diagram/package.json new file mode 100644 index 0000000..3843cea --- /dev/null +++ b/skills/flows-diagram/package.json @@ -0,0 +1,11 @@ +{ + "name": "flows-diagram", + "version": "1.0.0", + "description": "Animated Amigoscode backend flow diagrams: square MP4, looping GIF, and a branded reel with voiceover and captions.", + "private": true, + "type": "commonjs", + "license": "MIT", + "dependencies": { + "puppeteer-core": "^24.43.1" + } +} diff --git a/skills/flows-diagram/references/pipeline.md b/skills/flows-diagram/references/pipeline.md new file mode 100644 index 0000000..46fddfc --- /dev/null +++ b/skills/flows-diagram/references/pipeline.md @@ -0,0 +1,113 @@ +# Render pipeline (`scripts/make.mjs`) + +One command turns a scene into all three outputs. It compiles `animations.jsx` + the scene with +Babel, mounts them in system Chrome via puppeteer-core, steps `window.__setTime(t)` for each of +300 frames (30fps x 10s), screenshots `#frame`/`#reel`, and pipes frames through ffmpeg. + +React and Babel are fetched once into `$SKILL/.cache` on the first render. Chrome is located +automatically (macOS and Linux paths); set `CHROME_PATH` to override. `ffmpeg` and `ffprobe` must +be on `PATH`, and `puppeteer-core` installed in the skill folder (`npm install`). + +## CLI contract + +``` +node $SKILL/scripts/make.mjs <projectDir> <sceneFile> <outBase> "<Title>" "<EYEBROW>" "<caption>" +``` + +- `<projectDir>` — absolute path, or a folder name inside the workspace `$FLOWS`, e.g. + `kafka-flow`. Must contain `animations.jsx` and `<sceneFile>`. +- `<sceneFile>` — the scene, e.g. `kafka-scene.jsx` (exports `window.SceneRouter`). +- `<outBase>` — output basename. Outputs land in `<projectDir>/`: + - `<outBase>.mp4` — 1440x1440 square + - `<outBase>.gif` — 960x960 infinite loop + - `<outBase>-reel.mp4` — 1080x1920 branded reel +- `<Title>` — big headline (shown on square + reel). Title Case, no dashes. +- `<EYEBROW>` — small uppercase kicker above the title, e.g. `APACHE KAFKA`. +- `<caption>` — small monospace line under the reel diagram only (the square ignores it; pass + `"x"` when rendering square-only). Example: `Producers → Partitions → Consumer Group`. + +## Environment variables + +| Var | Default | Meaning | +|---|---|---| +| `FLOWS_DIR` | see below | Workspace holding the `<name>-flow/` folders. Falls back to `workspaceDir` in `~/amigoscode-skills/flows-diagram-config.json`, then `~/flows`, then `~/amigoscode-skills/flows`. | +| `CHROME_PATH` | auto | Chrome/Chromium binary used for rendering. | +| `MODE` | `full` | `check` = render ONE mid-timeline frame to `$FLOWS/.work/<outBase>/<outBase>-check.png` (fast layout check). Always use this while dialing in the diagram + crop. | +| `TARGET` | `full` | `landscape` = square mp4 + gif only; `reel` = reel only; `full` = all three. | +| `CROP_TOP` | `0` | Pixels trimmed off the TOP of the 960-tall diagram. Raise it to pull the diagram DOWN, lower it to push UP. Use it to center the diagram between the title and the footer. | +| `CROP_BOTTOM` | `0` | Pixels trimmed off the BOTTOM of the diagram. Mostly sets how much empty stage the card keeps below the content; keep it large enough that the card never reaches the wordmark. | +| `VARIANT` | `N-tier` | Passed to `SceneRouter`; only scenes that branch on it (e.g. request-flow) care. | +| `KEEP_WORK` | unset | `1` keeps `$FLOWS/.work/<outBase>` scratch frames for debugging. Otherwise they are wiped after a successful render. | + +The layout is fixed in `make.mjs` (mark top, eyebrow+title near top, diagram card at +`CARD_TOP=372`, wordmark at bottom). The only per-diagram tuning is `CROP_TOP`/`CROP_BOTTOM`. The +goal is to CENTER the diagram vertically between the title and the footer, with roughly equal gaps +above and below. Because the card top is fixed, `CROP_TOP` is what moves the visible diagram: a +tall diagram fills the body on its own, but a short or single-row diagram needs a smaller +`CROP_TOP` so it drops to the middle instead of hugging the title. Dial these on the `MODE=check` +frame and eyeball the top and bottom gaps. Only affects the square; the reel scales the whole +frame so its diagram is already centered. + +## Workflow per diagram + +``` +# 1. fast layout loop — one frame, ~5s each, adjust CROP until the diagram hugs the title +MODE=check CROP_TOP=150 CROP_BOTTOM=140 node "$SKILL/scripts/make.mjs" foo-flow foo-scene.jsx foo "How Foo Works" "SUBSYSTEM" "x" +# 2. full render once happy +CROP_TOP=150 CROP_BOTTOM=140 node "$SKILL/scripts/make.mjs" foo-flow foo-scene.jsx foo "How Foo Works" "SUBSYSTEM" "A → B → C" +``` + +Rendering is Chrome + ffmpeg heavy. Run renders **sequentially**, never several `make.mjs` in +parallel (Chrome and ffmpeg contend and the machine thrashes). + +## Tuned render matrix (the 26 diagrams built with this pipeline) + +Copy the closest row when adding a similar diagram. `CROP` is `CROP_TOP/CROP_BOTTOM`. These are +the values that shipped; the scenes themselves are not bundled with the skill. + +| folder | outBase | CROP | Title | EYEBROW | +|---|---|---|---|---| +| request-flow | request-flow | 40/40 | How a Request Flows | SPRING BOOT (VARIANT=N-tier) | +| kafka-flow | kafka-flow | 120/120 | How Kafka Works | APACHE KAFKA | +| auth-flow | auth-flow | 60/60 | How JWT Auth Works | SPRING SECURITY | +| cicd-flow | cicd-flow | 92/58 | How CI/CD Works | DEVOPS | +| vthreads-flow | vthreads-flow | 150/140 | Virtual vs Platform Threads | JAVA 21 | +| replication-flow | replication-flow | 170/140 | How DB Replication Works | POSTGRESQL | +| ratelimit-flow | ratelimit | 145/185 | How Rate Limiting Works | TOKEN BUCKET | +| cache-flow | cache | 130/138 | Cache-Aside with Redis | CACHING | +| transaction-flow | transaction | 66/64 | How @Transactional Works | SPRING AOP | +| passby-flow | passby | 150/140 | Pass by Value vs Reference | JAVA MEMORY | +| gc-flow | gc | 150/140 | How G1 Garbage Collection Works | JVM MEMORY | +| threadpool-flow | threadpool | 130/150 | How a Thread Pool Works | EXECUTORSERVICE | +| deadlock-flow | deadlock | 120/120 | How Deadlocks Happen | JAVA CONCURRENCY | +| cfuture-flow | cfuture | 55/45 | How CompletableFuture Works | ASYNC JAVA | +| beanlifecycle-flow | beanlifecycle | 60/70 | The Spring Bean Lifecycle | SPRING IOC | +| filterchain-flow | filterchain | 20/20 | The Security Filter Chain | SPRING SECURITY | +| startup-flow | startup | 60/65 | How Spring Boot Starts Up | SPRING BOOT | +| index-flow | index | 150/140 | How Database Indexes Work | POSTGRESQL | +| hikari-flow | hikari | 180/165 | How a Connection Pool Works | HIKARICP | +| nplusone-flow | nplusone | 150/140 | The N+1 Query Problem | SPRING DATA JPA | +| locking-flow | locking | 150/140 | Optimistic vs Pessimistic Locking | JPA CONCURRENCY | +| circuit-flow | circuit | 130/150 | How a Circuit Breaker Works | RESILIENCE4J | +| loadbalancer-flow | loadbalancer | 150/140 | How a Load Balancer Works | SCALING | +| saga-flow | saga | 150/120 | How the Saga Pattern Works | DISTRIBUTED TX | +| retry-flow | retry | 140/40 | Retry with Exponential Backoff | RESILIENCE | +| idempotency-flow | idempotency | 124/130 | How Idempotency Keys Work | API DESIGN | + +## Resumable batch render (many diagrams) + +When re-rendering or building several, drive `make.mjs` from one sequential shell loop with a +`done_file` so a killed run resumes without redoing finished diagrams. Pattern: + +```zsh +done_file=/tmp/square-done.txt; touch "$done_file" +run() { # $1 env $2 args $3 outBase + grep -qx "$3" "$done_file" && { echo "skip $3"; return; } + eval "$1 node \"$SKILL/scripts/make.mjs\" $2" && echo "$3" >> "$done_file" +} +run 'CROP_TOP=120 CROP_BOTTOM=120' 'kafka-flow kafka-scene.jsx kafka-flow "How Kafka Works" "APACHE KAFKA" "x"' kafka-flow +# ...one run line per diagram... +``` + +Renders can take ~1 to 2 min each; a long batch may exceed a single background-command lifetime, +so run in the background and relaunch the same script (the `done_file` skips finished work). diff --git a/skills/flows-diagram/references/scene-contract.md b/skills/flows-diagram/references/scene-contract.md new file mode 100644 index 0000000..d147f94 --- /dev/null +++ b/skills/flows-diagram/references/scene-contract.md @@ -0,0 +1,83 @@ +# Scene contract (`<name>-scene.jsx`) + +A scene is one JSX file that draws an animated SVG diagram on a 1440x960 canvas, driven by the +current time (0..10s). Copy `$SKILL/assets/template/example-scene.jsx` as the template. It is the +cleanest reference: cards, a database cylinder, container zones, and time-driven animated +"streaks" (packets) along rounded routes. + +## Required shape + +Every scene must, by the end of the file: + +```js +window.SceneRouter = SceneRouter; // REQUIRED — make.mjs mounts this +window.FlowScene = FlowScene; // convention — for standalone/loop preview +``` + +`SceneRouter({ variant, accent })` returns the root `<svg width={1440} height={960} +viewBox="0 0 1440 960">` containing `<Defs/>`, the `url(#bgGrad)` background rect (which +`make.mjs` forces transparent so the page background shows through), and your diagram group. +`variant` comes from the `VARIANT` env var (ignore it unless the diagram has alternate forms, +e.g. request-flow's N-tier). `accent` is passed in as `#5a37a6`; most scenes use their own `C` +palette instead. + +## The engine (from `animations.jsx`, on `window`) + +```js +const { Stage, useTime, Easing, clamp } = window; +``` + +- `useTime()` → current time in seconds (0..10), re-renders every frame. All motion derives + from this. `make.mjs` steps it via `window.__setTime(t)`. +- `Easing.easeInOutCubic(x)` and friends for smooth 0..1 progress. +- `clamp(v,a,b)`. The template also defines local `clmp` and `bump(t,center,width)` (a gaussian + pulse, used for node glows that peak as a packet arrives). + +## Palette `C` (locked brand colors) + +Purple `#7f56d9` = primary / writes / producers. Teal `#2FA39B` = secondary / success / reads / +replication. Red `#E5484D` = failure. Ink `#20273e` for text, `#7B84A0` for sub-labels. Copy the +whole `C` object from the template and extend it; do not invent new hues. + +## Building blocks in the template (reuse these) + +- `roundedPath(points, r)` — SVG path through waypoints with rounded corners. Define each route + as an array of `[x,y]` points in `L.routes`. +- `polyPoint(points, s)` — point at fraction `s` (0..1) along a polyline (fallback before SVG + `getPointAtLength` measures are ready). +- `KCard({x,y,title,sub,icon,glow,accent})` — the white rounded node card with an icon chip. + `glow` (0..1) lights its border as flow arrives. +- `KIcon` + `ICONS` — small stroked glyphs. Add entries to `ICONS` (a `<g>` of paths on a 24x24 + grid) for new node types. +- `DBNode({x,y,w,label,sub,glow,accent})` — the database cylinder. +- `Defs()` — `bgGrad` (background), `glow` (gaussian blur for packet halos), `pillShadow` (card + drop shadow). Keep these ids; the frame CSS and other helpers depend on `bgGrad`. + +## Animating flow (the core idea) + +1. Lay out nodes as constants (`APP`, `PRI`, `REPL`, container boxes in `L`). +2. Define routes as point arrays in `L.routes`. +3. Build a `FLOWS` list: each entry `{ route, s, e, kind, node, target }` is one packet that + travels `route` from time `s` to `e`. Stagger `s` values across the 10s so the diagram always + has something moving (retention). Keep the choreography seamless as a loop (nothing mid-flight + at t=10 that would pop at t=0). +4. Each frame: compute which `FLOWS` are active, draw a moving dash + a glowing head circle along + the route, and accumulate `bump()` glows on the source/target nodes. + +## Motion rules + +- 10 seconds, 30fps, `k=1` (no breathing zoom — the template sets `const k = 1`). +- Something visible in motion at all times; new visual beats every ~2 to 4s. +- Keep the diagram within roughly the central band of the 1440x960 canvas; `CROP_TOP`/ + `CROP_BOTTOM` in `make.mjs` trims the empty top/bottom padding, so leave predictable margins + rather than pushing art to the extreme edges. + +## Workflow + +1. `cp "$SKILL/assets/template/animations.jsx" foo-flow/animations.jsx` (do not edit; it is the + shared engine — every project keeps its own copy). +2. `cp "$SKILL/assets/template/example-scene.jsx" foo-flow/foo-scene.jsx` and rewrite the layout, + routes, `FLOWS`, and node cards for the new concept. +3. Iterate with `MODE=check node "$SKILL/scripts/make.mjs" foo-flow foo-scene.jsx foo "Title" "EYEBROW" "x"`. +4. For a hard layout, spin up a general-purpose agent to draft the scene from the template with + these rules, then render and eyeball the check frame yourself. diff --git a/skills/flows-diagram/references/voiceover-captions.md b/skills/flows-diagram/references/voiceover-captions.md new file mode 100644 index 0000000..b09a35a --- /dev/null +++ b/skills/flows-diagram/references/voiceover-captions.md @@ -0,0 +1,109 @@ +# Voiceover, captions, caption.txt, title.txt + +Four text/media artifacts per diagram, on top of the rendered videos: + +- `caption.txt` — the long-form social post copy (Instagram/LinkedIn body). /infographic style. +- `title.txt` — the short upload title / file name. +- `vo-script.txt` — the ~44-word narration script (input to the voiceover). +- `<base>-reel-voice-over.mp4` — the reel with brand voiceover + Submagic captions + emojis. + +## title.txt + +One line, Title Case, 3 to 8 words, developer-searchable. Works as a clean file name: no emojis, +no hashtags, no quotes, no trailing punctuation, no dashes. Prefer "How X Works", "Why X ...", +"The X ...", or "X vs Y". Keep real tech names. Examples: `How Kafka Works`, +`Optimistic vs Pessimistic Locking`, `The N+1 Query Problem`. + +## caption.txt (mentor voice, /infographic style) + +Plain text, no markdown, no emojis, no en/em dashes. `→` is the only bullet marker. No sentence +starts with "I". Shape: + +``` +<hook line — reframe a misconception> + +<one or two reframing lines> + +Here is the mental model you should have +→ step one +→ step two +→ ... (about 6 arrows) + +The mistake <juniors/teams> make is <...> +<two or three short lines> + +Senior engineers <...> +<two or three short lines> + +<one memorable closing line> + +<a question that invites a reply> + +Share your thoughts below + +Follow Amigoscode for lessons that turn developers into senior engineers +``` + +## vo-script.txt (the narration) + +One line, plain text. 40 to 48 words so the brand voice lands near 18 to 21 seconds (it reads at +~2.6 words/sec; over ~48 words drifts past 22s). Mentor voice, second person, present tense. Open +with a reframe/hook, walk the mechanism in order, close with one memorable line. No em/en dashes. +No sentence starts with "I". Use the real technical nouns (producer, consumer, partition, offset, +cache, thread, lock, retry, index, pool, token) so the on-screen keyword captions get emoji hits. +Reference: + +> Kafka is not a queue, it is a durable log. Producers append records to a topic, split into +> partitions so it can scale. A consumer group shares those partitions, and each consumer tracks +> its own offset. Nothing is ever deleted, so you can always replay the log. + +## Voiceover + caption render pipeline + +Three node scripts in `$SKILL/scripts/`, then one ffmpeg composite. Run them from the workspace +`$FLOWS`. The ElevenLabs API key is read from `$ELEVEN_LABS`, then `$SKILL/.env`, then `~/.env`; +never print it. Default voice is the Amigoscode brand voice `BtWabtumIemAotTjP5sk` (override with +`VOICE_ID=`, or `voiceId` in `~/amigoscode-skills/flows-diagram-config.json`). + +```zsh +folder=kafka-flow ; base=kafka-flow # base = the reel's outBase +TEXT=$(cat $folder/vo-script.txt) +VO=$folder/$base-vo.mp3 +WORDS=/tmp/$base-words.json +CAPS=/tmp/caps-$base +REEL=$folder/$base-reel.mp4 +OUT=$folder/$base-reel-voice-over.mp4 + +# 1. TTS -> $folder/$base-vo.mp3 +node "$SKILL/scripts/gen-vo.mjs" "$folder" "$base" "$TEXT" +# 2. forced alignment -> word timings json +node "$SKILL/scripts/vo-align.mjs" "$VO" "$TEXT" "$WORDS" +DUR=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$VO") +# 3. Submagic caption overlay -> transparent PNG frames (args are FIXED for reels) +node "$SKILL/scripts/caption-render.mjs" "$WORDS" "$CAPS" 1080 1920 30 "$DUR" 1545 60 1420 +# 4. composite: loop the 10s reel under the captions, mux the voiceover, cut at audio length +ffmpeg -y -stream_loop -1 -i "$REEL" -framerate 30 -i "$CAPS/f_%04d.png" -i "$VO" \ + -filter_complex "[0:v][1:v]overlay=0:0[v]" -map "[v]" -map 2:a \ + -t "$DUR" -r 30 -c:v libx264 -pix_fmt yuv420p -crf 18 -c:a aac -b:a 192k \ + -movflags +faststart "$OUT" +rm -rf "$CAPS" # always delete caption scratch frames +``` + +Notes: +- The reel is 10s; `-stream_loop -1` loops it and `-t $DUR` cuts at the audio length. Use `-t`, + never `-shortest` (with an infinite loop `-shortest` overshoots). +- `caption-render.mjs` positional args: `<wordsJson> <framesDir> <W> <H> <fps> <dur> <capCenterY> + <fontSize> <emojiY>`. For the 1080x1920 reel the tuned values are `1545 60 1420` — keep them. +- Emojis animate in (fade + rise + easeOutBack bounce) on keyword hits. The keyword→emoji map is + built into `caption-render.mjs` and already covers every topic in the series; override for one + run with `EMOJI_JSON=<path-to-json>` (merged over the defaults). +- Chrome must launch WITHOUT `--default-background-color` (it crashes); `omitBackground:true` + already yields transparency. + +## Batch across many diagrams + +Agents write the text files (`caption.txt`, `title.txt`, `vo-script.txt`) in parallel — one +general-purpose agent per 3 to 4 diagrams, each handed the exact style spec above. Then run the +voiceover render **sequentially** in one resumable driver with a `done_file` (puppeteer + ffmpeg +contend, so never parallelize the render). Manifest is `"folder outBase"` pairs; note the outBase +often differs from the folder (e.g. `ratelimit-flow` → `ratelimit`). Durations land ~18 to 23s; +trim a `vo-script.txt` and re-render just that one if it drifts too long. diff --git a/skills/flows-diagram/scripts/caption-render.mjs b/skills/flows-diagram/scripts/caption-render.mjs new file mode 100644 index 0000000..c952f0b --- /dev/null +++ b/skills/flows-diagram/scripts/caption-render.mjs @@ -0,0 +1,117 @@ +// Render a Submagic-style animated caption overlay as a transparent PNG sequence. +// node caption-render.mjs <wordsJson> <framesDir> <W> <H> <fps> <dur> <capCenterY> <fontSize> <emojiY> +import fs from 'node:fs'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { chromePath } from './paths.mjs'; +const require = createRequire(import.meta.url); +const puppeteer = require('puppeteer-core'); +const CHROME = chromePath(); + +const [wordsJson, framesDir, W, H, FPS, DUR, CAP_Y, FONT, EMOJI_Y] = + process.argv.slice(2).map((x, i) => (i >= 2 && i <= 8 ? Number(x) : x)); +const words = JSON.parse(fs.readFileSync(wordsJson, 'utf8')); + +// curated keyword -> emoji. Broad enough to cover every diagram topic, but the +// per-video scripts only hit a handful of these, so captions stay tasteful. +// Override for a single run with EMOJI_JSON=<path-to-json>. +const EMOJI = (() => { + const base = { + // request-flow + request: '📨', boot: '🍃', controller: '🎮', service: '⚙️', + repository: '🗄️', database: '💾', response: '📦', bug: '🐛', + // kafka + kafka: '🟣', queue: '📮', log: '📜', producer: '📤', producers: '📤', + consumer: '📥', consumers: '📥', topic: '📋', partition: '🗂️', + partitions: '🗂️', offset: '📍', replay: '⏪', broker: '🖥️', + // shared backend vocabulary (used by other diagrams) + cache: '🧊', redis: '🧊', thread: '🧵', threads: '🧵', lock: '🔒', + retry: '🔁', index: '📇', pool: '🏊', deadlock: '💀', token: '🎟️', + scale: '📈', replica: '📑', saga: '🧩', jwt: '🔑', memory: '🧠', + }; + const p = process.env.EMOJI_JSON; + if (p) { try { return { ...base, ...JSON.parse(fs.readFileSync(p, 'utf8')) }; } catch {} } + return base; +})(); + +const html = `<!DOCTYPE html><html><head><meta charset="utf-8"> +<link href="https://fonts.googleapis.com/css2?family=Epilogue:wght@700;800;900&display=swap" rel="stylesheet"> +<style> + html,body{margin:0;background:transparent;} + #stage{position:relative;width:${W}px;height:${H}px;overflow:hidden;font-family:'Epilogue',system-ui,sans-serif;} + #emoji{position:absolute;left:0;right:0;top:${EMOJI_Y}px;text-align:center;font-size:96px;line-height:1;transform-origin:center center;will-change:transform,opacity;} + #wrap{position:absolute;left:50%;top:${CAP_Y}px;transform:translate(-50%,-50%); + width:900px;text-align:center;line-height:1.18;} + .w{display:inline-block;margin:4px 8px;font-size:${FONT}px;font-weight:800;letter-spacing:-0.5px; + text-transform:uppercase;color:#20273e; + text-shadow:0 2px 10px rgba(255,255,255,0.9),0 0 2px rgba(255,255,255,0.9);} + .w.active{color:#fff;background:#7f56d9;border-radius:14px;padding:2px 16px;margin:4px 8px; + box-shadow:0 8px 22px rgba(127,86,217,0.45);text-shadow:none;} +</style></head><body> +<div id="stage"><div id="emoji"></div><div id="wrap"></div></div> +<script> + const WORDS = ${JSON.stringify(words)}; + const EMOJI = ${JSON.stringify(EMOJI)}; + const clean = (t)=>t.toLowerCase().replace(/[^a-z]/g,''); + // group into short caption chunks (<=3 words, break on sentence/comma end) + const chunks=[]; let cur=[]; + for(const w of WORDS){ cur.push(w); + if(cur.length>=3 || /[.,!?]$/.test(w.text)){ chunks.push(cur); cur=[]; } } + if(cur.length) chunks.push(cur); + const C = chunks.map(ws=>({ws,start:ws[0].start,end:ws[ws.length-1].end})); + for(let i=0;i<C.length;i++) C[i].hold = (i+1<C.length? C[i+1].start : C[i].end+0.6); + const wrap=document.getElementById('wrap'), emo=document.getElementById('emoji'); + function ease(x){return 1-Math.pow(1-x,3);} + function easeOutBack(x){const c1=1.70158,c3=c1+1;return 1+c3*Math.pow(x-1,3)+c1*Math.pow(x-1,2);} + // emoji events: each keyword emoji animates in and holds briefly past its word + const EV=[]; for(const w of WORDS){ const e=EMOJI[clean(w.text)]; if(e) EV.push({e,start:w.start,until:w.end+0.55}); } + window.__setTime=function(t){ + let ci=-1; + for(let i=0;i<C.length;i++){ if(t>=C[i].start-0.06 && t<C[i].hold){ ci=i; break; } } + if(ci<0){ wrap.innerHTML=''; emo.textContent=''; emo.style.opacity=0; return; } + const ch=C[ci]; let html=''; + for(const w of ch.ws){ + const on = t>=w.start-0.02 && t<=w.end+0.06; + const cls = on? 'w active':'w'; + let style=''; + if(on){ const p=Math.min(1,(t-w.start)/0.13); const s=1.28-0.28*ease(p); + style='transform:scale('+s.toFixed(3)+');'; } + html+='<span class="'+cls+'" style="'+style+'">'+w.text.replace(/[.,!?]+$/,'')+'</span>'; + } + wrap.innerHTML=html; + // emoji: animate in (fade + rise + bounce scale), hold, then fade out + let ev=null; for(let i=EV.length-1;i>=0;i--){ if(t>=EV[i].start && t<EV[i].until){ ev=EV[i]; break; } } + if(ev){ + const p=Math.min(1,(t-ev.start)/0.30); + const s=Math.max(0.05,easeOutBack(p)); // 0 -> overshoot -> 1 + const ty=(1-ease(p))*34; // rises up into place + let op=Math.min(1,(t-ev.start)/0.12); // quick fade in + const fo=(ev.until-t)/0.22; if(fo<1) op=Math.min(op,Math.max(0,fo)); // fade out + emo.textContent=ev.e; emo.style.opacity=op.toFixed(3); + emo.style.transform='translateY('+ty.toFixed(1)+'px) scale('+s.toFixed(3)+')'; + } else { emo.textContent=''; emo.style.opacity=0; } + }; +</script></body></html>`; + +const N = Math.ceil(DUR * FPS); +fs.rmSync(framesDir, { recursive: true, force: true }); +fs.mkdirSync(framesDir, { recursive: true }); +const hp = path.join(framesDir, '_cap.html'); +fs.writeFileSync(hp, html); + +const browser = await puppeteer.launch({ executablePath: CHROME, headless: true, + args: ['--no-sandbox', '--hide-scrollbars', '--force-color-profile=srgb'] }); +const page = await browser.newPage(); +await page.setViewport({ width: W, height: H, deviceScaleFactor: 1 }); +await page.goto('file://' + hp, { waitUntil: 'networkidle0' }); +await page.evaluate(() => document.fonts && document.fonts.ready); +await new Promise((r) => setTimeout(r, 400)); +const el = await page.$('#stage'); +for (let f = 0; f < N; f++) { + const t = f / FPS; + await page.evaluate((tt) => new Promise((res) => { window.__setTime(tt); requestAnimationFrame(() => requestAnimationFrame(res)); }), t); + await el.screenshot({ path: path.join(framesDir, `f_${String(f).padStart(4, '0')}.png`), omitBackground: true }); + if (f % 60 === 0) process.stdout.write(` ${f}/${N}\r`); +} +await browser.close(); +console.log(`\n${N} caption frames -> ${framesDir}`); diff --git a/skills/flows-diagram/scripts/gen-vo.mjs b/skills/flows-diagram/scripts/gen-vo.mjs new file mode 100644 index 0000000..1c1ab86 --- /dev/null +++ b/skills/flows-diagram/scripts/gen-vo.mjs @@ -0,0 +1,36 @@ +// Generate a voiceover MP3 for a diagram via ElevenLabs. +// node gen-vo.mjs <projectDir> <outBase> "<script text>" +// Writes <projectDir>/<outBase>-vo.mp3. Key read from $ELEVEN_LABS, the skill's +// .env, or ~/.env. Override the voice with VOICE_ID=<id>; the default is the +// Amigoscode brand voice. The voice id also comes from the skill config. + +import fs from 'node:fs'; +import path from 'node:path'; +import { elevenLabsKey, readConfig, resolveProject } from './paths.mjs'; + +const [projArg, outBase, text] = process.argv.slice(2); +if (!projArg || !outBase || !text) { + console.error('usage: node gen-vo.mjs <projectDir> <outBase> "<script text>"'); + process.exit(1); +} + +let KEY; +try { KEY = elevenLabsKey(); } catch (e) { console.error(e.message); process.exit(1); } + +const VOICE = process.env.VOICE_ID || readConfig().voiceId; // Amigoscode brand voice +const out = path.join(resolveProject(projArg), `${outBase}-vo.mp3`); + +const body = { + text: text.trim(), + model_id: readConfig().voiceModel, + voice_settings: readConfig().voiceSettings, +}; + +const res = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${VOICE}?output_format=mp3_44100_128`, { + method: 'POST', + headers: { 'xi-api-key': KEY, 'Content-Type': 'application/json' }, + body: JSON.stringify(body), +}); +if (!res.ok) { console.error(`ElevenLabs ${res.status}: ${await res.text()}`); process.exit(1); } +fs.writeFileSync(out, Buffer.from(await res.arrayBuffer())); +console.log('wrote', out, `(${Math.round(fs.statSync(out).size / 1024)} KB)`); diff --git a/skills/flows-diagram/scripts/make.mjs b/skills/flows-diagram/scripts/make.mjs new file mode 100644 index 0000000..d050fec --- /dev/null +++ b/skills/flows-diagram/scripts/make.mjs @@ -0,0 +1,212 @@ +// Shared pipeline for the animated flow-diagram series. +// +// MODE=check node make.mjs <projectDir> <sceneFile> <outBase> "<title>" "<EYEBROW>" "<caption>" +// check -> one landscape frame to .work/<outBase>-check.png (fast layout check) +// node make.mjs <projectDir> <sceneFile> <outBase> "<title>" "<EYEBROW>" "<caption>" +// full -> <projectDir>/<outBase>.mp4 (1440x960 landscape) +// <projectDir>/<outBase>.gif (960x640 infinite loop) +// <projectDir>/<outBase>-reel.mp4 (1080x1920 branded reel) +// +// projectDir may be absolute, or a folder name inside the workspace (e.g. "kafka-flow"). +// The workspace is FLOWS_DIR, else the configured workspaceDir, else ~/flows, else +// ~/amigoscode-skills/flows. Each project needs animations.jsx + the named sceneFile +// (exporting window.SceneRouter). +// +// Example: +// node make.mjs kafka-flow kafka-scene.jsx kafka-flow \ +// "How Kafka Works" "APACHE KAFKA" "Producers → Partitions → Consumer Group" + +import fs from 'node:fs'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { spawnSync } from 'node:child_process'; +import { ASSETS_DIR, CACHE_DIR, chromePath, ensureCache, resolveProject, workspaceDir } from './paths.mjs'; + +const require = createRequire(import.meta.url); +const puppeteer = require('puppeteer-core'); + +const CHROME = chromePath(); +const LOGO = path.join(ASSETS_DIR, 'amigoscode-logo.png'); // white wordmark (bottom) +const MARK = path.join(ASSETS_DIR, 'logo-mark.svg'); // purple mark (top) + +const MODE = process.env.MODE || 'full'; +const [projArg, sceneFile, outBase, TITLE, EYEBROW, CAPTION] = process.argv.slice(2); +if (!projArg || !sceneFile || !outBase) { + console.error('usage: node make.mjs <projectDir> <sceneFile> <outBase> "<Title>" "<EYEBROW>" "<caption>"'); + process.exit(1); +} +const projectDir = resolveProject(projArg); +// per-render scratch, kept in the workspace so the skill folder stays clean +const WORK = path.join(workspaceDir({ create: true }), '.work', outBase); +fs.mkdirSync(WORK, { recursive: true }); + +await ensureCache(); // React + Babel, fetched once into .cache/ + +const Babel = require(path.join(CACHE_DIR, 'babel.js')); +const react = fs.readFileSync(path.join(CACHE_DIR, 'react.js'), 'utf8'); +const reactDom = fs.readFileSync(path.join(CACHE_DIR, 'react-dom.js'), 'utf8'); +const animations = fs.readFileSync(path.join(projectDir, 'animations.jsx'), 'utf8'); +const scene = fs.readFileSync(path.join(projectDir, sceneFile), 'utf8'); +const logoData = 'data:image/png;base64,' + fs.readFileSync(LOGO).toString('base64'); +const logoInkData = 'data:image/png;base64,' + fs.readFileSync(path.join(ASSETS_DIR, 'amigoscode-logo-ink.png')).toString('base64'); +const markData = 'data:image/svg+xml;base64,' + fs.readFileSync(MARK).toString('base64'); + +const opts = { presets: [[Babel.availablePresets['react'], { runtime: 'classic' }]] }; +const compile = (code, name) => Babel.transform(code, { ...opts, filename: name }).code; +const cAnimations = compile(animations, 'animations.jsx'); +const cScene = compile(scene, sceneFile); + +const FPS = 30, DURATION = 10, N = FPS * DURATION; +const VARIANT = process.env.VARIANT || 'N-tier'; // scenes that ignore it (e.g. kafka) are unaffected +const mountFor = (dur) => ` +(function(){ + var e = React.createElement, extSet = null; + function Root(){ + var s = React.useState(0); extSet = s[1]; + var ctx = { time:s[0], duration:${dur}, playing:false, setTime:s[1], setPlaying:function(){} }; + return e(window.TimelineContext.Provider,{value:ctx}, e(window.SceneRouter,{variant:${JSON.stringify(VARIANT)},accent:'#5a37a6'})); + } + ReactDOM.createRoot(document.getElementById('stage')).render(e(Root)); + window.__setTime=function(v){ if(extSet) extSet(v); }; +})();`; +const scripts = `<script>${react}</script><script>${reactDom}</script> +<script>(function(){${cAnimations}})();</script> +<script>(function(){${cScene}})();</script>`; + +// Landscape: 1:1 SQUARE, composed like the reel. Purple mark at the top, then +// eyebrow + title pinned near the top edge, the full-width diagram centered in +// the middle, and the ink wordmark pinned near the bottom edge. +// CROP_TOP/CROP_BOTTOM trim the diagram's empty padding. +const LW = 1440, LH = 1440; +const CROP_TOP = +(process.env.CROP_TOP || 0), CROP_BOTTOM = +(process.env.CROP_BOTTOM || 0); +const CARD_H = 960 - CROP_TOP - CROP_BOTTOM; +// header (mark + eyebrow + title) pinned near the top, diagram top-aligned just +// below the title so it hugs the header, wordmark pinned near the bottom. +const CARD_TOP = 372; +const landscapeHTML = `<!DOCTYPE html><html><head><meta charset="utf-8"> +<link href="https://fonts.googleapis.com/css2?family=Epilogue:wght@100..900&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet"> +<style> + html,body{margin:0} + #frame{position:relative;width:${LW}px;height:${LH}px;overflow:hidden; + background:radial-gradient(125% 65% at 50% 30%, #FCFBFF 0%, #F1EEFA 100%); + font-family:'Epilogue',system-ui,sans-serif;} + /* make the diagram's own bg transparent so the frame's single background + shows through continuously — mark, header, diagram and footer share one bg. */ + #stage svg rect[fill="url(#bgGrad)"]{fill:transparent !important;} + .mark{position:absolute;top:74px;left:50%;transform:translateX(-50%);width:104px;height:104px;} + .header{position:absolute;top:256px;left:0;right:0;text-align:center;padding:0 100px;} + .eyebrow{color:#7F56D9;font-size:23px;font-weight:700;letter-spacing:8px;text-transform:uppercase;margin:0 0 14px;} + .title{color:#20273e;font-size:54px;font-weight:800;line-height:1.05;letter-spacing:-1px;margin:0;} + .card{position:absolute;top:${CARD_TOP}px;left:0;width:${LW}px;height:${CARD_H}px;overflow:hidden;} + .card #stage{margin-top:-${CROP_TOP}px;} + .card svg{display:block;width:${LW}px;height:960px;} + .footer{position:absolute;bottom:74px;left:0;right:0;text-align:center;} + .wordmark{width:286px;opacity:0.94;} +</style></head><body> +<div id="frame"> + <img class="mark" src="${markData}"> + <div class="header"><p class="eyebrow">${EYEBROW || ''}</p><h1 class="title">${TITLE || ''}</h1></div> + <div class="card"><div id="stage"></div></div> + <div class="footer"><img class="wordmark" src="${logoInkData}"></div> +</div>${scripts}<script>${mountFor(DURATION)}</script></body></html>`; + +const reelHTML = `<!DOCTYPE html><html><head><meta charset="utf-8"> +<link href="https://fonts.googleapis.com/css2?family=Epilogue:wght@100..900&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet"> +<style> + html,body{margin:0} + #reel{position:relative;width:1080px;height:1920px;overflow:hidden; + background:radial-gradient(95% 48% at 50% 46%, #FCFBFF 0%, #F1EEFA 100%); + font-family:'Epilogue',system-ui,sans-serif;} + /* diagram bg transparent so the reel's single background shows through */ + #stage svg rect[fill="url(#bgGrad)"]{fill:transparent !important;} + .glow{position:absolute;left:50%;top:52%;width:1240px;height:1240px;transform:translate(-50%,-50%); + background:radial-gradient(circle, rgba(127,86,217,0.12), rgba(127,86,217,0) 60%);} + .mark{position:absolute;top:104px;left:50%;transform:translateX(-50%);width:132px;height:132px;} + .top{position:absolute;top:392px;left:0;right:0;text-align:center;padding:0 80px;} + .eyebrow{color:#7F56D9;font-size:30px;font-weight:700;letter-spacing:9px;text-transform:uppercase;margin:0 0 20px;} + .title{color:#20273e;font-size:78px;font-weight:800;line-height:1.05;letter-spacing:-1.5px;margin:0;} + .card{position:absolute;left:0;top:660px;width:1080px;height:720px;overflow:hidden;} + .card svg{display:block;width:1080px;height:720px;} + .caption{position:absolute;top:1452px;left:0;right:0;text-align:center;color:#7a7296;font-size:24px;font-weight:600;font-family:'JetBrains Mono',monospace;} + .logo{position:absolute;bottom:150px;left:50%;transform:translateX(-50%);width:430px;opacity:0.95;} +</style></head><body> +<div id="reel"> + <div class="glow"></div> + <img class="mark" src="${markData}"> + <div class="top"><p class="eyebrow">${EYEBROW || ''}</p><h1 class="title">${TITLE || ''}</h1></div> + <div class="card"><div id="stage"></div></div> + <div class="caption">${CAPTION || ''}</div> + <img class="logo" src="${logoInkData}"> +</div>${scripts}<script>${mountFor(DURATION)}</script></body></html>`; + +async function capture(html, { w, h, scale, sel, framesDir }) { + fs.rmSync(framesDir, { recursive: true, force: true }); + fs.mkdirSync(framesDir, { recursive: true }); + const hp = path.join(WORK, 'harness.html'); + fs.writeFileSync(hp, html); + const browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--hide-scrollbars', '--force-color-profile=srgb'] }); + const page = await browser.newPage(); + await page.setViewport({ width: w, height: h, deviceScaleFactor: scale }); + await page.goto('file://' + hp, { waitUntil: 'networkidle0' }); + await page.evaluate(() => document.fonts && document.fonts.ready); + await new Promise((r) => setTimeout(r, 600)); + const el = await page.$(sel); + const frames = MODE === 'check' ? [Math.round(N * 0.26)] : [...Array(N).keys()]; + for (const f of frames) { + const t = (f / N) * DURATION; + await page.evaluate((tt) => new Promise((res) => { window.__setTime(tt); requestAnimationFrame(() => requestAnimationFrame(res)); }), t); + await el.screenshot({ path: path.join(framesDir, `f_${String(f).padStart(4, '0')}.png`) }); + if (f % 30 === 0) process.stdout.write(` ${f}/${N}\r`); + } + await browser.close(); +} + +const TARGET = process.env.TARGET || 'full'; // landscape | reel | full +const sz = (p) => Math.round(fs.statSync(p).size / 1024); +// scratch frames/harness are disposable once ffmpeg has the outputs; wipe them +// after a successful render. Set KEEP_WORK=1 to keep them for debugging. +const KEEP_WORK = process.env.KEEP_WORK === '1'; +const rmrf = (p) => fs.rmSync(p, { recursive: true, force: true }); + +(async () => { + if (MODE === 'check') { + const fd = path.join(WORK, 'frames-check'); + await capture(landscapeHTML, { w: LW + 40, h: LH + 40, scale: 1, sel: '#frame', framesDir: fd }); + const only = fs.readdirSync(fd)[0]; + const out = path.join(WORK, `${outBase}-check.png`); + fs.copyFileSync(path.join(fd, only), out); + console.log('check frame:', out); + if (!KEEP_WORK) { rmrf(fd); rmrf(path.join(WORK, 'harness.html')); } + return; + } + + const done = []; + if (TARGET === 'landscape' || TARGET === 'full') { + const fdL = path.join(WORK, 'frames-land'); + await capture(landscapeHTML, { w: LW + 40, h: LH + 40, scale: 2, sel: '#frame', framesDir: fdL }); + console.log(` ${N}/${N} landscape frames `); + const mp4 = path.join(projectDir, `${outBase}.mp4`); + spawnSync('ffmpeg', ['-y', '-framerate', String(FPS), '-i', path.join(fdL, 'f_%04d.png'), + '-vf', `scale=${LW}:${LH}:flags=lanczos`, '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-crf', '18', '-movflags', '+faststart', mp4], + { stdio: ['ignore', 'ignore', 'inherit'] }); + const pal = path.join(WORK, 'pal.png'); + spawnSync('ffmpeg', ['-y', '-i', mp4, '-vf', 'fps=20,scale=960:-1:flags=lanczos,palettegen=stats_mode=diff', pal], { stdio: 'ignore' }); + const gif = path.join(projectDir, `${outBase}.gif`); + spawnSync('ffmpeg', ['-y', '-i', mp4, '-i', pal, '-lavfi', 'fps=20,scale=960:-1:flags=lanczos,paletteuse=dither=bayer:bayer_scale=3:diff_mode=rectangle', '-loop', '0', gif], { stdio: 'ignore' }); + done.push(`${mp4} (${sz(mp4)} KB)`, `${gif} (${sz(gif)} KB)`); + } + + if (TARGET === 'reel' || TARGET === 'full') { + const fdR = path.join(WORK, 'frames-reel'); + await capture(reelHTML, { w: 1080, h: 1920, scale: 2, sel: '#reel', framesDir: fdR }); + console.log(` ${N}/${N} reel frames `); + const reel = path.join(projectDir, `${outBase}-reel.mp4`); + spawnSync('ffmpeg', ['-y', '-framerate', String(FPS), '-i', path.join(fdR, 'f_%04d.png'), + '-vf', 'scale=1080:1920:flags=lanczos', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-crf', '18', '-movflags', '+faststart', reel], + { stdio: ['ignore', 'ignore', 'inherit'] }); + done.push(`${reel} (${sz(reel)} KB)`); + } + + if (!KEEP_WORK) rmrf(WORK); // wipe this render's scratch frames/harness + console.log('done:\n ' + done.join('\n ')); +})().catch((e) => { console.error(e); process.exit(1); }); diff --git a/skills/flows-diagram/scripts/paths.mjs b/skills/flows-diagram/scripts/paths.mjs new file mode 100644 index 0000000..f8061f4 --- /dev/null +++ b/skills/flows-diagram/scripts/paths.mjs @@ -0,0 +1,113 @@ +// Shared path / environment resolution for the flows-diagram pipeline. +// +// The skill ships as a self-contained folder, but the diagrams a user authors +// (scenes + rendered video) live in a workspace OUTSIDE the skill so an update +// of the skill never touches their work. +// +// Workspace resolution order: +// 1. FLOWS_DIR env var +// 2. "workspaceDir" in ~/amigoscode-skills/flows-diagram-config.json +// 3. ~/flows (legacy home of the original project) +// 4. ~/amigoscode-skills/flows (default, created on demand) + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const SKILL_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +export const ASSETS_DIR = path.join(SKILL_DIR, 'assets'); +export const CACHE_DIR = path.join(SKILL_DIR, '.cache'); +export const CONFIG_PATH = path.join(os.homedir(), 'amigoscode-skills', 'flows-diagram-config.json'); + +const expand = (p) => (p.startsWith('~') ? path.join(os.homedir(), p.slice(1)) : p); + +export function readConfig() { + const defaults = JSON.parse(fs.readFileSync(path.join(SKILL_DIR, 'config.default.json'), 'utf8')); + if (!fs.existsSync(CONFIG_PATH)) return defaults; + try { + return { ...defaults, ...JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')) }; + } catch { + return defaults; + } +} + +export function workspaceDir({ create = false } = {}) { + const cfg = readConfig(); + const candidates = [ + process.env.FLOWS_DIR, + cfg.workspaceDir, + path.join(os.homedir(), 'flows'), + ].filter(Boolean).map(expand); + + for (const c of candidates) if (fs.existsSync(c)) return c; + + const fallback = expand(candidates[0] || path.join(os.homedir(), 'amigoscode-skills', 'flows')); + if (create) fs.mkdirSync(fallback, { recursive: true }); + return fallback; +} + +// A project arg may be absolute, relative to the workspace, or relative to cwd. +export function resolveProject(arg) { + if (!arg) throw new Error('missing <projectDir>'); + const tries = [ + path.isAbsolute(arg) ? arg : null, + path.resolve(workspaceDir(), arg), + path.resolve(process.cwd(), arg), + ].filter(Boolean); + for (const t of tries) if (fs.existsSync(t)) return t; + return tries[0]; // caller creates it / fails with a clear ENOENT +} + +// Chrome for puppeteer-core. CHROME_PATH wins; otherwise probe the usual homes. +const CHROME_CANDIDATES = [ + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Chromium.app/Contents/MacOS/Chromium', + '/usr/bin/google-chrome', + '/usr/bin/google-chrome-stable', + '/usr/bin/chromium', + '/usr/bin/chromium-browser', + '/snap/bin/chromium', +]; + +export function chromePath() { + const env = process.env.CHROME_PATH || process.env.CHROME; + if (env) { + if (!fs.existsSync(env)) throw new Error(`CHROME_PATH does not exist: ${env}`); + return env; + } + for (const c of CHROME_CANDIDATES) if (fs.existsSync(c)) return c; + throw new Error( + 'Google Chrome not found. Install Chrome, or set CHROME_PATH=/path/to/chrome.' + ); +} + +// The ElevenLabs key: process env, then the skill's .env, then ~/.env. +export function elevenLabsKey() { + if (process.env.ELEVEN_LABS) return process.env.ELEVEN_LABS.trim(); + for (const f of [path.join(SKILL_DIR, '.env'), path.join(os.homedir(), '.env')]) { + if (!fs.existsSync(f)) continue; + const m = fs.readFileSync(f, 'utf8').match(/^\s*ELEVEN_LABS\s*=\s*(.+?)\s*$/m); + if (m) return m[1].replace(/^["']|["']$/g, ''); + } + throw new Error('ELEVEN_LABS key not found. Set it in the environment, skills/flows-diagram/.env, or ~/.env.'); +} + +// React + Babel are fetched once into .cache/ and reused by every render. +const VENDOR = { + 'react.js': 'https://unpkg.com/react@18.3.1/umd/react.production.min.js', + 'react-dom.js': 'https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js', + 'babel.js': 'https://unpkg.com/@babel/standalone@^7/babel.min.js', +}; + +export async function ensureCache() { + fs.mkdirSync(CACHE_DIR, { recursive: true }); + for (const [file, url] of Object.entries(VENDOR)) { + const dest = path.join(CACHE_DIR, file); + if (fs.existsSync(dest) && fs.statSync(dest).size > 1024) continue; + process.stdout.write(` fetching ${file} ...\n`); + const res = await fetch(url); + if (!res.ok) throw new Error(`failed to fetch ${url}: ${res.status}`); + fs.writeFileSync(dest, Buffer.from(await res.arrayBuffer())); + } +} diff --git a/skills/flows-diagram/scripts/vo-align.mjs b/skills/flows-diagram/scripts/vo-align.mjs new file mode 100644 index 0000000..37fe27c --- /dev/null +++ b/skills/flows-diagram/scripts/vo-align.mjs @@ -0,0 +1,27 @@ +// Word-level timestamps for a voiceover via ElevenLabs forced alignment. +// node vo-align.mjs <audio.mp3> "<exact transcript>" <out-words.json> +import fs from 'node:fs'; +import path from 'node:path'; +import { elevenLabsKey } from './paths.mjs'; + +const [audioPath, text, outJson] = process.argv.slice(2); +if (!audioPath || !text || !outJson) { + console.error('usage: node vo-align.mjs <audio.mp3> "<exact transcript>" <out-words.json>'); + process.exit(1); +} +let KEY; +try { KEY = elevenLabsKey(); } catch (e) { console.error(e.message); process.exit(1); } + +const fd = new FormData(); +fd.append('file', new Blob([fs.readFileSync(audioPath)], { type: 'audio/mpeg' }), path.basename(audioPath)); +fd.append('text', text); + +const res = await fetch('https://api.elevenlabs.io/v1/forced-alignment', { + method: 'POST', headers: { 'xi-api-key': KEY }, body: fd, +}); +if (!res.ok) { console.error(`forced-alignment ${res.status}: ${await res.text()}`); process.exit(1); } +const data = await res.json(); +const words = (data.words || []).filter((w) => w.text && w.text.trim()) + .map((w) => ({ text: w.text.trim(), start: w.start, end: w.end })); +fs.writeFileSync(outJson, JSON.stringify(words, null, 2)); +console.log(`wrote ${outJson}: ${words.length} words, ends ${words[words.length - 1]?.end?.toFixed(2)}s`);