diff --git a/README.md b/README.md index b0cfa0f..c261256 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,9 @@ There's no hosted click-to-try demo yet _(coming soon)_. But you can run the pla machine in about a minute โ€” see **[Try it in 60 seconds](#-try-it-in-60-seconds)** below. It's a little sandbox where you load a character, click its animations, and type to make it talk. -> ๐ŸŽฌ _An animated preview (a character actually moving and talking) is on the way โ€” it's generated from the -> live demo, which arrives in a later step._ +![Genie moving and talking in the MASH playground, rendered entirely in the browser](assets/gifs/genie-speaking.gif) + +_Genie, loaded from a `.acs` file and speaking in his authentic voice โ€” running entirely in your browser._ --- @@ -60,7 +61,9 @@ scratch, zero knowledge assumed. The original four โ€” **Genie, Merlin, Peedy, Robby** โ€” plus Office favorites like **Clippy** and **Rover**, and any other character anyone ever made. If it's a `.acs` file, vivify aims to run it. -> ๐Ÿ–ผ๏ธ _A picture gallery of the characters is coming once the demo-capture step lands._ +![Genie, rendered in the browser by vivify](assets/screenshots/genie-portrait.png) + +_The gallery grows as characters are captured โ€” here's Genie. Load any `.acs` to meet the rest._ --- diff --git a/assets/gifs/.gitkeep b/assets/gifs/.gitkeep new file mode 100644 index 0000000..a13b7c3 --- /dev/null +++ b/assets/gifs/.gitkeep @@ -0,0 +1 @@ +# Captured GIFs of the running app land here (operator-generated). See scripts/capture/. diff --git a/assets/screenshots/.gitkeep b/assets/screenshots/.gitkeep new file mode 100644 index 0000000..74792f9 --- /dev/null +++ b/assets/screenshots/.gitkeep @@ -0,0 +1 @@ +# Captured screenshots of the running app land here (operator-generated). See scripts/capture/. diff --git a/docs/README.md b/docs/README.md index 44ef65c..2aeec48 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,12 +7,14 @@ path here for you. New to all of this? You're in exactly the right place โ€” start with **[What is this?](what-is-this.md)** and we'll explain it from scratch. +![The MASH playground running in a browser โ€” a character on stage with its animation grid](../assets/screenshots/genie-app.png) + --- ## Pick your platform -Setting it up on your own computer? Start here โ€” each guide is step-by-step (screenshots are coming in a -later update). +Setting it up on your own computer? Start here โ€” each guide is step-by-step, with a screenshot of the +running app at the finish line. | ๐ŸชŸ Windows | ๐ŸŽ macOS | ๐Ÿง Linux | | --- | --- | --- | diff --git a/docs/characters.md b/docs/characters.md index 9ea9b1a..a765203 100644 --- a/docs/characters.md +++ b/docs/characters.md @@ -1,14 +1,24 @@ # Characters -> ๐Ÿšง **Coming soon.** This page lands in **Cycle 17**. It's a placeholder for now, so links -> pointing here already work โ€” no dead ends. +The cast of Microsoft Agent โ€” **Genie**, **Merlin**, **Peedy**, **Robby**, the infamous **Clippy**, and +any other character anyone ever made. If it's a `.acs` file, vivify aims to run it. -**What it'll cover:** a gallery of the classic characters, plus how to get your own `.acs` character files. +![Genie, rendered in the browser by vivify](../assets/screenshots/genie-portrait.png) -In the meantime: +_The gallery grows as characters are captured. Here's Genie โ€” load any `.acs` to meet the rest._ -- New to all of this? Start with **[What is this?](what-is-this.md)**. -- Want to try it right now? See the **[main README](../README.md)**. +## How to get your own `.acs` files + +vivify ships **no** character files โ€” they're Microsoft's, and you supply your own. The +**[Legal & assets](legal-and-assets.md)** page explains exactly where to find them and why we don't +bundle them. Once you have a `.acs`, drop it onto the playground (see any of the +[install guides](README.md)) and it runs. + +## Where to next + +- **New here?** Start with **[What is this?](what-is-this.md)** โ€” zero knowledge assumed. +- **Want to set it up?** Pick your platform from the **[documentation home](README.md)**. +- **Where do the files come from?** See **[Legal & assets](legal-and-assets.md)**. --- diff --git a/docs/cycles/cycle-18-screenshots.md b/docs/cycles/cycle-18-screenshots.md new file mode 100644 index 0000000..ff02848 --- /dev/null +++ b/docs/cycles/cycle-18-screenshots.md @@ -0,0 +1,100 @@ +# Cycle 18 โ€” screenshots + GIFs of the running app (Playwright capture) + +## Goal +Produce real screenshots and animated GIFs of vivify actually running โ€” a character loaded, the +animation grid, the speech balloon, and Genie talking with his mouth moving โ€” and wire them into the +docs pages that currently signpost "images coming." This is the **last planned docs cycle**. + +**The hard constraint:** capturing the live app needs the running stack, which only exists on the +operator's machine โ€” Docker for MASH, and (for the talking/lip-sync shots) the Wine + SAPI4 + TruVoice +voice container plus the operator's user-supplied `.acs` and voice files. The CI sandbox has none of +that and cannot run Wine or the authentic voice. So this cycle ships **the capture tooling + the docs +wiring**; the operator runs the script locally and commits the produced image files. + +**Docs + capture-script only (tooling, not app code); CI stays green.** + +## What CC builds (this PR) +1. **A Playwright capture script** โ€” `scripts/capture/capture-docs.ts` (+ `gif.ts`, `README.md`) that + drives MASH at a configurable URL (default `http://localhost:8090`), uploads a `.acs` the operator + supplies, plays an animation, types into the balloon, triggers Speak, and saves stills (PNG) and + short GIFs (PNG-frame loop โ†’ `gifenc`). Parameterized + documented for a clean first local run. +2. **Docs wiring** โ€” replace the "coming soon / images coming" signposts with real `![alt](path)` + references at the output paths, with good alt text, so the pages render the images once the files + land. +3. **ADR-0028** โ€” records the load-bearing asset decision (screenshots/GIFs of the running app are + permitted documentation, distinct from committing source `.acs`/binaries; a deliberate, scoped + carve-out from [ADR-0006](../decisions/0006-permissive-license-no-bundled-ip.md)). + +## What the operator runs (and commits) +1. Bring the stack up: `docker compose up` (MASH on `:8090`; the voice container on `:8080` is needed + only for the authentic talking/lip-sync shots โ€” Tier 2 from the install pages). +2. Install the browser once: `pnpm capture:setup` (runs `playwright install chromium`). +3. Capture: `pnpm capture -- --acs /path/to/Genie.acs` (defaults: `--url http://localhost:8090`, + `--name genie`, output to `assets/`). The script writes the five files below. +4. `git add assets/screenshots assets/gifs && git commit` โ€” the operator commits the actual images to + this branch before merge. + +The `.acs` is supplied via `--acs` and **never committed** (`*.acs` is gitignored). The script bakes in +**no** download of any proprietary asset. + +## Assets produced (default `--name genie`) +| Path | What it shows | Wired into | +| --- | --- | --- | +| `assets/screenshots/genie-app.png` | The whole MASH window: character on stage + animation grid + controls | install ร—3 ("you should see"), docs landing, dev overview, README (60-seconds) | +| `assets/screenshots/genie-portrait.png` | Just the character (the `#stage` element) | characters gallery, README cast | +| `assets/screenshots/genie-speaking.png` | A still mid-speech (balloon up, mouth moving) | โ€” (companion; reserved) | +| `assets/gifs/genie-animation.gif` | A representative animation playing | โ€” (reserved for future) | +| `assets/gifs/genie-speaking.gif` | Genie talking with his mouth moving | README hero ("See it move"), what-is-this, dev quickstart | + +Doc paths assume the default `genie` prefix. Capture a different character with `--name ` and the +files become `-โ€ฆ`; update the doc paths or stick with `genie` for the wired pages. The gallery grows +by running the script per character. + +## Honest boundary (flagged, not hidden) +- CC **cannot** run Wine / the authentic voice, and likely cannot launch a real browser in the sandbox, + so CC does **not** produce the real images. The PR lands scripts + wiring; the operator generates and + commits the images. +- Until the operator commits the files, the new `![โ€ฆ]` references **404 on GitHub** โ€” expected. Merge + only after the images are committed to this branch. +- Playwright can only photograph the **browser** (the running app). The install pages' Docker/terminal + steps stay as written text; their image is the end-result app screenshot, so the old "screenshots for + every step" promise is corrected to that honest scope. +- The talking/lip-sync shots (`*-speaking.*`) need the voice container up. The animation GIF and the + app/portrait stills need only MASH. If Speak can't reach the voice server, the script logs a warning + and still saves what rendered. + +## IP / asset hygiene +- Never commit `.acs` or voice binaries (enforced by `.gitignore`: `*.acs`, `*.wav`). The script reads + the `.acs` from an operator-supplied path and never writes it anywhere. +- Captured PNG/GIF of the **running app** (rendered character pixels in the demo) are documentation of + the app โ€” permitted by ADR-0028. The captured images must not embed or expose the `.acs` source โ€” + they show only the rendered character, which is the intended documentation. +- No proprietary fetch/download is baked into the scripts. + +## Tooling notes +- New **root** devDependencies: `playwright`, `tsx`, `pngjs` + `@types/pngjs`, `gifenc`. Root scripts: + `capture` (`tsx scripts/capture/capture-docs.ts`) and `capture:setup` (`playwright install chromium`). +- `scripts/` is **not** a pnpm workspace (mirrors the existing `packages/acs/scripts/spike-dump.ts` + tsx pattern); it resolves deps from root `node_modules` and is **not** in the CI `typecheck` graph. It + is linted by `eslint .` and formatted by Prettier, so it must pass both. +- GIFs are built from a loop of element screenshots โ†’ `pngjs` decode โ†’ optional nearest-neighbor + downscale (default max width 480px) โ†’ `gifenc` 256-color quantize. No system `ffmpeg` dependency. + +## Acceptance check +- `scripts/capture/capture-docs.ts` exists, is parameterized (`--url/--acs/--name/--out/--speak/ + --animation/--smoke`), documented in `scripts/capture/README.md`, and uses the real MASH selectors + (`#file`, `#stage`, `#animations .anim`, `#speak`, `#speakBtn`, `#voiceUrl`, `#status`). +- Every "images coming" signpost listed above is replaced by a real `![alt](assets/โ€ฆ)` reference with + descriptive alt text, at the correct relative depth. +- ADR-0028 is written and linked from the cycle doc. +- `pnpm -r typecheck && pnpm -r test && pnpm lint && pnpm format` is green with the new deps and script. + +## Verification +- **CI (CC):** the four checks above pass. Best-effort: `--smoke` mode against a dev MASH to validate + selectors/plumbing without a `.acs` โ€” reported honestly if the sandbox can't launch a browser. +- **Operator:** run the four steps above; confirm the five files land in `assets/`, open the docs on + GitHub and see them render. Tell CC if a shot needs a specific character/animation loaded. + +## Non-goals +Nothing after this (last planned docs cycle). No app/`@vivify/core` code change. No committing `.acs` +or binaries. No hosted demo. No merge โ€” open a PR (base `main`) and stop. diff --git a/docs/decisions/0028-screenshots-of-running-app.md b/docs/decisions/0028-screenshots-of-running-app.md new file mode 100644 index 0000000..598187a --- /dev/null +++ b/docs/decisions/0028-screenshots-of-running-app.md @@ -0,0 +1,39 @@ +# ADR-0028: Screenshots and GIFs of the running app are committable documentation + +Status: Accepted ยท Date: 2026-06-21 + +## Context + +[ADR-0006](0006-permissive-license-no-bundled-ip.md) forbids committing third-party IP: no `.acs` +files, no SAPI4/TruVoice binaries, no extracted Microsoft assets. The docs (README, install pages, +what-is-this, the developer pages, the characters page) signpost screenshots and GIFs of vivify +running โ€” and a captured screenshot of the running demo contains **rendered character pixels** (e.g. +Genie), which originate from Microsoft IP. That sits in tension with ADR-0006, so the boundary needs to +be stated, not assumed. + +## Decision + +Permit committing **screenshots and GIFs of the running application** โ€” the rendered character in the +MASH demo, its animation grid, balloon, and lip-sync โ€” as documentation of what the software does. +These live under `assets/screenshots/` and `assets/gifs/`. + +This is distinct from, and does not loosen, ADR-0006's hard rules: + +- The **source `.acs` files and engine binaries are still never committed.** The capture script + (`scripts/capture/`) reads the operator's `.acs` by path only, never writes or bundles it, and bakes + in no download of any proprietary asset. +- A captured image must show only the **rendered character**, never expose or embed the `.acs` source. +- Images are produced by the operator from their own legally-sourced character file and committed by + them; CI never has the assets and never generates them. + +## Consequences + +- The docs can show the real product. A screenshot/GIF of a running app is ordinary product + documentation (comparable to any project documenting its UI), and is treated as such here. +- This is a deliberate, scoped carve-out โ€” a documentation image of rendered pixels, not redistribution + of the character file or engine. If a specific character's owner objects, the relevant image can be + swapped for a different (operator-supplied) character without code changes. +- `.gitignore` continues to enforce `*.acs` and `*.wav`; PNG/GIF under `assets/` are intentionally not + ignored so the operator can commit them. +- Because the assets are operator-generated, the docs' image references 404 until those files are + committed โ€” an accepted, temporary state, not a broken build. diff --git a/docs/developers/overview.md b/docs/developers/overview.md index 162c624..0cebdd4 100644 --- a/docs/developers/overview.md +++ b/docs/developers/overview.md @@ -145,8 +145,9 @@ vivify is built in **small, reviewable cycles**. The working model, in brief (th - **Conventional commits, one PR per cycle.** CI (typecheck + test + lint) must be green **and** the diff reviewed before merge โ€” never force-merged on green alone. -> ๐Ÿšง **Screenshots and GIFs are coming in the next cycle.** This page is words for now; a character -> actually moving and talking is worth more than a paragraph, and that's on the way. +![The MASH demo running โ€” a character on stage beside its animation grid, built only on @vivify/core's public API](../../assets/screenshots/genie-app.png) + +_The MASH demo (`apps/mash`) running on the public API โ€” both the showcase and the worked example._ --- diff --git a/docs/developers/quickstart.md b/docs/developers/quickstart.md index 2e01c25..6bbd68e 100644 --- a/docs/developers/quickstart.md +++ b/docs/developers/quickstart.md @@ -55,6 +55,8 @@ agent.speak('Hello! I am alive in your browser.'); That's it โ€” he's alive, and talking. +![A character loaded in the browser and speaking, driven by @vivify/core](../../assets/gifs/genie-speaking.gif) + ### Why pass a provider at all? `createAgent`'s **default** provider is the silent `StubTtsProvider`: the character animates, but you diff --git a/docs/install/linux.md b/docs/install/linux.md index 7448ef7..07ba7b6 100644 --- a/docs/install/linux.md +++ b/docs/install/linux.md @@ -13,7 +13,7 @@ There are **two tiers**, and you can do just the first: > New to all of this? The 60-second overview is **[What is this?](../what-is-this.md)**. Stuck on a word? > The **[Glossary](../glossary.md)** explains every term in plain English. -> ๐Ÿ“ธ _Screenshots for every step are coming in a later update โ€” for now the steps are written out in full._ +> ๐Ÿ“ธ _Curious what success looks like? There's a screenshot of the running app at the end of Step 4._ --- @@ -81,13 +81,15 @@ starts fast.) When you see it settle and keep running, it's ready. Leave this te 1. Open your web browser to **http://localhost:8090**. 2. You'll need a character file (a `.acs` file). vivify ships none โ€” see **[where to get - one](../legal-and-assets.md)** (and the **[Characters](../characters.md)** page, _coming soon_). Drag + one](../legal-and-assets.md)** (and the **[Characters](../characters.md)** page). Drag the `.acs` file onto the page. 3. Click any animation in the list to play it. Type a sentence and click **Speak**. **That's it โ€” it's alive!** ๐ŸŽ‰ The character moves, shows its speech balloon, and talks using your browser's voice. +![The MASH playground running at localhost:8090 โ€” a character on stage with its animation grid](../../assets/screenshots/genie-app.png) + > To stop it: go back to the terminal and press **Ctrl + C**. --- diff --git a/docs/install/mac.md b/docs/install/mac.md index 7c9eadd..d819a6a 100644 --- a/docs/install/mac.md +++ b/docs/install/mac.md @@ -13,7 +13,7 @@ There are **two tiers**, and you can do just the first: > New to all of this? The 60-second overview is **[What is this?](../what-is-this.md)**. Stuck on a word? > The **[Glossary](../glossary.md)** explains every term in plain English. -> ๐Ÿ“ธ _Screenshots for every step are coming in a later update โ€” for now the steps are written out in full._ +> ๐Ÿ“ธ _Curious what success looks like? There's a screenshot of the running app at the end of Step 4._ --- @@ -74,13 +74,15 @@ starts fast.) When you see it settle and keep running, it's ready. Leave this wi 1. Open your web browser to **http://localhost:8090**. 2. You'll need a character file (a `.acs` file). vivify ships none โ€” see **[where to get - one](../legal-and-assets.md)** (and the **[Characters](../characters.md)** page, _coming soon_). Drag + one](../legal-and-assets.md)** (and the **[Characters](../characters.md)** page). Drag the `.acs` file onto the page. 3. Click any animation in the list to play it. Type a sentence and click **Speak**. **That's it โ€” it's alive!** ๐ŸŽ‰ The character moves, shows its speech balloon, and talks using your browser's voice. +![The MASH playground running at localhost:8090 โ€” a character on stage with its animation grid](../../assets/screenshots/genie-app.png) + > To stop it: go back to the Terminal and press **Control + C**. --- diff --git a/docs/install/windows.md b/docs/install/windows.md index 64fcad9..fa20b4f 100644 --- a/docs/install/windows.md +++ b/docs/install/windows.md @@ -13,7 +13,7 @@ There are **two tiers**, and you can do just the first: > New to all of this? The 60-second overview is **[What is this?](../what-is-this.md)**. Stuck on a word? > The **[Glossary](../glossary.md)** explains every term in plain English. -> ๐Ÿ“ธ _Screenshots for every step are coming in a later update โ€” for now the steps are written out in full._ +> ๐Ÿ“ธ _Curious what success looks like? There's a screenshot of the running app at the end of Step 4._ --- @@ -72,13 +72,15 @@ starts fast.) When you see it settle and keep running, it's ready. Leave this wi 1. Open your web browser to **http://localhost:8090**. 2. You'll need a character file (a `.acs` file). vivify ships none โ€” see **[where to get - one](../legal-and-assets.md)** (and the **[Characters](../characters.md)** page, _coming soon_). Drag + one](../legal-and-assets.md)** (and the **[Characters](../characters.md)** page). Drag the `.acs` file onto the page. 3. Click any animation in the list to play it. Type a sentence and click **Speak**. **That's it โ€” it's alive!** ๐ŸŽ‰ The character moves, shows its speech balloon, and talks using your browser's voice. +![The MASH playground running at localhost:8090 โ€” a character on stage with its animation grid](../../assets/screenshots/genie-app.png) + > To stop it: go back to the terminal and press **Ctrl + C**. --- diff --git a/docs/what-is-this.md b/docs/what-is-this.md index f7cd9de..dcc36dd 100644 --- a/docs/what-is-this.md +++ b/docs/what-is-this.md @@ -12,6 +12,8 @@ Let's start from zero. No background needed. show a little speech bubble with their words. vivify recreates them faithfully: the real pictures, the real animations, even their original voices. +![Genie moving and talking in vivify, running in a web browser](../assets/gifs/genie-speaking.gif) + That's it. The rest of this page fills in the story, in case the words "Microsoft Agent" don't ring a bell โ€” and for a lot of people, they won't. diff --git a/package.json b/package.json index b155c13..e0c21ed 100644 --- a/package.json +++ b/package.json @@ -12,13 +12,20 @@ "test": "pnpm -r test", "lint": "eslint .", "format": "prettier --check .", - "format:write": "prettier --write ." + "format:write": "prettier --write .", + "capture": "tsx scripts/capture/capture-docs.ts", + "capture:setup": "playwright install chromium" }, "devDependencies": { "@eslint/js": "^9.17.0", "@types/node": "^22.10.2", + "@types/pngjs": "^6.0.5", "eslint": "^9.17.0", + "gifenc": "^1.0.3", + "playwright": "^1.49.1", + "pngjs": "^7.0.0", "prettier": "^3.4.2", + "tsx": "^4.19.2", "typescript": "^5.7.2", "typescript-eslint": "^8.18.1", "vitest": "^2.1.8" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2833b88..bf771af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,12 +14,27 @@ importers: '@types/node': specifier: ^22.10.2 version: 22.20.0 + '@types/pngjs': + specifier: ^6.0.5 + version: 6.0.5 eslint: specifier: ^9.17.0 version: 9.39.4 + gifenc: + specifier: ^1.0.3 + version: 1.0.3 + playwright: + specifier: ^1.49.1 + version: 1.61.0 + pngjs: + specifier: ^7.0.0 + version: 7.0.0 prettier: specifier: ^3.4.2 version: 3.8.4 + tsx: + specifier: ^4.19.2 + version: 4.22.4 typescript: specifier: ^5.7.2 version: 5.9.3 @@ -1031,11 +1046,19 @@ packages: flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + gifenc@1.0.3: + resolution: {integrity: sha512-xdr6AdrfGBcfzncONUOlXMBuc5wJDtOueE3c5rdG0oNgtINLD+f2iFZltrBRZYzACRbKr+mSVU/x98zv2u3jmw==} + glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} @@ -1164,6 +1187,16 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + playwright-core@1.61.0: + resolution: {integrity: sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.0: + resolution: {integrity: sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==} + engines: {node: '>=18'} + hasBin: true + pngjs@7.0.0: resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} engines: {node: '>=14.19.0'} @@ -2177,9 +2210,14 @@ snapshots: flatted@3.4.2: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true + gifenc@1.0.3: {} + glob-parent@6.0.2: dependencies: is-glob: 4.0.3 @@ -2285,6 +2323,14 @@ snapshots: picomatch@4.0.4: {} + playwright-core@1.61.0: {} + + playwright@1.61.0: + dependencies: + playwright-core: 1.61.0 + optionalDependencies: + fsevents: 2.3.2 + pngjs@7.0.0: {} postcss@8.5.15: diff --git a/scripts/capture/README.md b/scripts/capture/README.md new file mode 100644 index 0000000..6221db5 --- /dev/null +++ b/scripts/capture/README.md @@ -0,0 +1,74 @@ +# Docs capture (Playwright) + +Generates the screenshots and GIFs of vivify running that the docs reference. It drives the **live** +MASH demo, so you run it locally against your own stack โ€” it can't run in CI (no Docker/Wine/voice +there). + +## What you need + +- The app running: **`docker compose up`** from the repo root. + - MASH serves at **http://localhost:8090** โ€” enough for the app/portrait stills and the animation GIF. + - The voice container at **http://localhost:8080** is needed only for the **talking / lip-sync** + shots (`*-speaking.*`). That's Tier 2 โ€” see the [install pages](../../docs/install/windows.md). +- A character file: **your own `.acs`** (e.g. `Genie.acs`). vivify ships none; see + [Legal & assets](../../docs/legal-and-assets.md). It is passed by path and **never committed** + (`*.acs` is gitignored). +- The browser, installed once: **`pnpm capture:setup`** (runs `playwright install chromium`). + +## Run it + +```bash +# from the repo root, with the stack up: +pnpm capture:setup # once +pnpm capture -- --acs /path/to/Genie.acs # capture with defaults +``` + +Note the `--` (everything after it is passed to the script, not to pnpm). + +### Options + +| Flag | Default | Meaning | +| --- | --- | --- | +| `--acs ` | _required_ | The `.acs` character to load (unless `--smoke`). | +| `--url ` | `http://localhost:8090` | The MASH URL. Use `http://localhost:5173` against `pnpm --filter mash dev`. | +| `--name ` | `genie` | Output filename prefix. | +| `--out ` | `/assets` | Output root (`screenshots/` and `gifs/` are created under it). | +| `--speak ` | a friendly hello | Text typed into the balloon. | +| `--animation ` | auto-pick | Which animation to play for the GIF. | +| `--no-speak` | off | Skip the talking shots (no voice container needed). | +| `--smoke` | off | Just verify the page + selectors load; no `.acs`, no images. | +| `--headed` | off | Show the browser window (default headless). | + +## What it writes (default `--name genie`) + +``` +assets/screenshots/genie-app.png the whole MASH window โ€” character + animation grid +assets/screenshots/genie-portrait.png just the character (the #stage element) +assets/screenshots/genie-speaking.png a still mid-speech (balloon up, mouth moving) +assets/gifs/genie-animation.gif a representative animation playing +assets/gifs/genie-speaking.gif Genie talking with his mouth moving +``` + +The docs reference the `genie-โ€ฆ` paths. If you capture a different character with `--name`, either +update the doc image paths or keep `genie` for the wired pages. Run once per character to grow the +gallery. + +## Then commit the images + +```bash +git add assets/screenshots assets/gifs +git commit -m "docs(assets): capture screenshots + GIFs of the running app" +``` + +The docs' `![โ€ฆ]` references resolve once these files are committed. (Before that, they 404 โ€” expected.) + +## Notes & troubleshooting + +- **Mouth not moving in `*-speaking.*`?** The voice container probably isn't up. Bring up the full + `docker compose up` (not just `mash`), or pass `--no-speak` to skip those shots. +- **GIFs** are built from a loop of element screenshots โ†’ `pngjs` โ†’ `gifenc` (256-colour, downscaled to + 480px wide by default). No `ffmpeg` needed. They show motion but aren't frame-accurate โ€” fine for docs. +- **Smoke test without a character:** `pnpm capture -- --smoke --url http://localhost:5173` confirms the + selectors against a dev server. +- The script reads the `.acs` only to upload it to the page; it never writes the `.acs` anywhere, and + bakes in no download of any proprietary asset. diff --git a/scripts/capture/capture-docs.ts b/scripts/capture/capture-docs.ts new file mode 100644 index 0000000..6e918c2 --- /dev/null +++ b/scripts/capture/capture-docs.ts @@ -0,0 +1,271 @@ +// Docs-capture script โ€” drives the running MASH demo with Playwright and saves +// screenshots + GIFs of vivify actually running, for the documentation pages. +// +// This needs the LIVE app, which only runs on the operator's machine: +// โ€ข MASH (Docker) โ†’ http://localhost:8090 (all shots) +// โ€ข the voice container โ†’ http://localhost:8080 (only the *-speaking.* shots) +// โ€ข an operator-supplied .acs โ†’ passed via --acs (never committed) +// +// Run: pnpm capture:setup (once โ€” installs the Chromium browser) +// pnpm capture -- --acs /path/to/Genie.acs +// +// See scripts/capture/README.md for the full guide and every flag. + +import { mkdirSync, existsSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { chromium, type Locator, type Page } from 'playwright'; +import { encodeGif } from './gif.js'; + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); + +interface Options { + url: string; + acs: string | null; + name: string; + out: string; + speak: string; + animation: string | null; + noSpeak: boolean; + smoke: boolean; + headed: boolean; +} + +function parseArgs(argv: string[]): Options { + const opts: Options = { + url: 'http://localhost:8090', + acs: null, + name: 'genie', + out: join(REPO_ROOT, 'assets'), + speak: 'Hello! I am alive in your browser.', + animation: null, + noSpeak: false, + smoke: false, + headed: false, + }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + const next = (): string => { + const v = argv[++i]; + if (v === undefined) throw new Error(`missing value for ${arg}`); + return v; + }; + switch (arg) { + case '--': + break; // pnpm forwards a literal `--` separator; ignore it. + case '--url': + opts.url = next(); + break; + case '--acs': + opts.acs = resolve(next()); + break; + case '--name': + opts.name = next(); + break; + case '--out': + opts.out = resolve(next()); + break; + case '--speak': + opts.speak = next(); + break; + case '--animation': + opts.animation = next(); + break; + case '--no-speak': + opts.noSpeak = true; + break; + case '--smoke': + opts.smoke = true; + break; + case '--headed': + opts.headed = true; + break; + case '--help': + case '-h': + printUsage(); + process.exit(0); + break; + default: + throw new Error(`unknown argument: ${arg}`); + } + } + return opts; +} + +function printUsage(): void { + console.log( + [ + 'Usage: pnpm capture -- --acs [options]', + '', + ' --acs REQUIRED (unless --smoke): the .acs character to load. Never committed.', + ' --url MASH URL (default http://localhost:8090).', + ' --name Output filename prefix (default "genie").', + ' --out Output root (default /assets).', + ' --speak Text typed into the balloon (default a friendly hello).', + ' --animation Animation to play for the GIF (default: auto-pick).', + ' --no-speak Skip the talking shots (no voice container needed).', + ' --smoke Verify the page + selectors only; no .acs, no images.', + ' --headed Run the browser visibly (default headless).', + ].join('\n'), + ); +} + +const log = (msg: string): void => console.log(`[capture] ${msg}`); +const warn = (msg: string): void => console.warn(`[capture] โš  ${msg}`); + +function ensureDirs(out: string): { shots: string; gifs: string } { + const shots = join(out, 'screenshots'); + const gifs = join(out, 'gifs'); + mkdirSync(shots, { recursive: true }); + mkdirSync(gifs, { recursive: true }); + return { shots, gifs }; +} + +// Capture a sequence of element screenshots over ~durationMs. Element screenshots +// aren't frame-accurate (each call has overhead), but the result clearly shows +// motion, which is all the docs need. +async function captureFrames(target: Locator, frames: number, gapMs: number): Promise { + const buffers: Buffer[] = []; + for (let i = 0; i < frames; i++) { + buffers.push(await target.screenshot()); + if (i < frames - 1) await target.page().waitForTimeout(gapMs); + } + return buffers; +} + +async function loadCharacter(page: Page, opts: Options): Promise { + log(`opening ${opts.url}`); + await page.goto(opts.url, { waitUntil: 'domcontentloaded' }); + await page.locator('#file').waitFor({ state: 'attached', timeout: 15_000 }); + + if (opts.smoke) return; + + log(`uploading ${opts.acs}`); + await page.locator('#file').setInputFiles(opts.acs as string); + // The app sets #status to "Loaded โ€” N animations โ€ฆ" on success. + await page + .locator('#status') + .filter({ hasText: /Loaded/i }) + .waitFor({ timeout: 30_000 }); + await page.locator('#animations .anim').first().waitFor({ timeout: 30_000 }); + await page.waitForTimeout(800); // let the first idle frame settle + const count = await page.locator('#animations .anim').count(); + log(`character loaded โ€” ${count} animations`); +} + +async function smokeCheck(page: Page): Promise { + const required = [ + '#file', + '#stage', + '#animations', + '#speak', + '#speakBtn', + '#voiceUrl', + '#status', + ]; + for (const sel of required) { + const ok = (await page.locator(sel).count()) > 0; + if (!ok) throw new Error(`smoke: selector ${sel} not found โ€” is this the MASH app?`); + log(`โœ“ ${sel}`); + } + log('smoke OK โ€” page and selectors present'); +} + +async function capturePng(target: Locator, file: string): Promise { + await target.screenshot({ path: file }); + log(`wrote ${file}`); +} + +async function pickAnimation(page: Page, requested: string | null): Promise { + const buttons = page.locator('#animations .anim'); + if (await buttons.count()) { + if (requested) { + const byName = buttons.filter({ hasText: requested }); + if (await byName.count()) return byName.first(); + warn(`animation "${requested}" not found โ€” using the first one`); + } + return buttons.first(); + } + return null; +} + +async function main(): Promise { + const opts = parseArgs(process.argv.slice(2)); + if (!opts.smoke && !opts.acs) { + warn('no --acs given. Pass a .acs path, or use --smoke to check selectors only.'); + printUsage(); + process.exit(1); + } + if (opts.acs && !existsSync(opts.acs)) throw new Error(`--acs file not found: ${opts.acs}`); + + const browser = await chromium.launch({ headless: !opts.headed }); + const page = await browser.newPage({ viewport: { width: 920, height: 860 } }); + try { + await loadCharacter(page, opts); + + if (opts.smoke) { + await smokeCheck(page); + return; + } + + const { shots, gifs } = ensureDirs(opts.out); + const window = page.locator('.window'); + const stage = page.locator('#stage'); + + // Stills: the whole app, and the character alone. + await capturePng(window, join(shots, `${opts.name}-app.png`)); + await capturePng(stage, join(shots, `${opts.name}-portrait.png`)); + + // GIF: a representative animation playing. + const anim = await pickAnimation(page, opts.animation); + if (anim) { + const animName = (await anim.textContent())?.trim() ?? 'animation'; + log(`playing "${animName}" for the animation GIF`); + await anim.click(); + const frames = await captureFrames(stage, 30, 90); + encodeGif(frames, join(gifs, `${opts.name}-animation.gif`), { delayMs: 90, maxWidth: 480 }); + log(`wrote ${join(gifs, `${opts.name}-animation.gif`)}`); + } else { + warn('no animations to capture โ€” skipping the animation GIF'); + } + + // Talking + lip-sync: needs the voice container at #voiceUrl (pre-filled to :8080). + if (opts.noSpeak) { + log('--no-speak set โ€” skipping the talking shots'); + } else { + await page + .locator('#stopBtn') + .click() + .catch(() => undefined); + await page.locator('#speak').fill(opts.speak); + log('clicking Speak โ€” needs the voice container for authentic audio + lip-sync'); + await page.locator('#speakBtn').click(); + await page.waitForTimeout(500); // let synthesis start and the balloon open + const status = (await page.locator('#status').textContent())?.trim() ?? ''; + if (/couldn't reach|failed/i.test(status)) { + warn( + `speech did not start: "${status}". The mouth may not move. Is the voice container up?`, + ); + } + const frames = await captureFrames(stage, 36, 80); + encodeGif(frames, join(gifs, `${opts.name}-speaking.gif`), { delayMs: 80, maxWidth: 480 }); + log(`wrote ${join(gifs, `${opts.name}-speaking.gif`)}`); + // A still from the middle of the utterance (balloon up, mouth mid-move). + const mid = frames[Math.floor(frames.length / 2)]; + if (mid) { + const file = join(shots, `${opts.name}-speaking.png`); + writeFileSync(file, mid); + log(`wrote ${file}`); + } + } + + log('done โ€” review the files, then `git add assets/ && git commit`.'); + } finally { + await browser.close(); + } +} + +main().catch((err: unknown) => { + console.error(`[capture] failed: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); +}); diff --git a/scripts/capture/gif.ts b/scripts/capture/gif.ts new file mode 100644 index 0000000..77b77df --- /dev/null +++ b/scripts/capture/gif.ts @@ -0,0 +1,66 @@ +// GIF encoding for the docs-capture script. Takes a sequence of PNG buffers +// (element screenshots from Playwright), optionally downscales them, and writes +// an animated GIF โ€” pure JS, no system ffmpeg. pngjs decodes each PNG to RGBA; +// gifenc quantizes to a 256-colour palette and encodes the frames. + +import { writeFileSync } from 'node:fs'; +import { PNG } from 'pngjs'; +// gifenc ships a CommonJS build with no `exports` map, so Node's ESM loader can't +// reliably see its named exports โ€” default-import the module and destructure. +import gifenc from 'gifenc'; + +const { GIFEncoder, quantize, applyPalette } = gifenc; + +interface RgbaFrame { + width: number; + height: number; + data: Uint8Array; // RGBA, length = width * height * 4 +} + +function decodePng(buffer: Buffer): RgbaFrame { + const png = PNG.sync.read(buffer); + return { width: png.width, height: png.height, data: Uint8Array.from(png.data) }; +} + +// Nearest-neighbour downscale so GIFs stay small. No-op if already within maxWidth. +function downscale(frame: RgbaFrame, maxWidth: number): RgbaFrame { + if (frame.width <= maxWidth) return frame; + const scale = maxWidth / frame.width; + const width = Math.max(1, Math.round(frame.width * scale)); + const height = Math.max(1, Math.round(frame.height * scale)); + const data = new Uint8Array(width * height * 4); + for (let y = 0; y < height; y++) { + const sy = Math.min(frame.height - 1, Math.floor(y / scale)); + for (let x = 0; x < width; x++) { + const sx = Math.min(frame.width - 1, Math.floor(x / scale)); + const si = (sy * frame.width + sx) * 4; + const di = (y * width + x) * 4; + data[di] = frame.data[si] ?? 0; + data[di + 1] = frame.data[si + 1] ?? 0; + data[di + 2] = frame.data[si + 2] ?? 0; + data[di + 3] = frame.data[si + 3] ?? 255; + } + } + return { width, height, data }; +} + +export interface GifOptions { + /** Delay between frames, in ms. */ + delayMs: number; + /** Downscale frames wider than this (px). */ + maxWidth: number; +} + +/** Encode PNG frame buffers into an animated GIF at `outPath`. */ +export function encodeGif(pngFrames: Buffer[], outPath: string, opts: GifOptions): void { + if (pngFrames.length === 0) throw new Error('encodeGif: no frames captured'); + const encoder = GIFEncoder(); + for (const buffer of pngFrames) { + const frame = downscale(decodePng(buffer), opts.maxWidth); + const palette = quantize(frame.data, 256); + const indexed = applyPalette(frame.data, palette); + encoder.writeFrame(indexed, frame.width, frame.height, { palette, delay: opts.delayMs }); + } + encoder.finish(); + writeFileSync(outPath, encoder.bytes()); +}