diff --git a/.github/workflows/adapters.yml b/.github/workflows/adapters.yml index cb117d5..be05c4c 100644 --- a/.github/workflows/adapters.yml +++ b/.github/workflows/adapters.yml @@ -13,6 +13,8 @@ on: schedule: - cron: '17 3 * * 1-6' - cron: '17 3 * * 0' +env: + PLAYWRIGHT_SKIP_BROWSER_GC: "1" permissions: contents: read jobs: @@ -60,6 +62,9 @@ jobs: .try-it/walkthrough.log .try-it/browser.json .try-it/browser-*.png + .try-it/turn-extremes.json + .try-it/turn-extremes.png + .try-it/turn-input.json if-no-files-found: error retention-days: 14 publish-tested-record: diff --git a/README.md b/README.md index deb69c1..e91a92c 100644 --- a/README.md +++ b/README.md @@ -337,6 +337,48 @@ never moves her hip; `CLOTH_SETTLES` says so, and it is a warning, not a failure settle moves the skirt again. `--lead-in 1` plays the idle once before `out/idle.gif` is cut from it, so the loop starts after the skirt has settled. +### 9d. Turn the head with depth + +**Depth-driven rigging** uses per-part depth from See-through to move drawings at +different depths differently as the head turns. This step uses the runs' scalar +`depth_median` values: the existing face-surface model bends the face and carries +its features, while the back hair moves the other way according to its depth. +It does not sample depth images. **The body does not turn.** Skip this step on the +[PSD route](#route-c-no-gpu-at-all), which carries no measured depth. + +Step 7 did not request depth, so its proposal has no `turn` block. Ask for one now +and copy only that block; the bones, cloth and hello stay as you made them. + +```sh +bunx rig-parts propose --parts work/rig --source inputs/painting.png --full inputs/layers/full --head inputs/layers/head --out work/turn --keypoints keypoints.json +bun ../getting-started/scripts/take.ts work/turn/proposal.json config.json turn +bun -e 'const f = "hello.anim.json", m = await Bun.file(f).json(), c = await Bun.file("config.json").json(), d = c.turn.depths; const depth = { eye_r: d.eyewhite_r, eye_l: d.eyewhite_l, brow_r: d.brow_r, brow_l: d.brow_l, mouth: d.mouth }; m.animations.turn = { duration: 4, tracks: [{ group: "features", property: "translatex", keys: [0, -12, 0, 12, 0].map((degrees, t) => ({ t, derive: { kind: "yaw", degrees, depth } })) }] }; await Bun.write(f, JSON.stringify(m, null, 2) + "\n")' +bunx rig-parts build --config config.json --source inputs/painting.png --full inputs/layers/full --head inputs/layers/head --out out --loop --lead-in 1 +bunx rigc render --candidate out/check/build --animation turn --viewport -448,-32,896,1280 --max 1280 --extremes --out out/turn-render +``` + +The new `turn` animation goes from rest to −12°, back to rest, to +12°, then rest +again, in four seconds. `hello.anim.json` now holds both `hello` and `turn`; the +config already names that file. The facial features use the face riders' proposed +depths. These scalar depths set the head's front/back relationship; the package's +face-surface model supplies the bend, rather than reconstructing a 3D head. +The settings and their limits are in the parts +[AUTHORING guide](https://github.com/rig-c/parts/blob/main/docs/AUTHORING.md). + +The render uses one pixel per rig pixel. Open +`out/turn-render/turn/extremes.png` for setup, the sampled extremes and their +absolute difference. In step 10, select **turn** from the animation menu to play it. + +![The example at rest, at minus and plus 12 degrees, then their absolute difference; native head crops.](docs/demo/head-turn.png) + +These frames use the guide's [hand-placed joints](docs/KEYPOINTS.md). +The [measurement receipt](docs/receipts/issue-42.json) records the versions, depths +and gate readings. The build and check retain the existing elbow seam warning and +four skirt-settling warnings; this run adds no new WARN. Two short hair marks are +already in the static shirt art and become exposed beside the moving side-lock +tips, especially at −12°. Those are **ghosts**, not clean boundaries; this step +retains and reports them rather than repairing the drawing. + ### 10. Play it in your browser diff --git a/docs/CI.md b/docs/CI.md index 1ede33f..2d1f672 100644 --- a/docs/CI.md +++ b/docs/CI.md @@ -11,8 +11,12 @@ The final server fence has `` immediately above it. Th runner supervises that command and opens the two addresses from its step in Chromium. It checks the actual WebGL backend, non-empty visible canvas pixels, changing stage screenshots, animation movement, tracks, pause/resume and breeze, +selects **turn** in the menu and checks its track, pose and changing pixels, then stops its server. Human visual and seam assessment is not run by this job. Other persistent commands hit the named walkthrough timeout rather than silently pass. +The step 9d native extremes plate, its JSON reading and the consumed turn config/keys +are retained as `turn-extremes.png`, `turn-extremes.json` and `turn-input.json` +before the consumer is removed, and uploaded with the walkthrough receipt. A fence requiring human interaction must have an adjacent marker such as ``. diff --git a/docs/demo/head-turn.png b/docs/demo/head-turn.png new file mode 100644 index 0000000..b368e24 Binary files /dev/null and b/docs/demo/head-turn.png differ diff --git a/docs/receipts/issue-42.json b/docs/receipts/issue-42.json new file mode 100644 index 0000000..fa4d1c3 --- /dev/null +++ b/docs/receipts/issue-42.json @@ -0,0 +1,148 @@ +{ + "spec": "getting-started-head-turn/1", + "date": "2026-10-30", + "status": "render and rig gates passed; known shirt ghosts disclosed; browser RED also occurs on retained origin baseline", + "guide_sha256": "f8efbb3764810c8083686c2456082d606562db773fcb6d90b83a7ae22bca22e1", + "versions": { + "@rig-c/parts": "1.83.0", + "@rig-c/core": "2.82.0", + "@rig-c/pose": "1.6.0", + "@rig-c/play": "1.21.2" + }, + "source": "rig-c/exam@01436ee3f02c189fe1a1e9656fe62068889837f5/tasks/01-build/inputs", + "joints": "docs/KEYPOINTS.md hand-placed alternative; detector not run", + "depth_consumed": "Per-part See-through depth_median; no depth images or sampled-depth roles", + "turn": { + "pivot": 0.730087, + "depths": { + "hair_back": 0.9375, + "upperarm_l": 0.90234375, + "forearm_l": 0.90234375, + "upperarm_r": 0.83984375, + "forearm_r": 0.83984375, + "thigh_r": 0.80078125, + "shin_r": 0.80078125, + "thigh_l": 0.80078125, + "shin_l": 0.80078125, + "foot_r": 0.796875, + "foot_l": 0.796875, + "neck": 0.7578125, + "bottomwear": 0.7265625, + "topwear": 0.6796875, + "hairpin": 0.8515625, + "ear_l": 0.59375, + "ear_r": 0.5859375, + "face": 0.466796875, + "mouth": 0.465796875, + "eyewhite_r": 0.465796875, + "eyewhite_l": 0.465796875, + "iris_l": 0.465796875, + "iris_r": 0.465796875, + "lash_l": 0.465796875, + "lash_r": 0.465796875, + "brow_r": 0.465796875, + "brow_l": 0.465796875, + "hair_front": 0.064453125 + } + }, + "head_pivot_read_by_published_function": 0.561246, + "head_fraction_examples": { + "hair_back": { + "depth": 0.9375, + "fraction": -0.757366 + }, + "hair_front": { + "depth": 0.064453125, + "fraction": 1 + }, + "hairpin": { + "depth": 0.8515625, + "fraction": -0.584381 + }, + "face": { + "depth": 0.466796875, + "fraction": 0.190118 + } + }, + "animation": { + "name": "turn", + "duration": 4, + "yaw_degrees": [ + 0, + -12, + 0, + 12, + 0 + ], + "key_times": [ + 0, + 1, + 2, + 3, + 4 + ] + }, + "verification": { + "fresh_npm_install_exit": 0, + "build_exit": 0, + "standalone_check_exit": 0, + "native_render_exit": 0, + "check_bars": { + "measured": 13, + "total": 14, + "skip": [ + "STRESS_OFF_CHAIN" + ] + }, + "warnings_before": 5, + "warnings_after": 5, + "warning_names": [ + "SEAM_OVER_MOTION: existing hello upperarm_r|forearm_r elbow", + "CLOTH_SETTLES: four existing skirt chains" + ], + "browser_candidate_exit": 1, + "browser_origin_control_exit": 1, + "browser_observation": "Both actual canvas2d vs expected webgl; shared environment under HQ control rule; no browser PASS", + "turn_menu": "not reached at WebGL gate" + }, + "picture": { + "file": "docs/demo/head-turn.png", + "sha256": "3e8a8b1a7f893792cff4bb5d3a788572559ae96238f0a02d769eb17e6122bb5d", + "source_extremes_sha256": "bc8e241673ccfef52d3fee0e8e81dc06e840f8b5e2f85ab3f09cfac6a3fbe226", + "viewport": [ + -448, + -32, + 896, + 1280 + ], + "scale_px_per_rig_unit": 1, + "head_crop": [ + 250, + 0, + 670, + 400 + ], + "panels": [ + "setup", + "f12 t=1 -12 degrees", + "f36 t=3 +12 degrees", + "abs(max-min)" + ], + "presentation": "Native pixels cropped without scaling; labels and grey presentation background only. No art repair.", + "geometry_pair_max_vertex_distance": 30.43282477837644 + }, + "visual_residual": "GHOST: short old hair-tip pixels in static assembled topwear, exposed beside moving locks at MIN. Exact samples already exist before turn; raw full topwear is light and head neck transparent there. Disclosed only; no repair.", + "body": "Across 49 samples, measured torso/limb bone matrices and topwear/limb vertices do not change; skirt physics may settle. Body does not turn.", + "origin_browser_control": { + "commit": "24ffaf55045869face99390dbed8c78843a5932e", + "readme_sha256": "87815b3ef8833c777ef36a20befef82d1e128771bde670e40474c9baf3c0bcfc", + "browser_helper_sha256": "7bff43a9fe931ef2a645d07dca92a8b1a11c5a7747ba37552ee164e4b709bc1a", + "retained_baseline_limits": "Guide hand-placed joints; equivalent hello file named hello.before-turn.json; no fresh full-origin walkthrough claim." + }, + "jobs": { + "build_check": "20261030T114410Z-3b0b1e53c9344636880c83c1b84d2954", + "native_render_and_candidate_browser_red": "20261030T115319Z-c7a9e801b58741be97b1067366a78995", + "origin_browser_red": "20261030T120527Z-52e88a1b013d4b9081e48a978170a8b0" + }, + "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)." +} diff --git a/scripts/try-it-browser.ts b/scripts/try-it-browser.ts index 744adc6..c4d9832 100644 --- a/scripts/try-it-browser.ts +++ b/scripts/try-it-browser.ts @@ -62,9 +62,20 @@ try { assert.deepEqual((await sample()).wind, [1, 0], 'Step 10 breeze off'); await page.locator('#breeze').check(); await page.waitForTimeout(100); const wind = (await sample()).wind; assert(wind[0] >= 0.5 && wind[0] <= 1 && wind[1] === 0, 'Step 10 breeze on'); + let turn = null; + if (url.searchParams.get('under') === 'idle') { + await page.locator('#anim').selectOption('turn'); + const a = await sample(); const sa = await page.locator('#stage').screenshot(); + await page.waitForTimeout(700); + const b = await sample(); const sb = await page.locator('#stage').screenshot(); + assert.deepEqual(b.tracks, ['idle', 'turn'], 'Step 9d turn menu selection'); + assert.notEqual(a.pose, b.pose, 'Step 9d frozen turn pose'); + assert.notEqual(sha(sa), sha(sb), 'Step 9d frozen turn pixels'); + turn = { tracks: b.tracks, advanced: true, screenshots_sha256: [sha(sa), sha(sb)] }; + } assert.deepEqual(errors, [], 'Step 10 browser errors'); writeFileSync(join(dirname(input.output), `browser-${observations.length}.png`), secondScreenshot); - observations.push({ url: link, backend: second.backend, painted: second.painted, screenshots_sha256: [sha(firstScreenshot), sha(secondScreenshot)], changed_pixels: true, advanced: true, pause_resume: true, breeze: true, tracks: second.tracks }); + observations.push({ url: link, backend: second.backend, painted: second.painted, screenshots_sha256: [sha(firstScreenshot), sha(secondScreenshot)], changed_pixels: true, advanced: true, pause_resume: true, breeze: true, tracks: second.tracks, turn }); await page.close(); } writeFileSync(input.output, JSON.stringify({ browser: browser.version(), backend: 'WebGL rasterization / visible canvas blits (SwiftShader)', observations }, null, 2) + '\n'); diff --git a/scripts/try-it.test.ts b/scripts/try-it.test.ts index b8131c7..ccad8c7 100644 --- a/scripts/try-it.test.ts +++ b/scripts/try-it.test.ts @@ -15,10 +15,10 @@ async function inDirectory(fn: (directory: string) => Promise) { try { await fn(directory); } finally { rmSync(directory, { recursive: true, force: true }); } } -test('extracts the real guide, including 9b/9c and the server; no hand-copied commands', () => { +test('extracts the real guide, including 9b/9c/9d and the server; no hand-copied commands', () => { const source = readFileSync(join(root, 'README.md'), 'utf8'); const steps = extract(source); - expect(steps.map(s => s.title.split('.')[0])).toEqual(['1','2','3','4','5','6','7','8','9','9b','9c','10']); + expect(steps.map(s => s.title.split('.')[0])).toEqual(['1','2','3','4','5','6','7','8','9','9b','9c','9d','10']); expect(steps[0].blocks[0].command).toContain('git clone https://github.com/rig-c/getting-started\n'); expect(steps[5].blocks[0].command).toContain('uv run --with rtmlib --with onnxruntime'); expect(steps.at(-1)!.blocks[0].mode).toBe('server'); diff --git a/scripts/try-it.ts b/scripts/try-it.ts index bc5bbb7..f665420 100644 --- a/scripts/try-it.ts +++ b/scripts/try-it.ts @@ -1,6 +1,6 @@ // Commands come from README.md, never from a second copy of the tutorial. import { createHash } from 'node:crypto'; -import { readFileSync, writeFileSync, mkdirSync, rmSync, copyFileSync, mkdtempSync, realpathSync } from 'node:fs'; +import { readFileSync, writeFileSync, mkdirSync, rmSync, copyFileSync, mkdtempSync, realpathSync, existsSync } from 'node:fs'; import { resolve, join, sep } from 'node:path'; export const packages = ['@rig-c/parts', '@rig-c/core', '@rig-c/pose', '@rig-c/play'] as const; @@ -207,9 +207,18 @@ export async function main() { const result = await execute(steps, consumer, [process.execPath, join(root, 'scripts/try-it-browser.ts'), join(out, 'browser-input.json')], { ...process.env, ...isolated, OMP_NUM_THREADS: '4', OPENBLAS_NUM_THREADS: '4', MKL_NUM_THREADS: '4' }); if (await Bun.file(join(consumer, 'walkthrough.log')).exists()) copyFileSync(join(consumer, 'walkthrough.log'), join(out, 'walkthrough.log')); receipt.rows = result.rows; + const consumerRoot = join(consumer, 'rig-c-start/my-rig'); + for (const name of ['extremes.json', 'extremes.png']) { + const file = join(consumerRoot, 'out/turn-render/turn', name); + if (existsSync(file)) copyFileSync(file, join(out, `turn-${name}`)); + } + if (existsSync(join(consumerRoot, 'config.json')) && existsSync(join(consumerRoot, 'hello.anim.json'))) { + const config = JSON.parse(readFileSync(join(consumerRoot, 'config.json'), 'utf8')); + const motion = JSON.parse(readFileSync(join(consumerRoot, 'hello.anim.json'), 'utf8')); + if (config.turn && motion.animations?.turn) writeFileSync(join(out, 'turn-input.json'), JSON.stringify({ turn: config.turn, animation: motion.animations.turn }, null, 2) + '\n'); + } if (result.exit || result.rows.some(r => r.status !== 'PASS')) throw Error(`Step ${result.rows.find(r => r.status !== 'PASS')?.step}: incomplete walkthrough (exit ${result.exit})`); phase = 'VERSIONS'; - const consumerRoot = join(consumer, 'rig-c-start/my-rig'); const installed = Object.fromEntries(packages.map(p => [p, JSON.parse(readFileSync(join(consumerRoot, 'node_modules', p, 'package.json'), 'utf8')).version])); const after = await latest(); assertVersions(installed, before, after); const clone = join(consumer, 'rig-c-start/getting-started');