diff --git a/README.md b/README.md index 6c1996b..fec16ba 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,10 @@ You start with one painting of a character. You end with that character rigged — split into movable pieces on a skeleton — breathing, blinking and waving in your browser. - +![The guide's painting, its See-through layers fanning apart, the rig's actual bones and mesh triangles, then the character breathing, blinking and waving in the browser.](docs/demo/walkthrough.webp) + +Under-eight-second looping WebP, from this walkthrough's outputs. +[Rebuild it](docs/demo/README.md). Example artwork: CC BY-NC 4.0; credit below. ## The whole thing in one glance diff --git a/docs/demo/README.md b/docs/demo/README.md new file mode 100644 index 0000000..55b17ef --- /dev/null +++ b/docs/demo/README.md @@ -0,0 +1,89 @@ +# Rebuild the README demo + +`walkthrough.webp` is a 720 × 600, 7.75-second looping WebP. WebP works as a +Markdown image and stays below 2 MB without requiring a video player. Its stages +show the guide's painting, five of its original See-through full-run layers fanned +apart, the built rig's bones and mesh triangles, and the real `@rig-c/play` page +playing the idle's blink, then `hello` over `idle`. The last stage includes the +page's breeze. It selects these through the guide's animation menu: 16 captures +of idle from 2.083 s, then 41 of hello from 1.583 s, sampled at 12 Hz. The hello's +own eye-narrowing keys take precedence over idle, so the separate idle view keeps +the real blink visible without changing either animation. The introduction lasts +0.75 s for the painting, 1.25 s for the layers and 1 s for the rig. + +Complete **Try it first** in the root README through step 10, using the published +packages and the pinned example archive. Keep that `my-rig` directory, including +its `node_modules`. No drawing, repaired pixels, replacement joints or extra motion +keys are inputs to this exporter. + +Use a dedicated scratch directory outside the checkout and consumer. The commands +below install export tools there, without changing the guide's dependencies. +`uv` supplies Python and Pillow; the two JavaScript scripts run with Bun. + +```sh +export DEMO_SCRATCH="$HOME/.cache/rig-demo-export" +export TMPDIR="$DEMO_SCRATCH/tmp" +export XDG_CACHE_HOME="$DEMO_SCRATCH/cache" +export BUN_INSTALL_CACHE_DIR="$DEMO_SCRATCH/bun-cache" +export UV_CACHE_DIR="$DEMO_SCRATCH/uv-cache" +export UV_PYTHON_INSTALL_DIR="$DEMO_SCRATCH/python" +export PLAYWRIGHT_BROWSERS_PATH="$DEMO_SCRATCH/browsers" +export PLAYWRIGHT_SKIP_BROWSER_GC=1 +mkdir -p "$TMPDIR" "$XDG_CACHE_HOME" "$BUN_INSTALL_CACHE_DIR" "$UV_CACHE_DIR" "$UV_PYTHON_INSTALL_DIR" "$PLAYWRIGHT_BROWSERS_PATH" "$DEMO_SCRATCH/browser-tools" +cd "$DEMO_SCRATCH/browser-tools" +bun add --exact playwright@1.58.2 +bun node_modules/playwright/cli.js install chromium +cd /path/to/getting-started +bun docs/demo/regenerate.ts --consumer /path/to/my-rig --scratch "$DEMO_SCRATCH" --check +bun docs/demo/regenerate.ts --consumer /path/to/my-rig --scratch "$DEMO_SCRATCH" +``` + +On Linux, Chromium's required system libraries must be installed before running +the exporter. If Playwright reports missing libraries, install the dependencies +required by your distribution before continuing. +Keep `PLAYWRIGHT_SKIP_BROWSER_GC=1` so installs do not remove other cached browser +revisions, especially when sharing a browser cache. + +The preflight reads the actual layer manifest, animation data and every model page +path. The full run serves the unchanged `scripts/play.html`, loads its model via +the real browser loader and requires WebGL, changing poses/pixels and no browser +errors. Only the clock is controlled: the guide's RAF callback advances at 24 Hz, +and screenshots sample it at 12 Hz. Bone lines and mesh edges are derived from +`player.bone()` and `player.posedFrame()`, not approximated by hand. Labels, image +layout and layer transforms provide presentation. Pillow prepares deterministic +thumbnails: LANCZOS downscaling, and each layer's opaque-alpha bounding box plus +eight pixels of padding for its presentation crop. Original files and all rig +inputs remain unchanged. This export does not repair the guide's separately documented +elbow warning ([issue 18](https://github.com/rig-c/getting-started/issues/18)). + +The script captures and encodes twice in fresh pages, records both raw screenshot +digests and requires matching **final WebP bytes**, then writes the media and +`provenance.json` beside it. Raw PNG hashes can differ due to browser rasterization; +the lossy encoding can map those differences to identical WebP bytes. This is +reported explicitly and never used to waive the final-byte check. Pillow +11.3.0 reads the encoded file back to check its dimensions, duration, infinite loop +and byte limit. The receipt identifies the browser, encoder, package versions, +README digest, source assets and compiled-model hashes. Identical bytes are checked +within that environment; different browser, font or libwebp versions may change +pixels or encoding. Keep the recorded environment when comparing rebuilds. +Optionally pass `--guide-commit ` to record the source guide +commit beside its digest. Verify that commit against the checkout before exporting; +the exporter validates the SHA's format and records the supplied identity. + +Raw PNGs and the two trial WebPs are scratch files. Remove that dedicated scratch +directory after reviewing the output and preserving its digests. Commit only the +final WebP, the scripts, this note and the provenance receipt. + +Changing the root README changes its **whole-file** guide SHA. Per +[the CI contract](../CI.md), it requires a fresh walkthrough and an automated +REFERENCE record proposal even when no guide command changed. Do not copy that +proposal over the historical measurements by hand. The walkthrough's literal +clone uses public helpers; separately running this checkout's exporter validates +unpublished exporter changes, while a later published-guide run validates what a +reader can fetch. + +The scripts are MIT. The demo's character imagery is derived from `rig-c/exam` +inputs and remains **CC BY-NC 4.0**, not MIT. The painting's required credit is: +*Painted with Nova Anime XL IL v8.0 by Crody (https://civitai.com/models/376130).* See +[the guide credits](../../README.md#credits-and-licences) and the example archive's +`LICENSE` and `NOTICE.md`. diff --git a/docs/demo/encode.py b/docs/demo/encode.py new file mode 100644 index 0000000..e2ad2c6 --- /dev/null +++ b/docs/demo/encode.py @@ -0,0 +1,50 @@ +"""Encode and read back actual browser captures; never paint or retouch a frame.""" +import sys +import json +from pathlib import Path +from PIL import Image, features + +if sys.argv[1] == '--prepare': + source, out = Path(sys.argv[2]), Path(sys.argv[3]) + out.mkdir(parents=True, exist_ok=True) + painting = Image.open(source / 'inputs/painting.png').convert('RGBA') + painting.thumbnail((380, 490), Image.Resampling.LANCZOS) + painting.save(out / 'painting.png') + layers = json.loads((source / 'inputs/layers/full/layers.json').read_text()) + names = ['back hair', 'bottomwear', 'topwear', 'face', 'front hair'] + for i, name in enumerate(names): + assert any(layer['name'] == name for layer in layers['layers']) + image = Image.open(source / f'inputs/layers/full/parts/{name}.png').convert('RGBA') + # Presentation crops around opaque art, with padding; original pixels + # and source files are untouched. This is never a part/rig repair. + box = image.getchannel('A').point(lambda a: 255 if a >= 250 else 0).getbbox() + assert box, f'No opaque art for {name}' + box = (max(0, box[0]-8), max(0, box[1]-8), min(image.width, box[2]+8), min(image.height, box[3]+8)) + image = image.crop(box) + image.thumbnail((122, 294), Image.Resampling.LANCZOS) + canvas = Image.new('RGBA', (122, 294)) + canvas.alpha_composite(image, ((122-image.width)//2, (294-image.height)//2)) + canvas.save(out / f'layer-{i}.png') + print(f'PRESENTATION_PREPARE Pillow=11.3.0 libwebp={features.version("webp")} original_files_unchanged=true') + sys.exit(0) + +frames = sorted(Path(sys.argv[1]).glob('[0-9][0-9][0-9].png')) +assert len(frames) == 93, f'Expected 93 captures, found {len(frames)}' +images = [Image.open(f).convert('RGB') for f in frames] +assert all(im.size == (720, 600) for im in images) +durations = [round((i + 1) * 1000 / 12) - round(i * 1000 / 12) for i in range(len(images))] +images[0].save(sys.argv[2], format='WEBP', save_all=True, append_images=images[1:], + duration=durations, loop=0, quality=72, method=6, minimize_size=False) +with Image.open(sys.argv[2]) as result: + assert result.size == (720, 600) and result.info['loop'] == 0 + duration = 0 + for i in range(result.n_frames): + result.seek(i) + result.load() + duration += result.info['duration'] + assert duration == 7750, duration +size = Path(sys.argv[2]).stat().st_size +assert size < 2_000_000, size +print(f'WEBP_READER bytes={size} duration_ms={duration} width=720 loop=0 libwebp={features.version("webp")}') +Path(sys.argv[2] + '.json').write_text(json.dumps({'Pillow': '11.3.0', 'libwebp': features.version('webp'), + 'quality': 72, 'method': 6, 'loop': 0, 'bytes': size, 'duration_ms': duration}) + '\n') diff --git a/docs/demo/provenance.json b/docs/demo/provenance.json new file mode 100644 index 0000000..39cbeef --- /dev/null +++ b/docs/demo/provenance.json @@ -0,0 +1,117 @@ +{ + "spec": "walkthrough-demo/1", + "width": 720, + "height": 600, + "duration_ms": 7750, + "fps": 12, + "source_frames": 93, + "bytes": 466228, + "walkthrough_input": "rig-c/exam@01436ee3f02c189fe1a1e9656fe62068889837f5/tasks/01-build/inputs", + "guide_sha256": "5c9fd1ede594e4180a5fb550f29aba64df8808a17df2d93d62e735bb645c09e4", + "guide_commit": "9c422e08be005139928e4fb6514eb19d44e248df", + "versions": { + "@rig-c/parts": "1.82.0", + "@rig-c/core": "2.82.0", + "@rig-c/pose": "1.6.0", + "@rig-c/play": "1.21.2" + }, + "browser": "145.0.7632.6", + "graphics": [ + "--no-sandbox", + "--use-gl=angle", + "--use-angle=swiftshader", + "--enable-unsafe-swiftshader", + "--disable-gpu-rasterization", + "--disable-accelerated-2d-canvas", + "--force-color-profile=srgb" + ], + "encoder": "Pillow 11.3.0 / libwebp (version printed by encode.py); lossy quality 72, method 6, loop 0", + "input_sha256": { + "config.json": "c6220d3cc531828c27b0e7174f4f52a859e93d2a2369036665abfa945c334c8f", + "inputs/layers/full/layers.json": "22af0a8945889c657bfe153e7577e0740647402c0fa2ca87e5a29714d71d848c", + "inputs/layers/full/parts/back hair.png": "7aeced05dc951e50a2805bb498158204dcc61605a97ab69f8af7a69ad16abd27", + "inputs/layers/full/parts/bottomwear.png": "8c40aa16102602d08592721b0ab95fad6647acc966eb030d0118e79d235938f8", + "inputs/layers/full/parts/face.png": "216a510a382f437fd8ab357b952c2110bc40790fb558a2646d028eada2e66331", + "inputs/layers/full/parts/front hair.png": "3203bde03e693c1a5d1311efb1aededc1bf1895ab4f0fced6fee1e5f82ec6c3d", + "inputs/layers/full/parts/topwear.png": "4e87cfa4b06b4592f0871eb4c44f22db9dbd55c8ce4c2f1cfb8e7f5b52bd67a7", + "inputs/painting.png": "1064fc12130381dcfea6d617d4812b5b7809586f5e165925d2f29815e1cb90e5", + "inputs/seethrough.json": "b280325c6733b0c6670d7bd0003dfb45449fe106d9beeb785709c0e38730163a", + "keypoints.json": "76e912e77e743b0b6c89639bad4aac38c830ddb27878cd00d8153a1d1470d589", + "out/check/build/skeleton.json": "569858e360cb9bb51e59a0e07589b039bea4c2b72a62bff11189c61e0f21b195", + "out/check/build/skeleton.model.json": "8a6be9194202c8bc2194d786f218a8180689013ced44cf21a4ec43241a4d28a8", + "out/check/build/skeleton.png": "e74553da76db90b6e9dbeffc941affe2027ee4703d94753478b88ccf12bb6367" + }, + "player_sha256": "4ce8843428205e6a0c093cfa3d75112e7574be98510921aa7e30918d81dc427d", + "script_sha256": "a35071579de2cfb7c160500961f21e9515794e987c45dee7e5b93f6a85c5e1d2", + "encoder_script_sha256": "11e2e88f5fc9dd96315d79e990e3e79b51dc21566035bb9304068365d9c4f30b", + "encoder_runtime": { + "Pillow": "11.3.0", + "libwebp": "1.5.0", + "quality": 72, + "method": 6, + "loop": 0, + "bytes": 466228, + "duration_ms": 7750 + }, + "raw_screenshots_identical": true, + "passes": [ + { + "frames_sha256": "247593fd03b988dfe6345664eea19ef68ac03dc5e070df4f4dc6f3ffaa4168b7", + "webp_sha256": "1de8a7dd45cc3461c408f0e4d0d03df64c4d877b1ed2e2180281e31a0be1168f", + "geometry": { + "meshes": 6, + "triangles": 434, + "bones": 48, + "backend": "webgl" + }, + "ranges": { + "eye_r_scaleY": { + "min": 0.12000019950726247, + "max": 1.000231093453364 + }, + "eye_l_scaleY": { + "min": 0.12000019950726247, + "max": 1.000231093453364 + }, + "chest_scaleX": { + "min": 1, + "max": 1.0079994125443006 + }, + "forearm_rotation": { + "min": -175.49207819215727, + "max": 163.94211154807687 + } + } + }, + { + "frames_sha256": "247593fd03b988dfe6345664eea19ef68ac03dc5e070df4f4dc6f3ffaa4168b7", + "webp_sha256": "1de8a7dd45cc3461c408f0e4d0d03df64c4d877b1ed2e2180281e31a0be1168f", + "geometry": { + "meshes": 6, + "triangles": 434, + "bones": 48, + "backend": "webgl" + }, + "ranges": { + "eye_r_scaleY": { + "min": 0.12000019950726247, + "max": 1.000231093453364 + }, + "eye_l_scaleY": { + "min": 0.12000019950726247, + "max": 1.000231093453364 + }, + "chest_scaleX": { + "min": 1, + "max": 1.0079994125443006 + }, + "forearm_rotation": { + "min": -175.49207819215727, + "max": 163.94211154807687 + } + } + } + ], + "licence": "Character imagery: CC BY-NC 4.0, rig-c/exam. Painted with Nova Anime XL IL v8.0 by Crody (https://civitai.com/models/376130). Scripts: MIT.", + "provenance": "Source files are unretouched. Pillow crops layer thumbnail bounds at opaque alpha>=250 plus 8px padding and scales thumbnails with LANCZOS for presentation only. Bone/mesh lines come from actual runtime geometry. Playing frames capture the unchanged guide page driven at fixed 24 Hz, with two compositor frames before each screenshot." +} diff --git a/docs/demo/regenerate.ts b/docs/demo/regenerate.ts new file mode 100644 index 0000000..23a1f6b --- /dev/null +++ b/docs/demo/regenerate.ts @@ -0,0 +1,224 @@ +// Export real walkthrough assets and the unmodified guide player. No character art is generated here. +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve, sep } from 'node:path'; + +function arg(key: string) { const i = process.argv.indexOf(key); return i < 0 ? undefined : process.argv[i + 1]; } +const root = resolve(import.meta.dir, '../..'); +const guideCommit = arg('--guide-commit'); +assert(!process.argv.includes('--guide-commit') || (guideCommit && /^[0-9a-f]{40}$/.test(guideCommit)), '--guide-commit requires a full lowercase 40-character commit SHA'); +const consumer = resolve(arg('--consumer') ?? '.'); +const scratchArg = arg('--scratch'); +assert(scratchArg, 'Set --scratch to a dedicated directory outside the checkout'); +mkdirSync(scratchArg, { recursive: true }); +const scratch = realpathSync(scratchArg); +assert(![root, consumer].some(p => scratch === p || scratch.startsWith(p + sep)), 'Scratch must be outside checkout and consumer'); +const output = resolve(arg('--out') ?? join(import.meta.dir, 'walkthrough.webp')); +const tools = resolve(arg('--tools') ?? join(scratch, 'browser-tools')); +const modelPath = 'out/check/build/skeleton.model.json'; +const sha = (b: Uint8Array | string) => createHash('sha256').update(b).digest('hex'); +const files = new Set(); +function input(path: string) { + const p = resolve(consumer, path); + assert(p.startsWith(consumer + sep), `Input escapes consumer: ${path}`); + assert(existsSync(p), `Missing walkthrough output: ${path}`); + files.add(path); return p; +} +const read = (path: string) => JSON.parse(readFileSync(input(path), 'utf8')); +const model = read(modelPath); +assert(model.spec === 'rigc-compiled/3', 'Expected the walkthrough compiled model'); +const skeleton = read('out/check/build/skeleton.json'); +assert(skeleton.animations.idle && skeleton.animations.hello, 'Run the walkthrough through step 9c'); +input('inputs/painting.png'); input('inputs/seethrough.json'); input('config.json'); input('keypoints.json'); +const layerDir = 'inputs/layers/full'; +const layers = read(layerDir + '/layers.json'); +const selected = ['back hair', 'bottomwear', 'topwear', 'face', 'front hair']; +const cards = selected.map(name => { + const layer = layers.layers.find((l: any) => l.name === name); + assert(layer, `Missing See-through layer: ${name}`); + // The pinned exam ships the normalized parts/.png layout; its manifest + // retains the original See-through download filenames, which are not files. + const filename = `parts/${name}.png`; + input(layerDir + '/' + filename); return { ...layer, filename }; +}); +// Every atlas reference must resolve through the same paths loadModel will use. +const pageNames = model.pages.map((p: any) => p.name); +for (const name of pageNames) input(join(dirname(modelPath), name)); +console.log('DEMO_READER_PREFLIGHT', JSON.stringify({ layers: cards.map(c => c.name), pages: pageNames, animations: Object.keys(skeleton.animations) })); +assert(existsSync(join(tools, 'node_modules/playwright/index.mjs')), 'Install playwright@1.58.2 in --tools first'); +const { chromium } = await import(join(tools, 'node_modules/playwright/index.mjs')); + +const W = 720, H = 600, fps = 12, count = 93, duration = 7750; +const presentation = join(scratch, 'presentation'); +if (!process.argv.includes('--check')) { + const prepare = Bun.spawn(['uv', 'run', '--with', 'pillow==11.3.0', 'python', join(import.meta.dir, 'encode.py'), '--prepare', consumer, presentation], { stdout: 'inherit', stderr: 'inherit' }); + assert.equal(await prepare.exited, 0, 'Deterministic presentation thumbnail preparation'); +} +const titles = ['1 / One painting', '2 / See-through layers', '3 / Bones + meshes', '4 / Playing in the browser']; +const esc = (s: string) => s.replaceAll('&', '&').replaceAll('<', '<').replaceAll('"', '"'); +const layout = ``; +const caption = (stage: number, sub: string) => `
${titles[stage]}${sub}
`; +const intro = (stage: number) => `${layout}${caption(stage, stage === 0 ? 'The guide’s own example · painting → layers → rig → motion' : 'Five original full-run layers · cropped thumbnails')}${stage === 0 ? '' : cards.map((c, i) => `
${esc(c.name)}
`).join('')}`; +const playerHtml = readFileSync(join(root, 'scripts/play.html'), 'utf8'); +const server = Bun.serve({ hostname: '127.0.0.1', port: 0, async fetch(req) { + const path = decodeURIComponent(new URL(req.url).pathname); + if (/^\/presentation\/(painting|layer-[0-4])\.png$/.test(path)) return new Response(Bun.file(join(presentation, path.slice('/presentation/'.length)))); + if (path === '/painting') return new Response(intro(0), { headers: { 'Content-Type': 'text/html' } }); + if (path === '/layers') return new Response(intro(1), { headers: { 'Content-Type': 'text/html' } }); + if (path === '/') return new Response(playerHtml, { headers: { 'Content-Type': 'text/html' } }); + const p = resolve(consumer, '.' + path); + if (!p.startsWith(consumer + sep) || !existsSync(p)) return new Response('Not found', { status: 404 }); + return new Response(Bun.file(p)); +} }); +// Pin presentation rasterization across fresh processes; meshes still require +// the real WebGL backend below. These flags do not change the guide's player. +const graphics = ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader', '--disable-gpu-rasterization', '--disable-accelerated-2d-canvas', '--force-color-profile=srgb']; +const browser = await chromium.launch({ headless: true, args: graphics }); +const passes: any[] = []; +try { + if (process.argv.includes('--check')) { + const page = await browser.newPage({ viewport: { width: W, height: H } }); + const errors: string[] = []; + page.on('pageerror', (e: Error) => errors.push(String(e))); + page.on('response', (r: any) => { if (r.status() >= 400) errors.push(`${r.status()} ${r.url()}`); }); + await page.goto(`${server.url}?model=${modelPath}&anim=hello&under=idle`); + await page.waitForFunction('!!window.rigPlayer', null, { polling: 100, timeout: 60_000 }); + const loaded = await page.evaluate(() => { + const p = (window as any).rigPlayer; p.frame(0); + return { backend: p.renderer.meshBackendActive, drawn: p.posedFrame().order.length, + tracks: [p.entry(0).animation, p.entry(1).animation] }; + }); + assert.equal(loaded.backend, 'webgl'); assert(loaded.drawn > 0); + assert.deepEqual(loaded.tracks, ['idle', 'hello']); assert.deepEqual(errors, []); + console.log('DEMO_BROWSER_READER_PASS', JSON.stringify(loaded)); + } else { + for (let pass = 0; pass < 2; pass++) { + const frames = join(scratch, `frames-${pass}`); mkdirSync(frames, { recursive: true }); + const context = await browser.newContext({ viewport: { width: W, height: H }, deviceScaleFactor: 1 }); + const page = await context.newPage(); const errors: string[] = []; + page.on('pageerror', (e: Error) => errors.push(String(e))); + page.on('response', (r: any) => { if (r.status() >= 400) errors.push(`${r.status()} ${r.url()}`); }); + page.on('requestfailed', (r: any) => errors.push(`${r.url()}: ${r.failure()?.errorText}`)); + // Drive the guide's own RAF tick at fixed 1/24 s, including its breeze clock. + await page.addInitScript(() => { + let t = 0, queue: FrameRequestCallback[] = []; + const present = window.requestAnimationFrame.bind(window); + (window as any).demoPresent = () => new Promise(resolve => present(() => present(resolve))); + performance.now = () => t; + window.requestAnimationFrame = cb => { queue.push(cb); return queue.length; }; + (window as any).demoTick = () => { t += 1000 / 24; const q = queue; queue = []; for (const cb of q) cb(t); }; + }); + const hashes: string[] = []; + const capture = async (i: number) => { + await page.evaluate(() => (window as any).demoPresent()); + const b = await page.screenshot({ animations: 'disabled' }); hashes.push(sha(b)); + writeFileSync(join(frames, `${String(i).padStart(3, '0')}.png`), b); + }; + await page.goto(`${server.url}painting`); await page.locator('img').evaluateAll((images: HTMLImageElement[]) => Promise.all(images.map(i => i.decode()))); + for (let i = 0; i < 9; i++) await capture(i); + await page.goto(`${server.url}layers`); await page.locator('img').evaluateAll((images: HTMLImageElement[]) => Promise.all(images.map(i => i.decode()))); + for (let i = 0; i < 15; i++) { + await page.locator('.card').evaluateAll((elements: HTMLElement[], t: number) => elements.forEach((el, j) => { + el.style.left = `${289 + (j - 2) * (20 + t * 117)}px`; + el.style.transform = `rotate(${(j - 2) * (1 + t * 1.5)}deg)`; + }), Math.min(1, i / 9)); await capture(i + 9); + } + await page.goto(`${server.url}?model=${modelPath}&anim=hello&under=idle`); + await page.waitForFunction('!!window.rigPlayer', null, { polling: 100, timeout: 60_000 }); + await page.addStyleTag({ content: layout.replace('', '') }); + await page.evaluate(() => window.dispatchEvent(new Event('resize'))); + await page.locator('body').evaluate((body: HTMLElement, html: string) => body.insertAdjacentHTML('afterbegin', html), caption(2, 'Actual posed vertices and bone transforms · no edited artwork')); + // Geometry is read from the player, never a hand-drawn approximation of the rig. + const geometry = await page.evaluate((bones: any[]) => { + const p = (window as any).rigPlayer; p.frame(0); + const frame = p.posedFrame(); + const root = document.getElementById('root')!, stage = document.getElementById('stage')!; + const style = getComputedStyle(root), scale = new DOMMatrix(style.transform).a; + const left = parseFloat(style.left), top = parseFloat(style.top); + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('width', '720'); svg.setAttribute('height', '484'); + svg.style.cssText = 'position:absolute;inset:0;pointer-events:none'; + const line = (a: number[], b: number[], color: string, width: number) => { + const l = document.createElementNS(svg.namespaceURI, 'line'); + for (const [k,v] of Object.entries({ x1:left+a[0]*scale,y1:top-a[1]*scale,x2:left+b[0]*scale,y2:top-b[1]*scale,stroke:color,'stroke-width':width })) l.setAttribute(k,String(v)); + svg.appendChild(l); + }; + let meshes = 0, triangles = 0; + for (const slot of frame.order) if (slot.kind === 'mesh') { + meshes++; triangles += slot.triangles.length / 3; + for (let i = 0; i < slot.triangles.length; i += 3) for (let j = 0; j < 3; j++) { + const a=slot.triangles[i+j]*2,b=slot.triangles[i+(j+1)%3]*2; + line(slot.vertices.slice(a,a+2),slot.vertices.slice(b,b+2),'#037d9b',0.65); + } + } + for (const b of bones) { const world = p.bone(b.name); if (b.length > 0) line([world.x,world.y],[world.x+world.a*b.length,world.y+world.c*b.length],'#f59a23',1.5); } + stage.appendChild(svg); + return { meshes, triangles, bones: bones.length, backend: p.renderer.meshBackendActive }; + }, skeleton.bones); + assert.equal(geometry.backend, 'webgl'); assert(geometry.meshes > 0 && geometry.triangles > 0); + for (let i = 0; i < 12; i++) await capture(i + 24); + await page.locator('#stage svg').evaluate((el: SVGElement) => el.remove()); + await page.locator('#caption').evaluate((el: HTMLElement, html: string) => el.outerHTML = html, caption(3, 'idle → hello over idle · breathing, blinking, waving · WebGL')); + // Show the idle's actual blink before the hello's eye-narrowing keys take + // over. Select through the guide's real menu; never add or modify keys. + await page.locator('#anim').selectOption('idle'); + await page.evaluate(() => { for (let i = 0; i < 48; i++) (window as any).demoTick(); }); + const poses: string[] = [], readings: any[] = []; + for (let i = 0; i < 57; i++) { + if (i === 16) { + await page.locator('#anim').selectOption('hello'); + await page.evaluate(() => { for (let j = 0; j < 36; j++) (window as any).demoTick(); }); + } + await page.evaluate(() => { (window as any).demoTick(); (window as any).demoTick(); }); + poses.push(await page.evaluate(() => JSON.stringify((window as any).rigPlayer.posedFrame()))); + readings.push(await page.evaluate(() => { + const p = (window as any).rigPlayer; + return { animation: p.entry(1).animation, time: p.entry(1).animationTime, eye_r_scaleY: p.bone('eye_r').scaleY, + eye_l_scaleY: p.bone('eye_l').scaleY, chest_scaleX: p.bone('chest').scaleX, + forearm_rotation: p.bone('sleeve_r1').rotationX }; + })); + await capture(i + 36); + } + assert(new Set(poses).size > 1, 'Frozen browser pose'); assert(new Set(hashes.slice(36)).size > 1, 'Frozen browser pixels'); + assert.deepEqual(errors, [], 'Browser load/capture errors'); + const webp = join(scratch, `pass-${pass}.webp`); + const child = Bun.spawn(['uv', 'run', '--with', 'pillow==11.3.0', 'python', join(import.meta.dir, 'encode.py'), frames, webp], { stdout: 'inherit', stderr: 'inherit' }); + assert.equal(await child.exited, 0, 'WebP encode/reader validation'); + const ranges = Object.fromEntries(['eye_r_scaleY','eye_l_scaleY','chest_scaleX','forearm_rotation'].map(key => + [key, { min: Math.min(...readings.map(r => r[key])), max: Math.max(...readings.map(r => r[key])) }])); + assert(ranges.eye_r_scaleY.min < 0.2 && ranges.eye_l_scaleY.min < 0.2, 'Actual idle blink not captured'); + assert(ranges.chest_scaleX.max > ranges.chest_scaleX.min, 'Actual breath not captured'); + assert(ranges.forearm_rotation.max - ranges.forearm_rotation.min > 30, 'Actual wave not captured'); + const passResult = { frames_sha256: sha(hashes.join('\n') + '\n'), webp_sha256: sha(readFileSync(webp)), geometry, ranges }; + passes.push(passResult); + console.log('DEMO_PASS', pass, JSON.stringify(passResult)); + await context.close(); + } + // The rebuild contract is the final animation's bytes. Raw screenshot + // digests remain provenance; a lossy encoder can map distinct rasters to + // the same bytes, so PNG equality would impose a different contract. + assert.equal(passes[0].webp_sha256, passes[1].webp_sha256, 'Encoder is nondeterministic'); + const bytes = readFileSync(join(scratch, 'pass-0.webp')); assert(bytes.length < 2_000_000); + mkdirSync(dirname(output), { recursive: true }); writeFileSync(output, bytes); + const versions = Object.fromEntries(['@rig-c/parts', '@rig-c/core', '@rig-c/pose', '@rig-c/play'].map(n => [n, JSON.parse(readFileSync(join(consumer,'node_modules',n,'package.json'),'utf8')).version])); + const receipt = { spec: 'walkthrough-demo/1', width: W, height: H, duration_ms: duration, fps, source_frames: count, bytes: bytes.length, + walkthrough_input: 'rig-c/exam@01436ee3f02c189fe1a1e9656fe62068889837f5/tasks/01-build/inputs', + guide_sha256: sha(readFileSync(join(root,'README.md'))), ...(guideCommit ? { guide_commit: guideCommit } : {}), versions, browser: browser.version(), graphics, encoder: 'Pillow 11.3.0 / libwebp (version printed by encode.py); lossy quality 72, method 6, loop 0', + input_sha256: Object.fromEntries([...files].sort().map(p => [p, sha(readFileSync(join(consumer,p)))])), + player_sha256: sha(playerHtml), script_sha256: sha(readFileSync(import.meta.filename)), encoder_script_sha256: sha(readFileSync(join(import.meta.dir,'encode.py'))), + encoder_runtime: JSON.parse(readFileSync(join(scratch,'pass-0.webp.json'),'utf8')), raw_screenshots_identical: passes[0].frames_sha256 === passes[1].frames_sha256, passes, + licence: 'Character imagery: CC BY-NC 4.0, rig-c/exam. Painted with Nova Anime XL IL v8.0 by Crody (https://civitai.com/models/376130). Scripts: MIT.', + provenance: 'Source files are unretouched. Pillow crops layer thumbnail bounds at opaque alpha>=250 plus 8px padding and scales thumbnails with LANCZOS for presentation only. Bone/mesh lines come from actual runtime geometry. Playing frames capture the unchanged guide page driven at fixed 24 Hz, with two compositor frames before each screenshot.' }; + writeFileSync(join(dirname(output), 'provenance.json'), JSON.stringify(receipt, null, 2) + '\n'); + console.log('DEMO_RESULT', JSON.stringify({ bytes: bytes.length, duration_ms: duration, sha256: sha(bytes), identical: true, browser: browser.version() })); + } +} finally { await browser.close(); await server.stop(true); } diff --git a/docs/demo/walkthrough.webp b/docs/demo/walkthrough.webp new file mode 100644 index 0000000..944c466 Binary files /dev/null and b/docs/demo/walkthrough.webp differ diff --git a/scripts/try-it-browser.ts b/scripts/try-it-browser.ts index 0e85be2..744adc6 100644 --- a/scripts/try-it-browser.ts +++ b/scripts/try-it-browser.ts @@ -11,7 +11,7 @@ for (const command of [['bun', 'add', '--exact', 'playwright@1.58.2'], ['bun', j assert.equal(await child.exited, 0, `Step 10 browser setup: ${command[0]} ${command[1]}`); } const { chromium } = await import(join(tools, 'node_modules/playwright/index.mjs')); -const browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] }); +const browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] }); const observations: unknown[] = []; try { for (const link of input.links) {