Skip to content

Commit 6a2f1ba

Browse files
committed
docs: add launch GIF set + shared capture pipeline
Adds the Phase 2 launch-asset GIFs (wall-sit, jumping-jacks, jab-cross, dead-bug) in docs/launch-media/, rendered from the real playground. Extracts the capture core into scripts/lib/gif-capture.mjs (with an optional per-target vertical crop anchor) so the README pipeline (capture-gifs.mjs) and the new launch pipeline (capture-launch-gifs.mjs) share one implementation.
1 parent d2cb5a7 commit 6a2f1ba

7 files changed

Lines changed: 173 additions & 105 deletions

File tree

docs/launch-media/dead-bug.gif

1.05 MB
Loading

docs/launch-media/jab-cross.gif

535 KB
Loading
419 KB
Loading

docs/launch-media/wall-sit.gif

1.6 MB
Loading

scripts/capture-gifs.mjs

Lines changed: 5 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -2,29 +2,17 @@
22
/**
33
* Regenerates the README movement GIFs from the real renderer.
44
*
5-
* Boots the playground with Vite, drives the live viewer headlessly with
6-
* Playwright (seek → captureFrame per animation frame), and encodes looping
7-
* GIFs with gifenc. Frames are composed to the target size inside the page
8-
* (cover-crop of the viewer canvas), so no image tooling is needed in node.
9-
*
10-
* Not wired into `npm run build`: run it manually when the figure or a
11-
* showcased movement changes, and commit the output.
5+
* Thin wrapper over scripts/lib/gif-capture.mjs (the shared capture core, also
6+
* used by capture-launch-gifs.mjs). Not wired into `npm run build`: run it
7+
* manually when the figure or a showcased movement changes, and commit output.
128
*
139
* Usage:
1410
* node scripts/capture-gifs.mjs # all README gifs
1511
* node scripts/capture-gifs.mjs squat # just one
16-
*
17-
* The Chromium binary is resolved from PLAYWRIGHT_BROWSERS_PATH/chromium or
18-
* POSECODE_CHROMIUM.
1912
*/
20-
import { createServer } from "vite";
21-
import { chromium } from "playwright-core";
22-
import gifencPkg from "gifenc"; // CJS: no named ESM exports
23-
const { GIFEncoder, quantize, applyPalette } = gifencPkg;
24-
import { writeFile } from "node:fs/promises";
25-
import { existsSync } from "node:fs";
2613
import { fileURLToPath } from "node:url";
2714
import { dirname, resolve } from "node:path";
15+
import { captureGifs } from "./lib/gif-capture.mjs";
2816

2917
const here = dirname(fileURLToPath(import.meta.url));
3018
const repoRoot = resolve(here, "..");
@@ -37,99 +25,11 @@ const TARGETS = [
3725
{ id: "lateral", out: "lateral-raise", size: [420, 582], fps: 14 },
3826
];
3927

40-
function chromiumPath() {
41-
if (process.env.POSECODE_CHROMIUM) return process.env.POSECODE_CHROMIUM;
42-
const base = process.env.PLAYWRIGHT_BROWSERS_PATH;
43-
if (base && existsSync(`${base}/chromium`)) return `${base}/chromium`;
44-
return chromium.executablePath();
45-
}
46-
4728
const only = process.argv[2];
4829
const targets = TARGETS.filter((t) => !only || t.id === only || t.out === only);
4930
if (targets.length === 0) {
5031
console.error(`no such target: ${only}`);
5132
process.exit(1);
5233
}
5334

