storytellurx/scripts/image-generation/list-world-assets.ts
Elias Rhouzlane e2b80509f7 ok
2026-09-09 16:15:17 +02:00

153 lines
4.9 KiB
TypeScript

import { z } from "zod";
import { readFileSync } from "node:fs";
import { basename, resolve } from "node:path";
import { fileURLToPath } from "node:url";
export const personaAssetPlacements = [
"title",
"portal",
"frame",
"buttonIdle",
"buttonHover",
"buttonPressed",
"suggestion",
"composer",
"message",
"metrics",
"divider",
"pause",
"atmosphere",
"stopButton",
] as const;
export type PersonaAssetPlacement = (typeof personaAssetPlacements)[number];
const AssetSchema = z.object({
name: z.string().min(1),
prompt: z.string().min(1),
publicPath: z.string(),
outputPath: z.string(),
usage: z.array(z.string().min(1)).min(1),
treatment: z.string().min(1),
composition: z.string().min(1),
owner: z.string(),
});
const WorldAssetSchema = AssetSchema.extend({
placements: z.record(z.enum(personaAssetPlacements), AssetSchema),
});
export type WorldAsset = z.infer<typeof WorldAssetSchema>;
type PlannedAsset = z.infer<typeof AssetSchema> & {
persona: string;
placement: "world" | PersonaAssetPlacement;
};
export type WorldAssetReport = {
planned: number;
assets: Array<PlannedAsset & { status: "planned"; filename: string }>;
};
/** Reads only the manifest: planned assets need not exist to be integrated. */
export function readWorldAssets(projectRoot = resolve()): Record<string, WorldAsset> {
const manifestPath = resolve(projectRoot, "src/apps/product/content/persona-assets.json");
return z
.record(z.string(), WorldAssetSchema)
.parse(JSON.parse(readFileSync(manifestPath, "utf8")));
}
function flattenWorldAssets(assets: Record<string, WorldAsset>): PlannedAsset[] {
return Object.entries(assets).flatMap(([persona, world]) => {
const { placements, ...asset } = world;
return [
{ ...asset, persona, placement: "world" as const },
...personaAssetPlacements.map((placement) => ({
...placements[placement],
persona,
placement,
})),
];
});
}
export function validateWorldAssets(assets: Record<string, WorldAsset>): string[] {
const result = z.record(z.string(), WorldAssetSchema).safeParse(assets);
if (!result.success) {
return result.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`);
}
const errors: string[] = [];
const names = new Set<string>();
const publicPaths = new Set<string>();
for (const asset of flattenWorldAssets(result.data)) {
const { persona, placement, name, publicPath, outputPath, owner } = asset;
const key = `${persona}.${placement}`;
const expectedOwner = `src/apps/product/personas/${persona}/persona.ts`;
if (!/^[a-z0-9]+(?:[-_][a-z0-9]+)*$/.test(name)) {
errors.push(`${key}: name must be a stable lowercase filename stem`);
}
if (!publicPath.startsWith("/assets/generations/")) {
errors.push(`${key}: publicPath must be under /assets/generations`);
}
if (publicPath.split("/").includes("..") || publicPath.includes("\\")) {
errors.push(`${key}: publicPath must not traverse directories`);
}
if (outputPath !== `public${publicPath}`) {
errors.push(`${key}: outputPath must equal public${publicPath}`);
}
if (!basename(outputPath).startsWith(`${name}.`)) {
errors.push(`${key}: output filename must start with ${name}.`);
}
if (owner !== expectedOwner) {
errors.push(`${key}: owner must equal ${expectedOwner}`);
}
if (names.has(name)) errors.push(`${key}: duplicate name ${name}`);
if (publicPaths.has(publicPath)) {
errors.push(`${key}: duplicate publicPath ${publicPath}`);
}
names.add(name);
publicPaths.add(publicPath);
}
return errors;
}
export function reportWorldAssets(projectRoot = resolve()): WorldAssetReport {
const assets = flattenWorldAssets(readWorldAssets(projectRoot)).map((asset) => ({
...asset,
status: "planned" as const,
filename: basename(asset.outputPath),
}));
return { planned: assets.length, assets };
}
function formatReport(report: WorldAssetReport): string {
const rows = report.assets.map((asset) =>
[
`[${asset.status}] ${asset.persona}.${asset.placement}`,
`name: ${asset.name}`,
`file: ${asset.filename}`,
`public: ${asset.publicPath}`,
`repository: ${asset.outputPath}`,
`owner: ${asset.owner}`,
`uses: ${asset.usage.join(", ")}`,
`treatment: ${asset.treatment}`,
`composition: ${asset.composition}`,
].join("\n "),
);
return [`Planned: ${report.planned}`, ...rows].join("\n");
}
function main(): void {
const errors = validateWorldAssets(readWorldAssets());
if (errors.length > 0) {
console.error(
`Invalid persona asset manifest:\n${errors.map((error) => `- ${error}`).join("\n")}`,
);
process.exitCode = 1;
return;
}
const report = reportWorldAssets();
console.log(
process.argv.includes("--json") ? JSON.stringify(report, null, 2) : formatReport(report),
);
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main();