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
2 changes: 1 addition & 1 deletion server/services/sprites/animationTrackWorkflow.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ const newId = () => `track-${++sequence}`;
// the record's single locked main. That is the registry difference under test.
async function characterWithEastAnchor(id) {
await records.createRecord({ kind: 'character', name: 'Placeholder Hero' }, id);
await lockAllAnchors(TEST_ROOT, id, { lockReference, directions: ['east'] });
await lockAllAnchors(TEST_ROOT, id, { lockReference, directions: ['east'], records });
return id;
}

Expand Down
67 changes: 59 additions & 8 deletions server/services/sprites/atlas.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,11 @@ import { createHash } from 'crypto';
import {
capSharpThreads,
lockAllAnchors as lockAllAnchorsFixture,
lockAllAnchorsReal,
placeCandidate,
trackSpan as fullSpan,
writeWalkFramePng,
normalizeManifestForComparison,
} from './spriteTestFixtures.js';

const restoreSharpThreads = capSharpThreads();
Expand Down Expand Up @@ -82,7 +84,7 @@ const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');

async function lockAllAnchors(id) {
await records.createRecord({ kind: 'character', name: 'Atlas Walker' }, id);
await lockAllAnchorsFixture(TEST_ROOT, id, { lockReference, directions: SPRITE_DIRECTIONS });
await lockAllAnchorsFixture(TEST_ROOT, id, { lockReference, directions: SPRITE_DIRECTIONS, records });
}

