feat: add project-aware agent workspace
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user