54-
const server = await createServer({
55-
configFile: resolve(repoRoot, "playground/vite.config.ts"),
56-
server: { port: 0, host: "127.0.0.1" },
57-
logLevel: "error",
58-
});
59-
await server.listen();
60-
// Use the ACTUAL bound port: with `port: 0`, config.server.port stays 0 (and
61-
// `0 ?? …` keeps 0), so read the listening socket instead.
62-
const port = server.httpServer.address().port;
63-
const origin = `http://127.0.0.1:${port}`;
64-
65-
const browser = await chromium.launch({ executablePath: chromiumPath() });
66-
const page = await browser.newPage({ viewport: { width: 1600, height: 1000 } });
67-
page.on("pageerror", (e) => console.error("[page]", e.message));
68-
69-
for (const t of targets) {
70-
const [w, h] = t.size;
71-
// `/play/<id>` is a production rewrite (vercel); in the vite dev server used
72-
// here it doesn't resolve, so use the SPA entry + hash which loads the doc.
73-
await page.goto(`${origin}/play.html#doc=${t.id}`, { waitUntil: "load" });
74-
await page.reload({ waitUntil: "load" });
75-
await page.waitForFunction(() => window.__posecodeViewer?.duration > 0, null, {
76-
timeout: 60000,
77-
});
78-
await page.waitForFunction(() => window.__posecodeViewer.characterActive === true, null, {
79-
timeout: 60000,
80-
});
81-
await page.waitForTimeout(1600); // let the auto-framing camera settle
82-
83-
const duration = await page.evaluate(() => {
84-
const v = window.__posecodeViewer;
85-
v.pause();
86-
return v.duration;
87-
});
88-
const frameCount = Math.round(duration * t.fps);
89-
const delayMs = Math.round(1000 / t.fps);
90-
91-
const gif = GIFEncoder();
92-
let palette = null;
93-
for (let i = 0; i < frameCount; i++) {
94-
const time = (i / t.fps) % duration;
95-
const b64 = await page.evaluate(
96-
({ time, w, h }) => {
97-
const v = window.__posecodeViewer;
98-
v.seek(time);
99-
v.captureFrame();
100-
const src = document.getElementById("canvas");
101-
// Cover-crop the viewer canvas into the target frame.
102-
const scale = Math.max(w / src.width, h / src.height);
103-
const sw = w / scale;
104-
const sh = h / scale;
105-
const sx = (src.width - sw) / 2;
106-
const sy = (src.height - sh) / 2;
107-
const out = new OffscreenCanvas(w, h);
108-
const ctx = out.getContext("2d");
109-
ctx.drawImage(src, sx, sy, sw, sh, 0, 0, w, h);
110-
const data = ctx.getImageData(0, 0, w, h).data;
111-
let bin = "";
112-
for (let j = 0; j < data.length; j += 8192) {
113-
bin += String.fromCharCode.apply(null, data.subarray(j, j + 8192));
114-
}
115-
return btoa(bin);
116-
},
117-
{ time, w, h },
118-
);
119-
const rgba = Uint8Array.from(Buffer.from(b64, "base64"));
120-
// One palette for the whole clip keeps the loop flicker-free (the scene
121-
// lighting is static; only the figure moves).
122-
if (!palette) palette = quantize(rgba, 256);
123-
const indexed = applyPalette(rgba, palette);
124-
gif.writeFrame(indexed, w, h, { palette: i === 0 ? palette : undefined, delay: delayMs });
125-
}
126-
gif.finish();
127-
128-
const outName = `${t.out ?? t.id}.gif`;
129-
const outPath = resolve(repoRoot, "docs/media", outName);
130-
await writeFile(outPath, gif.bytes());
131-
console.log(`wrote docs/media/${outName} (${frameCount} frames @ ${t.fps}fps, ${w}x${h})`);
132-
}
133-
134-
await browser.close();
135-
await server.close();
35+
await captureGifs(targets, { repoRoot, outDir: "docs/media" });

scripts/capture-launch-gifs.mjs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Launch-asset GIFs (Phase 2 "ammunition"), separate from the README set.
4+
*
5+
* These are the shareable atoms for the launch: tight looping clips, ~420px
6+
* wide, sized per movement so floor poses (dead bug) get a landscape frame and
7+
* standing poses stay portrait. Output lands in docs/launch-media/ so it's
8+
* committed and reusable anytime.
9+
*
10+
* Usage:
11+
* node scripts/capture-launch-gifs.mjs # all launch gifs
12+
* node scripts/capture-launch-gifs.mjs wall-sit # just one
13+
*/
14+
import { fileURLToPath } from "node:url";
15+
import { dirname, resolve } from "node:path";
16+
import { captureGifs } from "./lib/gif-capture.mjs";
17+
18+
const here = dirname(fileURLToPath(import.meta.url));
19+
const repoRoot = resolve(here, "..");
20+
21+
/** Per-movement framing: standing poses portrait, floor poses landscape. */
22+
const TARGETS = [
23+
{ id: "wall-sit", size: [420, 560], fps: 14 },
24+
{ id: "jumping-jacks", size: [480, 534], fps: 14 },
25+
{ id: "jab-cross", size: [480, 540], fps: 14 },
26+
{ id: "dead-bug", size: [560, 420], fps: 14, anchorY: 0.68 },
27+
];
28+
29+
const only = process.argv[2];
30+
const targets = TARGETS.filter((t) => !only || t.id === only || t.out === only);
31+
if (targets.length === 0) {
32+
console.error(`no such target: ${only}`);
33+
process.exit(1);
34+
}
35+
36+
await captureGifs(targets, { repoRoot, outDir: "docs/launch-media" });

