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
5 changes: 5 additions & 0 deletions .github/workflows/adapters.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

<!-- try-it-ci: server -->
Expand Down
4 changes: 4 additions & 0 deletions docs/CI.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,12 @@ The final server fence has `<!-- try-it-ci: server -->` 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
`<!-- try-it-ci: not-run reason="requires a person at a licensed desktop" -->`.
Expand Down
Binary file added docs/demo/head-turn.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
148 changes: 148 additions & 0 deletions docs/receipts/issue-42.json
Original file line number Diff line number Diff line change
@@ -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)."
}
13 changes: 12 additions & 1 deletion scripts/try-it-browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
4 changes: 2 additions & 2 deletions scripts/try-it.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ async function inDirectory(fn: (directory: string) => Promise<void>) {
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');
Expand Down
13 changes: 11 additions & 2 deletions scripts/try-it.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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');
Expand Down
Loading