diff --git a/README.md b/README.md index 0950a0e..835e6af 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,8 @@ npm run build # generated-artifact Pages build npm run check:full # release-grade full generation, validation, test, desktop, and build path ``` +The generator streams one theme at a time, reuses verified mode-and-size outputs, and reports cache, elapsed-time, and peak-RSS telemetry. Use `npm run generate -- --ids=qinglan-odyssey --summary` for a targeted regeneration, `npm run generate:check -- --concurrency=1 --summary` for a reproducibility readback, and `npm run generate:benchmark -- --scenario=warm` for the 60-second warm-cache budget. + ### Method C: Install the desktop app manually 1. Download the build for your operating system and CPU from [GitHub Releases](https://github.com/rwang23/awesome-codex-theme/releases). @@ -204,6 +206,8 @@ npm run test:generated npm run test:ci npm run generate npm run generate:check +npm run generate -- --ids=qinglan-odyssey --summary +npm run generate:benchmark -- --scenario=warm npm run validate npm run check:full npm run screenshots:capture diff --git a/README.zh-CN.md b/README.zh-CN.md index 83957f2..8236dec 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -78,6 +78,8 @@ npm run build # 依赖生成产物的 Pages 构建 npm run check:full # 发布级完整生成、校验、测试、桌面检查与构建 ``` +生成器按主题流式处理,复用已经验证的 mode/尺寸产物,并报告缓存、耗时与峰值 RSS。用 `npm run generate -- --ids=qinglan-odyssey --summary` 定向重建,用 `npm run generate:check -- --concurrency=1 --summary` 回读可复现性,用 `npm run generate:benchmark -- --scenario=warm` 检查 60 秒的热缓存预算。 + ### 方式 C:手动安装桌面应用 1. 从 [GitHub Releases](https://github.com/rwang23/awesome-codex-theme/releases) 下载与你的系统和 CPU 匹配的版本。 @@ -204,6 +206,8 @@ npm run test:generated npm run test:ci npm run generate npm run generate:check +npm run generate -- --ids=qinglan-odyssey --summary +npm run generate:benchmark -- --scenario=warm npm run validate npm run check:full npm run screenshots:capture diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 035473d..3a733bf 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- Reworked theme generation into a bounded, incremental pipeline with + source-art provenance checks, complete render fingerprints, atomic per-theme + writes, cache-aware progress, and reproducible benchmark commands. - Changed the Gallery community section to clearly state that public community submissions are not open and removed its hosted-community link. Added a direct Theme Manager download action to the Gallery hero. diff --git a/package.json b/package.json index 9459561..ca39cc0 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "art:generate": "node scripts/run-image-jobs.mjs", "generate": "node scripts/generate-themes.mjs", "generate:check": "node scripts/generate-themes.mjs --check", + "generate:benchmark": "node scripts/benchmark-generator.mjs", "validate": "node scripts/ensure-generated.mjs && node scripts/validate.mjs", "test": "npm run test:source", "test:source": "node --test tests/source-contract.test.mjs tests/full-skin-runtime.test.mjs", diff --git a/schemas/registry.schema.json b/schemas/registry.schema.json index 5cc7f9a..180c485 100644 --- a/schemas/registry.schema.json +++ b/schemas/registry.schema.json @@ -182,6 +182,8 @@ "required": [ "preview", "previewSha256", + "previewBytes", + "previewRenderFingerprint", "assetSha256", "assetBytes", "fullSkin", @@ -196,6 +198,14 @@ "type": "string", "pattern": "^[a-f0-9]{64}$" }, + "previewBytes": { + "type": "integer", + "minimum": 1 + }, + "previewRenderFingerprint": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, "assetSha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" diff --git a/schemas/theme-pack.schema.json b/schemas/theme-pack.schema.json index 0bda87d..691b7f2 100644 --- a/schemas/theme-pack.schema.json +++ b/schemas/theme-pack.schema.json @@ -347,12 +347,49 @@ "mode": { "type": "object", "additionalProperties": false, - "required": ["asset", "art", "tokens", "integrity", "nativeTheme"], + "required": ["asset", "preview", "art", "tokens", "integrity", "nativeTheme"], "properties": { "asset": { "type": "string", "pattern": "^assets/[a-z0-9-]+\\.png$" }, + "preview": { + "type": "object", + "additionalProperties": false, + "required": ["path", "integrity"], + "properties": { + "path": { + "type": "string", + "pattern": "^previews/(light|dark)\\.png$" + }, + "integrity": { + "type": "object", + "additionalProperties": false, + "required": ["sha256", "bytes", "width", "height", "renderFingerprint"], + "properties": { + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "bytes": { + "type": "integer", + "minimum": 1, + "maximum": 16777216 + }, + "width": { + "const": 960 + }, + "height": { + "const": 540 + }, + "renderFingerprint": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + } + } + } + }, "art": { "type": "object", "additionalProperties": false, diff --git a/scripts/benchmark-generator.mjs b/scripts/benchmark-generator.mjs new file mode 100644 index 0000000..0388446 --- /dev/null +++ b/scripts/benchmark-generator.mjs @@ -0,0 +1,85 @@ +import { + generateRepository, + parseGenerationOptions, +} from "./lib/theme-generator.mjs"; + +const DEFAULT_WARM_LIMIT_MS = 60_000; + +function usage() { + return [ + "Usage: node scripts/benchmark-generator.mjs [--scenario=warm|cold|one-theme] [--ids=id[,id]] [--concurrency=1..4] [--max-ms=60000]", + "", + "All benchmark scenarios run in --check mode and never write generated files.", + "warm verifies cache reuse; cold forces rendering; one-theme requires --ids and forces only that theme.", + ].join("\n"); +} + +function parseBenchmarkOptions(argv) { + let scenario = "warm"; + let maxMs = DEFAULT_WARM_LIMIT_MS; + const generatorArgs = []; + + for (const argument of argv) { + if (argument === "--help" || argument === "-h") return { help: true }; + if (argument.startsWith("--scenario=")) { + scenario = argument.slice("--scenario=".length); + continue; + } + if (argument.startsWith("--max-ms=")) { + maxMs = Number.parseInt(argument.slice("--max-ms=".length), 10); + continue; + } + generatorArgs.push(argument); + } + + if (!new Set(["warm", "cold", "one-theme"]).has(scenario)) { + throw new Error("--scenario must be warm, cold, or one-theme"); + } + if (!Number.isInteger(maxMs) || maxMs < 1) throw new Error("--max-ms must be a positive integer"); + const generator = parseGenerationOptions(["--check", ...generatorArgs]); + if (generator.help) return { help: true }; + if (generator.force) throw new Error("The benchmark selects force mode from its scenario; omit --force"); + if (scenario === "one-theme" && (!generator.ids || generator.ids.length !== 1)) { + throw new Error("--scenario=one-theme requires exactly one --ids value"); + } + + return { + generator: { + ...generator, + force: scenario !== "warm", + }, + maxMs, + scenario, + }; +} + +async function main() { + const options = parseBenchmarkOptions(process.argv.slice(2)); + if (options.help) { + console.log(usage()); + return; + } + const summary = await generateRepository({ options: options.generator }); + if (summary.drift) throw new Error("Benchmark requires reproducible generated output:\n- " + summary.drift.join("\n- ")); + + const result = { + ...summary, + budgetMs: options.maxMs, + scenario: options.scenario, + withinBudget: summary.elapsedMs <= options.maxMs, + }; + console.log(JSON.stringify(result)); + if (!result.withinBudget) { + process.exitCode = 1; + console.error( + "Generator benchmark exceeded " + options.maxMs + "ms: " + summary.elapsedMs + "ms (" + options.scenario + ").", + ); + } +} + +try { + await main(); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +} diff --git a/scripts/generate-theme-worker.mjs b/scripts/generate-theme-worker.mjs new file mode 100644 index 0000000..d41420b --- /dev/null +++ b/scripts/generate-theme-worker.mjs @@ -0,0 +1,28 @@ +import { readFile } from "node:fs/promises"; +import { parentPort } from "node:worker_threads"; + +import { decodePng, renderSourceArtImage } from "./lib/png.mjs"; + +function transferableBuffer(data) { + return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength); +} + +parentPort.on("message", async function ({ id, sourcePath, theme, outputs }) { + try { + const source = await readFile(sourcePath); + const decoded = decodePng(source); + const rendered = outputs.map(function (output) { + const data = renderSourceArtImage(decoded, theme, output.mode, output.width, output.height); + return { + key: output.key, + data: transferableBuffer(data), + }; + }); + parentPort.postMessage({ id, rendered }, rendered.map((output) => output.data)); + } catch (error) { + parentPort.postMessage({ + id, + error: error instanceof Error ? error.message : String(error), + }); + } +}); diff --git a/scripts/generate-themes.mjs b/scripts/generate-themes.mjs index 6e8660f..32b693b 100644 --- a/scripts/generate-themes.mjs +++ b/scripts/generate-themes.mjs @@ -1,431 +1,23 @@ -import { createHash } from "node:crypto"; -import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; -import path from "node:path"; import { fileURLToPath } from "node:url"; -import { readPngDimensions, renderSourceArtPng } from "./lib/png.mjs"; -import { createZip } from "./lib/zip.mjs"; -import { - CODEX_NATIVE_TESTED_VERSION, - createCodexNativeTheme, - serializeCodexNativeTheme, -} from "./lib/codex-native-theme.mjs"; +export { + generateRepository, + generatorUsage, + parseGenerationOptions, + readVerifiedSourceProvenance, + REPOSITORY_ROOT, + runGeneratorCli, +} from "./lib/theme-generator.mjs"; -const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); -const ROOT = path.resolve(SCRIPT_DIR, ".."); -const CATALOG_PATH = path.join(ROOT, "themes", "catalog.json"); -const CAPTURE_MANIFEST_PATH = path.join( - ROOT, - "screenshots", - "codex-beta-26.715.3651.0", - "manifest.json", -); -const CHECK_MODE = process.argv.includes("--check"); -const GENERATOR_ID = "act-theme-generator-v4.0"; -const FULL_SKIN_FORMAT = "act-full-skin-v1"; -const FULL_SKIN_TESTED_VERSION = "26.715.3651.0"; -const SOURCE_ART_RENDERER_ID = "act-source-art-renderer-v1"; -const FAN_ART_LICENSE_ID = "LicenseRef-ACT-Fan-Art-Notice"; -const FAN_ART_POLICY_URL = "https://github.com/rwang23/awesome-codex-theme/blob/main/docs/fan-art-policy.md"; +import { generatorUsage, runGeneratorCli } from "./lib/theme-generator.mjs"; -function sha256(buffer) { - return createHash("sha256").update(buffer).digest("hex"); -} - -function jsonBuffer(value) { - return Buffer.from(JSON.stringify(value, null, 2) + "\n", "utf8"); -} - -function rightsFor(theme) { - const fanArt = theme.rightsProfile === "fan-art"; - return fanArt - ? { - fanArt: true, - license: { - spdx: FAN_ART_LICENSE_ID, - scope: "artwork-only", - url: FAN_ART_POLICY_URL, - }, - provenanceType: "fan-art", - rightsVerified: false, - notes: "Unofficial AI-generated fan art. No official image assets were used. No license or endorsement from the underlying franchise rights holders is claimed.", - } - : { - fanArt: false, - license: { - spdx: "CC0-1.0", - scope: "artwork-and-manifest", - url: "https://creativecommons.org/publicdomain/zero/1.0/", - }, - provenanceType: "original", - rightsVerified: true, - notes: "Original AI-generated source art, reviewed for third-party characters, logos, signatures, text, screenshots, and franchise assets.", - }; -} - -function renderFingerprint(theme, mode, sourceProvenance, width, height) { - return sha256(Buffer.from(JSON.stringify({ - renderer: SOURCE_ART_RENDERER_ID, - sourceSha256: sourceProvenance.sourceSha256, - mode, - width, - height, - background: theme[mode].tokens.background, - }), "utf8")); -} - -async function reusableArt(theme, sourceProvenance, existingRegistryTheme) { +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { try { - const root = path.join(ROOT, "themes", theme.id); - const manifest = JSON.parse(await readFile(path.join(root, "manifest.json"), "utf8")); - if (manifest.provenance?.sourceSha256 !== sourceProvenance.sourceSha256) return null; - const result = { assets: {}, previews: {} }; - for (const mode of ["light", "dark"]) { - if (manifest.modes?.[mode]?.tokens?.background !== theme[mode].tokens.background) return null; - const asset = await readFile(path.join(root, "assets", "background-" + mode + ".png")); - const preview = await readFile(path.join(root, "previews", mode + ".png")); - const assetDimensions = readPngDimensions(asset); - const previewDimensions = readPngDimensions(preview); - if (assetDimensions.width !== 2560 || assetDimensions.height !== 1440 - || previewDimensions.width !== 960 || previewDimensions.height !== 540 - || sha256(asset) !== manifest.modes[mode].integrity.sha256 - || asset.length !== manifest.modes[mode].integrity.bytes - || sha256(preview) !== existingRegistryTheme?.previews?.[mode]?.previewSha256) { - return null; - } - result.assets[mode] = asset; - result.previews[mode] = preview; - } - return result; - } catch { - return null; - } -} - -function manifestFor(theme, assets, nativeThemes, sourceProvenance) { - const rights = rightsFor(theme); - const mode = (name) => ({ - asset: "assets/background-" + name + ".png", - art: { - focusX: theme.art.focusX, - focusY: theme.art.focusY, - safeArea: theme.art.safeArea, - taskMode: theme.art.taskMode, - }, - tokens: theme[name].tokens, - integrity: { - sha256: sha256(assets[name]), - bytes: assets[name].length, - width: 2560, - height: 1440, - renderFingerprint: renderFingerprint(theme, name, sourceProvenance, 2560, 1440), - }, - nativeTheme: { - format: "codex-theme-v1", - path: "native/" + name + ".codex-theme.txt", - sha256: sha256(nativeThemes[name]), - bytes: nativeThemes[name].length, - testedVersion: CODEX_NATIVE_TESTED_VERSION, - }, - }); - - return { - schemaVersion: 1, - id: theme.id, - version: theme.version, - collection: theme.collection, - variant: theme.variant, - pair: theme.pair, - rightsProfile: theme.rightsProfile || "original", - name: theme.name, - tagline: theme.tagline, - description: theme.description, - ...(rights.fanArt ? { fanArt: theme.fanArt } : {}), - author: { - name: "Awesome Codex Theme", - }, - license: rights.license, - provenance: { - type: rights.provenanceType, - source: "themes/source-art/" + theme.id + ".provenance.json", - generator: "openai-image-job + " + GENERATOR_ID, - aiGenerated: true, - rightsVerified: rights.rightsVerified, - model: sourceProvenance.model, - jobId: sourceProvenance.jobId, - promptSha256: sourceProvenance.promptSha256, - sourceSha256: sourceProvenance.sourceSha256, - notes: rights.notes, - }, - compatibility: { - codexDesktopTested: FULL_SKIN_TESTED_VERSION, - engines: [ - { id: "codex-full-skin", coverage: "full-skin-v1", testedVersion: FULL_SKIN_TESTED_VERSION }, - { id: "codex-native", coverage: "native-theme-v1", testedVersion: CODEX_NATIVE_TESTED_VERSION }, - ], - }, - motion: { - default: "reduced", - animated: false, - }, - tags: theme.tags, - modes: { - light: mode("light"), - dark: mode("dark"), - }, - }; -} - -function nativeThemeBuffer(theme, mode) { - return Buffer.from( - serializeCodexNativeTheme(createCodexNativeTheme(theme, mode)) + "\n", - "utf8", - ); -} - -async function loadCaptureContext() { - try { - const manifest = JSON.parse(await readFile(CAPTURE_MANIFEST_PATH, "utf8")); - if (manifest.schemaVersion !== "act-full-skin-capture-manifest-v1" - || manifest.status !== "complete" - || manifest.runtime?.format !== FULL_SKIN_FORMAT - || manifest.runtime?.removedAfterCapture !== true) { - return { manifest: null, index: new Map() }; - } - return { - manifest, - index: new Map( - manifest.captures.map((capture) => [ - capture.themeId + "|" + capture.mode, - capture, - ]), - ), - }; + const result = await runGeneratorCli(); + process.exitCode = result.exitCode; } catch (error) { - if (error.code === "ENOENT") return { manifest: null, index: new Map() }; - throw error; - } -} - -export async function buildGeneratedFiles() { - const catalog = JSON.parse(await readFile(CATALOG_PATH, "utf8")); - const existingRegistry = await readFile(path.join(ROOT, "themes", "registry.json"), "utf8") - .then(JSON.parse) - .catch(() => ({ themes: [] })); - const existingRegistryIndex = new Map( - (existingRegistry.themes || []).map((theme) => [theme.id, theme]), - ); - const captureContext = await loadCaptureContext(); - const files = new Map(); - const registryThemes = []; - - for (const theme of catalog.themes) { - const rights = rightsFor(theme); - const themeRoot = "themes/" + theme.id; - const [sourceArt, sourceProvenance] = await Promise.all([ - readFile(path.join(ROOT, "themes", "source-art", theme.id + ".png")), - readFile(path.join(ROOT, "themes", "source-art", theme.id + ".provenance.json"), "utf8") - .then(JSON.parse), - ]); - const reusable = await reusableArt(theme, sourceProvenance, existingRegistryIndex.get(theme.id)); - const assets = reusable?.assets || { - light: renderSourceArtPng(sourceArt, theme, "light", 2560, 1440), - dark: renderSourceArtPng(sourceArt, theme, "dark", 2560, 1440), - }; - const previews = reusable?.previews || { - light: renderSourceArtPng(sourceArt, theme, "light", 960, 540), - dark: renderSourceArtPng(sourceArt, theme, "dark", 960, 540), - }; - const nativeThemes = { - light: nativeThemeBuffer(theme, "light"), - dark: nativeThemeBuffer(theme, "dark"), - }; - const manifest = manifestFor(theme, assets, nativeThemes, sourceProvenance); - const manifestBuffer = jsonBuffer(manifest); - - files.set(themeRoot + "/manifest.json", manifestBuffer); - files.set(themeRoot + "/assets/background-light.png", assets.light); - files.set(themeRoot + "/assets/background-dark.png", assets.dark); - files.set(themeRoot + "/previews/light.png", previews.light); - files.set(themeRoot + "/previews/dark.png", previews.dark); - - const canonicalPackage = createZip([ - { name: "manifest.json", data: manifestBuffer }, - { name: "assets/background-light.png", data: assets.light }, - { name: "assets/background-dark.png", data: assets.dark }, - { name: "native/light.codex-theme.txt", data: nativeThemes.light }, - { name: "native/dark.codex-theme.txt", data: nativeThemes.dark }, - ]); - const packagePath = "packages/" + theme.id + "-" + theme.version + ".act-theme"; - files.set(packagePath, canonicalPackage); - - const modeRecords = {}; - for (const mode of ["light", "dark"]) { - const nativeThemePath = "packages/native/" + theme.id + "-" + mode + ".codex-theme.txt"; - const capture = captureContext.index.get(theme.id + "|" + mode); - files.set(nativeThemePath, nativeThemes[mode]); - modeRecords[mode] = { - preview: themeRoot + "/previews/" + mode + ".png", - previewSha256: sha256(previews[mode]), - assetSha256: sha256(assets[mode]), - assetBytes: assets[mode].length, - fullSkin: { - format: FULL_SKIN_FORMAT, - asset: themeRoot + "/assets/background-" + mode + ".png", - sha256: sha256(assets[mode]), - bytes: assets[mode].length, - art: manifest.modes[mode].art, - tokens: manifest.modes[mode].tokens, - testedVersion: FULL_SKIN_TESTED_VERSION, - }, - nativeTheme: { - format: "codex-theme-v1", - path: nativeThemePath, - sha256: sha256(nativeThemes[mode]), - bytes: nativeThemes[mode].length, - testedVersion: CODEX_NATIVE_TESTED_VERSION, - value: nativeThemes[mode].toString("utf8").trim(), - }, - ...(capture ? { - capture: { - path: capture.path, - sha256: capture.sha256, - bytes: capture.bytes, - width: capture.width, - height: capture.height, - assetSha256: capture.assetSha256, - runtimeSha256: capture.runtimeSha256, - markerVersion: capture.markerVersion, - selectors: capture.selectors, - appVersion: captureContext.manifest.testBench.version, - packageFullName: captureContext.manifest.testBench.packageFullName, - fixture: captureContext.manifest.fixture.id, - modelLabel: capture.modelLabel, - capturedAt: capture.capturedAt, - }, - } : {}), - }; - } - - registryThemes.push({ - id: theme.id, - version: theme.version, - collection: theme.collection, - pair: theme.pair, - variant: theme.variant, - rightsProfile: theme.rightsProfile || "original", - audience: theme.audience || "global", - ...(theme.featuredRank ? { featuredRank: theme.featuredRank } : {}), - name: theme.name, - tagline: theme.tagline, - description: theme.description, - tags: theme.tags, - ...(rights.fanArt ? { fanArt: theme.fanArt } : {}), - license: { - spdx: rights.license.spdx, - rightsVerified: rights.rightsVerified, - url: rights.license.url, - }, - provenance: { - type: rights.provenanceType, - aiGenerated: true, - rightsVerified: rights.rightsVerified, - generator: "openai-image-job + " + GENERATOR_ID, - model: sourceProvenance.model, - jobId: sourceProvenance.jobId, - sourceArt: "themes/source-art/" + theme.id + ".png", - record: "themes/source-art/" + theme.id + ".provenance.json", - promptRecord: "themes/source-art/jobs.json#" + theme.id, - promptSha256: sourceProvenance.promptSha256, - sourceSha256: sourceProvenance.sourceSha256, - }, - motion: { - default: "reduced", - animated: false, - }, - previews: modeRecords, - package: { - path: packagePath, - sha256: sha256(canonicalPackage), - bytes: canonicalPackage.length, - manifest: themeRoot + "/manifest.json", - manifestSha256: sha256(manifestBuffer), - }, - exports: { - "codex-full-skin": { - coverage: "full-skin-v1", - format: FULL_SKIN_FORMAT, - testedVersion: FULL_SKIN_TESTED_VERSION, - delivery: "Awesome Codex Theme Manager", - limitations: ["runtime-session", "no-layout-replacement"], - }, - "codex-native": { - coverage: "native-theme-v1", - format: "codex-theme-v1", - testedVersion: CODEX_NATIVE_TESTED_VERSION, - importPath: "Settings > Appearance > Import", - limitations: ["background-image"], - }, - }, - }); + console.error(error instanceof Error ? error.message : String(error)); + console.error(generatorUsage()); + process.exitCode = 1; } - - const registry = { - schemaVersion: 1, - standard: "act-theme-pack-v1", - generatedBy: GENERATOR_ID, - catalogRevision: catalog.catalogRevision, - collections: catalog.collections.map((collection) => ({ - ...collection, - themeCount: catalog.themes.filter((theme) => theme.collection === collection.id).length, - })), - themes: registryThemes, - }; - files.set("themes/registry.json", jsonBuffer(registry)); - return files; -} - -async function compareFile(relativePath, expected) { - const target = path.join(ROOT, ...relativePath.split("/")); - try { - const actual = await readFile(target); - return actual.equals(expected); - } catch { - return false; - } -} - -async function main() { - const files = await buildGeneratedFiles(); - const drift = []; - - if (CHECK_MODE) { - for (const [relativePath, expected] of files) { - if (!(await compareFile(relativePath, expected))) drift.push(relativePath); - } - if (drift.length) { - console.error("Generated theme output is stale:"); - for (const file of drift) console.error(" " + file); - process.exitCode = 1; - return; - } - console.log("Generated theme output is reproducible (" + files.size + " files)."); - return; - } - - for (const [relativePath, data] of files) { - const target = path.join(ROOT, ...relativePath.split("/")); - await mkdir(path.dirname(target), { recursive: true }); - let unchanged = false; - try { - const info = await stat(target); - if (info.isFile()) unchanged = (await readFile(target)).equals(data); - } catch {} - if (!unchanged) await writeFile(target, data); - } - console.log("Generated " + files.size + " theme files from themes/catalog.json."); -} - -if (path.resolve(process.argv[1] || "") === fileURLToPath(import.meta.url)) { - await main(); } diff --git a/scripts/lib/png.mjs b/scripts/lib/png.mjs index 72cd64d..13e3e35 100644 --- a/scripts/lib/png.mjs +++ b/scripts/lib/png.mjs @@ -1,5 +1,7 @@ import { deflateSync, inflateSync } from "node:zlib"; +import { SOURCE_ART_RENDERER_CONSTANTS } from "./source-art-contract.mjs"; + const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); const CRC_TABLE = new Uint32Array(256); @@ -566,27 +568,30 @@ export function resizePng(buffer, width, height) { } function gradeSourcePixel(pixel, theme, mode, normalizedX, normalizedY) { + const grading = SOURCE_ART_RENDERER_CONSTANTS.grading; const background = parseHex(theme[mode].tokens.background); let color = pixel.slice(0, 3); if (mode === "dark") { - color = color.map((value) => value * 0.76); - color = mix(color, background, 0.31); + color = color.map((value) => value * grading.darkScale); + color = mix(color, background, grading.darkBackgroundMix); } else { - color = mix(color, [255, 255, 255], 0.035); - color = mix(color, background, 0.07); + color = mix(color, [255, 255, 255], grading.lightWhiteMix); + color = mix(color, background, grading.lightBackgroundMix); } - const quietEdge = 1 - smoothstep(0.08, 0.6, normalizedX); + const quietEdge = 1 - smoothstep(grading.quietEdgeStart, grading.quietEdgeEnd, normalizedX); const safeAreaOpacity = mode === "dark" - ? 0.12 + quietEdge * 0.46 - : 0.05 + quietEdge * 0.35; + ? grading.darkSafeAreaBase + quietEdge * grading.darkSafeAreaRange + : grading.lightSafeAreaBase + quietEdge * grading.lightSafeAreaRange; color = mix(color, background, safeAreaOpacity); - const topShade = smoothstep(0, 0.18, normalizedY); - const bottomShade = 1 - smoothstep(0.78, 1, normalizedY); + const topShade = smoothstep(0, grading.verticalTopEnd, normalizedY); + const bottomShade = 1 - smoothstep(grading.verticalBottomStart, 1, normalizedY); const edgeShade = Math.min(topShade, bottomShade); - if (mode === "dark") color = color.map((value) => value * (0.94 + edgeShade * 0.06)); + if (mode === "dark") { + color = color.map((value) => value * (grading.darkEdgeFloor + edgeShade * grading.darkEdgeRange)); + } return [ clampByte(color[0]), @@ -596,8 +601,7 @@ function gradeSourcePixel(pixel, theme, mode, normalizedX, normalizedY) { ]; } -export function renderSourceArtPng(sourceBuffer, theme, mode, width, height) { - const image = decodePng(sourceBuffer); +export function renderSourceArtImage(image, theme, mode, width, height) { const targetAspect = width / height; const sourceAspect = image.width / image.height; let cropX = 0; @@ -626,3 +630,7 @@ export function renderSourceArtPng(sourceBuffer, theme, mode, width, height) { ); }); } + +export function renderSourceArtPng(sourceBuffer, theme, mode, width, height) { + return renderSourceArtImage(decodePng(sourceBuffer), theme, mode, width, height); +} diff --git a/scripts/lib/source-art-contract.mjs b/scripts/lib/source-art-contract.mjs new file mode 100644 index 0000000..51b7e75 --- /dev/null +++ b/scripts/lib/source-art-contract.mjs @@ -0,0 +1,49 @@ +import { createHash } from "node:crypto"; + +export const SOURCE_ART_RENDERER_ID = "act-source-art-renderer-v2"; + +// Every value that changes the deterministic source-art result belongs here. +// The generator stores this contract in its render fingerprints so a later +// renderer change cannot silently reuse older pixels. +export const SOURCE_ART_RENDERER_CONSTANTS = Object.freeze({ + crop: Object.freeze({ strategy: "center-cover-v1" }), + encoding: Object.freeze({ format: "png-rgba8", deflate: "node-zlib-default" }), + grading: Object.freeze({ + darkBackgroundMix: 0.31, + darkEdgeFloor: 0.94, + darkEdgeRange: 0.06, + darkSafeAreaBase: 0.12, + darkSafeAreaRange: 0.46, + darkScale: 0.76, + lightBackgroundMix: 0.07, + lightSafeAreaBase: 0.05, + lightSafeAreaRange: 0.35, + lightWhiteMix: 0.035, + quietEdgeEnd: 0.6, + quietEdgeStart: 0.08, + verticalBottomStart: 0.78, + verticalTopEnd: 0.18, + }), +}); + +function sha256(value) { + return createHash("sha256").update(value).digest("hex"); +} + +export function renderFingerprint(theme, mode, sourceProvenance, width, height) { + return sha256(Buffer.from(JSON.stringify({ + renderer: SOURCE_ART_RENDERER_ID, + constants: SOURCE_ART_RENDERER_CONSTANTS, + sourceSha256: sourceProvenance.sourceSha256, + mode, + width, + height, + art: { + focusX: theme.art.focusX, + focusY: theme.art.focusY, + safeArea: theme.art.safeArea, + taskMode: theme.art.taskMode, + }, + tokens: theme[mode].tokens, + }), "utf8")); +} diff --git a/scripts/lib/theme-generator.mjs b/scripts/lib/theme-generator.mjs new file mode 100644 index 0000000..e49afa3 --- /dev/null +++ b/scripts/lib/theme-generator.mjs @@ -0,0 +1,871 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { Worker } from "node:worker_threads"; + +import { readPngDimensions } from "./png.mjs"; +import { renderFingerprint, SOURCE_ART_RENDERER_ID } from "./source-art-contract.mjs"; +import { createZip } from "./zip.mjs"; +import { + CODEX_NATIVE_TESTED_VERSION, + createCodexNativeTheme, + serializeCodexNativeTheme, +} from "./codex-native-theme.mjs"; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +export const REPOSITORY_ROOT = path.resolve(SCRIPT_DIR, "..", ".."); +const WORKER_URL = new URL("../generate-theme-worker.mjs", import.meta.url); +const CATALOG_PATH = "themes/catalog.json"; +const REGISTRY_PATH = "themes/registry.json"; +const CAPTURE_MANIFEST_PATH = "screenshots/codex-beta-26.715.3651.0/manifest.json"; +const GENERATOR_ID = "act-theme-generator-v5.0"; +const FULL_SKIN_FORMAT = "act-full-skin-v1"; +const FULL_SKIN_TESTED_VERSION = "26.715.3651.0"; +const FAN_ART_LICENSE_ID = "LicenseRef-ACT-Fan-Art-Notice"; +const FAN_ART_POLICY_URL = "https://github.com/rwang23/awesome-codex-theme/blob/main/docs/fan-art-policy.md"; +const OUTPUTS = Object.freeze([ + Object.freeze({ kind: "asset", width: 2560, height: 1440 }), + Object.freeze({ kind: "preview", width: 960, height: 540 }), +]); +const MODES = Object.freeze(["light", "dark"]); +const MAX_CONCURRENCY = 4; +const DEFAULT_CONCURRENCY = 2; +const PROGRESS_HEARTBEAT_MS = 30_000; +let temporarySequence = 0; + +function sha256(buffer) { + return createHash("sha256").update(buffer).digest("hex"); +} + +function jsonBuffer(value) { + return Buffer.from(JSON.stringify(value, null, 2) + "\n", "utf8"); +} + +function outputKey(kind, mode) { + return kind + ":" + mode; +} + +function outputPath(theme, kind, mode) { + return kind === "asset" + ? "themes/" + theme.id + "/assets/background-" + mode + ".png" + : "themes/" + theme.id + "/previews/" + mode + ".png"; +} + +function outputIntegrity(data, width, height, fingerprint) { + return { + sha256: sha256(data), + bytes: data.length, + width, + height, + renderFingerprint: fingerprint, + }; +} + +function legacyRenderFingerprint(theme, mode, sourceProvenance, width, height) { + return sha256(Buffer.from(JSON.stringify({ + renderer: "act-source-art-renderer-v1", + sourceSha256: sourceProvenance.sourceSha256, + mode, + width, + height, + background: theme[mode].tokens.background, + }), "utf8")); +} + +function rightsFor(theme) { + const fanArt = theme.rightsProfile === "fan-art"; + return fanArt + ? { + fanArt: true, + license: { + spdx: FAN_ART_LICENSE_ID, + scope: "artwork-only", + url: FAN_ART_POLICY_URL, + }, + provenanceType: "fan-art", + rightsVerified: false, + notes: "Unofficial AI-generated fan art. No official image assets were used. No license or endorsement from the underlying franchise rights holders is claimed.", + } + : { + fanArt: false, + license: { + spdx: "CC0-1.0", + scope: "artwork-and-manifest", + url: "https://creativecommons.org/publicdomain/zero/1.0/", + }, + provenanceType: "original", + rightsVerified: true, + notes: "Original AI-generated source art, reviewed for third-party characters, logos, signatures, text, screenshots, and franchise assets.", + }; +} + +function nativeThemeBuffer(theme, mode) { + return Buffer.from( + serializeCodexNativeTheme(createCodexNativeTheme(theme, mode)) + "\n", + "utf8", + ); +} + +function manifestFor(theme, assets, previews, nativeThemes, sourceProvenance) { + const rights = rightsFor(theme); + const mode = (name) => ({ + asset: "assets/background-" + name + ".png", + preview: { + path: "previews/" + name + ".png", + integrity: outputIntegrity( + previews[name], + 960, + 540, + renderFingerprint(theme, name, sourceProvenance, 960, 540), + ), + }, + art: { + focusX: theme.art.focusX, + focusY: theme.art.focusY, + safeArea: theme.art.safeArea, + taskMode: theme.art.taskMode, + }, + tokens: theme[name].tokens, + integrity: outputIntegrity( + assets[name], + 2560, + 1440, + renderFingerprint(theme, name, sourceProvenance, 2560, 1440), + ), + nativeTheme: { + format: "codex-theme-v1", + path: "native/" + name + ".codex-theme.txt", + sha256: sha256(nativeThemes[name]), + bytes: nativeThemes[name].length, + testedVersion: CODEX_NATIVE_TESTED_VERSION, + }, + }); + + return { + schemaVersion: 1, + id: theme.id, + version: theme.version, + collection: theme.collection, + variant: theme.variant, + pair: theme.pair, + rightsProfile: theme.rightsProfile || "original", + name: theme.name, + tagline: theme.tagline, + description: theme.description, + ...(rights.fanArt ? { fanArt: theme.fanArt } : {}), + author: { + name: "Awesome Codex Theme", + }, + license: rights.license, + provenance: { + type: rights.provenanceType, + source: "themes/source-art/" + theme.id + ".provenance.json", + generator: "openai-image-job + " + GENERATOR_ID, + aiGenerated: true, + rightsVerified: rights.rightsVerified, + model: sourceProvenance.model, + jobId: sourceProvenance.jobId, + promptSha256: sourceProvenance.promptSha256, + sourceSha256: sourceProvenance.sourceSha256, + notes: rights.notes, + }, + compatibility: { + codexDesktopTested: FULL_SKIN_TESTED_VERSION, + engines: [ + { id: "codex-full-skin", coverage: "full-skin-v1", testedVersion: FULL_SKIN_TESTED_VERSION }, + { id: "codex-native", coverage: "native-theme-v1", testedVersion: CODEX_NATIVE_TESTED_VERSION }, + ], + }, + motion: { + default: "reduced", + animated: false, + }, + tags: theme.tags, + modes: { + light: mode("light"), + dark: mode("dark"), + }, + }; +} + +async function readJson(root, relativePath) { + return JSON.parse(await readFile(path.join(root, ...relativePath.split("/")), "utf8")); +} + +async function loadCaptureContext(root) { + try { + const manifest = await readJson(root, CAPTURE_MANIFEST_PATH); + if (manifest.schemaVersion !== "act-full-skin-capture-manifest-v1" + || manifest.status !== "complete" + || manifest.runtime?.format !== FULL_SKIN_FORMAT + || manifest.runtime?.removedAfterCapture !== true) { + return { manifest: null, index: new Map() }; + } + return { + manifest, + index: new Map( + manifest.captures.map((capture) => [capture.themeId + "|" + capture.mode, capture]), + ), + }; + } catch (error) { + if (error.code === "ENOENT") return { manifest: null, index: new Map() }; + throw error; + } +} + +async function readExistingManifest(root, themeId) { + try { + return await readJson(root, "themes/" + themeId + "/manifest.json"); + } catch { + return null; + } +} + +export async function readVerifiedSourceProvenance(root, themeId) { + const [source, provenance] = await Promise.all([ + readFile(path.join(root, "themes", "source-art", themeId + ".png")), + readJson(root, "themes/source-art/" + themeId + ".provenance.json"), + ]); + if (provenance.sourceSha256 !== sha256(source)) { + throw new Error("Source-art hash does not match provenance for " + themeId); + } + return provenance; +} + +function sourceMetadataMatches(theme, manifest, mode) { + return JSON.stringify(manifest.modes?.[mode]?.art) === JSON.stringify({ + focusX: theme.art.focusX, + focusY: theme.art.focusY, + safeArea: theme.art.safeArea, + taskMode: theme.art.taskMode, + }) && JSON.stringify(manifest.modes?.[mode]?.tokens) === JSON.stringify(theme[mode].tokens); +} + +async function readReusableOutput(root, theme, sourceProvenance, manifest, existingRecord, kind, mode) { + if (!manifest || manifest.provenance?.sourceSha256 !== sourceProvenance.sourceSha256) return null; + const manifestMode = manifest.modes?.[mode]; + const currentFingerprint = renderFingerprint( + theme, + mode, + sourceProvenance, + OUTPUTS.find((candidate) => candidate.kind === kind).width, + OUTPUTS.find((candidate) => candidate.kind === kind).height, + ); + const legacyV4 = manifest.provenance?.generator === "openai-image-job + act-theme-generator-v4.0" + && sourceMetadataMatches(theme, manifest, mode); + const output = kind === "asset" + ? { relative: manifestMode?.asset, integrity: manifestMode?.integrity } + : { + relative: manifestMode?.preview?.path || "previews/" + mode + ".png", + integrity: manifestMode?.preview?.integrity || { + sha256: existingRecord?.previews?.[mode]?.previewSha256, + bytes: existingRecord?.previews?.[mode]?.previewBytes, + renderFingerprint: legacyV4 + ? legacyRenderFingerprint(theme, mode, sourceProvenance, 960, 540) + : null, + }, + }; + const expected = OUTPUTS.find((candidate) => candidate.kind === kind); + if (!output.relative || !output.integrity) return null; + const fingerprintMatches = output.integrity.renderFingerprint === currentFingerprint; + const legacyFingerprintMatches = legacyV4 + && output.integrity.renderFingerprint === legacyRenderFingerprint( + theme, + mode, + sourceProvenance, + expected.width, + expected.height, + ); + if (!fingerprintMatches && !legacyFingerprintMatches) { + return null; + } + + try { + const data = await readFile(path.join(root, "themes", theme.id, ...output.relative.split("/"))); + const dimensions = readPngDimensions(data); + if (dimensions.width !== expected.width + || dimensions.height !== expected.height + || output.integrity.sha256 !== sha256(data) + || (output.integrity.bytes && output.integrity.bytes !== data.length)) { + return null; + } + return data; + } catch { + return null; + } +} + +class RenderWorkerPool { + #closed = false; + #nextId = 1; + #queue = []; + #slots = []; + + constructor(concurrency) { + for (let index = 0; index < concurrency; index += 1) this.#slots.push(this.#createSlot()); + } + + #createSlot() { + const worker = new Worker(WORKER_URL); + const slot = { worker, job: null }; + worker.on("message", (message) => { + if (!slot.job || slot.job.id !== message.id) return; + const job = slot.job; + slot.job = null; + if (message.error) job.reject(new Error(message.error)); + else job.resolve(new Map(message.rendered.map((output) => [output.key, Buffer.from(output.data)]))); + this.#drain(); + }); + worker.on("error", (error) => this.#fail(error)); + worker.on("exit", (code) => { + if (!this.#closed && code !== 0) this.#fail(new Error("Theme renderer worker exited with code " + code)); + }); + return slot; + } + + #fail(error) { + if (this.#closed) return; + this.#closed = true; + for (const slot of this.#slots) { + if (slot.job) slot.job.reject(error); + slot.job = null; + void slot.worker.terminate(); + } + while (this.#queue.length) this.#queue.shift().reject(error); + } + + #drain() { + if (this.#closed) return; + for (const slot of this.#slots) { + if (slot.job || this.#queue.length === 0) continue; + const job = this.#queue.shift(); + slot.job = job; + slot.worker.postMessage({ id: job.id, ...job.task }); + } + } + + render(task) { + if (this.#closed) return Promise.reject(new Error("Theme renderer pool is closed")); + return new Promise((resolve, reject) => { + this.#queue.push({ id: this.#nextId++, task, resolve, reject }); + this.#drain(); + }); + } + + async close() { + if (this.#closed) return; + this.#closed = true; + await Promise.all(this.#slots.map((slot) => slot.worker.terminate())); + } +} + +function createThemeOutputs(theme, assets, previews, nativeThemes, manifest, manifestBuffer, canonicalPackage) { + const themeRoot = "themes/" + theme.id; + return [ + { relativePath: themeRoot + "/manifest.json", data: manifestBuffer }, + { relativePath: themeRoot + "/assets/background-light.png", data: assets.light }, + { relativePath: themeRoot + "/assets/background-dark.png", data: assets.dark }, + { relativePath: themeRoot + "/previews/light.png", data: previews.light }, + { relativePath: themeRoot + "/previews/dark.png", data: previews.dark }, + { relativePath: "packages/" + theme.id + "-" + theme.version + ".act-theme", data: canonicalPackage }, + { relativePath: "packages/native/" + theme.id + "-light.codex-theme.txt", data: nativeThemes.light }, + { relativePath: "packages/native/" + theme.id + "-dark.codex-theme.txt", data: nativeThemes.dark }, + ]; +} + +function registryRecordFor(theme, manifest, manifestBuffer, canonicalPackage, previews, assets, nativeThemes, sourceProvenance, captureContext) { + const rights = rightsFor(theme); + const themeRoot = "themes/" + theme.id; + const modeRecords = {}; + for (const mode of MODES) { + const capture = captureContext.index.get(theme.id + "|" + mode); + modeRecords[mode] = { + preview: themeRoot + "/previews/" + mode + ".png", + previewSha256: sha256(previews[mode]), + previewBytes: previews[mode].length, + previewRenderFingerprint: manifest.modes[mode].preview.integrity.renderFingerprint, + assetSha256: sha256(assets[mode]), + assetBytes: assets[mode].length, + fullSkin: { + format: FULL_SKIN_FORMAT, + asset: themeRoot + "/assets/background-" + mode + ".png", + sha256: sha256(assets[mode]), + bytes: assets[mode].length, + art: manifest.modes[mode].art, + tokens: manifest.modes[mode].tokens, + testedVersion: FULL_SKIN_TESTED_VERSION, + }, + nativeTheme: { + format: "codex-theme-v1", + path: "packages/native/" + theme.id + "-" + mode + ".codex-theme.txt", + sha256: sha256(nativeThemes[mode]), + bytes: nativeThemes[mode].length, + testedVersion: CODEX_NATIVE_TESTED_VERSION, + value: nativeThemes[mode].toString("utf8").trim(), + }, + ...(capture ? { + capture: { + path: capture.path, + sha256: capture.sha256, + bytes: capture.bytes, + width: capture.width, + height: capture.height, + assetSha256: capture.assetSha256, + runtimeSha256: capture.runtimeSha256, + markerVersion: capture.markerVersion, + selectors: capture.selectors, + appVersion: captureContext.manifest.testBench.version, + packageFullName: captureContext.manifest.testBench.packageFullName, + fixture: captureContext.manifest.fixture.id, + modelLabel: capture.modelLabel, + capturedAt: capture.capturedAt, + }, + } : {}), + }; + } + + return { + id: theme.id, + version: theme.version, + collection: theme.collection, + pair: theme.pair, + variant: theme.variant, + rightsProfile: theme.rightsProfile || "original", + audience: theme.audience || "global", + ...(theme.featuredRank ? { featuredRank: theme.featuredRank } : {}), + name: theme.name, + tagline: theme.tagline, + description: theme.description, + tags: theme.tags, + ...(rights.fanArt ? { fanArt: theme.fanArt } : {}), + license: { + spdx: rights.license.spdx, + rightsVerified: rights.rightsVerified, + url: rights.license.url, + }, + provenance: { + type: rights.provenanceType, + aiGenerated: true, + rightsVerified: rights.rightsVerified, + generator: "openai-image-job + " + GENERATOR_ID, + model: sourceProvenance.model, + jobId: sourceProvenance.jobId, + sourceArt: "themes/source-art/" + theme.id + ".png", + record: "themes/source-art/" + theme.id + ".provenance.json", + promptRecord: "themes/source-art/jobs.json#" + theme.id, + promptSha256: sourceProvenance.promptSha256, + sourceSha256: sourceProvenance.sourceSha256, + }, + motion: { + default: "reduced", + animated: false, + }, + previews: modeRecords, + package: { + path: "packages/" + theme.id + "-" + theme.version + ".act-theme", + sha256: sha256(canonicalPackage), + bytes: canonicalPackage.length, + manifest: themeRoot + "/manifest.json", + manifestSha256: sha256(manifestBuffer), + }, + exports: { + "codex-full-skin": { + coverage: "full-skin-v1", + format: FULL_SKIN_FORMAT, + testedVersion: FULL_SKIN_TESTED_VERSION, + delivery: "Awesome Codex Theme Manager", + limitations: ["runtime-session", "no-layout-replacement"], + }, + "codex-native": { + coverage: "native-theme-v1", + format: "codex-theme-v1", + testedVersion: CODEX_NATIVE_TESTED_VERSION, + importPath: "Settings > Appearance > Import", + limitations: ["background-image"], + }, + }, + }; +} + +async function matchesFile(target, expected) { + try { + const info = await stat(target); + return info.isFile() && (await readFile(target)).equals(expected); + } catch { + return false; + } +} + +async function atomicWrite(target, data) { + await mkdir(path.dirname(target), { recursive: true }); + const temporary = target + "." + process.pid + "." + Date.now() + "." + temporarySequence++ + ".tmp"; + await writeFile(temporary, data); + try { + await rename(temporary, target); + } catch (error) { + if (error.code !== "EEXIST" && error.code !== "EPERM") { + await rm(temporary, { force: true }); + throw error; + } + await rm(target, { force: true }); + await rename(temporary, target); + } +} + +async function reconcileFile(root, output, check, drift, collectOutput) { + const target = path.join(root, ...output.relativePath.split("/")); + if (collectOutput) collectOutput(output.relativePath, output.data); + if (await matchesFile(target, output.data)) return; + if (check) { + drift.push(output.relativePath); + return; + } + await atomicWrite(target, output.data); +} + +async function runBounded(items, concurrency, worker) { + let cursor = 0; + const results = new Array(items.length); + const workerCount = Math.max(1, Math.min(concurrency, items.length || 1)); + await Promise.all(Array.from({ length: workerCount }, async () => { + while (true) { + const index = cursor; + cursor += 1; + if (index >= items.length) return; + results[index] = await worker(items[index], index); + } + })); + return results; +} + +async function generateTheme({ root, theme, sourceProvenance, existingManifest, existingRecord, force, render, captureContext }) { + const assets = {}; + const previews = {}; + const missing = []; + let cacheHits = 0; + + for (const mode of MODES) { + for (const output of OUTPUTS) { + const reusable = force + ? null + : await readReusableOutput( + root, + theme, + sourceProvenance, + existingManifest, + existingRecord, + output.kind, + mode, + ); + const key = outputKey(output.kind, mode); + if (reusable) { + if (output.kind === "asset") assets[mode] = reusable; + else previews[mode] = reusable; + cacheHits += 1; + } else { + missing.push({ key, mode, width: output.width, height: output.height, kind: output.kind }); + } + } + } + + if (missing.length) { + const rendered = await render({ + sourcePath: path.join(root, "themes", "source-art", theme.id + ".png"), + theme, + outputs: missing.map(({ key, mode, width, height }) => ({ key, mode, width, height })), + }); + for (const output of missing) { + const data = rendered.get(output.key); + if (!data) throw new Error("Theme renderer omitted " + output.key + " for " + theme.id); + if (output.kind === "asset") assets[output.mode] = data; + else previews[output.mode] = data; + } + } + + const nativeThemes = { + light: nativeThemeBuffer(theme, "light"), + dark: nativeThemeBuffer(theme, "dark"), + }; + const manifest = manifestFor(theme, assets, previews, nativeThemes, sourceProvenance); + const manifestBuffer = jsonBuffer(manifest); + const canonicalPackage = createZip([ + { name: "manifest.json", data: manifestBuffer }, + { name: "assets/background-light.png", data: assets.light }, + { name: "assets/background-dark.png", data: assets.dark }, + { name: "native/light.codex-theme.txt", data: nativeThemes.light }, + { name: "native/dark.codex-theme.txt", data: nativeThemes.dark }, + ]); + + return { + cacheHits, + cacheMisses: missing.length, + outputs: createThemeOutputs(theme, assets, previews, nativeThemes, manifest, manifestBuffer, canonicalPackage), + record: registryRecordFor( + theme, + manifest, + manifestBuffer, + canonicalPackage, + previews, + assets, + nativeThemes, + sourceProvenance, + captureContext, + ), + }; +} + +function resolveThemes(catalog, ids) { + if (!ids) return catalog.themes; + const requested = new Set(ids); + const known = new Set(catalog.themes.map((theme) => theme.id)); + const unknown = [...requested].filter((id) => !known.has(id)); + if (unknown.length) throw new Error("Unknown theme id(s): " + unknown.join(", ")); + return catalog.themes.filter((theme) => requested.has(theme.id)); +} + +function hasCurrentRegistryRecord(record) { + return MODES.every((mode) => ( + Number.isInteger(record?.previews?.[mode]?.previewBytes) + && typeof record.previews[mode].previewRenderFingerprint === "string" + )); +} + +function createRegistry(catalog, records) { + return { + schemaVersion: 1, + standard: "act-theme-pack-v1", + generatedBy: GENERATOR_ID, + catalogRevision: catalog.catalogRevision, + collections: catalog.collections.map((collection) => ({ + ...collection, + themeCount: catalog.themes.filter((theme) => theme.collection === collection.id).length, + })), + themes: catalog.themes.map((theme) => { + const record = records.get(theme.id); + if (!record) throw new Error("Registry record is missing for " + theme.id); + return record; + }), + }; +} + +function updatePeakRss(stats) { + stats.peakRssBytes = Math.max(stats.peakRssBytes, process.memoryUsage().rss); +} + +function progressPayload(stats, event, themeId) { + updatePeakRss(stats); + return { + event, + themeId: themeId || null, + completed: stats.completed, + total: stats.total, + cacheHits: stats.cacheHits, + cacheMisses: stats.cacheMisses, + elapsedMs: Date.now() - stats.startedAt, + rssBytes: process.memoryUsage().rss, + peakRssBytes: stats.peakRssBytes, + }; +} + +export function parseGenerationOptions(argv = process.argv.slice(2)) { + const options = { + check: false, + concurrency: DEFAULT_CONCURRENCY, + force: false, + help: false, + ids: null, + jsonProgress: false, + summary: false, + }; + + for (const argument of argv) { + if (argument === "--check") options.check = true; + else if (argument === "--force") options.force = true; + else if (argument === "--json-progress") options.jsonProgress = true; + else if (argument === "--summary") options.summary = true; + else if (argument === "--help" || argument === "-h") options.help = true; + else if (argument.startsWith("--ids=")) { + const ids = argument.slice("--ids=".length).split(",").map((id) => id.trim()).filter(Boolean); + if (!ids.length) throw new Error("--ids requires at least one theme id"); + options.ids = [...new Set(ids)]; + } else if (argument.startsWith("--concurrency=")) { + const rawValue = argument.slice("--concurrency=".length); + const value = Number(rawValue); + if (!/^[1-9]\d*$/.test(rawValue) + || !Number.isInteger(value) + || value < 1 + || value > MAX_CONCURRENCY) { + throw new Error("--concurrency must be an integer from 1 to " + MAX_CONCURRENCY); + } + options.concurrency = value; + } else { + throw new Error("Unknown generator option: " + argument); + } + } + return options; +} + +export function generatorUsage() { + return [ + "Usage: node scripts/generate-themes.mjs [--check] [--force] [--ids=id[,id]] [--concurrency=1..4] [--json-progress] [--summary]", + "", + "Generation writes one theme at a time. --check verifies generated output without writing it.", + ].join("\n"); +} + +export async function generateRepository({ + root = REPOSITORY_ROOT, + options = {}, + onProgress = () => {}, + collectOutput = null, +} = {}) { + const normalized = { + check: Boolean(options.check), + concurrency: options.concurrency ?? DEFAULT_CONCURRENCY, + force: Boolean(options.force), + ids: options.ids || null, + }; + if (!Number.isInteger(normalized.concurrency) + || normalized.concurrency < 1 + || normalized.concurrency > MAX_CONCURRENCY) { + throw new Error("concurrency must be an integer from 1 to " + MAX_CONCURRENCY); + } + + const catalog = await readJson(root, CATALOG_PATH); + const existingRegistry = await readJson(root, REGISTRY_PATH).catch(() => ({ themes: [] })); + const existingRecords = new Map((existingRegistry.themes || []).map((theme) => [theme.id, theme])); + const themes = resolveThemes(catalog, normalized.ids); + if (normalized.ids) { + if (existingRegistry.generatedBy !== GENERATOR_ID) { + throw new Error("Partial generation requires a current v5 Registry. Run `npm run generate` once first."); + } + const missingRecords = catalog.themes + .filter((theme) => !normalized.ids.includes(theme.id) && !existingRecords.has(theme.id)) + .map((theme) => theme.id); + if (missingRecords.length) { + throw new Error("Partial generation requires existing Registry records for: " + missingRecords.join(", ")); + } + const legacyRecords = catalog.themes + .filter((theme) => !normalized.ids.includes(theme.id) && !hasCurrentRegistryRecord(existingRecords.get(theme.id))) + .map((theme) => theme.id); + if (legacyRecords.length) { + throw new Error("Partial generation requires v5 Registry records for: " + legacyRecords.join(", ")); + } + } + + const captureContext = await loadCaptureContext(root); + const records = new Map(existingRecords); + const drift = []; + const stats = { + cacheHits: 0, + cacheMisses: 0, + completed: 0, + peakRssBytes: process.memoryUsage().rss, + startedAt: Date.now(), + total: themes.length, + }; + const heartbeat = setInterval(() => onProgress(progressPayload(stats, "heartbeat")), PROGRESS_HEARTBEAT_MS); + heartbeat.unref(); + let pool = null; + const render = async (task) => { + pool ||= new RenderWorkerPool(normalized.concurrency); + return pool.render(task); + }; + + try { + const generated = await runBounded(themes, normalized.concurrency, async (theme) => { + const sourceProvenance = await readVerifiedSourceProvenance(root, theme.id); + const result = await generateTheme({ + root, + theme, + sourceProvenance, + existingManifest: await readExistingManifest(root, theme.id), + existingRecord: existingRecords.get(theme.id), + force: normalized.force, + render, + captureContext, + }); + for (const output of result.outputs) { + await reconcileFile(root, output, normalized.check, drift, collectOutput); + } + records.set(theme.id, result.record); + stats.completed += 1; + stats.cacheHits += result.cacheHits; + stats.cacheMisses += result.cacheMisses; + onProgress(progressPayload(stats, "theme", theme.id)); + return result.record; + }); + void generated; + + const registry = createRegistry(catalog, records); + await reconcileFile( + root, + { relativePath: REGISTRY_PATH, data: jsonBuffer(registry) }, + normalized.check, + drift, + collectOutput, + ); + } finally { + clearInterval(heartbeat); + if (pool) await pool.close(); + } + + const summary = { + cacheHits: stats.cacheHits, + cacheMisses: stats.cacheMisses, + elapsedMs: Date.now() - stats.startedAt, + generatedThemes: themes.length, + peakRssBytes: stats.peakRssBytes, + renderer: SOURCE_ART_RENDERER_ID, + totalThemes: catalog.themes.length, + ...(drift.length ? { drift } : {}), + }; + return summary; +} + +function formatMiB(bytes) { + return (bytes / (1024 * 1024)).toFixed(1) + " MiB"; +} + +function createConsoleReporter(options, io) { + return function (progress) { + if (options.jsonProgress) { + io.log(JSON.stringify(progress)); + return; + } + const prefix = progress.event === "heartbeat" ? "Still generating" : "Generated"; + const theme = progress.themeId ? " " + progress.themeId : ""; + io.log( + prefix + " " + progress.completed + "/" + progress.total + theme + + " (cache " + progress.cacheHits + " hit / " + progress.cacheMisses + " miss, " + + Math.round(progress.elapsedMs / 1000) + "s, peak " + formatMiB(progress.peakRssBytes) + ")", + ); + }; +} + +export async function runGeneratorCli(argv = process.argv.slice(2), io = console) { + const options = parseGenerationOptions(argv); + if (options.help) { + io.log(generatorUsage()); + return { exitCode: 0 }; + } + const summary = await generateRepository({ + options, + onProgress: createConsoleReporter(options, io), + }); + if (summary.drift) { + io.error("Generated theme output is stale:"); + for (const file of summary.drift) io.error(" " + file); + return { exitCode: 1, summary }; + } + if (options.jsonProgress || options.summary) io.log(JSON.stringify({ event: "summary", ...summary })); + else { + io.log( + "Generated " + summary.generatedThemes + " theme(s) from themes/catalog.json " + + "(" + summary.cacheHits + " cache hit / " + summary.cacheMisses + " miss, " + + Math.round(summary.elapsedMs / 1000) + "s, peak " + formatMiB(summary.peakRssBytes) + ").", + ); + } + return { exitCode: 0, summary }; +} diff --git a/scripts/validate.mjs b/scripts/validate.mjs index d641655..52ebd04 100644 --- a/scripts/validate.mjs +++ b/scripts/validate.mjs @@ -9,6 +9,7 @@ import { CODEX_NATIVE_TESTED_VERSION, parseCodexNativeTheme, } from "./lib/codex-native-theme.mjs"; +import { renderFingerprint } from "./lib/source-art-contract.mjs"; const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(SCRIPT_DIR, ".."); @@ -21,7 +22,6 @@ const MAX_IMAGE_BYTES = 16 * 1024 * 1024; const CODEX_BETA_CAPTURE_VERSION = "26.715.3651.0"; const CODEX_BETA_PACKAGE = "OpenAI.CodexBeta_26.715.3651.0_x64__2p2nqsd0c76g0"; const FULL_SKIN_FORMAT = "act-full-skin-v1"; -const SOURCE_ART_RENDERER_ID = "act-source-art-renderer-v1"; const SOURCE_WIDTH = 1536; const SOURCE_HEIGHT = 1024; const CANONICAL_ENTRIES = [ @@ -36,17 +36,6 @@ function sha256(buffer) { return createHash("sha256").update(buffer).digest("hex"); } -function renderFingerprint(theme, mode, sourceProvenance, width, height) { - return sha256(Buffer.from(JSON.stringify({ - renderer: SOURCE_ART_RENDERER_ID, - sourceSha256: sourceProvenance.sourceSha256, - mode, - width, - height, - background: theme[mode].tokens.background, - }), "utf8")); -} - function check(condition, message, errors) { if (!condition) errors.push(message); } @@ -532,6 +521,35 @@ export async function validateRepository() { ); check(sha256(asset) === modeRecord.assetSha256, theme.id + " " + mode + " registry asset hash mismatch", errors); check(asset.length === manifestMode.integrity.bytes && asset.length === modeRecord.assetBytes, theme.id + " " + mode + " asset byte count mismatch", errors); + check(manifestMode.preview?.path === "previews/" + mode + ".png", theme.id + " " + mode + " preview path mismatch", errors); + check( + manifestMode.preview?.integrity?.renderFingerprint + === renderFingerprint(catalogTheme, mode, sourceProvenance, 960, 540), + theme.id + " " + mode + " preview render fingerprint mismatch", + errors, + ); + check( + sha256(preview) === manifestMode.preview?.integrity?.sha256, + theme.id + " " + mode + " manifest preview hash mismatch", + errors, + ); + check( + preview.length === manifestMode.preview?.integrity?.bytes, + theme.id + " " + mode + " manifest preview byte count mismatch", + errors, + ); + check( + previewDimensions.width === manifestMode.preview?.integrity?.width + && previewDimensions.height === manifestMode.preview?.integrity?.height, + theme.id + " " + mode + " manifest preview dimensions mismatch", + errors, + ); + check(modeRecord.previewBytes === preview.length, theme.id + " " + mode + " registry preview byte count mismatch", errors); + check( + modeRecord.previewRenderFingerprint === manifestMode.preview?.integrity?.renderFingerprint, + theme.id + " " + mode + " registry preview render fingerprint mismatch", + errors, + ); check(modeRecord.fullSkin?.format === FULL_SKIN_FORMAT, theme.id + " " + mode + " full-skin format mismatch", errors); check(modeRecord.fullSkin?.asset === "themes/" + theme.id + "/" + manifestMode.asset, theme.id + " " + mode + " full-skin asset path mismatch", errors); check(modeRecord.fullSkin?.sha256 === sha256(asset), theme.id + " " + mode + " full-skin asset hash mismatch", errors); diff --git a/tests/source-contract.test.mjs b/tests/source-contract.test.mjs index 054e87a..85cdac8 100644 --- a/tests/source-contract.test.mjs +++ b/tests/source-contract.test.mjs @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -6,10 +7,19 @@ import test from "node:test"; import { fileURLToPath } from "node:url"; import { assertGeneratedArtifacts } from "../scripts/ensure-generated.mjs"; +import { + generateRepository, + parseGenerationOptions, + readVerifiedSourceProvenance, +} from "../scripts/generate-themes.mjs"; import { CODEX_THEME_PREFIX, parseCodexNativeTheme, } from "../scripts/lib/codex-native-theme.mjs"; +import { + renderFingerprint, + SOURCE_ART_RENDERER_CONSTANTS, +} from "../scripts/lib/source-art-contract.mjs"; import { contrastRatio, isSafeRelativePath } from "../scripts/validate.mjs"; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); @@ -119,3 +129,114 @@ test("CI invokes the complete source and generated test contract", async functio assert.match(desktopWorkflow, /run: npm run test:ci/); assert.match(pagesWorkflow, /run: npm run test:ci && npm run build/); }); + +test("incremental generator options stay bounded and explicit", function () { + assert.deepEqual( + parseGenerationOptions([ + "--ids=qinglan-odyssey,jiangnan-rain-courtyard", + "--concurrency=1", + "--force", + "--json-progress", + "--summary", + ]), + { + check: false, + concurrency: 1, + force: true, + help: false, + ids: ["qinglan-odyssey", "jiangnan-rain-courtyard"], + jsonProgress: true, + summary: true, + }, + ); + assert.throws(() => parseGenerationOptions(["--concurrency=5"]), /from 1 to 4/); + assert.throws(() => parseGenerationOptions(["--concurrency=1.5"]), /from 1 to 4/); + assert.throws(() => parseGenerationOptions(["--concurrency=1junk"]), /from 1 to 4/); + assert.throws(() => parseGenerationOptions(["--ids="]), /at least one theme id/); +}); + +test("render fingerprints invalidate every declared visual input", function () { + const theme = { + art: { focusX: 0.6, focusY: 0.4, safeArea: "left", taskMode: "ambient" }, + light: { + tokens: { + background: "#123456", + surface: "#234567", + surfaceAlt: "#345678", + text: "#FFFFFF", + muted: "#CCCCCC", + accent: "#AA5500", + accentContrast: "#FFFFFF", + border: "#778899", + }, + }, + }; + const provenance = { sourceSha256: "a".repeat(64) }; + const baseline = renderFingerprint(theme, "light", provenance, 2560, 1440); + const mutations = [ + (candidate) => { candidate.art.focusX = 0.2; }, + (candidate) => { candidate.art.focusY = 0.8; }, + (candidate) => { candidate.art.safeArea = "right"; }, + (candidate) => { candidate.art.taskMode = "banner"; }, + (candidate) => { candidate.light.tokens.accent = "#0055AA"; }, + ]; + + for (const mutate of mutations) { + const candidate = structuredClone(theme); + mutate(candidate); + assert.notEqual(renderFingerprint(candidate, "light", provenance, 2560, 1440), baseline); + } + assert.notEqual(renderFingerprint(theme, "light", provenance, 960, 540), baseline); + assert.ok(Object.keys(SOURCE_ART_RENDERER_CONSTANTS.grading).length > 8); +}); + +test("generator rejects source art whose bytes no longer match provenance", async function () { + const temporary = await mkdtemp(path.join(os.tmpdir(), "awesome-codex-theme-source-provenance-")); + const sourceDirectory = path.join(temporary, "themes", "source-art"); + const source = Buffer.from("verified-source-art", "utf8"); + const sourceSha256 = createHash("sha256").update(source).digest("hex"); + + try { + await mkdir(sourceDirectory, { recursive: true }); + await writeFile(path.join(sourceDirectory, "fixture.png"), source); + await writeFile( + path.join(sourceDirectory, "fixture.provenance.json"), + JSON.stringify({ sourceSha256 }) + "\n", + "utf8", + ); + await assert.doesNotReject(readVerifiedSourceProvenance(temporary, "fixture")); + + await writeFile(path.join(sourceDirectory, "fixture.png"), Buffer.from("unexpected-source-art", "utf8")); + await assert.rejects( + readVerifiedSourceProvenance(temporary, "fixture"), + /Source-art hash does not match provenance/, + ); + } finally { + await rm(temporary, { recursive: true, force: true }); + } +}); + +test("targeted generation refuses a mixed legacy Registry migration", async function () { + const temporary = await mkdtemp(path.join(os.tmpdir(), "awesome-codex-theme-partial-migration-")); + const themesDirectory = path.join(temporary, "themes"); + + try { + await mkdir(themesDirectory, { recursive: true }); + await writeFile( + path.join(themesDirectory, "catalog.json"), + JSON.stringify({ collections: [], themes: [{ id: "fixture" }] }) + "\n", + "utf8", + ); + await writeFile( + path.join(themesDirectory, "registry.json"), + JSON.stringify({ generatedBy: "act-theme-generator-v4.0", themes: [{ id: "fixture" }] }) + "\n", + "utf8", + ); + await assert.rejects( + generateRepository({ root: temporary, options: { ids: ["fixture"] } }), + /Partial generation requires a current v5 Registry/, + ); + } finally { + await rm(temporary, { recursive: true, force: true }); + } +});