Files
next-tjwater-frontend/scripts/build-agent-persona.mjs
jiang 2d8506fea0
Generic Container CI/CD / test-build-publish (push) Successful in 2m9s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 2m39s
feat: add project-aware agent workspace
2026-10-22 18:52:18 +08:00

280 lines
12 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"));
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`);
}
}
}