diff --git a/bun.lock b/bun.lock index 93cf6e2e56..e2875368da 100644 --- a/bun.lock +++ b/bun.lock @@ -24,7 +24,7 @@ }, "packages/aws-lambda": { "name": "@hyperframes/aws-lambda", - "version": "0.8.5", + "version": "0.8.13", "dependencies": { "@aws-sdk/client-s3": "^3.700.0", "@aws-sdk/client-sfn": "^3.700.0", @@ -57,7 +57,7 @@ }, "packages/cli": { "name": "@hyperframes/cli", - "version": "0.8.5", + "version": "0.8.13", "bin": { "hyperframes": "./bin/hyperframes.mjs", }, @@ -94,6 +94,7 @@ "@types/fontkit": "^2.0.9", "@types/mime-types": "^3.0.1", "@types/node": "^25.0.10", + "ajv": "^8.20.0", "linkedom": "^0.18.12", "picocolors": "^1.1.1", "tsup": "^8.0.0", @@ -107,7 +108,7 @@ }, "packages/core": { "name": "@hyperframes/core", - "version": "0.8.5", + "version": "0.8.13", "dependencies": { "@chenglou/pretext": "^0.0.5", "@hyperframes/lint": "workspace:*", @@ -132,7 +133,7 @@ }, "packages/engine": { "name": "@hyperframes/engine", - "version": "0.8.5", + "version": "0.8.13", "dependencies": { "@hono/node-server": "^2.0.5", "@hyperframes/core": "workspace:^", @@ -151,7 +152,7 @@ }, "packages/gcp-cloud-run": { "name": "@hyperframes/gcp-cloud-run", - "version": "0.8.5", + "version": "0.8.13", "dependencies": { "@google-cloud/storage": "^7.14.0", "@google-cloud/workflows": "^4.2.0", @@ -172,7 +173,7 @@ }, "packages/lint": { "name": "@hyperframes/lint", - "version": "0.8.5", + "version": "0.8.13", "dependencies": { "@hyperframes/parsers": "workspace:*", "htmlparser2": "^10.1.0", @@ -190,7 +191,7 @@ }, "packages/parsers": { "name": "@hyperframes/parsers", - "version": "0.8.5", + "version": "0.8.13", "dependencies": { "@babel/parser": "^7.27.0", "acorn": "^8.17.0", @@ -210,7 +211,7 @@ }, "packages/player": { "name": "@hyperframes/player", - "version": "0.8.5", + "version": "0.8.13", "dependencies": { "@hyperframes/core": "workspace:*", }, @@ -225,7 +226,7 @@ }, "packages/producer": { "name": "@hyperframes/producer", - "version": "0.8.5", + "version": "0.8.13", "dependencies": { "@fontsource/archivo-black": "^5.2.8", "@fontsource/eb-garamond": "^5.2.7", @@ -270,7 +271,7 @@ }, "packages/sdk": { "name": "@hyperframes/sdk", - "version": "0.8.5", + "version": "0.8.13", "dependencies": { "@hyperframes/core": "workspace:*", "@hyperframes/parsers": "workspace:*", @@ -300,7 +301,7 @@ }, "packages/shader-transitions": { "name": "@hyperframes/shader-transitions", - "version": "0.8.5", + "version": "0.8.13", "dependencies": { "html2canvas": "^1.4.1", }, @@ -312,7 +313,7 @@ }, "packages/studio": { "name": "@hyperframes/studio", - "version": "0.8.5", + "version": "0.8.13", "dependencies": { "@codemirror/autocomplete": "^6.20.1", "@codemirror/commands": "^6.10.3", @@ -363,7 +364,7 @@ }, "packages/studio-server": { "name": "@hyperframes/studio-server", - "version": "0.8.5", + "version": "0.8.13", "dependencies": { "@hyperframes/core": "workspace:*", "@hyperframes/parsers": "workspace:*", diff --git a/docs/schema/hyperframes.json b/docs/schema/hyperframes.json index 8821e6631e..d950bc6fe0 100644 --- a/docs/schema/hyperframes.json +++ b/docs/schema/hyperframes.json @@ -56,6 +56,29 @@ "type": "string", "pattern": "^[a-z0-9][a-z0-9-]{0,63}$", "description": "Owning authoring-workflow skill slug (e.g. product-launch-video). Set by `hyperframes init --skill` or seeded from the first `hyperframes render --skill`; every render of this project is then attributed to it on anonymous telemetry, without re-passing the flag." + }, + "registryItems": { + "type": "array", + "description": "Catalog items installed by `hyperframes add`, in install order. Installed files are plain composition HTML with no provenance marker, so this is the only record that a file came from the registry; a render reads it back to report which catalog items the finished video actually used. Append-only, deduped by name.", + "items": { + "type": "object", + "required": ["name", "type", "target"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "Registry item name, e.g. data-chart." + }, + "type": { + "type": "string", + "description": "Registry item type, e.g. hyperframes:block." + }, + "target": { + "type": "string", + "description": "Primary installed file, relative to the project root." + } + } + } } } } diff --git a/packages/cli/package.json b/packages/cli/package.json index 3329b40539..ff56892fae 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -61,6 +61,7 @@ "@types/fontkit": "^2.0.9", "@types/mime-types": "^3.0.1", "@types/node": "^25.0.10", + "ajv": "^8.20.0", "linkedom": "^0.18.12", "picocolors": "^1.1.1", "tsup": "^8.0.0", diff --git a/packages/cli/src/commands/add.ts b/packages/cli/src/commands/add.ts index fd6ed2aba8..a0d8574711 100644 --- a/packages/cli/src/commands/add.ts +++ b/packages/cli/src/commands/add.ts @@ -25,6 +25,7 @@ import { DEFAULT_PROJECT_CONFIG, loadProjectConfig, projectConfigPath, + recordProjectRegistryItems, writeProjectConfig, } from "../utils/projectConfig.js"; import { copyToClipboard } from "../utils/clipboard.js"; @@ -78,6 +79,23 @@ function variableValuesAttribute(values: Record | null): string return ` data-variable-values='${json}'`; } +/** + * The file a consumer points at for this item: the snippet if it has one, else + * its composition, else whatever landed first. Project-relative, empty when the + * item installed no files. + * + * One owner for this choice. The paste snippet's `data-composition-src` and the + * recorded manifest target must name the same file, or a render would look for + * a block at a path the composition never mounts and report it as dropped. + */ +function primaryInstalledTarget(item: RegistryItem): string { + const primary = + item.files.find((f) => f.type === "hyperframes:snippet") ?? + item.files.find((f) => f.type === "hyperframes:composition") ?? + item.files[0]; + return primary?.target ?? ""; +} + export function buildSnippet( item: RegistryItem, relativeTarget: string, @@ -321,13 +339,22 @@ export async function runAdd(opts: RunAddArgs): Promise { }); } + // Persist what came from the registry. Installed files are plain composition + // HTML with no provenance marker, so without this a later render cannot tell + // a catalog block from one the user wrote — and "did the catalog item survive + // into the video?" stays unanswerable. + recordProjectRegistryItems( + projectDir, + installPlan.map((planItem) => ({ + name: planItem.name, + type: planItem.type, + target: primaryInstalledTarget(planItem), + })), + ); + // 6. Build include snippet + clipboard copy for the requested item. const itemForInstall = installPlan[installPlan.length - 1]!; - const primaryFile = - itemForInstall.files.find((f) => f.type === "hyperframes:snippet") ?? - itemForInstall.files.find((f) => f.type === "hyperframes:composition") ?? - itemForInstall.files[0]; - const snippetTargetRel = primaryFile?.target ?? ""; + const snippetTargetRel = primaryInstalledTarget(itemForInstall); const snippet = buildSnippet(item, snippetTargetRel, variableValues); const clipboardCopied = !opts.skipClipboard && snippet ? copyToClipboard(snippet) : false; diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 8170fc75aa..102a45bcc8 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -4,6 +4,7 @@ import type { Example } from "./_examples.js"; import { mkdtempSync, readdirSync, readFileSync, statSync, writeFileSync, rmSync } from "node:fs"; import { createRenderPlan, resolveBrowserGpuForCli, type RenderFormat } from "./render/plan.js"; import { seedProjectAuthoringSkill } from "../utils/projectConfig.js"; +import type { CatalogUsage } from "../utils/catalogUsage.js"; import { presentRenderPlan } from "./render/present.js"; import { executeRenderPlan, renderLintContinuationHint, runRenderLint } from "./render/execute.js"; // Test-only seams retained at the command boundary for render behavior tests. @@ -377,6 +378,12 @@ export interface RenderOptions { quality: "draft" | "standard" | "high"; /** Authoring workflow skill that drove this render (telemetry attribution). */ authoringSkill?: string; + /** + * Catalog items installed in this project and those the rendered composition + * reaches. Resolved once in the render plan; absent on programmatic callers + * that build options by hand, which simply omit the catalog properties. + */ + catalogUsage?: CatalogUsage; format: RenderFormat; gifLoop?: number; workers?: number; @@ -763,6 +770,7 @@ async function renderDocker( docker: true, gpu: options.gpu, authoringSkill: options.authoringSkill, + catalogUsage: options.catalogUsage, ...getMemorySnapshot(), }), ); @@ -1498,6 +1506,7 @@ function trackRenderMetrics( docker, gpu: options.gpu, authoringSkill: options.authoringSkill, + catalogUsage: options.catalogUsage, staticDedupEnabled: perf?.staticDedup?.enabled, staticDedupArmed: perf?.staticDedup?.armed, staticDedupSkipReason: perf?.staticDedup?.skipReason, diff --git a/packages/cli/src/commands/render/execute.ts b/packages/cli/src/commands/render/execute.ts index 163c849f23..301a9894fd 100644 --- a/packages/cli/src/commands/render/execute.ts +++ b/packages/cli/src/commands/render/execute.ts @@ -101,6 +101,7 @@ export async function executeRenderPlan( fps: plan.fps, quality: plan.quality, authoringSkill: plan.authoringSkill, + catalogUsage: plan.catalogUsage, format: plan.format, gifLoop: plan.gifLoop, workers: plan.workers, @@ -254,6 +255,7 @@ async function executeBatchRender( fps: plan.fps, quality: plan.quality, authoringSkill: plan.authoringSkill, + catalogUsage: plan.catalogUsage, format: plan.format, workers: plan.workers, gpu: plan.useGpu, diff --git a/packages/cli/src/commands/render/plan.test.ts b/packages/cli/src/commands/render/plan.test.ts index 03482cb0af..7aed45f5e5 100644 --- a/packages/cli/src/commands/render/plan.test.ts +++ b/packages/cli/src/commands/render/plan.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { CliUsageError } from "../../utils/commandResult.js"; @@ -37,6 +37,42 @@ describe("createRenderPlan", () => { expect(Object.isFrozen(plan.environment)).toBe(true); }); + // The catalog join reaches the render event through the plan, so a plan that + // silently drops it would leave every render reporting no catalog items. + it("resolves catalog usage from the project manifest and the render entry", () => { + writeFileSync( + join(projectDir, "index.html"), + '
' + + '
', + ); + mkdirSync(join(projectDir, "compositions"), { recursive: true }); + // `