From 0c36aee189e6b0be402889157f0943e336d825dc Mon Sep 17 00:00:00 2001 From: alowpoly Date: Mon, 17 Aug 2026 22:49:01 -0300 Subject: [PATCH 1/2] feat(cityflow): add retained Cityflow adapter --- .gitignore | 1 + package.json | 9 + src/adapters/cityflow/AGENTS.md | 10 + src/adapters/cityflow/README.md | 27 ++ src/adapters/cityflow/index.html | 57 +++ .../cityflow/notes/provenance-bible.md | 66 +++ .../notes/references/source-lock.json | 15 + .../cityflow/src/csscityflow/client.mjs | 90 ++++ .../src/csscityflow/preparedPlayback.mjs | 251 +++++++++++ .../cityflow/src/csscityflow/styles.css | 177 ++++++++ src/adapters/cityflow/src/main.mjs | 4 + .../src/prepare/csscityflow/model.mjs | 280 ++++++++++++ .../src/prepare/csscityflow/sourceModel.mjs | 328 ++++++++++++++ .../cityflow/test/csscityflow-model.test.mjs | 66 +++ .../test/csscityflow-runtime-surface.test.mjs | 25 ++ .../test/csscityflow-source-model.test.mjs | 42 ++ .../tools/check-prepare-determinism.mjs | 52 +++ .../tools/native/capture-cityflow-state.c | 228 ++++++++++ .../tools/native/headless/capture-cityflow.c | 168 +++++++ .../native/headless/include/screenhackI.h | 30 ++ .../tools/native/headless/include/utils.h | 6 + .../tools/native/headless/include/visual.h | 10 + .../tools/native/headless/include/xlockmore.h | 125 ++++++ .../tools/oracle/run-visual-oracle.mjs | 419 ++++++++++++++++++ .../cityflow/tools/prepare-csscityflow.mjs | 77 ++++ .../cityflow/tools/run-native-oracle.mjs | 83 ++++ src/adapters/cityflow/tools/smoke-browser.mjs | 113 +++++ src/adapters/cityflow/vite.config.mjs | 18 + 28 files changed, 2777 insertions(+) create mode 100644 src/adapters/cityflow/AGENTS.md create mode 100644 src/adapters/cityflow/README.md create mode 100644 src/adapters/cityflow/index.html create mode 100644 src/adapters/cityflow/notes/provenance-bible.md create mode 100644 src/adapters/cityflow/notes/references/source-lock.json create mode 100644 src/adapters/cityflow/src/csscityflow/client.mjs create mode 100644 src/adapters/cityflow/src/csscityflow/preparedPlayback.mjs create mode 100644 src/adapters/cityflow/src/csscityflow/styles.css create mode 100644 src/adapters/cityflow/src/main.mjs create mode 100644 src/adapters/cityflow/src/prepare/csscityflow/model.mjs create mode 100644 src/adapters/cityflow/src/prepare/csscityflow/sourceModel.mjs create mode 100644 src/adapters/cityflow/test/csscityflow-model.test.mjs create mode 100644 src/adapters/cityflow/test/csscityflow-runtime-surface.test.mjs create mode 100644 src/adapters/cityflow/test/csscityflow-source-model.test.mjs create mode 100644 src/adapters/cityflow/tools/check-prepare-determinism.mjs create mode 100644 src/adapters/cityflow/tools/native/capture-cityflow-state.c create mode 100644 src/adapters/cityflow/tools/native/headless/capture-cityflow.c create mode 100644 src/adapters/cityflow/tools/native/headless/include/screenhackI.h create mode 100644 src/adapters/cityflow/tools/native/headless/include/utils.h create mode 100644 src/adapters/cityflow/tools/native/headless/include/visual.h create mode 100644 src/adapters/cityflow/tools/native/headless/include/xlockmore.h create mode 100644 src/adapters/cityflow/tools/oracle/run-visual-oracle.mjs create mode 100644 src/adapters/cityflow/tools/prepare-csscityflow.mjs create mode 100644 src/adapters/cityflow/tools/run-native-oracle.mjs create mode 100644 src/adapters/cityflow/tools/smoke-browser.mjs create mode 100644 src/adapters/cityflow/vite.config.mjs diff --git a/.gitignore b/.gitignore index b338a70..94d8187 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ sm64config.txt # Deterministic local prepare/build output build/generated/ +build/oracles/ build/package-inputs/ build/release/ build/reports/ diff --git a/package.json b/package.json index 95dea43..e32d29a 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "dev:flowerbox": "vite --config src/adapters/flowerbox/vite.config.mjs --host 127.0.0.1", "dev:cloth": "vite --config src/adapters/cloth/vite.config.mjs --host 127.0.0.1", "dev:flipflop": "vite --config src/adapters/flipflop/vite.config.mjs --host 127.0.0.1", + "dev:cityflow": "vite --config src/adapters/cityflow/vite.config.mjs --host 127.0.0.1", "dev:gears": "vite --config src/adapters/gears/vite.config.mjs --host 127.0.0.1", "dev:gravitywell": "vite --config src/adapters/gravitywell/vite.config.mjs --host 127.0.0.1", "dev:cyclone": "vite --config src/adapters/cyclone/vite.config.mjs --host 127.0.0.1", @@ -62,6 +63,8 @@ "build:cloth:deploy": "CSSCLOTH_DEPLOY_BUILD=1 pnpm build:cloth", "build:flipflop": "vite build --config src/adapters/flipflop/vite.config.mjs", "build:flipflop:full": "pnpm prepare:flipflop && pnpm build:flipflop", + "build:cityflow": "vite build --config src/adapters/cityflow/vite.config.mjs", + "build:cityflow:full": "pnpm prepare:cityflow && pnpm build:cityflow", "build:flowerbox:full": "pnpm prepare:flowerbox:artifact && pnpm build:flowerbox", "build:gears": "vite build --config src/adapters/gears/vite.config.mjs", "build:gears:full": "pnpm prepare:gears:source && pnpm build:gears", @@ -104,6 +107,8 @@ "prepare:cloth:artifact": "node scripts/prepare/csscloth-product-bank.mjs", "package:cloth:artifact": "node src/adapters/cloth/tools/package-product-bank.mjs --source build/generated/public/csscloth --archive build/release/csscloth-product-bank-v2.tar.gz", "prepare:flipflop": "node src/adapters/flipflop/tools/prepare-cssflipflop.mjs", + "prepare:cityflow": "node src/adapters/cityflow/tools/prepare-csscityflow.mjs", + "prepare:cityflow:check": "node src/adapters/cityflow/tools/check-prepare-determinism.mjs", "prepare:gears:artifact": "node scripts/prepare/cssgears-product-bank.mjs", "prepare:gears:source": "node src/adapters/gears/tools/prepare-cssgears.mjs && node src/adapters/gears/tools/prepare-polycss-snapshot.mjs", "prepare:gravitywell:artifact": "node scripts/prepare/cssgravitywell-product-bank.mjs", @@ -153,6 +158,10 @@ "test:cloth:assets": "node --test src/adapters/cloth/test/csscloth-assets.test.mjs", "test:flipflop": "node --test src/adapters/flipflop/test/*.test.mjs", "oracle:flipflop:native": "node src/adapters/flipflop/tools/run-native-oracle.mjs", + "test:cityflow": "node --test src/adapters/cityflow/test/*.test.mjs", + "test:cityflow:browser": "node src/adapters/cityflow/tools/smoke-browser.mjs", + "oracle:cityflow:native": "node src/adapters/cityflow/tools/run-native-oracle.mjs", + "oracle:cityflow:visual": "node src/adapters/cityflow/tools/oracle/run-visual-oracle.mjs", "test:flowerbox:deploy": "node src/adapters/flowerbox/tools/smoke-browser.mjs --deploy", "verify:gears:bank": "node src/adapters/gears/tools/verify-product-bank.mjs", "test:gears": "node --test src/adapters/gears/test/cssgears-route-state.test.mjs src/adapters/gears/test/cssgears-shell.test.mjs src/adapters/gears/test/cssgears-source-profile.test.mjs", diff --git a/src/adapters/cityflow/AGENTS.md b/src/adapters/cityflow/AGENTS.md new file mode 100644 index 0000000..6ba7943 --- /dev/null +++ b/src/adapters/cityflow/AGENTS.md @@ -0,0 +1,10 @@ +# Cityflow adapter contract + +- Read `notes/provenance-bible.md` before changing source simulation, box geometry, colors, lighting, camera, timing, or parity claims. +- Preserve the XScreenSaver simulation contract: six waves, 25 wave speed, 256 radius, 12-degree skew, 20 ms cadence, the source-supported `count` control set to 200, and only the source-emitted top, front, and right faces. The source default remains 800; do not describe 200 as that default. +- Preserve the seeded initialization, wave equation, palette, lighting, camera, and cadence. Cityflow has one prepared product bank and no runtime bank selection. +- Preparation owns the exact seeded `ya_random` initialization, palette, box census, wave phases, source lighting, native far-plane cutoff, retained Morph package, canonical transform dictionary, and typed transform/color frame tables. +- Runtime may mount the prepared graph, derive static face lighting from each prepared box transform, and apply changed prepared transform indices and palette classes. Playback must follow the merged DOMFORMAT `polycss-playback@0` precedent: timer wait, one paint-aligned callback when due, and `elapsed` catch-up. It must not calculate geometry, rasterize assets, grow the DOM, or rebuild topology during playback. +- Keep every box as one retained rigid root with exactly three solid `` leaves. No Canvas, WebGL, WebGPU, SVG scene geometry, masks, clip paths, runtime atlas work, or paint-heavy CSS effects. +- Native state claims require the pinned headless C source harness at revision `906693799e4fb7581436590cf84ecb2d3c9186ba`. Native visual claims require the independent CGL frame-sequence oracle and calibrated native/browser comparison. +- Keep source checkouts, native binaries, captures, traces, and generated packages ignored. diff --git a/src/adapters/cityflow/README.md b/src/adapters/cityflow/README.md new file mode 100644 index 0000000..1eaa4af --- /dev/null +++ b/src/adapters/cityflow/README.md @@ -0,0 +1,27 @@ +# css.graphics/cityflow + +Source-backed XScreenSaver Cityflow rendered as retained PolyCSS Morph boxes. +The single product bank uses the source's own `count` control at 200 boxes; +the XScreenSaver default is 800. Every box retains the source-emitted top, +front, and right faces. + +```bash +export CSSCITYFLOW_SOURCE_ROOT=/path/to/xscreensaver +pnpm prepare:cityflow +pnpm dev:cityflow +pnpm test:cityflow +pnpm prepare:cityflow:check +pnpm test:cityflow:browser +pnpm oracle:cityflow:native +pnpm oracle:cityflow:visual +pnpm build:cityflow +``` + +Preparation owns the seeded source initialization, smooth color map, box +geometry, source-light factors, OpenGL far-plane cutoff, Morph packages, and +source-cadence transform/color tables. Duplicate transforms are removed during +preparation. Runtime mounts one retained graph, derives its static face-light +factors from the prepared transforms, and sparsely publishes changed prepared +indices with the merged DOMFORMAT timer/rAF `elapsed` scheduler. Runtime does +no geometry calculation, atlas rasterization, topology rebuilding, or DOM +growth. diff --git a/src/adapters/cityflow/index.html b/src/adapters/cityflow/index.html new file mode 100644 index 0000000..1f2e59b --- /dev/null +++ b/src/adapters/cityflow/index.html @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + Cityflow - Powered by PolyCSS + + + + + + diff --git a/src/adapters/cityflow/notes/provenance-bible.md b/src/adapters/cityflow/notes/provenance-bible.md new file mode 100644 index 0000000..2a237d0 --- /dev/null +++ b/src/adapters/cityflow/notes/provenance-bible.md @@ -0,0 +1,66 @@ +# cssCityflow provenance + +## Authority + +- Project: XScreenSaver Cityflow +- Revision: `906693799e4fb7581436590cf84ecb2d3c9186ba` +- Primary source: `hacks/glx/cityflow.c` +- Primary SHA-256: `9113c9f3214ba6c1f350b3863c306e6015e47a856a17bbe24d597f909dfa027b` +- Configuration: `hacks/config/cityflow.xml` +- Configuration SHA-256: `0453c12e39e4b7d32e66a7780d69979aaaa42d0632a3b6757df9c1cee6c7c919` + +The primary file grants permission to use, copy, modify, distribute, and sell +the software and documentation provided its copyright and permission notices +are preserved. Cityflow has no external model or texture data. + +## Source contract + +The default mode initializes 800 randomly positioned and skewed boxes, six +moving interference sources, a radius of 256, wave speed 25, skew 12, and a +20 ms delay. Although each box is a solid, the source deliberately emits only +its top, front, and right quads; the left and back faces are compiled out and +there is no bottom face. The default retained census is therefore 800 roots +and 2,400 leaves. + +The product uses the source-supported `count` option at 200 boxes in one +prepared bank. The source default remains 800, and the source's +`1.8 / sqrt(count)` box scale is preserved for the selected count. + +The deterministic prepared seed is product authoring input because the native +screen saver normally seeds `ya_random` from process and wall-clock state. The +source equations, random stream, smooth color-map construction, draw cadence, +lighting, and camera remain authoritative. + +## Runtime boundary + +Preparation writes one static Morph package plus source-cadence typed transform +and color-index tables. Every box retains three solid quad leaves. +Because CSS perspective has no OpenGL far plane, preparation derives the side +face cutoff from the exact source model-view sequence and clips the source +`bottom = 5` faces conservatively at eye-space `z = -50`. Each prepared box +matrix carries real initial geometry rather than adapter metadata. Runtime +derives the exact static source-light factors from that matrix, then applies +only changed prepared transform indices and palette classes. Scheduling follows +the merged DOMFORMAT `polycss-playback@0` precedent: a timer waits for the next +deadline, one paint-aligned callback publishes the due state, and `elapsed` +catch-up collapses missed source ticks. There is no runtime geometry, +rasterization, topology rebuilding, or DOM growth. + +## Proof state + +- Source-state oracle: product ticks 0, 73, and 251 match the pinned + C implementation within explicit float tolerance. +- Browser retained-DOM smoke: desktop and mobile viewports mount the same + expected leaf census, keep DOM identity stable, publish prepared transforms, + and construct/redraw no atlases. +- Native visual capture: the headless macOS CGL harness executes the pinned + `cityflow.c` source directly; two 48-frame runs are byte-identical. +- Browser visual parity: 48 source-cadence frames pass the calibrated native / + retained-DOM comparison at mean delta `<= 1`, changed-pixel ratio `<= 0.05`, + and channel threshold `2`. The worst observed mean delta is `0.541659`; the + residual is confined to polygon-edge rasterization. +- FrameSleuth performance on the untouched 1280 x 720 product route records a + `19.728 ms` DrawFrame p50 and `34.213 ms` p95 against the source's `20 ms` + cadence. The only scheduled work is the DOMFORMAT-precedented timer / + paint-aligned callback; publications use prepared data and the retained graph + remains 200 roots / 600 leaves. diff --git a/src/adapters/cityflow/notes/references/source-lock.json b/src/adapters/cityflow/notes/references/source-lock.json new file mode 100644 index 0000000..64aa16a --- /dev/null +++ b/src/adapters/cityflow/notes/references/source-lock.json @@ -0,0 +1,15 @@ +{ + "schema": "csscityflow-source-lock@1", + "project": "XScreenSaver Cityflow", + "repository": "https://github.com/Zygo/xscreensaver", + "revision": "906693799e4fb7581436590cf84ecb2d3c9186ba", + "primary": { + "path": "hacks/glx/cityflow.c", + "sha256": "9113c9f3214ba6c1f350b3863c306e6015e47a856a17bbe24d597f909dfa027b" + }, + "config": { + "path": "hacks/config/cityflow.xml", + "sha256": "0453c12e39e4b7d32e66a7780d69979aaaa42d0632a3b6757df9c1cee6c7c919" + }, + "license": "permissive notice embedded in cityflow.c" +} diff --git a/src/adapters/cityflow/src/csscityflow/client.mjs b/src/adapters/cityflow/src/csscityflow/client.mjs new file mode 100644 index 0000000..6edb39d --- /dev/null +++ b/src/adapters/cityflow/src/csscityflow/client.mjs @@ -0,0 +1,90 @@ +import { createPolyPerspectiveCamera } from "@layoutit/polycss"; +import { loadPolyMorphPackage, mountPolyMorphModel } from "@layoutit/polycss-morph"; +import { + createCityflowPreparedPlayer, + loadCityflowPreparedPlayback, +} from "./preparedPlayback.mjs"; + +export function mountCityflow(host) { + const state = { ready: false, errors: [], bankId: null, mounted: null, player: null }; + globalThis.__csscityflow = state; + main().catch((error) => fail(error)); + + async function main() { + const bankId = "desktop"; + const modelId = "cityflow"; + const [loaded, playback] = await Promise.all([ + loadPolyMorphPackage("/csscityflow/", { modelId }), + loadCityflowPreparedPlayback(`/csscityflow/${modelId}.playback.json`), + loadPreparedStylesheet(`/csscityflow/${modelId}.css`), + ]); + const perspective = innerHeight / (2 * Math.tan(Math.PI / 12)); + const mounted = mountPolyMorphModel(host, loaded.model, { + resources: loaded.resources, + camera: createPolyPerspectiveCamera({ + perspective, + target: [0, 0, 0], + rotX: 0, + rotY: 0, + zoom: 50, + distance: -perspective, + }), + }); + const shapeElements = []; + for (const [index, box] of loaded.model.render.shapes.entries()) { + const element = mounted.shapeElements.get(box.id); + if (!element) throw new Error(`Missing retained Cityflow box ${box.id}`); + const source = readBoxState(loaded.model, index); + element.classList.add("csscityflow-box"); + shapeElements.push(element); + for (const [faceIndex, leaf] of [...element.children].entries()) { + leaf.style.setProperty("--csscityflow-light", source.lightFactors[faceIndex]); + } + } + const player = createCityflowPreparedPlayer({ playback, mounted, shapeElements }); + state.ready = true; + state.bankId = bankId; + state.mounted = mounted; + state.player = player; + document.body.classList.replace("loading", "ready"); + player.resume(); + } + + function fail(error) { + state.errors.push(String(error?.stack || error)); + document.body.classList.remove("loading"); + document.body.classList.add("error"); + console.error(error); + } + return state; +} + +function loadPreparedStylesheet(href) { + return new Promise((resolveLoad, rejectLoad) => { + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = href; + link.addEventListener("load", resolveLoad, { once: true }); + link.addEventListener("error", () => rejectLoad(new Error(`Cityflow stylesheet failed: ${href}`)), { once: true }); + document.head.append(link); + }); +} + +function readBoxState(model, index) { + const shape = model.render.shapes[index]; + const matrix = shape?.matrix; + if (!shape || !Array.isArray(matrix) || matrix.length !== 16) { + throw new Error("Prepared Cityflow box transform is missing"); + } + const width = Math.hypot(matrix[0], matrix[1]); + const cth = matrix[0] / width; + const sth = -matrix[1] / width; + const lightLength = Math.hypot(0, 0.25, -1); + return Object.freeze({ + lightFactors: Object.freeze([ + 0.4 + 1 / lightLength, + 0.4 + Math.max(0, cth * 0.25 / lightLength), + 0.4 + Math.max(0, -sth * 0.25 / lightLength), + ]), + }); +} diff --git a/src/adapters/cityflow/src/csscityflow/preparedPlayback.mjs b/src/adapters/cityflow/src/csscityflow/preparedPlayback.mjs new file mode 100644 index 0000000..ffaf7a4 --- /dev/null +++ b/src/adapters/cityflow/src/csscityflow/preparedPlayback.mjs @@ -0,0 +1,251 @@ +import { createPolyMorphPreparedDomTarget } from "@layoutit/polycss-morph"; + +const MATRIX_PATTERN = /^matrix3d\(-?\d+(?:\.\d+)?(?:,-?\d+(?:\.\d+)?){15}\)$/u; +const decodedPackets = new WeakMap(); + +export function createCityflowPreparedPlayer({ playback, mounted, shapeElements, ...overrides }) { + const decoded = validatePlayback(playback, mounted, shapeElements); + const transforms = playback.transforms; + const transformIndices = decoded.transformIndices; + const colorIndices = decoded.colorIndices; + const frameCount = playback.frameCount; + const boxCount = playback.boxCount; + const frameMilliseconds = playback.tickIntervalUs[0] / playback.tickIntervalUs[1] / 1_000; + const requestFrame = overrides.requestFrame ?? globalThis.requestAnimationFrame.bind(globalThis); + const cancelFrame = overrides.cancelFrame ?? globalThis.cancelAnimationFrame.bind(globalThis); + const requestDelay = overrides.requestDelay ?? globalThis.setTimeout.bind(globalThis); + const cancelDelay = overrides.cancelDelay ?? globalThis.clearTimeout.bind(globalThis); + const readNow = overrides.readNow ?? globalThis.performance.now.bind(globalThis.performance); + const target = createPolyMorphPreparedDomTarget({ + model: { + element: mounted.modelElement, + writeTransform(transform) { + if (mounted.modelElement.style.transform === transform) return false; + mounted.modelElement.style.transform = transform; + return true; + }, + }, + shapes: shapeElements.map((element) => ({ element })), + leaves: [], + }); + const currentTransformIndices = transformIndices.slice(0, boxCount); + const currentColorIndices = colorIndices.slice(0, boxCount); + const colorClasses = Array.from({ length: 256 }, (_, index) => `csscityflow-color-${index}`); + let paused = true; + let timer = null; + let request = null; + let clockOrigin = readNow(); + let tick = 0; + let frameIndex = 0; + let timerCallbackCount = 0; + let animationFrameCallbackCount = 0; + let transformWrites = 0; + let colorWrites = 0; + let collapsedTickCount = 0; + let publicationCount = 0; + let lastTransformWrites = 0; + let lastColorWrites = 0; + + for (let index = 0; index < boxCount; index += 1) { + shapeElements[index].classList.add(colorClasses[currentColorIndices[index]]); + target.shapes[index].writeTransform(transforms[currentTransformIndices[index]]); + } + target.assertStableDomIdentity(); + + function publishFrame(nextFrameIndex) { + const offset = nextFrameIndex * boxCount; + let nextColorWrites = 0; + let nextTransformWrites = 0; + for (let index = 0; index < boxCount; index += 1) { + const nextColorIndex = colorIndices[offset + index]; + if (currentColorIndices[index] === nextColorIndex) continue; + shapeElements[index].classList.replace( + colorClasses[currentColorIndices[index]], + colorClasses[nextColorIndex], + ); + currentColorIndices[index] = nextColorIndex; + nextColorWrites += 1; + } + for (let index = 0; index < boxCount; index += 1) { + const nextTransformIndex = transformIndices[offset + index]; + if (currentTransformIndices[index] === nextTransformIndex) continue; + target.shapes[index].writeTransform(transforms[nextTransformIndex]); + currentTransformIndices[index] = nextTransformIndex; + nextTransformWrites += 1; + } + frameIndex = nextFrameIndex; + publicationCount += 1; + transformWrites += nextTransformWrites; + colorWrites += nextColorWrites; + lastTransformWrites = nextTransformWrites; + lastColorWrites = nextColorWrites; + return frameIndex; + } + + function cancelScheduled() { + if (timer !== null) cancelDelay(timer); + if (request !== null) cancelFrame(request); + timer = null; + request = null; + } + + function schedule() { + if (paused || timer !== null || request !== null) return; + const delay = Math.max(0, clockOrigin + frameMilliseconds - readNow() - 1); + timer = requestDelay(wake, delay); + } + + function wake() { + timer = null; + timerCallbackCount += 1; + if (!paused) request = requestFrame(loop); + } + + function loop(timestamp) { + request = null; + if (paused) return; + animationFrameCallbackCount += 1; + const due = Math.floor(Math.max(0, timestamp - clockOrigin + 0.5) / frameMilliseconds); + if (due > 0) { + tick += due; + collapsedTickCount += Math.max(0, due - 1); + publishFrame(tick % frameCount); + clockOrigin += due * frameMilliseconds; + } + schedule(); + } + + function snapshot() { + target.assertStableDomIdentity(); + mounted.assertStableDomIdentity(); + return Object.freeze({ + schema: "csscityflow-prepared-player-stats@1", + ready: true, + paused, + tick, + frameIndex, + frameCount, + boxCount, + frameMilliseconds, + catchUpPolicy: playback.catchUpPolicy, + timerCallbackCount, + animationFrameCallbackCount, + collapsedTickCount, + publicationCount, + transformWrites, + colorWrites, + initialTransformWrites: boxCount, + lastPublication: Object.freeze({ + frameIndex, + transformWrites: lastTransformWrites, + colorWrites: lastColorWrites, + }), + identityStable: true, + runtimeGeometryCalculationCount: 0, + runtimeAtlasRasterizationCount: 0, + runtimeDomGrowth: false, + }); + } + + return Object.freeze({ + get paused() { return paused; }, + get frameIndex() { return frameIndex; }, + pause() { + paused = true; + cancelScheduled(); + return snapshot(); + }, + resume() { + if (!paused) return snapshot(); + paused = false; + clockOrigin = readNow(); + schedule(); + return snapshot(); + }, + seekFrame(value) { + if (!Number.isSafeInteger(value) || value < 0 || value >= frameCount) { + throw new RangeError("Cityflow prepared frame is out of range"); + } + cancelScheduled(); + tick = value; + publishFrame(value); + clockOrigin = readNow(); + if (!paused) schedule(); + return snapshot(); + }, + assertStableDomIdentity() { + target.assertStableDomIdentity(); + mounted.assertStableDomIdentity(); + return true; + }, + stats: snapshot, + destroy() { + paused = true; + cancelScheduled(); + target.destroy(); + }, + }); +} + +export async function loadCityflowPreparedPlayback(url, fetchImpl = globalThis.fetch) { + const response = await fetchImpl(url, { cache: "no-store" }); + if (!response.ok) throw new Error(`Cityflow prepared playback failed to load: ${response.status}`); + const playback = await response.json(); + validatePacket(playback); + return playback; +} + +function validatePlayback(playback, mounted, shapeElements) { + const decoded = validatePacket(playback); + if (!mounted?.modelElement || typeof mounted.assertStableDomIdentity !== "function" || + !Array.isArray(shapeElements) || shapeElements.length !== playback.boxCount || + shapeElements.some((element) => !(element instanceof HTMLElement))) { + throw new Error("Cityflow retained playback targets drifted"); + } + return decoded; +} + +function validatePacket(playback) { + if (playback?.schema !== "csscityflow-prepared-playback@1" || + playback.precedent !== "domformat@0/polycss-playback@0@cc8da736" || + playback.catchUpPolicy !== "elapsed" || playback.frameCount !== 252 || + !Number.isSafeInteger(playback.boxCount) || playback.boxCount < 1 || + !Array.isArray(playback.tickIntervalUs) || playback.tickIntervalUs.length !== 2 || + playback.tickIntervalUs[0] !== 20_000 || playback.tickIntervalUs[1] !== 1 || + !Array.isArray(playback.transforms) || playback.transforms.length < playback.boxCount || + playback.transforms.some((transform) => typeof transform !== "string" || !MATRIX_PATTERN.test(transform)) || + typeof playback.transformIndicesBase64 !== "string" || + typeof playback.colorIndicesBase64 !== "string") { + throw new Error("Cityflow prepared playback packet drifted"); + } + const cached = decodedPackets.get(playback); + if (cached) return cached; + const expectedCells = playback.frameCount * playback.boxCount; + const transformIndices = decodeUint32(playback.transformIndicesBase64); + const colorIndices = decodeUint8(playback.colorIndicesBase64); + if (transformIndices.length !== expectedCells || colorIndices.length !== expectedCells || + transformIndices.some((index) => index >= playback.transforms.length)) { + throw new Error("Cityflow prepared playback table drifted"); + } + const decoded = Object.freeze({ transformIndices, colorIndices }); + decodedPackets.set(playback, decoded); + return decoded; +} + +function decodeUint8(value) { + const binary = atob(value); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); + return bytes; +} + +function decodeUint32(value) { + const bytes = decodeUint8(value); + if (bytes.byteLength % 4 !== 0) throw new Error("Cityflow uint32 playback table is truncated"); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const values = new Uint32Array(bytes.byteLength / 4); + for (let index = 0; index < values.length; index += 1) { + values[index] = view.getUint32(index * 4, true); + } + return values; +} diff --git a/src/adapters/cityflow/src/csscityflow/styles.css b/src/adapters/cityflow/src/csscityflow/styles.css new file mode 100644 index 0000000..10f45ca --- /dev/null +++ b/src/adapters/cityflow/src/csscityflow/styles.css @@ -0,0 +1,177 @@ +:root { + color-scheme: dark; + background: var(--csscityflow-background, #000); + color: #f2f2f2; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} + +* { + box-sizing: border-box; +} + +html, +body { + width: 100%; + height: 100%; + margin: 0; +} + +body { + position: relative; + overflow: hidden; + background: var(--csscityflow-background, #000); +} + +body.loading::after { + content: ""; + position: fixed; + inset: 50% auto auto 50%; + z-index: 20; + width: 18px; + height: 18px; + margin: -9px 0 0 -9px; + border: 1px solid rgb(240 240 240 / 18%); + border-top-color: rgb(240 240 240 / 70%); + border-radius: 50%; +} + +.site-header { + position: fixed; + inset: 0 0 auto; + z-index: 21; + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + height: 50px; + padding: 0 12px 0 16px; + font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-synthesis: none; + pointer-events: none; +} + +.site-wordmark-heading { + margin: 0; + font: inherit; +} + +.site-wordmark { + display: inline-flex; + align-items: center; + color: #aeb4bc; + opacity: 0.72; + pointer-events: auto; + text-decoration: none; +} + +.site-wordmark-svg { + display: block; + width: 218px; + height: 30px; + overflow: visible; +} + +.site-wordmark-svg text { + fill: currentColor; + font-family: inherit; + font-size: 24px; + font-weight: 400; + letter-spacing: 0.1px; +} + +.site-wordmark-css { font-weight: 500; } +.site-wordmark-graphics { font-weight: 200; } +.site-wordmark-path { font-weight: 100; } + +.site-action-icon-only { + display: inline-flex; + width: 36px; + min-width: 36px; + height: 50px; + align-items: center; + justify-content: center; + padding: 0; + color: #f0f0f0; + background: transparent; + border: 0; + font: 400 13px/1 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + letter-spacing: 0.01em; + cursor: pointer; + opacity: 0.72; + pointer-events: auto; + text-decoration: none; +} + +.site-action-icon { + width: 18px; + height: 18px; + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.8; +} + +.site-action-icon-outline { + stroke: rgb(0 0 0 / 60%); + stroke-width: 4; +} + +.site-wordmark:hover, +.site-wordmark:focus-visible, +.site-action-icon-only:hover, +.site-action-icon-only:focus-visible { + opacity: 1; +} + +.site-wordmark:focus-visible { + outline: 1px solid currentColor; + outline-offset: 4px; +} + +.site-action-icon-only:focus-visible { + outline: 1px solid currentColor; + outline-offset: -5px; +} + +body > .polycss-camera { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: hidden; + contain: layout paint size style; + perspective: calc(50vh / .2679491924) !important; + perspective-origin: 50% 50%; +} + +body > .polycss-camera > .polycss-scene { + transform: translateZ(calc(50vh / .2679491924)) !important; +} + +.polycss-mesh { + position: absolute; + transform-style: preserve-3d; + transform-origin: 0 0 0; +} + +body.loading > .polycss-camera { + visibility: hidden; +} + +body.error::after { + content: "Cityflow failed to load"; + position: fixed; + inset: 50% auto auto 50%; + color: #f88; + translate: -50% -50%; +} + +@media (max-width: 520px) { + .site-header { padding-inline: 12px; } + .site-wordmark-svg { width: 176px; } +} + +@media (max-width: 390px) { + .site-wordmark-svg { width: 148px; } +} diff --git a/src/adapters/cityflow/src/main.mjs b/src/adapters/cityflow/src/main.mjs new file mode 100644 index 0000000..eb0fc4b --- /dev/null +++ b/src/adapters/cityflow/src/main.mjs @@ -0,0 +1,4 @@ +import "./csscityflow/styles.css"; +import { mountCityflow } from "./csscityflow/client.mjs"; + +mountCityflow(document.body); diff --git a/src/adapters/cityflow/src/prepare/csscityflow/model.mjs b/src/adapters/cityflow/src/prepare/csscityflow/model.mjs new file mode 100644 index 0000000..baec713 --- /dev/null +++ b/src/adapters/cityflow/src/prepare/csscityflow/model.mjs @@ -0,0 +1,280 @@ +import { Buffer } from "node:buffer"; +import { + CSSCITYFLOW_FACE_IDS, + CSSCITYFLOW_FRAME_MILLISECONDS, + buildCityflowSourceState, + cityflowFrameAt, +} from "./sourceModel.mjs"; + +const SOURCE_MODEL_MATRIX = Object.freeze([ + 29.600317554756, 6.483735104931, 8.604207095757, 0, + -10.773634514759, 17.813915794524, 23.639864707904, 0, + 0, 25.15701856649, -18.95717322929, 0, + -2.7, -2.7, -30, 1, +]); +export const CSSCITYFLOW_SOURCE_BOTTOM = 5; +export const CSSCITYFLOW_SOURCE_FAR_PLANE = -50; +const CSSCITYFLOW_SOURCE_VIEW = Object.freeze({ + cameraDistance: 30, + worldScale: 15, + floorRotationDegrees: -90, + floorOffsetX: -0.18, + floorOffsetZ: -0.18, + tiltDegrees: 37, + turnDegrees: 20, + boxScale: 2.1, +}); +const FACE_MATRICES = Object.freeze({ + top: Object.freeze([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.5, -0.5, 1, 1]), + front: Object.freeze([1, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, -0.5, 0.5, 0, 1]), + right: Object.freeze([0, 1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0.5, -0.5, 0, 1]), +}); + +export function buildCityflowMorphModel(options = {}) { + const state = buildCityflowSourceState(options); + const initialFrame = cityflowFrameAt(state, 0); + const shapeIds = state.boxes.map((box) => `box-${String(box.renderIndex).padStart(4, "0")}`); + const polygons = []; + const vertices = []; + const leaves = state.boxes.flatMap((box, boxIndex) => CSSCITYFLOW_FACE_IDS.map((faceId, faceIndex) => { + const leafIndex = boxIndex * CSSCITYFLOW_FACE_IDS.length + faceIndex; + const polygonId = `polygon-${String(leafIndex).padStart(4, "0")}`; + const vertexOffset = vertices.length; + vertices.push( + Object.freeze([0, 0, 0]), + Object.freeze([1, 0, 0]), + Object.freeze([1, 1, 0]), + Object.freeze([0, 1, 0]), + ); + polygons.push(Object.freeze({ + id: polygonId, + vertexIndices: Object.freeze([vertexOffset, vertexOffset + 1, vertexOffset + 2, vertexOffset + 3]), + normalIndices: Object.freeze([faceIndex, faceIndex, faceIndex, faceIndex]), + })); + return Object.freeze({ + id: `${shapeIds[boxIndex]}-${faceId}`, + polygonId, + shapeId: shapeIds[boxIndex], + materialId: "cityflow-face", + strategy: "solid-quad", + width: 1, + height: 1, + matrix: FACE_MATRICES[faceId], + atlas: null, + fallback: null, + }); + })); + return Object.freeze({ + state, + model: Object.freeze({ + schema: "polycss-morph.model@1", + identity: Object.freeze({ id: state.source.modelId, name: state.source.name, revision: "1.0.0" }), + profile: "static-prepared", + capabilities: Object.freeze(["retained-render"]), + budgets: Object.freeze({ + maxVertices: vertices.length, + maxPolygons: polygons.length, + maxLeaves: leaves.length, + maxFrames: 0, + maxJoints: 0, + maxResources: 1, + maxBytes: 16 * 1024 * 1024, + }), + topology: Object.freeze({ + vertices: Object.freeze(vertices), + normals: Object.freeze([ + Object.freeze([0, 0, -1]), + Object.freeze([0, 1, 0]), + Object.freeze([1, 0, 0]), + ]), + polygons: Object.freeze(polygons), + }), + materials: Object.freeze([Object.freeze({ + id: "cityflow-face", + color: Object.freeze([1, 1, 1, 1]), + })]), + render: Object.freeze({ + modelMatrix: SOURCE_MODEL_MATRIX, + shapes: Object.freeze(shapeIds.map((id, index) => Object.freeze({ + id, + matrix: boxStateMatrix(state.boxes[index], initialFrame.boxes[index]), + }))), + leaves: Object.freeze(leaves), + }), + deformation: Object.freeze({ kind: "none" }), + controls: Object.freeze([]), + springs: Object.freeze([]), + animations: Object.freeze([]), + playback: null, + provenance: Object.freeze({ + generator: "csscityflow-preparer", + generatorVersion: "1.0.0", + sources: Object.freeze([Object.freeze({ + id: "xscreensaver-cityflow", + kind: "open-data", + uri: `https://github.com/Zygo/xscreensaver/blob/${state.source.commit}/${state.source.primaryPath}`, + sha256: state.source.primarySha256, + license: "XScreenSaver cityflow.c permissive notice", + })]), + }), + }), + }); +} + +function boxStateMatrix(box, sample) { + const clippedBottoms = cityflowClippedBottoms(box); + return Object.freeze(boxPlaybackMatrixValues( + box, + -sample.height / 2, + Math.min(clippedBottoms.front, clippedBottoms.right), + )); +} + +export function cityflowClippedBottoms(box) { + const x = box.centerX; + const y = box.centerY; + const xw = box.cth * box.width / 2; + const xd = box.sth * box.depth / 2; + const yw = -box.sth * box.width / 2; + const yd = box.cth * box.depth / 2; + return Object.freeze({ + front: clippedBottom([ + [x + xw + xd, y + yw + yd], + [x - xw + xd, y - yw + yd], + ]), + right: clippedBottom([ + [x + xw - xd, y + yw - yd], + [x + xw + xd, y + yw + yd], + ]), + }); +} + +function clippedBottom(edge) { + return Math.min(CSSCITYFLOW_SOURCE_BOTTOM, ...edge.map(([x, y]) => { + const eyeZAtZero = cityflowEyeZ(x, y, 0); + const eyeZPerZ = cityflowEyeZ(x, y, 1) - eyeZAtZero; + return (CSSCITYFLOW_SOURCE_FAR_PLANE - eyeZAtZero) / eyeZPerZ; + })); +} + +export function cityflowEyeZ(x, y, z) { + let point = [ + x * CSSCITYFLOW_SOURCE_VIEW.boxScale, + y * CSSCITYFLOW_SOURCE_VIEW.boxScale, + z * CSSCITYFLOW_SOURCE_VIEW.boxScale, + ]; + point = rotateZ(point, CSSCITYFLOW_SOURCE_VIEW.turnDegrees); + point = rotateX(point, CSSCITYFLOW_SOURCE_VIEW.tiltDegrees); + point = [ + point[0] + CSSCITYFLOW_SOURCE_VIEW.floorOffsetX, + point[1], + point[2] + CSSCITYFLOW_SOURCE_VIEW.floorOffsetZ, + ]; + point = rotateX(point, CSSCITYFLOW_SOURCE_VIEW.floorRotationDegrees); + point = point.map((value) => value * CSSCITYFLOW_SOURCE_VIEW.worldScale); + point = rotateX(point, -180); + return point[2] - CSSCITYFLOW_SOURCE_VIEW.cameraDistance; +} + +function rotateX([x, y, z], degrees) { + const radians = degrees * Math.PI / 180; + const cosine = Math.cos(radians); + const sine = Math.sin(radians); + return [x, cosine * y - sine * z, sine * y + cosine * z]; +} + +function rotateZ([x, y, z], degrees) { + const radians = degrees * Math.PI / 180; + const cosine = Math.cos(radians); + const sine = Math.sin(radians); + return [cosine * x - sine * y, sine * x + cosine * y, z]; +} + +export function buildCityflowPreparedCss(state) { + const rules = [ + `:root{--csscityflow-background:${paletteColor(state.palette[0])}}`, + `.csscityflow-box>b{color:rgb(from var(--csscityflow-base) calc(r * var(--csscityflow-light)) calc(g * var(--csscityflow-light)) calc(b * var(--csscityflow-light)))!important}`, + `.csscityflow-box>b:first-child{backface-visibility:visible!important}`, + `.csscityflow-box>b:not(:first-child){backface-visibility:hidden!important}`, + ]; + for (let index = 0; index < state.palette.length; index += 1) { + rules.push(`.csscityflow-color-${index}{--csscityflow-base:${paletteColor(state.palette[index])}}`); + } + return rules.join(""); +} + +export function buildCityflowPreparedPlayback(state) { + const frameCount = 252; + const transformIndices = new Uint32Array(frameCount * state.boxes.length); + const colorIndices = new Uint8Array(frameCount * state.boxes.length); + const transforms = []; + const transformIndexByValue = new Map(); + const bottoms = state.boxes.map((box) => { + const clipped = cityflowClippedBottoms(box); + return Math.min(clipped.front, clipped.right); + }); + for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) { + const frame = cityflowFrameAt(state, frameIndex); + for (let boxIndex = 0; boxIndex < state.boxes.length; boxIndex += 1) { + const sample = frame.boxes[boxIndex]; + const transform = boxPlaybackMatrix( + state.boxes[boxIndex], + -sample.height / 2, + bottoms[boxIndex], + ); + let transformIndex = transformIndexByValue.get(transform); + if (transformIndex === undefined) { + transformIndex = transforms.length; + transformIndexByValue.set(transform, transformIndex); + transforms.push(transform); + } + const offset = frameIndex * state.boxes.length + boxIndex; + transformIndices[offset] = transformIndex; + colorIndices[offset] = sample.colorIndex; + } + } + return Object.freeze({ + schema: "csscityflow-prepared-playback@1", + precedent: "domformat@0/polycss-playback@0@cc8da736", + catchUpPolicy: "elapsed", + frameCount, + tickIntervalUs: Object.freeze([CSSCITYFLOW_FRAME_MILLISECONDS * 1_000, 1]), + boxCount: state.boxes.length, + transforms: Object.freeze(transforms), + transformIndicesBase64: Buffer.from(transformIndices.buffer).toString("base64"), + colorIndicesBase64: Buffer.from(colorIndices.buffer).toString("base64"), + }); +} + +function boxPlaybackMatrix(box, top, bottom) { + return `matrix3d(${boxPlaybackMatrixValues(box, top, bottom).map(formatNumber).join(",")})`; +} + +function boxPlaybackMatrixValues(box, top, bottom) { + return [ + box.cth * box.width, + -box.sth * box.width, + 0, + 0, + box.sth * box.depth, + box.cth * box.depth, + 0, + 0, + 0, + 0, + top - bottom, + 0, + box.centerX, + box.centerY, + bottom, + 1, + ]; +} + +function paletteColor(color) { + return `#${color.map((channel) => Math.round(channel * 255 / 65536).toString(16).padStart(2, "0")).join("")}`; +} + +function formatNumber(value) { + return Number(value.toFixed(9)).toString(); +} diff --git a/src/adapters/cityflow/src/prepare/csscityflow/sourceModel.mjs b/src/adapters/cityflow/src/prepare/csscityflow/sourceModel.mjs new file mode 100644 index 0000000..168acf6 --- /dev/null +++ b/src/adapters/cityflow/src/prepare/csscityflow/sourceModel.mjs @@ -0,0 +1,328 @@ +const RANDOM_VECTOR = Object.freeze([ + 0o35340171546, 0o10401501101, 0o22364657325, 0o24130436022, 0o02167303062, + 0o37570375137, 0o37210607110, 0o16272055420, 0o23011770546, 0o17143426366, + 0o14753657433, 0o21657231332, 0o23553406142, 0o04236526362, 0o10365611275, + 0o07117336710, 0o11051276551, 0o02362132524, 0o01011540233, 0o12162531646, + 0o07056762337, 0o06631245521, 0o14164542224, 0o32633236305, 0o23342700176, + 0o02433062234, 0o15257225043, 0o26762051606, 0o00742573230, 0o05366042132, + 0o12126416411, 0o00520471171, 0o00725646277, 0o20116577576, 0o25765742604, + 0o07633473735, 0o15674255275, 0o17555634041, 0o06503154145, 0o21576344247, + 0o14577627653, 0o02707523333, 0o34146376720, 0o30060227734, 0o13765414060, + 0o36072251540, 0o07255221037, 0o24364674123, 0o06200353166, 0o10126373326, + 0o15664104320, 0o16401041535, 0o16215305520, 0o33115351014, 0o17411670323, +]); + +const SOURCE_COMMON = Object.freeze({ + commit: "906693799e4fb7581436590cf84ecb2d3c9186ba", + primaryPath: "hacks/glx/cityflow.c", + primarySha256: "9113c9f3214ba6c1f350b3863c306e6015e47a856a17bbe24d597f909dfa027b", + configPath: "hacks/config/cityflow.xml", + configSha256: "0453c12e39e4b7d32e66a7780d69979aaaa42d0632a3b6757df9c1cee6c7c919", + delayMicroseconds: 20_000, + skewDegrees: 12, + waveCount: 6, + waveSpeed: 25, + waveRadius: 256, + textureSize: 512, + paletteSize: 256, +}); + +export const CITYFLOW_BANKS = Object.freeze({ + desktop: createBank("desktop", "cityflow", "Cityflow", 200), +}); + +export const CITYFLOW_SOURCE = CITYFLOW_BANKS.desktop; +export const CSSCITYFLOW_SEED = 26081702; +export const CSSCITYFLOW_FRAME_MILLISECONDS = SOURCE_COMMON.delayMicroseconds / 1_000; +export const CSSCITYFLOW_PHASE_STEP = SOURCE_COMMON.waveSpeed / 1_000; +export const CSSCITYFLOW_PHASE_PERIOD = Math.PI * 2; +export const CSSCITYFLOW_FACE_IDS = Object.freeze(["top", "front", "right"]); + +function createBank(id, modelId, name, boxCount) { + return Object.freeze({ ...SOURCE_COMMON, id, modelId, name, boxCount }); +} + +export function resolveCityflowBank(bankId) { + const bank = CITYFLOW_BANKS[bankId]; + if (!bank) throw new RangeError(`Unknown Cityflow prepared bank: ${bankId}`); + return bank; +} + +export function buildCityflowSourceState({ + bankId = "desktop", + seed = CSSCITYFLOW_SEED, +} = {}) { + const source = resolveCityflowBank(bankId); + if (!Number.isSafeInteger(seed) || seed <= 0) throw new RangeError("Cityflow seed must be positive"); + const rng = createYaRandom(seed); + const palette = makeSmoothColorMap(rng, source.paletteSize); + const waves = Object.freeze(Array.from({ length: source.waveCount }, (_, index) => Object.freeze({ + index, + xTheta: rng.frand(Math.PI * 2), + yTheta: rng.frand(Math.PI * 2), + }))); + const scale = f32(1.8 / Math.sqrt(source.boxCount)); + const boxes = []; + let minX = 0; + let maxX = 0; + let minY = 0; + let maxY = 0; + for (let sourceIndex = 0; sourceIndex < source.boxCount; sourceIndex += 1) { + const theta = -rng.frand(source.skewDegrees) * Math.PI / 180; + const x = f32(rng.frand(1) - 0.5); + const y = f32(rng.frand(1) - 0.5); + const z = f32(rng.frand(0.12)); + const cth = f32(Math.cos(theta)); + const sth = f32(Math.sin(theta)); + const width = f32(scale * (rng.frand(1) + 0.2)); + const depth = f32(scale * (rng.frand(1) + 0.2)); + minX = Math.min(minX, x); + maxX = Math.max(maxX, x); + minY = Math.min(minY, y); + maxY = Math.max(maxY, y); + boxes.push({ sourceIndex, x, y, z, cth, sth, width, depth }); + } + boxes.sort((left, right) => Math.trunc(right.y * 10_000) - Math.trunc(left.y * 10_000)); + const preparedBoxes = boxes.map((box, renderIndex) => { + const fx = f32(f32(box.x - minX) / f32(maxX - minX)); + const fy = f32(f32(box.y - minY) / f32(maxY - minY)); + const sampleX = Math.trunc(f32(source.textureSize * fx)) % source.textureSize; + const sampleY = Math.trunc(f32(source.textureSize * fy)) % source.textureSize; + const centerX = f32(f32(box.cth * box.x) + f32(box.sth * box.y)); + const centerY = f32(f32(-box.sth * box.x) + f32(box.cth * box.y)); + return Object.freeze({ + ...box, + renderIndex, + centerX, + centerY, + sampleX, + sampleY, + lightFactors: cityflowLightFactors(box), + }); + }); + return Object.freeze({ + schema: "csscityflow-source-state@1", + seed, + source, + palette, + waves, + bounds: Object.freeze({ minX, maxX, minY, maxY }), + boxes: Object.freeze(preparedBoxes), + }); +} + +export function cityflowFrameAt(sourceState, tick) { + if (sourceState?.schema !== "csscityflow-source-state@1") { + throw new TypeError("Cityflow frame requires prepared source state"); + } + if (!Number.isSafeInteger(tick) || tick < 0) throw new RangeError("Cityflow tick must be non-negative"); + const wavePositions = sourceState.waves.map((wave) => { + let xTheta = wave.xTheta; + let yTheta = wave.yTheta; + for (let index = 0; index <= tick; index += 1) { + xTheta += CSSCITYFLOW_PHASE_STEP; + if (xTheta > CSSCITYFLOW_PHASE_PERIOD) xTheta -= CSSCITYFLOW_PHASE_PERIOD; + yTheta += CSSCITYFLOW_PHASE_STEP; + if (yTheta > CSSCITYFLOW_PHASE_PERIOD) yTheta -= CSSCITYFLOW_PHASE_PERIOD; + } + return Object.freeze({ + xTheta, + yTheta, + x: Math.trunc(sourceState.source.textureSize / 2 + + Math.cos(xTheta) * sourceState.source.textureSize / 2), + y: Math.trunc(sourceState.source.textureSize / 2 + + Math.cos(yTheta) * sourceState.source.textureSize / 2), + }); + }); + const heights = makeWaveHeightTable(sourceState.source); + const boxes = sourceState.boxes.map((box) => { + let value = 0; + for (const wave of wavePositions) { + const dx = box.sampleX - wave.x; + const dy = box.sampleY - wave.y; + const distance = Math.trunc(Math.sqrt(dx * dx + dy * dy)); + value += distance >= sourceState.source.waveRadius ? 0 : heights[distance]; + } + value = Math.trunc(value * 0.4); + if (value > 255) value = 255; + const height = f32(box.z + value / 256 / 2.5 + 0.1); + const colorIndex = Math.trunc(height * sourceState.source.paletteSize * 0.7) % + sourceState.source.paletteSize; + return Object.freeze({ renderIndex: box.renderIndex, sourceIndex: box.sourceIndex, height, colorIndex }); + }); + return Object.freeze({ tick, wavePositions: Object.freeze(wavePositions), boxes: Object.freeze(boxes) }); +} + +export function cityflowLightFactors(box) { + const lightLength = Math.hypot(0, 0.25, -1); + const top = 0.4 + 1 / lightLength; + const front = 0.4 + Math.max(0, box.cth * 0.25 / lightLength); + const right = 0.4 + Math.max(0, -box.sth * 0.25 / lightLength); + return Object.freeze([top, front, right].map((value) => Math.max(0, value))); +} + +export function makeWaveHeightTable(source = CITYFLOW_SOURCE) { + return Object.freeze(Array.from({ length: source.waveRadius }, (_, distance) => { + const maximum = source.paletteSize * (source.waveRadius - distance) / source.waveRadius; + return Math.trunc((maximum + maximum * Math.cos(distance / 50)) / 2); + })); +} + +export function createYaRandom(seed) { + const vector = RANDOM_VECTOR.map((value) => value >>> 0); + let value = seed >>> 0; + vector[0] = (vector[0] + value) >>> 0; + for (let index = 1; index < vector.length; index += 1) { + value = Math.imul(value, 999) >>> 0; + value = rotateLeft(value, 9); + value = (value + Math.imul(vector[index - 1], 1001)) >>> 0; + value = rotateLeft(value, 15); + vector[index] = (vector[index] + value) >>> 0; + } + let index1 = vector[0] % vector.length; + let index2 = (index1 + 24) % vector.length; + return Object.freeze({ + next() { + const result = (vector[index1] + vector[index2]) >>> 0; + vector[index1] = result; + index1 = (index1 + 1) % vector.length; + index2 = (index2 + 1) % vector.length; + return result; + }, + frand(maximum) { + return this.next() * maximum / 0xFFFFFFFF; + }, + }); +} + +function makeSmoothColorMap(rng, colorCount) { + let pointCount; + const selector = rng.next() % 20; + if (selector <= 5) pointCount = 2; + else if (selector <= 15) pointCount = 3; + else if (selector <= 18) pointCount = 4; + else pointCount = 5; + const hues = new Array(pointCount); + const saturations = new Array(pointCount); + const values = new Array(pointCount); + let totalSaturation = 0; + let totalValue = 0; + let loop = 0; + while (true) { + for (let index = 0; index < pointCount; index += 1) { + while (true) { + if (++loop > 10_000) throw new Error("Cityflow source palette selection did not converge"); + hues[index] = rng.next() % 360; + saturations[index] = rng.frand(1); + values[index] = rng.frand(0.8) + 0.2; + if (index === 0) break; + const other = index + 1 === pointCount ? 0 : index - 1; + let hueDistance = Math.abs(hues[other] / 360 - hues[index] / 360); + if (hueDistance > 0.5) hueDistance = 0.5 - (hueDistance - 0.5); + const distance = Math.sqrt(hueDistance * hueDistance + + (saturations[other] - saturations[index]) ** 2 + + (values[other] - values[index]) ** 2); + if (distance >= 0.2) break; + } + totalSaturation += saturations[index]; + totalValue += values[index]; + } + if (totalSaturation / pointCount >= 0.2 && totalValue / pointCount >= 0.3) break; + } + return Object.freeze(makeColorPath(hues, saturations, values, colorCount)); +} + +function makeColorPath(hues, saturations, values, colorCount) { + if (hues.length === 2) return makeColorRamp(hues, saturations, values, colorCount); + const count = hues.length; + const hueDistances = new Array(count); + const edges = new Array(count); + let circumference = 0; + for (let index = 0; index < count; index += 1) { + const next = (index + 1) % count; + let distance = Math.abs((hues[index] - hues[next]) / 360); + if (distance > 0.5) distance = 0.5 - (distance - 0.5); + hueDistances[index] = distance; + } + for (let index = 0; index < count; index += 1) { + const next = (index + 1) % count; + edges[index] = Math.sqrt(hueDistances[index] * hueDistances[next] + + (saturations[next] - saturations[index]) ** 2 + + (values[next] - values[index]) ** 2); + circumference += edges[index]; + } + if (circumference < 0.0001) throw new Error("Cityflow source palette path collapsed"); + const segmentCounts = edges.map((edge) => Math.trunc(colorCount * edge / circumference)); + const hueSteps = new Array(count); + const saturationSteps = new Array(count); + const valueSteps = new Array(count); + for (let index = 0; index < count; index += 1) { + const next = (index + 1) % count; + if (segmentCounts[index] > 0) { + hueSteps[index] = 360 * hueDistances[index] / segmentCounts[index]; + saturationSteps[index] = (saturations[next] - saturations[index]) / segmentCounts[index]; + valueSteps[index] = (values[next] - values[index]) / segmentCounts[index]; + } + } + const colors = []; + for (let index = 0; index < count; index += 1) { + const distance = hues[(index + 1) % count] - hues[index]; + let direction = distance >= 0 ? -1 : 1; + if (distance <= 180 && distance >= -180) direction = -direction; + for (let step = 0; step < segmentCounts[index]; step += 1) { + let hue = hues[index] + step * hueSteps[index] * direction; + if (hue < 0) hue += 360; + colors.push(hsvToRgb(Math.trunc(hue), + saturations[index] + step * saturationSteps[index], + values[index] + step * valueSteps[index])); + } + } + if (colors.length === 0) throw new Error("Cityflow source palette emitted no colors"); + while (colors.length < colorCount) colors.push(colors.at(-1)); + return colors.slice(0, colorCount); +} + +function makeColorRamp(hues, saturations, values, colorCount) { + const halfCount = Math.trunc(colorCount / 2) + 1; + const hueStep = (hues[1] - hues[0]) / halfCount; + const saturationStep = (saturations[1] - saturations[0]) / halfCount; + const valueStep = (values[1] - values[0]) / halfCount; + const colors = new Array(colorCount); + for (let index = 0; index < halfCount; index += 1) { + colors[index] = hsvToRgb( + Math.trunc(hues[0] + index * hueStep), + saturations[0] + index * saturationStep, + values[0] + index * valueStep, + ); + } + for (let index = halfCount; index < colorCount; index += 1) { + colors[index] = colors[colorCount - index]; + } + return colors; +} + +function hsvToRgb(hue, saturation, value) { + const s = Math.max(0, Math.min(1, saturation)); + const v = Math.max(0, Math.min(1, value)); + const h = ((hue % 360) + 360) % 360 / 60; + const sector = Math.trunc(h); + const fraction = h - sector; + const p1 = v * (1 - s); + const p2 = v * (1 - s * fraction); + const p3 = v * (1 - s * (1 - fraction)); + const channels = sector === 0 ? [v, p3, p1] + : sector === 1 ? [p2, v, p1] + : sector === 2 ? [p1, v, p3] + : sector === 3 ? [p1, p2, v] + : sector === 4 ? [p3, p1, v] + : [v, p1, p2]; + return Object.freeze(channels.map((channel) => Math.trunc(channel * 65535))); +} + +function rotateLeft(value, bits) { + return ((value << bits) | (value >>> (32 - bits))) >>> 0; +} + +function f32(value) { + return Math.fround(value); +} diff --git a/src/adapters/cityflow/test/csscityflow-model.test.mjs b/src/adapters/cityflow/test/csscityflow-model.test.mjs new file mode 100644 index 0000000..ab33833 --- /dev/null +++ b/src/adapters/cityflow/test/csscityflow-model.test.mjs @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + CSSCITYFLOW_SOURCE_FAR_PLANE, + buildCityflowMorphModel, + buildCityflowPreparedCss, + buildCityflowPreparedPlayback, + cityflowClippedBottoms, + cityflowEyeZ, +} from "../src/prepare/csscityflow/model.mjs"; + +test("builds one retained root and three solid quad leaves per source box", () => { + for (const [bankId, boxCount] of [["desktop", 200]]) { + const { state, model } = buildCityflowMorphModel({ bankId }); + assert.equal(model.profile, "static-prepared"); + assert.equal(model.render.shapes.length, boxCount); + assert.equal(model.render.leaves.length, boxCount * 3); + assert.equal(model.topology.polygons.length, boxCount * 3); + assert.equal(model.topology.vertices.length, boxCount * 12); + assert.ok(model.render.leaves.every((leaf) => + leaf.strategy === "solid-quad" && leaf.width === 1 && leaf.height === 1 && leaf.atlas === null)); + assert.deepEqual(model.materials, [{ id: "cityflow-face", color: [1, 1, 1, 1] }]); + } +}); + +test("prepares DOMFORMAT-precedented elapsed playback without author keyframes", () => { + const { state } = buildCityflowMorphModel(); + const css = buildCityflowPreparedCss(state); + const playback = buildCityflowPreparedPlayback(state); + assert.equal(playback.schema, "csscityflow-prepared-playback@1"); + assert.equal(playback.precedent, "domformat@0/polycss-playback@0@cc8da736"); + assert.equal(playback.catchUpPolicy, "elapsed"); + assert.deepEqual(playback.tickIntervalUs, [20_000, 1]); + assert.equal(playback.boxCount, 200); + assert.equal( + Buffer.from(playback.transformIndicesBase64, "base64").byteLength, + playback.frameCount * playback.boxCount * Uint32Array.BYTES_PER_ELEMENT, + ); + assert.equal( + Buffer.from(playback.colorIndicesBase64, "base64").byteLength, + playback.frameCount * playback.boxCount * Uint8Array.BYTES_PER_ELEMENT, + ); + assert.ok(playback.transforms.length >= playback.boxCount); + assert.doesNotMatch(css, /@keyframes|animation-/u); + assert.match(css, /\.csscityflow-color-255\{/u); + assert.match(css, /b:first-child\{backface-visibility:visible!important\}/u); + assert.equal((css.match(/hypot\(/gu) ?? []).length, 0); + assert.doesNotMatch(css, /filter|clip-path|mask|linear-gradient|radial-gradient/u); +}); + +test("clips source side faces at the native far plane during preparation", () => { + const { state } = buildCityflowMorphModel(); + for (const box of state.boxes) { + const x = box.centerX; + const y = box.centerY; + const xw = box.cth * box.width / 2; + const xd = box.sth * box.depth / 2; + const yw = -box.sth * box.width / 2; + const yd = box.cth * box.depth / 2; + const bottoms = cityflowClippedBottoms(box); + assert.ok(cityflowEyeZ(x + xw + xd, y + yw + yd, bottoms.front) >= CSSCITYFLOW_SOURCE_FAR_PLANE - 1e-9); + assert.ok(cityflowEyeZ(x - xw + xd, y - yw + yd, bottoms.front) >= CSSCITYFLOW_SOURCE_FAR_PLANE - 1e-9); + assert.ok(cityflowEyeZ(x + xw - xd, y + yw - yd, bottoms.right) >= CSSCITYFLOW_SOURCE_FAR_PLANE - 1e-9); + assert.ok(cityflowEyeZ(x + xw + xd, y + yw + yd, bottoms.right) >= CSSCITYFLOW_SOURCE_FAR_PLANE - 1e-9); + } +}); diff --git a/src/adapters/cityflow/test/csscityflow-runtime-surface.test.mjs b/src/adapters/cityflow/test/csscityflow-runtime-surface.test.mjs new file mode 100644 index 0000000..0df0be2 --- /dev/null +++ b/src/adapters/cityflow/test/csscityflow-runtime-surface.test.mjs @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +test("uses the DOMFORMAT-precedented elapsed deadline playback transport", async () => { + const [client, player] = await Promise.all([ + readFile(new URL("../src/csscityflow/client.mjs", import.meta.url), "utf8"), + readFile(new URL("../src/csscityflow/preparedPlayback.mjs", import.meta.url), "utf8"), + ]); + assert.match(client, /mountPolyMorphModel/u); + assert.match(player, /domformat@0\/polycss-playback@0@cc8da736/u); + assert.match(player, /requestAnimationFrame/u); + assert.match(player, /setTimeout/u); + assert.doesNotMatch(`${client}\n${player}`, /setInterval|canvas|getContext/u); +}); + +test("keeps the route shell minimal and source-named", async () => { + const html = await readFile(new URL("../index.html", import.meta.url), "utf8"); + assert.match(html, /site-wordmark-css/u); + assert.match(html, /site-wordmark-graphics/u); + assert.match(html, /\/cityflow/u); + assert.equal((html.match(/ { + assert.equal(CITYFLOW_BANKS.desktop.boxCount, 200); + assert.equal(CITYFLOW_BANKS.desktop.waveCount, 6); + assert.equal(CITYFLOW_BANKS.desktop.waveRadius, 256); + assert.equal(CITYFLOW_BANKS.desktop.textureSize, 512); + assert.equal(CSSCITYFLOW_FRAME_MILLISECONDS, 20); + assert.deepEqual(CSSCITYFLOW_FACE_IDS, ["top", "front", "right"]); + assert.ok(buildCityflowSourceState().boxes.every((box) => box.lightFactors[0] > 1)); +}); + +test("prepares deterministic source state and exact checkpoint frames", () => { + const left = buildCityflowSourceState(); + const right = buildCityflowSourceState(); + assert.deepEqual(left, right); + assert.equal(left.boxes.length, 200); + assert.equal(left.palette.length, 256); + assert.equal(left.waves.length, 6); + const frame = cityflowFrameAt(left, 73); + assert.equal(frame.boxes.length, 200); + assert.equal(frame.wavePositions.length, 6); + assert.ok(frame.boxes.every(({ height, colorIndex }) => + height >= 0.1 && height < 0.62 && colorIndex >= 0 && colorIndex < 256)); +}); + +test("keeps source front-to-back box ordering", () => { + const state = buildCityflowSourceState(); + for (let index = 1; index < state.boxes.length; index += 1) { + const previous = Math.trunc(state.boxes[index - 1].y * 10_000); + const current = Math.trunc(state.boxes[index].y * 10_000); + assert.ok(previous >= current); + } +}); diff --git a/src/adapters/cityflow/tools/check-prepare-determinism.mjs b/src/adapters/cityflow/tools/check-prepare-determinism.mjs new file mode 100644 index 0000000..ead2f2e --- /dev/null +++ b/src/adapters/cityflow/tools/check-prepare-determinism.mjs @@ -0,0 +1,52 @@ +#!/usr/bin/env node +import { createHash } from "node:crypto"; +import { execFile } from "node:child_process"; +import { readFile, readdir } from "node:fs/promises"; +import { join, relative, resolve } from "node:path"; +import { promisify } from "node:util"; + +const run = promisify(execFile); +const adapterRoot = resolve(import.meta.dirname, ".."); +const repositoryRoot = resolve(adapterRoot, "..", "..", ".."); +const outputRoot = resolve(repositoryRoot, "build/generated/public/csscityflow"); +const prepareScript = resolve(adapterRoot, "tools/prepare-csscityflow.mjs"); + +const first = await prepareAndHash(); +const second = await prepareAndHash(); +if (JSON.stringify(first) !== JSON.stringify(second)) { + throw new Error(`Cityflow preparation is nondeterministic:\n${JSON.stringify({ first, second }, null, 2)}`); +} +console.log(JSON.stringify({ status: "deterministic", ...second }, null, 2)); + +async function prepareAndHash() { + await run(process.execPath, [prepareScript], { + cwd: repositoryRoot, + env: process.env, + timeout: 300_000, + }); + const files = await walk(outputRoot); + const closure = createHash("sha256"); + let bytes = 0; + for (const path of files) { + const relativePath = relative(outputRoot, path); + const contents = await readFile(path); + closure.update(relativePath).update("\0").update(contents).update("\0"); + bytes += contents.length; + } + return Object.freeze({ + sha256: closure.digest("hex"), + bytes, + files: Object.freeze(files.map((path) => relative(outputRoot, path))), + }); +} + +async function walk(root) { + const paths = []; + for (const entry of (await readdir(root, { withFileTypes: true })) + .sort((left, right) => left.name.localeCompare(right.name))) { + const path = join(root, entry.name); + if (entry.isDirectory()) paths.push(...await walk(path)); + else if (entry.isFile()) paths.push(path); + } + return paths; +} diff --git a/src/adapters/cityflow/tools/native/capture-cityflow-state.c b/src/adapters/cityflow/tools/native/capture-cityflow-state.c new file mode 100644 index 0000000..a8fa1bd --- /dev/null +++ b/src/adapters/cityflow/tools/native/capture-cityflow-state.c @@ -0,0 +1,228 @@ +#include +#include +#include +#include +#include "yarandom.h" + +#undef ya_rand_init + +#define MAXPOINTS 50 +#define PALETTE_SIZE 256 +#define WAVE_COUNT 6 +#define WAVE_RADIUS 256 +#define TEXTURE_SIZE 512 +#define WAVE_STEP 0.025 +#define PI 3.14159265358979323846 + +typedef struct { unsigned short red, green, blue; } color; +typedef struct { double xth, yth; int x, y; } wave; +typedef struct { + int source_index; + float x, y, z, cth, sth, w, d; +} cube; + +static void hsv_to_rgb(int h, double s, double v, + unsigned short *r, unsigned short *g, unsigned short *b) { + if (s < 0) s = 0; + if (v < 0) v = 0; + if (s > 1) s = 1; + if (v > 1) v = 1; + double H = (h % 360) / 60.0; + int i = H; + double f = H - i; + double p1 = v * (1 - s); + double p2 = v * (1 - s * f); + double p3 = v * (1 - s * (1 - f)); + double R, G, B; + if (i == 0) { R = v; G = p3; B = p1; } + else if (i == 1) { R = p2; G = v; B = p1; } + else if (i == 2) { R = p1; G = v; B = p3; } + else if (i == 3) { R = p1; G = p2; B = v; } + else if (i == 4) { R = p3; G = p1; B = v; } + else { R = v; G = p1; B = p2; } + *r = R * 65535; + *g = G * 65535; + *b = B * 65535; +} + +static void make_color_ramp(int h1, double s1, double v1, + int h2, double s2, double v2, + color *colors, int color_count) { + int ncolors = color_count / 2 + 1; + double dh = (h2 - h1) / (double)ncolors; + double ds = (s2 - s1) / ncolors; + double dv = (v2 - v1) / ncolors; + for (int i = 0; i < ncolors; i++) + hsv_to_rgb((int)(h1 + i * dh), s1 + i * ds, v1 + i * dv, + &colors[i].red, &colors[i].green, &colors[i].blue); + for (int i = ncolors; i < color_count; i++) colors[i] = colors[color_count - i]; +} + +static void make_color_path(int npoints, int *h, double *s, double *v, + color *colors, int color_count) { + if (npoints == 2) { + make_color_ramp(h[0], s[0], v[0], h[1], s[1], v[1], colors, color_count); + return; + } + int ncolors[MAXPOINTS] = {0}; + double dh[MAXPOINTS] = {0}, ds[MAXPOINTS] = {0}, dv[MAXPOINTS] = {0}; + double hue_distance[MAXPOINTS] = {0}, edge[MAXPOINTS] = {0}; + double circumference = 0; + for (int i = 0; i < npoints; i++) { + int j = (i + 1) % npoints; + double distance = fabs((h[i] - h[j]) / 360.0); + if (distance > 0.5) distance = 0.5 - (distance - 0.5); + hue_distance[i] = distance; + } + for (int i = 0; i < npoints; i++) { + int j = (i + 1) % npoints; + edge[i] = sqrt(hue_distance[i] * hue_distance[j] + + (s[j] - s[i]) * (s[j] - s[i]) + + (v[j] - v[i]) * (v[j] - v[i])); + circumference += edge[i]; + } + for (int i = 0; i < npoints; i++) { + int j = (i + 1) % npoints; + ncolors[i] = color_count * edge[i] / circumference; + if (ncolors[i] > 0) { + dh[i] = 360 * hue_distance[i] / ncolors[i]; + ds[i] = (s[j] - s[i]) / ncolors[i]; + dv[i] = (v[j] - v[i]) / ncolors[i]; + } + } + int output = 0; + for (int i = 0; i < npoints; i++) { + int distance = h[(i + 1) % npoints] - h[i]; + int direction = distance >= 0 ? -1 : 1; + if (distance <= 180 && distance >= -180) direction = -direction; + for (int j = 0; j < ncolors[i]; j++, output++) { + double hue = h[i] + j * dh[i] * direction; + if (hue < 0) hue += 360; + else if (hue > 360) hue -= 0; + hsv_to_rgb((int)hue, s[i] + j * ds[i], v[i] + j * dv[i], + &colors[output].red, &colors[output].green, &colors[output].blue); + } + } + while (output < color_count) { + colors[output] = colors[output - 1]; + output++; + } +} + +static void make_smooth_colormap(color *colors) { + int selector = random() % 20; + int npoints = selector <= 5 ? 2 : selector <= 15 ? 3 : selector <= 18 ? 4 : 5; + int h[MAXPOINTS]; + double s[MAXPOINTS], v[MAXPOINTS]; + double total_s = 0, total_v = 0; + int loop = 0; +repick_all: + for (int i = 0; i < npoints; i++) { +repick_one: + if (++loop > 10000) abort(); + h[i] = random() % 360; + s[i] = frand(1.0); + v[i] = frand(0.8) + 0.2; + if (i > 0) { + int j = i + 1 == npoints ? 0 : i - 1; + double dh = fabs(h[j] / 360.0 - h[i] / 360.0); + if (dh > 0.5) dh = 0.5 - (dh - 0.5); + double distance = sqrt(dh * dh + (s[j] - s[i]) * (s[j] - s[i]) + + (v[j] - v[i]) * (v[j] - v[i])); + if (distance < 0.2) goto repick_one; + } + total_s += s[i]; + total_v += v[i]; + } + if (total_s / npoints < 0.2 || total_v / npoints < 0.3) goto repick_all; + make_color_path(npoints, h, s, v, colors, PALETTE_SIZE); +} + +static int compare_cubes(const void *left, const void *right) { + const cube *a = left, *b = right; + return (int)(b->y * 10000) - (int)(a->y * 10000); +} + +int main(int argc, char **argv) { + unsigned int seed = argc > 1 ? (unsigned int)strtoul(argv[1], 0, 10) : 26081702; + int count = argc > 2 ? atoi(argv[2]) : 800; + int tick = argc > 3 ? atoi(argv[3]) : 0; + ya_rand_init(seed); + color palette[PALETTE_SIZE] = {0}; + make_smooth_colormap(palette); + wave waves[WAVE_COUNT] = {0}; + for (int i = 0; i < WAVE_COUNT; i++) { + waves[i].xth = frand(2.0) * PI; + waves[i].yth = frand(2.0) * PI; + } + cube *cubes = calloc((size_t)count, sizeof(*cubes)); + float minimum_x = 0, maximum_x = 0, minimum_y = 0, maximum_y = 0; + float scale = 1.8 / sqrt(count); + for (int i = 0; i < count; i++) { + double theta = -frand(12) * PI / 180; + cube *item = &cubes[i]; + item->source_index = i; + item->x = frand(1) - 0.5; + item->y = frand(1) - 0.5; + item->z = frand(0.12); + item->cth = cos(theta); + item->sth = sin(theta); + item->w = scale * (frand(1) + 0.2); + item->d = scale * (frand(1) + 0.2); + if (item->x < minimum_x) minimum_x = item->x; + if (item->x > maximum_x) maximum_x = item->x; + if (item->y < minimum_y) minimum_y = item->y; + if (item->y > maximum_y) maximum_y = item->y; + } + qsort(cubes, (size_t)count, sizeof(*cubes), compare_cubes); + int heights[WAVE_RADIUS]; + for (int i = 0; i < WAVE_RADIUS; i++) { + float maximum = PALETTE_SIZE * (WAVE_RADIUS - i) / (float)WAVE_RADIUS; + heights[i] = (maximum + maximum * cos(i / 50.0)) / 2.0; + } + for (int step = 0; step <= tick; step++) { + for (int i = 0; i < WAVE_COUNT; i++) { + waves[i].xth += WAVE_STEP; + if (waves[i].xth > 2 * PI) waves[i].xth -= 2 * PI; + waves[i].yth += WAVE_STEP; + if (waves[i].yth > 2 * PI) waves[i].yth -= 2 * PI; + waves[i].x = TEXTURE_SIZE / 2 + cos(waves[i].xth) * TEXTURE_SIZE / 2; + waves[i].y = TEXTURE_SIZE / 2 + cos(waves[i].yth) * TEXTURE_SIZE / 2; + } + } + printf("{\"seed\":%u,\"count\":%d,\"tick\":%d,", seed, count, tick); + printf("\"palette\":["); + int palette_indices[] = {0, 1, 64, 128, 192, 255}; + for (int i = 0; i < 6; i++) { + color c = palette[palette_indices[i]]; + printf("%s[%u,%u,%u]", i ? "," : "", c.red, c.green, c.blue); + } + printf("],\"waves\":["); + for (int i = 0; i < WAVE_COUNT; i++) + printf("%s[%.17g,%.17g,%d,%d]", i ? "," : "", waves[i].xth, waves[i].yth, waves[i].x, waves[i].y); + printf("],\"boxes\":["); + int sample_count = count < 8 ? count : 8; + for (int i = 0; i < sample_count; i++) { + cube *item = &cubes[i]; + float fx = (item->x - minimum_x) / (maximum_x - minimum_x); + float fy = (item->y - minimum_y) / (maximum_y - minimum_y); + int sample_x = (int)(TEXTURE_SIZE * fx) % TEXTURE_SIZE; + int sample_y = (int)(TEXTURE_SIZE * fy) % TEXTURE_SIZE; + int value = 0; + for (int j = 0; j < WAVE_COUNT; j++) { + int dx = sample_x - waves[j].x, dy = sample_y - waves[j].y; + int distance = sqrt(dx * dx + dy * dy); + value += distance >= WAVE_RADIUS ? 0 : heights[distance]; + } + value *= 0.4; + if (value > 255) value = 255; + float height = item->z + value / 256.0 / 2.5 + 0.1; + int color_index = ((int)(height * PALETTE_SIZE * 0.7)) % PALETTE_SIZE; + printf("%s[%d,%.9g,%.9g,%.9g,%.9g,%.9g,%.9g,%.9g,%d,%d,%.9g,%d]", + i ? "," : "", item->source_index, item->x, item->y, item->z, + item->cth, item->sth, item->w, item->d, sample_x, sample_y, height, color_index); + } + printf("]}\n"); + free(cubes); + return 0; +} diff --git a/src/adapters/cityflow/tools/native/headless/capture-cityflow.c b/src/adapters/cityflow/tools/native/headless/capture-cityflow.c new file mode 100644 index 0000000..e3e9a33 --- /dev/null +++ b/src/adapters/cityflow/tools/native/headless/capture-cityflow.c @@ -0,0 +1,168 @@ +#include +#include +#include +#include + +char *progname = "csscityflow-headless-native-oracle"; + +#define USE_GL 1 +#include "cityflow.c" + +static CGLContextObj capture_context; +static CGLPixelFormatObj capture_pixel_format; +static GLuint capture_framebuffer; +static GLuint capture_colorbuffer; +static GLuint capture_depthbuffer; +static GLXContext capture_glx_context; + +static void create_offscreen_context(int width, int height); +static void destroy_offscreen_context(void); +static int write_ppm(const char *path, int width, int height); +static long parse_positive(const char *text, const char *label); + +int +main(int argc, char **argv) +{ + ModeInfo mi; + unsigned int seed; + int width; + int height; + int frame_count; + char path[4096]; + + if (argc != 7) { + fprintf(stderr, "usage: %s frames-directory seed count width height frames\n", argv[0]); + return 2; + } + seed = (unsigned int)parse_positive(argv[2], "seed"); + width = (int)parse_positive(argv[4], "width"); + height = (int)parse_positive(argv[5], "height"); + frame_count = (int)parse_positive(argv[6], "frames"); + if (mkdir(argv[1], 0777) != 0 && errno != EEXIST) { + perror("mkdir frames"); + return 3; + } + + memset(&mi, 0, sizeof(mi)); + mi.width = width; + mi.height = height; + mi.count = (int)parse_positive(argv[3], "count"); + mi.wireframe_p = False; + mi.fps_p = False; + + create_offscreen_context(width, height); + glBindFramebufferEXT(GL_FRAMEBUFFER_EXT, capture_framebuffer); + glDrawBuffer(GL_COLOR_ATTACHMENT0_EXT); + glReadBuffer(GL_COLOR_ATTACHMENT0_EXT); + +#undef ya_rand_init + ya_rand_init(seed); + wave_count = 6; + wave_speed = 25; + wave_radius = 256; + skew = 12; + init_cube(&mi); + + for (int frame = 0; frame < frame_count; frame++) { + draw_cube(&mi); + snprintf(path, sizeof(path), "%s/frame_%04d.ppm", argv[1], frame); + if (!write_ppm(path, width, height)) return 4; + } + + free_cube(&mi); + free(ccs); + ccs = NULL; + destroy_offscreen_context(); + return 0; +} + +static int +write_ppm(const char *path, int width, int height) +{ + unsigned char *rgba = malloc((size_t)width * (size_t)height * 4); + unsigned char *row = malloc((size_t)width * 3); + FILE *stream; + if (!rgba || !row) abort(); + glFinish(); + glPixelStorei(GL_PACK_ALIGNMENT, 1); + glReadBuffer(GL_COLOR_ATTACHMENT0_EXT); + glReadPixels(0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, rgba); + if (glGetError() != GL_NO_ERROR) return 0; + stream = fopen(path, "wb"); + if (!stream) return 0; + fprintf(stream, "P6\n%d %d\n255\n", width, height); + for (int y = height - 1; y >= 0; y--) { + const unsigned char *source = rgba + (size_t)y * (size_t)width * 4; + for (int x = 0; x < width; x++) { + row[x * 3] = source[x * 4]; + row[x * 3 + 1] = source[x * 4 + 1]; + row[x * 3 + 2] = source[x * 4 + 2]; + } + if (fwrite(row, 1, (size_t)width * 3, stream) != (size_t)width * 3) return 0; + } + free(row); + free(rgba); + return fclose(stream) == 0; +} + +static void +create_offscreen_context(int width, int height) +{ + CGLPixelFormatAttribute attributes[] = { + kCGLPFAOpenGLProfile, (CGLPixelFormatAttribute)kCGLOGLPVersion_Legacy, + kCGLPFAAccelerated, + kCGLPFAAllowOfflineRenderers, + kCGLPFAColorSize, (CGLPixelFormatAttribute)24, + kCGLPFAAlphaSize, (CGLPixelFormatAttribute)8, + kCGLPFADepthSize, (CGLPixelFormatAttribute)24, + (CGLPixelFormatAttribute)0, + }; + GLint count = 0; + if (CGLChoosePixelFormat(attributes, &capture_pixel_format, &count) != kCGLNoError || !capture_pixel_format) abort(); + if (CGLCreateContext(capture_pixel_format, NULL, &capture_context) != kCGLNoError || !capture_context) abort(); + if (CGLSetCurrentContext(capture_context) != kCGLNoError) abort(); + capture_glx_context = capture_context; + + glGenFramebuffersEXT(1, &capture_framebuffer); + glBindFramebufferEXT(GL_FRAMEBUFFER_EXT, capture_framebuffer); + glGenRenderbuffersEXT(1, &capture_colorbuffer); + glBindRenderbufferEXT(GL_RENDERBUFFER_EXT, capture_colorbuffer); + glRenderbufferStorageEXT(GL_RENDERBUFFER_EXT, GL_RGBA8, width, height); + glFramebufferRenderbufferEXT(GL_FRAMEBUFFER_EXT, GL_COLOR_ATTACHMENT0_EXT, GL_RENDERBUFFER_EXT, capture_colorbuffer); + glGenRenderbuffersEXT(1, &capture_depthbuffer); + glBindRenderbufferEXT(GL_RENDERBUFFER_EXT, capture_depthbuffer); + glRenderbufferStorageEXT(GL_RENDERBUFFER_EXT, GL_DEPTH_COMPONENT24, width, height); + glFramebufferRenderbufferEXT(GL_FRAMEBUFFER_EXT, GL_DEPTH_ATTACHMENT_EXT, GL_RENDERBUFFER_EXT, capture_depthbuffer); + if (glCheckFramebufferStatusEXT(GL_FRAMEBUFFER_EXT) != GL_FRAMEBUFFER_COMPLETE_EXT) abort(); +} + +static void +destroy_offscreen_context(void) +{ + if (capture_depthbuffer) glDeleteRenderbuffersEXT(1, &capture_depthbuffer); + if (capture_colorbuffer) glDeleteRenderbuffersEXT(1, &capture_colorbuffer); + if (capture_framebuffer) glDeleteFramebuffersEXT(1, &capture_framebuffer); + CGLSetCurrentContext(NULL); + if (capture_context) CGLDestroyContext(capture_context); + if (capture_pixel_format) CGLDestroyPixelFormat(capture_pixel_format); +} + +static long +parse_positive(const char *text, const char *label) +{ + char *end = NULL; + errno = 0; + long value = strtol(text, &end, 10); + if (errno || !end || *end || value <= 0 || value > INT_MAX) { + fprintf(stderr, "%s must be a positive integer\n", label); + exit(2); + } + return value; +} + +GLXContext *init_GL(ModeInfo *mi) { (void)mi; return &capture_glx_context; } +Bool glXMakeCurrent(Display *display, Window window, GLXContext context) { (void)display; (void)window; (void)context; return True; } +void glXSwapBuffers(Display *display, Window window) { (void)display; (void)window; } +void do_fps(ModeInfo *mi) { (void)mi; } +Bool screenhack_event_helper(Display *display, Window window, XEvent *event) { (void)display; (void)window; (void)event; return False; } +double current_device_rotation(void) { return 0; } diff --git a/src/adapters/cityflow/tools/native/headless/include/screenhackI.h b/src/adapters/cityflow/tools/native/headless/include/screenhackI.h new file mode 100644 index 0000000..47d4505 --- /dev/null +++ b/src/adapters/cityflow/tools/native/headless/include/screenhackI.h @@ -0,0 +1,30 @@ +#ifndef CSSCITYFLOW_HEADLESS_SCREENHACK_I_H +#define CSSCITYFLOW_HEADLESS_SCREENHACK_I_H + +#include +#include +#include +#include +#include + +#define GL_GLEXT_PROTOTYPES 1 +#include +#include +#include + +#ifndef M_PI +#define M_PI 3.14159265358979323846 +#endif + +#define countof(value) (sizeof(value) / sizeof(*(value))) + +typedef int Bool; + +#ifndef False +#define False 0 +#endif +#ifndef True +#define True 1 +#endif + +#endif diff --git a/src/adapters/cityflow/tools/native/headless/include/utils.h b/src/adapters/cityflow/tools/native/headless/include/utils.h new file mode 100644 index 0000000..8c24eeb --- /dev/null +++ b/src/adapters/cityflow/tools/native/headless/include/utils.h @@ -0,0 +1,6 @@ +#ifndef CSSCITYFLOW_HEADLESS_UTILS_H +#define CSSCITYFLOW_HEADLESS_UTILS_H + +#include "xlockmore.h" + +#endif diff --git a/src/adapters/cityflow/tools/native/headless/include/visual.h b/src/adapters/cityflow/tools/native/headless/include/visual.h new file mode 100644 index 0000000..a40c842 --- /dev/null +++ b/src/adapters/cityflow/tools/native/headless/include/visual.h @@ -0,0 +1,10 @@ +#ifndef CSSCITYFLOW_HEADLESS_VISUAL_H +#define CSSCITYFLOW_HEADLESS_VISUAL_H + +#include "xlockmore.h" + +static inline Bool has_writable_cells(Screen *screen, Visual *visual) { + (void)screen; (void)visual; return False; +} + +#endif diff --git a/src/adapters/cityflow/tools/native/headless/include/xlockmore.h b/src/adapters/cityflow/tools/native/headless/include/xlockmore.h new file mode 100644 index 0000000..8dedebc --- /dev/null +++ b/src/adapters/cityflow/tools/native/headless/include/xlockmore.h @@ -0,0 +1,125 @@ +#ifndef CSSCITYFLOW_HEADLESS_XLOCKMORE_H +#define CSSCITYFLOW_HEADLESS_XLOCKMORE_H + +#include "screenhackI.h" +#include "yarandom.h" + +typedef struct Display Display; +typedef struct Screen Screen; +typedef struct Visual Visual; +typedef unsigned long Window; +typedef unsigned long Colormap; +typedef unsigned long KeySym; +typedef void *GLXContext; + +typedef struct { + unsigned long pixel; + unsigned short red; + unsigned short green; + unsigned short blue; + unsigned char flags; + unsigned char pad; +} XColor; + +typedef union XEvent { + struct { int type; } xany; + struct { int type; } xkey; + struct { int type; unsigned int button; unsigned int state; int x; int y; } xbutton; + struct { int type; int x; int y; } xmotion; +} XEvent; + +typedef struct { + Colormap colormap; +} XWindowAttributes; + +typedef struct { + int screen; + int width; + int height; + int count; + Bool wireframe_p; + Bool fps_p; + int polygon_count; + int recursion_depth; + Display *display; + Display *dpy; + Window window; + XWindowAttributes xgwa; +} ModeInfo; + +typedef struct { + const char *option; + const char *specifier; + int arg_kind; + const char *value; +} XrmOptionDescRec; + +typedef struct { + void *var; + const char *name; + const char *class_name; + const char *default_value; + int type; +} argtype; + +typedef struct { + int option_count; + XrmOptionDescRec *options; + int variable_count; + argtype *variables; + void *defaults; +} ModeSpecOpt; + +enum { XrmoptionNoArg = 0, XrmoptionSepArg = 1 }; +enum { t_Bool = 1, t_Float = 2, t_Int = 3 }; +enum { + KeyPress = 2, ButtonPress = 4, ButtonRelease = 5, MotionNotify = 6, + Button1 = 1, + XK_Up = 1, XK_Right = 2, XK_Next = 3, XK_Down = 4, XK_Left = 5, XK_Prior = 6 +}; +enum { DoRed = 1, DoGreen = 2, DoBlue = 4 }; + +#define ENTRYPOINT +#define XSCREENSAVER_MODULE_2(name, symbol, prefix) + +#define MI_SCREEN(mi) ((mi)->screen) +#define MI_WIDTH(mi) ((mi)->width) +#define MI_HEIGHT(mi) ((mi)->height) +#define MI_COUNT(mi) ((mi)->count) +#define MI_IS_WIREFRAME(mi) ((mi)->wireframe_p) +#define MI_DISPLAY(mi) ((mi)->display) +#define MI_WINDOW(mi) ((mi)->window) +#define MI_INIT(mi, storage) do { \ + if (!(storage)) (storage) = calloc(1, sizeof(*(storage))); \ + if (!(storage)) abort(); \ +} while (0) + +#define DisplayOfScreen(screen) ((Display *)0) + +static inline int XLookupString(void *event, char *buffer, int length, KeySym *keysym, void *compose) { + (void)event; (void)keysym; (void)compose; if (length > 0) buffer[0] = 0; return 0; +} +static inline int XAllocColorCells(Display *display, Colormap map, Bool contiguous, unsigned long *planes, unsigned int planeCount, unsigned long *pixels, unsigned int count) { + (void)display; (void)map; (void)contiguous; (void)planes; (void)planeCount; (void)pixels; (void)count; return 0; +} +static inline int XAllocColor(Display *display, Colormap map, XColor *color) { + (void)display; (void)map; (void)color; return 0; +} +static inline void XStoreColors(Display *display, Colormap map, XColor *colors, int count) { + (void)display; (void)map; (void)colors; (void)count; +} +static inline void XFreeColors(Display *display, Colormap map, unsigned long *pixels, int count, unsigned long planes) { + (void)display; (void)map; (void)pixels; (void)count; (void)planes; +} +static inline void XFlush(Display *display) { (void)display; } + +GLXContext *init_GL(ModeInfo *mi); +Bool glXMakeCurrent(Display *display, Window window, GLXContext context); +void glXSwapBuffers(Display *display, Window window); +void do_fps(ModeInfo *mi); +Bool screenhack_event_helper(Display *display, Window window, XEvent *event); +char *get_string_resource(Display *display, char *name, char *class_name); +int XParseColor(Display *display, Colormap colormap, const char *specification, XColor *color); +double current_device_rotation(void); + +#endif diff --git a/src/adapters/cityflow/tools/oracle/run-visual-oracle.mjs b/src/adapters/cityflow/tools/oracle/run-visual-oracle.mjs new file mode 100644 index 0000000..ff5831b --- /dev/null +++ b/src/adapters/cityflow/tools/oracle/run-visual-oracle.mjs @@ -0,0 +1,419 @@ +#!/usr/bin/env node +import { spawn, spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { constants as fsConstants } from "node:fs"; +import { + access, + chmod, + copyFile, + mkdir, + readFile, + readdir, + rename, + rm, + writeFile, +} from "node:fs/promises"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; +import { createServer } from "node:net"; +import { chromium } from "playwright"; +import sharp from "sharp"; +import { + CSSCITYFLOW_FRAME_MILLISECONDS, + CSSCITYFLOW_SEED, + CITYFLOW_SOURCE, +} from "../../src/prepare/csscityflow/sourceModel.mjs"; + +const adapterRoot = resolve(import.meta.dirname, "../.."); +const repositoryRoot = resolve(adapterRoot, "../../.."); +const sourceRoot = resolveRequiredSourceRoot(); +const outputRoot = resolve( + process.env.CSSCITYFLOW_VISUAL_ORACLE_OUT ?? + join(repositoryRoot, "bench/results/csscityflow/native-browser-visual"), +); +const width = 1280; +const height = 720; +const frameCount = readFrameCount(); +const diffFrames = [ + "worst", + ...new Set([0, 0.25, 0.5, 0.75, 1].map((ratio) => Math.round((frameCount - 1) * ratio))), +].join(","); +const sourceLock = JSON.parse(await readFile(join(adapterRoot, "notes/references/source-lock.json"), "utf8")); +const oracleScript = resolve( + process.env.CSS_FRAME_SEQUENCE_ORACLE_SCRIPT ?? + join(process.env.CODEX_HOME ?? join(homedir(), ".codex"), "skills/frame-sequence-oracle/scripts/frame-sequence.mjs"), +); + +await assertSourceIdentity(); +await assertReadable(oracleScript, "frame-sequence oracle"); +await rm(outputRoot, { recursive: true, force: true }); +await mkdir(outputRoot, { recursive: true }); + +const binary = await compileNativeCapture(); +const nativeA = await captureNativeRun(binary, join(outputRoot, "native/run-a")); +const nativeB = await captureNativeRun(binary, join(outputRoot, "native/run-b")); +const nativeAa = await compareSequences({ + expected: nativeA, + actual: nativeB, + out: join(outputRoot, "native/visual-aa"), + label: "csscityflow_native_visual_aa", + thresholds: ["0", "0", "0"], + diffFrames: "worst", +}); +if (!nativeAa.pass) throw new Error("Native Cityflow frame capture is not deterministic"); + +const server = await startBrowserServer(); +let browser; +try { + browser = await chromium.launch({ channel: "chrome", headless: true }); + const browserA = await captureBrowserRun(browser, server.url, join(outputRoot, "browser/run-a")); + const browserB = await captureBrowserRun(browser, server.url, join(outputRoot, "browser/run-b")); + const browserAa = await compareSequences({ + expected: browserA.framesDir, + actual: browserB.framesDir, + out: join(outputRoot, "browser/visual-aa"), + label: "csscityflow_browser_visual_aa", + thresholds: ["0", "0", "0"], + diffFrames: "worst", + }); + if (!browserAa.pass) throw new Error("Browser Cityflow frame capture is not deterministic"); + const nativeBrowser = await compareSequences({ + expected: nativeA, + actual: browserA.framesDir, + out: join(outputRoot, "native-browser-compare"), + label: "csscityflow_native_browser", + thresholds: ["1", "0.05", "2"], + diffFrames, + }); + const worstFrame = nativeBrowser.worst[0]?.frame ?? 0; + const preview = await writeTriptych({ + nativeDir: nativeA, + browserDir: browserA.framesDir, + comparison: nativeBrowser, + frame: worstFrame, + }); + const report = { + schema: "csscityflow-native-browser-visual-oracle@1", + status: nativeBrowser.pass ? "aligned" : "not-aligned", + source: { + revision: sourceLock.revision, + primaryPath: sourceLock.primary.path, + primarySha256: sourceLock.primary.sha256, + }, + seed: CSSCITYFLOW_SEED, + frames: frameCount, + sourceFrameMilliseconds: CSSCITYFLOW_FRAME_MILLISECONDS, + viewport: { width, height, deviceScaleFactor: 1 }, + native: { aaExact: nativeAa.pass, framesDir: nativeA }, + browser: { + aaExact: browserAa.pass, + framesDir: browserA.framesDir, + audit: browserA.audit, + rendererContract: "retained DOM / CSS transforms; no Canvas, WebGL, SVG scene, or runtime rasterization", + }, + comparison: { + pass: nativeBrowser.pass, + thresholds: nativeBrowser.thresholds, + reportPath: nativeBrowser.manifestPath, + worst: nativeBrowser.worst[0] ?? null, + }, + preview, + }; + const reportPath = join(outputRoot, "native-browser-visual-oracle.json"); + await writeJson(reportPath, report); + console.log(JSON.stringify({ ...report, reportPath }, null, 2)); + if (!nativeBrowser.pass) process.exitCode = 1; +} finally { + await browser?.close(); + server.process.kill("SIGTERM"); +} + +async function compileNativeCapture() { + if (process.platform !== "darwin") throw new Error("The Cityflow native visual oracle requires macOS CGL"); + const compiler = process.env.CC || "clang"; + const sdk = resolveSdk(); + const captureSource = join(adapterRoot, "tools/native/headless/capture-cityflow.c"); + const captureInclude = join(adapterRoot, "tools/native/headless/include"); + const inputs = [ + captureSource, + ...["screenhackI.h", "utils.h", "visual.h", "xlockmore.h"].map((name) => join(captureInclude, name)), + join(sourceRoot, sourceLock.primary.path), + ...["gltrackball.c", "gltrackball.h", "trackball.c", "trackball.h", "quaternion.c", "quaternion.h"].map((name) => join(sourceRoot, "hacks/glx", name)), + ...["yarandom.c", "yarandom.h", "colors.c", "colors.h", "hsv.c", "hsv.h"].map((name) => join(sourceRoot, "utils", name)), + ]; + const identity = createHash("sha256").update(compiler).update(sdk); + for (const path of inputs) identity.update(path).update(await readFile(path)); + const buildDir = join(outputRoot, "native-build", identity.digest("hex").slice(0, 20)); + const binary = join(buildDir, "capture-cityflow"); + await mkdir(buildDir, { recursive: true }); + for (const name of ["colors.c", "colors.h", "hsv.c", "hsv.h"]) { + await copyFile(join(sourceRoot, "utils", name), join(buildDir, name)); + } + const temporary = `${binary}.tmp-${process.pid}`; + const result = spawnSync(compiler, [ + "-std=gnu11", "-O2", "-Wno-deprecated-declarations", + "-DHAVE_UNISTD_H", "-DGETTIMEOFDAY_TWO_ARGS", + "-isysroot", sdk, + "-include", join(captureInclude, "xlockmore.h"), + "-I", captureInclude, + "-I", buildDir, + "-I", join(sourceRoot, "hacks/glx"), + "-I", join(sourceRoot, "utils"), + captureSource, + join(sourceRoot, "hacks/glx/gltrackball.c"), + join(sourceRoot, "hacks/glx/trackball.c"), + join(sourceRoot, "hacks/glx/quaternion.c"), + join(sourceRoot, "utils/yarandom.c"), + join(buildDir, "colors.c"), + join(buildDir, "hsv.c"), + "-framework", "OpenGL", "-lm", "-o", temporary, + ], { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 }); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(`Unable to compile native cityflow.c capture:\n${result.stderr || result.stdout}`); + await chmod(temporary, 0o755); + await rename(temporary, binary); + await writeJson(join(buildDir, "native-binding.json"), { + schema: "csscityflow-native-build-binding@1", + compiler, + sdk, + executableSha256: sha256(await readFile(binary)), + inputs: await Promise.all(inputs.map(async (path) => ({ path, sha256: sha256(await readFile(path)) }))), + }); + return binary; +} + +async function captureNativeRun(binary, runRoot) { + const framesDir = join(runRoot, "frames"); + await mkdir(framesDir, { recursive: true }); + const result = spawnSync(binary, [ + framesDir, + String(CSSCITYFLOW_SEED), + String(CITYFLOW_SOURCE.boxCount), + String(width), + String(height), + String(frameCount), + ], { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 }); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(`Native cityflow.c capture failed:\n${result.stderr || result.stdout}`); + const frames = (await readdir(framesDir)).filter((name) => /^frame_\d{4}\.ppm$/u.test(name)); + if (frames.length !== frameCount) throw new Error(`Native capture wrote ${frames.length} of ${frameCount} frames`); + return framesDir; +} + +async function captureBrowserRun(browser, url, runRoot) { + const framesDir = join(runRoot, "frames"); + await mkdir(framesDir, { recursive: true }); + const page = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: 1 }); + const errors = []; + page.on("pageerror", (error) => errors.push(error.stack || error.message)); + page.on("console", (message) => { if (message.type() === "error") errors.push(message.text()); }); + await page.route("**/favicon.ico", (route) => route.fulfill({ status: 204, body: "" })); + try { + await page.goto(url, { waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => globalThis.__csscityflow?.ready || globalThis.__csscityflow?.errors?.length, null, { timeout: 30_000 }); + const initial = await page.evaluate(async () => { + document.querySelector(".site-header")?.remove(); + const roots = [...document.querySelectorAll(".csscityflow-box")]; + const leaves = [...document.querySelectorAll(".csscityflow-box>b")]; + globalThis.__csscityflowOracleIdentity = { roots, leaves }; + globalThis.__csscityflow.player.pause(); + globalThis.__csscityflow.player.seekFrame(1); + await new Promise((resolveFrame) => requestAnimationFrame(() => requestAnimationFrame(resolveFrame))); + return { + roots: roots.length, + leaves: leaves.length, + animations: roots.flatMap((element) => element.getAnimations()).length, + canvas: document.querySelectorAll("canvas").length, + sceneSvg: document.querySelectorAll(".polycss-camera svg").length, + }; + }); + if (errors.length || initial.roots !== CITYFLOW_SOURCE.boxCount || + initial.leaves !== CITYFLOW_SOURCE.boxCount * 3 || initial.animations !== 0 || + initial.canvas !== 0 || initial.sceneSvg !== 0) { + throw new Error(`Cityflow browser oracle is invalid: ${JSON.stringify({ initial, errors })}`); + } + for (let frame = 0; frame < frameCount; frame += 1) { + await page.evaluate(async ({ frameIndex }) => { + globalThis.__csscityflow.player.seekFrame(frameIndex); + await new Promise((resolveFrame) => requestAnimationFrame(() => requestAnimationFrame(resolveFrame))); + }, { frameIndex: frame }); + await page.screenshot({ path: join(framesDir, frameName(frame)) }); + } + const audit = await page.evaluate(() => { + const expected = globalThis.__csscityflowOracleIdentity; + const roots = [...document.querySelectorAll(".csscityflow-box")]; + const leaves = [...document.querySelectorAll(".csscityflow-box>b")]; + return { + stableRootIdentity: roots.every((element, index) => element === expected.roots[index]), + stableLeafIdentity: leaves.every((element, index) => element === expected.leaves[index]), + rootCount: roots.length, + leafCount: leaves.length, + canvasCount: document.querySelectorAll("canvas").length, + sceneSvgCount: document.querySelectorAll(".polycss-camera svg").length, + }; + }); + if (!audit.stableRootIdentity || !audit.stableLeafIdentity || errors.length) { + throw new Error(`Cityflow browser DOM identity drifted: ${JSON.stringify({ audit, errors })}`); + } + await writeJson(join(runRoot, "browser-capture.json"), { + schema: "csscityflow-browser-frame-sequence@1", + url, + frameCount, + viewport: { width, height, deviceScaleFactor: 1 }, + captureMode: "paused-prepared-elapsed-player-source-frame-seek", + audit, + }); + return { framesDir, audit }; + } finally { + await page.close(); + } +} + +async function compareSequences({ expected, actual, out, label, thresholds, diffFrames }) { + const [mean, changed, channel] = thresholds; + const result = spawnSync(process.execPath, [ + oracleScript, + "compare", + "--expected", expected, + "--actual", actual, + "--out", out, + "--replace", + "--label", label, + "--mean-threshold", mean, + "--changed-threshold", changed, + "--channel-threshold", channel, + "--diff-frames", diffFrames, + "--max-frames", String(frameCount), + ], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(result.stderr || result.stdout || `Frame comparison ${label} failed`); + return JSON.parse(await readFile(join(out, `${label}.json`), "utf8")); +} + +async function writeTriptych({ nativeDir, browserDir, comparison, frame }) { + const previewDir = join(outputRoot, "preview"); + await mkdir(previewDir, { recursive: true }); + const nativePath = join(nativeDir, `frame_${String(frame).padStart(4, "0")}.ppm`); + const browserPath = join(browserDir, frameName(frame)); + const diff = comparison.diffs.find((entry) => entry.frame === frame); + if (!diff?.png) throw new Error(`Missing diff for frame ${frame}`); + const nativePreviewPath = join(previewDir, `native-frame-${String(frame).padStart(4, "0")}.png`); + const conversion = spawnSync("sips", ["-s", "format", "png", nativePath, "--out", nativePreviewPath], { + encoding: "utf8", + maxBuffer: 8 * 1024 * 1024, + }); + if (conversion.status !== 0) throw new Error(conversion.stderr || "Unable to convert native Cityflow PPM preview"); + const labels = ["NATIVE cityflow.c", "BROWSER retained DOM", "ABSOLUTE DIFF"]; + const panels = await Promise.all([nativePreviewPath, browserPath, diff.png].map((path) => sharp(path).png().toBuffer())); + const labelSvg = Buffer.from(`${labels.map((label, index) => `${label} · frame ${frame}`).join("")}`); + const triptychPath = join(previewDir, `native-browser-diff-frame-${String(frame).padStart(4, "0")}.png`); + await sharp({ create: { width: width * 3, height: height + 40, channels: 3, background: "#000" } }) + .composite([ + { input: labelSvg, left: 0, top: 0 }, + ...panels.map((input, index) => ({ input, left: index * width, top: 40 })), + ]) + .png() + .toFile(triptychPath); + return { frame, triptychPath }; +} + +async function startBrowserServer() { + const configured = process.env.CSSCITYFLOW_ORACLE_URL; + if (configured) return { url: configured, process: { kill() {} } }; + const port = await freePort(); + let output = ""; + const child = spawn("pnpm", [ + "exec", "vite", + "--config", join(adapterRoot, "vite.config.mjs"), + "--host", "127.0.0.1", + "--port", String(port), + "--strictPort", + ], { cwd: repositoryRoot, stdio: ["ignore", "pipe", "pipe"] }); + child.stdout.on("data", (chunk) => { output += chunk.toString(); }); + child.stderr.on("data", (chunk) => { output += chunk.toString(); }); + await waitFor(() => output.includes("Local:") || output.includes(`127.0.0.1:${port}`), 20_000, () => { + if (child.exitCode !== null) throw new Error(`Cityflow oracle Vite exited early:\n${output}`); + }); + return { url: `http://127.0.0.1:${port}/`, process: child }; +} + +async function assertSourceIdentity() { + const revision = git("rev-parse", "HEAD"); + const primaryBytes = await readFile(join(sourceRoot, sourceLock.primary.path)); + const configBytes = await readFile(join(sourceRoot, sourceLock.config.path)); + if (revision !== sourceLock.revision || sha256(primaryBytes) !== sourceLock.primary.sha256 || + sha256(configBytes) !== sourceLock.config.sha256) { + throw new Error("Cityflow source identity does not match the pinned source lock"); + } +} + +function resolveSdk() { + const configured = process.env.CSSCITYFLOW_MACOS_SDK; + if (configured) return resolve(configured); + const result = spawnSync("xcrun", ["--sdk", "macosx", "--show-sdk-path"], { encoding: "utf8" }); + if (result.status !== 0) throw new Error(result.stderr || "Unable to locate macOS SDK"); + return result.stdout.trim(); +} + +function resolveRequiredSourceRoot() { + const configured = process.env.CSSCITYFLOW_SOURCE_ROOT; + if (!configured) throw new Error("Set CSSCITYFLOW_SOURCE_ROOT to the pinned XScreenSaver checkout"); + return resolve(configured); +} + +function readFrameCount() { + const value = Number(process.env.CSSCITYFLOW_VISUAL_ORACLE_FRAMES ?? 48); + if (!Number.isSafeInteger(value) || value < 1 || value > 252) { + throw new RangeError("Cityflow visual oracle frame count must be an integer from 1 through 252"); + } + return value; +} + +function git(...args) { + const result = spawnSync("git", ["-C", sourceRoot, ...args], { encoding: "utf8" }); + if (result.status !== 0) throw new Error(result.stderr || `git ${args.join(" ")} failed`); + return result.stdout.trim(); +} + +function sha256(bytes) { + return createHash("sha256").update(bytes).digest("hex"); +} + +function frameName(index) { + return `frame_${String(index).padStart(4, "0")}.png`; +} + +async function writeJson(path, value) { + await mkdir(resolve(path, ".."), { recursive: true }); + await writeFile(path, `${JSON.stringify(value, null, 2)}\n`); +} + +async function assertReadable(path, label) { + try { + await access(path, fsConstants.R_OK); + } catch { + throw new Error(`Missing ${label}: ${path}`); + } +} + +function freePort() { + return new Promise((resolvePort, reject) => { + const instance = createServer(); + instance.listen(0, "127.0.0.1", () => { + const address = instance.address(); + const port = typeof address === "object" && address ? address.port : 0; + instance.close(() => resolvePort(port)); + }); + instance.on("error", reject); + }); +} + +async function waitFor(predicate, timeoutMilliseconds, onPoll) { + const started = Date.now(); + while (Date.now() - started < timeoutMilliseconds) { + onPoll(); + if (predicate()) return; + await new Promise((resolveWait) => setTimeout(resolveWait, 100)); + } + throw new Error("Timed out waiting for Cityflow oracle server"); +} diff --git a/src/adapters/cityflow/tools/prepare-csscityflow.mjs b/src/adapters/cityflow/tools/prepare-csscityflow.mjs new file mode 100644 index 0000000..0153919 --- /dev/null +++ b/src/adapters/cityflow/tools/prepare-csscityflow.mjs @@ -0,0 +1,77 @@ +#!/usr/bin/env node +import { createHash } from "node:crypto"; +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { buildPolyMorphCatalog, buildPolyMorphPackage } from "@layoutit/polycss-morph"; +import { + buildCityflowMorphModel, + buildCityflowPreparedCss, + buildCityflowPreparedPlayback, +} from "../src/prepare/csscityflow/model.mjs"; +import { CITYFLOW_BANKS } from "../src/prepare/csscityflow/sourceModel.mjs"; + +const adapterRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const repositoryRoot = resolve(adapterRoot, "..", "..", ".."); +const outputRoot = join(repositoryRoot, "build/generated/public/csscityflow"); +const stagingRoot = join(repositoryRoot, `build/generated/.csscityflow-${process.pid}`); +const sourceRoot = resolveRequiredSourceRoot(); + +await verifySourceLock(); +await rm(stagingRoot, { recursive: true, force: true }); +await mkdir(stagingRoot, { recursive: true }); +const packages = []; +for (const bankId of Object.keys(CITYFLOW_BANKS)) { + const { state, model } = buildCityflowMorphModel({ bankId }); + const built = await buildPolyMorphPackage(model); + const packageRoot = join(stagingRoot, model.identity.id); + await mkdir(packageRoot, { recursive: true }); + for (const [path, bytes] of built.files) await writeBytes(join(packageRoot, path), bytes); + await writeBytes(join(packageRoot, "manifest.json"), built.manifestBytes); + packages.push({ + manifest: built.manifest, + manifestPath: `${model.identity.id}/manifest.json`, + manifestSha256: built.manifestSha256, + }); + await writeFile(join(stagingRoot, `${model.identity.id}.css`), `${buildCityflowPreparedCss(state)}\n`); + await writeFile( + join(stagingRoot, `${model.identity.id}.playback.json`), + `${JSON.stringify(buildCityflowPreparedPlayback(state))}\n`, + ); +} +const catalog = await buildPolyMorphCatalog("cityflow", packages); +await writeBytes(join(stagingRoot, "catalog.json"), catalog.bytes); +await rm(outputRoot, { recursive: true, force: true }); +await mkdir(dirname(outputRoot), { recursive: true }); +await rename(stagingRoot, outputRoot); +console.log(JSON.stringify({ + status: "prepared", + outputRoot, + banks: packages.map(({ manifest }) => ({ id: manifest.identity.id, resources: manifest.resources.length })), +}, null, 2)); + +async function verifySourceLock() { + const head = (await readFile(join(sourceRoot, ".git/HEAD"), "utf8")).trim(); + const revision = head.startsWith("ref: ") + ? (await readFile(join(sourceRoot, ".git", head.slice(5)), "utf8")).trim() + : head; + if (revision !== CITYFLOW_BANKS.desktop.commit) throw new Error(`Cityflow source commit drifted: ${revision}`); + for (const [path, expected] of [ + [CITYFLOW_BANKS.desktop.primaryPath, CITYFLOW_BANKS.desktop.primarySha256], + [CITYFLOW_BANKS.desktop.configPath, CITYFLOW_BANKS.desktop.configSha256], + ]) { + const actual = createHash("sha256").update(await readFile(join(sourceRoot, path))).digest("hex"); + if (actual !== expected) throw new Error(`Cityflow source bytes drifted: ${path}`); + } +} + +function resolveRequiredSourceRoot() { + const configured = process.env.CSSCITYFLOW_SOURCE_ROOT; + if (!configured) throw new Error("Set CSSCITYFLOW_SOURCE_ROOT to the pinned XScreenSaver checkout"); + return resolve(configured); +} + +async function writeBytes(path, bytes) { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, bytes); +} diff --git a/src/adapters/cityflow/tools/run-native-oracle.mjs b/src/adapters/cityflow/tools/run-native-oracle.mjs new file mode 100644 index 0000000..2476a8c --- /dev/null +++ b/src/adapters/cityflow/tools/run-native-oracle.mjs @@ -0,0 +1,83 @@ +#!/usr/bin/env node +import { execFile } from "node:child_process"; +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { buildCityflowSourceState, cityflowFrameAt } from "../src/prepare/csscityflow/sourceModel.mjs"; + +const run = promisify(execFile); +const adapterRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const repositoryRoot = resolve(adapterRoot, "..", "..", ".."); +const sourceRoot = resolveRequiredSourceRoot(); +const outputRoot = join(repositoryRoot, "build/oracles/cityflow/native-state"); +const binary = join(outputRoot, "capture-cityflow-state"); +await mkdir(outputRoot, { recursive: true }); +await run("cc", [ + "-std=c11", "-O2", + "-DHAVE_UNISTD_H", "-DGETTIMEOFDAY_TWO_ARGS", + `-I${join(sourceRoot, "utils")}`, + join(adapterRoot, "tools/native/capture-cityflow-state.c"), + join(sourceRoot, "utils/yarandom.c"), + "-lm", "-o", binary, +]); + +const checks = []; +for (const bankId of ["desktop"]) { + for (const tick of [0, 73, 251]) { + const state = buildCityflowSourceState({ bankId }); + const frame = cityflowFrameAt(state, tick); + const { stdout } = await run(binary, [String(state.seed), String(state.source.boxCount), String(tick)]); + const native = JSON.parse(stdout); + compareState(state, frame, native); + checks.push({ bankId, tick, boxCount: state.boxes.length, status: "exact-with-float-tolerance" }); + await writeFile(join(outputRoot, `${bankId}-tick-${tick}.json`), `${JSON.stringify(native, null, 2)}\n`); + } +} +const report = { schema: "csscityflow-native-state-oracle@1", sourceRoot, checks }; +await writeFile(join(outputRoot, "report.json"), `${JSON.stringify(report, null, 2)}\n`); +console.log(JSON.stringify(report, null, 2)); + +function resolveRequiredSourceRoot() { + const configured = process.env.CSSCITYFLOW_SOURCE_ROOT; + if (!configured) throw new Error("Set CSSCITYFLOW_SOURCE_ROOT for the native Cityflow oracle"); + return resolve(configured); +} + +function compareState(state, frame, native) { + const indices = [0, 1, 64, 128, 192, 255]; + for (const [sampleIndex, paletteIndex] of indices.entries()) { + for (let channel = 0; channel < 3; channel += 1) { + equal(native.palette[sampleIndex][channel], state.palette[paletteIndex][channel], `palette ${paletteIndex}:${channel}`); + } + } + for (let index = 0; index < 6; index += 1) { + close(native.waves[index][0], frame.wavePositions[index].xTheta, 1e-12, `wave ${index} x theta`); + close(native.waves[index][1], frame.wavePositions[index].yTheta, 1e-12, `wave ${index} y theta`); + equal(native.waves[index][2], frame.wavePositions[index].x, `wave ${index} x`); + equal(native.waves[index][3], frame.wavePositions[index].y, `wave ${index} y`); + } + for (let index = 0; index < native.boxes.length; index += 1) { + const actual = native.boxes[index]; + const box = state.boxes[index]; + const rendered = frame.boxes[index]; + equal(actual[0], box.sourceIndex, `box ${index} source index`); + for (const [offset, key] of [[1, "x"], [2, "y"], [3, "z"], [4, "cth"], [5, "sth"], [6, "width"], [7, "depth"]]) { + close(actual[offset], box[key], 2e-7, `box ${index} ${key}`); + } + equal(actual[8], box.sampleX, `box ${index} sample x`); + equal(actual[9], box.sampleY, `box ${index} sample y`); + close(actual[10], rendered.height, 2e-7, `box ${index} height`); + equal(actual[11], rendered.colorIndex, `box ${index} color`); + } +} + +function equal(actual, expected, label) { + if (actual !== expected) throw new Error(`${label}: native ${actual}, prepared ${expected}`); +} + +function close(actual, expected, tolerance, label) { + if (Math.abs(actual - expected) > tolerance) { + throw new Error(`${label}: native ${actual}, prepared ${expected}, tolerance ${tolerance}`); + } +} diff --git a/src/adapters/cityflow/tools/smoke-browser.mjs b/src/adapters/cityflow/tools/smoke-browser.mjs new file mode 100644 index 0000000..cdc84b2 --- /dev/null +++ b/src/adapters/cityflow/tools/smoke-browser.mjs @@ -0,0 +1,113 @@ +#!/usr/bin/env node +import { spawn } from "node:child_process"; +import { mkdir, writeFile } from "node:fs/promises"; +import { createServer } from "node:net"; +import { resolve } from "node:path"; +import { chromium } from "playwright"; + +const adapterRoot = resolve(import.meta.dirname, ".."); +const repositoryRoot = resolve(adapterRoot, "..", "..", ".."); +const outputRoot = resolve(repositoryRoot, "bench/results/csscityflow/smoke"); +const externalUrl = process.env.CSSCITYFLOW_SMOKE_URL; +const port = externalUrl ? null : await freePort(); +const route = externalUrl ?? `http://127.0.0.1:${port}/`; +let server = null; +let serverOutput = ""; +if (!externalUrl) { + server = spawn("pnpm", [ + "exec", "vite", "--config", resolve(adapterRoot, "vite.config.mjs"), + "--host", "127.0.0.1", "--port", String(port), "--strictPort", + ], { cwd: repositoryRoot, stdio: ["ignore", "pipe", "pipe"] }); + server.stdout.on("data", (chunk) => { serverOutput += chunk.toString(); }); + server.stderr.on("data", (chunk) => { serverOutput += chunk.toString(); }); +} + +let browser; +try { + await mkdir(outputRoot, { recursive: true }); + if (server) await waitFor(() => serverOutput.includes("Local:"), 20_000); + browser = await chromium.launch({ channel: "chrome", headless: true }); + const desktop = await capture({ profile: "desktop", width: 1280, height: 720, bankId: "desktop", boxes: 200, leaves: 600 }); + const mobile = await capture({ profile: "mobile", width: 390, height: 844, bankId: "desktop", boxes: 200, leaves: 600 }); + const report = { schema: "csscityflow-browser-smoke@1", route, desktop, mobile }; + await writeFile(resolve(outputRoot, "report.json"), `${JSON.stringify(report, null, 2)}\n`); + console.log(JSON.stringify(report, null, 2)); +} finally { + await browser?.close(); + server?.kill("SIGTERM"); +} + +async function capture({ profile, width, height, bankId, boxes, leaves }) { + const page = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: 1 }); + await page.route("**/favicon.ico", (request) => request.fulfill({ status: 204 })); + const errors = []; + page.on("pageerror", (error) => errors.push(error.stack || error.message)); + page.on("console", (message) => { if (message.type() === "error") errors.push(message.text()); }); + await page.goto(route, { waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => document.body.classList.contains("ready") || + document.body.classList.contains("error"), null, { timeout: 30_000 }); + const before = await page.evaluate(() => { + const state = globalThis.__csscityflow; + const shapes = [...document.querySelectorAll(".csscityflow-box")]; + let stable = true; + try { state?.mounted?.assertStableDomIdentity(); } catch { stable = false; } + return { + status: document.body.className, + ready: state?.ready, + bankId: state?.bankId, + errors: state?.errors ?? [], + stable, + shapeCount: shapes.length, + leafCount: document.querySelectorAll(".csscityflow-box > b").length, + canvasCount: document.querySelectorAll("canvas").length, + svgSceneCount: document.querySelectorAll(".polycss-camera svg").length, + animationName: getComputedStyle(shapes[0]).animationName, + transforms: shapes.slice(0, 40).map((shape) => getComputedStyle(shape).transform), + atlasConstructions: state?.mounted?.stats?.atlasConstructions, + atlasRedraws: state?.mounted?.stats?.atlasRedraws ?? 0, + player: state?.player?.stats(), + }; + }); + await page.waitForTimeout(250); + const after = await page.evaluate(() => { + const shapes = [...document.querySelectorAll(".csscityflow-box")]; + return { + transforms: shapes.slice(0, 40).map((shape) => getComputedStyle(shape).transform), + player: globalThis.__csscityflow?.player?.stats(), + }; + }); + const changedTransformCount = before.transforms.reduce((sum, transform, index) => + sum + Number(transform !== after.transforms[index]), 0); + if (errors.length || before.errors.length || before.status !== "ready" || !before.ready || + before.bankId !== bankId || !before.stable || before.shapeCount !== boxes || + before.leafCount !== leaves || before.canvasCount !== 0 || before.svgSceneCount !== 0 || + before.animationName !== "none" || before.player?.catchUpPolicy !== "elapsed" || + after.player?.timerCallbackCount < 1 || after.player?.animationFrameCallbackCount < 1 || + changedTransformCount === 0 || before.atlasConstructions !== 0 || before.atlasRedraws !== 0) { + throw new Error(`Cityflow ${profile} browser smoke failed: ${JSON.stringify({ before, after, changedTransformCount, errors })}`); + } + const screenshotPath = resolve(outputRoot, `${profile}.png`); + await page.screenshot({ path: screenshotPath }); + await page.close(); + return Object.freeze({ width, height, boxes, leaves, changedTransformCount, screenshotPath }); +} + +async function freePort() { + return new Promise((resolvePort, reject) => { + const socket = createServer(); + socket.unref(); + socket.on("error", reject); + socket.listen(0, "127.0.0.1", () => { + const address = socket.address(); + socket.close(() => resolvePort(address.port)); + }); + }); +} + +async function waitFor(predicate, timeoutMilliseconds) { + const started = Date.now(); + while (!predicate()) { + if (Date.now() - started > timeoutMilliseconds) throw new Error(`Cityflow server did not start:\n${serverOutput}`); + await new Promise((resolveDelay) => setTimeout(resolveDelay, 25)); + } +} diff --git a/src/adapters/cityflow/vite.config.mjs b/src/adapters/cityflow/vite.config.mjs new file mode 100644 index 0000000..ade8c62 --- /dev/null +++ b/src/adapters/cityflow/vite.config.mjs @@ -0,0 +1,18 @@ +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vite"; + +const adapterRoot = fileURLToPath(new URL(".", import.meta.url)); +const repositoryRoot = resolve(adapterRoot, "..", "..", ".."); + +export default defineConfig({ + root: adapterRoot, + publicDir: resolve(repositoryRoot, "build/generated/public"), + server: { host: "127.0.0.1" }, + preview: { host: "127.0.0.1" }, + build: { + target: "es2022", + outDir: resolve(repositoryRoot, "dist/cityflow"), + emptyOutDir: true, + }, +}); From 297afe8e602d7cb3d970c3439cedc6990c6b2942 Mon Sep 17 00:00:00 2001 From: alowpoly Date: Thu, 3 Sep 2026 09:02:44 -0300 Subject: [PATCH 2/2] feat(cityflow): finish prepared adapter and mobile bank --- astro.config.mjs | 2 + netlify.toml | 5 + package.json | 11 +- scripts/copy-deploy-products.mjs | 1 + scripts/frame-sleuth-trace.mjs | 12 +- scripts/frame-sleuth.mjs | 105 +- scripts/frame-sleuth.test.mjs | 29 + scripts/verify-source-only.mjs | 2 + site/pages/cityflow.astro | 6 + site/project-adapters.ts | 1 + site/public/landing/cityflow.webp | Bin 0 -> 47046 bytes site/public/landing/sidebar/cityflow.webp | Bin 0 -> 44530 bytes site/public/projects.json | 18 + site/public/sitemap.xml | 3 + site/scene-router.mjs | 4 + site/test/landing.test.mjs | 4 + src/adapters/cityflow/AGENTS.md | 12 +- src/adapters/cityflow/NOTICE.md | 38 + src/adapters/cityflow/README.md | 115 +- src/adapters/cityflow/index.html | 36 +- .../cityflow/notes/provenance-bible.md | 733 ++++++++++- .../notes/references/prepared-side-depth.json | 40 + .../prepared-static-visibility.json | 221 ++++ ...ibility-adaptive-smooth-sine-balanced.json | 482 +++++++ ...pared-visibility-adaptive-smooth-sine.json | 483 +++++++ .../prepared-visibility-cosine.json | 486 +++++++ ...-visibility-global-motion-uniformized.json | 482 +++++++ .../prepared-visibility-sine-power.json | 484 +++++++ ...pared-visibility-user-stage-2206x1224.json | 480 +++++++ .../references/prepared-visibility-wide.json | 46 + .../notes/references/prepared-visibility.json | 57 + .../notes/references/source-lock.json | 23 +- .../cityflow/src/csscityflow/client.mjs | 187 ++- .../src/csscityflow/deadlineScheduler.mjs | 149 +++ .../src/csscityflow/preparedPlayback.mjs | 805 +++++++++-- .../csscityflow/preparedTransformTable.mjs | 245 ++++ .../src/csscityflow/profileSelection.mjs | 27 + .../src/csscityflow/sourceProjection.mjs | 34 + .../cityflow/src/csscityflow/styles.css | 139 +- src/adapters/cityflow/src/main.mjs | 4 +- .../src/prepare/csscityflow/model.mjs | 1172 ++++++++++++++++- .../prepare/csscityflow/sourceAuthority.mjs | 102 ++ .../src/prepare/csscityflow/sourceModel.mjs | 5 + .../cityflow/test/csscityflow-assets.test.mjs | 184 +++ .../csscityflow-deadline-scheduler.test.mjs | 238 ++++ .../test/csscityflow-deploy.deploy.mjs | 85 ++ .../cityflow/test/csscityflow-model.test.mjs | 344 ++++- .../csscityflow-profile-selection.test.mjs | 53 + .../test/csscityflow-runtime-surface.test.mjs | 131 +- .../test/csscityflow-source-model.test.mjs | 24 + .../csscityflow-source-projection.test.mjs | 27 + .../tools/analyze-motion-smoothness.mjs | 361 +++++ .../tools/audit-sparse-color-publication.mjs | 206 +++ .../cityflow/tools/capture-preview.mjs | 52 + .../tools/check-prepare-determinism.mjs | 1 + .../cityflow/tools/explore-motion-easing.mjs | 232 ++++ .../tools/generate-wide-visibility.mjs | 172 +++ .../tools/native/capture-cityflow-state.c | 1 + .../headless/capture-cityflow-visibility.c | 173 +++ .../tools/native/headless/capture-cityflow.c | 1 + .../native/headless/include/screenhackI.h | 1 + .../tools/native/headless/include/utils.h | 1 + .../tools/native/headless/include/visual.h | 1 + .../tools/native/headless/include/xlockmore.h | 1 + .../oracle/ablate-visibility-diff-faces.mjs | 137 ++ .../tools/oracle/audit-static-suppression.mjs | 116 ++ .../tools/oracle/build-product-visibility.mjs | 173 +++ .../oracle/capture-motion-face-audit.mjs | 481 +++++++ .../oracle/capture-presentation-sequence.mjs | 107 ++ .../oracle/discover-browser-visible-faces.mjs | 133 ++ .../oracle/identify-culled-diff-faces.mjs | 93 ++ .../tools/oracle/run-visual-oracle.mjs | 83 +- .../cityflow/tools/prepare-csscityflow.mjs | 93 +- .../cityflow/tools/run-native-oracle.mjs | 26 +- src/adapters/cityflow/tools/smoke-browser.mjs | 395 +++++- .../cityflow/tools/trace-performance.mjs | 182 +++ src/adapters/cityflow/vite.config.mjs | 25 +- 77 files changed, 11067 insertions(+), 556 deletions(-) create mode 100644 site/pages/cityflow.astro create mode 100644 site/public/landing/cityflow.webp create mode 100644 site/public/landing/sidebar/cityflow.webp create mode 100644 src/adapters/cityflow/NOTICE.md create mode 100644 src/adapters/cityflow/notes/references/prepared-side-depth.json create mode 100644 src/adapters/cityflow/notes/references/prepared-static-visibility.json create mode 100644 src/adapters/cityflow/notes/references/prepared-visibility-adaptive-smooth-sine-balanced.json create mode 100644 src/adapters/cityflow/notes/references/prepared-visibility-adaptive-smooth-sine.json create mode 100644 src/adapters/cityflow/notes/references/prepared-visibility-cosine.json create mode 100644 src/adapters/cityflow/notes/references/prepared-visibility-global-motion-uniformized.json create mode 100644 src/adapters/cityflow/notes/references/prepared-visibility-sine-power.json create mode 100644 src/adapters/cityflow/notes/references/prepared-visibility-user-stage-2206x1224.json create mode 100644 src/adapters/cityflow/notes/references/prepared-visibility-wide.json create mode 100644 src/adapters/cityflow/notes/references/prepared-visibility.json create mode 100644 src/adapters/cityflow/src/csscityflow/deadlineScheduler.mjs create mode 100644 src/adapters/cityflow/src/csscityflow/preparedTransformTable.mjs create mode 100644 src/adapters/cityflow/src/csscityflow/profileSelection.mjs create mode 100644 src/adapters/cityflow/src/csscityflow/sourceProjection.mjs create mode 100644 src/adapters/cityflow/src/prepare/csscityflow/sourceAuthority.mjs create mode 100644 src/adapters/cityflow/test/csscityflow-assets.test.mjs create mode 100644 src/adapters/cityflow/test/csscityflow-deadline-scheduler.test.mjs create mode 100644 src/adapters/cityflow/test/csscityflow-deploy.deploy.mjs create mode 100644 src/adapters/cityflow/test/csscityflow-profile-selection.test.mjs create mode 100644 src/adapters/cityflow/test/csscityflow-source-projection.test.mjs create mode 100644 src/adapters/cityflow/tools/analyze-motion-smoothness.mjs create mode 100644 src/adapters/cityflow/tools/audit-sparse-color-publication.mjs create mode 100644 src/adapters/cityflow/tools/capture-preview.mjs create mode 100644 src/adapters/cityflow/tools/explore-motion-easing.mjs create mode 100644 src/adapters/cityflow/tools/generate-wide-visibility.mjs create mode 100644 src/adapters/cityflow/tools/native/headless/capture-cityflow-visibility.c create mode 100644 src/adapters/cityflow/tools/oracle/ablate-visibility-diff-faces.mjs create mode 100644 src/adapters/cityflow/tools/oracle/audit-static-suppression.mjs create mode 100644 src/adapters/cityflow/tools/oracle/build-product-visibility.mjs create mode 100644 src/adapters/cityflow/tools/oracle/capture-motion-face-audit.mjs create mode 100644 src/adapters/cityflow/tools/oracle/capture-presentation-sequence.mjs create mode 100644 src/adapters/cityflow/tools/oracle/discover-browser-visible-faces.mjs create mode 100644 src/adapters/cityflow/tools/oracle/identify-culled-diff-faces.mjs create mode 100644 src/adapters/cityflow/tools/trace-performance.mjs diff --git a/astro.config.mjs b/astro.config.mjs index 90b27b2..9f47638 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -20,6 +20,7 @@ const generatedAssetRoots = new Map([ "csscyclone/", "csschaos/", "cssblackhole/", + "csscityflow/", ].map((prefix) => [prefix, generatedPublicRoot]), ]); @@ -78,6 +79,7 @@ function generatedAssetsPlugin() { function mediaTypeForPath(path) { return ({ ".avif": "image/avif", + ".css": "text/css; charset=utf-8", ".gz": "application/gzip", ".json": "application/json", ".png": "image/png", diff --git a/netlify.toml b/netlify.toml index a051555..73a3356 100644 --- a/netlify.toml +++ b/netlify.toml @@ -123,6 +123,11 @@ [headers.values] Cache-Control = "public, max-age=31536000, immutable" +[[headers]] + for = "/csscityflow/*" + [headers.values] + Cache-Control = "no-cache" + [[headers]] for = "/csschaos/*.bin.br" [headers.values] diff --git a/package.json b/package.json index e32d29a..5bbcf68 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "build:flipflop:full": "pnpm prepare:flipflop && pnpm build:flipflop", "build:cityflow": "vite build --config src/adapters/cityflow/vite.config.mjs", "build:cityflow:full": "pnpm prepare:cityflow && pnpm build:cityflow", + "build:cityflow:deploy": "CSSCITYFLOW_DEPLOY_BUILD=1 pnpm build:cityflow", "build:flowerbox:full": "pnpm prepare:flowerbox:artifact && pnpm build:flowerbox", "build:gears": "vite build --config src/adapters/gears/vite.config.mjs", "build:gears:full": "pnpm prepare:gears:source && pnpm build:gears", @@ -95,7 +96,7 @@ "build:solitaire": "vite build --config src/adapters/solitaire/vite.config.mjs", "build:solitaire:full": "pnpm prepare:solitaire && pnpm build:solitaire", "build:solitaire:deploy": "CSSSOLITAIRE_DEPLOY_BUILD=1 pnpm build:solitaire", - "build:deploy": "pnpm prepare:3dpipes:artifact && pnpm prepare:cloth:artifact && pnpm prepare:gears:artifact && pnpm prepare:cyclone:artifact && pnpm prepare:galaxy:artifact && pnpm prepare:menger:artifact && pnpm prepare:maze:artifact && pnpm prepare:electropaint:deploy && pnpm prepare:solitaire:artifact && pnpm prepare:luminet && pnpm prepare:chaos && node scripts/copy-deploy-products.mjs && CSSGRAPHICS_DEPLOY_BUILD=1 pnpm build:site", + "build:deploy": "pnpm prepare:3dpipes:artifact && pnpm prepare:cloth:artifact && pnpm prepare:gears:artifact && pnpm prepare:cyclone:artifact && pnpm prepare:galaxy:artifact && pnpm prepare:menger:artifact && pnpm prepare:maze:artifact && pnpm prepare:electropaint:deploy && pnpm prepare:solitaire:artifact && pnpm prepare:luminet && pnpm prepare:chaos && pnpm prepare:cityflow && node scripts/copy-deploy-products.mjs && CSSGRAPHICS_DEPLOY_BUILD=1 pnpm build:site", "prepack": "pnpm build:lib", "preview": "astro preview --host 127.0.0.1", "prepare:super-mario-64": "pnpm build:lib && node scripts/prepare/super-mario-64.mjs", @@ -109,6 +110,7 @@ "prepare:flipflop": "node src/adapters/flipflop/tools/prepare-cssflipflop.mjs", "prepare:cityflow": "node src/adapters/cityflow/tools/prepare-csscityflow.mjs", "prepare:cityflow:check": "node src/adapters/cityflow/tools/check-prepare-determinism.mjs", + "capture:cityflow:preview": "node src/adapters/cityflow/tools/capture-preview.mjs", "prepare:gears:artifact": "node scripts/prepare/cssgears-product-bank.mjs", "prepare:gears:source": "node src/adapters/gears/tools/prepare-cssgears.mjs && node src/adapters/gears/tools/prepare-polycss-snapshot.mjs", "prepare:gravitywell:artifact": "node scripts/prepare/cssgravitywell-product-bank.mjs", @@ -159,9 +161,16 @@ "test:flipflop": "node --test src/adapters/flipflop/test/*.test.mjs", "oracle:flipflop:native": "node src/adapters/flipflop/tools/run-native-oracle.mjs", "test:cityflow": "node --test src/adapters/cityflow/test/*.test.mjs", + "test:cityflow:assets": "node --test src/adapters/cityflow/test/csscityflow-assets.test.mjs", "test:cityflow:browser": "node src/adapters/cityflow/tools/smoke-browser.mjs", + "test:cityflow:sparse-publication": "node src/adapters/cityflow/tools/audit-sparse-color-publication.mjs", + "test:cityflow:deploy": "node --test src/adapters/cityflow/test/csscityflow-deploy.deploy.mjs && node src/adapters/cityflow/tools/smoke-browser.mjs --deploy", "oracle:cityflow:native": "node src/adapters/cityflow/tools/run-native-oracle.mjs", "oracle:cityflow:visual": "node src/adapters/cityflow/tools/oracle/run-visual-oracle.mjs", + "oracle:cityflow:visual:full": "CSSCITYFLOW_VISUAL_ORACLE_FRAMES=251 node src/adapters/cityflow/tools/oracle/run-visual-oracle.mjs", + "audit:cityflow:motion": "node src/adapters/cityflow/tools/oracle/capture-motion-face-audit.mjs", + "audit:cityflow:smoothness": "node src/adapters/cityflow/tools/analyze-motion-smoothness.mjs", + "perf:cityflow:trace": "node src/adapters/cityflow/tools/trace-performance.mjs", "test:flowerbox:deploy": "node src/adapters/flowerbox/tools/smoke-browser.mjs --deploy", "verify:gears:bank": "node src/adapters/gears/tools/verify-product-bank.mjs", "test:gears": "node --test src/adapters/gears/test/cssgears-route-state.test.mjs src/adapters/gears/test/cssgears-shell.test.mjs src/adapters/gears/test/cssgears-source-profile.test.mjs", diff --git a/scripts/copy-deploy-products.mjs b/scripts/copy-deploy-products.mjs index c14a9f4..4ae7bfd 100644 --- a/scripts/copy-deploy-products.mjs +++ b/scripts/copy-deploy-products.mjs @@ -18,6 +18,7 @@ const products = [ [resolve(generatedPublicRoot, "cssmaze"), "cssmaze"], [resolve(generatedPublicRoot, "cssselectropaint"), "cssselectropaint"], [resolve(generatedPublicRoot, "csssolitaire"), "csssolitaire"], + [resolve(generatedPublicRoot, "csscityflow"), "csscityflow"], ]; await rm(deployRoot, { recursive: true, force: true }); diff --git a/scripts/frame-sleuth-trace.mjs b/scripts/frame-sleuth-trace.mjs index 9ec5adf..efcfc09 100644 --- a/scripts/frame-sleuth-trace.mjs +++ b/scripts/frame-sleuth-trace.mjs @@ -150,7 +150,13 @@ export function captureOutputPaths(tracePath) { }); } -export async function captureFrameSleuthTrace(options, { browserType = chromium } = {}) { +export async function captureFrameSleuthTrace(options, { + browserType = chromium, + activatePage = null, +} = {}) { + if (activatePage !== null && typeof activatePage !== "function") { + throw new TypeError("FrameSleuth page activation must be a function"); + } const paths = captureOutputPaths(options.output); await assertOutputsAbsent(paths, options.screenshots, options.frameSleuthFilter === true); await mkdir(dirname(paths.trace), { recursive: true }); @@ -179,6 +185,7 @@ export async function captureFrameSleuthTrace(options, { browserType = chromium let navigationError = null; try { await page.goto(options.url, { waitUntil: "commit", timeout: 30_000 }); + if (activatePage) await activatePage(page); await page.waitForTimeout(Math.max(0, recordingEndsAt - Date.now())); } catch (error) { navigationError = error; @@ -237,7 +244,8 @@ export async function captureFrameSleuthTrace(options, { browserType = chromium }, viewport: options.width == null ? "browser-default" : { width: options.width, height: options.height }, cache: "disabled-fresh-context", - untouchedPage: true, + untouchedPage: activatePage === null, + pageActivation: activatePage === null ? null : "programmatic-after-navigation-commit", categories, screenshotsRequested: options.screenshots, leanCategories: options.lean === true, diff --git a/scripts/frame-sleuth.mjs b/scripts/frame-sleuth.mjs index a958c4f..5be09b0 100644 --- a/scripts/frame-sleuth.mjs +++ b/scripts/frame-sleuth.mjs @@ -96,15 +96,20 @@ export function analyzeTrace(loaded, options = {}) { const rendererEvents = events.filter((event) => event.pid === selection.pid); const mainEvents = rendererEvents.filter((event) => event.tid === selection.mainTid); const compositorEvents = rendererEvents.filter((event) => event.tid === selection.compositorTid); - const rasterTids = new Set( - rendererEvents.filter((event) => event.name === "RasterTask").map((event) => event.tid), + const rasterEvents = events.filter((event) => event.name === "RasterTask"); + const rasterThreadKeys = new Set( + rasterEvents.map((event) => threadKey(event.pid, event.tid)), ); const rafEvents = sortedEvents(mainEvents, "FireAnimationFrame"); const updateEvents = sortedEvents(mainEvents, "UpdateLayoutTree"); const timerEvents = sortedEvents(mainEvents, "TimerFire"); const drawEvents = uniqueTimestampEvents(sortedEvents(compositorEvents, "DrawFrame")); - const presentationEvents = uniqueTimestampEvents(sortedEvents(mainEvents, "AnimationFrame::Presentation")); + const rawPresentationEvents = sortedEvents(mainEvents, "AnimationFrame::Presentation"); + const unboundPresentationEvents = rawPresentationEvents.filter(isUnboundPresentationEvent); + const presentationEvents = uniqueTimestampEvents( + rawPresentationEvents.filter((event) => !isUnboundPresentationEvent(event)), + ); const layerTreeId = dominantValue(drawEvents.map((event) => event.args?.layerTreeId).filter((value) => value != null)); const pipelineRecords = collectPipelineRecords(compositorEvents, layerTreeId); const capabilities = summarizeCapabilities({ @@ -112,6 +117,7 @@ export function analyzeTrace(loaded, options = {}) { mainEvents, compositorEvents, rendererEvents, + rasterEvents, drawEvents, pipelineRecords, selection, @@ -120,6 +126,11 @@ export function analyzeTrace(loaded, options = {}) { if (layerTreeId == null && pipelineRecords.length > 0) { capabilities.warnings.push("DrawFrame events did not identify a dominant layer tree; PipelineReporter records are renderer-process scoped."); } + if (unboundPresentationEvents.length > 0) { + capabilities.warnings.push( + `${unboundPresentationEvents.length} unbound trace-start AnimationFrame::Presentation marker(s) were excluded from cadence.`, + ); + } const cadence = estimateDisplayCadence(compositorEvents, rafEvents, pipelineRecords); const rafStats = summarizeTimestamps(rafEvents.map((event) => event.ts)); const timerStats = summarizeTimestamps(timerEvents.map((event) => event.ts)); @@ -135,7 +146,7 @@ export function analyzeTrace(loaded, options = {}) { frameEvents: indexedFrameEvents.get(frame.index) ?? [], metadata, selection, - rasterTids, + rasterThreadKeys, cadence, rafEvents, updateEvents, @@ -180,7 +191,7 @@ export function analyzeTrace(loaded, options = {}) { frameEvents: indexedTimelineEvents.get(worstTimelineInterval.index) ?? [], metadata, selection, - rasterTids, + rasterThreadKeys, cadence, rafEvents, updateEvents, @@ -248,7 +259,7 @@ export function analyzeTrace(loaded, options = {}) { [1.25, 1.5, 2].map((multiple) => [String(multiple), frameIntervals.filter((frame) => frame.intervalMs > drawBaselineMs * multiple).length]), ), }, - workload: summarizeWorkload({ events, mainEvents, selection, metadata, rasterTids, window }), + workload: summarizeWorkload({ events, mainEvents, selection, metadata, rasterThreadKeys, window }), cpuProfile: { chunkCount: cpuProfile.chunkCount, unresolvedSampleCount: cpuProfile.unresolvedSampleCount, @@ -276,6 +287,7 @@ export function analyzeTrace(loaded, options = {}) { drawBaselineMs, baselineMs: timelineBaselineMs, source: presentationIntervals.length > 0 ? "AnimationFrame::Presentation" : "DrawFrame fallback", + ignoredUnboundEventCount: unboundPresentationEvents.length, stats: timelineStats, worstFrame: worstTimelineFrame, frames: timelineFrames, @@ -293,6 +305,11 @@ export function analyzeTrace(loaded, options = {}) { }; } +function isUnboundPresentationEvent(event) { + const beginFrameId = event.args?.begin_frame_id; + return beginFrameId?.source_id === 0 && beginFrameId?.sequence_number === 0; +} + export function compareAnalyses(before, after) { const metrics = { worstDrawGapMs: [before.cadence.drawFrame.maxMs, after.cadence.drawFrame.maxMs], @@ -356,6 +373,7 @@ export function renderMarkdown(analysis, comparison = null, artifacts = {}) { `DrawFrame-baseline outliers: ${analysis.cadence.drawGapsAboveBaselineMultiples["1.25"]} >1.25×; ${analysis.cadence.drawGapsAboveBaselineMultiples["1.5"]} >1.5×; ${analysis.cadence.drawGapsAboveBaselineMultiples["2"]} >2× the trace's DrawFrame p50.`, `Long main-thread tasks: ${formatAvailabilityCount(analysis.longTasks.available, analysis.longTasks.count)}. Pipeline drops: ${formatAvailabilityCount(analysis.pipeline.available, analysis.pipeline.droppedCount)}${analysis.pipeline.available ? ` (${analysis.pipeline.severeDrawStallAlignedCount} aligned with a severe DrawFrame stall)` : ""}.`, `Renderer-main GC: ${analysis.garbageCollection.eventCount} slices, ${formatMs(analysis.garbageCollection.occupancyMs)} occupancy, ${formatMs(analysis.garbageCollection.totalMs)} inclusive nested time, ${formatMs(analysis.garbageCollection.maxMs)} max slice.`, + rasterEvidenceSummary(analysis.capabilities.signals.raster), ]; if (artifacts.frameChart) { @@ -848,8 +866,17 @@ function selectRequestedFrame(frameDetails, slowest, window, options) { return null; } -function summarizeCapabilities({ loadedEvents, mainEvents, compositorEvents, rendererEvents, drawEvents, pipelineRecords, selection, cpuProfile }) { +function summarizeCapabilities({ loadedEvents, mainEvents, compositorEvents, rendererEvents, rasterEvents, drawEvents, pipelineRecords, selection, cpuProfile }) { const screenshotCount = loadedEvents.filter((event) => event.name === "Screenshot" && screenshotPayload(event)).length; + const rendererRasterEventCount = rasterEvents.filter((event) => event.pid === selection.pid).length; + const globalRasterEventCount = rasterEvents.length - rendererRasterEventCount; + const rasterScope = rendererRasterEventCount > 0 && globalRasterEventCount > 0 + ? "mixed-global-nonexclusive" + : globalRasterEventCount > 0 + ? "global-nonexclusive" + : rendererRasterEventCount > 0 + ? "selected-renderer" + : "not-captured"; const capabilities = { drawFrame: capability(drawEvents.length, "compositor frame-spacing analysis", 2), beginFrame: capability(countNamed(compositorEvents, "BeginFrame"), "display-cadence estimation"), @@ -857,7 +884,12 @@ function summarizeCapabilities({ loadedEvents, mainEvents, compositorEvents, ren runTask: capability(mainEvents.filter((event) => isComplete(event) && isTask(event)).length, "main-thread long-task exclusion"), pipelineReporter: capability(pipelineRecords.length, "dropped-frame marker analysis"), paint: capability(countNamed(rendererEvents, "Paint"), "paint-cost analysis"), - raster: capability(countNamed(rendererEvents, "RasterTask"), "raster-cost analysis"), + raster: { + ...capability(rasterEvents.length, "raster-cost analysis with trace-scope qualification"), + scope: rasterScope, + rendererEventCount: rendererRasterEventCount, + globalEventCount: globalRasterEventCount, + }, functionCall: capability(countNamed(mainEvents, "FunctionCall"), "JavaScript function attribution"), cpuProfile: capability(cpuProfile.samples.length, "sampled JavaScript self-time attribution"), screenshots: capability(screenshotCount, "visual evidence extraction"), @@ -868,6 +900,11 @@ function summarizeCapabilities({ loadedEvents, mainEvents, compositorEvents, ren if (!capabilities.pipelineReporter.available) warnings.push("PipelineReporter was not captured; dropped-frame marker conclusions are unavailable."); if (!capabilities.paint.available) warnings.push("Paint events were not captured; paint cost is unknown, not zero."); if (!capabilities.raster.available) warnings.push("RasterTask events were not captured; raster cost is unknown, not zero."); + if (capabilities.raster.globalEventCount > 0) { + warnings.push( + `${capabilities.raster.globalEventCount} RasterTask event(s) were captured outside the selected renderer; their timing is global/nonexclusive and cannot be attributed exclusively to the selected page.`, + ); + } if (!capabilities.cpuProfile.available) warnings.push("V8 CPU profile samples were not captured; JavaScript inside task and microtask envelopes may be opaque."); if (!capabilities.beginFrame.available) warnings.push("BeginFrame was not captured; display cadence uses a weaker fallback signal."); if (!capabilities.screenshots.available) warnings.push("Screenshot events were not captured; FrameSleuth cannot provide visual evidence for this trace."); @@ -1114,16 +1151,18 @@ export async function extractFrameScreenshots(loaded, analysis, directory) { } function analyzeFrame(context) { - const { frame, frameEvents, metadata, selection, rasterTids, cadence, rafEvents, updateEvents, timerToRaf, pipelineRecords, traceStartTs, capabilities, schedulerCoupling, drawBaselineMs, cpuProfile, intervalKind = "DrawFrame" } = context; + const { frame, frameEvents, metadata, selection, rasterThreadKeys, cadence, rafEvents, updateEvents, timerToRaf, pipelineRecords, traceStartTs, capabilities, schedulerCoupling, drawBaselineMs, cpuProfile, intervalKind = "DrawFrame" } = context; const attributed = frameEvents.filter((event) => { if (!isFocusEvent(event.name)) return false; - const role = roleFor(event, selection, metadata, rasterTids); - return role.startsWith("renderer-") || (role === "gpu-global" && event.name === "GPUTask"); + const role = roleFor(event, selection, metadata, rasterThreadKeys); + return role.startsWith("renderer-") || + (role === "gpu-global" && event.name === "GPUTask") || + (role === "raster-global" && event.name === "RasterTask"); }); - const namedWork = aggregateRoleNamedWork(attributed, frame, (event) => roleFor(event, selection, metadata, rasterTids)); + const namedWork = aggregateRoleNamedWork(attributed, frame, (event) => roleFor(event, selection, metadata, rasterThreadKeys)); const roleIntervals = new Map(); for (const event of frameEvents) { - const role = roleFor(event, selection, metadata, rasterTids); + const role = roleFor(event, selection, metadata, rasterThreadKeys); if (role === "other-global") continue; const clipped = clipInterval(event, frame.startTs, frame.endTs); const intervals = roleIntervals.get(role) ?? []; @@ -1226,11 +1265,12 @@ function diagnoseFrame({ frame, metrics, cadence, timerPairs, framePipeline, cap }); } if (metrics.rasterTotalMs > budget * 0.6 || metrics.rasterMaxMs > budget * 0.3) { + const globalRaster = capabilities.signals.raster.scope !== "selected-renderer"; diagnoses.push({ id: "raster-heavy", - label: "raster pressure", - confidence: "high", - evidence: `RasterTask consumed ${formatMs(metrics.rasterTotalMs)} inclusive, with a ${formatMs(metrics.rasterMaxMs)} maximum event.`, + label: globalRaster ? "global raster pressure" : "raster pressure", + confidence: globalRaster ? "low" : "high", + evidence: `${globalRaster ? "Global/nonexclusive " : ""}RasterTask consumed ${formatMs(metrics.rasterTotalMs)} inclusive, with a ${formatMs(metrics.rasterMaxMs)} maximum event.${globalRaster ? " Chrome did not bind this work exclusively to the selected page." : ""}`, }); } if (metrics.gcOccupancyMs > budget * 0.2 || metrics.gcMaxMs > Math.max(2, budget * 0.15)) { @@ -1260,18 +1300,20 @@ function diagnoseFrame({ frame, metrics, cadence, timerPairs, framePipeline, cap id: "no-dominant-cpu-work", label: "no dominant captured CPU work", confidence: missing.length === 0 ? "medium" : "low", - evidence: `the ${formatMs(frame.intervalMs)} ${intervalKind} interval contains ${formatMs(metrics.mainBusyMs)} of renderer-main work, Paint max ${formatMs(metrics.paintMaxMs)}, and RasterTask max ${formatMs(metrics.rasterMaxMs)}.${missing.length ? ` Missing evidence: ${missing.join(", ")}.` : ""}`, + evidence: `the ${formatMs(frame.intervalMs)} ${intervalKind} interval contains ${formatMs(metrics.mainBusyMs)} of renderer-main work, Paint max ${formatMs(metrics.paintMaxMs)}, and RasterTask max ${formatMs(metrics.rasterMaxMs)}${capabilities.signals.raster.scope.includes("global") ? " at global/nonexclusive trace scope" : ""}.${missing.length ? ` Missing evidence: ${missing.join(", ")}.` : ""}`, }); } return diagnoses; } -function summarizeWorkload({ events, mainEvents, selection, metadata, rasterTids, window }) { +function summarizeWorkload({ events, mainEvents, selection, metadata, rasterThreadKeys, window }) { const named = {}; const attributable = events.filter((event) => { if (!isComplete(event) || !isFocusEvent(event.name)) return false; - const role = roleFor(event, selection, metadata, rasterTids); - return role.startsWith("renderer-") || (role === "gpu-global" && event.name === "GPUTask"); + const role = roleFor(event, selection, metadata, rasterThreadKeys); + return role.startsWith("renderer-") || + (role === "gpu-global" && event.name === "GPUTask") || + (role === "raster-global" && event.name === "RasterTask"); }); for (const entry of aggregateNamedWork(attributable, window)) { named[entry.name] = entry; @@ -1279,7 +1321,7 @@ function summarizeWorkload({ events, mainEvents, selection, metadata, rasterTids const roles = {}; const byRole = new Map(); for (const event of events.filter(isComplete)) { - const role = roleFor(event, selection, metadata, rasterTids); + const role = roleFor(event, selection, metadata, rasterThreadKeys); if (role === "other-global") continue; const intervals = byRole.get(role) ?? []; intervals.push(clipInterval(event, window.startTs, window.endTs)); @@ -1384,6 +1426,9 @@ function answerQuestion(analysis, frame, comparison) { if (analysis.focus === "rendering") { const paint = analysis.capabilities.signals.paint.available ? formatMs(analysis.workload.named.Paint?.maxMs) : "not captured"; const raster = analysis.capabilities.signals.raster.available ? formatMs(analysis.workload.named.RasterTask?.maxMs) : "not captured"; + const rasterScope = analysis.capabilities.signals.raster.scope.includes("global") + ? " at global/nonexclusive trace scope" + : ""; const lightingConclusion = frame && /lighting/iu.test(analysis.question ?? "") ? frame.metrics.paintMaxMs < analysis.cadence.display.ms * 0.1 && frame.metrics.rasterMaxMs < analysis.cadence.display.ms * 0.1 ? `Captured paint/raster evidence does not implicate lighting in this performance stall; the interval's primary evidence is ${frame.diagnoses[0].label}. ` @@ -1392,7 +1437,7 @@ function answerQuestion(analysis, frame, comparison) { const selected = frame ? ` On the selected interval they maxed at ${formatMs(frame.metrics.paintMaxMs)} and ${formatMs(frame.metrics.rasterMaxMs)}.` : " No DrawFrame interval was available for frame-local attribution."; - return `${lightingConclusion}Style/layout published ${analysis.cadence.styleAndLayout.eventCount} times at p50 ${formatMs(analysis.cadence.styleAndLayout.p50Ms)}. Across the trace, Paint max was ${paint} and RasterTask max was ${raster}.${selected}`; + return `${lightingConclusion}Style/layout published ${analysis.cadence.styleAndLayout.eventCount} times at p50 ${formatMs(analysis.cadence.styleAndLayout.p50Ms)}. Across the trace, Paint max was ${paint} and RasterTask max was ${raster}${rasterScope}.${selected}`; } if (analysis.focus === "scheduler") { return `rAF p50/max were ${formatMs(analysis.cadence.requestAnimationFrame.p50Ms)}/${formatMs(analysis.cadence.requestAnimationFrame.maxMs)}. Timer/rAF handoff pattern: ${analysis.cadence.schedulerCoupling.coupled ? "compatible" : "not supported"}; correlated timer → next-rAF delay max ${formatMs(analysis.cadence.timerToRafDelay.maxMs)}.${frame ? ` ${frame.diagnoses[0].evidence}` : " No DrawFrame interval was available for local attribution."}`; @@ -1457,16 +1502,18 @@ function inferQuestionFocus(question) { if (/javascript|function|script|\bjs\b/iu.test(question)) return "javascript"; if (/\bgpu\b|compositor/iu.test(question)) return "gpu"; if (/screenshot|visual|image|show.*frame/iu.test(question)) return "screenshots"; - if (/coverage|captur|missing|trust|evidence/iu.test(question)) return "coverage"; if (/paint|raster|render|lighting|style|layout/iu.test(question)) return "rendering"; + if (/coverage|captur|missing|trust|evidence/iu.test(question)) return "coverage"; if (/timer|scheduler|raf|cadence|jitter|stutter|smooth/iu.test(question)) return "scheduler"; return "summary"; } -function roleFor(event, selection, metadata, rasterTids) { +function roleFor(event, selection, metadata, rasterThreadKeys) { + const rasterThread = rasterThreadKeys.has(threadKey(event.pid, event.tid)); if (event.pid === selection.pid && event.tid === selection.mainTid) return "renderer-main"; if (event.pid === selection.pid && event.tid === selection.compositorTid) return "renderer-compositor"; - if (event.pid === selection.pid && (rasterTids.has(event.tid) || event.name === "RasterTask")) return "renderer-raster"; + if (event.pid === selection.pid && (rasterThread || event.name === "RasterTask")) return "renderer-raster"; + if (rasterThread || event.name === "RasterTask") return "raster-global"; const processName = metadata.processNames.get(event.pid) ?? ""; const threadName = metadata.threadNames.get(threadKey(event.pid, event.tid)) ?? ""; if (processName === "GPU Process") return "gpu-global"; @@ -1637,6 +1684,14 @@ function formatAvailabilityCount(available, value) { return available ? String(value) : "not captured"; } +function rasterEvidenceSummary(signal) { + if (!signal.available) return "RasterTask evidence: not captured; raster cost is unknown, not zero."; + if (signal.scope === "selected-renderer") { + return `RasterTask evidence: ${signal.eventCount} selected-renderer event(s).`; + } + return `RasterTask evidence: ${signal.eventCount} event(s), including ${signal.globalEventCount} at global/nonexclusive trace scope; cross-process raster is not attributable exclusively to the selected page.`; +} + function cadenceRow(label, stats) { return `| ${label} | ${stats.eventCount ?? stats.count ?? 0} | ${formatMs(stats.p50Ms)} | ${formatMs(stats.p95Ms)} | ${formatMs(stats.maxMs)} |`; } diff --git a/scripts/frame-sleuth.test.mjs b/scripts/frame-sleuth.test.mjs index ebeb880..73e62d6 100644 --- a/scripts/frame-sleuth.test.mjs +++ b/scripts/frame-sleuth.test.mjs @@ -113,6 +113,29 @@ test("reports unavailable evidence as unknown instead of zero", () => { assert.match(renderMarkdown(analysis), /Long main-thread tasks: not captured/); }); +test("captures browser-process raster work without attributing it to the selected renderer", () => { + const events = syntheticTrace({}).filter((event) => event.name !== "RasterTask"); + events.push(metadata("process_name", 1, 0, "Browser")); + events.push(metadata("thread_name", 1, 2, "CompositorTileWorker1")); + events.push(complete("RasterTask", 1, 2, 30_000, 200)); + + const analysis = analyzeTrace(loaded(events), { frame: 2, question: "was raster work captured?" }); + + assert.equal(analysis.focus, "rendering"); + assert.equal(analysis.capabilities.signals.raster.available, true); + assert.equal(analysis.capabilities.signals.raster.eventCount, 1); + assert.equal(analysis.capabilities.signals.raster.rendererEventCount, 0); + assert.equal(analysis.capabilities.signals.raster.globalEventCount, 1); + assert.equal(analysis.capabilities.signals.raster.scope, "global-nonexclusive"); + assert.equal(analysis.workload.named.RasterTask.maxMs, 0.2); + assert.equal(analysis.requestedFrame.metrics.rasterMaxMs, 0.2); + assert.ok(analysis.requestedFrame.namedWork.some( + (entry) => entry.name === "RasterTask" && entry.role === "raster-global", + )); + assert.match(analysis.capabilities.warnings.join("\n"), /outside the selected renderer/u); + assert.match(renderMarkdown(analysis), /global\/nonexclusive trace scope/u); +}); + test("rejects ambiguous and nonexistent frame selectors", () => { const trace = loaded(syntheticTrace({})); assert.throws(() => analyzeTrace(trace, { frame: 999 }), /does not exist/); @@ -165,11 +188,17 @@ test("prefers renderer presentation timestamps for the frame-time chart", () => [11_000, 27_000, 73_000, 90_000, 107_000].forEach((ts) => { events.push(instant("AnimationFrame::Presentation", 10, 11, ts)); }); + events.push({ + ...instant("AnimationFrame::Presentation", 10, 11, 50_000), + args: { begin_frame_id: { source_id: 0, sequence_number: 0 } }, + }); const analysis = analyzeTrace(loaded(events)); assert.equal(analysis.timeline.source, "AnimationFrame::Presentation"); + assert.equal(analysis.timeline.ignoredUnboundEventCount, 1); assert.equal(analysis.timeline.baselineMs, 17); assert.equal(analysis.timeline.worstFrame.intervalKind, "presented animation frame"); assert.equal(analysis.timeline.worstFrame.intervalMs, 46); + assert.match(analysis.capabilities.warnings.join("\n"), /unbound trace-start/u); assert.match(renderFrameChartSvg(analysis), /Presented animation-frame interval \(ms\)/); assert.match(renderMarkdown(analysis, null, { frameChart: "frame-times.svg" }), /Worst presented animation-frame interval/u); }); diff --git a/scripts/verify-source-only.mjs b/scripts/verify-source-only.mjs index 4db42c1..c8ac0a7 100644 --- a/scripts/verify-source-only.mjs +++ b/scripts/verify-source-only.mjs @@ -39,6 +39,7 @@ const README_MEDIA_PATHS = new Set([ "site/public/favicon.ico", "site/public/landing/chaos.webp", "site/public/landing/cloth.webp", + "site/public/landing/cityflow.webp", "site/public/landing/cyclone.webp", "site/public/landing/electropaint.webp", "site/public/landing/flowerbox.webp", @@ -52,6 +53,7 @@ const README_MEDIA_PATHS = new Set([ "site/public/landing/solitaire.webp", "site/public/landing/sidebar/chaos.webp", "site/public/landing/sidebar/cloth.webp", + "site/public/landing/sidebar/cityflow.webp", "site/public/landing/sidebar/cyclone.webp", "site/public/landing/sidebar/electropaint.webp", "site/public/landing/sidebar/galaxy.webp", diff --git a/site/pages/cityflow.astro b/site/pages/cityflow.astro new file mode 100644 index 0000000..2ce55f5 --- /dev/null +++ b/site/pages/cityflow.astro @@ -0,0 +1,6 @@ +--- +import "../../src/adapters/cityflow/src/csscityflow/styles.css"; +import ProjectPage from "../components/ProjectPage.astro"; +--- + + diff --git a/site/project-adapters.ts b/site/project-adapters.ts index 2c1ce0b..717e78b 100644 --- a/site/project-adapters.ts +++ b/site/project-adapters.ts @@ -1,4 +1,5 @@ export const PROJECT_ADAPTER_DIRECTORIES: Readonly> = Object.freeze({ + cityflow: "cityflow", chaos: "dysts-lab", luminet: "blackhole", cloth: "cloth", diff --git a/site/public/landing/cityflow.webp b/site/public/landing/cityflow.webp new file mode 100644 index 0000000000000000000000000000000000000000..e185ab08faf60fd3b953a7c3314fce7551e1e973 GIT binary patch literal 47046 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 YouMudT>Qu{einVM`Q)K8jS2<+2OfDD;{X5v literal 0 HcmV?d00001 diff --git a/site/public/projects.json b/site/public/projects.json index 8672d86..09ca1cb 100644 --- a/site/public/projects.json +++ b/site/public/projects.json @@ -1,6 +1,24 @@ { "schema": "cssgraphics.projects@2", "projects": [ + { + "number": 12, + "id": "cityflow", + "name": "Cityflow", + "route": "/cityflow/", + "preview": "/landing/cityflow.webp", + "numberTone": "light", + "source": "XScreenSaver", + "credits": [ + { + "relation": "Adapted from", + "name": "XScreenSaver", + "url": "https://github.com/Zygo/xscreensaver/blob/906693799e4fb7581436590cf84ecb2d3c9186ba/hacks/glx/cityflow.c" + } + ], + "description": "XScreenSaver Cityflow rendered with 200 retained HTML and CSS boxes using PolyCSS.", + "date": "2026-08-31" + }, { "number": 11, "id": "chaos", diff --git a/site/public/sitemap.xml b/site/public/sitemap.xml index 15aeb29..8beabbc 100644 --- a/site/public/sitemap.xml +++ b/site/public/sitemap.xml @@ -3,6 +3,9 @@ https://css.graphics/ + + https://css.graphics/cityflow/ + https://css.graphics/chaos/ diff --git a/site/scene-router.mjs b/site/scene-router.mjs index 8800e01..4ca5414 100644 --- a/site/scene-router.mjs +++ b/site/scene-router.mjs @@ -42,6 +42,10 @@ function requireSceneLifecycle(mount) { async function mountForProject(projectId, host) { switch (projectId) { + case "cityflow": { + const { mountCityflow } = await import("../src/adapters/cityflow/src/csscityflow/client.mjs"); + return mountCityflow(host); + } case "chaos": { const { mountChaosClient } = await import("../src/adapters/dysts-lab/src/cssdysts/client.mjs"); return mountChaosClient(host); diff --git a/site/test/landing.test.mjs b/site/test/landing.test.mjs index 0fe53ea..b0cbbfd 100644 --- a/site/test/landing.test.mjs +++ b/site/test/landing.test.mjs @@ -8,6 +8,7 @@ import { fileURLToPath } from "node:url"; const siteRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const repositoryRoot = resolve(siteRoot, ".."); const expectedProjects = [ + ["cityflow", 12, "XScreenSaver", "2026-08-31", "XScreenSaver Cityflow"], ["chaos", 11, "dysts", "2026-08-27", "50 motion-curated chaotic attractors"], ["luminet", 10, "Luminet", "2026-08-24", "A Luminet Schwarzschild black hole"], ["galaxy", 9, "XScreenSaver", "2026-08-23", "XScreenSaver Galaxy"], @@ -22,6 +23,7 @@ const expectedProjects = [ ]; const projectsExcludedFromLanding = ["flowerbox", "gravitywell"]; const expectedNumberTones = new Map([ + ["cityflow", "light"], ["chaos", "light"], ["luminet", "light"], ["galaxy", "light"], @@ -35,6 +37,7 @@ const expectedNumberTones = new Map([ ["pipes", "light"], ]); const projectAdapterDirectories = new Map([ + ["cityflow", "cityflow"], ["chaos", "dysts-lab"], ["luminet", "blackhole"], ["cloth", "cloth"], @@ -116,6 +119,7 @@ test("landing presents the current deployed collection", async () => { assert.doesNotMatch(sceneRouter, /mountFlocksClient|cssflocks/u); assert.match(sceneRouter, /mountBlackHoleClient\(host\)/u); assert.match(sceneRouter, /mountChaosClient\(host\)/u); + assert.match(sceneRouter, /mountCityflow\(host\)/u); assert.match(sceneRouter, /mountCycloneClient\(host\)/u); assert.match(sceneRouter, /mountGalaxyClient\(host\)/u); assert.match(sceneRouter, /addEventListener\("visibilitychange", syncSceneVisibility\)/u); diff --git a/src/adapters/cityflow/AGENTS.md b/src/adapters/cityflow/AGENTS.md index 6ba7943..ea22951 100644 --- a/src/adapters/cityflow/AGENTS.md +++ b/src/adapters/cityflow/AGENTS.md @@ -1,10 +1,12 @@ # Cityflow adapter contract - Read `notes/provenance-bible.md` before changing source simulation, box geometry, colors, lighting, camera, timing, or parity claims. -- Preserve the XScreenSaver simulation contract: six waves, 25 wave speed, 256 radius, 12-degree skew, 20 ms cadence, the source-supported `count` control set to 200, and only the source-emitted top, front, and right faces. The source default remains 800; do not describe 200 as that default. -- Preserve the seeded initialization, wave equation, palette, lighting, camera, and cadence. Cityflow has one prepared product bank and no runtime bank selection. -- Preparation owns the exact seeded `ya_random` initialization, palette, box census, wave phases, source lighting, native far-plane cutoff, retained Morph package, canonical transform dictionary, and typed transform/color frame tables. -- Runtime may mount the prepared graph, derive static face lighting from each prepared box transform, and apply changed prepared transform indices and palette classes. Playback must follow the merged DOMFORMAT `polycss-playback@0` precedent: timer wait, one paint-aligned callback when due, and `elapsed` catch-up. It must not calculate geometry, rasterize assets, grow the DOM, or rebuild topology during playback. -- Keep every box as one retained rigid root with exactly three solid `` leaves. No Canvas, WebGL, WebGPU, SVG scene geometry, masks, clip paths, runtime atlas work, or paint-heavy CSS effects. +- Preserve the XScreenSaver simulation contract: six waves, 25 wave speed, 256 radius, 12-degree skew, 20 ms cadence, the source-supported `count` control set to 200 for desktop and 100 for mobile, and only the source-emitted top, front, and right faces. The source default remains 800; do not describe either prepared count as that default. +- Preserve the seeded initialization, wave equation, palette, lighting, camera, and cadence independently in both prepared banks. Select `desktop` or `mobile` once, before fetching or mounting any bank asset, using the established `<600px`, coarse-pointer, and mobile-UA profile policy. Do not fetch both banks, switch banks after mount, or rebuild a bank at runtime. Responsive projection updates are not bank selection. +- Keep 251 exact source states, the nearest whole-frame approximation to the `2π / 0.025` wave period, and the 301-state periodic uniform cubic B-spline C2 presentation over the same real-time span. After reconstruction, apply the prepared zero-phase five-tap binomial filter `[1, 4, 6, 4, 1] / 16`, fold every circular direction run of twelve frames or less into its surrounding direction, apply the zero-phase three-tap filter `[1, 2, 1] / 4`, repeat the twelve-frame fold, apply a final five-tap pass, and repeat the fold once more. Refit each resulting monotonic run between the centers of its prepared extrema with the normalized integral of `sin(πt) × (1 - r × sin²(πt))`, where `r = 0.55 × clamp((runFrames - 27) / 30, 0, 1)`. Preserve periodic zero-sum closure, every prepared extremum and range, and the direction-run order. Label the finite loop, reconstruction, filters, short-run folding, adaptive smooth-sine easing, and color interpolation as browser presentation adaptations; do not claim an exact native loop. +- Preparation owns the exact seeded `ya_random` initialization, palette, box census, wave phases, source lighting, native far-plane cutoff, retained Morph package, per-box affine templates and fixed-point height-delta streams, presentation/source transform indices, final face-color dictionaries and indices, diagnostic visibility schedules, and presentation/source frame tables. The loader may expand the prepared transform streams once into cached final CSS strings before readiness; the frame loop may only index those strings. +- Keep the older native, single-viewport, and face-level visibility tables as diagnostic evidence only. Desktop product playback may consume only the viewport-independent whole-box schedule in `prepared-static-visibility.json`: the union of the three full-cycle projection censuses, twelve-frame transition dilation, and the eight full-frame CSS 3D sorting dependencies. Preserve its 128 always-visible boxes, 166--187 visible-box range, 240 prepared box transitions, and maximum six transitions per frame unless fresh full-frame evidence proves a replacement. The 100-box mobile bank currently prepares all 100 boxes as visible for all 301 presentation states, with zero visibility transitions; do not infer a mobile culling schedule from desktop indices. Never derive product suppression from one viewport, one frame, or face-level no-interior-pixel results. Keep every root in the selected bank's CSS 3D sorting tree, and toggle all three leaves of a box atomically. Do not use root `display`, root visibility, opacity-only suppression, or independent per-face culling. +- Runtime may mount the prepared graph and publish each visible box's complete cached root transform, each retained leaf's prepared final background color, and prepared whole-box visibility transitions. Hidden leaves must use direct `visibility:hidden`. Runtime must not construct animations, select a visibility bank by viewport, or calculate visibility at runtime. Hidden boxes skip transform writes; a box returning to visibility must receive its current prepared transform. Keep exact source-frame seeks that restore all 200 roots and 600 faces for oracle capture, then restore the prepared presentation row on a presentation seek. The first animation-frame callback after resume publishes immediately. Playback then advances exactly one adjacent prepared state when its paint-aligned deadline is due, with a one-quarter-frame early tolerance for display-phase drift. If accumulated deadline drift would reject a callback delivered at least three-quarters of a frame after the last publication, publish that adjacent state and re-phase the deadline. Continue to suppress earlier sub-vsync duplicate callbacks. A late callback resets the next deadline. Playback must not skip states, replay overdue states in a burst, or introduce a timer-to-rAF handoff. After the one-time prepared transform-table expansion before readiness, the frame loop must not calculate geometry, lighting, or color, assemble or format transforms or other style values, rasterize assets, grow the DOM, or rebuild topology. +- Keep every box as one retained rigid root with exactly three solid `` leaves. All three leaves remain the only geometry and color owners; do not add pseudo-element paint surfaces. The front and right leaves retain a subpixel-free `1px` layout height. Desktop prepared depth defaults to `0.1`; the native-gap census in `notes/references/prepared-side-depth.json` extends only 19 measured desktop faces, with a `0.28` maximum. Mobile uses a prepared uniform `0.28` depth and no borrowed desktop face overrides; the smaller value was rejected by the full mobile native/browser sequence. Keep those depths in prepared leaf matrices, never runtime variables. Product playback uses the fixed source camera and provides no pointer orbit or wheel zoom controls. No Canvas, WebGL, WebGPU, SVG scene geometry, masks, clip paths, runtime atlas work, dynamic color expressions, or paint-heavy CSS effects. - Native state claims require the pinned headless C source harness at revision `906693799e4fb7581436590cf84ecb2d3c9186ba`. Native visual claims require the independent CGL frame-sequence oracle and calibrated native/browser comparison. - Keep source checkouts, native binaries, captures, traces, and generated packages ignored. diff --git a/src/adapters/cityflow/NOTICE.md b/src/adapters/cityflow/NOTICE.md new file mode 100644 index 0000000..072e208 --- /dev/null +++ b/src/adapters/cityflow/NOTICE.md @@ -0,0 +1,38 @@ +# Cityflow source notice + +The Cityflow adapter derives its preparation-time initialization, wave motion, +box geometry, palette, lighting, camera, cadence, and three-face rendering from +XScreenSaver's `hacks/glx/cityflow.c`, revision +`906693799e4fb7581436590cf84ecb2d3c9186ba`, with SHA-256 +`9113c9f3214ba6c1f350b3863c306e6015e47a856a17bbe24d597f909dfa027b`. +The exact source and native-oracle support inputs are bound in +`notes/references/source-lock.json` and recorded as HPND. + +The desktop and mobile products select the source-supported `count` option at +200 and 100 boxes respectively. The XScreenSaver default remains 800. Their +deterministic authoring seed and 251-frame exact source-state banks are product +preparation choices. The normal browser presentation is a prepared 301-state +periodic uniform cubic B-spline C2 +reconstruction over the same real-time span, followed by documented zero-phase +filters, short-direction-run folding, and an adaptive smooth-sine extrema refit. +Preparation also interpolates browser face colors between adjacent source +palette entries and emits complete prepared transform dictionaries, final +three-face color dictionaries, and packed indices. Exact +source-frame seeking remains available to the native visual oracle. The source +bank is the nearest whole-frame approximation to the source wave period; the +smooth periodic browser cycle is a documented presentation adaptation and does +not claim that the continuously advancing source has an exact finite loop. + +## Upstream attribution and permission notice + +The following notice is reproduced from the pinned `hacks/glx/cityflow.c` +source: + +> cityflow, Copyright (c) 2014-2017 Jamie Zawinski +> +> Permission to use, copy, modify, distribute, and sell this software and its +> documentation for any purpose is hereby granted without fee, provided that +> the above copyright notice appear in all copies and that both that copyright +> notice and this permission notice appear in supporting documentation. No +> representations are made about the suitability of this software for any +> purpose. It is provided "as is" without express or implied warranty. diff --git a/src/adapters/cityflow/README.md b/src/adapters/cityflow/README.md index 1eaa4af..95a8124 100644 --- a/src/adapters/cityflow/README.md +++ b/src/adapters/cityflow/README.md @@ -1,27 +1,122 @@ # css.graphics/cityflow Source-backed XScreenSaver Cityflow rendered as retained PolyCSS Morph boxes. -The single product bank uses the source's own `count` control at 200 boxes; -the XScreenSaver default is 800. Every box retains the source-emitted top, -front, and right faces. +The desktop and mobile prepared banks use the source's own `count` control at +200 and 100 boxes respectively; the XScreenSaver default is 800. Every box +retains the source-emitted top, front, and right faces. ```bash -export CSSCITYFLOW_SOURCE_ROOT=/path/to/xscreensaver pnpm prepare:cityflow pnpm dev:cityflow pnpm test:cityflow +pnpm test:cityflow:assets pnpm prepare:cityflow:check pnpm test:cityflow:browser pnpm oracle:cityflow:native pnpm oracle:cityflow:visual +pnpm oracle:cityflow:visual:full +pnpm audit:cityflow:motion +pnpm audit:cityflow:smoothness -- --frames /absolute/path/to/presentation-frames +pnpm perf:cityflow:trace pnpm build:cityflow +pnpm build:cityflow:deploy +pnpm test:cityflow:deploy ``` +Preparation downloads only the 14 hash-bound files from the pinned XScreenSaver +revision into the ignored `.local/upstreams/` tree. Set +`CSSCITYFLOW_SOURCE_ROOT=/path/to/xscreensaver` to verify an existing checkout +instead; its Git revision and every consumed file must match the source lock. + Preparation owns the seeded source initialization, smooth color map, box geometry, source-light factors, OpenGL far-plane cutoff, Morph packages, and -source-cadence transform/color tables. Duplicate transforms are removed during -preparation. Runtime mounts one retained graph, derives its static face-light -factors from the prepared transforms, and sparsely publishes changed prepared -indices with the merged DOMFORMAT timer/rAF `elapsed` scheduler. Runtime does -no geometry calculation, atlas rasterization, topology rebuilding, or DOM -growth. +the exact 251-frame source-state tables. It also resamples those source states +into a periodic 301-frame presentation bank with a periodic uniform cubic +B-spline C2 reconstruction. The reconstruction is a convex four-sample blend, +closes position, velocity, and acceleration at the loop boundary, and cannot +overshoot its four source heights. A zero-phase five-tap binomial filter with +weights `[1, 4, 6, 4, 1] / 16` smooths the prepared presentation without phase +lag. Preparation absorbs every circular direction run of twelve frames or +less into its surrounding direction, applies a zero-phase three-tap pass, +repeats the fold, and finishes with another five-tap pass and fold. It +rebalances positive and negative travel to keep +periodic zero-sum closure, recenters each trajectory, and clamps its affine +range to the filtered input range. Preparation then refits each monotonic run +between the centers of its extrema with the normalized integral of +`sin(πt) × (1 - r × sin²(πt))`. Short runs keep cosine easing; `r` rises +linearly from zero at 27 frames to `0.55` at 57 frames. This reduces the long +runs' low-motion tails without the fractional endpoint shape of the former +`sin(πt)^0.85` curve, while preserving every extremum, range, and direction-run +order. The separate source bank remains exact. +Prepared face colors interpolate between adjacent source palette entries. +Preparation emits one affine template and one fixed-point height-delta stream per +box, plus packed presentation/source indices and deduplicated final three-face +color materials. The loader expands the prepared component streams once into +cached final `matrix3d(...)` strings before playback becomes ready. The animation +loop only indexes those cached strings. The pinned native depth/backface and older face-level visibility +schedules remain diagnostic evidence only. The desktop product keeps its +200-root/600-leaf topology and uses one viewport-independent prepared whole-box +visibility schedule. It is the union of three full-cycle solid-face-ID +projection censuses, dilated by twelve frames around every transition, plus +eight boxes proven to affect Chrome's CSS 3D sorting even when their own pixels +are hidden. The schedule leaves 128 boxes always visible and publishes 166--187 +boxes, or 498--561 faces, per frame. Its 240 box transitions change at most six +boxes, or 18 direct leaf visibility properties, in one frame. Five boxes stay +hidden throughout. The independently prepared mobile product has 100 roots and +300 leaves and keeps all of them visible throughout its 301-state presentation; +it does not reuse desktop visibility indices. Every selected-bank root remains +in the CSS 3D sorting tree; leaves are hidden only as whole boxes. +All retained leaves remain the geometry and color +owners; there is no pseudo-element paint surface. The side +leaves use a subpixel-free `1px` layout height. Desktop prepared depth defaults +to `0.1`; a native/browser black-gap census extends only 19 measured desktop +faces up to `0.28`. Mobile uses a prepared uniform `0.28` depth after the +`0.1` candidate failed its complete native/browser frame comparison. These +matrices close exposed moving-tower sides without restoring the former +full-height side planes. +Playback writes each paint-participating box's cached complete transform directly and publishes the +prepared final background color directly to each retained face. No dynamic color +expression, transform variable, global per-frame selector bank, or stylesheet-rule +mutation participates in playback. Prepared box transitions set `visibility` +directly on all three leaves. Hidden boxes skip transform publication; re-entry +publishes the current transform. The runtime performs no viewport query and no +visibility, geometry, or projection calculation. +Face colors change only when their packed +prepared material index changes. One paint-aligned rAF clock advances exactly +one adjacent prepared state when due. If delivery is late, it resets the next +deadline instead of jumping across states or replaying a burst. The clock uses +the later of the animation-frame timestamp and actual callback delivery time. +If accumulated deadline phase drift would reject a callback delivered at least +three-quarters of a frame after the last publication, the clock publishes that +adjacent state and re-phases once. Earlier sub-vsync duplicates remain suppressed. +The animation loop creates no animation objects and performs no number formatting, +background position update, transform assembly, or runtime atlas work. Exact source-frame debug seeks force +the corresponding exact prepared source color and transform state. Runtime +does no geometry calculation, atlas rasterization, topology rebuilding, or DOM growth. +The loader selects one prepared bank before any model, playback, or CSS fetch: +mobile for widths below 600 px, coarse-pointer devices, or a mobile user agent; +desktop otherwise. It fetches and mounts only that bank and never switches it +on resize. The product starts playback automatically with the fixed source +camera. It does not install pointer-orbit or wheel-zoom controls. + +The bank retains 251 exact source frames, the nearest whole-frame approximation +to the `2π / 0.025` wave period, plus a 301-state periodic presentation over +the same real-time span. The source bank remains exact for oracle seeks; the +filtered uniform cubic B-spline C2 reconstruction and interpolated colors are the documented browser presentation +adaptation. The source license and exact permission notice are preserved in +`NOTICE.md`. + +The smoothness audit measures circular velocity, acceleration, jerk, held +transitions, and direction runs up to three, six, and twelve frames from every prepared box-height trajectory. Its 12-, 18-, and 24-frame rolling values only locate clustered irregularity; they are diagnostics, not an optimization target or an acceptance claim. With `--frames`, it also +measures frame-domain motion-energy variation directly from the raw numbered +PNG sequence through FFmpeg VMAF Motion. The values are matched-build regression +signals, not a standalone claim of perceptual smoothness. + +The current release evidence includes a pixel-exact 301-transition sparse-versus- +full-publication audit at 1280x720 and 2560x1224. A full-loop LayerTree census at +1280x720 measures 508--571 total compositor layers, tracking the prepared visible +face count plus ten; a fully published frame would require 610. The normal-speed +trace passes the captured cadence and pipeline gates. A separate 4x CPU stress +trace exceeds the paint threshold and is diagnostic only. The broad independent- +page viewport comparator still shows small, non-repeatable raster-edge deltas on +some normal-ratio frames, so those results are not described as exact parity. diff --git a/src/adapters/cityflow/index.html b/src/adapters/cityflow/index.html index 1f2e59b..ce49712 100644 --- a/src/adapters/cityflow/index.html +++ b/src/adapters/cityflow/index.html @@ -11,6 +11,7 @@ + @@ -19,39 +20,24 @@ content="XScreenSaver Cityflow rendered entirely with retained HTML and CSS using PolyCSS." > + + + + + + + Cityflow - Powered by PolyCSS - - + + +
diff --git a/src/adapters/cityflow/notes/provenance-bible.md b/src/adapters/cityflow/notes/provenance-bible.md index 2a237d0..4f176ff 100644 --- a/src/adapters/cityflow/notes/provenance-bible.md +++ b/src/adapters/cityflow/notes/provenance-bible.md @@ -22,9 +22,11 @@ its top, front, and right quads; the left and back faces are compiled out and there is no bottom face. The default retained census is therefore 800 roots and 2,400 leaves. -The product uses the source-supported `count` option at 200 boxes in one -prepared bank. The source default remains 800, and the source's -`1.8 / sqrt(count)` box scale is preserved for the selected count. +The desktop and mobile prepared banks use the source-supported `count` option +at 200 and 100 boxes respectively. The source default remains 800, and the +source's `1.8 / sqrt(count)` box scale is preserved independently for each +selected count. The mobile count is therefore a source-supported prepared +simulation input, not a runtime subset of the desktop boxes. The deterministic prepared seed is product authoring input because the native screen saver normally seeds `ya_random` from process and wall-clock state. The @@ -33,34 +35,711 @@ lighting, and camera remain authoritative. ## Runtime boundary -Preparation writes one static Morph package plus source-cadence typed transform -and color-index tables. Every box retains three solid quad leaves. +Preparation writes independent desktop and mobile static Morph packages, each +with 251 exact source-cadence typed transform/color-index frames and a 301-frame +60 Hz presentation stream. Every box retains three solid quad leaves. +The 251-frame bank is the nearest whole-frame approximation to the source wave +period of `2π / 0.025 = 251.327...` frames. The native source advances +continuously, so the product labels this as a prepared periodic approximation +rather than an exact source loop. Because CSS perspective has no OpenGL far plane, preparation derives the side face cutoff from the exact source model-view sequence and clips the source `bottom = 5` faces conservatively at eye-space `z = -50`. Each prepared box -matrix carries real initial geometry rather than adapter metadata. Runtime -derives the exact static source-light factors from that matrix, then applies -only changed prepared transform indices and palette classes. Scheduling follows -the merged DOMFORMAT `polycss-playback@0` precedent: a timer waits for the next -deadline, one paint-aligned callback publishes the due state, and `elapsed` -catch-up collapses missed source ticks. There is no runtime geometry, -rasterization, topology rebuilding, or DOM growth. +matrix carries real initial geometry rather than adapter metadata. Preparation +uses the exact static source-light factors to deduplicate direct face-color +slots. It reconstructs the exact 50 Hz source heights into a periodic 301-state +uniform cubic B-spline C2 transform bank and applies the convex zero-phase +five-tap binomial filter `[1, 4, 6, 4, 1] / 16`. Preparation folds every +circular direction run of twelve frames or less into its surrounding direction, +applies the zero-phase three-tap filter `[1, 2, 1] / 4`, and repeats the +twelve-frame fold. A final five-tap pass smooths the fold boundaries and one +last twelve-frame fold removes the few short runs that pass reintroduced. +Preparation then refits each monotonic run between the centers of its prepared +extrema with the normalized integral of `sin(πt) × (1 - r × sin²(πt))`, where +`r = 0.55 × clamp((runFrames - 27) / 30, 0, 1)`. Each fold rebalances positive and negative travel to retain +periodic zero-sum closure, recenters the trajectory, and preserves the filtered +input range; the adaptive smooth-sine refit preserves every extremum, the complete +range, and the direction-run order. Short runs keep cosine easing while long runs +receive progressively flatter mid-run velocity without a fractional endpoint +shape. It prepares sRGB color interpolation from the resulting height +trajectory. +Real-time speed remains within the documented nearest-period replay. Preparation +writes one affine template and one exact fixed-point height-delta stream per box, +plus packed transform indices and deduplicated final three-face color dictionaries +and packed indices for presentation and source seeks. The loader expands the +prepared component streams once into cached final CSS strings before readiness; +the animation loop only performs table lookup. +A pinned native +depth/backface ID-buffer census across eight desktop, square, ultrawide, and +portrait viewports prepares a 301-frame visibility schedule. The source schedule +unions all sampled viewports and dilates visibility by one frame before and +after each transition. A prepared Chrome face-ID census at 1280×720 covers the +adaptive-smooth-sine trajectory; preparation unions it with the native schedule and +applies two conservative frames on each side. The full-cycle browser face audit also +keeps faces 342, 369, 452, 454, 504, and 552 conservatively visible and extends +the source schedule to twelve frames for faces 469, 470, 482, 543, 553, 565, +566, and 582. Product playback does not consume those frame-specific tables. +A separate Chrome solid-face-ID census covers all 301 presentation frames at +normal, wide, and ultrawide projection ratios. The product unions those censuses +at whole-box granularity and dilates transitions by twelve frames. Full-frame +raster attribution identified boxes 155, 156, 165, 171, 172, 179, 185, and 196 +as CSS 3D sorting dependencies, so they remain visible throughout even when the +ID census finds no pixels from their own faces. The resulting viewport-independent +desktop schedule leaves 128 boxes always visible and publishes 166--187 boxes, or +498--561 faces, per frame. It contains 240 box transitions and changes at most +six boxes per frame. The retained topology remains 200 roots and 600 leaves; +every root stays in the CSS 3D sorting tree, while all three leaves of a hidden +box receive direct `visibility:hidden`. The mobile bank prepares all 100 boxes +as visible for every presentation state and has no visibility transitions; it +does not reuse desktop box indices. All retained leaves remain the +sole geometry and color owners. Preparation resolves interpolated sRGB into final face colors, so each +leaf has one paint surface and no pseudo-element overlay. The side leaves use a +subpixel-free `1px` layout height. Desktop prepared depth defaults to `0.1`, +with native-gap-derived overrides on 19 faces and a `0.28` maximum. Mobile uses +a prepared uniform `0.28` depth with no borrowed desktop face overrides after +the `0.1` candidate failed the complete native/browser frame sequence. There is no background-position color transport, +dynamic transform/color variable, or runtime atlas. Exact source-frame seeks force the +corresponding source color and transform state. One continuous paint-aligned callback publishes exactly +one adjacent prepared state when due and uses the later of the animation-frame +timestamp and callback delivery time. Hidden boxes skip transform writes, and a +box returning to visibility receives its current prepared transform. No viewport +query or projection calculation participates in playback. A late callback resets the next deadline; +it does not skip prepared states or replay a burst. The player creates zero +animation objects, and frame publication performs no transform or color formatting. There is +no timer-to-rAF handoff, runtime geometry, rasterization, topology rebuilding, +or DOM growth. ## Proof state -- Source-state oracle: product ticks 0, 73, and 251 match the pinned +- Source-state oracle: product ticks 0, 73, 250, and the post-bank tick 251 match the pinned C implementation within explicit float tolerance. -- Browser retained-DOM smoke: desktop and mobile viewports mount the same - expected leaf census, keep DOM identity stable, publish prepared transforms, - and construct/redraw no atlases. -- Native visual capture: the headless macOS CGL harness executes the pinned - `cityflow.c` source directly; two 48-frame runs are byte-identical. -- Browser visual parity: 48 source-cadence frames pass the calibrated native / - retained-DOM comparison at mean delta `<= 1`, changed-pixel ratio `<= 0.05`, - and channel threshold `2`. The worst observed mean delta is `0.541659`; the - residual is confined to polygon-edge rasterization. -- FrameSleuth performance on the untouched 1280 x 720 product route records a - `19.728 ms` DrawFrame p50 and `34.213 ms` p95 against the source's `20 ms` - cadence. The only scheduled work is the DOMFORMAT-precedented timer / - paint-aligned callback; publications use prepared data and the retained graph - remains 200 roots / 600 leaves. +- Browser retained-DOM smoke: desktop mounts the 200-root/600-leaf bank and + mobile mounts the independent 100-root/300-leaf bank. Each profile requests + only its selected model, playback, and CSS assets, keeps DOM identity stable, + publishes prepared transforms, and constructs/redraws no atlases. +- Mobile bank visual sequence: the complete 251-frame 390×844 native/browser + comparison has byte-identical native A/A and browser A/A captures. Its worst + aligned frame is 159 at mean absolute delta `0.621742` and changed-pixel + ratio `0.0141`, below the calibrated `1.125`, `0.05`, and channel-2 limits. + The earlier `0.1` mobile side-depth candidate was rejected before adoption. +- Full-bank native visual capture: the headless macOS CGL harness executes the + pinned `cityflow.c` source directly; both native and browser 251-frame A/A + runs are byte-identical. +- Desktop browser visual parity: the current prepared whole-box visibility and + retained three-leaf geometry path passes all 251 prepared source-cadence frames at the + calibrated native / retained-DOM comparison at mean delta `<= 1.125`, + changed-pixel ratio `<= 0.05`, and channel threshold `2`. Frame 155 is the + worst observed frame at mean delta `1.115152` and changed-pixel ratio + `0.045005`; the residual is confined to cross-renderer polygon edges. +- Rejected presentation experiment: a `0.005` source-unit height grid passed + still-frame differential thresholds but caused visibly stepped motion. It was + removed. A 12 Hz per-building color-publication throttle was removed with it; + later experiments replaced it with prepared continuous interpolation and + prepared sRGB interpolation between adjacent source palette entries. +- Rejected schedulers and transport: elapsed catch-up skipped prepared + presentation states after a missed display deadline, amplifying visible + stepping. A split path with 200 Web Animations then transported transforms on + the compositor while an independent scheduler published color and opacity. + Aligning that scheduler to animation `currentTime` removed the face-phase bug, + but that earlier path still used the stepped linear presentation bank, and + the user's headed trace had presented-frame p95 `33.361 ms`, maximum + `50.041 ms`, and three smoothness-affecting pipeline records. The replacement + was the schema-6 direct-state publisher. It fixed delivery cadence but did not + fix the perceptual stepping in the prepared visual sequence. +- The next user-headed trace, `Trace-20260901T112231.json.gz`, still failed: + presented-frame p95 was `33.361 ms`, maximum was `35.116 ms`, 15 DrawFrame + gaps exceeded `33.3 ms`, and one pipeline record affected smoothness. It + delivered 191 rAF callbacks but produced only 155 style/layout publications. + The rejected callbacks took only `0.027–0.059 ms`; Cityflow was comparing its + deadline only with a stale animation-frame timestamp even though callback + delivery time had advanced. The scheduler now uses the later clock, as the + published Galaxy player does, while still publishing exactly one next state + and never catching up by skipping states. +- The rejected background-position atlas experiment was removed because it did + not reduce the retained 3D draw topology and failed visual parity on side + faces. A matched Chrome A/B instead showed that prepared opacity culling + reduced Viz-compositor occupancy from `71.7%` to `24.2%`, p95 task duration + from `14.051 ms` to `4.685 ms`, and tasks over `8.333 ms` from `290` to zero. +- Three pre-clock-fix local schema-6 traces had no holds above `33.3 ms`, but the + later headed trace above failed. They are retained as evidence that local + headless timing alone was insufficient, not as readiness proof. +- Two post-clock-fix local traces each paired 478 rAF callbacks with 478 + style/layout publications. Presented-frame p95 was `17.688 ms` and + `17.683 ms`; maxima were `19.215 ms` and `23.931 ms`. Both had zero holds above + `33.3 ms` and zero smoothness-affecting pipeline drops. In the `23.931 ms` + worst interval, renderer-main work was `2.339 ms` and maximum Paint was + `0.227 ms`; RasterTask was not captured and remains unknown. +- A post-clock-fix six-second Chrome recording sampled 361 rAF callbacks, + produced 360 sequential publications after the initial sample, created zero + runtime transform animations, and observed zero skipped prepared states. The + matched face audit captured all 602 requested 120 Hz samples against an + unculled white-face reference. Only `0.024150%` of pixels differed, the worst + frame differed in 629 edge pixels, and all 44 full-contrast mismatches were + isolated one-pixel raster ties; no connected missing-face region was found. + The full 251-frame native/browser oracle still passes with stable + 200-root/600-face identity and no missing frames. +- The post-clock-fix user-headed trace, `Trace-20260901T114126.json.gz`, closes + only the schema-6 delivery-cadence question. It does not close perceptual + smoothness. After excluding one unbound trace-start presentation marker + with `begin_frame_id { source_id: 0, sequence_number: 0 }`, all 243 measured + presentation intervals were `16.680–16.681 ms`. rAF p95/max was + `17.645/17.707 ms`, style/layout p95/max was `17.666/17.757 ms`, and DrawFrame + p95/max was `17.713/18.171 ms`, with no DrawFrame gaps above `33.3 ms`. + The trace contains one raw Chromium `STATE_DROPPED (BACKFILL)` record, but the + adjacent DrawFrame gap was `17.228 ms` with `2.313 ms` renderer-main work and + `0.287 ms` maximum Paint. Chromium defines `BACKFILL` as a synthesized report + for a skipped BeginFrame sequence when there were no partial compositor + updates; this record is therefore retained and qualified rather than reported + as zero raw drops or as a visible Cityflow motion hold. +- The user's subsequent visual rejection exposed the missing evidence: the + schema-6 white-face sequence contained pixel-identical consecutive prepared + states and a `481131` maximum versus `978` minimum channel-absolute delta. + Regular callback timing had hidden an uneven visual-state sequence. +- Rejected schema-7 experiment: a periodic C2 height bank, interpolated + presentation colors, and 200 CSS transform animations were installed on the + retained box nodes. A deterministic 603-frame seek capture changed at every + interval, and a local headless performance trace showed presented-frame + p95/max `17.506/18.595 ms`. Neither measurement tested the user's headed + real-time motion adequately. +- The user's schema-7 headed trace, `Trace-20260901T122418.json.gz`, proved that + design was a regression: of 215 measured presentation intervals, 20 were + approximately `33.36 ms` holds and 10 were same-vsync bunches near + `0.001 ms`; presented-frame p95/max was `33.360/33.361 ms`, DrawFrame max was + `30.654 ms`, and rAF max was `43.010 ms`. Renderer-main/compositor occupancy + rose to `1548.089/581.154 ms` over `3.856 s`, compared with + `503.271/184.457 ms` over `4.107 s` in the matched schema-6 headed trace. + Menger's single animated scene root did not justify 200 independent Cityflow + animations plus separately scheduled face color and opacity. Schema 7 was + removed. +- Rejected schema 8 prepared a periodic Catmull-Rom C1 transform bank that passes through + the exact source samples. It creates no CSS animations. One elapsed-time- + anchored rAF publisher applies a complete prepared transform, color, and + opacity state. Late intermediate states collapse to the latest due state; + they are not replayed in a burst. +- A fresh local schema-8 headless trace measured 477 presentation intervals at + p50/p95/max `16.654/17.645/24.289 ms`, DrawFrame p95/max + `17.618/18.119 ms`, no interval above `33.3 ms`, and no smoothness-affecting + pipeline record. Renderer-main/compositor occupancy was + `1161.437/409.012 ms` over eight seconds. RasterTask was not captured and + remains unknown. This cross-mode trace is diagnostic evidence, not headed + perceptual acceptance. +- A fresh schema-8 real-time headless trace with screenshot events measured 267 + presentation intervals at p50/p95/max `16.665/17.602/18.167 ms` and no + pipeline drop. Its steady section yielded 257 decoded screenshots over + `4.475 s`; none of the 256 consecutive pairs were pixel-identical. A separate + deterministic 302-frame, 60 Hz complete-cycle audit also had no identical + culled presentation interval and found no connected missing-face region. + These artifacts did not substitute for the user's headed visual judgment, + which rejected schema 8. +- The user's schema-8 headed trace, `Trace-20260901T130504.json.gz`, shows that + the rejection is not explained by missed presentation cadence: all 211 + measured presented intervals were `16.680–16.681 ms`, rAF max was + `17.698 ms`, and DrawFrame max was `18.161 ms`. One BACKFILL pipeline marker + occurred beside a `17.448 ms` DrawFrame interval. RasterTask and screenshots + were absent, so the trace cannot identify raster cost or show the visible + defect. +- Direct prepared-bank analysis found the schema-8 sequence defect: Catmull-Rom + produced 8,554 height samples outside their adjacent source-height bounds, + including 8,482 invented movements inside flat source intervals, and 8,535 + per-box direction reversals over one cycle. Regular callback and presentation + timestamps could not reveal this temporal trajectory error. +- Schema 9 replaces Catmull-Rom with periodic monotone cubic Hermite C1 + interpolation. Prepared-bank tests cover all 60,200 presentation box states + and require zero adjacent-source overshoots and zero flat-interval movements. + The face schedule receives a second deterministic temporal dilation because + the one-frame source schedule failed the first schema-9 face audit with a + two-pixel connected mismatch. +- The fresh schema-9 302-frame matched audit reports zero identical complete- + presentation intervals. The culled-versus-unculled comparison differs in + `0.022919%` of pixels; all 42 full-contrast mismatches are isolated one-pixel + raster ties, with no connected missing-face region. Its six-second live run + recorded 361 callbacks, zero CSS transform animations, and zero skipped + prepared states. +- The fresh schema-9 local trace measured presented-frame p95/max + `17.497/24.783 ms` and DrawFrame p95/max `17.495/18.808 ms`, with no interval + above `33.3 ms` and no smoothness-affecting pipeline record. RasterTask is + still uncaptured. This remains local headless diagnostic evidence; schema 9 + is not motion-ready until the user's headed visual judgment passes. +- Schema 11 prepares one complete root style per box and frame, keeps the top + leaf as the direct paint owner, paints the front and right faces with root + pseudo-elements, and removes the two retained side leaves from the render + tree. The physically shortened `0.1px` side geometry preserves the sampled + image while removing the long overlapping side-layer bounds. A Chrome 152 + sample at 1280×720 measured 237 compositor layers and 232 drawing layers; + the rejected per-leaf pseudo experiment measured 468 and 234 respectively. +- Schema 11 also replaces elapsed latest-state collapse with the adjacent-state + late-deadline-reset cadence used by the published prepared players. The fresh + six-second Chrome recording observed 361 callbacks and zero skipped prepared + states. Its synchronized 302-frame audit had zero identical consecutive + color-presentation frames. Culled versus fully published geometry differed + in `0.022766%` of pixels; all 26 full-contrast mismatches were isolated + one-pixel raster ties, with no connected missing-face region. +- The fresh schema-11 local Chrome 152 trace measured 477 presentation + intervals at p50/p95/max `16.660/17.630/20.750 ms`, with no interval above + `33.3 ms` and no smoothness-affecting pipeline drop. The worst interval held + `1.796 ms` of renderer-main work and `0.166 ms` maximum Paint. RasterTask was + not captured and remains unknown. This is local headless diagnostic evidence, + not a claim that the user's headed perceptual acceptance has passed. +- Schema 13 combines periodic uniform cubic B-spline C2 reconstruction with the + zero-phase five-tap binomial filter. Its matched raw-frame audit measured 102 + one-to-three-frame direction runs, normalized geometric jerk `0.049118`, and + normalized first-/second-difference VMAF Motion signals + `0.069184/0.071925`. The user reported improved but still imperfect motion. +- A rejected nine-tap experiment reduced one-to-three-frame direction runs to + 51 and normalized geometric jerk to `0.033541`, but its raw-pixel normalized + second-difference signal regressed to `0.075919`. It was not retained. +- Schema 16 retains the accepted five-tap filter and absorbs, during + preparation, each remaining one-to-three-frame circular direction run. The + matched complete-cycle audit reports zero such runs, normalized geometric + jerk `0.049064`, and normalized first-/second-difference VMAF Motion signals + `0.068997/0.071756`. These are matched-build regression signals, not proof of + perceptual acceptance; the user's headed visual judgment remains the gate. +- The fresh schema-16 local Chrome 152 trace measured 478 presented intervals + at p50/p95/max `16.658/17.527/18.843 ms`, with no interval above `33.3 ms` + and no smoothness-affecting pipeline drop. The worst interval contained + `2.834 ms` of renderer-main work and `0.246 ms` maximum Paint. RasterTask was + not captured and remains unknown. This cadence result does not replace headed + visual judgment of the adapted trajectory. +- The user's headed trace, `Trace-20260901T151117.json.gz`, contains one actual + `33.361 ms` presented hold. The callback immediately before the missing + presentation arrived `2.437 ms` before the shifted playback deadline and was + rejected by the former one-eighth-frame (`2.083 ms`) early tolerance. The + three BACKFILL records instead follow pairs of BeginFrames only + `0.199–0.947 ms` apart and lead to normal `16.680–16.681 ms` presentations; + they do not explain the visible hold. The scheduler now publishes the first + callback after resume immediately and accepts up to one-quarter frame + (`4.167 ms`) of display-phase drift while continuing to reject the observed + `3.159 ms` sub-vsync duplicate callback. A dedicated trace-shaped scheduler + regression test owns this boundary. This is a correction of the proven + headed cadence failure, not a claim of headed perceptual acceptance. +- Schema 17 folds every remaining direction run of six frames or less into the + surrounding direction, applies a zero-phase three-tap finish, and repeats the + fold. Against schema 16, complete-bank direction runs fall from 2,640 to + 2,388, median movement length rises from 17 to 20 frames, stationary + transitions fall from `0.129867` to `0.113754`, normalized acceleration falls + from `0.139575` to `0.136101`, and normalized jerk falls from `0.049064` to + `0.038965`. The 302-frame raw-browser sequence improves normalized first- and + second-difference VMAF Motion from `0.068997/0.071756` to + `0.067613/0.071262`; these remain regression signals, not perceptual proof. +- The schema-17 302-frame culled-versus-unculled audit differs in `0.021433%` + of pixels. All 40 full-contrast mismatches are isolated one-pixel raster + ties, and no presentation interval is pixel-identical. Its six-second live + capture records 361 callbacks, zero transform animations, and zero skipped + prepared states. The full 251-frame native/browser source oracle remains + aligned at its calibrated thresholds. +- The fresh schema-17 local Chrome 152 trace measures 477 presented intervals + at p50/p95/max `16.663/17.583/24.807 ms`, DrawFrame p95/max + `17.543/17.832 ms`, no interval above `33.3 ms`, and no smoothness-affecting + pipeline drop. RasterTask remains uncaptured and unknown. Headed perceptual + acceptance remains the final gate. +- Schema 18 extends the direction-run fold from six to twelve frames. It + reduces complete-bank direction runs from 2,388 to 1,426, raises the median + run from 20 to 30 frames, and leaves zero runs of twelve frames or less. + Its normalized geometric acceleration/jerk is `0.134791/0.040342`. At the + user's 2206×1224 stage size, the raw 302-frame full-color sequence measures + normalized first-/second-difference VMAF Motion `0.068248/0.073011`. The + second-difference regression against schema 17 prevented accepting that + trajectory as the endpoint. +- The user's later `Trace-20260901T154813.json.gz` contains 207 measured + presentation intervals, all `16.680–16.681 ms`. Its initial `50.287 ms` + DrawFrame gap is occupied by Chrome's `CpuProfiler::StartProfiling` + (`52.741 ms`), and the later BACKFILL marker follows duplicate BeginFrames + only `0.974 ms` apart while presentations remain regular. That trace therefore + does not explain the remaining bounce as a Cityflow publication hold. +- Schema 19 adds a zero-phase five-tap pass after the schema-18 fold boundaries + and folds the four short runs introduced by that pass. It retains zero runs + of twelve frames or less, reduces the run count to 1,418, raises the median + run to 31 frames, and reduces normalized geometric acceleration/jerk to + `0.131032/0.031010`. On matched 2206×1224 numbered browser frames, edge-only + normalized first-/second-difference VMAF Motion improves from + `0.037059/0.035730` to `0.035478/0.034311`. The full-color second-difference + signal regresses to `0.077611`, so both results remain disclosed and headed + perceptual judgment remains the final motion gate. +- The schema-19 302-frame white-face culled-versus-unculled audit has zero + strong mismatch pixels and zero connected missing-face failures. The full + 251-frame native/browser source oracle at the user's 2206×1224 stage size is + aligned; frame 155 is worst at mean delta `1.038737` and changed-pixel ratio + `0.037811`. Native and browser A/A runs are exact, DOM identity remains 200 + roots and 600 leaves, and no Canvas, WebGL, SVG scene, or transform animation + is present. +- The source's `reshape_cube` switches to a cropped square projection when the + render area is wider than 2:1. The adapter now reproduces that projection and + carries exact prepared wide presentation/source visibility tables. A + standalone 2560×1224 stage still fails calibrated native parity because a + partly visible native face remains a whole CSS plane and can overlap another + shape. This is a separate unresolved >2:1 stage case; the css.graphics page's + 2206×1224 Cityflow stage does not enter that branch. +- The final schema-19 local Chrome 152 trace passes every strict captured gate: + 477 presented intervals have p50/p95/max `16.654/17.599/18.150 ms`, no gap + exceeds `33.3 ms`, DrawFrame p95/max is `17.592/17.693 ms`, and no pipeline + record affects smoothness. Worst-frame renderer-main work is `1.942 ms` and + maximum Paint is `0.196 ms`. RasterTask is absent from the trace and remains + unknown; this headless result does not replace headed perceptual acceptance. +- The user's schema-19 `Trace-20260901T170042.json.gz` contains one `33.361 ms` + presentation interval at trace startup. Its two smoothness-affecting pipeline + drops and `53.811 ms` DrawFrame gap coincide with Chrome's + `CpuProfiler::StartProfiling`, which occupies `60.479 ms`. The remaining 670 + measured presentation intervals are all `16.680–16.681 ms`. This trace rules + out sustained frame-delivery failure; it does not accept the visible motion. +- Schema 20 preserves the schema-19 extrema, range, and 1,418 direction runs, + then applies a periodic cosine ease between consecutive extrema. Prepared + stationary transitions fall from `9.5498%` to `0.1096%`; normalized geometric + acceleration/jerk fall from `0.131032/0.031010` to `0.101081/0.021199`; and + motion-energy coefficient of variation falls from `0.275339` to `0.145322`. + On matched 2206×1224 numbered browser frames, motion-energy range tightens + from `0.33–3.32` to `0.90–2.30` and normalized first difference improves from + `0.068483` to `0.060057`; normalized second difference regresses from + `0.077611` to `0.090165`. These are disclosed regression signals, not headed + perceptual acceptance. +- The schema-20 face-ID-prepared visibility schedule exposes 265–324 of the 600 + retained faces and 123–147 contributing roots per frame. Its matched 302-frame + culled-versus-unculled audit has 182 strong mismatch pixels, all isolated + one-pixel raster ties, with no connected missing-face region and no identical + presentation interval. Its six-second live capture records 361 callbacks, + zero transform animations, and zero skipped prepared states. +- The schema-20 local Chrome 152 trace passes its captured gates: 476 presented + intervals have p50/p95/max `16.662/17.614/26.187 ms`, no gap exceeds + `33.3 ms`, DrawFrame p95/max is `17.540/17.673 ms`, and no pipeline record + affects smoothness. The worst presented interval contains `3.276 ms` of + renderer-main work and `0.321 ms` maximum Paint. RasterTask is absent and + remains unknown; the user's headed visual judgment remains the motion gate. +- The user's schema-20 `Trace-20260901T173433.json.gz` contains 730 measured + presentation intervals, all `16.680–16.681 ms`. rAF p95/max is + `17.602/17.692 ms`; style/layout p95/max is `17.639/23.884 ms`. The two + smoothness-affecting startup drops and `60.168 ms` DrawFrame gap coincide + with `CpuProfiler::StartProfiling`, which occupies `54.393 ms`. This trace + supports changing the prepared trajectory rather than the scheduler. +- Schema 21 preserves schema 20's extrema, range, direction-run order, and + 1,418 direction runs, but replaces cosine easing with the normalized integral + of `sin(πt)^0.85`. Motion-energy coefficient of variation falls from + `0.145322` to `0.138328`. On matched 2206×1224 numbered browser frames, + coefficient of variation falls from `0.2375` to `0.231456` and normalized + first difference improves from `0.060057` to `0.059548`; normalized second + difference regresses from `0.090165` to `0.092518`. The geometric normalized + jerk also regresses from `0.021199` to `0.025788`, so the change remains a + disclosed perceptual candidate rather than a claimed universal improvement. +- The schema-21 face-ID-prepared visibility schedule exposes 268–323 of the 600 + retained faces and 125–149 contributing roots per frame. Its matched + 302-frame culled-versus-unculled audit has 184 strong mismatch pixels, all + isolated one-pixel raster ties, with no connected missing-face region and no + identical presentation interval. Its six-second live capture records 361 + callbacks, zero transform animations, and zero skipped prepared states. +- The schema-21 local Chrome 152 trace passes every captured gate. Its 477 + presented intervals have p50/p95/max `16.675/18.246/22.710 ms`, no gap + exceeds `33.3 ms`, DrawFrame p95/max is `18.088/18.688 ms`, and no pipeline + record affects smoothness. The worst presented interval contains `2.538 ms` + of renderer-main work and `0.218 ms` maximum Paint. RasterTask is absent and + remains unknown; this headless result does not replace headed visual judgment. +- Schema 22 replaces schema 21's fractional sine-power curve with the prepared + adaptive smooth-sine integral. Runs through 24 frames keep cosine easing; + the center-velocity reduction rises linearly to `0.6` at 54 frames. Against + schema 21, the prepared motion-energy coefficient of variation falls from + `0.138328` to `0.136518`, and the relative-to-box-mean below-tenth movement + share falls from `8.46%` to `8.42%`. Geometric normalized jerk improves from + `0.025788` to `0.021691`; normalized acceleration regresses from `0.101652` + to `0.104776` and remains disclosed rather than hidden. +- On matched 2206×1224 numbered browser frames, schema 22 improves schema 21's + VMAF Motion coefficient of variation from `0.231456` to `0.228972`, normalized + first difference from `0.059548` to `0.057002`, and normalized second + difference from `0.092518` to `0.085388`. The latter also improves on schema + 20's `0.090165`. These are trajectory regression signals, not a substitute + for the user's headed perceptual judgment. +- The schema-22 face-ID-prepared visibility schedule exposes 265–323 of the 600 + retained faces and 122–147 contributing roots per frame with 2,666 sparse + toggles. Its matched 302-frame culled-versus-unculled audit has 150 strong + mismatch pixels, all isolated one-pixel raster ties; the largest connected + strong component is one pixel, so no missing-face region was found. The + synchronized presentation sequence has zero identical intervals. Its + six-second live capture records 361 callbacks, zero transform animations, + and zero skipped prepared states. +- The schema-22 local Chrome 152 trace passes every captured gate. Its 477 + presented intervals have p50/p95/max `16.672/17.547/18.187 ms`, no gap + exceeds `33.3 ms`, DrawFrame p95/max is `17.498/17.822 ms`, and no pipeline + record affects smoothness. The worst presented interval contains `2.479 ms` + of renderer-main work and `0.278 ms` maximum Paint. RasterTask is absent and + remains unknown; this headless result does not replace headed visual judgment. +- Schema 23 adjusts the adaptive smooth-sine ramp to begin at 27 frames, reach a + maximum center reduction of `0.55` at 57 frames, and preserves all 1,418 + direction runs with no nonstationary run of twelve frames or less. Its + prepared motion-energy coefficient of variation is `0.138567`; normalized + acceleration/jerk are `0.103795/0.021305`; and the relative-to-box-mean + below-tenth movement share is `8.646%`. These full-cycle values describe the + prepared trajectory but do not establish live cadence. +- Schema 24 replaces direct per-frame face-color replacement with a prepared base + color plus same-leaf `::after` next-color overlay opacity. Against schema 23 on + matched 2206×1224 numbered browser frames, VMAF Motion coefficient of variation + improves from `0.231438` to `0.231135`, normalized first/second differences from + `0.057636/0.086993` to `0.056956/0.085310`, and 18-frame rolling maximum from + `0.131940` to `0.122732`. Rolling values only locate clustered irregularity; + they are not a smoothness target or a substitute for live cadence and headed + perceptual judgment. +- Schema 25 splits the prepared matrix, opacity, and deduplicated face-color + channels, reducing the playback packet to 15.3 MB. Its 30-second Chrome trace + still records four smoothness-affecting pipeline drops and a `36.616 ms` + DrawFrame maximum. That trace rejects schema 25 as performance-ready despite + unchanged prepared motion. +- Schema 26 installs one prepared matrix template per box and publishes only the + prepared z-scale scalar each frame. The playback packet is 2.9 MB and the full + deterministic product is 3,345,317 bytes. All 302 captured 2206×1224 PNGs are + byte-identical to schema 24. Its 30-second Chrome trace measures 1,677 presented + intervals at p50/p95/max `16.662/18.023/23.774 ms`, DrawFrame p95/max + `17.795/23.533 ms`, no gap above `33.3 ms`, and zero smoothness-affecting + pipeline drops. The trace passes the captured cadence gates; headed perceptual + acceptance remains separate. +- Rejected schema 27 used the CSS individual `scale` property to avoid custom- + property substitution. It changed all 302 captured frames and was removed + before performance qualification. +- Schema 45 published each contributing complete state through one of 42,507 + global geometry/material selectors. Browser heap isolation showed that the + class replacement itself made Chrome's embedder heap rise from roughly + `911 MB` to `1.24 GB` before periodic cleanup; replacing the mutation with a + no-op kept it between roughly `12–19 MB`. This identified global selector + invalidation and cleanup as the intermittent long-frame source. +- Rejected schema 46 preserved pixels and bounded heap by mutating prepared + per-face `CSSStyleRule` declarations, but its 18-second trace presented only + about every `50.8 ms`. `UpdateLayoutTree` reached `53.864 ms`; direct rule + mutation invalidated the global style tree and was removed. +- Schema 47 keeps the three retained `` leaves and same-leaf `::after` + overlay. It publishes preformatted base color, next color, and opacity through + face-local attributes consumed by static typed `attr()` declarations, while + a non-inherited `--z` property owns the prepared matrix scalar. No frame class, + stylesheet mutation, animation, runtime number formatting, or inherited + color/opacity variable remains in the live presentation path. +- Against schema 45 at 2206×1224, 301 of 302 numbered frames are byte-identical. + Frame 83 differs in 28 of 2,700,144 pixels (`0.000010`), with a maximum channel + delta of one, from Chrome's typed-opacity rounding. The repeated schema-47 + frame is stable. This is qualified one-level raster tolerance, not an exact + 302-frame claim. +- The fresh schema-47 18-second Chrome 152 trace passes every captured cadence + gate: 958 presented intervals at p50/p95/max `16.660/17.840/24.102 ms`, zero + holds above `33.3 ms`, DrawFrame p95/max `17.741/19.215 ms`, zero + smoothness-affecting pipeline drops, and a `0.293 ms` maximum captured frame + callback. RasterTask is absent and remains unknown. +- The fresh 302-frame culled-versus-fully-published white-face audit records + three strong mismatch pixels across 278,323,200 compared pixels; every strong + component is one isolated pixel. All 301 presentation intervals have nonzero + temporal delta. Its six-second live recording captures 361 callbacks, zero + transform animations, and zero skipped prepared states. +- A separate 30.164-second Chrome heap sample covers 1,812 animation-frame + callbacks. Embedder heap cycles between `6.229–15.376 MB` and ends at + `8.985 MB`; JavaScript used heap settles near `8.959 MB`. The deterministic + prepared product is 2,584,975 bytes with SHA-256 + `b422b526e6834e1b18ad937769bcfb06889babc5e8a95e13b1934652d1b02547`; + playback plus motion CSS Brotli transfer is 836,675 bytes at quality 8. +- Schema 49 restores every retained face to product playback and publishes each + box's complete prepared `matrix3d(...)` value directly. The fixed-viewport + visibility banks remain diagnostic-only. A frozen 241-frame 2206×1224 X-orbit + sweep at normal zoom keeps all 600 faces visible, reports no oversized sample, + and bounds the largest absolute face rectangle coordinate at `3535.712 px`. + The static `0 0 -30px` scene pivot prevents the former CSS perspective-plane + crossing that produced billion-pixel rectangles and right-edge flashes. +- Two schema-49 all-face traces with the same-leaf color overlay reject that + paint path: DrawFrame p95/max was `26.007/43.025 ms` in the alternating repeat, + with two gaps above `40 ms`, ten above `33.3 ms`, and smoothness-affecting + pipeline drops. The playback callback itself was not the dominant cost. +- Schema 50 removes the second face paint surface. Preparation resolves every + interpolated sRGB color into one of 2,424 deduplicated final three-face tuples; + runtime writes those preformatted colors directly and performs no color + calculation, typed `attr()` evaluation, custom-property substitution, or + pseudo-element painting. The playback packet is 10,270,226 bytes raw and + 761,396 bytes at Brotli quality 8. +- Against the isolated one-surface `color-mix()` diagnostic at presentation + frame 220, the schema-50 prepared direct-color frame changes 7,907 of + 8,100,432 channel samples, all by exactly one level or less. Against schema + 49's double-raster overlay across all 302 frames, the worst mean absolute + delta is `0.055619`; changed-pixel ratio is `0.008566` at that frame and the + maximum edge delta is 38. This is a deliberate one-surface raster change, + not an exact-pixel claim. +- Two independent schema-50 302-frame captures at 2206×1224 are byte-identical. + The prepared trajectories retain 1,418 direction runs, no nonstationary run + of twelve frames or less, and a stationary-transition ratio of `0.001080`. + Captured VMAF Motion coefficient of variation is `0.225036`; normalized first + and second differences are `0.056301/0.084040`. These remain motion signals, + not perceptual proof. +- Two schema-50 product traces in Chrome 152 pass every captured cadence gate. + DrawFrame p95/max is `17.566/17.904 ms` and `17.503/17.911 ms`; both runs have + zero gaps above `33.3 ms` and zero smoothness-affecting pipeline drops. Worst + steady renderer-main work is `2.858 ms` and `3.286 ms`. RasterTask remains + absent and unknown; headed visual judgment remains separate. +- The uniform-`0.1` pre-repair 251-frame native and browser source sequences + were independently deterministic, but their calibrated comparison was not + aligned: worst mean absolute delta was `1.288004` at source frame 209 against + the `1.125` threshold. +- The former uniform `0.1` side-depth crop visibly exposed black openings when + tall towers rose. At source frame 125, the left opening contained 10,830 + connected pixels where native was non-black and the browser was black. A + full-side face-ID pass attributed it primarily to box 32 front and box 18 + right, with smaller box 1 right and box 18 front contributions. Preparation + now consumes `prepared-side-depth.json`: 19 measured side faces receive + static depth scales between `0.15` and `0.28`, while all others remain `0.1`. + The refreshed 251-frame source capture reduces the frame-125 left component + to zero. Excluding the outer 30-pixel horizontal and two-pixel vertical + viewport borders, the entire sequence contains 6,252 native-nonblack/browser- + black pixels; its largest remaining component is 688 pixels at the top + viewport edge. The new full 251-frame oracle is independently deterministic + and aligned under the existing calibration: worst frame 155 has mean absolute + delta `1.111180` and changed-pixel ratio `0.044644`, against thresholds + `1.125` and `0.05`. This is calibrated tolerance, not exact pixel identity. +- Two independent repaired 302-frame presentation captures at 2206×1224 are + byte-identical. The full normal-zoom X sweep samples 181 positions from + -180° through 180°, keeps all 600 faces, reports no oversized face, and has a + `3535.051 px` maximum absolute face bound. The deterministic prepared product + is 10,524,351 bytes with SHA-256 + `fea44989171c0205b894516bda365f6def16dee623b28815a8f06da9552cbc92`. +- The fresh post-repair Chrome 152 product trace passes every captured cadence + gate. DrawFrame p95/max is `17.524/18.001 ms`; presented p95/max is + `17.965/24.302 ms`; there are no gaps above `33.3 ms` or `40 ms` and no + smoothness-affecting pipeline drops. RasterTask remains absent and unknown. +- The supplied headed trace `Trace-20260902T131833.json.gz` rejects the + all-published-face product as smooth. After the trace-start marker, two + smoothness-affecting `STATE_DROPPED (BACKFILL)` records overlap one + `33.160 ms` DrawFrame gap at `+3835.970/+3852.650 ms`. That interval contains + only `2.848 ms` of renderer-main work and `0.861 ms` total Paint, while the + global GPU role is busy for `13.607 ms`. It therefore does not support a + JavaScript scheduling explanation. RasterTask and screenshots are absent, so + raster attribution remains unknown. +- The schema-52 product applied the earlier prepared opacity schedule. + Against fully published captures, all 301 normal 1280×720 frames and all 301 + wide 2473×1236 frames are byte-identical: zero changed frames and zero changed + pixels. This comparison includes CSS 3D sorting effects; it is not only a + face-ID census. Exact source seeks restore all 200 roots and 600 faces, and a + browser round-trip smoke proves that presentation seek restores 10 statically + hidden roots, 30 statically hidden faces, and the frame-zero opacity row. +- In matched 7.96-second steady headless traces, the earlier all-face product + recorded 113,644 Paint events and `357.642 ms` total Paint. Schema 52 records + 96,247 events and `302.098 ms`, reductions of `15.31%` and `15.53%`. + Global GPU-role busy time changes from `4031.446 ms` to `3908.387 ms` + (`3.05%` lower). The schema-52 trace has DrawFrame p95/max + `17.479/17.711 ms`, presented p95/max `17.686/24.739 ms`, zero gaps above + `33.3 ms`, and zero smoothness-affecting pipeline drops. RasterTask remains + absent and unknown; a new headed trace is still required to confirm the + intermittent user-environment drop is gone. +- The schema-53 display-carrier candidate preserves the exact schema-52 prepared visibility rows + but changes their runtime carrier from `opacity: 0` to `display: none`. + Transparent 3D faces continued to own compositor layers; hidden display faces + do not. Across all 301 normal 1280×720 frames and all 301 wide 2473×1236 + frames, before/after PNG bytes are identical. A full-loop Chrome LayerTree + census now follows the prepared 490–538 visible-face range: total compositor + layers vary from 500 to 548 instead of remaining at 580, removing 32–80 + invisible layers per state. A duration-matched normal-speed trace records zero + pipeline drops, presented p95/max `17.514/24.798 ms`, DrawFrame p95/max + `17.448/19.408 ms`, and lower mean GPU-role (`-3.08%`), Paint (`-4.48%`), + Commit (`-7.83%`), and animation-callback (`-17.25%`) work than schema 52. + It also introduces `0.095 ms` mean Layout work per frame. A separate 4x CPU + stress run is not an acceptance run: it records one pipeline-drop marker and + one `20.825 ms` DrawFrame interval with `3.712 ms` main-thread GC occupancy. + The six-file prepared closure is deterministic at 10,869,748 bytes with + SHA-256 `33d286dbcd3df8038c4037c6a57418380b3a27d9b90b0cba112d3aebc9176aae`. + RasterTask remains absent and unknown; perceptual smoothness still requires a + fresh headed user trace. +- The schema-54 product keeps the same rows and layer reduction but replaces the + schema-53 `display` carrier with direct `visibility` assignments. Across the + complete 301-frame normal and 301-frame wide sequences, schema 54 is byte-for-byte + identical to schema 53. Its full-loop LayerTree range is 500–548 total layers + with a 522.309 mean, versus the schema-52 fixed 580. Unlike schema 53, both + matched normal-speed schema-54 traces contain no Layout slices. Both traces + pass all strict gates with zero pipeline drops: DrawFrame p95/max is + `17.397/17.667 ms` and `17.427/18.626 ms`; presented p95/max is + `17.486/24.617 ms` and `17.524/23.517 ms`. Against schema 52, the first matched + run lowers mean renderer-main work by `4.04%`, Layerize by `3.79%`, Commit by + `6.29%`, and animation-callback work by `18.09%`. The separate 4x CPU stress + run still rejects, with four pipeline-drop markers and a `29.573 ms` maximum + DrawFrame interval; it is not normal-speed acceptance evidence. The six-file + prepared closure is deterministic at 10,869,766 bytes with SHA-256 + `8c8e85804940e249eb64b37262c338294a9334d146637cdaf6d586c456535ac5`. + RasterTask remains absent and unknown, so a fresh headed user trace remains the + perceptual acceptance gate. +- The refreshed full 251-frame native/browser oracle is deterministic on both + sides and remains aligned. The worst calibrated frame is 155 at mean absolute + delta `1.111180` and changed-pixel ratio `0.044644`, inside the existing + `1.125/0.05` thresholds. The deterministic prepared closure contains six files, + is 10,541,755 bytes, and has SHA-256 + `a9dc7385bd378cfec7f98af1130c365e988bbbb70b777c8013934400f397d996`. +- The shipped route starts prepared playback automatically with the fixed source + camera. Pointer-drag orbit, wheel zoom, the diagnostic orbit state, and the + orbit-only scene pivot are not part of the product interaction surface. +- The user's schema-54 trace, `Trace-20260902T171524.json.gz`, contains two + steady `33.361 ms` presentation holds. One is adapter-side: a normal display + callback arrived `15.436 ms` after the previous publication but was rejected + because the accumulated deadline had drifted more than the one-quarter-frame + early tolerance. The scheduler now rescues an otherwise-early callback when + it is at least three-quarters of a frame after the last publication, publishes + exactly one adjacent state, and re-phases the deadline. It still rejects the + observed `3.159 ms` sub-vsync duplicate and retains accumulated deadlines for + high-refresh callback streams. The other hold received no animation-frame + callback, so JavaScript cadence cannot repair it. The trace's 25 steady + `RasterTask` events are global/nonexclusive, total `1.568 ms`, peak at + `0.159 ms`, and do not overlap the worst hold; they do not explain that + browser-side missed delivery. This is a trace-shaped scheduler correction, + not a claim that a fresh headed run has no remaining hold. +- The schema-56 product replaces the earlier face-level carrier with the prepared + viewport-independent whole-box schedule. Root suppression was rejected because + removing a root changes Chrome's CSS 3D sorting: the largest attributed failure + was a 4,477-pixel right-edge component at presentation frame 245. Direct leaf + visibility had the same sorting problem until full-frame attribution found the + eight always-visible dependency boxes listed in the runtime boundary. The final + 1280x720 full-loop LayerTree census ranges from 508 to 571 total layers and from + 503 to 566 drawing layers. Those counts equal the 498--561 visible faces plus + ten and five layers respectively, proving that 39--102 of the fully published + 610 compositor layers are absent rather than merely transparent. +- The schema-56 sparse-publication audit is pixel-exact for all 301 transitions at + 1280x720 and 2560x1224: it changes zero pixel bytes against full color and + transform publication while avoiding 52,351 root-transform writes and 61,358 + leaf-color writes in each run. Full-frame checks repair every attributed large + sorting failure; the complete 2.1007-ratio loop has no material mismatch. The + broad independent-page comparator still reports small raster-edge components + on some normal-ratio frames, but a same-state recapture reduced one reported + 18-pixel component to five pixels and no single hidden box removed it. Those + tiny cross-page deltas are treated as nondeterministic raster noise, not as an + exact-parity pass. +- The final normal-speed Chrome 152 trace passes all captured gates: presented + p95/max is `18.561/25.173 ms`, DrawFrame p95/max is `18.476/18.822 ms`, and + there are zero presentation gaps above `33.3 ms` and zero pipeline drops. The + first 14-profile cadence sweep passed 12 profiles; phone 430x932 DPR 2 and + square 1024x1024 DPR 1 each recorded one isolated presentation hold while + renderer-main occupancy stayed below 10 ms and pipeline drops remained zero. + Immediate repeats of both profiles passed with maxima of `23.431 ms` and + `22.993 ms`. A subsequent complete 14-profile sweep passes every profile with + zero doubled holds and zero pipeline drops. Its exact-stage-wide and ultrawide + captures each contain two compensated presentation timestamp bunches; their + DrawFrame maxima remain `19.147 ms` and `19.660 ms`, respectively. A 4x CPU + stress trace is diagnostic, not acceptance evidence: it has no presentation + hold or pipeline drop but rejects the `2 ms` paint ceiling with a `2.825 ms` + maximum. RasterTask remains absent and unknown. +- The deterministic six-file schema-56 prepared closure is 10,869,518 bytes with + SHA-256 `b23b08446471afc8c90e25636865bfa974fe5d5acf1f17caf075ba7cf0b6a58c`. +- Schema 57 replaces the 76,051 complete wire `matrix3d(...)` strings with the + published CSSGraphics prepared-transform component-stream pattern and the + published Gravity Well signed-varint component transport. Cityflow needs one + affine template and one fixed-point height-delta stream per box because an + exhaustive preparation check proves that only affine component 8 changes + within each box. Preparation expands all 76,051 packed transforms and rejects + the packet unless every reconstructed string is byte-exact. The generated + playback payload contains no `matrix3d(` text; it is 1,417,370 raw bytes, + 677,941 gzip-9 bytes, and 660,798 Brotli-quality-8 bytes. The packed transform + streams themselves contain 269,301 bytes. The deterministic six-file closure + is 1,677,507 bytes with SHA-256 + `55b268c582cdcb69a0b0ec834c92baf0fe8c036ad5d99af342c79bb479b8351b`. + A fresh eight-keyframe 1280x720 comparison against the accepted schema-56 + sequence is pixel-exact at zero tolerance. A fresh 302-state normal and wide + raster audit changes zero pixel bytes, with zero skipped prepared states. A + fresh ten-second Chrome 152 trace passes every captured performance gate: + presented p95/max is `17.493/18.503 ms`, DrawFrame p95/max is + `17.395/17.692 ms`, and there are zero gaps above `33.3 ms` and zero + smoothness-affecting pipeline drops. RasterTask remains absent and unknown. +- Schema 58 adds an independent 100-root/300-leaf mobile prepared bank while + preserving the 200-root/600-leaf desktop bank. Selection follows the + published CSSGraphics profile pattern and happens once before bank fetch and + mount; the browser requests only the selected model, playback, and CSS. Both + payloads retain the schema-57 packed transform transport and contain no + `matrix3d(` text. Desktop playback is 1,417,410 raw / 660,745 Brotli-quality-8 + bytes; mobile is 770,522 raw / 342,005 Brotli-quality-8 bytes. The ten-file + prepared closure is deterministic at 2,580,217 bytes with SHA-256 + `7d8142a3767801a73e2d3692019d371eb11e5108ec0d5364b19317e26b5f49b2`. + Eight desktop keyframes remain pixel-exact against the accepted schema-57 + sequence. The full 251-frame mobile native/browser sequence passes with frame + 159 worst at mean delta `0.621742` and changed-pixel ratio `0.0141`. In the + steady portion of a ten-second 390×844 Chrome 152 trace, presented-frame + p95/max is `17.464/23.638 ms`, DrawFrame p95/max is `17.491/17.673 ms`, and + there are zero gaps above `33.3 ms` and zero pipeline-drop markers. RasterTask + is absent from the steady selection and remains unknown there. diff --git a/src/adapters/cityflow/notes/references/prepared-side-depth.json b/src/adapters/cityflow/notes/references/prepared-side-depth.json new file mode 100644 index 0000000..5babe79 --- /dev/null +++ b/src/adapters/cityflow/notes/references/prepared-side-depth.json @@ -0,0 +1,40 @@ +{ + "schema": "csscityflow-prepared-side-depth@1", + "defaultDepthScale": 0.1, + "maximumDepthScale": 0.28, + "entries": [ + { "faceIndex": 5, "boxIndex": 1, "face": "right", "depthScale": 0.15, "measuredRequiredScale": 0.113031, "measurementFrame": 122 }, + { "faceIndex": 55, "boxIndex": 18, "face": "front", "depthScale": 0.15, "measuredRequiredScale": 0.110379, "measurementFrame": 123 }, + { "faceIndex": 56, "boxIndex": 18, "face": "right", "depthScale": 0.17, "measuredRequiredScale": 0.131416, "measurementFrame": 124 }, + { "faceIndex": 97, "boxIndex": 32, "face": "front", "depthScale": 0.2, "measuredRequiredScale": 0.15282, "measurementFrame": 126 }, + { "faceIndex": 98, "boxIndex": 32, "face": "right", "depthScale": 0.17, "measuredRequiredScale": 0.125797, "measurementFrame": 210 }, + { "faceIndex": 229, "boxIndex": 76, "face": "front", "depthScale": 0.23, "measuredRequiredScale": 0.180619, "measurementFrame": 192 }, + { "faceIndex": 250, "boxIndex": 83, "face": "front", "depthScale": 0.18, "measuredRequiredScale": 0.133827, "measurementFrame": 192 }, + { "faceIndex": 251, "boxIndex": 83, "face": "right", "depthScale": 0.2, "measuredRequiredScale": 0.152479, "measurementFrame": 192 }, + { "faceIndex": 259, "boxIndex": 86, "face": "front", "depthScale": 0.22, "measuredRequiredScale": 0.173792, "measurementFrame": 194 }, + { "faceIndex": 343, "boxIndex": 114, "face": "front", "depthScale": 0.22, "measuredRequiredScale": 0.179503, "measurementFrame": 204 }, + { "faceIndex": 404, "boxIndex": 134, "face": "right", "depthScale": 0.18, "measuredRequiredScale": 0.144788, "measurementFrame": 190 }, + { "faceIndex": 409, "boxIndex": 136, "face": "front", "depthScale": 0.28, "measuredRequiredScale": 0.230142, "measurementFrame": 206 }, + { "faceIndex": 418, "boxIndex": 139, "face": "front", "depthScale": 0.22, "measuredRequiredScale": 0.179251, "measurementFrame": 9 }, + { "faceIndex": 493, "boxIndex": 164, "face": "front", "depthScale": 0.2, "measuredRequiredScale": 0.154559, "measurementFrame": 227 }, + { "faceIndex": 508, "boxIndex": 169, "face": "front", "depthScale": 0.18, "measuredRequiredScale": 0.143433, "measurementFrame": 12 }, + { "faceIndex": 539, "boxIndex": 179, "face": "right", "depthScale": 0.18, "measuredRequiredScale": 0.141495, "measurementFrame": 116 }, + { "faceIndex": 541, "boxIndex": 180, "face": "front", "depthScale": 0.22, "measuredRequiredScale": 0.171797, "measurementFrame": 21 }, + { "faceIndex": 542, "boxIndex": 180, "face": "right", "depthScale": 0.22, "measuredRequiredScale": 0.170417, "measurementFrame": 27 }, + { "faceIndex": 544, "boxIndex": 181, "face": "front", "depthScale": 0.18, "measuredRequiredScale": 0.135957, "measurementFrame": 27 } + ], + "measurement": { + "sourceFrameCount": 251, + "viewport": { "width": 1280, "height": 720, "deviceScaleFactor": 1 }, + "nativeSequence": "bench/results/csscityflow/native-browser-visual-schema-50/native/run-a/frames", + "browserSequence": "bench/results/csscityflow/native-browser-visual-schema-50/browser/run-a/frames", + "method": "native-nonblack-browser-black-pixel-census-with-full-side-face-id-ownership-and-inverse-content-quad-homography", + "selection": "faces-with-material-black-gap-contribution", + "safety": "measured-required-scale-rounded-up-with-at-least-0.03-additional-depth" + }, + "provenance": { + "sourceRepository": "https://github.com/Zygo/xscreensaver", + "sourceRevision": "906693799e4fb7581436590cf84ecb2d3c9186ba", + "sourcePath": "hacks/glx/cityflow.c" + } +} diff --git a/src/adapters/cityflow/notes/references/prepared-static-visibility.json b/src/adapters/cityflow/notes/references/prepared-static-visibility.json new file mode 100644 index 0000000..634201e --- /dev/null +++ b/src/adapters/cityflow/notes/references/prepared-static-visibility.json @@ -0,0 +1,221 @@ +{ + "schema": "csscityflow-prepared-static-visibility@3", + "sourceRevision": "906693799e4fb7581436590cf84ecb2d3c9186ba", + "seed": 26081702, + "frameCount": 301, + "boxCount": 200, + "facesPerBox": 3, + "faceCount": 600, + "visibleFaceCount": 585, + "hiddenFaceCount": 15, + "visibleBoxCount": 195, + "hiddenBoxCount": 5, + "hiddenFaceIndices": [ + 204, + 205, + 206, + 207, + 208, + 209, + 528, + 529, + 530, + 570, + 571, + 572, + 597, + 598, + 599 + ], + "hiddenBoxIndices": [ + 68, + 69, + 176, + 190, + 199 + ], + "sortingDependencyBoxIndices": [ + 155, + 156, + 165, + 171, + 172, + 179, + 185, + 196 + ], + "presentation": { + "schema": "csscityflow-prepared-presentation-box-visibility@1", + "encoding": "initial-box-bitset-plus-u16le-per-target-frame-toggle-offsets-and-box-indices", + "frameCount": 301, + "boxCount": 200, + "faceCount": 600, + "transitionDilationFrames": 12, + "alwaysVisibleBoxIndices": [ + 0, + 1, + 2, + 6, + 8, + 16, + 18, + 19, + 21, + 22, + 25, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 37, + 38, + 40, + 41, + 43, + 44, + 47, + 48, + 49, + 50, + 51, + 52, + 53, + 54, + 55, + 56, + 57, + 58, + 61, + 62, + 63, + 64, + 65, + 66, + 72, + 73, + 74, + 76, + 78, + 79, + 81, + 82, + 83, + 84, + 85, + 86, + 87, + 88, + 89, + 91, + 92, + 93, + 95, + 96, + 97, + 99, + 101, + 102, + 104, + 105, + 106, + 107, + 108, + 109, + 110, + 111, + 112, + 113, + 114, + 115, + 116, + 117, + 118, + 119, + 120, + 121, + 122, + 123, + 124, + 125, + 126, + 127, + 128, + 129, + 130, + 131, + 132, + 133, + 136, + 137, + 138, + 139, + 140, + 141, + 143, + 144, + 145, + 147, + 148, + 150, + 151, + 152, + 154, + 155, + 156, + 157, + 160, + 161, + 162, + 164, + 165, + 167, + 169, + 170, + 171, + 172, + 179, + 180, + 185, + 196 + ], + "initialVisibleCount": 510, + "initialVisibleBoxes": 170, + "minimumVisibleFaces": 498, + "maximumVisibleFaces": 561, + "meanVisibleFaces": 522.1495016611295, + "minimumVisibleBoxes": 166, + "maximumVisibleBoxes": 187, + "meanVisibleBoxes": 174.04983388704318, + "transitionCount": 240, + "maximumTransitionWritesPerFrame": 6, + "initialVisibleBoxBitsBase64": "99dv+3+////P3//7f///////+/33XzybNA==", + "transitionOffsetsBase64": "AAAAAAIAAgADAAMABAAEAAQABAAEAAUABgAIAAgACAAJAAkACQAKAA0ADQANAA4ADwAPABIAFQAVABYAGAAYABoAGwAcAB0AHQAfACAAIQAhACMAJQAlACYAJgAmACYAJgAmACcAKgAqACoAKgArACwALgAuAC8ALwAvADIAMgAyADQANwA6ADoAOgA6ADoAOgA6ADoAOgA6ADsAOwA8ADwAPAA8ADwAPAA8AD0APQA/AD8AQABBAEIARQBGAEcARwBKAEwATABNAE0ATwBPAE8AUABRAFUAVQBVAFYAVgBXAFcAVwBZAFoAWgBaAFoAXABcAF4AXgBhAGEAYQBhAGMAZgBoAGgAaABpAGoAagBrAG0AbQBuAG8AcQBxAHEAcgByAHIAcwBzAHMAcwBzAHMAcwBzAHMAcwBzAHMAdAB0AHQAdAB0AHUAdQB1AHYAdgB2AHYAeQB5AHkAewB7AHsAfgB/AIAAgACAAIAAgACAAIAAgACBAIIAgwCEAIQAiACLAIwAjACMAIwAjgCOAI4AjgCOAJAAkgCSAJMAlACWAJYAlwCXAJgAmgCbAJsAmwCbAJ0AnwCfAJ8AnwCfAKAAoQChAKIAowCkAKQApQCmAKYApgCnAKkAqgCtAK0ArgCvALAAsgCyALMAuQC5ALoAugC6ALsAuwC7ALwAvQDAAMAAwwDGAMYAxgDHAMcAywDMAM0AzgDQANEA1ADVANUA1QDWANcA2ADZANkA2wDgAOAA4ADhAOQA5gDmAOcA5wDnAOcA6QDpAOsA7ADsAOwA7ADtAO8A8ADwAA==", + "transitionBoxIndicesBase64": "CwAnAK4AkgAKAJ4AmQC/AFoAPAAtADsAsgA7ABwAngCtALEAAwAnAMIAqAAMAMIAsgC8ABwAowAEAAkADAC9ALcAtgC6AGcArwCSACQAmQCtALEAZACoAEcATQDDABEAQwCZACQALgAYABwAvQAnAJIAvAAtABQAhgAHAA8APAAYALwAAwAOAC4AFwAnAA8AkgCeAGIAtgAHAA4ATQCSAGcAAwAJAJ4AugBLAFAAGgBLAFAACgARAF4AwwAuAF4AtwBkAK8AHABiAIYABAC2ALcArwCSAAkAugAbALwAQwBHAAkAGwDDAC4AZwADAAcADQCvALwAngCjALwAQwBHAK8ARgDFAJ4AZAC9AMAAxgADAJIAxQBGABwAvwAHAMEATQC7ABQAuwANABwARwAMAAwAZACuAEMAvQAJAJIAhwAuAJ8AqAC3AKYAvwBNABoAxgC8AAwATQC1AAMAwADBAAMAjgCxABwAjgCtAK8AugC7ABsAxQC4AGcAngCvALgACwCmALsAkgCfAMMAqAAMAC4AtQC2ACoABQAqAAsARwAbAAMAQwDFAK4AvwBkAAUAhwAJAK4AHABaAGQArQCxAK8ATQCVAJ4AhgCSAJkADACGAAsAQwBHAAwAlQC8ABcA", + "policy": "viewport-independent-whole-box-only-three-projection-union-with-12-frame-dilation-plus-full-frame-sorting-dependencies" + }, + "coverage": { + "browser": "Google Chrome 152.0.7977.65 headless DPR 1", + "captureMethod": "solid-face-id-exact-interior-pixel-census-plus-full-frame-raster-diff", + "projectionViewports": [ + { + "width": 2472, + "height": 1236, + "branch": "normal-maximum-aspect" + }, + { + "width": 2473, + "height": 1236, + "branch": "wide-maximum-stage-height" + }, + { + "width": 3086, + "height": 1440, + "branch": "ultrawide-supported-stage" + } + ], + "policy": "whole-box-visibility-union-plus-full-frame-css-3d-sorting-dependencies" + } +} diff --git a/src/adapters/cityflow/notes/references/prepared-visibility-adaptive-smooth-sine-balanced.json b/src/adapters/cityflow/notes/references/prepared-visibility-adaptive-smooth-sine-balanced.json new file mode 100644 index 0000000..ab354ea --- /dev/null +++ b/src/adapters/cityflow/notes/references/prepared-visibility-adaptive-smooth-sine-balanced.json @@ -0,0 +1,482 @@ +{ + "schema": "csscityflow-browser-visible-face-id-source@1", + "encoding": "initial-bitset-plus-u16le-per-target-frame-toggle-offsets-and-face-indices", + "browser": { + "name": "Google Chrome", + "version": "152.0.7977.65", + "headless": true + }, + "viewport": { + "width": 1280, + "height": 720, + "deviceScaleFactor": 1 + }, + "frameCount": 301, + "faceCount": 600, + "initialVisibleCount": 232, + "minimumVisibleFaces": 190, + "maximumVisibleFaces": 260, + "meanVisibleFaces": 229.6046511627907, + "visibleFaceCount": 450, + "visibleFaceIndices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 11, + 12, + 13, + 15, + 16, + 17, + 18, + 20, + 21, + 22, + 24, + 25, + 26, + 27, + 29, + 31, + 32, + 33, + 35, + 36, + 37, + 38, + 39, + 42, + 43, + 44, + 45, + 47, + 48, + 49, + 51, + 52, + 53, + 54, + 55, + 56, + 57, + 58, + 59, + 60, + 61, + 62, + 63, + 64, + 65, + 66, + 67, + 68, + 69, + 70, + 72, + 73, + 74, + 75, + 76, + 77, + 78, + 80, + 81, + 82, + 83, + 84, + 85, + 86, + 87, + 88, + 90, + 92, + 93, + 94, + 95, + 96, + 97, + 98, + 99, + 101, + 102, + 103, + 104, + 105, + 106, + 107, + 108, + 110, + 111, + 112, + 113, + 114, + 115, + 116, + 120, + 121, + 122, + 123, + 124, + 125, + 126, + 127, + 129, + 130, + 131, + 132, + 133, + 134, + 135, + 136, + 137, + 141, + 142, + 143, + 144, + 145, + 146, + 147, + 148, + 152, + 153, + 154, + 155, + 156, + 157, + 158, + 159, + 160, + 161, + 162, + 163, + 164, + 165, + 166, + 167, + 168, + 169, + 170, + 171, + 172, + 173, + 174, + 175, + 176, + 177, + 178, + 182, + 183, + 184, + 185, + 186, + 187, + 188, + 189, + 190, + 191, + 192, + 194, + 195, + 196, + 198, + 199, + 200, + 210, + 211, + 216, + 217, + 218, + 219, + 220, + 221, + 222, + 225, + 226, + 227, + 228, + 229, + 230, + 232, + 233, + 234, + 235, + 236, + 237, + 238, + 239, + 240, + 241, + 242, + 243, + 244, + 245, + 246, + 247, + 248, + 249, + 250, + 251, + 252, + 253, + 254, + 255, + 256, + 257, + 258, + 259, + 260, + 261, + 262, + 263, + 264, + 265, + 267, + 268, + 269, + 272, + 273, + 274, + 275, + 276, + 277, + 278, + 279, + 280, + 282, + 283, + 284, + 285, + 286, + 287, + 288, + 289, + 290, + 291, + 292, + 293, + 297, + 298, + 303, + 304, + 305, + 306, + 307, + 308, + 309, + 311, + 312, + 313, + 314, + 315, + 316, + 317, + 318, + 319, + 320, + 321, + 322, + 323, + 324, + 325, + 326, + 327, + 328, + 330, + 331, + 332, + 333, + 334, + 335, + 336, + 337, + 338, + 339, + 340, + 341, + 342, + 343, + 344, + 345, + 346, + 347, + 348, + 349, + 350, + 351, + 352, + 353, + 354, + 355, + 356, + 357, + 358, + 360, + 361, + 362, + 363, + 364, + 365, + 366, + 367, + 368, + 369, + 370, + 371, + 372, + 373, + 374, + 375, + 376, + 377, + 378, + 379, + 380, + 381, + 382, + 383, + 384, + 385, + 386, + 387, + 388, + 389, + 390, + 391, + 392, + 393, + 394, + 395, + 396, + 397, + 398, + 399, + 401, + 402, + 404, + 405, + 406, + 407, + 408, + 409, + 411, + 414, + 415, + 416, + 417, + 418, + 420, + 421, + 422, + 423, + 424, + 425, + 426, + 427, + 428, + 431, + 432, + 433, + 434, + 435, + 436, + 437, + 438, + 441, + 442, + 443, + 444, + 445, + 446, + 447, + 448, + 450, + 451, + 452, + 453, + 455, + 456, + 457, + 458, + 461, + 462, + 463, + 464, + 465, + 466, + 467, + 468, + 469, + 470, + 471, + 472, + 473, + 476, + 477, + 478, + 479, + 480, + 481, + 482, + 483, + 484, + 486, + 487, + 488, + 492, + 493, + 494, + 498, + 499, + 500, + 501, + 502, + 503, + 504, + 505, + 507, + 508, + 509, + 510, + 511, + 512, + 513, + 514, + 515, + 517, + 518, + 538, + 539, + 540, + 541, + 542, + 543, + 544, + 545, + 550, + 551, + 552, + 553, + 554, + 559, + 560, + 565, + 566, + 582, + 583 + ], + "transitionCount": 7112, + "initialVisibleBitsBase64": "/wEUAQAgaYsACID0z9cUb3BAGdas/wHgQAEMfzD8RQ8cKPogL4AH2F9t994BX4sm338Aw7MDM24EhwHg2HEc2wEAAGgBAwAAAAAA", + "transitionOffsetsBase64": "AAAZAC0ASABhAHQAkQCmALUAyQDpAAMBGwE1AU8BYAF3AYwBogG6AdYB8gELAiECNAJKAl8CdQKOAqICwALcAvkCFwMmA0ADWgN1A5QDsQPJA+gDAAQbBDsEUgRpBIcEqQS/BN4E+AQSBScFPgVaBXMFiwWiBb4F0QXnBQIGHQYvBkQGUgZmBn4GmQasBscG4Ab2BhMHLgdCB1oHcweKB6AHqQe5B84H4wcACBcIKQg4CEwIaQiECJ4IsgjFCNkI8AgKCR8JLQk+CVQJZwl5CYwJnAmxCcMJzwneCfAJAQoYCjcKSgpgCnIKiwqfCroK0QroCv0KFAsqC0ULYQt5C5MLpQu3C8wL5gv4Cw8MHgwvDEgMXAx3DIwMngy0DM8M3gzlDPUMBA0XDS4NSA1jDYANmQ22DdEN6Q0IDigORQ5zDo0Opw7ADtsO9g4RDysPRw9dD3QPjg+gD7IPxw/YD/APBhAhEEgQYBB0EIcQohC7EMYQ5RAAER0ROxFXEXARihGnEbsR0hHoEf8RGBIvEkgSYRJ0EogSnhK2Es8S6RICEyUTRxNZE28TjBOiE7QT0hPuEwoUJBQ/FFwUehSWFLMU0BTsFA0VLhVKFWAVdBWIFaAVuBXNFeQV9xUMFiMWNhZQFmUWdxaOFqkWvRbRFukWARcRFxwXLhdIF1wXaxd+F5gXshfEF9QX6hcKGBsYLBhCGFoYcRiEGJ4YvhjXGPAYDxkmGUkZahmCGZsZuBnRGeoZBhocGj0aWRp3Go8apRq2Gswa4xr7Gg8bJBs5G00bbRuFG50bsxvIGw==", + "transitionFaceIndicesBase64": "FgA4AEIAcABxAH4A3gDiAO4ARgFIAU0BVAFVAWIBbQFwAXQBlQGwAbEBuwG+AcQB+AEWAC0AQgBxAJgApAC6AMcACAEaAUcBSAFhAWYBcAFzAYUBlAG+AcUBKgAtADgAcABxAH4AjgCkAOIA/AD/AAgBGgFIAUwBYQFiAWYBbQFwAXMBegGwAbEBuwHEAcUBKgAtAEIAfgCNAKYA7gD4APwA/wAIARoBRwFIAVUBcAF0AXoBogGwAcQByAHmASECJgIqAEIAcQB+AI0AjwCmALoA4gDuAPgA/wAIARoBVQGvAfMB+QEmAioALQBwAI0AjwCfALoA3gDoAOkA7gD+AP8ACAFGAUgBVQFeAXABegGvAbABsQG7Ab4BxQHZAf0B/gF+AI8AmAC6AOgA6QDuAP4A/wAIATsBRgFIAVUBXgGvAbABvgHZAfgBHwIWAHMAfgCPAJgA4gD8ADsBYQFwAa8BuwG8AdkB+AEPADQA4QDiAPgA/AD+AAgBGgE7AVUBZgGfAaABvwHDAcUB2QEbAjUCDwAWABsAcABzAJgApADiAOMA+AD8AP4AGgE7AUYBVQFhAWYBngGfAaoBqwGsAbYBuwG/AcAB3gHnAegB9AE1AhQAGwAkAF8AcABzAH4AjwCYAKQApgD8AAgBRgFIAWEBbgF6AZ8BqwGsAbYBuwG+Ab8BwAEbACQALQA0AF8AYwBwAI8ApACmAN4A4gDmAAgBGgFVAWEBaQGvAbEBvgHTAd8B+wEbAC0AcAB+AH8ApADCAOYA7gD8AP8ACAE7AUYBSAFVAWYBaQF7AasBrwG7Ab4BxQHTAQACGwA0AHAAfwDCAN4A4gDoAOkA7gD8AP8AGgEzAUcBVQFmAWkBqwGvAbABuwG+Ab8BxQHZARsAfgC6ALsA/wAIARoBOwFHAU8BVQFpAaoBsAG7Ac4B2QE0AGcAfgCSAKQAuwDiAPgA/wAFARoBOwFIAVUBXgFhAXABegGeAa8BsAHTAfgBNQBwAH4AfwCNAJIApADoAOkA7gD4AP8AGgEkAVUBXgFhAbABswG2AdkBIQBwAH4AfwCNAJgA4gDuAPwA/wBIAV4BcAF6AaABrAGwAbsBvgHFAdMB8gEhAC0AmAC7AN4A4gDpAAABRgFIAV4BbgGgAawBsAG2AbsBvgG/Ac8B0wHZAd8B7AEtADUAfgCNAKYAugC7AN4A4gDpAPUA/AD/AAABOwFhAWUBbgF6AasBrAGxAbYBxQHQAdMB2QHnASoANQB+AKYA4gDpAO4A9QD4APwA/wAAARoBOwFVAV4BYQGrAawBsQG2AbsBzgHPAdMB3gHnAR8CGwAqADQAfACNAJgAuwDpAO4A+AD8AAABFwE7AUYBVQFbAV4BZgFuAXABegG9AdMB2QEWABsANAA1AHwAjQCYALoAuwD2AP8ARgFIAV4BYQFmAXABfAGbAaABqwH3ARYALQA0ADUAOAB+AI8A7gD+AAgBRAFeAWQBZQFuAXIBqgGrAQACNQBCAH4AjwDuAPYA/QD+AP8AAAEGAQgBGgE7AUQBRgFbAWEBZAFmAaoBAAItAEIAbABuAH4A9QD2AP0A/gD/AAABGgFVAVsBYQFmAXABsQHPAdkB8wEWADQANQB+AIEAuwDDAO4A9QD2AP4A/wBIAUsBWwFmAbEBvgHFAdkB8gEmAhYANAA1AGcAfgCBAKQA7gD8AP4AAAEaAUYBVQFhAWIBZgFwAXQBvgHFAcgBzgEhAiYCBAASAC0AQgBfAH4AjQCkAKYA6QDuAPYA/gBPAWUBrAG2Ac4B0AHYAQQAKgAtADQAQgBfAI0AkQCkAKYA3gD2APwA/gAAAUsBTwFVAWEBZgFwAawBsAG2AbsBzgHPAdAB4gE1AgQANQBCAKQA3gDpAO4A9QD2AP4A/wAAAQkBOwFIAUsBVQFiAWYBcAF0AbEBsgG2AbsBwAH7ATUCBAAtADUAQgB+AI0ApgC4AO4A9QD2APgA/wAJATsBSAFLAVUBYQFkAWUBZgGwAbEBtgHAAdAB2QHnAQQANQB+AI0ApgC4ALwA3gDuAPYA+AD/AAABFwEcATsBRAFGAUsBTgFPAV0BYQFiAWYBdAHAAdkB5gH2AQQADwAVABYALQBCALIARgFPAVUBZAFmAXABsgHoAQQADwAVABYAIQAtADcAQgB+AKQAsgC4AN4A4QAXARoBHAFEAU8BZAFlAWYBrwG+AcAB/QEEAC0AQgB+AI0AkQCkALIAuADhAPYA/wAXARoBSwFPAWEBZAFmAXABhAGqAa8BsgG7ASgCBAAPABUASwCBAI0AmADhAPYA/gD/ABcBGgFhAWQBZgF0AYQBqwGsAbEBsgG7Ab4BxQHZAegBDwAVACEAKgAtAF8AgQCYAKYA6QD1APYA/wAAARcBGgE7AWEBZAFmAXQBhAGgAaYBrAGwAbEBxAHFAdkB/gEEABYAIQBCAH4ApgC/AOkA9QD2AP4AAAEXATsBRAFIAUsBTQFkAaYBsAGxAbsBwAHEAcUB2QHhAewBBAAPACwAXwB+ALIAuAC/APUA/QD/ADsBRAFGAUgBTQFkAWkBcAF+AYQBoAG7AcABBAAPACwANABCAH4AjQCyALgAvwD1AP0AGgE7AUYBSAFNAWIBZAFmAY0BoAGmAawBsAGxAbIBuwG9AcUBAgIEADQANQCNAJgAsgDpAPYAFwEaAUQBTwFiAWQBZgFwAYQBoAGmAbEBuwHDAcUB8wEEABAAFQAhACoANQCYAKQAuAC/AOkA9QD4AAgBFwFEAUgBSwFPAVUBYQFiAWYBjQGwAbEBxQEEAAsAEAAVACoANACBAI0ApACnALgAvwD1APYA+AAIAUQBRgFIAU0BTwFiAWMBcAF0AY0BpgGsAbEBsgG7AdYBBAALABUAIQAqADQAQgB+AI0A9gD+ABoBRAFNAU8BYQFiAWMBawFwAXwBuwHFAQIABAAHAA8AFgAhAEIAfgClALIAvwD1AP4ARAFGAU8BYQFkAYQBsQG7AcAB7gECAAQADwAVAH4AggCOAKUAsgC/APUAAAEaAUQBSAFNAVUBYQFiAWsBdgGEAY0BrwGxAbIBuwHAAdUB9gEEAAcADwAVABYAGQAhACoAQAB+AIIAjQCyAPYA/QD+AP8AAAEIARoBTwFiAWQBcAF2AYQBjQGiAbEBuwHFAdkBAwIbAgQABwAPABUAGQAsADMAQACNAL8A9QD9ACkBSwFNAWEBZgGEAY8BsgG7Ab4BAgAEABUALAB+AIIAjQCfAKUAsgC3AL8A7gD1APYA+AD/AAgBKQFLAU8BYQFkAWYBdQGNAZ8BogHVAdgBGgICAAQAFQAhAEsAfAB+AIIAjQCfAKYAsAC4AO4A9gD4AP8ACAFVAWEBZAFmAXABsgG+AQACBAB8AKQApgCnALIAuAC/AN4A/gD/AAgBGgEbATsBTQFPAWEBZgFwAYQBhQGwAbIB3gHkAQQASwBfAIIAjQCkAKcA3gD1AAgBGgFIAU0BTwFmAXABdgGEAY0BvwHiAQIABAALAEsAfAB+AIIApgD1AP8AAAEaARsBRgFIAU8BVQFkAYUBsAGyAdQB8gEEAAsAIQBCAHwAfgCNAKYApwC4APQA9gD4AP4A/wAaAUYBTQFVAWEBZAFmAY0BogGvAbABsgHUAQQAFAAhAEIAXwCNAKcA9AD1APgA/gD/AAABOwFNAU8BVQFhAWQBegGiAa8B1gHfAegBAgAEABQASgBUAH4ApQCnALgA4gDpAP4A/wAAAUUBTQFPAVUBcAGEAY0BogGwAeEBBAAWAEoASwBUAI0ApQC4AOkA7gD1AAABOwFIAU8BVQFhAWQBcAGEAbAB3QHjAQIABAAWACEAXwB+AI0ApwC4ALkA4gD1APgA/QD+AAABCAEaARsBNQE3AUgBTwFhAXYBkQGbAb4BAgAEABYAIQCNALgAuQDuAPUA+AD9AAABCAEbATwBTQF2AbIBvgECAAQAFgAhAEsAXwCCAI0ApQDiAPYA+AD/AAgBFwE7ATwBTwFkAWYBsgG/AQIABAAhAEsAggCNAKYAtwDiAPYA+AD8ABcBNQE7AU0BZAFmAXABhAGNAaYBsAG+Ab8BGgIcAgIAFgBAAEsAjQCOAJgApQCwALgAuwD2APwA/QD/AAABOwFPAVUBcAF2AYQBjQGrAbABvgHYARYALABAAF4AXwD9ACkBKgE8AU0BYQFkAWYBdgGwAbIB2QEoAiwAQABeAH4AmACmALcA9gD/AAABKQEqATwBTwFVAWEBdgGEAbIB7gH1ASEAQABLAF4AXwB+ALkA4QDuADsBTQFpAXoBHwIhACMAXgB+AI0A4QDjAO4A/wAAAQkBGgE7AWEBZAFpAaIBqgG9AScCIQBLAH4AuAC7AOIA7gDyAPwA/wAAAQkBTwFhAWkBcAF6AXsBfgGiAa8BuwH4AUcCBwAhACMASwBeAF8AfgCYALgAuwDiAO4A/AD9AP4A/wAWARoBVQFhAWQBegGDAaIBuwG+AeMBBwBeAF8AggCmAKcA/gAIARcBPAFPAVUBYQFkAWYBcAF1AXoBrwFfAH4AggCRAJgApgCnALgA6QDuAPgA/QD/AAgBFwE7ATwBRAFIAVUBYQFmAXABvgHZAfIBJwIBAAQAIwBLAH4AjQCRAO4A8AD4AP0A/wA7ATwBRAFNAVUBYQFkAWYBcAGDAbAB3QH1AQQAIwBLAH4AkQCYAKcAuADpAO0A8QBIAU0BVQFhAWQBZgFwAXoBqwEBAicCAgALABgAIwBLAIEAggCNAJEAmAClAO0A8QD8AP0A/wA8AUgBVQFmAWkBcAGDAasBrwGwAb8BAgIDAgIABAALACMASwB+AIEAjQCmALgA7QD2APgA/gD/ABoBOwFNAVsBYQF6AbABvQG/AdgBAQInAgQAMABLAHMAgQCCAJEApgC4ALsA/QAIATsBVQFbAWsBcAG+Ad8B5AELAEsAfgCRALgAxwDtAPYA+AD+AAgBOgE7ATwBSAFNAVMBVQFhAWkBawF6AfQBLwILAIIAuADtAPUA+AD/ADoBPAFEAUgBTgFbAWEBZgFwAXoBrwGwAb4B0wH5AScCLwI2AhYASwB+AIIAkQDpAPUA9gD4AP8AOwFEAUgBTQFOAVUBawF6AaIBrwHTAd4BNgILABYASwBeAHsAkQCYAKYA6QD/ABoBRAFNAVsBYQFmAWsBcAGwAfMB9wEnAgsAIQCNAJgATwFVAWsBcAF6ASEAQgBLAHsAggC4ACEBOwFEAU8BZgFwAXQBegGvAcQBQQBCAEsAuADtAPUA+AD/AAUBGgE7ATwBSAFbAWQBZgFrAXQBegGiAa8BQgBdAIIAjQCmAN4A6QDtAPAA9QD4AAUBGwE7AUQBSAFNAVsBcgGNAcQBQQBCAEsAjQCOAKYAuADeAOkA8gD/ACoBOwFNAVsBYQFiAWQBZgFwAXQBegGNAZEBlAGmAa8BsAE2AoIAmAC4AO0ACAEMARsBKgE8AUQBTwFiAWUBZgFvAXABdAF6AY8BpgGvAbABNgKCAJgA3gD2AP8ABAEbAUgBWwFhAWQBZgFvAXABdAGvAbABvAGDAI0ApgD/AEgBTwFhAWQBZgFrAXABdAGNAa8BsAEEABgASwCDAIgAjQCYAKYAuQDeAPgA/wBHAWYBaQFrAXABdAF6AcQBBACIAI0AmACmALcA3gDpAO4A9gD4AP8AAQE7AUQBTwFbAWUBZgFpAXABdAF6AY0BsAGxAbsBxAEnAjAAPAA9AD4AggCIAI0AkQCmAMgA6QDuAP8AAQE8AUgBTwFbAWEBZAFmAXABegGqAbsBxQEnAksAggCRAKQA3gD2AP8ABwEVATwBSAFhAWQBZgFuAXoBfwGNAY8BkQGmAbABsQHFAc4BJwJBAHMAiACmAN4A9gAHATcBTQFbAV0BbQFuAXABdQGNAbABuwG+AScCBAA9AIgAjQD2AP8AIQFLAUwBTwFkAWUBaQFsAbABsQG+AcUB2QEEAD0AQQCNAJEApACmALgA7gD4AP8AGwFFAU8BWwFpAXABpgGwAfkBPgBCAIgAjQCRALgAvwDpAPIA9QD4AP8ABQEbASQBSwFbAWQBcAGmAdkB5gEoAgQAPQBCAFgAiACNAKYA6QDuAPAA+ABLAXABdQGDAZsBpgGrAbABsQG7Ab4BxQH9AScCKQIEAHsAuADeAOkA7QDuAPYA+AA/AVUBWwFlAXQBdQGmAbABsQG7Ab4B+AGCALgA6QDtAO4A/wA/AVMBdAGEAY0BsQG7Af4BQgCBAIIAjQCYAJsApgC4APgAPwFkAWkBcAF0AYQBjQGmAQQAjQCbAKYAuADDAO0A9QD4AP8AYQFkAWUBaQFwAYMBhAGmAbEBxQHZAUcCBABCAIIAjQCRAKYAsQD2APgA/wAbAUYBZAFwAXQBhAGxAcUBJwJJAEoAgQCCAI0AkQCkALEA+AAbAWkBcAFyAXQBhAGmAdkBGwJIAEkApAC+APcA/wBFAUYBYgFkAWYBaQFrAXABhAGfAbEBuwHFAUIASQBKAIIAmACkAL4A/wBGAUoBZgFwAX4BhAG7AcUBBABBAEIARQBKAIIAjQCYAKQApgD9AP8AEgFFAUYBZAGmAbABsQG8AScCPQBBAEIAiACmAO4A/AD/ABIBGwElAUYBZAFlAYEBsAH0AScCBACNAKYA4wDuAIIBoQGvAb4BwwEdAiACBAAtAD8AjQCyAOMA7gAjAUYBsAGxAbsBvgHDAcUBBAAtAEIAfgCDAJgApgDuAP8ARQFkAWkBoQGiAaYBsAHDAe4BQgCDAI0A7gAEAQcBHQFFAUYBYgFpAXUBggGwAbsBxQEcAgYAQgB9AI0A6ADpAO4A/QD/AEoBYgFkAWYBaQF6AYIBhAGvAbABsQHDAcUBHgIhAC0AQgBJAEoASwBMAH4AjgCRAJgA0wDoAOkA9QD9AP8ABQFFAUYBSgFiAWQBZgFrAXQBegGEAaEBuwHFAS0ATAB+AJEAmAD1AAcBRQFGAVQBZAF0AXUBegGvAbsB/QEdAh8CIQA/AH4AjQCmAN4A6ADpAPcA/QAHAUQBRQFGAWEBdAGNAbABuwHFAc4BHQICAD4AfgCNAI4ApgDeAOgA6QD9AP8AagF1AXoBoQGxAbsB2QEBAAIALQCOAJgApgCyAO4A/AD/ABMBNAFEAUUBYgFqAXQBegGNAaEBsAGxAdkB5gHzAQQALQBTAF0AfgCNAI4ApgCyAO4A/ABFAU8BdAF1AXoBnwG7AcUB1AEbAC0AQQBEAH4AjgCUAJgApgCyAN4A6ADpAPYA/QBEAUUBTwFiAWoBoQG7Ab4BxQHUAfgBKAIPACwAQABBAEMARABTAF4AjQCyAN4A6ADpACkBagGDAaIBpgGxAbsBvgHAAcUBCwAjACwAQwBfAH4AjQCYALIA4gD9ACkBRAFFAUYBTwFpAaEBogGmAasBsQG7AQsALABAAEEARABMAFgAcACNAJgAwwAXAWIBaQGVAZYBlwGbAaIBpgHAASEALAAvAEEARABwAH4AjQDiAOgA6QD1AAcBKQFGAU8BYgFpAYEBogGmAbYB6AECAAsAIQAvAEEATAB+AIIA2QDuAPUA+AD9ACoBMAFEAUUBaQGCAbABtgEfAgIACwAaABsAIwAvAEEARABTAIIAmADAAOgA6QDrAO0A7gD4ABIBKgFEAUUBaQFsAYIBjQGrAQcAGgAqAEQAUwB7AJgAwADDAOsA9QD9ABIBRAFPAWwBdQF+AY0BoQGqAasBugG+AckB/gEdAiECAgAHABYAGQAaACoALwBQAH0AfgCyANMA7gD4AEQBRgFIAXUBogGrAb4BwgEdAiACFgAaACMAQgBFAEYAXgB7AH0AkQCYAN4A7gD4AP0AKgFGAU8BjQGrAawBsgG2AcoB3gEAAhUAKgBCAEYAXwB+AIIA3gDrAO4A+ABGAUgBjQGiAawBsgG2ARUAIQBGAFAAggCIAI4AkQCYAN4A9QARARQBRQFGAYMBhQHtAQIADwAhACMAMAA9AD4ARgBeAHAAiACUAK8AsgDeAOMA7gDwAPYA+ABIAQIADwAVACMAKgBBAF8AawBwAH0AiACmAN4A4wDrAP0AGAFFAUcBSAFPAXQBlwGqAa8B+AECABUAcwB7AI4A3gDrAPIA8wAwAUUBSAFPAWsBdAGVAZYBqgELABUAFgA8AEIAcABzAHsAfgCYALsA7gD0ABEBGAFrAZ8BvgHkAR0CHgIfAikCAgALABUAFgAhAG8AcQBzAJgAsgDuAPcARQFOAU8BAgAVABYAIQBCAHEAdACRAK4AsADuACMBRQFIAU8BqAHFARUAFgAhAEAAQgBfAHQAkgC/AN4A7gD0AP4AEwEbASUBRQFIAU8BawF0AXoBvgHFARsCFQAhACwAQABIALoAvADeAO4A8wD+ABsBQAFLAUwBTwFrAXQBqAHuARUAFgAgACwAQABxAHQAfgCnAOIA6ADpAOsAEwEkAUQBSAFOAWIBawF7AYEBxQHtAfcB/AEAAhYAIAAsAEsAXwBrAHQAewCCAJgA4gDuAAEBEwEbAU8BawFsAW0BxQEhAgIAIAAhACwAVgBfAHEAggCYAKUA6wABAT8BSAFPAWIBagHFASAAIQBfAHIAdAB+AIgAmADZAO4A/gBIAWoBdAF1AYIBlAG/AcUBygHTAfUBAgAgAEIAXwB0AIIAiAClANoA6QDrAO4A/gAaARsBKgFIAUoBZgFoAXQBdQGLAb8BxQHTASYCIABCAF4AdACCAOMA6AAaARsBZgGLAcUB2AHZASYCCwB0ABQBFwFkAWkBhwELAF4A7gD/AFUBaQGHAaIBqwG+Ab8BxQHRAfgB/AEwAgYAXgDuABUBGwFOAU8BVQGLAaIBqwG+Ab8BxwHRAQYABwAhAF0AawCYAOsA7gD0APgA/QD+AP8ACAFOAWoBjgHHATACAQAHACEAQgB0AJEApgDiAO4A9AD9AAgBCQETASoBPwFpAWsBdAG+AcIByQEFAgIABwBCAJEAkgDiAPgA/gD/AAkBFQEaASkBKgFlAWYBaQFqAWsBdAF1AasBvgG/AckB9AEBAAIABwAsAEIAawCYAJ8A3gDuAPcACAE6AU8BYgFmAWoBdQF+AasBsAG/AcUB0wHkAQYCHAIBACEALAA8AEIAVQByAIgAmADeAOEA7gD1AP4A/wAIARoBHQFiAWQBagF+AYcBqwGwAcUBxwHJAdMBFgAhAEIAbAByAJgAnwDiAOsA9QD+AP8ACAEaAUgBTwFkAWoBcAF1AYcBjgGrAbEBxwELABYALAAzADUAUABdAG4AcQB/AJEAkgCYAOIA+AD/AAwBGgFIAU4BTwFlAWYBagF1AYsBsQHFAegBCwAUACwAQgBGAFAAcABxAH8AkQCSAJgA0wD/AAgBFwFIAU4BZAFlAXABdQF+Ab4BvwH5AR8CBgAWADAAUABfAHAAggCYAJ4A9QAIAREBFgEXAUgBUQFkAWUBZgFpAasBvgG/Ad8BFgAsAD4AQgBGAFUAXwBsAHAAggCRAJgA9gD4AP8AAAEQAREBGgE0AToBSAFPAVUBZAFlAXUBqwGwAb4BKAIhACwARgBSAG8AcAByAJQAmAClAMgA6ADpAOsA9QD2AP8AAAERARoBMAE4ATkBOgFGAU8BVQFjAWoBsAG+Ad4BCwAhACQAKgBCAF8AmACuAN4A7gD1APgA/wAHARABEQEaATsBXQFjAWUBcAF1AYcBwAHUAdUB1gHzAQsAEAAfACEAJQBCAFAAUgBeAF8AcABxAHQAfQCRAJoAowCkAK8AsAC6ANkA6ADpAO4A9QD4APwABwEIARoBIgE5AT0BRgFIAU8BUQFkAWUBcAF1AY0BwAHUAd8BGgBGAEgASQBQAF4AXwBuAHEAcwB7AH4AhwCRAKUAugDeAPYA/AAIAREBOQFGAXABdQGNARoAJQArAEYASQBQAF8ApADHAN4A9gD4AAgBEQE5ATsBPAFNAU8BZAFmAWgBcAGHAbIBvgEaACUAPgBIAFAAXgBfAH4AmACeAKcA3gDoAOkA9gAIAREBOQE8AUgBTwFyAXUBsAG+AREAJQBIAHAAfgCHAJEAmACcAJ4A6ADpAPIA9gD4APwA/wARATsBPAFkAWYBdQGNAaIBvQG+ARoAGwAlACsAPABGAFAAXgBfAHAAiQCRAMAA4QDjAPgA/wAhATwBSAFOAU8BZAF0AY0BsAG+ARoAJQA8AD0APgBBAEIASABeAF8AiQCRAKkAuwDCAPgA/AD/ABEBOwFPAXQBsAGyAb0BvgHHAUIASABcAGoAfgCOAJgA6ADpAO4A/QAIARoBOwE8AU8BfAGNAbABuwG9Ab4BxwEnAigCNQIaAB0AJQBIAFYAaACRAJgApgC2AN4A6ADpAPIA9gD9AP8AEQE9AUgBYQGNAZ8BsAG7AdcBJwI1AgcASABwAH4AtgC7AN4A7gD8AP8ABwEIAQkBPAE9AUABTwFhAXABgwGqAbABBwAZABoAOgBIAHAAmACmALsA6wDzAP8ABwEIAQkBEQEcAT0BRQFIAWIBZQGqAQcADQARAEIASABTAFwAfgCOAJgApgDrAO4A8wD2AP0A/wAIAR4BOwE9AUUBYgFlAXABsQEAAA0AQQBCAKYAtgC6AO4A+AD9AP8ASAFLAWYBjQGxAb4BJwJBAEgAUwBqAH4AjgCmALoA9gD4AAgBOwGfAaoBvgEGAicCRwIHABIAJAAvAFMApgC2AMIA8wD/AAgBEQFmAWkBjQGwAbIBugG7AcABJwJIAI4AuwDAANkA9gD/AAgBEQFGAUgBdQGNAbIBuwG+AcABGgAlAFMAfgC7ANIA7gD2APgACAESAT0BRQFGAUgBTQFUAWUBdQF+AY0BvgEnAi8CGgBIAEkAfgCRAKwAuwDuAPgA/wAHAQgBEgEwAT0BRAFGAUgBTQFkAWoBfwEZABoALQBCAJEAsAC6ALsA6wDuAPgA/wAIAREBHgE9AUQBSAFLAWwBdQGwAbwBvgEFAicCNQIHABkAIQAtAC8AQABCAEUAagB6AIcAnACeAKIAugC7AN4A4wDrAO4A8wD4AP0A/wAGAQcBCAESATUBOwFHAUsBYgF1AY0BsAG8AfkBNQIHAEwAcACiALoA3gDjAO4A9gD4AP0ACAESASIBOwFEAUcBSAFNAWIBjQG8Ab4BzwEZAC0ATABTAGgAcACHAKIAvADuAPgACAE8AUQBRgFIAVgBjQHgAUYCBwANABAALQBIAE4AUwCBAPYABAEHARABPAFEAUYBSAFLAVgBYgEHACYAPgBEAEwATgBTAHsAgQDzAPYA+AD/AAUBBwEIATgBOQE6AU0BVQFYAWkBcgF1AcQBzwEaAD4ARABIAEwAewCiAOgA8wD4AP8AEAEXATsBPAFNAVUBWAFpAWwBdQGNAa8BsAHEAQcADQB+AOgACAEkAVUBdQGBAbEBzwEmAD4ASABJAEwAUwBYAF4AXwBzAH4AhwCuAK8A4QDjAPYACAEaATwBRgFkAWwBjQGvAbEBuwHFAeIBJwIpAgcAJgA+AEwAUwBWAFgAXgB9AIcA8wD2AP8AGgE1AVgBXQFpAXQBdQF2AY0BsAG+AcUBzwHhARoAJgAnAD4ATABTAF8AcACIAKIA4QD2AP4A/wAHAQgBEgEaAUsBVQFpAXQBdQF2AaIBsQG7Ab4BJwIHACYAPwBMAFMAVgBxAH4AhwCIAKIA6QDzAP4A/wAHARIBJQFGAUsBTQFYAWEBZgFpAXUBqwHPAfQBJwIHAA0AGgA9AEgAUwBwAHEAfgCBAIcA2gDpAOsA/AAIARoBTQFVAWIBdQGNAasBsAG7Ac8BJwI2AgAADQAfAEgATABeAGoAcABxAIgA/QD+AAgBGgFLAU4BYQFkAY0BqwGsAbABuwHAATUCHwAxAEQASABMAE4AXgBxAIcAkQDrAPYA/QD+ABoBPAFYAWwBjQGhAasBrAGvAbEBwAHPARoAHwA/AEQASABOAHEAhwCIAJEA6QDrAPYA+AAMARoBHAFLAVUBWAFiAWwBkgG7AdQB3gHzAR0CJwIfACcAPABTAF4AcQCBAIcA6QDrAO4ADAEXAT8BYgGNAaEBugEdAkYCGgAfAFMAXgBlAG4AbwBxAH4AhwAXATwBRgFNAVUBWAFkAaoBsAG7AcUB1AEvAgcAKgA6AFgAXgBxAHoAhwCwALwA6wAXATUBRgFLAU0BWAFkAXABsAG+AdMBBwAaAEAASABeAH4AhwCwABcBIQFGAUsBVQFhAWQBaQFwAYQBjQGvAdMB/gECAhoAJgBIAFgAXgBfAG8AngDpAOsAFwFGAUgBSwFNAWEBaQGEAbsBvgHFAeQBAgIbAioCBwAmAEAARABRAF4AXwBvAIIA6QDrABMBSAFNAU8BZAFpAZYBuwHFAR0CIAIqAgcADQAaACYAJwAxADoARABIAFEAbwCCAI8AmgCmALAA6wBsAXABjQGWAaABrwEdAkcCBwANABkAGgAxADoAjQCUAKYArgCvALAAuwDrAP4ARgFIAXABdgF5AbsBzwHTAR0CHgIZADEATgBvAIcAmACjAKwA7AD+ABwBJQFEAUYBbAGCAcMBxQHTAQcAGQAaAB0AJwBIAE4AcQB7AOkA6wBIAWQBaQGNAaEBsQG+AR0CHgIfAEIASACCAI0AjwDrACUBSAFkAZIBogGsAa8BsQG2AbsBvgHAAd8BAAIdAi0AOgBCAE0AWABzAIIAhwC6ALsAyADrABoBJQEzAWMBhQGiAawBtgG+AcAB1AHeAQcAGgAfADoASABvAHEAfgCNAI8AsADSAOsA/gAaATwBWAFjAWwBqAGxAb4BxQHdASoCBwAaABsAHwAtAE0AXwBoAHEAfgCOAJ0ApwC7ANMA6QDsAP0A/gA8AVUBWAGsAdQB3gEqAg0AGQAaAB8AXgBwAHEAggCPAJ0A8wD9AAgBGwFIAVUBZAFpAawBsAGxAbsBxQHyAR0CDQAZAC0APwBCAEQASABNAF4AXwBwAH4AggCPALsA9AD+AAABCAEXARsBMwFIAVUBYQFmAW4BpQGwAbsB0gHcAeMBHQIqAgcADQAfACoALQA/AEIASABNAHAAcwB+AI8AsADIAPUA/gAAARcBNwE8AVUBWwFmAW4BuwG8AcUB0gHcAegB9gEdAioCBwANAC0AQgBEAF4AXwBwAHMA/gD/ABUBSwFiAWYBaQFwAR0CGgBCAEgAXgBfAI0AjgCdAPUA/gD/ABsBRQFhAWIBaQFwAaIBsQG2AeMB8gEHAA0AIQA/AEIARABIAHAAcwB6AHwAfgCBAI8AnQDhAAABGwE8AUgBaQGiAagBqQGwAbEBtgHFAd0BBwANACEAQgBEAHMAfAB+AIEApQD1AAABOwFIAU0BaQFwAYQBmQGoAQICKgINABYAGgA6AD8ATQBeAF8AfgBOAXABcgGEAbEB3QHyAQICKgIHAAsADQAWAC0AOgBIAE0AXgBsAH8AjQCPAKUApwC6AN4A9AD2ABoBOwFNAWQBcAGEAZgBsQHFAfgBHQIHAAsAGgBIAE0AXwBwAI0AjwCYALsA3gDzAPYA/gAAASQBTQFOAXABkQGwAbkBuwG+AcUB9QEqAgcALQBAAEIASABwAI8AsADeAOgA6QDuAPUA9gD4AAABOwE8AUcBSAFNAWEBYgFpAbkB4wHsASoCBwAWABkAGgAtADoAQABCAE0AkQCjALoA3gDpAPYAAAEaAUcBSAFhAWIBZQGEAaQBsAG7AQcAFgAaAC0AOgBVAFYAfAC6AN4A6QD1AP4AAAE7AUgBcAF1AXgBhAGwAbIBuwG+AcQB2QEdAgcAGQAsAC0APwBWAHwAhwCPAJAAmwC4ALkAugDDAOgA6QD2ABsBRwFwAXUBeAF9AYQBsAGyAbkBKAIaAB8ALAAtAD8ASABNAF4AgQCHAJEAmwDEAOEA4wD1AAwBKQE/AUcBSAFOAU8BYgFmAXUBuwHjAR0CNgIHAA0AFgAaAB8ASABNAIIAjwCRAN4A9AD2APgA/QD+ABsBSAFNAWQBZgFzAXYBeQG7AcQB4wEdAgIADQAWACMAQABIAE0AVgBeAGMAggCbAKYA7gD0APgA/AD9ABcBGgEbATsBPwFIAU0BYgGnAb8BNQIBAAIAGgAsAD8AQABIAE0AVgCPAJAApgC5ALoAwADeAO4A/AD+AAABDAEXARsBOwFIAVQB7gEeAh8CAQANAA8AIQAsAC0APwBCAEgAjgCRAJsAngC2AMIA3gDuAPUAAAEEAR8BPwFmAWoBbgFzAbIB1wEWAB8AIQAqAEIASACOAJEAmACbAKQAtgDeAOgA6QDuAPUA9gD+AP8AAAEIARwBHwE/AVYBYQFiAWYBbgGBAbIBHAICAAsAFgAaAC0AQgBIAIIAhwCOAI8AkQCYAJsAowCkALYA3gDoAOkA9gD+AP8AAAEJASoBRgFIAVoBWwFhAWQBZgEBAAIABwALAA0ALAAtAD8ASAB8AIEAggCOAKMAtgC4AOgA9AD/AAgBFgEkAUYBSAFaAWQBZQFmAQEADQAaACEALAA/AF8AfACHAIkAkQCbAKEAqQC4AOgA9AD2AEgBTQFqAf8BDQAhAE0AYwBlAI4AkQCYAJsAoQCjAKYA9gAbATcBSAFNAVoBdQHmAQEAAgAEAAcAGgBNAF8AbABuAIIAhwCJAI4ApgD+AAkBRQFGAUgBagECAAQADQAtAEIAfACHAIkAmACkAKYAtwC4AMQACQEaAUYBSAFWAVoBZgFyAbsB9gEEAA0AGgBCAEgAVQCCAIkAjwCkAKYAtwDIAN4A+AD+AP8AFgEdAUgBVgGFAbsB9wFIAHwAggCPAJgAwwDeAOgA6QD4AAgBFgEXARsBQgFEAUYBYgGoAbIBuwEEAA0APwBCAEgAZQB8AIIAjwCgALwA6ADpAO4ACAEJARcBRwFIAVYBYgGoAbIBBwANACoAPwBlAI4AoAC2AO4A/wAbATsBRwFIAUsBVgGPAagBvAECAAcAKgA/AEIASAB8AI4AmACwAOgA6QD/ABoBGwE7AU0BagGHAagBuwECAAQAGgAtAD8AaACCAI4AmACbALAAtgDCAN4A7AD/ADsBRAFwAYQBhwGzAbsBLQBBAEIAggCOAI8AwADoAOkA7gANATUBRAFqAXABdgGEAacBzQEEABoAPwBBAE0AgQCPAJgAsAC7AN4A7ADuAP4AGwFEAUYBSAFqAXABhAGgAb4BzQHnAe0BBAA/AEIATQCPALsA/gAaATsBRAFGAUgBXQFqAX0BhAGgAbsBvgG/AfsBLQB6AIEAmwCfAKEAuwDeAOwAFQEbASoBSAFsAXABhAGoAc0BDQAaAC0AQgBNAF8AggCPAJgAoQCwANoA3gD4AP8ACAFIAVYBfgGEAb4BxAHNAQIABAANACEAPwBNAF8AcwCCALAAuwDhAPgA/gD/AAgBCwE7AUQBSAFLAWIBagG7Ab4BwAHNAQIAIQA/AEIATQB0AJgAsAC7AOMA7gD1AP4AOwFIAUsBZgG7AcABzQECAC0ATQCCAIgAmAC7AO4A9QATASkBRAFiAXABgQGCAYQBuwHVAe0BAgAHAA8AFgAaAC0AQQBCAIIAjwCYALAAuwDeAEYBSAFLAVYBWAFiAXABfAGEAbsBGgAfACoAQQBfAHIAgQDeACUBRAFFAUgBSwFMAVUBVgFbAWEBcAGEAcIBxAHWAe0BBwAWABoAIwBfAIgA7gD/AEgBVQFWAVgBYQGEAdgB4QELABkALQB/AIgA3gAWASMBRAFIAXABAgAHABkALQBfAIEA3gDhAO4A+AA7AUwBVQFWAWEBYwFmAeABBwALACwATQBfAH4AsAC7AMcA7gD1AP4AAAELATwBRAFLAWEBYwFmAWkBcAF4AYQBvgHiAQIADwAZACEALACCALsA3gD1AP0A/gAAAQsBOwFEAVgBZgFwAYQBvgEHABkAIQBAAIIA7gD4AP0AOwFIAWEBYwFmAWkB5wFAAHAAmADeAO4A+ABIAU4BVAFYAWEBZQFmAXABhAGZAZsBvgHnAQsAIwAtAEEAcAByAIIAjgCPAJgAnQCmALsA7gD1ADsBRAFLAU4BVQFkAWYBaQFsAb4BwwEHAAsAGQAaAEEAQgCCAIgAjgCPAJ0ApgDeAOMA9QD4AAsBOgE7AUsBTgFVAVgBaQFsAakBBwAZACEAQgBNAIgACwE6AUsBTQFVAWQBZgFpAaUBqwG6Ae0BBwAhAC0ANwBBAJEAOgE7AUYBSwFYAWQBaQF2AYQBngEMAEAAQQBCAHAAdAB8AJEA3gAzAUQBRgFLAU0BTwFYAWMBaQFsAZgBqgG+AQwADQAtAEAAQQBCAE0AbABuAHAAfACmANoA+AALARMBOwFEAUgBSwFNAVUBWAFhAWMBZgFpAXABpgG8Ab0BvgENABoAQQCYADsBRAFGAUsBVQFYAWEBYwFkAWYBaQFyAaYBBwAtAEAAQgCYAKYArwALARUBOwFEAUgBTQFRAWYBaQFsAQcADQBAAE0AiACPAN4A/QD+AAABOwFGAU0BVQFhAWMBZgFwAYMBhAGgAaIBBwANAC0ASACIAI8AkQCyAN4A4gDsAPgAAAFGAUsBTQFYAWEBYwGSAaABpgHOAecBFQAtAEIAkQDeAOIA7AD2APgA/QD+ADsBPAFNAVEBVQFYAWEBYwFkAXABegHnAQcAFQAaACQASQCCAI4A7AD4ADwBSAFLAVEBVQFYAWEBYwFkAYQBAgAMACQALQBCAEkAggCOAJgApgDeAOMA7gD1AP4AFwEjAToBOwFGAVUBegGhAaIBpgHIAQIABwANAEIATQCOAI8AmADiAOMA7ADuAPUA9wD9AP4AAAELASUBOgE7AUYBSwFVAVgBZgF1AXoBhAGhAaQB8gEMABoATQBfAHAAjgCYAKYA4gD1AP0AAAE8AUYBSwFRAVUBWAFkAYQBoQHDAd4B3wHkAQIABwAMAA0ALQBCAEoAcACPAJEAmAD1AAsBPAFIAUsBcAF7AYQBlAGhAaIBpgG+AecBDAAaADgAQABCAEoAcABzAIgAjgCPAKYAsgALATsBPAFEAUgBSwFhAWYBbQFwAXoBkgGhAaIBpgG5Ab4B5wFAAF8AcwCIAI8AmACmAN4A4gDsAO4A9QD4AP4ARAFIAU0BWAFdAWEBZgF6AecBDQAaAB8ASABNAHAAcwCOAJEAmAC7AN4A4gDsAO4A9QD+AAABCwE7AU0BTwFmAXABegGbAZ4BoQGsAa8BwgHEAecB7gEeAhoAHwAtAEgASgBNAHMApgDeAO4A+AD+AAABOgE7AT0BSAFPAVUBWAFiAWQBZgFuAXABegGEAZ4BogGsAa8B4wEgAhoAHwAqAEAASABKAJgA9QD/AAABCwEdATsBRQFIAU0BVQFiAWYBegGeAa8B8wEdAhUAIwAtAEAAmACmAN4A9QD4AP8AAAELATsBWAFkAW4BcAF4AXoBhAGeAaEBogGvAd0BBwAVACMALQBwAIIAjgCRALsA4gDsAO4A/gAAAQsBOwFMAU0BVQFYAWQBcAFzAXoBhAGgAaEBpgGvAQwAIwBAAHAAkQCmALsA4gDsAPgAAAELARsBPAFNAVUBZgFsAXABcwF6AYQBoAGhAa8BBwAMACMAQABCAE0AggDiAOkA+AD+ABsBOwFCAUQBRgFcAWQBbQGmAa8BuwHIAfQBKQIaACMALQA4AEIATQBwAKYA4gDsAPgA/AD+AAkBCwE7ATwBRAFGAU0BVQFmAXABegGeAaEBpgG7AQsAQgCYAOkA7gD8AP8ACQELAUgBSwFNAVUBXAFkAWkBcAF+AZ8BoAGvAfkBCwAWABoAOABCAI8AmACmAOIA7gD1APgA/AD/AAgBEgE7ATwBRAFIAUoBSwFNAVUBWwFcAV4BZgF6AYQBoQGmAf0BFgAaACMAJAAqAEIAYwBwAI8AmACmAOIA7ADuAPUA/wBLAVUBXAFdAWIBZgF6AYQBqwGvAdkB/wEkACoALQBfAGMAiACPAJEAmACmAN4A7gD4AP8ACAELARoBRgFKAV0BYQFiAWYBawF0AXoBhAGvAb4B2QEVABYAGgAtAF8AYwCPAJgAngCmALAA3gD4APwA/wAAAQsBXAFmAWsBcAF0AXoB+wEHABUAFgAtAEUAcwB/AJgA4gDsAO4A+AD/AAABCAFGAUsBVQFeAWEBYgG+AQ0AFgAqAC0AOABCAH8AkQCwAO4A9QD/AAgBSwFeAecB6AENABoAQgBxAHMAkQCYAJ8AsADuAAgBRgFeAWEBYgFkAW4BdAGmAbABxAHsAQcADQAWABoAOABCAE0AcABxAH8AmACfAN4A9QD8AP8AGgFIAVQBXgFiAbwB2QEVABYAGgAqAE0AcAB/AJEApgDeAPwAAAEIAUgBVQFcAWEBYgFkAXQBpgG+AcUB5gEVABYALQBxAJEAmACwAO4A/wAAAUYBVQFeAWIBZgF0AbABxQH9ATUCFgAtADgAQgBxAJEApgDsAPgAAAEaAR8BRgFhAWYBcwG+AcQB2QH+ATUCQgBwAJEAmADiAO4A9QD/AAABCAELAR8BSAFhAWQBZgGEAZ4BqgGwAcQBKgAtAHAAfwCBAJIAmACmALwA4gDsAPUA/ABGAWIBcAFzAYQBngGvASoAQgBwAH8AgQCRAJIAmACmALoAyADeAOgA9QD4APwA/wAIAQ0BGgE9AUcBSAFdAV4BYgFkAXQBegGEAZ4BrwE4AEIAmACvAN4A4gDpAO4A9QD8AP8ARgFHAUgBXQFeAXABdAGEAZ4BwwEAAhsCKAItAEIAcQBzAI8AmADeAOIA9QD3ADsBRgFHAUwBYgFmAXQBegGEAZ4BsAGxAbsBxQE4AEIAcACPAJ4A3gDuAPUA+AD8ABIBRAFGAUcBSAFMAU4BXgFiAWYBdAGwAS0AOABYAHEAcwDoAOkA+ABIAVUBXgFiAXQBgwGVAZ4BsAGxAbsBxAHFAQ==", + "provenance": { + "method": "chrome-solid-face-id-exact-interior-pixel-census", + "route": "http://127.0.0.1:4325/cityflow/", + "presentation": "schema-23-folded-adaptive-smooth-sine-27-57-0.55-eased-extrema-60hz" + } +} diff --git a/src/adapters/cityflow/notes/references/prepared-visibility-adaptive-smooth-sine.json b/src/adapters/cityflow/notes/references/prepared-visibility-adaptive-smooth-sine.json new file mode 100644 index 0000000..d3b9235 --- /dev/null +++ b/src/adapters/cityflow/notes/references/prepared-visibility-adaptive-smooth-sine.json @@ -0,0 +1,483 @@ +{ + "schema": "csscityflow-browser-visible-face-id-source@1", + "encoding": "initial-bitset-plus-u16le-per-target-frame-toggle-offsets-and-face-indices", + "browser": { + "name": "Google Chrome", + "version": "152.0.7977.65", + "headless": true + }, + "viewport": { + "width": 1280, + "height": 720, + "deviceScaleFactor": 1 + }, + "frameCount": 301, + "faceCount": 600, + "initialVisibleCount": 235, + "minimumVisibleFaces": 193, + "maximumVisibleFaces": 259, + "meanVisibleFaces": 229.62790697674419, + "visibleFaceCount": 451, + "visibleFaceIndices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 20, + 21, + 22, + 24, + 25, + 26, + 27, + 29, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 42, + 43, + 44, + 45, + 47, + 48, + 49, + 51, + 52, + 53, + 54, + 55, + 56, + 57, + 58, + 59, + 60, + 61, + 62, + 63, + 64, + 65, + 66, + 67, + 68, + 69, + 70, + 72, + 73, + 74, + 75, + 76, + 77, + 78, + 80, + 81, + 82, + 83, + 84, + 85, + 86, + 87, + 88, + 90, + 92, + 93, + 94, + 95, + 96, + 97, + 98, + 99, + 101, + 102, + 103, + 104, + 105, + 106, + 107, + 108, + 110, + 111, + 112, + 113, + 114, + 115, + 116, + 120, + 121, + 122, + 123, + 124, + 125, + 126, + 127, + 129, + 130, + 132, + 133, + 134, + 135, + 136, + 137, + 141, + 142, + 143, + 144, + 145, + 146, + 147, + 148, + 152, + 153, + 154, + 155, + 156, + 157, + 158, + 159, + 162, + 163, + 164, + 165, + 166, + 167, + 168, + 169, + 170, + 171, + 172, + 173, + 174, + 175, + 176, + 178, + 182, + 183, + 184, + 185, + 186, + 187, + 188, + 189, + 190, + 191, + 192, + 194, + 195, + 196, + 198, + 199, + 200, + 210, + 211, + 216, + 217, + 218, + 219, + 220, + 221, + 222, + 223, + 224, + 225, + 226, + 227, + 228, + 229, + 230, + 232, + 233, + 234, + 235, + 236, + 237, + 238, + 239, + 240, + 242, + 243, + 244, + 245, + 246, + 247, + 248, + 249, + 250, + 251, + 252, + 253, + 254, + 255, + 256, + 257, + 258, + 259, + 260, + 261, + 262, + 263, + 264, + 265, + 267, + 268, + 269, + 272, + 273, + 274, + 275, + 276, + 277, + 278, + 279, + 280, + 282, + 283, + 284, + 285, + 286, + 287, + 288, + 289, + 290, + 291, + 292, + 293, + 297, + 298, + 303, + 304, + 305, + 306, + 307, + 308, + 309, + 311, + 312, + 313, + 314, + 315, + 316, + 317, + 318, + 319, + 320, + 321, + 322, + 323, + 324, + 325, + 326, + 327, + 328, + 330, + 331, + 332, + 333, + 334, + 335, + 336, + 337, + 338, + 339, + 340, + 341, + 342, + 343, + 344, + 345, + 346, + 347, + 348, + 349, + 350, + 351, + 352, + 353, + 354, + 355, + 356, + 357, + 358, + 359, + 360, + 361, + 362, + 363, + 364, + 365, + 366, + 368, + 369, + 370, + 371, + 372, + 373, + 374, + 375, + 376, + 377, + 378, + 379, + 380, + 381, + 382, + 383, + 384, + 385, + 386, + 387, + 388, + 389, + 390, + 391, + 392, + 393, + 394, + 395, + 396, + 397, + 398, + 399, + 401, + 402, + 404, + 405, + 406, + 407, + 408, + 409, + 411, + 414, + 415, + 416, + 417, + 418, + 420, + 421, + 422, + 423, + 424, + 425, + 426, + 427, + 428, + 431, + 432, + 433, + 434, + 435, + 436, + 437, + 438, + 441, + 442, + 443, + 444, + 445, + 446, + 447, + 448, + 450, + 451, + 452, + 453, + 455, + 456, + 457, + 458, + 461, + 462, + 463, + 464, + 465, + 466, + 467, + 468, + 469, + 470, + 471, + 472, + 473, + 474, + 476, + 477, + 478, + 479, + 480, + 481, + 482, + 483, + 484, + 486, + 487, + 488, + 492, + 493, + 494, + 498, + 499, + 500, + 501, + 502, + 503, + 504, + 505, + 507, + 508, + 509, + 510, + 511, + 512, + 513, + 514, + 515, + 517, + 518, + 538, + 539, + 540, + 541, + 542, + 543, + 544, + 545, + 549, + 550, + 551, + 552, + 553, + 554, + 559, + 565, + 566, + 582, + 583, + 592 + ], + "transitionCount": 7250, + "initialVisibleBitsBase64": "/wEUAQAgaYoECID0z9cVL3DAGdas/wHgQAEMPzT8RQ4cKPogL4AH2F9t994Ff5sm338Aw7MDMW4khwHi2HEc2gEAAGgBAwAAAAAA", + "transitionOffsetsBase64": "AAARACsARABZAHIAkwCtAL8A0QDsAAoBIwE9AVIBZAF5AYwBoAG9AdMB7gEGAiMCNgJJAl8CeAKaArQCxwLkAgEDIgM6A1gDcAOSA7ID0QPrAwUEJgQ/BFQEbQR9BJYErgTDBOEE9AQNBR8FMwVPBWgFewWVBaoFuwXNBekFAgYaBjIGRQZVBmsGhgaUBqsGwAbUBukGCAchBzYHUAdmB3wHjAelB70H0gfuBwIIEwgkCDYITwhmCH8IlwisCLwI0AjsCP4IEQkhCTEJQQlNCVYJZwl5CY4JoAm0CcYJ2AnvCQgKHgo1Ck4KaAqBCp8KuwrTCuwKBwshCz0LXQt2C5MLogvAC90L+QsKDCQMNgxHDFUMbQyHDKYMuQzSDOgM/AwPDSANLQ1BDVwNdg2LDagNxg3jDfwNGg47DlsOdg6nDsUO2w73DhUPNA9UD24PhQ+cD70P0A/mD/wPDxAbEDEQRhBgEIAQlRCxENAQ6RD6EBMRNhFXEWkRhBGeEbwR2hHyEQUSHBIxEkMSWRJvEpESrRLNEuAS8hIDEyITPBNRE28TiROcE7ITzxPqE/8TGhQvFEcUYBR5FJEUrxTRFPAUERUxFVEVchWQFaMVvhXTFesVAxYYFikWQRZZFnEWhhabFrEWyBbbFvIWAxcYFygXPxdOF2MXcxeFF5IXpRe2F8wX5Bf2FwUYGxgyGEYYXxh8GJcYqxjGGOAY/RgZGTAZSBlrGYcZrhnNGeIZ/xkbGj0aYRp7GpwauBrVGvQaEBslG0EbXRt4G4wbpRvCG+Eb8hsNHCUcPRxSHA==", + "transitionFaceIndicesBase64": "FgAtAEIAcACRAOgA6QD4AAABRgFHAUgBTQFUAXABlQG+ARYAQgB+AI8AkQC6AMcA3gDiAPgA/gBHAUgBVQFeAWEBZgFwAXMBhQGUAa8BsQG+AdkB+AEqAC0AQgBwAIEAjQCSAJgApgDeAP4A/wAIAUgBTAFVAV4BYQFmAXABcwGvAbEBuwHFASoAcAB+AIEAjQCOAJIAmAD/ABoBRwFIAVUBZQFwAXQBegGiAcgB2QHmAS0AXwBwAHEAgQCRAJgAnwC6AOIA7gD4AP8ACAEaAVUBYgFlAWYBZwF0Aa8BwAHZAfMBDQBfAHAAcwCBAI0AmACfALoA4gDoAPgA/wAIARoBOwFGAUgBVQFeAWIBZwFwAa8BsQG7Ab4BwAHFAfkB/QH+ASECDQBzAH4AnwCmALoA6AD0APgA/AAaAUYBSAFVAV4BZQFwAXoBsAGxAbsBvAG+AcUB+AEfAnMAjQCYAOIA7gD0APgA/AAIARoBOwFhAWUBZgF6AZ8B2QH4ARYAfgB/AN4A4QAIATsBVQF6AZ4BoAGqAa8BsAHDAdkBGwI1AhQAFgAkADQAfwCNAJgAugDeAOMA7gAIAUYBVQFeAWEBegGsAa8BtgHAAcUB1AHnAegB9AElAhsAJAA0AF8AmACmALoAwwDuAP8AGgFGAUgBXgFhAW4BqwGsAa8BsQG2Ab4BvwHAAcUB0wHUAd4BJQI1AiQALQA0AGMApgC6AMMA7gD4APwAGgE7AVUBXgFhAWkBegF7Aa8BsQG+Ab8BxQHfAfsBJAAtAEIAXwCNAKQAugDuAPgAAAEMARoBRgFIAVUBXgFmAWkBegGrAbABvgG/AdMBAAI1AhsAJABCAKQAuwDiAO4A/AAAAQgBDAEaAU8BXgFmAWkBegGwAb4BvwE1AiQANACPALsA6ADpAO4AGgEzATsBXgFpAaoBuwG/AcUBzgHTASEAKgBnAI8ApADiAP4ACAE7AUYBVQFhAXABngGgAasBuwG/AcUB0wH4ARsAKgAtAH8ApADpAP4A/wAFATsBRgFVAV4BYQFwAaABswG7AcUBDwAbADUAfwCYAOgA7gD9AAgBGgEkAV4BZgGrAawBsQG7Ab4B0wHyAQ8AIQAtAHAAiACNAJgApADpAO4A+AD9AP8ACAE7AUYBXgFlAXABegGsAbEBvgG/AcUBzwHTAd8B7AEkAC0ANQBCAHAAiACNALoA4gD8AP0A/gA7AWEBbgF0AXoBxQHPAdAB2QEAAiQALQA0ADUAQgBfAKQAugD4AP0A/gAIAUYBSAFeAWEBZgFuAXQBqwGxAc4BzwHeAecBAAIfAjQANQBfAH4AjQDiAOkA7gD1APgA/AAIARoBRgFIAVsBXgFmAW4BcAGxAcUB2QHnASEANAA1ADgAugDeAOIA7gD1APgA/gD/AAABCAEXARoBSAFeAWEBZgF8AZsBoAGrAbEBtgG9AcUB9wEWACEALQB+AN4A/AD+AAABOwFEAUgBXgFmAW4BcgGwAbEBtgHPARYALQA0ADUAjwD1APYACAEaATsBRAFGAU8BXgFhAWYBqwGwAdMBGwAtADQAbABuAI0AjwDeAPUA9gD8AP8AAAEGAQgBGgFVAXABqwHnAfMBNgIbAH4AmADuAPYA+AD8AP8AAAEaAUYBSAFLAU8BYQFlAWYBcAGxAb4BzwHTAecB8gE2Ag8AEgAtADQANQBBAEIAZwCNAJgApACmALsAwwDeAO4A9gD4APwA/gAaAUgBSwFPAVUBWwFhAWIBdAG+AcUByAHPAdkBBAAhAC0ANABBAEIAfgCNAKQApgD2AP4A/wAIAUYBSwFPAVUBcAGxAbIBxQHPAdgB2QEhAgQALQCNAKYA3gDpAPYA/gAIAUsBYQFmAXABsgHAAcUBzgHPAeIBBAA0ADUAgQCPAJEApACmAN4A9QD2AP4A/wAAARcBOwFEAUYBSwFVAWEBYgFwAXQBsAG2AcABzwH7AQQADwA0ADUAfACBAI0AjwCYAKQA6QDuAPUA9gD+AP8AAAEXATsBRAFLAU4BcAGvAbYBwAHFAdAB5wEEABYAGQA0AHwAmAC8AO4A9QD2AP0A/gD/AAABFwEcATsBRAFGAUgBTwFVAV0BYgF0Aa8BuwHAAcUBzwHmAegB9gEEABkALQA1ADcAfgCNAKQA3gDuAPgA/QAIARcBHAFGAUsBTwFVAWEBcAGrAbIBxQEEABYANQBCAH4ApAC4AN4A7gD1APgA/QAAAQgBFwEaAUQBRgFPAWEBZAFwAasBrAGwAbIBuwG+AcUB/QEEAA8AIQAqAEIAjQCkALgA4QD2AP0A/gD/ABoBTwFhAWQBZQGEAaoBsgG7AcUBKAIhADQAQgB8AH4AjQCRAJgApACyAN8A4ADhAPUA9gD+AP8AAAEXARoBRgFhAWYBdAGrAawBrwGxAbIBuwG+AcUB2QHoAQQANABCAEsAfACNAJgApgC/AN8A4ADhAOkA9QD2AP0A/wAAARcBGgE7AUYBTwFmAXQBhAGgAa8BsAGyAcUB/gEEAA8AFgAqAC0ANABfAI0ApgCyAL8A9QD2AP0A/gAXATsBRAFGAU0BTwFwAYQBrAGwAbEBsgG7AdkB4QHsAQQAIQAtADQAQgBfAI0AsgC4AL8A6QD/AAABFwE7AUQBRgFNAU8BaQFwAX4BsQG7AcUB2QEEAAsAFgAhAEIAfgC4AL8A9AD1AP4AFwEaATsBRQFNAWIBjQGsAbABsQGyAbsBvQHFAQICBAALAA8AFgAhACoALAA1AEIAjQCkALIAuAC/AOIA9AD2AAABGgFEAUUBVQFhAWQBcAGEAaABsQG7AcABwwHFAfMBBAAsADQAQgB+AIIAjQCkAKYAsgC4AOIA6QD1APgARAFGAUgBSwGEAaYBsAGxAcABxQEqADUAQgCBAIIAjQCmAKcA6QD2APgAAAFEAUgBTQFiAWQBsQGyAbsB1gEEAAcACwAVADUApQCyAPUA9gD+AAABCAEaAUYBSAFNAWEBYgFmAWsBcAF0AXwBuwHFAQIACwAqADQANQBAALIA/QAIAUYBZAFmAXABsQG7Ae4BDwAUABUAKgBAAI0AjgC4APUA/QD+ABoBRAFNAU8BZAFmAWsBjQGvAbEBsgG7AdUB9gEEABQAMwCCAKYAuAD1APYA/wAAARoBRAFPAWEBZAGEAY0BogGxAbsBxQHZAQMCGwIEAAcAIQBCAFQAggCNALIAuAD+AAgBRAFPAWEBZAGEAY8BrAGyAbsBvgECAAQABwAWACEAQgBUAHwAjQCyALcAuADuAPYA/wAAAQgBSAFPAWEBcAF1AYQBjQGfAaIBrAHVAdgBGgICAAQAFgBCAHwAsADuAPUA9gD/AAgBOwFNAWQBZgFwAbIBvgEAAgQAIQBCAH4AjQCmAN4A9QD+AP8AAAEIARoBGwFIAU0BTwFhAXABhAGFAbABsgHeAeQBIQBLAI0ApAC5AL8A3gAAAQgBGgE7AU0BZAFmAXYBjQG/AeIBBwAWACEAfgCkAPUA+AD/ABoBGwE7AUgBTwFVAWEBZAGFAbABsgHyAQIABAAHAAsAFgAhAF8AggCmALgA4gD1APYA+AD+AP8AGgFNAVUBZAFmAY0BogGvAbABsgHEAdMBAgAEAAsASwBUAF8AggCNAKYA4gD1AP4A/wAaATsBVQFhAWQBegGvAcQB0wHWAd8B6AEEAEsAVACNAKYAuAC5AOkA7gAaAUUBTwFVAWEBcAGEAY0BsAHhAQQAIQAsAI0ApgC4AOIA6QDuAPUA+AA1ATsBPAFIAU8BVQFhAWQBcAGEAZEBmwGwAd0B4wEEAAsAIQAsAI0ApACnALgA4gD1APgA/QAAARcBGwE3ATwBSAFPAb4B0gECAAQACwAWAKQApwC4APUA/wAIARcBGgEbATsBPAG+ATUCAgAEAF8AjQCRAKQApgDuAPgA/QD/ADsBPAFPAWEBZAFmARoCBAAWACEAXwCNAJEApACmALcA7gD4AP8ANQE7ATwBVQFkAWYBcAGEAY0BpgGwAb4BxAHSARwCNQICAAQAIQBAAIIAjgCYALgAuwD1AP8AAAE8AU0BTwFhAXABdgGEAY0BqwGwAb4BxAHYAQIABAALAEAAXgCCAKYApwCwAO4A9QD2APgAOwFNAWQBZgF2AaYBsAGyAdkB7gEoAgQACwB+AI0AmACmALcA7gD2APgA/gD/ABoBOwE8AU8BVQFhAWYBdgGEAaYBsgH1AQIAXgB+AI0ApwC5AOEA9gD+AP8AAAEaAU0BYQFmAWkBegEfAicCAgBBAEsApAC7AOMA9gD4AAABPAFVAWEBZAFpAaoBvQFBAKQAuAC7AOEA8gD1AAABTQFPAVgBYQFmAWkBcAF6AXsBrwG7AfgBJwJHAksAXwB+AI0AmACmALgA9QD2APgA/AD+AAABFgEaASoBVQFYAWQBZgFwAXoBfgGDAaIBuwHjAQcAIwD8AP4AFwEqATwBTwFVAWQBZgFrAXUBrwEHACMAQABCAI0AmAC4AO4A9gD+AAABFwEaAUQBSAFVAWEBawFwAXoB2QHyAScCLABAAEIASwCNAKYA7gDwAP4A/wAAARcBPAFVAWEBZAFwAYMBsAHdAfUBAQAsAEsAggCNAJgApQCmAKcAuAD2AP8AFwEaAUQBSAFVAWQBegHTAQIACwBAAEsAgQCYAJ0ApgCnAO4A/AD/ADwBSAFpAXoBgwGvAbAB0wEBAgIABAALABgAIQBAAHMAgQCCAJ0ApACnALgA7QDuAPYA/wAaATwBTQFVAWEBqwGwAb0BvgHYAQECAgIDAicCBAAWACEAMABLAIEAjQCkAKYAuAC7AMcA7QDuAP0AAAE7AU0BVQFiAXABqwG+Ad8B5AEEAAsAFgBLAH4AjQCRALgA7gAAATsBSAFTAVsBYQFiAWkBegGrAb4B9AEBAAQACwAhAH4AggCNAJEApACmALgA7gD4AAABSAFVAVsBZgFuAXABegGrAbABvgH5AScCAQAWACEAewB+AIIAuADiAOkA7gD4ACoBSAFVAWEBYgFuAXoBogHAAdQB3gELABYASwCNALgA4gDpAO0A9gAAASoBVQFhAWIBZgFwAbABwAHUAfMB9wEnAgsASwBeAHsAfgCCAKQApgC4AO4APAFNAU4BYQFwAXoBFgB+AIIApACmALgA7gAAAQgBGwEhAUQBTQFOAVUBWwFhAWIBZgFpAWsBcAF6AcQBJwIEABYAfgCkAKYA+AD/AAABBQEIARoBOwE8AUgBTQFiAWQBZgFrAXoBogG+AcQBJwIEAEEAQgBLAIIA3gDpAPAA+AD/AAUBCAFEAUgBTQFOAWEBaQFyAY0BvgEEAEEAQgBLAIIAjQCOAKQAuADeAOkA8gD/AE0BTgFbAWQBZgFrAXABdAF6AY0BkQGUAbABJwI2AgQASwBdAI0ApAC4AO0A+AAMAU0BZgFrAXABdAF6AY8BpgGwAScCNgK5AN4A+AD/AAQBOwE8AUgBTQFhAWQBZQFmAXABpgGwAbwBSwCNAJgAtwDIAPYAPAFEAUcBSAFkAWYBcAF0AY0BsAG+ARgAiACNAJgAuADIAO4AOwE8AUQBTwFhAWIBZQFpAXQBvgE2AkEASwBdAKQApgC4AOkA7gD2ACkBPAFIAU0BTwFhAWIBZgFpAXABegGNAbABsQG7ATYCMAA8AD0APgBBAEsAXQCNAKQA6QDuAPYA/wAVASkBPAFIAWYBdAF6AaoBuwHFAQQASwBdAHMAjQDuAPQAPAFEAUgBTQFPAWEBZQF0AXUBfwGNAY8BkQGmAbABsQHFAc4BBABeAI0ApgDIAPQA9gAbATcBSAFPAVsBXQFhAWUBZgFtAXUBegGNAaYBsAG7Ab4BggCYAKQAuADeAPYAAAEbASEBSwFMAWQBZQFmAWwBegGwAbEBvgHFAdkBXgCCAJEApAC4AN4AAAEkAUUBSwFbAXABdQGmAbAB+QE9AD4AggCIAJEAmAC/AOkA8gD4AP8ABQFLAVsBcAF6AasB2QHmASgCWABdAIIAiACNAI8ApgDjAOkA7gDwAPIA9QD4AP8AWwFkAXABegGDAZsBsAGxAbsBvgHFAf0BKQIEAHsAggCNAI8ApADjAO0A8gAbAVUBZQFwAaYBsAGxAbsBvgEEAIIApADeAO0A9gD/ABsBRgFTAWQBaQGNAaYBsQG7AfgB/gEnAlYAjwCYAKYA7gAbAT8BYQFkAWUBaQF1AYQBjQGmAScCBABWAIIAjwDtAPUAGwFGAWQBdAF1AYMBsQHFAdkBRwIEAIIAjwCYAKYAwwD2AP8ARgFmAXQBegGmAbEBxQEnAo0AjwCYAO4A/wAbAUYBZAFyAXQB2QEbAkgA9wD4ABsBYgFrAYQBnwGmAUIASQBKAI0ApACmAN4A7gD4AEYBSgFmAXoBfgGEAbABJwIEAEIASQBKAJgApACmAL4AwwDeAPwA/QAbAUQBRgFkAaYBvAFCAEUASQBKAIgApgC+AMMAGwElAUQBRQFkAWUBaQGBAbABuwHFAdkB9AEEAD0AQgCYAKYA7gD/ABsBRQFGAUoBaQGCAa8BvgHDAR0CIAJ+AI0AmACmALIA4wDuAP8AIwFKAWQBdQGEAbABsQG+AcMBxQHZAe4BjQCYAKYA4gDjAGQBZgFwAXUBegGhAaIBpgGwAbEBuwHDAcUBPwBCAFIAfgCNAOIA6ADpAP8ABAEHAXUBegGEAaEBsQG7AcUBCwAsAEwAUgB9AH4A/QAFARcBHQFKAU8BYgFmAWkBawFwAXQBrwGwAcMBHAIeAgYACwAWACEALABCAEkASgBLAEwAfgCOAKQA6ADpAEQBRQFGAUoBTwFkAaEBpgGwAbEBFgA/AJgApADTABcBRAFFAUYBVAFkAWoBawF0AYQBpgGvAbsBzgHZAf0BHwIEACEAQgBLAKQApgD3AP0ABwFEAUUBYQFqAWsBdAF1AXoBhAGNAaIBsAG7AdkBBAAWAD4AQgBLAH4AjQCkAKYAsgDoAOkA7gAHATwBRAFFAUYBTwFqAXUBegGhAaIBsAEBABYAQgB+AI0AmADoAOkA7gD9ACkBNAE8AWIBagFrAXUBjQGhAbABsQG7AdkB5gHzAR8CFAAtAEIASwBMAFMAfgCNAI4ApgD9ABMBKQFFAWoBdAGfAbABsQG7AcUB1AHZAfgBHwIUABsAQABCAEsAUwBdAH4AjgCUAJgApgDeAOkA7gD2AP0A/wAHATwBRgFiAWoBawF1AbABtgHAAdQBKAIEAA8AFgAjAC0AQwBMAFMAfgCmAMAA3gDpAO4A/QAHASkBPAFGAU8BagF0AXUBgwG2AbsBwAHFAQIAFgBAAEEAQwBEAFMAWABfAI0AmACbAKYAwADiAOMA7gD/ACkBRQFGAWoBqwGwAQIAFgAtAEEAXgCNAJgAmwCkAMMA4gDjAO4ABwEXAUUBawGBAZUBlgGXAaEBsAG2AdQBFAAWACwALwBBAEQAjQCkAOgA6QDrAPUA/QApAUQBRgFrAZsBogGsAbABtgHAAccB1AHoAR8CFAAWACwALwBMAFMAXwCCAIgAjQCRANkA6wDtAPUA/QAqATABRAGhAaIBrAGwAb4BwAHHAQIAFQAWABoAGwAjAC8AQQBEAEwAUwCCAIgAjQCRAJgA6ADpAP0AEgEqAUQBRQFsAY0BoQG+AdEBAgAaACwARABeAF8AewB+AJgAwwDTAO4A9QD4ABIBRAFFAWEBYgFpAWsBbAF+AY0BqgGyAboByQHRAf4BHQIhAgsAFQAZABoAKgAsAC8AUABeAHsAfQCyAOsA7gBEAUYBYQFiAWkBawGrAcIB1AEdAiACAgALABoAIwAqAEIARQBGAH0AfgCRAJgA3gDrAPgA/QAqAUQBRgFIAU8BaQGNAasBsgHKAdQB3gEAAgIADwAjACwARgBeAH4A6wDuAPgAFgFEAUYBSAGNAQ8AIwAqACsALABCAEYAUABfAIgAjgCRAJgA3gDoAOkA6wDuAPUA+AARARQBFgFEAUYBTwFpAWoBawHtAQIAFQAhACoAKwAsADAANQA9AD4AQgBGAHAAiACUAK8AsgDjAOsA8ADyAEgBaQFqAWsBdQGDAYUBrwECABUAFgAhACwANQBBAEIAXgBfAGsAcAB9AIgApgDjAOgA6QDrAPYARwFIAU8BdQG+AcUB0QH4ASEAcwB7AIIAjgDrAPMAGAEwAUUBSAGVAZYBlwHFAdEBHQIWACEAKgAsADwAQgBwAHEAcwB7AH4AggCYALIAuwDeAPQA/QARARgBTwGfAb4BHgIfAikCAgAVACEAKgAsAEIAbwBxAHMAkQCYAPcA/QBFAU8BvgHFAeQBAgBCAHQArgCwAN4AEwEjAUUBSAFOAU8BdQF6AagBvgHFASEAXgCSAL8A4wD0ABsBJQFFAUgBcAF1Ab4BGwIVABYAKgBIAF4AXwB0ALoAvADjAPMA/gATAUABSwFMAU8BZgFwAagBvgHFAe4BAAILABYAIAAqAEAAXgBfAH4ApwDeAP0A/gATASQBRAFIAU4BTwFmAWkBewGBAaIBxQHtAfcBAgAHAAsAIAAhAEsAVgBeAGsAewCCAJgA6ADpAOsA/QD+ABABPwFPAWkBbAFtAXQBdQGiAaoBqwHTAfwBIQIGAAcAXgCCAJgA3gDrAP4AEAFIAU8BagFwAXQBdQGqAasBxQHTAQYACwAgACEAXgByAH4AiACYANkA6wDuAEgBVQFiAWYBagFwAXQBggGUAcUByQHKAfUBAgALABYAIABCAF4AggCIAKUA2gDrAO4AGgEqAUgBYgFoAXQBhwGLAckBJgIWAEIAXgB0AIIA6ADpAPUAAQEaARsBSgFVAWYBaQGLAasB0wHYASYCdAClAN4A9QABAQgBFAEXARsBTwFkAWYBaQFqAasBvwHFAdMB2QFCAF4AcQDeAOMA6wD/ABsBTwFVAWIBZgGHAYsBxwH4AfwBQgBfAHEA7gAIARUBTwFVAWIBagF1Ab8BxwEBAAcACwBCAF0AawB+AI8AkQCYAOsA+AD9AP4AagFwAYsBjgGrAcUBBwALACEAQgB0AH4AmACmAN4A4gDrAO4A+AD9AAABCAETAT8BYgFrAXABdAGLAasBsQHFAQUCAgALACEAjwCSAJgA3gDiAOsA/gAAARUBTwFVAWIBZQFrAW4BdAGxAb8BwgH0ARwCJgJQAgIACwBrAJ8A9wD4AAgBGgEpAToBTwFVAWIBZgFuAasBvgG/AeQBJgJQAgYAPABVAHIAiACPAJEAmADeAPgA/AD+AP8ACAEdAU8BYgFkAWYBaQGHAasBsAG+Ab8BxQHJAdMBBgIGAAcAFgAiAEYAbAByAI8AkQCYAJ8A4QDiAOsA/AD+AP8ADAEaASkBSAFkAWoBdQF2AYcBjgGwAb4BvwEHABQAFgAiADMANQBCAF0AbgB/AJIAmADTAOIA7gD/ABABSAFOAU8BZQFpAXYBiwGrAb4BxQHoAR8CLAA0AFAAXgB/AI0AkgDuAPgA/wAIARABKQFIAU4BZAFlAWoBcAGHAasBxQHTAd8B+QEGABYALAAvADQARgBQAF8AcACNAJEAngDeAO4A9QD4AAgBEAERARYBSAFRAWQBZQFqAXABfgGHAbIBvgEWACUALAAvADAAPgBQAFUAXgBfAGwAcACRAJgA3gDmAPYA+AAAAQgBEAE0AUgBVQFkAWUBZgFpAWoBsAGyAdMBKAIhACUALAAvAEYAUgBvAHIAlACYAMgA5gDoAOkA6wDuAPUA/AD/AAABCAEpATABOAFPAWoBdQGwAb4B0wHVAdYBHwAhACUAKgBGAF4AkQCuAN4A3wDrAPUA/wAaASkBOwE9AUYBVQFlAYMBjQGsAcABxwHfAfMBCwAQACEAJAAlACoALwBCAFIAXgBfAHAAcQB0AH0AkQCaAKMApAClAK8AsAC6ANkA3gDfAOsA7gD1APYA+AD9AP8AEQFIAVEBXQFkAWUBZgGDAYcBjQGsAbABwAHHAd4BAQILABoAKgBCAF4AbgBxAHMAewB+AIcApQC6AMcA3gDoAOkA7gD2APgA/QARASIBPQFRAWYBhwGNAbABAQIaACsARgBeAH4ApAClAPYA/wA5ATsBPAFNAU8BUQFkAWgBagF1AY0BsAGyARoAJQA+AEYASABeAH4AmACeAKUApwDpAO4A9gD8AP8AEAERARoBOQE7ATwBSAFyAY0BogGwAbIBEQBGAEgAXwBwAIcAkQCYAJwAngDpAO4A8gD2APwA/wAQAREBGgE5ATwBPQFbAWEBZAF1AY0BqwGyAScCGgAbACQAKwBGAFAAXwBwAH4AiQCRAMAA4QDjAO4A+AD8ACEBPAE9AUgBTgFbAWQBZgF1AY0BpgGrAbIBwAEHAA0AJAAlAD0APgBBAF4AXwB7AIkAmACpALsAwgDuAPYA/wAIAREBGgE8AT0BYQFmAXQBdQGNAaYBvQG+AcABBwANABoAJQBCAEgAXABeAF8AewCOAKYA6ADpAOsA7gD4AAgBGgE8AWYBdAF1AXwBvgEoAg0AHQBCAEgAUwBWAGgAagB+AJgAtgC7AN4A6wDyAPgACAFIAU8BdQGDAZ8B1wENABoAJQBwAH4ApgC2AN4A6ADpAOsA7gD4APwA/QAHAQgBQAFFAWEBdAHHATUCBwAZABoAJQA6AFMAcAB+AJEAmACmAOsA8wD2APgA/QAHAQgBHAFFAUgBTwFhAWUBagF0AY0BsAGyAbsBxwEnAjUCBwBIAFMAXACOAJEAmACmAPMA9gD9AB4BOwFlAY0BsAGyAbsBxwEHABEAGgBIAFMAagB+AJEApgC2ALsA/QD/ADsBSwFqAXUBhwGfAaoBxwEGAgAABwASABkAGgBTAH4AjgCmAPYA+AD9AP8AAAEIAUUBRgFIAXUBhwGwAUcCGQAkAC8ASACRALYAugDCANkA8wD2APgA/QD/AAABEQFFAUYBJwJCAFMAjgC6ALsAEQFIAWkBugG+AccBJwIaAEIASAB+ALsAwADSAN4A9QAIARIBOwFFAUYBTQFUAWUBfgGwAb4BxwEnAiUASABJAJEArAC7AN4A7gD1APYA+AD/AEQBTQFkAWoBfwGNAQUCJwIvAhkALwBFAFMAfgCRALAAugC7AOIA4wD2APgA/QAGAREBEgEwAUQBRwFLAVgBbAGNAbwBxwEHABkAIQBAAGoAcAB6AIcAnACeALoAuwDiAOMA7gDzAPYA+AD9AP8ACAESAR4BNQFHAUsBWAFpAbABxwH5AScCBwBMAHAAfQCHAKIAugDuAPUA9gARARIBIgE7AT0BRAFNAVUBaQF0Ac8BEAAZAEIARABIAEwATgBTAGgAfgC8AO4A9QD2APgAEQE7ATwBRAFGAUgBVQFiAXQBdQGNAbABRgIHAA0AJgBCAEQASABOAH0AfgDjAPYABAEFAQgBFwE5ATsBPQFEAUYBSAFLAVUBYgFpAXIBdAF1AaYBsAHgAQcAGgAmAEIARABOAFMAcwB7AOMA8wD9AP8AEQEXATgBOgE7AT0BaQF0AXUBpgG7AcUBGgAmAEQATgBzAHsAogDuAPYA/wAQAWIBdQGNAa8BsQEnAgcADQAmAEIASQBOAFMAXgDuAPYA+AD9AAgBEAESARcBJAFIAVgBYgF0AXYBsAGxAc8BSABMAE4AUwBWAF8AcwB+AK4ArwDhAOMA6wDzAPYA+AAIARIBFwEaAUsBTQFYAWIBZAF1AXYBgQGNAa8BsAG+AcUB4gEpAgcAGgAmACcAPgBIAEwAUwBWAFgAXgBzAH0AggDrAAgBFwEaATwBRgFLAU0BWAFdAWEBdAGNAaIBuwG+Ac8B4QEnAhoAWABfAIIA8wD2AP0ACAETARoBNQFIAUsBWAFhAWIBdAHFAT0AQgBMAE4AcABzAH4AhwDhAOkA8wD8AP0A/wATAUYBZAFmAXQBdQGwAbEBvgHFAc8B9AEnAgAAHwA/AEIATABOAHAAfgDaAOkA7gD9AAgBGgElAUsBZAFpAWwBdQGNAbABsQHPAScCNgIfACYAQgBMAFMAWABeAF8AagBwAHEAgQCHAI8A6wDuAP0ACAEaAU0BTgFVAXUBjQGsAb4BwAHPAScCNQIaAB8AMQBYAF4AXwBxAH4AhwCPAJ0A6wD2AP4AGgE8AUYBRwFLAU0BVQFhAWwBdQGNAaEBrAGvAboBwAEaADwAPwBCAEgATABTAHEAnQDuAPYA+AAaARwBRgFHAUgBYQFkAZIBsQHPAd4B8wEfACYAJwA/AEgAZQBxAP4APwFGAWQBjQGhAaoBsQG7Ab8BLwJGAgcAHwA6AEwATgBfAG4AbwBxAIcAFwE8AUsBTQFVAVgBYQFkAWwBsAG7Ab8BxQEHACoASABMAE4AXwB6AIEApgCwALwA/gBGAU0BVQFYAWEBbAGvAcUB/gEaAEAAQgBIAHEAfgCHAP4AIQE1AUYBSwF2AY0BqwG+ARsCHQIaACYAPwBCAG8AfgCHAJ4A6QBGAU0BVQFYAWwBdgGgAasBvgG/AcUB5AEdAhoAJAAmADoAQABCAFEAbwCPAKYA6QATAUgBTQFPAVUBWAGqAbABIAIqAkcCGgAkACYAJwA6AD8AQgBRAFgAXgBvAH4AhwCPALAAuwDpAEcBSAFVAWEBYgFpAWwBeQGNAaoBqwGvAbEBuwG+Ab8BHgIHAD8AWABeAHEAfAB+AIcAjQCuAK8AsAC7AOkAHAElAUcBSAFhAWIBZAF2AasBwwHFAc8BHQIqAhkAGgAdACQAJwBeAF8AbwB7AHwAggCHAI8AlACYAJoAowCsALAAuwDsAPUARAFpAYIBjQGgAbsBvgG/AcUBHQIaACQAMQBIAF4AXwCCAIcA6QD1AKABrwGxAbsBvgG/Ad8BAAIeAgcAQgBIAIcAjQCPAEgBWAFhAWIBZAFpAZIBoQG7Ab4BxQEdAh8APwBCAFgAfgCCALoAuwDIADMBSAFYAWEBYgFkAaIBxQEfAC0APwBEAE0AXgBvAH4AggCHAI0AjwCYAKcAsADSAOsA7AA8AUgBZAFpAYUBogGxAb4BxQHUAd0BHQIqAhoAGwAtAEQASABNAGgAcQBzAI4AmAC7ANMA6QA8AUgBYQFkAWwBqAG+AcUB1AHjAfIBKgINABoAHwAtADoASABfAHMAjwDzAAgBSAFVAWEBaQGwAbEBuwHFAeMBHQIHAA0AGgAfADoARABIAE0AXgBfAHMAfgCCALsAyAD0AP4ACAEzATwBSAFdAaUBsAG7AdIB3AHjAfYBKgIZAB8AKgAtAD8ARABIAE0AcABzAH4AggCwAP4AGwE3AVUBWwFdAWQBZgG8AdIB3AHoASoCBwAaAD8AQgBeAF8AcABzAI8A4AD/AAABGwFIAUsBVQFkAdIBKgIHAA0AGQA/AE4AXgBfAI0AjgCdAOAA4QD/AAABFQFFAVUBdQGiAbYB0gEqAhoALAAtAD8AQgBIAE4AXgBfAHAAegCPAJ0A4AAbATsBPAFIAVUBYgF1AaIBqAGpAbYBuwHjAfIBKgIaACwAPwBOAF4AXwBzAHwAfgCPAKUA4AD1AP4AGwFIAU4BVQFiAXABdQGEAZkBqAG5Ad0BKgIHABoAPwBOAF4AXwBzAHwA/gAbAU0BTgFwAXUBhAG5AbsB3QHyAR0CKgIHAF4AXwBsAHMAfAB/AI0AjwClALoA3gDzAPQA9gD+AAABGgEbAXABcgGEAZgBuQH1AfgBKgINABoASABNAHMAjQCnALsA3gDuAPYA+AAkAUcBZAFpAXABggG5Ab4B2gENACEAOgBAAEQATQBeAIIAjwCYAN8A9QD2AP4AAAE7AUcBSAGCAZEBvgHjAewBHQIhADoAQABEAHwAggCjALAAugDeAN8A6ADpAPUA9gAaATwBSAFNAV0BZQGEAaQBuwHaAQcACwANABoALABIAFUAXgCPALoA/gAqATsBRwFIAU0BXQFwAXgBhAGyAbkBuwHEAdkBCwAsAEgASgBWAJEAugDDAOgA6QD1AP0AGwEqATsBRwFaAXABdQF4AX0BhAGwAbIBBwAaAD8ASABKAE0AVgBeAIEAhwCQAJsAuADhAOMA9gD8AP0ADAEpAT8BSAFOAU8BWAFaAWYBdQEoAjYCBwAWABoAHwAtAD8AQgBIAF4AXwCHAJ8AxADeAPYA+AD8AP4AAAE7AUgBTQFYAWQBZgFzAXUBdgF5AagBvwHAAcQBHQIHAAsAFgAfACwAQABNAFUAXwBjAHwAggCbAJ8A3gDoAOkA9AAAARIBGgEbATsBPwFIAacBqAG+AcABHQI1AgEACwANABoAIwAsAC0AOgA/AEAAQgBKAFUAVgB8AIIAkAC6AMAA6ADpAPgA/AAAAQwBEgEbAUgBVAFiAWQBsgG+AQEADQAPACEAOgA/AEoATgBWAI4AjwCbAJ4AowC2AMIA3gD0APUA/AAAAQQBGwEfAT8BZAFmAW4B1wHuAR4CHwINABYAHwAhACoATgCJAI4AkQCYAJsAowCkAKYAtgDeAO4A9QD2APgA/gD/ABwBHwE/AVYBYgFmAWoBbgGBAbIBAgALAA0AFgAaAD8ASABfAIIAjgCPAJgAmwCkAKYAtgC5AN4A4wDuAPYA+AD+AAABCQEqAUYBSAFbAWQBZgFzARwCAQACAAsALAA/AF8AgQCCAI4AjwCRAJsAowCpALYAuADeAOMA6ADpAP0AAAEWASQBSAFWAWIBZQFmAbIBAQAHAA0AGgAsAHwAjwCRAKMAuADeAPYA/QD/AEgBVgFaAaABsgEBAAcAGgA/AEgAXwBjAGUAfACJAI4AjwCYAKMA6ADpAPYA/wA3AUgBWgFkAXUBoAGoAeYB/wEEABoAPwBIAE0AXwBsAG4AhwCOAI8AuQDjAO4A+AAJAUUBSAFaAWoBqAEEAA0ALQBIAE0AfACCAIcAjwCYALgAxADjAPgACQEbAUYBSAFWAVoBagFyAYUBqAECAAcADAANABYAPwBCAEgAVQB8AIIAyADeAPgA/wAaAR0BRAFGAVYBagGoAbsB9gECAAcADAANABYAGgAtAD8ATQCCAJgAwwDeAO4ACAEXARsBQgFmAagB9wEEAA0AQgBIAE0AggC8AP8ACAEJARcBSAFLAU0BlwGoAbsBBAANABoASAB8AI4AjwCmALYA3gDuAPgA/QD/ABsBRgFNAVYBZAFqAYIBjwGXAbwBBAAHABoAPwBCAHwAjgCPAJgApgCwAN4A6ADpAO4A/QAaARsBRgFNAVYBYwFkAYIBBwAaADcAQgBoAIIAjgCYAJsApgCwALYAwgDeAOgA6QDsAO4A+AD/ABoBRAFjAWoBBABCAE0AggCOAI8AmACmAMAA7gD9AA0BFwEaAUYBcAF2AYQBpwGzAbsBCwAaAEIAgQCwALsA3gDsAPgA/QAWARcBGwE1AUQBRgFkAaABuwG+AecBCwA3AD8AQgBNAHoApgC7AO4A/wAWARcBSAFdAWQBagF9AYQBoAG+Ab8B7QFBAIEAggCYAJsAnwCmALsA7ADuAP8AFQEXARoBGwEqAUgBVgFqAWwBqAHNAfsBDQAaAEEAQgBNAF8AjwCYALAA2gDeAPgA/wBIAWoBfgG+AcQBzQENAD8ATQBfAHMAggCYALAAuwDeAO4A9gD4AP4A/wALAUgBVgFqAXABvgHAAc0BLQA/AHQAjwC7AOEA4wD2APgA/gAIAQ0BOwFWAWIBwAHNAS0AiACPAJgAuwDuAP8ACAENARMBKQE7AUYBVgFiAXwBgQGCAbsB1QHtAQcADwAaAJgAuwDuAPgAOwFFAUgBWAFhAWIBZgG7AdYBFgAaAB8AKgAtAF8AcgCBAIIAjwDuAP8ASAFMAVgBWwFhAWIBZgFwAcIBxAHhAQcAFgAaACMATQBfAIgA3gD9ACUBYgFwAYQB2AHtARYAGQAtAE0AfwCCAIgAmACbAN4A+AD9AP8AFgEjAVUBVgFYAWIBZgGEAQcAGQAtAE0AXwCBAJsA4QDuAEsBWAFiAWMBZgFwAeABBwAtAEIAXwBwAH4AsADHAO4APAFEAUgBYwFkAXABeAG+AeIBGQBNAHAAmACmAPUA+ABhAWMBZAFmAYQBvgEHAA8AGQBAAEIAcACCAJgApgD1AP0ARAFIAUwBYQFmAYQBmQHnAUAAQgCCALsA/QAAAToBOwFEAU4BVAFiAWQBZQGbAb4B5wEWACMALQBCAHAAcgCOAI8A7gD4ADoBOwFEAUsBTgFhAWIBZAFwAYQBvgHDAQcACwAZABoALQBCAE0AXwBwAIgAjgCPAOMA7gD4ADoBTgFYAXABhAGpAasBugHtAQIABwALABkATQBfAHAAiADuAE0BVQFYAWEBYwFpAXABdgGlAQIABwA3AHAA7gA6AUYBSwFVAVgBYQFkAZYBngGqAUAAQgB0AJEA3gD4AAABOgFGAUgBTQFPAVgBYQFkAWkBbAFwAZYBmAG8Ab0BAgANABoALQBAAEEAbABuAHAAkQCYANoA3gD1APgAEwEzAUgBTQFjAW4BcAGEAQIADQAtAEEAQgBNAIIAkQCmAO4A9QD4AP4AOwFYAWMBZgFpAW4BcgECABoAIQBAAEIATQCCAI4AkQCYAK8A7gD9AAsBFQE7AUUBSAFNAVEBWAFsAXABhAGiAQIADQAaACEAQABCAEgATQCBAIgAjgCmAPUA+AD+AAsBRQFGAUgBSwFNAVEBYgFkAWYBaQFwAYMBhAEHAA0AFQAaAC0ASgBNAIEAiACYAKYAsgDuAPUA+AD9ADsBRAFGAUsBTQFiAWQBcAGEAZIBpgECABUAGgBKAHAAjgCYAKYA9QD2ADsBPAFGAU0BUQFVAWMBpgG+Ac4BAgAHAAwAGgBNAHAAggCOAI8AmACmAOIA7ADuAPgA/QD+ABcBOwE8AXABegGEAaEBogGmAb4BDAAVABoAQgBNAF8AcACCAI8AmADeAOIA/QD+ACMBOwFIAUsBVQFhAWMBZgFwAaEBpgHIAQIABwANABUAGgAkAEIAXwBwAN4A7AD1APcA/gAlATsBRgFLAU0BUQFVAVgBYQFkAXABpAGmAegB8gEMABoAJAAtAF8AjwCbAKYA/gAAATwBSAFLAU0BUQFVAWQBcAF1AYQBoQGmAcAB3gHfAeQB5wHoAQcADQBCAF8AjwCRAJsA9QD4AAABCwE8AU0BVQFkAWYBewGUAaEBuQHAAcMBAAIaAC0AQABJAE0AjgCmAO4AAAELAToBOwE8AUQBSwFYAWIBZAF6AZIBmwGmAecBAAIMAC0AOABAAEgASQCOAI8AkQCmALIA7gD1APgA/gALAToBOwFEAUgBTQFRAVUBXQFhAWIBZAFmAW0BcAF6AYQBoQGmAecBDQAaAB8AQgBIAEoATQBwAIgAjwCRAPgA/gAAATsBSwFiAXABhAGhAaYBrAGvAcIBxAHjAecB7gEHAA0AGQAaAEIASABKAHAAgQCIAJEApgC7AO4A9QD4AP4ACwE6AT0BRQFIAU0BVQFYAWEBZAFlAWYBawFwAXoBngGiAaYBrAGvAR4CIAIHAAwADQAZABoAKgBAAEIAgQCRAPUAHQE8AUIBRQFIAWIBZAFlAWYBawFuAXABegGeAaIBrwG+Ad0B8wEdAgwAQABzAJEApgALATsBRQFIAUsBWAFkAW4BcAFzAXgBhAGeAa8BvgHnAQcAFQAjAEIAjgCRAJgA3gDiAOwA7gD+AAsBGwE7ATwBSwFMAU0BYQFkAWYBcAFzAZ4BoQGmAa8B5wEHAA4AFQAaAEAAcABzAH8AjwCmAMgA3gDiAOwAGwE7ATwBRAFLAWEBZAFmAWwBbQFwAa8BxQEpAg4AFgAaACQAKgA4AEAAfwCPAJEAmACmAMgA6QAJARsBOwE8AUsBTQFVAVgBXAFhAWYBcAF6AYQBlgGmAcUByAHnAfQBFgAaACMAJAAqAC0AQgBwAI8AkQDiAOwA+AAAAQgBCwEbATwBRAFGAU0BXAFhAWIBZgFrAXABcwF0AXoBlgGeAaEBpgGvAecBFgAjAC0AQgBjAHMAjwD4AAgBCQELATsBPAFEAUYBSwFNAWQBaQFwAXMBfgGfAaABpgGvARYAGgAjADgAQgBjAHMAkQCYAKYA4gDpAOwA7gD1APwACAESATwBSgFLAU0BVQFYAVsBYQFiAWQBZgFrAXQB+QH9ARYAGgAjAC0AQgBjAHAAkQCYAKYA+AD8AP8AOwFEAUgBSgFNAVwBYQFkAWYBegGEAaEBrwG+Af8BIwA4AEIAfwCBAIgAkQCYAKYA3gDiAOwA+AD8AAABOwE9AUQBSAFNAVUBXgFhAWQBZgFrAasBrwG+ARoAKgAtAEIAcAB/AIEAmACeAKYAsADeAOIA7gD4AP8APQFGAVwBYQFkAWYBawFwAXoBhAG+AcUB2QHnAfsBBwAWACoAOABCAEUAgQCYAOwA+AAIARoBRgFLAVUBXAFeAWEBZAFmAXABdAF6AbEBvgHFAdkB5wENABYAQgCBAJgAnwCwAOwA9QD/AEYBYgFmAXABdAF6AYQBpgGxAecB6AEHABUAGgAtADgAQgBwAH4AgQCfALAA7ADuAPwA/wAIAUYBVQFcAWIBbgFwAXoBhAGmAbABxAHsARUAGgAtADgATQBwAHEAfgCBAI8AmACfAO4A9QD4AEYBSAFUAVUBXAFhAWQBZgF0AaYBvAHFAdkBGgAqAC0ATQBfAHEAfwCPAJgAnwCmAOwA7gD1APwAGgFIAVwBZAF0AaYBsQG7Ab4B5gH9ATYCFgAqAC0AOABCAF8AfwCRALAA4gD/ABoBRgFiAWYBdAGxAcUB2QE2Ag0AFgBwAH8AkQCSAJgApgDuAPwA/wAIAQ0BGgFGAVUBYQFmAXMBsQG+AcUB2QH+ASYCDQAVAC0AcABzAH8AjwCRAOIA/AAIAQ0BGgFIAVUBXgFhAWQBZgGEAZ4BqgGwAbEBuwHFAdkBAgImAhUAKgAtADgAcABzAI8AkQCSAJgAvADIAOIA+AD8AP8AAAELARoBRgFVAV4BYgFwAXMBhAGwAbEBuwHFAQICKgBCAHAAkQCYAK8AugAAAT0BSAFiAWQBdAF6AYQBrwGwAS0AOABCAHAAkQCSAJgA6ADpAP8ACAEcAUYBSAFwAXQBhAGeAa8BsQG7AcMBxQEAAhsCIQIoAi0AXwCYAJ4A3gDiAPcACAENARoBHAE7AUQBRgFHAU4BYgFmAXQBegGEAZ4BsAEhAg0ALQBCAFgAXwBxAJIAmADiAO4A/wAIARIBGgFGAUcBSAFhAWIBZgF0AYMBsAGxAQ0ALQA4AI8AkQDeAO4A/wAAAUcBSAFhAWIBdAGVAZ4BsAGxAbsBxQHZAQ==", + "provenance": { + "method": "chrome-solid-face-id-exact-interior-pixel-census", + "route": "http://127.0.0.1:4325/cityflow/", + "presentation": "schema-22-folded-adaptive-smooth-sine-24-54-0.6-eased-extrema-60hz" + } +} diff --git a/src/adapters/cityflow/notes/references/prepared-visibility-cosine.json b/src/adapters/cityflow/notes/references/prepared-visibility-cosine.json new file mode 100644 index 0000000..6401f5e --- /dev/null +++ b/src/adapters/cityflow/notes/references/prepared-visibility-cosine.json @@ -0,0 +1,486 @@ +{ + "schema": "csscityflow-browser-visible-face-id-source@1", + "encoding": "initial-bitset-plus-u16le-per-target-frame-toggle-offsets-and-face-indices", + "browser": { + "name": "Google Chrome", + "version": "152.0.7977.65", + "headless": true + }, + "viewport": { + "width": 1280, + "height": 720, + "deviceScaleFactor": 1 + }, + "frameCount": 301, + "faceCount": 600, + "initialVisibleCount": 229, + "minimumVisibleFaces": 188, + "maximumVisibleFaces": 260, + "meanVisibleFaces": 228.7607973421927, + "visibleFaceCount": 454, + "visibleFaceIndices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 11, + 12, + 13, + 15, + 16, + 17, + 18, + 20, + 21, + 22, + 24, + 25, + 26, + 27, + 29, + 31, + 32, + 33, + 35, + 36, + 37, + 38, + 39, + 42, + 43, + 44, + 45, + 46, + 47, + 48, + 49, + 51, + 52, + 53, + 54, + 55, + 56, + 57, + 58, + 59, + 60, + 61, + 62, + 63, + 64, + 65, + 66, + 67, + 68, + 69, + 70, + 72, + 73, + 74, + 75, + 76, + 77, + 78, + 80, + 81, + 82, + 83, + 84, + 85, + 86, + 87, + 88, + 90, + 92, + 93, + 94, + 95, + 96, + 97, + 98, + 99, + 101, + 102, + 103, + 104, + 105, + 106, + 107, + 108, + 110, + 111, + 112, + 113, + 114, + 115, + 116, + 120, + 121, + 122, + 123, + 124, + 125, + 126, + 127, + 129, + 130, + 131, + 132, + 133, + 134, + 135, + 136, + 137, + 141, + 142, + 143, + 144, + 145, + 146, + 147, + 148, + 152, + 153, + 154, + 155, + 156, + 157, + 158, + 159, + 162, + 163, + 164, + 165, + 166, + 167, + 168, + 169, + 170, + 171, + 172, + 173, + 174, + 175, + 176, + 178, + 182, + 183, + 184, + 185, + 186, + 187, + 188, + 189, + 190, + 191, + 192, + 194, + 195, + 196, + 198, + 199, + 200, + 210, + 211, + 216, + 217, + 218, + 219, + 220, + 221, + 222, + 223, + 224, + 225, + 226, + 227, + 228, + 229, + 231, + 232, + 233, + 234, + 235, + 236, + 237, + 238, + 239, + 240, + 241, + 242, + 243, + 244, + 245, + 246, + 247, + 248, + 249, + 250, + 251, + 252, + 253, + 254, + 255, + 256, + 257, + 258, + 259, + 260, + 261, + 262, + 263, + 264, + 265, + 267, + 268, + 269, + 272, + 273, + 274, + 275, + 276, + 277, + 278, + 279, + 280, + 282, + 283, + 284, + 285, + 286, + 287, + 288, + 289, + 290, + 291, + 292, + 293, + 297, + 298, + 303, + 304, + 305, + 306, + 307, + 308, + 309, + 311, + 312, + 313, + 314, + 315, + 316, + 317, + 318, + 319, + 320, + 321, + 322, + 323, + 324, + 325, + 326, + 327, + 328, + 330, + 331, + 332, + 333, + 334, + 335, + 336, + 337, + 338, + 339, + 340, + 341, + 342, + 343, + 344, + 345, + 346, + 347, + 348, + 349, + 350, + 351, + 352, + 353, + 354, + 355, + 356, + 357, + 358, + 360, + 361, + 362, + 363, + 364, + 365, + 366, + 367, + 368, + 369, + 370, + 371, + 372, + 373, + 374, + 375, + 376, + 377, + 378, + 379, + 380, + 381, + 382, + 383, + 384, + 385, + 386, + 387, + 388, + 389, + 390, + 391, + 392, + 393, + 394, + 395, + 396, + 397, + 398, + 399, + 401, + 402, + 404, + 405, + 406, + 407, + 408, + 409, + 411, + 414, + 415, + 416, + 417, + 418, + 420, + 421, + 422, + 423, + 424, + 425, + 426, + 427, + 428, + 431, + 432, + 433, + 434, + 435, + 436, + 437, + 438, + 441, + 442, + 443, + 444, + 445, + 446, + 447, + 448, + 450, + 451, + 452, + 453, + 455, + 456, + 457, + 458, + 461, + 462, + 463, + 464, + 465, + 466, + 467, + 468, + 469, + 470, + 471, + 472, + 473, + 474, + 475, + 476, + 477, + 478, + 479, + 480, + 481, + 482, + 483, + 484, + 486, + 487, + 488, + 492, + 493, + 494, + 498, + 499, + 500, + 501, + 502, + 503, + 504, + 505, + 507, + 508, + 509, + 510, + 511, + 512, + 513, + 514, + 515, + 517, + 518, + 538, + 539, + 540, + 541, + 542, + 543, + 544, + 545, + 550, + 551, + 552, + 553, + 554, + 559, + 560, + 561, + 565, + 566, + 582, + 583, + 586 + ], + "transitionCount": 7088, + "initialVisibleBitsBase64": "/wFUAQAgaYoACID1x9cWL3AAG1es/wHgQAEMPzT8RQ8cKfogL4AH2E9t154FX4si338Aw7ODMWYEhwHg2HEc2wEAAGgBAwAAAAAA", + "transitionOffsetsBase64": "AAAXADAARQBdAHEAhwCXAKkAxQDkAPsAEQEkATgBUAFgAXYBjwGqAb8B2QH0AQkCGAIsAjsCUQJoAnwCmwK8AtgC8gICAxwDMwNVA3ADiwOiA7sD1QPxAwsEIQQyBEYEYgR7BJIEqQTIBN0E9QQOBSIFOwVVBWsFgQWXBasFyAXhBfoFEQYoBkIGWgZuBooGnga0Bs0G5Ab9BhEHLwdAB1UHZgd/B5oHqwfFB98H8QcFCBsINAhHCF8IegiPCK0IwAjdCPQICAkaCS4JOwlLCV8JcAl/CZkJpgnACcwJ2wntCQcKGQorCkAKUwpnCocKoAq6CtMK7AoBCxILKgtGC1sLcAuPC6oLwAvSC+sL/AsSDCYMOgxVDGkMfAyTDK0MxQzYDOYM9QwHDSINPA1PDWgNgg2cDboN1w31DRIOMw5YDngOkA6oDsgO5A78Dg8PKg89D1sPdQ+JD6EPtg/UD+UP9w8LECIQOxBTEGoQhhCgELoQ0BDnEPoQERErEUMRZBF/EZARnxGwEckR7BEEEhsSMBJFEl8SfBKdEroS0hLrEgYTHxM6E1ETaROCE5YTrRPJE+QTARQgFEQUZBR9FKAUtxTWFPQUDxUuFUcVZRWBFZcVrxXJFdcV7RX/FRUWKhZCFlAWYhZ0FogWnxazFsMW2BbqFv0WExcqFzwXTxdoF34XjRejF7gXyhfmF/gXFBgwGEoYXhh7GJMYqBjDGNYY8RgSGSsZSBlnGYYZqRnHGeoZCBomGkQaXhp0Gooamxq1Gssa4Br9GhAbIRs3G00bZBt7G5AbnhuwGw==", + "transitionFaceIndicesBase64": "LQBCAHEAjgCeAP8AAAEIARIBOwFEAUYBSAFOAVQBVQFhAXABgwGVAa8BvgEmAi0AcQCNAJEAkgC6AMcA3gDiAAgBOwFEAUcBSAFNAVUBXgFmAXABdAF6Aa8BsAG+AQICWABwAI0AkQCSAN4A4gAaATsBSAFNAWYBcAF0AXoBhQGxAbsBxQECAiYCDQBxAH4AjQCPAJEA3gD4AP8ACAEaATsBRwFIAVUBYgFwAXQBsAHIAdkB5gHzAQICDQAWAJgAugDeAOgA6QD4AAgBGgE7AUwBVQFiAW0BlAHZAfkBAgIhAn4AjQCPAJgA3gAIARoBRgFIAVUBZgFwAXQBsQG7Ab4BxQH3Af0B/gEmAjUCDQBCAHAAugDeAEYBRwFIAVUBZgFwAXQBvgH4AR8CNQINABYAQgCBALoA4gDpAPgA/wAaAUcBYQGfAaIBsAG8AfcB+AEWAC0AcwCBAI0AkgCYAKQApgDfAOgA7gD4AP8ABQEIARoBOwFVAXoBngGgAawBwAHDAd4BGwI1AhYAQgB/AI8AkgCYAKQApgC6AN8A4QDjAO4ACAEaATMBOwFGAVUBYQF6AaoBrAGxAbsBwAHFAdMB5wHoATUCDQAtAEIAZwBwAI0AjwDeAOIA7gD/AEYBSAFhAWYBbgGbAa8BsAGxAbsBvgH0AQ0ALQBCAH4AfwCNAJgAugDeAO4A+AA7AV4BZgGbAa8BsQG7Ab4BxQHTAfsBLQB+AH8AjQCYAKYA/gD/AAgBGgE7AUgBXgFhAWYBegG7Ab4BAAJ+AH8AkgDiAPgA/gD/ADsBRgFPAVUBXgFhAWkBqwGwAbsBvgHfAfgBLQB+AI0AkgCmALoA6ADpAAABCAEaASQBSAFVAV4BYQFpAXsBqgGrAbABsQHOATUCLQBCAI0AugD8AP8AAAEaAUYBXgFhAWYBcAG7AdkBNQIUABsANABCAHAA/AAIARcBRgFIAV4BYQFiAXABdAGeAa8BsQGzAcUB2QE2AhsAJAAtAI0AugDDAO4A/gD/AAgBGgE7AUgBVQFeAWIBdAF6AZsBrwGxAb4BzwE1AjYCNAB+AI0ApgC6AMMA6QDuAP4A/wAGATsBRgFIAVUBXgFuAZsBsAG7Ab4BvwHFAdAB3wHsATUCJAA0AHAAgQCNALoA6ADuAPwARgFhAWYBqwGsAa8BsAGxAbYBvQHTAfIBGwAkAEIAXwCBAI0ApACmAO4A/AA7AV4BYQFmAXoBqwGsAa8BsAGxAbYBuwHOAc8B3gEfAiQAQgBfAI8ApADDAPUA/gA7AUYBSAFeAWMBbgFyAX4BmwGgAasBsAGxAbIBzgHPAdMB2QH3ARsAJABCAI8AkQDpAO4A9QD2AP4A/wA7AUgBYQFjAWYBfAF+AasBsgHOARsAJAAtADUAjQC7AO4A/AD/AAABOwFGAWYBsQHPARYAGwA0ADUAQgB+AI0ApgDeAO4A+AD8AP4AFwFGAWEBZQFmAbEB0wE1AH4ApgDeAOkA7gD4APwA/gAAARcBVQFmAXAB8wEWABsAQgBnAGwAbgD2APwA/wBGAUgBTwFhAXABsQGyAb4BxQHPAdMB2QHnARsAIQA1AEIAfgCNAPYA+AAXAUYBTwFVAWIBZQF0AbIBuwG+AcQB2AHnAfIBIQIhAC0ANAA1ADgAQgB+AI0ApAD4APwA/gD/AAABSAFLAasBxAHFAeIBLQA0ADUASwB+AJEApACmAN4A9AD1APYA/AD9AP4AAAEXAUgBSwFPAVUBZQFmAXABqwGvAbABsgG7AcUBzgEEABQANQBfAKYAuADuAPQA9QD2APgA/QD+AP8AAAE7AUYBSAFVAWIBZgFwAXQBrwGwAbEBtgG7AcUByAHQAdkB+wEEAA8AFAAtADQANQBfAN4A7gD2APgA/gD/AAABOwFGAUgBTwFVAWQBqwGwAbIBuwHAAc8B5wH2AQ8AIQB+AI0ApAC4AOEA9gD9AP8AHAE7AUQBRgFLAU8BXQFiAWQBdAGrAbIBuwHAAdkB5gEhAC0ANQCNAKQA3gD4AP0ASAFVAXABuwHAAcUB2QHoATQANQBfAGsAjQCmALwA3gD4ABoBHAFEAUYBSAFLAU8BYQFwAYQBrAGwAbIBtgG+AcAB/QEPABIAFAA1AGsAfAB+AI0A9gD+AP8AFwEaAUYBSAFPAVUBYQFkAXABhAHFASgCBAAPABQAFQA0AF8AfAB+AKYAsgDpAPUA9gD/AAgBFwEaAUYBTgFPAWYBcAF0AYQBqgGrAawBsAGxAbIBvgHZAeEB6AEEAA8AFQAWADQAfgCNAKYAuADeAOkA9gD/AAgBGgE7AUYBSAFLAWEBdAGEAY0BrAG7AdkB/gEPABYANAB+AKQApgDeAO4A9QD2AP4AOwFEAUYBSAFLAVUBYQFmAYQBjQGgAawBsAGxAbIB7AEPABYAIQA3AH4AuADuAP8AOwFEAUgBTwFhAWYBaQFwAXQBfgGEAawBsQG7Ab0BDwAhACoAfgCNAJgApAD1APgAAAEaATsBRgFLAWIBZgFwAXQBhAGNAaYBsAGyAbsBAgILABYANAB+AJgAsgC4APYA+AD+AAgBFwEaAUQBTQFiAWQBcAGEAaABpgGxAbsBwwHFAdYBBAALAA8AFAAVACoAfgCBAKYAsgC4APUA+AAAAQgBFwFEAUYBSwFVAWEBYgFmAXABhAGwAbEBxQEEAAsADwAUABUAFgAhAEIAgQCNAKYA9gD4AP4ARAFIAU0BTwFhAWIBZAF0AaYBsQGyAbsBBAAWACEAKgBCAI0A9gAaAUQBSAFNAU8BYQFiAWQBZgFwAYQBrAG7AcUB8wELABYAIQCOAEQBRgFIAVUBYQFmAXABfAGEAbEBuwHVAe4BBAAPAEEAfgCNABoBRAFGAUgBSwFNAU8BYgFkAWYBjQGvAbIBuwH2AQQADwAWACwANQBBAEIAgQCCAKQAsAD2AP8ACAEaAU8BYgFwAYQBjQGfAaIBuwHFAdkB2gEDAhsCAgAHABYAKgAsADUAQgB+AIIApACmAKcA9QAAAUYBTQFPAWQBZgGEAbABsgG7Ab4B2gEEAAsADwAVACwAfgCNAKYA9QD2AP8AAAEXAU8BZgF1AYQBjQGiAbAB1QHYARoCBAALABUALACNAKQApQD2APwA/gD/ABcBRAFGAUgBTwFwAYQBjwGyAb4BvwEAAg8AIQAzAEoASwBUAH4AjQCkAKYA3gD8AP4A/wAAAQgBGgEbATsBRAFIAUsBTQFhAWQBcAGFAbABsgHeAeQBAgAhACoAQABKAEsAVADeAPQAAAEIARoBRAFIAU0BTwFVAWYBdgGNAeIBAgAWACoAQABLAKYAuAD0APUA/wAaARsBRAFIAUsBVQFhAWYBcAGFAbABsgHSAfIBAgAEAAsAFgAhACwASwCNAKUAsACyAPYA/wBLAU0BYQFkAXoBjQGiAa8BsAGyAb8B0gECAAQAIQAsAF8AfgCNAKUAsACyAPUAGgFLAU0BVQFhAWQBrwHWAd8BAgALACEAQABKAEsAVABfAH4AuAD1AP4AGgE7AUsBTQFPAWEBcAGEAY0BsAG/AeEB6AECAAcAFgAhAEIASgBUAKYAuAD+AP8AAAEaATsBSAFPAVUBZAFwAYQBmwGwAdIB3AHdAeMBBAAHAEAAQgB+AKQAuADiAOkA9QD/AAgBGgEbAUgBTwFhAXYBkQG+AdIB3AECACEAfgCNAKQApgCnALcAuADiAO4A+AD/ABsBNQE3ATwBRQF2Ab4B0gHcAQQACwAWACwAQABfAH4ApwDpAO4A+AD+AAABOwE8AU0BTwFkAWYB0gHcARwCAgALACwAQABfAH4AjQCmAOIANQE7AU0BYQFkAWYBcAGEAY0BsAG+AQIAFABAAEsAjQCOAJgApACmAKcAuAD1AP4AAAEaATsBPAFPAVUBYQFwAXYBhAGNAaYBqwGwAb4B2AECAAQAFABAAEIASwB+AKQApwCwALcA4QD1AP8APAFNAVUBZAFmAXYBsAGyAdkBGgIoAgQAQgCNAJgApAClAOEA4gDuAPUA9gD4AAABGgE8AU8BVQFmAXYBhAGyAe4B9QEfAicCBABAAF4AjQCkAKUApwDhAOMA7gD1APYA/gAaATsBPAFNAWEBZgFpAXoBhAFHAgQAQABCAF4AXwClAKcAuwDhAO4A9QD+AAkBGgE7ATwBVQFkAWkBhAGqAb0BJwICAEIAfgC3ALgAuQDuAPIA9AD1APYA+AD/AAABCQEaAU8BZgFpAXABewF+Aa8BuwH4AScCAQACACEAQAB9AH4AjQCYAKUAuADpAPQAAAE8AWQBZgFpAXABgwGiAbsBvgHEAeMBAQAhACwAQACNAKQAAAEWARoBPAFPAVUBZAFmAWkBbwFwAa8BxAEBAgEAAgALACwAQQBeAF8AewCNAJgApAClALgA7gDwAPYA+AA8AUQBSAFhAW8BdQF6Ab4B2QHyAScCAgALABgAQQBeAH4A9gAAAQgBGgE8AUQBVQFkAYMBsAHdAfUBJwJKAn4AmACkAKUAuAC7AOkA8QD4AP0ACAFIAVUBYQFkAWYBcAGrAQECAgIDAkoCBwALADAAQQBfAJgApAC7AO4A9gD4APwA/gAaASoBSAFVAWkBawGDAasBrwGwAb0BvwEHACMAQABBAFYAjQCkALgA3gDtAPEA9gD4ACoBPAFTAWEBZgFrAbABvwHYAScCBAALACMAQABBAFYAXgCNAJgApACmALgA3gDjAPYA+AD+AP8ARAFVAWEBcAG+Ad8B5AEEAEsAVgB+AIEAjQClAKYAuADjAPgA/gD/AEQBSAFbAWEBaQF6AScCQQBLAFYAewB+AJgApAClALgAuwD2AP4A/wAaASoBPAFEAUgBTwFVAVsBYQFmAXABsAG+AdMB9AH5AScCTABdAIIAkQCkAP8AKgFEAUgBTwFbAWEBogG2AcAB0wEnAgcAQQBzAH0AjQCRAKcA6QAIATwBWwFhAWUBZgFwAbABtgHAAd4B8wH3AQcAQQBMAI0ApACmAKcA6QDtAPYAAAEIAWEBZQFwAXoBJwJCAEsAewCCAI0AkQCkAMcA3gDtAPYAAAEhATwBTQFbAWIBZgFrAXABdAF6AZQBogGvARAAQQBLAHMAjQCRAKQAuADeAO0A9gAFARoBOwE8AUgBTQFhAWIBZAFmAWkBawFwAXQBegGvARAAQQBzAJgApAClALgA3gDwAAUBGgE8AUgBZQFyAY0BNgJCAHsAjQCYAKQApgCnAN4A6ADtAPIA9gD/ABoBRAFbAWIBZAFlAWYBawGNAZEBpgGwATYCQgBMAI0AjgCkAOgA7QD/AAQBGgEbASoBOwE8AUQBTQFPAWEBZQFmAWkBcAGPAaYBsAEnAkEAQgBMAIIApgD/ACoBRAFIAU0BYgFkAWYBawFwAbABvAEnAoIA3gD2APgACAEQARsBKgE8AUQBSAFNAU8BYgFkAWUBegGNAbABJwIEABgAmADeAOMA+AD/AAwBEAEqATcBPAFEAUgBTQFhAWUBZgFtAXoBrwEnAo0AmACmAMgA3gDjAOkA9gD4AEQBSAFNAU8BYQFiAWUBZgFpAXABegGNAa8BsAGxAbsBBAAwADwAPgDeAOkA9gD4AP8AGwE7ATwBTwFwAXoBqgGvAbsBxQEkAD0AQQBzAIgAmADuABsBOwFNAWQBZgFpAX8BjQGPAZEBrwGwAbEBwAHFAc4BJwIHACQAQQCIAJgApADeAO4A/wAbATsBPAFEAUgBXQFkAWUBZgF1AXoBjQGwAbsBvgHAAeYBJwIHAD4AQQCkAN4A8AD1AP8ARwFMAU0BTwFbAWEBbAF1AbABsQG+AcUB2QEQAD0APgBBAI0AkQCmAL8A6QDyAPgAFQEbASEBRQFIAUsBTwFbAWEBZQFmAXABdQF6AaYBsAH5Af0BJwIQAD0AQQCIAJEAuADeAOkA+AD/AAUBWwFkAWUBcAGmAdkB/gEoAgcAPQA+AFgAewCBAKYAuAC5AMMA3gDjAOkAGwFLAVsBYQFkAXABdAF1AasBsAGxAbsBvgHFAScCKQIEAAcAggCDAI0AmAC3AN4A4wDpAO0A9gD4ABsBVQFhAXQBmwGwAbEBuwG+AfgBBACCAJgA3gDpAPgA/wAbAT8BRgFLAVMBZQFrAWwBcAGNAaYBsQG7AUEAQgCNAJgApACmAO0A+AD/ACQBRQFkAWsBbAF1AYQBjQGmAUIAmACkAKYA7QDuAPUA+AD/ABsBRQFlAWYBdAF6AYMBxQHZARsCRwKCAKYA6QDuAP8AGwFhAWUBZgF0AXUBegHFAUEASABKAIIAjQCmAO4A9gAbAUUBZAFlAXIBdAF+AdkBBABCAEkASgCIAO4AEgEbAUUBSgFmAXQBdQF6AYMBnwGxAbsBxAHFAQYAPwBCAEoAjQCkAKYA/wASAWIBaQFrAXQBegG7AcQBxQFFAEoApADjAPcA+ABEAUUBcAF6AYQBpgGwAbEBHQIEAD0AQQBCAEoAmADDAOMA7gD4AP8AGwElAUQBRQFlAWYBaQFwAXoBgQGEAa8BsAH0ASACBABBAIIAmACmAMMA7gD/AEoBggG+AcMBxAEEAC0AggCNAJgApgCyAOMABwEdASMBRQFKAWQBdQGhAaYBsAGxAbsBvAG+AcMBxAHFAe4BLQBMAH0ApgDjAEUBdQGhAaIBrwGwAcMBQgBMAKYABAFEAUUBRgFUAWQBoQGwAbsBxQEcAh4CLQBCAFMAjQCYAKYA6ADpAP8AZAFpAWoBdQGmAbABsQHDAcUBIQBBAEkASgBLAFMAjgCYAKQAwADTAOgA6QBFAWQBZgFqAWwBdQF6AaEBpgGvAbsBxQEfAi0AQQBCAH4AwADoAOkA/ABEAUYBZgFqAWwBdQGhAaYBuwH9ASEASwBMAOgA6QD3AP0A/wAFARcBZgFqAXUBjQGmAbABuwHFAQIALQBCAEsATABTAJgApAD9ABMBFwFFAWIBZgFpAWsBdAF6AbEBuwHZAQEAAgAtAD8ASwBTAJgAsgDAAP8ANAFhAWkBagGNAbABsQHZAeYBBACNAI4ApgDAAOgA6QD9AEQBRgFpAWoBdQGfAaYBuwHFAc4B1AHzARsAPgBBAEsAXQBeAF8AcACNAI4AlACYAKYA3gDuAP0A/wA8AUQBRQFpAWoBdQGDAaEBpgG7Ab4BwAHEAcUB1AEPACwAQgBEAF4AcACDAMMA3gD/ACkBPAFFAU8BaQFqAaEBsQG7Ab4BwAHEAcUB1AEoAgsAGQAjACwAQgBMAE0AUwBeAIEAmADiAO4A9gD4AP0A/wApAUYBTwFpAasBsQG7AcAB1AEHAAsAGQAsAEMARABMAE0AUwBYAH4AjQCYAOIA6ADpAPgAFwFEAUUBYgFpAZsBrAHAAQcAGQAhACwALQAvAEEAfgCNAJgA3gDuAPUABwEpAUUBRgFiAX4BlQGWAZcBrAHUAegBAgALABkAIQAtAC8AQQBDAH4AjQDZAN4A7gD1ADABSAGDAYUBsAHUAR8CAgALABoAGwAjAC8AQQBEAMMA3gDuAP0ARAFPAWwBjQGXARoAKgAtAEQATAB8AJgA3gDrAO4A9QD9AE8BbAGNAZcBoQGqAboBvgHJAfgBHQIhAgIAFgAZABoAKgAvAEwAUABeAF8AfAB9AH4AmADrAO0A7gD2AGIBdAGBAY0BqwG+AcIB/gEdAiACFgAaACMARQBGAF4AewB9AJEAsgDTAO4AFAEqAUYBYgF0AasBygHeAQACAgALABUAKgAsAEIARgBeAHsAfgDeAOkA6wD9AEUBRwFIAU4BTwGNAasBCwAVABYALAAwAD0APgBCAEYAUABeAF8AfgCIAI4AkQCYAKYA3gDpAO4A9QD9ABEBQAFGAU4BTwGXAdEB2wECABUAFgAqACwAQQBCAEYAcAB+AIgAjgCUAK8A4wDuAPQAGAFGAUgBTwGXAasB0QHUAdsB5AECABYALABCAGsAcAB9AIgA4wDrAPAA8gDzAPQARQFPAWsBdQGDAdQB7QH4ARUAFgAqAHMAgQCCALIA3gDrAO4A9AD3ADABawF1AYIBgwGvAQsAFQAWADwAQgBwAHMAfgCBAIIAmAC7AN4A/QARARgBfgGCAZUBlgGXAZ8BvgEfAikCFQAWACoAXwBvAHMAmACyAN4A7gD9AEYBTgFPAWIBfgEeAgsAXwB0AHsAkgCdAK4AsAC6ALwA3gDoAOkA/QAjAUUBSAFOAXQBugG+AR0CLABAAEgAcQB0AHsAkQCdAP0AEwEbASUBQAFFAUgBTwFiAXQBsAG6AQIABgAhACwAcQB7AO4A8wD0AEABSwFMAWIBawF0AYEBqwGwAfcB/AEGACAAcQB0AHsAfgCnAL8A6wDuAAEBJAFEAUgBTgFPAWIBawFsAW0BdAF6AaoBqwHFAe4BGwIhACwASwBrAHEAdAB7AJgA6QD9AE4BcAF7AaIBqgHFAccB9QEAAiECAgAgACwAiACYAOgA/QABARsBPwFIAU8BZgFwAYcBogHHAdgBJgICAFYAcgB0AH4AmADZAN4A6wAbAUgBSgFPAVUBZAFmAYcBlAGqAdEB2QH7ASYCIAAhAEIAdACCAIgA2gDeAOMACAEWARoBSAFVAWQBZgFoAWoBawF0AaoBuQG/AcoB7QEmAiAAIQBCAF4AdACCAOsACQEWARoBGwEqAU8BVQFmAWoBawF0AYIBvwHFAdEB+wEmAgIAIAAhAF8AdADuAPUA/wAIAQkBFAEXAU4BVQFkAXABhwHFAfgBIAAhAF8A9QD4AAkBTgFPAWYBcAGHAasBxQHTAV0AXwByAKIA7gD4APwACQEVAU8BZgHCAdMBBQIwAgcAcgCYAKIA6wDuAP0A/gD/AAkBGgFVAYsBjgHCAfQB/AEwAgEABwALAGsAdACCAJEA3gDhAOsA7gD3AP0ACAEJARABEwEaASoBOgE/AVUBYgF0Ab4B5AEGAgcACwBVAIIAkQCSAJ8A6wD8AP4AFQEaASkBKgE6AU8BYgFlAWYBawF0AYcBiwGrAb4BxwECAAcACwBrAJEApgDeAPQA/wAQARYBGgE6AU8BZgFrAYcBvgHHAQIACwAhADwAiACPAJgA9AD+AP8AFgEaAWIBZAFmAWkBagFwAXQBdQF+AYsBuQG+AckBFgAhADMAcQCRAJ8A4gDrAP4A/wAIARoBHQFGAUgBYgFkAWYBaQFwAXQBiwGOAbkBxQHTARYANQBQAGwAfwCPAJIAmADAAOIA6wD4APwA/wAIAQwBSAFdAWUBaQFqAXUBhwGyAb8BHAIUABYAQgBGAFAAbgBwAHEAfwCRAJIAmADAAPYA/AD/AAgBEAESAUgBTwFkAWUBagGHAb8BwgHHAdMB+QEGABYAQgBQAF0AcACRAJgAngDuAPUA+AD9AAgBEAERARIBFgFIAU8BUQFkAWUBagF0AbIBxwHTAegBMAA+AEYAVQBeAF8AbABwAJEAmADTAO4A+AD8AP0A/wAAATQBSAFiAWQBZQFqAXQBuQG+Ab8B0wHfASgCIQAvAEIARgBSAF4AbwBwAHIAyAD1APgA/AAAARoBIgEwATgBPQFRAWIBZgF0AXUBuQG+Ab8B3gEfAiEAJAAqAC8AXgCUAJgApQCuAOIA6ADpAOsA9QD4AP0ABwERARoBOQE7AT0BRgFNAWUBZgFwAXQBuQG7Ab0B3wHzARAAHwAhACUAUABSAHAAcQB9AJEAmACjALAAugDZAOIA6QDrAO4A9QD2APgA/AAHARoBOQE7AUYBSAFhAWIBZAFlAXABsgG7ASECGgBGAFAAbgBxAHQAewB+AIcAkQCkAKUArwC6AOgA7gD9AP8ACAEQARoBKgE5AU8BYQFiAWoBcAFyAYMB1QEhAhoAJQArAEYAUACaAKQA9gD4AP8ACAEQAREBKgE7AT0BTgFkAWgBagFwAXUBgwGyARoAJQA+AEgAUABeAHMAmACeAKcA7gD2APgA/AD9ABEBSAFhAWYBcAF1AaIBpgG7AREAJQBeAF8AcABzAH4AhwCOAJEAmACcAJ4A3gDuAPIA9gAIARoBIQE8AT0BYQFiAWQBZgFwAXwBpgGwAbsB1gEaABsAJQArADwARgBIAFAAXABfAHAAcwCRAMAA3gDhAOMA8gD9AAgBPAFIAWIBZAFmAXwBsAEnAhoAJQA8AD0APgBBAFoAXgBfAGoAwgDHAN4A7gD2APgA/wARAT0BZgF8AbAB1wEnAl4AXwB+AJgAqQC7AN4A6ADpAP0A/wAIAQkBPAFmAXQBdQGwAScCGgAdACUAVgBaAGgAfgCYAKYAtgDeAOgA6QDuAPYA/AD9ABEBPAFIAU8BZgF0AXUBgwGfAScCBwBCAF4AcAB+ALYAuwDeAPYABwEIARwBRQFPAWIBZgEnAigCNQIHABoALQA6AEIAXgBwAJgApgC6ALsA8wD2APgA/wAHAQgBCQERAR4BQAFFAUgBYgFlAWYBuwG+AScCNQIAAAcADQARABkALQBIAFYAXACOAJEAmACmALoA8wD2APgA/QD/AEsBZQGwAbsBvgEnAkcCDQA+AEEAXgBfAKYAtgD4AP0A/wAIAREBOwFGAUgBZQG6Ab4BwAEnAhoAPgBBAEgAVgBeAF8AagCmAP8ACAESATsBRAFFAUYBSAFlAX4BjQGvAbABvgHAAQcAGgBIAHAAjgCRALYAwgDzAAgBEQESAUUBdAGHAaoBqwGvAcQBxwEvAiQAJQAvAD4AUwBwAH4AjgCRAKYAuwDAANkA9gD4AAcBCAE7AUQBRQFUAWkBdAGHAaYBqwG7AcQBxwEwAiUAPgBTAJEApgCsALoAEgE7AUYBSAFqAX8BjQGmAbsBMAIaAC0AQgBTAGoAfgCwANIA+AD/AAcBRAFIAWUBbAF1AY0BvAESABkAJQAtAEIAUwCmALoA6ADpAPYABwERARIBHgE9AUQBRgGmAScCBwAZAEUASABJAHAAegDzAP0A/wAHAQgBFwE1AUYBVQF1AaYBsAG+Ac8BBgInAgcALwBIAHAAhwCcAOgA6QD/AAgBEAEXAT0BRAFHAUsBTQFVAWQBjQG+AeAB+QEnAkYCGQAhAEAASABMAGgAhwC6AOsA/QAEAQYBCAEQAREBGgE9AUQBRwFIAVUBbAGNAaYBBwANACYAcwCeAK8AvADnAOgA6QDrAP0A/wAaASIBRAFGAUgBVQFsAZ8BpgEnAgcAEAAmAEQASABTAFYAewCHAK4A5wDoAOkA8wAIAREBEgEaASQBPQFNAVUBYgFyAbABsQHiAScCGgBCAE4AVgBzAHsA/QD/AAcBCAESARcBMAE4ATkBOgE9AU0BYgGBAY0BsAGxAc8B1AHhAQcADQBCAEQASABMAE4AcwCHAKIA+AD/AAUBEgFEAUYBSAFNAVUBXQFiAa8BsQHFAdQBBQIaACYASABTAHMAogDhAOMA6QDuAP8ANQFIAWwBjQGvAbABsQG7AcUBzwE2AgcASABTAHAAcwDpAO4A9gD4AAcBFwE7AUQBVQFiAWwBjQGwAbEBuwHFASkCMQIaAGoA4QAIAQwBFwElAUYBSAFNAVUBYQFkAXQBxQHPAScCMQI1AhoAJwA+AE4AcQDpAO4A8wD4APwA/wAMARIBRgFIAU0BVQFYAaIBsQHPAfQBJwI+AEgATABOAFgAcABxAH0AgQCHAJ4A6QDuAPgACAEaAU0BTgFYAWEBdAF1AY0BuwG+AScCBwAaACYATABOAFgAcABxAKIA2gDuAAgBGgFIAUsBTQFYAXQBjQGSAaYBrAG7Ab4BGgAfACYAPQA+AEwATgBxAIcAnACiAPYA+AAXARoBPAFIAUsBTQFYAWEBZAFmAWkBdAF1AY0BoQGmAawBrwGwAScCBwAxAD4ASABMAE4AcQD2ABcBGgEbARwBNQFIAU4BVQFhAWIBZAF0AXUBoQGwAb4B3gHzAScCAAA8AD4ASQBOAHEAgQAbAT8BSAFOAWIBaQF0AYUBuwG+AQ0ASABYAG8AcQA8AUYBSAFVAY0BpgGwAbEBuwEqAg0AKgA+AE4AWAB+ABcBNwFLAVgBZAGmAasBsAGxAb8BKgImAD4ATgBWAGUAcQB+ALwAFwEhAUYBSAFLAWEBYgFpAWwBjQGrAbsBvwHkAR0CKgIvAgcAJgAnAD8AUQBTAFYAWABeAF8AbgBvAHoArgCvALsA/gATAUYBSwFPAVgBYQFiAWwBdQGwAbEBugG7Ab4BzwEgAioCRgIkADoAQABIAFEAVgBYAF4AXwBvALsA7AD+ABsBRgFVAWkBbAF1AasBsAGxAb4BzwEHACQAJgA6AD8ASABWAF4AbwCPAJQAmgCdALsAFwEbAUgBbAGNAasBsAG+AR4CGgAmADoAPwBAAI0AnQDpABcBNwFIAVgBYQFpAXYBggGqAb4B/gEeAioCBwBIAF4AbwBxAKwAsADpAOsA/gAcAUQBRgFIAVgBYQFkAWwBzwEbAioCBwAZACYAOgBIAF4AXwCYAKMAsAC6AOkA/gAlAUYBSAFkAWwBjQGhAbEBuwG+Ac8B3wEqAgcAGgA6AD8AXgBfAHEAewCNAI8AsADIAOkA6wAXASUBYQFkAWkBogGsAa8BsAGxAbYBwAHPASoCRwIHABkAHQAfAD8ATgBoAG8AcQBzALAAuwDSAOkA6wDsABcBGgFIAWEBZAFpAWwBjQGSAaABogGoAawBtgG7Ab4BwAEHABkAGgAxAD8AQwBOAHAAcQCNAJwAsADTAOwA/QAXARoBJQFIAVgBWwFkAWwBeQGiAbEBvAG+Ad0BGgAfAEMASABwAI4AjwC7AOkA9QD9ADMBVQFYAWkBbAGCAZEBoAGiAbEBvgHDAR0CGwAfAD8AQwBOAF4AcABzAKcA7AD1AAgBMwFIAVUBZgFpAYIBjQGlAbABuwG+AQACHQIHABoALQBAAE0ATgBYAF4AjwC7APMA9AD1AAgBFQE8AUgBVQFkAWkBoAG7Ab4BxQHjAfIBKgIHABkAHwArAD8AQwBIAE0AcwCPALAAuwDgAPUA/gAAATcBSAFVAWQBdQGwAbsBxQEqAg0AGQAaAB8AKgArAEQAXgBfAHMAfgCOAI8AsAC7AMgA4AD/AAABSwFiAWQBcgF1AakBuwEdAg0APwBAAEQASABeAF8AcwB8AH4AjQCkALAA9QD+AP8ARQFiAWQBaQHjAfIBHQIaAD8AQwBIAFYAcABzAHoAfACPAKQApQC7AOEASAFkAWkBmQGoAbkBuwG+AdIB8gEHABoAPwBEAE4AbABwAHwAjwClAKcAugD+ADsBPAFIAU0BcAGEAagBvgHSAegB9gH4AS0APwBEAE4AVgBeAF8AcwB8AJEAsAC6AOAA6ADpAHABhAGYAbkBuwEaAB8AKgAtAF4AXwCNAI8AkQDeAOAA6ADpAPYA/gAAARoBSAFNAWkBcAGEAeMBBwANABoAHwAqAC0ATgBVAFYAfAB+AI0AjwCYAKMA3gDoAOkA9AD1ABsBJAE7AUgBYQFwAXIBpAENAB8ALQA6AEAASABOAFYAfACPAJEAowC6AOgA6QDzAPgAGwE7ATwBSAFhAWIBdQHjAR0CKAIHAB8ALAA6AFUAXgB/AIEAggCPAJEAkgCjALoA7gD2ABoBSAFiAXIBdQGEAagBuQG7AeMB7AEdAjYCBwAaACwALQBAAEgATgBVAFYAXwCBAIIAkgC6AN4A9AD+AAABGwE7AUgBZQFwAXUBhAGoAbIBuQG7Ab4BHwIHACEALQA/AEgATgBWAF8AYwB8AJAAkQCSALgAugDDAPQA9gD4AP4ADAEXARsBPwFkAXABdQF2AX0BhAGyAbkBvgHZAdoBNQIHAB8AIQAtAD8ASABOAF4AfACBAIcAkgC5AMQA4QDjAPYA+AAAARcBGwEpAUgBTgFPAWYBoAG5AdoB7gH1AR4CGgAfACMAKgAsAEgATgB8ALkAugDeAO4A/gA8AUgBTQFiAWQBZgF1AaABsAGyAbkBvgENAA8AGgAfACEALABOAHwAhwCjAKYAwADuAPUA/AD+ABcBGgEbATsBPAE/AUgBTQFUAVYBYgFkAWoBpwGyAb4BvwHXARwCDQAhAC0AQwBOAIIAhwCPAJAAmwCjAKQAuQDAAN4A9QD8AAABFwEbATsBSAF5ARoALQA6AEIAXgBfAIIAhwCOAJEAmwCeAKQApgC2AMAAwgDeAPUA/gD/AAQBDAEfAT8BVgFiAWYBbgGoAbsBGgA6AE0AXgBfAIcAjgCRAJgAtgC5AN4A9QD2AP4ACAEWARsBHAEfAT8BVgFbAWEBZQFmAW4BgQGoAbsBCwAaACwAPwBCAE0AggCOAI8AkQCYALYAuQDeAOgA6QD2AAgBCQEqAUYBSAFWAVsBYQFmAbsBCwAaACEALABCAE0ATgBeAGMAZQB8AIEAggCHAI4AmwCjAKYAtgC3ALgAuQDeAOMA6ADpACQBVgFmAXUBuwEEAAcACwAaACEALAAtAD8ATQBOAF4AXwB8AI8AowCkALcAuADeAOMA6ADpAPYASAFbAQcACwAWABoALABCAE0ATgBVAGwAbgB8AIcAjgCYAJsAowCkAPYAGwE3AUgBYgFkAXIBsgHAAfYB9wH/AQEABAAHABYAGgAtADoAPwBCAE4AXwB8AI4AjwCkAOgA6QD/AAkBRQFGAUgBYgFkAWoBsgG7AcABAQACADoATQCCAIcAjwCYAKMAuADEAMgA/wAJARoBRAFGAUgBZAFmAbsB5gECAAQABwANABoALQA/AEIATQB8AIIA3gDoAOkA7gD4AP8AFwEbAR0BZAFqAYcBuwEEAA0AGgBNAIIAiQCPAJgAowCkAKYAwwDeAOgA6QDuAPgA/gAIARcBQgFIAWoBhwGPAbsBBAA/AEgAggCPAO4A+AD/AAgBCQFIAWoBhAGzAQEABwAWAD8AQgBIAHwAjgCPAKkAtgDoAOkA7gD+AP8AGwFLAWoBhAGFAbsBBAAHABYAGgCJAI4AjwCYAKYAsAC8AOgA6QD4AP8AGgEbAWoBBABNAGUAggCOAJgAmwCmALAAtgDCAN4A7ADuABcBRgFNAVYBoAG7AcQB7QEEABoALQBNAGUAggCOAI8AmADAAO4ADQE1AUYBSAFkAWoBcAGgAbIBvAECAAQAGgA/AGgAgQCwALsA3gDsABcBGwE7AUgBVgFkAWoBcAF2AYIBsgG+Ac0B5wECAD8AQgCYALsA+AA7AUgBVgFqAacBvgHNAfsBAgAWAC0AgQCYAJsAnwC7AOwAFQEbAUgBVgFkAWoBfQG/Ac0BDQAWABoAXwCCAI8AsADaAN4A+AD/ABoBXQFkAWwBqAG+Ac0BBAANAD8AQgBfAHMAuwDeAOEA4wDpAP4ACwEqAUQBagF+AbsBvgHAASEAPwBNAHQAggCPAJgAsAC7APgA/gD/ABMBOwFEAUUBRgFIAXABggG7AcABzQEhAEIAjwCRAJgAuwDpAPgA/wAXATsBRQFGAUgBSwFYAWIBZgFwAYEBBwAPABoAQgCPAJEAuwDuAP8AFwElAVgBZgGCAc0B7QEaAB8AKgBCAF8AcgB6AIEAiACYAN4A7gD+AEgBWAFbAXABwgHEAdUB7QEHABoAIwAsAE0AXwCYAKYAsADeAP4A/wAjAUUBRgFIAWEBfAECABkALAAtAEIAgQCCAKYAsAD/ABYBKQE7AUQBSAFYAXABuwHWAQcAGQAsAC0AXwCPAJgA4QD4AP8AAAFEAUgBVQFWAVgBYQFmAXABuwHYAeEBBwAPACwAXwB/AIIAmACwAMcA+AD/AAABCwFIAUwBTgFWAVgBZgF4AYQBvgHgAQsAGQAjACwAPwBCAE0AggCPAKYA3gD/AAsBVgFkAakBvgHiAQcACwAZACQALABAAE0AggAAATwBSAFLAU4BVQFkAWYBmQGpAecBAgAkAC0APwBAAEIATQBwAH4AjwDeAPgAAAFIAUsBTgFhAWMBZAFlAXABhAGZAb4B5wEtAEIAcAByAI8ApgC7AN4A4wDuAPUAOgE7AUUBSAFMAWEBdgGEAZ4BvgHDAQIABwAaADcAiACYAPUAMwE6AUUBVQFpAXABmQGbAQcADAAtAEEATQCIAJgA3gDuADoBOwFEAUgBSwFVAVgBZgFpAXABhAGpAe0BAgAHAAwALQBBAEIAmAAAATsBRAFGAUgBTQFPAVQBYAFpAWwBpQGrAboBAgBAAEIATQCPAKYA3gAAAUYBTQFYAWMBZAFmAWkBqgG+Ac4BDQAtAEAAQQBCAGwAbgB0AI8AmADaAN4A3wDuAPUA+AATAToBRgFIAU0BVQFlAXIBgwGYAaABvgENABoAcACBAIIApgCvAN8A9QD4AAsBOgFNAVUBYwFlAWkBoAEtAEAATQBwAIIAkQCyAN4A7gD1APgAAAEVAUUBSAFLAU0BUQFVAVgBYQFmAWkBbAFwAYQBpgG8AQIADQAkAC0AQABBAEIAiACRAJgA3gDuAPQAAAELATsBPAFEAUUBRgFIAWEBYwFpAXABhAGmAb0BBwANAC0AgQCIAJgApgD0APUAAAEXATsBPAFEAUYBSAFNAVEBVQFYAWEBZAFmAWkBkgGmAQIAJABwAO4A9QD2APgA/QD+AAABSAFLAU0BTgFRAVUBYQFkAWYByAECAAcAGgBwAI8AmACmAMIA3gDiAOwA9QD4AP0A/gAAAToBOwE8AUQBRgFLAU4BVQFYAWYBdQF6AaIBQQBCAHAAjwCYAKYAwgDiAPgA/QAAAQsBIwE6AUgBSwFYAWABYQF7AZQBoQGiAaYBDQA4AEEAQgBNAJgA3gDsAO4A9wD9AAsBJQFRAWEBYwFkAWYBegGEAZIBGgAtAEIAcACYAKYA7gD4AAABOwFLAU0BUQFhAWMBZAFmAW0BcAF6AaEBpAHUAd4B3wHkAfIBIwAtAEAAQgBIAE0AAAFVAWQBZgFrAXABegGEAaABogGsAcMB1AEMABoAIwAtAEAASgCBAIgApgDiAOwA7gD4ADsBRAFIAU0BYgFkAWYBawF6AaABogGmAawBuQEMACMALQBKAIEAiACmALIA4gDpAPgA/QD+AAABCwE6AT0BRAFFAUgBSwFNAVEBYQFiAWMBZAFwAYQBogGmAawB5wEjAJgAmwC7AOwA7gD9AP4AAAE6AVEBXQFhAWIBYwFkAXoBhAGhAawBrwHEAecB6AHuAS4AQgCOAJEAmACbAKYAuwDeAO4A+AD+AAsBGgE6ATwBPQFRAWEBYgFmAZsBoQGvAcIB6AHzAR4CIAIaAB8AIwBAAEIATQBwAI4AkQC7AN4A6QDuAPQA+AAaARsBOgE7ATwBTQFVAWEBYgFkAXABegGbAa8BvgEdAhYAIwAqAC0ALgBAAEEASQBwAIEApgDuAPQA+AAbATsBRQFNAVUBYQFiAWYBcAF0AZsBoQGiAaYBrwG+AeMBDAAjAC0AQQBIAEkATQBwAIEAiACOAN4A6wDuAPgA/gAAARsBOgFFAUsBTAFNAVUBYgFmAWsBcAF0AXgBegGEAZ4BoQGvARYAIwBAAE0AcAB/AJIAmACbAKYA3gDrAO4AAAEbAR0BPAE9AUsBVQFhAWIBZAFmAWsBdAGEAa8B3QH0AQwAFgAjACoALQBAAEIATQBzAH8AiACPAJEAkgCYAJsA6QD1APgA/gAbATsBSAFLAU0BVQFcAWwBdAGfAaABpgGvAbsByAEaACMAKgA3AHMAgQCPAJEAkgCYAKYA4gDsAO4A+AAJARsBOwFEAUgBTQFhAWIBZAFpAW0BfgGEAaYBuwEjADcAOABCAHAAgQCSAJgA3gDiAOgA6wDsAPUA/gAaATsBSAFLAVgBXgFiAWYBcAF0AaEBqwGvAfkBKQIaACMALQCmAN4A6ADpAOsA/AD9AP8AAAEJATwBRgFKAVUBWAFbAWIBZAFmAWsBcAF0AXoBhAGmAb4B/QEaAHEAiACRAJgApgDrAPwA/QAAAQgBEgEaAUIBRgFIAVwBYgFkAWsBhAGmAa8BvgHZAf8BDQAWADgAcACmAOIA6wD1AAsBGgFGAUcBSwFcAWQBZgF6AYQBpgGvAdkB5wEaAC0ARQBwAHEAkQCeAKYAsADiAPUA+AD/AAsBDQE8AUYBRwFKAWIB5wH7ARYALQBCAE0AjwCRAJgA+AAIAQ0BRwFVAVwBXgFiAWYBlgFNAHAAjwCRAJgAsADeAOIA7AD1AAgBGgFHAUgBVQFcAV4BYgF6AYQBlgGwAbwBvgHnAegBBwANABUAGgBfAHEAsADeAOIA7ADuAPUAGgFGAV4BYgFuAXABdAG+AcQB7AENABUAGgBCAF8AcACYAOIA7AD1APgA/AD/ABoBVAFVAV4BYQFiAXQBsAEHAA0AGgBCAF8AcABxAJEAmACmAOIA7ADuAPUA/AD/AAgBCwFIAV4BYgFmAXABdAF6AYQBsAG+AeYBBwANAF8AfgCRAJgAsADiAOgA7ADuAPwACAFGAVwBYgFmAdkB/QEHAH4AmACmAOkA+AD8AP8ACAENAUYBZgFzAZ4BvgHZAf4BDQBCAHAAkQCmAN4A7gD4AP8ACAEaAUgBVQFhAWQBZgGEAaoBsQG7AcUB2QENACoALQBCAH4AfwCmALwA3gD1APgAGgFGAUcBVQFhAWIBcAFzAYQBngGxASoALQB+AH8AugDeAPUAAAEIAT0BSAFVAWEBZAF0AXoBhAGeAa8BuwHFAdkBGwI4AEIAcQCPAJEAmADIAN4A/ABGAUcBSAFVAV4BcAF0AX4BhAGeAa8BwwEAAigCQgBwAH8AjwCvAOgA6QD4APwAAAE7AUYBRwFNAV4BZgFtAXoBfgGEAZ4BQgBwAHEAnwCmAOIA/wA7AUQBRgFIAV4BZgH4ATgAcQB/AJEAmACmAOIA9wD4AAABRwFIAVUBYQF6AZUBngEmAg==", + "provenance": { + "method": "chrome-solid-face-id-exact-interior-pixel-census", + "route": "http://127.0.0.1:4325/cityflow/", + "presentation": "schema-20-folded-cosine-eased-extrema-60hz" + } +} diff --git a/src/adapters/cityflow/notes/references/prepared-visibility-global-motion-uniformized.json b/src/adapters/cityflow/notes/references/prepared-visibility-global-motion-uniformized.json new file mode 100644 index 0000000..88e1ab0 --- /dev/null +++ b/src/adapters/cityflow/notes/references/prepared-visibility-global-motion-uniformized.json @@ -0,0 +1,482 @@ +{ + "schema": "csscityflow-browser-visible-face-id-source@1", + "encoding": "initial-bitset-plus-u16le-per-target-frame-toggle-offsets-and-face-indices", + "browser": { + "name": "Google Chrome", + "version": "152.0.7977.65", + "headless": true + }, + "viewport": { + "width": 1280, + "height": 720, + "deviceScaleFactor": 1 + }, + "frameCount": 301, + "faceCount": 600, + "initialVisibleCount": 231, + "minimumVisibleFaces": 190, + "maximumVisibleFaces": 261, + "meanVisibleFaces": 229.31229235880397, + "visibleFaceCount": 450, + "visibleFaceIndices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 11, + 12, + 13, + 15, + 16, + 17, + 18, + 20, + 21, + 22, + 24, + 25, + 26, + 27, + 29, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 42, + 43, + 44, + 45, + 47, + 48, + 49, + 51, + 52, + 53, + 54, + 55, + 56, + 57, + 58, + 59, + 60, + 61, + 62, + 63, + 64, + 65, + 66, + 67, + 68, + 69, + 70, + 72, + 73, + 74, + 75, + 76, + 77, + 78, + 80, + 81, + 82, + 83, + 84, + 85, + 86, + 87, + 88, + 90, + 92, + 93, + 94, + 95, + 96, + 97, + 98, + 99, + 101, + 102, + 103, + 104, + 105, + 106, + 107, + 108, + 110, + 111, + 112, + 113, + 114, + 115, + 116, + 120, + 121, + 122, + 123, + 124, + 125, + 126, + 127, + 129, + 130, + 132, + 133, + 134, + 135, + 136, + 137, + 141, + 142, + 143, + 144, + 145, + 146, + 147, + 148, + 152, + 153, + 154, + 155, + 156, + 157, + 158, + 159, + 162, + 163, + 164, + 165, + 166, + 167, + 168, + 169, + 170, + 171, + 172, + 173, + 174, + 175, + 176, + 177, + 178, + 182, + 183, + 184, + 185, + 186, + 187, + 188, + 189, + 190, + 191, + 192, + 194, + 195, + 196, + 198, + 199, + 200, + 210, + 211, + 216, + 217, + 218, + 219, + 220, + 221, + 222, + 224, + 225, + 226, + 227, + 228, + 229, + 232, + 233, + 234, + 235, + 236, + 237, + 238, + 239, + 240, + 242, + 243, + 244, + 245, + 246, + 247, + 248, + 249, + 250, + 251, + 252, + 253, + 254, + 255, + 256, + 257, + 258, + 259, + 260, + 261, + 262, + 263, + 264, + 265, + 267, + 268, + 269, + 272, + 273, + 274, + 275, + 276, + 277, + 278, + 279, + 280, + 282, + 283, + 284, + 285, + 286, + 287, + 288, + 289, + 290, + 291, + 292, + 293, + 297, + 298, + 299, + 303, + 304, + 305, + 306, + 307, + 308, + 309, + 311, + 312, + 313, + 314, + 315, + 316, + 317, + 318, + 319, + 320, + 321, + 322, + 323, + 324, + 325, + 326, + 327, + 328, + 330, + 331, + 332, + 333, + 334, + 335, + 336, + 337, + 338, + 339, + 340, + 341, + 342, + 343, + 344, + 345, + 346, + 347, + 348, + 349, + 350, + 351, + 352, + 353, + 354, + 355, + 356, + 357, + 358, + 360, + 361, + 362, + 363, + 364, + 365, + 366, + 367, + 368, + 369, + 370, + 371, + 372, + 373, + 374, + 375, + 376, + 377, + 378, + 379, + 380, + 381, + 382, + 383, + 384, + 385, + 386, + 387, + 388, + 389, + 390, + 391, + 392, + 393, + 394, + 395, + 396, + 397, + 398, + 399, + 401, + 402, + 404, + 405, + 406, + 407, + 408, + 409, + 411, + 414, + 415, + 416, + 417, + 418, + 420, + 421, + 422, + 423, + 424, + 425, + 426, + 427, + 428, + 431, + 432, + 433, + 434, + 435, + 436, + 437, + 438, + 441, + 442, + 443, + 444, + 445, + 446, + 447, + 448, + 449, + 450, + 451, + 452, + 453, + 455, + 456, + 457, + 458, + 461, + 462, + 463, + 464, + 465, + 466, + 467, + 468, + 469, + 470, + 471, + 472, + 473, + 474, + 476, + 477, + 478, + 479, + 480, + 481, + 482, + 483, + 484, + 486, + 487, + 488, + 492, + 493, + 494, + 498, + 499, + 500, + 501, + 502, + 503, + 504, + 505, + 507, + 508, + 509, + 510, + 511, + 512, + 513, + 514, + 515, + 517, + 518, + 538, + 539, + 540, + 541, + 542, + 543, + 544, + 545, + 550, + 551, + 552, + 553, + 554, + 559, + 562, + 565, + 566, + 582, + 583 + ], + "transitionCount": 7124, + "initialVisibleBitsBase64": "/wFUAQAgaYoACID0z9cVb3BAGdas/wHgQAEMPzC8RY8cKPogL4AH2J9M995DX4om338Aw7cDMy4EhwHg2HEc2wEAAGgBAwAAAAAA", + "transitionOffsetsBase64": "AAAaADUAUQBkAIAAmQCwAMUA4AD9ABIBJQE8AU8BZgF+AZ4BuQHQAe8BBgIfAjoCTQJqAoQCnwK4AtIC8gIbAzcDUANmA4UDoAO1A9oD8wMKBBsEOgRWBHgEkQSmBLcEzQToBAAFFAUlBT0FUgVkBXcFigWiBb4F0AXoBfsFDwYkBj0GUQZlBn0GlwapBsMG2QbwBgYHIAc3B0sHXQdyB4MHnQewB8YH3AfwB/4HDwgjCDMISghdCHIIigieCLMIwwjXCOoI/QgJCRkJJQkzCUMJUAlrCXwJigmdCa8JyQndCfcJDAonCjsKUgpqCoUKnQq5CtMK8woSCzALQwtfC24LhgujC7kLzQvmC/ILDAwgDDcMUAxhDHIMhQycDL4M1AzqDAENFA0pDT8NVw1vDYgNnA22Dc0N3g0HDiAOOg5hDnYOkg6rDscO4Q79DhQPKA9CD1kPbQ+FD5oPrA/AD9MP5g8FECUQOhBXEHUQkRCpEL8Q2BD8EBMRMxFSEWoRhhGlEboRxhHdEfERBRIiEjwSWBJ3EpASpRK/EtUS8RINEycTPBNTE2YThhOeE7cT0hPtEwYUIBQ9FFsUfBSYFLoU2BT2FBAVJhVBFVsVbxWPFbIVyRXiFfUVBxYYFi4WQRZYFnAWiBacFrEWxxbfFvcWCxcYFyYXOBdJF1kXaRd9F5IXpBe4F88X5Bf4FwwYHxg4GFIYZRh+GJsYtRjJGOUY/hgbGTYZUxlmGXoZkhm1GdIZ9hkSGi8aSxpkGnsakhqrGsQa3Br8Gh0bLxtEG1wbdRuOG6QbuhvUGw==", + "transitionFaceIndicesBase64": "LQBCAFgAcABxAH4AgQCRAJgAugDeAOIA6ADpAP8AAAE7AVQBYQFiAWYBbQGxAbsBvgH4AUIAfgCBAI0AkQCmAMcA7gD4AP8ASAFNAVUBYgFmAXABcwF0AYUBlAGwAbEBuwHFAdkBAgImAi0AcABxAH8AgQCNAI4AmACkAKYA4gD4AP4A/wBGAUcBSAFMAVUBYQFtAXABcwGwAcUB2QECAiYCLQB/AI0AkQCYAKQApgDAAN4A7gD4APwA/gCvAbABsQG7AcgB5gFwAI8AnwCmAMAA4gDoAOkA7gD4APwA/gAIATsBXgFhAWUBcAF0AXoBrwGwAbEBuwHzAfkB/gEhAn4AfwCNAI8AkgC6AN4A6ADpAP4A/wA7AUcBSAFVAV4BYQFlAWYBdAF6AbABwAH9AR8CCwBzAH8AgQCNAJIAmAD4AP8ARgFHAWIBYwFmAXABegGwAbsBvAG+AcABxQHZAQsAFgBwAH4AgQCkALoA7gD4AP8ACAEaAUYBSAFiAWMBcAGfAaABvgHZARYANACBAI0AkgCkAKYAugDhAOIA4wDuAP8AGgFIAVUBYQFwAZ4BqwGvAbABwwHFAegB9AEbAhQAGwA0AHMAgQCNAJIApgC6AOIA/AD/AAgBOwFGAUgBVQFeAWEBZgF6AasBrwG7Ab4BvwHTAdkB5wE0AHAAcwB+AI0AwwDiAPwAGgFGAUgBVQFeAW4BsAGxAb4BvwHZAfsBNQIbAC0ANABjAMMA4gDuADsBXgFhAWYBewGxAbsBvwHFAdMBAAI1AhsAJAAtAHAAfgD8ABoBMwE7AUgBTwFeAXABqwGsAbsBvgG/AcUB2QHeAQACNQIbADQAfgB/AOgA6QDuADsBVQFeAWEBaQGrAawBvgHFAc4BAAI1AiQANABnAHAAfgB/AI8A4gDuAPUABQEaAVUBYQFpAXoBngGrAa8BvgHFAdkB+AF+AI8A4gDpAO4A+AD8AAgBOwFGAUgBVQFeAWEBZgFwAXoBqwGsAbMBtgHFAdEB2QEPADQANQBwAH4AjwCYALoA6AD0APUA+AD8AP8AAAEIASQBVQFeAWEBZgFuAXoBqwGvAbABuwG+AdEB1gHZAfIBDwA0AEIAjQCPAJgApgC6AOkA7gD0APwAAAFlAW4BegGrAawBsAGxAbsBvwHFAc8B0wHWAewBIQAtAEIAXwBwAKYAugDuAP4AAAEaAUYBXgFhAW4BsAGxAbYBxQHQAdMB3gEfAiEAJAA1AF8AfgCNAJgApACmALoA4gDpAO4A+AD+AP8AAAEXARoBOwFVAVsBYQFuAaABqwGvAbABvQHFAc4BFgAkAC0ANACYAKQApgC6AOIA9QD2AP8AOwFGAVUBYQFuAXwBmwGvAbYB9wEyAjQAOABCAH4AjQCkAKYA7gD1AP4A/wAIARcBOwFEAUYBVQFhAW4BcgGrAbYB0wHZATICFgAtADQANQBCAH4AjQCkAKYA+AD8AP4AAAEGAQgBFwEaATsBRAFIAVUBZQGwAbIBuwHFAdkBLQA1AGwAbgB8AJgApgDeAPYA/wAAARoBRgFIAVUBsgHFAecBJgItADQANQBfAHwAmACkAKYAuwDDAPUAAAFGAUgBTwFVAVsBYQFwAasBrwGwAbsBwAHTAdkB5wHyASYCEgAtADQANQBfAGcAfgCkAN4A9QD8AP8AAAEIATsBRgFIAWEBZgGrAa8BsgHAAcgB0wEhAgQAIQAtADQANQCNAKYA6QD0APwA/gD/AAgBOwFVAWEBeAGwAbIBuwHFAdMB1AHYAdkB4gHzAQQAIQBCAH4AgQCRAKYA7gD0APUA+AD9AP4AFwFPAWEBZgFwAXgBrAGwAbYBxQHOAdQBBAAqAC0ANABCAGUA3gDpAPUA+AD8AP0A/gAXAUQBSAFVAWIBrAGwAbIBtgG7Ab4BxQH7AQQAFgBCAGUApgD1AP0A/gAAAQkBFwE7AUYBSAFLAU4BTwFVAWYBcAF0AaoBrwGwAbEBsgG7Ab4BxQHQAdkB9gEEABYALQA0AEIAgQCNAKYAuAC8AN4A9QD2APgA/QD+AP8AAAEIAQkBFwEaARwBOwFGAUgBSwFdAWIBZQFmAXABrwGxAcABzwHZAeYB5wHoATUCBAAWAC0ANwBCAH4AjQCyALgA4gDuAPgA/gAIARcBGgEcAUYBSAFPAWYBcAF0AbABvgHAAcUBNQIEAA8AIQAtADQAfgCNAJEApgDiAPUA/gAAAQgBFwEaAUsBcAGrAawBsAGyAbsBxQH9AQQAFgAqAC0AQgCmAP4AAAEIARcBOwFEAUYBSwFhAWIBdAGEAb4BxQHoASgCBAALABUAIQAtADQAQgBLAF8AjQCyAOEA6QDuAPUA9gD4AP0A/wAXARoBRAFGAUsBTwFiAWQBZgGvAcUB/gEEAAsADwAVACEAjQC4AO4A9QD4AP0A/gAAARcBRAFGAUsBTQFPAWYBdAGEAaABrwGyAeEB7AEEABQAQgBfAJgApACyAL8A6QD2AAgBFwEaAUYBTQFPAWIBZAFwAY0B2QEEAAsADwAUABYAKgBCAI0AmACkALIAuAC/APUA/gD/AAABCAEaAUgBTwFhAWIBZAFmAXABfgGEAaABpgGwAbEBsgG9AcUB8wECAgQACwAPABYANQCCALIAvwDpAPgAAAEaATsBRgFLAU8BVQFpAaYBrAGwAbEBsgHDAcUBBAAWAC0ANQB+AIIAvwD2APgA/gA7AUYBSAFhAWQBcAF1AXYBhAGNAaYBsgG7AQQAFQAqACwALQCBAKQAvwDpAO4A9QD2AAABCAFEAb4B1gEEAA8AFQAhACwAfgCNAKQApwC4AN4A7gD1APYA/gAIARcBRAFGAUgBTQFPAVUBZgFwAYQBjQGxAbIBuwHFAQIABAAHAA8AIQClALIAuAC/APUA9gD9AP8AAAEXARoBRAFLAU8BYQFwAXwBogGxAbIBvgHFAe4BBAALACEAKgB8AI0AjgCmAKcAsgC4AL8A3gD1APYA/QD+AP8AGgFEAUYBSAFNAU8BYQFkAWYBjQGvAbEBuwHUAdUB9gEEABQAFgAhACoAfACmAKcAuAD2AP0A/gD/AAABCAFVAWQBZgFwAbEBuwHUAdkBAwIbAgIABAALABQAIQAzAKUAvwD2AP0A/wAIARoBKQFLAWEBcAGNAY8BogGyARYASwCNALcAvwD1ABoBKQE7AU8BZgGEAZ8B1QHYAdoBGgIHAAsAfgCNAKUAsAC4APYA/gD/AAgBOwFhAWQBZgFwAYQBjQHaAd4B5AEAAgIABAAHAAsAFgAsALgAvwD1APYA/gD/AAABCAEaARsBOwFGAUgBSwFPAWEBZAFwAYUBjQGyARYALACCAI0ApgD1APYA+AD+AP8AAAEIARsBSAFPAVUBYQFmAXABsgG/AcAB2AHiASEAfgCCAI0ApgCnALgAuQD2APgA/gD/AAgBOwFGAYUBsAHAAdgB8gEHACEApwC5AOIA/gAaATsBTQFPAVUBYQF6AY0BrwGwAdYBAgAHAEoASwBUAH4AggCNAP4A/wAAAQgBNAFNAU8BVQFhAY0BogGvAdMB3wHhAegBAgAHAEoASwBUAH4ApgDiAOkA/wA0ATsBRQFNAU8BjQGbAbAB0wHdAeMBBwBKAH4AggCRAKYAuADpAPUA/gA1ATwBSAFNAU8BVQGNAZEBAgALACwASgCCAJEA/gD/AAABCAEaATcBOwFIAWQBZgFwAYQBjQELABYAIwAsAH4AggCNAKUApwDeAP4AAAE7ATwBTQFkAXABhAGNAQIAIwBCAH4AjQCRAKQA3gDiAPYA/gAAAQgBFwFIAU0BZAF6AY0BpgGwAbIBGgIcAhYAQgCOAJEApAClALcA4gD2AP8AAAEIARcBNQE8AUgBTQFPAVUBZAFmAXABegGEAY0BsAGyAb4BQgB+AI0ApQCmAKcAuwD2APgA/wAAARoBYgFkAY0BqwGyAdgBCwAsAEIAXwCYAKUAsADuAPUA9gD4AAABKQE8AU8BYgFwAYQBjQGwAb4B2QHuASgCCwAWACwAXgBfAH4ApgC3APUA9gD4AAABGgEpAU0BVQGiAbIB9QEWACEASwBeAF8AmAC5AOEA9gD4ADsBPAFIAU8BYQFkAYQBogGwAR8CIQBeAF8AmADhAOMA9QD/AAABCQEqATwBTQFPAWkBcAGqAbABvQEnAjUCjQCYAKYAuADiAO4A8gD1AP4A/wAAAQkBKgE7AUgBTQFVAWQBZgF7AX4BrwEnAjUCRwIjAEsAXwCNAKYAuADiAPgA/gD/ABYBKgFPAWEBcAF6AYMBogH4AScCAgAjAF4AfgCmAKcAuwDuAPgAKgFEAVUBYQFiAWQBZgF6Aa8BsAHjAV8AfgCNAKYApwC7APAA9gD4AP4A/wAaATwBRAFhAWIBZAFmAXABgwGwAb4B2QHyAQEAAgAWAF8AfgCNAKQApwDeAO4A9gD4AP4AFwEaATwBRAFPAVUBYQFmAXoBvgHdAfUBNQIWAEAASwB+AKUApwDeAO0A7gAXAUQBYQFmAYMBrwGwAQECNQIBABgAIwAsAEAASwB+AIEAjQCmAO0A7gD2APwA/QAaAUQBYQFkAXABvQHHAdgBAQICAgMCAQACACwAMABCAHMAjQCnALgAuwDuAPYARAFIAU0BWwFpAXoBqwHHAd8BJwICABYAIwBCAEsAfgCkAKYAuADHABoBTQFTAWEBZAFpAXoBqwGwAb4B0wHkAfQBIwBAAI0AmADtAPYA/wAIARoBOwFEAUgBVQFbAWQBbgF2AasBrwG+AccB+QEWACMAQACRAJgApACmAOkA7gD2AP8ACAEaATwBTgFPAWQBZgFuAXoBogGrAa8BsAHHAdMBBAAhAEsAXgB7AI0AkQC4AOkA9gD/AAgBGgFIAU8BVQFmAXoBsQG+Ad4B8wH3AQQALACCAKQAuADtAPYA/wAIARoBRAFNAU4BVQF1AY0BrwGxAb4BJwIEAAsAIQBBAEsAewCCAI0ApgAaASEBTwFmAWkBegGNAa8BvgEEAAsALABBAJEApgC4AP8AOwE8AUgBTQFPAVsBZAFlAWkBegGwAb4BJwKNAJEAuADeAOkA7QD2ADwBSAFOAU8BZQFpAWsBsAG+AScCQQBdAIIA6QDuAPAA/wAbATwBSAFOAU8BWwFhAWQBaQFrAXABcgF6AZQBqgGwAb4BAQInAkEAQgCNAI4ApgDuAPIA/wAIARsBKgE7AUQBjwGRAaoBrwG+AQECQgBLAJEA3gDoAO0A7gD4AP8ABAEMASoBOwFEAUgBYQFlAWYBcAF6Ab4BJwIEAEEAkQCmALgAyADeAOgA7gD2APgAGwE7AUgBTQFPAVsBYgFkAWsBvAHHAQQAQQCCAI0AuADIABsBOwFEAU8BWwFiAWQBZQFrAY0BrwGwAccBJwIEABgAQQBLAIgAjQC5APgAGwFHAVsBZAGNAaoBBABBAEsAmACmALcAyADpAO4A+AD/ADsBTQFmAXoBsQG7ATwAPQA+AIgAjQCYAKQAuADpAO4AOwFEAU8BWwFhAXABsAGxAbsB2QEEADAASwCkAKYA+AD/ADwBRAFhAXABfwGPAZEBsQHZAQQASwBzAIgAjQD2APgA/wAVARsBNwE7ATwBTQFPAWQBbQFwAXUBsAG7AcUBzgGNAKQApgC4APQA9gD/ABsBSAFPAVsBXQFkAWUBaQFwAXUBpgHZAQQAjQCRAKQApgDuAPQAIQFIAUsBTAFPAWUBaQFsAXABsAGxAbsBxQHZAQQAQQCIAJEAuAC/APgA/wAbASQBRQFLAVsBZQFmAXABegGrAbEBuwHFAdkB5gH5AT0AiACYAKQA6QDuAPIA9QAFAQcBGwFLAVsBZQFmAaIBpgGxAbsBKAIEAD0APgBYAHsAjQC4AOkA8AD4AFsBZQFwAXUBegGDAZsBpgHFAf0BKQIEAI0ApACmALgA6QDtAAcBPwFVAWQBcAF0AaYB2QH4AT0AQQCNAJgApgC4AOkA7QD/AD8BRgFTAXABdAGwAbEBuwH+AScCNgIQAIEAggC4AN4A+AD/AD8BaQFrAXABhAGNAbABsQHFAdkBJwI2AgQAEACCAI0AuADDAO0A+ABGAWEBZAFpAXoBgwGNAbsBxQHZAUcCBACkAN4A9QD/AGsBcAF0AXUBegGEAScCgQCNAKQApgDeAPgA/wBGAWwBcAFyAXQBdQGEARsCJwJIAEoAjQCkAKYAvgD3APgA/wBsAZ8BpgFBAEkAjQCkAKYAsQC+AEoBYgFrAXABdAF+AbABBABBAEkASgCYALEA7gD9AP8AEgFFAUYBZQFwAbwBJwJFAIgA7gD/ABIBJQFkAYEBuwHFAdkB9AEnAi0APQBCAEkASgCNAJgApADuAPwA/QD/AEUBRgFmAXABggGhAa8BsAG7Ab4BwwHFAdkBHQIgAgQALQA/AEIAmACkALIA4wDuABsBIwFkAWYBaQFwAbABvgGCAI0A4wDuAP0A/wChAaIBpgGwAbsBxAHFAe4BFgAtAH4AggCNAKYA7gAEAR0BYgFmAWkBegGhAbsBwwHEAcUB2QEGAC0AfQCNAOgA6QD9AP8ABwFKAWIBZAFmAXoBrwHFARwCHgILABYAfgCNAI4ApgDoAOkABQFEAUUBSgFPAWIBZAFmAWkBawF0AXUBegGEAbsBvgHFAdkBCwAWAEkASgBMAKYA0wAbAUQBRQFUAWIBaQFqAXoBrwGxAb4B/QEfAj8AQgBMAH4ApgDoAOkA9wD9ABsBRAFFAUYBTwFhAWIBaQFqAXUBegGEAY0BsQG7AcUBzgEtAD4ASwB+AKYAsgDoAP8ARAFFAUYBTwFmAWoBcAF1AXoBhAGNAbEB2QEBAAIAFgBCAEsAUwCNAKQApgDpAP0A/wA0ATwBRgFPAWIBagFwAXQBdQGEAaEBpgHFAeYB8wECABQALQBLAF0AjQCkAO4A/QATATwBTwFiAXQBnwGhAaIBpgGxAdkBBAAUABsAIQA+AEIAUwB+AI0AlACYAOgA6QDuAP0APAFPAWIBdAGiAbABxAEoAg8AFgAhAC0APgBAAF8A9gA8AUQBRQFGAWIBawF0AYMBoQGiAbABtgHEAcUB2QH4AQIAFgAjAEIAQwBeAH4AjQCYAKYA6QDuABMBFgFEAUUBawFsAaEBogGmAasBrAG2AcABxQHZAQIAGQBAAEIAQwBTAFgAjQCmAMMA6ADuAPgA/QATARYBRQFGAWsBbgGhAaYBsAHHAQIAFQAZACEALwBBAEIARABTAHAAfgCNAOsA+AD9AAcBFwFrAWwBbgGBAZsBoQGiAawBsAHAAccBAgALABUAGQAhAC0ALwBBAEIAQwBEAFMAXwBwAJgA2QDoAOkAKQFPAaEBogGwAdQB6AEfAgsAGQAaABsALQAvAEAAQgBDAEQAUwBeAF8AewB+AOgA6QDrAO0A/QD/ADABRAFFAWYBjQGVAZYBlwGhAcAB1AELABUAFgAaACMAKgAvAEIAQwBEAEwAXgCCAIgAjQCRAMMA/wBEAU8BjQGXAaIBqgGsAbYBugHAAckB/gEhAgIACwAVABYAGQBAAEMATAB7AI0A0wDuAPUA+ABEAUUBRgFIAU8BaQF1AX4BjQGXAaIBrAHAAcIB3gEgAgIABgAVACoAXgB+AIIAiACYALIA+AAWASoBSAGNAasBtgHAAd4BBgALABUAIwAqAEIARQBGAF4AfgCRAJgA3gDrAO4AFgFEAUYBSAFPAV0BZgF1AasBsgHKAd4BAAILABUAKgBCAFAAXgBfAJgAwgDjAOsARQFdAYUBsgELAD0APgBGAFAAfgCOAJQAwgDeAOMA6ADpAPUA/QARARQBKwFGAXUBgwGNAaEB7QELABUALAAwAEEAQgBeAH0AfgCBAIIAmACvALIA4wDoAOkA8AD2ACsBRwFIAU8BdAF1AZUBlgGXAa8BAgAhACoALABCAGsAcwB+AIEAggCmAOMA+AAYAUUBRgFIAU8BdAF1AeQB+AECAAsAFQAhAF8AiACOAO4A8gDzAPgA/QAwAUUBSAFPAWoBjQGwAcUBCwAVACoAPABwAHEAfgC7AOsA7gD0AP0AEQEYAUgBTwFqAZ8BsAG+AcUBHQIeAh8CKQIWACoAQgBvAH4AmACyAPcAdQGNAasBxQECAAsAFQAWACoAQgBxAHQAfgCRAJgArgCwAN4A/QAjAUUBSAFOAXUBjQGoAasBugG+AcUBAgALACwAQgB0AH4AkgCYAN4A7gD0ABMBGwElAUgBTgF1AXoBugG+ASoAQABCAEgAmAC6ALwAvwDeAOsA8wATAUABSwFMAU4BTwGoAb4BxQHuARsCJgIVACoALACYAKcA3gD9AP4AEwEkAUQBTgFPAWQBaQFwAXsBgQGqAcUB7QH3AfwBAAImAgIAIQBCAEsAXgBxAHsAiADoAOkA/gAIAWQBaQFsAXABqgECAAsAIQBWAGsAcQCIAOsA7gAQAT8BTwFkAW0BqwHFASECCwAgAEIAXgB+AP0AAQEIARABGwFIAU8BZAFwAZQBqwHFAdMB9QECACAAIQBCAF8AcgB+AIgA2QDeAO4A/wAWARsBTwFVAWgBawGHAb8BxQHKAdMBIQBfAH4AggCbANoA3gDoAOkA6wDuAP0A/gD/AAEBFgEXARsBKgFIAUoBTgFVAWQBZgFqAWsBggGHAYsBvwHFAccB2AFeAF8AcQCCAIgAmwClAP0A/gAUAUgBTgFPAVUBZAFmAXABhwGLAasB2QEhAgIAQgBeAF8AcQB+AJgApQDjAP8AFwEbAVUBZAFmAWoBdAGrAcUBxwH4ASECXwByAH4AmACiAO4ACQEQARUBFwEaARsBSAFVAWYBdAF1AYcBiwG+Ab8ByQH8AUIAXQBeAGsAcgB+AJEAogDeAOIAEAEaAVUBZAGwAb4BvwHFAckBAQAWAHQAfgCRAKYA3gDuAAkBEwE/AU8BYgFkAXABdQGOAbABxQHHAQUCAgAWAGsAkQCSAJgA3gDrAPgA/wAJARUBKQE6AWIBZAFpAaIBqgGrAcIB9AECAAcACwCPAJgAogDeAOIA6wD3AAkBFwFVAWYBaQFwAXQBogGqAasBxwHJAQYCHAIGAAsAIQBVAIgAjwCRAJ8AogDeAOEA7gD8ABcBHQE6AU8BVQFkAWUBZgFpAXQBqwEGAAcAFgAhADwAbABxAJ8A3gDiAO4A+AD9AP4A/wA6AUgBYgFpAWoBhwGOAasBxwHkAQYAFgAsADMAQgBQAHEAfgCRAJgA/AD+AAwBSAFPAWIBcAF1AYsBxQEUACwANQBCAEYAXQBuAHEA0wDeAOIA7gD9AAgBEAESARoBTwFVAWoBcAF8AYcBxQHoAR8CBgBCAEYAUABxAJgA3gD8AP0A/wAQAREBEgEaAVUBaQFqAXUBfAF+AccB3wH5AQYAFgA+AF8AcQB+AJEAmACeAPgA/AD9AP8AFgFRAWYBvgEWACEALwAwAEYAUABSAFUAXgBfAGwAbwBwAHEAcgCRAJQAmADIAOsA7gD2APgA/wAaATABNAFIAVUBYgFkAWUBagF1AasBsAGyAb4BxQHTASgCJQAqAC8AXwB+AJEA6AD2AP8AAAERARoBOAE5ATsBRgFVAWIBZQFqAY0BqwHTAdUB8wEWAB8AJABQAHAAmACuAN4A6QD4AP0A/wAAARoBPQFGAVEBXQFiAWQBZgGNAbIB1gHeAd8BEAAWACUAKgBQAFIAXgBuAHAAcQBzAHQAfQCRAJgAmgCjAKUArwCwANkA3gDoAOkA+AD9AAgBEQEiATkBPQFGAUgBTwFiAWQBZgGHAbABGgAlACoARgBeAHEAcwB7AKUA7gD8AAgBEQE7AUgBTwFkAaIBtgHAAdQBGgAlACsASABQAH4AmADHAN4A/AD9AP8AEQE5ATsBPAE9AUgBTQFhAWQBaAFqAYcBogG2AcAB1AEHAD4ARgBIAFAAXgBzAH4AhwCRAJgAngCnAN4A6ADpAAgBGgE8AT0BTwFkAWoBcgGEAQcAEQAaAEYAUABeAH4AhwCRAJwAngDeAOgA6QDuAPIA9gD8AP0A/wApAT0BSAFhAWQBhAGyAb0BGwArAF4AXwBwAHsAfgCJAMAA3gDhAOMA9gD4APwA/QAIARoBIQEpAUgBTgFPAWIBZgG7AQ0APQA+AEEAXgBfAHAAewB+AIkAqQC7AMIA3gD4AP0A/wAIARABPAFIAU8BYQGiAbIBuwG9ATUCDQAaACUASABcAGoAfgCRAJgApgC2AN4A6QD4ABABEQE8AU8BYgFkAXwBvQE1Ag0AHQBWAGgAjgCRALYA6ADrAPIA/wA8AUgBYQFiAWQBnwHXAScCKAINABoAJQB+AI4ApgC7AN4A6ADpAOsA9gD4APwACAE7ATwBPQFAAU8BYgGDAbABvgEBAicCGQAlAFMAjgCRAJgAogCmALsA3gDzAPYA/QAIARwBOwE9AUgBcAF0AaoBvgEBAg0AEQA6AEgAXABwAJEAogC2AO4A8wD2AP0A/wAIAR4BpgGwAbEBwQEAAA0AUwBqAHAAfQCRALYA7gDzAP8ABwEIAREBSAFLAWYBcAF0AY0BpgGwAbEBwQEHABIASABTAH0AfgCOAJEApgC2APYA/wAHATsBSAFmAY0BnwEGAicCRwIkAC8ASAB+ALYAuwDCANkA/wAIARIBOwE9AUYBSAGNAa8BugEaAD4ASABTAMAA9gD4AP8ABwEIARIBOwE9AUQBTQFUAWUBaQGNAa8BBwAiAD4AcACsALoAuwDSAPMA9gD4AP8ABwE7AUQBRQF+AbABLwIHACIASQBTAHAAkQC6AO4A9gD4ADsBZAFqAWwBfwGmAbABzwEnAgcAGgAlAC8ARQBIAEwAagCHAJEAnACwALoA6wD2AP8ABwEIAREBHgEwATsBPQFEAWYBdQGNAbABvAHPAQUCBwAaACEALQBAAEgATABTAHoAhwCeALsA6ADpAOsA7gD4AP0ABgEHAQgBNQE9AUYBSAFNAWYBdAF1AY0BpgH5AUIAhwDoAOkA9gD4AP0AEQESARcBIgE9AUYBSAFLAVUBWAF0AY0BpgHPARAAGQAmAC0AQgBIAE4AUwBoAHAAcwB7AIcAvAD2APgA/wAEAREBEgEXASoBSwFYAWIBdQGmAeABRgImAEIASABOAFMAVgBwAHMAewCIAOsA8wD2AAcBGgEqATgBOQE6ATwBRAFIAUsBTQFiAWkBcgGNAb4BJwIHAA0AQgBTAFYAcwCIAOsA8wD2APgA/wAFAQcBPAFEAUcBSAFLAWIBjQGWAbABuwG+AcQBxQEnAg0AQgBIAH0A6QD2APgACAEQARcBGgE7ATwBRwFLAWkBdAF1AY0BlgGvAbABxAEnAiYAQgBzAH0ArwDhAOMA6QAIARABJAE8AVUBbAF0AXUBgQGvAbEBuwHFAeIBGgBCAEgASQBTAFYAXwBzAH4AnQCuAPYA+AAIARoBPAFYAWIBdQGwAbEBxwHPAScCKQIHAA0AGgAmACcAQgBMAFgAXgBfAHAAcwB9AH4AiACdAPMA9gD4AAgBEgEaATUBSwFNAVUBWAFdAWkBdQGNAaIBxQHHAc8B4QEHAA0APgBTAFYAWABeAHAAhwCIAOEA8wD2AP4ACAESATwBRgFLAU0BVQFkAScCJgA9AD8AQgBEAEgATABTAF4AXwBwAHMAewCsAOkA9gD4APwA/gD/AAgBGgElAUYBSwFVAWYBaQFsAfQBJwI2AgAAHwA/AEIARABIAF8AewCBAIcArADaAOkA7gD4AAgBGgE8AUsBTQFOAVgBYgFpAY0BogGxAb4BxQHPAQECQgBfAGoAcAB+AIcAiADuAAgBGgFIAU0BTgFVAWEBYgFpAXABdQGiAbEBzwEBAjUCJgAxAEIATgBTAG8AcQB6AH4AhwCIAOsA9gAMARoBSAFLAU4BVQFYAWEBaQFwAXUBoQGvAbEBvgEHABoAMQA8AD8ATgBTAF8AcABxAHoA6wDuAPgADAEcAUcBSwFOAVUBWAFhAWIBjQGSAaEBsAGxAboB3gHzARoAJwAxAGUAfgCBAIgA6wD2AD8BRgFHAUsBTgFiAWQBaQGNAbsBLwJGAkwAXgBuAH4AhwCIABoBPAFVAWEBaQGqAQcADQAqADoAPwBEAEgATAB6AIcAvADrAP0AFwE1AUYBWAFkAWkBuwHFAf4BHQINABoAQABCAEQASAD9ACEBRgFNAVUBWAFkAWwBrwGwAbEBuwHFAR0CJgBCAEgATABeAJ4ApgCwABMBRgFNAVUBaQFsAbEBuwG+AeQBGwIdAg0AGgAmAEAARABIAEwAUQBeAHMAfgCaAKMApgCwALsA/QBPAVUBaQGNAaABsAGxAb4BzwEdAh4CIAINACYAJwA/AEQASABRAHEAcwB+AI0AowCuAK8AsAC7AOsA/QBYAWkBrwGwAbsBvgEdAkcCGQAmACcASABRAFgAXgBfAG8AhwCPAJQApgCsALsA/QAcAWIBZAF5AYIBjQGgAbEBuwG+AR0CHgIHAB0APwBRAFgAXgBfAHEAewB+AIcAkQCYAKMApgCwALsA6QDrAOwA9gD9AAgBJQFEAUgBYgFkAWwBoAHDARoAMQA/AEQASABvAHEAfgCRALAAuwD2AP8ACAElAUgBZAFvAY0BrwGwAbEB3wEAAh0CGgAfAEIATgBxAIIAhwCPAOsA/wAlAUgBZAFvAXYBhQGSAbABsQG8AR0CGgAfAC0APwBCAEQASABNAE4AWABxAIIAhwC6ALsAyADSADMBPAFIAWQBbAGoAbsBvAEdAgcAGgAtAEgATQBoAG8AcABxAHMAmACnALAAuwDTAOwA/QChAbEBsgG7AcUBGwAfADoASABNAF8AcACHAI0AjgCPAJgAsAC7AOkA/QAIATwBVQFiAWQBZgFsAawBsQGyAb4B3QEHABoAOgA/AEIASABNAE4AXwBxAHMAhwCNAI8A8wD0AP4ACAEzATwBSAFiAWkBrAG7Ab4BxQHSARkAPwBIAE4AXgBwAHEAcwCCALAAyAD+ADcBSAFLAVUBWwF2AaUBrAGxAbYBuwHFAdIB9gENABkAGgAqAC0AQgBwAHMA9QBIAVUBdgGiAbEBtgG7AbwB0gHcAd4B6AEHAA0AGgA6AEIAXgBwAHMAggCOAPUA/gAVARsBRQF1AaIBrAG+AcUB0gHcAd4BBwAaADoAQgBEAEgAXgB+AI0A4QD+ADwBhAGwAbEBtgG+AcUB3QEHAAsAGgBEAEgATgBeAF8AegB+AIEAjQClAOAA/wAAARsBSAFNAVUBZAFpAXUBhAGZAakBsQG2AbsBvgHFAdoBBwALACwARABNAE4AXwB+AIEAjQCnALAA4AD1AAABGwFIAU0BVQFpAbABuQG+AdoBGQAtAD8ARABIAE0AfACBAI8ApwCwAPUA/gD/AAgBGwFVAWkBcgGYAbABuQG7Ad0B8gEZACwALQA/AE0AbABzAHwAfwCBAKUApgCnALoAuwDeAPQA9gD+AAgBTQFhAWQBaQGwAbsB+AENAEIARABIAE0AXgBzAHwAjwCYAKYA3gDuAPMA9QD4AP8AAAEbASQBTQFhAWkBggGRAfUBHQIHAA0AGQAaACEAOgBAAF4AfACPALAA6ADpAP0A/gD/ABsBOwE8AWUBggG+AeMB7AEqAgcADQAtADoAPwBAAEQATgB8AI8AowC6AMMA6QD1APYA/QD+AAABCAEaAWIBdgGkAbsBvgENABYAGQAaACEALQA/AE0ATgBVAFYAfACjAMAAwwDeAOkA9QAIARcBGgEbAUgBTQFdAagBxAHZASoCAgAWAEIAVgCRAJIAowDAAMMA6ADpAP4AFwEbATsBPAFNAVoBXQFiAX0BhAGoAbABuQG7Ab4B0wEdAigCAgAHAAsAGgAfAD8AQgBIAE0AXgCBAJAAkQCSALgA3gDjAOkA9QAMARsBKQE8AUgBTQFOAU8BWgGEAbsBvgHTATYCBwALABoAHwAhAD8AQgBNAJEAuADEAN4A4QDpAP4AGwE7AT8BSAFNAVoBYQFkAXUBeQG7Ab4BxAEHABYAIQAjAEAASABNAFYAXgBfAGMAmwC6AOgA6QDuAPUAFwEaARsBOwE/AUgBWgFhAWQBagFzAXUBpwG7Ab4BvwE1AgIACwAWABoAPwBAAFYAXwB8AIIAkACRAJsAuADAAOgA6QDuAPUAAAEMARcBVAFqAXUBhAGyAbsB7gEdAgIABwALAA8AIQA/AEIASAB8AIIAhwCRAJIAmACeALkAwgDeAPMABAE/AVoBYgFkAWoBcwGEAdcBHQIfAhYAGgAfACEAKgAtAI4AkgCYALYAuQDzAPUA/gD/AAABCQEbARwBPwFWAVoBZAGBAcABHAIBAA0AFgAsAIEAhwCPAJEApACmALYA9QAqAUgBWgFbAWIBZAGyAbsBwAEeAgEABwAaACwALQBNAIcAjgCPAJEAmACkAKYAqQC4AOgA6QD2AP4A/wAWASQBWgFkAW8BdQGEAQIACwANAE0AXgBjAGUAfACHAIkAkQCYAJsAowD2AP8AGwFGAUgBVgFiAW8BdQGEAbsB/wECAAcACwA/AE0AXgB8AIIAjQCPAJEAuADoAOkANwFGAVYBZAFqAeYBAQAHAA0AKgA/AEIATQBsAG4AggCNAI8AkQCYAJsAtgC4AMQA3gAJARcBGwFFAUYBSAFWAVoBZgFqAXUBqAHBAQIABAAHAA0AKgBIAFUAfACJAI8AmAC2AMgA3gDjAPYA/wAJARoBGwE7AUQBSAFWAVoBYgFmAWoBcgF1AYQBhQGHAcEB9gECAAsADQA/AEgAjgCYAN4A4wDuAPYA+AAJAR0BOwFqAXUBhAGHAZcBqAG7AfcBBAALAA0APwBCAF8AfACOAKQAtgDDAN4A7gD2APgA/wAJARcBGgEbAUIBRwFIAXABlwEEABoALQBCAF8AfACkAKYAvAD2AAkBGgEbAUQBRwFLAXABjwG7AQ0AGgAtAHwAggCmALAAtgDuAP4A/wAIAUQBTQFiAZcBuwG8AQQADQBfAIIAjwCYAOgA6QDuAP4ACAE7AUYBTQFqAZcBuwECAAQADQA/AGgAjwCbALAAwADCAN4A7AD4AP8AGgEbATsBRgFNAWIBpwGzAQIABwANABoAPwBNAI8A3gDoAOkA+AAbATUBRgFWAWIBZgFqAc0BGgBNAF8AgQCYALsA4wD+AAgBDQFEAUYBSAFiAWUBZgFqAX0BuwG+Ac0B5wHtAQcAegCBAIIApgCwALsA4wD2AP4ACAEXARoBRAFIAV0BZgFqAXABhAG7Ab4BvwH7AQQABwA/AEIAggCbAJ8ApgDeAPYA/wAVARcBGwEqATsBSAFWAWYBbAFwAYQBqAHEAQcADQBCAE0AjwCYAKYAsADaAN4A7gD4AP8AFwE7AUsBVgF2AX4BzQENABoAQgBNAHMAmACmAOEA7gD4AAsBFwEaATsBSAFLAVUBYgFqAXABvgEtAD8AQgB0AI8AsADjAO4A/QAIARoBOwFFAUgBSwFVAVYBYgFmAXABvgHNAQcADQAWABoAPwBBAEIAiACPAO4A/QD/AAgBEwEXASkBRQFIAUsBVQGBAYIB1QHtAQcADQAPAC0APwBBAIIAjwCYALsA+AD/ABcBOwFEAUUBRgFLAVUBYQFmAXwBwAHnASoAQgByAIEAggCPAN4A/wAlAUQBSwFMAVsBYQFmAXABwAHCAdYB5wEHAB8AIwBNAI8AsAD4AP8AcAHEAdgB4QHtAQcALQBCAH8AggCwABYBIwFIAVgBZgFwAYQBvgEaAEIATQCBAN4A7gD/AAABRAFIAVUBVgFjAWUBaQGgAb4B4AEtAHAAggCwAMcA4QD4AAABOwE8AUQBSAFLAWkBeAGZAaABGgAfAHAAfgCIAO4A+AA7AUQBSAFMAU4BWAFjAZkB4gEHAA0ADwAQAB8ALQBAAE0AcACCAJgA9ABEAUwBWAFjAQsADQAQABYALQBAAHAAiACOAJgApgD0APUA+AA7AUgBSwFMAXYBmQELAA0AIwAtAE0AcAByAIIAjgCmALsA9QD4AAsBOgE7AVQBVQFYAZsBwAENABkALQBNAHAAmADjAO4AOgFVAWYBaQGEAZgBpQGqAcABwwECABkAGgBNAJgA7gD1AAsBOgFEAUgBTQFVAVgBaQFwAaYBqQG6Ae0BAgAHABoANwBBAHAAcwCOAPUA+AALAUgBTQFVAWEBYwFmAWkBcAGEAZ4BpgG+ARoAQQBCAHAAcwCOAJEApgDeAO4A+AAAAQsBRAFPAVgBYwFpAWwBqwG+AQ0AQgBsAG4AdACRAJgA2gAzAToBOwFEAUgBVQFhAWMBZAGEAaYBvAEMABoAcACPAKYA3gDuAPUAAAETAToBOwFLAVUBYQFkAWkBcgGmAb0BAgANACwAQACPAJEAmACvAN4A9QBGAUgBTQFYAWQBZgFpAWwBhAECAAwADQAsAC0AVgCRAKYA3gD9AP4ACwEVATsBRQFGAUgBTQFRAVgBYQFkAWYBcAGEAQIADQAaAC0AQABIAFYAcACdAKYAsgDuAPUACwE7AUUBRgFIAUsBTQFRAVgBYwFkAYMBzgECAA0AGgAtAEEAcACdALIA3gD2AP0A/gBIAUsBVQFhAWYBkgGhAQIAGgAtAEEAkQCyAN4A4gDsAPUA+AAXAToBSAFLAU0BUQFVAWEBZAFmAXABegGEAaEBAgAHAA0AGgBKAE0AggCYAKYA4gDsAPgA/gAAASMBOgE7ATwBRgFLAVUBWAFjAXABdQF6AYQB3gHyAQIABwAaAE0AggCOAJEAmADeAO4A9QD+AAABCwElATsBRgFLAU0BVQFhAWYByAHfAeQB6AECAEoAXwCCAI4AjwCmAN4A7gD3AA0BRgFLAVEBVQFYAWYBegGkAegBAgANABoAHwBKAHAAggCOAI8ApgD1APgA/gAAAQsBDQE8AVEBWAFiAXABewGEAZQBoQGmAcABwwECAA0AFQAfAEIASgBwAI4A4gDuAPgACwE8AUsBTQFhAWIBZgF6AZIBpgG2AbkBvgHUAQIADQAVABoALQA4AEAASQBfAHAA3gDiAP4AAAE7AUQBTQFkAWYBbQFwAXoBhAG2Ab4BwAHUAecBIAICAA0AQABCAEkAcACYALIA3gDuAPgACwE6ATsBRAFFAUgBTQFRAVUBWAFdAZsBpgHnAfMBIAICAAcAFQAaAC0AQgBIAHAAkQCYAKYAuwDeAO4A+AA6AUUBTQFVAVgBYQFkAaEBpgHCAcQB4wHuASACDQAVAB8AKgAtAEIAggD4ABsBOgE9AUUBSwFVAVgBbgFzAR0CHgIaACoAQACCAJEA3gD4AP4AHQE8AUUBSAFNAVgBZAFrAXABcwGEAa8BIwAqACsAQAB/AJEAkgDuAPUAAAELARsBPAFFAUgBYQFiAW4BdAF4Aa8BuwHdAecBBwAaACMAKwBCAI4AkQCYAKYA4gDuAPUA/gAAAQsBGwE8AUgBTAFNAWIBZAFrAXABcwF0AXoBhAGeAaYBrwGxAbsB5wH0AQcAIwAqAC0AQABCAH8AggCRAJIApgDeAOIA9QAAAQkBRAFLAVUBbAFwAXMBdAF6AYQBngGhAaYBrwEHABUAFgAaACoALQBAAEIAcwCCAJEAmACmALsA4gDpAO4A9QD+AAABCQELARsBQgFEAUsBTQFhAWQBbQF0Aa8BsQHIAdkBKQIVABYAGgAtADgAcwCmALsA3gDiAOwA7gD4AP4A/wAIAQsBDQE8AUgBTQFcAWEBZAF0AZ4BqwHZASoAkQCmAOkA7AD8AP8AAAEIAQ0BGgEbATsBPAFIAUsBVQFYAWEBZAFpAXABdAF+AYQBnwGgAa8B+QEWABoAKgA4AIgAjwCYAOwA+AAAAQgBEgEaARsBOwFEAUYBSgFVAVgBWwFhAWIBZgFrAXABegGEAQcAKgAtAEIAcACIAI8AmACmALAA4gD/ADsBTQFiAWQBZgFrAXABegGEAaYBrwH9Af8BBwAjACoALQBjAIgApgCwAOIA7AD/AAgBCwEaAUYBSAFKAU0BYgFkAaYBrwGxAQcAFQBjAHMAgQCYAKYAsADiAOwA7gAIAQsBGgFHAUgBVQFcAWEBYgFwAXoBsQEVACoALQBCAEUAYwBwAHMAgQCRAJgAngDiAOwA7gD8AP8APQFHAWEBYgFkAXoB5wH7AQcADQAaAC0AOABCAF8AcACBAJgA3gDjAOwA7gD/AAgBPQFIAUsBXAFkAWYBcAF0AdkBBwAqAF8AcABxAIEAmADiAOMA7gD8ABoBHwFIAVwBXgFkAWYBdAF6AbAB2QHoAewBBwAWABoAKgAtADgAQgBwAHEAkQCSAKYA9AD1APgA/AAIARoBHwFGAUgBVQFeAWIBbgFwAXoBsQG7AbwBxAHFARUAFgAaACoALQBCAHAAjwCRAJIAsADeAOIA7ADuAPQA9QD4AAABCAFGAVQBYQFiAWYBcAF0AYQBqgGxAbsBxQHmARUAOABwAHEAjwCRAO4A9QD4AAABRgFIAV4BYQFiAXoBhAH9AQ0AFgAtAHAAcQBzAKYAwADiAOwA7gD1AAgBGgEfAWQBZgFwAXoBvgH+AQ0AFQAWAC0AcQBzAMAA4gDsAO4A9QALAR8BVQFeAWEBYgFkAXABngG7Ab4BxQHZARUALQA4AEIAcQCRALoAvADIAOIA7AD1APgARwFhAWIBZAFwAXQBegGeAaYBuwHFAdkBLQA4AHAAcwCvAOgA6QANAT0BRgFHAUgBYgFkAWYBcAF0AXoBhAGeAbABsQG7AcUBGwItADgAQgCBAI8A9wD8AP8ACAEaAWEBcAF6AaIBpgGwAbEBuwHDAcUBAAIoAi0AOABCAIEAngCkAPUA/AD/ADsBRAFGAU0BTgFVAWEBZgFwAXMBegGwAbEBOABCAHMAgQCPAJEAmACkALoA3gDuAPUAAAESATsBRwFNAVUBYgFzAXoBgwGeAbABsQG+AQ==", + "provenance": { + "method": "chrome-solid-face-id-exact-interior-pixel-census", + "route": "http://127.0.0.1:4325/cityflow/", + "presentation": "schema-24-folded-adaptive-smooth-sine-27-57-0.55-eased-global-motion-uniformization-0.4-60hz" + } +} diff --git a/src/adapters/cityflow/notes/references/prepared-visibility-sine-power.json b/src/adapters/cityflow/notes/references/prepared-visibility-sine-power.json new file mode 100644 index 0000000..283587e --- /dev/null +++ b/src/adapters/cityflow/notes/references/prepared-visibility-sine-power.json @@ -0,0 +1,484 @@ +{ + "schema": "csscityflow-browser-visible-face-id-source@1", + "encoding": "initial-bitset-plus-u16le-per-target-frame-toggle-offsets-and-face-indices", + "browser": { + "name": "Google Chrome", + "version": "152.0.7977.65", + "headless": true + }, + "viewport": { + "width": 1280, + "height": 720, + "deviceScaleFactor": 1 + }, + "frameCount": 301, + "faceCount": 600, + "initialVisibleCount": 236, + "minimumVisibleFaces": 190, + "maximumVisibleFaces": 260, + "meanVisibleFaces": 229.30564784053155, + "visibleFaceCount": 452, + "visibleFaceIndices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 11, + 12, + 13, + 15, + 16, + 17, + 18, + 20, + 21, + 22, + 24, + 25, + 26, + 27, + 29, + 31, + 32, + 33, + 35, + 36, + 37, + 38, + 39, + 42, + 43, + 44, + 45, + 46, + 47, + 48, + 49, + 51, + 52, + 53, + 54, + 55, + 56, + 57, + 58, + 59, + 60, + 61, + 62, + 63, + 64, + 65, + 66, + 67, + 68, + 69, + 70, + 72, + 73, + 74, + 75, + 76, + 77, + 78, + 80, + 81, + 82, + 83, + 84, + 85, + 86, + 87, + 88, + 90, + 92, + 93, + 94, + 95, + 96, + 97, + 98, + 99, + 101, + 102, + 103, + 104, + 105, + 106, + 107, + 108, + 110, + 111, + 112, + 113, + 114, + 115, + 116, + 120, + 121, + 122, + 123, + 124, + 125, + 126, + 127, + 129, + 130, + 131, + 132, + 133, + 134, + 135, + 136, + 137, + 141, + 142, + 143, + 144, + 145, + 146, + 147, + 148, + 152, + 153, + 154, + 155, + 156, + 157, + 158, + 159, + 161, + 162, + 163, + 164, + 165, + 166, + 167, + 168, + 169, + 170, + 171, + 172, + 173, + 174, + 175, + 176, + 178, + 182, + 183, + 184, + 185, + 186, + 187, + 188, + 189, + 190, + 191, + 192, + 194, + 195, + 196, + 198, + 199, + 200, + 210, + 211, + 216, + 217, + 218, + 219, + 220, + 221, + 222, + 223, + 224, + 225, + 226, + 227, + 228, + 229, + 232, + 233, + 234, + 235, + 236, + 237, + 238, + 239, + 240, + 242, + 243, + 244, + 245, + 246, + 247, + 248, + 249, + 250, + 251, + 252, + 253, + 254, + 255, + 256, + 257, + 258, + 259, + 260, + 261, + 262, + 263, + 264, + 265, + 267, + 268, + 269, + 272, + 273, + 274, + 275, + 276, + 277, + 278, + 279, + 280, + 282, + 283, + 284, + 285, + 286, + 287, + 288, + 289, + 290, + 291, + 292, + 293, + 297, + 298, + 303, + 304, + 305, + 306, + 307, + 308, + 309, + 311, + 312, + 313, + 314, + 315, + 316, + 317, + 318, + 319, + 320, + 321, + 322, + 323, + 324, + 325, + 326, + 327, + 328, + 330, + 331, + 332, + 333, + 334, + 335, + 336, + 337, + 338, + 339, + 340, + 341, + 342, + 343, + 344, + 345, + 346, + 347, + 348, + 349, + 350, + 351, + 352, + 353, + 354, + 355, + 356, + 357, + 358, + 360, + 361, + 362, + 363, + 364, + 365, + 366, + 367, + 368, + 369, + 370, + 371, + 372, + 373, + 374, + 375, + 376, + 377, + 378, + 379, + 380, + 381, + 382, + 383, + 384, + 385, + 386, + 387, + 388, + 389, + 390, + 391, + 392, + 393, + 394, + 395, + 396, + 397, + 398, + 399, + 401, + 402, + 404, + 405, + 406, + 407, + 408, + 409, + 411, + 414, + 415, + 416, + 417, + 418, + 420, + 421, + 422, + 423, + 424, + 425, + 426, + 427, + 428, + 431, + 432, + 433, + 434, + 435, + 436, + 437, + 438, + 441, + 442, + 443, + 444, + 445, + 446, + 447, + 448, + 449, + 450, + 451, + 452, + 453, + 455, + 456, + 457, + 458, + 461, + 462, + 463, + 464, + 465, + 466, + 467, + 468, + 469, + 470, + 471, + 472, + 473, + 474, + 475, + 476, + 477, + 478, + 479, + 480, + 481, + 482, + 483, + 484, + 486, + 487, + 488, + 492, + 493, + 494, + 498, + 499, + 500, + 501, + 502, + 503, + 504, + 505, + 507, + 508, + 509, + 510, + 511, + 512, + 513, + 514, + 515, + 517, + 518, + 538, + 539, + 540, + 541, + 542, + 543, + 544, + 545, + 550, + 551, + 552, + 553, + 554, + 559, + 560, + 565, + 566, + 582, + 583 + ], + "transitionCount": 7188, + "initialVisibleBitsBase64": "/yFUAQAAaYoACID1x9cVv3BgGVe8/wHgQAEMfzT8RQ8cKfokL4AH2F9M195FX5om338Aw7MDMSYEhwHg2HEc2wUAAGgBAwAAAAAA", + "transitionOffsetsBase64": "AAAUACoAQABTAGgAeACOAKEAtgDNAN0A8QAFAR4BMgFIAV4BewGYAbQBygHeAf0BGwIvAkYCXAJ+ApsCsgLFAtgC8gIMAyIDNwNSA2wDigOeA7MDywPiA/oDDQQjBDkEVARrBIgEnAS1BM8E5QQFBScFRwVgBXIFiQWZBagFugXPBeoF/AUVBiwGQAZSBmoGggahBr4G1QbvBgYHJQc/B1MHZgd5B44Hoge7B84H5Af3BwkIIAg0CEwIYgh1CIoIoAi5CNMI5gj2CAUJEQkiCTQJRglWCW8JhgmdCbQJyQneCfIJBwofCjIKSQpZCnUKjwqjCrwK1QriCvIKEAsrC0QLUwtsC4cLmguuC8ALzwvpC/8LGgwrDEMMWAxzDIcMmwyxDMcM4Az4DAsNIw01DUYNYQ2FDaoNwQ3jDQEOIw5PDnQOkg6qDsgO4A76DhEPLg9GD18Pdw+KD6APuA/PD+cPAhAPECoQSRBlEIAQmhC6ENMQ8hANESoRSxFbEW8RixGoEcYR2hH0EQsSJRI2Ek0SaBKBEpUSsBLXEvMSExMuE1ETaRN+E5kTvBPOE+YTAhQcFDgUURRpFIkUqhTFFOMUAxUWFS0VShVfFXYVkRWoFcMV3hX1FQYWIBYyFkgWXBZzFo0WpRa8FtQW6Bb/FhMXLRdGF1kXbBeDF5IXoxe9F9cX6hcAGBYYKRg/GE0YZRiBGJoYuBjQGOoYAxkdGT8ZYBl0GZYZsxnRGegZBhomGj8aVxp5GpEapxrFGtwa8xoMGy0bPBtRG2UbehuKG5sbshvKG+Mb+BsUHA==", + "transitionFaceIndicesBase64": "DQBwAHEAfAB+AH8AkQCkAN4A/wAIARoBSAFUAXABsAHFAdkBAgImAg0AQgBwAHwAfgB/AI0AjgCRAJgApADHAN4AGgFVAa8BsAG7Ab4BxQHZAQICDQAqAEIAXwBxAI0AmAC6AOIA7gD/AAgBSAFNAXoBhQGwAbsBvgHFAdkBJgINACoAWABfAHAAcQCNAP8ASAFVAV4BZgFwAZQBrwHIAeYB8wEmAnAAcQB+AJEAmAC6AN4A4gD/ABoBRwFMAVUBXgFiAWYBbQFwAfkB/gEhAg0AFgCYAKYA3gDuAP4ARwFIAVUBYgFmAbABvgHZAf0BDQAtAEIAfgCSAKYA3gDiAOgA6QD4AP4A/wBGAV4BYQGwAbEBvgHZAfgBHwJCAHAAcwCNAJIAugD4AP8AGgFIAV4BYQF6AZ8BoAGiAbEBvAH4AUIAXwBjAH4AugDAAN4A4gDuAPgACAE7AUgBegGeAbABwwHTAdkBGwI1AhYAXwCNAJIApADAAOEA4gDjAPgA/wAFATMBOwFIAVUBYQGqAdkB3gHnAegBNQJjAHAAcwB+AKQA/AAIAUYBYQFuAXoBqwGwAb8B0wH0AWcAcwB+AI0AjwCSALoA6QDuAPwACAEQARoBRgFIAVUBegGxAb8B+wF+AI8AkgC6AOgA+AD+AP8ACAEQARoBOwFIAVUBZgF6AasBsAG/AQACQgB/AI0AkgDiAPgA/gD/AAABCAEaATsBSAFPAV4BYQFmAXoBewGwAbEBuwG+AdEB3wErAC0AQgBwAH4ApAC6AOIA7gAAAQgBOwFIAWEBegGqAb4BzgHRAfgBFAAbACsALQB/AKQAugDeAOgA6QDuAPgAAAEIASQBRgFeAWEBcAF6AbsBvwEtAHAAfgCNAKQA3gDiAO4A/gD/ABoBRgFIAV4BYQGeAasBsAGzAbsB0wHZAS0ANABCAHAApACmAOIA7gD4APwA/gD/AAABCAEaAUcBSAFeAWUBZgFuAXABegGwAbsBvgHFAdMB2QEbAEIAfgCYAKYAugDiAPwA/wAAAQgBFwFGAUcBSAFeAWUBZgFuAXoBqwGxAbsBvgG/AcUBzwHfAewBJAA0AH4AjgCYAKQA4gDoAOkA/gD/AAABBgE7AUgBXgFlAWYBbgFwAXQBrAGxAbsBxQHQAd4B8gFCAHAAjQCOAI8ApACmAP4A/wAaATsBXgFhAWUBdAF6AasBrAG9Ac4B5wEfAg8AJABCAHAAfgCOAI8ApgD1APYA/AAaAUYBSAFjAXABmwGgAasB5wEPABsAJAA0ADUAQgB+AI4AwwDpAO4A9AD1APYA/AD+AFsBXgFjAWYBbgFyAXwBsAGxAbsBwAHFAdMB9wE1AhYAGwAhACQANAB+AIEAhwCNAJEApAC7AN4A7gD0APgA/gD/ABoBOwFEAUYBVQFbAbABsQG7AcAB0wE1AhYAIQAqAIEAhwCNAKQA3gD2APgA/AD/AAABGgE7AUQBRgFIAWUBzwEqAC0AbABuAI0A7gD1APYA+AD8AP8AAAEXAWUBcAGrAbEBsgG2AbsBxQHPAfMBDwAhAC0ANQB+AI0ApgDeAOkA9gD/ABcBRgFPAWEBcAGrAbEBuwG+AcUB0wEPACEANAA1ADgAZwB+AJgApACmAN4A7gD1APYA+AD8AP8AFwFIAWEBZQFwAaoBqwGvAbEBsgG+Ac8B0wHZAfIBIQI1AioALQA0AEIAgQCYAKQApgC4APYA/AD/AAgBSwFPAVUBYgFwAaoBqwGvAbABsQG2Ac8B0wHYAdkBNQIUACoALQB+AIEApgC4APYA/QD/ABcBOwFIAVUBYgFkAbABsgHFAcgBzgHTAeIBFAAkAC0AQgBLAJEAuADiAP0A/gAIAUQBRgFPAVUBYQFkAbAB+wEkAH4ApADeAOIA7gD4AP4A/wAcAUsBVQFhAbEBxQHPAdAB2QHnAUIAfgCkAKYAuADeAOEA7gD4AP0AFwEaATsBSAFdAWEBdAGvAbABsQGyAbsBxQHZAeYB9gEEABIAFAA0ADUAQgCmALwA9QD9AP4AAAEXAUQBRgFLAVUBYgFkAWkBrwGxAb4BxQHZAegBBAAPABQANAA1AH4A7gD1APgA/AAAARwBOwFGAU8BZgFpAXQBsQG+AdkB/QEPABYAIQA1AEIAfgCyAO4A+AD8ABoBTgFPAWEBYwFkAWYBcAGsAbsBJgIEABQAFgAhACoANQBCAF8AjQCyAPYAFwEaAUsBTwFhAWMBZAF0AYQBqgGrAbsB4QHoASYCKAIEAA8AFAAWAI0AsgDeAOkA7gD2APgA/AAXARoBRAFGAUsBTwFVAXABjQGsAbEBuwHZAf4BBAAPABYAIQAqADcAXwB+AI0AmADeAOkA7gD1APgAAAFEAU8BVQFkAXQBjQGgAawBsQG7Ab4BxQHaAewBFgAhACoANQB+AJgAsgD2ABoBRgFPAWQBdAF+AY0BsQG7Ab0BvgHaAQQADwCyALgA9QD+AP8AAAFPAWIBZAFmAY0BoAGsAbABsQGyAbsBxQECAgQADwAWACoALAA1AH4ApgC4APgA/ABEAUYBTQFPAWIBaQF0AY0BpgGyAbsBwAHDARUAIQAsAH4AjQCkAKYA+AD/AEsBVQFhAWIBZgFwAXQBhAGmAawBsAGyAcAB1gEEAAsADwAVABYAKgA1AI0ApAD4AP4AGgFGAU0BYQF0AY0BpgGsAbEBvgHAAcUB8wELACEAKgCNAPUA+AD+AP8ARAFGAUgBTQFmAXABhAGxAbIBvgHbAQ8AFgAhACoANQB+AI4AuADeAPUA/wAIAUgBYQFwAXwBjQGyAcAB1QHbAe4BFQAWAIEAjQCkALgA3gD4AAgBRgFIAU0BTwFVAWIBcAGNAa8BsQG7AcUB9gECAAQADwAVACEAjQCkAKcAuAD2APgA/gAaAUYBTQFPAWIBjQGfAaIBsQGyAbsB1wHZAQMCGwIEAAcACwAPABUAIQAsAHwAfgCNALAAuAD+AP8AAAEXARoBRAFIAU8BcAGyAdcBAgAEABUAIQAsAEoAfAB+AKQApQDuAPUA9gD+AAABFwEaAUYBSAFhAWYBcAF1AY8BogGyAdUB2AEaAgIABAAHAAsAFgAqAEoApAClALgA7gBGAUgBTwFVAWEBcAGEAb8BwAECAAcAMwBLAKQApQCnALgA9gD+ABoBGwE7AUYBSAFNAU8BZgGEAYUBvgHAAd4B5AEAAgQASwB+AI0ApACnAPUA9gD+AP8AAAEIARoBOwFEAUsBTQFPAWEBZgFwAXYBsAGyAb4B4gECAAQACwAWACwAXwB+ALIA/gAAARoBGwE7AUYBSAFVAWEBZAGFAb4BxAHyAQIACwAUABYASgBLAFQAggClALIAuAD1APYA/gAAARoBRgFLAU8BVQFkAWYBjQGiAbABsgG+AcQB0gHTAdYB3AECABQAFgAsAEoASwBUAF8AggCNAKcA+AD9AP4A/wAaAR8BOwFGAUsBTwFVAWEBZAFmAXABegGEAY0BsAGyAb8B0wHfAQQABwAWACEASwB+AI0ApQCnALgA9AD1APgA/QD+AP8AAAEaAR8BSwFNAVUBcAGEAbABsgG+Ab8B0gHcAeEB6AEHABYAIQBCAI0ApQCnALgA4gDpAO4A9AD+ADsBSAFVAWEBZAGbAbABvgHSAdwB3QHjASEAIwBCAH4AuAD1APgACAEbATsBRQFIAU0BYQFkAZEB0gHcAQIAFgAhAIIAmAClAKcA4gDpAO4A9QD4AP4ACAEaARsBNQE3ATsBTQFkAbABsgECABYALAB+AIIAjQCYAKUApgD1AAABCAE7AUgBYQEcAhYALACCAI0AjgClAOIA/gD/ADsBSAFmAaYBsgEmAgQAFgCCAI0ApgDuAPgA/wA1AVUBYQFmAXYBqwGwAdgBGgImAgQAIwBeAIIApQCwAN4A4gD2AAABKQE7AU0BVQFhAXYBsAGyAdkBJwIoAgQAXgB+AIIAjQCYAKUApgCnAN4A7gD1APgA/wAaASkBSAFNAU8BYQFkAXYBhAGyAe4B9QEfAgQACwBAAF8AfgCmALsA4QD2AAABGgFIAU0BTwFVAWQBhAGNAQIACwAWAEAAXgB+AI0AkQCmALcA4QDjAPgA/wAAAU0BTwFmAWkBegGEAaoBsAG9AUcCAgAWACEAXgCNAJEAmwClAKYApwC5APIAAAEXAWYBcAF6AXsBjQGvAbABuwH4AQQAIQBeAH0AfgCYAJsApQD4AP0A/gAXARoBVQF+AYMBogG7AeMBJwIEACwAXgB+AKYA/QD+AP8AAAEWARoBPAFhAWIBawF1Aa8BJwIBAAsALABAAEEAXwCNAJEApgDuAPAA/wAqATsBPAFPAVUBYgFkAWsBcAF6AdkBAQIHAEEAXgBfAH4AkQC4AOkA7gD4AP4A/wAqATsBSAFPAWEBZgF6AYMBsAHdAfIB9QEBAAQABwALACwAQABBAF4AewCCAJ0ApgCnALgA6QD4AP4A/wAAATwBSAFVAWEBZAF6AasBsAHHAQECAgIDAhgAIwAsAEsAfgCNAJ0ApgCnAO0A7gD8AP4A/wAAARcBSAFPAWEBZgFwAXgBegGDAasBrwG9AccB2AEBAAQAIwBLAHsAggCNAKYAuAD2AP0A/gD/AAABFwE7ATwBSAFTAVUBYQF4Ab8BBwALADAAVgBeAH4AgQCCAI0AuADtAPYAAAEXATsBSAFNAVUBWwFiAXABegGrAd8B5AEnAgcACwBWAH0AfgCCAI0AkQCmAO4A/gAXATwBRAFNAWEBYgFkAWYBcAGwAb8BwwEjAEsAZQCNAJEApgC4ALsA7QDuAPYA/gAaATwBRAFIAVUBWwFkAWYBaQFwAXoBqwGwAcMB0wH0AfkBJgInAiEAIwBLAEwAXQBlAHMAfgCkAKUApgCnAOkA7QDuAPYAGgE8AUgBWwFpAXABrAHTAdQBJgIEACEApAClALgA6QDtAPYAOwE8AVUBZAGiAawBsAHUAd4B8wH3AScCBAAsAEEATAB7AH4AjQCnAOkA7QD2AE4BWAFhAWQBcAGwAccBMAJBAKYAxwDpAAABIQE7AU0BTgFYAVsBYQFiAWkBawFwAbABxwEwAiwAQQBCAEsAjQCkAKUApgCnAAABPAFPAWEBYgFmAWsBcAF6AZQBogEnAkEASwB7AKQApgCnALgA7QDwAPYA/wAIARoBOwE8AUQBTQFbAWUBaQFCAI4AmACmAKcAuADyAAgBGgEbATsBPAFEAU8BWwFiAWUBZgFwAXIBegGRAbABxwEnAkEAjQCRAJgApADjAO0AGgE7ATwBRAFIAU8BWwFiAWUBjwGwAccBjQCkAKYA3gDjAO4A9gD4AP8ABAEIARsBOwFIAU0BWwFhAWIBZAFlAWYBcAEEAIMAjQCRAO4A+AAQAUQBSAFNAU8BWwFiAWQBZQFmAa8BsAG8AQQABwAYAEEAgwCNAO4A9gD/AAwBEAEbAU0BYQFkAWsBsAEnAgcAPAA9AEEASwCIAOkA9gD/ADcBSAFbAWEBZQFmAWsBbQF6AZsBrwGwAdkBJwIwAD4ASwCNAKYAyADeAOkA7gAbATsBTQFPAWQBcAGPAZsBqgGwAdkBBwBBAHMApADeAPQA/wAbASkBOwE8AUQBSAFPAWYBcAF6AX8BjQGRAaYBzgHZAScCBwBBAEIAiACkAPQA9gD/ACkBOwE8AUQBRwFNAVsBXQFhAWYBegGNAaYBJwJCAJgA3gDuAPUA+AAVAUQBTAFhAWUBZgFsAXABegGwAbsBxQHmAQQAkQCYAKYAwADeAPAA8gD2ACEBRQFbAWQBaQF1AY0BsAG7AcUB2QH5AQQAPQCIAI0AkQC5AL8AwADeAO4A9gD4AAUBSwFmAWkBcAF1AXoB2QH9ASgCPgBBAFgAewCBAKYAtwDeAO4A/wBLAVsBZAFmAWwBcAF6AZsBpgGrAbAB2QH+AScCKQI9AEEAgwCRAJgAwwDeAOkA7QDuAPYA/wASAUsBWwFhAWkBbAF1AY0BrwGwAcUB2QH4AScCPQCNAJEAmACmABIBJAE/AVMBVQFhAWYBcAF0AXoBrwGxAcUB2QEEAI0A7QD2AGQBZQFmAWkBawF0AXUBegGDAYQBpgGwAQQAggCmAN4A6QDtAPUAGwFGAWQBZQFrAXoBsQEbAkIAggD/ABsBYQFkAWYBaQF1Ab4B2QFHAkIASgCCAI0AmACmAN4A9gAbAWQBaQFyAX4BgwGmAb4B2QFBAEgASQCCAI0AsgD4AP8AGwFGAUoBZgFwAXoBhAGfAbEBuwFBAEoAiACNAJgApgCyAOIA7gD4AP8ARAFiAWsBdAGmAbEBuwEEAD8ARQBJAIIAjQDiAOMA7gD3AEQBRgFwAYQBpgGwAQYAPQBJAEoAggCNAJgApACyAMMA4wD/ABsBJQFlAYEBrwGwAbEBuwHFAdkB9AEdAiACBABBAEkASgCYAKQAwwD/ABcBRQFKAWQBZgFwAXoBggGhAbEBuwG8AcUB2QEdAgQAQQBCAEkASgCJAI0AmACmAMMAFwEdASMBRQFkAWYBcAF6AaEBogGxAcQBHQIEAEIASgB9AIIAiQCNAKYAwgDDAPUABwFKAWYBaQFwAXoBhAGhAaYBsQHEAe4BSgCCAMIAwwD1AP8ABAFEAUUBVAFkAWYBaQFwAXoBhAGmAa8BuwHDARwCQQBCAEkASgCNAJgApgDDAPwA/QBGAWkBagF1AaYBsAGxAbsBwwHZAR4CLQBBAEIASwBTAI4AmACmANMA6ADpAEQBRQFGAWoBdQGhAbABsQHZARYALQBCAEsAfgCNAJEAmACmAO4A/QD/AAUBRgFPAWkBdQGhAa8B/QEfAhYAQQBMAFMAfgCRAKYA6ADpAO4A/QD/AE8BYgFkAWkBawF0AXUBpgGwAbEBuwHZASEAQQBLAEwAfgCmAO4A9wD9ABMBPAFFAWkBcAGmAbABsQG7AdkBAQAhAD8AQgCDAJgApgCyAO4A/QD/ADQBPAFFAU8BYQFqAXABsQHOAdkB5gHzAQQALQA+AEwAjgCmADwBRAFPAWkBagGfAbABsQHFAdQBBAAbAEAASwBMAF0AfgCBAI4AlACYAKYA3gDjAOgA6QA8AUUBdAGDAaEBogGwAbEBxQHUAfgBKAICAAQADwAjAEAAQQBCAEMARABeAF8AfgCYAKYAwwDjAPYA/QBGAU8BdAGiAaYBsQHZAfgBAgBDAEQASwBfAI0AuADeAOsA8AD/AEQBRQFGAU4BUwGhAaIBpgGrAQIACwAhAC0AQQBYAF8AfgCNAJEApgC4AOkA6wDuAPAA/QD/ABcBRAFOAVMBdAGiAawBAgAhAC0ALwBBAJEAmADoAO4A/QAHASkBRAFpAXQBlQGWAZcBmwGmAawBsAHUAegB+AELAEwAUwDDANkAMAFqAX4BpgGwAccB1AEfAhsAIQBBAEQAUwB+AIIA7gD4AEgBTwGDAYUBoQGwAdEBIQAjACUAKgAvAEEATABTAF8AfgCCAOsA7QDuAPUA+AD9AGIBaQFqAYEBqgG6AccByQHRAegB/gEdAiECFQAWABkAIQAlACoALwBBAEQAUwBeAOgA6QDrAPYA/QBEAU8BYgFpAY0BqwHCAdQB6AEdAiACAgAVABYAIQAvAEUARgB7AH0AmACyANMA6ADpAP0AKgFEAUUBTwFrAasBvgHUAd4BAAICACMAewB9AH4AiACYAN4AFAFGAUcBaQFrAb4BygEwAD0APgBCAEYAUAB+AIEAggCIAI4AjwCUAJgA3gDuAPUAEQFFAUYBSAFiAWkBjQG+AUEAQgBQAF4AXwBwAH0AgQCCAIgAjwCYAKYArwDjAOsA/QAYAUABRQFGAUgBYgGiAb4B5AHtAQsALABCAGsAcABzAIgAjgCYAOMA8ADyAP0AMAFFAZcBogGvAfgBCwAVABYAKgAsAHsAfgCIAJgAsgC7AO4A8wD3AP0ASAF0AZUBlgG+ARUAPABCAHAAewCYAOsA7gD0AP0AEQEYAUUBRgF0AZ8BrwEpAhYAKgBvALIA6wDuAP0ASAGNAagBrwGyAR0CHgIfAgIAFQAhACwAQgBzAH4AkQCSAK4AsAC8AOgA6QD9AP4AIwFAAUUBTgFPAXUBjQGoAbIBvgECABUAIQAsAEgAcwCIALoA4wDrAO4A/gATARsBJQFFAUgBawF0AaIBqgG+ARYAQABCAH4AiAC/AOMA6wDzAPQAQAFIAUsBTAFOAU8BawF0AXoBhwGiAaoBvgHHAe4B9wH8ASAAQgBLAF4ApwAkAUQBTwFiAWsBbAFtAYEBhwG/AccBGwICABYAIABeAGsAewB+AO4A/QAbAUgBTwFiAWYBawF1AXsBqwG/AccB9QEAAiECJgICACwAXgB+AJgA3wDoAOkA6wDuAP0AAQEIAT8BSAFmAasBxQHHAe0BJgICACwAVgBeAHIAggCIAJgA2QDfAO4A/QABAQgBGgFIAU8BVQFrAXABggGUAasByQHKAdgB2QECAIIAiADrAO4ACAEaASoBSgFiAWQBZgFoAWsBcAGHAccByQEmAjACCwBfAHQA2gDjAOsA7gD9AAgBFwFPAWIBZAFmAYcBqwHFAckBJgIwAl4AdADrAO4A9QD9AP4A/wAIARQBFwFIAVUBaQFwAYcBqwG5AcUBxwHJAdMBCwCYAN4A6wD1APgA/QD+AAgBCQEXAUgBTwFVAWQBaQFwAYcBqwHCAdMB+AECABYAcgCYAN4A4gDrAO4A+AAIARUBGgFIAU8BVQFkAXABfAGLAasBwgHHAdMB/AEFAgIABwAWAF0AcQByAHQAfgCRAJgA4gAIARoBZAFqAXABdQF8AYsBjgGrAb8BxwHTAQcALABrAHEAfgDeAOEA6wD/AAgBCQETAToBPwGLAasBvwH0AQYCAQACAAcALACSAJgApgDeAOsA9wD8AP8ACAEVASkBZQFmAWoBdQGrAbABvwHkATUCAgAHACEAVQBrAH4AnwDeAOIA6wAIARoBTwFmAYsBvwHJATUCAQAhADwAiADiAOsA/wAIARoBOgFVAWIBaQF+AYsBsAG/AQEABwAWADMANABCAH4AggCPAJEAnwD8AP8AGgEdAToBUQFiAWkBagF1AYsBjgG/AdEB9gEcAgcAFAAWACEANQBCAFAAbABxAIIAjwCRAKEA/wAIAQwBFwE6AUYBSAFPAVUBZAFpAWoBcAF1AXYBhAGHAbEBxwHRAfYBIQI1AgsAIQAsADQAQgBGAF0AXwBuAHEAmAChAN4A7gD2APgA/wAIARcBGgE6AV0BZAFlAXABdAF1AXYBhAGxAb8BwgHFAegB+QEhAjUCBgALACwARgBQAF8AjwCYAPwACAEQAREBEgEWAUgBUQFkAWUBhwGyAcUBxwHfASEAKgA+AEIAUABVAF8AbAByAH4AjwCeANMA9QD8AP0A/wAQARIBGgE0AUgBYgFkAWYBagF0AYcBsgG5Ab8BxQEfAigCKgAvAEIARgBSAF4AXwBvAHAAfgCRAJQApQDIAN8A6ADpAPUA/AD/ACIBMAE4ATkBOwFPAWYBhwGyAd4BFgAaAB8AQgBeAF8AcACRAN8A9gD4APwA/QD/AAcBCAERARoBOwFGAU0BTwFhAWIBZgFwAXUBfAGEAbIB1AHfAfMBRwIQABYAGgAkACUAKgAvADAAQgBQAFIAXgBfAHwAkQCjAKUArgCwALoA2QDoAOkA/wAHARABOQE7AUgBTwFhAWYBagFwAXUBfAGEAYcBsgG9Ab4BxwHUAUcCSABQAF8AbgBwAHQAewB8AH0AfgCHAJEAmgCkAK8AugDrAO4A9gD4APwA/wAQAREBOwE9AUYBSAFPAWEBZAFqAXABhwG+AccB1QElACsARgBIAHMAngCkALoA3gDrAO4A9gD8AP8ACAEQAREBOQE7AUgBTwFhAWYBaAFwAXIBdQGwAbIBuwEkACUAPgBCAFAAXgCYAN4A6wD2APgA/wAQAREBOwE9AUgBTgFhAWYBagF1AbsB1gERACQAJQBCAEYASABQAF4AcwB+AIcAjgCYAJ4ApwDrAO4A8gD2APgACAEhATsBPQFPAWEBagGiAbABvQEbACsARgBQAF4AcwB7AH4AnAC6AMAA4QDjAO4A9gD9AP8ACAEaATwBSAFkAXQBvQElAD0APgBBAEgAXABeAGoAewDHAO4A8gD2AP0A/wAIAU8BYgFkAWUBdAF8AYcBjQGwAScCDQAaAEgAWgBwAIEApgCpALsAwgDrAO4A/wA7ATwBTwFlAXABhwGNAbABvgHXAQ0AGgAdACUAWgBeAGgAgQCOALoA6wDuAPYA/wAJARABEQE7ATwBRQFiAWYBcAF1AYMBsAG+AScCKAIHACUASABWAF4AcAC6AOsA+AD8AAgBEAERARwBOwE8AU8BZgF1AZ8BsAGyAb4BMAIHABkAJQBWAH4AggCOAJgA8wD4AP0AAAEHAQkBEQE7AUABRQFiAWUBZgGwAbIBvgEwAhEAGgAlADoASABcAIIAmAC2ALoA6wD4AP0A/wAAAQcBEQEeAUsBYgFlAWYBdAFHAgAABwA+AFMAVgB+AI4AmAC2APMA9gD4AP8AaQF0AYcBjQGwAcEBGgA+AEgAagCOAJEAugDeAPMA+AD9AP8AEQFEAUUBSAFpAYcBjQGqAboBwQEHABoAJAAlAC8ASABTAI4AkQCYAKYAugC7AMIA2QDeAPYA/QA7AUQBRQFpAX4BjQENACUAUwCmALoAwADgAO4A+AAHAREBOwFEAUgBVAFpAW4BfwGmAbABvgEnAi8CDQAaAD4AUwB+AKwA4ADuAPgA/wAHAQgBEgE7AUUBRgFpAWoBjQGmAbIBvgEnAjACBwASAD4ASAB+AKYAsAC6ANIA7gD2APgABwEIARIBOwFVAWUBbAFuAbABsgG8Ab4BBgInAjACBwC6AOgA6QD2AAcBCAESAR4BTQFVAY0BvgEHAA0AJQAvAEIARQBIAEkAagB6AIcAnADuAPMA9gD4AP8ACAERARIBFwE1AUsBVQGNAbAB+QEHAA0AIQBCAEwAVAB9AIcAngC6AOgA6QDuAPMA9gD4AP0ABgEIARABEgEXAVUBYgFkAZ8BxwHPAeABJwJGAhkALQBAAFQAaAB9AO4A9QD2APgA/QD/AAQBBwEIARABEQESAT0BRAFGAUcBSAFLAWIBbAHHAScCBwAQACYALQBTAHsAhwCiALwA9QD9AP8ABwEIAREBFwEaASIBRwFIAUsBTQFiAWwBdAF1AbABBwBIAEwAVgBwAHMAewCvAOMA/QD/ABABEgEkATABRgFNAWIBaQFyAXQBjQG7AeIBBQInAgcADQAmAEgAVgBzAH4AhwCiAK4A4wDzAPgA/wAFAQgBEAERARIBGgE4ATkBOgFLAWIBgQGNAaYBuwHFAc8BJwIHAA0ASABwAIcAogDzAPgA/wAaATwBRAFIAVUBXQFiAWkBjQGmAa8BsAGxAbsBxQHhAQ0AJgBEAEgAcABzAHsAfgCHAKIA4QDjAOkA7gD9AAcBEAERATsBRgFIAU0BWAFiAWwBjQGvAbABsQG7Ac8BDQAmAEQASABOAHAAcQB7AIIA6QDuAP0ABwEIARABEgFGAU0BVQFYAWIBZAF0AbABuwEpAjYCDQAmACcARABOAHAAcQCCAKIA6wD2AP8ABwE1ATwBRgFLAVUBWAFhAWIBZAFpAWwBdAF2AY0BuwE1Ag0ARABIAEwATgBqAHEAfQCiAOEA6QDrAO4A8wD4APwABwEIARIBFwEaASUBRgFYAWEBYgFkAXYBjQGiAbAB9AEnAiYATABOAIEAhwDpAP8AFwEaATwBSAFLAVUBYgFkAc8BGgAmAD0AWABvAIcA2gAIAUYBSAFLAU4BYQGiAawBsAG2AbsBzwEqAgAAGgAfAFgAbwB7AH4AhwCdAJ4A6QApAU0BYQFiAWYBaQF0AY0BoQGiAaYBrAGvAbYBuwHPASoCMQBCAEkAbwBwAHsAnQDpAOsA7gD4AAgBFwEcASkBNQFGAUgBSwFiAWkBbAF0AY0BkgGmAcABzwHzAQcAHwA8AD4AQgBIAEwAcABxAIEAhwCcAOsA7gD+AAgBFwE/AU0BYgFkAWkBdQGNAaEBsQHAAc8B3gEqAgcAHwBIAEwAcQB+AOsA+AD+AAwBPAFGAUgBSwFVAWQBbAGNAbsBzwEmACoAPgBIAGUAbwBxAIEAvADrAAwBNwFGAUsBWAFsAXUBhQGwAbEBuwG+AR0CKgIvAkYCGgAnAD4APwBIAFYAWABvAHoAewB+AIEA6wAhAUgBSwFYAWEBaQFsAbEB5AEeAgcAGgBIAFMAWABuAHEAewB+AK4A6QDrABMBFwFLAU8BVQFhAWwBsAGxAboBuwG+AR4CIAIHABoAOgA/AEAAfgCvAOkA6wD2AAgBFwEaAWkBbAGvAR0CGgA/AEQASABvAI8AlACaAOwA9gD9AAgBGgE3AUYBRwFIAVgBqgGvAbsBvwEeAiQAJgA/AEAAXgBvAH4AjQCPAOkA/QD+ABwBRgFHAVgBaQGCAY0BsQG+Ab8BzwH+ARsCHQIeAgcAJAA/AEQASABRAFYAXgBvAHEAewCPAKwAsAC7AOkA6wBEAUYBdgGwAbEBvgHFAc8BBwAZABoAJgBRAG8AcQCYAKMAugDpAP4AJQFGAUgBYQGhAbEBxQFHAhoAJgA/AEgAWABeAF8AcwB+ACUBSAFhAWQBcAGEAZIBoAGvAbABtgG+AcABxQHPAdQB3gHfAQcAGgAdAB8AJgAxAEAAXwBoAG8AcwB+AI8AnAC7AMgA0gDrAPYA/gAlATwBSAFkAWkBbAFwAXkBhAGNAagBsQG2AcABxQHUAd4BHQIqAhoAHwAtADoATQBYAF4AcABxAHMAjwDTAP4AAAEXAUgBWwFkAWwBjQG2Ab4BwAHDAcUB3QEdAioCGgAfAC0AOgBNAFgAXwBwAI0AjgCPAJgAsAC7AOkA7AD2AP4AAAE8AUgBXQFkAWkBbAGiAawBsAG2AbwBwAEAAgcAGwAfAC0ASABfAI4AjwCYAKcAuwD+ABoBGwFIAVUBXQFmAWkBogGlAagBrAG7AcUB8gECAgcADQAaAB8ALQA6AD8AQgBDAE0AVgBeAHAAfgCNAI4AjwCwALsA8wAAARoBGwE8AUgBZAGEAZEBqAGwAbEBuwHjAQICKgINABoALQA6AD8AQABCAEMAXgBzAH4AjwCwAPQAAAEVATcBSAFVAWQBhAGgAbEBKgIaACoAOgBDAEgATQBzAI0AjwCkAKYAuwDIAPUARQFLAWQBoAGwAdQB8gEaAC0AOgA/AEgAXgBfAHMAfAB+AI4ApACmAOEA/gA8AUgBYQGpAbABsQG2AbsBvgHFAeMBKgINABoAOgA/AEQASABWAF4AXwBzAHoAfAB+AIEAjwClAN4A9QD+AAABSAFVAWEBmQGxAbYBuwG+AcUB0gHUAegB8gH2ASoCDQA6AEgATQBeAGwAfACBAKUAugC7AN4AAAFNAWQB3AH4ASoCBwAZABoARABNAF4AcABzAH4AkQCnALoA6ADpAPUAAAEIATsBTgFVAZgBsAHSAdwBDQAZABoALQBIAE0ATgBwAHMAfACRAJgAsAC6AOgA6QD0APUA9gD/AAABGwFOAWkBsAG5AeMBKgIHAA0AGgA/AEIASABNAE4AVQBWAHwAgQDgAOIA6ADzAP8AAAEbASQBOwFIAaQBsgG5ASoCBwAaAD8AQgBIAE0AVgB8AH8AgQCPAKMAugDgAOIA6QD1APgAAAEIATsBPAFHAWkBsQGyAfIBHQIaAE0AVQB8AI8AkQCkAKYA3gDoAOkA7gD1AEcBaQFyAYIBqAGxAbsB7AHyAR0CKAIqAgcACwAZABoAHwAsAC0AQABIAE0AXgCPAKYA3gD+AAABFwFlAXUBggGoAbIBuwE2AgsAGQAfAEAAQwBIAE0AVQBWAF8AYwB8AJAAkgC4ALkAugDDAN4AFwEbAT8BSAFNAWQBdQF9AbIB2QHaAfUBHwIsAEIASABNAFYAXwCBAJIAowCkALoAxADeAOMA6ADpAP4ADAEXARsBKQFIAU4BTwF1AXYBqAGyAbsBxAHuAR4CNQIWACMALQA6AEgAXgB8AI8AkQCjALgAuQDhAO4A9QD2APgA/gAAARcBdQGoAbABsgG5AbsBxAELABYAGgAqAC0ATQB8AIIAhwCRAJsAuAC6AOgA6QD4APwA/gAaARsBSAFUAWoBcAF5AYQBpwG/AdcB2gELAA8AHwAtADoAQgBEAEgATgCCAIcAjwCQAJIAmwCjAMAA9QD4APwA/wAMATsBPwFIAVYBagFwAXUBhAGoARwCQgBEAE0ATgCHAI4AkgCYAMIA9QD4AAQBCQE/AWoBbgFvAYcBqAECAAsALQBNAF4AhwCRAJ4AowC2AO4A9QD/AAgBHAE/AW4BbwFwAXgBgQGEAYcBAgAHAAsALQBCAEMATQBeAIEAhwCOAJEAmACjAKYAuQD/AAgBFgEbASoBRgFiAWUBZgFwAXgBhAG7AQcAFgA6AD8AQwBNAGMAZQB8AJgApgC5APYAJAFGAUgBWwFmAXUBqAGyAQEADwAWACwALQA6AD8AQgBEAF4AhwCPAJgAtgDoAPYAGwFGAWQBagGoAbIB/wEBAA8AFgAaACwAPwBCAEMATQBsAG4AiQCPAKMApgC5AOgACAEJARcBNwFGAWoBlwG7AfYB9wEBAAcAFgAtAD8AQwBEAE0AVQBeAHwAiQCmAAgBCQEXAUUBRgFNAWYBcgGXAbsBAQACAA0AOgA/AE0AggCJAI4AjwCRAKMAuAC5AMQAyADeAP0A/wAbAUgBTQFWAWQBZgFqAeYBAgAHAA0AGgA6AD8ATgB8AIIAjgCYAKQApgCpAOgA6QD8AP0ADAEXARoBHQFEAVYBZgFqAbsBAQAHAA0AGgBOAI4ApACmAMMA3gDoAOkA7gD8AP4A/wAMARcBGgFCAUgBagGPAQQADQAaAEIAfACPAJgAtgDuAP4AGgEbATsBZAGFAagBswEEAAcALQBCAEgATQB8AIkAjgCPAJgAsAC2ALwA3gDjAPgACQE7AUsBZAFqAZcBqAGyAbsBBwA/AEIASABNAF8AfACPAJgAtgDeAOMA+AAMARsBlwGyAbsBBAAHABoAPwBCAF8AmACmALYAwgDsAPYADAEXARoBGwE7AUgBTQFjAbwBxAFNAI8AmACbAKYAsADAAN4A9gD/AA0BGwE7AUgBYwFkAWoBdgHBAe0BBwANAD8AQgBoAI8AmwCwALsA3gDsAP4A/wAXARsBNQFEAUgBZAGCAacBuwHBAQIABAAHAA0APwBCAE0AggCbAKYAuwDsAO4A+AD+ABsBOwFEAUYBVgFwAX0BuwHNAecB+wEEAAcAGgAhAEIATQCCAJsAnwCmAOkAFQEXARoBOwFEAUYBSAFqAWwBcAGoAb8BzQECAAcAIQAtAEIAXwCPALAAuwDaAN4A7gD4AP8ADQEqAUQBSAFWAV0BZAFsAXABBAAHAAsADQAaAEIAXwCwALsA3gDhAOMA6QD/AAsBDQEXAUYBSAFkAWoBbAF+AYQBBAALAA0AFgAaAHMAggCPALsA+AD9ADsBRgFVAWIBZgFwAYIBhAG7AQIABAAHABYAGgA/AEIATQB0AIIAjwCYALsA/QATARcBOwFIAVUBWAFwAYEBuwEHAA8AQQBNAHoAiAC7APUA/wAXAUQBVgFiAWYBcAGCAaABzQHVAe0BAgAfACMAKgBBAIEAjwDeAPUA+AD/ACUBKQFEAUUBVgFYAVsBYgFmAXABfAGgAcIBzQHnAQsADQA/AEIATQBfAHIAiACmALAA3gDuAPgA/wAjASkBRgFLAVgBYQFwAcQB1gHnAe0BCwAaAC0ATQBfAH8AgQCIAJgApgCwAPgA/gAWASkBOwFFAWYB4QELAA0AFgAaAC0AQgCYAO4A/gD/AAABVQFhAWIBZgFwAYQBuwHYAQIACwAPABYAQgBNAHAAiACwAOEA9gD/AAABOwFFAUwBVgFiAWkBeAG7AeAB5wFCAE0AiACYAMcA3gDuAPYACwFIAWYBaQFwAYQB4gEHAA0AGgAjAEAAQQBwAH4A7gALATsBPAFMAVgBYQFmAecBAgAHAAsADQAaAC0AQABCAE0AcACBAIIAmADeAPYA+AAAAQsBRAFIAUsBTgFjAWUBaQGZAQIACwAaAC0AQgBNAHAAcgCBAIIAjwC7AOMA9gALAUQBSwFOAVUBYQFkAXABhAGZAZsBngECAA0AGgA3AEEASgBNAJgA3gA6AUsBTgFYAWQBaQF2AZkBoAHDAQcADAANAC0ASgCBAIIAjwCYAN4A7gAAATMBVAFYAWQBZgFwAaABqQG6Ae0BDAANAEIATQBwAIEAggCYAJ0A7gD4AAABOwFGAU0BTwFVAWEBbAGEAaUBqwECAAcADQBAAEIAcACdAN8A7gAAATsBRAFGAUsBYQFkAXABhAGqAQcADQAtAEAATQBsAG4AdADaAN8A7gD4AAABEwE7AUQBSwFYAWMBgwGYAc4BDQBNAHAAmACmAK8A+AA7AUYBVQFYAWMBZgFyAQcADABAAEIAcACRAJsApgAAARUBOwFEAUUBRwFLAU0BUQFVAVgBYwFmAXABvAG9AQcADAAaAEAAQQBNAJEAmwCyAP0A/gALAToBPAFEAUUBRwFLAU0BVQFhAWMBZgFpAWwBcAF6AecBQQBCAJgA3gDiAPgA/gALAQ0BOgE7AUgBSwFNAVEBVQFYAWQBaQFwAXoBhAGSAaYB5wEHAA0AGgBNAHAAgQDeAOIA9gD4AP0ACwENARcBPAFIAUsBTQFRAVUBWAFhAWMBZAFmAXABegGEAaEBogEHAAwADQAaAIEAggCIAI4A4gDsAP0A/gAAAQsBRAFGAU0BVQFYAWYBdQGEAaYByAECAAwADQAaAHAAggCDAIgAjgCPAJgApgDiAPUA/QD+AAABIwE8AUsBVQGEAZQBoAGhAaIBAgANABoAgwCPAJgA7AD1AP4AAAELAQ0BJQFLAU0BUQFVAWEBZAFmAXABegF7AYQBoAENADgAQgBIAHMApgD1APcA/gAAAQsBDQFHAVUBWAFkAW0BhAGSAaEBpAGmAd4B3wHkAfIBJAAtAEAATQBwAHMAjwCRAJgApgDeAOwA9QD4AAABCwE6AUcBSAFNAVEBYQFiAWQBZgF6AYQBngGgAaEBogGsAbkBwwEaAB8AIwAkAC0AQABCAE0AXwBwAIgAjgCPAJgAsgDeAOwA7gD1APgA/gALAUQBSAFLAU0BWAFhAWMBZAGgAaEBrAEaAB8AJABwAIgAjgDpAPUACwFEAUsBVQFhAWIBYwFmAZ4BoQGiAaYBBwAfACMAJAAtAEIAjgCPAJEApgDeAPgA/gAAAQsBOwE9AUUBRwFLAU0BUQFdAWYBmwGeAaEBogGsAa8BwAHCAcQB7gEVABYAHwBfAHAAjgCPALsA3gDpAO4A+AD+ADoBOwE9AUUBRwFIAU0BVQFkAXABhAGvAcAB8wEeAiACFQAWAB8AKgAtAC4AQABCAH8AjwCSAJgApgD0ADoBRQFHAUgBWAFkAWsBcAF6AYQBngGhAawBrwHjAR0CBwAjAC4AQABwAH8AkgCYAKYA9AAbATwBRAFHAUsBTQFrAXABegGEAaEBogGvAQcAFQAaACMALQBAAHAAjgDiAOwA/gAAAT0BRAFLAUwBTQFYAWEBZAF4AYQBhQGeAaIBpgGvAbsB2QHdAQcAFQAaACMALQBAAEgAcACIAI8AkQCYAKYA4gDsAO4A9QAAARsBHQFGAUsBVQFYAWQBcAFzAYUBrwG7AcUB2QEHAEIAiACRAJgA6QAbAUYBSwFVAVgBYQFkAWwBbQFwAXQBnwGgAaEBogGmAcUByAH0AQcAFgAaACoAQgBwAHEA7gD1AAABGwEfATsBVQFYAWEBZgF0AXoBhAGhAa8BuwG+ARoAKgA4AHEAjwCRAJgA6ADuAPUA+AAAAQkBGwEfATsBRgFIAVwBYQFkAWYBaQFwAXMBegF+AasBrwG7Ab4B5wH5ASkCFQAtAHAA4gDuAPgA/AAIAQkBEgEbATsBPAFEAUoBSwFNAVsBZAGEAaYBvgHnAf0BFQAaACMAKgBCAHEAmACmALAA3gD1AP8ASAFLAU0BWAFiAWYBrwG+AfgB/wEWACoALQA4AEIAcABxAIgApgCwAN4A4gDoAOkACwEaAUIBRgFKAV4BYgFkAWYBawFwAYQBpgGvAbEB+AENAC0ARQBxAI8AkQCeAKYAsAD4APwACAELARoBPAFGAUgBXAFkAWsBpgG7AfsBGgAtAHAAcQBzAH8AkgCYAO4A9QD4AAgBGgFGAUsBVQFcAWEBZAGmAbEBuwHnAQ0AFgAtAEIAXwBzAH8AgQCSAOIA7ADuAAgBGgFGAUgBSwFVAWEBZAFmAXABdAGEAegBBwANABYAGgAtAEIAXwBwAHEAgQCRAJgApgCwAOIA7ADuAPwAGgFIAUsBVQFeAWEBYgFkAWYBbgGEAbABvAHEAewBBwAWACoAcQDiAO4ACAFLAVUBYgFkAWYBegGEAdkBDQAtAHAAcQCRAKYAsADsAO4A9QD4APwACwFIAVQBYQFiAWQBdAHFAeYBKgBCAJgApgCwAOIA7AAIARoBRgFcAWYBcAFzAXQBegGEAcUB2QH9AQ0ALQBwAHEAcwCYAKYA6AD/AAgBDQEaAV4BYgFmAXABcwF6AYQB2QH+AS0AQgBxAHMA6QDsAO4A+ABGAWIBZgF6AaIBqgGwAdkBLQCmALwA9QAIARoBSAFVAV4BYgFmAXMBdAGEAaIBsAHZAS0AOABCAHEApgC6AN4A9QD8AAABPQFGAUcBSAFVAWIBZAFmAXABcwGEAdkB+AENADgAjwCRAMgA3gDuAPwAAAEIAUYBSAFVAV4BZAFwAXQBngHDAdkB+AEAAhsCKAItAHAAjwCSAJgArwC6AN4A4gDuAPUA/AAaATsBRgFHAUgBXgFhAWIBbQF0AYQBngHZAQ0AXwBwAHEAgQCNAJIAmACfAOIA6ADpAPUACAEaAUQBRgFiAXABngE1Al8AcABxAH4AgQCYAJ4AugDeAOIA9wD8AP8AEgFGAU4BYQFiAWYBegGDAZ4BsAHFAdkB+AEmAjUC", + "provenance": { + "method": "chrome-solid-face-id-exact-interior-pixel-census", + "route": "http://127.0.0.1:4325/cityflow/", + "presentation": "schema-21-folded-sine-power-0.85-eased-extrema-60hz" + } +} diff --git a/src/adapters/cityflow/notes/references/prepared-visibility-user-stage-2206x1224.json b/src/adapters/cityflow/notes/references/prepared-visibility-user-stage-2206x1224.json new file mode 100644 index 0000000..dcf2245 --- /dev/null +++ b/src/adapters/cityflow/notes/references/prepared-visibility-user-stage-2206x1224.json @@ -0,0 +1,480 @@ +{ + "schema": "csscityflow-browser-visible-face-id-source@1", + "encoding": "initial-bitset-plus-u16le-per-target-frame-toggle-offsets-and-face-indices", + "sourceRevision": "906693799e4fb7581436590cf84ecb2d3c9186ba", + "seed": 26081702, + "transitionDilationFrames": 0, + "browser": { + "name": "Google Chrome", + "version": "152.0.7977.65", + "headless": true + }, + "viewport": { + "width": 2206, + "height": 1224, + "deviceScaleFactor": 1 + }, + "frameCount": 301, + "faceCount": 600, + "initialVisibleCount": 243, + "minimumVisibleFaces": 200, + "maximumVisibleFaces": 279, + "meanVisibleFaces": 241.38205980066445, + "visibleFaceCount": 445, + "visibleFaceIndices": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 20, + 21, + 22, + 24, + 25, + 26, + 27, + 29, + 31, + 33, + 35, + 36, + 37, + 38, + 39, + 42, + 43, + 44, + 45, + 47, + 48, + 49, + 51, + 52, + 53, + 54, + 55, + 56, + 57, + 58, + 59, + 60, + 61, + 62, + 63, + 64, + 65, + 66, + 67, + 68, + 69, + 70, + 72, + 73, + 74, + 75, + 76, + 77, + 78, + 80, + 81, + 82, + 83, + 84, + 85, + 86, + 87, + 88, + 90, + 92, + 93, + 94, + 95, + 96, + 97, + 98, + 99, + 101, + 102, + 103, + 104, + 105, + 106, + 107, + 108, + 110, + 111, + 112, + 113, + 114, + 115, + 116, + 120, + 121, + 122, + 123, + 124, + 125, + 126, + 127, + 129, + 130, + 131, + 132, + 133, + 134, + 135, + 136, + 137, + 141, + 142, + 143, + 144, + 145, + 146, + 147, + 148, + 150, + 152, + 153, + 154, + 155, + 156, + 157, + 158, + 159, + 160, + 162, + 163, + 164, + 165, + 166, + 167, + 168, + 169, + 170, + 171, + 172, + 173, + 174, + 175, + 176, + 178, + 182, + 183, + 185, + 186, + 187, + 188, + 189, + 190, + 191, + 192, + 194, + 196, + 198, + 199, + 200, + 210, + 211, + 216, + 217, + 218, + 219, + 220, + 221, + 222, + 223, + 224, + 225, + 226, + 227, + 228, + 229, + 230, + 232, + 233, + 234, + 235, + 236, + 237, + 238, + 239, + 240, + 242, + 243, + 244, + 245, + 246, + 247, + 248, + 249, + 250, + 251, + 252, + 253, + 254, + 255, + 256, + 258, + 259, + 260, + 262, + 263, + 264, + 265, + 267, + 268, + 269, + 272, + 273, + 274, + 275, + 276, + 277, + 278, + 279, + 280, + 281, + 282, + 283, + 284, + 285, + 286, + 287, + 288, + 289, + 290, + 291, + 293, + 297, + 298, + 299, + 302, + 303, + 304, + 305, + 306, + 309, + 311, + 312, + 313, + 314, + 315, + 316, + 317, + 318, + 319, + 320, + 321, + 322, + 323, + 324, + 325, + 326, + 327, + 328, + 330, + 331, + 332, + 333, + 334, + 335, + 336, + 337, + 338, + 339, + 340, + 341, + 342, + 343, + 344, + 345, + 346, + 347, + 348, + 350, + 351, + 352, + 353, + 354, + 355, + 356, + 357, + 358, + 360, + 361, + 362, + 363, + 364, + 365, + 366, + 367, + 368, + 369, + 371, + 372, + 373, + 374, + 375, + 376, + 377, + 378, + 381, + 382, + 384, + 385, + 386, + 387, + 388, + 389, + 390, + 391, + 392, + 393, + 394, + 395, + 396, + 397, + 398, + 399, + 401, + 402, + 403, + 404, + 406, + 407, + 408, + 409, + 411, + 412, + 414, + 415, + 416, + 417, + 418, + 419, + 420, + 421, + 422, + 423, + 424, + 425, + 426, + 427, + 428, + 431, + 432, + 433, + 434, + 435, + 436, + 437, + 438, + 439, + 441, + 442, + 443, + 444, + 445, + 446, + 447, + 448, + 449, + 450, + 451, + 452, + 453, + 454, + 455, + 456, + 457, + 458, + 461, + 462, + 463, + 464, + 465, + 466, + 467, + 468, + 471, + 472, + 473, + 474, + 476, + 477, + 478, + 479, + 480, + 481, + 483, + 484, + 486, + 487, + 488, + 490, + 492, + 493, + 494, + 496, + 497, + 498, + 499, + 500, + 501, + 502, + 503, + 504, + 505, + 507, + 508, + 510, + 511, + 512, + 513, + 514, + 515, + 517, + 518, + 538, + 539, + 540, + 541, + 542, + 543, + 544, + 551, + 552, + 553, + 555, + 559, + 560, + 562, + 565, + 566, + 582, + 583 + ], + "transitionCount": 7422, + "initialVisibleBitsBase64": "/wFUgQAkaYsECID1z9cUb3AAG9es/wHkwAEMfzT8RQ8cKfokL4AHyN9t954Bf5si/38Aw7ODMWYEhwHg2HEc2wEAAGgBAwAAAAAA", + "transitionOffsetsBase64": "AAASAC8ARwBaAHAAiACjAL0A4AD+ABoBNQFMAWYBgAGeAcAB3wH5ARUCOQJaAnUCiwKnAsMC2gLzAg4DIwM8A1MDZgN7A48DqAO9A9YD7wMCBBsENQRMBGkEgwSbBLQExgTgBPcEDwUlBT0FVQVpBYMFmwWwBckF2gXvBQ0GHgYwBkMGWQZvBoMGkwafBrMG0gbuBgcHFwczB0gHXQd1B5AHpge/B9gH9gcOCCIINghDCFIIcgiJCKQIugjTCOQI+ggPCSMJOwlRCWgJegmJCZ0JtQnMCeIJ/gkNCiAKMgpLCmcKgAqZCrcK1AryCg8LJwtEC18LeQuUC7ML1Av1CwsMIAw7DF8MdgyKDKAMugzVDO0MAw0gDTYNSQ1eDXMNjQ2jDbYN0w3tDQgOKQ5MDmkOhA6hDsEO2w7/DicPRQ95D5oPtA/RD/APCxAsEEEQYBB1EI0QpxC/ENQQ6hAAERoRMRFIEWIRehGYEbMRyhHqEQgSHRI9ElgScRKJEqMSvxLaEvkSFhMrE0YTbBOOE6sTwBPYE+0T/xMdFDMUSRRYFG8UgxSSFKYUvBTQFN8U+BQPFTIVThVsFYoVphXAFdgV+RUVFioWRxZnFoAWmRavFsEW4xb+FhMXJhc2F04XZBd9F5IXphe/F9YX6hcFGB0YNBhGGFoYcBiFGJgYpRi8GM8Y5Bj7GBEZKxlIGVoZbxmEGaMZtxnQGecZChonGkIaWhpxGoQalxqyGska5RoCGxwbORtTG2kbhRugG7gb0BvmG/8bExwqHD4cThxlHH8cmxy4HM4c5hz+HA==", + "transitionFaceIndicesBase64": "cABxAH8AugDHAAgBGgFHAUwBTQFmAXoBhQGvAbcBvgHEAdkBHwAqADgAQgBYAHAAcQB+AI0AkQCkALoA3gDiAP8ACAEaAUcBTAFVAWIBdAGUAbsBxAHFAdkB9wECAi0AOACBAI0AkQCkALoA3gDjAPwACAEaAVUBXgFlAWYBcAF0Aa8BsQG+AcQB9wECAjgAfwCSAKYA3gDiAOMA+AD8AF4BZgFwAXoBmwGjAcQByAHmATUCQgBwAH4AfwCBAI0AkQCSAKYAugD0APgACAFIAWUBmwHEAcUB1AHZAfMBNQItAHAAcwB+AH8AgQCSAJ8A6ADpAPQA+AAIAUgBYQFwAXoBsAGxAbsBvgHUAfkB/gFwAHMAfwCBAI0AkgCkAMIA3gDiAOgA6QD4APwA/wAIARoBZgF6AZ8BoAGxAbsBvgHEAcUB9QEWAHAAfgCPAKQApgDCAN4A4gAAAQgBRwFlAW8BcAF6Aa8BsAGxAbsBwAHEAcUBzgHZAfUBcwB+AH8AgQCNAKYAugDeAO4A/wAAARoBOwFHAUgBVQFlAWYBbwGeAaIBqgGrAa8BsQG7AcABwwHFAc4B0QHTAdkBGwI1AgcAFgBwAHMAgQCPALoAwgDeAOMA7gD/ADsBSAFVAWUBqwGsAbYBuwG+AcABxAHFAdEB0wHUAdkB5wHoAQcAFAAbACQAfwCNAMAAwgDiAP8ACAEaAWEBZQF6AaIBrAGxAbYBvgG/AcABxAHOAdQB3gE1AjYCGwAkAGMAcAB/AI0ApAC6AN4A/gAIARoBOwFHAWEBZgF6AasBrwGxAbsBvgHOAdMB2QH7ATUCJAA0AH4ApADAAN4A4gD1APwA/gBHAUgBZQFuAXYBegGeAa8BvgHTAd8BAAICAhsANAB+AI0ApADeAOIA6ADpAPUACAE7AUgBZgFpAXABdgGeAbsBvwHAAc4B2QEAAgICKwIbADQAfgB/AIEAkgCkALoA/AD/AAcBCAFhAWUBZgFuAZIBqgGrAbEBxQHTAdkB9AEAAisCDwAbACEAJAAqAHMAfwCBAI0AkgCkAKYA3gDiAPwA/wAaATsBSAFlAWkBbgGeAbEBtgG/AdMB6AH0AfkBDwAhACQAKgA0AI0AjwCSAN4A4gDuAPUA/gAIARoBKQFlAXABegGiAasBrAGxAbMBuwG+Ab8BxQHZAd4B3wH4ATUCNgIbACQAKgA1AH8AgQCNAI8AkgCmAN4A4gDoAOkA7gD+AAABCAEaATsBSAFhAXABuwG+Ab8BxQHTAdkB6AE1AiEAKgBnAH4AgQCPAJEAugDeAOIA6ADpAPUAXgFhAWUBZgF6AasBsQG7Ad4B5wHsAfgBNQIkACoALQA1AI8ApAC6AN4A6QD1APwA/gAAARoBXgFmAbEBtwG7Ab4BxAHFAc8B0wHUAd4B5wH5AQcAGwAhACQALQA0ADUAugDpAPUA+AD8AP0A/gD/AAABBgEaATsBWAFmAW4BcAGgAasBrwGxAbcBuwG+AcQB0AHZAegB9wH4AQcAGwAqADQANQB+AI8AkQCmALoA3gDhAOIA6ADpAPgA/AD/ABcBGgE7AVsBXgFjAWYBcAGbAa8BsQHOAdMB2QHnASQANABnAI0AjwCRAKQApgC6AOEA4gD1AP0A/gAAAQgBbgFwAbsBvgHOAc8B0wHUAdgB5wEyAhYAJAA1ADgAjwD1APwA/gAXATsBWwFhAWMBcAGrAa8BuwG+Ac4BzwHTAegBDwAWACQALQA1AIEAjwCRAKQA7gD2APwA/gD/AAABBgEIASoBOwGSAaIBqwGsAb0BwQHTAfMBMgIPACQAKgAtAGwAbgCNAI4A7gD2APwA/gD/AAABOwFVAVgBZAFmAXABogGrAawBsQHFAdkB5wE1AioALQCBAI0AjgCkAKYA3gD8AP4AOwFVAWQBZgGvAbEBxAHOAdkB5wH4ATICNQIkAC0AjQCkAN4A6ADpAPUA/gAAAQwBSwFVAWEBZgGiAbsBvgHEAcUByAHOAdkB+QEyAhIAJAAtADQAXwCBAIgApgDeAPUA/gD/AAYBDAFLAVUBYQFmAaIBrAGxAbsBvgHFAc8B5wH4AQ8AIQAtADQAXwCBAI0A3gD1AP4AAAFVAWIBZAGPAaIBrAG7AcQBxQHOAX4AggCmAOgA6QD8AP4A/wAAAQYBCQFVAVgBYgFkAaIBrAGwAbEBuwHFAdkB5wH7AQACFQAqADUAfgCCAI0AjgD1AP4AAAEJARwBOwFYAWIBZAF0AaIBrAGwAdAB5wEAAhUANABCAIEApACmALwA3gD+AAABOwFYAWIBZAFlAbAB5gHoAfYBFgAqAC0ANQBCAIEAggCkAN4A7gD1APgA/gA7AVgBZQGwAbEBxAHFAdkBFQAqAC0ANAA1ADcAfgDuAPUA+AAAAVUBYgGiAawBtwG7AcQB1AHqAQcAFQAWAC0AQgCCAN4A9gD+AP8ACAE7AU4BVQFiAXQBogGqAasBrAG3AbsBxQHUATUCBwBCAEsAjQDoAOkA7gD2APgA/gD/AGIBdAF4AYQBrAGxAcUB2QEoAjUCFQAhAC0ANAA1AH4ApADuAPUA9gD4AP8AAAEMAWIBZAF4AYQBrAG+AcQBxQHoAewB/gEPABYALAAtADUAXwBlAI0A6ADpAPYA/gD/AAgBDAFhAWQBcAF4AYQBogG+AcUB2QHhAQ8AIQAsAEIAZQBrAPUA/wAMARsBYgFwAX4BhAGNAbABuwG9AcQBBwAqADUAQgBfAGsAfgCNAPUA9gD+AAgBDAEaAWEBZAFpAXQBhAGmAawBsAG3AbsBxAEVACEALAA0ADUAfgCCAI0A6ADpAPYA/gAAAQgBGgFNAWEBhAGNAaYBsQG3AcMBxQHqAQICCwAPABYAIQAsADUAQgB+AKQA6ADpAPUA/gAIAU0BVQFiAXgBrAGxAcQBxQHTAQcADwAVACEAKgAtADUAawB+AIEAggD1APYA/gD/ABoBSwFVAWYBawGNAaYBrAG2AcQB8wH0AfkBAAIHAAsADwAVABYALAAtADQAQgCNAKYApwD0APUA/gD/AAABGgFLAWEBZAFmAWsBjQGmAcUBCwAVABYAQgCCAI0ApAClAKYApwD0APYA/gD/AAgBDAEaAUgBhAGNAaYBsQHEAdIBBwALAA8AFQAhACoALACCAI0ApACnAN4A9QD/AAABDAEaARsBSAGEAbEBxAHFAe4B9gEHAA8ALABCAEsAawCkAKcA3gD9AAgBGgFkAWYBsQG7AdoBAwILABUAFgAhACwAMwBKAEsAjQCkAKYApwDeAPYA/QD+AAABCAEaAUgBjQGmAa8BuwHaARsCFQAhAEIASgBLAFQAtwDeAPUA9gD8AAgBYQFmAXUBjQGfAa8BsQG7Ab8B2QEaAgcACwAhACwAQgBUAIIAnwClAPUA/AD9AP4ADAEaARsBHAFIAWEBZAGEAaYBxAHSAQcACwAhAEAAQgB8AH4AggCfAP0A/gAIAQwBOwFkAXABjQGmAb4BxAHeAeQBCwAsAEAAfAB+AIIAsACyALkA9QAAAQYBGwFNAWEBZAFwAXYBhAGFAY0BpgG7Ab4BCwAWACEAZQB+AIIApAClAKYA3gD9AP8ABgEMARoBOwFwAYQBhQGmAa8BuwHeAfIBFgBLAGUAfgCRAKQApQCnAN4A9QD9AP8AAAEIARoBYQFkAa8BsAHeARYALABAAEoASwBUAGUAjQClAKYApwDiAP4A/wAaARsBTQFhAXABhAGNAaYBsAG/AdMB6AEhACwAQABCAEoAVACRAKQA4gDoAOkA/gD/AAABCAEMATwBRQGNAb8B3AHfAeEBNQILABYAQgB+AIIAjQCmALkA/gD/AAYBCAEMARoBGwGRAZsBnAG+AdwB4wEWACEALABLAH4AggCNAKQA6ADpAP4ABgEIARoBNQE3AXABegGEAZwBrwGwAb4B3QE1AiMALABAAEsAggCIAJEA9QD/AAABTQFkAaIBrwGwAdIB3AELABYAIwCCAI0AkQCkAKUApgD2AP8ABgEIAQwBGgFNAWQBpgG7AdIB3AELACwAQAB+AIIAjQCRAKQApQCmALAAtwD1APYA/QAGAQgBGgFNAVUBYQFmAXABhAGrAbsBvgEaAhwCKAICACEAjQCRAKYApwD2AP0A/wAAAQwBVQFmAXABhAGmAdkBAgAsAEAAXwB+AIIApQDuAPYA/wAAAQYBDAFNAWYBpgHBAe4BAgAWACEAIwBAAF8ApQCnALcA7gAGAQwBFwFhAWYBegGmAb4B9QEUABYAIwB+AKcAuQDeAPYA/QD+AAgBCQEaAWEBZAFmAWkBegGEAaYBvgEnAgsAFABCAF8AfgCCAKQApgCnAN4A4wD1APYA/QA1AWEBZAFmAXABdgG9Ab4BCwBCAEsAfgCCAKcA8gD2AP0AAAEIAQkBGgFIAWEBwAH4ATUCNgJHAiMASwB+AKUApwDoAPYA/QD+AEgBYQF6AX4BjQHjAScCQAB+AKUA6QD+ABcBZAFwAXUBgwGNAScCCwBAAF8AfgCCAI0ApwD4AP4ACAEXARoBOwFhAWQBZgFwAXoBjQHyAQIABAAPAC0AXwB+AIIAjQCkAKUApgCnAOYA8AD2APgA/gAIARoBOwFhAWQBZgF6AYMBuwHTAdkB3QH1AR8CAQACAAsAFgAtAEAAfgCCAI0ApADmAPYA/QD+AP8AFgFhAWQBjQGqAa8BsAG7Ab8B0wH0AQECHwIEABYAGQAtAEAAjQCkAO4A9QD8AP8AAAEWARcBVQFhAYMBjQGvAbABvQG+Ab8BAwIfAgEAIQCBAIIA7gD1ABYBVQFhAWQBZgF6AdgBAQICAh8CAQACAAQAGAAhADAAggCNAKcA6ADpAO0A9gD4AAABDAEWAVMBYQFkAWYBaQF6AY0B3wHkAfUBHwICAAsAQgCCAI0AkQDHAO0A9gD4AAwBFgFPAWEBZgGrAb4B9QEfAicCKwIEAAsAIQBLAH4AggCNAJEA7QAWARoBPAFOAU8BZgFpAasBvgHTAfkBJwIBAEIASwBeAH4AjQCmAO0ADAEaAU4BYQFiAXoBogGsAbYBvgHTAdQB3gH1AR8CKwIBAAQAGQAsAC0AfgCCAKQApgDtAPYACAEWARoBTgFhAWIBegGNAaIBrAG2AcQB1AHzAfUBKwIhAEsAkQDeAO0A9QD2AAgBGgFIAU4BTwFYAWYBcAF6AbAB9QH2AQECKwIwAgsAQQBlAH4AkQDeAPUA9gAIAQwBGwEhATsBSAFYAWEBZAFmAXoBjQGwAfUB9gEBAjACCwAZAC0AQQB+AIgAkQCkAKYA6QDtAP8ADAEWARsBOwFPAWQBaQFwAa8BsAHEAfUBMAIEABkALAAtAEEASwCCAIgAjQCRAKQApgC7AN4A6ADwAP8AFwEaARsBRAFIAVgBWwFpAXABegGvAfUBMAIEABkALQBBAF0AfgCCAKYA6ADpAPIASAFNAVgBYQFkAXQBegGRAZQBsAG+AcABKwIZAC0AQQB+AIIApgDeAO0ADAEWAUQBTQF0AY8BrwG+AcABxAEnAjACGQAtAEIASwCCAIgApADeAPYAYQFlAWYBcAF6Aa8BsAG+AcQBKwIwAgQAGABBAEIASwBdAI0ABwFHAWUBZgFwAScCBABLAF0AggCRALcAuQDeAP8AGwFNAWEBaQFwAXoBGQAtADwAPQA+AGUAggCNAJEApgDeAOMA9AAbAVsBZAFmAWkBegGNAaYBqgGvAbABsQG7Ab4BwAHEAdkBHwInAgQAGQAtADAASwBlAIIAjQCmAOMA9AAWATsBTQFbAY0BjwGmAbEBuwHAAcUBHwIEABkALQBBAEIAggCRAKQA/wAVARYBRAFbAWEBZgF6AY0BkQGiAaYBrwGwAbEBwAHEAcUB2QEZAC0AQgCCAI0AkQCkAPYAFgFNAVsBZgF1AaIBsAG7Ab4BwAHFAc4B2QEfAgQAGQAtAEEAXQCCAIgAjQDIAPYAFgEhATcBSwFMAWQBZQFmAWwBbQFwAaYBsAHEAScCBAAZAC0APgAHAUUBSwFkAXoBjQGwAbEBuwG+AcUB2QEfAgQALQA9AD4AggCNAI8AvwDeAPIASwFbAWQBZgFpAXABjQGbAasB5gEfAicCBAAtAD0APgBYAI0AjwCkAN4A6ADpAPIA9gBkAWkBgwGmAbEBHwInAisCBAAZAC0APQCBAO4A8ADyAGUBbgFwAY0BuwG+AcQBxQHZAScCKAIrAgQAPQCCAIgAjwDoAOkA7QDuAFMBVQFkAWUBZgFuAXoBjQGiAbEBuwG+AcUB2QH+AQQAGQAtAD0AXgCCAIMAjwDeAO0AZAFmAXABhAGmAbcBuwHEAdkB+AEnAisCBABCAIIAgwCRAKQApgDtAAcBPwFFAWQBZQFwAYMBjQGmAbcBuwEfAicCKwJHAgQAggCRAKYA9gBEAUUBZAFwAXoBhAGNAaYBvgHEAdkB9QEpAkIAXgCbAKQA3gD/ABkBRAFlAaYBsAH1AfcBGwIfAgQASACBAJgAmwDCAN4A9wD/ABcBGwFkAWYBawFwAXQBhAGwAb4BHwJJAEoAmACyAMIA4gAWARsBRQFiAWQBZQFmAWsBfgGNAZ8BpgGxAbsBwwHEAfcBHwIEAEIAewCkAKYAsgDeAOIABwEWAUQBRQFiAWQBZQFmAXABegGEAcQBxQH2AR8CGQAtAD0ARQBJAH4ApgCyAEQBYgFlAWYBawFwAXoBgQGEAY0BsQG+AcQBJwIEABkALQBBAEkAfgCCAKQApgDeAOMA/AAZASUBRAFmAXQBegGCAYQBjQG7Ab4BxQHZAfkBIAInAkEAQgCkAKYAwADjAP0AGwFEAXQBegGEAbABxAEdAgcAfgCNAMAA/QD/ACMBYgFkAWYBawF0AY0BpgGwAbEBuwG+AdkBBwAZAC0AQgBTAH4AiACNAP0A/wAdAXABegGNAbEBvgHDAcQBBAAWABkAIQAtAFMAjQCRAKYAsgDpAP0AYgFwAXQBegGNAaYBsAG3AcQBxQHZAe4BHAIWACEAPwBJAEoASwCkAKYAsgDAAOkA7gD/AEQBRQFiAWYBdAF1AXoBjQGmAbABtwG7AcQBxQEeAhYAGQAtAD8AUwBeAF8AfgCNAKQAwADuABYBYwFkAXoBhAGNAaYBsQG7AcUBzgE1AjYCAQAHABYAIQBeAF8AkQCYANMA/wAWAUQBRQFjAWQBdQF6AYQBsAGxAbcBvgHEAfUBAgIBAAIABAAHAAsAIQAsAC0AQgBTAH4AmACyAOMA9wD/ABYBRAFjAWYBegGEAY0BsAG3AbsBvgHYAeYBAgIBAAIACwAWACwAQgBMAFMAfgCHAJgApADjAOgA6QBEAUUBYwFmAYQBtgG7AcABxQHGAfMB9gH4ASgCBAAPABYAIwAtAEsAUwCCAIcAjgCkAKYAsgDoAOkA9AATATwBRAFFAUoBZAFmAXUBnwGiAawBtwHFAQICFgAbAC0AQgBLAEwAXwB+AJgApgD0APYAGQE8AUQBRQFkAWYBaQF0AXUBgwGNAawBtgG3AcAB1AECAgsAFgAZAEAAQgBeAF8AfgCCAJgAsgDeAOgA6QAWAWkBdAGNAaYBrAGxAbYBxAHHAQYACwAhAD4AQABLAFgAXwCCAJEAlACYAN4A3wDiAP0A/wAWAUQBZgGhAasBrAG2AbcBuwHFAccB1AECACEAKgA+AH4AkQCyAN8A4gDjAP0A/wAZATwBRAFFAUYBZgGWAaEBpgGrAawBsQHAAcQBxwEPACEALAAvAD4ASwB7AH4AggCOAKYA4wD/ABkBRQFGAVQBZgGBAY0BlwGbAaIBqwHGAccBAgASABYAIQAjAEEATAB7AH4AjQCOAJEA2QDtAP8ARQFjAWYBiwGNAaIBrAGwAcABxgHUAegBAgALABQAFgAbACEAKgAsAC8AQwBMAFMAewB+AI4AsgDeAOgA6QDyAPwAMAFEAUUBYwFmAbABtwHGAdQB/gELABUAIQAsAEAAQQB7AH4AiACOALIA0wDeAPIA9AD8AP8AZAFmAYsBjQGiAaoBrAG3AboBvgHAAcYByQHUAR0CIAIWABkAHwAqACwANQBAAEEAQwBGAEsAUABTAHsAiACRALIAuwD0AP8AFgEZAWYBaQGsAbABtwG+AcAB1AHeAQACHQIWABkAIQAqAEEARQCIALIA4wAWASsBRAFFAWkBdgGLAY0BlwGrAbIBwgHGAQsAEgAhAFAAXwB7AH4AjgCRALsA4wA7AUgBZgF2AX4BlwGiAasBygEdAgsAFQAqACwARgBrAHsAiACUALIA4wDrAPAA8gD2AP0AFgFIAWQBZgFpAYMBhQHGAccB7QEdAgsAFQAWAB8AKgAwADUAPQA+AEEARgBLAF8AcABzAHsAiACvALsA3gDjAOsA/QARARQBFgEYAT0BaQFqAX4BjQGcAb4BxgHHAQsAFgAsAEIARgBLAHAAcwDeAO4AFgEwATwBRwFIAWoBlgGcAbIBxAHGAccB+AEVACEAQgBzAHsAsgC7AN4A6wDuAEgBfgGNAZcBvgHEAccB0QEdAh4CFQAqADwASwBwAHMAfgCCAJEA3gDrAPcA/QAGAREBPAFHAY0BnwGyAdEBKQICAAsAFgBCAEsAbwBzAH4AggCIAI4AkgDeAPMA/QAGARYBGAFHAUoBjQGcAbIBvgHGAccBCwAWACEASwB0AIgArgCwALsAvwDeAP0AEwEWARsBIwElAUcBSgF6AYsBjQGcAbIBxgHuARsCAgAqAEIAdACRAJgAsgDeAOMA6wDuAPMA/QD+AEUBSAF1AY0BnAGqAbIBuwHGAccBFQAWAEIASABLALIA3gDrAP0AQAFIAUsBTAFmAXUBiwGcAaoBsgG7AcYBAAIWAEAAQgBxAJ0ApwC6ALwA3gDoAOkA7gD+ABYBPAFEAUcBcAGBAY0BnAGrAbIBuwG+AcABxgHkAe0BAgALABQALABxAJ0A6wAGARYBPAE/AWYBbAFtAXABgwGLAY0BnAGrAbsBvgECAAsAIQAsAEIAVgB+AP4ABgEWAUcBSAFmAXABggGDAZQBqwG+AQIAFgAhAEIAmACnANkA3gDrAP0APAFHAUgBcAGLAasBsgHBAccByQHKARYALABeAGsAfgCYAKcA9AAIARABGgE8AUoBVQFoAasBuwHHAckB9gH4AQIALABCAF4AawCCAJsA2gDoAOkA9AD9AP4A/wAGAQgBPAE9AU4BZAFmAWoBqwG7AccB+AECAHIAggCbAP0ABgEQARQBFgEaARsBPAFOAVQBZAFqAXABiwGyAccB8QH4AQYAXwByAI8AmADeAP0A/gAIARUBFgFIAU4BVAGLAbIBvAHFAfEBBgAWAF4AXwByAI8AmADeAPgACAEWARoBGwFOAVUBZgFwAXUBiwGcAasBsgG+AcIBxQHGAccB0wH2AQEAXQBfAH4AiACPAJEA4gDuAP0A/gAJARABGwE8AUgBcAF1AYQBnAG7Ab4BxQHGAdMBBQJ0AH4AggCPAJgA3gDiAO4A+AD9AP4ACAEWARoBKwE8AUgBTgFqAXQBdQGEAY4BvgHHAckB0QECAAcACwAUABYAgQCCAIgAjwCRAJ8A6wD+ABABEwEVARoBOgE/AWoBdAF1AZwBqwGyAb4BvwHHAckB0QHTAQYCHAIBAAIABwALACEAawBsAHIAfgCBAIIAiACSAN4A9wD8AP0AFgEaAR0BTgFVAWUBZgFwAZwBsAGyAbsBvgG/AccByQHTAeQBAQAWAB8AIQA1ADwAVQB+AI8A4gDrAPgA/QAJAU4BVQFmAWkBagFwAXYBiwGOAZwBqwGwAbIBuwG/AQIAFgAzAHIAkQD4APwA/QD+AP8ADAE7AU4BVQFpAWoBcAGHAYsBnAGrAbsBvgG/AccB0wHoAQIABgAOABYAQgBQAF0AXwBuAJEAnwDeAPwA/gD/AAgBDAEQARoBSAFVAXYBhwGcAbABsgG7Ab8B0wECAAsADQAWAB8AIQBGAF4AcACCANMA3gDiAP8AAAEIAQwBEAEaAVUBYQFqAXABfgGNAasBsAG7Ab8BxQHHAfkBAgALAA4ANABwAHIAjwD9AAABDAEQARYBGgE8AWUBaQFqAXABdQGNAZwBsQGyAb8B0wHfAQsADQAfACEAKgAvADAANAA+AFUAXgBvAH4AjwCUAOgA6QD1APgA/wAAAQwBVQFlAWkBdAGrAbABsQGyAbsBvAG/AdIB0wH2AQsALAAwAEIASABSAF4AcAB+AJ4AyADeAOgA6QDrAPUA+AD8ABoBMAE4ATkBOwE8AT0BRgFIAVUBZQFqAXQBdQGwAbIBvAHEAccB3gHzAfYBGgAsAEIAUgCRAKUArgC6AN4A/AD9AP8ABgEIAQwBOQFlAWgBagFwAaIBsAGyAbYBtwG7AbwB1AHfATYCEAAWABoAIQAkACUALwAwAEYAUgBfAGwAdACRAJ4AowCkAKYArwCwALoA2QDoAOkA6wD9AP8ABgEIAQwBEAERARoBPQFGAVUBZQFmAWgBaQFqAYMBjQGiAbABtgG3AbsBxwHTAdQBKAIWACUARgBIAF8AcwB9AJEAmgCkALoAxwDeAOgA6QDrAP0AAAEQAREBIgE5AT0BRgFIAU0BZQGDAYQBhwGNAbsBNQIaACUAKwBIAFIAXgBfAHMAhwCRAKUApgC6AOsA9gD8AP0A/wAQAREBGgE8AWgBdQGHAcQBGgAlAF4AXwBwAHsAhwCRALoA3gDrAPYA/QAIAQkBFQEXARoBPAE9AU4BZQFmAXABdQGwAbsBJwIoAhEAGgAlAD4AbgBwAHsAgQCHAJEApwC6AOsA8gD8AAgBCQEMAREBFQE9AWUBZgF1AYQBjQGwAbsBxwEfAicCGwArAEgAXwBzAHsAgQCHAIkAkQCcAJ4AogDeAOMA6wD4AP0A/wAQASEBWAFmAaIBqgG7AccBJQA9AD4AQQBGAEgAUABeAF8AcwB+AIkAogCpAMAAwgDeAPUA9gD4AP8ADAEQAVgBZAFwAY0BogGrAawB9gEfAicCJQBeAHMAjgC2ALoA9QD8AP0AEAE8AWUBZgGNAasBrAGwAb0BvgHXAfYBDQASAB0AJQBIAEkAUwBcAGgAagB7AH4AkQCYALoA3gDyAP8ADAEQARwBQAFGAWUBcAGDAZ8BsAG+AcQBMAINACUAVgBcAHsAkQC2AN4A8wD2APwA/QD/AAgBDAE8AUcBSAG+AcQBxwFIAH4AmACjAKYAtgC6APQA/QD/AAgBDAEQAUgBYQFlAZMBogGwAbsBvgEfAisCMAIHADoAfgCYAKMApgD0AP0A/wAIAQwBEAESAUgBYwFmAYcBkgGiAaYBsAHHAR8CJwIrAjACQgBIAGoApgC6ALsA/wAIAQwBEQESAR4BRwFIAUsBhwGNAaYBsAG3AcQBxwEwAkcCAAANABEAQgBIAJgA9gAMARIBVAFkAWUBcAF1AZMBtwHEAccB8QEnAisCJAAvAEEASAB+AI4AmAC2ANkA6wD2AP8AAAFIAWkBnwGsAboB8QEfAicCKwINAEEASAB+ALYA6wD/AAABEQESAUgBagGNAawBrwG3Ab4BxAHHAeoBBgInAn0AfgCmALYAwADCANIA8wD2AAgBDAE1AUUBTQFlAWoBfgGNAaIBrwGwAcQBxwEBAh8CLwIlAIEArACwALoA+AD/AAwBMAFIAU0BVQFmAWoBbAGNAaIBpgGwAbcBvgEBAh8CGgAvAEUAgQCHALoA6ADpAPMA9gD4AAgBDAERAUQBTQFmAXABjQGmAa8BvgEwAhoAIQBAAEIATAB+AJwAngC7AN4A6ADpAO4A8wD/AAgBEQEeATwBRAFIAVUBrwEFAh8CMAINABoATABTAGoAegCCAJ0AugDeAO4A8wD9AAwBFwEiATwBPQFIAU0BVQFwAbABvgEHAA0AEABCAFMAaABwAHsAggCHAIgAnQC8APMA/QAIARABEgEXAT0BVQFmAY0BsAG3AccBzwHgASsCRgIHACYAMQBEAEwATgBwAHsAiAC7APMA9gD9AAcBDAEQARIBSAFNAWYBdQGNAbcBuwHHAR8CKwImAD4AQgBMAE4AWACsAPYABwERATgBOQE6ATwBPQFGAU0BaQFwAXUBjQG7Ac8BDQAaACcAMQA+AEIAWAB+AJEArAC7AOgA6QD9AP8ABwEMARABEQEXATsBRAFIAUsBTQFVAWkBcAF0Ab4B0QEBAgcADQAaACYAMQBEAEgAWACHAJEA4wDoAOkA8wD/ABABEgFLAVUBWAFwAXQBgQGwAbEBuwHRAeEBAQIrAjEASABJAEwAVgBfAGUA8wASAUYBSAFLAVUBWAFmAXABsAGxAbcBuwG+AQ0AMQBEAEwAUwBYAF8AewB+AIIAhwCIAK4ArwAEARoBRgFIAWEBZgFwAY0BogGrAa8BtwG7AcUBzwEnAikCKwINAD4ATABuAHAAewB+AIEAggCIAPMA9gD8ABoBRgFIAWYBcAF0AY0BogGrAa8BsQG3AbsBHwI/AEQAVgBwAH4AgQCHAOkA6wD2APwA/wAMARIBRQFIAWYBdAG3AbsBxQHPARoCJwIrAgAAGgA9AFgAagBzAH4AiADaAOkA/gASARoBNQFFAUsBYQFpAbsBxAH5ARoCHwIrAlgAXgCBAI8A6wD2APwA/gD/AAwBGwElAUcBSAF2AY0BpgGrAbABsQG2AcQBxQHPARoCHwINAEgAUwBeAIcAiACPAOkA/wATARoBGwFGAUsBTgFhAXYBowGmAasBsAGxAbYBuwHFAc8BGgIfAgcADQAaACYARABIAHAAsADpAOsA7gD4AAgBEwEaAT8BRgFVAWkBcAGfAaoBsQG6AcUBzwErAg0APAA/AEwATgBTAFgAcAB7ALAA6wD2AAgBSAFLAWEBYwFwAY0BnwGsAbEBvgHFAdEB1AHeAfMB9AEnAisCDQAaADEARABOAFMAWABvAIEAhwCmAOsA9gAIATsBSAFVAWkBcAGNAasBrAGwAbEBuwG+AdEB1AEaAhoAPwBMAE4AUwB6AH4ApgC8ACEBNQE7AY0BqwGwAbEBuwEaAh0CLwJGAgcAJgA6AD8ASABMAG8AcwB+AIEAhwCmALAA6wD+AFUBYQFsAXABdgGNAasBvgG/Ac8B/gEBAhoAQABEAEgATgBYAF4AcwB+AIEAggCHAKYAsACyAOkA/QATARcBPAFHAUgBSwFVAWwBcAF2AY0BuwG+Ab8BxQHHAc8B5AEbAh0CKwIHABoAHwAnAEQAWABeAHMAggCaAJ4ArACyALsA6QDrAPYA/QD+AAgBGgEqATwBSAFLAVUBjQGrAa8BxQHPAQECHQIrAiQAOgBAAE4AcwCHAI8ApgCsAK4AsAC7AOkA6wD2AP4ACAEaASUBPAFhAWkBcAF2Aa8BuwEfAiACRwIHAB8AJgA6AFEAcwCCAI0ApgCvALAA6QAMARsBPAFhAXABoAG+AcMBAQIHABoAKgCUAJgAowCsALAAuwDrAP4A/wAMARsBKQFEAUgBYQFkAXkBjQGwAb4BxQEdACQARABOAFEAcwCCAOkA6wDsAP8ASAFhAWkBcAGCAYUBsAG/AcUBAQI6AD8ARAB+AIcAjQCdAOsA/gAMAWEBcAGCAaEBvwHFAd8BAAIHAA0AKgA6AD8ATQBYAHMAfgCCAIcAjQCOAJ0AsADIAOwA/gAMARoBVQFkAWkBcAGCAZIBogGxAcUB3AEHAA0AaAB+AIEAggCHAI8AsAC6ANIA/QD+ABoBZAFpAXABogHcAd0BAAICAgcADQAbAEQASgB+AIEAhwCnALsA0wDpAP0A/gAaAWwBqAGxAbsB8gEAAgICBwBKAH4AjQCPALAAuwD9ABoBZAFpAXgBhAHFAeMBDQA/AEQATQBsAIIAjwDIAP0A/gD/ABsBNwE8AUsBYQF4AYQBoQGlAdwB9gEAAjoAPwCCAI0AuwD+AP8AAAEbASkBVQFhAaEBsQG7AcUB3AHjAegBAAINACwAOgBzAH4AjwDgAPYAFQEqAUUBVQFmAW4BxQENABoALABEAEgAcwCNAKQA4AD1APYAGwE8AW4BggGiAawBsQHaAfIBDQAaAEgAegB+AIIApAD1AP0A/gAAARoBGwE7ATwBTQFmAaIBqQGsAdoB6AELAA0ARABOAHwAfgCBAIIApQD1AP0AGgFmAXABggGwAbEBxQHdAegBCwAWAE0ATgCBAPUAAAFVAWEBZAFwAbABuwHdAfIBHwBEAEgATQB8AH8AjQCOAKUApwC6APUA9gD+AAABGgE8AWEBbgF4AZgBuwHFAfgBAAIaACEARABIAGMAfACNAKQApgD2AP4AAAEaARsBRwFbAWkBbgF4AZkBvgHFAQACCwAWABoAIQAsADoAPwBAAEQATQB8AI0AmACkALAA3gDoAOkA7gD2APgA/QD+AAABPAFHAWEBZgFwAX4BggGEAZEB7AH1AQIACwAqACwAOgBAAEQATQB8AI0AowCmAP0A/gAAATsBTQFhAWUBZgFuAXABfgGCAYQBpAG7Ab4BAgANABoAPwBEAE0ATgBVAFYAfACPAJEApgDeAPYA/gAEARcBOwFHAU0BZgFuAXABhAGxAbsBxQHaAeMBFgAaADoARABOAFYAggCPAJEApgDeAOYA6ADpAPYA/gAAARcBPAFOAWYBcAF9AYQBsQG7AcUB2QHaASgCBwANABYAGgAfACEAOgBNAE4AXwCBAIIAjwCRAJIAowC3AOYA9gD4AAwBSAFmAbABuQG7Ab4BNgIHAA0AHwA6AEAATgBeAF8AggCjAKQApgC3AMQA4wD1APgAAAEaAR8BRwFIAWYBdgF5AbsBAQAQABEAGQAaADoAQgBNAE4AVgBeAHwAkgCkAPwA/gAAARcBHwFjAacBuwHaATUCAQACAA0AEAARABkAIQAsAEAAQgBNAE4AVgB8AIIAjgCmAMAA9QD8AAABDAEXATsBTQFkAWoBeAG7Ab4B2gHuAR4CAgALAA0AHwAqACwALQBEAE0AfACSAJgAngCkAKYAwgDeAPYA/gAAAQkBOwFIAU0BdQF4AYQBlwELABYAGQAaAEEARAB8AI4AkgCkAKYA9QD+AP8ACQFhAWYBggGEAZcBHAIWABkAGgAhAC0AQQBCAEgATQCJAI4AjwCRAKkA9QD2AP4ACQEbAVYBWgFbAWYBeAGBAYIBuwG/AdoBAQANABkAGgAhAC0AQgBNAIEAggCkAKYAtgDeAOgA6QD1APYA/QD+AAkBFgFWAVoBWwFlAW4BcAF4AZcBuwHaAQEAAgALABoAggCYAKAApACmALYA3gDoAOkA9gD8AP0ACQEcAUYBZgFuAXABdQF4AZcBAgALABYASABjAI4AmACgAKMAtgDmAPwA/QAJATcBRgFHAUgBZgFqAW4BeAHXAeYB/wEBABYAGgAtAEIASABsAG4AfACNAI4A3gDmAP0A/wAAARsBRQFHAVoBbgG7AQ8ALQBCAE0AggC7AMQA3gD/AAABCQEbAUYBSAFUAVoBYQF2AQcADQAaAD8AQQBIAFUAiQCNAI4AmAC2ALsAyADeAOMA6ADpAPQA9gD/AAYBFgEbAR0BOwFGAU0BVgFmAYUBuwH2Af8BBwANABoAIwA/AEEATQB8AI0AjgCYALYAuwDjAPQA9gAJARYBFwE7AUIBRgFIAU0BuwH3Af8BQQBNAHwAggCNAI4AjwC2ALwA6ADpAP8AAAEIARcBOwFWAXABhAGTAbsBDQAaAEEASAB8AI4AjwC2AN4A/QAAAQgBCQE7AUgBSwFNAXABjwEEAEIAXwCCAKYAuwDoAOkA/QAJAUgBTQGEAZcBuwG+AQQADQAaAD8AQgBNAF8AaAB6AIIAjwC2AN4A7AD+ABcBSAFwAYIBhAGXAbsBvgHHAQQAFgA/AEEAegCJAI8AmAC7AMAAwgDoAOkA7gD+ABcBSAGEAacBswG7Ac0BBAAWAEEATQBlAIkAjwCWAJgA3gDjAOYA7ADuAPYABgENATUBRgFIAXABggG7Ab4B7QEaAE0AegCCAI8AlgCgAOMA5gDsAPYAOwFGAUgBcAF9Ab4B5wHqAe0B8AECAAcAIQBNAJgAnwCgAKYA9gAIARUBGwE7AUQBbAGYAagBvgHwAfsBAgAHAAsAGgAhAC0AXwCYALsA2gD2APgABgEIARcBOwFEAUYBVgF+AZIBmAG+Ac0B5wECAAsAGgBCAE0AXwCCAIgAjgCPALAAuwDjAPgA/gALARcBOwE8AUQBRQFIAc0BBwAaACwALQBNAHMAggCIAI8AsADfABoBRQFIAVQBVgFmAXABvwHnAQIABwANABYAGgAqACwAPwBCAE0AdADfAPUA/gAIARMBFwE7AUUBWwFmAXABgQGCAYQBuwHNAQIADQAWAB8ALAA/AIEAggCIAI8AmACwALsA9QD/AAgBFwE7AUQBVQFjAWwBuwHSAQIAFgAsAC0ATQCIAI8AmACmALAA3gBVAVgBYQFjAWYBbAFwAZkBuwG+AcIB0wECABYAHwAsAF8AcgCCAP8ABgEWASUBKQFLAUwBcAG+AeEB7QELAA0AFgAZAB8ALABNAF8AjwD/AAsBIwFLAU8BVQFmAWkBbAG7Ab4BAgAHAAsAFgBIAH8AgQCIAI8ApgCwAN4A+AD/AAsBLgFPAWEBaQGLAb4B4AECAAcAFgBBAEIAfgCIAI8AmACbALAAuwD1AP8ABgEuAUsBTAGLAZMB2AECAA0AFgBBAEIAmwCmAMcA3gD4AP4ABgFIAVgBYQFjAXABhAGLAQIAQABCAE0AcACYAN4A9QD+AAsBSAFYAWYBCwANACoALABAAEIASgBwAIgAmACmAN4AAAELAToBWAFhAWYBaQGLAZsBnwHBAQsALABJAEoAcgCCAIgAmACmAMgA3gD9AAABPAFLAVUBWAFpAXABBwANABYAggCYAKYAyADeAPUAKgE8AVUBZgFwAaUBqQGrAboBwwHnAe0BBwANABYAGQAfAEEASQBNAIIAiACOAJgA9QD9AFgBYwFmAWkBbAFwAYQBiwHnAQ0ANwBBAIEAiACRAJgA3gALAUsBTQFOAVUBWAFjAWYBaQGLAZgBngGqAb4BHwBAAEoATQB0AIEAjgCkAKYAwgD1AAABBgELAUgBTQFVAVgBYwFmAWkBhAGLAZIBvQG+AQ0AHwAkAC0AQABCAGwAbgCYAKQApgDCANoA3gDuAPUABgETAUgBWAFbAWEBYwFlAWkBcAF2AYsBogEkAEIAiACOAJgAmwCmAN4A7gAAAQYBCwE7AWQBaQFwAYQBlwEMAB8AQABCAI4AmwCvAO4ABgELARUBOwFLAVEBZgFwAYQBiwGXAaIBpgEMAB8AJABAAEIASgBNAIEAggCOAJgApgDuAAABOgFLAVEBYwFkAWkBngENACQAQQBwAIEAggCOAI8AkQCYAKYAsgC7AN4A7AD1APYA/QA6ATsBSAFNAVgBZAFmAWkBgwGLAZIBngHOAQ0AQQCCAI8AkQDeAOIA9QAXAUgBTQFRAVUBWAFhAXABngGhAaIBpgEkAC0AQgBKAE0AcACCAJEA4gD9AP4AAAFRAVUBWAFhAWMBZAFmAXoBhAGLAZ4BoQEgAi0AQgCRAPUAAAELASMBJQFIAUsBTQFRAVgBZAFmAWkBcAF6AYQBiwGiAfIBIAINACQASgBNAGMAggCOAI8AkQDsAPUA/QD+ACoBOwFIAUsBUQFVAWEBZAFmAXABegGEAaQBpgG2AcMByAHUAd4B3wHkAecBDAAtAE0AggCPAJEA3gD3APgA/QD+AAsBKgFLAVEBYQFkAWYBcAF1AXoBhAGLAaEBpgGsAbYBwAHUAQwADQAtAEAASQBKAHMAjwDeAN8A9QD4AP4AAAELAVEBWAFkAXABhAGLAZQBoQGsAbkBwAHnAQwAQABJAEoAcwCCAI8AkQCYAN8A9QA7AUQBSAFNAVgBcAGSAZsBpgGsAcAB5wHoASQALQA4AIIAkQCYALIA4gDjAPUA/QALATsBRAFLAVUBbQFwAaYBrAHUAecB6AEMACQAiACPAOIAAAELARoBSAFYAWEBngGhAaYBtgHAAcIB4wHuARUAggCPAN4A/QD+ABoBOgE7AT0BWAFkAZ4BoQG2AcAB1AHoASACQABCAE0AggCRAKYA4gD9AAsBHQE7AUUBWAFhAZ4BnwGhAaIBpgG7AcQB3QHnAegB9AEdAh4CBwAVAEAAQgBzAJEAmACmAOIA+AD9AAsBQgFLAWQBegGeAaEBogGmAcQB5wHzAQcAFQAaAEAATQBwAHMAggCYAKYA+AAAAQsBRwFMAU0BYQFkAWwBcAF6AYQBngGhAaYBuwG+AecBBwAMABUAGgBAAEIAcwB/AIIAkgDeAAABCAEJAUYBRwFIAUsBVQFhAW0BngGwAbEBvgHEAecB+QEpAgcADAAWABoAQgBIAHAAjwCYAOkA7gAIAQkBCwFEAUgBYQFkAZ4BoQGwAbEBvgHEAcgB5wENAC0AcwB/AIEAkgCYAKYA4gDsAO4A9QD4APwA/gD/AAgBCQFGAUsBVQFlAWYBcAGfAaEBuwHEAfcBGgCBAIgAkQCmAOIA7AD0APgA/wAGAQkBCwEaAUQBZQFmAWkBegGgAaYBuwG+AcQBxQHnAXAAiACPAOkA9AD1AP8ABgESARoBOwFLAU0BXgFhAWQBcAF+AaEBuwG+AcUBDQAWAHAAjwCRAKYA4gDsAPwAAAEaAUQBSwFNAVUBXgFhAWQBawFwAXoBhAGhAbABuwG+AdkB/wENABUAFgAtAHAAcwCPAKYAsADeAOIA7AD8AP8AAAFLAVUBXgFkAWYBawF6AaYBsQG7AcUB2QEVABoAcACRAKYA4gDsAPUA/AAAARoBYQFkAWYBcAF6AaYBqwGxAbsBvgHFAfgB+wEaADgARQCRAJ4ApgCwAN4A7gD1APwAAAEGAQgBCwFcAV4BZAFwAXoBpgHFAdkB5wEHAC0ATQBzAH8AkgCYALAA7gD1APwABgEIAV4BYQFkAXABegGEAbABuwHoAQcAFQBNAH8AkgCmALAA3gD1AP8AAAELAVwBYQFiAWQBZgFuAXQBsAG7Ab4BxQHZAewBFQAtAJIAmADeAPwA/wBcAV4BYQFiAWQBZgF0AaYBsQG7Ab4BxAHFARYAGgCRAJgA3gD4APwA/wALARoBRwFcAWEBYgFmAXQBrwGxAbsBvgHEAcUB5gEWAC0AQgCRAJIA3gDuAPUACwFHAWQBdAF6AaYBrwGxAbsBvgHFAdkBQgBwAJgA9QD8AA0BPAFeAWYBcAF0AXoBrwH1AfcB/gEtAHAAcwCBAI8AkgCmAPUA/AALAQ0BGgFIAWIBZgFwAXQBegGvAb4BxAH1AQICFQBzAJEAkgCYALwAyAD1APgA/AAAARoBPAFIAWEBYgFmAXQBegGEAaoBsQG7AcUB2QECAhUAgQCvALoA3gDoAOkA7gD1APwA/wANATwBPQFEAUcBXgFhAWIBcAF0AZ4BxAHFAdkB9gEAAigCDQAVAC0AOABCAH8AgQCPAJEAmACbALoA3gD1AAABSAFOAVsBXgFmAXABdAF6Ab4BxAHFAdkB9gEbAg0AFQAtADgAQgCOAJgAmwCeALoA9QD3APwAOwFHAUgBTQFhAWYBcAF6AaoBDQA4AHMAfwCBAJIAmADeAPUA/AAIARoBRwFMAWEBZAGDAZ4BqgGwAcMBxAH1AfcBDQA4AHMAfgB/AJIAmADoAOkA9QASAUcBTQFhAWIBZAF0AbABtwG+AcQB2QH1AfcB", + "provenance": { + "method": "chrome-solid-face-id-exact-interior-pixel-census", + "route": "http://127.0.0.1:4325/cityflow/", + "presentation": "schema-37-folded-adaptive-smooth-sine-12-42-0.55-eased-extrema-60hz" + } +} diff --git a/src/adapters/cityflow/notes/references/prepared-visibility-wide.json b/src/adapters/cityflow/notes/references/prepared-visibility-wide.json new file mode 100644 index 0000000..f36d554 --- /dev/null +++ b/src/adapters/cityflow/notes/references/prepared-visibility-wide.json @@ -0,0 +1,46 @@ +{ + "schema": "csscityflow-prepared-wide-visibility-source@1", + "sourceRevision": "906693799e4fb7581436590cf84ecb2d3c9186ba", + "seed": 26081702, + "frameCount": 301, + "sourceFrameCount": 251, + "faceCount": 600, + "viewport": { + "width": 2560, + "height": 1224 + }, + "selection": "stage-width-greater-than-two-times-stage-height", + "transitionDilationFrames": 0, + "initialVisibleCount": 93, + "minimumVisibleFaces": 83, + "maximumVisibleFaces": 112, + "meanVisibleFaces": 99.41196013289037, + "minimumVisibleBoxes": 41, + "maximumVisibleBoxes": 57, + "meanVisibleBoxes": 48.66445182724252, + "transitionCount": 532, + "initialVisibleBitsBase64": "AAAAAAAAAAAAAAAAAQAAABAAAAAAKQAAwAEAOBAcAAIEKHggPYAPwA8AB9YBB4Aix38AgLEDACYEBwEAgHEA2AEAACAAAAAAAAAA", + "transitionOffsetsBase64": "AAAFAAgACgAKAAsADQAOABQAGQAbABsAHQAeAB8AHwAiACUAJgApACoAKwArACsAKwArACsAKwAsACwALAAtAC8AMAAwADIAMgAyADQANAA1ADYANwA3ADcAOQA7ADsAPAA8AD4APwBAAEIAQwBDAEQARQBFAEUARQBGAEgASABIAEkASwBMAE0ATQBPAFAAUABRAFMAVABUAFcAWABYAFgAWQBcAFwAXABdAF0AXQBeAF8AYABjAGMAYwBkAGYAZgBnAGkAbABsAHAAcABwAHcAdwB3AHgAeAB6AH4AfwCBAIcAiACIAIoAjACMAI0AjwCTAJUAmwCcAJ4AoQCjAKYApwCsAK8AsgC2ALkAvgDBAMUAxgDLAM4AzwDVANgA2wDeAN8A4wDjAOYA5wDrAO0A7gDwAPIA8gD3APgA+AD7APwA/AD9AP8A/wABAQMBBAEFAQYBBgEHAQsBDAEMAQwBDQEOAQ8BDwEPAQ8BDwEPAREBEgEVARUBFwEYARkBGQEaARoBGwEcAR4BIQEkASQBJQEnASgBKgEsAS4BLwEvATEBMQEyATIBNgE3AToBPgFAAUEBRQFLAUsBUQFSAVMBVQFaAWEBYgFkAWcBaAFrAW4BcAFyAXgBegF9AYEBgwGJAYoBjQGTAZYBmAGcAaMBpQGpAasBrQGuAbMBtwG4AbwBvQHCAcUBxwHKAcwBzwHTAdQB1AHXAdkB2gHeAeAB4wHjAeYB5gHoAekB6wHuAe8B8wH1AfYB9gH4AfgB+AH6AfsB/AH+Af8BAgIGAgcCCgINAg8CEQIUAg==", + "transitionFaceIndicesBase64": "nwGgAbUBzwHmAZ4ByAHQAV4B/gGDAV4B/QG8AZ8BoAHDAdAB5wHoAbUBvAG9Ac8BAAK0AfsBfAGFAbwBoAGNAc4B7AFpAZ8BvAGzAZ8BvAHIAb0BaQHnAfsB+wH9AfsBfAHmAZsB/gF+AewBHQLDAQECnwHuAQECaQEdAhsCAAJ0AXUB6AEcApsB7gGPAUcC9wGDAZEBfgG9AXQBdQGPAZEBjwG9AZEBqACFAfcBjwGRAeUAhAGFAbwBswHOAYMBgwGEAYUB5QCFAeYBgwETAbwBgQCoAFwBogAgAY8B/gGRAZ8BswHDAf0BGwJHAs4B2AHZAREBXAH3AQACgQAcAh0CFQF+AZsBvQHYAdkBqgAgAT8B7gH9AZEBnwHDAWIAMAE0Ac8BpAH/AaoARwGbAaQB5gHoAccAFwHJAUgBiwH/AREBugHrAMIB7QGRAZAAJQGlAbkB7gHKAdgB/AHQAfsBHQIMAWoBfgGfAYYAogCoATAB2QH+AQACGwKiAD8BYQElAUcBSAHtAYcByACBAbkBygHoAcMA+wDCAfsBogDDABcBqAHJAfwB+wAVAaUBaAHJAfUBfAGLAboBQAEiATQBYQHJAZ8AqAAMARYBoQAVAT8B0AHrAEABqABoARwCFgEwAaEAyADrAIEB4gGfAGoBhwFHAhEBogARASQBhgCiACIBnwH1AdkBMAGQACQBPwFpAZEBzwGoABUBJAHIAOsAEQGGABMBzwGBAJAAqAAlAWIA4QGBACUBnwFgAKoA7ACFAPkAHQK9AKIA0AHQAXgAaQH5AO4BhQDlAJMAeAB+AQMBxwDIAH4BugHsAGAAvgCCAfsApQG5Ae0BhgD6AKkBEQGkAagBzwGqACUBKQEbAhwCRwKBAJAAqADDAEcBSAF9AQACpwHsASUBWgGfAcIB7AGTAMAAKgFHAUgB6AHsAYEAwgCBAWAAzwHoAWoBkACHAcIBhgBaAewByAAdAccAwwG+AMMAKgFCAZ8BpwEWAe0BogB0AXUBhQC9AOwAKQFlAWYBnwDAAMIA+gATAX0BhwGhAKgAqgD7ABUBZQFmAWoBgQF+AagBwwHsAO0BgQDlABcBHQEDARYBQgHoAewB/gH/AaoAggFgADQBwgHmAYUAqACLAcIBYgDIAJsBwgHYAeEBgQAXAZwB9wHrAJAAoQBHAUgBqQGGAJ8AugHQAe0BNAF0AXUB6wB6ARUBjwGlAeIB5wFIAacB+wFHAXoBfAGnARMBJAGkAdABiwGcAWIAqAC5AcgBzwGbAZ8BwwHuAR0CfgHCAcQBxwDEAcQBXAG8AYEAgwHEAf0BaQG8Ab0B/wFeAYUBswHIAewBbgGNAfsBdwHnAegBngGBALwBAAKfAaABzgH9AeYBXAF3Af4BgwGEAcMBXgEAAoMBhAFuAbQBAAI=", + "source": { + "schema": "csscityflow-prepared-wide-source-visibility@1", + "frameCount": 251, + "transitionDilationFrames": 0, + "initialVisibleCount": 90, + "minimumVisibleFaces": 83, + "maximumVisibleFaces": 115, + "meanVisibleFaces": 98.49003984063745, + "minimumVisibleBoxes": 41, + "maximumVisibleBoxes": 58, + "meanVisibleBoxes": 48.28286852589641, + "transitionCount": 644, + "initialVisibleBitsBase64": "AAAAAAAAAAAAAAAAAQAAABAAAAAAKAAAwAEAOBAcAAIEKHggPYAPwA8AB9YBB4Ajx38AALADAAYEBwAAgHEA2AEAACgAAAAAAAAA", + "transitionOffsetsBase64": "AAADAAYACQALABAAEgAUABcAGQAgACEAJAAlACYAKAApACoALAAtAC0ALQAuADAAMAAyADIANAA1ADcAOQA6ADsAPAA+AD4AQABAAEAAQABCAEIAQwBFAEgASABLAEwATgBOAFAAUABRAFEAVABYAFgAWwBbAFsAWwBeAF8AXwBgAGIAZABkAGQAZABoAGoAagBrAGwAbQBuAHQAdAB0AHQAdwB6AHwAfQB/AIEAgwCFAIYAiACLAIsAkwCVAJYAmACdAJ4AogCjAKoArgCyALQAtgC6AL8AxADIANAA1QDXANoA3wDjAOMA6gDwAPUA/QAGAQsBDwERARUBFwEZARsBHwEhASIBJwEoASoBLQEwATEBNAE0ATQBNAE1ATYBOAE6AToBOwE9AT8BQQFBAUIBQgFCAUUBRQFHAUcBSwFMAU0BTgFQAVIBVAFXAVsBXAFfAWEBZQFnAWkBawFtAW8BbwFwAXgBfQGEAYcBjgGQAZUBmAGgAaMBpgGoAa0BrwGxAbQBugHDAcUBzQHSAdcB2gHkAegB6wHwAfcB+gH+AQACBwIKAg8CEQIYAhoCHgIiAioCLAIwAjICMwI0AjUCOQI+AkACQwJIAkgCSwJMAkwCUQJXAlkCXAJcAmECYgJjAmMCZQJpAm8CcwJ0AnkCeQKAAoQC", + "transitionFaceIndicesBase64": "bgGDAeYBXgF8AcgBgwHJAf4BYAB4AZ4BnwHJAf0BGwLnAegBXgH7AZ8BwwEAAmkBhQGgAbQBtQG8Ab0BzwHQAb0BjQGfAewBjwHOAWkBswHIAf8BvQHEAfcB5wFHAUgBRwFIAcQB/wH7AfsB/QGPAfsB5gH3Af4BfgGbAewBHQLDAe4BqADoAR0ChQEAAhsCdAF1AZsBhQF8ARwCqADuAY8BgwGRAc8BaQF+AfcBRwJ0AXUBvQGFAY8BvQHPAfcBAwGRAeUAjQFpAYQBhQG8AZ4BswEDAc4BaQGDAWAAvQCDAYQBhQGeAeUAgwGFAVwBjQHmAREBvAEgAagAvQCoAP4BqAD9AaIAwwGfARsCRwKzAc4B2AFcAY8BmwG9AdgB5wHoAQACHAIdAucBqgB+ARUBIAE/AegB9wGfAb0BzwHuAf0BfAGQADABNAGkAb0B0AHmAaoAvQHDAegBYgATAZsBvQHHAKQBkACLAesARwFIAboB3AAXAcIByQHtAaIApQG5AfsB/AERAWoBygHiAdwAfAF+AYIBqAHuAf4BHQKGAAwBggHYARsCYQEAAvsAhwGfAagAJQEwAT8BgQGQAPoA4QHtAcYAyAAXAVoBuQHJAcoBnwBHAUgBwgHJAegBwwD6ABUBHgF8AcMAHgElAVIBaAGOAaUB+wGhAMYAeQGKAYsBjgGoAboB/AHDACIBQAFaAckBqADDAPsAFgExAWEBxgAMATQBQAHrANABigHPAcYAFQEwAT8BaAG9ATEBHAJ5AcgA6wAWAVIBgQERAWoBhwGhABEBRwKfACIBzwG9AYYAkACiADABPwHZAfUBzwHhAesAogBpARMBFQHIAGkB4QHrANkB9QGGAM8BkACiACQBegGBAKIAqgBiAOsAgQD5AIUA0AFgAOwAHQJmAHgAogDcAHoBZgC9AO4BeQB+AZMA+QB0AXUBrADlAIUAQgFCAdABvQDcAHkAAwGUAMcAyADsAPsAdAF1AboBHQJ4AJAAvQC5Ae0BYACGAL4A+gB+AYIBpQGpAc8BHQIpAaQBqAEAAhsCHAJHAoEArACUAL0AwwAlAX0BJAFHAUgBJAFDAVoBiwGnAcIB6AHsAcAADQFZAb0AKgFqAZMASAHCACUBRwGBAYsBgQDPAQ0BzwFgAKIAzwGGAB0BWQFaAYcB/wHHAMgAFgFCAUMBwgHoAewB/wHDAdABvgAqAWUBZgF0AXUBpwHQAb0AFwFAAdAB7QGFAJAAKQHsAe0BEwEVAX0BnwChAMMA7AD6AA0BQAF0AcMB0AHAAMIA+wAYAX4BgQGHAeUA7AANAagB6AGBAB0BZQFmAWoBfAHCAQMBdAH3AWAAqgA0AfcBQgGLAWIAhQAWAT8BggH7AfwBgQAYAY8ByADrAJwB4QH+AdAB/wGQAKEAFwE/AXwBjwHmAZ8AqQGGAKcBygHiAUcBygHOAdgBqAA0AXQBdQGbAboB7QH7AUcB/AHrAKUB5wH7ARUBfAETAfcBpAFiACQBiwHOAagAnAGnAcgB0AGoALkBxwB+Ac8BmwHDAdgB7gEdAlwBkQHEAcIBgQAgAV4BgwGPAYUBkQG8AcQB2AH9AZ8B/wFpAbwBvQEgAVABbgHIAewBdwGNAfsBAAJvAXABngGzAYEAnwGgAcMB5wHoAVABvAHOAf0B5gFeAW8BcAGMAf4BeAGDAbQBtQHPAdABGwJcAXcBjAG9AQ==" + }, + "provenance": { + "method": "pinned-native-cityflow-depth-and-backface-id-buffer-wide-source-viewport", + "presentation": "schema-18-prepared-height-bank-plus-exact-source-height-rows", + "sourceViewportBranch": "width-greater-than-height-times-two-square-viewport" + } +} diff --git a/src/adapters/cityflow/notes/references/prepared-visibility.json b/src/adapters/cityflow/notes/references/prepared-visibility.json new file mode 100644 index 0000000..9c1f722 --- /dev/null +++ b/src/adapters/cityflow/notes/references/prepared-visibility.json @@ -0,0 +1,57 @@ +{ + "schema": "csscityflow-prepared-visibility-source@1", + "sourceRevision": "906693799e4fb7581436590cf84ecb2d3c9186ba", + "seed": 26081702, + "frameCount": 301, + "faceCount": 600, + "viewportUnion": [ + { + "width": 1280, + "height": 720 + }, + { + "width": 2027, + "height": 1236 + }, + { + "width": 2560, + "height": 1224 + }, + { + "width": 3440, + "height": 1440 + }, + { + "width": 1024, + "height": 1024 + }, + { + "width": 800, + "height": 600 + }, + { + "width": 390, + "height": 844 + }, + { + "width": 430, + "height": 932 + } + ], + "transitionDilationFrames": 1, + "initialVisibleCount": 224, + "minimumVisibleFaces": 191, + "maximumVisibleFaces": 241, + "meanVisibleFaces": 225.22923588039868, + "transitionCount": 1472, + "initialVisibleBitsBase64": "bwEUAAAAaIsAAIB0x9cXL3IAGVas/wHkyAEMPzC8BQ58KHokP4AfwB/0x/4hf44/338QA/YDMCYEhwHg+HEU2AEAAGgDAwAAAAAA", + "transitionOffsetsBase64": "AAAFAAgADgARABUAGwAfACMAJgAqAC4AMgA3ADkAOwA/AEEAQgBFAEsASwBNAE4AUQBRAFIAVgBXAFoAXgBhAGMAZQBoAG0AcgB1AHgAfQCCAIMAhwCJAIoAjQCPAJIAmACeAKUApgCrAK8AswC4AL0AvwDBAMIAwwDGAMgAywDOANIA2wDkAOcA7QDuAPEA8wD7AP4ABAEIAQsBDwERARMBFwEbASABIQEjASMBJgEoASoBKwEuATQBOQE+AT8BRwFPAVIBVAFZAVwBZAFpAXABcgF4AXwBggGHAYoBjQGSAZkBogGoAbIBtgHDAcwB1QHbAd8B4wHtAfgBAAIHAhICHgIhAicCLAI4AkACRgJLAlQCWAJfAmMCbgJ3An0ChgKKAo4CkgKYApoCnwKkAqkCrAKzArwCwQLIAs8C1wLhAuQC6gLxAvcC/wIEAwgDCgMNAxEDFwMZAxwDHgMgAyUDKwMtAy8DNAM5Az0DQgNHA00DUgNTA1YDXQNlA2oDcQN4A4EDhgOJA40DkwOYA54DowOpA7cDuwO+A8UDzAPOA9UD1gPbA9wD4gPnA+8D9AP7AwMECwQTBB8ELAQzBDUEPQRFBEYESgRNBFEEVgRZBGAEYwRoBHIEdgR4BIAEiQSQBJQEnAShBKYErQSzBLQEtQS2BL8EwQTIBM0E0ATWBN8E4wTnBPIE+AQBBQYFDQUQBRUFIQUnBTEFNQU4BT4FQQVIBU0FWAVgBWUFZgVrBWwFcwV6BX0FgwWEBYcFjAWRBZMFlgWZBZoFnAWkBaoFsAWzBbgFugXABQ==", + "transitionFaceIndicesBase64": "AgDhAOMAfwEhAm4BngGfATgAXQFeAbQBtQG8AUsBzwH9AXgByAHPAeYBtAG1AbwB4QH+AR8CfwGgAbwB1gE2AF4BhAHDAYUB5wHoAZ8BqgHVAfsBNQDiAfgBGwJpAdgB2QHeAZ8BvQHOAfYB9wGzAQACjQGPAWkByAHVAeUBqgH1AewBfwH2AfgB4QDjAL0BxAHeAeUB5wEFAh8CNQDZAQYC+AFHAUgB4AHzAfkBNgC7AdkBRwFIAfUBIQLWAfYB+wFLAdYBqgHEATgA5gEGAgIANAGEAfgBKAJdAX8BjwH9Af4BmwHWAQUCHAGEAasBRAHgAewBAQICAjcAvAB+AaIBuwF/AYQBAQICAikC1gH5AUQBAQBdARoChAGSAaEBGwIcAnQBdgHDAdUB7gH0AU0BoQH2AQACBQIpAuEANQFGAdgB2QHoAfMBGgE0AaYB3gHiAUcC9gCSAeQBBgJOAU8BdQHVATcBSwHfAeUBHwIcAUUB3QHjAfIBmwHWAYEAGgLuAXoBZgGPAeEBNAFrAZEBqwHYAWYB2QH1AeEASgF8AfgBAAA8AGkBfgGEAaoBzwHjASgCGAAwALoAvAA1ATcBRAHdARoCXQF2AaIB4QDjAHUBvQHyAUYCUwFEAXsBgwG9Ad8BugC8ADUBNwHPAdkB5AH0AVsBhAGmATAAPACBAHQBhAEGAvYAZQHYAfkBjwHeAfUBcgGNAfMBBQK8ABoCYwBlABgA4QDjAKIBfgB/ADUB9wFEAVQBWwFpARoCkQFVASgCBgGDAbwBBgGiARgAfwEAAEsAaQGzATAAwwDhAPYAlAFGAjwAewGEAYUBzgG6AOMA9wCFAaoBXQFYAGUA+AAFATcBvAEaAigCYwC6AEsBTAFTAWwBbQF7AbwARQFGAgAA8AB+AH8A8gBGAY0BvgBUAVUBvgDtAKsB2AHZAeEB9wH5Ab8AswHiAfYB/QFFAAUBwwHOAeYBKAIpAmMAZQAAADAA0wByAaIB1gFIAIEA0gD4AT8AZQGfAeMB/gFGAj0AtwC5AJsB4wGPAeAB5QF7AO8A9QE/AEcAaAC6AL0BfQAhAX8BoQHVAfQBGwJjAJQA7wB8AX4B5gEdAigCRwIbAGUAnAC4APcAIAIBAGwAowDSADQBegGfAdcB8wH2ARUBdAG9AQUCWABuALoA7QD2APgAPwFrAXsBlAEAAhwCHgIwANMAbQGVAZYBlwHPAe4BBgI+AGsA8ADyAKIB1AH9ASACIQIdAE0BTwHeAeQB/gEPAF8AMAHQATcAfwHDAS8CSABLAJwAvAApAUoBqwG9AegBKQIEAGgAuAC6APAA8gATASsBTAFOAaoBAgAdABcBRwFIAYsBkgEdAjcARwB0AZsBAAIbAh4CGwC3ALkA6wDwAPIAGgFsAaEBugH0AUEARQBwAJQAowDZAO8AKgFEAcIByQHtAbkB+wH8AQwBEQFdAWoBygHuATAAPQDHADABHwICAA8AXgBfAL8AEQFLAWEBfgGoAeQBGgI+AK8A4QDjAPIAlQGWAZcBQAB7AH0AIQE/AS8CdADyAJ8B7QH4AXEApwCuALAAXQGBAYcB3gEhAm8A7wATASUBPAC6ANoAuQHJAcoBLwISANgAwgHoARQANQByAJ8ApQDDAMYAyADhAOMAHAIGADMAFQEXASoBSwHJAeUB+AElAUcBSAGoAeUB+wEBAAMAawChAMYAfwGLAboB/AG8AMMAUgFoAWsA2ACKAckBXQArAUABeQHZABIBFgEpAUAB+AHrAGEBPAAxAdABKAJHArwADAF/AYoBzwGlABMB5QH5AS8CEgFLAWgBPgBdAG8A4QA0Ab0BHwIDAA8AbAC6AOMAFQExAXkBLwIwAG4AngAiAT8BBgAUAMgAFgEwAVIBgQESADMANQByAL8A7wDzAaIAowCuAK8AxwBLAWoBhwEQABEAIQBsAG4AcQB0AKUAsADrAHsAfQCUABsApQDZABEBIgFlAZ8AoQCiAKcANAFlAc8BHQA6AD0AXgBfAH8BAAA+AGoAeQCcAPIAIQG9ARwAQQDhAOMASwF5ANoAMAFGAqkAxwDTAO0ASwFAAGsAEQE/AWgAegCaANkANAFEAfIANAHtABMBzwGcAJ4AkwDrABEAcAC6AL4AaQFFAHgAeQAVARoBKAJ9ALwAGQDvABEALQBYAL0AaQFcAF4AXwDIAPoAaAD7AAMBbAERAFgAagBuAJ8AHABHANIAqgE1AhAAIQB4AHoArwAhASoAXgBlAGwAeQAwAOEA4wD2ADUAQABxAOsATgHPASkCJABLAGMAnwDrAAYBpgE2AhQAMwBcAHAAgQB0AOEAAwEEASQBqwH5ATMAOgB7ALAAvQD6APsADwAkACoALQBHAGoAkwCiAGUBGQBMAOMA1AH0AVgAagAoAgAAGwAdAK4AcACBADQBTQHzASgCRwCiAKMA0AEdAnQArgCvAMcA7AB0AVsAlADcABwB3gHjAPoARAFGAU4BWwEUADUAQABxAKkAsAA1AXYBfgGiAd8B5AHuASACBQF1AfIB+AH2AE8BbAEEAGMAZQCaADcBTgHdAUwARQFLAVsBoQHQAeMBHAGSAX4AfwC8ANwA4QD6AEYC0gA7ANMAVwHUAR8CHwI9AKQAvwDIAFsBqwHsAFsBcgEdAkYCAAC+AAQBdQGhAaYBqgG6ATAARwB2ASACKQJbAHQBuQHPAd0B4wHtAUUAfgB/ACkBfgGCAZkB3wHDAAQBGgEcAVYBpQGpAR0COwCBAMQABAFxAXMBmAGhATAATgFbAaQBpgGoAd0B3wHjAQACIAIpAhoBHAEkAUcBSAFPAdQBHgIfAjUCNgJGAkcCPADAAMIAJQF9ARwCKAIbAi8CQQBYAGsAJAFaAacBwgHoAeEA4wANASoBQwFGAYsB7AFLACUBagGLARoCRwFIAVkBDQE3AVkBgQGBAEQBRQFXAc8BNQHoARoCfgB/AMUATQFaAZkB7AEdAUMBwgEXARoBcQFzAYcBQQDIAAUBFgFCAUQBcgF0AXUBwwEGAWUBZgGnASoB7QFBAH4AfwCSAKQAWwF2ARoCxADFAEABSwFxAX0BogHQAQYCnwChACkBQAHQAewB7QHhABUBGAEFAsMA2gDjAOwADQE0AX4BwwGwAMAAwgCBAYcBcQDsAA0BkwGoAYEAkgATAR0BKQFWAWoBAABwAHQAKwFxAegBwgFCAV0BBACuAK8AFgGCAZMBmwGrAQUCcgCLAccAyAA/AZwBnQH7AfwBawCBAK4AGAGPAbAA6wAGAm4AKwE/AUQBfwGPAXIAKQFsAZgBogGpAdAB/gH/AbwAFwG6Ae0BbgBHAaoB1wFBAHEAdAChAK8AwwDhAJIBlQGWAZcBZgGlAacB5gH7AfwBMADaABMBdQF2AaYB3gHfAfMBPACfAOsARwF0AXAApwAVATQB4AHkAQYCxwAaAV0BGACkAc4B8gEFAl4A4wD2AE0BTgGVAZYBlwGhAecB9AH7AQQARAFGAU8BpwG7AacA9wAkAWUBfwGLAc4BHQIeAiACZQG5AdAB3QFlAKIB4wFrAZwBnQHDAc8B7gF8AX8B9gGBAPgANAF6AcQB1QH5ATAASgF+AasBwgE8AGMAbQF/AZEBkgGbAcgB1gH4ASkCRQFKAUsBbAFtAXsBlAH1AboAvAASAUYB4QH9AQEARAGqAbsB/wFcAUUBXAFdAZ8BvAHEAcgBXgBMAWkBvAG9AeIB7AH4AGsBgwFuAYUBjwGRAdkB9gHYAUoBXAEAAjQBTQFcAX8BjQH3AEQB5wH7ARsCTgHoAW8BcAF3AUoB5gH3AboATwH+AeEA4wD2ABoBhAGSAZQBwwF4AYMBngHlAf0BKAI3ADQBdwGfAaABswF7AbwB8wFvAXABfAHOAfkBEgFMAWMAZQBLAXoBkgH4AQ==", + "provenance": { + "method": "pinned-native-cityflow-depth-and-backface-id-buffer-cross-viewport-union", + "presentation": "prepared-linear-height-interpolation-60hz", + "validatedPresentation": "base-source-only-schema9-adds-second-dilation-and-browser-face-audit", + "opacityPolicy": "zero-for-proven-noncontributing-face-one-otherwise", + "transitionSafety": "one-prepared-frame-dilation-before-and-after" + } +} diff --git a/src/adapters/cityflow/notes/references/source-lock.json b/src/adapters/cityflow/notes/references/source-lock.json index 64aa16a..67463bc 100644 --- a/src/adapters/cityflow/notes/references/source-lock.json +++ b/src/adapters/cityflow/notes/references/source-lock.json @@ -1,5 +1,5 @@ { - "schema": "csscityflow-source-lock@1", + "schema": "csscityflow-source-lock@2", "project": "XScreenSaver Cityflow", "repository": "https://github.com/Zygo/xscreensaver", "revision": "906693799e4fb7581436590cf84ecb2d3c9186ba", @@ -11,5 +11,24 @@ "path": "hacks/config/cityflow.xml", "sha256": "0453c12e39e4b7d32e66a7780d69979aaaa42d0632a3b6757df9c1cee6c7c919" }, - "license": "permissive notice embedded in cityflow.c" + "license": { + "spdx": "HPND", + "notice": "src/adapters/cityflow/NOTICE.md" + }, + "sources": [ + { "path": "hacks/glx/cityflow.c", "sha256": "9113c9f3214ba6c1f350b3863c306e6015e47a856a17bbe24d597f909dfa027b" }, + { "path": "hacks/config/cityflow.xml", "sha256": "0453c12e39e4b7d32e66a7780d69979aaaa42d0632a3b6757df9c1cee6c7c919" }, + { "path": "utils/yarandom.c", "sha256": "32967bcf84b46a60968723d6d3d86d1d69d9f30a52b36dddfdedaa3ee3e4c931" }, + { "path": "utils/yarandom.h", "sha256": "958707f757cd273579290830a0546215b61db5e11110cf46656333c51002269b" }, + { "path": "utils/colors.c", "sha256": "d10c5334e617a3043f674e6b5849e01887433a9c48b3df081a1b47cb61a48318" }, + { "path": "utils/colors.h", "sha256": "3ddd76a53f318fdc968ccb254aa125ae750a0054f6dbe5e22d2234a56e9c415e" }, + { "path": "utils/hsv.c", "sha256": "b0dcc28f6ed1b6709f00aac1e796a8d28c8285a6a3c698d008bce0aed4d0f265" }, + { "path": "utils/hsv.h", "sha256": "4f263f1cc0b56694281417107b2e51b7b5e4ac032f6020349b6e671cbcaac2b6" }, + { "path": "hacks/glx/gltrackball.c", "sha256": "c5f1ae69419557dcc83d330d67c9f52ee9e8668bb0d6e90585fca52ce43efc10" }, + { "path": "hacks/glx/gltrackball.h", "sha256": "6d74d4e6d5eb831de0a7531b87aeaf2e2d052b819ef782b99cd201751514dc4b" }, + { "path": "hacks/glx/trackball.c", "sha256": "618033a011d1f0f1204c13ff79000f214dd02ad5fe7f0bfeb088c015ef2bc1e0" }, + { "path": "hacks/glx/trackball.h", "sha256": "cb3c83705d94727f44911d29265630ed8e19653b95c12d82b939c90bddebbdaa" }, + { "path": "hacks/glx/quaternion.c", "sha256": "94ef86a29833f4af7e30bf278273d935724b0caa4147585a7ce0288fef80dab4" }, + { "path": "hacks/glx/quaternion.h", "sha256": "bcd01e64dbc58a47bfc3214e55b095a5915fe249a8d71ffde5e09bbf52647522" } + ] } diff --git a/src/adapters/cityflow/src/csscityflow/client.mjs b/src/adapters/cityflow/src/csscityflow/client.mjs index 6edb39d..96a2244 100644 --- a/src/adapters/cityflow/src/csscityflow/client.mjs +++ b/src/adapters/cityflow/src/csscityflow/client.mjs @@ -1,24 +1,69 @@ +// SPDX-License-Identifier: HPND import { createPolyPerspectiveCamera } from "@layoutit/polycss"; import { loadPolyMorphPackage, mountPolyMorphModel } from "@layoutit/polycss-morph"; import { createCityflowPreparedPlayer, loadCityflowPreparedPlayback, } from "./preparedPlayback.mjs"; +import { + bindCityflowSourceProjection, + cityflowSourceProjection, +} from "./sourceProjection.mjs"; +import { + CSSCITYFLOW_PREPARED_BANKS, + selectCityflowPreparedBank, +} from "./profileSelection.mjs"; export function mountCityflow(host) { - const state = { ready: false, errors: [], bankId: null, mounted: null, player: null }; - globalThis.__csscityflow = state; + let destroyed = false; + let unbindProjection = () => {}; + const state = { + ready: false, + errors: [], + metadata: null, + bankId: null, + mounted: null, + player: null, + }; + const controller = Object.freeze({ + pause() { + return state.player?.pause(); + }, + resume() { + if (!destroyed) return state.player?.resume(); + }, + destroy() { + if (destroyed) return; + destroyed = true; + unbindProjection(); + state.player?.destroy(); + state.mounted?.destroy(); + state.ready = false; + if (globalThis.__csscityflow === state) delete globalThis.__csscityflow; + }, + }); + Object.defineProperty(globalThis, "__csscityflow", { + configurable: true, + value: state, + }); main().catch((error) => fail(error)); + return controller; async function main() { - const bankId = "desktop"; - const modelId = "cityflow"; - const [loaded, playback] = await Promise.all([ + const bankId = selectCityflowPreparedBank({ + width: host.clientWidth || innerWidth, + height: host.clientHeight || innerHeight, + }); + const modelId = CSSCITYFLOW_PREPARED_BANKS[bankId].modelId; + const [metadata, loaded, playback] = await Promise.all([ + fetchPreparedMetadata("/csscityflow/prepared.json"), loadPolyMorphPackage("/csscityflow/", { modelId }), loadCityflowPreparedPlayback(`/csscityflow/${modelId}.playback.json`), loadPreparedStylesheet(`/csscityflow/${modelId}.css`), ]); - const perspective = innerHeight / (2 * Math.tan(Math.PI / 12)); + assertPreparedMetadata(metadata, playback, bankId, modelId); + if (destroyed) return; + const perspective = cityflowSourceProjection(host.clientWidth, host.clientHeight).perspective; const mounted = mountPolyMorphModel(host, loaded.model, { resources: loaded.resources, camera: createPolyPerspectiveCamera({ @@ -31,32 +76,129 @@ export function mountCityflow(host) { }), }); const shapeElements = []; - for (const [index, box] of loaded.model.render.shapes.entries()) { + for (const box of loaded.model.render.shapes) { const element = mounted.shapeElements.get(box.id); if (!element) throw new Error(`Missing retained Cityflow box ${box.id}`); - const source = readBoxState(loaded.model, index); element.classList.add("csscityflow-box"); shapeElements.push(element); - for (const [faceIndex, leaf] of [...element.children].entries()) { - leaf.style.setProperty("--csscityflow-light", source.lightFactors[faceIndex]); - } } const player = createCityflowPreparedPlayer({ playback, mounted, shapeElements }); - state.ready = true; + const cameraElement = host.querySelector(":scope > .polycss-camera"); + unbindProjection = bindCityflowSourceProjection(host, cameraElement, (projection) => { + mounted.camera.update({ distance: -projection.perspective }); + mounted.updateCamera(); + }); + if (destroyed) { + player.destroy(); + mounted.destroy(); + return; + } + state.metadata = metadata; state.bankId = bankId; state.mounted = mounted; state.player = player; + await waitForPaint(); + if (destroyed) return; + state.ready = true; document.body.classList.replace("loading", "ready"); + performance.mark("csscityflow-ready"); player.resume(); } function fail(error) { + if (destroyed) return; state.errors.push(String(error?.stack || error)); document.body.classList.remove("loading"); document.body.classList.add("error"); console.error(error); } - return state; +} + +async function fetchPreparedMetadata(url) { + const response = await fetch(url, { cache: "no-store" }); + if (!response.ok) throw new Error(`Cityflow prepared metadata failed to load: ${response.status}`); + return response.json(); +} + +function assertPreparedMetadata(metadata, playback, bankId, modelId) { + const bank = metadata?.banks?.find((candidate) => candidate.id === bankId); + if (metadata?.schema !== "csscityflow-prepared-product@2" || metadata.status !== "ready" || + metadata.defaultBank !== "desktop" || + metadata.profileSelection?.mobileBreakpointWidth !== 600 || + metadata.profileSelection?.mobileCapabilityQuery !== "(hover: none) and (pointer: coarse)" || + metadata.source?.revision !== "906693799e4fb7581436590cf84ecb2d3c9186ba" || + metadata.source?.license !== "HPND" || metadata.renderer?.runtimeGeometry !== false || + metadata.renderer?.runtimeRasterization !== false || metadata.renderer?.runtimeDomGrowth !== false || + metadata.banks?.length !== 2 || bank?.modelId !== modelId || bank?.seed !== 26081702 || + bank?.boxCount !== (bankId === "mobile" ? 100 : 200) || + bank?.leafCount !== bank.boxCount * 3 || bank?.frameCount !== 301 || + bank?.sourceFrameCount !== 251 || bank?.frameCount !== playback.frameCount || + playback.bankId !== bankId || playback.modelId !== modelId || + bank?.retainedFacePublication?.schema !== "csscityflow-retained-face-publication@3" || + bank.retainedFacePublication.policy !== + "prepared-whole-box-visibility-no-face-culling" || + bank.retainedFacePublication.faceCount !== bank.leafCount || + bank.retainedFacePublication.boxCount !== bank.boxCount || + bank.retainedFacePublication.visibleFaceCount !== + (bankId === "mobile" ? 300 : 585) || + bank.retainedFacePublication.hiddenFaceCount !== + (bankId === "mobile" ? 0 : 15) || + bank.retainedFacePublication.visibleBoxCount !== + (bankId === "mobile" ? 100 : 195) || + bank.retainedFacePublication.hiddenBoxCount !== + (bankId === "mobile" ? 0 : 5) || + bank.retainedFacePublication.staticVisibility?.schema !== + "csscityflow-prepared-static-visibility@3" || + bank.retainedFacePublication.sideDepth?.schema !== + "csscityflow-prepared-side-depth@1" || + bank.retainedFacePublication.sideDepth.defaultDepthScale !== + (bankId === "mobile" ? 0.28 : 0.1) || + bank.retainedFacePublication.sideDepth.maximumDepthScale !== + 0.28 || + bank.retainedFacePublication.sideDepth.overrideCount !== + (bankId === "mobile" ? 0 : 19) || + bank?.diagnosticVisibility?.usage !== + "diagnostic-only-never-consumed-by-product-playback" || + bank?.presentation?.kind !== "prepared-periodic-source-sample-reconstruction" || + bank.presentation.heightInterpolation !== + "periodic-uniform-cubic-b-spline-c2-source-approximation" || + bank.presentation.temporalFilter !== + "prepared-periodic-five-tap-fold-twelve-three-tap-refold-twelve-five-tap-refold-twelve-adaptive-smooth-sine-eased-extrema@1" || + bank.presentation.directionRunSuppression !== + "prepared-circular-twelve-frame-or-short-direction-run-folding-zero-sum-adaptive-smooth-sine-24-54-0.6-eased@1" || + bank.presentation.colorInterpolation !== + "prepared-srgb-interpolated-final-face-color" || + bank.presentation.transformPublication !== + "prepared-packed-transform-components-expanded-once-plus-sparse-final-face-color-and-whole-box-leaf-visibility-publication" || + bank.presentation.statePublication?.schema !== + "csscityflow-prepared-state-publication@22" || + bank.presentation.statePublication.frameCount !== 301 || + bank.presentation.statePublication.animationCount !== 0 || + bank.presentation.statePublication.runtimeFormatting !== false || + bank.presentation.statePublication.loadTimeAssembly !== + "one-time-prepared-transform-component-table-expansion" || + bank.presentation.statePublication.sourceSeekAssembly !== + "none-cached-expanded-transform-and-final-face-color-dictionaries" || + bank.presentation.statePublication.atomicProperties !== + "prepared-root-transform-plus-direct-leaf-visibility-and-final-face-background-color" || + bank.presentation.statePublication.minimumShapeStyleWritesPerScheduledTick !== 0 || + bank.presentation.statePublication.maximumShapeStyleWritesPerScheduledTick !== + playback.staticVisibility.presentation.maximumVisibleBoxes || + bank.presentation.statePublication.maximumLeafColorStyleWritesPerScheduledTick !== + playback.colors.presentationTransitions.maximumWritesPerFrame || + bank.presentation.statePublication.maximumVisibilityStyleWritesPerScheduledTick !== + playback.staticVisibility.presentation.maximumTransitionWritesPerFrame * 3 || + bank?.loop?.kind !== "prepared-periodic-source-sample-reconstruction" || + bank.loop.exactSourceLoop !== false || + bank.loop.presentationPeriodFrames !== 301 || + bank.loop.closureContinuity !== + "periodic-zero-sum-twelve-frame-direction-run-folded-adaptive-smooth-sine-eased-sample-cycle") { + throw new Error("Cityflow prepared metadata drifted"); + } +} + +function waitForPaint() { + return new Promise((resolvePaint) => requestAnimationFrame(() => requestAnimationFrame(resolvePaint))); } function loadPreparedStylesheet(href) { @@ -69,22 +211,3 @@ function loadPreparedStylesheet(href) { document.head.append(link); }); } - -function readBoxState(model, index) { - const shape = model.render.shapes[index]; - const matrix = shape?.matrix; - if (!shape || !Array.isArray(matrix) || matrix.length !== 16) { - throw new Error("Prepared Cityflow box transform is missing"); - } - const width = Math.hypot(matrix[0], matrix[1]); - const cth = matrix[0] / width; - const sth = -matrix[1] / width; - const lightLength = Math.hypot(0, 0.25, -1); - return Object.freeze({ - lightFactors: Object.freeze([ - 0.4 + 1 / lightLength, - 0.4 + Math.max(0, cth * 0.25 / lightLength), - 0.4 + Math.max(0, -sth * 0.25 / lightLength), - ]), - }); -} diff --git a/src/adapters/cityflow/src/csscityflow/deadlineScheduler.mjs b/src/adapters/cityflow/src/csscityflow/deadlineScheduler.mjs new file mode 100644 index 0000000..85af8ef --- /dev/null +++ b/src/adapters/cityflow/src/csscityflow/deadlineScheduler.mjs @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: HPND +const EARLY_DEADLINE_TOLERANCE_RATIO = 1 / 4; +const MINIMUM_DISTINCT_PUBLICATION_SPACING_RATIO = + 1 - EARLY_DEADLINE_TOLERANCE_RATIO; + +export function createCityflowDeadlineScheduler({ + frameMilliseconds, + publishDue, + readNow = () => globalThis.performance.now(), + requestFrame = globalThis.requestAnimationFrame.bind(globalThis), + cancelFrame = globalThis.cancelAnimationFrame.bind(globalThis), +}) { + if (!Number.isFinite(frameMilliseconds) || frameMilliseconds <= 0 || + typeof publishDue !== "function" || typeof readNow !== "function") { + throw new TypeError("Cityflow deadline scheduler contract drifted"); + } + let paused = true; + let request = null; + let nextFrameAt = null; + let tick = 0; + let animationFrameCallbackCount = 0; + let earlyCallbackCount = 0; + let displayPhaseResyncCount = 0; + let lateDeadlineResetCount = 0; + let publishedAdjacentTransitionCount = 0; + let firstFramePending = false; + let lastPublicationAt = null; + + function cancelScheduled() { + if (request !== null) cancelFrame(request); + request = null; + } + + function schedule() { + if (paused || request !== null) return; + request = requestFrame(loop); + } + + function loop(timestamp) { + request = null; + if (paused) return; + animationFrameCallbackCount += 1; + if (!Number.isFinite(timestamp)) { + paused = true; + throw new TypeError("Cityflow animation-frame timestamp drifted"); + } + const deliveredAt = readNow(); + if (!Number.isFinite(deliveredAt)) { + paused = true; + throw new TypeError("Cityflow callback-delivery timestamp drifted"); + } + const publicationTime = Math.max(timestamp, deliveredAt); + const earlyTolerance = frameMilliseconds * EARLY_DEADLINE_TOLERANCE_RATIO; + const minimumDistinctPublicationSpacing = + frameMilliseconds * MINIMUM_DISTINCT_PUBLICATION_SPACING_RATIO; + let resyncDisplayPhase = false; + if (publicationTime + earlyTolerance < nextFrameAt) { + const timeSinceLastPublication = lastPublicationAt === null + ? null + : publicationTime - lastPublicationAt; + if (timeSinceLastPublication === null || + timeSinceLastPublication < minimumDistinctPublicationSpacing) { + earlyCallbackCount += 1; + schedule(); + return; + } + resyncDisplayPhase = true; + displayPhaseResyncCount += 1; + } + tick += 1; + publishDue(tick, 1); + publishedAdjacentTransitionCount += 1; + lastPublicationAt = publicationTime; + if (firstFramePending || resyncDisplayPhase) { + firstFramePending = false; + nextFrameAt = publicationTime + frameMilliseconds; + schedule(); + return; + } + nextFrameAt += frameMilliseconds; + if (nextFrameAt <= publicationTime) { + nextFrameAt = publicationTime + frameMilliseconds; + lateDeadlineResetCount += 1; + } + schedule(); + } + + function stats() { + return Object.freeze({ + paused, + tick, + frameMilliseconds, + timerCallbackCount: 0, + animationFrameCallbackCount, + earlyCallbackCount, + earlyDeadlineToleranceMilliseconds: + frameMilliseconds * EARLY_DEADLINE_TOLERANCE_RATIO, + minimumDistinctPublicationSpacingMilliseconds: + frameMilliseconds * MINIMUM_DISTINCT_PUBLICATION_SPACING_RATIO, + displayPhaseResyncCount, + lateDeadlineResetCount, + publishedAdjacentTransitionCount, + preparedStateSkipCount: 0, + schedulerTimeSource: "maximum-animation-frame-timestamp-and-callback-delivery", + publicationPacingTimeSource: "adjacent-state-deadline-schedule", + resumePublicationPolicy: "first-animation-frame-immediate-then-deadline-paced", + }); + } + + return Object.freeze({ + get paused() { return paused; }, + pause() { + paused = true; + cancelScheduled(); + return stats(); + }, + resume() { + if (!paused) return stats(); + paused = false; + const now = readNow(); + if (!Number.isFinite(now)) { + paused = true; + throw new TypeError("Cityflow resume timestamp drifted"); + } + firstFramePending = true; + lastPublicationAt = null; + nextFrameAt = now; + schedule(); + return stats(); + }, + seekTick(value) { + if (!Number.isSafeInteger(value) || value < 0) { + throw new RangeError("Cityflow scheduler tick is out of range"); + } + cancelScheduled(); + tick = value; + firstFramePending = false; + lastPublicationAt = null; + nextFrameAt = paused ? null : readNow() + frameMilliseconds; + schedule(); + return stats(); + }, + stats, + destroy() { + paused = true; + cancelScheduled(); + }, + }); +} diff --git a/src/adapters/cityflow/src/csscityflow/preparedPlayback.mjs b/src/adapters/cityflow/src/csscityflow/preparedPlayback.mjs index ffaf7a4..8b7e66d 100644 --- a/src/adapters/cityflow/src/csscityflow/preparedPlayback.mjs +++ b/src/adapters/cityflow/src/csscityflow/preparedPlayback.mjs @@ -1,21 +1,36 @@ +// SPDX-License-Identifier: HPND import { createPolyMorphPreparedDomTarget } from "@layoutit/polycss-morph"; +import { createCityflowDeadlineScheduler } from "./deadlineScheduler.mjs"; +import { expandCityflowPreparedTransforms } from "./preparedTransformTable.mjs"; -const MATRIX_PATTERN = /^matrix3d\(-?\d+(?:\.\d+)?(?:,-?\d+(?:\.\d+)?){15}\)$/u; +const COLOR_PATTERN = /^#[a-f0-9]{6}$/u; const decodedPackets = new WeakMap(); -export function createCityflowPreparedPlayer({ playback, mounted, shapeElements, ...overrides }) { +export function createCityflowPreparedPlayer({ + playback, + mounted, + shapeElements, + ...overrides +}) { const decoded = validatePlayback(playback, mounted, shapeElements); - const transforms = playback.transforms; - const transformIndices = decoded.transformIndices; - const colorIndices = decoded.colorIndices; const frameCount = playback.frameCount; const boxCount = playback.boxCount; + const facesPerBox = playback.facesPerBox; const frameMilliseconds = playback.tickIntervalUs[0] / playback.tickIntervalUs[1] / 1_000; - const requestFrame = overrides.requestFrame ?? globalThis.requestAnimationFrame.bind(globalThis); - const cancelFrame = overrides.cancelFrame ?? globalThis.cancelAnimationFrame.bind(globalThis); - const requestDelay = overrides.requestDelay ?? globalThis.setTimeout.bind(globalThis); - const cancelDelay = overrides.cancelDelay ?? globalThis.clearTimeout.bind(globalThis); - const readNow = overrides.readNow ?? globalThis.performance.now.bind(globalThis.performance); + const transforms = decoded.transforms; + const transformOffsets = playback.transformIndices.transformOffsets; + const presentationTransformIndices = decoded.presentationTransformIndices; + const sourceTransformIndices = decoded.sourceTransformIndices; + const presentationMaterialIndices = decoded.presentationMaterialIndices; + const sourceMaterialIndices = decoded.sourceMaterialIndices; + const presentationColorTransitionOffsets = decoded.presentationColorTransitionOffsets; + const presentationColorTransitionFaceIndices = decoded.presentationColorTransitionFaceIndices; + const presentationColorTransitionColorIndices = decoded.presentationColorTransitionColorIndices; + const presentationTransitionColors = playback.colors.presentationTransitions.colors; + const presentationBoxVisibilityRows = decoded.presentationBoxVisibilityRows; + const presentationBoxVisibilityOffsets = decoded.presentationBoxVisibilityOffsets; + const presentationBoxVisibilityIndices = decoded.presentationBoxVisibilityIndices; + const materials = playback.colors.materials; const target = createPolyMorphPreparedDomTarget({ model: { element: mounted.modelElement, @@ -28,118 +43,290 @@ export function createCityflowPreparedPlayer({ playback, mounted, shapeElements, shapes: shapeElements.map((element) => ({ element })), leaves: [], }); - const currentTransformIndices = transformIndices.slice(0, boxCount); - const currentColorIndices = colorIndices.slice(0, boxCount); - const colorClasses = Array.from({ length: 256 }, (_, index) => `csscityflow-color-${index}`); - let paused = true; - let timer = null; - let request = null; - let clockOrigin = readNow(); - let tick = 0; + const leafElements = shapeElements.map((element) => [...element.children]); + if (leafElements.some((leaves) => leaves.length !== facesPerBox || + leaves.some((leaf) => !(leaf instanceof HTMLElement)))) { + throw new Error("Cityflow retained face binding drifted"); + } + const flatLeafElements = leafElements.flat(); + const visibleBoxMask = presentationBoxVisibilityRows[0].slice(); + const visibleFaceMask = Uint8Array.from( + { length: boxCount * facesPerBox }, + (_, faceIndex) => visibleBoxMask[Math.floor(faceIndex / facesPerBox)], + ); + let visibleFaceCount = visibleFaceMask.reduce((sum, visible) => sum + visible, 0); + let visibleBoxCount = visibleBoxMask.reduce((sum, visible) => sum + visible, 0); + let currentVisibilityFrameIndex = 0; + let sourceVisibilityMode = false; + let initialVisibilityStyleWrites = 0; + for (let boxIndex = 0; boxIndex < visibleBoxMask.length; boxIndex += 1) { + if (visibleBoxMask[boxIndex] !== 0) continue; + for (const leaf of leafElements[boxIndex]) leaf.style.visibility = "hidden"; + initialVisibilityStyleWrites += facesPerBox; + } + const currentTransformIndices = new Int32Array(boxCount).fill(-1); + const currentFaceColors = new Array(boxCount * facesPerBox).fill(null); let frameIndex = 0; - let timerCallbackCount = 0; - let animationFrameCallbackCount = 0; - let transformWrites = 0; - let colorWrites = 0; - let collapsedTickCount = 0; + let sourceFrameIndex = null; let publicationCount = 0; - let lastTransformWrites = 0; - let lastColorWrites = 0; + let shapeStyleWrites = 0; + let styleChannelWrites = 0; + let leafColorStyleWrites = 0; + let visibilityStyleWrites = 0; + let sourceSeekStyleAssemblies = 0; + let nextShapeStyleWrites = 0; + let nextLeafColorStyleWrites = 0; + let nextVisibilityStyleWrites = 0; + const lastPublication = { + frameIndex: 0, + shapeStyleWrites: 0, + leafColorStyleWrites: 0, + visibilityStyleWrites: 0, + }; - for (let index = 0; index < boxCount; index += 1) { - shapeElements[index].classList.add(colorClasses[currentColorIndices[index]]); - target.shapes[index].writeTransform(transforms[currentTransformIndices[index]]); - } + publishPresentationState(0, false); + const initialShapeStyleWrites = nextShapeStyleWrites; + const initialLeafColorStyleWrites = nextLeafColorStyleWrites; target.assertStableDomIdentity(); + const scheduler = createCityflowDeadlineScheduler({ + ...overrides, + frameMilliseconds, + publishDue(tick) { + publishFrame(tick % frameCount); + }, + }); function publishFrame(nextFrameIndex) { + if (nextFrameIndex === frameIndex && sourceFrameIndex === null) return frameIndex; + const sequential = sourceFrameIndex === null && !sourceVisibilityMode && + nextFrameIndex === (frameIndex + 1) % frameCount; + publishPresentationState(nextFrameIndex, sequential); + frameIndex = nextFrameIndex; + sourceFrameIndex = null; + recordPublication(frameIndex); + return frameIndex; + } + + function publishSourceFrame(nextSourceFrameIndex) { + nextVisibilityStyleWrites = enterSourceVisibility(); + const nextFrameIndex = Math.min( + frameCount - 1, + Math.round(nextSourceFrameIndex * playback.sourceTickIntervalUs[0] / + playback.sourceTickIntervalUs[1] / + (playback.tickIntervalUs[0] / playback.tickIntervalUs[1])), + ); + const offset = nextSourceFrameIndex * boxCount; + nextShapeStyleWrites = publishTransforms(sourceTransformIndices, offset); + nextLeafColorStyleWrites = publishAbsoluteColors(sourceMaterialIndices, offset); + sourceFrameIndex = nextSourceFrameIndex; + frameIndex = nextFrameIndex; + recordPublication(frameIndex); + return sourceFrameIndex; + } + + function publishPresentationState(nextFrameIndex, sequential) { + nextVisibilityStyleWrites = publishPresentationBoxVisibility(nextFrameIndex); const offset = nextFrameIndex * boxCount; - let nextColorWrites = 0; - let nextTransformWrites = 0; - for (let index = 0; index < boxCount; index += 1) { - const nextColorIndex = colorIndices[offset + index]; - if (currentColorIndices[index] === nextColorIndex) continue; - shapeElements[index].classList.replace( - colorClasses[currentColorIndices[index]], - colorClasses[nextColorIndex], - ); - currentColorIndices[index] = nextColorIndex; - nextColorWrites += 1; + nextShapeStyleWrites = publishTransforms(presentationTransformIndices, offset); + nextLeafColorStyleWrites = sequential + ? publishPreparedColorTransitions(nextFrameIndex) + : publishAbsoluteColors(presentationMaterialIndices, offset); + } + + function publishTransforms(transformIndices, offset) { + let writes = 0; + for (let boxIndex = 0; boxIndex < boxCount; boxIndex += 1) { + if (visibleBoxMask[boxIndex] === 0) continue; + const transformIndex = transformIndices[offset + boxIndex]; + if (currentTransformIndices[boxIndex] === transformIndex) continue; + shapeElements[boxIndex].style.transform = + transforms[transformOffsets[boxIndex] + transformIndex]; + currentTransformIndices[boxIndex] = transformIndex; + writes += 1; } - for (let index = 0; index < boxCount; index += 1) { - const nextTransformIndex = transformIndices[offset + index]; - if (currentTransformIndices[index] === nextTransformIndex) continue; - target.shapes[index].writeTransform(transforms[nextTransformIndex]); - currentTransformIndices[index] = nextTransformIndex; - nextTransformWrites += 1; + return writes; + } + + function publishAbsoluteColors(materialIndices, offset) { + let writes = 0; + for (let boxIndex = 0; boxIndex < boxCount; boxIndex += 1) { + const materialIndex = materialIndices[offset + boxIndex]; + const material = materials[materialIndex]; + for (let localFaceIndex = 0; localFaceIndex < facesPerBox; localFaceIndex += 1) { + const faceIndex = boxIndex * facesPerBox + localFaceIndex; + if (visibleFaceMask[faceIndex] === 0 || + currentFaceColors[faceIndex] === material[localFaceIndex]) continue; + leafElements[boxIndex][localFaceIndex].style.backgroundColor = material[localFaceIndex]; + currentFaceColors[faceIndex] = material[localFaceIndex]; + writes += 1; + } } - frameIndex = nextFrameIndex; - publicationCount += 1; - transformWrites += nextTransformWrites; - colorWrites += nextColorWrites; - lastTransformWrites = nextTransformWrites; - lastColorWrites = nextColorWrites; - return frameIndex; + return writes; } - function cancelScheduled() { - if (timer !== null) cancelDelay(timer); - if (request !== null) cancelFrame(request); - timer = null; - request = null; + function publishPreparedColorTransitions(nextFrameIndex) { + let writes = 0; + for (let cursor = presentationColorTransitionOffsets[nextFrameIndex]; + cursor < presentationColorTransitionOffsets[nextFrameIndex + 1]; cursor += 1) { + const faceIndex = presentationColorTransitionFaceIndices[cursor]; + const color = presentationTransitionColors[ + presentationColorTransitionColorIndices[cursor] + ]; + flatLeafElements[faceIndex].style.backgroundColor = color; + currentFaceColors[faceIndex] = color; + writes += 1; + } + return writes; } - function schedule() { - if (paused || timer !== null || request !== null) return; - const delay = Math.max(0, clockOrigin + frameMilliseconds - readNow() - 1); - timer = requestDelay(wake, delay); + function publishPresentationBoxVisibility(nextFrameIndex) { + let writes = 0; + if (sourceVisibilityMode) { + writes += applyBoxVisibilityRow(presentationBoxVisibilityRows[nextFrameIndex]); + sourceVisibilityMode = false; + } else if (nextFrameIndex === (currentVisibilityFrameIndex + 1) % frameCount) { + for (let cursor = presentationBoxVisibilityOffsets[nextFrameIndex]; + cursor < presentationBoxVisibilityOffsets[nextFrameIndex + 1]; cursor += 1) { + writes += setBoxVisibility( + presentationBoxVisibilityIndices[cursor], + visibleBoxMask[presentationBoxVisibilityIndices[cursor]] === 0, + ); + } + } else if (nextFrameIndex !== currentVisibilityFrameIndex) { + writes += applyBoxVisibilityRow(presentationBoxVisibilityRows[nextFrameIndex]); + } + currentVisibilityFrameIndex = nextFrameIndex; + return writes; } - function wake() { - timer = null; - timerCallbackCount += 1; - if (!paused) request = requestFrame(loop); + function enterSourceVisibility() { + if (sourceVisibilityMode) return 0; + const writes = applyBoxVisibilityRow(new Uint8Array(boxCount).fill(1)); + currentVisibilityFrameIndex = null; + sourceVisibilityMode = true; + return writes; } - function loop(timestamp) { - request = null; - if (paused) return; - animationFrameCallbackCount += 1; - const due = Math.floor(Math.max(0, timestamp - clockOrigin + 0.5) / frameMilliseconds); - if (due > 0) { - tick += due; - collapsedTickCount += Math.max(0, due - 1); - publishFrame(tick % frameCount); - clockOrigin += due * frameMilliseconds; + function applyBoxVisibilityRow(row) { + let writes = 0; + for (let boxIndex = 0; boxIndex < row.length; boxIndex += 1) { + if (visibleBoxMask[boxIndex] === row[boxIndex]) continue; + writes += setBoxVisibility(boxIndex, row[boxIndex] !== 0); } - schedule(); + return writes; + } + + function setBoxVisibility(boxIndex, visible) { + const nextVisible = Number(visible); + if (visibleBoxMask[boxIndex] === nextVisible) return 0; + for (const leaf of leafElements[boxIndex]) { + leaf.style.visibility = visible ? "" : "hidden"; + } + visibleBoxMask[boxIndex] = nextVisible; + visibleBoxCount += visible ? 1 : -1; + const boxFaceOffset = boxIndex * facesPerBox; + for (let localFaceIndex = 0; localFaceIndex < facesPerBox; localFaceIndex += 1) { + visibleFaceMask[boxFaceOffset + localFaceIndex] = nextVisible; + } + visibleFaceCount += visible ? facesPerBox : -facesPerBox; + if (visible) currentTransformIndices[boxIndex] = -1; + return facesPerBox; + } + + function recordPublication(nextFrameIndex) { + publicationCount += 1; + shapeStyleWrites += nextShapeStyleWrites; + leafColorStyleWrites += nextLeafColorStyleWrites; + visibilityStyleWrites += nextVisibilityStyleWrites; + styleChannelWrites += nextShapeStyleWrites + nextLeafColorStyleWrites + + nextVisibilityStyleWrites; + lastPublication.frameIndex = nextFrameIndex; + lastPublication.shapeStyleWrites = nextShapeStyleWrites; + lastPublication.leafColorStyleWrites = nextLeafColorStyleWrites; + lastPublication.visibilityStyleWrites = nextVisibilityStyleWrites; } function snapshot() { + const schedulerStats = scheduler.stats(); target.assertStableDomIdentity(); mounted.assertStableDomIdentity(); return Object.freeze({ - schema: "csscityflow-prepared-player-stats@1", + schema: "csscityflow-prepared-player-stats@7", ready: true, - paused, - tick, + paused: schedulerStats.paused, + tick: schedulerStats.tick, frameIndex, frameCount, + sourceFrameIndex, + activeVisibilityVariant: sourceVisibilityMode + ? "exact-source-seek-all-retained-faces" + : "prepared-whole-box-visibility", + sourceFrameCount: playback.sourceFrameCount, boxCount, frameMilliseconds, catchUpPolicy: playback.catchUpPolicy, - timerCallbackCount, - animationFrameCallbackCount, - collapsedTickCount, + timerCallbackCount: schedulerStats.timerCallbackCount, + animationFrameCallbackCount: schedulerStats.animationFrameCallbackCount, + schedulerEarlyCallbackCount: schedulerStats.earlyCallbackCount, + schedulerEarlyDeadlineToleranceMilliseconds: + schedulerStats.earlyDeadlineToleranceMilliseconds, + schedulerMinimumDistinctPublicationSpacingMilliseconds: + schedulerStats.minimumDistinctPublicationSpacingMilliseconds, + schedulerDisplayPhaseResyncCount: schedulerStats.displayPhaseResyncCount, + schedulerLateDeadlineResetCount: schedulerStats.lateDeadlineResetCount, + publishedAdjacentTransitionCount: schedulerStats.publishedAdjacentTransitionCount, + preparedStateSkipCount: schedulerStats.preparedStateSkipCount, + schedulerTimeSource: schedulerStats.schedulerTimeSource, + publicationPacingTimeSource: schedulerStats.publicationPacingTimeSource, + resumePublicationPolicy: schedulerStats.resumePublicationPolicy, publicationCount, - transformWrites, - colorWrites, - initialTransformWrites: boxCount, - lastPublication: Object.freeze({ - frameIndex, - transformWrites: lastTransformWrites, - colorWrites: lastColorWrites, - }), + shapeStyleWrites, + styleChannelWrites, + leafColorStyleWrites, + visibilityStyleWrites, + sourceSeekStyleAssemblies, + retainedFaceCount: boxCount * facesPerBox, + retainedBoxCount: boxCount, + visibleFaceCount, + visibleBoxCount, + staticSuppressedFaceCount: playback.staticVisibility.hiddenFaceCount, + staticSuppressedBoxCount: playback.staticVisibility.hiddenBoxCount, + staticSuppressionBindingWrites: + playback.staticVisibility.hiddenFaceCount, + visibilityWrites: visibilityStyleWrites, + visibilityTransitionAssignments: visibilityStyleWrites, + preparedTimelineAuthority: "sequential-prepared-state-index", + presentationMode: playback.presentation.kind, + transformPresentationMode: playback.presentation.transformPublication, + preparedTransformAnimationCount: 0, + preparedTransformStateCount: playback.frameCount, + preparedTransformAnimationMode: "none-prepared-visible-root-state-publication", + runtimeShapeStyleWriteUpperBound: + playback.staticVisibility.presentation.maximumVisibleBoxes, + runtimeLeafColorStyleWriteUpperBound: + playback.presentation.statePublication.maximumLeafColorStyleWritesPerScheduledTick, + runtimeLeafColorStyleWrites: leafColorStyleWrites, + pseudoElementSideFacePublication: false, + pseudoElementFaceColorOverlay: false, + retainedSideLeafPaintOwners: 1, + sideLeafPreparedHeight: 1, + sideLeafPreparedDefaultDepthScale: playback.sideDepth.defaultDepthScale, + sideLeafPreparedMaximumDepthScale: playback.sideDepth.maximumDepthScale, + sideLeafPreparedOverrideCount: playback.sideDepth.overrideCount, + sideLeafPreparedDefaultTopOffset: 1 - playback.sideDepth.defaultDepthScale, + sideLeafPreparedMinimumTopOffset: 1 - playback.sideDepth.maximumDepthScale, + sideLeafLayoutSubpixelFree: true, + initialShapeStyleWrites, + initialLeafColorStyleWrites, + initialVisibilityWrites: + initialVisibilityStyleWrites, + visibilityCullingPolicy: + "prepared-viewport-independent-whole-box-direct-leaf-visibility-no-face-culling", + minimumVisibleFaceCount: playback.staticVisibility.presentation.minimumVisibleFaces, + maximumVisibleFaceCount: playback.staticVisibility.presentation.maximumVisibleFaces, + minimumVisibleBoxCount: playback.staticVisibility.presentation.minimumVisibleBoxes, + maximumVisibleBoxCount: playback.staticVisibility.presentation.maximumVisibleBoxes, + lastPublication: Object.freeze({ ...lastPublication }), identityStable: true, runtimeGeometryCalculationCount: 0, runtimeAtlasRasterizationCount: 0, @@ -148,31 +335,50 @@ export function createCityflowPreparedPlayer({ playback, mounted, shapeElements, } return Object.freeze({ - get paused() { return paused; }, + get paused() { return scheduler.paused; }, get frameIndex() { return frameIndex; }, pause() { - paused = true; - cancelScheduled(); + scheduler.pause(); return snapshot(); }, resume() { - if (!paused) return snapshot(); - paused = false; - clockOrigin = readNow(); - schedule(); + scheduler.resume(); return snapshot(); }, seekFrame(value) { if (!Number.isSafeInteger(value) || value < 0 || value >= frameCount) { throw new RangeError("Cityflow prepared frame is out of range"); } - cancelScheduled(); - tick = value; publishFrame(value); - clockOrigin = readNow(); - if (!paused) schedule(); + scheduler.seekTick(value); + return snapshot(); + }, + seekPresentationTime(value) { + if (!Number.isFinite(value) || value < 0) { + throw new RangeError("Cityflow prepared presentation time is out of range"); + } + const normalized = value % (frameCount * frameMilliseconds); + const nextFrameIndex = Math.floor((normalized + 0.001) / frameMilliseconds) % frameCount; + publishFrame(nextFrameIndex); + scheduler.seekTick(nextFrameIndex); return snapshot(); }, + seekSourceFrame(value) { + if (!Number.isSafeInteger(value) || value < 0 || value >= playback.sourceFrameCount) { + throw new RangeError("Cityflow prepared source frame is out of range"); + } + publishSourceFrame(value); + scheduler.seekTick(frameIndex); + return snapshot(); + }, + preparedTransformAt(boxIndex, transformIndex) { + if (!Number.isSafeInteger(boxIndex) || boxIndex < 0 || boxIndex >= boxCount || + !Number.isSafeInteger(transformIndex) || transformIndex < 0 || + transformIndex >= transformOffsets[boxIndex + 1] - transformOffsets[boxIndex]) { + throw new RangeError("Cityflow prepared transform address is out of range"); + } + return transforms[transformOffsets[boxIndex] + transformIndex]; + }, assertStableDomIdentity() { target.assertStableDomIdentity(); mounted.assertStableDomIdentity(); @@ -180,8 +386,7 @@ export function createCityflowPreparedPlayer({ playback, mounted, shapeElements, }, stats: snapshot, destroy() { - paused = true; - cancelScheduled(); + scheduler.destroy(); target.destroy(); }, }); @@ -206,32 +411,369 @@ function validatePlayback(playback, mounted, shapeElements) { } function validatePacket(playback) { - if (playback?.schema !== "csscityflow-prepared-playback@1" || + const stateCount = playback?.frameCount * playback?.boxCount; + const sourceStateCount = playback?.sourceFrameCount * playback?.boxCount; + const expectedModelId = playback?.bankId === "desktop" ? "cityflow" + : playback?.bankId === "mobile" ? "cityflow-mobile" : null; + const expectedBoxCount = playback?.bankId === "desktop" ? 200 + : playback?.bankId === "mobile" ? 100 : null; + const expectedSideDepthDefault = playback?.bankId === "desktop" ? 0.1 : 0.28; + const expectedSideDepthMaximum = 0.28; + const expectedSideDepthOverrides = playback?.bankId === "desktop" ? 19 : 0; + if (playback?.schema !== "csscityflow-prepared-playback@58" || playback.precedent !== "domformat@0/polycss-playback@0@cc8da736" || - playback.catchUpPolicy !== "elapsed" || playback.frameCount !== 252 || - !Number.isSafeInteger(playback.boxCount) || playback.boxCount < 1 || - !Array.isArray(playback.tickIntervalUs) || playback.tickIntervalUs.length !== 2 || - playback.tickIntervalUs[0] !== 20_000 || playback.tickIntervalUs[1] !== 1 || - !Array.isArray(playback.transforms) || playback.transforms.length < playback.boxCount || - playback.transforms.some((transform) => typeof transform !== "string" || !MATRIX_PATTERN.test(transform)) || - typeof playback.transformIndicesBase64 !== "string" || - typeof playback.colorIndicesBase64 !== "string") { + playback.modelId !== expectedModelId || playback.boxCount !== expectedBoxCount || + playback.catchUpPolicy !== "adjacent-state-late-deadline-reset" || + playback.frameCount !== 301 || playback.sourceFrameCount !== 251 || + playback.paletteSize !== 256 || playback.facesPerBox !== 3 || + playback.sideDepth?.schema !== "csscityflow-prepared-side-depth@1" || + playback.sideDepth.defaultDepthScale !== expectedSideDepthDefault || + playback.sideDepth.maximumDepthScale !== expectedSideDepthMaximum || + playback.sideDepth.overrideCount !== expectedSideDepthOverrides || + playback.staticVisibility?.schema !== "csscityflow-prepared-static-visibility@3" || + playback.staticVisibility.faceCount !== playback.boxCount * playback.facesPerBox || + playback.staticVisibility.visibleFaceCount + playback.staticVisibility.hiddenFaceCount !== + playback.staticVisibility.faceCount || + playback.staticVisibility.visibleBoxCount + playback.staticVisibility.hiddenBoxCount !== + playback.boxCount || + playback.staticVisibility.policy !== + "prepared-whole-box-visibility-no-face-culling" || + playback.staticVisibility.presentation?.schema !== + "csscityflow-prepared-presentation-box-visibility@1" || + playback.staticVisibility.presentation.frameCount !== playback.frameCount || + playback.staticVisibility.presentation.boxCount !== playback.boxCount || + playback.staticVisibility.presentation.faceCount !== + playback.boxCount * playback.facesPerBox || + !Number.isSafeInteger(playback.staticVisibility.presentation.transitionDilationFrames) || + playback.staticVisibility.presentation.transitionDilationFrames < 0 || + playback.staticVisibility.presentation.initialVisibleCount !== + playback.staticVisibility.presentation.initialVisibleBoxes * playback.facesPerBox || + playback.staticVisibility.presentation.minimumVisibleFaces !== + playback.staticVisibility.presentation.minimumVisibleBoxes * playback.facesPerBox || + playback.staticVisibility.presentation.maximumVisibleFaces !== + playback.staticVisibility.presentation.maximumVisibleBoxes * playback.facesPerBox || + playback.staticVisibility.presentation.minimumVisibleBoxes < 1 || + playback.staticVisibility.presentation.maximumVisibleBoxes > playback.boxCount || + !Number.isSafeInteger(playback.staticVisibility.presentation.transitionCount) || + playback.staticVisibility.presentation.transitionCount < 0 || + !Number.isSafeInteger(playback.staticVisibility.presentation.maximumTransitionWritesPerFrame) || + playback.staticVisibility.presentation.maximumTransitionWritesPerFrame < 0 || + typeof playback.staticVisibility.presentation.policy !== "string" || + playback.tickIntervalUs?.[0] !== 50_000 || playback.tickIntervalUs?.[1] !== 3 || + playback.sourceTickIntervalUs?.[0] !== 20_000 || + playback.sourceTickIntervalUs?.[1] !== 1 || + playback.presentation?.kind !== "prepared-periodic-source-sample-reconstruction" || + playback.presentation.sourceFramesPerSecond !== 50 || + playback.presentation.framesPerSecond !== 60 || + playback.presentation.exactSourceStateSeek !== true || + playback.presentation.heightInterpolation !== + "periodic-uniform-cubic-b-spline-c2-source-approximation" || + playback.presentation.temporalFilter !== + "prepared-periodic-five-tap-fold-twelve-three-tap-refold-twelve-five-tap-refold-twelve-adaptive-smooth-sine-eased-extrema@1" || + playback.presentation.directionRunSuppression !== + "prepared-circular-twelve-frame-or-short-direction-run-folding-zero-sum-adaptive-smooth-sine-24-54-0.6-eased@1" || + playback.presentation.colorInterpolation !== + "prepared-srgb-interpolated-final-face-color" || + playback.presentation.transformPublication !== + "prepared-packed-transform-components-expanded-once-plus-sparse-final-face-color-and-whole-box-leaf-visibility-publication" || + playback.presentation.statePublication?.schema !== + "csscityflow-prepared-state-publication@22" || + playback.presentation.statePublication.frameCount !== playback.frameCount || + playback.presentation.statePublication.animationCount !== 0 || + playback.presentation.statePublication.runtimeFormatting !== false || + playback.presentation.statePublication.loadTimeAssembly !== + "one-time-prepared-transform-component-table-expansion" || + playback.presentation.statePublication.sourceSeekAssembly !== + "none-cached-expanded-transform-and-final-face-color-dictionaries" || + playback.presentation.statePublication.atomicProperties !== + "prepared-root-transform-plus-direct-leaf-visibility-and-final-face-background-color" || + playback.presentation.statePublication.minimumShapeStyleWritesPerScheduledTick !== + 0 || + playback.presentation.statePublication.maximumShapeStyleWritesPerScheduledTick !== + playback.staticVisibility.presentation.maximumVisibleBoxes || + playback.presentation.statePublication.maximumLeafColorStyleWritesPerScheduledTick !== + playback.colors?.presentationTransitions?.maximumWritesPerFrame || + playback.presentation.statePublication.maximumVisibilityStyleWritesPerScheduledTick !== + playback.staticVisibility.presentation.maximumTransitionWritesPerFrame * + playback.facesPerBox || + playback.transforms !== undefined || + playback.transformTable?.schema !== "csscityflow-prepared-transform-table@1" || + playback.transformTable.count < playback.boxCount || + playback.transformIndices?.schema !== "csscityflow-prepared-transform-indices@2" || + playback.transformIndices.encoding !== "per-box-u16le-base64-plus-transform-offsets" || + playback.transformIndices.count !== stateCount || + playback.transformIndices.sourceCount !== sourceStateCount || + !validTransformOffsets(playback.transformIndices.transformOffsets, + playback.boxCount, playback.transformTable.count) || + typeof playback.transformIndices.presentationBase64 !== "string" || + typeof playback.transformIndices.sourceBase64 !== "string" || + playback.colors?.schema !== "csscityflow-prepared-face-local-materials@7" || + playback.colors.encoding !== + "prepared-three-final-color-tuples-plus-packed-absolute-indices-and-sparse-presentation-transitions" || + !Array.isArray(playback.colors.materials) || playback.colors.materials.length < 1 || + playback.colors.materials.some((material) => !Array.isArray(material) || + material.length !== playback.facesPerBox || + material.some((color) => typeof color !== "string" || !COLOR_PATTERN.test(color))) || + typeof playback.colors.presentationMaterialIndicesBase64 !== "string" || + typeof playback.colors.sourceMaterialIndicesBase64 !== "string" || + playback.colors.presentationTransitions?.schema !== + "csscityflow-prepared-face-color-transitions@2" || + playback.colors.presentationTransitions.encoding !== + "final-color-dictionary-plus-u32le-target-frame-offsets-and-u16le-face-and-color-indices" || + playback.colors.presentationTransitions.frameCount !== playback.frameCount || + playback.colors.presentationTransitions.faceCount !== + playback.boxCount * playback.facesPerBox || + !Number.isSafeInteger(playback.colors.presentationTransitions.transitionCount) || + playback.colors.presentationTransitions.transitionCount < 1 || + !Number.isSafeInteger(playback.colors.presentationTransitions.maximumWritesPerFrame) || + playback.colors.presentationTransitions.maximumWritesPerFrame < 1 || + !Array.isArray(playback.colors.presentationTransitions.colors) || + playback.colors.presentationTransitions.colors.length < 1 || + playback.colors.presentationTransitions.colors.some((color) => + typeof color !== "string" || !COLOR_PATTERN.test(color)) || + typeof playback.colors.presentationTransitions.offsetsBase64 !== "string" || + typeof playback.colors.presentationTransitions.faceIndicesBase64 !== "string" || + typeof playback.colors.presentationTransitions.colorIndicesBase64 !== "string" || + (playback.diagnostics?.visibility?.bankId !== undefined && + playback.diagnostics.visibility.bankId !== playback.bankId) || + playback.diagnostics?.productPolicy !== + "diagnostic-only-never-consumed-by-product-playback" || + playback.loop?.kind !== "prepared-periodic-source-sample-reconstruction" || + playback.loop.exactSourceLoop !== false || + playback.loop.presentationPeriodFrames !== playback.frameCount || + playback.loop.closureContinuity !== + "periodic-zero-sum-twelve-frame-direction-run-folded-adaptive-smooth-sine-eased-sample-cycle") { throw new Error("Cityflow prepared playback packet drifted"); } const cached = decodedPackets.get(playback); if (cached) return cached; - const expectedCells = playback.frameCount * playback.boxCount; - const transformIndices = decodeUint32(playback.transformIndicesBase64); - const colorIndices = decodeUint8(playback.colorIndicesBase64); - if (transformIndices.length !== expectedCells || colorIndices.length !== expectedCells || - transformIndices.some((index) => index >= playback.transforms.length)) { - throw new Error("Cityflow prepared playback table drifted"); - } - const decoded = Object.freeze({ transformIndices, colorIndices }); + const staticVisibility = decodeStaticVisibility( + playback.staticVisibility, + playback.boxCount, + playback.facesPerBox, + ); + const transforms = expandCityflowPreparedTransforms( + playback.transformTable, + playback.transformIndices.transformOffsets, + ); + const presentationTransformIndices = decodeUint16(playback.transformIndices.presentationBase64); + const sourceTransformIndices = decodeUint16(playback.transformIndices.sourceBase64); + const presentationMaterialIndices = + decodeUint16(playback.colors.presentationMaterialIndicesBase64); + const sourceMaterialIndices = decodeUint16(playback.colors.sourceMaterialIndicesBase64); + const presentationColorTransitionOffsets = + decodeUint32(playback.colors.presentationTransitions.offsetsBase64); + const presentationColorTransitionFaceIndices = + decodeUint16(playback.colors.presentationTransitions.faceIndicesBase64); + const presentationColorTransitionColorIndices = + decodeUint16(playback.colors.presentationTransitions.colorIndicesBase64); + if (presentationTransformIndices.length !== stateCount || + sourceTransformIndices.length !== sourceStateCount || + !validMaterialIndices(presentationMaterialIndices, stateCount, + playback.colors.materials.length) || + !validMaterialIndices(sourceMaterialIndices, sourceStateCount, + playback.colors.materials.length) || + !validPerBoxTransformIndices(presentationTransformIndices, playback.frameCount, + playback.boxCount, playback.transformIndices.transformOffsets) || + !validPerBoxTransformIndices(sourceTransformIndices, playback.sourceFrameCount, + playback.boxCount, playback.transformIndices.transformOffsets) || + !validColorTransitions({ + offsets: presentationColorTransitionOffsets, + faceIndices: presentationColorTransitionFaceIndices, + colorIndices: presentationColorTransitionColorIndices, + frameCount: playback.frameCount, + faceCount: playback.boxCount * playback.facesPerBox, + colorCount: playback.colors.presentationTransitions.colors.length, + transitionCount: playback.colors.presentationTransitions.transitionCount, + maximumWritesPerFrame: playback.colors.presentationTransitions.maximumWritesPerFrame, + })) { + throw new Error("Cityflow prepared state tables drifted"); + } + const decoded = Object.freeze({ + transforms, + presentationTransformIndices, + sourceTransformIndices, + presentationMaterialIndices, + sourceMaterialIndices, + presentationColorTransitionOffsets, + presentationColorTransitionFaceIndices, + presentationColorTransitionColorIndices, + presentationBoxVisibilityRows: staticVisibility.presentationBoxVisibilityRows, + presentationBoxVisibilityOffsets: staticVisibility.presentationBoxVisibilityOffsets, + presentationBoxVisibilityIndices: staticVisibility.presentationBoxVisibilityIndices, + }); decodedPackets.set(playback, decoded); return decoded; } +function decodeStaticVisibility(value, boxCount, facesPerBox) { + const hiddenFaceIndices = value?.hiddenFaceIndices; + const hiddenBoxIndices = value?.hiddenBoxIndices; + const presentation = value?.presentation; + if (!Array.isArray(hiddenFaceIndices) || + hiddenFaceIndices.length !== value.hiddenFaceCount || + !Array.isArray(hiddenBoxIndices) || + hiddenBoxIndices.length !== value.hiddenBoxCount || + hiddenFaceIndices.some((faceIndex, index) => + !Number.isSafeInteger(faceIndex) || faceIndex < 0 || faceIndex >= boxCount * facesPerBox || + (index > 0 && faceIndex <= hiddenFaceIndices[index - 1])) || + hiddenBoxIndices.some((boxIndex, index) => + !Number.isSafeInteger(boxIndex) || boxIndex < 0 || boxIndex >= boxCount || + (index > 0 && boxIndex <= hiddenBoxIndices[index - 1])) || + presentation?.encoding !== + "initial-box-bitset-plus-u16le-per-target-frame-toggle-offsets-and-box-indices" || + !Array.isArray(presentation.alwaysVisibleBoxIndices) || + presentation.alwaysVisibleBoxIndices.some((boxIndex, index) => + !Number.isSafeInteger(boxIndex) || boxIndex < 0 || boxIndex >= boxCount || + (index > 0 && boxIndex <= presentation.alwaysVisibleBoxIndices[index - 1])) || + typeof presentation.initialVisibleBoxBitsBase64 !== "string" || + typeof presentation.transitionOffsetsBase64 !== "string" || + typeof presentation.transitionBoxIndicesBase64 !== "string") { + throw new Error("Cityflow prepared static visibility drifted"); + } + const hiddenFaceMask = new Uint8Array(boxCount * facesPerBox); + for (const faceIndex of hiddenFaceIndices) hiddenFaceMask[faceIndex] = 1; + const computedHiddenBoxIndices = Array.from({ length: boxCount }, (_, boxIndex) => boxIndex) + .filter((boxIndex) => Array.from({ length: facesPerBox }, (_, faceIndex) => + hiddenFaceMask[boxIndex * facesPerBox + faceIndex]).every(Boolean)); + if (computedHiddenBoxIndices.length !== hiddenBoxIndices.length || + computedHiddenBoxIndices.some((boxIndex, index) => boxIndex !== hiddenBoxIndices[index])) { + throw new Error("Cityflow prepared static hidden-box visibility drifted"); + } + const decoded = decodePresentationVisibility(presentation, boxCount, facesPerBox); + const rows = decoded.presentationBoxVisibilityRows; + const visibleBoxCounts = rows.map((row) => row.reduce((sum, visible) => sum + visible, 0)); + const computedAlwaysVisibleBoxIndices = Array.from({ length: boxCount }, (_, boxIndex) => boxIndex) + .filter((boxIndex) => rows.every((row) => row[boxIndex] !== 0)); + const computedHiddenPresentationBoxIndices = + Array.from({ length: boxCount }, (_, boxIndex) => boxIndex) + .filter((boxIndex) => rows.every((row) => row[boxIndex] === 0)); + const computedMinimumVisibleBoxes = Math.min(...visibleBoxCounts); + const computedMaximumVisibleBoxes = Math.max(...visibleBoxCounts); + const computedMeanVisibleBoxes = visibleBoxCounts.reduce((sum, count) => sum + count, 0) / + visibleBoxCounts.length; + if (visibleBoxCounts[0] !== presentation.initialVisibleBoxes || + presentation.initialVisibleCount !== visibleBoxCounts[0] * facesPerBox || + computedMinimumVisibleBoxes !== presentation.minimumVisibleBoxes || + computedMaximumVisibleBoxes !== presentation.maximumVisibleBoxes || + computedMinimumVisibleBoxes * facesPerBox !== presentation.minimumVisibleFaces || + computedMaximumVisibleBoxes * facesPerBox !== presentation.maximumVisibleFaces || + Math.abs(computedMeanVisibleBoxes - presentation.meanVisibleBoxes) > Number.EPSILON || + Math.abs(computedMeanVisibleBoxes * facesPerBox - presentation.meanVisibleFaces) > + Number.EPSILON * facesPerBox || + computedAlwaysVisibleBoxIndices.length !== presentation.alwaysVisibleBoxIndices.length || + computedAlwaysVisibleBoxIndices.some((boxIndex, index) => + boxIndex !== presentation.alwaysVisibleBoxIndices[index]) || + computedHiddenPresentationBoxIndices.length !== hiddenBoxIndices.length || + computedHiddenPresentationBoxIndices.some((boxIndex, index) => + boxIndex !== hiddenBoxIndices[index]) || + value.visibleBoxCount !== boxCount - hiddenBoxIndices.length || + value.visibleFaceCount !== value.visibleBoxCount * facesPerBox) { + throw new Error("Cityflow prepared static visibility metrics drifted"); + } + return Object.freeze(decoded); +} + +function decodePresentationVisibility(presentation, boxCount, facesPerBox) { + const initial = decodeUint8(presentation.initialVisibleBoxBitsBase64); + const presentationBoxVisibilityOffsets = decodeUint16(presentation.transitionOffsetsBase64); + const presentationBoxVisibilityIndices = decodeUint16(presentation.transitionBoxIndicesBase64); + if (initial.length !== Math.ceil(boxCount / 8) || + presentationBoxVisibilityOffsets.length !== presentation.frameCount + 1 || + presentationBoxVisibilityOffsets[0] !== 0 || + presentationBoxVisibilityOffsets.at(-1) !== presentationBoxVisibilityIndices.length || + presentationBoxVisibilityIndices.length !== presentation.transitionCount || + presentationBoxVisibilityIndices.some((boxIndex) => boxIndex >= boxCount) || + Array.from(presentationBoxVisibilityOffsets).some((offset, index, offsets) => + index > 0 && offset < offsets[index - 1])) { + throw new Error("Cityflow prepared presentation visibility encoding drifted"); + } + const visible = Uint8Array.from({ length: boxCount }, (_, boxIndex) => + initial[boxIndex >> 3] >> (boxIndex & 7) & 1); + const presentationBoxVisibilityRows = [visible.slice()]; + for (let frameIndex = 1; frameIndex < presentation.frameCount; frameIndex += 1) { + for (let cursor = presentationBoxVisibilityOffsets[frameIndex]; + cursor < presentationBoxVisibilityOffsets[frameIndex + 1]; cursor += 1) { + visible[presentationBoxVisibilityIndices[cursor]] ^= 1; + } + presentationBoxVisibilityRows.push(visible.slice()); + } + let computedMaximumTransitionWritesPerFrame = 0; + for (let frameIndex = 0; frameIndex < presentation.frameCount; frameIndex += 1) { + const start = presentationBoxVisibilityOffsets[frameIndex]; + const end = presentationBoxVisibilityOffsets[frameIndex + 1]; + computedMaximumTransitionWritesPerFrame = Math.max( + computedMaximumTransitionWritesPerFrame, + end - start, + ); + const toggled = new Set(presentationBoxVisibilityIndices.slice(start, end)); + if (toggled.size !== end - start) { + throw new Error("Cityflow prepared presentation visibility toggles drifted"); + } + } + if (computedMaximumTransitionWritesPerFrame !== + presentation.maximumTransitionWritesPerFrame) { + throw new Error("Cityflow prepared presentation visibility metrics drifted"); + } + return { + presentationBoxVisibilityRows: Object.freeze(presentationBoxVisibilityRows), + presentationBoxVisibilityOffsets, + presentationBoxVisibilityIndices, + }; +} + +function validMaterialIndices(values, count, materialCount) { + return values instanceof Uint16Array && values.length === count && values.every((value) => + Number.isSafeInteger(value) && value >= 0 && value < materialCount); +} + +function validTransformOffsets(offsets, boxCount, transformCount) { + return Array.isArray(offsets) && offsets.length === boxCount + 1 && offsets[0] === 0 && + offsets.at(-1) === transformCount && offsets.every((offset, index) => + Number.isSafeInteger(offset) && offset >= 0 && + (index === 0 || offset > offsets[index - 1])); +} + +function validPerBoxTransformIndices(values, frameCount, boxCount, offsets) { + for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) { + for (let boxIndex = 0; boxIndex < boxCount; boxIndex += 1) { + if (values[frameIndex * boxCount + boxIndex] >= offsets[boxIndex + 1] - offsets[boxIndex]) { + return false; + } + } + } + return true; +} + +function validColorTransitions({ + offsets, + faceIndices, + colorIndices, + frameCount, + faceCount, + colorCount, + transitionCount, + maximumWritesPerFrame, +}) { + if (!(offsets instanceof Uint32Array) || offsets.length !== frameCount + 1 || + offsets[0] !== 0 || offsets.at(-1) !== transitionCount || + !(faceIndices instanceof Uint16Array) || faceIndices.length !== transitionCount || + !(colorIndices instanceof Uint16Array) || colorIndices.length !== transitionCount || + faceIndices.some((faceIndex) => faceIndex >= faceCount) || + colorIndices.some((colorIndex) => colorIndex >= colorCount)) { + return false; + } + let computedMaximum = 0; + for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) { + if (offsets[frameIndex + 1] < offsets[frameIndex]) return false; + computedMaximum = Math.max( + computedMaximum, + offsets[frameIndex + 1] - offsets[frameIndex], + ); + } + return computedMaximum === maximumWritesPerFrame; +} + function decodeUint8(value) { const binary = atob(value); const bytes = new Uint8Array(binary.length); @@ -239,13 +781,28 @@ function decodeUint8(value) { return bytes; } +function decodeUint16(value) { + const bytes = decodeUint8(value); + if (bytes.byteLength % Uint16Array.BYTES_PER_ELEMENT !== 0) { + throw new Error("Cityflow uint16 playback table is truncated"); + } + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const values = new Uint16Array(bytes.byteLength / Uint16Array.BYTES_PER_ELEMENT); + for (let index = 0; index < values.length; index += 1) { + values[index] = view.getUint16(index * Uint16Array.BYTES_PER_ELEMENT, true); + } + return values; +} + function decodeUint32(value) { const bytes = decodeUint8(value); - if (bytes.byteLength % 4 !== 0) throw new Error("Cityflow uint32 playback table is truncated"); + if (bytes.byteLength % Uint32Array.BYTES_PER_ELEMENT !== 0) { + throw new Error("Cityflow uint32 playback table is truncated"); + } const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - const values = new Uint32Array(bytes.byteLength / 4); + const values = new Uint32Array(bytes.byteLength / Uint32Array.BYTES_PER_ELEMENT); for (let index = 0; index < values.length; index += 1) { - values[index] = view.getUint32(index * 4, true); + values[index] = view.getUint32(index * Uint32Array.BYTES_PER_ELEMENT, true); } return values; } diff --git a/src/adapters/cityflow/src/csscityflow/preparedTransformTable.mjs b/src/adapters/cityflow/src/csscityflow/preparedTransformTable.mjs new file mode 100644 index 0000000..bb291bf --- /dev/null +++ b/src/adapters/cityflow/src/csscityflow/preparedTransformTable.mjs @@ -0,0 +1,245 @@ +// SPDX-License-Identifier: HPND + +const AFFINE_MATRIX_POSITIONS = Object.freeze([0, 1, 2, 4, 5, 6, 8, 9, 10, 12, 13, 14]); +const AFFINE_MATRIX_WIDTH = AFFINE_MATRIX_POSITIONS.length; +const CHANGING_COMPONENT = 8; +const MAXIMUM_DECIMAL_PLACES = 9; +const TRANSFORM_TABLE_SCHEMA = "csscityflow-prepared-transform-table@1"; +const TRANSFORM_TABLE_ENCODING = + "per-box-affine-template-plus-z-delta-signed-varint-base64"; + +export function buildCityflowPreparedTransformTable(transforms, transformOffsets) { + if (!validTransformOffsets(transformOffsets, transformOffsets?.length - 1, transforms?.length) || + !Array.isArray(transforms)) { + throw new Error("Cityflow prepared transform input drifted"); + } + const groups = []; + for (let boxIndex = 0; boxIndex < transformOffsets.length - 1; boxIndex += 1) { + const start = transformOffsets[boxIndex]; + const end = transformOffsets[boxIndex + 1]; + const rows = transforms.slice(start, end).map((transform, localIndex) => + affineMatrixValues(transform, `Cityflow transform ${start + localIndex}`)); + const template = rows[0].slice(); + template[CHANGING_COMPONENT] = 0; + for (const row of rows) { + if (row.some((value, component) => + component !== CHANGING_COMPONENT && value !== template[component])) { + throw new Error(`Cityflow box ${boxIndex} changes more than its prepared height component`); + } + } + const changingValues = rows.map((row) => row[CHANGING_COMPONENT]); + const scale = exactDecimalScale(changingValues); + if (scale === 0) { + throw new Error(`Cityflow box ${boxIndex} height precision exceeds prepared transport`); + } + groups.push(Object.freeze({ + template: Object.freeze(template), + scale, + deltaCount: rows.length, + deltasBase64: encodeSignedVarintDeltas(changingValues, scale), + })); + } + const table = Object.freeze({ + schema: TRANSFORM_TABLE_SCHEMA, + precedent: + "cssgraphics.prepared-transform-table@1+cssgravitywell-sparse-component-varints@2", + encoding: TRANSFORM_TABLE_ENCODING, + count: transforms.length, + width: AFFINE_MATRIX_WIDTH, + changingComponent: CHANGING_COMPONENT, + groups: Object.freeze(groups), + }); + const expanded = expandCityflowPreparedTransforms(table, transformOffsets); + if (expanded.some((transform, index) => transform !== transforms[index])) { + throw new Error("Cityflow prepared transform transport is not byte exact"); + } + return table; +} + +export function expandCityflowPreparedTransforms(table, transformOffsets) { + const boxCount = transformOffsets?.length - 1; + if (table?.schema !== TRANSFORM_TABLE_SCHEMA || + table.precedent !== + "cssgraphics.prepared-transform-table@1+cssgravitywell-sparse-component-varints@2" || + table.encoding !== TRANSFORM_TABLE_ENCODING || + !Number.isSafeInteger(table.count) || table.count < 1 || + table.width !== AFFINE_MATRIX_WIDTH || + table.changingComponent !== CHANGING_COMPONENT || + !validTransformOffsets(transformOffsets, boxCount, table.count) || + !Array.isArray(table.groups) || table.groups.length !== boxCount) { + throw new Error("Cityflow prepared transform table drifted"); + } + const transforms = new Array(table.count); + for (let boxIndex = 0; boxIndex < boxCount; boxIndex += 1) { + const group = table.groups[boxIndex]; + const start = transformOffsets[boxIndex]; + const end = transformOffsets[boxIndex + 1]; + const count = end - start; + if (!Array.isArray(group?.template) || group.template.length !== AFFINE_MATRIX_WIDTH || + group.template.some((value) => typeof value !== "number" || !Number.isFinite(value)) || + group.template[CHANGING_COMPONENT] !== 0 || + !Number.isSafeInteger(group.scale) || !isSupportedScale(group.scale) || + group.deltaCount !== count || typeof group.deltasBase64 !== "string") { + throw new Error(`Cityflow prepared transform group ${boxIndex} drifted`); + } + const changingValues = decodeSignedVarintDeltas( + group.deltasBase64, + group.scale, + count, + boxIndex, + ); + for (let localIndex = 0; localIndex < count; localIndex += 1) { + const values = group.template.slice(); + values[CHANGING_COMPONENT] = changingValues[localIndex]; + transforms[start + localIndex] = affineMatrixString(values); + } + } + return Object.freeze(transforms); +} + +function affineMatrixValues(transform, label) { + if (typeof transform !== "string" || !transform.startsWith("matrix3d(") || + !transform.endsWith(")")) { + throw new Error(`${label} is not a canonical matrix3d string`); + } + const tokens = transform.slice(9, -1).split(","); + if (tokens.length !== 16 || tokens[3] !== "0" || tokens[7] !== "0" || + tokens[11] !== "0" || tokens[15] !== "1") { + throw new Error(`${label} does not use the prepared affine matrix layout`); + } + return AFFINE_MATRIX_POSITIONS.map((position) => { + const token = tokens[position]; + const value = Number(token); + if (!Number.isFinite(value) || String(value) !== token) { + throw new Error(`${label} contains a noncanonical number`); + } + return value; + }); +} + +function affineMatrixString(values) { + const matrix = [ + values[0], values[1], values[2], 0, + values[3], values[4], values[5], 0, + values[6], values[7], values[8], 0, + values[9], values[10], values[11], 1, + ]; + return `matrix3d(${matrix.join(",")})`; +} + +function exactDecimalScale(values) { + for (let decimals = 0; decimals <= MAXIMUM_DECIMAL_PLACES; decimals += 1) { + const scale = 10 ** decimals; + if (values.every((value) => { + const scaled = Math.round(value * scale); + return Number.isSafeInteger(scaled) && String(scaled / scale) === String(value); + })) return scale; + } + return 0; +} + +function isSupportedScale(scale) { + for (let decimals = 0; decimals <= MAXIMUM_DECIMAL_PLACES; decimals += 1) { + if (scale === 10 ** decimals) return true; + } + return false; +} + +function encodeSignedVarintDeltas(values, scale) { + const bytes = []; + let previous = 0; + for (const value of values) { + const next = Math.round(value * scale); + const delta = next - previous; + previous = next; + appendSignedVarint(bytes, delta); + } + return encodeBase64(Uint8Array.from(bytes)); +} + +function appendSignedVarint(target, value) { + if (!Number.isSafeInteger(value)) { + throw new RangeError("Cityflow prepared fixed-point matrix delta is unsafe"); + } + let encoded = value >= 0 ? value * 2 : (-value * 2) - 1; + if (!Number.isSafeInteger(encoded)) { + throw new RangeError("Cityflow prepared fixed-point matrix zigzag value is unsafe"); + } + while (encoded >= 0x80) { + target.push((encoded % 0x80) | 0x80); + encoded = Math.floor(encoded / 0x80); + } + target.push(encoded); +} + +function decodeSignedVarintDeltas(base64, scale, count, boxIndex) { + const bytes = decodeBase64(base64); + const values = new Array(count); + let offset = 0; + let current = 0; + for (let index = 0; index < count; index += 1) { + let encoded = 0; + let multiplier = 1; + let complete = false; + while (offset < bytes.length) { + const byte = bytes[offset]; + offset += 1; + encoded += (byte & 0x7f) * multiplier; + if (!Number.isSafeInteger(encoded)) { + throw new Error(`Cityflow prepared transform group ${boxIndex} varint is unsafe`); + } + if ((byte & 0x80) === 0) { + complete = true; + break; + } + multiplier *= 0x80; + if (!Number.isSafeInteger(multiplier)) { + throw new Error(`Cityflow prepared transform group ${boxIndex} varint is too long`); + } + } + if (!complete) { + throw new Error(`Cityflow prepared transform group ${boxIndex} is truncated`); + } + const delta = encoded % 2 === 0 ? encoded / 2 : -(encoded + 1) / 2; + current += delta; + if (!Number.isSafeInteger(current)) { + throw new Error(`Cityflow prepared transform group ${boxIndex} value is unsafe`); + } + values[index] = current / scale; + } + if (offset !== bytes.length) { + throw new Error(`Cityflow prepared transform group ${boxIndex} has trailing bytes`); + } + return values; +} + +function validTransformOffsets(offsets, boxCount, transformCount) { + return Number.isSafeInteger(boxCount) && boxCount > 0 && + Array.isArray(offsets) && offsets.length === boxCount + 1 && offsets[0] === 0 && + offsets.at(-1) === transformCount && offsets.every((offset, index) => + Number.isSafeInteger(offset) && offset >= 0 && + (index === 0 || offset > offsets[index - 1])); +} + +function encodeBase64(bytes) { + let binary = ""; + const chunkSize = 0x8000; + for (let offset = 0; offset < bytes.length; offset += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)); + } + return btoa(binary); +} + +function decodeBase64(value) { + let binary; + try { + binary = atob(value); + } catch { + throw new Error("Cityflow prepared transform base64 drifted"); + } + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return bytes; +} diff --git a/src/adapters/cityflow/src/csscityflow/profileSelection.mjs b/src/adapters/cityflow/src/csscityflow/profileSelection.mjs new file mode 100644 index 0000000..0df4c66 --- /dev/null +++ b/src/adapters/cityflow/src/csscityflow/profileSelection.mjs @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: HPND + +const MOBILE_USER_AGENT = /Android|iPhone|iPad|iPod|Mobile/u; + +export const CSSCITYFLOW_MOBILE_BREAKPOINT_WIDTH = 600; +export const CSSCITYFLOW_MOBILE_CAPABILITY_QUERY = "(hover: none) and (pointer: coarse)"; +export const CSSCITYFLOW_PREPARED_BANKS = Object.freeze({ + desktop: Object.freeze({ id: "desktop", modelId: "cityflow" }), + mobile: Object.freeze({ id: "mobile", modelId: "cityflow-mobile" }), +}); + +export function selectCityflowPreparedBank({ + width = globalThis.innerWidth, + height = globalThis.innerHeight, + mediaMatches = (query) => globalThis.matchMedia(query).matches, + userAgentDataMobile = globalThis.navigator?.userAgentData?.mobile === true, + userAgent = globalThis.navigator?.userAgent ?? "", +} = {}) { + if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(height) || height <= 0) { + throw new TypeError("Prepared Cityflow viewport drifted"); + } + return userAgentDataMobile || MOBILE_USER_AGENT.test(userAgent) || + mediaMatches(CSSCITYFLOW_MOBILE_CAPABILITY_QUERY) || + width < CSSCITYFLOW_MOBILE_BREAKPOINT_WIDTH + ? "mobile" + : "desktop"; +} diff --git a/src/adapters/cityflow/src/csscityflow/sourceProjection.mjs b/src/adapters/cityflow/src/csscityflow/sourceProjection.mjs new file mode 100644 index 0000000..efd671a --- /dev/null +++ b/src/adapters/cityflow/src/csscityflow/sourceProjection.mjs @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: HPND + +const HALF_VERTICAL_FIELD_OF_VIEW_RADIANS = Math.PI / 12; + +export function cityflowSourceProjection(width, height) { + if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(height) || height <= 0) { + throw new TypeError("Cityflow projection dimensions must be positive finite numbers"); + } + const usesWideSourceViewport = width > height * 2; + const viewportHeight = usesWideSourceViewport ? width : height; + return Object.freeze({ + perspective: viewportHeight / (2 * Math.tan(HALF_VERTICAL_FIELD_OF_VIEW_RADIANS)), + viewportHeight, + viewportTop: usesWideSourceViewport ? height - viewportHeight / 2 : 0, + usesWideSourceViewport, + }); +} + +export function bindCityflowSourceProjection(host, cameraElement, onProjection = () => {}) { + if (!(host instanceof HTMLElement) || !(cameraElement instanceof HTMLElement)) { + throw new TypeError("Cityflow projection binding requires retained host and camera elements"); + } + const apply = () => { + const projection = cityflowSourceProjection(host.clientWidth, host.clientHeight); + cameraElement.style.setProperty("--csscityflow-camera-height", `${projection.viewportHeight}px`); + cameraElement.style.setProperty("--csscityflow-camera-top", `${projection.viewportTop}px`); + cameraElement.style.setProperty("--csscityflow-perspective", `${projection.perspective}px`); + onProjection(projection); + }; + apply(); + const observer = new ResizeObserver(apply); + observer.observe(host); + return () => observer.disconnect(); +} diff --git a/src/adapters/cityflow/src/csscityflow/styles.css b/src/adapters/cityflow/src/csscityflow/styles.css index 10f45ca..d61bd68 100644 --- a/src/adapters/cityflow/src/csscityflow/styles.css +++ b/src/adapters/cityflow/src/csscityflow/styles.css @@ -1,4 +1,6 @@ +/* SPDX-License-Identifier: HPND */ :root { + --examples-stage-background: #000; color-scheme: dark; background: var(--csscityflow-background, #000); color: #f2f2f2; @@ -22,140 +24,30 @@ body { background: var(--csscityflow-background, #000); } -body.loading::after { - content: ""; - position: fixed; - inset: 50% auto auto 50%; - z-index: 20; - width: 18px; - height: 18px; - margin: -9px 0 0 -9px; - border: 1px solid rgb(240 240 240 / 18%); - border-top-color: rgb(240 240 240 / 70%); - border-radius: 50%; -} - -.site-header { - position: fixed; - inset: 0 0 auto; - z-index: 21; - display: flex; - align-items: center; - justify-content: space-between; - width: 100%; - height: 50px; - padding: 0 12px 0 16px; - font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - font-synthesis: none; - pointer-events: none; -} - -.site-wordmark-heading { - margin: 0; - font: inherit; -} - -.site-wordmark { - display: inline-flex; - align-items: center; - color: #aeb4bc; - opacity: 0.72; - pointer-events: auto; - text-decoration: none; -} - -.site-wordmark-svg { - display: block; - width: 218px; - height: 30px; - overflow: visible; -} - -.site-wordmark-svg text { - fill: currentColor; - font-family: inherit; - font-size: 24px; - font-weight: 400; - letter-spacing: 0.1px; -} - -.site-wordmark-css { font-weight: 500; } -.site-wordmark-graphics { font-weight: 200; } -.site-wordmark-path { font-weight: 100; } - -.site-action-icon-only { - display: inline-flex; - width: 36px; - min-width: 36px; - height: 50px; - align-items: center; - justify-content: center; - padding: 0; - color: #f0f0f0; - background: transparent; - border: 0; - font: 400 13px/1 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - letter-spacing: 0.01em; - cursor: pointer; - opacity: 0.72; - pointer-events: auto; - text-decoration: none; -} - -.site-action-icon { - width: 18px; - height: 18px; - fill: none; - stroke: currentColor; - stroke-linecap: round; - stroke-linejoin: round; - stroke-width: 1.8; -} - -.site-action-icon-outline { - stroke: rgb(0 0 0 / 60%); - stroke-width: 4; -} - -.site-wordmark:hover, -.site-wordmark:focus-visible, -.site-action-icon-only:hover, -.site-action-icon-only:focus-visible { - opacity: 1; -} - -.site-wordmark:focus-visible { - outline: 1px solid currentColor; - outline-offset: 4px; -} - -.site-action-icon-only:focus-visible { - outline: 1px solid currentColor; - outline-offset: -5px; +.example-stage { + overflow: hidden; + background: #000; } -body > .polycss-camera { +.example-stage > .polycss-camera { position: absolute; - inset: 0; + inset: auto 0 auto 0; + top: var(--csscityflow-camera-top, 0px); width: 100%; - height: 100%; + height: var(--csscityflow-camera-height, 100%); overflow: hidden; contain: layout paint size style; - perspective: calc(50vh / .2679491924) !important; + perspective: var(--csscityflow-perspective) !important; perspective-origin: 50% 50%; } -body > .polycss-camera > .polycss-scene { - transform: translateZ(calc(50vh / .2679491924)) !important; -} - .polycss-mesh { position: absolute; transform-style: preserve-3d; transform-origin: 0 0 0; } -body.loading > .polycss-camera { +body.loading .example-stage > .polycss-camera { visibility: hidden; } @@ -166,12 +58,3 @@ body.error::after { color: #f88; translate: -50% -50%; } - -@media (max-width: 520px) { - .site-header { padding-inline: 12px; } - .site-wordmark-svg { width: 176px; } -} - -@media (max-width: 390px) { - .site-wordmark-svg { width: 148px; } -} diff --git a/src/adapters/cityflow/src/main.mjs b/src/adapters/cityflow/src/main.mjs index eb0fc4b..e3edea5 100644 --- a/src/adapters/cityflow/src/main.mjs +++ b/src/adapters/cityflow/src/main.mjs @@ -1,4 +1,6 @@ +// SPDX-License-Identifier: HPND import "./csscityflow/styles.css"; +import { requireExamplesStage } from "../../../../site/examples-shell-client.mjs"; import { mountCityflow } from "./csscityflow/client.mjs"; -mountCityflow(document.body); +mountCityflow(requireExamplesStage()); diff --git a/src/adapters/cityflow/src/prepare/csscityflow/model.mjs b/src/adapters/cityflow/src/prepare/csscityflow/model.mjs index baec713..7e6df03 100644 --- a/src/adapters/cityflow/src/prepare/csscityflow/model.mjs +++ b/src/adapters/cityflow/src/prepare/csscityflow/model.mjs @@ -1,10 +1,25 @@ +// SPDX-License-Identifier: HPND import { Buffer } from "node:buffer"; import { CSSCITYFLOW_FACE_IDS, CSSCITYFLOW_FRAME_MILLISECONDS, + CSSCITYFLOW_PREPARED_FRAME_COUNT, + CSSCITYFLOW_PRESENTATION_FRAME_COUNT, + CSSCITYFLOW_PRESENTATION_FRAME_MILLISECONDS, buildCityflowSourceState, cityflowFrameAt, } from "./sourceModel.mjs"; +import preparedVisibility from "../../../notes/references/prepared-visibility.json" with { type: "json" }; +import preparedEasedVisibility from "../../../notes/references/prepared-visibility-adaptive-smooth-sine.json" with { type: "json" }; +import preparedWideVisibility from "../../../notes/references/prepared-visibility-wide.json" with { type: "json" }; +import preparedSideDepth from "../../../notes/references/prepared-side-depth.json" with { type: "json" }; +import preparedStaticVisibility from "../../../notes/references/prepared-static-visibility.json" with { type: "json" }; +import { buildCityflowPreparedTransformTable } from "../../csscityflow/preparedTransformTable.mjs"; + +const MAXIMUM_BRIEF_DIRECTION_RUN_FRAMES = 12; +const PRESENTATION_EASING_RAMP_START_FRAMES = 24; +const PRESENTATION_EASING_RAMP_FULL_FRAMES = 54; +const PRESENTATION_EASING_MAXIMUM_CENTER_REDUCTION = 0.6; const SOURCE_MODEL_MATRIX = Object.freeze([ 29.600317554756, 6.483735104931, 8.604207095757, 0, @@ -24,11 +39,12 @@ const CSSCITYFLOW_SOURCE_VIEW = Object.freeze({ turnDegrees: 20, boxScale: 2.1, }); -const FACE_MATRICES = Object.freeze({ - top: Object.freeze([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.5, -0.5, 1, 1]), - front: Object.freeze([1, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 0, -0.5, 0.5, 0, 1]), - right: Object.freeze([0, 1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0.5, -0.5, 0, 1]), -}); +const CSSCITYFLOW_DESKTOP_DEFAULT_SIDE_DEPTH_SCALE = preparedSideDepth.defaultDepthScale; +const CSSCITYFLOW_DESKTOP_SIDE_DEPTH_BY_FACE_INDEX = validatePreparedSideDepth(preparedSideDepth); +const CSSCITYFLOW_DESKTOP_STATIC_VISIBILITY = validatePreparedStaticVisibility(preparedStaticVisibility); +const TOP_FACE_MATRIX = Object.freeze([ + 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -0.5, -0.5, 1, 1, +]); export function buildCityflowMorphModel(options = {}) { const state = buildCityflowSourceState(options); @@ -59,7 +75,7 @@ export function buildCityflowMorphModel(options = {}) { strategy: "solid-quad", width: 1, height: 1, - matrix: FACE_MATRICES[faceId], + matrix: preparedFaceMatrix(state.source.id, boxIndex, faceId, faceIndex), atlas: null, fallback: null, }); @@ -114,7 +130,7 @@ export function buildCityflowMorphModel(options = {}) { kind: "open-data", uri: `https://github.com/Zygo/xscreensaver/blob/${state.source.commit}/${state.source.primaryPath}`, sha256: state.source.primarySha256, - license: "XScreenSaver cityflow.c permissive notice", + license: "HPND", })]), }), }), @@ -191,61 +207,1105 @@ function rotateZ([x, y, z], degrees) { } export function buildCityflowPreparedCss(state) { - const rules = [ + return [ `:root{--csscityflow-background:${paletteColor(state.palette[0])}}`, - `.csscityflow-box>b{color:rgb(from var(--csscityflow-base) calc(r * var(--csscityflow-light)) calc(g * var(--csscityflow-light)) calc(b * var(--csscityflow-light)))!important}`, - `.csscityflow-box>b:first-child{backface-visibility:visible!important}`, - `.csscityflow-box>b:not(:first-child){backface-visibility:hidden!important}`, - ]; - for (let index = 0; index < state.palette.length; index += 1) { - rules.push(`.csscityflow-color-${index}{--csscityflow-base:${paletteColor(state.palette[index])}}`); - } - return rules.join(""); + `.csscityflow-box>b{display:block!important;backface-visibility:hidden}`, + `.csscityflow-box>b:nth-child(1){backface-visibility:visible}`, + ].join(""); } export function buildCityflowPreparedPlayback(state) { - const frameCount = 252; - const transformIndices = new Uint32Array(frameCount * state.boxes.length); - const colorIndices = new Uint8Array(frameCount * state.boxes.length); - const transforms = []; - const transformIndexByValue = new Map(); + const frameCount = CSSCITYFLOW_PRESENTATION_FRAME_COUNT; + const sourceFrameCount = CSSCITYFLOW_PREPARED_FRAME_COUNT; + const boxCount = state.boxes.length; + const paletteSize = state.palette.length; + const staticVisibility = state.source.id === "desktop" + ? CSSCITYFLOW_DESKTOP_STATIC_VISIBILITY + : buildFullyVisibleStaticVisibility(state, frameCount); + const sideDepth = cityflowPreparedSideDepthProfile(state.source.id); + const presentationTransformIndices = new Uint16Array(frameCount * boxCount); + const presentationMaterialIndices = new Uint16Array(frameCount * boxCount); + const sourceTransformIndices = new Uint16Array(sourceFrameCount * boxCount); + const sourceMaterialIndices = new Uint16Array(sourceFrameCount * boxCount); + const transformBanks = Array.from({ length: boxCount }, () => []); + const transformIndexByValue = Array.from({ length: boxCount }, () => new Map()); + const materials = []; + const materialIndexByValue = new Map(); + const paletteMaterialIndices = new Uint16Array(boxCount * paletteSize); + const materialIndex = (material) => { + const key = material.join(","); + let index = materialIndexByValue.get(key); + if (index === undefined) { + index = materials.length; + if (index > 0xffff) { + throw new Error("Cityflow prepared materials exceeded uint16"); + } + materialIndexByValue.set(key, index); + materials.push(Object.freeze(material)); + } + return index; + }; const bottoms = state.boxes.map((box) => { const clipped = cityflowClippedBottoms(box); return Math.min(clipped.front, clipped.right); }); + + for (let boxIndex = 0; boxIndex < boxCount; boxIndex += 1) { + const box = state.boxes[boxIndex]; + for (let paletteIndex = 0; paletteIndex < paletteSize; paletteIndex += 1) { + const material = CSSCITYFLOW_FACE_IDS.map((_, faceIndex) => + litPaletteColor(state.palette[paletteIndex], box.lightFactors[faceIndex])); + paletteMaterialIndices[boxIndex * paletteSize + paletteIndex] = + materialIndex(material); + } + } + + const sourceFrames = buildCityflowSourceFrames(state); + const transformIndex = (boxIndex, height) => { + const transform = boxPlaybackMatrix( + state.boxes[boxIndex], + -height / 2, + bottoms[boxIndex], + ); + let index = transformIndexByValue[boxIndex].get(transform); + if (index === undefined) { + index = transformBanks[boxIndex].length; + if (index > 0xffff) throw new Error("Cityflow per-box transforms exceeded uint16"); + transformIndexByValue[boxIndex].set(transform, index); + transformBanks[boxIndex].push(transform); + } + return index; + }; + for (let frameIndex = 0; frameIndex < sourceFrameCount; frameIndex += 1) { + for (let boxIndex = 0; boxIndex < boxCount; boxIndex += 1) { + const sample = sourceFrames[frameIndex].boxes[boxIndex]; + const offset = frameIndex * boxCount + boxIndex; + sourceTransformIndices[offset] = transformIndex(boxIndex, sample.height); + sourceMaterialIndices[offset] = + paletteMaterialIndices[boxIndex * paletteSize + sample.colorIndex]; + } + } + + const presentationHeights = buildCityflowPreparedHeightRows(state, sourceFrames); for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) { - const frame = cityflowFrameAt(state, frameIndex); - for (let boxIndex = 0; boxIndex < state.boxes.length; boxIndex += 1) { - const sample = frame.boxes[boxIndex]; - const transform = boxPlaybackMatrix( - state.boxes[boxIndex], - -sample.height / 2, - bottoms[boxIndex], + for (let boxIndex = 0; boxIndex < boxCount; boxIndex += 1) { + const height = presentationHeights[frameIndex][boxIndex]; + const palettePosition = height * paletteSize * 0.7; + const offset = frameIndex * boxCount + boxIndex; + presentationTransformIndices[offset] = transformIndex(boxIndex, height); + presentationMaterialIndices[offset] = materialIndex( + CSSCITYFLOW_FACE_IDS.map((_, faceIndex) => interpolatedLitPaletteColor( + state.palette, + palettePosition, + state.boxes[boxIndex].lightFactors[faceIndex], + )), ); - let transformIndex = transformIndexByValue.get(transform); - if (transformIndex === undefined) { - transformIndex = transforms.length; - transformIndexByValue.set(transform, transformIndex); - transforms.push(transform); - } - const offset = frameIndex * state.boxes.length + boxIndex; - transformIndices[offset] = transformIndex; - colorIndices[offset] = sample.colorIndex; } } + + const transforms = []; + const transformOffsets = [0]; + for (const bank of transformBanks) { + transforms.push(...bank); + transformOffsets.push(transforms.length); + } + const transformTable = buildCityflowPreparedTransformTable(transforms, transformOffsets); + const presentationColorTransitions = buildPresentationColorTransitions({ + frameCount, + boxCount, + facesPerBox: CSSCITYFLOW_FACE_IDS.length, + materials, + presentationMaterialIndices, + staticVisibility, + }); + return Object.freeze({ - schema: "csscityflow-prepared-playback@1", + schema: "csscityflow-prepared-playback@58", precedent: "domformat@0/polycss-playback@0@cc8da736", - catchUpPolicy: "elapsed", + bankId: state.source.id, + modelId: state.source.modelId, + catchUpPolicy: "adjacent-state-late-deadline-reset", + frameCount, + tickIntervalUs: Object.freeze([50_000, 3]), + sourceFrameCount, + sourceTickIntervalUs: Object.freeze([CSSCITYFLOW_FRAME_MILLISECONDS * 1_000, 1]), + boxCount, + paletteSize, + facesPerBox: CSSCITYFLOW_FACE_IDS.length, + staticVisibility, + sideDepth: Object.freeze({ + schema: preparedSideDepth.schema, + defaultDepthScale: sideDepth.defaultDepthScale, + maximumDepthScale: sideDepth.maximumDepthScale, + overrideCount: sideDepth.byFaceIndex.size, + }), + loop: Object.freeze({ + kind: "prepared-periodic-source-sample-reconstruction", + exactSourceLoop: false, + phaseStepRadians: Math.PI * 2 / frameCount, + sourcePeriodFrames: Math.PI * 2 / 0.025, + presentationPeriodFrames: frameCount, + closureContinuity: + "periodic-zero-sum-twelve-frame-direction-run-folded-adaptive-smooth-sine-eased-sample-cycle", + }), + presentation: Object.freeze({ + kind: "prepared-periodic-source-sample-reconstruction", + sourceFramesPerSecond: 1_000 / CSSCITYFLOW_FRAME_MILLISECONDS, + framesPerSecond: 60, + exactSourceStateSeek: true, + heightInterpolation: "periodic-uniform-cubic-b-spline-c2-source-approximation", + temporalFilter: + "prepared-periodic-five-tap-fold-twelve-three-tap-refold-twelve-five-tap-refold-twelve-adaptive-smooth-sine-eased-extrema@1", + directionRunSuppression: + "prepared-circular-twelve-frame-or-short-direction-run-folding-zero-sum-adaptive-smooth-sine-24-54-0.6-eased@1", + colorInterpolation: + "prepared-srgb-interpolated-final-face-color", + transformPublication: + "prepared-packed-transform-components-expanded-once-plus-sparse-final-face-color-and-whole-box-leaf-visibility-publication", + statePublication: Object.freeze({ + schema: "csscityflow-prepared-state-publication@22", + frameCount, + animationCount: 0, + runtimeFormatting: false, + loadTimeAssembly: "one-time-prepared-transform-component-table-expansion", + sourceSeekAssembly: "none-cached-expanded-transform-and-final-face-color-dictionaries", + atomicProperties: + "prepared-root-transform-plus-direct-leaf-visibility-and-final-face-background-color", + minimumShapeStyleWritesPerScheduledTick: 0, + maximumShapeStyleWritesPerScheduledTick: + staticVisibility.presentation.maximumVisibleBoxes, + maximumLeafColorStyleWritesPerScheduledTick: + presentationColorTransitions.maximumWritesPerFrame, + maximumVisibilityStyleWritesPerScheduledTick: + staticVisibility.presentation.maximumTransitionWritesPerFrame * + CSSCITYFLOW_FACE_IDS.length, + }), + }), + transformTable, + transformIndices: Object.freeze({ + schema: "csscityflow-prepared-transform-indices@2", + encoding: "per-box-u16le-base64-plus-transform-offsets", + count: frameCount * boxCount, + transformOffsets: Object.freeze(transformOffsets), + presentationBase64: Buffer.from(presentationTransformIndices.buffer).toString("base64"), + sourceCount: sourceFrameCount * boxCount, + sourceBase64: Buffer.from(sourceTransformIndices.buffer).toString("base64"), + }), + colors: Object.freeze({ + schema: "csscityflow-prepared-face-local-materials@7", + encoding: + "prepared-three-final-color-tuples-plus-packed-absolute-indices-and-sparse-presentation-transitions", + materials: Object.freeze(materials), + presentationMaterialIndicesBase64: + Buffer.from(presentationMaterialIndices.buffer).toString("base64"), + sourceMaterialIndicesBase64: + Buffer.from(sourceMaterialIndices.buffer).toString("base64"), + presentationTransitions: presentationColorTransitions, + }), + diagnostics: Object.freeze({ + visibility: state.source.id === "desktop" + ? buildPreparedVisibilityContract(state, frameCount) + : Object.freeze({ + schema: "csscityflow-mobile-diagnostic-visibility@1", + bankId: state.source.id, + faceCount: state.boxes.length * CSSCITYFLOW_FACE_IDS.length, + frameCount, + coverage: "product-publishes-complete-mobile-bank", + }), + productPolicy: "diagnostic-only-never-consumed-by-product-playback", + }), + }); +} + +function buildPresentationColorTransitions({ + frameCount, + boxCount, + facesPerBox, + materials, + presentationMaterialIndices, + staticVisibility, +}) { + const faceCount = boxCount * facesPerBox; + const presentation = staticVisibility.presentation; + const initial = Buffer.from(presentation.initialVisibleBoxBitsBase64, "base64"); + const visibilityOffsets = decodeLittleEndianUint16( + Buffer.from(presentation.transitionOffsetsBase64, "base64"), + ); + const visibilityIndices = decodeLittleEndianUint16( + Buffer.from(presentation.transitionBoxIndicesBase64, "base64"), + ); + const visible = Uint8Array.from({ length: boxCount }, (_, boxIndex) => + initial[boxIndex >> 3] >> (boxIndex & 7) & 1); + const visibilityRows = [visible.slice()]; + for (let frameIndex = 1; frameIndex < frameCount; frameIndex += 1) { + for (let cursor = visibilityOffsets[frameIndex]; + cursor < visibilityOffsets[frameIndex + 1]; cursor += 1) { + visible[visibilityIndices[cursor]] ^= 1; + } + visibilityRows.push(visible.slice()); + } + + const offsets = [0]; + const faceIndices = []; + const colors = []; + const colorIndices = []; + const colorIndexByValue = new Map(); + const colorIndex = (color) => { + let index = colorIndexByValue.get(color); + if (index === undefined) { + index = colors.length; + if (index > 0xffff) { + throw new Error("Cityflow prepared transition colors exceeded uint16"); + } + colorIndexByValue.set(color, index); + colors.push(color); + } + return index; + }; + let maximumWritesPerFrame = 0; + for (let targetFrameIndex = 0; targetFrameIndex < frameCount; targetFrameIndex += 1) { + const previousFrameIndex = (targetFrameIndex - 1 + frameCount) % frameCount; + const targetOffset = targetFrameIndex * boxCount; + const previousOffset = previousFrameIndex * boxCount; + const targetVisible = visibilityRows[targetFrameIndex]; + const previousVisible = visibilityRows[previousFrameIndex]; + const frameStart = faceIndices.length; + for (let boxIndex = 0; boxIndex < boxCount; boxIndex += 1) { + if (targetVisible[boxIndex] === 0) continue; + const targetMaterialIndex = presentationMaterialIndices[targetOffset + boxIndex]; + const previousMaterialIndex = presentationMaterialIndices[previousOffset + boxIndex]; + for (let localFaceIndex = 0; localFaceIndex < facesPerBox; localFaceIndex += 1) { + if (previousVisible[boxIndex] !== 0 && + materials[targetMaterialIndex][localFaceIndex] === + materials[previousMaterialIndex][localFaceIndex]) { + continue; + } + faceIndices.push(boxIndex * facesPerBox + localFaceIndex); + colorIndices.push(colorIndex(materials[targetMaterialIndex][localFaceIndex])); + } + } + const frameWrites = faceIndices.length - frameStart; + maximumWritesPerFrame = Math.max(maximumWritesPerFrame, frameWrites); + offsets.push(faceIndices.length); + } + return Object.freeze({ + schema: "csscityflow-prepared-face-color-transitions@2", + encoding: + "final-color-dictionary-plus-u32le-target-frame-offsets-and-u16le-face-and-color-indices", + frameCount, + faceCount, + transitionCount: faceIndices.length, + maximumWritesPerFrame, + colors: Object.freeze(colors), + offsetsBase64: encodeLittleEndianUint32(offsets), + faceIndicesBase64: encodeLittleEndianUint16(faceIndices), + colorIndicesBase64: encodeLittleEndianUint16(colorIndices), + }); +} + +export function cityflowPreparedSideDepth(faceIndex, bankId = "desktop") { + const profile = cityflowPreparedSideDepthProfile(bankId); + return profile.byFaceIndex.get(faceIndex) ?? profile.defaultDepthScale; +} + +function preparedFaceMatrix(bankId, boxIndex, faceId, faceIndex) { + if (faceId === "top") return TOP_FACE_MATRIX; + const depthScale = cityflowPreparedSideDepth( + boxIndex * CSSCITYFLOW_FACE_IDS.length + faceIndex, + bankId, + ); + const topOffset = 1 - depthScale; + if (faceId === "front") { + return Object.freeze([ + 1, 0, 0, 0, 0, 0, depthScale, 0, 0, 1, 0, 0, + -0.5, 0.5, topOffset, 1, + ]); + } + return Object.freeze([ + 0, 1, 0, 0, 0, 0, depthScale, 0, 1, 0, 0, 0, + 0.5, -0.5, topOffset, 1, + ]); +} + +function cityflowPreparedSideDepthProfile(bankId) { + if (bankId === "desktop") { + return Object.freeze({ + defaultDepthScale: CSSCITYFLOW_DESKTOP_DEFAULT_SIDE_DEPTH_SCALE, + maximumDepthScale: preparedSideDepth.maximumDepthScale, + byFaceIndex: CSSCITYFLOW_DESKTOP_SIDE_DEPTH_BY_FACE_INDEX, + }); + } + if (bankId === "mobile") { + return Object.freeze({ + defaultDepthScale: 0.28, + maximumDepthScale: 0.28, + byFaceIndex: new Map(), + }); + } + throw new RangeError(`Unknown Cityflow side-depth bank: ${bankId}`); +} + +function buildFullyVisibleStaticVisibility(state, frameCount) { + const boxCount = state.boxes.length; + const facesPerBox = CSSCITYFLOW_FACE_IDS.length; + const faceCount = boxCount * facesPerBox; + const initial = Buffer.alloc(Math.ceil(boxCount / 8), 0xff); + if (boxCount % 8 !== 0) initial[initial.length - 1] &= (1 << (boxCount % 8)) - 1; + const transitionOffsets = new Array(frameCount + 1).fill(0); + return Object.freeze({ + schema: "csscityflow-prepared-static-visibility@3", + faceCount, + visibleFaceCount: faceCount, + hiddenFaceCount: 0, + visibleBoxCount: boxCount, + hiddenBoxCount: 0, + hiddenFaceIndices: Object.freeze([]), + hiddenBoxIndices: Object.freeze([]), + sortingDependencyBoxIndices: Object.freeze([]), + policy: "prepared-whole-box-visibility-no-face-culling", + presentation: Object.freeze({ + schema: "csscityflow-prepared-presentation-box-visibility@1", + encoding: "initial-box-bitset-plus-u16le-per-target-frame-toggle-offsets-and-box-indices", + frameCount, + boxCount, + faceCount, + transitionDilationFrames: 0, + alwaysVisibleBoxIndices: Object.freeze(Array.from({ length: boxCount }, (_, index) => index)), + initialVisibleCount: faceCount, + initialVisibleBoxes: boxCount, + minimumVisibleFaces: faceCount, + maximumVisibleFaces: faceCount, + meanVisibleFaces: faceCount, + minimumVisibleBoxes: boxCount, + maximumVisibleBoxes: boxCount, + meanVisibleBoxes: boxCount, + transitionCount: 0, + maximumTransitionWritesPerFrame: 0, + initialVisibleBoxBitsBase64: initial.toString("base64"), + transitionOffsetsBase64: encodeLittleEndianUint16(transitionOffsets), + transitionBoxIndicesBase64: "", + policy: "complete-mobile-bank-whole-box-publication", + }), + }); +} + +function validatePreparedSideDepth(value) { + if (value?.schema !== "csscityflow-prepared-side-depth@1" || + value.defaultDepthScale !== 0.1 || + !Number.isFinite(value.maximumDepthScale) || + !Array.isArray(value.entries)) { + throw new Error("Cityflow prepared side-depth reference drifted"); + } + const byFaceIndex = new Map(); + for (const entry of value.entries) { + const expectedFace = CSSCITYFLOW_FACE_IDS[entry.faceIndex % CSSCITYFLOW_FACE_IDS.length]; + if (!Number.isSafeInteger(entry.faceIndex) || entry.faceIndex < 0 || entry.faceIndex >= 600 || + entry.boxIndex !== Math.floor(entry.faceIndex / CSSCITYFLOW_FACE_IDS.length) || + entry.face !== expectedFace || entry.face === "top" || + !Number.isFinite(entry.depthScale) || + entry.depthScale <= value.defaultDepthScale || + entry.depthScale > value.maximumDepthScale || + !Number.isFinite(entry.measuredRequiredScale) || + entry.measuredRequiredScale >= entry.depthScale || + byFaceIndex.has(entry.faceIndex)) { + throw new Error("Cityflow prepared side-depth entry drifted"); + } + byFaceIndex.set(entry.faceIndex, entry.depthScale); + } + if (Math.max(...byFaceIndex.values()) !== value.maximumDepthScale) { + throw new Error("Cityflow prepared side-depth maximum drifted"); + } + return byFaceIndex; +} + +function validatePreparedStaticVisibility(value) { + const hiddenFaceIndices = value?.hiddenFaceIndices; + const hiddenBoxIndices = value?.hiddenBoxIndices; + const sortingDependencyBoxIndices = value?.sortingDependencyBoxIndices; + const presentation = value?.presentation; + const initial = Buffer.from(presentation?.initialVisibleBoxBitsBase64 ?? "", "base64"); + const offsetBytes = Buffer.from(presentation?.transitionOffsetsBase64 ?? "", "base64"); + const indexBytes = Buffer.from(presentation?.transitionBoxIndicesBase64 ?? "", "base64"); + if (value?.schema !== "csscityflow-prepared-static-visibility@3" || + value.sourceRevision !== "906693799e4fb7581436590cf84ecb2d3c9186ba" || + value.seed !== 26081702 || value.frameCount !== 301 || value.boxCount !== 200 || + value.facesPerBox !== CSSCITYFLOW_FACE_IDS.length || value.faceCount !== 600 || + value.visibleFaceCount !== 585 || value.hiddenFaceCount !== 15 || + value.visibleBoxCount !== 195 || value.hiddenBoxCount !== 5 || + !Array.isArray(hiddenFaceIndices) || hiddenFaceIndices.length !== value.hiddenFaceCount || + !Array.isArray(hiddenBoxIndices) || hiddenBoxIndices.length !== value.hiddenBoxCount || + hiddenFaceIndices.some((faceIndex, index) => + !Number.isSafeInteger(faceIndex) || faceIndex < 0 || faceIndex >= value.faceCount || + (index > 0 && faceIndex <= hiddenFaceIndices[index - 1])) || + hiddenBoxIndices.some((boxIndex, index) => + !Number.isSafeInteger(boxIndex) || boxIndex < 0 || boxIndex >= value.boxCount || + (index > 0 && boxIndex <= hiddenBoxIndices[index - 1])) || + hiddenBoxIndices.some((boxIndex) => + !CSSCITYFLOW_FACE_IDS.every((_, faceIndex) => + hiddenFaceIndices.includes(boxIndex * CSSCITYFLOW_FACE_IDS.length + faceIndex))) || + !Array.isArray(sortingDependencyBoxIndices) || + sortingDependencyBoxIndices.length !== 8 || + sortingDependencyBoxIndices.some((boxIndex, index) => + boxIndex !== [155, 156, 165, 171, 172, 179, 185, 196][index]) || + value.coverage?.policy !== + "whole-box-visibility-union-plus-full-frame-css-3d-sorting-dependencies" || + !Array.isArray(value.coverage.projectionViewports) || + value.coverage.projectionViewports.length !== 3 || + presentation?.schema !== "csscityflow-prepared-presentation-box-visibility@1" || + presentation.encoding !== + "initial-box-bitset-plus-u16le-per-target-frame-toggle-offsets-and-box-indices" || + presentation.frameCount !== value.frameCount || + presentation.boxCount !== value.boxCount || presentation.faceCount !== value.faceCount || + presentation.transitionDilationFrames !== 12 || + presentation.policy !== + "viewport-independent-whole-box-only-three-projection-union-with-12-frame-dilation-plus-full-frame-sorting-dependencies" || + !Array.isArray(presentation.alwaysVisibleBoxIndices) || + presentation.alwaysVisibleBoxIndices.length !== 128 || + presentation.alwaysVisibleBoxIndices.some((boxIndex, index) => + !Number.isSafeInteger(boxIndex) || boxIndex < 0 || boxIndex >= value.boxCount || + (index > 0 && boxIndex <= presentation.alwaysVisibleBoxIndices[index - 1])) || + presentation.initialVisibleCount !== 510 || + presentation.initialVisibleBoxes !== 170 || + presentation.minimumVisibleFaces !== 498 || + presentation.maximumVisibleFaces !== 561 || + Math.abs(presentation.meanVisibleFaces - 522.1495016611295) > 1e-12 || + presentation.minimumVisibleBoxes !== 166 || + presentation.maximumVisibleBoxes !== 187 || + Math.abs(presentation.meanVisibleBoxes - 174.04983388704318) > 1e-12 || + presentation.transitionCount !== 240 || + presentation.maximumTransitionWritesPerFrame !== 6 || + initial.byteLength !== Math.ceil(value.boxCount / 8) || + offsetBytes.byteLength !== (value.frameCount + 1) * Uint16Array.BYTES_PER_ELEMENT || + indexBytes.byteLength !== presentation.transitionCount * Uint16Array.BYTES_PER_ELEMENT) { + throw new Error("Cityflow prepared static-visibility reference drifted"); + } + const offsets = decodeLittleEndianUint16(offsetBytes); + const indices = decodeLittleEndianUint16(indexBytes); + const visible = Uint8Array.from({ length: value.boxCount }, (_, boxIndex) => + initial[boxIndex >> 3] >> (boxIndex & 7) & 1); + const visibleBoxCounts = []; + const alwaysVisible = new Uint8Array(value.boxCount).fill(1); + const everVisible = new Uint8Array(value.boxCount); + for (let frameIndex = 0; frameIndex < value.frameCount; frameIndex += 1) { + if (frameIndex > 0) { + for (let cursor = offsets[frameIndex]; cursor < offsets[frameIndex + 1]; cursor += 1) { + visible[indices[cursor]] ^= 1; + } + } + for (let boxIndex = 0; boxIndex < value.boxCount; boxIndex += 1) { + alwaysVisible[boxIndex] &= visible[boxIndex]; + everVisible[boxIndex] |= visible[boxIndex]; + } + visibleBoxCounts.push(visible.reduce((sum, entry) => sum + entry, 0)); + } + const visibleFaceCounts = visibleBoxCounts.map((count) => count * CSSCITYFLOW_FACE_IDS.length); + if (offsets[0] !== 0 || offsets.at(-1) !== indices.length || + offsets.some((offset, index) => index > 0 && offset < offsets[index - 1]) || + indices.some((boxIndex) => boxIndex >= value.boxCount) || + visibleFaceCounts[0] !== presentation.initialVisibleCount || + visibleBoxCounts[0] !== presentation.initialVisibleBoxes || + Math.min(...visibleFaceCounts) !== presentation.minimumVisibleFaces || + Math.max(...visibleFaceCounts) !== presentation.maximumVisibleFaces || + Math.abs(visibleFaceCounts.reduce((sum, entry) => sum + entry, 0) / value.frameCount - + presentation.meanVisibleFaces) > 1e-12 || + Math.min(...visibleBoxCounts) !== presentation.minimumVisibleBoxes || + Math.max(...visibleBoxCounts) !== presentation.maximumVisibleBoxes || + Math.abs(visibleBoxCounts.reduce((sum, entry) => sum + entry, 0) / value.frameCount - + presentation.meanVisibleBoxes) > 1e-12 || + hiddenBoxIndices.some((boxIndex) => everVisible[boxIndex] !== 0) || + sortingDependencyBoxIndices.some((boxIndex) => alwaysVisible[boxIndex] === 0) || + presentation.alwaysVisibleBoxIndices.some((boxIndex) => + alwaysVisible[boxIndex] === 0) || + Array.from(alwaysVisible).reduce((sum, entry) => sum + entry, 0) !== + presentation.alwaysVisibleBoxIndices.length) { + throw new Error("Cityflow prepared presentation visibility schedule drifted"); + } + return Object.freeze({ + schema: value.schema, + faceCount: value.faceCount, + visibleFaceCount: value.visibleFaceCount, + hiddenFaceCount: value.hiddenFaceCount, + visibleBoxCount: value.visibleBoxCount, + hiddenBoxCount: value.hiddenBoxCount, + hiddenFaceIndices: Object.freeze([...hiddenFaceIndices]), + hiddenBoxIndices: Object.freeze([...hiddenBoxIndices]), + sortingDependencyBoxIndices: Object.freeze([...sortingDependencyBoxIndices]), + policy: "prepared-whole-box-visibility-no-face-culling", + presentation: Object.freeze({ + schema: presentation.schema, + encoding: presentation.encoding, + frameCount: presentation.frameCount, + boxCount: presentation.boxCount, + faceCount: presentation.faceCount, + transitionDilationFrames: presentation.transitionDilationFrames, + alwaysVisibleBoxIndices: Object.freeze([...presentation.alwaysVisibleBoxIndices]), + initialVisibleCount: presentation.initialVisibleCount, + initialVisibleBoxes: presentation.initialVisibleBoxes, + minimumVisibleFaces: presentation.minimumVisibleFaces, + maximumVisibleFaces: presentation.maximumVisibleFaces, + meanVisibleFaces: presentation.meanVisibleFaces, + minimumVisibleBoxes: presentation.minimumVisibleBoxes, + maximumVisibleBoxes: presentation.maximumVisibleBoxes, + meanVisibleBoxes: presentation.meanVisibleBoxes, + transitionCount: presentation.transitionCount, + maximumTransitionWritesPerFrame: presentation.maximumTransitionWritesPerFrame, + initialVisibleBoxBitsBase64: presentation.initialVisibleBoxBitsBase64, + transitionOffsetsBase64: presentation.transitionOffsetsBase64, + transitionBoxIndicesBase64: presentation.transitionBoxIndicesBase64, + policy: presentation.policy, + }), + }); +} + +export function buildCityflowSourceFrames(state) { + return Object.freeze(Array.from( + { length: CSSCITYFLOW_PREPARED_FRAME_COUNT }, + (_, frameIndex) => cityflowFrameAt(state, frameIndex), + )); +} + +export function buildCityflowPreparedHeightRows( + state, + sourceFrames = buildCityflowSourceFrames(state), +) { + const frameCount = CSSCITYFLOW_PRESENTATION_FRAME_COUNT; + const sourceFrameCount = sourceFrames.length; + const reconstructedHeights = Array.from({ length: frameCount }, (_, frameIndex) => { + const sourcePosition = frameIndex * sourceFrameCount / frameCount; + const sourceFrameIndex = Math.floor(sourcePosition); + const fraction = sourcePosition - sourceFrameIndex; + return Float64Array.from({ length: state.boxes.length }, (_, boxIndex) => + periodicUniformCubicBSplineHeight( + sourceFrames, + sourceFrameIndex, + fraction, + boxIndex, + )); + }); + const foldedHeights = foldBriefDirectionRuns( + periodicFiveTapBinomialFilter( + foldBriefDirectionRuns( + periodicThreeTapBinomialFilter( + foldBriefDirectionRuns( + periodicFiveTapBinomialFilter(reconstructedHeights), + MAXIMUM_BRIEF_DIRECTION_RUN_FRAMES, + ), + ), + MAXIMUM_BRIEF_DIRECTION_RUN_FRAMES, + ), + ), + MAXIMUM_BRIEF_DIRECTION_RUN_FRAMES, + ); + return Object.freeze(adaptiveSmoothSineEaseDirectionRuns(foldedHeights)); +} + +function periodicUniformCubicBSplineHeight(sourceFrames, sourceFrameIndex, fraction, boxIndex) { + const count = sourceFrames.length; + const previous = sourceFrames[(sourceFrameIndex - 1 + count) % count].boxes[boxIndex].height; + const current = sourceFrames[sourceFrameIndex % count].boxes[boxIndex].height; + const next = sourceFrames[(sourceFrameIndex + 1) % count].boxes[boxIndex].height; + const following = sourceFrames[(sourceFrameIndex + 2) % count].boxes[boxIndex].height; + const inverse = 1 - fraction; + const squared = fraction * fraction; + const cubed = squared * fraction; + const previousWeight = inverse * inverse * inverse / 6; + const currentWeight = (3 * cubed - 6 * squared + 4) / 6; + const nextWeight = (-3 * cubed + 3 * squared + 3 * fraction + 1) / 6; + const followingWeight = cubed / 6; + return previousWeight * previous + currentWeight * current + + nextWeight * next + followingWeight * following; +} + +function periodicFiveTapBinomialFilter(rows) { + const weights = [1, 4, 6, 4, 1]; + return rows.map((_, frameIndex) => Float64Array.from( + { length: rows[frameIndex].length }, + (_, boxIndex) => weights.reduce((sum, weight, weightIndex) => { + const offset = weightIndex - 2; + const sourceIndex = (frameIndex + offset + rows.length) % rows.length; + return sum + weight * rows[sourceIndex][boxIndex]; + }, 0) / 16, + )); +} + +function periodicThreeTapBinomialFilter(rows) { + const weights = [1, 2, 1]; + return rows.map((_, frameIndex) => Float64Array.from( + { length: rows[frameIndex].length }, + (_, boxIndex) => weights.reduce((sum, weight, weightIndex) => { + const offset = weightIndex - 1; + const sourceIndex = (frameIndex + offset + rows.length) % rows.length; + return sum + weight * rows[sourceIndex][boxIndex]; + }, 0) / 4, + )); +} + +function foldBriefDirectionRuns(rows, maximumRunLength) { + const frameCount = rows.length; + const boxCount = rows[0].length; + const output = rows.map((row) => Float64Array.from(row)); + for (let boxIndex = 0; boxIndex < boxCount; boxIndex += 1) { + const velocities = Array.from({ length: frameCount }, (_, frameIndex) => + rows[frameIndex][boxIndex] - rows[(frameIndex - 1 + frameCount) % frameCount][boxIndex]); + for (let iteration = 0; iteration < frameCount; iteration += 1) { + const movingFrameIndices = velocities + .map((velocity, frameIndex) => Math.abs(velocity) > 1e-7 ? frameIndex : -1) + .filter((frameIndex) => frameIndex >= 0); + if (movingFrameIndices.length === 0) break; + const directions = movingFrameIndices.map((frameIndex) => + Math.sign(velocities[frameIndex])); + const shortRun = circularDirectionRuns(directions) + .find((run) => run.length <= maximumRunLength); + if (!shortRun) break; + for (let index = 0; index < shortRun.length; index += 1) { + const directionIndex = (shortRun.start + index) % directions.length; + const frameIndex = movingFrameIndices[directionIndex]; + velocities[frameIndex] *= -1; + } + } + const movingFrameIndices = velocities + .map((velocity, frameIndex) => Math.abs(velocity) > 1e-7 ? frameIndex : -1) + .filter((frameIndex) => frameIndex >= 0); + if (movingFrameIndices.length === 0) continue; + const directions = movingFrameIndices.map((frameIndex) => + Math.sign(velocities[frameIndex])); + if (circularDirectionRuns(directions).some((run) => run.length <= maximumRunLength)) { + throw new Error("Cityflow brief direction-run folding did not converge"); + } + let positiveDistance = 0; + let negativeDistance = 0; + for (let index = 0; index < movingFrameIndices.length; index += 1) { + const distance = Math.abs(velocities[movingFrameIndices[index]]); + if (directions[index] > 0) positiveDistance += distance; + else negativeDistance += distance; + } + if (positiveDistance === 0 || negativeDistance === 0) { + throw new Error("Cityflow brief direction-run folding collapsed a periodic trajectory"); + } + const balancedDistance = (positiveDistance + negativeDistance) / 2; + const positiveScale = balancedDistance / positiveDistance; + const negativeScale = balancedDistance / negativeDistance; + for (let index = 0; index < movingFrameIndices.length; index += 1) { + const frameIndex = movingFrameIndices[index]; + const direction = directions[index]; + velocities[frameIndex] = direction * Math.abs(velocities[frameIndex]) * + (direction > 0 ? positiveScale : negativeScale); + } + const reconstructed = new Float64Array(frameCount); + reconstructed[0] = rows[0][boxIndex]; + for (let frameIndex = 1; frameIndex < frameCount; frameIndex += 1) { + reconstructed[frameIndex] = reconstructed[frameIndex - 1] + velocities[frameIndex]; + } + const sourceMean = rows.reduce((sum, row) => sum + row[boxIndex], 0) / frameCount; + const reconstructedMean = reconstructed.reduce((sum, value) => sum + value, 0) / frameCount; + const centeringOffset = sourceMean - reconstructedMean; + const sourceMinimum = Math.min(...rows.map((row) => row[boxIndex])); + const sourceMaximum = Math.max(...rows.map((row) => row[boxIndex])); + const centeredMinimum = Math.min(...reconstructed) + centeringOffset; + const centeredMaximum = Math.max(...reconstructed) + centeringOffset; + const lowerScale = centeredMinimum < sourceMinimum + ? (sourceMean - sourceMinimum) / (sourceMean - centeredMinimum) + : 1; + const upperScale = centeredMaximum > sourceMaximum + ? (sourceMaximum - sourceMean) / (centeredMaximum - sourceMean) + : 1; + const rangeScale = Math.min(1, lowerScale, upperScale); + for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) { + const centered = reconstructed[frameIndex] + centeringOffset; + output[frameIndex][boxIndex] = sourceMean + (centered - sourceMean) * rangeScale; + } + } + return output; +} + +function circularDirectionRuns(directions) { + const runs = []; + let direction = directions[0]; + let start = 0; + for (let index = 1; index < directions.length; index += 1) { + if (directions[index] === direction) continue; + runs.push({ direction, start, length: index - start }); + direction = directions[index]; + start = index; + } + runs.push({ direction, start, length: directions.length - start }); + if (runs.length > 1 && runs[0].direction === runs.at(-1).direction) { + runs[0] = { + direction: runs[0].direction, + start: runs.at(-1).start, + length: runs[0].length + runs.at(-1).length, + }; + runs.pop(); + } + return runs; +} + +function adaptiveSmoothSineEaseDirectionRuns(rows) { + const frameCount = rows.length; + const boxCount = rows[0].length; + const output = Array.from({ length: frameCount }, () => new Float64Array(boxCount)); + for (let boxIndex = 0; boxIndex < boxCount; boxIndex += 1) { + const velocities = Array.from({ length: frameCount }, (_, frameIndex) => + rows[frameIndex][boxIndex] - + rows[(frameIndex - 1 + frameCount) % frameCount][boxIndex]); + const movingFrameIndices = velocities + .map((velocity, frameIndex) => Math.abs(velocity) > 1e-7 ? frameIndex : -1) + .filter((frameIndex) => frameIndex >= 0); + if (movingFrameIndices.length === 0) { + for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) { + output[frameIndex][boxIndex] = rows[frameIndex][boxIndex]; + } + continue; + } + const runs = circularDirectionRuns(movingFrameIndices.map((frameIndex) => + Math.sign(velocities[frameIndex]))); + const extrema = runs.map((run, runIndex) => { + const lastMovingFrameIndex = movingFrameIndices[ + (run.start + run.length - 1) % movingFrameIndices.length + ]; + const nextRun = runs[(runIndex + 1) % runs.length]; + const nextMovingFrameIndex = movingFrameIndices[nextRun.start]; + const stationarySpan = + (nextMovingFrameIndex - lastMovingFrameIndex + frameCount) % frameCount; + return { + frameIndex: (lastMovingFrameIndex + Math.floor(stationarySpan / 2)) % frameCount, + height: rows[lastMovingFrameIndex][boxIndex], + }; + }).sort((left, right) => left.frameIndex - right.frameIndex); + for (let extremaIndex = 0; extremaIndex < extrema.length; extremaIndex += 1) { + const start = extrema[extremaIndex]; + const end = extrema[(extremaIndex + 1) % extrema.length]; + const duration = (end.frameIndex - start.frameIndex + frameCount) % frameCount; + const ramp = Math.max(0, Math.min(1, + (duration - PRESENTATION_EASING_RAMP_START_FRAMES) / + (PRESENTATION_EASING_RAMP_FULL_FRAMES - PRESENTATION_EASING_RAMP_START_FRAMES))); + const centerReduction = PRESENTATION_EASING_MAXIMUM_CENTER_REDUCTION * ramp; + for (let offset = 0; offset < duration; offset += 1) { + const fraction = offset / duration; + const easedFraction = smoothSineEasedFraction(fraction, centerReduction); + const frameIndex = (start.frameIndex + offset) % frameCount; + output[frameIndex][boxIndex] = + start.height + (end.height - start.height) * easedFraction; + } + } + } + return output; +} + +function smoothSineEasedFraction(fraction, centerReduction) { + const cosine = Math.cos(Math.PI * fraction); + const sineIntegral = 1 - cosine; + const sineCubedIntegral = 2 / 3 - cosine + cosine ** 3 / 3; + return (sineIntegral - centerReduction * sineCubedIntegral) / + (2 - 4 * centerReduction / 3); +} + +function buildPreparedVisibilityContract(state, frameCount) { + const faceCount = state.boxes.length * CSSCITYFLOW_FACE_IDS.length; + const initial = Buffer.from(preparedVisibility.initialVisibleBitsBase64, "base64"); + const offsetBytes = Buffer.from(preparedVisibility.transitionOffsetsBase64, "base64"); + const faceBytes = Buffer.from(preparedVisibility.transitionFaceIndicesBase64, "base64"); + if (preparedVisibility.schema !== "csscityflow-prepared-visibility-source@1" || + preparedVisibility.sourceRevision !== state.source.commit || + preparedVisibility.seed !== state.seed || preparedVisibility.frameCount !== frameCount || + preparedVisibility.faceCount !== faceCount || + preparedVisibility.transitionDilationFrames !== 1 || + initial.byteLength !== Math.ceil(faceCount / 8) || + offsetBytes.byteLength !== (frameCount + 1) * Uint16Array.BYTES_PER_ELEMENT || + faceBytes.byteLength !== preparedVisibility.transitionCount * Uint16Array.BYTES_PER_ELEMENT) { + throw new Error("Cityflow prepared visibility source drifted"); + } + const offsets = decodeLittleEndianUint16(offsetBytes); + const indices = decodeLittleEndianUint16(faceBytes); + if (offsets[0] !== 0 || offsets.at(-1) !== indices.length || + offsets.some((offset, index) => index > 0 && offset < offsets[index - 1]) || + indices.some((index) => index >= faceCount)) { + throw new Error("Cityflow prepared visibility schedule drifted"); + } + const visible = Uint8Array.from({ length: faceCount }, (_, faceIndex) => + initial[faceIndex >> 3] >> (faceIndex & 7) & 1); + let visibleCount = visible.reduce((sum, value) => sum + value, 0); + if (visibleCount !== preparedVisibility.initialVisibleCount) { + throw new Error("Cityflow prepared initial visibility drifted"); + } + const sourceRows = [visible.slice()]; + let minimumVisibleFaces = visibleCount; + let maximumVisibleFaces = visibleCount; + let visibleFaceSum = visibleCount; + for (let frameIndex = 1; frameIndex < frameCount; frameIndex += 1) { + applyVisibilityToggles(frameIndex); + sourceRows.push(visible.slice()); + minimumVisibleFaces = Math.min(minimumVisibleFaces, visibleCount); + maximumVisibleFaces = Math.max(maximumVisibleFaces, visibleCount); + visibleFaceSum += visibleCount; + } + applyVisibilityToggles(0); + const initialRoundTrip = visible.every((value, index) => + value === (initial[index >> 3] >> (index & 7) & 1)); + if (!initialRoundTrip || minimumVisibleFaces !== preparedVisibility.minimumVisibleFaces || + maximumVisibleFaces !== preparedVisibility.maximumVisibleFaces || + Math.abs(visibleFaceSum / frameCount - preparedVisibility.meanVisibleFaces) > 1e-12) { + throw new Error("Cityflow prepared visibility census drifted"); + } + const easedInitial = Buffer.from( + preparedEasedVisibility.initialVisibleBitsBase64 ?? "", + "base64", + ); + const easedOffsetBytes = Buffer.from( + preparedEasedVisibility.transitionOffsetsBase64 ?? "", + "base64", + ); + const easedFaceBytes = Buffer.from( + preparedEasedVisibility.transitionFaceIndicesBase64 ?? "", + "base64", + ); + if (preparedEasedVisibility.schema !== "csscityflow-browser-visible-face-id-source@1" || + preparedEasedVisibility.encoding !== + "initial-bitset-plus-u16le-per-target-frame-toggle-offsets-and-face-indices" || + preparedEasedVisibility.frameCount !== frameCount || + preparedEasedVisibility.faceCount !== faceCount || + preparedEasedVisibility.viewport?.width !== 1280 || + preparedEasedVisibility.viewport?.height !== 720 || + easedInitial.byteLength !== Math.ceil(faceCount / 8) || + easedOffsetBytes.byteLength !== (frameCount + 1) * Uint16Array.BYTES_PER_ELEMENT || + easedFaceBytes.byteLength !== + preparedEasedVisibility.transitionCount * Uint16Array.BYTES_PER_ELEMENT) { + throw new Error("Cityflow prepared eased visibility source drifted"); + } + const easedOffsets = decodeLittleEndianUint16(easedOffsetBytes); + const easedIndices = decodeLittleEndianUint16(easedFaceBytes); + if (easedOffsets[0] !== 0 || easedOffsets.at(-1) !== easedIndices.length || + easedOffsets.some((offset, index) => index > 0 && offset < easedOffsets[index - 1]) || + easedIndices.some((faceIndex) => faceIndex >= faceCount)) { + throw new Error("Cityflow prepared eased visibility schedule drifted"); + } + const easedVisible = Uint8Array.from({ length: faceCount }, (_, faceIndex) => + easedInitial[faceIndex >> 3] >> (faceIndex & 7) & 1); + const easedRows = [easedVisible.slice()]; + for (let frameIndex = 1; frameIndex < frameCount; frameIndex += 1) { + for (let cursor = easedOffsets[frameIndex]; cursor < easedOffsets[frameIndex + 1]; cursor += 1) { + easedVisible[easedIndices[cursor]] ^= 1; + } + easedRows.push(easedVisible.slice()); + } + const preparedRows = sourceRows.map((_, frameIndex) => Uint8Array.from( + { length: faceCount }, + (_, faceIndex) => sourceRows[(frameIndex - 2 + frameCount) % frameCount][faceIndex] | + sourceRows[(frameIndex - 1 + frameCount) % frameCount][faceIndex] | + sourceRows[frameIndex][faceIndex] | + sourceRows[(frameIndex + 1) % frameCount][faceIndex] | + sourceRows[(frameIndex + 2) % frameCount][faceIndex] | + easedRows[(frameIndex - 2 + frameCount) % frameCount][faceIndex] | + easedRows[(frameIndex - 1 + frameCount) % frameCount][faceIndex] | + easedRows[frameIndex][faceIndex] | + easedRows[(frameIndex + 1) % frameCount][faceIndex] | + easedRows[(frameIndex + 2) % frameCount][faceIndex], + )); + const conservativeExtendedDilationFrames = 12; + const conservativeExtendedDilationFaceIndices = Object.freeze([ + 156 * CSSCITYFLOW_FACE_IDS.length + 1, + 156 * CSSCITYFLOW_FACE_IDS.length + 2, + 160 * CSSCITYFLOW_FACE_IDS.length + 2, + 181 * CSSCITYFLOW_FACE_IDS.length, + 184 * CSSCITYFLOW_FACE_IDS.length + 1, + 188 * CSSCITYFLOW_FACE_IDS.length + 1, + 188 * CSSCITYFLOW_FACE_IDS.length + 2, + 194 * CSSCITYFLOW_FACE_IDS.length, + ]); + for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) { + for (const faceIndex of conservativeExtendedDilationFaceIndices) { + for (let offset = -conservativeExtendedDilationFrames; + offset <= conservativeExtendedDilationFrames; offset += 1) { + if (sourceRows[(frameIndex + offset + frameCount) % frameCount][faceIndex] !== 0) { + preparedRows[frameIndex][faceIndex] = 1; + break; + } + } + } + } + const conservativeAlwaysVisibleFaceIndices = Object.freeze([ + 114 * CSSCITYFLOW_FACE_IDS.length, + 123 * CSSCITYFLOW_FACE_IDS.length, + 150 * CSSCITYFLOW_FACE_IDS.length + 2, + 151 * CSSCITYFLOW_FACE_IDS.length + 1, + 168 * CSSCITYFLOW_FACE_IDS.length, + 184 * CSSCITYFLOW_FACE_IDS.length, + ]); + for (const row of preparedRows) { + for (const faceIndex of conservativeAlwaysVisibleFaceIndices) row[faceIndex] = 1; + } + const preparedInitial = Buffer.alloc(Math.ceil(faceCount / 8)); + for (let faceIndex = 0; faceIndex < faceCount; faceIndex += 1) { + if (preparedRows[0][faceIndex] !== 0) { + preparedInitial[faceIndex >> 3] |= 1 << (faceIndex & 7); + } + } + const preparedOffsets = [0]; + const preparedIndices = []; + for (let targetFrameIndex = 0; targetFrameIndex < frameCount; targetFrameIndex += 1) { + const previousFrameIndex = (targetFrameIndex - 1 + frameCount) % frameCount; + for (let faceIndex = 0; faceIndex < faceCount; faceIndex += 1) { + if (preparedRows[targetFrameIndex][faceIndex] !== preparedRows[previousFrameIndex][faceIndex]) { + preparedIndices.push(faceIndex); + } + } + preparedOffsets.push(preparedIndices.length); + } + const preparedOffsetBytes = Buffer.alloc( + preparedOffsets.length * Uint16Array.BYTES_PER_ELEMENT, + ); + const preparedFaceBytes = Buffer.alloc( + preparedIndices.length * Uint16Array.BYTES_PER_ELEMENT, + ); + preparedOffsets.forEach((value, index) => preparedOffsetBytes.writeUInt16LE(value, index * 2)); + preparedIndices.forEach((value, index) => preparedFaceBytes.writeUInt16LE(value, index * 2)); + const preparedVisibleCounts = preparedRows.map((row) => + row.reduce((sum, value) => sum + value, 0)); + const preparedVisibleBoxCounts = preparedRows.map((row) => + state.boxes.reduce((sum, _, boxIndex) => sum + Number( + row[boxIndex * CSSCITYFLOW_FACE_IDS.length] !== 0 || + row[boxIndex * CSSCITYFLOW_FACE_IDS.length + 1] !== 0 || + row[boxIndex * CSSCITYFLOW_FACE_IDS.length + 2] !== 0, + ), 0)); + const preparedVisibleFaceSum = preparedVisibleCounts.reduce((sum, value) => sum + value, 0); + const preparedVisibleBoxSum = preparedVisibleBoxCounts.reduce((sum, value) => sum + value, 0); + const wide = buildPreparedWideVisibilityContract(state, frameCount, faceCount); + return Object.freeze({ + schema: "csscityflow-prepared-visibility-culling@2", + encoding: "initial-bitset-plus-u16le-per-target-frame-toggle-offsets-and-face-indices", + faceCount, frameCount, - tickIntervalUs: Object.freeze([CSSCITYFLOW_FRAME_MILLISECONDS * 1_000, 1]), - boxCount: state.boxes.length, - transforms: Object.freeze(transforms), - transformIndicesBase64: Buffer.from(transformIndices.buffer).toString("base64"), - colorIndicesBase64: Buffer.from(colorIndices.buffer).toString("base64"), + transitionDilationFrames: preparedVisibility.transitionDilationFrames + 2, + initialVisibleCount: preparedVisibleCounts[0], + minimumVisibleFaces: Math.min(...preparedVisibleCounts), + maximumVisibleFaces: Math.max(...preparedVisibleCounts), + meanVisibleFaces: preparedVisibleFaceSum / frameCount, + minimumVisibleBoxes: Math.min(...preparedVisibleBoxCounts), + maximumVisibleBoxes: Math.max(...preparedVisibleBoxCounts), + meanVisibleBoxes: preparedVisibleBoxSum / frameCount, + transitionCount: preparedIndices.length, + conservativeAlwaysVisibleFaceIndices, + conservativeExtendedDilationFrames, + conservativeExtendedDilationFaceIndices, + initialVisibleBitsBase64: preparedInitial.toString("base64"), + transitionOffsetsBase64: preparedOffsetBytes.toString("base64"), + transitionFaceIndicesBase64: preparedFaceBytes.toString("base64"), + wide, + viewportUnion: Object.freeze(preparedVisibility.viewportUnion.map((viewport) => + Object.freeze({ width: viewport.width, height: viewport.height }))), + provenance: Object.freeze({ + ...preparedVisibility.provenance, + transitionSafety: + "source-one-frame-plus-two-prepared-trajectory-frames-before-and-after", + baseTransitionDilationFrames: preparedVisibility.transitionDilationFrames, + additionalPreparedTrajectoryDilationFrames: 2, + effectiveTransitionDilationFrames: preparedVisibility.transitionDilationFrames + 2, + validatedPresentation: + "prepared-folded-adaptive-smooth-sine-eased-extrema-plus-browser-face-id-union-60hz", + }), + }); + + function applyVisibilityToggles(frameIndex) { + const seen = new Set(); + for (let cursor = offsets[frameIndex]; cursor < offsets[frameIndex + 1]; cursor += 1) { + const faceIndex = indices[cursor]; + if (seen.has(faceIndex)) throw new Error("Cityflow prepared visibility row toggles a face twice"); + seen.add(faceIndex); + visible[faceIndex] ^= 1; + visibleCount += visible[faceIndex] === 1 ? 1 : -1; + } + } +} + +function buildPreparedWideVisibilityContract(state, frameCount, faceCount) { + const initial = Buffer.from(preparedWideVisibility.initialVisibleBitsBase64, "base64"); + const offsets = Buffer.from(preparedWideVisibility.transitionOffsetsBase64, "base64"); + const indices = Buffer.from(preparedWideVisibility.transitionFaceIndicesBase64, "base64"); + const sourceInitial = Buffer.from(preparedWideVisibility.source?.initialVisibleBitsBase64 ?? "", "base64"); + const sourceOffsets = Buffer.from(preparedWideVisibility.source?.transitionOffsetsBase64 ?? "", "base64"); + const sourceIndices = Buffer.from(preparedWideVisibility.source?.transitionFaceIndicesBase64 ?? "", "base64"); + if (preparedWideVisibility.schema !== "csscityflow-prepared-wide-visibility-source@1" || + preparedWideVisibility.sourceRevision !== state.source.commit || + preparedWideVisibility.seed !== state.seed || + preparedWideVisibility.frameCount !== frameCount || + preparedWideVisibility.sourceFrameCount !== CSSCITYFLOW_PREPARED_FRAME_COUNT || + preparedWideVisibility.faceCount !== faceCount || + preparedWideVisibility.viewport?.width !== 2560 || + preparedWideVisibility.viewport?.height !== 1224 || + preparedWideVisibility.selection !== + "stage-width-greater-than-two-times-stage-height" || + preparedWideVisibility.transitionDilationFrames !== 0 || + initial.byteLength !== Math.ceil(faceCount / 8) || + offsets.byteLength !== (frameCount + 1) * Uint16Array.BYTES_PER_ELEMENT || + indices.byteLength !== preparedWideVisibility.transitionCount * Uint16Array.BYTES_PER_ELEMENT || + preparedWideVisibility.source?.schema !== "csscityflow-prepared-wide-source-visibility@1" || + preparedWideVisibility.source.frameCount !== CSSCITYFLOW_PREPARED_FRAME_COUNT || + preparedWideVisibility.source.transitionDilationFrames !== 0 || + sourceInitial.byteLength !== Math.ceil(faceCount / 8) || + sourceOffsets.byteLength !== (CSSCITYFLOW_PREPARED_FRAME_COUNT + 1) * Uint16Array.BYTES_PER_ELEMENT || + sourceIndices.byteLength !== + preparedWideVisibility.source.transitionCount * Uint16Array.BYTES_PER_ELEMENT) { + throw new Error("Cityflow prepared wide visibility source drifted"); + } + return Object.freeze({ + schema: "csscityflow-prepared-visibility-variant@1", + encoding: "initial-bitset-plus-u16le-per-target-frame-toggle-offsets-and-face-indices", + selection: preparedWideVisibility.selection, + sourceViewport: Object.freeze({ ...preparedWideVisibility.viewport }), + faceCount, + frameCount, + transitionDilationFrames: preparedWideVisibility.transitionDilationFrames, + initialVisibleCount: preparedWideVisibility.initialVisibleCount, + minimumVisibleFaces: preparedWideVisibility.minimumVisibleFaces, + maximumVisibleFaces: preparedWideVisibility.maximumVisibleFaces, + meanVisibleFaces: preparedWideVisibility.meanVisibleFaces, + minimumVisibleBoxes: preparedWideVisibility.minimumVisibleBoxes, + maximumVisibleBoxes: preparedWideVisibility.maximumVisibleBoxes, + meanVisibleBoxes: preparedWideVisibility.meanVisibleBoxes, + transitionCount: preparedWideVisibility.transitionCount, + initialVisibleBitsBase64: preparedWideVisibility.initialVisibleBitsBase64, + transitionOffsetsBase64: preparedWideVisibility.transitionOffsetsBase64, + transitionFaceIndicesBase64: preparedWideVisibility.transitionFaceIndicesBase64, + source: Object.freeze({ + schema: preparedWideVisibility.source.schema, + encoding: "initial-bitset-plus-u16le-per-target-frame-toggle-offsets-and-face-indices", + faceCount, + frameCount: preparedWideVisibility.source.frameCount, + transitionDilationFrames: preparedWideVisibility.source.transitionDilationFrames, + initialVisibleCount: preparedWideVisibility.source.initialVisibleCount, + minimumVisibleFaces: preparedWideVisibility.source.minimumVisibleFaces, + maximumVisibleFaces: preparedWideVisibility.source.maximumVisibleFaces, + meanVisibleFaces: preparedWideVisibility.source.meanVisibleFaces, + minimumVisibleBoxes: preparedWideVisibility.source.minimumVisibleBoxes, + maximumVisibleBoxes: preparedWideVisibility.source.maximumVisibleBoxes, + meanVisibleBoxes: preparedWideVisibility.source.meanVisibleBoxes, + transitionCount: preparedWideVisibility.source.transitionCount, + initialVisibleBitsBase64: preparedWideVisibility.source.initialVisibleBitsBase64, + transitionOffsetsBase64: preparedWideVisibility.source.transitionOffsetsBase64, + transitionFaceIndicesBase64: preparedWideVisibility.source.transitionFaceIndicesBase64, + }), + provenance: Object.freeze({ ...preparedWideVisibility.provenance }), }); } +function decodeLittleEndianUint16(bytes) { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + return Uint16Array.from({ length: bytes.byteLength / 2 }, (_, index) => + view.getUint16(index * 2, true)); +} + +function encodeLittleEndianUint16(values) { + const bytes = Buffer.alloc(values.length * Uint16Array.BYTES_PER_ELEMENT); + values.forEach((value, index) => bytes.writeUInt16LE(value, index * 2)); + return bytes.toString("base64"); +} + +function encodeLittleEndianUint32(values) { + const bytes = Buffer.alloc(values.length * Uint32Array.BYTES_PER_ELEMENT); + values.forEach((value, index) => bytes.writeUInt32LE(value, index * 4)); + return bytes.toString("base64"); +} + function boxPlaybackMatrix(box, top, bottom) { return `matrix3d(${boxPlaybackMatrixValues(box, top, bottom).map(formatNumber).join(",")})`; } @@ -275,6 +1335,30 @@ function paletteColor(color) { return `#${color.map((channel) => Math.round(channel * 255 / 65536).toString(16).padStart(2, "0")).join("")}`; } +function litPaletteColor(color, lightFactor) { + return `#${color.map((channel) => { + const base = Math.round(channel * 255 / 65536); + return Math.min(255, Math.round(base * lightFactor)).toString(16).padStart(2, "0"); + }).join("")}`; +} + +function interpolatedLitPaletteColor(palette, position, lightFactor) { + const wrapped = (position % palette.length + palette.length) % palette.length; + const index = Math.floor(wrapped); + const fraction = wrapped - index; + const current = palette[index]; + const next = palette[(index + 1) % palette.length]; + return `#${current.map((channel, channelIndex) => { + const currentBase = Math.round(channel * 255 / 65536); + const nextBase = Math.round(next[channelIndex] * 255 / 65536); + const currentLit = Math.min(255, Math.round(currentBase * lightFactor)); + const nextLit = Math.min(255, Math.round(nextBase * lightFactor)); + return Math.round(currentLit + (nextLit - currentLit) * fraction) + .toString(16) + .padStart(2, "0"); + }).join("")}`; +} + function formatNumber(value) { return Number(value.toFixed(9)).toString(); } diff --git a/src/adapters/cityflow/src/prepare/csscityflow/sourceAuthority.mjs b/src/adapters/cityflow/src/prepare/csscityflow/sourceAuthority.mjs new file mode 100644 index 0000000..04eb8c2 --- /dev/null +++ b/src/adapters/cityflow/src/prepare/csscityflow/sourceAuthority.mjs @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: HPND +import { createHash } from "node:crypto"; +import { execFile } from "node:child_process"; +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { promisify } from "node:util"; +import sourceLock from "../../../notes/references/source-lock.json" with { type: "json" }; + +const execFileAsync = promisify(execFile); +const repositoryRoot = resolve(import.meta.dirname, "../../../../../.."); + +export async function ensureCityflowSourceTree({ + configuredRoot = process.env.CSSCITYFLOW_SOURCE_ROOT, + fetchImpl = fetch, +} = {}) { + const managed = !configuredRoot; + const sourceRoot = resolve(configuredRoot ?? resolve( + repositoryRoot, + `.local/upstreams/xscreensaver-cityflow-${sourceLock.revision.slice(0, 7)}`, + )); + if (configuredRoot) await assertConfiguredRevision(sourceRoot); + const files = []; + for (const source of sourceLock.sources) { + const path = resolve(sourceRoot, source.path); + let bytes = await readFile(path).catch((error) => { + if (error?.code === "ENOENT") return null; + throw error; + }); + if (bytes === null) { + if (!managed) throw new Error(`Missing pinned Cityflow source input: ${source.path}`); + bytes = await downloadPinnedSource(source, fetchImpl); + await writeAtomically(path, bytes); + } + const actualSha256 = sha256(bytes); + if (actualSha256 !== source.sha256) { + throw new Error(`Cityflow source bytes drifted: ${source.path}`); + } + files.push(Object.freeze({ path: source.path, sha256: actualSha256 })); + } + return Object.freeze({ + sourceRoot, + repository: sourceLock.repository, + revision: sourceLock.revision, + identityKind: configuredRoot ? "git-checkout-and-bytes" : "pinned-byte-tree", + files: Object.freeze(files), + }); +} + +export function createPinnedCityflowSourceUrl(source) { + const repository = new URL(sourceLock.repository); + const segments = repository.pathname.replace(/^\/+|\/+$/gu, "").split("/"); + if (repository.protocol !== "https:" || repository.hostname !== "github.com" || + segments.length !== 2 || !/^[a-f0-9]{40}$/u.test(sourceLock.revision) || + !/^[A-Za-z0-9._/-]+$/u.test(source.path) || source.path.startsWith("/") || + source.path.endsWith("/") || source.path.includes("..")) { + throw new Error("Cityflow pinned source URL contract drifted"); + } + return `https://raw.githubusercontent.com/${segments[0]}/${segments[1]}/` + + `${sourceLock.revision}/${source.path}`; +} + +async function assertConfiguredRevision(sourceRoot) { + let stdout; + try { + ({ stdout } = await execFileAsync("git", ["-C", sourceRoot, "rev-parse", "HEAD"])); + } catch (error) { + throw new Error(`CSSCITYFLOW_SOURCE_ROOT is not the pinned XScreenSaver checkout: ${error.message}`); + } + if (stdout.trim() !== sourceLock.revision) { + throw new Error(`Cityflow source commit drifted: ${stdout.trim()}`); + } +} + +async function downloadPinnedSource(source, fetchImpl) { + const url = createPinnedCityflowSourceUrl(source); + const response = await fetchImpl(url, { + headers: { Accept: "text/plain, application/octet-stream;q=0.9" }, + redirect: "follow", + }); + if (!response.ok) throw new Error(`Pinned Cityflow source download failed: ${response.status} ${url}`); + const bytes = Buffer.from(await response.arrayBuffer()); + if (sha256(bytes) !== source.sha256) { + throw new Error(`Pinned Cityflow source download drifted: ${source.path}`); + } + return bytes; +} + +async function writeAtomically(path, bytes) { + await mkdir(dirname(path), { recursive: true }); + const temporary = `${path}.${process.pid}.tmp`; + await rm(temporary, { force: true }); + try { + await writeFile(temporary, bytes, { flag: "wx" }); + await rename(temporary, path); + } finally { + await rm(temporary, { force: true }); + } +} + +function sha256(bytes) { + return createHash("sha256").update(bytes).digest("hex"); +} diff --git a/src/adapters/cityflow/src/prepare/csscityflow/sourceModel.mjs b/src/adapters/cityflow/src/prepare/csscityflow/sourceModel.mjs index 168acf6..302673d 100644 --- a/src/adapters/cityflow/src/prepare/csscityflow/sourceModel.mjs +++ b/src/adapters/cityflow/src/prepare/csscityflow/sourceModel.mjs @@ -1,3 +1,4 @@ +// SPDX-License-Identifier: HPND const RANDOM_VECTOR = Object.freeze([ 0o35340171546, 0o10401501101, 0o22364657325, 0o24130436022, 0o02167303062, 0o37570375137, 0o37210607110, 0o16272055420, 0o23011770546, 0o17143426366, @@ -29,11 +30,15 @@ const SOURCE_COMMON = Object.freeze({ export const CITYFLOW_BANKS = Object.freeze({ desktop: createBank("desktop", "cityflow", "Cityflow", 200), + mobile: createBank("mobile", "cityflow-mobile", "Cityflow Mobile", 100), }); export const CITYFLOW_SOURCE = CITYFLOW_BANKS.desktop; export const CSSCITYFLOW_SEED = 26081702; export const CSSCITYFLOW_FRAME_MILLISECONDS = SOURCE_COMMON.delayMicroseconds / 1_000; +export const CSSCITYFLOW_PREPARED_FRAME_COUNT = 251; +export const CSSCITYFLOW_PRESENTATION_FRAME_COUNT = 301; +export const CSSCITYFLOW_PRESENTATION_FRAME_MILLISECONDS = 1_000 / 60; export const CSSCITYFLOW_PHASE_STEP = SOURCE_COMMON.waveSpeed / 1_000; export const CSSCITYFLOW_PHASE_PERIOD = Math.PI * 2; export const CSSCITYFLOW_FACE_IDS = Object.freeze(["top", "front", "right"]); diff --git a/src/adapters/cityflow/test/csscityflow-assets.test.mjs b/src/adapters/cityflow/test/csscityflow-assets.test.mjs new file mode 100644 index 0000000..ac0ead9 --- /dev/null +++ b/src/adapters/cityflow/test/csscityflow-assets.test.mjs @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: HPND +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import test from "node:test"; +import { brotliCompressSync, constants as zlibConstants } from "node:zlib"; + +const repositoryRoot = resolve(import.meta.dirname, "../../../.."); +const productRoot = resolve(repositoryRoot, "build/generated/public/csscityflow"); + +test("prepared Cityflow product binds source, topology, playback, and bounded transfer", async () => { + const [metadataText, catalogText, manifestText, modelText, playbackText, css, + mobileManifestText, mobileModelText, mobilePlaybackText, mobileCss] = await Promise.all([ + readFile(resolve(productRoot, "prepared.json"), "utf8"), + readFile(resolve(productRoot, "catalog.json"), "utf8"), + readFile(resolve(productRoot, "cityflow/manifest.json"), "utf8"), + readFile(resolve(productRoot, "cityflow/model.json"), "utf8"), + readFile(resolve(productRoot, "cityflow.playback.json"), "utf8"), + readFile(resolve(productRoot, "cityflow.css"), "utf8"), + readFile(resolve(productRoot, "cityflow-mobile/manifest.json"), "utf8"), + readFile(resolve(productRoot, "cityflow-mobile/model.json"), "utf8"), + readFile(resolve(productRoot, "cityflow-mobile.playback.json"), "utf8"), + readFile(resolve(productRoot, "cityflow-mobile.css"), "utf8"), + ]); + const metadata = JSON.parse(metadataText); + const catalog = JSON.parse(catalogText); + const manifest = JSON.parse(manifestText); + const model = JSON.parse(modelText); + const playback = JSON.parse(playbackText); + const mobileManifest = JSON.parse(mobileManifestText); + const mobileModel = JSON.parse(mobileModelText); + const mobilePlayback = JSON.parse(mobilePlaybackText); + const bank = metadata.banks.find(({ id }) => id === "desktop"); + const mobileBank = metadata.banks.find(({ id }) => id === "mobile"); + assert.equal(metadata.schema, "csscityflow-prepared-product@2"); + assert.equal(metadata.status, "ready"); + assert.equal(metadata.defaultBank, "desktop"); + assert.equal(metadata.profileSelection.mobileBreakpointWidth, 600); + assert.equal(metadata.profileSelection.mobileCapabilityQuery, + "(hover: none) and (pointer: coarse)"); + assert.equal(metadata.source.revision, "906693799e4fb7581436590cf84ecb2d3c9186ba"); + assert.equal(metadata.source.license, "HPND"); + assert.equal(metadata.source.files.length, 14); + assert.ok(metadata.source.files.every(({ path, sha256 }) => + !path.startsWith("/") && /^[a-f0-9]{64}$/u.test(sha256))); + assert.equal(bank.boxCount, 200); + assert.equal(bank.leafCount, 600); + assert.equal(bank.frameCount, 301); + assert.equal(bank.sourceFrameCount, 251); + assert.equal(bank.retainedFacePublication.schema, "csscityflow-retained-face-publication@3"); + assert.equal(bank.retainedFacePublication.policy, + "prepared-whole-box-visibility-no-face-culling"); + assert.equal(bank.retainedFacePublication.faceCount, 600); + assert.equal(bank.retainedFacePublication.boxCount, 200); + assert.equal(bank.retainedFacePublication.visibleFaceCount, 585); + assert.equal(bank.retainedFacePublication.hiddenFaceCount, 15); + assert.equal(bank.retainedFacePublication.visibleBoxCount, 195); + assert.equal(bank.retainedFacePublication.hiddenBoxCount, 5); + assert.equal(bank.retainedFacePublication.staticVisibility.hiddenFaceIndices.length, 15); + assert.equal(bank.retainedFacePublication.staticVisibility.schema, + "csscityflow-prepared-static-visibility@3"); + assert.equal(bank.diagnosticVisibility.usage, + "diagnostic-only-never-consumed-by-product-playback"); + assert.equal(bank.presentation.kind, "prepared-periodic-source-sample-reconstruction"); + assert.equal(bank.presentation.heightInterpolation, + "periodic-uniform-cubic-b-spline-c2-source-approximation"); + assert.equal(bank.presentation.temporalFilter, + "prepared-periodic-five-tap-fold-twelve-three-tap-refold-twelve-five-tap-refold-twelve-adaptive-smooth-sine-eased-extrema@1"); + assert.equal(bank.presentation.directionRunSuppression, + "prepared-circular-twelve-frame-or-short-direction-run-folding-zero-sum-adaptive-smooth-sine-24-54-0.6-eased@1"); + assert.equal(bank.presentation.colorInterpolation, + "prepared-srgb-interpolated-final-face-color"); + assert.equal(bank.presentation.transformPublication, + "prepared-packed-transform-components-expanded-once-plus-sparse-final-face-color-and-whole-box-leaf-visibility-publication"); + assert.equal(bank.presentation.statePublication.animationCount, 0); + assert.equal(bank.presentation.statePublication.frameCount, 301); + assert.equal(bank.presentation.statePublication.runtimeFormatting, false); + assert.equal(bank.presentation.statePublication.minimumShapeStyleWritesPerScheduledTick, 0); + assert.equal(bank.presentation.statePublication.maximumShapeStyleWritesPerScheduledTick, 187); + assert.equal(bank.presentation.statePublication.maximumLeafColorStyleWritesPerScheduledTick, 271); + assert.equal(bank.presentation.statePublication.maximumVisibilityStyleWritesPerScheduledTick, 18); + assert.equal(bank.loop.exactSourceLoop, false); + assert.equal(catalog.packages.length, 2); + assert.equal(manifest.identity.id, "cityflow"); + assert.equal(model.render.shapes.length, 200); + assert.equal(model.render.leaves.length, 600); + assert.equal(playback.frameCount, 301); + assert.equal(playback.sourceFrameCount, 251); + assert.equal(playback.schema, "csscityflow-prepared-playback@58"); + assert.equal(playback.bankId, "desktop"); + assert.equal(playback.modelId, "cityflow"); + assert.equal(playback.visibility, undefined); + assert.equal(playback.diagnostics.visibility.schema, + "csscityflow-prepared-visibility-culling@2"); + assert.equal(playback.diagnostics.productPolicy, + "diagnostic-only-never-consumed-by-product-playback"); + assert.equal(playback.transforms, undefined); + assert.equal(playback.transformTable.schema, "csscityflow-prepared-transform-table@1"); + assert.equal(playback.transformTable.count, 76051); + assert.equal(playback.transformTable.groups.length, 200); + assert.doesNotMatch(playbackText, /matrix3d\(/u, + "prepared wire payload must not send expanded matrix strings"); + assert.ok(Buffer.byteLength(playbackText) < 1.5 * 1024 * 1024, + `Prepared playback raw transfer grew to ${Buffer.byteLength(playbackText)} bytes`); + const packedTransformBytes = playback.transformTable.groups.reduce((sum, group) => + sum + Buffer.from(group.deltasBase64, "base64").byteLength, 0); + assert.ok(packedTransformBytes < 300_000, + `Prepared transform component streams grew to ${packedTransformBytes} bytes`); + assert.equal(Buffer.from(playback.transformIndices.presentationBase64, "base64").byteLength, + 301 * 200 * Uint16Array.BYTES_PER_ELEMENT); + assert.equal(Buffer.from(playback.transformIndices.sourceBase64, "base64").byteLength, + 251 * 200 * Uint16Array.BYTES_PER_ELEMENT); + assert.equal(Buffer.from(playback.colors.presentationMaterialIndicesBase64, "base64").byteLength, + 301 * 200 * Uint16Array.BYTES_PER_ELEMENT); + assert.equal(Buffer.from(playback.colors.sourceMaterialIndicesBase64, "base64").byteLength, + 251 * 200 * Uint16Array.BYTES_PER_ELEMENT); + assert.equal(playback.colors.schema, "csscityflow-prepared-face-local-materials@7"); + assert.equal(playback.colors.presentationTransitions.transitionCount, 61358); + assert.equal(playback.colors.presentationTransitions.maximumWritesPerFrame, 271); + assert.equal(playback.colors.presentationTransitions.colors.length, 662); + assert.equal(Buffer.from(playback.colors.presentationTransitions.offsetsBase64, "base64").byteLength, + (301 + 1) * Uint32Array.BYTES_PER_ELEMENT); + assert.equal(Buffer.from(playback.colors.presentationTransitions.faceIndicesBase64, "base64").byteLength, + 61358 * Uint16Array.BYTES_PER_ELEMENT); + assert.equal(Buffer.from(playback.colors.presentationTransitions.colorIndicesBase64, "base64").byteLength, + 61358 * Uint16Array.BYTES_PER_ELEMENT); + assert.equal(Buffer.from( + playback.staticVisibility.presentation.initialVisibleBoxBitsBase64, + "base64", + ).byteLength, Math.ceil(200 / 8)); + assert.equal(Buffer.from( + playback.staticVisibility.presentation.transitionOffsetsBase64, + "base64", + ).byteLength, (301 + 1) * Uint16Array.BYTES_PER_ELEMENT); + assert.equal(Buffer.from( + playback.staticVisibility.presentation.transitionBoxIndicesBase64, + "base64", + ).byteLength, 240 * Uint16Array.BYTES_PER_ELEMENT); + assert.equal((css.match(/@keyframes csscityflow-motion-/gu) ?? []).length, 0); + assert.doesNotMatch(css, /::before/u); + assert.doesNotMatch(css, /data-csscityflow-|attr\(|::after/u); + assert.doesNotMatch(`${metadataText}\n${catalogText}\n${manifestText}\n${modelText}\n${playbackText}`, + /\/Users\/|[A-Z]:\\/u); + const brotliBytes = brotliCompressSync(Buffer.from(playbackText), { + params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 8 }, + }); + const cssBrotliBytes = brotliCompressSync(Buffer.from(css), { + params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 8 }, + }); + assert.ok(brotliBytes.length < 1024 * 1024, + `Prepared playback Brotli transfer grew to ${brotliBytes.length} bytes`); + assert.ok(cssBrotliBytes.length < 10_000, + `Prepared motion CSS Brotli transfer grew to ${cssBrotliBytes.length} bytes`); + assert.ok(brotliBytes.length + cssBrotliBytes.length < 1024 * 1024, + `Combined prepared motion transfer grew to ${brotliBytes.length + cssBrotliBytes.length} bytes`); + + assert.equal(mobileBank.modelId, "cityflow-mobile"); + assert.equal(mobileBank.boxCount, 100); + assert.equal(mobileBank.leafCount, 300); + assert.equal(mobileManifest.identity.id, "cityflow-mobile"); + assert.equal(mobileModel.render.shapes.length, 100); + assert.equal(mobileModel.render.leaves.length, 300); + assert.equal(mobilePlayback.schema, "csscityflow-prepared-playback@58"); + assert.equal(mobilePlayback.bankId, "mobile"); + assert.equal(mobilePlayback.modelId, "cityflow-mobile"); + assert.equal(mobilePlayback.boxCount, 100); + assert.equal(mobilePlayback.transformTable.groups.length, 100); + assert.equal(mobilePlayback.staticVisibility.visibleFaceCount, 300); + assert.equal(mobilePlayback.staticVisibility.hiddenFaceCount, 0); + assert.equal(mobilePlayback.staticVisibility.presentation.transitionCount, 0); + assert.equal(mobilePlayback.sideDepth.defaultDepthScale, 0.28); + assert.equal(mobilePlayback.sideDepth.maximumDepthScale, 0.28); + assert.equal(mobilePlayback.sideDepth.overrideCount, 0); + assert.equal(mobilePlayback.presentation.statePublication.maximumShapeStyleWritesPerScheduledTick, 100); + assert.doesNotMatch(mobilePlaybackText, /matrix3d\(/u, + "mobile prepared wire payload must not send expanded matrix strings"); + assert.ok(Buffer.byteLength(mobilePlaybackText) < 800_000, + `Mobile prepared playback raw transfer grew to ${Buffer.byteLength(mobilePlaybackText)} bytes`); + assert.doesNotMatch(mobileCss, /will-change|attr\(|::before|::after/u); + assert.doesNotMatch( + `${metadataText}\n${catalogText}\n${manifestText}\n${modelText}\n${playbackText}\n${mobileManifestText}\n${mobileModelText}\n${mobilePlaybackText}`, + /\/Users\/|[A-Z]:\\/u, + ); +}); diff --git a/src/adapters/cityflow/test/csscityflow-deadline-scheduler.test.mjs b/src/adapters/cityflow/test/csscityflow-deadline-scheduler.test.mjs new file mode 100644 index 0000000..0522d27 --- /dev/null +++ b/src/adapters/cityflow/test/csscityflow-deadline-scheduler.test.mjs @@ -0,0 +1,238 @@ +// SPDX-License-Identifier: HPND +import assert from "node:assert/strict"; +import test from "node:test"; +import { createCityflowDeadlineScheduler } from "../src/csscityflow/deadlineScheduler.mjs"; + +test("publishes only the next prepared state and resets a missed deadline", () => { + const harness = createHarness(); + const publications = []; + const scheduler = createCityflowDeadlineScheduler({ + frameMilliseconds: 20, + publishDue(tick, due) { publications.push({ tick, due }); }, + ...harness.overrides, + }); + + scheduler.resume(); + scheduler.resume(); + assert.equal(harness.frames.size, 1, "resume must not double-schedule"); + harness.now = 116; + harness.runFrame(116); + assert.deepEqual(publications, [{ tick: 1, due: 1 }]); + harness.now = 120; + harness.runFrame(120); + assert.deepEqual( + publications, + [{ tick: 1, due: 1 }], + "the immediate first publication must restart the full deadline interval", + ); + + harness.now = 181; + harness.runFrame(181); + assert.deepEqual(publications.at(-1), { tick: 2, due: 1 }); + assert.equal(scheduler.stats().preparedStateSkipCount, 0); + assert.equal(scheduler.stats().lateDeadlineResetCount, 1); + assert.equal(scheduler.stats().timerCallbackCount, 0); + assert.equal(scheduler.stats().animationFrameCallbackCount, 3); + assert.equal(scheduler.stats().schedulerTimeSource, + "maximum-animation-frame-timestamp-and-callback-delivery"); + assert.equal(scheduler.stats().resumePublicationPolicy, + "first-animation-frame-immediate-then-deadline-paced"); + assert.equal(scheduler.stats().earlyDeadlineToleranceMilliseconds, 5); + + harness.now = 190; + harness.runFrame(190); + assert.equal(publications.length, 2, "an early callback after resync must not duplicate motion"); + harness.now = 201; + harness.runFrame(201); + assert.deepEqual(publications.at(-1), { tick: 3, due: 1 }); + + scheduler.pause(); + assert.equal(harness.frames.size, 0); + assert.equal(scheduler.stats().paused, true); +}); + +test("uses callback delivery time when the animation-frame timestamp stalls", () => { + const harness = createHarness(); + const publications = []; + const scheduler = createCityflowDeadlineScheduler({ + frameMilliseconds: 50 / 3, + publishDue(tick) { publications.push(tick); }, + ...harness.overrides, + }); + + scheduler.resume(); + harness.now = 101; + harness.runFrame(100); + assert.deepEqual(publications, [1]); + + harness.now = 117.7; + harness.runFrame(100); + assert.deepEqual( + publications, + [1, 2], + "a normally delivered callback must not be discarded because its presentation timestamp stalled", + ); + + harness.now = 117.8; + harness.runFrame(100); + assert.deepEqual(publications, [1, 2]); + assert.equal(scheduler.stats().preparedStateSkipCount, 0); +}); + +test("does not republish a prepared state for two callbacks in one delivery interval", () => { + const harness = createHarness(); + const publications = []; + const scheduler = createCityflowDeadlineScheduler({ + frameMilliseconds: 50 / 3, + publishDue(tick) { publications.push(tick); }, + ...harness.overrides, + }); + + scheduler.resume(); + harness.now = 101; + harness.runFrame(101); + assert.deepEqual(publications, [1]); + + harness.now = 104.053; + harness.runFrame(101); + assert.deepEqual( + publications, + [1], + "a due animation-frame timestamp delivered 3.053 ms later must not republish", + ); + harness.now = 117.7; + harness.runFrame(117.7); + assert.deepEqual(publications, [1, 2], "the next spaced callback must publish the next state"); + assert.equal(scheduler.stats().preparedStateSkipCount, 0); + assert.equal(scheduler.stats().publicationPacingTimeSource, "adjacent-state-deadline-schedule"); +}); + +test("accepts the next display callback when delivery is slightly ahead of the shifted deadline", () => { + const harness = createHarness(); + const publications = []; + const scheduler = createCityflowDeadlineScheduler({ + frameMilliseconds: 50 / 3, + publishDue(tick) { publications.push(tick); }, + ...harness.overrides, + }); + + scheduler.resume(); + harness.now = 100.599; + harness.runFrame(100); + assert.deepEqual(publications, [1]); + + harness.now = 103.758; + harness.runFrame(103.758); + assert.deepEqual(publications, [1], "a same-vsync callback must remain suppressed"); + + harness.now = 114.83; + harness.runFrame(114.819); + assert.deepEqual( + publications, + [1, 2], + "the next display callback must not become a held presentation frame", + ); +}); + +test("rescues a normal-cadence callback after deadline phase drift and rephases once", () => { + const harness = createHarness(); + const publications = []; + const scheduler = createCityflowDeadlineScheduler({ + frameMilliseconds: 50 / 3, + publishDue(tick) { publications.push(tick); }, + ...harness.overrides, + }); + + scheduler.resume(); + for (const timestamp of [100, 115.436, 130.872, 146.308]) { + harness.now = timestamp; + harness.runFrame(timestamp); + } + assert.deepEqual(publications, [1, 2, 3, 4]); + + harness.now = 161.744; + harness.runFrame(161.744); + assert.deepEqual( + publications, + [1, 2, 3, 4, 5], + "a callback 15.436 ms after the last publication must not create a 33 ms hold", + ); + + harness.now = 164.903; + harness.runFrame(164.903); + assert.deepEqual(publications, [1, 2, 3, 4, 5], "a 3.159 ms duplicate must remain suppressed"); + + harness.now = 177.18; + harness.runFrame(177.18); + assert.deepEqual(publications, [1, 2, 3, 4, 5, 6]); + assert.equal(scheduler.stats().displayPhaseResyncCount, 1); + assert.equal(scheduler.stats().earlyCallbackCount, 1); + assert.equal(scheduler.stats().minimumDistinctPublicationSpacingMilliseconds, 12.5); +}); + +test("keeps the accumulated deadline cadence on a 90 Hz callback stream", () => { + const harness = createHarness(); + const publications = []; + const scheduler = createCityflowDeadlineScheduler({ + frameMilliseconds: 50 / 3, + publishDue(tick) { publications.push(tick); }, + ...harness.overrides, + }); + + scheduler.resume(); + for (let callbackIndex = 0; callbackIndex < 19; callbackIndex += 1) { + const timestamp = 100 + callbackIndex * (1000 / 90); + harness.now = timestamp; + harness.runFrame(timestamp); + } + + assert.equal(publications.length, 13, "90 Hz callbacks must remain paced near 60 Hz, not 45 Hz"); + assert.equal(scheduler.stats().displayPhaseResyncCount, 0); +}); + +test("seek resets the deadline without changing pause state", () => { + const harness = createHarness(); + const scheduler = createCityflowDeadlineScheduler({ + frameMilliseconds: 20, + publishDue() {}, + ...harness.overrides, + }); + assert.equal(scheduler.seekTick(19).tick, 19); + assert.equal(harness.frames.size, 0); + scheduler.resume(); + assert.equal(harness.frames.size, 1); + harness.now = 300; + const stats = scheduler.seekTick(7); + assert.equal(stats.tick, 7); + assert.equal(stats.paused, false); + assert.equal(harness.frames.size, 1); + assert.throws(() => scheduler.seekTick(-1), /out of range/u); + scheduler.destroy(); +}); + +function createHarness() { + let nextId = 1; + const frames = new Map(); + const harness = { + now: 100, + frames, + overrides: { + readNow: () => harness.now, + requestFrame(callback) { + const id = nextId++; + frames.set(id, callback); + return id; + }, + cancelFrame(id) { frames.delete(id); }, + }, + runFrame(timestamp) { take(frames)(timestamp); }, + }; + return harness; +} + +function take(queue) { + const [id, value] = queue.entries().next().value ?? []; + if (id === undefined) throw new Error("Expected a queued callback"); + queue.delete(id); + return value; +} diff --git a/src/adapters/cityflow/test/csscityflow-deploy.deploy.mjs b/src/adapters/cityflow/test/csscityflow-deploy.deploy.mjs new file mode 100644 index 0000000..8bcefb2 --- /dev/null +++ b/src/adapters/cityflow/test/csscityflow-deploy.deploy.mjs @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: HPND +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { readFile, readdir } from "node:fs/promises"; +import { join, relative, resolve } from "node:path"; +import test from "node:test"; +import sharp from "sharp"; + +const repositoryRoot = resolve(import.meta.dirname, "../../../.."); +const generatedRoot = resolve(repositoryRoot, "build/generated/public/csscityflow"); +const deployedAssetsRoot = resolve(repositoryRoot, "dist/site/csscityflow"); +const deployedRouteRoot = resolve(repositoryRoot, "dist/site/cityflow"); + +test("atomic site deploy contains the exact Cityflow tree and current shared shell", async () => { + const [generated, deployed, routeFiles, html] = await Promise.all([ + digestTree(generatedRoot), + digestTree(deployedAssetsRoot), + listFiles(deployedRouteRoot), + readFile(resolve(deployedRouteRoot, "index.html"), "utf8"), + ]); + assert.deepEqual(deployed, generated); + assert.ok(generated.has("prepared.json")); + assert.ok(generated.has("cityflow.playback.json")); + assert.ok(routeFiles.includes("index.html")); + assert.equal(routeFiles.filter((path) => path === "index.html").length, 1); + const bundles = routeFiles.filter((path) => path !== "index.html"); + assert.ok(bundles.length >= 2); + assert.ok(bundles.every((path) => + /^assets\/[A-Za-z][A-Za-z0-9-]*-[A-Za-z0-9_-]{8,}\.(?:css|js)$/u.test(path)), + `unhashed or unexpected Cityflow route file: ${bundles.join(", ")}`); + assert.match(html, /data-project-id="cityflow"/u); + assert.match(html, /(?:src|href)="\/cityflow\/assets\//u); + assert.doesNotMatch(html, /noindex|nofollow/u); +}); + +test("public Cityflow attribution, previews, preparation, and cache contracts are complete", async () => { + const [manifestText, netlify, packageJson, astroConfig, notice, sourceAuthority, preview, sidebar] = + await Promise.all([ + readFile(resolve(repositoryRoot, "site/public/projects.json"), "utf8"), + readFile(resolve(repositoryRoot, "netlify.toml"), "utf8"), + readFile(resolve(repositoryRoot, "package.json"), "utf8"), + readFile(resolve(repositoryRoot, "astro.config.mjs"), "utf8"), + readFile(resolve(repositoryRoot, "src/adapters/cityflow/NOTICE.md"), "utf8"), + readFile(resolve(repositoryRoot, + "src/adapters/cityflow/src/prepare/csscityflow/sourceAuthority.mjs"), "utf8"), + sharp(resolve(repositoryRoot, "site/public/landing/cityflow.webp")).metadata(), + sharp(resolve(repositoryRoot, "site/public/landing/sidebar/cityflow.webp")).metadata(), + ]); + const project = JSON.parse(manifestText).projects.find(({ id }) => id === "cityflow"); + assert.equal(project?.number, 12); + assert.equal(project?.route, "/cityflow/"); + assert.equal(project?.preview, "/landing/cityflow.webp"); + assert.equal(preview.width, 960); + assert.equal(preview.height, 540); + assert.equal(sidebar.width, 480); + assert.equal(sidebar.height, 270); + assert.match(packageJson, /"build:cityflow:deploy": "CSSCITYFLOW_DEPLOY_BUILD=1 pnpm build:cityflow"/u); + assert.match(packageJson, /pnpm prepare:cityflow[^\n]+node scripts\/copy-deploy-products\.mjs/u); + assert.match(astroConfig, /"\.css": "text\/css; charset=utf-8"/u); + assert.match(netlify, /for = "\/csscityflow\/\*"[\s\S]*?Cache-Control = "no-cache"/u); + assert.match(sourceAuthority, /https:\/\/raw\.githubusercontent\.com\/\$\{segments\[0\]\}\/\$\{segments\[1\]\}/u); + assert.match(notice, /Permission to use, copy, modify, distribute, and sell this software/u); +}); + +async function listFiles(root) { + const files = []; + async function visit(directory) { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) await visit(path); + else if (entry.isFile()) files.push(relative(root, path)); + else throw new Error(`Unexpected deploy entry ${path}`); + } + } + await visit(root); + return files.sort(); +} + +async function digestTree(root) { + const entries = new Map(); + for (const path of await listFiles(root)) { + entries.set(path, createHash("sha256").update(await readFile(resolve(root, path))).digest("hex")); + } + return entries; +} diff --git a/src/adapters/cityflow/test/csscityflow-model.test.mjs b/src/adapters/cityflow/test/csscityflow-model.test.mjs index ab33833..f39114b 100644 --- a/src/adapters/cityflow/test/csscityflow-model.test.mjs +++ b/src/adapters/cityflow/test/csscityflow-model.test.mjs @@ -1,3 +1,4 @@ +// SPDX-License-Identifier: HPND import assert from "node:assert/strict"; import test from "node:test"; import { @@ -5,12 +6,21 @@ import { buildCityflowMorphModel, buildCityflowPreparedCss, buildCityflowPreparedPlayback, + cityflowPreparedSideDepth, cityflowClippedBottoms, cityflowEyeZ, } from "../src/prepare/csscityflow/model.mjs"; +import { expandCityflowPreparedTransforms } from + "../src/csscityflow/preparedTransformTable.mjs"; +import { + CSSCITYFLOW_PHASE_PERIOD, + CSSCITYFLOW_PHASE_STEP, + CSSCITYFLOW_PREPARED_FRAME_COUNT, + CSSCITYFLOW_PRESENTATION_FRAME_COUNT, +} from "../src/prepare/csscityflow/sourceModel.mjs"; -test("builds one retained root and three solid quad leaves per source box", () => { - for (const [bankId, boxCount] of [["desktop", 200]]) { +test("builds one retained root and three prepared source-face leaves per box", () => { + for (const [bankId, boxCount] of [["desktop", 200], ["mobile", 100]]) { const { state, model } = buildCityflowMorphModel({ bankId }); assert.equal(model.profile, "static-prepared"); assert.equal(model.render.shapes.length, boxCount); @@ -18,36 +28,332 @@ test("builds one retained root and three solid quad leaves per source box", () = assert.equal(model.topology.polygons.length, boxCount * 3); assert.equal(model.topology.vertices.length, boxCount * 12); assert.ok(model.render.leaves.every((leaf) => - leaf.strategy === "solid-quad" && leaf.width === 1 && leaf.height === 1 && leaf.atlas === null)); + leaf.strategy === "solid-quad" && leaf.width === 1 && + leaf.height === 1 && leaf.atlas === null)); + const [top, front, right] = model.render.leaves; + assert.equal(top.matrix[14], 1); + const defaultDepth = bankId === "desktop" ? 0.1 : 0.28; + assert.equal(front.matrix[6], defaultDepth); + assert.equal(right.matrix[6], defaultDepth); + assert.equal(front.matrix[14], 1 - defaultDepth); + assert.equal(right.matrix[14], 1 - defaultDepth); + assert.equal(front.matrix[14] + front.matrix[6] * front.height, 1); + assert.equal(right.matrix[14] + right.matrix[6] * right.height, 1); + const preparedDepths = model.render.leaves + .map((leaf, faceIndex) => faceIndex % 3 === 0 ? null : leaf.matrix[6]) + .filter((depth) => depth !== null); + assert.equal(preparedDepths.filter((depth) => depth > defaultDepth).length, + bankId === "desktop" ? 19 : 0); + assert.equal(Math.max(...preparedDepths), 0.28); + assert.equal(cityflowPreparedSideDepth(97, bankId), bankId === "desktop" ? 0.2 : 0.28); + assert.equal(model.render.leaves[97].matrix[14], bankId === "desktop" ? 0.8 : 0.72); + assert.equal(model.render.leaves[97].matrix[14] + model.render.leaves[97].matrix[6], 1); assert.deepEqual(model.materials, [{ id: "cityflow-face", color: [1, 1, 1, 1] }]); } }); -test("prepares DOMFORMAT-precedented elapsed playback without author keyframes", () => { +test("prepares DOMFORMAT-precedented exact states and C2 reconstructed presentation", () => { const { state } = buildCityflowMorphModel(); - const css = buildCityflowPreparedCss(state); const playback = buildCityflowPreparedPlayback(state); - assert.equal(playback.schema, "csscityflow-prepared-playback@1"); + const css = buildCityflowPreparedCss(state); + assert.equal(playback.schema, "csscityflow-prepared-playback@58"); + assert.equal(playback.bankId, "desktop"); + assert.equal(playback.modelId, "cityflow"); assert.equal(playback.precedent, "domformat@0/polycss-playback@0@cc8da736"); - assert.equal(playback.catchUpPolicy, "elapsed"); - assert.deepEqual(playback.tickIntervalUs, [20_000, 1]); + assert.equal(playback.catchUpPolicy, "adjacent-state-late-deadline-reset"); + assert.equal(playback.frameCount, CSSCITYFLOW_PRESENTATION_FRAME_COUNT); + assert.equal(playback.sourceFrameCount, CSSCITYFLOW_PREPARED_FRAME_COUNT); + assert.equal(playback.sourceFrameCount, Math.round(CSSCITYFLOW_PHASE_PERIOD / CSSCITYFLOW_PHASE_STEP)); + assert.equal(playback.loop.kind, "prepared-periodic-source-sample-reconstruction"); + assert.equal(playback.loop.exactSourceLoop, false); + assert.equal(playback.loop.presentationPeriodFrames, 301); + assert.equal(playback.loop.closureContinuity, + "periodic-zero-sum-twelve-frame-direction-run-folded-adaptive-smooth-sine-eased-sample-cycle"); + assert.deepEqual(playback.tickIntervalUs, [50_000, 3]); + assert.deepEqual(playback.sourceTickIntervalUs, [20_000, 1]); + assert.deepEqual(playback.presentation, { + kind: "prepared-periodic-source-sample-reconstruction", + sourceFramesPerSecond: 50, + framesPerSecond: 60, + exactSourceStateSeek: true, + heightInterpolation: "periodic-uniform-cubic-b-spline-c2-source-approximation", + temporalFilter: + "prepared-periodic-five-tap-fold-twelve-three-tap-refold-twelve-five-tap-refold-twelve-adaptive-smooth-sine-eased-extrema@1", + directionRunSuppression: + "prepared-circular-twelve-frame-or-short-direction-run-folding-zero-sum-adaptive-smooth-sine-24-54-0.6-eased@1", + colorInterpolation: "prepared-srgb-interpolated-final-face-color", + transformPublication: + "prepared-packed-transform-components-expanded-once-plus-sparse-final-face-color-and-whole-box-leaf-visibility-publication", + statePublication: { + schema: "csscityflow-prepared-state-publication@22", + frameCount: 301, + animationCount: 0, + runtimeFormatting: false, + loadTimeAssembly: "one-time-prepared-transform-component-table-expansion", + sourceSeekAssembly: "none-cached-expanded-transform-and-final-face-color-dictionaries", + atomicProperties: + "prepared-root-transform-plus-direct-leaf-visibility-and-final-face-background-color", + minimumShapeStyleWritesPerScheduledTick: 0, + maximumShapeStyleWritesPerScheduledTick: 187, + maximumLeafColorStyleWritesPerScheduledTick: 271, + maximumVisibilityStyleWritesPerScheduledTick: 18, + }, + }); assert.equal(playback.boxCount, 200); - assert.equal( - Buffer.from(playback.transformIndicesBase64, "base64").byteLength, - playback.frameCount * playback.boxCount * Uint32Array.BYTES_PER_ELEMENT, + assert.equal(playback.transforms, undefined); + assert.equal(playback.transformTable.schema, "csscityflow-prepared-transform-table@1"); + assert.equal(playback.transformTable.precedent, + "cssgraphics.prepared-transform-table@1+cssgravitywell-sparse-component-varints@2"); + assert.equal(playback.transformTable.encoding, + "per-box-affine-template-plus-z-delta-signed-varint-base64"); + assert.equal(playback.transformTable.count, 76051); + assert.equal(playback.transformTable.width, 12); + assert.equal(playback.transformTable.changingComponent, 8); + assert.equal(playback.transformTable.groups.length, playback.boxCount); + const transforms = expandCityflowPreparedTransforms( + playback.transformTable, + playback.transformIndices.transformOffsets, ); - assert.equal( - Buffer.from(playback.colorIndicesBase64, "base64").byteLength, - playback.frameCount * playback.boxCount * Uint8Array.BYTES_PER_ELEMENT, - ); - assert.ok(playback.transforms.length >= playback.boxCount); - assert.doesNotMatch(css, /@keyframes|animation-/u); - assert.match(css, /\.csscityflow-color-255\{/u); - assert.match(css, /b:first-child\{backface-visibility:visible!important\}/u); + assert.equal(transforms.length, playback.transformTable.count); + assert.ok(transforms.every((transform) => /^matrix3d\([^)]+\)$/u.test(transform))); + assert.equal(decodeUint16(playback.transformIndices.presentationBase64).length, + playback.frameCount * playback.boxCount); + assert.equal(decodeUint16(playback.transformIndices.sourceBase64).length, + playback.sourceFrameCount * playback.boxCount); + assert.equal(decodeUint16(playback.colors.presentationMaterialIndicesBase64).length, + playback.frameCount * playback.boxCount); + assert.equal(decodeUint16(playback.colors.sourceMaterialIndicesBase64).length, + playback.sourceFrameCount * playback.boxCount); + assert.equal(playback.colors.schema, "csscityflow-prepared-face-local-materials@7"); + assert.equal(playback.colors.presentationTransitions.schema, + "csscityflow-prepared-face-color-transitions@2"); + assert.equal(playback.colors.presentationTransitions.transitionCount, 61358); + assert.equal(playback.colors.presentationTransitions.maximumWritesPerFrame, 271); + assert.equal(decodeUint32(playback.colors.presentationTransitions.offsetsBase64).length, + playback.frameCount + 1); + assert.equal(decodeUint16(playback.colors.presentationTransitions.faceIndicesBase64).length, + playback.colors.presentationTransitions.transitionCount); + assert.equal(playback.colors.presentationTransitions.colors.length, 662); + assert.equal(decodeUint16(playback.colors.presentationTransitions.colorIndicesBase64).length, + playback.colors.presentationTransitions.transitionCount); + assertPreparedColorTransitions(playback); + assert.ok(playback.colors.materials.every((material) => + material.length === 3 && material.every((color) => /^#[a-f0-9]{6}$/u.test(color)))); + const sideLeaf = buildCityflowMorphModel().model.render.leaves[1]; + assert.equal(sideLeaf.height, 1, "side crop must not use a quantized subpixel layout height"); + assert.equal(sideLeaf.matrix[14], 0.9); + assert.equal(sideLeaf.matrix[6], 0.1); + assert.equal(playback.paletteSize, 256); + assert.equal(playback.facesPerBox, 3); + assert.equal(playback.staticVisibility.schema, "csscityflow-prepared-static-visibility@3"); + assert.equal(playback.staticVisibility.visibleFaceCount, 585); + assert.equal(playback.staticVisibility.hiddenFaceCount, 15); + assert.equal(playback.staticVisibility.visibleBoxCount, 195); + assert.equal(playback.staticVisibility.hiddenBoxCount, 5); + assert.equal(playback.staticVisibility.hiddenFaceIndices.length, 15); + assert.deepEqual(playback.staticVisibility.hiddenBoxIndices, + [68, 69, 176, 190, 199]); + assert.equal(playback.staticVisibility.presentation.schema, + "csscityflow-prepared-presentation-box-visibility@1"); + assert.equal(playback.staticVisibility.presentation.encoding, + "initial-box-bitset-plus-u16le-per-target-frame-toggle-offsets-and-box-indices"); + assert.equal(playback.staticVisibility.presentation.transitionDilationFrames, 12); + assert.equal(playback.staticVisibility.presentation.alwaysVisibleBoxIndices.length, 128); + assert.equal(playback.staticVisibility.presentation.initialVisibleCount, 510); + assert.equal(playback.staticVisibility.presentation.initialVisibleBoxes, 170); + assert.equal(playback.staticVisibility.presentation.minimumVisibleFaces, 498); + assert.equal(playback.staticVisibility.presentation.maximumVisibleFaces, 561); + assert.equal(playback.staticVisibility.presentation.minimumVisibleBoxes, 166); + assert.equal(playback.staticVisibility.presentation.maximumVisibleBoxes, 187); + assert.equal(playback.staticVisibility.presentation.transitionCount, 240); + assert.equal(playback.staticVisibility.presentation.maximumTransitionWritesPerFrame, 6); + assert.equal(Buffer.from( + playback.staticVisibility.presentation.initialVisibleBoxBitsBase64, + "base64", + ).byteLength, Math.ceil(playback.boxCount / 8)); + assert.equal(decodeUint16( + playback.staticVisibility.presentation.transitionOffsetsBase64, + ).length, playback.frameCount + 1); + assert.equal(decodeUint16( + playback.staticVisibility.presentation.transitionBoxIndicesBase64, + ).length, playback.staticVisibility.presentation.transitionCount); + assert.deepEqual(playback.sideDepth, { + schema: "csscityflow-prepared-side-depth@1", + defaultDepthScale: 0.1, + maximumDepthScale: 0.28, + overrideCount: 19, + }); + assert.equal(playback.visibility, undefined); + assert.equal(playback.diagnostics.productPolicy, + "diagnostic-only-never-consumed-by-product-playback"); + assert.equal(playback.diagnostics.visibility.schema, + "csscityflow-prepared-visibility-culling@2"); + assert.equal(playback.diagnostics.visibility.faceCount, 600); + assert.equal(playback.diagnostics.visibility.wide.source.schema, + "csscityflow-prepared-wide-source-visibility@1"); + assert.equal((css.match(/@keyframes csscityflow-motion-/gu) ?? []).length, 0); + assert.doesNotMatch(css, /will-change/u); + assert.doesNotMatch(css, /animation-/u); + assert.doesNotMatch(css, /::before/u); + assert.doesNotMatch(css, /attr\(|::after/u); + assert.doesNotMatch(css, /--z|@property/u); + assert.doesNotMatch(css, /\.csscityflow-box[^}]*transform:/u); assert.equal((css.match(/hypot\(/gu) ?? []).length, 0); assert.doesNotMatch(css, /filter|clip-path|mask|linear-gradient|radial-gradient/u); + + let stationaryTransitionCount = 0; + const presentationTransformIndices = decodeUint16(playback.transformIndices.presentationBase64); + const sourceTransformIndices = decodeUint16(playback.transformIndices.sourceBase64); + for (let boxIndex = 0; boxIndex < playback.boxCount; boxIndex += 1) { + const sourceZScales = Array.from({ length: playback.sourceFrameCount }, (_, frameIndex) => + zScale(transforms[playback.transformIndices.transformOffsets[boxIndex] + + sourceTransformIndices[frameIndex * playback.boxCount + boxIndex]])); + const presentationZScales = Array.from({ length: playback.frameCount }, (_, frameIndex) => + zScale(transforms[playback.transformIndices.transformOffsets[boxIndex] + + presentationTransformIndices[frameIndex * playback.boxCount + boxIndex] + ])); + assert.ok(Math.min(...presentationZScales) >= Math.min(...sourceZScales) - 2e-9); + assert.ok(Math.max(...presentationZScales) <= Math.max(...sourceZScales) + 2e-9); + const velocities = presentationZScales.map((zScale, frameIndex) => + zScale - presentationZScales[(frameIndex - 1 + playback.frameCount) % playback.frameCount]); + const directions = velocities + .filter((velocity) => Math.abs(velocity) > 1e-7) + .map(Math.sign); + assert.ok(circularDirectionRunLengths(directions).every((length) => length > 12)); + stationaryTransitionCount += velocities + .filter((velocity) => Math.abs(velocity) <= 1e-9).length; + } + assert.ok(stationaryTransitionCount / (playback.frameCount * playback.boxCount) < 0.002); }); +test("prepares an independent packed mobile product bank", () => { + const { state, model } = buildCityflowMorphModel({ bankId: "mobile" }); + const playback = buildCityflowPreparedPlayback(state); + assert.equal(model.identity.id, "cityflow-mobile"); + assert.equal(playback.schema, "csscityflow-prepared-playback@58"); + assert.equal(playback.bankId, "mobile"); + assert.equal(playback.modelId, "cityflow-mobile"); + assert.equal(playback.boxCount, 100); + assert.equal(playback.transformTable.groups.length, 100); + assert.equal(playback.transformIndices.count, 301 * 100); + assert.equal(playback.transformIndices.sourceCount, 251 * 100); + assert.equal(playback.staticVisibility.visibleBoxCount, 100); + assert.equal(playback.staticVisibility.hiddenBoxCount, 0); + assert.equal(playback.staticVisibility.presentation.minimumVisibleBoxes, 100); + assert.equal(playback.staticVisibility.presentation.maximumVisibleBoxes, 100); + assert.equal(playback.staticVisibility.presentation.transitionCount, 0); + assert.equal(playback.presentation.statePublication.maximumShapeStyleWritesPerScheduledTick, 100); + assert.equal(playback.presentation.statePublication.maximumVisibilityStyleWritesPerScheduledTick, 0); + assert.deepEqual(playback.sideDepth, { + schema: "csscityflow-prepared-side-depth@1", + defaultDepthScale: 0.28, + maximumDepthScale: 0.28, + overrideCount: 0, + }); + assert.equal(playback.diagnostics.visibility.schema, + "csscityflow-mobile-diagnostic-visibility@1"); + assert.equal(playback.diagnostics.visibility.bankId, "mobile"); +}); + +function circularDirectionRunLengths(directions) { + const lengths = []; + let direction = directions[0]; + let length = 1; + for (let index = 1; index < directions.length; index += 1) { + if (directions[index] === direction) length += 1; + else { + lengths.push(length); + direction = directions[index]; + length = 1; + } + } + lengths.push(length); + if (lengths.length > 1 && directions[0] === directions.at(-1)) { + lengths[0] += lengths.pop(); + } + return lengths; +} + +function decodeUint16(value) { + const bytes = Buffer.from(value, "base64"); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + return Uint16Array.from({ length: bytes.byteLength / 2 }, (_, index) => + view.getUint16(index * 2, true)); +} + +function decodeUint32(value) { + const bytes = Buffer.from(value, "base64"); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + return Uint32Array.from({ length: bytes.byteLength / 4 }, (_, index) => + view.getUint32(index * 4, true)); +} + +function assertPreparedColorTransitions(playback) { + const transitionOffsets = decodeUint32( + playback.colors.presentationTransitions.offsetsBase64, + ); + const transitionFaceIndices = decodeUint16( + playback.colors.presentationTransitions.faceIndicesBase64, + ); + const transitionColorIndices = decodeUint16( + playback.colors.presentationTransitions.colorIndicesBase64, + ); + const materialIndices = decodeUint16(playback.colors.presentationMaterialIndicesBase64); + const visibility = playback.staticVisibility.presentation; + const initialBits = Buffer.from(visibility.initialVisibleBoxBitsBase64, "base64"); + const visibilityOffsets = decodeUint16(visibility.transitionOffsetsBase64); + const visibilityBoxIndices = decodeUint16(visibility.transitionBoxIndicesBase64); + const visible = Uint8Array.from({ length: playback.boxCount }, (_, boxIndex) => + initialBits[boxIndex >> 3] >> (boxIndex & 7) & 1); + const visibilityRows = [visible.slice()]; + for (let frameIndex = 1; frameIndex < playback.frameCount; frameIndex += 1) { + for (let cursor = visibilityOffsets[frameIndex]; + cursor < visibilityOffsets[frameIndex + 1]; cursor += 1) { + visible[visibilityBoxIndices[cursor]] ^= 1; + } + visibilityRows.push(visible.slice()); + } + let computedMaximum = 0; + for (let targetFrameIndex = 0; + targetFrameIndex < playback.frameCount; targetFrameIndex += 1) { + const previousFrameIndex = + (targetFrameIndex - 1 + playback.frameCount) % playback.frameCount; + const expected = []; + for (let faceIndex = 0; faceIndex < visibility.faceCount; faceIndex += 1) { + const boxIndex = Math.floor(faceIndex / playback.facesPerBox); + if (visibilityRows[targetFrameIndex][boxIndex] === 0) continue; + const localFaceIndex = faceIndex % playback.facesPerBox; + const targetMaterialIndex = + materialIndices[targetFrameIndex * playback.boxCount + boxIndex]; + const previousMaterialIndex = + materialIndices[previousFrameIndex * playback.boxCount + boxIndex]; + if (visibilityRows[previousFrameIndex][boxIndex] === 0 || + playback.colors.materials[targetMaterialIndex][localFaceIndex] !== + playback.colors.materials[previousMaterialIndex][localFaceIndex]) { + expected.push([ + faceIndex, + playback.colors.materials[targetMaterialIndex][localFaceIndex], + ]); + } + } + const start = transitionOffsets[targetFrameIndex]; + const end = transitionOffsets[targetFrameIndex + 1]; + const actual = Array.from({ length: end - start }, (_, index) => [ + transitionFaceIndices[start + index], + playback.colors.presentationTransitions.colors[transitionColorIndices[start + index]], + ]); + assert.deepEqual(actual, expected, + `prepared color transition frame ${targetFrameIndex} drifted`); + computedMaximum = Math.max(computedMaximum, actual.length); + } + assert.equal(computedMaximum, + playback.colors.presentationTransitions.maximumWritesPerFrame); +} + +function zScale(transform) { + return Number(transform.slice("matrix3d(".length, -1).split(",")[10]); +} + test("clips source side faces at the native far plane during preparation", () => { const { state } = buildCityflowMorphModel(); for (const box of state.boxes) { diff --git a/src/adapters/cityflow/test/csscityflow-profile-selection.test.mjs b/src/adapters/cityflow/test/csscityflow-profile-selection.test.mjs new file mode 100644 index 0000000..ab95ecd --- /dev/null +++ b/src/adapters/cityflow/test/csscityflow-profile-selection.test.mjs @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: HPND +import assert from "node:assert/strict"; +import test from "node:test"; +import { + CSSCITYFLOW_MOBILE_BREAKPOINT_WIDTH, + CSSCITYFLOW_PREPARED_BANKS, + selectCityflowPreparedBank, +} from "../src/csscityflow/profileSelection.mjs"; + +const noCapabilities = () => false; + +test("selects the mobile Cityflow bank before fetch and mount", () => { + assert.equal(CSSCITYFLOW_MOBILE_BREAKPOINT_WIDTH, 600); + assert.equal(selectCityflowPreparedBank({ + width: 390, + height: 844, + mediaMatches: noCapabilities, + userAgent: "desktop-test", + }), "mobile"); + assert.equal(selectCityflowPreparedBank({ + width: 1280, + height: 720, + mediaMatches: (query) => query.includes("pointer: coarse"), + userAgent: "desktop-test", + }), "mobile"); + assert.equal(selectCityflowPreparedBank({ + width: 1280, + height: 720, + mediaMatches: noCapabilities, + userAgent: "Mozilla/5.0 (iPhone)", + }), "mobile"); +}); + +test("preserves the desktop bank for desktop devices", () => { + assert.equal(selectCityflowPreparedBank({ + width: 1280, + height: 720, + mediaMatches: noCapabilities, + userAgent: "desktop-test", + }), "desktop"); + assert.deepEqual(CSSCITYFLOW_PREPARED_BANKS, { + desktop: { id: "desktop", modelId: "cityflow" }, + mobile: { id: "mobile", modelId: "cityflow-mobile" }, + }); +}); + +test("rejects invalid Cityflow viewport dimensions", () => { + assert.throws(() => selectCityflowPreparedBank({ + width: 0, + height: 844, + mediaMatches: noCapabilities, + }), /viewport drifted/u); +}); diff --git a/src/adapters/cityflow/test/csscityflow-runtime-surface.test.mjs b/src/adapters/cityflow/test/csscityflow-runtime-surface.test.mjs index 0df0be2..fa428ea 100644 --- a/src/adapters/cityflow/test/csscityflow-runtime-surface.test.mjs +++ b/src/adapters/cityflow/test/csscityflow-runtime-surface.test.mjs @@ -1,25 +1,132 @@ +// SPDX-License-Identifier: HPND import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; import test from "node:test"; -test("uses the DOMFORMAT-precedented elapsed deadline playback transport", async () => { - const [client, player] = await Promise.all([ +test("publishes every prepared box automatically with a fixed source camera", async () => { + const [client, player, profileSelection, projection, styles, scheduler, tracePerformance] = await Promise.all([ readFile(new URL("../src/csscityflow/client.mjs", import.meta.url), "utf8"), readFile(new URL("../src/csscityflow/preparedPlayback.mjs", import.meta.url), "utf8"), + readFile(new URL("../src/csscityflow/profileSelection.mjs", import.meta.url), "utf8"), + readFile(new URL("../src/csscityflow/sourceProjection.mjs", import.meta.url), "utf8"), + readFile(new URL("../src/csscityflow/styles.css", import.meta.url), "utf8"), + readFile(new URL("../src/csscityflow/deadlineScheduler.mjs", import.meta.url), "utf8"), + readFile(new URL("../tools/trace-performance.mjs", import.meta.url), "utf8"), ]); assert.match(client, /mountPolyMorphModel/u); + assert.match(client, /selectCityflowPreparedBank/u); + assert.ok(client.indexOf("selectCityflowPreparedBank({") < + client.indexOf("await Promise.all(["), "prepared bank selection must precede all bank fetches"); + assert.match(client, /loadPolyMorphPackage\("\/csscityflow\/", \{ modelId \}\)/u); + assert.match(client, + /loadCityflowPreparedPlayback\(`\/csscityflow\/\$\{modelId\}\.playback\.json`\)/u); + assert.match(client, /loadPreparedStylesheet\(`\/csscityflow\/\$\{modelId\}\.css`\)/u); + assert.doesNotMatch(client, + /addEventListener\([^\n]*resize[^\n]*selectCityflowPreparedBank/u); + assert.match(profileSelection, /width < CSSCITYFLOW_MOBILE_BREAKPOINT_WIDTH/u); + assert.match(profileSelection, /\(hover: none\) and \(pointer: coarse\)/u); + assert.match(client, /bindCityflowSourceProjection/u); + assert.match(client, /resume\(\)\s*\{\s*if \(!destroyed\) return state\.player\?\.resume\(\)/u); + assert.match(client, /performance\.mark\("csscityflow-ready"\);\s*player\.resume\(\)/u); + assert.doesNotMatch(client, /orbitControls|bindCityflowOrbitControls|state\.orbit|animationFrozen/u); + assert.match(client, /mounted\.camera\.update\(\{ distance: -projection\.perspective \}\)/u); + assert.match(client, /rotX:\s*0,\s*rotY:\s*0,\s*zoom:\s*50,/u); + assert.match(projection, /width > height \* 2/u); + assert.match(projection, /height - viewportHeight \/ 2/u); + assert.match(projection, /ResizeObserver/u); + assert.match(styles, /--csscityflow-camera-top/u); + assert.match(styles, /--csscityflow-camera-height/u); + assert.match(styles, /--csscityflow-perspective/u); + assert.doesNotMatch(styles, + /data-csscityflow-(?:orbit|dragging)|cursor:\s*(?:grab|grabbing)|touch-action|pointer-events:\s*auto|transform-origin:\s*0 0 -30px/u); + assert.doesNotMatch(styles, /\.polycss-scene\s*\{[^}]*transform:/su); assert.match(player, /domformat@0\/polycss-playback@0@cc8da736/u); - assert.match(player, /requestAnimationFrame/u); - assert.match(player, /setTimeout/u); - assert.doesNotMatch(`${client}\n${player}`, /setInterval|canvas|getContext/u); + assert.match(player, + /shapeElements\[boxIndex\]\.style\.transform\s*=\s*[\s\S]*transforms\[transformOffsets\[boxIndex\] \+ transformIndex\]/u); + assert.match(player, /const flatLeafElements = leafElements\.flat\(\)/u); + assert.match(player, /presentationTransitionColors\[[\s\S]*presentationColorTransitionColorIndices\[cursor\]/u); + assert.doesNotMatch(player, /publishPreparedBoxState|currentMaterialIndices/u); + assert.doesNotMatch(player, /setProperty\("--z"/u); + assert.match(player, /\.style\.backgroundColor = color/u); + assert.doesNotMatch(player, /data-csscityflow-(?:opacity|base|overlay)/u); + assert.match(player, /leaf\.style\.visibility = "hidden"/u); + assert.doesNotMatch(player, /selectVisibilityVariant/u); + assert.doesNotMatch(player, /shapeElements\[boxIndex\]\.classList\.toggle/u); + assert.match(player, + /prepared-viewport-independent-whole-box-direct-leaf-visibility-no-face-culling/u); + assert.doesNotMatch(player, /style\.backgroundPosition/u); + assert.match(player, /leaf\.style\.visibility = visible \? "" : "hidden"/u); + assert.doesNotMatch(player, /shapeElements\[boxIndex\]\.style\.(?:display|visibility)/u); + assert.doesNotMatch(player, /(?:flatLeafElements|leafElements)[^\n]*style\.display/u); + assert.match(player, /seekSourceFrame/u); + assert.match(player, /prepared-periodic-source-sample-reconstruction/u); + assert.match(player, + /prepared-packed-transform-components-expanded-once-plus-sparse-final-face-color-and-whole-box-leaf-visibility-publication/u); + assert.match(player, /pseudoElementSideFacePublication: false/u); + assert.match(player, /pseudoElementFaceColorOverlay: false/u); + assert.match(player, /retainedSideLeafPaintOwners: 1/u); + assert.match(player, /sideLeafPreparedHeight: 1/u); + assert.match(player, /sideLeafPreparedDefaultDepthScale: playback\.sideDepth\.defaultDepthScale/u); + assert.match(player, /sideLeafPreparedMaximumDepthScale: playback\.sideDepth\.maximumDepthScale/u); + assert.match(player, /sideLeafPreparedOverrideCount: playback\.sideDepth\.overrideCount/u); + assert.match(player, /sideLeafPreparedDefaultTopOffset: 1 - playback\.sideDepth\.defaultDepthScale/u); + assert.match(player, /sideLeafPreparedMinimumTopOffset: 1 - playback\.sideDepth\.maximumDepthScale/u); + assert.match(player, /sideLeafLayoutSubpixelFree: true/u); + assert.match(player, /preparedTimelineAuthority: "sequential-prepared-state-index"/u); + assert.doesNotMatch(player, /transformAnimations/u); + assert.doesNotMatch(player, /element\.animate/u); + assert.match(player, /adjacent-state-late-deadline-reset/u); + assert.match(scheduler, /requestAnimationFrame/u); + assert.match(scheduler, + /schedulerTimeSource: "maximum-animation-frame-timestamp-and-callback-delivery"/u); + assert.match(scheduler, /publicationPacingTimeSource: "adjacent-state-deadline-schedule"/u); + assert.match(scheduler, /minimumDistinctPublicationSpacingMilliseconds/u); + assert.match(scheduler, /displayPhaseResyncCount/u); + assert.match(scheduler, + /resumePublicationPolicy: "first-animation-frame-immediate-then-deadline-paced"/u); + assert.match(scheduler, /Math\.max\(timestamp, deliveredAt\)/u); + assert.match(scheduler, /publishDue\(tick, 1\)/u); + assert.match(scheduler, /tick \+= 1/u); + assert.match(scheduler, /nextFrameAt = publicationTime \+ frameMilliseconds/u); + assert.doesNotMatch(scheduler, /targetTick|collapsedResyncCount|skippedPresentationFrameCount/u); + assert.match(tracePerformance, /AnimationFrame::Presentation/u); + assert.match(tracePerformance, /activatePage:\s*async/u); + assert.match(tracePerformance, /__csscityflow\.player\.resume\(\)/u); + assert.match(tracePerformance, /noPresentedThirtyThreeMillisecondHolds/u); + assert.match(tracePerformance, /noSmoothnessAffectingPipelineDrops/u); + assert.doesNotMatch(tracePerformance, /color-mix|data-csscityflow-|PERF_EXPERIMENT/u); + assert.doesNotMatch(tracePerformance, /presentationCadenceP95: draw\.p95Ms/u); + assert.doesNotMatch(`${client}\n${player}\n${scheduler}`, /setTimeout|setInterval|canvas|getContext/u); }); -test("keeps the route shell minimal and source-named", async () => { - const html = await readFile(new URL("../index.html", import.meta.url), "utf8"); - assert.match(html, /site-wordmark-css/u); - assert.match(html, /site-wordmark-graphics/u); - assert.match(html, /\/cityflow/u); - assert.equal((html.match(/ { + const adapterRoot = resolve(import.meta.dirname, ".."); + const repositoryRoot = resolve(adapterRoot, "../../.."); + const [html, main, config, projects, sceneRouter, packageJson, notice] = await Promise.all([ + readFile(resolve(adapterRoot, "index.html"), "utf8"), + readFile(resolve(adapterRoot, "src/main.mjs"), "utf8"), + readFile(resolve(adapterRoot, "vite.config.mjs"), "utf8"), + readFile(resolve(repositoryRoot, "site/public/projects.json"), "utf8"), + readFile(resolve(repositoryRoot, "site/scene-router.mjs"), "utf8"), + readFile(resolve(repositoryRoot, "package.json"), "utf8"), + readFile(resolve(adapterRoot, "NOTICE.md"), "utf8"), + ]); + const project = JSON.parse(projects).projects.find(({ id }) => id === "cityflow"); + assert.equal(project?.number, 12); + assert.equal(project?.route, "/cityflow/"); + assert.equal(project?.preview, "/landing/cityflow.webp"); + assert.match(project?.credits?.[0]?.url ?? "", /906693799e4fb7581436590cf84ecb2d3c9186ba\/hacks\/glx\/cityflow\.c$/u); + assert.match(html, //u); + assert.match(html, /
{ assert.equal(CITYFLOW_BANKS.desktop.boxCount, 200); + assert.equal(CITYFLOW_BANKS.mobile.boxCount, 100); + assert.equal(CITYFLOW_BANKS.mobile.modelId, "cityflow-mobile"); + assert.equal(CITYFLOW_BANKS.desktop.commit, sourceLock.revision); + assert.equal(CITYFLOW_BANKS.desktop.primarySha256, sourceLock.primary.sha256); + assert.equal(CITYFLOW_BANKS.desktop.configSha256, sourceLock.config.sha256); + assert.equal(sourceLock.license.spdx, "HPND"); assert.equal(CITYFLOW_BANKS.desktop.waveCount, 6); assert.equal(CITYFLOW_BANKS.desktop.waveRadius, 256); assert.equal(CITYFLOW_BANKS.desktop.textureSize, 512); assert.equal(CSSCITYFLOW_FRAME_MILLISECONDS, 20); + assert.equal(CSSCITYFLOW_PREPARED_FRAME_COUNT, 251); + assert.equal(CSSCITYFLOW_PRESENTATION_FRAME_COUNT, 301); + assert.equal(CSSCITYFLOW_PRESENTATION_FRAME_MILLISECONDS, 1_000 / 60); assert.deepEqual(CSSCITYFLOW_FACE_IDS, ["top", "front", "right"]); assert.ok(buildCityflowSourceState().boxes.every((box) => box.lightFactors[0] > 1)); }); @@ -32,6 +46,16 @@ test("prepares deterministic source state and exact checkpoint frames", () => { height >= 0.1 && height < 0.62 && colorIndex >= 0 && colorIndex < 256)); }); +test("prepares a deterministic source-supported mobile count bank", () => { + const left = buildCityflowSourceState({ bankId: "mobile" }); + const right = buildCityflowSourceState({ bankId: "mobile" }); + assert.deepEqual(left, right); + assert.equal(left.source.boxCount, 100); + assert.equal(left.boxes.length, 100); + assert.equal(cityflowFrameAt(left, 73).boxes.length, 100); + assert.ok(left.boxes.every((box) => box.width > 0 && box.depth > 0)); +}); + test("keeps source front-to-back box ordering", () => { const state = buildCityflowSourceState(); for (let index = 1; index < state.boxes.length; index += 1) { diff --git a/src/adapters/cityflow/test/csscityflow-source-projection.test.mjs b/src/adapters/cityflow/test/csscityflow-source-projection.test.mjs new file mode 100644 index 0000000..bde724d --- /dev/null +++ b/src/adapters/cityflow/test/csscityflow-source-projection.test.mjs @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: HPND +import assert from "node:assert/strict"; +import test from "node:test"; +import { cityflowSourceProjection } from "../src/csscityflow/sourceProjection.mjs"; + +test("preserves the ordinary source viewport through a 2:1 aspect ratio", () => { + const projection = cityflowSourceProjection(1280, 720); + assert.equal(projection.viewportHeight, 720); + assert.equal(projection.viewportTop, 0); + assert.equal(projection.usesWideSourceViewport, false); + assert.ok(Math.abs(projection.perspective - 1343.5382907247958) < 1e-9); + + assert.equal(cityflowSourceProjection(1440, 720).usesWideSourceViewport, false); +}); + +test("reproduces cityflow.c's square viewport and lower-half crop above 2:1", () => { + const projection = cityflowSourceProjection(2560, 1224); + assert.equal(projection.viewportHeight, 2560); + assert.equal(projection.viewportTop, -56); + assert.equal(projection.usesWideSourceViewport, true); + assert.ok(Math.abs(projection.perspective - 4777.025033688163) < 1e-9); +}); + +test("rejects invalid viewport dimensions", () => { + assert.throws(() => cityflowSourceProjection(0, 720), /positive finite/u); + assert.throws(() => cityflowSourceProjection(1280, Number.NaN), /positive finite/u); +}); diff --git a/src/adapters/cityflow/tools/analyze-motion-smoothness.mjs b/src/adapters/cityflow/tools/analyze-motion-smoothness.mjs new file mode 100644 index 0000000..70b1db9 --- /dev/null +++ b/src/adapters/cityflow/tools/analyze-motion-smoothness.mjs @@ -0,0 +1,361 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: HPND +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { promisify } from "node:util"; +import { expandCityflowPreparedTransforms } from + "../src/csscityflow/preparedTransformTable.mjs"; + +const execFileAsync = promisify(execFile); +const repositoryRoot = resolve(import.meta.dirname, "../../../.."); +const playbackPath = resolveArgument( + "--playback", + "build/generated/public/csscityflow/cityflow.playback.json", +); +const framesRoot = optionalResolvedArgument("--frames"); +const outputPath = optionalResolvedArgument("--output"); +const label = argumentValue("--label") ?? "cityflow"; +const playbackBytes = await readFile(playbackPath); +const playback = JSON.parse(playbackBytes); +const geometry = analyzePreparedGeometry(playback); +const frameDomain = framesRoot ? await analyzeRawFrameSequence(framesRoot) : null; +const report = { + schema: "csscityflow-motion-smoothness-audit@4", + generatedAt: new Date().toISOString(), + label, + inputs: { + playbackPath, + playbackSha256: createHash("sha256").update(playbackBytes).digest("hex"), + framesRoot, + frameSequence: framesRoot ? "frame_%04d.png at 60 fps" : null, + }, + geometry, + frameDomain, + interpretation: { + scope: "Prepared box-height trajectories and captured raw browser pixels.", + lowerIsSmoother: [ + "geometry.normalizedAccelerationRms", + "geometry.normalizedJerkRms", + "geometry.stationaryTransitionRatio", + "geometry.directionRuns.upToThreeFrameRatio", + "geometry.directionRuns.upToThreeFrameDistanceRatio", + "geometry.directionRuns.upToSixFrameRatio", + "geometry.directionRuns.upToSixFrameDistanceRatio", + "geometry.directionRuns.upToTwelveFrameRatio", + "geometry.directionRuns.upToTwelveFrameDistanceRatio", + "frameDomain.normalizedMotionEnergyFirstDifferenceRms", + "frameDomain.normalizedMotionEnergySecondDifferenceRms", + ], + qualification: "These metrics detect holds and uneven motion. They do not prove perceptual smoothness by themselves.", + relativeMovementQualification: + "Below-mean movement ratios describe the velocity distribution; minimizing them alone would erase physically expected easing at extrema.", + }, +}; + +const serialized = `${JSON.stringify(report, null, 2)}\n`; +if (outputPath) await writeFile(outputPath, serialized); +process.stdout.write(serialized); + +function analyzePreparedGeometry(bank) { + const transformIndices = preparedPresentationTransformIndices(bank); + const transforms = expandCityflowPreparedTransforms( + bank.transformTable, + bank.transformIndices.transformOffsets, + ); + const heights = Array.from({ length: bank.frameCount }, () => new Float64Array(bank.boxCount)); + for (let frameIndex = 0; frameIndex < bank.frameCount; frameIndex += 1) { + for (let boxIndex = 0; boxIndex < bank.boxCount; boxIndex += 1) { + const transformIndex = transformIndices[frameIndex * bank.boxCount + boxIndex]; + const transform = transforms[bank.transformIndices.transformOffsets[boxIndex] + transformIndex]; + heights[frameIndex][boxIndex] = preparedHeightScale(transform); + } + } + const velocities = circularDifference(heights); + const accelerations = circularDifference(velocities); + const jerks = circularDifference(accelerations); + const velocityRms = matrixRms(velocities); + const accelerationRms = matrixRms(accelerations); + const jerkRms = matrixRms(jerks); + const stationaryTransitionCount = velocities.reduce( + (count, row) => count + row.reduce( + (rowCount, value) => rowCount + Number(Math.abs(value) <= 1e-9), + 0, + ), + 0, + ); + const motionEnergy = velocities.map((row) => + row.reduce((sum, value) => sum + Math.abs(value), 0) / bank.boxCount); + const motionEnergyStats = statistics(motionEnergy); + const directionRuns = analyzeDirectionRuns(velocities); + const relativeMovement = analyzeRelativeMovement(velocities); + return { + metric: "circular-finite-differences-of-prepared-matrix-z-scale", + frameCount: bank.frameCount, + boxCount: bank.boxCount, + transitionCount: bank.frameCount * bank.boxCount, + velocityRms, + accelerationRms, + jerkRms, + normalizedAccelerationRms: divideOrNull(accelerationRms, velocityRms), + normalizedJerkRms: divideOrNull(jerkRms, velocityRms), + stationaryTransitionCount, + stationaryTransitionRatio: stationaryTransitionCount / (bank.frameCount * bank.boxCount), + directionRuns, + relativeMovement, + motionEnergy: motionEnergyStats, + }; +} + +function analyzeRelativeMovement(velocities) { + let belowTenthMeanCount = 0; + let belowQuarterMeanCount = 0; + for (let boxIndex = 0; boxIndex < velocities[0].length; boxIndex += 1) { + const movements = velocities.map((row) => Math.abs(row[boxIndex])); + const mean = movements.reduce((sum, value) => sum + value, 0) / movements.length; + belowTenthMeanCount += movements.filter((value) => value < mean * 0.1).length; + belowQuarterMeanCount += movements.filter((value) => value < mean * 0.25).length; + } + const transitionCount = velocities.length * velocities[0].length; + return { + metric: "absolute-transition-distance-relative-to-each-box-cycle-mean", + belowTenthMeanCount, + belowTenthMeanRatio: belowTenthMeanCount / transitionCount, + belowQuarterMeanCount, + belowQuarterMeanRatio: belowQuarterMeanCount / transitionCount, + }; +} + +function analyzeDirectionRuns(velocities) { + const runLengths = []; + const runDistances = []; + for (let boxIndex = 0; boxIndex < velocities[0].length; boxIndex += 1) { + const moving = velocities.map((row) => row[boxIndex]) + .filter((value) => Math.abs(value) > 1e-7); + if (moving.length === 0) continue; + const lengths = []; + const distances = []; + let direction = Math.sign(moving[0]); + let length = 0; + let distance = 0; + for (const velocity of moving) { + const nextDirection = Math.sign(velocity); + if (nextDirection !== direction) { + lengths.push(length); + distances.push(distance); + direction = nextDirection; + length = 0; + distance = 0; + } + length += 1; + distance += Math.abs(velocity); + } + if (lengths.length > 0 && direction === Math.sign(moving[0])) { + lengths[0] += length; + distances[0] += distance; + } else { + lengths.push(length); + distances.push(distance); + } + runLengths.push(...lengths); + runDistances.push(...distances); + } + const totalDistance = runDistances.reduce((sum, value) => sum + value, 0); + const shortRunCount = runLengths.filter((length) => length <= 3).length; + const shortRunDistance = runDistances.reduce( + (sum, distance, index) => sum + (runLengths[index] <= 3 ? distance : 0), + 0, + ); + const upToSixFrameCount = runLengths.filter((length) => length <= 6).length; + const upToSixFrameDistance = runDistances.reduce( + (sum, distance, index) => sum + (runLengths[index] <= 6 ? distance : 0), + 0, + ); + const upToTwelveFrameCount = runLengths.filter((length) => length <= 12).length; + const upToTwelveFrameDistance = runDistances.reduce( + (sum, distance, index) => sum + (runLengths[index] <= 12 ? distance : 0), + 0, + ); + const sortedLengths = [...runLengths].sort((left, right) => left - right); + return { + metric: "circular-nonstationary-same-direction-runs", + stationaryEpsilon: 1e-7, + count: runLengths.length, + oneFrameCount: runLengths.filter((length) => length === 1).length, + upToTwoFrameCount: runLengths.filter((length) => length <= 2).length, + upToThreeFrameCount: shortRunCount, + upToThreeFrameRatio: shortRunCount / runLengths.length, + upToThreeFrameDistanceRatio: divideOrNull(shortRunDistance, totalDistance), + upToSixFrameCount, + upToSixFrameRatio: upToSixFrameCount / runLengths.length, + upToSixFrameDistanceRatio: divideOrNull(upToSixFrameDistance, totalDistance), + upToTwelveFrameCount, + upToTwelveFrameRatio: upToTwelveFrameCount / runLengths.length, + upToTwelveFrameDistanceRatio: divideOrNull(upToTwelveFrameDistance, totalDistance), + medianFrames: percentile(sortedLengths, 0.5), + p95Frames: percentile(sortedLengths, 0.95), + }; +} + +async function analyzeRawFrameSequence(root) { + const temporaryRoot = await mkdtemp(join(tmpdir(), "csscityflow-vmafmotion-")); + const statsPath = join(temporaryRoot, "motion.log"); + try { + await execFileAsync("ffmpeg", [ + "-hide_banner", + "-loglevel", "error", + "-framerate", "60", + "-i", join(root, "frame_%04d.png"), + "-vf", `vmafmotion=stats_file=${statsPath}`, + "-f", "null", + "-", + ], { maxBuffer: 16 * 1024 * 1024 }); + const rows = (await readFile(statsPath, "utf8")) + .trim() + .split("\n") + .map((line) => { + const match = line.match(/^n:(\d+)\s+motion:([\d.]+)$/); + if (!match) throw new Error(`Unexpected vmafmotion row: ${line}`); + return { frame: Number.parseInt(match[1], 10), motion: Number.parseFloat(match[2]) }; + }); + if (rows.length < 2) throw new Error("Frame sequence produced fewer than two VMAF Motion rows"); + // Frame zero has no predecessor. Exclude its synthetic zero and retain every + // measured transition, including the duplicated loop-closing capture. + const motionEnergy = rows.slice(1).map(({ motion }) => motion); + const firstDifferences = linearDifference(motionEnergy); + const secondDifferences = linearDifference(firstDifferences); + const motionEnergyStats = statistics(motionEnergy); + return { + metric: "ffmpeg-vmafmotion-on-raw-numbered-png-sequence", + sourceFrameCount: rows.length, + measuredTransitionCount: motionEnergy.length, + motionEnergy: motionEnergyStats, + motionEnergyFirstDifferenceRms: vectorRms(firstDifferences), + motionEnergySecondDifferenceRms: vectorRms(secondDifferences), + normalizedMotionEnergyFirstDifferenceRms: divideOrNull( + vectorRms(firstDifferences), + motionEnergyStats.mean, + ), + normalizedMotionEnergySecondDifferenceRms: divideOrNull( + vectorRms(secondDifferences), + motionEnergyStats.mean, + ), + note: "VMAF Motion is a frame-domain motion-energy signal, not optical-flow tracking.", + }; + } finally { + await rm(temporaryRoot, { recursive: true, force: true }); + } +} + +function preparedPresentationTransformIndices(bank) { + const stateCount = bank.frameCount * bank.boxCount; + if (!Number.isInteger(bank.frameCount) || !Number.isInteger(bank.boxCount) || + bank.transformTable?.schema !== "csscityflow-prepared-transform-table@1" || + !Array.isArray(bank.transformIndices?.transformOffsets) || + bank.transformIndices.transformOffsets.length !== bank.boxCount + 1 || + typeof bank.transformIndices.presentationBase64 !== "string") { + throw new Error("Cityflow playback does not contain a complete prepared transform bank"); + } + const bytes = Buffer.from(bank.transformIndices.presentationBase64, "base64"); + if (bytes.byteLength !== stateCount * Uint16Array.BYTES_PER_ELEMENT) { + throw new Error("Cityflow prepared presentation transform indices are incomplete"); + } + const indices = new Uint16Array(stateCount); + for (let index = 0; index < stateCount; index += 1) { + const transformIndex = bytes.readUInt16LE(index * Uint16Array.BYTES_PER_ELEMENT); + const start = bank.transformIndices.transformOffsets[index % bank.boxCount]; + const end = bank.transformIndices.transformOffsets[index % bank.boxCount + 1]; + if (start + transformIndex >= end || end > bank.transformTable.count) { + throw new Error(`Cityflow prepared transform index ${index} is out of range`); + } + indices[index] = transformIndex; + } + return indices; +} + +function preparedHeightScale(transform) { + const match = transform.match(/^matrix3d\(([^)]+)\)$/u); + if (!match) throw new Error(`Prepared Cityflow transform is not matrix3d: ${transform}`); + const values = match[1].split(",").map(Number); + if (values.length !== 16 || values.some((value) => !Number.isFinite(value))) { + throw new Error(`Prepared Cityflow matrix3d is malformed: ${match[1]}`); + } + return values[10]; +} + +function circularDifference(rows) { + return rows.map((row, frameIndex) => { + const previous = rows[(frameIndex - 1 + rows.length) % rows.length]; + return Float64Array.from(row, (value, boxIndex) => value - previous[boxIndex]); + }); +} + +function linearDifference(values) { + return values.slice(1).map((value, index) => value - values[index]); +} + +function matrixRms(rows) { + let squaredSum = 0; + let count = 0; + for (const row of rows) { + for (const value of row) { + squaredSum += value * value; + count += 1; + } + } + return Math.sqrt(squaredSum / count); +} + +function vectorRms(values) { + return Math.sqrt(values.reduce((sum, value) => sum + value * value, 0) / values.length); +} + +function statistics(values) { + const sorted = [...values].sort((left, right) => left - right); + const mean = values.reduce((sum, value) => sum + value, 0) / values.length; + const standardDeviation = Math.sqrt( + values.reduce((sum, value) => sum + (value - mean) ** 2, 0) / values.length, + ); + return { + minimum: sorted[0], + p05: percentile(sorted, 0.05), + p50: percentile(sorted, 0.5), + p95: percentile(sorted, 0.95), + maximum: sorted.at(-1), + mean, + standardDeviation, + coefficientOfVariation: divideOrNull(standardDeviation, mean), + }; +} + +function percentile(sorted, fraction) { + const index = (sorted.length - 1) * fraction; + const lower = Math.floor(index); + const upper = Math.ceil(index); + const blend = index - lower; + return sorted[lower] * (1 - blend) + sorted[upper] * blend; +} + +function divideOrNull(numerator, denominator) { + return denominator === 0 ? null : numerator / denominator; +} + +function resolveArgument(name, fallback) { + return resolve(repositoryRoot, argumentValue(name) ?? fallback); +} + +function optionalResolvedArgument(name) { + const value = argumentValue(name); + return value ? resolve(repositoryRoot, value) : null; +} + +function argumentValue(name) { + const index = process.argv.indexOf(name); + if (index < 0) return null; + if (!process.argv[index + 1] || process.argv[index + 1].startsWith("--")) { + throw new Error(`${name} requires a value`); + } + return process.argv[index + 1]; +} diff --git a/src/adapters/cityflow/tools/audit-sparse-color-publication.mjs b/src/adapters/cityflow/tools/audit-sparse-color-publication.mjs new file mode 100644 index 0000000..72231ba --- /dev/null +++ b/src/adapters/cityflow/tools/audit-sparse-color-publication.mjs @@ -0,0 +1,206 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: HPND +import { spawn } from "node:child_process"; +import { mkdir, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { createServer } from "node:net"; +import { chromium } from "playwright"; +import sharp from "sharp"; + +const adapterRoot = resolve(import.meta.dirname, ".."); +const repositoryRoot = resolve(adapterRoot, "../../.."); +const outputRoot = resolve( + repositoryRoot, + "bench/results/csscityflow/sparse-color-publication", + new Date().toISOString().replaceAll(":", "-"), +); +const port = await freePort(); +const route = `http://127.0.0.1:${port}/cityflow/`; +let serverOutput = ""; +const server = spawn("pnpm", [ + "exec", "vite", "--config", resolve(adapterRoot, "vite.config.mjs"), + "--host", "127.0.0.1", "--port", String(port), "--strictPort", +], { cwd: repositoryRoot, stdio: ["ignore", "pipe", "pipe"] }); +server.stdout.on("data", (chunk) => { serverOutput += chunk.toString(); }); +server.stderr.on("data", (chunk) => { serverOutput += chunk.toString(); }); + +let browser; +try { + await mkdir(outputRoot, { recursive: true }); + await waitForServer(); + browser = await chromium.launch({ channel: "chrome", headless: true }); + const viewports = []; + for (const viewport of [ + { id: "normal", width: 1280, height: 720 }, + { id: "wide", width: 2560, height: 1224 }, + ]) { + viewports.push(await auditViewport(viewport)); + } + const report = { + schema: "csscityflow-sparse-color-publication-audit@1", + status: "pixel-exact", + route, + browser: { name: "Google Chrome", version: browser.version(), headless: true }, + comparison: + "sequential-sparse-product-publication-versus-same-state-full-face-color-publication", + viewports, + }; + const reportPath = resolve(outputRoot, "report.json"); + await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`); + console.log(JSON.stringify({ ...report, reportPath }, null, 2)); +} finally { + await browser?.close(); + server.kill("SIGTERM"); +} + +async function auditViewport(viewport) { + const candidate = await openPage(viewport); + const reference = await openPage(viewport); + try { + await Promise.all([ + initializeCandidate(candidate), + initializeReference(reference), + ]); + const before = await candidate.evaluate(() => globalThis.__csscityflow.player.stats()); + let comparedPixelCount = 0; + for (let step = 0; step <= before.frameCount; step += 1) { + const frameIndex = step % before.frameCount; + await Promise.all([ + candidate.evaluate((index) => globalThis.__csscityflow.player.seekFrame(index), frameIndex), + reference.evaluate((index) => { + const state = globalThis.__csscityflowFullColorReference; + globalThis.__csscityflow.player.seekFrame(index); + const roots = document.querySelectorAll(".csscityflow-box"); + for (let boxIndex = 0; boxIndex < state.playback.boxCount; boxIndex += 1) { + const material = state.playback.colors.materials[ + state.materialIndices[index * state.playback.boxCount + boxIndex] + ]; + const leaves = roots[boxIndex].children; + for (let localFaceIndex = 0; + localFaceIndex < state.playback.facesPerBox; localFaceIndex += 1) { + leaves[localFaceIndex].style.backgroundColor = material[localFaceIndex]; + } + } + }, frameIndex), + ]); + const [candidatePng, referencePng] = await Promise.all([ + candidate.screenshot({ type: "png" }), + reference.screenshot({ type: "png" }), + ]); + if (!candidatePng.equals(referencePng)) { + const [candidatePixels, referencePixels] = await Promise.all([ + sharp(candidatePng).raw().toBuffer({ resolveWithObject: true }), + sharp(referencePng).raw().toBuffer({ resolveWithObject: true }), + ]); + const changedBytes = candidatePixels.data.reduce((sum, value, index) => + sum + Number(value !== referencePixels.data[index]), 0); + if (changedBytes !== 0) { + const candidatePath = resolve(outputRoot, + `${viewport.id}-frame-${String(frameIndex).padStart(3, "0")}-sparse.png`); + const referencePath = resolve(outputRoot, + `${viewport.id}-frame-${String(frameIndex).padStart(3, "0")}-full.png`); + await Promise.all([ + writeFile(candidatePath, candidatePng), + writeFile(referencePath, referencePng), + ]); + throw new Error(`Cityflow ${viewport.id} sparse color publication changed ` + + `${changedBytes} pixel bytes at frame ${frameIndex}: ` + + `${candidatePath} ${referencePath}`); + } + } + comparedPixelCount += viewport.width * viewport.height; + } + const after = await candidate.evaluate(() => globalThis.__csscityflow.player.stats()); + return Object.freeze({ + ...viewport, + deviceScaleFactor: 1, + comparedFrameCount: before.frameCount + 1, + comparedPixelCount, + changedPixelBytes: 0, + publicationDelta: after.publicationCount - before.publicationCount, + shapeStyleWriteDelta: after.shapeStyleWrites - before.shapeStyleWrites, + leafColorStyleWriteDelta: after.leafColorStyleWrites - before.leafColorStyleWrites, + visibilityStyleWriteDelta: after.visibilityStyleWrites - before.visibilityStyleWrites, + preparedStateSkipDelta: after.preparedStateSkipCount - before.preparedStateSkipCount, + identityStable: after.identityStable, + }); + } finally { + await Promise.all([candidate.close(), reference.close()]); + } +} + +async function openPage({ width, height }) { + const page = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: 1 }); + await page.route("**/favicon.ico", (request) => request.fulfill({ status: 204 })); + const errors = []; + page.on("pageerror", (error) => errors.push(error.stack || error.message)); + page.on("console", (message) => { + if (message.type() === "error") errors.push(message.text()); + }); + await page.goto(route, { waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => globalThis.__csscityflow?.ready || + globalThis.__csscityflow?.errors?.length, null, { timeout: 30_000 }); + if (errors.length || !await page.evaluate(() => globalThis.__csscityflow?.ready)) { + throw new Error(`Cityflow sparse publication audit page failed: ${JSON.stringify(errors)}`); + } + await page.addStyleTag({ content: ` + .examples-sidebar, .example-info { display: none !important; } + .example-stage { position: fixed !important; inset: 0 !important; } + ` }); + return page; +} + +async function initializeCandidate(page) { + await page.evaluate(async () => { + globalThis.__csscityflow.player.pause(); + globalThis.__csscityflow.player.seekFrame(0); + await new Promise((resolveFrame) => requestAnimationFrame(resolveFrame)); + }); +} + +async function initializeReference(page) { + await page.evaluate(async () => { + globalThis.__csscityflow.player.pause(); + globalThis.__csscityflow.player.seekFrame(0); + const response = await fetch("/csscityflow/cityflow.playback.json", { cache: "no-store" }); + if (!response.ok) throw new Error(`Cityflow playback failed: ${response.status}`); + const playback = await response.json(); + const binary = atob(playback.colors.presentationMaterialIndicesBase64); + const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0)); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const materialIndices = Uint16Array.from({ length: bytes.byteLength / 2 }, (_, index) => + view.getUint16(index * 2, true)); + globalThis.__csscityflowFullColorReference = { playback, materialIndices }; + await new Promise((resolveFrame) => requestAnimationFrame(resolveFrame)); + }); +} + +async function freePort() { + const socket = createServer(); + await new Promise((resolveListen, reject) => { + socket.once("error", reject); + socket.listen(0, "127.0.0.1", resolveListen); + }); + const address = socket.address(); + await new Promise((resolveClose) => socket.close(resolveClose)); + if (!address || typeof address === "string") { + throw new Error("Cityflow sparse publication audit port is unavailable"); + } + return address.port; +} + +async function waitForServer() { + const deadline = Date.now() + 20_000; + while (Date.now() < deadline) { + if (server.exitCode !== null) { + throw new Error(`Cityflow sparse publication server exited early:\n${serverOutput}`); + } + try { + if ((await fetch(route)).ok) return; + } catch { + // The server is still starting. + } + await new Promise((resolveWait) => setTimeout(resolveWait, 50)); + } + throw new Error(`Cityflow sparse publication server did not start:\n${serverOutput}`); +} diff --git a/src/adapters/cityflow/tools/capture-preview.mjs b/src/adapters/cityflow/tools/capture-preview.mjs new file mode 100644 index 0000000..8b24f0e --- /dev/null +++ b/src/adapters/cityflow/tools/capture-preview.mjs @@ -0,0 +1,52 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: HPND +import { mkdir } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { chromium } from "playwright"; +import sharp from "sharp"; +import { createServer } from "vite"; + +const repositoryRoot = resolve(import.meta.dirname, "../../../.."); +const outputPath = resolve(repositoryRoot, "site/public/landing/cityflow.webp"); +const sidebarPath = resolve(repositoryRoot, "site/public/landing/sidebar/cityflow.webp"); +const server = await createServer({ + configFile: resolve(repositoryRoot, "src/adapters/cityflow/vite.config.mjs"), + logLevel: "error", + server: { host: "127.0.0.1", port: 4217, strictPort: true, hmr: false }, +}); +let browser; +try { + await server.listen(); + browser = await chromium.launch({ channel: "chrome", headless: true }); + const page = await browser.newPage({ viewport: { width: 960, height: 540 }, deviceScaleFactor: 1 }); + await page.route("**/landing/cityflow.webp", (route) => route.fulfill({ status: 204, body: "" })); + await page.goto("http://127.0.0.1:4217/", { waitUntil: "domcontentloaded", timeout: 30_000 }); + await page.waitForFunction(() => globalThis.__csscityflow?.ready === true, null, { timeout: 30_000 }); + await page.evaluate(async () => { + globalThis.__csscityflow.player.pause(); + globalThis.__csscityflow.player.seekFrame(92); + await new Promise((resolvePaint) => requestAnimationFrame(() => requestAnimationFrame(resolvePaint))); + }); + await page.addStyleTag({ content: ` + .examples-sidebar, .example-info { display: none !important; } + .example-stage { position: fixed !important; inset: 0 !important; } + ` }); + await page.evaluate(() => new Promise((resolvePaint) => + requestAnimationFrame(() => requestAnimationFrame(resolvePaint)))); + const png = await page.screenshot({ type: "png" }); + await mkdir(dirname(sidebarPath), { recursive: true }); + await sharp(png).webp({ lossless: true, effort: 6 }).toFile(outputPath); + await sharp(png).resize(480, 270).webp({ lossless: true, effort: 6 }).toFile(sidebarPath); + for (const path of [outputPath, sidebarPath]) { + const metadata = await sharp(path).metadata(); + const expectedWidth = path === outputPath ? 960 : 480; + const expectedHeight = path === outputPath ? 540 : 270; + if (metadata.width !== expectedWidth || metadata.height !== expectedHeight) { + throw new Error(`Cityflow preview dimensions drifted: ${path}`); + } + } + console.log(JSON.stringify({ status: "prepared", outputPath, sidebarPath }, null, 2)); +} finally { + await browser?.close(); + await server.close(); +} diff --git a/src/adapters/cityflow/tools/check-prepare-determinism.mjs b/src/adapters/cityflow/tools/check-prepare-determinism.mjs index ead2f2e..c0435ec 100644 --- a/src/adapters/cityflow/tools/check-prepare-determinism.mjs +++ b/src/adapters/cityflow/tools/check-prepare-determinism.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +// SPDX-License-Identifier: HPND import { createHash } from "node:crypto"; import { execFile } from "node:child_process"; import { readFile, readdir } from "node:fs/promises"; diff --git a/src/adapters/cityflow/tools/explore-motion-easing.mjs b/src/adapters/cityflow/tools/explore-motion-easing.mjs new file mode 100644 index 0000000..1fb535a --- /dev/null +++ b/src/adapters/cityflow/tools/explore-motion-easing.mjs @@ -0,0 +1,232 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: HPND +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +const repositoryRoot = resolve(import.meta.dirname, "../../../.."); +const playbackPath = resolve( + repositoryRoot, + "build/generated/public/csscityflow/cityflow.playback.json", +); +const playback = JSON.parse(await readFile(playbackPath, "utf8")); +const sourceRows = heightRows(playback); +const exponents = [0, 0.35, 0.5, 0.65, 0.75, 0.8, 0.85, 0.9, 0.95, 1, 1.15, 1.35]; +const variants = Object.fromEntries(exponents.map((exponent) => [ + `sine-power-${exponent}`, + analyze(refitDirectionRuns(sourceRows, () => sinePowerEase(exponent))), +])); +variants.currentPreparedPlayback = analyze(sourceRows); +variants.smoothstep = analyze(refitDirectionRuns(sourceRows, () => smoothstep)); +variants.smootherstep = analyze(refitDirectionRuns(sourceRows, () => smootherstep)); +for (const centerReduction of [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7]) { + variants[`smooth-sine-center-reduction-${centerReduction}`] = analyze(refitDirectionRuns( + sourceRows, + () => smoothSineEase(centerReduction), + )); +} +for (const threshold of [18, 24, 30, 36, 42, 48, 60]) { + for (const centerReduction of [0.2, 0.3, 0.4, 0.5]) { + variants[`adaptive-duration-${threshold}-reduction-${centerReduction}`] = analyze( + refitDirectionRuns(sourceRows, ({ duration }) => + duration >= threshold ? smoothSineEase(centerReduction) : cosineEase), + ); + } +} +for (const startDuration of [18, 24, 30]) { + for (const fullDuration of [42, 48, 54, 60]) { + for (const maximumReduction of [0.4, 0.5, 0.6]) { + variants[ + `adaptive-ramp-${startDuration}-${fullDuration}-reduction-${maximumReduction}` + ] = analyze(refitDirectionRuns(sourceRows, ({ duration }) => { + const mix = Math.max(0, Math.min(1, + (duration - startDuration) / (fullDuration - startDuration))); + return smoothSineEase(maximumReduction * mix); + })); + } + } +} + +process.stdout.write(`${JSON.stringify({ + schema: "csscityflow-motion-easing-experiment@1", + inputSchema: playback.schema, + note: "Variants preserve the current prepared extrema frames and heights. Sine power 1 and smooth-sine center reduction 0 model the prior cosine curve. Adaptive variants keep cosine on short runs.", + variants, +}, null, 2)}\n`); + +function heightRows(bank) { + return Array.from({ length: bank.frameCount }, (_, frameIndex) => + Float64Array.from({ length: bank.boxCount }, (_, boxIndex) => { + const style = bank.presentationShapeStyles[frameIndex * bank.boxCount + boxIndex]; + const matrix = style.slice("transform:matrix3d(".length, style.indexOf(");")).split(","); + return Number(matrix[10]); + })); +} + +function refitDirectionRuns(rows, easingForRun) { + const frameCount = rows.length; + const boxCount = rows[0].length; + const output = Array.from({ length: frameCount }, () => new Float64Array(boxCount)); + for (let boxIndex = 0; boxIndex < boxCount; boxIndex += 1) { + const velocities = Array.from({ length: frameCount }, (_, frameIndex) => + rows[frameIndex][boxIndex] - rows[(frameIndex - 1 + frameCount) % frameCount][boxIndex]); + const movingFrameIndices = velocities + .map((velocity, frameIndex) => Math.abs(velocity) > 1e-7 ? frameIndex : -1) + .filter((frameIndex) => frameIndex >= 0); + const runs = circularDirectionRuns(movingFrameIndices.map((frameIndex) => + Math.sign(velocities[frameIndex]))); + const extrema = runs.map((run, runIndex) => { + const lastMovingFrameIndex = movingFrameIndices[ + (run.start + run.length - 1) % movingFrameIndices.length + ]; + const nextMovingFrameIndex = movingFrameIndices[runs[(runIndex + 1) % runs.length].start]; + const stationarySpan = (nextMovingFrameIndex - lastMovingFrameIndex + frameCount) % frameCount; + return { + frameIndex: (lastMovingFrameIndex + Math.floor(stationarySpan / 2)) % frameCount, + height: rows[lastMovingFrameIndex][boxIndex], + }; + }).sort((left, right) => left.frameIndex - right.frameIndex); + for (let extremaIndex = 0; extremaIndex < extrema.length; extremaIndex += 1) { + const start = extrema[extremaIndex]; + const end = extrema[(extremaIndex + 1) % extrema.length]; + const duration = (end.frameIndex - start.frameIndex + frameCount) % frameCount; + const easing = easingForRun({ + amplitude: Math.abs(end.height - start.height), + boxIndex, + duration, + end, + start, + }); + for (let offset = 0; offset < duration; offset += 1) { + const fraction = easing(offset / duration); + output[(start.frameIndex + offset) % frameCount][boxIndex] = + start.height + (end.height - start.height) * fraction; + } + } + } + return output; +} + +function sinePowerEase(exponent) { + const cdf = normalizedCdf((fraction) => Math.sin(Math.PI * fraction) ** exponent); + return (fraction) => sampleCdf(cdf, fraction); +} + +function smoothSineEase(centerReduction) { + const cdf = normalizedCdf((fraction) => { + const sine = Math.sin(Math.PI * fraction); + return sine * (1 - centerReduction * sine * sine); + }); + return (fraction) => sampleCdf(cdf, fraction); +} + +function normalizedCdf(weightAt) { + const resolution = 65_536; + const values = new Float64Array(resolution + 1); + let integral = 0; + let previous = 0; + for (let index = 1; index <= resolution; index += 1) { + const x = index / resolution; + const current = weightAt(x); + integral += (previous + current) / (2 * resolution); + values[index] = integral; + previous = current; + } + for (let index = 1; index <= resolution; index += 1) values[index] /= integral; + return values; +} + +function cosineEase(fraction) { + return (1 - Math.cos(Math.PI * fraction)) / 2; +} + +function smoothstep(fraction) { + return fraction * fraction * (3 - 2 * fraction); +} + +function smootherstep(fraction) { + return fraction ** 3 * (fraction * (fraction * 6 - 15) + 10); +} + +function sampleCdf(cdf, fraction) { + const position = fraction * (cdf.length - 1); + const index = Math.floor(position); + const remainder = position - index; + return cdf[index] + (cdf[Math.min(index + 1, cdf.length - 1)] - cdf[index]) * remainder; +} + +function circularDirectionRuns(directions) { + const runs = []; + let direction = directions[0]; + let start = 0; + for (let index = 1; index < directions.length; index += 1) { + if (directions[index] === direction) continue; + runs.push({ direction, start, length: index - start }); + direction = directions[index]; + start = index; + } + runs.push({ direction, start, length: directions.length - start }); + if (runs.length > 1 && runs[0].direction === runs.at(-1).direction) { + runs[0] = { + direction: runs[0].direction, + start: runs.at(-1).start, + length: runs[0].length + runs.at(-1).length, + }; + runs.pop(); + } + return runs; +} + +function analyze(rows) { + const velocities = circularDifference(rows); + const accelerations = circularDifference(velocities); + const jerks = circularDifference(accelerations); + const velocityRms = rms(velocities); + const absoluteVelocities = Array.from({ length: rows[0].length }, () => []); + for (const row of velocities) { + row.forEach((value, boxIndex) => absoluteVelocities[boxIndex].push(Math.abs(value))); + } + let belowTenth = 0; + let belowQuarter = 0; + let stationary = 0; + for (const values of absoluteVelocities) { + const mean = values.reduce((sum, value) => sum + value, 0) / values.length; + belowTenth += values.filter((value) => value < mean * 0.1).length; + belowQuarter += values.filter((value) => value < mean * 0.25).length; + stationary += values.filter((value) => value <= 1e-9).length; + } + const motionEnergy = velocities.map((row) => + row.reduce((sum, value) => sum + Math.abs(value), 0) / row.length); + const motionMean = motionEnergy.reduce((sum, value) => sum + value, 0) / motionEnergy.length; + const motionDeviation = Math.sqrt(motionEnergy.reduce( + (sum, value) => sum + (value - motionMean) ** 2, + 0, + ) / motionEnergy.length); + const transitionCount = rows.length * rows[0].length; + return { + normalizedAccelerationRms: rms(accelerations) / velocityRms, + normalizedJerkRms: rms(jerks) / velocityRms, + stationaryTransitionRatio: stationary / transitionCount, + belowTenthMeanMovementRatio: belowTenth / transitionCount, + belowQuarterMeanMovementRatio: belowQuarter / transitionCount, + motionEnergyCoefficientOfVariation: motionDeviation / motionMean, + motionEnergyMinimum: Math.min(...motionEnergy), + motionEnergyMaximum: Math.max(...motionEnergy), + }; +} + +function circularDifference(rows) { + return rows.map((row, frameIndex) => { + const previous = rows[(frameIndex - 1 + rows.length) % rows.length]; + return Float64Array.from(row, (value, boxIndex) => value - previous[boxIndex]); + }); +} + +function rms(rows) { + let sum = 0; + let count = 0; + for (const row of rows) for (const value of row) { + sum += value * value; + count += 1; + } + return Math.sqrt(sum / count); +} diff --git a/src/adapters/cityflow/tools/generate-wide-visibility.mjs b/src/adapters/cityflow/tools/generate-wide-visibility.mjs new file mode 100644 index 0000000..96b47b3 --- /dev/null +++ b/src/adapters/cityflow/tools/generate-wide-visibility.mjs @@ -0,0 +1,172 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: HPND +import { spawnSync } from "node:child_process"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { + buildCityflowPreparedHeightRows, + buildCityflowSourceFrames, +} from "../src/prepare/csscityflow/model.mjs"; +import { ensureCityflowSourceTree } from "../src/prepare/csscityflow/sourceAuthority.mjs"; +import { + CSSCITYFLOW_FACE_IDS, + CSSCITYFLOW_PRESENTATION_FRAME_COUNT, + CSSCITYFLOW_SEED, + buildCityflowSourceState, +} from "../src/prepare/csscityflow/sourceModel.mjs"; + +const adapterRoot = resolve(import.meta.dirname, ".."); +const repositoryRoot = resolve(adapterRoot, "../../.."); +const sourceIdentity = await ensureCityflowSourceTree(); +const outputPath = resolve(adapterRoot, "notes/references/prepared-visibility-wide.json"); +const buildRoot = resolve(repositoryRoot, "build/oracles/cityflow/native-wide-visibility"); +const viewport = Object.freeze({ width: 2560, height: 1224 }); +const state = buildCityflowSourceState({ bankId: "desktop" }); +const sourceFrames = buildCityflowSourceFrames(state); +const presentationHeights = buildCityflowPreparedHeightRows(state, sourceFrames); +const faceCount = state.boxes.length * CSSCITYFLOW_FACE_IDS.length; +const rowBytes = Math.ceil(faceCount / 8); +await mkdir(buildRoot, { recursive: true }); +const binary = await compileCapture(); +const presentationRows = await captureRows({ + binary, + label: "presentation", + heightRows: presentationHeights, +}); +const sourceRows = await captureRows({ + binary, + label: "source", + heightRows: sourceFrames.map((frame) => frame.boxes.map((box) => box.height)), +}); +const transitionDilationFrames = 0; +const rows = presentationRows; +const encoded = encodeRows(rows, faceCount, state.boxes.length); +const sourceEncoded = encodeRows(sourceRows, faceCount, state.boxes.length); +const report = { + schema: "csscityflow-prepared-wide-visibility-source@1", + sourceRevision: sourceIdentity.revision, + seed: CSSCITYFLOW_SEED, + frameCount: rows.length, + sourceFrameCount: sourceRows.length, + faceCount, + viewport, + selection: "stage-width-greater-than-two-times-stage-height", + transitionDilationFrames, + ...encoded, + source: { + schema: "csscityflow-prepared-wide-source-visibility@1", + frameCount: sourceRows.length, + transitionDilationFrames: 0, + ...sourceEncoded, + }, + provenance: { + method: "pinned-native-cityflow-depth-and-backface-id-buffer-wide-source-viewport", + presentation: "schema-18-prepared-height-bank-plus-exact-source-height-rows", + sourceViewportBranch: "width-greater-than-height-times-two-square-viewport", + }, +}; +await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`); +console.log(JSON.stringify({ outputPath, ...report, initialVisibleBitsBase64: "[encoded]", transitionOffsetsBase64: "[encoded]", transitionFaceIndicesBase64: "[encoded]" }, null, 2)); + +async function compileCapture() { + if (process.platform !== "darwin") throw new Error("Cityflow native visibility generation requires macOS CGL"); + const sourceRoot = sourceIdentity.sourceRoot; + for (const name of ["colors.c", "colors.h", "hsv.c", "hsv.h"]) { + await writeFile(join(buildRoot, name), await readFile(join(sourceRoot, "utils", name))); + } + const binary = join(buildRoot, "capture-cityflow-visibility"); + const sdk = run("xcrun", ["--sdk", "macosx", "--show-sdk-path"]).stdout.trim(); + run("clang", [ + "-std=gnu11", "-O2", "-Wno-deprecated-declarations", + "-DHAVE_UNISTD_H", "-DGETTIMEOFDAY_TWO_ARGS", + "-isysroot", sdk, + "-include", join(adapterRoot, "tools/native/headless/include/xlockmore.h"), + "-I", join(adapterRoot, "tools/native/headless/include"), + "-I", buildRoot, + "-I", join(sourceRoot, "hacks/glx"), + "-I", join(sourceRoot, "utils"), + join(adapterRoot, "tools/native/headless/capture-cityflow-visibility.c"), + join(sourceRoot, "hacks/glx/gltrackball.c"), + join(sourceRoot, "hacks/glx/trackball.c"), + join(sourceRoot, "hacks/glx/quaternion.c"), + join(sourceRoot, "utils/yarandom.c"), + join(buildRoot, "colors.c"), + join(buildRoot, "hsv.c"), + "-framework", "OpenGL", "-lm", "-o", binary, + ]); + return binary; +} + +async function captureRows({ binary, label, heightRows }) { + const heightPath = join(buildRoot, `${label}-heights-f64le.bin`); + const visibilityPath = join(buildRoot, `${label}-visibility.bin`); + const bytes = Buffer.alloc(heightRows.length * state.boxes.length * 8); + let offset = 0; + for (const row of heightRows) { + for (const height of row) { + bytes.writeDoubleLE(height, offset); + offset += 8; + } + } + await writeFile(heightPath, bytes); + run(binary, [ + visibilityPath, + String(CSSCITYFLOW_SEED), + String(state.boxes.length), + String(viewport.width), + String(viewport.height), + String(heightRows.length), + heightPath, + ]); + const captured = await readFile(visibilityPath); + if (captured.length !== heightRows.length * rowBytes) { + throw new Error(`Cityflow ${label} visibility byte count drifted`); + } + return Array.from({ length: heightRows.length }, (_, frameIndex) => + Buffer.from(captured.subarray(frameIndex * rowBytes, (frameIndex + 1) * rowBytes))); +} + +function encodeRows(rows, count, boxCount) { + const offsets = [0]; + const indices = []; + for (let frameIndex = 0; frameIndex < rows.length; frameIndex += 1) { + const previous = rows[(frameIndex - 1 + rows.length) % rows.length]; + for (let faceIndex = 0; faceIndex < count; faceIndex += 1) { + if (bit(rows[frameIndex], faceIndex) !== bit(previous, faceIndex)) indices.push(faceIndex); + } + offsets.push(indices.length); + } + const offsetBytes = Buffer.alloc(offsets.length * 2); + const indexBytes = Buffer.alloc(indices.length * 2); + offsets.forEach((value, index) => offsetBytes.writeUInt16LE(value, index * 2)); + indices.forEach((value, index) => indexBytes.writeUInt16LE(value, index * 2)); + const counts = rows.map((row) => Array.from({ length: count }, (_, index) => bit(row, index)) + .reduce((sum, value) => sum + value, 0)); + const boxCounts = rows.map((row) => Array.from({ length: boxCount }, (_, boxIndex) => + Number(CSSCITYFLOW_FACE_IDS.some((_, faceIndex) => + bit(row, boxIndex * CSSCITYFLOW_FACE_IDS.length + faceIndex)))).reduce((sum, value) => sum + value, 0)); + return { + initialVisibleCount: counts[0], + minimumVisibleFaces: Math.min(...counts), + maximumVisibleFaces: Math.max(...counts), + meanVisibleFaces: counts.reduce((sum, value) => sum + value, 0) / counts.length, + minimumVisibleBoxes: Math.min(...boxCounts), + maximumVisibleBoxes: Math.max(...boxCounts), + meanVisibleBoxes: boxCounts.reduce((sum, value) => sum + value, 0) / boxCounts.length, + transitionCount: indices.length, + initialVisibleBitsBase64: rows[0].toString("base64"), + transitionOffsetsBase64: offsetBytes.toString("base64"), + transitionFaceIndicesBase64: indexBytes.toString("base64"), + }; +} + +function bit(row, index) { + return row[index >> 3] >> (index & 7) & 1; +} + +function run(command, args) { + const result = spawnSync(command, args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(result.stderr || result.stdout || `${command} failed`); + return result; +} diff --git a/src/adapters/cityflow/tools/native/capture-cityflow-state.c b/src/adapters/cityflow/tools/native/capture-cityflow-state.c index a8fa1bd..f810a06 100644 --- a/src/adapters/cityflow/tools/native/capture-cityflow-state.c +++ b/src/adapters/cityflow/tools/native/capture-cityflow-state.c @@ -1,3 +1,4 @@ +/* SPDX-License-Identifier: HPND */ #include #include #include diff --git a/src/adapters/cityflow/tools/native/headless/capture-cityflow-visibility.c b/src/adapters/cityflow/tools/native/headless/capture-cityflow-visibility.c new file mode 100644 index 0000000..226eb27 --- /dev/null +++ b/src/adapters/cityflow/tools/native/headless/capture-cityflow-visibility.c @@ -0,0 +1,173 @@ +/* SPDX-License-Identifier: HPND */ +#define main capture_cityflow_color_main +#include "capture-cityflow.c" +#undef main + +static void draw_visibility_ids(ModeInfo *mi, unsigned char *visible, Bool advance); +static void emit_face(cube *box, int face, unsigned int encoded_id); + +int +main(int argc, char **argv) +{ + ModeInfo mi; + unsigned int seed; + int width; + int height; + int frame_count; + size_t row_bytes; + unsigned char *visible; + FILE *stream; + FILE *height_stream = NULL; + double *heights = NULL; + + if (argc != 7 && argc != 8) { + fprintf(stderr, "usage: %s output.bin seed count width height frames [float64-heights.bin]\n", argv[0]); + return 2; + } + seed = (unsigned int)parse_positive(argv[2], "seed"); + width = (int)parse_positive(argv[4], "width"); + height = (int)parse_positive(argv[5], "height"); + frame_count = (int)parse_positive(argv[6], "frames"); + memset(&mi, 0, sizeof(mi)); + mi.width = width; + mi.height = height; + mi.count = (int)parse_positive(argv[3], "count"); + row_bytes = ((size_t)mi.count * 3 + 7) / 8; + visible = calloc(row_bytes, 1); + if (!visible) abort(); + stream = fopen(argv[1], "wb"); + if (!stream) { + perror("open visibility output"); + return 3; + } + if (argc == 8) { + height_stream = fopen(argv[7], "rb"); + heights = malloc((size_t)mi.count * sizeof(*heights)); + if (!height_stream || !heights) { + perror("open visibility heights"); + return 4; + } + } + + create_offscreen_context(width, height); + glBindFramebufferEXT(GL_FRAMEBUFFER_EXT, capture_framebuffer); + glDrawBuffer(GL_COLOR_ATTACHMENT0_EXT); + glReadBuffer(GL_COLOR_ATTACHMENT0_EXT); +#undef ya_rand_init + ya_rand_init(seed); + wave_count = 6; + wave_speed = 25; + wave_radius = 256; + skew = 12; + init_cube(&mi); + + for (int frame = 0; frame < frame_count; frame++) { + if (height_stream) { + if (fread(heights, sizeof(*heights), (size_t)mi.count, height_stream) != (size_t)mi.count) { + fprintf(stderr, "visibility heights ended at frame %d\n", frame); + return 5; + } + for (int box_index = 0; box_index < mi.count; box_index++) { + ccs[MI_SCREEN(&mi)].cubes[box_index].h = (GLfloat)heights[box_index]; + } + } + memset(visible, 0, row_bytes); + draw_visibility_ids(&mi, visible, !height_stream); + if (fwrite(visible, 1, row_bytes, stream) != row_bytes) return 6; + } + + if (height_stream && fclose(height_stream) != 0) return 7; + if (fclose(stream) != 0) return 8; + free(heights); + free(visible); + free_cube(&mi); + free(ccs); + ccs = NULL; + destroy_offscreen_context(); + return 0; +} + +static void +draw_visibility_ids(ModeInfo *mi, unsigned char *visible, Bool advance) +{ + cube_configuration *cc = &ccs[MI_SCREEN(mi)]; + int width = MI_WIDTH(mi); + int height = MI_HEIGHT(mi); + size_t pixel_count = (size_t)width * (size_t)height; + unsigned char *rgba = malloc(pixel_count * 4); + if (!rgba) abort(); + + if (advance) { + interference(mi); + animate_cubes(mi); + } + glDisable(GL_LIGHTING); + glDisable(GL_DITHER); + glEnable(GL_DEPTH_TEST); + glEnable(GL_CULL_FACE); + glShadeModel(GL_FLAT); + glClearColor(0, 0, 0, 1); + glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); + glPushMatrix(); + glRotatef(current_device_rotation(), 0, 0, 1); + gltrackball_rotate(cc->trackball); + glRotatef(-180, 1, 0, 0); + glScalef(15, 15, 15); + glRotatef(-90, 1, 0, 0); + glTranslatef(-0.18, 0, -0.18); + glRotatef(37, 1, 0, 0); + glRotatef(20, 0, 0, 1); + glScalef(2.1, 2.1, 2.1); + glBegin(GL_QUADS); + for (int box_index = 0; box_index < cc->ncubes; box_index++) { + for (int face = 0; face < 3; face++) { + emit_face(&cc->cubes[box_index], face, (unsigned int)(box_index * 3 + face + 1)); + } + } + glEnd(); + glPopMatrix(); + glFinish(); + glPixelStorei(GL_PACK_ALIGNMENT, 1); + glReadPixels(0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, rgba); + if (glGetError() != GL_NO_ERROR) abort(); + for (size_t pixel = 0; pixel < pixel_count; pixel++) { + unsigned int encoded_id = (unsigned int)rgba[pixel * 4] | + ((unsigned int)rgba[pixel * 4 + 1] << 8); + if (encoded_id == 0 || encoded_id > (unsigned int)cc->ncubes * 3) continue; + unsigned int face_index = encoded_id - 1; + visible[face_index >> 3] |= 1 << (face_index & 7); + } + free(rgba); +} + +static void +emit_face(cube *box, int face, unsigned int encoded_id) +{ + GLfloat cth = box->cth; + GLfloat sth = box->sth; + GLfloat x = cth * box->x + sth * box->y; + GLfloat y = -sth * box->x + cth * box->y; + GLfloat w = box->w / 2; + GLfloat h = box->h / 2; + GLfloat d = box->d / 2; + GLfloat bottom = 5; + GLfloat xw = cth * w, xd = sth * d; + GLfloat yw = -sth * w, yd = cth * d; + glColor3ub(encoded_id & 255, (encoded_id >> 8) & 255, 0); + if (face == 0) { + glVertex3f(x+xw+xd, y+yw+yd, -h); + glVertex3f(x+xw-xd, y+yw-yd, -h); + glVertex3f(x-xw-xd, y-yw-yd, -h); + glVertex3f(x-xw+xd, y-yw+yd, -h); + } else if (face == 1) { + glVertex3f(x+xw+xd, y+yw+yd, bottom); + glVertex3f(x+xw+xd, y+yw+yd, -h); + glVertex3f(x-xw+xd, y-yw+yd, -h); + glVertex3f(x-xw+xd, y-yw+yd, bottom); + } else { + glVertex3f(x+xw-xd, y+yw-yd, -h); + glVertex3f(x+xw+xd, y+yw+yd, -h); + glVertex3f(x+xw+xd, y+yw+yd, bottom); + glVertex3f(x+xw-xd, y+yw-yd, bottom); + } +} diff --git a/src/adapters/cityflow/tools/native/headless/capture-cityflow.c b/src/adapters/cityflow/tools/native/headless/capture-cityflow.c index e3e9a33..ae9b86f 100644 --- a/src/adapters/cityflow/tools/native/headless/capture-cityflow.c +++ b/src/adapters/cityflow/tools/native/headless/capture-cityflow.c @@ -1,3 +1,4 @@ +/* SPDX-License-Identifier: HPND */ #include #include #include diff --git a/src/adapters/cityflow/tools/native/headless/include/screenhackI.h b/src/adapters/cityflow/tools/native/headless/include/screenhackI.h index 47d4505..eac82ea 100644 --- a/src/adapters/cityflow/tools/native/headless/include/screenhackI.h +++ b/src/adapters/cityflow/tools/native/headless/include/screenhackI.h @@ -1,3 +1,4 @@ +/* SPDX-License-Identifier: HPND */ #ifndef CSSCITYFLOW_HEADLESS_SCREENHACK_I_H #define CSSCITYFLOW_HEADLESS_SCREENHACK_I_H diff --git a/src/adapters/cityflow/tools/native/headless/include/utils.h b/src/adapters/cityflow/tools/native/headless/include/utils.h index 8c24eeb..4173182 100644 --- a/src/adapters/cityflow/tools/native/headless/include/utils.h +++ b/src/adapters/cityflow/tools/native/headless/include/utils.h @@ -1,3 +1,4 @@ +/* SPDX-License-Identifier: HPND */ #ifndef CSSCITYFLOW_HEADLESS_UTILS_H #define CSSCITYFLOW_HEADLESS_UTILS_H diff --git a/src/adapters/cityflow/tools/native/headless/include/visual.h b/src/adapters/cityflow/tools/native/headless/include/visual.h index a40c842..eaaa204 100644 --- a/src/adapters/cityflow/tools/native/headless/include/visual.h +++ b/src/adapters/cityflow/tools/native/headless/include/visual.h @@ -1,3 +1,4 @@ +/* SPDX-License-Identifier: HPND */ #ifndef CSSCITYFLOW_HEADLESS_VISUAL_H #define CSSCITYFLOW_HEADLESS_VISUAL_H diff --git a/src/adapters/cityflow/tools/native/headless/include/xlockmore.h b/src/adapters/cityflow/tools/native/headless/include/xlockmore.h index 8dedebc..8dab4da 100644 --- a/src/adapters/cityflow/tools/native/headless/include/xlockmore.h +++ b/src/adapters/cityflow/tools/native/headless/include/xlockmore.h @@ -1,3 +1,4 @@ +/* SPDX-License-Identifier: HPND */ #ifndef CSSCITYFLOW_HEADLESS_XLOCKMORE_H #define CSSCITYFLOW_HEADLESS_XLOCKMORE_H diff --git a/src/adapters/cityflow/tools/oracle/ablate-visibility-diff-faces.mjs b/src/adapters/cityflow/tools/oracle/ablate-visibility-diff-faces.mjs new file mode 100644 index 0000000..3065fb4 --- /dev/null +++ b/src/adapters/cityflow/tools/oracle/ablate-visibility-diff-faces.mjs @@ -0,0 +1,137 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: HPND +import { resolve } from "node:path"; +import { chromium } from "playwright"; +import sharp from "sharp"; + +const route = process.env.CSSCITYFLOW_DIFF_FACE_URL ?? "http://127.0.0.1:4325/cityflow/"; +const width = positiveInteger("CSSCITYFLOW_DIFF_FACE_WIDTH", 2473); +const height = positiveInteger("CSSCITYFLOW_DIFF_FACE_HEIGHT", 1236); +const frameIndex = nonNegativeInteger("CSSCITYFLOW_DIFF_FACE_FRAME", 253); +const referencePath = resolve(process.env.CSSCITYFLOW_DIFF_FACE_REFERENCE ?? + `bench/results/csscityflow/static-visibility/unculled-wide/frames/frame_${pad(frameIndex)}.png`); +const candidatePath = resolve(process.env.CSSCITYFLOW_DIFF_FACE_CANDIDATE ?? + `bench/results/csscityflow/product-visibility/after-wide/frames/frame_${pad(frameIndex)}.png`); +const reference = await rawRgb(referencePath); +const candidate = await rawRgb(candidatePath); +const expected = diff(reference, candidate); +const browser = await chromium.launch({ channel: "chrome", headless: true }); +try { + const page = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: 1 }); + await page.goto(route, { waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => globalThis.__csscityflow?.ready, null, { timeout: 30_000 }); + await page.addStyleTag({ content: ` + .examples-sidebar, .example-info { display: none !important; } + .example-stage { position: fixed !important; inset: 0 !important; } + ` }); + const hiddenFaceIndices = await page.evaluate(async (nextFrameIndex) => { + const player = globalThis.__csscityflow.player; + player.pause(); + player.seekFrame(nextFrameIndex); + const playback = await (await fetch("/csscityflow/cityflow.playback.json", { + cache: "no-store", + })).json(); + const decodeUint16 = (base64) => { + const bytes = Uint8Array.from(atob(base64), (character) => character.charCodeAt(0)); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + return Uint16Array.from({ length: bytes.byteLength / 2 }, (_, index) => + view.getUint16(index * 2, true)); + }; + const transformIndices = decodeUint16(playback.transformIndices.presentationBase64); + const materialIndices = decodeUint16(playback.colors.presentationMaterialIndicesBase64); + const roots = [...document.querySelectorAll(".csscityflow-box")]; + const leaves = roots.flatMap((root) => [...root.children]); + for (let boxIndex = 0; boxIndex < playback.boxCount; boxIndex += 1) { + roots[boxIndex].style.transform = player.preparedTransformAt( + boxIndex, + transformIndices[nextFrameIndex * playback.boxCount + boxIndex], + ); + const material = playback.colors.materials[ + materialIndices[nextFrameIndex * playback.boxCount + boxIndex] + ]; + [...roots[boxIndex].children].forEach((leaf, faceIndex) => { + leaf.style.backgroundColor = material[faceIndex]; + }); + } + await new Promise((resolveFrame) => requestAnimationFrame(resolveFrame)); + const staticallyHidden = new Set(playback.staticVisibility.hiddenFaceIndices); + return leaves.map((leaf, faceIndex) => ({ + faceIndex, + visibility: getComputedStyle(leaf).visibility, + })).filter(({ faceIndex, visibility }) => + visibility === "hidden" && !staticallyHidden.has(faceIndex)) + .map(({ faceIndex }) => faceIndex); + }, frameIndex); + const live = await rawRgb(await page.screenshot()); + const liveDiff = diff(reference, live); + if (liveDiff.changedPixels !== expected.changedPixels || + liveDiff.maximumChannelDelta !== expected.maximumChannelDelta) { + throw new Error(`Cityflow live candidate drifted: ${JSON.stringify({ expected, liveDiff })}`); + } + const ranked = []; + for (const faceIndex of hiddenFaceIndices) { + await page.evaluate((nextFaceIndex) => { + document.querySelectorAll(".csscityflow-box>b")[nextFaceIndex] + .style.visibility = ""; + }, faceIndex); + const restoredDiff = diff(reference, await rawRgb(await page.screenshot())); + ranked.push({ + faceIndex, + restoredChangedPixels: restoredDiff.changedPixels, + changedPixelReduction: expected.changedPixels - restoredDiff.changedPixels, + maximumChannelDelta: restoredDiff.maximumChannelDelta, + }); + await page.evaluate((nextFaceIndex) => { + document.querySelectorAll(".csscityflow-box>b")[nextFaceIndex] + .style.visibility = "hidden"; + }, faceIndex); + } + ranked.sort((left, right) => right.changedPixelReduction - left.changedPixelReduction); + console.log(JSON.stringify({ + frameIndex, + hiddenFaceCount: hiddenFaceIndices.length, + baseline: expected, + positiveRestorations: ranked.filter(({ changedPixelReduction }) => + changedPixelReduction > 0), + }, null, 2)); +} finally { + await browser.close(); +} + +async function rawRgb(input) { + return (await sharp(input).removeAlpha().raw().toBuffer({ resolveWithObject: true })).data; +} + +function diff(reference, candidate) { + if (reference.byteLength !== candidate.byteLength) { + throw new Error("Cityflow visibility-diff frame geometry drifted"); + } + let changedPixels = 0; + let maximumChannelDelta = 0; + for (let byteIndex = 0; byteIndex < reference.byteLength; byteIndex += 3) { + let changed = false; + for (let channel = 0; channel < 3; channel += 1) { + const delta = Math.abs(reference[byteIndex + channel] - candidate[byteIndex + channel]); + changed ||= delta !== 0; + maximumChannelDelta = Math.max(maximumChannelDelta, delta); + } + changedPixels += Number(changed); + } + return { changedPixels, maximumChannelDelta }; +} + +function positiveInteger(name, fallback) { + const value = Number.parseInt(process.env[name] ?? String(fallback), 10); + if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${name} must be positive`); + return value; +} + +function nonNegativeInteger(name, fallback) { + const value = Number.parseInt(process.env[name] ?? String(fallback), 10); + if (!Number.isSafeInteger(value) || value < 0) throw new Error(`${name} must be non-negative`); + return value; +} + +function pad(value) { + return String(value).padStart(4, "0"); +} diff --git a/src/adapters/cityflow/tools/oracle/audit-static-suppression.mjs b/src/adapters/cityflow/tools/oracle/audit-static-suppression.mjs new file mode 100644 index 0000000..5ceb76b --- /dev/null +++ b/src/adapters/cityflow/tools/oracle/audit-static-suppression.mjs @@ -0,0 +1,116 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: HPND +import { resolve } from "node:path"; +import { chromium } from "playwright"; +import sharp from "sharp"; + +const route = process.env.CSSCITYFLOW_STATIC_AUDIT_URL ?? "http://127.0.0.1:4325/cityflow/"; +const referenceRoot = resolve(process.env.CSSCITYFLOW_STATIC_AUDIT_REFERENCE ?? + "bench/results/csscityflow/static-visibility/unculled-wide/frames"); +const width = Number.parseInt(process.env.CSSCITYFLOW_STATIC_AUDIT_WIDTH ?? "2473", 10); +const height = Number.parseInt(process.env.CSSCITYFLOW_STATIC_AUDIT_HEIGHT ?? "1236", 10); +const frameIndices = (process.env.CSSCITYFLOW_STATIC_AUDIT_FRAMES ?? + "41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,192,193,194,195,196,198,202,203") + .split(",").map(Number); + +const browser = await chromium.launch({ channel: "chrome", headless: true }); +try { + const page = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: 1 }); + await page.goto(route, { waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => globalThis.__csscityflow?.ready, null, { timeout: 30_000 }); + await page.addStyleTag({ content: ` + .examples-sidebar, .example-info { display: none !important; } + .example-stage { position: fixed !important; inset: 0 !important; } + ` }); + const hiddenBoxIndices = await page.evaluate(async () => { + globalThis.__csscityflow.player.pause(); + const response = await fetch("/csscityflow/cityflow.playback.json", { cache: "no-store" }); + if (!response.ok) throw new Error(`Cityflow static audit playback failed: ${response.status}`); + const playback = await response.json(); + const decodeUint16 = (base64) => { + const bytes = Uint8Array.from(atob(base64), (character) => character.charCodeAt(0)); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + return Uint16Array.from({ length: bytes.byteLength / 2 }, (_, index) => + view.getUint16(index * 2, true)); + }; + globalThis.__csscityflowStaticAudit = { + playback, + transformIndices: decodeUint16(playback.transformIndices.presentationBase64), + materialIndices: decodeUint16(playback.colors.presentationMaterialIndicesBase64), + }; + return playback.staticVisibility.hiddenBoxIndices; + }); + const results = Object.fromEntries(hiddenBoxIndices.map((boxIndex) => [boxIndex, { + changedFrameCount: 0, + changedPixelCount: 0, + maximumChangedPixelsPerFrame: 0, + maximumChannelDelta: 0, + }])); + for (const frameIndex of frameIndices) { + await page.evaluate((nextFrameIndex) => { + const { playback, transformIndices, materialIndices } = globalThis.__csscityflowStaticAudit; + globalThis.__csscityflow.player.seekFrame(nextFrameIndex); + const roots = [...document.querySelectorAll(".csscityflow-box")]; + for (const boxIndex of playback.staticVisibility.hiddenBoxIndices) { + const root = roots[boxIndex]; + root.style.setProperty("display", "block", "important"); + root.style.transform = globalThis.__csscityflow.player.preparedTransformAt( + boxIndex, + transformIndices[nextFrameIndex * playback.boxCount + boxIndex], + ); + const material = playback.colors.materials[ + materialIndices[nextFrameIndex * playback.boxCount + boxIndex] + ]; + [...root.children].forEach((leaf, faceIndex) => { + leaf.style.setProperty("display", "block", "important"); + leaf.style.backgroundColor = material[faceIndex]; + }); + } + return new Promise((resolveFrame) => requestAnimationFrame(resolveFrame)); + }, frameIndex); + const reference = await sharp(resolve( + referenceRoot, + `frame_${String(frameIndex).padStart(4, "0")}.png`, + )).removeAlpha().raw().toBuffer(); + for (const boxIndex of hiddenBoxIndices) { + await page.evaluate((nextBoxIndex) => { + document.querySelectorAll(".csscityflow-box")[nextBoxIndex] + .style.setProperty("display", "none", "important"); + }, boxIndex); + const candidate = await sharp(await page.screenshot()).removeAlpha().raw().toBuffer(); + await page.evaluate((nextBoxIndex) => { + document.querySelectorAll(".csscityflow-box")[nextBoxIndex] + .style.setProperty("display", "block", "important"); + }, boxIndex); + let changedPixelCount = 0; + let maximumChannelDelta = 0; + for (let byteIndex = 0; byteIndex < reference.length; byteIndex += 3) { + const channelDelta = Math.max( + Math.abs(reference[byteIndex] - candidate[byteIndex]), + Math.abs(reference[byteIndex + 1] - candidate[byteIndex + 1]), + Math.abs(reference[byteIndex + 2] - candidate[byteIndex + 2]), + ); + changedPixelCount += Number(channelDelta !== 0); + maximumChannelDelta = Math.max(maximumChannelDelta, channelDelta); + } + if (changedPixelCount > 0) results[boxIndex].changedFrameCount += 1; + results[boxIndex].changedPixelCount += changedPixelCount; + results[boxIndex].maximumChangedPixelsPerFrame = Math.max( + results[boxIndex].maximumChangedPixelsPerFrame, + changedPixelCount, + ); + results[boxIndex].maximumChannelDelta = Math.max( + results[boxIndex].maximumChannelDelta, + maximumChannelDelta, + ); + } + } + console.log(JSON.stringify({ + schema: "csscityflow-static-suppression-audit@1", + viewport: { width, height, deviceScaleFactor: 1 }, + frameIndices, + results, + }, null, 2)); +} finally { + await browser.close(); +} diff --git a/src/adapters/cityflow/tools/oracle/build-product-visibility.mjs b/src/adapters/cityflow/tools/oracle/build-product-visibility.mjs new file mode 100644 index 0000000..541d10f --- /dev/null +++ b/src/adapters/cityflow/tools/oracle/build-product-visibility.mjs @@ -0,0 +1,173 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: HPND +import { readFile, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +const normalPath = resolve(process.env.CSSCITYFLOW_PRODUCT_VISIBILITY_NORMAL ?? + "bench/results/csscityflow/static-visibility/current/normal-2472x1236.json"); +const widePath = resolve(process.env.CSSCITYFLOW_PRODUCT_VISIBILITY_WIDE ?? + "bench/results/csscityflow/static-visibility/current/wide-threshold-2473x1236.json"); +const ultrawidePath = resolve(process.env.CSSCITYFLOW_PRODUCT_VISIBILITY_ULTRAWIDE ?? + "bench/results/csscityflow/static-visibility/current/ultrawide-3086x1440.json"); +const outputPath = resolve(process.env.CSSCITYFLOW_PRODUCT_VISIBILITY_OUT ?? + "src/adapters/cityflow/notes/references/prepared-static-visibility.json"); +const [normal, wide, ultrawide] = await Promise.all([normalPath, widePath, ultrawidePath].map(async (path) => + JSON.parse(await readFile(path, "utf8")))); +const frameCount = 301; +const boxCount = 200; +const facesPerBox = 3; +const faceCount = boxCount * facesPerBox; +const transitionDilationFrames = Number.parseInt( + process.env.CSSCITYFLOW_PRODUCT_VISIBILITY_DILATION_FRAMES ?? "12", + 10, +); +if (!Number.isSafeInteger(transitionDilationFrames) || transitionDilationFrames < 0 || + transitionDilationFrames > 12) { + throw new Error("Cityflow product visibility dilation must be an integer from 0 through 12"); +} +if (normal.frameCount !== frameCount || wide.frameCount !== frameCount || + ultrawide.frameCount !== frameCount || normal.faceCount !== faceCount || + wide.faceCount !== faceCount || ultrawide.faceCount !== faceCount || + normal.viewport.width !== 2472 || normal.viewport.height !== 1236 || + wide.viewport.width !== 2473 || wide.viewport.height !== 1236 || + ultrawide.viewport.width !== 3086 || ultrawide.viewport.height !== 1440) { + throw new Error("Cityflow product visibility inputs drifted"); +} +const sourceRows = unionRows([normal, wide, ultrawide].map(decodeRows)); +const sourceBoxRows = sourceRows.map((row) => Uint8Array.from( + { length: boxCount }, + (_, boxIndex) => Number(Array.from({ length: facesPerBox }, (_, faceIndex) => + row[boxIndex * facesPerBox + faceIndex]).some(Boolean)), +)); +const sortingDependencyBoxIndices = Object.freeze([155, 156, 165, 171, 172, 179, 185, 196]); +const hiddenBoxIndices = Object.freeze([68, 69, 176, 190, 199]); +const hiddenFaceIndices = Object.freeze(hiddenBoxIndices.flatMap((boxIndex) => + Array.from({ length: facesPerBox }, (_, faceIndex) => boxIndex * facesPerBox + faceIndex))); +if (hiddenFaceIndices.some((faceIndex) => sourceRows.some((row) => row[faceIndex] !== 0))) { + throw new Error("Cityflow static hidden face became visible in a supported projection sample"); +} +const presentationRows = dilateBoxVisibility(sourceBoxRows, transitionDilationFrames); +for (const row of presentationRows) { + for (const boxIndex of sortingDependencyBoxIndices) row[boxIndex] = 1; +} +const presentation = buildBoxPresentation(presentationRows); +const report = { + schema: "csscityflow-prepared-static-visibility@3", + sourceRevision: "906693799e4fb7581436590cf84ecb2d3c9186ba", + seed: 26081702, + frameCount, + boxCount, + facesPerBox, + faceCount, + visibleFaceCount: faceCount - hiddenFaceIndices.length, + hiddenFaceCount: hiddenFaceIndices.length, + visibleBoxCount: boxCount - hiddenBoxIndices.length, + hiddenBoxCount: hiddenBoxIndices.length, + hiddenFaceIndices, + hiddenBoxIndices, + sortingDependencyBoxIndices, + presentation, + coverage: { + browser: "Google Chrome 152.0.7977.65 headless DPR 1", + captureMethod: "solid-face-id-exact-interior-pixel-census-plus-full-frame-raster-diff", + projectionViewports: [ + { width: 2472, height: 1236, branch: "normal-maximum-aspect" }, + { width: 2473, height: 1236, branch: "wide-maximum-stage-height" }, + { width: 3086, height: 1440, branch: "ultrawide-supported-stage" }, + ], + policy: + "whole-box-visibility-union-plus-full-frame-css-3d-sorting-dependencies", + }, +}; +await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`); +console.log(JSON.stringify({ outputPath, presentation: report.presentation }, null, 2)); + +function unionRows(rowSets) { + return Array.from({ length: frameCount }, (_, frameIndex) => Uint8Array.from( + { length: faceCount }, + (_, faceIndex) => rowSets.some((rows) => rows[frameIndex][faceIndex] !== 0) ? 1 : 0, + )); +} + +function dilateBoxVisibility(rows, radius) { + return rows.map((_, frameIndex) => Uint8Array.from( + { length: boxCount }, + (_, boxIndex) => Number(Array.from({ length: radius * 2 + 1 }, (_, offset) => + rows[(frameIndex - radius + offset + frameCount) % frameCount][boxIndex]).some(Boolean)), + )); +} + +function buildBoxPresentation(rows) { + const alwaysVisibleBoxIndices = Object.freeze( + Array.from({ length: boxCount }, (_, boxIndex) => boxIndex) + .filter((boxIndex) => rows.every((row) => row[boxIndex] !== 0)), + ); + const initial = Buffer.alloc(Math.ceil(boxCount / 8)); + for (let boxIndex = 0; boxIndex < boxCount; boxIndex += 1) { + if (rows[0][boxIndex] !== 0) initial[boxIndex >> 3] |= 1 << (boxIndex & 7); + } + const transitionOffsets = [0]; + const transitionBoxIndices = []; + for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) { + const previousFrameIndex = (frameIndex - 1 + frameCount) % frameCount; + for (let boxIndex = 0; boxIndex < boxCount; boxIndex += 1) { + if (rows[frameIndex][boxIndex] !== rows[previousFrameIndex][boxIndex]) { + transitionBoxIndices.push(boxIndex); + } + } + transitionOffsets.push(transitionBoxIndices.length); + } + const offsetBytes = Buffer.alloc(transitionOffsets.length * Uint16Array.BYTES_PER_ELEMENT); + const indexBytes = Buffer.alloc(transitionBoxIndices.length * Uint16Array.BYTES_PER_ELEMENT); + transitionOffsets.forEach((value, index) => offsetBytes.writeUInt16LE(value, index * 2)); + transitionBoxIndices.forEach((value, index) => indexBytes.writeUInt16LE(value, index * 2)); + const visibleBoxCounts = rows.map((row) => row.reduce((sum, value) => sum + value, 0)); + const visibleFaceCounts = visibleBoxCounts.map((count) => count * facesPerBox); + return { + schema: "csscityflow-prepared-presentation-box-visibility@1", + encoding: "initial-box-bitset-plus-u16le-per-target-frame-toggle-offsets-and-box-indices", + frameCount, + boxCount, + faceCount, + transitionDilationFrames, + alwaysVisibleBoxIndices, + initialVisibleCount: visibleFaceCounts[0], + initialVisibleBoxes: visibleBoxCounts[0], + minimumVisibleFaces: Math.min(...visibleFaceCounts), + maximumVisibleFaces: Math.max(...visibleFaceCounts), + meanVisibleFaces: visibleFaceCounts.reduce((sum, value) => sum + value, 0) / frameCount, + minimumVisibleBoxes: Math.min(...visibleBoxCounts), + maximumVisibleBoxes: Math.max(...visibleBoxCounts), + meanVisibleBoxes: visibleBoxCounts.reduce((sum, value) => sum + value, 0) / frameCount, + transitionCount: transitionBoxIndices.length, + maximumTransitionWritesPerFrame: Math.max(...transitionOffsets.slice(1) + .map((offset, index) => offset - transitionOffsets[index])), + initialVisibleBoxBitsBase64: initial.toString("base64"), + transitionOffsetsBase64: offsetBytes.toString("base64"), + transitionBoxIndicesBase64: indexBytes.toString("base64"), + policy: `viewport-independent-whole-box-only-three-projection-union-with-${transitionDilationFrames}-frame-dilation-plus-full-frame-sorting-dependencies`, + }; +} + +function decodeRows(report) { + const initial = Buffer.from(report.initialVisibleBitsBase64, "base64"); + const offsets = decodeUint16(report.transitionOffsetsBase64); + const indices = decodeUint16(report.transitionFaceIndicesBase64); + const visible = Uint8Array.from({ length: report.faceCount }, (_, faceIndex) => + initial[faceIndex >> 3] >> (faceIndex & 7) & 1); + const rows = [visible.slice()]; + for (let frameIndex = 1; frameIndex < report.frameCount; frameIndex += 1) { + for (let cursor = offsets[frameIndex]; cursor < offsets[frameIndex + 1]; cursor += 1) { + visible[indices[cursor]] ^= 1; + } + rows.push(visible.slice()); + } + return rows; +} + +function decodeUint16(base64) { + const bytes = Buffer.from(base64, "base64"); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + return Uint16Array.from({ length: bytes.byteLength / 2 }, (_, index) => + view.getUint16(index * 2, true)); +} diff --git a/src/adapters/cityflow/tools/oracle/capture-motion-face-audit.mjs b/src/adapters/cityflow/tools/oracle/capture-motion-face-audit.mjs new file mode 100644 index 0000000..06a8fec --- /dev/null +++ b/src/adapters/cityflow/tools/oracle/capture-motion-face-audit.mjs @@ -0,0 +1,481 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: HPND +import { execFile } from "node:child_process"; +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { promisify } from "node:util"; +import { chromium } from "playwright"; +import sharp from "sharp"; + +const execFileAsync = promisify(execFile); +const repositoryRoot = resolve(import.meta.dirname, "../../../../.."); +const generatedAt = new Date().toISOString(); +const outputRoot = resolve( + repositoryRoot, + "bench/results/csscityflow/motion-face-audit", + generatedAt.replaceAll(":", "-"), +); +const liveVideoRoot = resolve(outputRoot, "live-video-raw"); +const culledFramesRoot = resolve(outputRoot, "culled-frames"); +const unculledFramesRoot = resolve(outputRoot, "unculled-frames"); +const presentationFramesRoot = resolve(outputRoot, "presentation-frames"); +const route = process.env.CSSCITYFLOW_MOTION_AUDIT_URL ?? + "http://127.0.0.1:4325/cityflow/"; +const width = 1_280; +const height = 720; +const liveDurationMilliseconds = 6_000; +const sampleRateHz = 60; +const sampleCount = 302; + +await rm(outputRoot, { recursive: true, force: true }); +await Promise.all([ + mkdir(liveVideoRoot, { recursive: true }), + mkdir(culledFramesRoot, { recursive: true }), + mkdir(unculledFramesRoot, { recursive: true }), + mkdir(presentationFramesRoot, { recursive: true }), +]); +const response = await fetch(route); +if (!response.ok) throw new Error(`Cityflow motion-audit route failed: ${response.status}`); + +const browser = await chromium.launch({ channel: "chrome", headless: true }); +let browserVersion; +try { + browserVersion = browser.version(); + const live = await captureLiveVideo(browser); + const sequence = await captureMatchedSequence(browser); + const culledVideo = resolve(outputRoot, "culled-60fps.mp4"); + const unculledVideo = resolve(outputRoot, "unculled-60fps.mp4"); + const presentationVideo = resolve(outputRoot, "presentation-60fps.mp4"); + await Promise.all([ + encodeSequence(culledFramesRoot, culledVideo), + encodeSequence(unculledFramesRoot, unculledVideo), + encodeSequence(presentationFramesRoot, presentationVideo), + ]); + const report = { + schema: "csscityflow-motion-face-audit@2", + generatedAt, + route, + browser: { + name: "Google Chrome", + channel: "chrome", + version: browserVersion, + headless: true, + }, + viewport: { width, height, deviceScaleFactor: 1 }, + live, + sequence: { + ...sequence, + sampleRateHz, + sampleCount, + culledFramesRoot, + unculledFramesRoot, + presentationFramesRoot, + culledVideo, + unculledVideo, + presentationVideo, + }, + }; + const reportPath = resolve(outputRoot, "capture.json"); + await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`); + console.log(JSON.stringify({ ...report, reportPath, outputRoot }, null, 2)); +} finally { + await browser.close(); +} + +async function captureLiveVideo(browserHandle) { + const context = await browserHandle.newContext({ + viewport: { width, height }, + deviceScaleFactor: 1, + recordVideo: { dir: liveVideoRoot, size: { width, height } }, + }); + const page = await context.newPage(); + const errors = collectErrors(page); + await openReadyScene(page); + const video = page.video(); + const telemetry = await page.evaluate(async ({ durationMilliseconds }) => { + const player = globalThis.__csscityflow.player; + player.pause(); + player.seekFrame(0); + await new Promise((resolveFrame) => requestAnimationFrame(() => requestAnimationFrame(resolveFrame))); + const samples = []; + const startedAt = performance.now(); + player.resume(); + await new Promise((resolveSamples) => { + function sample(timestamp) { + const stats = player.stats(); + samples.push({ + callback: samples.length, + elapsedMilliseconds: timestamp - startedAt, + frameIndex: stats.frameIndex, + frameCount: stats.frameCount, + visibleFaceCount: stats.visibleFaceCount, + shapeStyleWrites: stats.lastPublication.shapeStyleWrites, + visibilityWrites: stats.lastPublication.visibilityWrites, + publicationCount: stats.publicationCount, + }); + if (timestamp - startedAt >= durationMilliseconds) resolveSamples(); + else requestAnimationFrame(sample); + } + requestAnimationFrame(sample); + }); + player.pause(); + return { + durationMilliseconds, + callbackCount: samples.length, + transformAnimationCount: [...document.querySelectorAll(".csscityflow-box")].reduce( + (sum, root) => sum + root.getAnimations().length, + 0, + ), + skippedPreparedStateCount: samples.slice(1).reduce((sum, sample, index) => { + const previous = samples[index]; + const publicationDelta = sample.publicationCount - previous.publicationCount; + if (publicationDelta === 0) return sum; + const frameDelta = (sample.frameIndex - previous.frameIndex + sample.frameCount) % + sample.frameCount; + return sum + Number(publicationDelta !== 1 || frameDelta !== 1); + }, 0), + samples, + }; + }, { durationMilliseconds: liveDurationMilliseconds }); + const videoPath = resolve(outputRoot, "cityflow-live.webm"); + await page.close(); + await context.close(); + await video.saveAs(videoPath); + if (errors.length > 0 || telemetry.transformAnimationCount !== 0 || + telemetry.skippedPreparedStateCount !== 0) { + throw new Error(`Cityflow live-video capture failed: ${JSON.stringify({ + errors, + transformAnimationCount: telemetry.transformAnimationCount, + skippedPreparedStateCount: telemetry.skippedPreparedStateCount, + })}`); + } + return { videoPath, telemetry }; +} + +async function captureMatchedSequence(browserHandle) { + const context = await browserHandle.newContext({ + viewport: { width, height }, + deviceScaleFactor: 1, + }); + const culledPage = await context.newPage(); + const unculledPage = await context.newPage(); + const presentationPage = await context.newPage(); + const culledErrors = collectErrors(culledPage); + const unculledErrors = collectErrors(unculledPage); + const presentationErrors = collectErrors(presentationPage); + await Promise.all([ + openReadyScene(culledPage), + openReadyScene(unculledPage), + openReadyScene(presentationPage), + ]); + await Promise.all([ + installGeometryAuditStyles(culledPage, false), + installGeometryAuditStyles(unculledPage, true), + ]); + await unculledPage.evaluate(async () => { + const response = await fetch("/csscityflow/cityflow.playback.json", { cache: "no-store" }); + if (!response.ok) throw new Error(`Cityflow audit playback failed: ${response.status}`); + globalThis.__csscityflowAuditPlayback = await response.json(); + }); + const frameMilliseconds = await culledPage.evaluate(() => { + const player = globalThis.__csscityflow.player; + player.pause(); + player.seekFrame(0); + return player.stats().frameMilliseconds; + }); + await unculledPage.evaluate(() => { + globalThis.__csscityflow.player.pause(); + globalThis.__csscityflow.player.seekFrame(0); + }); + await presentationPage.evaluate(() => { + globalThis.__csscityflow.player.pause(); + globalThis.__csscityflow.player.seekFrame(0); + }); + const samples = []; + for (let sampleIndex = 0; sampleIndex < sampleCount; sampleIndex += 1) { + const presentationTimeMilliseconds = sampleIndex * 1_000 / sampleRateHz; + const frameIndex = Math.floor( + (presentationTimeMilliseconds + 0.001) / frameMilliseconds, + ) % 301; + await Promise.all([ + setAuditTime(culledPage, presentationTimeMilliseconds), + setAuditTime(unculledPage, presentationTimeMilliseconds, true), + setAuditTime(presentationPage, presentationTimeMilliseconds), + ]); + const frameName = `frame_${String(sampleIndex).padStart(4, "0")}.png`; + await Promise.all([ + culledPage.screenshot({ path: resolve(culledFramesRoot, frameName) }), + unculledPage.screenshot({ path: resolve(unculledFramesRoot, frameName) }), + presentationPage.screenshot({ path: resolve(presentationFramesRoot, frameName) }), + ]); + samples.push({ sampleIndex, presentationTimeMilliseconds, frameIndex }); + } + await context.close(); + const errors = [...culledErrors, ...unculledErrors, ...presentationErrors]; + if (errors.length > 0) throw new Error(`Cityflow matched-sequence capture failed: ${errors.join("\n")}`); + const samplesPath = resolve(outputRoot, "sequence-samples.json"); + await writeFile(samplesPath, `${JSON.stringify(samples, null, 2)}\n`); + const faceComparison = await compareMatchedFrames(); + const temporalDeltas = { + culledGeometry: await compareConsecutiveFrames(culledFramesRoot), + unculledGeometry: await compareConsecutiveFrames(unculledFramesRoot), + presentation: await compareConsecutiveFrames(presentationFramesRoot), + }; + if (faceComparison.largestStrongMismatchComponentPixelCount > 1) { + throw new Error(`Cityflow matched-sequence face comparison failed: ${JSON.stringify( + faceComparison, + )}`); + } + return { frameMilliseconds, samplesPath, faceComparison, temporalDeltas }; +} + +async function compareConsecutiveFrames(framesRoot) { + const rows = []; + let previous = await rawFrame(framesRoot, 0); + for (let sampleIndex = 1; sampleIndex < sampleCount; sampleIndex += 1) { + const current = await rawFrame(framesRoot, sampleIndex); + let channelAbsoluteDelta = 0; + let changedPixelCount = 0; + for (let byteIndex = 0; byteIndex < current.data.length; byteIndex += 3) { + const delta = Math.abs(current.data[byteIndex] - previous.data[byteIndex]) + + Math.abs(current.data[byteIndex + 1] - previous.data[byteIndex + 1]) + + Math.abs(current.data[byteIndex + 2] - previous.data[byteIndex + 2]); + channelAbsoluteDelta += delta; + changedPixelCount += Number(delta !== 0); + } + rows.push({ + fromSampleIndex: sampleIndex - 1, + toSampleIndex: sampleIndex, + channelAbsoluteDelta, + changedPixelCount, + }); + previous = current; + } + const ordered = rows.map(({ channelAbsoluteDelta }) => channelAbsoluteDelta) + .sort((left, right) => left - right); + return { + intervalCount: rows.length, + zeroDeltaIntervalCount: rows.filter(({ channelAbsoluteDelta }) => + channelAbsoluteDelta === 0).length, + minimumChannelAbsoluteDelta: ordered[0], + medianChannelAbsoluteDelta: percentile(ordered, 0.5), + p95ChannelAbsoluteDelta: percentile(ordered, 0.95), + maximumChannelAbsoluteDelta: ordered.at(-1), + loopClosureInterval: rows.at(-1), + worstIntervals: [...rows].sort((left, right) => + right.channelAbsoluteDelta - left.channelAbsoluteDelta).slice(0, 10), + }; +} + +async function rawFrame(framesRoot, sampleIndex) { + return sharp(resolve(framesRoot, `frame_${String(sampleIndex).padStart(4, "0")}.png`)) + .removeAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); +} + +function percentile(values, fraction) { + if (values.length === 0) return 0; + return values[Math.floor((values.length - 1) * fraction)]; +} + +async function compareMatchedFrames() { + const frameNames = Array.from( + { length: sampleCount }, + (_, sampleIndex) => `frame_${String(sampleIndex).padStart(4, "0")}.png`, + ); + const strongBlackMaximum = 64; + const strongWhiteMinimum = 192; + let totalPixelCount = 0; + let changedPixelCount = 0; + let strongMismatchPixelCount = 0; + let largestStrongMismatchComponentPixelCount = 0; + let maxChannelDelta = 0; + let worstFrame = null; + + for (const frameName of frameNames) { + const [culled, unculled] = await Promise.all([ + sharp(resolve(culledFramesRoot, frameName)).removeAlpha().raw().toBuffer({ + resolveWithObject: true, + }), + sharp(resolve(unculledFramesRoot, frameName)).removeAlpha().raw().toBuffer({ + resolveWithObject: true, + }), + ]); + const pixelCount = culled.info.width * culled.info.height; + let frameChangedPixelCount = 0; + let frameStrongMismatchPixelCount = 0; + let frameMaxChannelDelta = 0; + const frameStrongMismatchPixels = new Set(); + for (let byteIndex = 0; byteIndex < culled.data.length; byteIndex += 3) { + const pixelIndex = byteIndex / 3; + const culledMaximum = Math.max( + culled.data[byteIndex], + culled.data[byteIndex + 1], + culled.data[byteIndex + 2], + ); + const culledMinimum = Math.min( + culled.data[byteIndex], + culled.data[byteIndex + 1], + culled.data[byteIndex + 2], + ); + const unculledMaximum = Math.max( + unculled.data[byteIndex], + unculled.data[byteIndex + 1], + unculled.data[byteIndex + 2], + ); + const unculledMinimum = Math.min( + unculled.data[byteIndex], + unculled.data[byteIndex + 1], + unculled.data[byteIndex + 2], + ); + const channelDelta = Math.max( + Math.abs(culled.data[byteIndex] - unculled.data[byteIndex]), + Math.abs(culled.data[byteIndex + 1] - unculled.data[byteIndex + 1]), + Math.abs(culled.data[byteIndex + 2] - unculled.data[byteIndex + 2]), + ); + if (channelDelta !== 0) frameChangedPixelCount += 1; + if ( + (culledMaximum <= strongBlackMaximum && unculledMinimum >= strongWhiteMinimum) || + (unculledMaximum <= strongBlackMaximum && culledMinimum >= strongWhiteMinimum) + ) { + frameStrongMismatchPixelCount += 1; + frameStrongMismatchPixels.add(pixelIndex); + } + frameMaxChannelDelta = Math.max(frameMaxChannelDelta, channelDelta); + } + const frameLargestStrongMismatchComponentPixelCount = largestComponentPixelCount( + frameStrongMismatchPixels, + culled.info.width, + culled.info.height, + ); + totalPixelCount += pixelCount; + changedPixelCount += frameChangedPixelCount; + strongMismatchPixelCount += frameStrongMismatchPixelCount; + largestStrongMismatchComponentPixelCount = Math.max( + largestStrongMismatchComponentPixelCount, + frameLargestStrongMismatchComponentPixelCount, + ); + maxChannelDelta = Math.max(maxChannelDelta, frameMaxChannelDelta); + if (!worstFrame || frameChangedPixelCount > worstFrame.changedPixelCount) { + worstFrame = { + frameName, + changedPixelCount: frameChangedPixelCount, + changedPixelRatio: frameChangedPixelCount / pixelCount, + strongMismatchPixelCount: frameStrongMismatchPixelCount, + largestStrongMismatchComponentPixelCount: + frameLargestStrongMismatchComponentPixelCount, + maxChannelDelta: frameMaxChannelDelta, + }; + } + } + + return { + comparison: "paired-white-face-coverage", + strongBlackMaximum, + strongWhiteMinimum, + frameCount: frameNames.length, + totalPixelCount, + changedPixelCount, + changedPixelRatio: changedPixelCount / totalPixelCount, + strongMismatchPixelCount, + largestStrongMismatchComponentPixelCount, + maxChannelDelta, + worstFrame, + }; +} + +function largestComponentPixelCount(pixelIndices, width, height) { + let largestPixelCount = 0; + while (pixelIndices.size > 0) { + const firstPixelIndex = pixelIndices.values().next().value; + pixelIndices.delete(firstPixelIndex); + const pending = [firstPixelIndex]; + let componentPixelCount = 0; + while (pending.length > 0) { + const pixelIndex = pending.pop(); + componentPixelCount += 1; + const x = pixelIndex % width; + const y = Math.floor(pixelIndex / width); + const neighbors = []; + if (x > 0) neighbors.push(pixelIndex - 1); + if (x + 1 < width) neighbors.push(pixelIndex + 1); + if (y > 0) neighbors.push(pixelIndex - width); + if (y + 1 < height) neighbors.push(pixelIndex + width); + for (const neighbor of neighbors) { + if (pixelIndices.delete(neighbor)) pending.push(neighbor); + } + } + largestPixelCount = Math.max(largestPixelCount, componentPixelCount); + } + return largestPixelCount; +} + +async function openReadyScene(page) { + await page.route("**/favicon.ico", (routeHandler) => + routeHandler.fulfill({ status: 204, body: "" })); + await page.goto(route, { waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => globalThis.__csscityflow?.ready || + globalThis.__csscityflow?.errors?.length, null, { timeout: 30_000 }); + const errors = await page.evaluate(() => globalThis.__csscityflow?.errors ?? []); + if (errors.length > 0) throw new Error(`Cityflow page failed: ${errors.join("\n")}`); + await page.addStyleTag({ content: ` + .examples-sidebar, .example-info { display: none !important; } + .example-stage { position: fixed !important; inset: 0 !important; } + ` }); +} + +async function installGeometryAuditStyles(page, unculled) { + await page.addStyleTag({ content: ` + :root, body, .example-stage { background: #000 !important; } + .csscityflow-box > b:first-child, + .csscityflow-box::before, + .csscityflow-box::after, + .csscityflow-box.csscityflow-side-1-hidden::before, + .csscityflow-box.csscityflow-side-2-hidden::after { + background: #fff !important; + ${unculled ? "visibility: visible !important;" : ""} + } + .csscityflow-box > b { + ${unculled ? "visibility: visible !important;" : ""} + } + ` }); +} + +async function setAuditTime(page, presentationTimeMilliseconds, publishAllShapes = false) { + await page.evaluate(({ presentationTimeMilliseconds: nextPresentationTime, publishAll }) => { + const player = globalThis.__csscityflow.player; + const stats = player.seekPresentationTime(nextPresentationTime); + if (!publishAll) return; + const playback = globalThis.__csscityflowAuditPlayback; + const boxes = [...document.querySelectorAll(".csscityflow-box")]; + if (playback?.presentationShapeStyles?.length !== stats.frameCount * stats.boxCount || + boxes.length !== stats.boxCount) { + throw new Error("Cityflow unculled audit playback binding drifted"); + } + const offset = stats.frameIndex * stats.boxCount; + boxes.forEach((box, boxIndex) => { + box.style.cssText = playback.presentationShapeStyles[offset + boxIndex]; + }); + }, { presentationTimeMilliseconds, publishAll: publishAllShapes }); +} + +async function encodeSequence(framesRoot, outputPath) { + await execFileAsync("ffmpeg", [ + "-hide_banner", "-loglevel", "error", "-y", + "-framerate", String(sampleRateHz), + "-i", resolve(framesRoot, "frame_%04d.png"), + "-c:v", "libx264", "-preset", "medium", "-crf", "18", + "-pix_fmt", "yuv420p", "-movflags", "+faststart", + outputPath, + ]); +} + +function collectErrors(page) { + const errors = []; + page.on("pageerror", (error) => errors.push(error.stack || error.message)); + page.on("console", (message) => { + if (message.type() === "error") errors.push(message.text()); + }); + return errors; +} diff --git a/src/adapters/cityflow/tools/oracle/capture-presentation-sequence.mjs b/src/adapters/cityflow/tools/oracle/capture-presentation-sequence.mjs new file mode 100644 index 0000000..0cef4eb --- /dev/null +++ b/src/adapters/cityflow/tools/oracle/capture-presentation-sequence.mjs @@ -0,0 +1,107 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: HPND +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { chromium } from "playwright"; + +const outputRoot = resolve(process.env.CSSCITYFLOW_PRESENTATION_SEQUENCE_OUT ?? + "bench/results/csscityflow/presentation-sequence"); +const framesRoot = resolve(outputRoot, "frames"); +const route = process.env.CSSCITYFLOW_PRESENTATION_SEQUENCE_URL ?? "http://127.0.0.1:4325/cityflow/"; +const width = positiveInteger("CSSCITYFLOW_PRESENTATION_SEQUENCE_WIDTH", 1280); +const height = positiveInteger("CSSCITYFLOW_PRESENTATION_SEQUENCE_HEIGHT", 720); +const frameCount = positiveInteger("CSSCITYFLOW_PRESENTATION_SEQUENCE_FRAMES", 302); +const unculled = process.env.CSSCITYFLOW_PRESENTATION_SEQUENCE_UNCULLED === "1"; +await rm(outputRoot, { recursive: true, force: true }); +await mkdir(framesRoot, { recursive: true }); +const browser = await chromium.launch({ channel: "chrome", headless: true }); +try { + const page = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: 1 }); + const errors = []; + page.on("pageerror", (error) => errors.push(error.stack || error.message)); + page.on("console", (message) => { if (message.type() === "error") errors.push(message.text()); }); + await page.goto(route, { waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => globalThis.__csscityflow?.ready, null, { timeout: 30_000 }); + await page.addStyleTag({ content: ` + .examples-sidebar, .example-info { display: none !important; } + .example-stage { position: fixed !important; inset: 0 !important; } + ` }); + await page.evaluate(async (restoreStaticSuppression) => { + globalThis.__csscityflow.player.pause(); + if (restoreStaticSuppression) { + const response = await fetch("/csscityflow/cityflow.playback.json", { cache: "no-store" }); + if (!response.ok) throw new Error(`Cityflow presentation playback failed: ${response.status}`); + const playback = await response.json(); + const decodeUint16 = (base64) => { + const binary = atob(base64); + const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0)); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + return Uint16Array.from({ length: bytes.byteLength / 2 }, (_, index) => + view.getUint16(index * 2, true)); + }; + globalThis.__csscityflowUnculledCapture = { + playback, + transformIndices: decodeUint16(playback.transformIndices.presentationBase64), + materialIndices: decodeUint16(playback.colors.presentationMaterialIndicesBase64), + }; + } + await new Promise((resolveFrame) => requestAnimationFrame(() => requestAnimationFrame(resolveFrame))); + }, unculled); + for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) { + await page.evaluate(async ({ index, restoreStaticSuppression }) => { + globalThis.__csscityflow.player.seekFrame(index % globalThis.__csscityflow.player.stats().frameCount); + if (restoreStaticSuppression) { + const { playback, transformIndices, materialIndices } = + globalThis.__csscityflowUnculledCapture; + const frameIndex = index % playback.frameCount; + const roots = [...document.querySelectorAll(".csscityflow-box")]; + for (let boxIndex = 0; boxIndex < playback.boxCount; boxIndex += 1) { + const root = roots[boxIndex]; + root.style.setProperty("display", "block", "important"); + root.style.transform = globalThis.__csscityflow.player.preparedTransformAt( + boxIndex, + transformIndices[frameIndex * playback.boxCount + boxIndex], + ); + const material = playback.colors.materials[ + materialIndices[frameIndex * playback.boxCount + boxIndex] + ]; + [...root.children].forEach((leaf, faceIndex) => { + leaf.style.setProperty("display", "block", "important"); + leaf.style.setProperty("opacity", "1", "important"); + leaf.style.backgroundColor = material[faceIndex]; + }); + } + } + await new Promise((resolveFrame) => requestAnimationFrame(resolveFrame)); + }, { index: frameIndex, restoreStaticSuppression: unculled }); + await page.screenshot({ path: resolve(framesRoot, `frame_${String(frameIndex).padStart(4, "0")}.png`) }); + } + const audit = await page.evaluate(() => ({ + player: globalThis.__csscityflow.player.stats(), + roots: document.querySelectorAll(".csscityflow-box").length, + leaves: document.querySelectorAll(".csscityflow-box>b").length, + })); + if (errors.length) throw new Error(`Cityflow presentation capture failed: ${JSON.stringify(errors)}`); + const report = { + schema: "csscityflow-browser-presentation-frame-sequence@1", + route, + browser: { name: "Google Chrome", version: browser.version(), headless: true }, + viewport: { width, height, deviceScaleFactor: 1 }, + frameCount, + frameRate: 60, + framePattern: resolve(framesRoot, "frame_%04d.png"), + captureMode: "paused-prepared-sequential-player-frame-seek", + staticSuppressionRestoredForOracle: unculled, + audit, + }; + await writeFile(resolve(outputRoot, "capture.json"), `${JSON.stringify(report, null, 2)}\n`); + console.log(JSON.stringify(report, null, 2)); +} finally { + await browser.close(); +} + +function positiveInteger(name, fallback) { + const value = Number.parseInt(process.env[name] ?? String(fallback), 10); + if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`); + return value; +} diff --git a/src/adapters/cityflow/tools/oracle/discover-browser-visible-faces.mjs b/src/adapters/cityflow/tools/oracle/discover-browser-visible-faces.mjs new file mode 100644 index 0000000..39afe39 --- /dev/null +++ b/src/adapters/cityflow/tools/oracle/discover-browser-visible-faces.mjs @@ -0,0 +1,133 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: HPND +import { writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { chromium } from "playwright"; +import sharp from "sharp"; + +const route = process.env.CSSCITYFLOW_VISIBLE_FACE_URL ?? "http://127.0.0.1:4325/cityflow/"; +const width = Number.parseInt(process.env.CSSCITYFLOW_VISIBLE_FACE_WIDTH ?? "1280", 10); +const height = Number.parseInt(process.env.CSSCITYFLOW_VISIBLE_FACE_HEIGHT ?? "720", 10); +const outputPath = resolve(process.env.CSSCITYFLOW_VISIBLE_FACE_OUT ?? + "bench/results/csscityflow/browser-visible-faces.json"); +const browser = await chromium.launch({ channel: "chrome", headless: true }); +try { + const page = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: 1 }); + const errors = []; + page.on("pageerror", (error) => errors.push(error.stack || error.message)); + page.on("console", (message) => { + if (message.type() === "error") errors.push(message.text()); + }); + await page.goto(route, { waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => globalThis.__csscityflow?.ready, null, { timeout: 30_000 }); + await page.addStyleTag({ content: ` + .examples-sidebar, .example-info { display: none !important; } + .example-stage { position: fixed !important; inset: 0 !important; } + ` }); + const setup = await page.evaluate(async () => { + const response = await fetch("/csscityflow/cityflow.playback.json", { cache: "no-store" }); + if (!response.ok) throw new Error(`Cityflow face-ID playback failed: ${response.status}`); + const playback = await response.json(); + const boxes = [...document.querySelectorAll(".csscityflow-box")]; + const leaves = boxes.flatMap((box) => [...box.children]); + if (boxes.length !== playback.boxCount || leaves.length !== playback.boxCount * playback.facesPerBox) { + throw new Error("Cityflow face-ID retained binding drifted"); + } + globalThis.__csscityflow.player.pause(); + globalThis.__csscityflowFaceIdPlayback = playback; + const colors = leaves.map((leaf, faceIndex) => { + const red = 32 + faceIndex % 10 * 20; + const green = 32 + Math.floor(faceIndex / 10) % 10 * 20; + const blue = 32 + Math.floor(faceIndex / 100) * 20; + const color = `rgb(${red}, ${green}, ${blue})`; + leaf.style.setProperty("display", "block", "important"); + leaf.style.setProperty("visibility", "visible", "important"); + leaf.style.setProperty("opacity", "1", "important"); + leaf.style.setProperty("background-color", color, "important"); + return [red, green, blue]; + }); + globalThis.__csscityflowFaceIdColors = colors; + return { frameCount: playback.frameCount, boxCount: playback.boxCount, colors }; + }); + const faceIndexByColor = new Map(setup.colors.map((color, faceIndex) => + [color.join(","), faceIndex])); + const visibleFrameIndices = Array.from({ length: setup.colors.length }, () => []); + for (let frameIndex = 0; frameIndex < setup.frameCount; frameIndex += 1) { + await page.evaluate((nextFrameIndex) => { + globalThis.__csscityflow.player.seekFrame(nextFrameIndex); + const leaves = [...document.querySelectorAll(".csscityflow-box > b")]; + leaves.forEach((leaf, faceIndex) => { + const [red, green, blue] = globalThis.__csscityflowFaceIdColors[faceIndex]; + leaf.style.setProperty("display", "block", "important"); + leaf.style.setProperty("visibility", "visible", "important"); + leaf.style.setProperty("opacity", "1", "important"); + leaf.style.setProperty("background-color", `rgb(${red}, ${green}, ${blue})`, "important"); + }); + }, frameIndex); + const screenshot = await page.screenshot(); + const image = await sharp(screenshot).removeAlpha().raw().toBuffer(); + const found = new Set(); + for (let byteIndex = 0; byteIndex < image.length; byteIndex += 3) { + const faceIndex = faceIndexByColor.get( + `${image[byteIndex]},${image[byteIndex + 1]},${image[byteIndex + 2]}`, + ); + if (faceIndex !== undefined) found.add(faceIndex); + } + for (const faceIndex of found) visibleFrameIndices[faceIndex].push(frameIndex); + } + if (errors.length > 0) throw new Error(`Cityflow face-ID capture failed: ${errors.join("\n")}`); + const visibleFaceIndices = visibleFrameIndices + .map((frames, faceIndex) => frames.length > 0 ? faceIndex : -1) + .filter((faceIndex) => faceIndex >= 0); + const rows = Array.from({ length: setup.frameCount }, () => new Uint8Array(setup.colors.length)); + visibleFrameIndices.forEach((frames, faceIndex) => { + for (const frameIndex of frames) rows[frameIndex][faceIndex] = 1; + }); + const initial = Buffer.alloc(Math.ceil(setup.colors.length / 8)); + for (let faceIndex = 0; faceIndex < setup.colors.length; faceIndex += 1) { + if (rows[0][faceIndex] !== 0) initial[faceIndex >> 3] |= 1 << (faceIndex & 7); + } + const offsets = [0]; + const indices = []; + for (let frameIndex = 0; frameIndex < setup.frameCount; frameIndex += 1) { + const previousFrameIndex = (frameIndex - 1 + setup.frameCount) % setup.frameCount; + for (let faceIndex = 0; faceIndex < setup.colors.length; faceIndex += 1) { + if (rows[frameIndex][faceIndex] !== rows[previousFrameIndex][faceIndex]) { + indices.push(faceIndex); + } + } + offsets.push(indices.length); + } + const offsetBytes = Buffer.alloc(offsets.length * Uint16Array.BYTES_PER_ELEMENT); + const indexBytes = Buffer.alloc(indices.length * Uint16Array.BYTES_PER_ELEMENT); + offsets.forEach((value, index) => offsetBytes.writeUInt16LE(value, index * 2)); + indices.forEach((value, index) => indexBytes.writeUInt16LE(value, index * 2)); + const visibleCounts = rows.map((row) => row.reduce((sum, value) => sum + value, 0)); + const report = { + schema: "csscityflow-browser-visible-face-id-source@1", + encoding: "initial-bitset-plus-u16le-per-target-frame-toggle-offsets-and-face-indices", + browser: { name: "Google Chrome", version: browser.version(), headless: true }, + viewport: { width, height, deviceScaleFactor: 1 }, + frameCount: setup.frameCount, + faceCount: setup.colors.length, + initialVisibleCount: visibleCounts[0], + minimumVisibleFaces: Math.min(...visibleCounts), + maximumVisibleFaces: Math.max(...visibleCounts), + meanVisibleFaces: visibleCounts.reduce((sum, value) => sum + value, 0) / setup.frameCount, + visibleFaceCount: visibleFaceIndices.length, + visibleFaceIndices, + transitionCount: indices.length, + initialVisibleBitsBase64: initial.toString("base64"), + transitionOffsetsBase64: offsetBytes.toString("base64"), + transitionFaceIndicesBase64: indexBytes.toString("base64"), + provenance: { + method: "chrome-solid-face-id-exact-interior-pixel-census", + route, + presentation: "current-prepared-presentation-player-seek-frame", + }, + }; + await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`); + console.log(JSON.stringify({ ...report, outputPath }, null, 2)); +} finally { + await browser.close(); +} diff --git a/src/adapters/cityflow/tools/oracle/identify-culled-diff-faces.mjs b/src/adapters/cityflow/tools/oracle/identify-culled-diff-faces.mjs new file mode 100644 index 0000000..856f06b --- /dev/null +++ b/src/adapters/cityflow/tools/oracle/identify-culled-diff-faces.mjs @@ -0,0 +1,93 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: HPND +import { resolve } from "node:path"; +import { chromium } from "playwright"; +import sharp from "sharp"; + +const route = process.env.CSSCITYFLOW_DIFF_FACE_URL ?? "http://127.0.0.1:4325/cityflow/"; +const width = Number.parseInt(process.env.CSSCITYFLOW_DIFF_FACE_WIDTH ?? "2473", 10); +const height = Number.parseInt(process.env.CSSCITYFLOW_DIFF_FACE_HEIGHT ?? "1236", 10); +const frameIndex = Number.parseInt(process.env.CSSCITYFLOW_DIFF_FACE_FRAME ?? "253", 10); +const referencePath = resolve(process.env.CSSCITYFLOW_DIFF_FACE_REFERENCE ?? + `bench/results/csscityflow/static-visibility/unculled-wide/frames/frame_${String(frameIndex).padStart(4, "0")}.png`); +const candidatePath = resolve(process.env.CSSCITYFLOW_DIFF_FACE_CANDIDATE ?? + `bench/results/csscityflow/product-visibility/after-wide/frames/frame_${String(frameIndex).padStart(4, "0")}.png`); +const [reference, candidate] = await Promise.all([referencePath, candidatePath].map((path) => + sharp(path).removeAlpha().raw().toBuffer({ resolveWithObject: true }))); +const changed = []; +for (let byteIndex = 0; byteIndex < reference.data.length; byteIndex += 3) { + const delta = Math.max( + Math.abs(reference.data[byteIndex] - candidate.data[byteIndex]), + Math.abs(reference.data[byteIndex + 1] - candidate.data[byteIndex + 1]), + Math.abs(reference.data[byteIndex + 2] - candidate.data[byteIndex + 2]), + ); + if (delta === 0) continue; + const pixelIndex = byteIndex / 3; + changed.push({ x: pixelIndex % width, y: Math.floor(pixelIndex / width), delta }); +} +changed.sort((left, right) => right.delta - left.delta); +const samples = []; +for (const pixel of changed) { + if (samples.some((sample) => Math.hypot(sample.x - pixel.x, sample.y - pixel.y) < 12)) continue; + samples.push(pixel); + if (samples.length >= 200) break; +} + +const browser = await chromium.launch({ channel: "chrome", headless: true }); +try { + const page = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: 1 }); + await page.goto(route, { waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => globalThis.__csscityflow?.ready, null, { timeout: 30_000 }); + await page.addStyleTag({ content: ` + .examples-sidebar, .example-info { display: none !important; } + .example-stage { position: fixed !important; inset: 0 !important; } + .csscityflow-box>b { pointer-events: auto !important; } + ` }); + const hits = await page.evaluate(async ({ frameIndex: nextFrameIndex, samples: points }) => { + const player = globalThis.__csscityflow.player; + player.pause(); + player.seekFrame(nextFrameIndex); + const response = await fetch("/csscityflow/cityflow.playback.json", { cache: "no-store" }); + const playback = await response.json(); + const decodeUint16 = (base64) => { + const bytes = Uint8Array.from(atob(base64), (character) => character.charCodeAt(0)); + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + return Uint16Array.from({ length: bytes.byteLength / 2 }, (_, index) => + view.getUint16(index * 2, true)); + }; + const transformIndices = decodeUint16(playback.transformIndices.presentationBase64); + const roots = [...document.querySelectorAll(".csscityflow-box")]; + const leaves = roots.flatMap((root) => [...root.children]); + for (let boxIndex = 0; boxIndex < playback.boxCount; boxIndex += 1) { + roots[boxIndex].style.transform = player.preparedTransformAt( + boxIndex, + transformIndices[nextFrameIndex * playback.boxCount + boxIndex], + ); + } + await new Promise((resolveFrame) => requestAnimationFrame(resolveFrame)); + return points.map((point) => ({ + ...point, + hits: document.elementsFromPoint(point.x, point.y) + .filter((element) => element.matches?.(".csscityflow-box>b")) + .map((element) => ({ + faceIndex: leaves.indexOf(element), + opacity: getComputedStyle(element).opacity, + display: getComputedStyle(element).display, + })), + })); + }, { frameIndex, samples }); + const hiddenHitCounts = new Map(); + for (const sample of hits) { + for (const hit of sample.hits.filter(({ opacity, display }) => opacity === "0" || display === "none")) { + hiddenHitCounts.set(hit.faceIndex, (hiddenHitCounts.get(hit.faceIndex) ?? 0) + 1); + } + } + console.log(JSON.stringify({ + frameIndex, + changedPixelCount: changed.length, + sampleCount: samples.length, + hiddenHitCounts: [...hiddenHitCounts].sort((left, right) => right[1] - left[1]), + }, null, 2)); +} finally { + await browser.close(); +} diff --git a/src/adapters/cityflow/tools/oracle/run-visual-oracle.mjs b/src/adapters/cityflow/tools/oracle/run-visual-oracle.mjs index ff5831b..95d9f4e 100644 --- a/src/adapters/cityflow/tools/oracle/run-visual-oracle.mjs +++ b/src/adapters/cityflow/tools/oracle/run-visual-oracle.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +// SPDX-License-Identifier: HPND import { spawn, spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import { constants as fsConstants } from "node:fs"; @@ -20,19 +21,26 @@ import { chromium } from "playwright"; import sharp from "sharp"; import { CSSCITYFLOW_FRAME_MILLISECONDS, + CSSCITYFLOW_PREPARED_FRAME_COUNT, CSSCITYFLOW_SEED, - CITYFLOW_SOURCE, + CITYFLOW_BANKS, } from "../../src/prepare/csscityflow/sourceModel.mjs"; +import { ensureCityflowSourceTree } from "../../src/prepare/csscityflow/sourceAuthority.mjs"; const adapterRoot = resolve(import.meta.dirname, "../.."); const repositoryRoot = resolve(adapterRoot, "../../.."); -const sourceRoot = resolveRequiredSourceRoot(); +const sourceIdentity = await ensureCityflowSourceTree(); +const sourceRoot = sourceIdentity.sourceRoot; const outputRoot = resolve( process.env.CSSCITYFLOW_VISUAL_ORACLE_OUT ?? join(repositoryRoot, "bench/results/csscityflow/native-browser-visual"), ); -const width = 1280; -const height = 720; +const width = readPositiveIntegerEnvironment("CSSCITYFLOW_VISUAL_ORACLE_WIDTH", 1280); +const height = readPositiveIntegerEnvironment("CSSCITYFLOW_VISUAL_ORACLE_HEIGHT", 720); +const bankId = process.env.CSSCITYFLOW_VISUAL_ORACLE_BANK ?? + (width < 600 ? "mobile" : "desktop"); +const bank = CITYFLOW_BANKS[bankId]; +if (!bank) throw new Error(`Unknown Cityflow visual-oracle bank: ${bankId}`); const frameCount = readFrameCount(); const diffFrames = [ "worst", @@ -82,7 +90,7 @@ try { actual: browserA.framesDir, out: join(outputRoot, "native-browser-compare"), label: "csscityflow_native_browser", - thresholds: ["1", "0.05", "2"], + thresholds: ["1.125", "0.05", "2"], diffFrames, }); const worstFrame = nativeBrowser.worst[0]?.frame ?? 0; @@ -96,11 +104,15 @@ try { schema: "csscityflow-native-browser-visual-oracle@1", status: nativeBrowser.pass ? "aligned" : "not-aligned", source: { + repository: sourceIdentity.repository, revision: sourceLock.revision, primaryPath: sourceLock.primary.path, primarySha256: sourceLock.primary.sha256, + verifiedFiles: sourceIdentity.files, }, seed: CSSCITYFLOW_SEED, + bankId, + boxCount: bank.boxCount, frames: frameCount, sourceFrameMilliseconds: CSSCITYFLOW_FRAME_MILLISECONDS, viewport: { width, height, deviceScaleFactor: 1 }, @@ -188,7 +200,7 @@ async function captureNativeRun(binary, runRoot) { const result = spawnSync(binary, [ framesDir, String(CSSCITYFLOW_SEED), - String(CITYFLOW_SOURCE.boxCount), + String(bank.boxCount), String(width), String(height), String(frameCount), @@ -211,13 +223,16 @@ async function captureBrowserRun(browser, url, runRoot) { try { await page.goto(url, { waitUntil: "domcontentloaded" }); await page.waitForFunction(() => globalThis.__csscityflow?.ready || globalThis.__csscityflow?.errors?.length, null, { timeout: 30_000 }); + await page.addStyleTag({ content: ` + .examples-sidebar, .example-info { display: none !important; } + .example-stage { position: fixed !important; inset: 0 !important; } + ` }); const initial = await page.evaluate(async () => { - document.querySelector(".site-header")?.remove(); const roots = [...document.querySelectorAll(".csscityflow-box")]; const leaves = [...document.querySelectorAll(".csscityflow-box>b")]; globalThis.__csscityflowOracleIdentity = { roots, leaves }; globalThis.__csscityflow.player.pause(); - globalThis.__csscityflow.player.seekFrame(1); + globalThis.__csscityflow.player.seekSourceFrame(1); await new Promise((resolveFrame) => requestAnimationFrame(() => requestAnimationFrame(resolveFrame))); return { roots: roots.length, @@ -227,14 +242,15 @@ async function captureBrowserRun(browser, url, runRoot) { sceneSvg: document.querySelectorAll(".polycss-camera svg").length, }; }); - if (errors.length || initial.roots !== CITYFLOW_SOURCE.boxCount || - initial.leaves !== CITYFLOW_SOURCE.boxCount * 3 || initial.animations !== 0 || + if (errors.length || initial.roots !== bank.boxCount || + initial.leaves !== bank.boxCount * 3 || + initial.animations !== 0 || initial.canvas !== 0 || initial.sceneSvg !== 0) { throw new Error(`Cityflow browser oracle is invalid: ${JSON.stringify({ initial, errors })}`); } for (let frame = 0; frame < frameCount; frame += 1) { await page.evaluate(async ({ frameIndex }) => { - globalThis.__csscityflow.player.seekFrame(frameIndex); + globalThis.__csscityflow.player.seekSourceFrame(frameIndex); await new Promise((resolveFrame) => requestAnimationFrame(() => requestAnimationFrame(resolveFrame))); }, { frameIndex: frame }); await page.screenshot({ path: join(framesDir, frameName(frame)) }); @@ -248,11 +264,18 @@ async function captureBrowserRun(browser, url, runRoot) { stableLeafIdentity: leaves.every((element, index) => element === expected.leaves[index]), rootCount: roots.length, leafCount: leaves.length, + transformAnimationCount: roots.flatMap((element) => element.getAnimations()).length, canvasCount: document.querySelectorAll("canvas").length, sceneSvgCount: document.querySelectorAll(".polycss-camera svg").length, + player: globalThis.__csscityflow.player.stats(), + stage: { + width: document.querySelector(".example-stage")?.clientWidth, + height: document.querySelector(".example-stage")?.clientHeight, + }, }; }); - if (!audit.stableRootIdentity || !audit.stableLeafIdentity || errors.length) { + if (!audit.stableRootIdentity || !audit.stableLeafIdentity || + audit.transformAnimationCount !== 0 || errors.length) { throw new Error(`Cityflow browser DOM identity drifted: ${JSON.stringify({ audit, errors })}`); } await writeJson(join(runRoot, "browser-capture.json"), { @@ -260,7 +283,7 @@ async function captureBrowserRun(browser, url, runRoot) { url, frameCount, viewport: { width, height, deviceScaleFactor: 1 }, - captureMode: "paused-prepared-elapsed-player-source-frame-seek", + captureMode: "paused-prepared-sequential-player-source-frame-seek", audit, }); return { framesDir, audit }; @@ -338,11 +361,11 @@ async function startBrowserServer() { } async function assertSourceIdentity() { - const revision = git("rev-parse", "HEAD"); - const primaryBytes = await readFile(join(sourceRoot, sourceLock.primary.path)); - const configBytes = await readFile(join(sourceRoot, sourceLock.config.path)); - if (revision !== sourceLock.revision || sha256(primaryBytes) !== sourceLock.primary.sha256 || - sha256(configBytes) !== sourceLock.config.sha256) { + if (sourceIdentity.revision !== sourceLock.revision || + !sourceIdentity.files.some(({ path, sha256: digest }) => + path === sourceLock.primary.path && digest === sourceLock.primary.sha256) || + !sourceIdentity.files.some(({ path, sha256: digest }) => + path === sourceLock.config.path && digest === sourceLock.config.sha256)) { throw new Error("Cityflow source identity does not match the pinned source lock"); } } @@ -355,24 +378,24 @@ function resolveSdk() { return result.stdout.trim(); } -function resolveRequiredSourceRoot() { - const configured = process.env.CSSCITYFLOW_SOURCE_ROOT; - if (!configured) throw new Error("Set CSSCITYFLOW_SOURCE_ROOT to the pinned XScreenSaver checkout"); - return resolve(configured); -} - function readFrameCount() { const value = Number(process.env.CSSCITYFLOW_VISUAL_ORACLE_FRAMES ?? 48); - if (!Number.isSafeInteger(value) || value < 1 || value > 252) { - throw new RangeError("Cityflow visual oracle frame count must be an integer from 1 through 252"); + if (!Number.isSafeInteger(value) || value < 1 || value > CSSCITYFLOW_PREPARED_FRAME_COUNT) { + throw new RangeError( + `Cityflow visual oracle frame count must be an integer from 1 through ${CSSCITYFLOW_PREPARED_FRAME_COUNT}`, + ); } return value; } -function git(...args) { - const result = spawnSync("git", ["-C", sourceRoot, ...args], { encoding: "utf8" }); - if (result.status !== 0) throw new Error(result.stderr || `git ${args.join(" ")} failed`); - return result.stdout.trim(); +function readPositiveIntegerEnvironment(name, fallback) { + const raw = process.env[name]; + if (raw === undefined) return fallback; + const value = Number.parseInt(raw, 10); + if (!Number.isSafeInteger(value) || value < 1) { + throw new RangeError(`${name} must be a positive integer`); + } + return value; } function sha256(bytes) { diff --git a/src/adapters/cityflow/tools/prepare-csscityflow.mjs b/src/adapters/cityflow/tools/prepare-csscityflow.mjs index 0153919..b9de210 100644 --- a/src/adapters/cityflow/tools/prepare-csscityflow.mjs +++ b/src/adapters/cityflow/tools/prepare-csscityflow.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { createHash } from "node:crypto"; -import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +// SPDX-License-Identifier: HPND +import { mkdir, rename, rm, writeFile } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { buildPolyMorphCatalog, buildPolyMorphPackage } from "@layoutit/polycss-morph"; @@ -10,17 +10,17 @@ import { buildCityflowPreparedPlayback, } from "../src/prepare/csscityflow/model.mjs"; import { CITYFLOW_BANKS } from "../src/prepare/csscityflow/sourceModel.mjs"; +import { ensureCityflowSourceTree } from "../src/prepare/csscityflow/sourceAuthority.mjs"; const adapterRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const repositoryRoot = resolve(adapterRoot, "..", "..", ".."); const outputRoot = join(repositoryRoot, "build/generated/public/csscityflow"); const stagingRoot = join(repositoryRoot, `build/generated/.csscityflow-${process.pid}`); -const sourceRoot = resolveRequiredSourceRoot(); - -await verifySourceLock(); +const sourceIdentity = await ensureCityflowSourceTree(); await rm(stagingRoot, { recursive: true, force: true }); await mkdir(stagingRoot, { recursive: true }); const packages = []; +const preparedBanks = []; for (const bankId of Object.keys(CITYFLOW_BANKS)) { const { state, model } = buildCityflowMorphModel({ bankId }); const built = await buildPolyMorphPackage(model); @@ -33,14 +33,72 @@ for (const bankId of Object.keys(CITYFLOW_BANKS)) { manifestPath: `${model.identity.id}/manifest.json`, manifestSha256: built.manifestSha256, }); - await writeFile(join(stagingRoot, `${model.identity.id}.css`), `${buildCityflowPreparedCss(state)}\n`); + const playback = buildCityflowPreparedPlayback(state); + await writeFile( + join(stagingRoot, `${model.identity.id}.css`), + `${buildCityflowPreparedCss(state)}\n`, + ); await writeFile( join(stagingRoot, `${model.identity.id}.playback.json`), - `${JSON.stringify(buildCityflowPreparedPlayback(state))}\n`, + `${JSON.stringify(playback)}\n`, ); + preparedBanks.push(Object.freeze({ + id: bankId, + modelId: model.identity.id, + seed: state.seed, + boxCount: state.boxes.length, + leafCount: state.boxes.length * 3, + frameCount: playback.frameCount, + sourceFrameCount: playback.sourceFrameCount, + tickIntervalUs: playback.tickIntervalUs, + sourceTickIntervalUs: playback.sourceTickIntervalUs, + presentation: playback.presentation, + loop: playback.loop, + retainedFacePublication: Object.freeze({ + schema: "csscityflow-retained-face-publication@3", + policy: "prepared-whole-box-visibility-no-face-culling", + faceCount: state.boxes.length * playback.facesPerBox, + boxCount: state.boxes.length, + visibleFaceCount: playback.staticVisibility.visibleFaceCount, + hiddenFaceCount: playback.staticVisibility.hiddenFaceCount, + visibleBoxCount: playback.staticVisibility.visibleBoxCount, + hiddenBoxCount: playback.staticVisibility.hiddenBoxCount, + staticVisibility: playback.staticVisibility, + sideDepth: playback.sideDepth, + }), + diagnosticVisibility: Object.freeze({ + schema: playback.diagnostics.visibility.schema, + usage: playback.diagnostics.productPolicy, + coverage: playback.diagnostics.visibility.coverage ?? null, + viewportUnion: playback.diagnostics.visibility.viewportUnion ?? [], + }), + })); } const catalog = await buildPolyMorphCatalog("cityflow", packages); await writeBytes(join(stagingRoot, "catalog.json"), catalog.bytes); +await writeFile(join(stagingRoot, "prepared.json"), `${JSON.stringify({ + schema: "csscityflow-prepared-product@2", + status: "ready", + defaultBank: "desktop", + profileSelection: { + mode: "viewport-capability-or-mobile-user-agent-before-profile-fetch-and-mount", + mobileBreakpointWidth: 600, + mobileCapabilityQuery: "(hover: none) and (pointer: coarse)", + }, + source: { + repository: sourceIdentity.repository, + revision: sourceIdentity.revision, + license: "HPND", + files: sourceIdentity.files, + }, + renderer: { + kind: "retained-dom-polycss-prepared-playback", + runtimeGeometry: false, + runtimeRasterization: false, + runtimeDomGrowth: false, + }, + banks: preparedBanks, +}, null, 2)}\n`); await rm(outputRoot, { recursive: true, force: true }); await mkdir(dirname(outputRoot), { recursive: true }); await rename(stagingRoot, outputRoot); @@ -50,27 +108,6 @@ console.log(JSON.stringify({ banks: packages.map(({ manifest }) => ({ id: manifest.identity.id, resources: manifest.resources.length })), }, null, 2)); -async function verifySourceLock() { - const head = (await readFile(join(sourceRoot, ".git/HEAD"), "utf8")).trim(); - const revision = head.startsWith("ref: ") - ? (await readFile(join(sourceRoot, ".git", head.slice(5)), "utf8")).trim() - : head; - if (revision !== CITYFLOW_BANKS.desktop.commit) throw new Error(`Cityflow source commit drifted: ${revision}`); - for (const [path, expected] of [ - [CITYFLOW_BANKS.desktop.primaryPath, CITYFLOW_BANKS.desktop.primarySha256], - [CITYFLOW_BANKS.desktop.configPath, CITYFLOW_BANKS.desktop.configSha256], - ]) { - const actual = createHash("sha256").update(await readFile(join(sourceRoot, path))).digest("hex"); - if (actual !== expected) throw new Error(`Cityflow source bytes drifted: ${path}`); - } -} - -function resolveRequiredSourceRoot() { - const configured = process.env.CSSCITYFLOW_SOURCE_ROOT; - if (!configured) throw new Error("Set CSSCITYFLOW_SOURCE_ROOT to the pinned XScreenSaver checkout"); - return resolve(configured); -} - async function writeBytes(path, bytes) { await mkdir(dirname(path), { recursive: true }); await writeFile(path, bytes); diff --git a/src/adapters/cityflow/tools/run-native-oracle.mjs b/src/adapters/cityflow/tools/run-native-oracle.mjs index 2476a8c..332a17e 100644 --- a/src/adapters/cityflow/tools/run-native-oracle.mjs +++ b/src/adapters/cityflow/tools/run-native-oracle.mjs @@ -1,15 +1,18 @@ #!/usr/bin/env node +// SPDX-License-Identifier: HPND import { execFile } from "node:child_process"; import { mkdir, writeFile } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { buildCityflowSourceState, cityflowFrameAt } from "../src/prepare/csscityflow/sourceModel.mjs"; +import { ensureCityflowSourceTree } from "../src/prepare/csscityflow/sourceAuthority.mjs"; const run = promisify(execFile); const adapterRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const repositoryRoot = resolve(adapterRoot, "..", "..", ".."); -const sourceRoot = resolveRequiredSourceRoot(); +const sourceIdentity = await ensureCityflowSourceTree(); +const sourceRoot = sourceIdentity.sourceRoot; const outputRoot = join(repositoryRoot, "build/oracles/cityflow/native-state"); const binary = join(outputRoot, "capture-cityflow-state"); await mkdir(outputRoot, { recursive: true }); @@ -23,8 +26,8 @@ await run("cc", [ ]); const checks = []; -for (const bankId of ["desktop"]) { - for (const tick of [0, 73, 251]) { +for (const bankId of ["desktop", "mobile"]) { + for (const tick of [0, 73, 250, 251]) { const state = buildCityflowSourceState({ bankId }); const frame = cityflowFrameAt(state, tick); const { stdout } = await run(binary, [String(state.seed), String(state.source.boxCount), String(tick)]); @@ -34,16 +37,19 @@ for (const bankId of ["desktop"]) { await writeFile(join(outputRoot, `${bankId}-tick-${tick}.json`), `${JSON.stringify(native, null, 2)}\n`); } } -const report = { schema: "csscityflow-native-state-oracle@1", sourceRoot, checks }; +const report = { + schema: "csscityflow-native-state-oracle@2", + source: { + repository: sourceIdentity.repository, + revision: sourceIdentity.revision, + files: sourceIdentity.files, + }, + sourceRoot, + checks, +}; await writeFile(join(outputRoot, "report.json"), `${JSON.stringify(report, null, 2)}\n`); console.log(JSON.stringify(report, null, 2)); -function resolveRequiredSourceRoot() { - const configured = process.env.CSSCITYFLOW_SOURCE_ROOT; - if (!configured) throw new Error("Set CSSCITYFLOW_SOURCE_ROOT for the native Cityflow oracle"); - return resolve(configured); -} - function compareState(state, frame, native) { const indices = [0, 1, 64, 128, 192, 255]; for (const [sampleIndex, paletteIndex] of indices.entries()) { diff --git a/src/adapters/cityflow/tools/smoke-browser.mjs b/src/adapters/cityflow/tools/smoke-browser.mjs index cdc84b2..d25bd6f 100644 --- a/src/adapters/cityflow/tools/smoke-browser.mjs +++ b/src/adapters/cityflow/tools/smoke-browser.mjs @@ -1,71 +1,153 @@ #!/usr/bin/env node +// SPDX-License-Identifier: HPND import { spawn } from "node:child_process"; -import { mkdir, writeFile } from "node:fs/promises"; +import { createServer as createHttpServer } from "node:http"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import { createServer } from "node:net"; -import { resolve } from "node:path"; +import { extname, resolve, sep } from "node:path"; import { chromium } from "playwright"; const adapterRoot = resolve(import.meta.dirname, ".."); const repositoryRoot = resolve(adapterRoot, "..", "..", ".."); -const outputRoot = resolve(repositoryRoot, "bench/results/csscityflow/smoke"); +const deploy = process.argv.includes("--deploy"); +const outputRoot = resolve(repositoryRoot, "bench/results/csscityflow", deploy ? "deploy" : "smoke"); const externalUrl = process.env.CSSCITYFLOW_SMOKE_URL; const port = externalUrl ? null : await freePort(); -const route = externalUrl ?? `http://127.0.0.1:${port}/`; +const origin = externalUrl ? new URL(externalUrl).origin : `http://127.0.0.1:${port}`; +const route = externalUrl ?? `${origin}/cityflow/`; let server = null; let serverOutput = ""; if (!externalUrl) { - server = spawn("pnpm", [ - "exec", "vite", "--config", resolve(adapterRoot, "vite.config.mjs"), - "--host", "127.0.0.1", "--port", String(port), "--strictPort", - ], { cwd: repositoryRoot, stdio: ["ignore", "pipe", "pipe"] }); - server.stdout.on("data", (chunk) => { serverOutput += chunk.toString(); }); - server.stderr.on("data", (chunk) => { serverOutput += chunk.toString(); }); + if (deploy) { + server = createStaticDeployServer(resolve(repositoryRoot, "dist/site")); + await new Promise((resolveListen, reject) => { + server.once("error", reject); + server.listen(port, "127.0.0.1", resolveListen); + }); + } else { + server = spawn("pnpm", [ + "exec", "vite", "--config", resolve(adapterRoot, "vite.config.mjs"), + "--host", "127.0.0.1", "--port", String(port), "--strictPort", + ], { cwd: repositoryRoot, stdio: ["ignore", "pipe", "pipe"] }); + server.stdout.on("data", (chunk) => { serverOutput += chunk.toString(); }); + server.stderr.on("data", (chunk) => { serverOutput += chunk.toString(); }); + } } let browser; try { await mkdir(outputRoot, { recursive: true }); - if (server) await waitFor(() => serverOutput.includes("Local:"), 20_000); + if (server && !deploy) await waitFor(() => serverOutput.includes("Local:"), 20_000); browser = await chromium.launch({ channel: "chrome", headless: true }); - const desktop = await capture({ profile: "desktop", width: 1280, height: 720, bankId: "desktop", boxes: 200, leaves: 600 }); - const mobile = await capture({ profile: "mobile", width: 390, height: 844, bankId: "desktop", boxes: 200, leaves: 600 }); - const report = { schema: "csscityflow-browser-smoke@1", route, desktop, mobile }; + const desktop = await capture({ + profile: "desktop", width: 1280, height: 720, bankId: "desktop", modelId: "cityflow", + boxes: 200, leaves: 600, visibleBoxRange: [166, 187], visibleFaceRange: [498, 561], + initialVisibleBoxes: 170, initialVisibleFaces: 510, initialVisibilityWrites: 90, + staticSuppressedBoxes: 5, staticSuppressedFaces: 15, + maximumShapeWrites: 187, maximumColorWrites: 271, + sideDepthMaximum: 0.28, sideDepthOverrides: 19, url: route, + }); + const mobile = await capture({ + profile: "mobile", width: 390, height: 844, bankId: "mobile", modelId: "cityflow-mobile", + boxes: 100, leaves: 300, visibleBoxRange: [100, 100], visibleFaceRange: [300, 300], + initialVisibleBoxes: 100, initialVisibleFaces: 300, initialVisibilityWrites: 0, + staticSuppressedBoxes: 0, staticSuppressedFaces: 0, + maximumShapeWrites: 100, maximumColorWrites: 159, + sideDepthDefault: 0.28, sideDepthMaximum: 0.28, sideDepthOverrides: 0, url: route, + }); + const home = deploy ? await captureHome({ width: 1280, height: 720, url: `${origin}/` }) : null; + const report = { schema: "csscityflow-browser-smoke@3", route, deploy, desktop, mobile, home }; await writeFile(resolve(outputRoot, "report.json"), `${JSON.stringify(report, null, 2)}\n`); console.log(JSON.stringify(report, null, 2)); } finally { await browser?.close(); - server?.kill("SIGTERM"); + if (deploy && server) await new Promise((resolveClose) => server.close(resolveClose)); + else server?.kill("SIGTERM"); } -async function capture({ profile, width, height, bankId, boxes, leaves }) { +async function capture({ + profile, + width, + height, + bankId, + modelId, + boxes, + leaves, + visibleBoxRange, + visibleFaceRange, + initialVisibleBoxes, + initialVisibleFaces, + initialVisibilityWrites, + staticSuppressedBoxes, + staticSuppressedFaces, + maximumShapeWrites, + maximumColorWrites, + sideDepthMaximum, + sideDepthDefault = 0.1, + sideDepthOverrides, + url, +}) { const page = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: 1 }); await page.route("**/favicon.ico", (request) => request.fulfill({ status: 204 })); const errors = []; page.on("pageerror", (error) => errors.push(error.stack || error.message)); page.on("console", (message) => { if (message.type() === "error") errors.push(message.text()); }); - await page.goto(route, { waitUntil: "domcontentloaded" }); - await page.waitForFunction(() => document.body.classList.contains("ready") || - document.body.classList.contains("error"), null, { timeout: 30_000 }); + const requests = []; + const preparedResponses = []; + const failedResponses = []; + page.on("request", (request) => requests.push(new URL(request.url()).pathname)); + page.on("response", (response) => { + const pathname = new URL(response.url()).pathname; + if (pathname.startsWith("/csscityflow/")) { + preparedResponses.push({ + path: pathname, + status: response.status(), + contentType: response.headers()["content-type"] ?? null, + }); + } + if (response.status() >= 400) failedResponses.push({ url: response.url(), status: response.status() }); + }); + await page.goto(url, { waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => globalThis.__csscityflow?.ready || + globalThis.__csscityflow?.errors?.length, null, { timeout: 30_000 }); const before = await page.evaluate(() => { const state = globalThis.__csscityflow; const shapes = [...document.querySelectorAll(".csscityflow-box")]; + const leaves = [...document.querySelectorAll(".csscityflow-box > b")]; let stable = true; try { state?.mounted?.assertStableDomIdentity(); } catch { stable = false; } return { status: document.body.className, + canonical: document.querySelector('link[rel="canonical"]')?.href ?? null, ready: state?.ready, bankId: state?.bankId, errors: state?.errors ?? [], stable, shapeCount: shapes.length, - leafCount: document.querySelectorAll(".csscityflow-box > b").length, + leafCount: leaves.length, + hiddenShapeCount: shapes.filter((shape) => getComputedStyle(shape).visibility === "hidden").length, + hiddenLeafCount: leaves.filter((leaf) => getComputedStyle(leaf).visibility === "hidden").length, + displayNoneShapeCount: shapes.filter((shape) => getComputedStyle(shape).display === "none").length, + zeroOpacityShapeCount: shapes.filter((shape) => getComputedStyle(shape).opacity === "0").length, + zeroOpacityLeafCount: leaves.filter((leaf) => getComputedStyle(leaf).opacity === "0").length, + firstLeafColor: leaves[0] ? getComputedStyle(leaves[0]).backgroundColor : null, + whiteLeafCount: leaves.filter((leaf) => + getComputedStyle(leaf).backgroundColor === "rgb(255, 255, 255)").length, canvasCount: document.querySelectorAll("canvas").length, svgSceneCount: document.querySelectorAll(".polycss-camera svg").length, - animationName: getComputedStyle(shapes[0]).animationName, + animationName: shapes[0] ? getComputedStyle(shapes[0]).animationName : null, + transformAnimationCount: shapes.reduce((sum, shape) => + sum + shape.getAnimations().filter(({ effect }) => + effect instanceof KeyframeEffect && effect.target === shape).length, 0), transforms: shapes.slice(0, 40).map((shape) => getComputedStyle(shape).transform), atlasConstructions: state?.mounted?.stats?.atlasConstructions, atlasRedraws: state?.mounted?.stats?.atlasRedraws ?? 0, player: state?.player?.stats(), + orbitStatePresent: Object.hasOwn(state ?? {}, "orbit"), + orbitDataset: document.querySelector(".example-stage")?.dataset.csscityflowOrbit ?? null, + draggingDataset: document.querySelector(".example-stage")?.dataset.csscityflowDragging ?? null, + camera: { ...state?.mounted?.camera?.state }, + metadata: state?.metadata, }; }); await page.waitForTimeout(250); @@ -74,22 +156,281 @@ async function capture({ profile, width, height, bankId, boxes, leaves }) { return { transforms: shapes.slice(0, 40).map((shape) => getComputedStyle(shape).transform), player: globalThis.__csscityflow?.player?.stats(), + camera: { ...globalThis.__csscityflow?.mounted?.camera?.state }, + }; + }); + const motion = await page.evaluate(async () => { + const player = globalThis.__csscityflow?.player; + if (!player) return { + missingPlayer: true, + state: globalThis.__csscityflow ?? null, + bodyClass: document.body.className, + }; + const samples = []; + for (let index = 0; index < 90; index += 1) { + await new Promise((resolveFrame) => requestAnimationFrame(resolveFrame)); + const stats = player.stats(); + samples.push({ + frameIndex: stats.frameIndex, + frameCount: stats.frameCount, + publicationCount: stats.publicationCount, + shapeStyleWrites: stats.lastPublication.shapeStyleWrites, + }); + } + const transitions = samples.slice(1).map((sample, index) => ({ + delta: (sample.frameIndex - samples[index].frameIndex + sample.frameCount) % sample.frameCount, + publicationDelta: sample.publicationCount - samples[index].publicationCount, + shapeStyleWrites: sample.shapeStyleWrites, + })); + const publishedTransitions = transitions.filter(({ publicationDelta }) => publicationDelta > 0); + return { + callbackCount: samples.length, + publishedTransitionCount: publishedTransitions.length, + stationaryCallbackCount: transitions.length - publishedTransitions.length, + skippedTransitionCount: publishedTransitions.filter(({ delta, publicationDelta }) => + delta !== 1 || publicationDelta !== 1).length, + zeroShapeStylePublicationCount: publishedTransitions.filter(({ shapeStyleWrites }) => + shapeStyleWrites === 0).length, + finalPlayer: player.stats(), }; }); const changedTransformCount = before.transforms.reduce((sum, transform, index) => sum + Number(transform !== after.transforms[index]), 0); - if (errors.length || before.errors.length || before.status !== "ready" || !before.ready || + const movingFrameBefore = await page.screenshot(); + await page.waitForTimeout(250); + const movingFrameAfter = await page.screenshot(); + const movingFrameBytesIdentical = movingFrameBefore.equals(movingFrameAfter); + const inputCameraBefore = await page.evaluate(() => ({ + ...globalThis.__csscityflow?.mounted?.camera?.state, + })); + const stageBox = await page.locator(".example-stage").boundingBox(); + if (!stageBox) throw new Error(`Cityflow ${profile} stage has no pointer target`); + await page.mouse.move(stageBox.x + stageBox.width * 0.5, stageBox.y + stageBox.height * 0.5); + await page.mouse.down(); + await page.mouse.move(stageBox.x + stageBox.width * 0.8, stageBox.y + stageBox.height * 0.5, + { steps: 8 }); + await page.mouse.up(); + await page.mouse.wheel(0, -160); + const inputResult = await page.evaluate(() => ({ + camera: { ...globalThis.__csscityflow?.mounted?.camera?.state }, + player: globalThis.__csscityflow?.player?.stats(), + orbitStatePresent: Object.hasOwn(globalThis.__csscityflow ?? {}, "orbit"), + orbitDataset: document.querySelector(".example-stage")?.dataset.csscityflowOrbit ?? null, + draggingDataset: document.querySelector(".example-stage")?.dataset.csscityflowDragging ?? null, + shapeCount: document.querySelectorAll(".csscityflow-box").length, + leafCount: document.querySelectorAll(".csscityflow-box > b").length, + })); + const sourceRoundTrip = await page.evaluate(async () => { + const player = globalThis.__csscityflow?.player; + player.pause(); + const sourcePlayer = player.seekSourceFrame(0); + await new Promise((resolveFrame) => requestAnimationFrame(resolveFrame)); + const source = { + player: sourcePlayer, + hiddenShapeCount: [...document.querySelectorAll(".csscityflow-box")] + .filter((shape) => getComputedStyle(shape).visibility === "hidden").length, + hiddenLeafCount: [...document.querySelectorAll(".csscityflow-box>b")] + .filter((leaf) => getComputedStyle(leaf).visibility === "hidden").length, + displayNoneShapeCount: [...document.querySelectorAll(".csscityflow-box")] + .filter((shape) => getComputedStyle(shape).display === "none").length, + zeroOpacityShapeCount: [...document.querySelectorAll(".csscityflow-box")] + .filter((shape) => getComputedStyle(shape).opacity === "0").length, + zeroOpacityLeafCount: [...document.querySelectorAll(".csscityflow-box>b")] + .filter((leaf) => getComputedStyle(leaf).opacity === "0").length, + }; + const presentationPlayer = player.seekFrame(0); + await new Promise((resolveFrame) => requestAnimationFrame(resolveFrame)); + const presentation = { + player: presentationPlayer, + hiddenShapeCount: [...document.querySelectorAll(".csscityflow-box")] + .filter((shape) => getComputedStyle(shape).visibility === "hidden").length, + hiddenLeafCount: [...document.querySelectorAll(".csscityflow-box>b")] + .filter((leaf) => getComputedStyle(leaf).visibility === "hidden").length, + displayNoneShapeCount: [...document.querySelectorAll(".csscityflow-box")] + .filter((shape) => getComputedStyle(shape).display === "none").length, + zeroOpacityShapeCount: [...document.querySelectorAll(".csscityflow-box")] + .filter((shape) => getComputedStyle(shape).opacity === "0").length, + zeroOpacityLeafCount: [...document.querySelectorAll(".csscityflow-box>b")] + .filter((leaf) => getComputedStyle(leaf).opacity === "0").length, + }; + return { source, presentation, resumed: player.resume() }; + }); + const expectedCanonical = profile === "home" ? "https://css.graphics/" : + "https://css.graphics/cityflow/"; + const preparedRequests = requests.filter((path) => path.startsWith("/csscityflow/")); + const preparedStylesheet = preparedResponses.find(({ path }) => + path === `/csscityflow/${modelId}.css`); + if (errors.length || failedResponses.length || before.errors.length || before.status !== "ready" || !before.ready || + before.canonical !== expectedCanonical || before.bankId !== bankId || !before.stable || before.shapeCount !== boxes || before.leafCount !== leaves || before.canvasCount !== 0 || before.svgSceneCount !== 0 || - before.animationName !== "none" || before.player?.catchUpPolicy !== "elapsed" || - after.player?.timerCallbackCount < 1 || after.player?.animationFrameCallbackCount < 1 || - changedTransformCount === 0 || before.atlasConstructions !== 0 || before.atlasRedraws !== 0) { - throw new Error(`Cityflow ${profile} browser smoke failed: ${JSON.stringify({ before, after, changedTransformCount, errors })}`); + !before.firstLeafColor || before.whiteLeafCount === leaves || + !preparedStylesheet?.contentType?.startsWith("text/css") || + before.animationName !== "none" || + before.player?.paused !== false || before.orbitStatePresent || + before.orbitDataset !== null || before.draggingDataset !== null || + before.camera?.rotX !== 0 || before.camera?.rotY !== 0 || before.camera?.zoom !== 50 || + before.player?.catchUpPolicy !== "adjacent-state-late-deadline-reset" || + before.player?.preparedStateSkipCount !== 0 || + before.player?.resumePublicationPolicy !== + "first-animation-frame-immediate-then-deadline-paced" || + before.player?.schedulerEarlyDeadlineToleranceMilliseconds !== 50 / 12 || + before.player?.schedulerMinimumDistinctPublicationSpacingMilliseconds !== 12.5 || + !Number.isSafeInteger(before.player?.schedulerDisplayPhaseResyncCount) || + before.player.schedulerDisplayPhaseResyncCount < 0 || + !Number.isSafeInteger(before.player?.schedulerLateDeadlineResetCount) || + before.player.schedulerLateDeadlineResetCount < 0 || + before.player?.preparedTimelineAuthority !== + "sequential-prepared-state-index" || + before.player?.transformPresentationMode !== + "prepared-packed-transform-components-expanded-once-plus-sparse-final-face-color-and-whole-box-leaf-visibility-publication" || + before.player?.preparedTransformAnimationCount !== 0 || + before.player?.preparedTransformStateCount !== 301 || + before.player?.runtimeShapeStyleWriteUpperBound !== maximumShapeWrites || + before.player?.runtimeLeafColorStyleWriteUpperBound !== maximumColorWrites || + !Number.isSafeInteger(before.player?.runtimeLeafColorStyleWrites) || + before.player.runtimeLeafColorStyleWrites < 0 || + before.player?.initialShapeStyleWrites !== initialVisibleBoxes || + before.player?.initialLeafColorStyleWrites !== initialVisibleFaces || + before.player?.initialVisibilityWrites !== initialVisibilityWrites || + before.player?.staticSuppressionBindingWrites !== staticSuppressedFaces || + before.player?.staticSuppressedFaceCount !== staticSuppressedFaces || + before.player?.staticSuppressedBoxCount !== staticSuppressedBoxes || + before.player?.pseudoElementSideFacePublication !== false || + before.player?.pseudoElementFaceColorOverlay !== false || + before.player?.retainedSideLeafPaintOwners !== 1 || + before.player?.sideLeafPreparedHeight !== 1 || + before.player?.sideLeafPreparedDefaultDepthScale !== sideDepthDefault || + before.player?.sideLeafPreparedMaximumDepthScale !== sideDepthMaximum || + before.player?.sideLeafPreparedOverrideCount !== sideDepthOverrides || + before.player?.sideLeafPreparedDefaultTopOffset !== 1 - sideDepthDefault || + before.player?.sideLeafPreparedMinimumTopOffset !== 1 - sideDepthMaximum || + before.player?.sideLeafLayoutSubpixelFree !== true || + before.player?.retainedFaceCount !== leaves || before.player?.retainedBoxCount !== boxes || + before.player?.visibleFaceCount !== before.player.visibleBoxCount * 3 || + before.player.visibleFaceCount < visibleFaceRange[0] || + before.player.visibleFaceCount > visibleFaceRange[1] || + before.player.visibleBoxCount < visibleBoxRange[0] || + before.player.visibleBoxCount > visibleBoxRange[1] || + before.hiddenShapeCount !== 0 || + before.hiddenLeafCount !== leaves - before.player.visibleFaceCount || + before.displayNoneShapeCount !== 0 || + before.zeroOpacityShapeCount !== 0 || + before.zeroOpacityLeafCount !== 0 || + !Number.isSafeInteger(before.player?.visibilityStyleWrites) || + before.player.visibilityStyleWrites < 0 || + !Number.isSafeInteger(before.player?.visibilityWrites) || before.player.visibilityWrites < 0 || + before.player?.visibilityCullingPolicy !== + "prepared-viewport-independent-whole-box-direct-leaf-visibility-no-face-culling" || + before.transformAnimationCount !== 0 || + before.metadata?.schema !== "csscityflow-prepared-product@2" || + after.player?.timerCallbackCount !== 0 || + after.player?.animationFrameCallbackCount <= before.player.animationFrameCallbackCount || + after.player?.frameIndex === before.player.frameIndex || + after.player?.publicationCount <= before.player.publicationCount || + JSON.stringify(after.camera) !== JSON.stringify(before.camera) || + motion.callbackCount !== 90 || motion.publishedTransitionCount <= 0 || + motion.skippedTransitionCount !== 0 || + motion.zeroShapeStylePublicationCount !== 0 || + motion.finalPlayer?.paused !== false || + motion.finalPlayer?.preparedStateSkipCount !== 0 || + changedTransformCount <= 0 || movingFrameBytesIdentical || + JSON.stringify(inputResult.camera) !== JSON.stringify(inputCameraBefore) || + inputResult.orbitStatePresent || inputResult.orbitDataset !== null || + inputResult.draggingDataset !== null || inputResult.player?.paused !== false || + inputResult.player?.visibleFaceCount !== inputResult.player?.visibleBoxCount * 3 || + inputResult.player?.visibleFaceCount < visibleFaceRange[0] || + inputResult.player?.visibleFaceCount > visibleFaceRange[1] || + inputResult.player?.visibleBoxCount < visibleBoxRange[0] || + inputResult.player?.visibleBoxCount > visibleBoxRange[1] || + inputResult.shapeCount !== boxes || inputResult.leafCount !== leaves || + sourceRoundTrip.source.player?.activeVisibilityVariant !== + "exact-source-seek-all-retained-faces" || + sourceRoundTrip.source.player?.visibleFaceCount !== leaves || + sourceRoundTrip.source.player?.visibleBoxCount !== boxes || + sourceRoundTrip.source.hiddenShapeCount !== 0 || + sourceRoundTrip.source.hiddenLeafCount !== 0 || + sourceRoundTrip.source.displayNoneShapeCount !== 0 || + sourceRoundTrip.source.zeroOpacityShapeCount !== 0 || + sourceRoundTrip.source.zeroOpacityLeafCount !== 0 || + sourceRoundTrip.presentation.player?.activeVisibilityVariant !== + "prepared-whole-box-visibility" || + sourceRoundTrip.presentation.player?.visibleFaceCount !== initialVisibleFaces || + sourceRoundTrip.presentation.player?.visibleBoxCount !== initialVisibleBoxes || + sourceRoundTrip.presentation.hiddenShapeCount !== 0 || + sourceRoundTrip.presentation.hiddenLeafCount !== leaves - initialVisibleFaces || + sourceRoundTrip.presentation.displayNoneShapeCount !== 0 || + sourceRoundTrip.presentation.zeroOpacityShapeCount !== 0 || + sourceRoundTrip.presentation.zeroOpacityLeafCount !== 0 || + sourceRoundTrip.resumed?.paused !== false || + before.atlasConstructions !== 0 || before.atlasRedraws !== 0) { + throw new Error(`Cityflow ${profile} browser smoke failed: ${JSON.stringify({ before, after, motion, movingFrameBytesIdentical, inputCameraBefore, inputResult, sourceRoundTrip, changedTransformCount, errors, failedResponses, preparedRequests, preparedResponses })}`); } const screenshotPath = resolve(outputRoot, `${profile}.png`); await page.screenshot({ path: screenshotPath }); await page.close(); - return Object.freeze({ width, height, boxes, leaves, changedTransformCount, screenshotPath }); + return Object.freeze({ width, height, boxes, leaves, changedTransformCount, motion, movingFrameBytesIdentical, inputCameraBefore, inputResult, sourceRoundTrip, preparedRequests, preparedResponses, screenshotPath }); +} + +async function captureHome({ width, height, url }) { + const page = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: 1 }); + await page.route("**/favicon.ico", (request) => request.fulfill({ status: 204 })); + const errors = []; + const failedResponses = []; + page.on("pageerror", (error) => errors.push(error.stack || error.message)); + page.on("console", (message) => { if (message.type() === "error") errors.push(message.text()); }); + page.on("response", (response) => { + if (response.status() >= 400) failedResponses.push({ url: response.url(), status: response.status() }); + }); + await page.goto(url, { waitUntil: "domcontentloaded" }); + await page.waitForSelector('a.project-thumbnail[href="/cityflow/"]', { timeout: 30_000 }); + const result = await page.evaluate(() => { + const card = document.querySelector('a.project-thumbnail[href="/cityflow/"]'); + return { + canonical: document.querySelector('link[rel="canonical"]')?.href ?? null, + title: card?.querySelector(".project-title")?.textContent?.trim() ?? null, + number: card?.querySelector(".project-number")?.textContent?.trim() ?? null, + image: card?.querySelector("img")?.getAttribute("src") ?? null, + sceneCount: document.querySelectorAll(".csscityflow-box").length, + }; + }); + if (errors.length || failedResponses.length || result.canonical !== "https://css.graphics/" || + result.title !== "Cityflow" || result.number !== "#012" || + result.image !== "/landing/sidebar/cityflow.webp" || result.sceneCount !== 0) { + throw new Error(`Cityflow home browser smoke failed: ${JSON.stringify({ result, errors, failedResponses })}`); + } + const screenshotPath = resolve(outputRoot, "home.png"); + await page.screenshot({ path: screenshotPath }); + await page.close(); + return Object.freeze({ width, height, ...result, screenshotPath }); +} + +function createStaticDeployServer(root) { + const rootPrefix = `${root}${sep}`; + return createHttpServer(async (request, response) => { + try { + const pathname = decodeURIComponent(new URL(request.url ?? "/", route).pathname); + const normalized = pathname.endsWith("/") ? `${pathname}index.html` : pathname; + const path = resolve(root, `.${normalized}`); + if (!path.startsWith(rootPrefix)) return response.writeHead(403).end(); + const bytes = await readFile(path); + response.statusCode = 200; + response.setHeader("Content-Type", mediaType(path)); + response.setHeader("Content-Length", bytes.byteLength); + response.end(request.method === "HEAD" ? undefined : bytes); + } catch (error) { + if (error?.code === "ENOENT") return response.writeHead(404).end(); + response.writeHead(500).end(); + } + }); +} + +function mediaType(path) { + return new Map([ + [".css", "text/css"], [".html", "text/html; charset=utf-8"], + [".ico", "image/x-icon"], [".js", "text/javascript; charset=utf-8"], + [".json", "application/json; charset=utf-8"], [".webp", "image/webp"], + ]).get(extname(path)) ?? "application/octet-stream"; } async function freePort() { diff --git a/src/adapters/cityflow/tools/trace-performance.mjs b/src/adapters/cityflow/tools/trace-performance.mjs new file mode 100644 index 0000000..91e87b0 --- /dev/null +++ b/src/adapters/cityflow/tools/trace-performance.mjs @@ -0,0 +1,182 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: HPND +import { execFile, spawn } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import { createServer as createNetServer } from "node:net"; +import { resolve } from "node:path"; +import { promisify } from "node:util"; +import { captureFrameSleuthTrace } from "../../../../scripts/frame-sleuth-trace.mjs"; + +const repositoryRoot = resolve(import.meta.dirname, "../../../.."); +const execFileAsync = promisify(execFile); +const durationMilliseconds = positiveNumberEnvironment("CSSCITYFLOW_PERF_DURATION_MS", 10_000); +const startupMilliseconds = positiveNumberEnvironment("CSSCITYFLOW_PERF_STARTUP_MS", 2_000); +const cpuThrottleRate = positiveNumberEnvironment("CSSCITYFLOW_PERF_CPU_THROTTLE_RATE", 1); +const viewportWidth = positiveIntegerEnvironment("CSSCITYFLOW_PERF_WIDTH", 2027); +const viewportHeight = positiveIntegerEnvironment("CSSCITYFLOW_PERF_HEIGHT", 1236); +const resultRoot = resolve( + process.env.CSSCITYFLOW_PERF_OUT ?? resolve( + repositoryRoot, + "bench/results/csscityflow/performance", + new Date().toISOString().replaceAll(":", "-"), + ), +); +let origin = await runningAstroOrigin(); +let server = null; +let ownsServer = false; +let serverOutput = ""; +if (!origin || !await routeIsReady(`${origin}/cityflow/`)) { + const port = await freePort(); + origin = `http://127.0.0.1:${port}`; + server = spawn("pnpm", [ + "exec", "astro", "dev", "--host", "127.0.0.1", "--port", String(port), + "--ignore-lock", + ], { cwd: repositoryRoot, stdio: ["ignore", "pipe", "pipe"] }); + ownsServer = true; + server.stdout.on("data", (chunk) => { serverOutput += chunk.toString(); }); + server.stderr.on("data", (chunk) => { serverOutput += chunk.toString(); }); +} +const url = `${origin}/cityflow/`; +try { + await waitForServer(url, server); + const capture = await captureFrameSleuthTrace({ + output: resolve(resultRoot, "cityflow.json.gz"), + url, + durationMs: durationMilliseconds, + startupMs: startupMilliseconds, + headless: true, + screenshots: false, + width: viewportWidth, + height: viewportHeight, + }, { + activatePage: async (page) => { + const cdp = await page.context().newCDPSession(page); + await cdp.send("Emulation.setCPUThrottlingRate", { rate: cpuThrottleRate }); + await page.waitForFunction(() => globalThis.__csscityflow?.ready, null, { + timeout: 30_000, + }); + await page.evaluate(() => globalThis.__csscityflow.player.resume()); + }, + }); + const steady = JSON.parse(await readFile(capture.reports.analysis, "utf8")).steady; + const draw = capture.cadence.drawFrame; + const presented = steady.timeline?.stats; + const presentedFrames = steady.timeline?.frames ?? []; + const worstPresented = steady.timeline?.worstFrame; + const worstDraw = capture.worstSteadyFrame; + const presentedGapsAboveMs = Object.freeze({ + "33.3": presentedFrames.filter(({ intervalMs }) => intervalMs > 33.3).length, + "40": presentedFrames.filter(({ intervalMs }) => intervalMs > 40).length, + }); + const acceptance = Object.freeze({ + noPageErrors: capture.errors.length === 0, + presentedTimelineAvailable: steady.timeline?.source === "AnimationFrame::Presentation" && + presented?.eventCount > 0, + presentedCadenceMean: presented?.meanMs <= 18, + presentedCadenceP95: presented?.p95Ms <= 20, + boundedPresentedMaximumGap: presented?.maxMs <= 30, + noPresentedThirtyThreeMillisecondHolds: presentedGapsAboveMs["33.3"] === 0, + noPresentedFortyMillisecondHolds: presentedGapsAboveMs["40"] === 0, + drawCadenceP95: draw.p95Ms <= 20, + boundedDrawMaximumGap: draw.maxMs <= 30, + noSmoothnessAffectingPipelineDrops: steady.pipeline.affectsSmoothnessCount === 0, + boundedMainThread: (worstPresented?.metrics.mainBusyMs ?? Infinity) < 20 && + (worstPresented?.metrics.maxRunTaskMs ?? Infinity) < 10, + boundedPaint: (steady.workload.named.Paint?.maxMs ?? Infinity) < 2, + }); + const accepted = Object.values(acceptance).every(Boolean); + console.log(JSON.stringify({ + schema: "csscityflow-performance-qualification@2", + accepted, + acceptance, + cpuThrottleRate, + viewport: { width: viewportWidth, height: viewportHeight }, + route: url, + routeKind: "integrated-cssgraphics-project-shell", + rasterEvidence: steady.capabilities.signals.raster.available + ? "captured" + : "not-captured-unknown-not-zero", + capture: capture.reports.analysis, + browser: capture.browser, + cadence: capture.cadence, + presentedAnimationFrame: Object.freeze({ + source: steady.timeline.source, + ...presented, + gapsAboveMs: presentedGapsAboveMs, + }), + worstPresentedFrame: worstPresented, + worstSteadyDrawFrame: worstDraw, + }, null, 2)); + if (!accepted) process.exitCode = 1; +} finally { + if (ownsServer && server) { + server.kill("SIGTERM"); + await Promise.race([ + new Promise((resolveExit) => server.once("exit", resolveExit)), + new Promise((resolveTimeout) => setTimeout(resolveTimeout, 2_000)), + ]); + } +} + +function positiveNumberEnvironment(name, fallback) { + const value = Number(process.env[name] ?? fallback); + if (!Number.isFinite(value) || value <= 0) { + throw new Error(`${name} must be a positive number`); + } + return value; +} + +function positiveIntegerEnvironment(name, fallback) { + const value = Number.parseInt(process.env[name] ?? fallback, 10); + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${name} must be a positive integer`); + } + return value; +} + +async function runningAstroOrigin() { + if (process.env.CSSCITYFLOW_PERF_ORIGIN) { + return new URL(process.env.CSSCITYFLOW_PERF_ORIGIN).origin; + } + try { + const { stdout, stderr } = await execFileAsync("pnpm", ["exec", "astro", "dev", "status"], { + cwd: repositoryRoot, + }); + return `${stdout}\n${stderr}`.match(/https?:\/\/127\.0\.0\.1:\d+/)?.[0] ?? null; + } catch { + return null; + } +} + +async function freePort() { + const probe = createNetServer(); + await new Promise((resolveListen, reject) => { + probe.once("error", reject); + probe.listen(0, "127.0.0.1", resolveListen); + }); + const address = probe.address(); + await new Promise((resolveClose) => probe.close(resolveClose)); + if (!address || typeof address === "string") throw new Error("Cityflow performance port is unavailable"); + return address.port; +} + +async function routeIsReady(url) { + try { + const response = await fetch(url); + return response.ok; + } catch { + return false; + } +} + +async function waitForServer(url, processHandle) { + const deadline = Date.now() + 20_000; + while (Date.now() < deadline) { + if (processHandle && processHandle.exitCode !== null) { + throw new Error(`Cityflow performance server exited early:\n${serverOutput}`); + } + if (await routeIsReady(url)) return; + await new Promise((resolveWait) => setTimeout(resolveWait, 100)); + } + throw new Error(`Cityflow performance server did not become ready:\n${serverOutput}`); +} diff --git a/src/adapters/cityflow/vite.config.mjs b/src/adapters/cityflow/vite.config.mjs index ade8c62..b18ede6 100644 --- a/src/adapters/cityflow/vite.config.mjs +++ b/src/adapters/cityflow/vite.config.mjs @@ -1,18 +1,35 @@ +// SPDX-License-Identifier: HPND +import { cp, mkdir, rm } from "node:fs/promises"; import { resolve } from "node:path"; -import { fileURLToPath } from "node:url"; import { defineConfig } from "vite"; +import { createExamplesShellPlugin } from "../../../site/examples-shell-plugin.mjs"; -const adapterRoot = fileURLToPath(new URL(".", import.meta.url)); +const adapterRoot = import.meta.dirname; const repositoryRoot = resolve(adapterRoot, "..", "..", ".."); +const generatedPublicDir = resolve(repositoryRoot, "build/generated/public"); +const deployBuild = process.env.CSSCITYFLOW_DEPLOY_BUILD === "1"; export default defineConfig({ + base: deployBuild ? "/cityflow/" : "/", root: adapterRoot, - publicDir: resolve(repositoryRoot, "build/generated/public"), + publicDir: deployBuild ? false : generatedPublicDir, + plugins: [createExamplesShellPlugin("cityflow"), ...(deployBuild ? [{ + name: "csscityflow-netlify-assets", + async closeBundle() { + const deployAssets = resolve(repositoryRoot, "dist/site/csscityflow"); + await mkdir(resolve(repositoryRoot, "dist/site"), { recursive: true }); + await rm(deployAssets, { recursive: true, force: true }); + await cp(resolve(generatedPublicDir, "csscityflow"), deployAssets, { + recursive: true, + force: true, + }); + }, + }] : [])], server: { host: "127.0.0.1" }, preview: { host: "127.0.0.1" }, build: { target: "es2022", - outDir: resolve(repositoryRoot, "dist/cityflow"), + outDir: resolve(repositoryRoot, deployBuild ? "dist/site/cityflow" : "dist/cityflow-local"), emptyOutDir: true, }, });