scripts/lib/gif-capture.mjs

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
/**
2+
* Shared GIF-capture core for the posecode playground.
3+
*
4+
* Boots the playground with Vite, drives the live viewer headlessly with
5+
* Playwright (seek → captureFrame per animation frame), and encodes looping
6+
* GIFs with gifenc. Frames are composed to the target size inside the page
7+
* (cover-crop of the viewer canvas), so no image tooling is needed in node.
8+
*
9+
* The Chromium binary is resolved from PLAYWRIGHT_BROWSERS_PATH/chromium or
10+
* POSECODE_CHROMIUM, falling back to playwright-core's bundled binary.
11+
*/
12+
import { createServer } from "vite";
13+
import { chromium } from "playwright-core";
14+
import gifencPkg from "gifenc"; // CJS: no named ESM exports
15+
const { GIFEncoder, quantize, applyPalette } = gifencPkg;
16+
import { writeFile, mkdir } from "node:fs/promises";
17+
import { existsSync } from "node:fs";
18+
import { resolve } from "node:path";
19+
20+
function chromiumPath() {
21+
if (process.env.POSECODE_CHROMIUM) return process.env.POSECODE_CHROMIUM;
22+
const base = process.env.PLAYWRIGHT_BROWSERS_PATH;
23+
if (base && existsSync(`${base}/chromium`)) return `${base}/chromium`;
24+
return chromium.executablePath();
25+
}
26+
27+
/**
28+
* @param {Array<{id:string,out?:string,size:[number,number],fps:number}>} targets
29+
* @param {{repoRoot:string,outDir:string}} opts outDir is relative to repoRoot.
30+
*/
31+
export async function captureGifs(targets, { repoRoot, outDir }) {
32+
if (targets.length === 0) throw new Error("no capture targets");
33+
34+
const outAbs = resolve(repoRoot, outDir);
35+
await mkdir(outAbs, { recursive: true });
36+
37+
const server = await createServer({
38+
configFile: resolve(repoRoot, "playground/vite.config.ts"),
39+
server: { port: 0, host: "127.0.0.1" },
40+
logLevel: "error",
41+
});
42+
await server.listen();
43+
// With `port: 0`, config.server.port stays 0, so read the listening socket.
44+
const port = server.httpServer.address().port;
45+
const origin = `http://127.0.0.1:${port}`;
46+
47+
const browser = await chromium.launch({ executablePath: chromiumPath() });
48+
const page = await browser.newPage({ viewport: { width: 1600, height: 1000 } });
49+
page.on("pageerror", (e) => console.error("[page]", e.message));
50+
51+
const written = [];
52+
try {
53+
for (const t of targets) {
54+
const [w, h] = t.size;
55+
const anchorY = t.anchorY ?? 0.5; // vertical crop bias (0 top, 1 bottom)
56+
// `/play/<id>` is a production rewrite (vercel); the vite dev server used
57+
// here doesn't resolve it, so use the SPA entry + hash which loads the doc.
58+
await page.goto(`${origin}/play.html#doc=${t.id}`, { waitUntil: "load" });
59+
await page.reload({ waitUntil: "load" });
60+
await page.waitForFunction(() => window.__posecodeViewer?.duration > 0, null, {
61+
timeout: 60000,
62+
});
63+
await page.waitForFunction(
64+
() => window.__posecodeViewer.characterActive === true,
65+
null,
66+
{ timeout: 60000 },
67+
);
68+
await page.waitForTimeout(1600); // let the auto-framing camera settle
69+
70+
const duration = await page.evaluate(() => {
71+
const v = window.__posecodeViewer;
72+
v.pause();
73+
return v.duration;
74+
});
75+
const frameCount = Math.round(duration * t.fps);
76+
const delayMs = Math.round(1000 / t.fps);
77+
78+
const gif = GIFEncoder();
79+
let palette = null;
80+
for (let i = 0; i < frameCount; i++) {
81+
const time = (i / t.fps) % duration;
82+
const b64 = await page.evaluate(
83+
({ time, w, h, anchorY }) => {
84+
const v = window.__posecodeViewer;
85+
v.seek(time);
86+
v.captureFrame();
87+
const src = document.getElementById("canvas");
88+
// Cover-crop the viewer canvas into the target frame.
89+
const scale = Math.max(w / src.width, h / src.height);
90+
const sw = w / scale;
91+
const sh = h / scale;
92+
const sx = (src.width - sw) / 2;
93+
const sy = (src.height - sh) * anchorY;
94+
const out = new OffscreenCanvas(w, h);
95+
const ctx = out.getContext("2d");
96+
ctx.drawImage(src, sx, sy, sw, sh, 0, 0, w, h);
97+
const data = ctx.getImageData(0, 0, w, h).data;
98+
let bin = "";
99+
for (let j = 0; j < data.length; j += 8192) {
100+
bin += String.fromCharCode.apply(null, data.subarray(j, j + 8192));
101+
}
102+
return btoa(bin);
103+
},
104+
{ time, w, h, anchorY },
105+
);
106+
const rgba = Uint8Array.from(Buffer.from(b64, "base64"));
107+
// One palette for the whole clip keeps the loop flicker-free (scene
108+
// lighting is static; only the figure moves).
109+
if (!palette) palette = quantize(rgba, 256);
110+
const indexed = applyPalette(rgba, palette);
111+
gif.writeFrame(indexed, w, h, {
112+
palette: i === 0 ? palette : undefined,
113+
delay: delayMs,
114+
});
115+
}
116+
gif.finish();
117+
118+
const outName = `${t.out ?? t.id}.gif`;
119+
const outPath = resolve(outAbs, outName);
120+
await writeFile(outPath, gif.bytes());
121+
const kb = (gif.bytes().length / 1024).toFixed(0);
122+
console.log(
123+
`wrote ${outDir}/${outName} (${frameCount} frames @ ${t.fps}fps, ${w}x${h}, ${kb}KB)`,
124+
);
125+
written.push(outPath);
126+
}
127+
} finally {
128+
await browser.close();
129+
await server.close();
130+
}
131+
return written;
132+
}

0 commit comments

Comments
 (0)