Preserve the original reflection material while adding pointer inertia and a short exit hold. Retain the Rive pointer constraint through the fade and blend rapid re-entry to avoid abrupt changes. Cover timing, re-entry, cancellation, renderer disposal and native Rive frames. The fade regression fails with the previous resource and passes with the updated resource.
307 lines
13 KiB
JavaScript
307 lines
13 KiB
JavaScript
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"));
|
|
const hoverTiming = JSON.parse(await readFile(new URL("../src/shared/ai-elements/persona-hover-timing.json", import.meta.url), "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;
|
|
let machineLayer;
|
|
for (const object of objects) {
|
|
if (object.type === 31) {
|
|
animation = valueOf(object, 55);
|
|
if (animation === "hover_in" || animation === "hover_out") {
|
|
setValue(object, 57, Math.round((animation === "hover_in" ? hoverTiming.enterMs : hoverTiming.exitMs) * 60 / 1000));
|
|
}
|
|
}
|
|
// Change timing only, keeping the original reflection geometry and color.
|
|
if ((animation === "hover_in" || animation === "hover_out") && object.type === 30) {
|
|
const durationMs = animation === "hover_in" ? hoverTiming.enterMs : hoverTiming.exitMs;
|
|
setValue(object, 67, Math.round(valueOf(object, 67, 0) * durationMs / 1000));
|
|
setValue(object, 68, 2);
|
|
setValue(object, 69, 261);
|
|
}
|
|
if (object.type === 57) machineLayer = valueOf(object, 138);
|
|
if (machineLayer === "hover" && object.type === 65 && valueOf(object, 152, 0) === 12) {
|
|
setValue(object, 158, hoverTiming.blendMs);
|
|
}
|
|
// These tracks only control opacity and scale. Keep the complete sphere.
|
|
if ((animation === "asleep" || animation === "wake") && object.type === 30) {
|
|
setValue(object, 70, 1);
|
|
}
|
|
}
|
|
|
|
const hoverOutEnd = objects.findIndex((object) => object.type === 31 && valueOf(object, 55) === "on_loop_1");
|
|
// Keep the pointer constraint active until the outgoing reflection is fully
|
|
// transparent. The original hover_off timeline releases it afterwards.
|
|
objects.splice(hoverOutEnd, 0,
|
|
{ type: 25, values: [[51, 129]] },
|
|
{ type: 26, values: [[53, 172]] },
|
|
{ type: 30, values: [[68, 1], [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`);
|
|
}
|
|
}
|
|
}
|