Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added docs/brand/posecode-logo-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/brand/posecode-logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/launch-media/dead-bug.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/launch-media/jab-cross.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/launch-media/jumping-jacks.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/launch-media/play-desktop.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/launch-media/play-mobile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/launch-media/play-transport.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/launch-media/wall-sit.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
110 changes: 5 additions & 105 deletions scripts/capture-gifs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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, "..");
Expand All @@ -37,99 +25,11 @@ 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) {
console.error(`no such target: ${only}`);
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/<id>` 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" });
36 changes: 36 additions & 0 deletions scripts/capture-launch-gifs.mjs
Original file line number Diff line number Diff line change
@@ -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" });
78 changes: 78 additions & 0 deletions scripts/capture-logo.mjs
Original file line number Diff line number Diff line change
@@ -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 = `
<svg width="132" height="132" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<g fill="none" stroke="${LIME}" stroke-width="8" stroke-linecap="round" stroke-linejoin="round">
<path d="M50 34V55"/>
<path d="M50 35L34 26L23 15"/>
<path d="M50 35L66 26L77 15"/>
<path d="M50 54L36 72L28 88"/>
<path d="M50 54L64 72L72 88"/>
</g>
<circle fill="${LIME}" cx="50" cy="20" r="10"/>
</svg>`;

const html = (dark) => `<!doctype html><html><head><meta charset="utf-8">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@800&display=swap" rel="stylesheet">
<style>
html,body{margin:0}
#lockup{
display:inline-flex;align-items:center;gap:34px;
padding:56px 72px;
${dark ? `background:${INK};` : "background:transparent;"}
font-family:"Hanken Grotesk",system-ui,sans-serif;
}
#lockup .mark{display:flex}
#lockup .word{font-weight:800;font-size:132px;line-height:1;letter-spacing:-0.02em}
#lockup .word .p{color:#f4f7fb}
#lockup .word .c{color:${LIME}}
</style></head>
<body><div id="lockup">
<span class="mark">${glyph}</span>
<span class="word"><span class="p">Pose</span><span class="c">code</span></span>
</div></body></html>`;

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();
}
79 changes: 79 additions & 0 deletions scripts/capture-screenshots.mjs
Original file line number Diff line number Diff line change
@@ -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();
}
}
Loading
Loading