const walkFramePng = writeWalkFramePng;
Expand All @@ -101,21 +103,28 @@ async function buildFinalizedWalkSet(recordId, {
status: 'complete',
directions: {},
};
for (const direction of SPRITE_DIRECTIONS) {
// #6180 — the 8 directions are independent (each writes its own runId
// under grok/), and writeWalkFramePng already serves the encoded PNG
// buffer from cache (walkFramePngCache), so there is no Sharp compute left
// to save here — only the per-file write latency, which is exactly what
// running the 8 directions concurrently hides. `seq++` for each direction
// still happens synchronously in SPRITE_DIRECTIONS order before any await,
// since .map() invokes its callback bodies in sequence, so run ids stay
// deterministic despite the writes interleaving after that.
await Promise.all(SPRITE_DIRECTIONS.map(async (direction) => {
const runId = `walk-${direction}-${(seq++).toString(16).padStart(8, '0')}`;
const generatedRel = `grok/${runId}/generated`;
const frames = [];
for (let i = 0; i < labels.length; i++) {
const frames = await Promise.all(Array.from({ length: labels.length }, async (_, i) => {
const name = `${String(i).padStart(2, '0')}-${labels[i]}.png`;
const rel = `${generatedRel}/frames/${name}`;
await walkFramePng(join(dir, rel), 20 + i * 8, varyArm ? 2 + (i % 4) * 2 : null, speckFrames.includes(i));
frames.push({
return {
outputIndex: i,
phase: labels[i],
path: rel,
sha256: sha256(await readFile(join(dir, rel))),
});
}
};
}));
const runManifest = {
schemaVersion: 1,
kind: 'deterministically-packaged-grok-walk-video',
Expand All @@ -138,7 +147,7 @@ async function buildFinalizedWalkSet(recordId, {
runManifestSha256: sha256(Buffer.from(manifestBytes)),
approvedAt: new Date().toISOString(),
};
}
}));
await mkdir(join(dir, 'walk'), { recursive: true });
const selectionRel = `walk/${recordId}-walk-selection-v1.json`;
const selectionBytes = JSON.stringify(selection);
Expand Down Expand Up @@ -313,6 +322,48 @@ async function replaceFinalizedFrame(recordId, direction, index, bytes) {
return frame.phase;
}

// #6180 — this suite's own `lockAllAnchors(id)` wrapper always requests the
// full SPRITE_DIRECTIONS set, so its 19 call sites are all fully covered by
// spriteTestFixtures.js's materialized fast path. This is the acceptance
// gate for that: it proves a materialized character is byte-for-byte
// indistinguishable (after id normalization) from one the real per-anchor
// Sharp lock pipeline built.
describe('lockAllAnchors fixture materialization (#6180)', () => {
it('matches the real lock pipeline byte-for-byte (file names, PNG bytes, manifest, record state) after id normalization', async () => {
const realId = newId();
await records.createRecord({ kind: 'character', name: 'Atlas Walker' }, realId);
await lockAllAnchorsReal(TEST_ROOT, realId, { lockReference, directions: SPRITE_DIRECTIONS });
const fastId = newId();
await lockAllAnchors(fastId);

const { listSpriteAssets } = await import('./paths.js');
const [realAssets, fastAssets] = await Promise.all([
listSpriteAssets(realId, { subdir: 'reference', metadata: false }),
listSpriteAssets(fastId, { subdir: 'reference', metadata: false }),
]);
const idNormalizedNames = (id, assets) => assets.map((a) => a.path.split(id).join('<id>')).sort();
expect(idNormalizedNames(fastId, fastAssets)).toEqual(idNormalizedNames(realId, realAssets));

for (const asset of realAssets.filter((a) => a.path.endsWith('.png'))) {
const fastRel = asset.path.split(realId).join(fastId);
const [realBytes, fastBytes] = await Promise.all([
readFile(join(TEST_ROOT, 'sprites', realId, asset.path)),
readFile(join(TEST_ROOT, 'sprites', fastId, fastRel)),
]);
expect(fastBytes.equals(realBytes)).toBe(true);
}

const [realManifest, fastManifest] = await Promise.all([loadManifest(realId), loadManifest(fastId)]);
expect(normalizeManifestForComparison(fastManifest, fastId))
.toEqual(normalizeManifestForComparison(realManifest, realId));

const [realRecord, fastRecord] = await Promise.all([records.getRecord(realId), records.getRecord(fastId)]);
expect(fastRecord.chromaKey).toBe(realRecord.chromaKey);
expect(fastRecord.status).toBe(realRecord.status);
expect(fastRecord.status).toBe('reference-complete');
});
});

beforeEach(() => {
rmSync(join(TEST_ROOT, 'sprite-records.json'), { force: true });
});
Expand Down
171 changes: 168 additions & 3 deletions server/services/sprites/spriteTestFixtures.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@
*/
import { join } from 'path';
import sharp from 'sharp';
import { mkdir, writeFile } from 'fs/promises';
import { SPRITE_DIRECTIONS } from './prompts.js';
import {
mkdir, writeFile, readFile, copyFile,
} from 'fs/promises';
import { SPRITE_DIRECTIONS, ANCHOR_DIRECTIONS } from './prompts.js';

/**
* Cap libvips at one thread per call, for a suite whose images are all tiny.
Expand Down Expand Up @@ -140,7 +142,12 @@ export async function placeCandidate(testRoot, recordId, target, name, opts = {}
// and atlas.test.js's lockAllAnchors (which only differ in which directions
// they pass and how they name/create the character beforehand). The turnaround
// comes first because anchors are gated on it (#2979).
export async function lockAllAnchors(testRoot, recordId, { lockReference, directions }) {
//
// This is the REAL pipeline (real Sharp normalize + chroma-key selection per
// anchor) — exported so callers that need a genuine from-scratch lock (the
// equivalence tests, and `getOrBuildLockPrototype` below) can still reach it.
// `lockAllAnchors` below is the fast path everything else uses.
export async function lockAllAnchorsReal(testRoot, recordId, { lockReference, directions }) {
await lockReference(recordId, {
target: 'turnaround',
candidate: await placeCandidate(testRoot, recordId, 'turnaround', 'turnaround-candidate-01.png'),
Expand All @@ -157,6 +164,164 @@ export async function lockAllAnchors(testRoot, recordId, { lockReference, direct
}
}

// #6180 — every `lockAllAnchors` caller in walk.test.js/atlas.test.js locks a
// character from BYTE-IDENTICAL cached candidate PNGs (the same fixture
// buffers every other candidate reuses), so 136 call sites were re-deriving
// the same normalize + chroma-key-selection tree over and over. One real lock
// per TEST_ROOT — always at the FULL anchor set, since every caller's
// `directions` is a subset of it — then every subsequent call recursive-copies
// that prototype's `reference/` tree and trims it to the requested subset.
//
// Keyed on testRoot (not a bare module-level singleton) so two suites sharing
// this module in one Vitest worker can't leak a prototype across each other's
// isolated tmpdir.
const lockPrototypes = new Map();
const LOCK_PROTOTYPE_ID = 'lock-fixture-prototype';

function getOrBuildLockPrototype(testRoot, { lockReference, records }) {
let pending = lockPrototypes.get(testRoot);
if (!pending) {
pending = (async () => {
await records.createRecord({ kind: 'character', name: 'Lock Fixture Prototype' }, LOCK_PROTOTYPE_ID);
await lockAllAnchorsReal(testRoot, LOCK_PROTOTYPE_ID, { lockReference, directions: ANCHOR_DIRECTIONS });
return LOCK_PROTOTYPE_ID;
})();
lockPrototypes.set(testRoot, pending);
}
return pending;
}

// Copy one locked artifact PNG from the prototype's id-stamped filename to the
// target's — driven by the ACTUAL filename the prototype's own lock wrote
// (read off the just-loaded manifest), not by re-deriving the naming
// convention, so this can't drift out of sync with reference.js's own scheme.
async function copyLockedArtifact(protoDir, targetDir, prototypeId, recordId, rel) {
const slash = rel.lastIndexOf('/');
const dir = rel.slice(0, slash + 1);
const filename = rel.slice(slash + 1);
const newRel = filename.startsWith(`${prototypeId}-`)
? `${dir}${recordId}-${filename.slice(prototypeId.length + 1)}`
: rel; // defensive; shouldn't happen
await mkdir(join(targetDir, dir), { recursive: true });
await copyFile(join(protoDir, rel), join(targetDir, newRel));
return newRel;
}

// The candidate a locked artifact's `lockedFrom` names never carries the id
// (placeCandidate's filenames are direction-only), so it copies straight
// across — but its `.generation.json` sidecar rides along on the same path.
async function copyCandidate(protoDir, targetDir, rel) {
await mkdir(join(targetDir, rel, '..'), { recursive: true });
await copyFile(join(protoDir, rel), join(targetDir, rel));
const sidecarRel = rel.replace(/\.png$/, '.generation.json');
await copyFile(join(protoDir, sidecarRel), join(targetDir, sidecarRel));
}

// Materialize `recordId` from the prototype WITHOUT paying for anchors nobody
// asked for: copy only the turnaround, the main, and each requested anchor
// (artifact + the candidate it was locked from), id-normalize the manifest,
// and reset every anchor NOT in `directions` to the exact seeded-pending shape
// `unlockReferenceAnchorImpl` (reference.js) produces — so a caller asking for
// one direction pays for one direction's files, not the prototype's full nine,
// and a caller asking for a partial set gets a manifest indistinguishable from
// one where only those anchors were ever locked. PNG bytes are copied
// verbatim, so every embedded `sha256` for a copied artifact stays valid with
// no recompute.
async function materializeLockedAnchors(testRoot, prototypeId, recordId, { directions, records }) {
const protoDir = join(testRoot, 'sprites', prototypeId);
const targetDir = join(testRoot, 'sprites', recordId);
const protoManifest = JSON.parse(
await readFile(join(protoDir, `reference/${prototypeId}-reference-set-v1.json`), 'utf8'),
);
const copyArtifact = (rel) => copyLockedArtifact(protoDir, targetDir, prototypeId, recordId, rel);

const manifest = { ...protoManifest, manifestId: `${recordId}-reference-set-v1`, characterFamily: recordId };

manifest.turnaround = { ...protoManifest.turnaround, path: await copyArtifact(protoManifest.turnaround.path) };
await copyCandidate(protoDir, targetDir, protoManifest.turnaround.lockedFrom);

manifest.mainReference = {
...protoManifest.mainReference, path: await copyArtifact(protoManifest.mainReference.path),
};
await copyCandidate(protoDir, targetDir, protoManifest.mainReference.lockedFrom);

const requested = new Set(directions.filter((d) => d !== 'south'));
manifest.anchors = await Promise.all(protoManifest.anchors.map(async (protoAnchor) => {
if (protoAnchor.direction === 'south') {
// South is never locked through its own `target` — the main lock syncs
// this entry directly from the SAME rel/candidate/sha256 it just wrote
// to mainReference (reference.js's lockReferenceImpl), so it mirrors
// the just-copied mainReference 1:1 regardless of what `directions` asked for.
return {
...protoAnchor,
status: 'locked',
path: manifest.mainReference.path,
lockedFrom: manifest.mainReference.lockedFrom,
sha256: manifest.mainReference.sha256,
};
}
if (!requested.has(protoAnchor.direction)) {
// The exact shape seedManifest() gives a never-generated anchor.
return {
id: protoAnchor.id, kind: protoAnchor.kind, direction: protoAnchor.direction,
status: 'pending', source: 'derive-from-turnaround',
};
}
const path = await copyArtifact(protoAnchor.path);
await copyCandidate(protoDir, targetDir, protoAnchor.lockedFrom);
return { ...protoAnchor, path };
}));

// The prototype is always fully locked, so its manifest.status is 'complete'
// — a real PARTIAL lock never reaches that (lockReferenceImpl only sets it
// inside the all-anchors-locked branch; otherwise it stays at the value the
// main lock left, 'in-progress'). Re-derive it from what actually got
// materialized above, or a partial character would misreport as finished.
const allLocked = manifest.anchors.every((a) => a.status === 'locked');
manifest.status = allLocked ? 'complete' : 'in-progress';

await mkdir(join(targetDir, 'reference'), { recursive: true });
await writeFile(join(targetDir, `reference/${recordId}-reference-set-v1.json`), JSON.stringify(manifest));

// Mirrors lockReferenceImpl's own record side effects: the turnaround lock
// sets {chromaKey, status:'reference'}; the record only reaches
// 'reference-complete' once every anchor (the full ANCHOR_DIRECTIONS set) is
// locked — never on a partial `directions` subset.
await records.updateRecord(recordId, {
chromaKey: manifest.chromaKey,
status: allLocked ? 'reference-complete' : 'reference',
});
}

export async function lockAllAnchors(testRoot, recordId, { lockReference, directions, records }) {
const prototypeId = await getOrBuildLockPrototype(testRoot, { lockReference, records });
await materializeLockedAnchors(testRoot, prototypeId, recordId, { directions, records });
}

/**
* Deep-clone `manifest`, strip every `lockedAt` timestamp (real and
* materialized locks never share a clock tick), and replace every occurrence
* of `id` inside a string value with `<id>` — the normalization the #6180
* equivalence tests in walk.test.js/atlas.test.js both need to compare a
* real-locked manifest against a materialized one.
*/
export function normalizeManifestForComparison(manifest, id) {
const clone = JSON.parse(JSON.stringify(manifest));
const strip = (node) => {
if (Array.isArray(node)) {
node.forEach(strip);
} else if (node && typeof node === 'object') {
delete node.lockedAt;
for (const key of Object.keys(node)) {
if (typeof node[key] === 'string') node[key] = node[key].split(id).join('<id>');
else strip(node[key]);
}
}
};
strip(clone);
return clone;
}

/**
* Assert that `prompt` carries the re-roll correction for `note` (#3216).
*
Expand Down
Loading