Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 14 additions & 13 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 23 additions & 0 deletions docs/schema/hyperframes.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
}
}
}
}
}
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
37 changes: 32 additions & 5 deletions packages/cli/src/commands/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
DEFAULT_PROJECT_CONFIG,
loadProjectConfig,
projectConfigPath,
recordProjectRegistryItems,
writeProjectConfig,
} from "../utils/projectConfig.js";
import { copyToClipboard } from "../utils/clipboard.js";
Expand Down Expand Up @@ -78,6 +79,23 @@ function variableValuesAttribute(values: Record<string, unknown> | 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,
Expand Down Expand Up @@ -321,13 +339,22 @@ export async function runAdd(opts: RunAddArgs): Promise<RunAddResult> {
});
}

// 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;

Expand Down
9 changes: 9 additions & 0 deletions packages/cli/src/commands/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -763,6 +770,7 @@ async function renderDocker(
docker: true,
gpu: options.gpu,
authoringSkill: options.authoringSkill,
catalogUsage: options.catalogUsage,
...getMemorySnapshot(),
}),
);
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/commands/render/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
38 changes: 37 additions & 1 deletion packages/cli/src/commands/render/plan.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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"),
'<main data-composition-id="main" data-width="1920" data-height="1080" data-fps="24">' +
'<div data-composition-src="compositions/kept.html" data-duration="2"></div></main>',
);
mkdirSync(join(projectDir, "compositions"), { recursive: true });
// `<template>`-wrapped, as sub-compositions are actually authored: template
// content is inert, so a DOM scan of these files would find nothing.
for (const name of ["kept", "dropped"]) {
writeFileSync(
join(projectDir, "compositions", `${name}.html`),
`<template id="${name}-template"><div data-composition-id="${name}" data-width="1920" data-height="1080"></div></template>`,
);
}
writeFileSync(
join(projectDir, "hyperframes.json"),
JSON.stringify({
registry: "https://example.test",
registryItems: [
{ name: "kept", type: "hyperframes:block", target: "compositions/kept.html" },
{ name: "dropped", type: "hyperframes:block", target: "compositions/dropped.html" },
],
}),
);

const plan = createRenderPlan({ dir: projectDir, output: "result.mp4" });
expect(plan.catalogUsage).toEqual({
installed: ["dropped", "kept"],
usedBlocks: ["kept"],
manifestUnreadable: false,
});
});

it("preserves an explicit strict-readiness opt-in", () => {
const plan = createRenderPlan({ dir: projectDir, "best-effort": false });
expect(plan.bestEffort).toBe(false);
Expand Down
9 changes: 8 additions & 1 deletion packages/cli/src/commands/render/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
} from "../../utils/renderArgs.js";
import { normalizeSkillSlug } from "../../telemetry/skill.js";
import { loadProjectConfig } from "../../utils/projectConfig.js";
import { type CatalogUsage, summarizeCatalogUsage } from "../../utils/catalogUsage.js";

const VALID_QUALITY = new Set(["draft", "standard", "high"]);
const RENDER_FORMATS = ["mp4", "webm", "mov", "png-sequence", "gif"] as const;
Expand Down Expand Up @@ -99,6 +100,8 @@ export interface RenderPlan {
quality: RenderQuality;
authoringSkill?: string;
invalidAuthoringSkill?: string;
/** Catalog items installed in this project, and those the entry reaches. */
catalogUsage: CatalogUsage;
format: RenderFormat;
gifLoop?: number;
gifFpsCapped: boolean;
Expand Down Expand Up @@ -204,6 +207,10 @@ export function createRenderPlan(args: RenderCommandArgs, now = new Date()): Ren
? args.skill
: undefined;

// Resolved here, once, from the same entry the render will use: batch rows
// vary only their variables, so every row shares this composition tree.
const catalogUsage = summarizeCatalogUsage(project.dir, renderTarget);

const formatRaw = args.format ?? "mp4";
const format = parseRenderFormat(formatRaw);
if (!format) {
Expand Down Expand Up @@ -412,6 +419,7 @@ export function createRenderPlan(args: RenderCommandArgs, now = new Date()): Ren
quality,
authoringSkill,
invalidAuthoringSkill,
catalogUsage,
format,
gifLoop,
gifFpsCapped,
Expand Down Expand Up @@ -461,7 +469,6 @@ export function renderOutputDirectory(plan: RenderPlan): string {

/** Resolve browser GPU mode from Docker, CLI, env, then the auto default. */
// Re-exported by render.ts to preserve its tested public seam.
// fallow-ignore-next-line unused-export
export function resolveBrowserGpuForCli(
useDocker: boolean,
browserGpuArg: boolean | undefined,
Expand Down
Loading
Loading