diff --git a/docs/brand/posecode-logo-dark.png b/docs/brand/posecode-logo-dark.png new file mode 100644 index 0000000..479ff08 Binary files /dev/null and b/docs/brand/posecode-logo-dark.png differ diff --git a/docs/brand/posecode-logo.png b/docs/brand/posecode-logo.png new file mode 100644 index 0000000..a9a6ae0 Binary files /dev/null and b/docs/brand/posecode-logo.png differ diff --git a/docs/launch-media/dead-bug.gif b/docs/launch-media/dead-bug.gif new file mode 100644 index 0000000..b659792 Binary files /dev/null and b/docs/launch-media/dead-bug.gif differ diff --git a/docs/launch-media/jab-cross.gif b/docs/launch-media/jab-cross.gif new file mode 100644 index 0000000..d5df390 Binary files /dev/null and b/docs/launch-media/jab-cross.gif differ diff --git a/docs/launch-media/jumping-jacks.gif b/docs/launch-media/jumping-jacks.gif new file mode 100644 index 0000000..75dac44 Binary files /dev/null and b/docs/launch-media/jumping-jacks.gif differ diff --git a/docs/launch-media/play-desktop.png b/docs/launch-media/play-desktop.png new file mode 100644 index 0000000..ac0bee9 Binary files /dev/null and b/docs/launch-media/play-desktop.png differ diff --git a/docs/launch-media/play-mobile.png b/docs/launch-media/play-mobile.png new file mode 100644 index 0000000..ef3b914 Binary files /dev/null and b/docs/launch-media/play-mobile.png differ diff --git a/docs/launch-media/play-transport.png b/docs/launch-media/play-transport.png new file mode 100644 index 0000000..3a8a60b Binary files /dev/null and b/docs/launch-media/play-transport.png differ diff --git a/docs/launch-media/wall-sit.gif b/docs/launch-media/wall-sit.gif new file mode 100644 index 0000000..f9a8325 Binary files /dev/null and b/docs/launch-media/wall-sit.gif differ diff --git a/scripts/capture-gifs.mjs b/scripts/capture-gifs.mjs index d917fb0..3c5fb28 100644 --- a/scripts/capture-gifs.mjs +++ b/scripts/capture-gifs.mjs @@ -2,29 +2,17 @@ /** * Regenerates the README movement GIFs from the real renderer. * - * Boots the playground with Vite, drives the live viewer headlessly with - * Playwright (seek → captureFrame per animation frame), and encodes looping - * GIFs with gifenc. Frames are composed to the target size inside the page - * (cover-crop of the viewer canvas), so no image tooling is needed in node. - * - * Not wired into `npm run build`: run it manually when the figure or a - * showcased movement changes, and commit the output. + * Thin wrapper over scripts/lib/gif-capture.mjs (the shared capture core, also + * used by capture-launch-gifs.mjs). Not wired into `npm run build`: run it + * manually when the figure or a showcased movement changes, and commit output. * * Usage: * node scripts/capture-gifs.mjs # all README gifs * node scripts/capture-gifs.mjs squat # just one - * - * The Chromium binary is resolved from PLAYWRIGHT_BROWSERS_PATH/chromium or - * POSECODE_CHROMIUM. */ -import { createServer } from "vite"; -import { chromium } from "playwright-core"; -import gifencPkg from "gifenc"; // CJS: no named ESM exports -const { GIFEncoder, quantize, applyPalette } = gifencPkg; -import { writeFile } from "node:fs/promises"; -import { existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; +import { captureGifs } from "./lib/gif-capture.mjs"; const here = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(here, ".."); @@ -37,13 +25,6 @@ const TARGETS = [ { id: "lateral", out: "lateral-raise", size: [420, 582], fps: 14 }, ]; -function chromiumPath() { - if (process.env.POSECODE_CHROMIUM) return process.env.POSECODE_CHROMIUM; - const base = process.env.PLAYWRIGHT_BROWSERS_PATH; - if (base && existsSync(`${base}/chromium`)) return `${base}/chromium`; - return chromium.executablePath(); -} - const only = process.argv[2]; const targets = TARGETS.filter((t) => !only || t.id === only || t.out === only); if (targets.length === 0) { @@ -51,85 +32,4 @@ if (targets.length === 0) { process.exit(1); } -const server = await createServer({ - configFile: resolve(repoRoot, "playground/vite.config.ts"), - server: { port: 0, host: "127.0.0.1" }, - logLevel: "error", -}); -await server.listen(); -// Use the ACTUAL bound port: with `port: 0`, config.server.port stays 0 (and -// `0 ?? …` keeps 0), so read the listening socket instead. -const port = server.httpServer.address().port; -const origin = `http://127.0.0.1:${port}`; - -const browser = await chromium.launch({ executablePath: chromiumPath() }); -const page = await browser.newPage({ viewport: { width: 1600, height: 1000 } }); -page.on("pageerror", (e) => console.error("[page]", e.message)); - -for (const t of targets) { - const [w, h] = t.size; - // `/play/` is a production rewrite (vercel); in the vite dev server used - // here it doesn't resolve, so use the SPA entry + hash which loads the doc. - await page.goto(`${origin}/play.html#doc=${t.id}`, { waitUntil: "load" }); - await page.reload({ waitUntil: "load" }); - await page.waitForFunction(() => window.__posecodeViewer?.duration > 0, null, { - timeout: 60000, - }); - await page.waitForFunction(() => window.__posecodeViewer.characterActive === true, null, { - timeout: 60000, - }); - await page.waitForTimeout(1600); // let the auto-framing camera settle - - const duration = await page.evaluate(() => { - const v = window.__posecodeViewer; - v.pause(); - return v.duration; - }); - const frameCount = Math.round(duration * t.fps); - const delayMs = Math.round(1000 / t.fps); - - const gif = GIFEncoder(); - let palette = null; - for (let i = 0; i < frameCount; i++) { - const time = (i / t.fps) % duration; - const b64 = await page.evaluate( - ({ time, w, h }) => { - const v = window.__posecodeViewer; - v.seek(time); - v.captureFrame(); - const src = document.getElementById("canvas"); - // Cover-crop the viewer canvas into the target frame. - const scale = Math.max(w / src.width, h / src.height); - const sw = w / scale; - const sh = h / scale; - const sx = (src.width - sw) / 2; - const sy = (src.height - sh) / 2; - const out = new OffscreenCanvas(w, h); - const ctx = out.getContext("2d"); - ctx.drawImage(src, sx, sy, sw, sh, 0, 0, w, h); - const data = ctx.getImageData(0, 0, w, h).data; - let bin = ""; - for (let j = 0; j < data.length; j += 8192) { - bin += String.fromCharCode.apply(null, data.subarray(j, j + 8192)); - } - return btoa(bin); - }, - { time, w, h }, - ); - const rgba = Uint8Array.from(Buffer.from(b64, "base64")); - // One palette for the whole clip keeps the loop flicker-free (the scene - // lighting is static; only the figure moves). - if (!palette) palette = quantize(rgba, 256); - const indexed = applyPalette(rgba, palette); - gif.writeFrame(indexed, w, h, { palette: i === 0 ? palette : undefined, delay: delayMs }); - } - gif.finish(); - - const outName = `${t.out ?? t.id}.gif`; - const outPath = resolve(repoRoot, "docs/media", outName); - await writeFile(outPath, gif.bytes()); - console.log(`wrote docs/media/${outName} (${frameCount} frames @ ${t.fps}fps, ${w}x${h})`); -} - -await browser.close(); -await server.close(); +await captureGifs(targets, { repoRoot, outDir: "docs/media" }); diff --git a/scripts/capture-launch-gifs.mjs b/scripts/capture-launch-gifs.mjs new file mode 100644 index 0000000..df83b24 --- /dev/null +++ b/scripts/capture-launch-gifs.mjs @@ -0,0 +1,36 @@ +#!/usr/bin/env node +/** + * Launch-asset GIFs (Phase 2 "ammunition"), separate from the README set. + * + * These are the shareable atoms for the launch: tight looping clips, ~420px + * wide, sized per movement so floor poses (dead bug) get a landscape frame and + * standing poses stay portrait. Output lands in docs/launch-media/ so it's + * committed and reusable anytime. + * + * Usage: + * node scripts/capture-launch-gifs.mjs # all launch gifs + * node scripts/capture-launch-gifs.mjs wall-sit # just one + */ +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; +import { captureGifs } from "./lib/gif-capture.mjs"; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, ".."); + +/** Per-movement framing: standing poses portrait, floor poses landscape. */ +const TARGETS = [ + { id: "wall-sit", size: [420, 560], fps: 14 }, + { id: "jumping-jacks", size: [480, 534], fps: 14 }, + { id: "jab-cross", size: [480, 540], fps: 14 }, + { id: "dead-bug", size: [560, 420], fps: 14, anchorY: 0.68 }, +]; + +const only = process.argv[2]; +const targets = TARGETS.filter((t) => !only || t.id === only || t.out === only); +if (targets.length === 0) { + console.error(`no such target: ${only}`); + process.exit(1); +} + +await captureGifs(targets, { repoRoot, outDir: "docs/launch-media" }); diff --git a/scripts/capture-logo.mjs b/scripts/capture-logo.mjs new file mode 100644 index 0000000..325969e --- /dev/null +++ b/scripts/capture-logo.mjs @@ -0,0 +1,78 @@ +#!/usr/bin/env node +/** + * Renders the Posecode logo lockup (lime figure glyph + "Posecode" wordmark) to + * PNG on a transparent and a dark background, for profiles / Product Hunt / decks. + * Uses the real brand font (Hanken Grotesk) via Google Fonts so the wordmark + * matches the site exactly. Output → docs/brand/. + * + * posecode-logo.png transparent background + * posecode-logo-dark.png ink (#0a0d12) background + * + * Usage: node scripts/capture-logo.mjs + */ +import { chromium } from "playwright-core"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; +import { mkdir } from "node:fs/promises"; +import { chromiumPath } from "./lib/gif-capture.mjs"; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, ".."); +const outDir = resolve(repoRoot, "docs/brand"); +await mkdir(outDir, { recursive: true }); + +const LIME = "#c6f24a"; +const INK = "#0a0d12"; + +// Figure glyph = the favicon paths, forced lime (no color-scheme dependence). +const glyph = ` +`; + +const html = (dark) => ` + + + + +
+ ${glyph} + Posecode +
`; + +const browser = await chromium.launch({ executablePath: chromiumPath() }); +const page = await browser.newPage({ deviceScaleFactor: 2 }); +try { + for (const [name, dark] of [["posecode-logo", false], ["posecode-logo-dark", true]]) { + await page.setContent(html(dark), { waitUntil: "networkidle" }); + await page.evaluate(() => document.fonts.ready); + await page.waitForTimeout(200); + const el = await page.$("#lockup"); + await el.screenshot({ + path: resolve(outDir, `${name}.png`), + omitBackground: !dark, + }); + console.log(`wrote docs/brand/${name}.png${dark ? " (ink bg)" : " (transparent)"}`); + } +} finally { + await browser.close(); +} diff --git a/scripts/capture-screenshots.mjs b/scripts/capture-screenshots.mjs new file mode 100644 index 0000000..fdb72d0 --- /dev/null +++ b/scripts/capture-screenshots.mjs @@ -0,0 +1,79 @@ +#!/usr/bin/env node +/** + * Launch-asset screenshots (Phase 2 "static images"), rendered from the real + * playground at 2x device scale for crisp PNGs. Output → docs/launch-media/. + * + * play-desktop.png full editor + viewer (1440x900 @2x) + * play-transport.png close-up of the transport bar + * play-mobile.png mobile viewport (390x844 @3x) + * + * Usage: node scripts/capture-screenshots.mjs + */ +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; +import { mkdir } from "node:fs/promises"; +import { bootPlayground, gotoDoc } from "./lib/gif-capture.mjs"; + +const here = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(here, ".."); +const outDir = resolve(repoRoot, "docs/launch-media"); +await mkdir(outDir, { recursive: true }); + +const HERO_DOC = "jumping-jacks"; +const HERO_SEEK = 0.5; // fraction of duration: arms overhead, legs wide + +// --- Desktop: full editor + viewer, plus a transport close-up --------------- +{ + const { page, origin, close } = await bootPlayground({ + repoRoot, + viewport: { width: 1440, height: 900 }, + deviceScaleFactor: 2, + }); + try { + await gotoDoc(page, origin, HERO_DOC); + await page.evaluate((f) => { + const v = window.__posecodeViewer; + v.pause(); + v.seek(v.duration * f); + v.captureFrame?.(); + }, HERO_SEEK); + await page.waitForTimeout(300); + + await page.screenshot({ path: resolve(outDir, "play-desktop.png") }); + console.log("wrote docs/launch-media/play-desktop.png (1440x900 @2x)"); + + const transport = await page.$(".transport"); + if (transport) { + await transport.screenshot({ path: resolve(outDir, "play-transport.png") }); + console.log("wrote docs/launch-media/play-transport.png"); + } else { + console.warn("!! .transport not found — skipped transport close-up"); + } + } finally { + await close(); + } +} + +// --- Mobile: portrait viewport ---------------------------------------------- +{ + const { page, origin, close } = await bootPlayground({ + repoRoot, + viewport: { width: 390, height: 844 }, + deviceScaleFactor: 3, + }); + try { + await gotoDoc(page, origin, HERO_DOC); + await page.evaluate((f) => { + const v = window.__posecodeViewer; + v.pause(); + v.seek(v.duration * f); + v.captureFrame?.(); + }, HERO_SEEK); + await page.waitForTimeout(300); + + await page.screenshot({ path: resolve(outDir, "play-mobile.png") }); + console.log("wrote docs/launch-media/play-mobile.png (390x844 @3x)"); + } finally { + await close(); + } +} diff --git a/scripts/lib/gif-capture.mjs b/scripts/lib/gif-capture.mjs new file mode 100644 index 0000000..36805b3 --- /dev/null +++ b/scripts/lib/gif-capture.mjs @@ -0,0 +1,154 @@ +/** + * Shared GIF-capture core for the posecode playground. + * + * Boots the playground with Vite, drives the live viewer headlessly with + * Playwright (seek → captureFrame per animation frame), and encodes looping + * GIFs with gifenc. Frames are composed to the target size inside the page + * (cover-crop of the viewer canvas), so no image tooling is needed in node. + * + * The Chromium binary is resolved from PLAYWRIGHT_BROWSERS_PATH/chromium or + * POSECODE_CHROMIUM, falling back to playwright-core's bundled binary. + */ +import { createServer } from "vite"; +import { chromium } from "playwright-core"; +import gifencPkg from "gifenc"; // CJS: no named ESM exports +const { GIFEncoder, quantize, applyPalette } = gifencPkg; +import { writeFile, mkdir } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; + +export function chromiumPath() { + if (process.env.POSECODE_CHROMIUM) return process.env.POSECODE_CHROMIUM; + const base = process.env.PLAYWRIGHT_BROWSERS_PATH; + if (base && existsSync(`${base}/chromium`)) return `${base}/chromium`; + return chromium.executablePath(); +} + +/** + * Boots the Vite playground + a headless Chromium page against it. Returns the + * page, its origin, and a `close()` that tears both down. Shared by the GIF and + * screenshot capture scripts so there is one boot path. + * + * @param {{repoRoot:string, viewport?:{width:number,height:number}, deviceScaleFactor?:number}} opts + */ +export async function bootPlayground({ repoRoot, viewport, deviceScaleFactor }) { + const server = await createServer({ + configFile: resolve(repoRoot, "playground/vite.config.ts"), + server: { port: 0, host: "127.0.0.1" }, + logLevel: "error", + }); + await server.listen(); + // With `port: 0`, config.server.port stays 0, so read the listening socket. + const port = server.httpServer.address().port; + const origin = `http://127.0.0.1:${port}`; + + const browser = await chromium.launch({ executablePath: chromiumPath() }); + const page = await browser.newPage({ + viewport: viewport ?? { width: 1600, height: 1000 }, + deviceScaleFactor: deviceScaleFactor ?? 1, + }); + page.on("pageerror", (e) => console.error("[page]", e.message)); + + const close = async () => { + await browser.close(); + await server.close(); + }; + return { page, origin, close }; +} + +/** Wait for the viewer + Xbot character to be ready for a given doc. */ +export async function gotoDoc(page, origin, id) { + await page.goto(`${origin}/play.html#doc=${id}`, { waitUntil: "load" }); + await page.reload({ waitUntil: "load" }); + await page.waitForFunction(() => window.__posecodeViewer?.duration > 0, null, { + timeout: 60000, + }); + await page.waitForFunction( + () => window.__posecodeViewer.characterActive === true, + null, + { timeout: 60000 }, + ); + await page.waitForTimeout(1600); // let the auto-framing camera settle +} + +/** + * @param {Array<{id:string,out?:string,size:[number,number],fps:number}>} targets + * @param {{repoRoot:string,outDir:string}} opts outDir is relative to repoRoot. + */ +export async function captureGifs(targets, { repoRoot, outDir }) { + if (targets.length === 0) throw new Error("no capture targets"); + + const outAbs = resolve(repoRoot, outDir); + await mkdir(outAbs, { recursive: true }); + + const { page, origin, close } = await bootPlayground({ repoRoot }); + + const written = []; + try { + for (const t of targets) { + const [w, h] = t.size; + const anchorY = t.anchorY ?? 0.5; // vertical crop bias (0 top, 1 bottom) + await gotoDoc(page, origin, t.id); + + const duration = await page.evaluate(() => { + const v = window.__posecodeViewer; + v.pause(); + return v.duration; + }); + const frameCount = Math.round(duration * t.fps); + const delayMs = Math.round(1000 / t.fps); + + const gif = GIFEncoder(); + let palette = null; + for (let i = 0; i < frameCount; i++) { + const time = (i / t.fps) % duration; + const b64 = await page.evaluate( + ({ time, w, h, anchorY }) => { + const v = window.__posecodeViewer; + v.seek(time); + v.captureFrame(); + const src = document.getElementById("canvas"); + // Cover-crop the viewer canvas into the target frame. + const scale = Math.max(w / src.width, h / src.height); + const sw = w / scale; + const sh = h / scale; + const sx = (src.width - sw) / 2; + const sy = (src.height - sh) * anchorY; + const out = new OffscreenCanvas(w, h); + const ctx = out.getContext("2d"); + ctx.drawImage(src, sx, sy, sw, sh, 0, 0, w, h); + const data = ctx.getImageData(0, 0, w, h).data; + let bin = ""; + for (let j = 0; j < data.length; j += 8192) { + bin += String.fromCharCode.apply(null, data.subarray(j, j + 8192)); + } + return btoa(bin); + }, + { time, w, h, anchorY }, + ); + const rgba = Uint8Array.from(Buffer.from(b64, "base64")); + // One palette for the whole clip keeps the loop flicker-free (scene + // lighting is static; only the figure moves). + if (!palette) palette = quantize(rgba, 256); + const indexed = applyPalette(rgba, palette); + gif.writeFrame(indexed, w, h, { + palette: i === 0 ? palette : undefined, + delay: delayMs, + }); + } + gif.finish(); + + const outName = `${t.out ?? t.id}.gif`; + const outPath = resolve(outAbs, outName); + await writeFile(outPath, gif.bytes()); + const kb = (gif.bytes().length / 1024).toFixed(0); + console.log( + `wrote ${outDir}/${outName} (${frameCount} frames @ ${t.fps}fps, ${w}x${h}, ${kb}KB)`, + ); + written.push(outPath); + } + } finally { + await close(); + } + return written; +}