import { Buffer } from "node:buffer"; import { createHash } from "node:crypto"; import { readFile, writeFile } from "node:fs/promises"; import { argv, stdout } from "node:process"; import { pathToFileURL, URL } from "node:url"; import { parseArgs } from "node:util"; const sourceUrl = new URL("../design/agent/obsidian-runtime.json", import.meta.url); const outputUrl = new URL("../public/agent/obsidian-water-v1.riv", import.meta.url); const manifestUrl = new URL("../src/shared/ai-elements/persona-resource-manifest.json", import.meta.url); const designUrl = new URL("../design/agent/obsidian-offline.json", import.meta.url); const defaultDesign = JSON.parse(await readFile(designUrl, "utf8")); const materialUrl = new URL("../design/agent/obsidian-material.json", import.meta.url); const defaultMaterial = JSON.parse(await readFile(materialUrl, "utf8")); function uint(value) { if (!Number.isSafeInteger(value) || value < 0) throw new Error(`Invalid Rive uint: ${value}`); const bytes = []; do { const next = value % 128; value = Math.floor(value / 128); bytes.push(next | (value > 0 ? 128 : 0)); } while (value > 0); return Buffer.from(bytes); } export function encodeSource(source, objects = source.objects) { const parts = [Buffer.from(source.headerHex, "hex")]; for (const object of objects) { parts.push(uint(object.type)); for (const [key, value] of object.values) { parts.push(uint(key)); const type = source.properties[key]?.type; if (type === "UInt") parts.push(uint(value)); else if (type === "Float" || type === "Color") { const bytes = Buffer.alloc(4); if (type === "Float") bytes.writeFloatLE(value); else bytes.writeUInt32LE(value); parts.push(bytes); } else if (type === "String") { const bytes = typeof value === "string" ? Buffer.from(value) : Buffer.from(value.hex, "hex"); parts.push(uint(bytes.length), bytes); } else throw new Error(`Unknown Rive property: ${key}`); } parts.push(uint(0)); } return Buffer.concat(parts); } const clone = (value) => JSON.parse(JSON.stringify(value)); const valueOf = (object, key, fallback) => object.values.find(([id]) => id === key)?.[1] ?? fallback; function setValue(object, key, value) { const property = object.values.find(([id]) => id === key); if (property) property[1] = value; else object.values.push([key, value]); } // Rive type/property keys are recorded alongside their names in the source JSON. function transition(stateId, asleep, duration) { return [ { type: 65, values: [[151, stateId], [158, duration]] }, { type: 71, values: [[155, 1], [156, asleep ? 0 : 1]] } ]; } function restoreOriginal(source) { const original = encodeSource(source); if (createHash("sha256").update(original).digest("hex") !== source.sourceSha256) { throw new Error("The decoded baseline no longer reproduces the original Obsidian file."); } return original; } function materialObjects(source, material) { const colors = new Map(); for (const { from, to } of material.colors) { if (!/^#[\da-f]{6}$/i.test(from) || !/^#[\da-f]{6}$/i.test(to)) { throw new Error("Material colors must use six-digit hex RGB."); } colors.set(parseInt(from.slice(1), 16), parseInt(to.slice(1), 16)); } const objects = clone(source.objects); for (const object of objects) { for (const property of object.values) { if (source.properties[property[0]]?.type !== "Color") continue; const color = colors.get(property[1] & 0xffffff); // Keep alpha, gradient positions, feathering and all geometry intact. if (color !== undefined) property[1] = ((property[1] & 0xff000000) | color) >>> 0; } } const artboard = objects.findIndex((object) => object.type === 1); const components = objects.slice(artboard).filter((object) => object.type !== 447); // The backing color is independent of the moving blue reflections. A pale, // opaque backing keeps map features from showing through the sphere. if (material.bodyColor !== undefined) { if (!/^#[\da-f]{6}$/i.test(material.bodyColor) || components[189]?.type !== 18) { throw new Error("Invalid body color or missing original body fill."); } const alpha = valueOf(components[189], 37) & 0xff000000; setValue(components[189], 37, (alpha | parseInt(material.bodyColor.slice(1), 16)) >>> 0); } // Keep the configured base opacity and its active-state keyframes in sync. if (material.bodyOpacity) { const { rest, active } = material.bodyOpacity; if (![rest, active].every((value) => Number.isFinite(value) && value >= 0 && value <= 1)) { throw new Error("Body opacity must be between zero and one."); } if (components[186]?.type !== 2) throw new Error("Missing original body node."); setValue(components[186], 18, rest); let objectId, propertyKey; for (const object of objects) { if (object.type === 25) objectId = valueOf(object, 51); if (object.type === 26) propertyKey = valueOf(object, 53); if (object.type === 30 && objectId === 186 && propertyKey === 18) { const value = valueOf(object, 70, 0); if (Math.abs(value - 0.1) < 0.000001) setValue(object, 70, rest); else if (Math.abs(value - 0.3) < 0.000001) setValue(object, 70, active); else throw new Error("Unexpected body opacity keyframe."); } } } return objects; } // A recolored baseline allows visual checks of geometry and online timelines. export function buildMaterialPersona(source, material = defaultMaterial) { restoreOriginal(source); return encodeSource(source, materialObjects(source, material)); } function opacityTrack(objectId, values, duration) { return [ { type: 25, values: [[51, objectId]] }, { type: 26, values: [[53, 18]] }, ...values.map((value, index) => ({ type: 30, values: [[67, Math.round(duration * index / (values.length - 1))], [68, 2], [69, 261], [70, value]] })) ]; } function validateDesign(offline) { if (!Number.isInteger(offline.fps) || offline.fps < 1 || offline.fps > 60 || !Number.isFinite(offline.amplitude) || offline.amplitude < 0 || offline.amplitude > 1 || !Number.isInteger(offline.transitionMs) || offline.transitionMs < 1 || offline.transitionMs > 5000 || !Array.isArray(offline.layers) || offline.layers.length !== 3) { throw new Error("Invalid offline timing, amplitude or layer count."); } const ids = new Set(); for (const layer of offline.layers) { if (!Array.isArray(layer.opacity)) throw new Error("Missing offline opacity tracks."); for (const { objectId, rest, crest } of layer.opacity) { if (!Number.isInteger(objectId) || ids.has(objectId) || !Number.isFinite(rest) || !Number.isFinite(crest) || rest < 0 || rest > crest || crest > 1) { throw new Error(`Invalid or repeated opacity track: ${objectId}`); } ids.add(objectId); } } } export function buildOfflinePersona(source, offline = defaultDesign, material = defaultMaterial) { restoreOriginal(source); validateDesign(offline); const objects = materialObjects(source, material); const animations = objects.filter((object) => object.type === 31); if (animations.length !== 20) throw new Error("Unexpected original animation count."); // Component IDs start at the artboard; binding records do not consume IDs. const artboard = objects.findIndex((object) => object.type === 1); const firstAnimation = objects.findIndex((object) => object.type === 31); const components = objects.slice(artboard, firstAnimation).filter((object) => object.type !== 447); let animation; for (const object of objects) { if (object.type === 31) animation = valueOf(object, 55); // These tracks only control opacity and scale. Keep the complete sphere. if ((animation === "asleep" || animation === "wake") && object.type === 30) { setValue(object, 70, 1); } } const loops = []; for (let number = 1; number <= 3; number++) { const start = objects.findIndex((object) => object.type === 31 && valueOf(object, 55) === `on_loop_${number}`); const end = objects.findIndex((object, index) => index > start && (object.type === 31 || object.type === 53)); if (start < 0 || end < 0) throw new Error(`Missing original water loop ${number}.`); const loop = clone(objects.slice(start, end)); setValue(loop[0], 55, `offline_loop_${number}`); setValue(loop[0], 56, offline.fps); let propertyKey; let baseline; for (const object of loop.slice(1)) { if (object.type === 26) { propertyKey = valueOf(object, 53); baseline = undefined; } // Full rotations must close at 2π; reduce only displacement and breathing. if (object.type === 30 && propertyKey !== 15) { const value = valueOf(object, 70, 0); baseline ??= value; setValue(object, 70, baseline + (value - baseline) * offline.amplitude); } } const duration = valueOf(loop[0], 57); const onlineTracks = []; for (const { objectId, rest, crest } of offline.layers[number - 1].opacity) { const component = components[objectId]; if (!component || ![2, 3].includes(component.type)) throw new Error(`Invalid light node: ${objectId}`); const opacity = valueOf(component, 18, 1); loop.push(...opacityTrack(objectId, [opacity * rest, opacity * crest, opacity * rest], duration)); // Both sides key opacity so reconnecting restores the original material. onlineTracks.push(...opacityTrack(objectId, [opacity, opacity], duration)); } objects.splice(end, 0, ...onlineTracks); loops.push(...loop); } const machine = objects.findIndex((object) => object.type === 53); // Append timelines so all original animation IDs remain unchanged. objects.splice(machine, 0, ...loops); const result = []; let layer; let changedLayers = 0; for (const object of objects) { if (object.type === 57) { if (/^loop[123]$/.test(layer)) { const number = Number(layer.at(-1)); result.push({ type: 61, values: [[149, 19 + number]] }); result.push(...transition(number === 1 ? 3 : 0, false, offline.transitionMs)); changedLayers++; } layer = valueOf(object, 138); } result.push(object); if (/^loop[123]$/.test(layer) && object.type === 61) { result.push(...transition(4, true, offline.transitionMs)); } } if (changedLayers !== 3) throw new Error("The original loop layers have changed."); // These standard properties were omitted from the original because of defaults. const schema = { ...source, properties: { ...source.properties, 56: { name: "fps", type: "UInt" }, 158: { name: "duration", type: "UInt" } } }; return encodeSource(schema, result); } if (argv[1] && import.meta.url === pathToFileURL(argv[1]).href) { const { values } = parseArgs({ options: { check: { type: "boolean" }, "restore-original": { type: "string" } } }); const source = JSON.parse(await readFile(sourceUrl, "utf8")); if (values["restore-original"]) { if (values.check) throw new Error("Choose --check or --restore-original, not both."); // Never overwrite an existing resource when extracting the original. await writeFile(values["restore-original"], restoreOriginal(source), { flag: "wx" }); stdout.write(`Restored original Obsidian to ${values["restore-original"]}.\n`); } else { const bytes = buildOfflinePersona(source); const manifest = JSON.stringify({ path: "/agent/obsidian-water-v1.riv", sha256: createHash("sha256").update(bytes).digest("hex"), byteLength: bytes.length }, null, 2) + "\n"; if (values.check) { if (!bytes.equals(await readFile(outputUrl))) throw new Error("Agent animation is stale; rebuild it."); if (manifest !== await readFile(manifestUrl, "utf8")) throw new Error("Agent cache version is stale; rebuild it."); stdout.write("Verified original round trip and reproducible Agent animation.\n"); } else { await writeFile(outputUrl, bytes); await writeFile(manifestUrl, manifest); stdout.write(`Built Agent animation (${bytes.length} bytes).\n`); } } }