diff --git a/netlify.toml b/netlify.toml index e40d3f8..b8cc9fd 100644 --- a/netlify.toml +++ b/netlify.toml @@ -52,6 +52,41 @@ [headers.values] Cache-Control = "public, max-age=31536000, immutable" +[[headers]] + for = "/cssflocks/*/blocks/*" + [headers.values] + Cache-Control = "public, max-age=31536000, immutable" + +[[headers]] + for = "/cssflocks/model/*" + [headers.values] + Cache-Control = "public, max-age=31536000, immutable" + +[[headers]] + for = "/cssflocks/manifest.json" + [headers.values] + Cache-Control = "no-cache" + +[[headers]] + for = "/cssflocks/prepared.json" + [headers.values] + Cache-Control = "no-cache" + +[[headers]] + for = "/cssflocks/*/catalog.json" + [headers.values] + Cache-Control = "no-cache" + +[[headers]] + for = "/cssflocks/model/catalog.json" + [headers.values] + Cache-Control = "no-cache" + +[[headers]] + for = "/cssflocks/scenes/*" + [headers.values] + Cache-Control = "no-cache" + [[headers]] for = "/cssmenger/assets/*" [headers.values] diff --git a/package.json b/package.json index 1eab9c7..20b0455 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "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", + "dev:flocks": "vite --config src/adapters/flocks/vite.config.mjs --host 127.0.0.1", "dev:electropaint": "vite --config src/adapters/electropaint/vite.config.mjs --host 127.0.0.1", "dev:menger": "vite --config src/adapters/menger/vite.config.mjs --host 127.0.0.1", "dev:maze": "vite --config src/adapters/maze/vite.config.mjs --host 127.0.0.1", @@ -61,6 +62,9 @@ "build:gravitywell:full": "pnpm prepare:gravitywell:artifact && pnpm build:gravitywell", "build:cyclone": "vite build --config src/adapters/cyclone/vite.config.mjs", "build:cyclone:full": "pnpm prepare:cyclone && pnpm build:cyclone", + "build:flocks": "vite build --config src/adapters/flocks/vite.config.mjs", + "build:flocks:full": "pnpm prepare:flocks && pnpm build:flocks", + "build:flocks:deploy": "CSSFLOCKS_DEPLOY_BUILD=1 pnpm build:flocks", "build:electropaint": "vite build --config src/adapters/electropaint/vite.config.mjs", "build:electropaint:full": "pnpm prepare:electropaint:source && pnpm build:electropaint", "build:electropaint:deploy": "CSSSELECTROPAINT_DEPLOY_BUILD=1 pnpm build:electropaint", @@ -72,7 +76,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 exec playwright install chromium && pnpm prepare:3dpipes && pnpm prepare:flowerbox:artifact && pnpm prepare:gears:artifact && pnpm prepare:gravitywell:artifact && pnpm prepare:cyclone && pnpm prepare:menger:artifact && pnpm prepare:maze:artifact && pnpm prepare:electropaint:deploy && pnpm prepare:solitaire && CSSPIPES_DEPLOY_BUILD=1 pnpm build:3dpipes && CSSFLOWER_DEPLOY_BUILD=1 pnpm build:flowerbox && CSSGEARS_DEPLOY_BUILD=1 pnpm build:gears && CSSGRAVITYWELL_DEPLOY_BUILD=1 pnpm build:gravitywell && CSSCYCLONE_DEPLOY_BUILD=1 pnpm build:cyclone && CSSMENGER_DEPLOY_BUILD=1 pnpm build:menger && CSSMAZE_DEPLOY_BUILD=1 pnpm build:maze && CSSSELECTROPAINT_DEPLOY_BUILD=1 pnpm build:electropaint && CSSSOLITAIRE_DEPLOY_BUILD=1 pnpm build:solitaire && CSSGRAPHICS_DEPLOY_BUILD=1 pnpm build:site", + "build:deploy": "pnpm exec playwright install chromium && pnpm prepare:3dpipes && pnpm prepare:flowerbox:artifact && pnpm prepare:gears:artifact && pnpm prepare:gravitywell:artifact && pnpm prepare:cyclone && pnpm prepare:flocks && pnpm prepare:menger:artifact && pnpm prepare:maze:artifact && pnpm prepare:electropaint:deploy && pnpm prepare:solitaire && CSSPIPES_DEPLOY_BUILD=1 pnpm build:3dpipes && CSSFLOWER_DEPLOY_BUILD=1 pnpm build:flowerbox && CSSGEARS_DEPLOY_BUILD=1 pnpm build:gears && CSSGRAVITYWELL_DEPLOY_BUILD=1 pnpm build:gravitywell && CSSCYCLONE_DEPLOY_BUILD=1 pnpm build:cyclone && CSSMENGER_DEPLOY_BUILD=1 pnpm build:menger && CSSMAZE_DEPLOY_BUILD=1 pnpm build:maze && CSSSELECTROPAINT_DEPLOY_BUILD=1 pnpm build:electropaint && CSSSOLITAIRE_DEPLOY_BUILD=1 pnpm build:solitaire && CSSFLOCKS_DEPLOY_BUILD=1 pnpm build:flocks && CSSGRAPHICS_DEPLOY_BUILD=1 pnpm build:site", "prepack": "pnpm build:lib", "preview": "vite preview --host 127.0.0.1", "prepare:super-mario-64": "pnpm build:lib && node scripts/prepare/super-mario-64.mjs", @@ -85,6 +89,8 @@ "prepare:gravitywell:artifact": "node scripts/prepare/cssgravitywell-product-bank.mjs", "prepare:gravitywell:source": "node src/adapters/gravitywell/tools/prepare-cssgravitywell.mjs", "prepare:cyclone": "node src/adapters/cyclone/tools/prepare-csscyclone.mjs", + "prepare:flocks": "node src/adapters/flocks/tools/prepare-cssflocks.mjs", + "prepare:flocks:check": "node src/adapters/flocks/tools/check-prepare-determinism.mjs", "prepare:electropaint:scene": "node src/adapters/electropaint/tools/prepare-scene.mjs", "prepare:electropaint:source": "pnpm prepare:electropaint:scene && node src/adapters/electropaint/tools/prepare-snapshot.mjs", "prepare:electropaint:artifact": "node scripts/prepare/cssselectropaint-product-bank.mjs", @@ -101,6 +107,7 @@ "prepare:catalog": "pnpm build:lib && node scripts/prepare/catalog.mjs", "prepare:site-models": "node scripts/prepare/site-models.mjs", "prepare:site-previews": "pnpm prepare:site-models && node scripts/prepare/site-previews.mjs", + "audit:adapters": "node scripts/audit-polycss-adapters.mjs", "verify:source-only": "node scripts/verify-source-only.mjs --strict", "verify:flowerbox:bank": "node src/adapters/flowerbox/tools/verify-product-bank.mjs", "verify:flowerbox:runtime": "node src/adapters/flowerbox/tools/audit-runtime-surface.mjs", @@ -118,6 +125,25 @@ "test:gravitywell:browser": "node src/adapters/gravitywell/tools/smoke-browser.mjs", "verify:gravitywell:bank": "node src/adapters/gravitywell/tools/verify-product-bank.mjs", "test:cyclone": "node --test src/adapters/cyclone/test/*.test.mjs", + "test:flocks": "node --test src/adapters/flocks/test/*.test.mjs", + "test:flocks:assets": "node --test src/adapters/flocks/test/cssflocks-assets.test.mjs", + "test:flocks:browser": "node src/adapters/flocks/tools/smoke-browser.mjs", + "test:flocks:deploy": "node --test src/adapters/flocks/test/cssflocks-deploy.test.mjs && node src/adapters/flocks/tools/smoke-browser.mjs --deploy", + "capture:flocks:reference": "node src/adapters/flocks/tools/capture-reference-frame.mjs", + "capture:flocks:browser": "node src/adapters/flocks/tools/capture-browser-frame.mjs", + "compare:flocks:visual": "node src/adapters/flocks/tools/compare-reference-frame.mjs", + "capture:flocks:reference:frames": "node src/adapters/flocks/tools/capture-reference-frames.mjs", + "capture:flocks:browser:frames": "node src/adapters/flocks/tools/capture-browser-frames.mjs", + "compare:flocks:frames": "node src/adapters/flocks/tools/compare-frame-sequence.mjs", + "oracle:flocks:native:state": "node src/adapters/flocks/tools/run-native-state-oracle.mjs", + "perf:flocks:baseline": "node src/adapters/flocks/tools/trace-performance.mjs --baseline", + "perf:flocks:trace": "node src/adapters/flocks/tools/trace-cadence.mjs", + "test:flocks:geometry": "node src/adapters/flocks/tools/run-native-geometry-oracle.mjs && node --test src/adapters/flocks/test/cssflocks-geometry-oracle.test.mjs", + "capture:flocks:geometry": "pnpm prepare:flocks && node src/adapters/flocks/tools/run-native-geometry-oracle.mjs && node src/adapters/flocks/tools/capture-geometry.mjs", + "capture:flocks:shell": "node src/adapters/flocks/tools/capture-shell.mjs", + "capture:flocks:startup-windows": "node src/adapters/flocks/tools/capture-startup-windows.mjs", + "capture:flocks:cadence-visual": "node src/adapters/flocks/tools/capture-cadence-visual.mjs", + "test:flocks:startup": "node src/adapters/flocks/tools/test-startup.mjs", "oracle:gravitywell:native:state": "node src/adapters/gravitywell/tools/run-native-state-oracle.mjs", "oracle:gravitywell:visual": "node src/adapters/gravitywell/tools/oracle/run-visual-oracle.mjs", "perf:gravitywell:frame-work": "node src/adapters/gravitywell/tools/trace-frame-work.mjs", diff --git a/scripts/audit-polycss-adapters.mjs b/scripts/audit-polycss-adapters.mjs new file mode 100644 index 0000000..ca5b384 --- /dev/null +++ b/scripts/audit-polycss-adapters.mjs @@ -0,0 +1,239 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { dirname, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const options = parseArguments(process.argv.slice(2)); +const slug = options.slug; +const namespace = `css${slug}`; +const adapterRoot = resolve(repositoryRoot, "src/adapters", slug); +const checks = []; + +check("adapter slug is safe", /^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(slug), slug); +check("adapter root exists", existsSync(adapterRoot), repositoryPath(adapterRoot)); + +const packageManifest = readJson(resolve(repositoryRoot, "package.json"), "package.json parses"); +const scripts = packageManifest?.scripts ?? {}; +const requiredScripts = [ + `dev:${slug}`, + `build:${slug}`, + `build:${slug}:full`, + `build:${slug}:deploy`, + `prepare:${slug}`, + `prepare:${slug}:check`, + `test:${slug}`, + `test:${slug}:assets`, + `test:${slug}:browser`, + `test:${slug}:deploy`, + `capture:${slug}:reference`, + `capture:${slug}:browser`, + `compare:${slug}:visual`, + `capture:${slug}:reference:frames`, + `capture:${slug}:browser:frames`, + `compare:${slug}:frames`, +]; +for (const name of requiredScripts) { + check(`package script ${name}`, typeof scripts[name] === "string" && scripts[name].trim().length > 0, scripts[name] ?? "missing"); +} +check("package depends on @layoutit/polycss", typeof packageManifest?.dependencies?.["@layoutit/polycss"] === "string", packageManifest?.dependencies?.["@layoutit/polycss"] ?? "missing"); +check("adapter audit script is wired", scripts["audit:adapters"] === "node scripts/audit-polycss-adapters.mjs", scripts["audit:adapters"] ?? "missing"); + +const requiredFiles = [ + ".env.example", + "NOTICE.md", + "README.md", + "index.html", + "vite.config.mjs", + "src/main.mjs", + `src/${namespace}/client.mjs`, + `src/${namespace}/debugApi.mjs`, + `src/${namespace}/devtoolsAttrs.mjs`, + `src/${namespace}/manifestClient.mjs`, + `src/${namespace}/polycssScene.mjs`, + `src/${namespace}/routeState.mjs`, + `src/${namespace}/styles.css`, + `src/prepare/${namespace}/paths.mjs`, + `src/prepare/${namespace}/dataSource.mjs`, + `src/prepare/${namespace}/slicePlan.mjs`, + `src/prepare/${namespace}/formatAdapters.mjs`, + `src/prepare/${namespace}/prepare.mjs`, + `src/prepare/${namespace}/sceneBuilder.mjs`, + `src/prepare/${namespace}/writeManifest.mjs`, + `src/prepare/${namespace}/provenance.mjs`, + `tools/prepare-${namespace}.mjs`, + "tools/check-prepare-determinism.mjs", + "tools/smoke-browser.mjs", + "tools/capture-reference-frame.mjs", + "tools/capture-browser-frame.mjs", + "tools/compare-reference-frame.mjs", + "tools/frameSequenceArtifacts.mjs", + "tools/capture-reference-frames.mjs", + "tools/capture-browser-frames.mjs", + "tools/compare-frame-sequence.mjs", + `test/${namespace}-route-state.test.mjs`, + `test/${namespace}-assets.test.mjs`, + `test/${namespace}-source-lock.test.mjs`, + "notes/references/source-lock.json", +]; +for (const path of requiredFiles) { + check(`adapter file ${path}`, existsSync(resolve(adapterRoot, path)), path); +} + +const sourceLock = readJson(resolve(adapterRoot, "notes/references/source-lock.json"), "source lock parses"); +check("source lock schema", sourceLock?.schema === `${namespace}-source-lock@1`, sourceLock?.schema ?? "missing"); +check("source revision is pinned", /^[0-9a-f]{40}$/u.test(sourceLock?.revision ?? ""), sourceLock?.revision ?? "missing"); +check("source digest is pinned", /^[0-9a-f]{64}$/u.test(sourceLock?.sha256 ?? ""), sourceLock?.sha256 ?? "missing"); +check("adapter source license is GPL-2.0-or-later", sourceLock?.license === "GPL-2.0-or-later", sourceLock?.license ?? "missing"); +const dependencyLicenses = Object.values(sourceLock?.dependencies ?? {}).flatMap((dependency) => + (dependency?.files ?? []).map((file) => file?.license)); +check("source dependency licenses are explicit", dependencyLicenses.length > 0 && dependencyLicenses.every((license) => typeof license === "string" && license.length > 0), dependencyLicenses.join(", ") || "missing"); + +const readme = readAdapter("README.md"); +const notice = readAdapter("NOTICE.md"); +const envExample = readAdapter(".env.example"); +for (const value of [sourceLock?.revision, sourceLock?.path, sourceLock?.sha256, sourceLock?.license]) { + check(`notice records ${value}`, typeof value === "string" && notice.includes(value), value ?? "missing"); +} +check("notice records dependency license", notice.includes("LGPL-2.1-or-later"), "NOTICE.md"); +check("notice points to GPL terms", notice.includes("src/adapters/electropaint/LICENSE.GPL-2.0") && existsSync(resolve(repositoryRoot, "src/adapters/electropaint/LICENSE.GPL-2.0")), "NOTICE.md"); +check("documentation has no bootstrap status language", !/first[ -]slice|placeholder|remain(?:s)? later gate|reserved for a later deploy/iu.test(`${readme}\n${notice}\n${envExample}`), "README.md, NOTICE.md, .env.example"); +check("environment documents generated root", /CSSFLOCKS_GENERATED_PUBLIC_DIR=/u.test(envExample), ".env.example"); +check("environment documents deploy switch", /CSSFLOCKS_DEPLOY_BUILD=0/u.test(envExample), ".env.example"); + +const documentedCommands = [...readme.matchAll(/^pnpm\s+([^\s]+)/gmu)].map((match) => match[1]); +for (const command of documentedCommands) { + check(`documented command ${command}`, typeof scripts[command] === "string", scripts[command] ?? "missing package script"); +} +check("README documents every promotion command", [ + "audit:adapters", + "verify:source-only", + `prepare:${slug}:check`, + `test:${slug}`, + `test:${slug}:browser`, + `capture:${slug}:reference:frames`, + `capture:${slug}:browser:frames`, + `compare:${slug}:frames`, + `build:${slug}:deploy`, + `test:${slug}:deploy`, +].every((name) => documentedCommands.includes(name)), documentedCommands.join(", ")); +check("README names only ignored evidence roots", [...readme.matchAll(/`((?:bench\/results|build\/generated)[^`]+)`/gu)] + .every((match) => match[1].startsWith("bench/results/") || match[1].startsWith("build/generated/")), "README.md"); + +const viteConfig = readAdapter("vite.config.mjs"); +check("Vite uses generated public assets in development", viteConfig.includes("publicDir: deployBuild ? false : generatedPublicDir"), "vite.config.mjs"); +check("Vite deploy copies only adapter assets", viteConfig.includes(`dist/site/${namespace}`) && viteConfig.includes(`resolve(generatedPublicDir, "${namespace}")`), "vite.config.mjs"); +check("Vite deploy route is adapter-scoped", viteConfig.includes(`base: deployBuild ? "/${slug}/"`) && viteConfig.includes(`"dist/site/${slug}"`), "vite.config.mjs"); + +const gitignore = readFileSync(resolve(repositoryRoot, ".gitignore"), "utf8"); +for (const ignored of [".local/", "build/generated/", "bench/results/", "dist/"]) { + check(`gitignore contains ${ignored}`, gitignore.split(/\r?\n/u).includes(ignored), ".gitignore"); +} +check("no standalone-layout aliases were added", !existsSync(resolve(repositoryRoot, "src", slug)) && + !existsSync(resolve(repositoryRoot, "tools", `prepare-${slug}.mjs`)), `src/${slug}, tools/prepare-${slug}.mjs`); +const adapterSource = walk(adapterRoot).filter((path) => /\.(?:mjs|js|ts|css|html|md|json|cpp|h)$/u.test(path)) + .map((path) => readFileSync(path, "utf8")).join("\n"); +check("adapter source has no checkout-specific absolute path", !/(?:\/Users\/|\/home\/)[^\s"'`]+|\.codex\/skills/iu.test(adapterSource), "src/adapters/"); + +const spdxFiles = [ + `src/prepare/${namespace}/modelBuilder.mjs`, + `src/prepare/${namespace}/prepare.mjs`, + `src/prepare/${namespace}/provenance.mjs`, + `src/prepare/${namespace}/sourceModel.mjs`, + `src/prepare/${namespace}/terminalSeam.mjs`, + `src/shared/${namespace}/bugTransform.mjs`, + `src/shared/${namespace}/preparedBlockTransport.mjs`, + "tools/native-geometry-oracle.cpp", + "tools/native-sequence-oracle.cpp", + "tools/native-state-oracle.cpp", +]; +for (const path of spdxFiles) { + check(`SPDX ${path}`, readAdapter(path).includes("SPDX-License-Identifier: GPL-2.0-or-later"), path); +} + +const runtimePaths = walk(resolve(adapterRoot, "src", namespace)).filter((path) => /\.(?:mjs|css)$/u.test(path)); +const runtimeSource = runtimePaths.map((path) => `${repositoryPath(path)}\n${readFileSync(path, "utf8")}`).join("\n"); +const forbiddenRuntime = [ + ["Canvas construction", /createElement\(\s*["']canvas["']|OffscreenCanvas/iu], + ["drawing context", /getContext\(\s*["'](?:2d|bitmaprenderer|webgl2?|webgpu)["']/iu], + ["GPU renderer", /WebGL(?:2)?RenderingContext|navigator\.gpu|GPUCanvasContext/iu], + ["runtime geometry", /runtimeGeometryConstructionCount\s*\+=|createElementNS\(/iu], +]; +for (const [label, pattern] of forbiddenRuntime) check(`runtime avoids ${label}`, !pattern.test(runtimeSource), label); +const css = readAdapter(`src/${namespace}/styles.css`); +for (const [label, pattern] of [ + ["clipping", /clip-path\s*:/iu], + ["masking", /(?:^|[;{])\s*(?:-webkit-)?mask(?:-image)?\s*:/imu], + ["filters", /(?:^|[;{])\s*(?:backdrop-)?filter\s*:/imu], + ["shadows", /(?:box|text)-shadow\s*:/iu], + ["gradients", /(?:linear|radial|conic)-gradient\s*\(/iu], + ["blend modes", /(?:mix|background)-blend-mode\s*:/iu], +]) check(`adapter CSS avoids ${label}`, !pattern.test(css), `src/${namespace}/styles.css`); + +const tracked = execFileSync("git", ["ls-files"], { cwd: repositoryRoot, encoding: "utf8" }).trim().split("\n").filter(Boolean); +check("generated and evidence trees are untracked", tracked.every((path) => + !["build/generated/", "bench/results/", ".local/", "dist/"].some((prefix) => path.startsWith(prefix))), "git ls-files"); + +const result = Object.freeze({ + schema: "cssgraphics-adapter-audit@1", + root: repositoryRoot, + slug, + namespace, + ok: checks.every((entry) => entry.ok), + checks, +}); +if (options.json) console.log(JSON.stringify(result, null, 2)); +else { + for (const entry of checks) console.log(`${entry.ok ? "PASS" : "FAIL"} ${entry.name}${entry.ok ? "" : `: ${entry.detail}`}`); + console.log(`Adapter audit ${result.ok ? "passed" : "failed"}: ${checks.filter((entry) => entry.ok).length}/${checks.length} checks`); +} +if (!result.ok) process.exitCode = 1; + +function check(name, ok, detail) { + checks.push(Object.freeze({ name, ok: Boolean(ok), detail: String(detail) })); +} + +function readAdapter(path) { + const absolute = resolve(adapterRoot, path); + if (!existsSync(absolute)) return ""; + return readFileSync(absolute, "utf8"); +} + +function readJson(path, checkName) { + try { + const value = JSON.parse(readFileSync(path, "utf8")); + check(checkName, true, repositoryPath(path)); + return value; + } catch (error) { + check(checkName, false, error instanceof Error ? error.message : String(error)); + return null; + } +} + +function walk(root) { + if (!existsSync(root)) return []; + return readdirSync(root, { withFileTypes: true }).flatMap((entry) => { + const path = join(root, entry.name); + return entry.isDirectory() ? walk(path) : entry.isFile() ? [path] : []; + }); +} + +function repositoryPath(path) { + return relative(repositoryRoot, path).replaceAll("\\", "/"); +} + +function parseArguments(argumentsList) { + let parsedSlug = ""; + let json = false; + for (let index = 0; index < argumentsList.length; index += 1) { + const argument = argumentsList[index]; + if (argument === "--") continue; + if (argument === "--slug") parsedSlug = argumentsList[++index] ?? ""; + else if (argument === "--json") json = true; + else throw new Error(`Unknown adapter-audit argument: ${argument}`); + } + if (!parsedSlug) throw new Error("Adapter audit requires --slug "); + return Object.freeze({ slug: parsedSlug, json }); +} diff --git a/scripts/filter-frame-sleuth-trace.py b/scripts/filter-frame-sleuth-trace.py new file mode 100644 index 0000000..e4ce29f --- /dev/null +++ b/scripts/filter-frame-sleuth-trace.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Stream-filter an oversized Chrome JSON trace to FrameSleuth's evidence surface.""" + +import gzip +import json +import re +import sys + + +KEEP_NAMES = { + "RunTask", + "ThreadControllerImpl::RunTask", + "FireAnimationFrame", + "TimerFire", + "FunctionCall", + "RunMicrotasks", + "UpdateLayoutTree", + "RecalculateStyles", + "Layout", + "PrePaint", + "Paint", + "PaintImage", + "UpdateLayer", + "Layerize", + "Commit", + "RasterTask", + "GPUTask", + "DrawFrame", + "BeginFrame", + "AnimationFrame::Presentation", + "PipelineReporter", + "TracingStartedInBrowser", + "FrameCommittedInBrowser", + "CommitLoad", + "FrameLoader:state_snapshot", + "Profile", + "ProfileChunk", +} +GC_NAME = re.compile(r"^(?:MinorGC|MajorGC|V8\.GC)") +STEADY_MARK = re.compile(r"steady.*(?:animation|playback)|(?:animation|playback).*steady", re.I) + + +def keep_event(event): + name = event.get("name", "") + return ( + event.get("ph") == "M" + or name in KEEP_NAMES + or GC_NAME.match(name) + or STEADY_MARK.search(name) + ) + + +def filter_trace(source_path, output_path): + decoder = json.JSONDecoder() + marker = '"traceEvents":[' + buffer = "" + kept = 0 + seen = 0 + with gzip.open(source_path, "rt", encoding="utf-8") as source: + while marker not in buffer: + chunk = source.read(1024 * 1024) + if not chunk: + raise ValueError("Chrome trace has no traceEvents array") + buffer += chunk + if len(buffer) > len(marker) * 2 and marker not in buffer: + buffer = buffer[-len(marker) * 2 :] + buffer = buffer.split(marker, 1)[1] + position = 0 + with gzip.open(output_path, "wt", encoding="utf-8", compresslevel=6) as output: + output.write('{"traceEvents":[') + first = True + while True: + while True: + while position < len(buffer) and buffer[position] in " \r\n\t,": + position += 1 + if position < len(buffer): + break + buffer = source.read(1024 * 1024) + position = 0 + if not buffer: + raise ValueError("Chrome trace ended inside traceEvents") + if buffer[position] == "]": + break + try: + event, end = decoder.raw_decode(buffer, position) + except json.JSONDecodeError: + chunk = source.read(1024 * 1024) + if not chunk: + raise ValueError("Chrome trace ended inside an event") + buffer = buffer[position:] + chunk + position = 0 + continue + if not isinstance(event, dict): + raise ValueError("Chrome trace event is not an object") + seen += 1 + if keep_event(event): + if not first: + output.write(",") + json.dump(event, output, separators=(",", ":"), ensure_ascii=False) + first = False + kept += 1 + position = end + if position > 1024 * 1024: + buffer = buffer[position:] + position = 0 + output.write('],"metadata":{"filteredFor":"FrameSleuth","sourceTrace":"') + output.write(source_path.replace("\\", "\\\\").replace('"', '\\"')) + output.write('"}}') + return seen, kept + + +if __name__ == "__main__": + if len(sys.argv) != 3: + raise SystemExit("usage: filter-frame-sleuth-trace.py ") + seen_count, kept_count = filter_trace(sys.argv[1], sys.argv[2]) + print(json.dumps({"seen": seen_count, "kept": kept_count})) diff --git a/scripts/frame-sleuth-trace.mjs b/scripts/frame-sleuth-trace.mjs index d19667f..9ec5adf 100644 --- a/scripts/frame-sleuth-trace.mjs +++ b/scripts/frame-sleuth-trace.mjs @@ -1,13 +1,17 @@ #!/usr/bin/env node import { createHash } from "node:crypto"; +import { execFile } from "node:child_process"; import { once } from "node:events"; import { createReadStream, createWriteStream } from "node:fs"; -import { access, mkdir, rm, stat, writeFile } from "node:fs/promises"; +import { access, mkdir, rename, rm, stat, writeFile } from "node:fs/promises"; import { basename, dirname, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { chromium } from "playwright"; import { analyzeTrace, extractFrameScreenshots, loadTrace, renderFrameChartSvg, renderMarkdown } from "./frame-sleuth.mjs"; +import { promisify } from "node:util"; + +const run = promisify(execFile); export const CAPTURE_SCHEMA = "cssgraphics-frame-sleuth-capture@1"; export const DEFAULT_CAPTURE_DURATION_MS = 8_000; @@ -42,6 +46,18 @@ const DEVTOOLS_TRACE_CATEGORIES = Object.freeze([ "v8.execute", ]); +const LEAN_TRACE_CATEGORIES = Object.freeze([ + "benchmark", + "blink.user_timing", + "browser", + "cc", + "devtools.timeline", + "loading", + "rail", + "renderer_host", + "toplevel", +]); + const HELP = `FrameSleuth Tracer — capture a DevTools-grade real-Chrome trace and analyze it Usage: @@ -63,9 +79,10 @@ about:blank before cold navigation. The tracer never waits for network-idle or app readiness and never pauses, seeks, steps, or warms the measured page. `; -export function traceCategories({ screenshots = false } = {}) { +export function traceCategories({ screenshots = false, lean = false, frameTimeline = false } = {}) { return Object.freeze([ - ...DEVTOOLS_TRACE_CATEGORIES, + ...(lean ? LEAN_TRACE_CATEGORIES : DEVTOOLS_TRACE_CATEGORIES), + ...(lean && frameTimeline ? ["disabled-by-default-devtools.timeline.frame"] : []), ...(screenshots ? ["disabled-by-default-devtools.screenshot"] : []), ]); } @@ -129,12 +146,13 @@ export function captureOutputPaths(tracePath) { fullChart: resolve(root, `${stem}.frame-times.svg`), steadyChart: resolve(root, `${stem}.frame-times-steady.svg`), screenshots: resolve(root, `${stem}-screenshots`), + rawTrace: resolve(root, `${stem}.raw.json.gz`), }); } export async function captureFrameSleuthTrace(options, { browserType = chromium } = {}) { const paths = captureOutputPaths(options.output); - await assertOutputsAbsent(paths, options.screenshots); + await assertOutputsAbsent(paths, options.screenshots, options.frameSleuthFilter === true); await mkdir(dirname(paths.trace), { recursive: true }); const categories = traceCategories(options); const errors = []; @@ -167,6 +185,14 @@ export async function captureFrameSleuthTrace(options, { browserType = chromium } await stopTrace(cdp, paths.trace); if (navigationError) throw navigationError; + let rawTrace = null; + if (options.frameSleuthFilter === true) { + await rename(paths.trace, paths.rawTrace); + await run("python3", [resolve(import.meta.dirname, "filter-frame-sleuth-trace.py"), paths.rawTrace, paths.trace], { + maxBuffer: 1024 * 1024, + }); + rawTrace = { path: paths.rawTrace, ...await fileIdentity(paths.rawTrace) }; + } const finalUrl = page.url(); await context.close(); context = null; @@ -214,8 +240,10 @@ export async function captureFrameSleuthTrace(options, { browserType = chromium untouchedPage: true, categories, screenshotsRequested: options.screenshots, + leanCategories: options.lean === true, errors, trace: { path: paths.trace, ...traceIdentity }, + rawTrace, reports: { full: paths.fullReport, steady: paths.steadyReport, @@ -271,9 +299,10 @@ async function stopTrace(cdp, path) { } } -async function assertOutputsAbsent(paths, screenshots) { +async function assertOutputsAbsent(paths, screenshots, filtered) { const candidates = [paths.trace, paths.capture, paths.analysis, paths.fullReport, paths.steadyReport, paths.fullChart, paths.steadyChart]; if (screenshots) candidates.push(paths.screenshots); + if (filtered) candidates.push(paths.rawTrace); for (const path of candidates) { try { await access(path); diff --git a/scripts/frame-sleuth-trace.test.mjs b/scripts/frame-sleuth-trace.test.mjs index b443623..5eb318f 100644 --- a/scripts/frame-sleuth-trace.test.mjs +++ b/scripts/frame-sleuth-trace.test.mjs @@ -54,6 +54,15 @@ test("captures DevTools frame, JavaScript, GC, navigation, and optional screensh assert.ok(traceCategories({ screenshots: true }).includes("disabled-by-default-devtools.screenshot")); }); +test("supports the bounded frame-timeline capture used by Flocks qualification", () => { + const categories = traceCategories({ lean: true, frameTimeline: true }); + assert.ok(categories.includes("disabled-by-default-devtools.timeline.frame")); + assert.ok(categories.includes("devtools.timeline")); + assert.ok(categories.includes("cc")); + assert.ok(!categories.includes("disabled-by-default-v8.cpu_profiler")); + assert.ok(!categories.includes("disabled-by-default-devtools.v8-source-rundown")); +}); + test("derives reports beside the raw trace", () => { const fixture = resolve("output/frame-sleuth-test/run/Trace.json.gz"); const root = resolve("output/frame-sleuth-test/run"); @@ -64,6 +73,7 @@ test("derives reports beside the raw trace", () => { assert.equal(paths.steadyReport, resolve(root, "Trace.frame-sleuth-steady.md")); assert.equal(paths.fullChart, resolve(root, "Trace.frame-times.svg")); assert.equal(paths.steadyChart, resolve(root, "Trace.frame-times-steady.svg")); + assert.equal(paths.rawTrace, resolve(root, "Trace.raw.json.gz")); }); test("starts tracing before cold navigation and contains no app warmup seam", async () => { diff --git a/src/adapters/flocks/.env.example b/src/adapters/flocks/.env.example new file mode 100644 index 0000000..743581e --- /dev/null +++ b/src/adapters/flocks/.env.example @@ -0,0 +1,6 @@ +# Optional alternate generated-public root for isolated preparation or testing. +CSSFLOCKS_GENERATED_PUBLIC_DIR= + +# Set to 1 only for the local deploy build that writes dist/site/flocks and +# copies the generated cssflocks tree into dist/site/cssflocks. +CSSFLOCKS_DEPLOY_BUILD=0 diff --git a/src/adapters/flocks/NOTICE.md b/src/adapters/flocks/NOTICE.md new file mode 100644 index 0000000..d02e50e --- /dev/null +++ b/src/adapters/flocks/NOTICE.md @@ -0,0 +1,23 @@ +# Flocks source notice + +This adapter is a source-informed PolyCSS recreation of **Flocks** by Terence M. Welsh. + +- Upstream: +- Revision: `a419fc4ecf4b9b19526448bf9f5dfc435e24ca4c` +- Source path: `src/flocks/flocks.cpp` +- Source SHA-256: `0db819da1d123ad4ae2cf5f53bec278e64b2f65ecabe617a843197446c1813d6` +- Source license: GNU General Public License, version 2 or later + +The adapter ports the source defaults, seeded leader/follower motion, hue following, velocity orientation, stretch calculation, and geometry-one topology. Browser product profiles use explicit deterministic prefixes of the source-ordered bug population; they do not change or relabel the source default of 4 leaders plus 1000 followers. + +Prepared-product deviations: + +- OpenGL/GLU rendering is replaced by six PolyCSS solid triangles per bug. +- Dynamic OpenGL lighting/specular response is replaced by fixed per-face flat-light factors. +- Every source transform state is published at 60 Hz, while each retained root's prepared color is published on one of five staggered phases (12 Hz per root) to bound repaint work. +- The exact-source bank lasts 216 seconds. Its final 8 seconds are followed by a prepare-only cubic-Hermite correspondence bridge so the retained roots can loop without the source stream's discontinuous restart. +- Source-optional dot, line, Chromatek, and connection modes are not exposed on the default route. + +The compiled native oracles include the pinned upstream source and qualify state evolution, GL transforms, GLU topology, winding, camera projection, and the numbered native reference sequence. Native/browser RGB results are diagnostic, not a visual-parity claim, because the accepted flat-lighting system intentionally differs from source OpenGL lighting. Physical-device mobile cadence has not been qualified. + +This adapter is distributed under GPL-2.0-or-later. The GPL version 2 terms are included at `src/adapters/electropaint/LICENSE.GPL-2.0`; the pinned rsMath and Rgbhsl dependencies are LGPL-2.1-or-later as recorded in `notes/references/source-lock.json`. diff --git a/src/adapters/flocks/README.md b/src/adapters/flocks/README.md new file mode 100644 index 0000000..22e3781 --- /dev/null +++ b/src/adapters/flocks/README.md @@ -0,0 +1,86 @@ +# Flocks PolyCSS adapter + +Source-backed Really Slick Screensavers Flocks rendered as stable retained DOM with PolyCSS. + +## Source contract + +- Upstream revision: `a419fc4ecf4b9b19526448bf9f5dfc435e24ca4c` +- Source path: `src/flocks/flocks.cpp` +- Source SHA-256: `0db819da1d123ad4ae2cf5f53bec278e64b2f65ecabe617a843197446c1813d6` +- Adapter license: GPL-2.0-or-later +- Source default: 4 leaders + 1000 followers +- Geometry-one topology: GLU sphere radius 2.5, 3 slices, 2 stacks; prepared as six solid triangles + +The Node preparer simulates all 1004 source-ordered bugs at a fixed 60 Hz. Browser profiles select an exact prefix only after full-source simulation: + +| Profile | Leaders | Followers | Roots | Leaves | +| --- | ---: | ---: | ---: | ---: | +| Desktop | 4 | 320 | 324 | 1944 | +| Mobile | 4 | 160 | 164 | 984 | + +## Reproduce + +```sh +pnpm audit:adapters -- --slug flocks +pnpm verify:source-only --strict +pnpm prepare:flocks +pnpm prepare:flocks:check +pnpm test:flocks +pnpm build:flocks +pnpm test:flocks:browser +pnpm test:flocks:startup +pnpm perf:flocks:trace -- --profile desktop --runs 3 +pnpm capture:flocks:reference:frames +pnpm capture:flocks:browser:frames +pnpm compare:flocks:frames +pnpm build:flocks:deploy +pnpm test:flocks:deploy +``` + +`pnpm dev:flocks` serves the adapter locally. The generated model, manifest, scene, catalogs, and playback blocks live under ignored `build/generated/public/cssflocks/`. The local deploy build writes only `dist/site/flocks/` and `dist/site/cssflocks/`; it does not publish or add Flocks to the curated landing page. + +The singular `capture:flocks:reference`, `capture:flocks:browser`, and `compare:flocks:visual` compatibility commands execute the same complete numbered sequence. There is no selected-still parity harness. + +## Runtime contract + +- one stable direct scene root per bug +- six stable `` solid-triangle leaves per root +- no retained model wrapper after mount +- no Canvas, SVG scene renderer, WebGL, texture atlas, runtime geometry, class swapping, or DOM growth +- one-second gzip checkpoint/delta blocks; the active block plus eleven successors are downloaded, verified, and decompressed behind the loading indicator +- the active block plus two successors are materialized before readiness; later successors are expanded sequentially in a worker and adopted as bounded 960-state main-thread idle slices +- every source transform state is published at 60 Hz; root colors use five staggered phases, or 12 Hz per retained root +- at most three materialized blocks and twelve verified/decompressed blocks are resident + +Dynamic OpenGL lighting is intentionally replaced by fixed per-face flat-light factors using `currentColor`. Dots, connections, Chromatek, and alternate geometry are not part of the route. + +## Measured budgets + +The deterministic bank contains 216 seconds of exact source motion plus an 8-second prepare-only cubic-Hermite terminal bridge. The bridge is an explicit behavior deviation used after a one-hour natural-seam search found no qualified source-only loop. + +| Measure | Desktop | Mobile profile | +| --- | ---: | ---: | +| Encoded 224-second bank | 11,528,143 B | 5,678,413 B | +| Cold-ready median, installed desktop Chrome | 1,033.77 ms | 599.80 ms | +| Maximum initial encoded transfer | 601,215 B | 305,342 B | +| Maximum resident prepared CSS strings | 15,664,056 B | 7,863,196 B | + +The mobile row is a desktop-Chrome profile/startup measurement, not physical-device cadence evidence. + +Three final installed-Chrome desktop traces at 324 roots / 1,944 leaves recorded a presented-frame p95 of 16.667-16.720 ms and worst DrawFrame gaps of 19.016-27.115 ms, with zero app-attributed long tasks, block waits, steady-state scheduler resets, browser errors, or retained-DOM drift. The startup harness observed zero materialization or playback long tasks after the loading boundary. These numbers establish the declared desktop trace gate on the reference machine; they are not a universal smoothness claim. + +The 45-frame source/native/browser sequence covers startup, an ordinary block handoff, a visible hue wrap, maximum visible stretch/orientation, and the terminal loop. All 14,580 transforms and staggered colors match decoded prepared state within the 0.001 CSSOM tolerance. Maximum transport errors are 0.015625 source-position units, 0.003906 velocity units, 0.000015241 hue, and 0.049692 projected pixels. Terminal p95 center motion is 6.6933 px within the qualified 7.16 px bound. + +## Evidence and limits + +Reproducible ignored evidence is written to: + +- `bench/results/cssflocks/startup/report.json` +- `bench/results/cssflocks/cadence/desktop/report.json` +- `bench/results/cssflocks/reference-frames/` +- `bench/results/cssflocks/browser-frames/` +- `bench/results/cssflocks/frame-comparison/report.json` +- `bench/results/cssflocks/frame-comparison/native-browser-contact-sheet.png` +- `bench/results/cssflocks/deploy/report.json` + +The compiled oracles include the pinned source and qualify state evolution, GL transforms, GLU topology, winding, camera projection, and the native reference sequence. Native/browser RGB differences remain diagnostic because source OpenGL lighting and the accepted fixed flat lighting intentionally differ. Real USB-phone cadence was explicitly skipped during this work and remains unproven; desktop emulation is not a substitute. diff --git a/src/adapters/flocks/index.html b/src/adapters/flocks/index.html new file mode 100644 index 0000000..8dd720a --- /dev/null +++ b/src/adapters/flocks/index.html @@ -0,0 +1,32 @@ + + + + + + + + + + Flocks - Powered by PolyCSS + + + + + + diff --git a/src/adapters/flocks/notes/references/source-lock.json b/src/adapters/flocks/notes/references/source-lock.json new file mode 100644 index 0000000..5e504c2 --- /dev/null +++ b/src/adapters/flocks/notes/references/source-lock.json @@ -0,0 +1,67 @@ +{ + "schema": "cssflocks-source-lock@1", + "repository": "https://github.com/reallyslickscreensavers/reallyslickscreensavers", + "revision": "a419fc4ecf4b9b19526448bf9f5dfc435e24ca4c", + "path": "src/flocks/flocks.cpp", + "sha256": "0db819da1d123ad4ae2cf5f53bec278e64b2f65ecabe617a843197446c1813d6", + "license": "GPL-2.0-or-later", + "copyright": "Copyright (C) 1999-2010 Terence M. Welsh", + "dependencies": { + "rslibs": { + "repository": "https://github.com/reallyslickscreensavers/rslibs", + "revision": "42d251b1a751956f54c8aad263e78af527ef2b82", + "files": [ + { + "path": "libs/rsMath/rsMath.h", + "sha256": "8d81f07b7ec4a0abc2788161fd600a260b648f0747387aff5abfea2b2510da93", + "license": "LGPL-2.1-or-later" + }, + { + "path": "libs/Rgbhsl/Rgbhsl.h", + "sha256": "d3dc7b72ebc34e31f3966ec122880599f19eda91edf7c4059cb3ef5baedbc45b", + "license": "LGPL-2.1-or-later" + }, + { + "path": "libs/Rgbhsl/Rgbhsl.cpp", + "sha256": "1866954fedbb83f5076cf078b35501882307285a88874c4e29658da76130e05b", + "license": "LGPL-2.1-or-later" + } + ] + } + }, + "defaults": { + "leaders": 4, + "followers": 1000, + "geometry": 1, + "size": 5, + "complexity": 1, + "speed": 15, + "stretch": 20, + "colorFadeSpeed": 15, + "chromatek": 0, + "connections": 0 + }, + "sourceGeometry": { + "primitive": "gluSphere", + "radius": 2.5, + "slices": 3, + "stacks": 2, + "preparedTriangleFaces": 6 + }, + "adapter": { + "simulationLocation": "node-preparer", + "browserPublication": "prepared-transform-and-currentColor-only", + "lighting": "accepted-fixed-flat-face-factors-deviation", + "lightingDeviation": { + "source": "directional GL_LIGHT0 with smooth GLU vertex normals and material specular", + "prepared": "six fixed face opacity factors from 0.78 through 1.0 with currentColor", + "runtimeLeafLightingUpdates": 0 + }, + "excludedSourceModes": ["dots", "unstretched-points", "chromatek", "connections"], + "sourceOracle": "compiled-pinned-source-state-and-gl-transform-oracle", + "visualReference": "compiled-pinned-source-glu-frame-sequence", + "browserComparison": "strict-prepared-state-projection-and-continuity-with-diagnostic-rgb", + "terminalLoop": "prepare-only-eight-second-cubic-hermite-correspondence-deviation", + "mobileDeviceCadence": "unproven" + } +} diff --git a/src/adapters/flocks/src/cssflocks/client.mjs b/src/adapters/flocks/src/cssflocks/client.mjs new file mode 100644 index 0000000..c6c293a --- /dev/null +++ b/src/adapters/flocks/src/cssflocks/client.mjs @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +import { loadPolyMorphPackage } from "@layoutit/polycss-morph"; +import { installFlocksDebugApi } from "./debugApi.mjs"; +import { loadFlocksJson, loadFlocksManifest } from "./manifestClient.mjs"; +import { createFlocksPreparedPlayer } from "./preparedPlayback.mjs"; +import { createFlocksPreparedBlockLoader, loadFlocksPreparedCatalog } from "./preparedStream.mjs"; +import { mountFlocksPolycssScene, assertFlocksDirectRoots } from "./polycssScene.mjs"; +import { selectFlocksPreparedProfile } from "./profileSelection.mjs"; +import { resolveFlocksRoute } from "./routeState.mjs"; +import { selectFlocksStartupWindow } from "../shared/cssflocks/startupWindows.mjs"; + +export async function mountFlocksClient(host) { + const state = { + ready: false, + errors: [], + manifest: null, + scene: null, + route: null, + profile: null, + mounted: null, + blockLoader: null, + player: null, + startupWindow: null, + }; + installFlocksDebugApi(state); + try { + const manifest = await loadFlocksManifest(); + const route = resolveFlocksRoute({ manifest }); + const [prepared, scene] = await Promise.all([ + loadFlocksJson("/cssflocks/prepared.json"), + loadFlocksJson(manifest.scenes.find((entry) => entry.id === route.sceneId).url), + ]); + const profileId = selectFlocksPreparedProfile({ width: host.clientWidth || innerWidth }); + const profile = prepared.profiles?.[profileId]; + if (prepared?.schema !== "cssflocks-prepared-scene@1" || prepared.status !== "ready" || + prepared.defaultScene !== route.sceneId || scene?.id !== route.sceneId || profile?.id !== profileId) { + throw new Error("Flocks manifest, scene, and profile binding drifted"); + } + const [loaded, catalog] = await Promise.all([ + loadPolyMorphPackage("/cssflocks/model/", { modelId: profile.model.id }), + loadFlocksPreparedCatalog(profile.playback), + ]); + if (loaded.model.identity.id !== profile.model.id || catalog.modelId !== profile.model.id || + catalog.bugCount !== profile.presentation.productBugCount) { + throw new Error("Flocks prepared model binding drifted"); + } + const blockLoader = createFlocksPreparedBlockLoader(catalog); + const startupWindow = selectFlocksStartupWindow({ + requestedId: route.startupWindowId, + previousId: readPreviousStartupWindowId(), + }); + const lookaheadBlockIndices = Array.from( + { length: catalog.runtimeLookaheadBlockCount }, + (unused, offset) => (startupWindow.blockIndex + offset + 1) % catalog.blockCount, + ); + const residentBlockIndices = [startupWindow.blockIndex, ...lookaheadBlockIndices]; + const startupLookaheadBlockIndices = lookaheadBlockIndices.slice( + 0, + catalog.startupMaterializedLookaheadBlockCount, + ); + blockLoader.retain(residentBlockIndices); + const [initialBlock, initialLookaheadBlocks] = await Promise.all([ + blockLoader.load(startupWindow.blockIndex, { eager: true }), + Promise.all(startupLookaheadBlockIndices.map((index) => + blockLoader.load(index, { eager: true }))), + blockLoader.prime(residentBlockIndices), + ]); + const mounted = mountFlocksPolycssScene(host, loaded, { sceneId: route.sceneId, profileId }); + const shapeElements = mounted.model.render.shapes.map((shape) => mounted.shapeElements.get(shape.id)); + const player = createFlocksPreparedPlayer({ + shapeElements, + catalog, + initialBlock, + initialLookaheadBlocks, + loadBlock: blockLoader.load, + onBlockWindow(indices) { + blockLoader.retain(indices); + for (const index of indices) blockLoader.prefetch(index); + }, + onError(error) { + state.errors.push(String(error?.stack || error)); + document.body.classList.add("error"); + }, + assertStableDom() { assertFlocksDirectRoots(mounted); }, + setPerspective(perspective) { + mounted.cameraElement.style.setProperty("--flocks-perspective", `${perspective}px`); + }, + }); + player.resize(); + addEventListener("resize", () => player.resize(), { passive: true }); + state.manifest = manifest; + state.scene = scene; + state.route = route; + state.profile = profile; + state.mounted = mounted; + state.blockLoader = blockLoader; + state.player = player; + state.startupWindow = startupWindow; + writePreviousStartupWindowId(startupWindow.id); + document.body.classList.replace("loading", "priming"); + await waitForPaint(); + await waitForPaint(); + state.ready = true; + document.body.classList.replace("priming", "ready"); + player.resume(); + return state; + } catch (error) { + state.errors.push(String(error?.stack || error)); + document.body.classList.remove("loading", "priming"); + document.body.classList.add("error"); + console.error(error); + throw error; + } +} + +const STARTUP_WINDOW_STORAGE_KEY = "cssflocks:last-startup-window"; + +function readPreviousStartupWindowId() { + try { return sessionStorage.getItem(STARTUP_WINDOW_STORAGE_KEY); } catch { return null; } +} + +function writePreviousStartupWindowId(id) { + try { sessionStorage.setItem(STARTUP_WINDOW_STORAGE_KEY, id); } catch {} +} + +function waitForPaint() { + return new Promise((resolve) => requestAnimationFrame(() => setTimeout(resolve, 0))); +} diff --git a/src/adapters/flocks/src/cssflocks/debugApi.mjs b/src/adapters/flocks/src/cssflocks/debugApi.mjs new file mode 100644 index 0000000..a7be118 --- /dev/null +++ b/src/adapters/flocks/src/cssflocks/debugApi.mjs @@ -0,0 +1,32 @@ +import { CSSFLOCKS_DEBUG_API_NAME } from "./types.mjs"; + +export function installFlocksDebugApi(state) { + Object.defineProperty(window, CSSFLOCKS_DEBUG_API_NAME, { + configurable: true, + value: Object.freeze({ + get ready() { return state.ready; }, + get errors() { return Object.freeze([...state.errors]); }, + get manifest() { return state.manifest; }, + get scene() { return state.scene; }, + get route() { return state.route; }, + get startupWindow() { return state.startupWindow; }, + pause() { return state.player?.pause(); }, + resume() { return state.player?.resume(); }, + seekFrame(index) { return state.player?.seekFrame(index); }, + seekStreamFrame(index) { return state.player?.seekStreamFrame(index); }, + stepFrame() { return state.player?.stepFrame(); }, + stats() { + if (!state.player || !state.blockLoader || !state.mounted) return null; + return Object.freeze({ + profileId: state.profile.id, + sourceDefaultBugCount: state.profile.presentation.sourceDefaultBugCount, + productBugCount: state.profile.presentation.productBugCount, + retainedBugRootCount: state.mounted.shapeElements.size, + retainedPolygonLeafCount: state.mounted.leafHandles.size, + ...state.blockLoader.stats(), + ...state.player.stats(), + }); + }, + }), + }); +} diff --git a/src/adapters/flocks/src/cssflocks/devtoolsAttrs.mjs b/src/adapters/flocks/src/cssflocks/devtoolsAttrs.mjs new file mode 100644 index 0000000..530e155 --- /dev/null +++ b/src/adapters/flocks/src/cssflocks/devtoolsAttrs.mjs @@ -0,0 +1,4 @@ +export function attachFlocksSceneMetadata(element, { sceneId, profileId }) { + element.dataset.cssflocksScene = sceneId; + element.dataset.cssflocksProfile = profileId; +} diff --git a/src/adapters/flocks/src/cssflocks/manifestClient.mjs b/src/adapters/flocks/src/cssflocks/manifestClient.mjs new file mode 100644 index 0000000..f098ecc --- /dev/null +++ b/src/adapters/flocks/src/cssflocks/manifestClient.mjs @@ -0,0 +1,15 @@ +export async function loadFlocksManifest() { + const response = await fetch("/cssflocks/manifest.json", { cache: "no-store" }); + if (!response.ok) throw new Error(`Flocks manifest failed: ${response.status}`); + const manifest = await response.json(); + if (manifest?.schema !== "cssflocks-manifest@1" || manifest.status !== "ready") { + throw new Error("Flocks manifest is not ready"); + } + return Object.freeze(manifest); +} + +export async function loadFlocksJson(url) { + const response = await fetch(url, { cache: "no-store" }); + if (!response.ok) throw new Error(`Flocks prepared asset failed: ${response.status} ${url}`); + return response.json(); +} diff --git a/src/adapters/flocks/src/cssflocks/polycssScene.mjs b/src/adapters/flocks/src/cssflocks/polycssScene.mjs new file mode 100644 index 0000000..ba42152 --- /dev/null +++ b/src/adapters/flocks/src/cssflocks/polycssScene.mjs @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +import { createPolyPerspectiveCamera } from "@layoutit/polycss"; +import { mountPolyMorphModel } from "@layoutit/polycss-morph"; +import { attachFlocksSceneMetadata } from "./devtoolsAttrs.mjs"; + +export function mountFlocksPolycssScene(host, loaded, { sceneId, profileId }) { + const mounted = mountPolyMorphModel(host, loaded.model, { + resources: loaded.resources, + camera: createPolyPerspectiveCamera({ perspective: 800, target: [0, 0, 0], rotX: 0, rotY: 0, zoom: 50 }), + }); + cleanPreparedDom(mounted); + attachFlocksSceneMetadata(mounted.sceneElement, { sceneId, profileId }); + return mounted; +} + +function cleanPreparedDom(mounted) { + mounted.cameraElement.className = "polycss-camera"; + mounted.cameraElement.removeAttribute("data-polycss-camera-projection"); + mounted.cameraElement.removeAttribute("data-polycss-camera-perspective"); + mounted.cameraElement.style.removeProperty("perspective"); + mounted.sceneElement.className = "polycss-scene"; + mounted.sceneElement.removeAttribute("aria-hidden"); + mounted.sceneElement.style.removeProperty("transform"); + mounted.modelElement.removeAttribute("class"); + mounted.modelElement.removeAttribute("data-poly-morph-model"); + for (const element of mounted.shapeElements.values()) { + element.removeAttribute("class"); + element.removeAttribute("data-poly-morph-shape"); + } + for (const { element } of mounted.leafHandles.values()) { + element.removeAttribute("class"); + element.removeAttribute("data-poly-morph-leaf"); + element.removeAttribute("data-poly-morph-strategy"); + element.removeAttribute("data-poly-morph-resolved-strategy"); + element.style.removeProperty("backface-visibility"); + element.style.removeProperty("background-color"); + element.style.removeProperty("color"); + element.style.removeProperty("opacity"); + element.style.removeProperty("transform-origin"); + element.style.removeProperty("visibility"); + } + if (mounted.modelElement.parentElement !== mounted.sceneElement || + [...mounted.shapeElements.values()].some((element) => element.parentElement !== mounted.modelElement)) { + throw new Error("Flocks prepared wrapper binding drifted"); + } + for (const element of mounted.shapeElements.values()) mounted.sceneElement.append(element); + if (mounted.modelElement.childElementCount !== 0) { + throw new Error("Flocks prepared model wrapper contains unexpected nodes"); + } + mounted.modelElement.remove(); + assertFlocksDirectRoots(mounted); +} + +export function assertFlocksDirectRoots(mounted) { + if (mounted.modelElement.isConnected || + [...mounted.shapeElements.values()].some((element) => element.parentElement !== mounted.sceneElement) || + [...mounted.leafHandles.values()].some(({ element }) => + ![...mounted.shapeElements.values()].includes(element.parentElement))) { + throw new Error("Flocks direct retained-root identity drifted"); + } +} diff --git a/src/adapters/flocks/src/cssflocks/preparedBlockWorker.mjs b/src/adapters/flocks/src/cssflocks/preparedBlockWorker.mjs new file mode 100644 index 0000000..2e78a73 --- /dev/null +++ b/src/adapters/flocks/src/cssflocks/preparedBlockWorker.mjs @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +import { decodeFlocksPreparedBlock } from "../shared/cssflocks/preparedBlockTransport.mjs"; + +const RESPONSE_CHUNK_SIZE = 960; +let catalog = null; +let tail = Promise.resolve(); +const canceledRequestIds = new Set(); + +self.addEventListener("message", ({ data }) => { + try { + if (data?.type === "initialize") { + if (catalog !== null || data.catalog?.schema !== "cssflocks-prepared-stream-catalog@1") { + throw new Error("Prepared Flocks worker catalog drifted"); + } + catalog = data.catalog; + self.postMessage({ type: "initialized" }); + return; + } + if (data?.type === "cancel" && Number.isSafeInteger(data.requestId)) { + canceledRequestIds.add(data.requestId); + return; + } + if (data?.type !== "materialize" || catalog === null || + !Number.isSafeInteger(data.requestId) || !(data.bytes instanceof Uint8Array)) { + throw new Error("Prepared Flocks worker request drifted"); + } + tail = tail.then(() => materialize(data), () => materialize(data)) + .catch((error) => postError(error, data.requestId)); + } catch (error) { + postError(error, Number.isSafeInteger(data?.requestId) ? data.requestId : null); + } +}); + +async function materialize(data) { + if (canceledRequestIds.delete(data.requestId)) return; + const startedAt = performance.now(); + const block = decodeFlocksPreparedBlock(data.bytes, data.descriptor, catalog); + if (canceledRequestIds.delete(data.requestId)) return; + const { transforms, colors } = block.playback; + const chunkCount = Math.ceil(transforms.length / RESPONSE_CHUNK_SIZE); + self.postMessage({ + type: "materialized-start", + requestId: data.requestId, + block: { ...block, playback: { ...block.playback, transforms: null, colors: null } }, + chunkCount, + durationMilliseconds: performance.now() - startedAt, + }); + for (let chunkIndex = 0; chunkIndex < chunkCount; chunkIndex += 1) { + if (canceledRequestIds.delete(data.requestId)) return; + const start = chunkIndex * RESPONSE_CHUNK_SIZE; + const end = Math.min(transforms.length, start + RESPONSE_CHUNK_SIZE); + const transformChunk = transforms.slice(start, end); + const colorChunk = colors.slice(start, end); + const chunkByteLength = transformChunk.reduce((total, value) => total + value.length, 0) + + colorChunk.reduce((total, value) => total + value.length, 0); + self.postMessage({ + type: "materialized-chunk", + requestId: data.requestId, + chunkIndex, + chunkCount, + transforms: transformChunk, + colors: colorChunk, + chunkByteLength, + }); + if (!data.eager && chunkIndex + 1 < chunkCount) { + await new Promise((resolve) => setTimeout(resolve, catalog.frameMilliseconds)); + } + } +} + +function postError(error, requestId) { + self.postMessage({ + type: "error", + requestId, + message: String(error?.message || error), + stack: String(error?.stack || ""), + }); +} diff --git a/src/adapters/flocks/src/cssflocks/preparedPlayback.mjs b/src/adapters/flocks/src/cssflocks/preparedPlayback.mjs new file mode 100644 index 0000000..ebcc5f8 --- /dev/null +++ b/src/adapters/flocks/src/cssflocks/preparedPlayback.mjs @@ -0,0 +1,322 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +export function resolveFlocksPerspective(viewportHeight, fieldOfViewDegrees = 50) { + if (!Number.isFinite(viewportHeight) || viewportHeight <= 0 || + !Number.isFinite(fieldOfViewDegrees) || fieldOfViewDegrees <= 0 || fieldOfViewDegrees >= 180) { + throw new RangeError("Flocks perspective inputs are invalid"); + } + return Number((viewportHeight / (2 * Math.tan(fieldOfViewDegrees * Math.PI / 360))).toFixed(4)); +} + +export const CSSFLOCKS_COLOR_PUBLICATION_INTERVAL_FRAMES = 5; + +export function createFlocksPreparedPlayer({ + shapeElements, + catalog, + initialBlock, + initialLookaheadBlocks = [], + loadBlock, + onBlockWindow = () => undefined, + onError = () => undefined, + assertStableDom = () => undefined, + setPerspective = () => undefined, + readNow = () => performance.now(), + requestFrame = (callback) => requestAnimationFrame(callback), + cancelFrame = (id) => cancelAnimationFrame(id), +}) { + validateBinding(shapeElements, catalog, initialBlock, initialLookaheadBlocks, loadBlock); + let activeBlock = initialBlock; + let activeBlockIndex = initialBlock.index; + let playback = initialBlock.playback; + let frameIndex = 0; + let paused = true; + let destroyed = false; + let frameRequest = null; + let nextFrameAt = 0; + const pending = new Map(initialLookaheadBlocks.map((block) => [block.index, { + block, + promise: Promise.resolve(block), + error: null, + }])); + const previousColors = new Array(catalog.bugCount).fill(null); + const sourceIndexByRoot = Array.from({ length: catalog.bugCount }, (_, index) => index); + let applyCount = 0; + let shapeTransformWrites = 0; + let rootColorWrites = 0; + let schedulerFrameCallbackCount = 0; + let schedulerLateResetCount = 0; + let blockSwitchCount = 0; + let blockWaitCount = 0; + let terminalWrapCount = 0; + let terminalPermutationCompositionCount = 0; + let prefetchCount = initialLookaheadBlocks.length; + let debugAbsoluteSeekCount = 0; + let debugStepCount = 0; + + publishFrame(0, true); + queueLookahead(); + + function publishFrame(nextFrameIndex, forceColors = false) { + const offset = nextFrameIndex * catalog.bugCount; + for (let bugIndex = 0; bugIndex < catalog.bugCount; bugIndex += 1) { + const element = shapeElements[bugIndex]; + const sourceIndex = sourceIndexByRoot[bugIndex]; + const transform = playback.transforms[offset + sourceIndex]; + if (element.style.transform !== transform) { + element.style.transform = transform; + shapeTransformWrites += 1; + } + if (forceColors || (nextFrameIndex + bugIndex) % CSSFLOCKS_COLOR_PUBLICATION_INTERVAL_FRAMES === 0) { + const color = playback.colors[offset + sourceIndex]; + if (forceColors || previousColors[bugIndex] !== color) { + element.style.color = color; + previousColors[bugIndex] = color; + rootColorWrites += 1; + } + } + } + frameIndex = nextFrameIndex; + applyCount += 1; + } + + function lookaheadIndices(blockIndex = activeBlockIndex) { + const indices = []; + for (let offset = 1; offset <= catalog.runtimeLookaheadBlockCount; offset += 1) { + const index = (blockIndex + offset) % catalog.blockCount; + if (!indices.includes(index)) indices.push(index); + } + return indices; + } + + function queueLookahead() { + const indices = lookaheadIndices(); + const materializedIndices = indices.slice(0, catalog.runtimeMaterializedLookaheadBlockCount); + const keep = new Set(materializedIndices); + for (const index of pending.keys()) if (!keep.has(index)) pending.delete(index); + onBlockWindow([activeBlockIndex, ...indices]); + for (const index of materializedIndices) { + if (pending.has(index)) continue; + const record = { block: null, promise: null, error: null }; + prefetchCount += 1; + record.promise = Promise.resolve(loadBlock(index)).then((block) => { + if (destroyed || pending.get(index) !== record) return null; + validateBlock(block, catalog); + record.block = block; + return block; + }).catch((error) => { + if (destroyed || pending.get(index) !== record) return null; + record.error = error; + onError(error); + return null; + }); + pending.set(index, record); + } + } + + async function activateNextBlock() { + const index = (activeBlockIndex + 1) % catalog.blockCount; + const record = pending.get(index); + if (!record) throw new Error(`Prepared Flocks lookahead block ${index} is unavailable`); + if (record.block === null) { + blockWaitCount += 1; + await record.promise; + } + if (!record.block) throw record.error ?? new Error(`Prepared Flocks lookahead block ${index} failed`); + const previousIndex = activeBlockIndex; + activeBlock = record.block; + activeBlockIndex = index; + playback = activeBlock.playback; + pending.delete(index); + blockSwitchCount += 1; + if (previousIndex === catalog.blockCount - 1 && index === 0) { + terminalWrapCount += 1; + const correspondence = catalog.terminalSeam?.correspondence; + if (Array.isArray(correspondence)) { + for (let rootIndex = 0; rootIndex < sourceIndexByRoot.length; rootIndex += 1) { + sourceIndexByRoot[rootIndex] = correspondence[sourceIndexByRoot[rootIndex]]; + } + terminalPermutationCompositionCount += 1; + } + } + publishFrame(0); + queueLookahead(); + } + + function schedule() { + if (paused || destroyed || frameRequest !== null) return; + frameRequest = requestFrame(wake); + } + + async function wake(timestamp) { + frameRequest = null; + if (paused || destroyed) return; + schedulerFrameCallbackCount += 1; + const now = Math.max(Number(timestamp) || 0, readNow()); + if (now + 0.75 < nextFrameAt) { + schedule(); + return; + } + try { + if (frameIndex + 1 >= playback.frameCount) await activateNextBlock(); + else publishFrame(frameIndex + 1); + nextFrameAt += playback.frameMilliseconds; + if (nextFrameAt <= now) { + nextFrameAt = now + playback.frameMilliseconds; + schedulerLateResetCount += 1; + } + } catch (error) { + paused = true; + onError(error); + return; + } + schedule(); + } + + function pause() { + if (paused || destroyed) return stats(); + paused = true; + if (frameRequest !== null) cancelFrame(frameRequest); + frameRequest = null; + return stats(); + } + + function resume() { + if (!paused || destroyed) return stats(); + paused = false; + nextFrameAt = readNow() + playback.frameMilliseconds; + schedule(); + return stats(); + } + + function seekFrame(nextFrameIndex) { + if (!Number.isSafeInteger(nextFrameIndex) || nextFrameIndex < 0 || nextFrameIndex >= playback.frameCount) { + throw new RangeError("Prepared Flocks frame is out of range"); + } + publishFrame(nextFrameIndex, true); + return stats(); + } + + async function seekStreamFrame(nextStreamFrameIndex) { + if (!paused) throw new Error("Prepared Flocks absolute seek requires paused playback"); + if (!Number.isSafeInteger(nextStreamFrameIndex) || nextStreamFrameIndex < 0 || + nextStreamFrameIndex >= catalog.streamFrameCount) { + throw new RangeError("Prepared Flocks stream frame is out of range"); + } + const nextBlockIndex = Math.floor(nextStreamFrameIndex / catalog.blockFrameCount); + const nextFrameIndex = nextStreamFrameIndex % catalog.blockFrameCount; + pending.clear(); + onBlockWindow([nextBlockIndex, ...lookaheadIndices(nextBlockIndex)]); + const nextBlock = nextBlockIndex === activeBlockIndex ? activeBlock : await loadBlock(nextBlockIndex); + validateBlock(nextBlock, catalog); + activeBlock = nextBlock; + activeBlockIndex = nextBlockIndex; + playback = nextBlock.playback; + for (let rootIndex = 0; rootIndex < sourceIndexByRoot.length; rootIndex += 1) { + sourceIndexByRoot[rootIndex] = rootIndex; + previousColors[rootIndex] = null; + } + debugAbsoluteSeekCount += 1; + publishFrame(nextFrameIndex, true); + queueLookahead(); + return stats(); + } + + async function stepFrame() { + if (!paused) throw new Error("Prepared Flocks debug step requires paused playback"); + if (frameIndex + 1 >= playback.frameCount) await activateNextBlock(); + else publishFrame(frameIndex + 1); + debugStepCount += 1; + return stats(); + } + + function resize(viewportHeight = globalThis.innerHeight) { + const perspective = resolveFlocksPerspective(viewportHeight, 50); + setPerspective(perspective); + return perspective; + } + + function stats() { + assertStableDom(); + return Object.freeze({ + paused, + frameIndex, + streamFrameIndex: activeBlock.startFrameIndex + frameIndex, + streamFrameCount: catalog.streamFrameCount, + activeBlockIndex, + applyCount, + shapeTransformWrites, + rootColorWrites, + colorPublicationIntervalFrames: CSSFLOCKS_COLOR_PUBLICATION_INTERVAL_FRAMES, + colorPublicationRateHz: 60 / CSSFLOCKS_COLOR_PUBLICATION_INTERVAL_FRAMES, + colorPublicationPhaseCount: CSSFLOCKS_COLOR_PUBLICATION_INTERVAL_FRAMES, + colorPublicationPolicy: "source-frame-plus-retained-root-index-round-robin", + schedulerFrameCallbackCount, + schedulerLateResetCount, + blockSwitchCount, + blockWaitCount, + terminalWrapCount, + terminalPermutationCompositionCount, + prefetchCount, + debugAbsoluteSeekCount, + debugStepCount, + pendingBlockIndices: Object.freeze(lookaheadIndices()), + pendingBlockReadyCount: lookaheadIndices() + .slice(0, catalog.runtimeMaterializedLookaheadBlockCount) + .filter((index) => pending.get(index)?.block).length, + runtimeGeometryConstructionCount: 0, + runtimeFrameMatrixFormattingCount: 0, + runtimeFrameColorFormattingCount: 0, + runtimeClassSwapCount: 0, + runtimeDomGrowth: false, + retainedModelWrapperCount: 0, + retainedDomStable: true, + }); + } + + return Object.freeze({ + pause, + resume, + seekFrame, + seekStreamFrame, + stepFrame, + resize, + stats, + destroy() { + destroyed = true; + paused = true; + if (frameRequest !== null) cancelFrame(frameRequest); + frameRequest = null; + }, + }); +} + +function validateBinding(shapeElements, catalog, initialBlock, initialLookaheadBlocks, loadBlock) { + if (!Array.isArray(shapeElements) || shapeElements.length !== catalog?.bugCount || + shapeElements.some((element) => !element?.style) || typeof loadBlock !== "function" || + !Number.isSafeInteger(catalog.runtimeLookaheadBlockCount) || catalog.runtimeLookaheadBlockCount < 1 || + catalog.runtimeLookaheadBlockCount > catalog.blockCount || + !Number.isSafeInteger(catalog.runtimeMaterializedLookaheadBlockCount) || + catalog.runtimeMaterializedLookaheadBlockCount < 1 || + catalog.runtimeMaterializedLookaheadBlockCount > catalog.runtimeLookaheadBlockCount || + !Number.isSafeInteger(catalog.startupMaterializedLookaheadBlockCount) || + catalog.startupMaterializedLookaheadBlockCount < 1 || + catalog.startupMaterializedLookaheadBlockCount > catalog.runtimeMaterializedLookaheadBlockCount || + !Array.isArray(initialLookaheadBlocks) || + initialLookaheadBlocks.length > catalog.startupMaterializedLookaheadBlockCount || + !Number.isSafeInteger(initialBlock?.index) || initialBlock.index < 0 || initialBlock.index >= catalog.blockCount) { + throw new Error("Prepared Flocks player binding drifted"); + } + validateBlock(initialBlock, catalog); + for (const block of initialLookaheadBlocks) validateBlock(block, catalog); +} + +function validateBlock(block, catalog) { + if (block?.schema !== "cssflocks-prepared-stream-block@1" || + !Number.isSafeInteger(block.index) || block.index < 0 || block.index >= catalog.blockCount || + block.startFrameIndex !== block.index * catalog.blockFrameCount || + block.playback?.bugCount !== catalog.bugCount || + block.playback.frameCount !== catalog.blockFrameCount || + block.playback.transforms?.length !== catalog.blockFrameCount * catalog.bugCount || + block.playback.colors?.length !== catalog.blockFrameCount * catalog.bugCount) { + throw new Error(`Prepared Flocks block ${block?.index ?? "unknown"} binding drifted`); + } +} diff --git a/src/adapters/flocks/src/cssflocks/preparedStream.mjs b/src/adapters/flocks/src/cssflocks/preparedStream.mjs new file mode 100644 index 0000000..45f921d --- /dev/null +++ b/src/adapters/flocks/src/cssflocks/preparedStream.mjs @@ -0,0 +1,484 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +import { + CSSFLOCKS_BLOCK_ENCODING, + CSSFLOCKS_PLAYBACK_SCHEMA, + decodeFlocksPreparedBlock, +} from "../shared/cssflocks/preparedBlockTransport.mjs"; +import { CSSFLOCKS_STARTUP_WINDOWS } from "../shared/cssflocks/startupWindows.mjs"; + +const RUNTIME_LOOKAHEAD_BLOCK_COUNT = 11; +const RUNTIME_MATERIALIZED_LOOKAHEAD_BLOCK_COUNT = 2; +const STARTUP_MATERIALIZED_LOOKAHEAD_BLOCK_COUNT = 2; + +export async function loadFlocksPreparedCatalog(descriptor) { + if (typeof descriptor?.catalogUrl !== "string" || + !/^[a-f0-9]{64}$/u.test(descriptor.catalogSha256 ?? "") || + !Number.isSafeInteger(descriptor.catalogBytes) || descriptor.catalogBytes < 1) { + throw new Error("Prepared Flocks catalog descriptor is invalid"); + } + const bytes = new Uint8Array(await fetchBytes(descriptor.catalogUrl, { cache: "no-store" })); + await verifyBytes(bytes, descriptor.catalogBytes, descriptor.catalogSha256, "catalog"); + const catalog = JSON.parse(new TextDecoder().decode(bytes)); + validateCatalog(catalog); + return Object.freeze(catalog); +} + +export function createFlocksPreparedBlockLoader(catalog) { + validateCatalog(catalog); + const worker = createWorkerMaterializer(catalog); + const decodedRecords = new Map(); + const blockRecords = new Map(); + let desiredBlockIndices = new Set(); + let fetchCount = 0; + let materializationCount = 0; + let workerMaterializationCount = 0; + let releaseCount = 0; + let verifiedBlockReleaseCount = 0; + let cumulativeEncodedBytes = 0; + let cumulativeDecodedBytes = 0; + let cumulativePreparedCssStringBytes = 0; + let residentDecodedBytes = 0; + let peakResidentDecodedBytes = 0; + let residentPreparedCssStringBytes = 0; + let peakResidentPreparedCssStringBytes = 0; + let abortedLoadCount = 0; + let staleResponseCount = 0; + let destroyed = false; + + function descriptorFor(index) { + if (destroyed) throw new Error("Prepared Flocks block loader is destroyed"); + const descriptor = catalog.entries[index]; + if (!descriptor || descriptor.index !== index) { + throw new RangeError(`Prepared Flocks block ${index} is missing`); + } + return descriptor; + } + + async function ensureDecoded(index) { + const descriptor = descriptorFor(index); + const existing = decodedRecords.get(index); + if (existing?.bytes) return existing; + if (existing?.promise) return existing.promise; + const promise = (async () => { + const encoded = new Uint8Array(await fetchBytes(descriptor.assetUrl, { cache: "force-cache" })); + await verifyBytes(encoded, descriptor.byteLength, descriptor.sha256, `block ${index}`); + cumulativeEncodedBytes += encoded.byteLength; + const bytes = await decompressGzip(encoded); + await verifyBytes(bytes, descriptor.decodedByteLength, descriptor.decodedSha256, `decoded block ${index}`); + const record = Object.freeze({ bytes, decodedByteLength: bytes.byteLength }); + decodedRecords.set(index, record); + fetchCount += 1; + cumulativeDecodedBytes += bytes.byteLength; + residentDecodedBytes += bytes.byteLength; + peakResidentDecodedBytes = Math.max(peakResidentDecodedBytes, residentDecodedBytes); + if (!desiredBlockIndices.has(index)) releaseDecoded(index, record); + return record; + })(); + decodedRecords.set(index, { promise }); + try { + return await promise; + } catch (error) { + if (decodedRecords.get(index)?.promise === promise) decodedRecords.delete(index); + throw error; + } + } + + async function load(index, { eager = false } = {}) { + const descriptor = descriptorFor(index); + const existing = blockRecords.get(index); + if (existing?.block) return existing.block; + if (existing?.promise) return existing.promise; + const record = { + controller: new AbortController(), + promise: null, + preparedCssStringByteLength: 0, + block: null, + }; + record.promise = (async () => { + const decodedRecord = await ensureDecoded(index); + assertCurrent(index, record); + const result = worker + ? await worker.materialize(decodedRecord.bytes, descriptor, { signal: record.controller.signal, eager }) + : Object.freeze({ block: decodeFlocksPreparedBlock(decodedRecord.bytes, descriptor, catalog) }); + assertCurrent(index, record); + const block = result.block; + validateBlock(block, descriptor, catalog); + materializationCount += 1; + if (worker) workerMaterializationCount += 1; + cumulativePreparedCssStringBytes += block.preparedCssStringByteLength; + record.preparedCssStringByteLength = block.preparedCssStringByteLength; + record.block = block; + residentPreparedCssStringBytes += block.preparedCssStringByteLength; + peakResidentPreparedCssStringBytes = Math.max( + peakResidentPreparedCssStringBytes, + residentPreparedCssStringBytes, + ); + if (!desiredBlockIndices.has(index)) releaseBlock(index, record); + return block; + })(); + blockRecords.set(index, record); + try { + return await record.promise; + } catch (error) { + if (blockRecords.get(index) === record) blockRecords.delete(index); + if (error?.name === "AbortError") abortedLoadCount += 1; + throw error; + } + } + + async function prime(indices) { + if (!Array.isArray(indices) || indices.some((index) => !Number.isSafeInteger(index))) { + throw new TypeError("Prepared Flocks verified block horizon is invalid"); + } + await Promise.all(indices.map((index) => ensureDecoded(index))); + } + + function prefetch(index) { + void ensureDecoded(index).catch(() => undefined); + } + + function retain(indices) { + desiredBlockIndices = new Set(indices); + for (const [index, record] of blockRecords) { + if (!desiredBlockIndices.has(index)) releaseBlock(index, record); + } + for (const [index, record] of decodedRecords) { + if (!desiredBlockIndices.has(index)) releaseDecoded(index, record); + } + } + + function releaseBlock(index, record) { + if (blockRecords.get(index) !== record) return; + blockRecords.delete(index); + record.controller.abort(); + if (record.block) { + residentPreparedCssStringBytes -= record.preparedCssStringByteLength; + releaseCount += 1; + } + } + + function releaseDecoded(index, record) { + if (!record?.bytes || decodedRecords.get(index) !== record) return; + decodedRecords.delete(index); + residentDecodedBytes -= record.decodedByteLength; + verifiedBlockReleaseCount += 1; + } + + return Object.freeze({ + load, + prime, + prefetch, + retain, + stats() { + return Object.freeze({ + fetchCount, + materializationCount, + workerMaterializationAvailable: worker !== null, + workerMaterializationCount, + releaseCount, + verifiedBlockReleaseCount, + cumulativeEncodedBytes, + cumulativeDecodedBytes, + cumulativePreparedCssStringBytes, + residentEncodedBytes: 0, + residentDecodedBytes, + peakResidentDecodedBytes, + residentPreparedCssStringBytes, + peakResidentPreparedCssStringBytes, + residentVerifiedBlockCount: [...decodedRecords.values()].filter((record) => record?.bytes).length, + residentBlockCount: [...blockRecords.values()].filter((record) => record?.block).length, + pendingBlockCount: [...blockRecords.values()].filter((record) => !record?.block).length, + abortedLoadCount, + staleResponseCount: staleResponseCount + (worker?.stats().staleResponseCount ?? 0), + workerMaterializationRequestCount: worker?.stats().requestCount ?? 0, + workerMaterializationCompletedBlockCount: worker?.stats().completedBlockCount ?? 0, + workerMaterializationMaximumBlockMilliseconds: worker?.stats().maximumBlockMilliseconds ?? 0, + workerMaterializationResponseChunkCount: worker?.stats().responseChunkCount ?? 0, + workerMaterializationResponseIdleSliceCount: worker?.stats().responseIdleSliceCount ?? 0, + workerMaterializationMaximumResponseChunkBytes: worker?.stats().maximumResponseChunkBytes ?? 0, + desiredBlockIndices: Object.freeze([...desiredBlockIndices].sort((left, right) => left - right)), + }); + }, + destroy() { + destroyed = true; + retain([]); + worker?.destroy(); + }, + }); + + function assertCurrent(index, record) { + if (destroyed || record.controller.signal.aborted || blockRecords.get(index) !== record) { + staleResponseCount += 1; + throw abortError(`Prepared Flocks block ${index} left the retained window`); + } + } +} + +function createWorkerMaterializer(catalog) { + if (typeof Worker !== "function") return null; + const worker = new Worker(new URL("./preparedBlockWorker.mjs", import.meta.url), { type: "module" }); + const responseChunkQueue = []; + const canceledRequestIds = new Set(); + const requestIdle = globalThis.requestIdleCallback?.bind(globalThis) ?? + ((callback) => setTimeout(() => callback({ didTimeout: true, timeRemaining: () => 0 }), 0)); + const cancelIdle = globalThis.cancelIdleCallback?.bind(globalThis) ?? clearTimeout; + let responseIdleRequest = null; + let requestId = 0; + let destroyed = false; + let staleResponseCount = 0; + let requestCount = 0; + let completedBlockCount = 0; + let maximumBlockMilliseconds = 0; + let responseChunkCount = 0; + let responseIdleSliceCount = 0; + let maximumResponseChunkBytes = 0; + const pending = new Map(); + let resolveInitialization; + let rejectInitialization; + const initialized = new Promise((resolve, reject) => { + resolveInitialization = resolve; + rejectInitialization = reject; + }); + + function rejectAll(error) { + rejectInitialization(error); + for (const record of pending.values()) { + record.cleanup?.(); + record.reject(error); + } + pending.clear(); + responseChunkQueue.length = 0; + if (responseIdleRequest !== null) cancelIdle(responseIdleRequest); + responseIdleRequest = null; + } + + function scheduleResponseChunk() { + if (responseIdleRequest !== null || responseChunkQueue.length === 0) return; + responseIdleRequest = requestIdle(processResponseChunk, { timeout: 500 }); + } + + function processResponseChunk() { + responseIdleRequest = null; + const queued = responseChunkQueue.shift(); + if (!queued) return; + const { data, record, response } = queued; + if (pending.get(data.requestId) !== record || record.response !== response) { + scheduleResponseChunk(); + return; + } + response.transforms.push(...data.transforms); + response.colors.push(...data.colors); + response.processedChunkCount += 1; + response.idleSliceCount += 1; + response.maximumChunkBytes = Math.max(response.maximumChunkBytes, data.chunkByteLength); + if (response.processedChunkCount === response.chunkCount) { + const expectedStateCount = response.block.playback.frameCount * response.block.playback.bugCount; + if (response.transforms.length !== expectedStateCount || response.colors.length !== expectedStateCount) { + rejectAll(new Error("Prepared Flocks worker response drifted")); + return; + } + const block = Object.freeze({ + ...response.block, + playback: Object.freeze({ + ...response.block.playback, + transforms: Object.freeze(response.transforms), + colors: Object.freeze(response.colors), + }), + }); + pending.delete(data.requestId); + record.cleanup?.(); + completedBlockCount += 1; + maximumBlockMilliseconds = Math.max(maximumBlockMilliseconds, response.durationMilliseconds); + responseChunkCount += response.chunkCount; + responseIdleSliceCount += response.idleSliceCount; + maximumResponseChunkBytes = Math.max(maximumResponseChunkBytes, response.maximumChunkBytes); + record.resolve(Object.freeze({ block })); + } + scheduleResponseChunk(); + } + + worker.addEventListener("message", ({ data }) => { + if (data?.type === "initialized") { + resolveInitialization(); + return; + } + const record = pending.get(data?.requestId); + if (!record) { + if (Number.isSafeInteger(data?.requestId) && canceledRequestIds.has(data.requestId)) { + if (data.type === "error" || + (data.type === "materialized-chunk" && data.chunkIndex + 1 === data.chunkCount)) { + canceledRequestIds.delete(data.requestId); + } + return; + } + if (Number.isSafeInteger(data?.requestId)) staleResponseCount += 1; + return; + } + if (data.type === "error") { + pending.delete(data.requestId); + record.cleanup?.(); + record.reject(new Error(data.stack || data.message)); + return; + } + if (data.type === "materialized-start") { + const expectedStateCount = data.block?.playback?.frameCount * data.block?.playback?.bugCount; + if (record.response !== null || !Number.isSafeInteger(data.chunkCount) || data.chunkCount < 1 || + !Number.isSafeInteger(expectedStateCount) || expectedStateCount < 1 || + !Number.isFinite(data.durationMilliseconds) || data.durationMilliseconds < 0) { + rejectAll(new Error("Prepared Flocks worker response drifted")); + return; + } + record.response = { + block: data.block, + chunkCount: data.chunkCount, + queuedChunkCount: 0, + processedChunkCount: 0, + durationMilliseconds: data.durationMilliseconds, + idleSliceCount: 0, + maximumChunkBytes: 0, + transforms: [], + colors: [], + }; + return; + } + if (data.type === "materialized-chunk") { + const response = record.response; + if (!response || !Number.isSafeInteger(data.chunkIndex) || data.chunkIndex < 0 || + data.chunkIndex !== response.queuedChunkCount || data.chunkCount !== response.chunkCount || + !Array.isArray(data.transforms) || data.transforms.length < 1 || data.transforms.length > 960 || + !Array.isArray(data.colors) || data.colors.length !== data.transforms.length || + !Number.isSafeInteger(data.chunkByteLength) || data.chunkByteLength < 1) { + rejectAll(new Error("Prepared Flocks worker response drifted")); + return; + } + response.queuedChunkCount += 1; + responseChunkQueue.push({ data, record, response }); + scheduleResponseChunk(); + } + }); + worker.addEventListener("error", (event) => { + rejectAll(new Error(event.message || "Prepared Flocks worker failed")); + }); + worker.postMessage({ type: "initialize", catalog }); + return Object.freeze({ + async materialize(bytes, descriptor, { signal, eager = false } = {}) { + if (destroyed) throw new Error("Prepared Flocks worker is destroyed"); + await initialized; + if (signal?.aborted) throw abortError(`Prepared Flocks block ${descriptor.index} materialization was canceled`); + const id = requestId++; + requestCount += 1; + return new Promise((resolve, reject) => { + const onAbort = () => { + const record = pending.get(id); + if (!record) return; + pending.delete(id); + canceledRequestIds.add(id); + worker.postMessage({ type: "cancel", requestId: id }); + record.cleanup(); + reject(abortError(`Prepared Flocks block ${descriptor.index} materialization was canceled`)); + }; + const cleanup = () => signal?.removeEventListener("abort", onAbort); + pending.set(id, { resolve, reject, cleanup, response: null }); + signal?.addEventListener("abort", onAbort, { once: true }); + const workerBytes = bytes.slice(); + worker.postMessage({ + type: "materialize", + requestId: id, + bytes: workerBytes, + descriptor, + eager, + }, [workerBytes.buffer]); + }); + }, + stats() { + return Object.freeze({ + staleResponseCount, + requestCount, + completedBlockCount, + maximumBlockMilliseconds, + responseChunkCount, + responseIdleSliceCount, + maximumResponseChunkBytes, + }); + }, + destroy() { + if (destroyed) return; + destroyed = true; + rejectAll(new Error("Prepared Flocks worker was destroyed")); + worker.terminate(); + }, + }); +} + +function validateCatalog(catalog) { + if (catalog?.schema !== "cssflocks-prepared-stream-catalog@1" || + catalog.playbackSchema !== CSSFLOCKS_PLAYBACK_SCHEMA || + catalog.sourceDefaultBugCount !== 1_004 || + catalog.leaderCount !== 4 || + catalog.bugCount !== catalog.leaderCount + catalog.followerCount || + catalog.leafCount !== catalog.bugCount * catalog.facesPerBug || + catalog.framesPerSecond !== 60 || + catalog.frameMilliseconds !== 1_000 / 60 || + catalog.blockCount !== catalog.entries?.length || + catalog.runtimeLookaheadBlockCount !== RUNTIME_LOOKAHEAD_BLOCK_COUNT || + catalog.runtimeMaterializedLookaheadBlockCount !== RUNTIME_MATERIALIZED_LOOKAHEAD_BLOCK_COUNT || + catalog.startupMaterializedLookaheadBlockCount !== STARTUP_MATERIALIZED_LOOKAHEAD_BLOCK_COUNT || + catalog.startupMaterializedLookaheadBlockCount > catalog.runtimeMaterializedLookaheadBlockCount || + catalog.runtimeMaterializedLookaheadBlockCount > catalog.runtimeLookaheadBlockCount || + catalog.streamFrameCount !== catalog.blockCount * catalog.blockFrameCount || + catalog.sourceFrameCount < 216 * catalog.framesPerSecond || + catalog.terminalSeam?.strategy !== "cubic-hermite-correspondence" || + catalog.terminalSeam?.sourceBehaviorDeviation !== true || + !isPermutation(catalog.terminalSeam?.correspondence, catalog.bugCount) || + !Array.isArray(catalog.startupWindows) || catalog.startupWindows.length !== CSSFLOCKS_STARTUP_WINDOWS.length || + catalog.startupWindows.some((window, index) => + window.id !== CSSFLOCKS_STARTUP_WINDOWS[index].id || window.blockIndex !== CSSFLOCKS_STARTUP_WINDOWS[index].blockIndex) || + catalog.entries.some((entry, index) => + entry.index !== index || entry.encoding !== CSSFLOCKS_BLOCK_ENCODING || + entry.startFrameIndex !== index * catalog.blockFrameCount || + entry.frameCount !== catalog.blockFrameCount)) { + throw new Error("Prepared Flocks catalog binding drifted"); + } +} + +function isPermutation(values, count) { + return Array.isArray(values) && values.length === count && + values.every((value) => Number.isSafeInteger(value) && value >= 0 && value < count) && + new Set(values).size === count; +} + +function validateBlock(block, descriptor, catalog) { + if (block?.schema !== "cssflocks-prepared-stream-block@1" || + block.index !== descriptor.index || block.startFrameIndex !== descriptor.startFrameIndex || + block.playback?.schema !== CSSFLOCKS_PLAYBACK_SCHEMA || + block.playback.modelId !== catalog.modelId || block.playback.bugCount !== catalog.bugCount || + block.playback.transforms?.length !== descriptor.frameCount * catalog.bugCount || + block.playback.colors?.length !== descriptor.frameCount * catalog.bugCount) { + throw new Error(`Prepared Flocks block ${descriptor.index} binding drifted`); + } +} + +async function fetchBytes(url, options) { + const response = await fetch(url, options); + if (!response.ok) throw new Error(`Prepared Flocks asset failed: ${response.status} ${url}`); + return response.arrayBuffer(); +} + +async function verifyBytes(bytes, expectedLength, expectedSha256, label) { + if (bytes.byteLength !== expectedLength) throw new Error(`Prepared Flocks ${label} byte length drifted`); + const digest = [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))] + .map((value) => value.toString(16).padStart(2, "0")).join(""); + if (digest !== expectedSha256) throw new Error(`Prepared Flocks ${label} SHA-256 drifted`); +} + +async function decompressGzip(bytes) { + if (typeof DecompressionStream !== "function") throw new Error("Prepared Flocks gzip decoding is unavailable"); + const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip")); + return new Uint8Array(await new Response(stream).arrayBuffer()); +} + +function abortError(message) { + const error = new Error(message); + error.name = "AbortError"; + return error; +} diff --git a/src/adapters/flocks/src/cssflocks/profileSelection.mjs b/src/adapters/flocks/src/cssflocks/profileSelection.mjs new file mode 100644 index 0000000..0bc5cb0 --- /dev/null +++ b/src/adapters/flocks/src/cssflocks/profileSelection.mjs @@ -0,0 +1,8 @@ +export function selectFlocksPreparedProfile({ + width, + coarsePointer = globalThis.matchMedia?.("(hover: none) and (pointer: coarse)")?.matches ?? false, +} = {}) { + const viewportWidth = Number(width ?? globalThis.innerWidth); + if (!Number.isFinite(viewportWidth) || viewportWidth <= 0) throw new RangeError("Flocks viewport width is invalid"); + return coarsePointer || viewportWidth <= 599 ? "mobile" : "desktop"; +} diff --git a/src/adapters/flocks/src/cssflocks/routeState.mjs b/src/adapters/flocks/src/cssflocks/routeState.mjs new file mode 100644 index 0000000..4bc5e8d --- /dev/null +++ b/src/adapters/flocks/src/cssflocks/routeState.mjs @@ -0,0 +1,17 @@ +export function resolveFlocksRoute({ search = globalThis.location?.search ?? "", manifest } = {}) { + const params = new URLSearchParams(search); + const keys = [...params.keys()]; + if (keys.some((key) => key !== "window") || params.getAll("window").length > 1) { + throw new RangeError("Flocks route accepts only one startup window selector"); + } + if (manifest?.status !== "ready" || typeof manifest.defaultScene !== "string" || + !manifest.scenes?.some((scene) => scene.id === manifest.defaultScene)) { + throw new Error("Flocks manifest default scene binding drifted"); + } + const startupWindowId = params.get("window"); + return Object.freeze({ + sceneId: manifest.defaultScene, + startupWindowId, + mode: startupWindowId === null ? "manifest-default" : "explicit-startup-window", + }); +} diff --git a/src/adapters/flocks/src/cssflocks/styles.css b/src/adapters/flocks/src/cssflocks/styles.css new file mode 100644 index 0000000..3351ec0 --- /dev/null +++ b/src/adapters/flocks/src/cssflocks/styles.css @@ -0,0 +1,225 @@ +:root { + color-scheme: dark; + background: #000; + color: #f0f0f0; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} + +* { box-sizing: border-box; } + +html, +body { + width: 100%; + height: 100%; + margin: 0; +} + +.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: #f0f0f0; + opacity: 0.72; + pointer-events: auto; + text-decoration: none; +} + +.site-wordmark-svg { + display: block; + width: 190px; + 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-actions { + display: flex; + align-items: center; + height: 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; + 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-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 { + position: relative; + overflow: hidden; + background: #000; +} + +body.loading::before, +body.priming::before { + content: ""; + position: fixed; + inset: 0; + z-index: 19; + background: #000; +} + +body.loading::after, +body.priming::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 / 72%); + border-radius: 50%; + animation: cssflocks-loading 0.8s linear infinite; +} + +@keyframes cssflocks-loading { + to { transform: rotate(1turn); } +} + +@media (prefers-reduced-motion: reduce) { + body.loading::after, + body.priming::after { animation: none; } +} + +body > .polycss-camera { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: hidden; + background: transparent; + perspective: var(--flocks-perspective); + perspective-origin: 50% 50%; +} + +body > .polycss-camera > .polycss-scene { + display: contents; + transform-style: preserve-3d; +} + +body > .polycss-camera > .polycss-scene > div { + position: absolute; + left: 50%; + top: 50%; + translate: 0 0 calc(var(--flocks-perspective) - 568px); + scale: 1 -1 1; + transform-style: preserve-3d; + transform-origin: 0 0 0; +} + +body > .polycss-camera > .polycss-scene { + transform: translateZ(var(--flocks-perspective)) + matrix3d(1, 0, 0, 0, 0, -1, 0, 0, 0, 0, 1, 0, 0, 0, -568, 1); +} + +body > .polycss-camera :is(u, b) { + position: absolute; + width: 32px; + height: 32px; + border-width: 0; + backface-visibility: hidden; + background-color: currentColor; + transform-origin: 0 0; +} + +body > .polycss-camera u { + border-top-left-radius: 50% 100%; + border-top-right-radius: 50% 100%; + corner-top-left-shape: bevel; + corner-top-right-shape: bevel; +} + +body > .polycss-camera .polycss-scene > div > :nth-child(1) { background-color: color-mix(in srgb, currentColor 78%, black); } +body > .polycss-camera .polycss-scene > div > :nth-child(2) { background-color: color-mix(in srgb, currentColor 90%, black); } +body > .polycss-camera .polycss-scene > div > :nth-child(3) { background-color: currentColor; } +body > .polycss-camera .polycss-scene > div > :nth-child(4) { background-color: color-mix(in srgb, currentColor 82%, black); } +body > .polycss-camera .polycss-scene > div > :nth-child(5) { background-color: color-mix(in srgb, currentColor 96%, black); } +body > .polycss-camera .polycss-scene > div > :nth-child(6) { background-color: color-mix(in srgb, currentColor 86%, black); } + +body.error::after { + content: "Flocks failed to load."; + position: fixed; + inset: 0; + z-index: 30; + display: grid; + place-items: center; + color: #ffb5a7; + font-size: 14px; +} + +@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/flocks/src/cssflocks/types.mjs b/src/adapters/flocks/src/cssflocks/types.mjs new file mode 100644 index 0000000..3a30052 --- /dev/null +++ b/src/adapters/flocks/src/cssflocks/types.mjs @@ -0,0 +1,3 @@ +export const CSSFLOCKS_DEBUG_API_NAME = "__cssFlocksDebug"; +export const CSSFLOCKS_MANIFEST_SCHEMA = "cssflocks-manifest@1"; +export const CSSFLOCKS_PREPARED_SCHEMA = "cssflocks-prepared-scene@1"; diff --git a/src/adapters/flocks/src/main.mjs b/src/adapters/flocks/src/main.mjs new file mode 100644 index 0000000..f42442e --- /dev/null +++ b/src/adapters/flocks/src/main.mjs @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +import "./cssflocks/styles.css"; +import { mountFlocksClient } from "./cssflocks/client.mjs"; + +mountFlocksClient(document.body).catch(() => {}); diff --git a/src/adapters/flocks/src/prepare/cssflocks/dataSource.mjs b/src/adapters/flocks/src/prepare/cssflocks/dataSource.mjs new file mode 100644 index 0000000..1e423eb --- /dev/null +++ b/src/adapters/flocks/src/prepare/cssflocks/dataSource.mjs @@ -0,0 +1,24 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { CSSFLOCKS_ADAPTER_ROOT, CSSFLOCKS_REPOSITORY_ROOT } from "./paths.mjs"; +import { CSSFLOCKS_SOURCE } from "./sourceModel.mjs"; + +export async function verifyFlocksSourceIdentity() { + const lock = JSON.parse(await readFile(join(CSSFLOCKS_ADAPTER_ROOT, "notes/references/source-lock.json"), "utf8")); + for (const field of ["repository", "revision", "path", "sha256", "license"]) { + if (lock[field] !== CSSFLOCKS_SOURCE[field]) throw new Error(`Flocks source lock ${field} drifted`); + } + const ignoredSource = join(CSSFLOCKS_REPOSITORY_ROOT, ".local/reallyslickscreensavers", lock.path); + try { + const bytes = await readFile(ignoredSource); + if (sha256(bytes) !== lock.sha256) throw new Error("Ignored Flocks source bytes do not match the pinned SHA-256"); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + return Object.freeze(lock); +} + +function sha256(bytes) { + return createHash("sha256").update(bytes).digest("hex"); +} diff --git a/src/adapters/flocks/src/prepare/cssflocks/formatAdapters.mjs b/src/adapters/flocks/src/prepare/cssflocks/formatAdapters.mjs new file mode 100644 index 0000000..94f867d --- /dev/null +++ b/src/adapters/flocks/src/prepare/cssflocks/formatAdapters.mjs @@ -0,0 +1,7 @@ +export const CSSFLOCKS_SOURCE_FORMAT_ADAPTERS = Object.freeze([ + Object.freeze({ + id: "really-slick-flocks-cpp", + path: "src/flocks/flocks.cpp", + role: "behavior-topology-defaults-and-provenance", + }), +]); diff --git a/src/adapters/flocks/src/prepare/cssflocks/modelBuilder.mjs b/src/adapters/flocks/src/prepare/cssflocks/modelBuilder.mjs new file mode 100644 index 0000000..d35219c --- /dev/null +++ b/src/adapters/flocks/src/prepare/cssflocks/modelBuilder.mjs @@ -0,0 +1,193 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +import { + SOLID_TRIANGLE_CANONICAL_SIZE, + computeSolidTrianglePlanFromCssPoints, +} from "@layoutit/polycss"; +import { CSSFLOCKS_SOURCE } from "./sourceModel.mjs"; + +const BUG_RADIUS = CSSFLOCKS_SOURCE.size * 0.5; +const EQUATOR = Object.freeze(Array.from({ length: CSSFLOCKS_SOURCE.complexity + 2 }, (_, index) => { + const angle = Math.PI / 2 + index * Math.PI * 2 / (CSSFLOCKS_SOURCE.complexity + 2); + return Object.freeze([Math.cos(angle) * BUG_RADIUS, Math.sin(angle) * BUG_RADIUS, 0]); +})); + +export const CSSFLOCKS_BUG_VERTICES = Object.freeze([ + Object.freeze([0, 0, BUG_RADIUS]), + ...EQUATOR, + Object.freeze([0, 0, -BUG_RADIUS]), +]); + +export const CSSFLOCKS_FACE_INDICES = Object.freeze([ + Object.freeze([0, 1, 2]), + Object.freeze([0, 2, 3]), + Object.freeze([0, 3, 1]), + Object.freeze([4, 2, 1]), + Object.freeze([4, 3, 2]), + Object.freeze([4, 1, 3]), +]); + +export const CSSFLOCKS_FACE_LIGHT_FACTORS = Object.freeze([0.78, 0.9, 1, 0.82, 0.96, 0.86]); + +const FACE_PLANS = Object.freeze(CSSFLOCKS_FACE_INDICES.map((localIndices, faceIndex) => + trianglePlan(localIndices.map((index) => CSSFLOCKS_BUG_VERTICES[index]), `bug-face-${faceIndex}`))); + +export const CSSFLOCKS_FACE_TILE_VERTEX_ORDERS = Object.freeze( + FACE_PLANS.map(({ tileVertexOrder }) => tileVertexOrder), +); + +export const CSSFLOCKS_FACE_MATRICES = Object.freeze( + FACE_PLANS.map(({ matrix }) => matrix), +); + +export function buildFlocksPreparedModel({ source, modelId = source?.profile?.modelId } = {}) { + if (source?.schema !== "cssflocks-source-sequence@1" || !source.profile || + typeof modelId !== "string" || modelId.length === 0 || source.frames.length < 1 || + source.frames[0].bugs.length !== source.profile.bugCount) { + throw new TypeError("A product-selected Flocks source sequence is required"); + } + const vertices = []; + const normals = []; + const polygons = []; + const shapes = []; + const leaves = []; + const initial = source.frames[0]; + for (let bugIndex = 0; bugIndex < source.profile.bugCount; bugIndex += 1) { + const vertexOffset = vertices.length; + const normalOffset = normals.length; + vertices.push(...CSSFLOCKS_BUG_VERTICES.map((vertex) => Object.freeze([...vertex]))); + normals.push(...CSSFLOCKS_BUG_VERTICES.map((vertex) => Object.freeze(normalized(vertex)))); + const shapeId = bugId(bugIndex); + shapes.push(Object.freeze({ id: shapeId, matrix: initial.bugs[bugIndex].matrix })); + for (let faceIndex = 0; faceIndex < CSSFLOCKS_FACE_INDICES.length; faceIndex += 1) { + const polygonId = `${shapeId}-face-${faceIndex}`; + const localIndices = CSSFLOCKS_FACE_INDICES[faceIndex]; + polygons.push(Object.freeze({ + id: polygonId, + vertexIndices: Object.freeze(localIndices.map((index) => vertexOffset + index)), + normalIndices: Object.freeze(localIndices.map((index) => normalOffset + index)), + })); + leaves.push(Object.freeze({ + id: `leaf-${polygonId}`, + polygonId, + shapeId, + materialId: "bug-current-color", + strategy: "solid-triangle", + width: SOLID_TRIANGLE_CANONICAL_SIZE, + height: SOLID_TRIANGLE_CANONICAL_SIZE, + matrix: FACE_PLANS[faceIndex].matrix, + atlas: null, + fallback: null, + })); + } + } + const model = deepFreeze({ + schema: "polycss-morph.model@1", + identity: Object.freeze({ id: modelId, name: `Flocks ${source.profile.id}`, revision: "0.1.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: 64 * 1024 * 1024, + }), + topology: Object.freeze({ + vertices: Object.freeze(vertices), + normals: Object.freeze(normals), + polygons: Object.freeze(polygons), + }), + materials: Object.freeze([Object.freeze({ + id: "bug-current-color", + color: Object.freeze([1, 1, 1, 1]), + })]), + render: Object.freeze({ + modelMatrix: source.modelMatrix, + shapes: Object.freeze(shapes), + leaves: Object.freeze(leaves), + }), + deformation: Object.freeze({ kind: "none" }), + controls: Object.freeze([]), + springs: Object.freeze([]), + animations: Object.freeze([]), + playback: null, + provenance: Object.freeze({ + generator: "cssflocks-preparer", + generatorVersion: "0.1.0", + sources: Object.freeze([Object.freeze({ + id: "really-slick-flocks", + kind: "open-data", + uri: `${CSSFLOCKS_SOURCE.repository}/blob/${CSSFLOCKS_SOURCE.revision}/${CSSFLOCKS_SOURCE.path}`, + sha256: CSSFLOCKS_SOURCE.sha256, + license: CSSFLOCKS_SOURCE.license, + })]), + }), + }); + return deepFreeze({ + model, + metrics: Object.freeze({ + sourceDefaultBugCount: source.bank.bugCount, + retainedBugRootCount: shapes.length, + retainedPolygonLeafCount: leaves.length, + polygonsPerBug: CSSFLOCKS_FACE_INDICES.length, + preparedVertexCount: vertices.length, + preparedPolygonCount: polygons.length, + sourceTriangleCount: shapes.length * CSSFLOCKS_FACE_INDICES.length, + atlasCount: 0, + unresolvedTextureCount: 0, + runtimeGeometryConstructionCount: 0, + runtimeAtlasRasterizationCount: 0, + runtimeDomGrowth: false, + flatLightingFactors: CSSFLOCKS_FACE_LIGHT_FACTORS, + }), + }); +} + +function trianglePlan(vertices, id) { + const plan = computeSolidTrianglePlanFromCssPoints( + { vertices: vertices.map((vertex) => [...vertex]) }, + 0, + { seamBleed: 0 }, + { includeColor: false, matrixDecimals: 10, primitive: "border" }, + ...vertices.flat(), + ); + const match = /^matrix3d\(([^)]+)\)$/u.exec(plan?.transformText ?? ""); + const values = match?.[1]?.split(",").map(Number); + if (!values || values.length !== 16 || values.some((value) => !Number.isFinite(value))) { + throw new Error(`Flocks triangle is not renderable: ${id}`); + } + const { a, b, c } = plan.basis ?? {}; + if (![a, b, c].every((value) => Number.isSafeInteger(value) && value >= 0 && value < 3) || + new Set([a, b, c]).size !== 3) { + throw new Error(`Flocks triangle basis is invalid: ${id}`); + } + return Object.freeze({ + matrix: Object.freeze(values.map(rounded10)), + tileVertexOrder: Object.freeze([c, a, b]), + }); +} + +function normalized(vector) { + const length = Math.hypot(...vector); + if (length < 1e-12) throw new Error("Flocks bug normal is degenerate"); + return vector.map((value) => rounded10(value / length)); +} + +function bugId(index) { + return `bug-${String(index).padStart(4, "0")}`; +} + +function rounded10(value) { + const result = Number(value.toFixed(10)); + return Object.is(result, -0) ? 0 : result; +} + +function deepFreeze(value) { + if (value && typeof value === "object" && !Object.isFrozen(value)) { + Object.freeze(value); + for (const child of Object.values(value)) deepFreeze(child); + } + return value; +} diff --git a/src/adapters/flocks/src/prepare/cssflocks/paths.mjs b/src/adapters/flocks/src/prepare/cssflocks/paths.mjs new file mode 100644 index 0000000..5ad80eb --- /dev/null +++ b/src/adapters/flocks/src/prepare/cssflocks/paths.mjs @@ -0,0 +1,13 @@ +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const CSSFLOCKS_ADAPTER_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); +export const CSSFLOCKS_REPOSITORY_ROOT = resolve(CSSFLOCKS_ADAPTER_ROOT, "..", "..", ".."); + +export function resolveFlocksGeneratedPublicRoot(env = process.env) { + return resolve(env.CSSFLOCKS_GENERATED_PUBLIC_DIR ?? join(CSSFLOCKS_REPOSITORY_ROOT, "build/generated/public")); +} + +export function resolveFlocksOutputRoot(env = process.env) { + return join(resolveFlocksGeneratedPublicRoot(env), "cssflocks"); +} diff --git a/src/adapters/flocks/src/prepare/cssflocks/prepare.mjs b/src/adapters/flocks/src/prepare/cssflocks/prepare.mjs new file mode 100644 index 0000000..7e7a9f6 --- /dev/null +++ b/src/adapters/flocks/src/prepare/cssflocks/prepare.mjs @@ -0,0 +1,323 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +import { createHash } from "node:crypto"; +import { mkdir, rename, rm, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { gzipSync } from "node:zlib"; +import { buildPolyMorphCatalog, buildPolyMorphPackage } from "@layoutit/polycss-morph/prepare"; +import { verifyFlocksSourceIdentity } from "./dataSource.mjs"; +import { CSSFLOCKS_FACE_INDICES, buildFlocksPreparedModel } from "./modelBuilder.mjs"; +import { resolveFlocksGeneratedPublicRoot, resolveFlocksOutputRoot } from "./paths.mjs"; +import { CSSFLOCKS_PROVENANCE } from "./provenance.mjs"; +import { CSSFLOCKS_SLICE_PLAN } from "./slicePlan.mjs"; +import { + CSSFLOCKS_PRODUCT_PROFILES, + CSSFLOCKS_PREPARED_CADENCE, + CSSFLOCKS_PREPARED_LOADER, + CSSFLOCKS_SOURCE, + CSSFLOCKS_SOURCE_BANK, + buildFlocksSourceBlocks, + selectFlocksProductPrefix, +} from "./sourceModel.mjs"; +import { + buildFlocksTerminalBridge, + buildFlocksTerminalCorrespondence, +} from "./terminalSeam.mjs"; +import { + CSSFLOCKS_BLOCK_ENCODING, + CSSFLOCKS_PLAYBACK_SCHEMA, + encodeFlocksPreparedBlock, +} from "../../shared/cssflocks/preparedBlockTransport.mjs"; +import { writeFlocksJson } from "./writeManifest.mjs"; +import { CSSFLOCKS_STARTUP_WINDOWS } from "../../shared/cssflocks/startupWindows.mjs"; + +export async function prepareFlocks({ env = process.env } = {}) { + const sourceLock = await verifyFlocksSourceIdentity(); + const generatedPublicRoot = resolveFlocksGeneratedPublicRoot(env); + const outputRoot = resolveFlocksOutputRoot(env); + const stagingRoot = join(generatedPublicRoot, `.cssflocks-${process.pid}`); + await rm(stagingRoot, { recursive: true, force: true }); + await mkdir(stagingRoot, { recursive: true }); + try { + const states = new Map(Object.values(CSSFLOCKS_PRODUCT_PROFILES).map((profile) => [profile.id, { + profile, + model: null, + entries: [], + encodedBytes: 0, + decodedBytes: 0, + initialFrame: null, + terminalFrame: null, + terminalSeam: null, + }])); + const writePreparedBlock = async ({ state, frames, index, startFrameIndex, continuityKind, sourceContinuousFromPrevious }) => { + const decoded = Buffer.from(encodeFlocksPreparedBlock({ + frames, + bugCount: state.profile.bugCount, + framesPerSecond: CSSFLOCKS_SOURCE_BANK.framesPerSecond, + })); + const encoded = gzipSync(decoded, { level: 9 }); + const encodedSha256 = sha256(encoded); + const decodedSha256 = sha256(decoded); + const assetUrl = `/cssflocks/${state.profile.id}/blocks/block-${String(index).padStart(3, "0")}-${encodedSha256}.bin`; + await writeBytes(join(stagingRoot, assetUrl.replace(/^\/cssflocks\//u, "")), encoded); + state.entries.push(Object.freeze({ + index, + startFrameIndex, + frameCount: frames.length, + sourceContinuousFromPrevious, + continuityKind, + assetUrl, + encoding: CSSFLOCKS_BLOCK_ENCODING, + byteLength: encoded.byteLength, + sha256: encodedSha256, + decodedByteLength: decoded.byteLength, + decodedSha256, + })); + state.encodedBytes += encoded.byteLength; + state.decodedBytes += decoded.byteLength; + }; + for (const sourceBlock of buildFlocksSourceBlocks({ bank: CSSFLOCKS_SOURCE_BANK })) { + for (const state of states.values()) { + const selected = selectFlocksProductPrefix(sourceBlock, state.profile); + if (state.model === null) state.model = buildFlocksPreparedModel({ source: selected }); + state.initialFrame ??= selected.frames[0]; + state.terminalFrame = selected.frames.at(-1); + await writePreparedBlock({ + state, + frames: selected.frames, + startFrameIndex: sourceBlock.bank.startFrameIndex, + index: sourceBlock.bank.blockIndex, + continuityKind: "exact-source-adjacent", + sourceContinuousFromPrevious: sourceBlock.bank.blockIndex > 0, + }); + } + } + const sourceBlockCount = CSSFLOCKS_SOURCE_BANK.frameCount / CSSFLOCKS_SOURCE_BANK.blockFrameCount; + const bridgeFrameCount = CSSFLOCKS_PREPARED_CADENCE.terminalBridgeSeconds * CSSFLOCKS_SOURCE_BANK.framesPerSecond; + for (const state of states.values()) { + const viewport = state.profile.id === "desktop" ? [1280, 800] : [390, 844]; + const correspondence = buildFlocksTerminalCorrespondence( + state.terminalFrame.bugs, + state.initialFrame.bugs, + viewport, + state.profile.leaderCount, + ); + const bridge = buildFlocksTerminalBridge({ + finalFrame: state.terminalFrame, + initialFrame: state.initialFrame, + correspondence: correspondence.permutation, + frameCount: bridgeFrameCount, + framesPerSecond: CSSFLOCKS_SOURCE_BANK.framesPerSecond, + }); + state.terminalSeam = Object.freeze({ + strategy: bridge.strategy, + sourceBehaviorDeviation: bridge.sourceBehaviorDeviation, + sourceFrameCount: CSSFLOCKS_SOURCE_BANK.frameCount, + bridgeFrameCount, + bridgeDurationMilliseconds: bridge.durationMilliseconds, + interpolationDurationMilliseconds: bridge.interpolationDurationMilliseconds, + correspondence: bridge.correspondence, + correspondenceMetrics: correspondence.metrics, + naturalWindowSearch: Object.freeze({ + horizonSeconds: 3_600, + candidatePairCount: 77_602, + qualifiedNaturalSeamFound: false, + }), + }); + for (let bridgeBlockIndex = 0; bridgeBlockIndex < bridgeFrameCount / CSSFLOCKS_SOURCE_BANK.blockFrameCount; bridgeBlockIndex += 1) { + const index = sourceBlockCount + bridgeBlockIndex; + const startFrameIndex = CSSFLOCKS_SOURCE_BANK.frameCount + bridgeBlockIndex * CSSFLOCKS_SOURCE_BANK.blockFrameCount; + const frameStart = bridgeBlockIndex * CSSFLOCKS_SOURCE_BANK.blockFrameCount; + await writePreparedBlock({ + state, + frames: bridge.frames.slice(frameStart, frameStart + CSSFLOCKS_SOURCE_BANK.blockFrameCount), + index, + startFrameIndex, + continuityKind: "prepare-only-hermite-terminal-bridge", + sourceContinuousFromPrevious: false, + }); + } + } + + const profileMetadata = {}; + const builtModels = []; + for (const state of states.values()) { + if (state.model === null) throw new Error(`Flocks ${state.profile.id} produced no prepared model`); + const catalog = Object.freeze({ + schema: "cssflocks-prepared-stream-catalog@1", + streamId: state.profile.id, + modelId: state.profile.modelId, + sourceDefaultBugCount: CSSFLOCKS_SOURCE_BANK.bugCount, + sourceDefaultLeaderCount: CSSFLOCKS_SOURCE_BANK.leaderCount, + sourceDefaultFollowerCount: CSSFLOCKS_SOURCE_BANK.followerCount, + productSelection: "source-ordered-prefix-after-full-source-simulation", + bugCount: state.profile.bugCount, + leaderCount: state.profile.leaderCount, + followerCount: state.profile.followerCount, + facesPerBug: CSSFLOCKS_FACE_INDICES.length, + leafCount: state.profile.bugCount * CSSFLOCKS_FACE_INDICES.length, + playbackSchema: CSSFLOCKS_PLAYBACK_SCHEMA, + seed: CSSFLOCKS_SOURCE_BANK.seed, + framesPerSecond: CSSFLOCKS_SOURCE_BANK.framesPerSecond, + frameMilliseconds: CSSFLOCKS_SOURCE_BANK.frameMilliseconds, + warmupFrames: CSSFLOCKS_SOURCE_BANK.warmupFrames, + sourceFrameCount: CSSFLOCKS_SOURCE_BANK.frameCount, + sourceDurationMilliseconds: CSSFLOCKS_SOURCE_BANK.frameCount / CSSFLOCKS_SOURCE_BANK.framesPerSecond * 1_000, + streamFrameCount: CSSFLOCKS_SOURCE_BANK.frameCount + bridgeFrameCount, + streamDurationMilliseconds: (CSSFLOCKS_SOURCE_BANK.frameCount + bridgeFrameCount) / CSSFLOCKS_SOURCE_BANK.framesPerSecond * 1_000, + blockCount: state.entries.length, + blockFrameCount: CSSFLOCKS_SOURCE_BANK.blockFrameCount, + runtimeLookaheadBlockCount: CSSFLOCKS_PREPARED_LOADER.runtimeLookaheadBlockCount, + runtimeMaterializedLookaheadBlockCount: + CSSFLOCKS_PREPARED_LOADER.runtimeMaterializedLookaheadBlockCount, + startupMaterializedLookaheadBlockCount: + CSSFLOCKS_PREPARED_LOADER.startupMaterializedLookaheadBlockCount, + loop: true, + terminalSeam: state.terminalSeam, + startupWindows: CSSFLOCKS_STARTUP_WINDOWS, + entries: Object.freeze(state.entries), + }); + const catalogBytes = Buffer.from(`${JSON.stringify(catalog)}\n`); + const catalogPath = join(stagingRoot, state.profile.id, "catalog.json"); + await writeBytes(catalogPath, catalogBytes); + const built = await buildPolyMorphPackage(state.model.model); + const packageRoot = join(stagingRoot, "model", state.profile.modelId); + for (const [path, bytes] of built.files) await writeBytes(join(packageRoot, path), bytes); + await writeBytes(join(packageRoot, "manifest.json"), built.manifestBytes); + builtModels.push({ state, built }); + profileMetadata[state.profile.id] = Object.freeze({ + id: state.profile.id, + model: Object.freeze({ + id: state.profile.modelId, + manifestSha256: built.manifestSha256, + ...state.model.metrics, + }), + playback: Object.freeze({ + catalogUrl: `/cssflocks/${state.profile.id}/catalog.json`, + catalogSha256: sha256(catalogBytes), + catalogBytes: catalogBytes.byteLength, + blockCount: state.entries.length, + preparedFrameCount: CSSFLOCKS_SOURCE_BANK.frameCount + bridgeFrameCount, + sourceFrameCount: CSSFLOCKS_SOURCE_BANK.frameCount, + terminalBridgeFrameCount: bridgeFrameCount, + preparedBlockEncodedBytes: state.encodedBytes, + preparedBlockDecodedBytes: state.decodedBytes, + transportEncoding: CSSFLOCKS_BLOCK_ENCODING, + }), + presentation: Object.freeze({ + sourceDefaultBugCount: CSSFLOCKS_SOURCE_BANK.bugCount, + productBugCount: state.profile.bugCount, + productLeaderCount: state.profile.leaderCount, + productFollowerCount: state.profile.followerCount, + fieldOfViewDegrees: CSSFLOCKS_SOURCE.fieldOfViewDegrees, + startupWarmupMilliseconds: CSSFLOCKS_SOURCE_BANK.warmupFrames / CSSFLOCKS_SOURCE_BANK.framesPerSecond * 1_000, + lighting: "fixed-flat-face-factors-currentColor", + }), + }); + } + const modelCatalog = await buildPolyMorphCatalog("flocks", builtModels.map(({ state, built }) => ({ + manifest: built.manifest, + manifestPath: `${state.profile.modelId}/manifest.json`, + manifestSha256: built.manifestSha256, + }))); + await writeBytes(join(stagingRoot, "model/catalog.json"), modelCatalog.bytes); + + const scene = Object.freeze({ + schema: "cssflocks-scene@1", + id: CSSFLOCKS_SLICE_PLAN.id, + label: "Flocks", + source: sourceLock, + camera: Object.freeze({ fieldOfViewDegrees: CSSFLOCKS_SOURCE.fieldOfViewDegrees, referenceAspectRatio: CSSFLOCKS_SOURCE_BANK.aspectRatio }), + profiles: profileMetadata, + metrics: Object.freeze({ + sourceTriangleCount: CSSFLOCKS_PRODUCT_PROFILES.desktop.bugCount * CSSFLOCKS_FACE_INDICES.length, + preparedPolygonCount: CSSFLOCKS_PRODUCT_PROFILES.desktop.bugCount * CSSFLOCKS_FACE_INDICES.length, + renderLeafCount: CSSFLOCKS_PRODUCT_PROFILES.desktop.bugCount * CSSFLOCKS_FACE_INDICES.length, + atlasCount: 0, + unresolvedTextureCount: 0, + mergeCandidateCount: 0, + mergeOutputCount: 0, + }), + warnings: Object.freeze([ + "Native/source GL lighting and accepted fixed flat lighting differ; RGB comparison is diagnostic while prepared state, projection, and continuity gates are strict.", + "The final eight-second cubic-Hermite terminal bridge is a documented prepare-only source-behavior deviation.", + ]), + }); + await writeFlocksJson(join(stagingRoot, "scenes/flocks-default.json"), scene); + const prepared = Object.freeze({ + schema: "cssflocks-prepared-scene@1", + status: "ready", + source: sourceLock, + renderer: Object.freeze({ + package: "@layoutit/polycss-morph", + profile: "static-prepared", + representation: "retained-six-solid-triangle-bug-roots", + runtimeGeometryConstruction: false, + runtimeAtlasRasterization: false, + runtimeDomGrowth: false, + runtimePreparedStateMaterialization: true, + runtimeFrameMatrixFormatting: false, + runtimeFrameColorFormatting: false, + }), + defaultScene: CSSFLOCKS_SLICE_PLAN.id, + sceneUrl: "/cssflocks/scenes/flocks-default.json", + profileSelection: Object.freeze({ + startupOnly: true, + desktopProfileId: "desktop", + mobileProfileId: "mobile", + mobileCapabilityQuery: "(hover: none) and (pointer: coarse)", + mobileMaximumViewportWidth: 599, + }), + profiles: Object.freeze(profileMetadata), + oracle: CSSFLOCKS_PROVENANCE, + }); + await writeFlocksJson(join(stagingRoot, "prepared.json"), prepared); + await writeFlocksJson(join(stagingRoot, "manifest.json"), { + schema: "cssflocks-manifest@1", + status: "ready", + title: "Flocks", + source: sourceLock, + scaffoldMode: CSSFLOCKS_SLICE_PLAN.scaffoldMode, + artifactMode: CSSFLOCKS_SLICE_PLAN.artifactMode, + generatedAssetRoot: "/cssflocks/", + defaultScene: CSSFLOCKS_SLICE_PLAN.id, + scenes: [{ id: CSSFLOCKS_SLICE_PLAN.id, url: "/cssflocks/scenes/flocks-default.json" }], + modelCount: 2, + sourceTriangleCount: scene.metrics.sourceTriangleCount, + preparedPolygonCount: scene.metrics.preparedPolygonCount, + renderLeafCount: scene.metrics.renderLeafCount, + atlasCount: 0, + unresolvedTextureCount: 0, + mergeCandidateCount: 0, + mergeOutputCount: 0, + debugApi: "window.__cssFlocksDebug", + warnings: scene.warnings, + }); + + await rm(outputRoot, { recursive: true, force: true }); + await mkdir(dirname(outputRoot), { recursive: true }); + await rename(stagingRoot, outputRoot); + return Object.freeze({ + outputRoot, + sourceDefaultBugCount: CSSFLOCKS_SOURCE_BANK.bugCount, + profiles: Object.freeze(Object.values(profileMetadata).map((profile) => Object.freeze({ + id: profile.id, + productBugCount: profile.presentation.productBugCount, + retainedPolygonLeafCount: profile.model.retainedPolygonLeafCount, + preparedBlockEncodedBytes: profile.playback.preparedBlockEncodedBytes, + preparedBlockDecodedBytes: profile.playback.preparedBlockDecodedBytes, + }))), + }); + } catch (error) { + await rm(stagingRoot, { recursive: true, force: true }); + throw error; + } +} + +async function writeBytes(path, bytes) { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, bytes); +} + +function sha256(bytes) { + return createHash("sha256").update(bytes).digest("hex"); +} diff --git a/src/adapters/flocks/src/prepare/cssflocks/provenance.mjs b/src/adapters/flocks/src/prepare/cssflocks/provenance.mjs new file mode 100644 index 0000000..6b5fcc4 --- /dev/null +++ b/src/adapters/flocks/src/prepare/cssflocks/provenance.mjs @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +import { CSSFLOCKS_SOURCE } from "./sourceModel.mjs"; + +export const CSSFLOCKS_PROVENANCE = Object.freeze({ + source: CSSFLOCKS_SOURCE, + sourceStateOracle: "compiled-pinned-source-state-and-gl-transform-oracle", + visualOracle: "compiled-pinned-source-glu-frame-sequence-rgb-diagnostic", + browserComparisonAuthority: "strict-prepared-state-projection-and-continuity-with-diagnostic-rgb", + productPrefix: "leaders-plus-source-ordered-follower-prefix-after-full-source-simulation", +}); diff --git a/src/adapters/flocks/src/prepare/cssflocks/sceneBuilder.mjs b/src/adapters/flocks/src/prepare/cssflocks/sceneBuilder.mjs new file mode 100644 index 0000000..5dde9ae --- /dev/null +++ b/src/adapters/flocks/src/prepare/cssflocks/sceneBuilder.mjs @@ -0,0 +1 @@ +export { buildFlocksPreparedModel } from "./modelBuilder.mjs"; diff --git a/src/adapters/flocks/src/prepare/cssflocks/slicePlan.mjs b/src/adapters/flocks/src/prepare/cssflocks/slicePlan.mjs new file mode 100644 index 0000000..d3b9f80 --- /dev/null +++ b/src/adapters/flocks/src/prepare/cssflocks/slicePlan.mjs @@ -0,0 +1,14 @@ +import { CSSFLOCKS_PRODUCT_PROFILES, CSSFLOCKS_SOURCE_BANK } from "./sourceModel.mjs"; + +export const CSSFLOCKS_SLICE_PLAN = Object.freeze({ + id: "flocks-default", + scaffoldMode: "particle-systems", + artifactMode: "prepared-polycss-model-with-blocked-source-state", + sourceDefaultBugCount: CSSFLOCKS_SOURCE_BANK.bugCount, + profiles: CSSFLOCKS_PRODUCT_PROFILES, + defaultProfileId: "desktop", + mobileProfileId: "mobile", + geometry: "six-solid-triangle-glu-sphere-topology", + lighting: "fixed-flat-face-factors-currentColor", + sourceModes: Object.freeze({ geometry: true, dots: false, chromatek: false, connections: false }), +}); diff --git a/src/adapters/flocks/src/prepare/cssflocks/sourceModel.mjs b/src/adapters/flocks/src/prepare/cssflocks/sourceModel.mjs new file mode 100644 index 0000000..48fabdc --- /dev/null +++ b/src/adapters/flocks/src/prepare/cssflocks/sourceModel.mjs @@ -0,0 +1,489 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +import { buildFlocksBugMatrix, flocksHueToHex } from "../../shared/cssflocks/bugTransform.mjs"; + +const PREPARED_FRAMES_PER_SECOND = 60; +const DEFAULT_ASPECT_RATIO = 16 / 9; + +export const CSSFLOCKS_SOURCE = Object.freeze({ + repository: "https://github.com/reallyslickscreensavers/reallyslickscreensavers", + revision: "a419fc4ecf4b9b19526448bf9f5dfc435e24ca4c", + path: "src/flocks/flocks.cpp", + sha256: "0db819da1d123ad4ae2cf5f53bec278e64b2f65ecabe617a843197446c1813d6", + license: "GPL-2.0-or-later", + fieldOfViewDegrees: 50, + leaders: 4, + followers: 1_000, + geometry: 1, + size: 5, + complexity: 1, + speed: 15, + stretch: 20, + colorFadeSpeed: 15, + chromatek: 0, + connections: 0, +}); + +export const CSSFLOCKS_PREPARED_CADENCE = Object.freeze({ + framesPerSecond: PREPARED_FRAMES_PER_SECOND, + frameMilliseconds: 1_000 / PREPARED_FRAMES_PER_SECOND, + warmupSeconds: 20, + blockSeconds: 1, + sourceStreamSeconds: 216, + terminalBridgeSeconds: 8, +}); + +export const CSSFLOCKS_PREPARED_LOADER = Object.freeze({ + runtimeLookaheadBlockCount: 11, + runtimeMaterializedLookaheadBlockCount: 2, + startupMaterializedLookaheadBlockCount: 2, +}); + +export const CSSFLOCKS_SOURCE_BANK = Object.freeze({ + id: "source-default", + name: "Flocks Source Default", + seed: 1, + leaderCount: CSSFLOCKS_SOURCE.leaders, + followerCount: CSSFLOCKS_SOURCE.followers, + bugCount: CSSFLOCKS_SOURCE.leaders + CSSFLOCKS_SOURCE.followers, + framesPerSecond: CSSFLOCKS_PREPARED_CADENCE.framesPerSecond, + frameMilliseconds: CSSFLOCKS_PREPARED_CADENCE.frameMilliseconds, + warmupFrames: Math.round(CSSFLOCKS_PREPARED_CADENCE.warmupSeconds * PREPARED_FRAMES_PER_SECOND), + frameCount: Math.round(CSSFLOCKS_PREPARED_CADENCE.sourceStreamSeconds * PREPARED_FRAMES_PER_SECOND), + blockFrameCount: Math.round(CSSFLOCKS_PREPARED_CADENCE.blockSeconds * PREPARED_FRAMES_PER_SECOND), + aspectRatio: DEFAULT_ASPECT_RATIO, +}); + +export const CSSFLOCKS_PRODUCT_PROFILES = Object.freeze({ + desktop: Object.freeze({ + id: "desktop", + modelId: "flocks", + bugCount: 324, + leaderCount: CSSFLOCKS_SOURCE.leaders, + followerCount: 320, + }), + mobile: Object.freeze({ + id: "mobile", + modelId: "flocks-mobile", + bugCount: 164, + leaderCount: CSSFLOCKS_SOURCE.leaders, + followerCount: 160, + }), +}); + +export function buildFlocksSourceOracleSequence({ bank = CSSFLOCKS_SOURCE_BANK } = {}) { + validateBank(bank); + const rng = createMt19937(bank.seed); + const bounds = computeSourceBounds(bank.aspectRatio); + const leaders = Array.from({ length: bank.leaderCount }, (_, index) => + createLeader(index, bounds, rng)); + const followers = Array.from({ length: bank.followerCount }, (_, index) => + createFollower(bank.leaderCount + index, bounds, rng)); + const deltaSeconds = f32(bank.frameMilliseconds / 1_000); + const initialBugs = Object.freeze([...leaders, ...followers].map(snapshotBug)); + const frames = []; + for (let frameIndex = 0; frameIndex < bank.frameCount; frameIndex += 1) { + stepFlocksSource({ leaders, followers, bounds, rng, deltaSeconds }); + frames.push(Object.freeze({ + index: frameIndex, + timeMs: frameIndex / bank.framesPerSecond * 1_000, + bugs: Object.freeze([...leaders, ...followers].map(snapshotBug)), + })); + } + return deepFreeze({ + schema: "cssflocks-source-oracle-sequence@1", + source: CSSFLOCKS_SOURCE, + bank: Object.freeze({ ...bank }), + bounds, + initialBugs, + frames: Object.freeze(frames), + }); +} + +export function buildFlocksSourceSequence({ bank = CSSFLOCKS_SOURCE_BANK } = {}) { + validateBank(bank); + const rng = createMt19937(bank.seed); + const bounds = computeSourceBounds(bank.aspectRatio); + const leaders = Array.from({ length: bank.leaderCount }, (_, index) => + createLeader(index, bounds, rng)); + const followers = Array.from({ length: bank.followerCount }, (_, index) => + createFollower(bank.leaderCount + index, bounds, rng)); + const deltaSeconds = f32(bank.frameMilliseconds / 1_000); + for (let frameIndex = 0; frameIndex < bank.warmupFrames; frameIndex += 1) { + stepFlocksSource({ leaders, followers, bounds, rng, deltaSeconds }); + } + const frames = []; + for (let frameIndex = 0; frameIndex < bank.frameCount; frameIndex += 1) { + stepFlocksSource({ leaders, followers, bounds, rng, deltaSeconds }); + frames.push(Object.freeze({ + index: frameIndex, + timeMs: frameIndex / bank.framesPerSecond * 1_000, + bugs: Object.freeze([...leaders, ...followers].map(snapshotBug)), + })); + } + return deepFreeze({ + schema: "cssflocks-source-sequence@1", + source: CSSFLOCKS_SOURCE, + bank: Object.freeze({ ...bank }), + bounds, + modelMatrix: flattenCss(multiply4( + scale4([1, -1, 1]), + translation([0, 0, -bounds.wide * 2]), + )), + frames: Object.freeze(frames), + durationMilliseconds: bank.frameCount / bank.framesPerSecond * 1_000, + }); +} + +export function* buildFlocksSourceBlocks({ bank = CSSFLOCKS_SOURCE_BANK } = {}) { + validateBank(bank); + const rng = createMt19937(bank.seed); + const bounds = computeSourceBounds(bank.aspectRatio); + const leaders = Array.from({ length: bank.leaderCount }, (_, index) => + createLeader(index, bounds, rng)); + const followers = Array.from({ length: bank.followerCount }, (_, index) => + createFollower(bank.leaderCount + index, bounds, rng)); + const deltaSeconds = f32(bank.frameMilliseconds / 1_000); + for (let frameIndex = 0; frameIndex < bank.warmupFrames; frameIndex += 1) { + stepFlocksSource({ leaders, followers, bounds, rng, deltaSeconds }); + } + const blockCount = bank.frameCount / bank.blockFrameCount; + for (let blockIndex = 0; blockIndex < blockCount; blockIndex += 1) { + const frames = []; + const startFrameIndex = blockIndex * bank.blockFrameCount; + for (let localFrameIndex = 0; localFrameIndex < bank.blockFrameCount; localFrameIndex += 1) { + stepFlocksSource({ leaders, followers, bounds, rng, deltaSeconds }); + frames.push(Object.freeze({ + index: startFrameIndex + localFrameIndex, + timeMs: (startFrameIndex + localFrameIndex) / bank.framesPerSecond * 1_000, + bugs: Object.freeze([...leaders, ...followers].map(snapshotBug)), + })); + } + yield deepFreeze({ + schema: "cssflocks-source-sequence@1", + source: CSSFLOCKS_SOURCE, + bank: Object.freeze({ ...bank, blockIndex, blockCount, startFrameIndex }), + bounds, + modelMatrix: flattenCss(multiply4( + scale4([1, -1, 1]), + translation([0, 0, -bounds.wide * 2]), + )), + frames: Object.freeze(frames), + durationMilliseconds: bank.blockFrameCount / bank.framesPerSecond * 1_000, + streamDurationMilliseconds: bank.frameCount / bank.framesPerSecond * 1_000, + }); + } +} + +export function* buildFlocksSourceEndpointSamples({ bank = CSSFLOCKS_SOURCE_BANK } = {}) { + validateBank(bank); + const rng = createMt19937(bank.seed); + const bounds = computeSourceBounds(bank.aspectRatio); + const leaders = Array.from({ length: bank.leaderCount }, (_, index) => + createLeader(index, bounds, rng)); + const followers = Array.from({ length: bank.followerCount }, (_, index) => + createFollower(bank.leaderCount + index, bounds, rng)); + const deltaSeconds = f32(bank.frameMilliseconds / 1_000); + for (let frameIndex = 0; frameIndex < bank.warmupFrames; frameIndex += 1) { + stepFlocksSource({ leaders, followers, bounds, rng, deltaSeconds }); + } + for (let frameIndex = 0; frameIndex < bank.frameCount; frameIndex += 1) { + stepFlocksSource({ leaders, followers, bounds, rng, deltaSeconds }); + if (frameIndex % bank.framesPerSecond !== 0 && frameIndex % bank.framesPerSecond !== bank.framesPerSecond - 1) continue; + yield Object.freeze({ + index: frameIndex, + bugs: Object.freeze([...leaders, ...followers].map(snapshotTransportBug)), + }); + } +} + +export function selectFlocksProductPrefix(source, profile) { + if (source?.schema !== "cssflocks-source-sequence@1" || + !profile || !Number.isSafeInteger(profile.bugCount) || + profile.leaderCount !== source.bank.leaderCount || + profile.bugCount > source.bank.bugCount || + profile.bugCount !== profile.leaderCount + profile.followerCount) { + throw new Error("Flocks product prefix binding drifted"); + } + return deepFreeze({ + ...source, + profile: Object.freeze({ ...profile }), + frames: Object.freeze(source.frames.map((frame) => Object.freeze({ + ...frame, + bugs: Object.freeze(frame.bugs.slice(0, profile.bugCount)), + }))), + }); +} + +export function stepFlocksSource({ leaders, followers, bounds, rng, deltaSeconds }) { + for (const leader of leaders) updateLeader(leader, bounds, rng, deltaSeconds); + for (const follower of followers) updateFollower(follower, leaders, rng, deltaSeconds); +} + +export function computeSourceBounds(aspectRatio = DEFAULT_ASPECT_RATIO) { + if (!Number.isFinite(aspectRatio) || aspectRatio <= 0) { + throw new RangeError("Flocks aspect ratio must be positive"); + } + if (aspectRatio >= 1) { + return Object.freeze({ wide: Math.trunc(160 * aspectRatio), high: 160, deep: 160, aspectRatio }); + } + return Object.freeze({ wide: 160, high: Math.trunc(160 / aspectRatio), deep: 160, aspectRatio }); +} + +function createLeader(index, bounds, rng) { + return { + index, + type: "leader", + hue: randf(rng, 1), + x: f32(randf(rng, bounds.wide * 2) - bounds.wide), + y: f32(randf(rng, bounds.high * 2) - bounds.high), + z: f32(randf(rng, bounds.wide * 2) + bounds.wide * 2), + xSpeed: 0, + ySpeed: 0, + zSpeed: 0, + maxSpeed: f32(8 * CSSFLOCKS_SOURCE.speed), + accel: f32(13 * CSSFLOCKS_SOURCE.speed), + right: 1, + up: 1, + forward: 1, + craziness: f32(randf(rng, 4) + f32(0.05)), + nextChange: 1, + leader: null, + }; +} + +function createFollower(index, bounds, rng) { + return { + index, + type: "follower", + hue: randf(rng, 1), + x: f32(randf(rng, bounds.wide * 2) - bounds.wide), + y: f32(randf(rng, bounds.high * 2) - bounds.high), + z: f32(randf(rng, bounds.wide * 5) + bounds.wide * 2), + xSpeed: 0, + ySpeed: 0, + zSpeed: 0, + maxSpeed: f32(f32(randf(rng, 6) + 4) * CSSFLOCKS_SOURCE.speed), + accel: f32(f32(randf(rng, 4) + 9) * CSSFLOCKS_SOURCE.speed), + right: 0, + up: 0, + forward: 0, + craziness: null, + nextChange: null, + leader: 0, + }; +} + +function updateLeader(bug, bounds, rng, deltaSeconds) { + bug.nextChange = f32(bug.nextChange - deltaSeconds); + if (bug.nextChange <= 0) { + if (randi(rng, 2)) bug.right += 1; + if (randi(rng, 2)) bug.up += 1; + if (randi(rng, 2)) bug.forward += 1; + if (bug.right >= 2) bug.right = 0; + if (bug.up >= 2) bug.up = 0; + if (bug.forward >= 2) bug.forward = 0; + bug.nextChange = randf(rng, bug.craziness); + } + bug.xSpeed = f32(bug.xSpeed + f32((bug.right ? 1 : -1) * f32(bug.accel * deltaSeconds))); + bug.ySpeed = f32(bug.ySpeed + f32((bug.up ? 1 : -1) * f32(bug.accel * deltaSeconds))); + bug.zSpeed = f32(bug.zSpeed + f32((bug.forward ? -1 : 1) * f32(bug.accel * deltaSeconds))); + if (bug.x < -bounds.wide) bug.right = 1; + if (bug.x > bounds.wide) bug.right = 0; + if (bug.y < -bounds.high) bug.up = 1; + if (bug.y > bounds.high) bug.up = 0; + if (bug.z < -bounds.deep) bug.forward = 0; + if (bug.z > bounds.deep) bug.forward = 1; + finishBugUpdate(bug, deltaSeconds); +} + +function updateFollower(bug, leaders, rng, deltaSeconds) { + if (randi(rng, 10) === 0) { + let oldDistance = 10_000_000; + for (let index = 0; index < leaders.length; index += 1) { + const leader = leaders[index]; + const xDistance = f32(leader.x - bug.x); + const yDistance = f32(leader.y - bug.y); + const zDistance = f32(leader.z - bug.z); + const distance = f32(f32(f32(xDistance * xDistance) + f32(yDistance * yDistance)) + f32(zDistance * zDistance)); + if (distance < oldDistance) { + oldDistance = distance; + bug.leader = index; + } + } + } + const leader = leaders[bug.leader]; + bug.xSpeed = f32(bug.xSpeed + f32((leader.x - bug.x > 0 ? 1 : -1) * f32(bug.accel * deltaSeconds))); + bug.ySpeed = f32(bug.ySpeed + f32((leader.y - bug.y > 0 ? 1 : -1) * f32(bug.accel * deltaSeconds))); + bug.zSpeed = f32(bug.zSpeed + f32((leader.z - bug.z > 0 ? 1 : -1) * f32(bug.accel * deltaSeconds))); + const colorFade = f32(CSSFLOCKS_SOURCE.colorFadeSpeed * f32(0.01)); + const colorStep = f32(colorFade * deltaSeconds); + const hueDistance = Math.abs(bug.hue - leader.hue); + if (hueDistance < colorStep) { + bug.hue = leader.hue; + } else if (hueDistance < 0.5) { + bug.hue = f32(bug.hue + (bug.hue > leader.hue ? -colorStep : colorStep)); + } else { + bug.hue = f32(bug.hue + (bug.hue > leader.hue ? colorStep : -colorStep)); + if (bug.hue > 1) bug.hue = f32(bug.hue - 1); + if (bug.hue < 0) bug.hue = f32(bug.hue + 1); + } + finishBugUpdate(bug, deltaSeconds); +} + +function finishBugUpdate(bug, deltaSeconds) { + bug.xSpeed = f32(clamp(bug.xSpeed, -bug.maxSpeed, bug.maxSpeed)); + bug.ySpeed = f32(clamp(bug.ySpeed, -bug.maxSpeed, bug.maxSpeed)); + bug.zSpeed = f32(clamp(bug.zSpeed, -bug.maxSpeed, bug.maxSpeed)); + bug.x = f32(bug.x + f32(bug.xSpeed * deltaSeconds)); + bug.y = f32(bug.y + f32(bug.ySpeed * deltaSeconds)); + bug.z = f32(bug.z + f32(bug.zSpeed * deltaSeconds)); +} + +function snapshotBug(bug) { + const transform = buildFlocksBugMatrix( + [bug.x, bug.y, bug.z], + [bug.xSpeed, bug.ySpeed, bug.zSpeed], + CSSFLOCKS_SOURCE.stretch, + ); + return Object.freeze({ + index: bug.index, + type: bug.type, + leader: bug.leader, + position: Object.freeze([rounded(bug.x), rounded(bug.y), rounded(bug.z)]), + velocity: Object.freeze([rounded(bug.xSpeed), rounded(bug.ySpeed), rounded(bug.zSpeed)]), + direction: transform.direction, + stretch: transform.stretch, + hue: rounded(bug.hue), + color: flocksHueToHex(bug.hue), + matrix: transform.matrix, + }); +} + +function snapshotTransportBug(bug) { + return Object.freeze({ + index: bug.index, + type: bug.type, + position: Object.freeze([rounded(bug.x), rounded(bug.y), rounded(bug.z)]), + velocity: Object.freeze([rounded(bug.xSpeed), rounded(bug.ySpeed), rounded(bug.zSpeed)]), + hue: rounded(bug.hue), + }); +} + +function validateBank(bank) { + for (const [name, value] of Object.entries({ + seed: bank?.seed, + leaderCount: bank?.leaderCount, + followerCount: bank?.followerCount, + bugCount: bank?.bugCount, + framesPerSecond: bank?.framesPerSecond, + warmupFrames: bank?.warmupFrames, + frameCount: bank?.frameCount, + blockFrameCount: bank?.blockFrameCount, + })) { + const minimum = name === "warmupFrames" || name === "followerCount" ? 0 : 1; + if (!Number.isSafeInteger(value) || value < minimum) { + throw new RangeError(`Flocks ${name} must be a safe integer >= ${minimum}`); + } + } + if (bank.bugCount !== bank.leaderCount + bank.followerCount || + bank.frameMilliseconds !== 1_000 / bank.framesPerSecond || + bank.frameCount % bank.blockFrameCount !== 0 || + !Number.isFinite(bank.aspectRatio) || bank.aspectRatio <= 0) { + throw new RangeError("Flocks bank binding is inconsistent"); + } +} + +function createMt19937(seed) { + const state = new Uint32Array(624); + state[0] = seed >>> 0; + for (let index = 1; index < state.length; index += 1) { + state[index] = (Math.imul(1812433253, state[index - 1] ^ (state[index - 1] >>> 30)) + index) >>> 0; + } + let cursor = state.length; + const nextUint32 = () => { + if (cursor >= state.length) { + for (let index = 0; index < state.length; index += 1) { + const value = (state[index] & 0x80000000) | (state[(index + 1) % 624] & 0x7fffffff); + state[index] = state[(index + 397) % 624] ^ (value >>> 1) ^ ((value & 1) ? 0x9908b0df : 0); + } + cursor = 0; + } + let value = state[cursor++]; + value ^= value >>> 11; + value ^= (value << 7) & 0x9d2c5680; + value ^= (value << 15) & 0xefc60000; + value ^= value >>> 18; + return value >>> 0; + }; + return Object.freeze({ + nextFloat() { + return (nextUint32() >>> 8) / 0x01000000; + }, + nextInt(maximum) { + if (!Number.isSafeInteger(maximum) || maximum <= 0) { + throw new RangeError("Flocks random integer maximum must be positive"); + } + if (maximum === 1) return 0; + const width = Math.ceil(Math.log2(maximum)); + const mask = 2 ** width - 1; + let value; + do value = nextUint32() & mask; + while (value >= maximum); + return value; + }, + }); +} + +function identity4() { + return [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]; +} + +function multiply4(left, right) { + return left.map((row) => right[0].map((unused, column) => + row.reduce((sum, value, index) => sum + value * right[index][column], 0))); +} + +function translation([x, y, z]) { + const matrix = identity4(); + matrix[0][3] = x; matrix[1][3] = y; matrix[2][3] = z; + return matrix; +} + +function scale4([x, y, z]) { + const matrix = identity4(); + matrix[0][0] = x; matrix[1][1] = y; matrix[2][2] = z; + return matrix; +} + +function flattenCss(matrix) { + return Object.freeze(matrix[0].map((unused, column) => matrix.map((row) => rounded(row[column]))).flat()); +} + +function randf(rng, maximum) { + return f32(rng.nextFloat() * maximum); +} + +function randi(rng, maximum) { + return rng.nextInt(maximum); +} + +function clamp(value, minimum, maximum) { + return Math.min(maximum, Math.max(minimum, value)); +} + +function f32(value) { + return Math.fround(value); +} + +function rounded(value) { + const result = Number(value.toFixed(6)); + return Object.is(result, -0) ? 0 : result; +} + +function deepFreeze(value) { + if (value && typeof value === "object" && !Object.isFrozen(value)) { + Object.freeze(value); + for (const child of Object.values(value)) deepFreeze(child); + } + return value; +} diff --git a/src/adapters/flocks/src/prepare/cssflocks/terminalSeam.mjs b/src/adapters/flocks/src/prepare/cssflocks/terminalSeam.mjs new file mode 100644 index 0000000..462027d --- /dev/null +++ b/src/adapters/flocks/src/prepare/cssflocks/terminalSeam.mjs @@ -0,0 +1,235 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +import { CSSFLOCKS_SOURCE } from "./sourceModel.mjs"; + +export function buildFlocksTerminalBridge({ + finalFrame, + initialFrame, + correspondence, + frameCount, + framesPerSecond, +}) { + if (!Array.isArray(finalFrame?.bugs) || !Array.isArray(initialFrame?.bugs) || + finalFrame.bugs.length !== initialFrame.bugs.length || + !Array.isArray(correspondence) || correspondence.length !== finalFrame.bugs.length || + new Set(correspondence).size !== correspondence.length || + correspondence.some((index) => !Number.isSafeInteger(index) || index < 0 || index >= correspondence.length) || + !Number.isSafeInteger(frameCount) || frameCount < 1 || + !Number.isSafeInteger(framesPerSecond) || framesPerSecond < 1) { + throw new TypeError("Complete Flocks terminal bridge inputs are required"); + } + const durationSeconds = (frameCount + 1) / framesPerSecond; + const frames = Array.from({ length: frameCount }, (_, frameIndex) => { + const time = (frameIndex + 1) / (frameCount + 1); + return Object.freeze({ + index: frameIndex, + timeMs: (frameIndex + 1) / framesPerSecond * 1_000, + bugs: Object.freeze(finalFrame.bugs.map((bug, bugIndex) => { + const target = initialFrame.bugs[correspondence[bugIndex]]; + const position = [0, 1, 2].map((axis) => hermitePosition( + bug.position[axis], bug.velocity[axis], target.position[axis], target.velocity[axis], time, durationSeconds, + )); + const velocity = [0, 1, 2].map((axis) => hermiteVelocity( + bug.position[axis], bug.velocity[axis], target.position[axis], target.velocity[axis], time, durationSeconds, + )); + const hueDelta = signedCircularHueDelta(target.hue, bug.hue); + return Object.freeze({ + index: bug.index, + type: bug.type, + position: Object.freeze(position.map(rounded)), + velocity: Object.freeze(velocity.map(rounded)), + hue: rounded(wrapHue(bug.hue + hueDelta * smoothstep(time))), + }); + })), + }); + }); + return Object.freeze({ + schema: "cssflocks-prepare-only-terminal-bridge@1", + strategy: "cubic-hermite-correspondence", + sourceBehaviorDeviation: true, + frameCount, + durationMilliseconds: frameCount / framesPerSecond * 1_000, + interpolationDurationMilliseconds: durationSeconds * 1_000, + correspondence: Object.freeze([...correspondence]), + frames: Object.freeze(frames), + }); +} + +export function buildFlocksTerminalCorrespondence(finalBugs, initialBugs, viewport, leaderCount) { + if (finalBugs.length !== initialBugs.length) throw new Error("Terminal correspondence cardinality drifted"); + const permutation = new Array(finalBugs.length); + assignRange(0, leaderCount); + assignRange(leaderCount, finalBugs.length); + const distances = []; + const hueDistances = []; + let visibilityMismatchCount = 0; + let visiblePairCount = 0; + for (let finalIndex = 0; finalIndex < finalBugs.length; finalIndex += 1) { + const initialIndex = permutation[finalIndex]; + const left = flocksVisualFeature(finalBugs[finalIndex], viewport); + const right = flocksVisualFeature(initialBugs[initialIndex], viewport); + if (left.visible !== right.visible) visibilityMismatchCount += 1; + if (left.visible && right.visible) { + visiblePairCount += 1; + distances.push(Math.hypot(left.x - right.x, left.y - right.y)); + } + hueDistances.push(flocksCircularHueDistance(finalBugs[finalIndex].hue, initialBugs[initialIndex].hue)); + } + distances.sort((left, right) => left - right); + hueDistances.sort((left, right) => left - right); + return Object.freeze({ + permutation: Object.freeze(permutation), + metrics: Object.freeze({ + bugCount: finalBugs.length, + visiblePairCount, + visibilityMismatchCount, + projectedCenterDistanceMean: mean(distances), + projectedCenterDistanceP95: percentile(distances, 0.95), + projectedCenterDistanceMax: distances.at(-1) ?? 0, + circularHueDistanceP95: percentile(hueDistances, 0.95), + circularHueDistanceMax: hueDistances.at(-1) ?? 0, + }), + }); + + function assignRange(start, end) { + const left = finalBugs.slice(start, end).map((bug) => flocksVisualFeature(bug, viewport)); + const right = initialBugs.slice(start, end).map((bug) => flocksVisualFeature(bug, viewport)); + const assignment = hungarian(left.map((feature, leftIndex) => right.map((candidate, rightIndex) => + correspondenceCost(feature, candidate, finalBugs[start + leftIndex], initialBugs[start + rightIndex], viewport)))); + assignment.forEach((rightIndex, leftIndex) => { permutation[start + leftIndex] = start + rightIndex; }); + } +} + +export function buildFlocksVisualSignature(bugs, viewport, { columns = 12, rows = 8, hueBins = 6 } = {}) { + const bins = new Uint16Array(columns * rows * hueBins); + let visibleCount = 0; + for (const bug of bugs) { + const feature = flocksVisualFeature(bug, viewport); + if (!feature.visible) continue; + visibleCount += 1; + const column = Math.min(columns - 1, Math.max(0, Math.floor(feature.x / viewport[0] * columns))); + const row = Math.min(rows - 1, Math.max(0, Math.floor(feature.y / viewport[1] * rows))); + const hue = ((bug.hue % 1) + 1) % 1; + const hueBin = Math.min(hueBins - 1, Math.floor(hue * hueBins)); + bins[(row * columns + column) * hueBins + hueBin] += 1; + } + return Object.freeze({ bins, visibleCount }); +} + +export function compareFlocksVisualSignatures(left, right) { + if (left.bins.length !== right.bins.length) throw new Error("Flocks seam signature dimensions drifted"); + let difference = 0; + for (let index = 0; index < left.bins.length; index += 1) difference += Math.abs(left.bins[index] - right.bins[index]); + return difference / Math.max(1, left.visibleCount + right.visibleCount); +} + +export function flocksVisualFeature(bug, [width, height]) { + const depth = 568 - bug.position[2]; + if (depth <= 0.1) return { x: width * 0.5, y: height * 0.5, visible: false }; + const scale = height / (2 * Math.tan(CSSFLOCKS_SOURCE.fieldOfViewDegrees * Math.PI / 360)) / depth; + const x = width / 2 + bug.position[0] * scale; + const y = height / 2 - bug.position[1] * scale; + return { x, y, visible: x >= -12 && x <= width + 12 && y >= -12 && y <= height + 12 }; +} + +export function flocksCircularHueDistance(left, right) { + const distance = Math.abs(left - right); + return Math.min(distance, 1 - distance); +} + +function hermitePosition(start, startVelocity, end, endVelocity, time, durationSeconds) { + const time2 = time * time; + const time3 = time2 * time; + return (2 * time3 - 3 * time2 + 1) * start + + (time3 - 2 * time2 + time) * durationSeconds * startVelocity + + (-2 * time3 + 3 * time2) * end + + (time3 - time2) * durationSeconds * endVelocity; +} + +function hermiteVelocity(start, startVelocity, end, endVelocity, time, durationSeconds) { + const time2 = time * time; + return ((6 * time2 - 6 * time) * start + + (3 * time2 - 4 * time + 1) * durationSeconds * startVelocity + + (-6 * time2 + 6 * time) * end + + (3 * time2 - 2 * time) * durationSeconds * endVelocity) / durationSeconds; +} + +function smoothstep(value) { + return value * value * (3 - 2 * value); +} + +function signedCircularHueDelta(target, start) { + let delta = target - start; + if (delta > 0.5) delta -= 1; + if (delta < -0.5) delta += 1; + return delta; +} + +function wrapHue(value) { + let result = value; + while (result > 1) result -= 1; + while (result < 0) result += 1; + return result; +} + +function rounded(value) { + const result = Number(value.toFixed(6)); + return Object.is(result, -0) ? 0 : result; +} + +function correspondenceCost(left, right, leftBug, rightBug, [width, height]) { + const huePixels = flocksCircularHueDistance(leftBug.hue, rightBug.hue) * Math.min(width, height) * 1.5; + const speedDelta = Math.abs(Math.hypot(...leftBug.velocity) - Math.hypot(...rightBug.velocity)) * 0.2; + if (left.visible && right.visible) return (left.x - right.x) ** 2 + (left.y - right.y) ** 2 + huePixels ** 2 + speedDelta ** 2; + if (left.visible !== right.visible) return (width ** 2 + height ** 2) * 4 + huePixels ** 2; + const positionDelta = Math.hypot(...leftBug.position.map((value, axis) => value - rightBug.position[axis])); + return huePixels ** 2 + positionDelta ** 2 * 0.01 + speedDelta ** 2; +} + +function hungarian(cost) { + const count = cost.length; + if (count === 0 || cost.some((row) => row.length !== count)) throw new Error("Square terminal cost matrix required"); + const u = new Float64Array(count + 1); + const v = new Float64Array(count + 1); + const p = new Int32Array(count + 1); + const way = new Int32Array(count + 1); + for (let row = 1; row <= count; row += 1) { + p[0] = row; + let column0 = 0; + const minimum = new Float64Array(count + 1).fill(Infinity); + const used = new Uint8Array(count + 1); + do { + used[column0] = 1; + const row0 = p[column0]; + let delta = Infinity; + let column1 = 0; + for (let column = 1; column <= count; column += 1) { + if (used[column]) continue; + const current = cost[row0 - 1][column - 1] - u[row0] - v[column]; + if (current < minimum[column]) { minimum[column] = current; way[column] = column0; } + if (minimum[column] < delta) { delta = minimum[column]; column1 = column; } + } + for (let column = 0; column <= count; column += 1) { + if (used[column]) { u[p[column]] += delta; v[column] -= delta; } + else minimum[column] -= delta; + } + column0 = column1; + } while (p[column0] !== 0); + do { + const column1 = way[column0]; + p[column0] = p[column1]; + column0 = column1; + } while (column0 !== 0); + } + const assignment = new Array(count); + for (let column = 1; column <= count; column += 1) assignment[p[column] - 1] = column - 1; + return assignment; +} + +function mean(values) { + return values.length === 0 ? 0 : values.reduce((sum, value) => sum + value, 0) / values.length; +} + +function percentile(values, fraction) { + if (values.length === 0) return 0; + return values[Math.min(values.length - 1, Math.ceil(values.length * fraction) - 1)]; +} diff --git a/src/adapters/flocks/src/prepare/cssflocks/writeManifest.mjs b/src/adapters/flocks/src/prepare/cssflocks/writeManifest.mjs new file mode 100644 index 0000000..9747f1e --- /dev/null +++ b/src/adapters/flocks/src/prepare/cssflocks/writeManifest.mjs @@ -0,0 +1,11 @@ +import { mkdir, rename, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; + +export async function writeFlocksJson(path, value) { + const bytes = Buffer.from(`${JSON.stringify(value, null, 2)}\n`); + const temporaryPath = `${path}.${process.pid}.tmp`; + await mkdir(dirname(path), { recursive: true }); + await writeFile(temporaryPath, bytes); + await rename(temporaryPath, path); + return bytes; +} diff --git a/src/adapters/flocks/src/shared/cssflocks/bugTransform.mjs b/src/adapters/flocks/src/shared/cssflocks/bugTransform.mjs new file mode 100644 index 0000000..102c997 --- /dev/null +++ b/src/adapters/flocks/src/shared/cssflocks/bugTransform.mjs @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +export function buildFlocksBugMatrix(position, velocity, stretchAmount = 20) { + if (!Array.isArray(position) || position.length !== 3 || position.some((value) => !Number.isFinite(value)) || + !Array.isArray(velocity) || velocity.length !== 3 || velocity.some((value) => !Number.isFinite(value)) || + !Number.isFinite(stretchAmount) || stretchAmount < 0) { + throw new TypeError("Complete Flocks bug transform inputs are required"); + } + const velocityLength = Math.hypot(...velocity); + const direction = velocityLength > 0 ? velocity.map((value) => value / velocityLength) : [0, 0, 0]; + const stretch = Math.max(velocityLength * 0.04 * stretchAmount * 0.05, 1); + const yaw = velocityLength > 0 ? Math.atan2(-direction[0], -direction[2]) : 0; + const pitch = velocityLength > 0 ? Math.asin(clamp(direction[1], -1, 1)) : 0; + return Object.freeze({ + direction: Object.freeze(direction.map(rounded)), + stretch: rounded(stretch), + matrix: flattenCss(multiply4( + translation(position), + multiply4(rotationY(yaw), multiply4(rotationX(pitch), scale4([1, 1, stretch]))), + )), + }); +} + +export function flocksHueToHex(hue) { + if (!Number.isFinite(hue)) throw new TypeError("Flocks hue must be finite"); + return `#${flocksHueToRgb(hue) + .map((value) => Math.round(clamp(value, 0, 1) * 255).toString(16).padStart(2, "0")) + .join("")}`; +} + +export function flocksHueToRgb(hue, saturation = 1, luminosity = 1) { + if (![hue, saturation, luminosity].every(Number.isFinite)) { + throw new TypeError("Finite Flocks HSL values are required"); + } + let h = hue % 1; + if (h < 0) h += 1; + let red; + let green; + let blue; + if (h < 1 / 6) { + red = 1; green = h * 6; blue = 0; + } else if (h < 1 / 2) { + green = 1; + if (h < 1 / 3) { + red = 1 - (h - 1 / 6) * 6; blue = 0; + } else { + blue = (h - 1 / 3) * 6; red = 0; + } + } else if (h < 5 / 6) { + blue = 1; + if (h < 2 / 3) { + green = 1 - (h - 1 / 2) * 6; red = 0; + } else { + red = (h - 2 / 3) * 6; green = 0; + } + } else { + red = 1; blue = 1 - (h - 5 / 6) * 6; green = 0; + } + return [red, green, blue].map((channel) => (1 - saturation * (1 - channel)) * luminosity); +} + +function identity4() { + return [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]; +} + +function multiply4(left, right) { + return left.map((row) => right[0].map((unused, column) => + row.reduce((sum, value, index) => sum + value * right[index][column], 0))); +} + +function translation([x, y, z]) { + const matrix = identity4(); + matrix[0][3] = x; matrix[1][3] = y; matrix[2][3] = z; + return matrix; +} + +function scale4([x, y, z]) { + const matrix = identity4(); + matrix[0][0] = x; matrix[1][1] = y; matrix[2][2] = z; + return matrix; +} + +function rotationY(radians) { + const cosine = Math.cos(radians); + const sine = Math.sin(radians); + return [[cosine, 0, sine, 0], [0, 1, 0, 0], [-sine, 0, cosine, 0], [0, 0, 0, 1]]; +} + +function rotationX(radians) { + const cosine = Math.cos(radians); + const sine = Math.sin(radians); + return [[1, 0, 0, 0], [0, cosine, -sine, 0], [0, sine, cosine, 0], [0, 0, 0, 1]]; +} + +function flattenCss(matrix) { + return Object.freeze(matrix[0].map((unused, column) => matrix.map((row) => rounded(row[column]))).flat()); +} + +function clamp(value, minimum, maximum) { + return Math.min(maximum, Math.max(minimum, value)); +} + +function rounded(value) { + const result = Number(value.toFixed(6)); + return Object.is(result, -0) ? 0 : result; +} diff --git a/src/adapters/flocks/src/shared/cssflocks/preparedBlockTransport.mjs b/src/adapters/flocks/src/shared/cssflocks/preparedBlockTransport.mjs new file mode 100644 index 0000000..d2e2baf --- /dev/null +++ b/src/adapters/flocks/src/shared/cssflocks/preparedBlockTransport.mjs @@ -0,0 +1,318 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +import { formatMatrix3dValues } from "@layoutit/polycss"; +import { buildFlocksBugMatrix, flocksHueToHex } from "./bugTransform.mjs"; + +export const CSSFLOCKS_BLOCK_ENCODING = "gzip-flocks-checkpoint-delta@2"; +export const CSSFLOCKS_PLAYBACK_SCHEMA = "cssflocks-prepared-dom-playback@1"; +export const CSSFLOCKS_BLOCK_SCHEMA = "cssflocks-prepared-stream-block@1"; + +export const CSSFLOCKS_TRANSPORT_LIMITS = deepFreeze({ + checkpoint: { + positionOrigin: { storage: "int16", scale: 1, minimum: -32_768, maximum: 32_767 }, + positionRelative: { storage: "int16", scale: 1 / 32, minimum: -1_024, maximum: 1_023.96875 }, + velocity: { storage: "int16", scale: 1 / 128, minimum: -256, maximum: 255.9921875 }, + hue: { storage: "uint16", scale: 1 / 65_535, minimum: 0, maximum: 1 }, + }, + delta: { + positionResidual: { storage: "int8", scale: 1 / 4_096, minimum: -0.03125, maximum: 0.031005859375 }, + velocity: { storage: "int16", scale: 1 / 128, minimum: -256, maximum: 255.9921875 }, + hueCircular: { storage: "int8", scale: 1 / 32_768, minimum: -0.00390625, maximum: 0.003875732421875 }, + }, +}); + +const MAGIC = "CFLK"; +const VERSION = 2; +const HEADER_BYTES = 40; +const CHECKPOINT_BYTES_PER_BUG = 14; +const DELTA_BYTES_PER_BUG = 10; +const POSITION_CHECKPOINT_DENOMINATOR = 32; +const VELOCITY_CHECKPOINT_DENOMINATOR = 128; +const POSITION_RESIDUAL_DENOMINATOR = 4_096; +const VELOCITY_DELTA_DENOMINATOR = 128; +const HUE_CHECKPOINT_DENOMINATOR = 65_535; +const HUE_DELTA_DENOMINATOR = 32_768; + +export function encodeFlocksPreparedBlock({ frames, bugCount, framesPerSecond }) { + validateEncodeInputs(frames, bugCount, framesPerSecond); + const byteLength = expectedByteLength(frames.length, bugCount); + const bytes = new Uint8Array(byteLength); + const view = new DataView(bytes.buffer); + const positionOrigins = buildPositionOrigins(frames[0].bugs); + writeHeader(bytes, view, { bugCount, frameCount: frames.length, framesPerSecond, positionOrigins }); + + const previousPositions = new Float64Array(bugCount * 3); + const previousVelocities = new Float64Array(bugCount * 3); + const previousHues = new Float64Array(bugCount); + let offset = HEADER_BYTES; + for (let bugIndex = 0; bugIndex < bugCount; bugIndex += 1) { + const bug = frames[0].bugs[bugIndex]; + const base = bugIndex * 3; + for (let axis = 0; axis < 3; axis += 1) { + const encoded = quantizeSigned(bug.position[axis] - positionOrigins[axis], POSITION_CHECKPOINT_DENOMINATOR, -0x8000, 0x7fff, `checkpoint position ${bugIndex}:${axis}`); + view.setInt16(offset, encoded, true); offset += 2; + previousPositions[base + axis] = positionOrigins[axis] + encoded / POSITION_CHECKPOINT_DENOMINATOR; + } + for (let axis = 0; axis < 3; axis += 1) { + const encoded = quantizeSigned(bug.velocity[axis], VELOCITY_CHECKPOINT_DENOMINATOR, -0x8000, 0x7fff, `checkpoint velocity ${bugIndex}:${axis}`); + view.setInt16(offset, encoded, true); offset += 2; + previousVelocities[base + axis] = encoded / VELOCITY_CHECKPOINT_DENOMINATOR; + } + const encodedHue = quantizeHue(bug.hue); + view.setUint16(offset, encodedHue, true); offset += 2; + previousHues[bugIndex] = encodedHue / HUE_CHECKPOINT_DENOMINATOR; + } + + const deltaSeconds = 1 / framesPerSecond; + for (let frameIndex = 1; frameIndex < frames.length; frameIndex += 1) { + for (let bugIndex = 0; bugIndex < bugCount; bugIndex += 1) { + const bug = frames[frameIndex].bugs[bugIndex]; + const base = bugIndex * 3; + const nextVelocities = new Float64Array(3); + for (let axis = 0; axis < 3; axis += 1) { + const delta = bug.velocity[axis] - previousVelocities[base + axis]; + const encoded = quantizeSigned(delta, VELOCITY_DELTA_DENOMINATOR, -0x8000, 0x7fff, `velocity delta ${frameIndex}:${bugIndex}:${axis}`); + view.setInt16(offset, encoded, true); offset += 2; + nextVelocities[axis] = previousVelocities[base + axis] + encoded / VELOCITY_DELTA_DENOMINATOR; + } + for (let axis = 0; axis < 3; axis += 1) { + const prediction = previousPositions[base + axis] + nextVelocities[axis] * deltaSeconds; + const residual = bug.position[axis] - prediction; + const encoded = quantizeSigned(residual, POSITION_RESIDUAL_DENOMINATOR, -0x80, 0x7f, `position residual ${frameIndex}:${bugIndex}:${axis}`); + view.setInt8(offset, encoded); offset += 1; + previousPositions[base + axis] = prediction + encoded / POSITION_RESIDUAL_DENOMINATOR; + previousVelocities[base + axis] = nextVelocities[axis]; + } + const hueDelta = circularHueDelta(bug.hue, previousHues[bugIndex]); + const encodedHueDelta = quantizeSigned(hueDelta, HUE_DELTA_DENOMINATOR, -0x80, 0x7f, `hue delta ${frameIndex}:${bugIndex}`); + view.setInt8(offset, encodedHueDelta); offset += 1; + previousHues[bugIndex] = wrapHue(previousHues[bugIndex] + encodedHueDelta / HUE_DELTA_DENOMINATOR); + } + } + if (offset !== bytes.byteLength) throw new Error("Prepared Flocks block byte count drifted"); + view.setUint32(28, transportCrc32(bytes), true); + return bytes; +} + +export function decodeFlocksPreparedSourceValues(bytes, descriptor, catalog) { + const values = new Float64Array(descriptor.frameCount * catalog.bugCount * 7); + decodeRecords(bytes, descriptor, catalog, (frameIndex, bugIndex, position, velocity, hue) => { + const offset = (frameIndex * catalog.bugCount + bugIndex) * 7; + values.set(position, offset); + values.set(velocity, offset + 3); + values[offset + 6] = hue; + }); + return values; +} + +export function decodeFlocksPreparedBlock(bytes, descriptor, catalog) { + const transforms = new Array(descriptor.frameCount * catalog.bugCount); + const colors = new Array(descriptor.frameCount * catalog.bugCount); + decodeRecords(bytes, descriptor, catalog, (frameIndex, bugIndex, position, velocity, hue) => { + const outputIndex = frameIndex * catalog.bugCount + bugIndex; + const transform = buildFlocksBugMatrix(position, velocity); + transforms[outputIndex] = `matrix3d(${formatMatrix3dValues(transform.matrix, 6)})`; + colors[outputIndex] = flocksHueToHex(hue); + }); + const preparedCssStringByteLength = [...transforms, ...colors] + .reduce((total, value) => total + value.length * 2, 0); + return Object.freeze({ + schema: CSSFLOCKS_BLOCK_SCHEMA, + index: descriptor.index, + startFrameIndex: descriptor.startFrameIndex, + playback: Object.freeze({ + schema: CSSFLOCKS_PLAYBACK_SCHEMA, + streamId: catalog.streamId, + modelId: catalog.modelId, + bugCount: catalog.bugCount, + leafCount: catalog.leafCount, + framesPerSecond: catalog.framesPerSecond, + frameMilliseconds: catalog.frameMilliseconds, + frameCount: descriptor.frameCount, + durationMilliseconds: descriptor.frameCount / catalog.framesPerSecond * 1_000, + transforms: Object.freeze(transforms), + colors: Object.freeze(colors), + }), + preparedMatrixExpansionCount: transforms.length, + preparedColorExpansionCount: colors.length, + preparedCssStringByteLength, + }); +} + +function decodeRecords(bytes, descriptor, catalog, visit) { + const viewBytes = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); + validateHeader(viewBytes, descriptor, catalog); + const view = new DataView(viewBytes.buffer, viewBytes.byteOffset, viewBytes.byteLength); + const previousPositions = new Float64Array(catalog.bugCount * 3); + const previousVelocities = new Float64Array(catalog.bugCount * 3); + const previousHues = new Float64Array(catalog.bugCount); + const positionOrigins = [view.getInt16(32, true), view.getInt16(34, true), view.getInt16(36, true)]; + let offset = HEADER_BYTES; + for (let bugIndex = 0; bugIndex < catalog.bugCount; bugIndex += 1) { + const base = bugIndex * 3; + for (let axis = 0; axis < 3; axis += 1) { + previousPositions[base + axis] = positionOrigins[axis] + view.getInt16(offset, true) / POSITION_CHECKPOINT_DENOMINATOR; offset += 2; + } + for (let axis = 0; axis < 3; axis += 1) { + previousVelocities[base + axis] = view.getInt16(offset, true) / VELOCITY_CHECKPOINT_DENOMINATOR; offset += 2; + } + previousHues[bugIndex] = view.getUint16(offset, true) / HUE_CHECKPOINT_DENOMINATOR; offset += 2; + visit(0, bugIndex, readVector(previousPositions, base), readVector(previousVelocities, base), previousHues[bugIndex]); + } + const deltaSeconds = 1 / catalog.framesPerSecond; + for (let frameIndex = 1; frameIndex < descriptor.frameCount; frameIndex += 1) { + for (let bugIndex = 0; bugIndex < catalog.bugCount; bugIndex += 1) { + const base = bugIndex * 3; + for (let axis = 0; axis < 3; axis += 1) { + previousVelocities[base + axis] += view.getInt16(offset, true) / VELOCITY_DELTA_DENOMINATOR; offset += 2; + } + for (let axis = 0; axis < 3; axis += 1) { + previousPositions[base + axis] += previousVelocities[base + axis] * deltaSeconds + view.getInt8(offset) / POSITION_RESIDUAL_DENOMINATOR; offset += 1; + } + previousHues[bugIndex] = wrapHue(previousHues[bugIndex] + view.getInt8(offset) / HUE_DELTA_DENOMINATOR); offset += 1; + visit(frameIndex, bugIndex, readVector(previousPositions, base), readVector(previousVelocities, base), previousHues[bugIndex]); + } + } + if (offset !== viewBytes.byteLength) throw new Error(`Prepared Flocks block ${descriptor.index} payload drifted`); +} + +function writeHeader(bytes, view, { bugCount, frameCount, framesPerSecond, positionOrigins }) { + for (let index = 0; index < MAGIC.length; index += 1) bytes[index] = MAGIC.charCodeAt(index); + bytes[4] = VERSION; + bytes[5] = HEADER_BYTES; + view.setUint16(6, bugCount, true); + view.setUint16(8, frameCount, true); + bytes[10] = CHECKPOINT_BYTES_PER_BUG; + bytes[11] = DELTA_BYTES_PER_BUG; + view.setUint16(12, POSITION_CHECKPOINT_DENOMINATOR, true); + view.setUint16(14, VELOCITY_CHECKPOINT_DENOMINATOR, true); + view.setUint16(16, POSITION_RESIDUAL_DENOMINATOR, true); + view.setUint16(18, VELOCITY_DELTA_DENOMINATOR, true); + view.setUint16(20, HUE_CHECKPOINT_DENOMINATOR, true); + view.setUint16(22, HUE_DELTA_DENOMINATOR, true); + view.setUint16(24, framesPerSecond, true); + view.setUint16(26, 0, true); + view.setUint32(28, 0, true); + for (let axis = 0; axis < 3; axis += 1) view.setInt16(32 + axis * 2, positionOrigins[axis], true); + view.setUint16(38, 0, true); +} + +function validateHeader(bytes, descriptor, catalog) { + if (!(bytes instanceof Uint8Array) || bytes.byteLength < HEADER_BYTES || + descriptor?.encoding !== CSSFLOCKS_BLOCK_ENCODING || + catalog?.playbackSchema !== CSSFLOCKS_PLAYBACK_SCHEMA || + !Number.isSafeInteger(descriptor?.frameCount) || descriptor.frameCount < 1 || descriptor.frameCount > 0xffff || + !Number.isSafeInteger(catalog?.bugCount) || catalog.bugCount < 1 || catalog.bugCount > 0xffff || + !Number.isSafeInteger(catalog?.framesPerSecond) || catalog.framesPerSecond < 1 || catalog.framesPerSecond > 0xffff) { + throw new TypeError("Prepared Flocks block binding is incomplete"); + } + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const expectedBytes = expectedByteLength(descriptor.frameCount, catalog.bugCount); + const magic = String.fromCharCode(...bytes.slice(0, 4)); + if (magic !== MAGIC || bytes[4] !== VERSION || bytes[5] !== HEADER_BYTES || + view.getUint16(6, true) !== catalog.bugCount || view.getUint16(8, true) !== descriptor.frameCount || + bytes[10] !== CHECKPOINT_BYTES_PER_BUG || bytes[11] !== DELTA_BYTES_PER_BUG || + view.getUint16(12, true) !== POSITION_CHECKPOINT_DENOMINATOR || + view.getUint16(14, true) !== VELOCITY_CHECKPOINT_DENOMINATOR || + view.getUint16(16, true) !== POSITION_RESIDUAL_DENOMINATOR || + view.getUint16(18, true) !== VELOCITY_DELTA_DENOMINATOR || + view.getUint16(20, true) !== HUE_CHECKPOINT_DENOMINATOR || + view.getUint16(22, true) !== HUE_DELTA_DENOMINATOR || + view.getUint16(24, true) !== catalog.framesPerSecond || view.getUint16(26, true) !== 0 || view.getUint16(38, true) !== 0 || + bytes.byteLength !== expectedBytes || descriptor.decodedByteLength !== expectedBytes) { + throw new Error(`Prepared Flocks block ${descriptor.index} header drifted`); + } + if (view.getUint32(28, true) !== transportCrc32(bytes)) { + throw new Error(`Prepared Flocks block ${descriptor.index} checksum drifted`); + } +} + +function validateEncodeInputs(frames, bugCount, framesPerSecond) { + if (!Array.isArray(frames) || frames.length < 1 || frames.length > 0xffff || + !Number.isSafeInteger(bugCount) || bugCount < 1 || bugCount > 0xffff || + !Number.isSafeInteger(framesPerSecond) || framesPerSecond < 1 || framesPerSecond > 0xffff || + frames.some((frame) => !Array.isArray(frame?.bugs) || frame.bugs.length !== bugCount || + frame.bugs.some((bug) => !validBug(bug)))) { + throw new TypeError("Complete bounded prepared Flocks source-state block inputs are required"); + } +} + +function validBug(bug) { + return Array.isArray(bug?.position) && bug.position.length === 3 && bug.position.every(Number.isFinite) && + Array.isArray(bug?.velocity) && bug.velocity.length === 3 && bug.velocity.every(Number.isFinite) && + Number.isFinite(bug?.hue) && bug.hue >= 0 && bug.hue <= 1; +} + +function expectedByteLength(frameCount, bugCount) { + return HEADER_BYTES + bugCount * (CHECKPOINT_BYTES_PER_BUG + (frameCount - 1) * DELTA_BYTES_PER_BUG); +} + +function buildPositionOrigins(bugs) { + const minimum = [Infinity, Infinity, Infinity]; + const maximum = [-Infinity, -Infinity, -Infinity]; + for (const bug of bugs) { + for (let axis = 0; axis < 3; axis += 1) { + minimum[axis] = Math.min(minimum[axis], bug.position[axis]); + maximum[axis] = Math.max(maximum[axis], bug.position[axis]); + } + } + return minimum.map((value, axis) => { + const origin = Math.round((value + maximum[axis]) / 2); + if (!Number.isSafeInteger(origin) || origin < -0x8000 || origin > 0x7fff) { + throw new RangeError(`Prepared Flocks checkpoint position origin ${axis} is outside the encoded range`); + } + return origin; + }); +} + +function quantizeSigned(value, denominator, minimum, maximum, label) { + const encoded = Math.round(value * denominator); + if (!Number.isSafeInteger(encoded) || encoded < minimum || encoded > maximum) { + throw new RangeError(`Prepared Flocks ${label} is outside the encoded range`); + } + return encoded; +} + +function quantizeHue(value) { + const encoded = Math.round(value * HUE_CHECKPOINT_DENOMINATOR); + if (!Number.isSafeInteger(encoded) || encoded < 0 || encoded > 0xffff) { + throw new RangeError("Prepared Flocks checkpoint hue is outside [0, 1]"); + } + return encoded; +} + +function circularHueDelta(target, previous) { + let delta = target - previous; + if (delta > 0.5) delta -= 1; + if (delta < -0.5) delta += 1; + return delta; +} + +function wrapHue(value) { + let result = value; + if (result > 1) result -= 1; + if (result < 0) result += 1; + return result; +} + +function readVector(values, offset) { + return [values[offset], values[offset + 1], values[offset + 2]]; +} + +function transportCrc32(bytes) { + let crc = 0xffffffff; + for (let index = 0; index < bytes.length; index += 1) { + if (index >= 28 && index < 32) continue; + const byte = bytes[index]; + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)); + } + return (crc ^ 0xffffffff) >>> 0; +} + +function deepFreeze(value) { + if (value && typeof value === "object" && !Object.isFrozen(value)) { + Object.freeze(value); + for (const entry of Object.values(value)) deepFreeze(entry); + } + return value; +} diff --git a/src/adapters/flocks/src/shared/cssflocks/startupWindows.mjs b/src/adapters/flocks/src/shared/cssflocks/startupWindows.mjs new file mode 100644 index 0000000..767e192 --- /dev/null +++ b/src/adapters/flocks/src/shared/cssflocks/startupWindows.mjs @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +export const CSSFLOCKS_STARTUP_WINDOWS = deepFreeze([ + { + id: "source-005s", blockIndex: 5, sourceFrameIndex: 300, + profiles: { + desktop: metrics(0.882716, 0.117284, 0.055944, 0.197917, 0.700926, 0.876395, 260.276, 7.968, 8.454, 7.436, 9.835, 5, 1.724), + mobile: metrics(0.713415, 0.286585, 0.008547, 0.197917, 0.855946, 0.567339, 457.177, 8.555, 8.960, 7.075, 9.368, 5, 1.804), + }, + }, + { + id: "source-114s", blockIndex: 114, sourceFrameIndex: 6_840, + profiles: { + desktop: metrics(1, 0, 0.012346, 0.229167, 0.796193, 0.670112, 205.826, 10.130, 12.164, 8.481, 10.081, 7, 1.521), + mobile: metrics(0.743902, 0.256098, 0.057377, 0.229167, 1.042307, 0.645892, 217.147, 10.782, 13.060, 8.377, 8.866, 6, 1.620), + }, + }, + { + id: "source-129s", blockIndex: 129, sourceFrameIndex: 7_740, + profiles: { + desktop: metrics(0.996914, 0.003086, 0.015480, 0.229167, 0.564304, 0.910388, 136.762, 12.493, 13.865, 6.407, 9.116, 6, 1.215), + mobile: metrics(0.774390, 0.225610, 0, 0.3125, 0.642610, 0.869473, 144.284, 13.307, 14.633, 6.443, 8.314, 6, 1.292), + }, + }, + { + id: "source-172s", blockIndex: 172, sourceFrameIndex: 10_320, + profiles: { + desktop: metrics(1, 0, 0.021605, 0.260417, 0.497974, 0.893601, 283.246, 6.349, 8.501, 7.251, 8.077, 8, 2.165), + mobile: metrics(0.573171, 0.426829, 0.021277, 0.208333, 0.782856, 0.872178, 298.824, 6.064, 8.977, 7.138, 8.077, 8, 2.129), + }, + }, +]); + +export function selectFlocksStartupWindow({ + requestedId = null, + previousId = null, + randomValue = Math.random(), +} = {}) { + if (requestedId !== null) { + const selected = CSSFLOCKS_STARTUP_WINDOWS.find((window) => window.id === requestedId); + if (!selected) throw new RangeError(`Unknown Flocks startup window: ${requestedId}`); + return selected; + } + if (!Number.isFinite(randomValue) || randomValue < 0 || randomValue >= 1) { + throw new RangeError("Flocks startup random value must be in [0, 1)"); + } + const candidates = CSSFLOCKS_STARTUP_WINDOWS.filter((window) => + CSSFLOCKS_STARTUP_WINDOWS.length === 1 || window.id !== previousId); + return candidates[Math.min(candidates.length - 1, Math.floor(randomValue * candidates.length))]; +} + +function metrics( + visibleBugFraction, + clippedBugFraction, + nearEdgeVisibleFraction, + occupiedCellFraction, + horizontalSpanFraction, + verticalSpanFraction, + minimumVisibleLeaderSeparationPixels, + projectedSizeMedianPixels, + projectedSizeP95Pixels, + stretchP95, + stretchMaximum, + occupiedHueBins, + hueEntropyBits, +) { + return { + visibleBugFraction, + clippedBugFraction, + nearEdgeVisibleFraction, + occupiedCellFraction, + horizontalSpanFraction, + verticalSpanFraction, + minimumVisibleLeaderSeparationPixels, + projectedSizeMedianPixels, + projectedSizeP95Pixels, + stretchP95, + stretchMaximum, + occupiedHueBins, + hueEntropyBits, + }; +} + +function deepFreeze(value) { + if (value && typeof value === "object" && !Object.isFrozen(value)) { + Object.freeze(value); + for (const child of Object.values(value)) deepFreeze(child); + } + return value; +} diff --git a/src/adapters/flocks/test/cssflocks-assets.test.mjs b/src/adapters/flocks/test/cssflocks-assets.test.mjs new file mode 100644 index 0000000..7f6b44c --- /dev/null +++ b/src/adapters/flocks/test/cssflocks-assets.test.mjs @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import { readdir, readFile, stat } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { test } from "node:test"; + +const root = resolve("build/generated/public/cssflocks"); + +test("prepared Flocks manifest binds the source-backed default scene", async () => { + const manifest = await readJson("manifest.json"); + const prepared = await readJson("prepared.json"); + const scene = await readJson("scenes/flocks-default.json"); + assert.equal(manifest.status, "ready"); + assert.equal(manifest.defaultScene, "flocks-default"); + assert.equal(prepared.defaultScene, manifest.defaultScene); + assert.equal(scene.id, manifest.defaultScene); + assert.equal(manifest.source.sha256, "0db819da1d123ad4ae2cf5f53bec278e64b2f65ecabe617a843197446c1813d6"); + assert.equal(scene.metrics.atlasCount, 0); + assert.equal(scene.metrics.unresolvedTextureCount, 0); + assert.equal(prepared.renderer.runtimeGeometryConstruction, false); + assert.equal(prepared.renderer.runtimeDomGrowth, false); +}); + +test("desktop and mobile assets declare exact source and product counts", async () => { + const prepared = await readJson("prepared.json"); + for (const [profileId, expected] of Object.entries({ + desktop: { bugs: 324, leaves: 1_944, model: "flocks" }, + mobile: { bugs: 164, leaves: 984, model: "flocks-mobile" }, + })) { + const profile = prepared.profiles[profileId]; + const catalog = await readJson(`${profileId}/catalog.json`); + assert.equal(profile.presentation.sourceDefaultBugCount, 1_004); + assert.equal(profile.presentation.productBugCount, expected.bugs); + assert.equal(profile.model.retainedBugRootCount, expected.bugs); + assert.equal(profile.model.retainedPolygonLeafCount, expected.leaves); + assert.equal(profile.model.id, expected.model); + assert.equal(catalog.sourceDefaultBugCount, 1_004); + assert.equal(catalog.bugCount, expected.bugs); + assert.equal(catalog.leafCount, expected.leaves); + assert.equal(catalog.sourceFrameCount, 12_960); + assert.equal(catalog.streamFrameCount, 13_440); + assert.equal(catalog.blockCount, 224); + assert.equal(catalog.entries.length, 224); + assert.equal(catalog.runtimeLookaheadBlockCount, 11); + assert.equal(catalog.runtimeMaterializedLookaheadBlockCount, 2); + assert.equal(catalog.startupMaterializedLookaheadBlockCount, 2); + assert.equal(catalog.terminalSeam.strategy, "cubic-hermite-correspondence"); + assert.equal(catalog.terminalSeam.sourceBehaviorDeviation, true); + assert.equal(catalog.terminalSeam.bridgeFrameCount, 480); + assert.equal(catalog.terminalSeam.correspondence.length, expected.bugs); + assert.equal(new Set(catalog.terminalSeam.correspondence).size, expected.bugs); + assert.ok(catalog.entries.slice(0, 216).every((entry, index) => + entry.continuityKind === "exact-source-adjacent" && entry.sourceContinuousFromPrevious === (index > 0))); + assert.ok(catalog.entries.slice(216).every((entry) => + entry.continuityKind === "prepare-only-hermite-terminal-bridge" && entry.sourceContinuousFromPrevious === false)); + assert.ok(profile.playback.preparedBlockEncodedBytes <= (profileId === "desktop" ? 12 : 6) * 1024 * 1024); + assert.ok(catalog.entries.every((entry) => entry.byteLength > 0 && entry.decodedByteLength > entry.byteLength)); + for (const entry of catalog.entries) { + const path = join(root, entry.assetUrl.replace(/^\/cssflocks\//u, "")); + assert.equal((await stat(path)).size, entry.byteLength); + } + } +}); + +test("prepared model packages and catalog exist without atlas assets", async () => { + for (const modelId of ["flocks", "flocks-mobile"]) { + const manifest = await readJson(`model/${modelId}/manifest.json`); + assert.equal(manifest.identity.id, modelId); + } + await stat(join(root, "model/catalog.json")); + const files = await readdir(root, { recursive: true }); + assert.ok(files.every((path) => !/atlas|\.png$|\.webp$/iu.test(path))); +}); + +async function readJson(path) { + return JSON.parse(await readFile(join(root, path), "utf8")); +} diff --git a/src/adapters/flocks/test/cssflocks-block-transport.test.mjs b/src/adapters/flocks/test/cssflocks-block-transport.test.mjs new file mode 100644 index 0000000..cd9db53 --- /dev/null +++ b/src/adapters/flocks/test/cssflocks-block-transport.test.mjs @@ -0,0 +1,90 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + CSSFLOCKS_PRODUCT_PROFILES, + CSSFLOCKS_SOURCE_BANK, + buildFlocksSourceSequence, + selectFlocksProductPrefix, +} from "../src/prepare/cssflocks/sourceModel.mjs"; +import { + CSSFLOCKS_BLOCK_ENCODING, + CSSFLOCKS_PLAYBACK_SCHEMA, + CSSFLOCKS_TRANSPORT_LIMITS, + decodeFlocksPreparedBlock, + decodeFlocksPreparedSourceValues, + encodeFlocksPreparedBlock, +} from "../src/shared/cssflocks/preparedBlockTransport.mjs"; + +test("Flocks source-state block round-trips into prepared transforms and currentColor values", () => { + const source = selectFlocksProductPrefix(buildFlocksSourceSequence({ + bank: Object.freeze({ ...CSSFLOCKS_SOURCE_BANK, warmupFrames: 0, frameCount: 2, blockFrameCount: 2 }), + }), CSSFLOCKS_PRODUCT_PROFILES.mobile); + const bytes = encodeFlocksPreparedBlock({ + frames: source.frames, + bugCount: source.profile.bugCount, + framesPerSecond: CSSFLOCKS_SOURCE_BANK.framesPerSecond, + }); + const descriptor = Object.freeze({ + index: 0, + startFrameIndex: 0, + frameCount: 2, + encoding: CSSFLOCKS_BLOCK_ENCODING, + decodedByteLength: bytes.byteLength, + }); + const catalog = Object.freeze({ + streamId: "mobile", + modelId: "flocks-mobile", + bugCount: 164, + leafCount: 984, + framesPerSecond: 60, + frameMilliseconds: 1_000 / 60, + playbackSchema: CSSFLOCKS_PLAYBACK_SCHEMA, + }); + const block = decodeFlocksPreparedBlock(bytes, descriptor, catalog); + assert.equal(block.playback.transforms.length, 328); + assert.equal(block.playback.colors.length, 328); + assert.match(block.playback.transforms[0], /^matrix3d\(/u); + assert.match(block.playback.colors[0], /^#[0-9a-f]{6}$/u); + assert.equal(block.preparedMatrixExpansionCount, 328); + assert.ok(block.preparedCssStringByteLength > 0); + assert.deepEqual(CSSFLOCKS_TRANSPORT_LIMITS.checkpoint.positionRelative, { + storage: "int16", scale: 1 / 32, minimum: -1_024, maximum: 1_023.96875, + }); + assert.deepEqual(CSSFLOCKS_TRANSPORT_LIMITS.delta.velocity, { + storage: "int16", scale: 1 / 128, minimum: -256, maximum: 255.9921875, + }); + assert.equal(decodeFlocksPreparedSourceValues(bytes, descriptor, catalog).length, 2 * 164 * 7); + + const corruptHeader = bytes.slice(); + corruptHeader[12] ^= 1; + assert.throws(() => decodeFlocksPreparedBlock(corruptHeader, descriptor, catalog), /header drifted/u); + const corruptCheckpoint = bytes.slice(); + corruptCheckpoint[40] ^= 1; + assert.throws(() => decodeFlocksPreparedBlock(corruptCheckpoint, descriptor, catalog), /checksum drifted/u); + const corruptDelta = bytes.slice(); + corruptDelta[40 + catalog.bugCount * 14] ^= 1; + assert.throws(() => decodeFlocksPreparedBlock(corruptDelta, descriptor, catalog), /checksum drifted/u); + assert.throws(() => decodeFlocksPreparedBlock(bytes, { ...descriptor, decodedByteLength: bytes.byteLength + 1 }, catalog), /header drifted/u); +}); + +test("Flocks checkpoint and predictive fields fail closed outside their explicit ranges", () => { + const bug = (position, velocity, hue = 0.5) => ({ position, velocity, hue }); + assert.throws(() => encodeFlocksPreparedBlock({ + frames: [{ bugs: [bug([40_000, 0, 0], [0, 0, 0])] }], + bugCount: 1, + framesPerSecond: 60, + }), /position origin/u); + assert.throws(() => encodeFlocksPreparedBlock({ + frames: [ + { bugs: [bug([0, 0, 0], [0, 0, 0])] }, + { bugs: [bug([0, 0, 0], [300, 0, 0])] }, + ], + bugCount: 1, + framesPerSecond: 60, + }), /velocity delta/u); + assert.throws(() => encodeFlocksPreparedBlock({ + frames: [{ bugs: [bug([0, 0, 0], [0, 0, 0], 1.01)] }], + bugCount: 1, + framesPerSecond: 60, + }), /bounded prepared Flocks/u); +}); diff --git a/src/adapters/flocks/test/cssflocks-deploy.test.mjs b/src/adapters/flocks/test/cssflocks-deploy.test.mjs new file mode 100644 index 0000000..492d8f3 --- /dev/null +++ b/src/adapters/flocks/test/cssflocks-deploy.test.mjs @@ -0,0 +1,78 @@ +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"; + +const repositoryRoot = resolve(import.meta.dirname, "../../../.."); +const generatedRoot = resolve(repositoryRoot, "build/generated/public/cssflocks"); +const deployedAssetsRoot = resolve(repositoryRoot, "dist/site/cssflocks"); +const deployedRouteRoot = resolve(repositoryRoot, "dist/site/flocks"); + +test("deploy build contains the exact generated cssflocks tree and only hashed route bundles", async () => { + const [generated, deployed, routeFiles] = await Promise.all([ + digestTree(generatedRoot), + digestTree(deployedAssetsRoot), + listFiles(deployedRouteRoot), + ]); + assert.equal(generated.size, 458); + assert.deepEqual(deployed, generated); + 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 >= 3); + 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 Flocks route file: ${bundles.join(", ")}`); + assert.equal(routeFiles.some((path) => path.startsWith("cssflocks/") || path.startsWith("cssface/") || path.startsWith("cssgraphics/")), false); + const html = await readFile(resolve(deployedRouteRoot, "index.html"), "utf8"); + assert.match(html, /(?:src|href)="\/flocks\/assets\//u); +}); + +test("Flocks deploy and cache policy are integrated without adding a landing project", async () => { + const [vite, netlify, packageJson, landingManifest, landingHtml] = await Promise.all([ + readFile(resolve(repositoryRoot, "src/adapters/flocks/vite.config.mjs"), "utf8"), + readFile(resolve(repositoryRoot, "netlify.toml"), "utf8"), + readFile(resolve(repositoryRoot, "package.json"), "utf8"), + readFile(resolve(repositoryRoot, "site/public/projects.json"), "utf8"), + readFile(resolve(repositoryRoot, "site/index.html"), "utf8"), + ]); + assert.match(vite, /publicDir:\s*deployBuild \? false : generatedPublicDir/u); + assert.match(vite, /rm\(deployAssets,[^;]+force: true/u); + assert.match(vite, /cp\(resolve\(generatedPublicDir, "cssflocks"\), deployAssets/u); + assert.match(vite, /deployBuild \? "dist\/site\/flocks" : "dist\/flocks"/u); + assert.match(netlify, /for = "\/cssflocks\/\*\/blocks\/\*"[\s\S]*?max-age=31536000, immutable/u); + assert.match(netlify, /for = "\/cssflocks\/model\/\*"[\s\S]*?max-age=31536000, immutable/u); + for (const path of ["manifest.json", "prepared.json"]) { + assert.match(netlify, new RegExp(`for = "\\/cssflocks\\/${path.replace(".", "\\.")}"[\\s\\S]*?Cache-Control = "no-cache"`, "u")); + } + assert.match(netlify, /for = "\/cssflocks\/\*\/catalog\.json"[\s\S]*?Cache-Control = "no-cache"/u); + assert.match(netlify, /for = "\/cssflocks\/model\/catalog\.json"[\s\S]*?Cache-Control = "no-cache"/u); + assert.match(netlify, /for = "\/cssflocks\/scenes\/\*"[\s\S]*?Cache-Control = "no-cache"/u); + assert.match(packageJson, /"build:flocks:deploy": "CSSFLOCKS_DEPLOY_BUILD=1 pnpm build:flocks"/u); + assert.match(packageJson, /pnpm prepare:flocks[\s\S]*CSSFLOCKS_DEPLOY_BUILD=1 pnpm build:flocks/u); + assert.match(packageJson, /"test:flocks:deploy"/u); + assert.equal(JSON.parse(landingManifest).projects.some((project) => project.id === "flocks"), false); + assert.doesNotMatch(landingHtml, /href="\/flocks\/"/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/flocks/test/cssflocks-frame-sequence.test.mjs b/src/adapters/flocks/test/cssflocks-frame-sequence.test.mjs new file mode 100644 index 0000000..952ea99 --- /dev/null +++ b/src/adapters/flocks/test/cssflocks-frame-sequence.test.mjs @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { test } from "node:test"; +import { + CSSFLOCKS_FRAME_SEQUENCE_COUNT, + CSSFLOCKS_FRAME_SEQUENCE_PLAN, + flattenFlocksFrameSequencePlan, +} from "../tools/frameSequencePlan.mjs"; + +test("frame-sequence plan covers every declared continuity boundary with stable ordinals", () => { + assert.equal(CSSFLOCKS_FRAME_SEQUENCE_COUNT, 45); + assert.deepEqual(CSSFLOCKS_FRAME_SEQUENCE_PLAN.map((segment) => segment.id), [ + "startup", + "ordinary-block-handoff", + "color-wrap", + "extreme-stretch-orientation", + "terminal-seam", + ]); + const frames = flattenFlocksFrameSequencePlan(); + assert.deepEqual(frames.map((frame) => frame.ordinal), Array.from({ length: 45 }, (_, index) => index)); + assert.deepEqual(frames.filter((frame) => frame.segmentId === "ordinary-block-handoff").map((frame) => frame.streamFrameIndex), + [6_896, 6_897, 6_898, 6_899, 6_900, 6_901, 6_902, 6_903, 6_904]); + assert.deepEqual(frames.filter((frame) => frame.segmentId === "terminal-seam").map((frame) => frame.streamFrameIndex), + [13_436, 13_437, 13_438, 13_439, 0, 1, 2, 3, 4]); + const colorWrap = CSSFLOCKS_FRAME_SEQUENCE_PLAN.find((segment) => segment.id === "color-wrap"); + assert.equal((colorWrap.focusFrameIndex + colorWrap.focusRootIndex) % 5, 0); +}); + +test("all public Flocks visual-proof commands execute the qualified sequence rather than placeholders", async () => { + for (const name of [ + "capture-reference-frame.mjs", + "capture-browser-frame.mjs", + "compare-reference-frame.mjs", + "frameSequenceArtifacts.mjs", + "capture-reference-frames.mjs", + "capture-browser-frames.mjs", + "compare-frame-sequence.mjs", + ]) { + const source = await readFile(resolve(import.meta.dirname, "../tools", name), "utf8"); + assert.doesNotMatch(source, /failPendingFlocksReference|pending a fixed scripted segment|process\.exitCode\s*=\s*2/u); + assert.doesNotMatch(source, /\/Users\/|\/home\/|\.codex\/skills/u); + } +}); diff --git a/src/adapters/flocks/test/cssflocks-geometry-oracle.test.mjs b/src/adapters/flocks/test/cssflocks-geometry-oracle.test.mjs new file mode 100644 index 0000000..f8736f2 --- /dev/null +++ b/src/adapters/flocks/test/cssflocks-geometry-oracle.test.mjs @@ -0,0 +1,112 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { test } from "node:test"; +import { + CSSFLOCKS_BUG_VERTICES, + CSSFLOCKS_FACE_INDICES, + CSSFLOCKS_FACE_LIGHT_FACTORS, +} from "../src/prepare/cssflocks/modelBuilder.mjs"; +import { + CSSFLOCKS_SOURCE_BANK, + buildFlocksSourceBlocks, +} from "../src/prepare/cssflocks/sourceModel.mjs"; +import { buildFlocksBugMatrix } from "../src/shared/cssflocks/bugTransform.mjs"; + +const repositoryRoot = resolve(import.meta.dirname, "../../../.."); +const oraclePath = resolve(repositoryRoot, "bench/results/cssflocks/geometry/native-geometry.json"); + +test("actual GLU 3-slice 2-stack feedback matches the prepared six-face topology and CCW winding", async () => { + const oracle = JSON.parse(await readFile(oraclePath, "utf8")); + assert.equal(oracle.triangleCount, 6); + assert.equal(oracle.frontFace, 0x0901); + assert.equal(oracle.cullFaceMode, 0x0405); + assert.equal(oracle.cullEnabled, true); + const preparedTriangles = CSSFLOCKS_FACE_INDICES.map((face) => face.map((index) => CSSFLOCKS_BUG_VERTICES[index])); + const unmatched = [...preparedTriangles]; + for (const nativeTriangle of oracle.triangles) { + const matchIndex = unmatched.findIndex((prepared) => cyclicTriangleDelta(nativeTriangle, prepared) < 0.001); + assert.notEqual(matchIndex, -1, `native GLU triangle has no same-winding prepared face: ${JSON.stringify(nativeTriangle)}`); + unmatched.splice(matchIndex, 1); + } + assert.equal(unmatched.length, 0); + for (const triangle of preparedTriangles) { + const [a, b, c] = triangle; + const normal = cross(subtract(b, a), subtract(c, a)); + const centroid = a.map((value, index) => (value + b[index] + c[index]) / 3); + assert.ok(dot(normal, centroid) > 0, "prepared face winding must point outward under GL_CCW"); + } +}); + +test("source projection and prepared transform reproduce native projected vertices", async () => { + const oracle = JSON.parse(await readFile(oraclePath, "utf8")); + const expectedProjection = perspectiveMatrix(50, 1280 / 720, 0.1, 2000); + oracle.projectionMatrix.forEach((value, index) => assert.ok(Math.abs(value - expectedProjection[index]) < 0.00001)); + const sourceFirstBlock = buildFlocksSourceBlocks({ bank: CSSFLOCKS_SOURCE_BANK }).next().value; + const bug = sourceFirstBlock.frames[0].bugs[0]; + oracle.projectedBug.position.forEach((value, index) => assert.ok(Math.abs(value - bug.position[index]) < 0.02)); + oracle.projectedBug.velocity.forEach((value, index) => assert.ok(Math.abs(value - bug.velocity[index]) < 0.002)); + const bugMatrix = buildFlocksBugMatrix(bug.position, bug.velocity).matrix; + const camera = translationMatrix(0, 0, -568); + const modelView = multiplyColumnMajor(camera, bugMatrix); + const projected = CSSFLOCKS_FACE_INDICES.map((face) => face.map((index) => + project(CSSFLOCKS_BUG_VERTICES[index], modelView, expectedProjection, 1280, 720))); + for (const nativeTriangle of oracle.projectedBug.triangles) { + const best = Math.min(...projected.map((triangle) => cyclicTriangleDelta(nativeTriangle, triangle))); + assert.ok(best <= 0.25, `projected GLU triangle drifted by ${best} pixels`); + } +}); + +test("negative local Z is the source velocity axis and flat lighting is an explicit bounded deviation", async () => { + for (const velocity of [[1, 0, 0], [0, 1, 0], [0, 0, 1], [1, 2, 3], [-1, -2, 4]]) { + const transform = buildFlocksBugMatrix([0, 0, 0], velocity); + const negativeLocalZ = [-transform.matrix[8] / transform.stretch, -transform.matrix[9] / transform.stretch, -transform.matrix[10] / transform.stretch]; + transform.direction.forEach((value, index) => assert.ok(Math.abs(value - negativeLocalZ[index]) < 0.000002)); + } + const oracle = JSON.parse(await readFile(oraclePath, "utf8")); + assert.deepEqual(oracle.light, { + ambient: [0.25, 0.25, 0.25, 0], + diffuse: [1, 1, 1, 0], + specular: [1, 1, 1, 0], + position: [500, 500, 500, 0], + }); + assert.ok(CSSFLOCKS_FACE_LIGHT_FACTORS.every((factor) => factor >= 0.75 && factor <= 1)); +}); + +function cyclicTriangleDelta(left, right) { + return Math.min(...[0, 1, 2].map((offset) => Math.max(...left.flatMap((vertex, vertexIndex) => + vertex.map((value, axis) => Math.abs(value - right[(vertexIndex + offset) % 3][axis])))))); +} + +function perspectiveMatrix(fieldOfViewDegrees, aspect, near, far) { + const f = 1 / Math.tan(fieldOfViewDegrees * Math.PI / 360); + return [f / aspect, 0, 0, 0, 0, f, 0, 0, 0, 0, (far + near) / (near - far), -1, 0, 0, 2 * far * near / (near - far), 0]; +} + +function translationMatrix(x, y, z) { + return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, x, y, z, 1]; +} + +function multiplyColumnMajor(left, right) { + const output = new Array(16).fill(0); + for (let column = 0; column < 4; column += 1) { + for (let row = 0; row < 4; row += 1) { + for (let index = 0; index < 4; index += 1) output[column * 4 + row] += left[index * 4 + row] * right[column * 4 + index]; + } + } + return output; +} + +function project(vertex, modelView, projection, width, height) { + const eye = apply(modelView, [...vertex, 1]); + const clip = apply(projection, eye); + return [(clip[0] / clip[3] + 1) * width / 2, (clip[1] / clip[3] + 1) * height / 2, (clip[2] / clip[3] + 1) / 2]; +} + +function apply(matrix, vector) { + return [0, 1, 2, 3].map((row) => vector.reduce((sum, value, column) => sum + matrix[column * 4 + row] * value, 0)); +} + +function subtract(left, right) { return left.map((value, index) => value - right[index]); } +function cross(left, right) { return [left[1] * right[2] - left[2] * right[1], left[2] * right[0] - left[0] * right[2], left[0] * right[1] - left[1] * right[0]]; } +function dot(left, right) { return left.reduce((sum, value, index) => sum + value * right[index], 0); } diff --git a/src/adapters/flocks/test/cssflocks-model.test.mjs b/src/adapters/flocks/test/cssflocks-model.test.mjs new file mode 100644 index 0000000..34250e3 --- /dev/null +++ b/src/adapters/flocks/test/cssflocks-model.test.mjs @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + CSSFLOCKS_FACE_INDICES, + CSSFLOCKS_FACE_LIGHT_FACTORS, + buildFlocksPreparedModel, +} from "../src/prepare/cssflocks/modelBuilder.mjs"; +import { + CSSFLOCKS_PRODUCT_PROFILES, + CSSFLOCKS_SOURCE_BANK, + buildFlocksSourceSequence, + selectFlocksProductPrefix, +} from "../src/prepare/cssflocks/sourceModel.mjs"; + +const bank = Object.freeze({ + ...CSSFLOCKS_SOURCE_BANK, + warmupFrames: 0, + frameCount: 1, + blockFrameCount: 1, +}); + +test("Flocks geometry-one prepares one triangular bipyramid per product bug", () => { + const source = selectFlocksProductPrefix( + buildFlocksSourceSequence({ bank }), + CSSFLOCKS_PRODUCT_PROFILES.mobile, + ); + const prepared = buildFlocksPreparedModel({ source }); + assert.equal(CSSFLOCKS_FACE_INDICES.length, 6); + assert.equal(prepared.model.render.shapes.length, 164); + assert.equal(prepared.model.render.leaves.length, 984); + assert.equal(prepared.model.topology.vertices.length, 820); + assert.equal(prepared.model.topology.polygons.length, 984); + assert.ok(prepared.model.render.leaves.every((leaf) => + leaf.strategy === "solid-triangle" && leaf.atlas === null && leaf.fallback === null)); + assert.equal(new Set(prepared.model.render.leaves.map((leaf) => leaf.shapeId)).size, 164); +}); + +test("prepared Flocks model records source provenance and zero-runtime geometry", () => { + const source = selectFlocksProductPrefix( + buildFlocksSourceSequence({ bank }), + CSSFLOCKS_PRODUCT_PROFILES.desktop, + ); + const prepared = buildFlocksPreparedModel({ source }); + assert.equal(prepared.model.identity.id, "flocks"); + assert.equal(prepared.model.provenance.sources[0].license, "GPL-2.0-or-later"); + assert.equal(prepared.metrics.sourceDefaultBugCount, 1_004); + assert.equal(prepared.metrics.retainedBugRootCount, 324); + assert.equal(prepared.metrics.retainedPolygonLeafCount, 1_944); + assert.equal(prepared.metrics.runtimeGeometryConstructionCount, 0); + assert.equal(prepared.metrics.runtimeAtlasRasterizationCount, 0); + assert.equal(prepared.metrics.runtimeDomGrowth, false); + assert.deepEqual(prepared.metrics.flatLightingFactors, CSSFLOCKS_FACE_LIGHT_FACTORS); + assert.ok(CSSFLOCKS_FACE_LIGHT_FACTORS.every((factor) => factor >= 0.75 && factor <= 1)); +}); diff --git a/src/adapters/flocks/test/cssflocks-native-state-oracle.test.mjs b/src/adapters/flocks/test/cssflocks-native-state-oracle.test.mjs new file mode 100644 index 0000000..c1b06e4 --- /dev/null +++ b/src/adapters/flocks/test/cssflocks-native-state-oracle.test.mjs @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { test } from "node:test"; +import { + CSSFLOCKS_SOURCE_BANK, + buildFlocksSourceOracleSequence, +} from "../src/prepare/cssflocks/sourceModel.mjs"; +import { + compareNativeStateRows, + parseNativeStateCsv, + requireLockedBytes, + requirePassingNativeStateComparison, +} from "../tools/nativeStateOracle.mjs"; + +const adapterRoot = resolve(import.meta.dirname, ".."); + +test("native state harness compiles the pinned upstream source instead of copying its simulation", async () => { + const source = await readFile(resolve(adapterRoot, "tools/native-state-oracle.cpp"), "utf8"); + assert.match(source, /#include CSSFLOCKS_SOURCE_PATH/u); + assert.match(source, /rsRandGen\(\)\.seed\(seed\)/u); + assert.match(source, /kFrameCount = 600/u); + assert.match(source, /lBugs\[index\]\.update\(lBugs\)/u); + assert.match(source, /fBugs\[followerIndex\]\.update\(lBugs\)/u); + assert.doesNotMatch(source, /class\s+Copied|simulateFollower|simulateLeader/u); +}); + +test("source lock binds the rsMath and Rgbhsl bytes used by the native harness", async () => { + const lock = JSON.parse(await readFile(resolve(adapterRoot, "notes/references/source-lock.json"), "utf8")); + assert.match(lock.dependencies.rslibs.revision, /^[0-9a-f]{40}$/u); + assert.deepEqual(lock.dependencies.rslibs.files.map((entry) => entry.path), [ + "libs/rsMath/rsMath.h", + "libs/Rgbhsl/Rgbhsl.h", + "libs/Rgbhsl/Rgbhsl.cpp", + ]); + for (const entry of lock.dependencies.rslibs.files) assert.match(entry.sha256, /^[0-9a-f]{64}$/u); +}); + +test("native CSV parsing and comparison fail closed on corruption or state mismatch", () => { + assert.throws(() => parseNativeStateCsv("frame,index\n0,0\n"), /header drifted/u); + assert.throws(() => requireLockedBytes(Buffer.from("wrong"), "0".repeat(64), "fixture"), /sha256 mismatch/u); + + const bank = Object.freeze({ ...CSSFLOCKS_SOURCE_BANK, warmupFrames: 0, frameCount: 1, blockFrameCount: 1 }); + const sourceOracle = buildFlocksSourceOracleSequence({ bank }); + const bug = sourceOracle.frames[0].bugs[0]; + const header = "frame,index,type,leader,hue,x,y,z,xSpeed,ySpeed,zSpeed,directionX,directionY,directionZ,stretch,drawn,translateX,translateY,translateZ,rotateY,rotateX,scaleZ,translationCount,rotationCount,scaleCount,r,g,b"; + const mismatchedRow = [0, 0, 0, -1, bug.hue, bug.position[0] + 1, bug.position[1], bug.position[2], ...bug.velocity, ...bug.direction, bug.stretch, 1, ...bug.position, -45, 35.264389, bug.stretch, 1, 2, 1, 0, 1, 0.5].join(","); + const rows = parseNativeStateCsv(`${header}\n${mismatchedRow}\n`); + const comparison = compareNativeStateRows(rows, sourceOracle); + assert.equal(comparison.status, "failed"); + assert.throws(() => requirePassingNativeStateComparison(comparison), /oracle failed/u); +}); diff --git a/src/adapters/flocks/test/cssflocks-playback.test.mjs b/src/adapters/flocks/test/cssflocks-playback.test.mjs new file mode 100644 index 0000000..5451c7d --- /dev/null +++ b/src/adapters/flocks/test/cssflocks-playback.test.mjs @@ -0,0 +1,321 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + CSSFLOCKS_COLOR_PUBLICATION_INTERVAL_FRAMES, + createFlocksPreparedPlayer, + resolveFlocksPerspective, +} from "../src/cssflocks/preparedPlayback.mjs"; + +const catalog = Object.freeze({ + bugCount: 2, + blockCount: 3, + blockFrameCount: 2, + streamFrameCount: 6, + runtimeLookaheadBlockCount: 2, + runtimeMaterializedLookaheadBlockCount: 2, + startupMaterializedLookaheadBlockCount: 2, + terminalSeam: Object.freeze({ correspondence: Object.freeze([1, 0]) }), +}); + +test("prepared Flocks player writes only stable root transforms and currentColor", () => { + const shapes = [{ style: {} }, { style: {} }]; + let stableChecks = 0; + const player = createFlocksPreparedPlayer({ + shapeElements: shapes, + catalog, + initialBlock: block(0), + initialLookaheadBlocks: [block(1), block(2)], + loadBlock: async (index) => block(index), + assertStableDom() { stableChecks += 1; }, + requestFrame: () => 1, + cancelFrame: () => undefined, + readNow: () => 0, + }); + assert.equal(shapes[0].style.transform, "matrix3d(0-0)"); + assert.equal(shapes[1].style.color, "#000001"); + const initial = player.stats(); + assert.equal(initial.shapeTransformWrites, 2); + assert.equal(initial.rootColorWrites, 2); + assert.equal(initial.runtimeClassSwapCount, 0); + player.seekFrame(1); + const advanced = player.stats(); + assert.equal(shapes[0].style.transform, "matrix3d(0-1)"); + assert.equal(shapes[0].style.color, "#000100"); + assert.equal(advanced.shapeTransformWrites, 4); + assert.equal(advanced.rootColorWrites, 4); + assert.ok(stableChecks >= 2); + player.destroy(); +}); + +test("Flocks perspective reproduces a 50-degree vertical field of view", () => { + assert.equal(resolveFlocksPerspective(900), 965.0281); +}); + +test("continuous playback retains every transform state while bounding flat-color repaint cadence", async () => { + const frameCount = 8; + const oneBugCatalog = Object.freeze({ + ...catalog, + bugCount: 1, + blockCount: 1, + blockFrameCount: frameCount, + streamFrameCount: frameCount, + runtimeLookaheadBlockCount: 1, + runtimeMaterializedLookaheadBlockCount: 1, + startupMaterializedLookaheadBlockCount: 1, + terminalSeam: Object.freeze({ correspondence: Object.freeze([0]) }), + }); + const oneBugBlock = Object.freeze({ + schema: "cssflocks-prepared-stream-block@1", + index: 0, + startFrameIndex: 0, + playback: Object.freeze({ + bugCount: 1, + frameCount, + frameMilliseconds: 1_000 / 60, + transforms: Object.freeze(Array.from({ length: frameCount }, (_, index) => `matrix3d(${index})`)), + colors: Object.freeze(Array.from({ length: frameCount }, (_, index) => `#00000${index}`)), + }), + }); + const shape = { style: {} }; + let callback = null; + let now = 0; + const player = createFlocksPreparedPlayer({ + shapeElements: [shape], + catalog: oneBugCatalog, + initialBlock: oneBugBlock, + loadBlock: async () => oneBugBlock, + requestFrame(next) { callback = next; return 1; }, + cancelFrame: () => undefined, + readNow: () => now, + }); + player.resume(); + const observedTransforms = []; + const observedColors = []; + for (let frame = 1; frame < frameCount; frame += 1) { + now += 17; + const next = callback; + callback = null; + await next(now); + observedTransforms.push(shape.style.transform); + observedColors.push(shape.style.color); + } + assert.deepEqual(observedTransforms, Array.from({ length: frameCount - 1 }, (_, index) => `matrix3d(${index + 1})`)); + assert.deepEqual(observedColors, ["#000000", "#000000", "#000000", "#000000", "#000005", "#000005", "#000005"]); + assert.equal(player.stats().colorPublicationIntervalFrames, CSSFLOCKS_COLOR_PUBLICATION_INTERVAL_FRAMES); + assert.equal(player.stats().colorPublicationRateHz, 12); + player.seekFrame(7); + assert.equal(shape.style.color, "#000007"); + player.destroy(); +}); + +test("flat-color repaint phases are evenly staggered by stable retained-root index", async () => { + const bugCount = CSSFLOCKS_COLOR_PUBLICATION_INTERVAL_FRAMES; + const frameCount = 6; + const staggeredCatalog = Object.freeze({ + ...catalog, + bugCount, + blockCount: 1, + blockFrameCount: frameCount, + streamFrameCount: frameCount, + runtimeLookaheadBlockCount: 1, + runtimeMaterializedLookaheadBlockCount: 1, + startupMaterializedLookaheadBlockCount: 1, + terminalSeam: Object.freeze({ correspondence: Object.freeze(Array.from({ length: bugCount }, (_, index) => index)) }), + }); + const staggeredBlock = Object.freeze({ + schema: "cssflocks-prepared-stream-block@1", + index: 0, + startFrameIndex: 0, + playback: Object.freeze({ + bugCount, + frameCount, + frameMilliseconds: 1_000 / 60, + transforms: Object.freeze(Array.from({ length: frameCount * bugCount }, (_, index) => `matrix3d(${index})`)), + colors: Object.freeze(Array.from({ length: frameCount * bugCount }, (_, index) => `#${index.toString(16).padStart(6, "0")}`)), + }), + }); + const shapes = Array.from({ length: bugCount }, () => ({ style: {} })); + let callback = null; + let now = 0; + const player = createFlocksPreparedPlayer({ + shapeElements: shapes, + catalog: staggeredCatalog, + initialBlock: staggeredBlock, + loadBlock: async () => staggeredBlock, + requestFrame(next) { callback = next; return 1; }, + cancelFrame: () => undefined, + readNow: () => now, + }); + player.resume(); + for (let frameIndex = 1; frameIndex < frameCount; frameIndex += 1) { + const before = shapes.map((shape) => shape.style.color); + now += 17; + const next = callback; + callback = null; + await next(now); + const changed = shapes.flatMap((shape, index) => shape.style.color === before[index] ? [] : [index]); + assert.deepEqual(changed, [(bugCount - frameIndex % bugCount) % bugCount]); + } + assert.equal(player.stats().colorPublicationPhaseCount, bugCount); + assert.equal(player.stats().colorPublicationPolicy, "source-frame-plus-retained-root-index-round-robin"); + player.destroy(); +}); + +test("player exposes the Cyclone-standard verified horizon while materializing only two successors", () => { + const horizonCatalog = Object.freeze({ + bugCount: 2, + blockCount: 13, + blockFrameCount: 1, + streamFrameCount: 13, + runtimeLookaheadBlockCount: 11, + runtimeMaterializedLookaheadBlockCount: 2, + startupMaterializedLookaheadBlockCount: 2, + terminalSeam: Object.freeze({ correspondence: Object.freeze([0, 1]) }), + }); + const windows = []; + const player = createFlocksPreparedPlayer({ + shapeElements: [{ style: {} }, { style: {} }], + catalog: horizonCatalog, + initialBlock: singleFrameBlock(0), + initialLookaheadBlocks: [singleFrameBlock(1), singleFrameBlock(2)], + loadBlock: async (index) => singleFrameBlock(index), + onBlockWindow(indices) { windows.push(indices); }, + }); + assert.deepEqual(player.stats().pendingBlockIndices, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]); + assert.equal(player.stats().pendingBlockReadyCount, 2); + assert.deepEqual(windows, [[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]]); + player.destroy(); +}); + +test("terminal correspondence composes retained-root source identities without DOM churn", async () => { + const shapes = [{ style: {} }, { style: {} }]; + let queuedFrame = null; + let now = 0; + const player = createFlocksPreparedPlayer({ + shapeElements: shapes, + catalog, + initialBlock: block(0), + initialLookaheadBlocks: [block(1), block(2)], + loadBlock: async (index) => block(index), + requestFrame(callback) { queuedFrame = callback; return 1; }, + cancelFrame: () => undefined, + readNow: () => now, + }); + player.resume(); + for (let step = 0; step < 6; step += 1) { + now += 20; + const callback = queuedFrame; + queuedFrame = null; + await callback(now); + } + assert.equal(player.stats().terminalWrapCount, 1); + assert.equal(player.stats().terminalPermutationCompositionCount, 1); + assert.equal(shapes[0].style.transform, "matrix3d(0-0b)"); + assert.equal(shapes[1].style.transform, "matrix3d(0-0)"); + player.destroy(); +}); + +test("paused absolute seek and stepping cross the terminal seam on stable retained roots", async () => { + const shapes = [{ style: {} }, { style: {} }]; + const identities = [...shapes]; + const player = createFlocksPreparedPlayer({ + shapeElements: shapes, + catalog, + initialBlock: block(0), + initialLookaheadBlocks: [block(1), block(2)], + loadBlock: async (index) => block(index), + }); + await player.seekStreamFrame(4); + assert.equal(player.stats().streamFrameIndex, 4); + assert.equal(shapes[0].style.transform, "matrix3d(2-0)"); + await player.stepFrame(); + assert.equal(player.stats().streamFrameIndex, 5); + await player.stepFrame(); + assert.equal(player.stats().streamFrameIndex, 0); + assert.equal(player.stats().terminalWrapCount, 1); + assert.equal(player.stats().terminalPermutationCompositionCount, 1); + assert.equal(shapes[0].style.transform, "matrix3d(0-0b)"); + assert.deepEqual(shapes, identities); + assert.equal(player.stats().debugAbsoluteSeekCount, 1); + assert.equal(player.stats().debugStepCount, 2); + player.destroy(); +}); + +test("superseded prefetch rejection cannot poison a later absolute sequence seek", async () => { + const staleOne = deferred(); + const staleTwo = deferred(); + const calls = new Map(); + const errors = []; + const seekCatalog = Object.freeze({ + bugCount: 2, + blockCount: 4, + blockFrameCount: 1, + streamFrameCount: 4, + runtimeLookaheadBlockCount: 3, + runtimeMaterializedLookaheadBlockCount: 2, + startupMaterializedLookaheadBlockCount: 2, + terminalSeam: Object.freeze({ correspondence: Object.freeze([0, 1]) }), + }); + const blocks = Array.from({ length: 4 }, (_, index) => singleFrameBlock(index)); + const player = createFlocksPreparedPlayer({ + shapeElements: [{ style: {} }, { style: {} }], + catalog: seekCatalog, + initialBlock: blocks[0], + loadBlock: async (index) => { + const count = (calls.get(index) ?? 0) + 1; + calls.set(index, count); + if (index === 1 && count === 1) return staleOne.promise; + if (index === 2 && count === 1) return staleTwo.promise; + return blocks[index]; + }, + onError(error) { errors.push(error); }, + }); + await player.seekStreamFrame(3); + staleOne.reject(new Error("superseded one")); + staleTwo.reject(new Error("superseded two")); + await Promise.resolve(); + await Promise.resolve(); + assert.deepEqual(errors, []); + assert.equal(player.stats().streamFrameIndex, 3); + player.destroy(); +}); + +function block(index) { + return Object.freeze({ + schema: "cssflocks-prepared-stream-block@1", + index, + startFrameIndex: index * 2, + playback: Object.freeze({ + bugCount: 2, + frameCount: 2, + frameMilliseconds: 1_000 / 60, + transforms: Object.freeze([`matrix3d(${index}-0)`, `matrix3d(${index}-0b)`, `matrix3d(${index}-1)`, `matrix3d(${index}-1b)`]), + colors: Object.freeze(["#000000", "#000001", "#000100", "#000101"]), + }), + }); +} + +function singleFrameBlock(index) { + return Object.freeze({ + schema: "cssflocks-prepared-stream-block@1", + index, + startFrameIndex: index, + playback: Object.freeze({ + bugCount: 2, + frameCount: 1, + frameMilliseconds: 1_000 / 60, + transforms: Object.freeze([`matrix3d(${index}-0)`, `matrix3d(${index}-1)`]), + colors: Object.freeze(["#000000", "#000001"]), + }), + }); +} + +function deferred() { + let resolve; + let reject; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} diff --git a/src/adapters/flocks/test/cssflocks-route-state.test.mjs b/src/adapters/flocks/test/cssflocks-route-state.test.mjs new file mode 100644 index 0000000..e4a5468 --- /dev/null +++ b/src/adapters/flocks/test/cssflocks-route-state.test.mjs @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { resolveFlocksRoute } from "../src/cssflocks/routeState.mjs"; +import { selectFlocksPreparedProfile } from "../src/cssflocks/profileSelection.mjs"; + +const manifest = Object.freeze({ + status: "ready", + defaultScene: "flocks-default", + scenes: Object.freeze([Object.freeze({ id: "flocks-default", url: "/cssflocks/scenes/flocks-default.json" })]), +}); + +test("no-param Flocks route resolves only through the manifest default", () => { + assert.deepEqual(resolveFlocksRoute({ search: "", manifest }), { + sceneId: "flocks-default", startupWindowId: null, mode: "manifest-default", + }); + assert.deepEqual(resolveFlocksRoute({ search: "?window=source-114s", manifest }), { + sceneId: "flocks-default", startupWindowId: "source-114s", mode: "explicit-startup-window", + }); + assert.throws(() => resolveFlocksRoute({ search: "?scene=fake", manifest }), /only one startup window/u); +}); + +test("Flocks startup profile selection is bounded and startup-only", () => { + assert.equal(selectFlocksPreparedProfile({ width: 1_200, coarsePointer: false }), "desktop"); + assert.equal(selectFlocksPreparedProfile({ width: 390, coarsePointer: false }), "mobile"); + assert.equal(selectFlocksPreparedProfile({ width: 1_200, coarsePointer: true }), "mobile"); +}); diff --git a/src/adapters/flocks/test/cssflocks-shell.test.mjs b/src/adapters/flocks/test/cssflocks-shell.test.mjs new file mode 100644 index 0000000..b141ad6 --- /dev/null +++ b/src/adapters/flocks/test/cssflocks-shell.test.mjs @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { test } from "node:test"; + +const adapterRoot = new URL("../", import.meta.url); + +test("Flocks uses the production css.graphics wordmark and repository action shell", async () => { + const html = await readFile(new URL("index.html", adapterRoot), "utf8"); + assert.match(html, //u); + assert.match(html, /

/u); + assert.match(html, /class="site-wordmark-svg"/u); + assert.match(html, /class="site-wordmark-css">css\.<\/tspan>graphics<\/tspan>\/flocks/u); + assert.match(html, /