mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
feat(cli): report which catalog items a render actually used (#3470)
`registry_item_added` fires when a catalog block is installed and `render_complete` fires when a video is produced, but nothing joined them, so "did this video use the catalog?" had no answer. `hyperframes add` now records each installed item in `hyperframes.json` (installed files are plain composition HTML with no provenance marker, so this manifest is the only record that a file came from the registry), and `render_complete` reports both the items the project installed and the blocks the rendered composition actually reaches. An item installed and then never mounted was tried and dropped, which no add-time event can express. The scan answering "which sub-compositions does this file mount" now has one owner, `collectSubCompositionSrcs` in `@hyperframes/parsers`, shared with lint's `lintMissingOrEmptySubComposition`. It holds two invariants that were previously restated per call site and got re-derived wrongly: it is a text scan rather than a DOM query, because `<template>` content is inert and every sub-composition except the render entry is wrapped in one; and references resolve root-relative at every nesting level, matching `parseSubCompositions`. It walks tag by tag rather than running open-ended spans across the whole file, so a malformed composition cannot stall the render plan. Also: `registryItems` is declared in the config schema, which closes with `additionalProperties: false`, with an ajv-backed test pinning every key the CLI writes; counts are never truncated by the name cap, and the reported used blocks stay a subset of the reported installed ones, with `registry_items_truncated` marking a windowed list; and an unreadable manifest reports itself rather than posing as a project that never used the catalog.
This commit is contained in:
@@ -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:*",
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<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,
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
'<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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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) {
|
||||
@@ -412,6 +419,7 @@ export function createRenderPlan(args: RenderCommandArgs, now = new Date()): Ren
|
||||
quality,
|
||||
authoringSkill,
|
||||
invalidAuthoringSkill,
|
||||
catalogUsage,
|
||||
format,
|
||||
gifLoop,
|
||||
gifFpsCapped,
|
||||
@@ -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,
|
||||
|
||||
@@ -190,6 +190,128 @@ describe("render telemetry events", () => {
|
||||
flush.mockClear();
|
||||
});
|
||||
|
||||
// The catalog join. Counts must be present at zero: the no-catalog cohort is
|
||||
// what the with-catalog cohort is compared against, and an absent property is
|
||||
// indistinguishable from an older CLI that never sent one.
|
||||
it("reports zero catalog counts for a project with no registry items", () => {
|
||||
trackRenderComplete({
|
||||
durationMs: 1000,
|
||||
fps: 30,
|
||||
quality: "draft",
|
||||
docker: false,
|
||||
gpu: false,
|
||||
catalogUsage: { installed: [], usedBlocks: [], manifestUnreadable: false },
|
||||
});
|
||||
const props = trackEvent.mock.calls[0]?.[1] as Record<string, unknown>;
|
||||
expect(props.registry_item_count).toBe(0);
|
||||
expect(props.registry_blocks_used_count).toBe(0);
|
||||
expect(props.registry_items).toBeUndefined();
|
||||
});
|
||||
|
||||
it("names the installed items and the subset the render reached", () => {
|
||||
trackRenderComplete({
|
||||
durationMs: 1000,
|
||||
fps: 30,
|
||||
quality: "draft",
|
||||
docker: false,
|
||||
gpu: false,
|
||||
catalogUsage: {
|
||||
installed: ["bar-chart-race", "data-chart"],
|
||||
usedBlocks: ["data-chart"],
|
||||
manifestUnreadable: false,
|
||||
},
|
||||
});
|
||||
const props = trackEvent.mock.calls[0]?.[1] as Record<string, unknown>;
|
||||
expect(props.registry_items).toBe("bar-chart-race,data-chart");
|
||||
expect(props.registry_item_count).toBe(2);
|
||||
expect(props.registry_blocks_used).toBe("data-chart");
|
||||
expect(props.registry_blocks_used_count).toBe(1);
|
||||
});
|
||||
|
||||
// A count is one integer with no cardinality risk. Capping it would lose the
|
||||
// real number with no way downstream to tell 40 installs from 400.
|
||||
it("caps the item names but reports the true counts past the cap", () => {
|
||||
const installed = Array.from({ length: 45 }, (_, i) => `b${String(i + 1).padStart(2, "0")}`);
|
||||
trackRenderComplete({
|
||||
durationMs: 1000,
|
||||
fps: 30,
|
||||
quality: "draft",
|
||||
docker: false,
|
||||
gpu: false,
|
||||
catalogUsage: { installed, usedBlocks: installed.slice(-5), manifestUnreadable: false },
|
||||
});
|
||||
const props = trackEvent.mock.calls[0]?.[1] as Record<string, unknown>;
|
||||
expect(props.registry_item_count).toBe(45);
|
||||
expect(props.registry_blocks_used_count).toBe(5);
|
||||
expect(String(props.registry_items).split(",")).toHaveLength(40);
|
||||
// The names are a window, and a query joining on them would otherwise read
|
||||
// this project as 45 abandoned items: every used block sits past the cap,
|
||||
// so `registry_blocks_used` is absent against a count of 5.
|
||||
expect(props.registry_items_truncated).toBe(true);
|
||||
expect(props.registry_blocks_used).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not claim truncation when every name fits", () => {
|
||||
trackRenderComplete({
|
||||
durationMs: 1000,
|
||||
fps: 30,
|
||||
quality: "draft",
|
||||
docker: false,
|
||||
gpu: false,
|
||||
catalogUsage: {
|
||||
installed: ["bar-chart-race", "data-chart"],
|
||||
usedBlocks: ["data-chart"],
|
||||
manifestUnreadable: false,
|
||||
},
|
||||
});
|
||||
const props = trackEvent.mock.calls[0]?.[1] as Record<string, unknown>;
|
||||
expect(props.registry_items_truncated).toBeUndefined();
|
||||
});
|
||||
|
||||
// Sliced independently the two lists come out disjoint, which breaks the one
|
||||
// relationship any drop-off query relies on.
|
||||
it("keeps the used names a subset of the reported installed names", () => {
|
||||
const installed = Array.from({ length: 45 }, (_, i) => `b${String(i + 1).padStart(2, "0")}`);
|
||||
trackRenderComplete({
|
||||
durationMs: 1000,
|
||||
fps: 30,
|
||||
quality: "draft",
|
||||
docker: false,
|
||||
gpu: false,
|
||||
catalogUsage: { installed, usedBlocks: installed.slice(-5), manifestUnreadable: false },
|
||||
});
|
||||
const props = trackEvent.mock.calls[0]?.[1] as Record<string, unknown>;
|
||||
const reported = new Set(String(props.registry_items).split(","));
|
||||
const used =
|
||||
props.registry_blocks_used === undefined ? [] : String(props.registry_blocks_used).split(",");
|
||||
expect(used.every((name) => reported.has(name))).toBe(true);
|
||||
});
|
||||
|
||||
// The control cohort is the one that must not silently absorb failures: a
|
||||
// project whose manifest cannot be read is not a project without a catalog.
|
||||
it("flags an unreadable manifest instead of reporting it as zero catalog items", () => {
|
||||
trackRenderComplete({
|
||||
durationMs: 1000,
|
||||
fps: 30,
|
||||
quality: "draft",
|
||||
docker: false,
|
||||
gpu: false,
|
||||
catalogUsage: { installed: [], usedBlocks: [], manifestUnreadable: true },
|
||||
});
|
||||
const props = trackEvent.mock.calls[0]?.[1] as Record<string, unknown>;
|
||||
expect(props.registry_manifest_unreadable).toBe(true);
|
||||
expect(props.registry_item_count).toBeUndefined();
|
||||
});
|
||||
|
||||
// A caller that built render options by hand makes no catalog claim, rather
|
||||
// than claiming zero items.
|
||||
it("omits the catalog props entirely when usage was never resolved", () => {
|
||||
trackRenderComplete({ durationMs: 1, fps: 30, quality: "draft", docker: false, gpu: false });
|
||||
const props = trackEvent.mock.calls[0]?.[1] as Record<string, unknown>;
|
||||
expect(props.registry_item_count).toBeUndefined();
|
||||
expect(props.registry_blocks_used_count).toBeUndefined();
|
||||
});
|
||||
|
||||
it("flushes immediately after render_complete and render_error (exit races the lazy flush)", () => {
|
||||
trackRenderComplete({ durationMs: 1000, fps: 30, quality: "draft", docker: false, gpu: false });
|
||||
expect(flush).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { redactTelemetryString, type OutputResolutionIssueKind } from "@hyperframes/core";
|
||||
import type { SubTimelineWaitOutcome } from "@hyperframes/engine";
|
||||
import { FEEDBACK_RATING_SCALE } from "../utils/feedbackRating.js";
|
||||
import type { CatalogUsage } from "../utils/catalogUsage.js";
|
||||
import { flush, shouldTrack, trackEvent } from "./client.js";
|
||||
import { readConfig } from "./config.js";
|
||||
import { getPowerState } from "./system.js";
|
||||
@@ -173,6 +174,60 @@ export function trackCommand(command: string, runId?: string): void {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cap on item names in one event. Registry names are low-cardinality slugs, but
|
||||
* a project with a hundred blocks should not push a hundred-name string into
|
||||
* every render. The cap belongs at the boundary that builds the string, and
|
||||
* deliberately NOT on the counts: a count is one integer with no cardinality
|
||||
* risk, and a saturated one loses the real number with no way downstream to
|
||||
* tell 40 installs from 400.
|
||||
*/
|
||||
const MAX_REPORTED_ITEM_NAMES = 40;
|
||||
|
||||
/**
|
||||
* Catalog half of `render_complete`.
|
||||
*
|
||||
* Counts are emitted even when zero: the no-catalog cohort is exactly what the
|
||||
* with-catalog cohort gets compared against, and a property that is simply
|
||||
* absent is indistinguishable from an older CLI that never sent one. Names ride
|
||||
* as a comma-joined string because event property values are scalars only (same
|
||||
* shape as `recent_render_ids` on `cli_render_feedback`).
|
||||
*
|
||||
* The used names are narrowed to the reported installed names, so
|
||||
* `registry_blocks_used` stays a subset of `registry_items` even when the cap
|
||||
* bites. Sliced independently, the two lists can come out disjoint, breaking
|
||||
* the one relationship a drop-off query relies on. When the cap does bite,
|
||||
* `registry_items_truncated` says so: the counts still carry the truth, but the
|
||||
* names are a window, and a query that joins on names must not read the
|
||||
* difference as abandonment.
|
||||
*
|
||||
* An unreadable manifest reports itself and omits the counts rather than
|
||||
* sending zeros, so a failed read cannot pose as a project that never used the
|
||||
* catalog. Undefined usage means the caller built render options by hand rather
|
||||
* than through the render plan, so it makes no catalog claim at all.
|
||||
*/
|
||||
function catalogEventProperties(
|
||||
usage: CatalogUsage | undefined,
|
||||
): Record<string, string | number | boolean> {
|
||||
if (!usage) return {};
|
||||
if (usage.manifestUnreadable) return { registry_manifest_unreadable: true };
|
||||
const names = usage.installed.slice(0, MAX_REPORTED_ITEM_NAMES);
|
||||
const reportedNames = new Set(names);
|
||||
const used = usage.usedBlocks.filter((name) => reportedNames.has(name));
|
||||
const truncated = names.length < usage.installed.length;
|
||||
return {
|
||||
registry_item_count: usage.installed.length,
|
||||
registry_blocks_used_count: usage.usedBlocks.length,
|
||||
// Say when the name lists are a window rather than the whole set. Without
|
||||
// it a name-joining drop-off query silently reads a truncated project as
|
||||
// all-abandoned: the used blocks can all sit past the cap, leaving an empty
|
||||
// `registry_blocks_used` against a non-zero count.
|
||||
...(truncated ? { registry_items_truncated: true } : {}),
|
||||
...(names.length > 0 ? { registry_items: names.join(",") } : {}),
|
||||
...(used.length > 0 ? { registry_blocks_used: used.join(",") } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function trackRenderComplete(
|
||||
props: {
|
||||
durationMs: number;
|
||||
@@ -180,6 +235,13 @@ export function trackRenderComplete(
|
||||
quality: string;
|
||||
/** Authoring workflow skill that drove this render (e.g. "product-launch-video"). */
|
||||
authoringSkill?: string;
|
||||
/**
|
||||
* Catalog items installed in this project, and those the rendered
|
||||
* composition reaches. The pair is what joins `registry_item_added` to a
|
||||
* finished video: an installed item missing from the used set was tried
|
||||
* and dropped, which no add-time event can express.
|
||||
*/
|
||||
catalogUsage?: CatalogUsage;
|
||||
workers?: number;
|
||||
// Worker auto-sizing provenance (RenderPerfSummary.workerSizing). Answers
|
||||
// "why N workers?" fleet-wide, and validates the advisory per-worker heap
|
||||
@@ -292,6 +354,7 @@ export function trackRenderComplete(
|
||||
fps: props.fps,
|
||||
quality: props.quality,
|
||||
authoring_skill: props.authoringSkill,
|
||||
...catalogEventProperties(props.catalogUsage),
|
||||
workers: props.workers,
|
||||
workers_bound_by: props.workersBoundBy,
|
||||
workers_cpu_based: props.workersCpuBased,
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { summarizeCatalogUsage, type CatalogUsage } from "./catalogUsage.js";
|
||||
import type { RegistryItemRecord } from "./projectConfig.js";
|
||||
|
||||
/**
|
||||
* Materialize a throwaway project, summarize it, and clean up. Every case here
|
||||
* needs the same fixture, so the shape lives once.
|
||||
*/
|
||||
function usageOf(
|
||||
files: Record<string, string>,
|
||||
registryItems?: RegistryItemRecord[],
|
||||
entry = "index.html",
|
||||
): CatalogUsage {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-catalog-test-"));
|
||||
try {
|
||||
writeFileSync(
|
||||
join(dir, "hyperframes.json"),
|
||||
JSON.stringify({
|
||||
registry: "https://example.test",
|
||||
...(registryItems ? { registryItems } : {}),
|
||||
}),
|
||||
);
|
||||
for (const [rel, html] of Object.entries(files)) {
|
||||
const path = join(dir, rel);
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, html);
|
||||
}
|
||||
return summarizeCatalogUsage(dir, join(dir, entry));
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** Same as {@link usageOf}, but writes `hyperframes.json` verbatim. */
|
||||
function usageOfRawConfig(configText: string | null): CatalogUsage {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-catalog-test-"));
|
||||
try {
|
||||
if (configText !== null) writeFileSync(join(dir, "hyperframes.json"), configText);
|
||||
writeFileSync(join(dir, "index.html"), entryDoc());
|
||||
return summarizeCatalogUsage(dir, join(dir, "index.html"));
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function mountTag(src: string): string {
|
||||
return `<div data-composition-src="${src}" data-duration="3" data-width="1920" data-height="1080"></div>`;
|
||||
}
|
||||
|
||||
/** Render entry: a plain document, never `<template>`-wrapped (lint forbids it). */
|
||||
function entryDoc(...srcs: string[]): string {
|
||||
return `<!doctype html><html><body><div id="root" data-composition-id="main" data-width="1920" data-height="1080">${srcs
|
||||
.map(mountTag)
|
||||
.join("")}</div></body></html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A sub-composition as they are actually authored everywhere in this repo:
|
||||
* wrapped in `<template>`. Template content is inert, so a DOM scan of this
|
||||
* file finds nothing — the fixture exists to keep that failure from returning.
|
||||
*/
|
||||
function subCompDoc(id: string, ...srcs: string[]): string {
|
||||
return `<template id="${id}-template"><div data-composition-id="${id}" data-width="1920" data-height="1080">${srcs
|
||||
.map(mountTag)
|
||||
.join("")}</div></template>`;
|
||||
}
|
||||
|
||||
const BLOCK = (name: string): RegistryItemRecord => ({
|
||||
name,
|
||||
type: "hyperframes:block",
|
||||
target: `compositions/${name}.html`,
|
||||
});
|
||||
|
||||
describe("summarizeCatalogUsage", () => {
|
||||
it("reports nothing for a project that never added a catalog item", () => {
|
||||
expect(usageOf({ "index.html": entryDoc() })).toEqual({
|
||||
installed: [],
|
||||
usedBlocks: [],
|
||||
manifestUnreadable: false,
|
||||
});
|
||||
});
|
||||
|
||||
// The whole point of the manifest: an item that was installed and then not
|
||||
// mounted is a rejection, and no add-time event can say so.
|
||||
it("separates an installed block that the entry mounts from one it dropped", () => {
|
||||
expect(
|
||||
usageOf(
|
||||
{
|
||||
"index.html": entryDoc("compositions/kept.html"),
|
||||
"compositions/kept.html": subCompDoc("kept"),
|
||||
"compositions/dropped.html": subCompDoc("dropped"),
|
||||
},
|
||||
[BLOCK("kept"), BLOCK("dropped")],
|
||||
),
|
||||
).toEqual({
|
||||
installed: ["dropped", "kept"],
|
||||
usedBlocks: ["kept"],
|
||||
manifestUnreadable: false,
|
||||
});
|
||||
});
|
||||
|
||||
// Regression for two invariants that a DOM scan of raw files gets wrong, each
|
||||
// of which reports a block that renders in every video as abandoned:
|
||||
// `<template>` content is invisible to `querySelectorAll`, and nested
|
||||
// `data-composition-src` is root-relative, not relative to its own file.
|
||||
it("follows a root-relative mount from inside a template-wrapped sub-composition", () => {
|
||||
expect(
|
||||
usageOf(
|
||||
{
|
||||
"index.html": entryDoc("compositions/outer.html"),
|
||||
"compositions/outer.html": subCompDoc("outer", "compositions/inner.html"),
|
||||
"compositions/inner.html": subCompDoc("inner"),
|
||||
},
|
||||
[BLOCK("outer"), BLOCK("inner")],
|
||||
).usedBlocks,
|
||||
).toEqual(["inner", "outer"]);
|
||||
});
|
||||
|
||||
// A cyclic project must not wedge a render that already produced a video.
|
||||
it("terminates on a mount cycle", () => {
|
||||
expect(
|
||||
usageOf(
|
||||
{
|
||||
"index.html": entryDoc("compositions/a.html"),
|
||||
"compositions/a.html": subCompDoc("a", "compositions/b.html"),
|
||||
"compositions/b.html": subCompDoc("b", "compositions/a.html"),
|
||||
},
|
||||
[BLOCK("a"), BLOCK("b")],
|
||||
).usedBlocks,
|
||||
).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
// A mount the author commented out does not render, so it is not "used".
|
||||
it("ignores a commented-out mount", () => {
|
||||
expect(
|
||||
usageOf(
|
||||
{
|
||||
"index.html": `<!doctype html><html><body><!-- ${mountTag("compositions/kept.html")} --></body></html>`,
|
||||
"compositions/kept.html": subCompDoc("kept"),
|
||||
},
|
||||
[BLOCK("kept")],
|
||||
),
|
||||
).toEqual({ installed: ["kept"], usedBlocks: [], manifestUnreadable: false });
|
||||
});
|
||||
|
||||
// Components are pasted inline, so there is no src to match. Reporting one as
|
||||
// "used" would be a guess; reporting it as installed is a fact.
|
||||
it("counts a component as installed but never as used", () => {
|
||||
expect(
|
||||
usageOf({ "index.html": entryDoc() }, [
|
||||
{
|
||||
name: "film-grain",
|
||||
type: "hyperframes:component",
|
||||
target: "compositions/components/film-grain.html",
|
||||
},
|
||||
]),
|
||||
).toEqual({ installed: ["film-grain"], usedBlocks: [], manifestUnreadable: false });
|
||||
});
|
||||
|
||||
it("drops a manifest name that is not a safe slug rather than sending it", () => {
|
||||
expect(
|
||||
usageOf({ "index.html": entryDoc() }, [
|
||||
{ name: "/Users/someone/secret", type: "hyperframes:block", target: "compositions/x.html" },
|
||||
BLOCK("fine"),
|
||||
]).installed,
|
||||
).toEqual(["fine"]);
|
||||
});
|
||||
|
||||
it("never matches a manifest target that escapes the project directory", () => {
|
||||
expect(
|
||||
usageOf({ "index.html": entryDoc("compositions/kept.html") }, [
|
||||
{ name: "escaping", type: "hyperframes:block", target: "../outside.html" },
|
||||
]).usedBlocks,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("survives an entry file that does not exist", () => {
|
||||
expect(usageOf({}, [BLOCK("kept")], "missing.html")).toEqual({
|
||||
installed: ["kept"],
|
||||
usedBlocks: [],
|
||||
manifestUnreadable: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("contributes nothing for a remote mount, which has no local file to match", () => {
|
||||
expect(
|
||||
usageOf({ "index.html": entryDoc("https://example.test/compositions/kept.html") }, [
|
||||
BLOCK("kept"),
|
||||
]).usedBlocks,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
// A degraded read must not enrol a catalog user into the no-catalog control
|
||||
// group, which would bias the comparison toward "the catalog changes nothing".
|
||||
it("distinguishes a corrupt manifest from a project that never used the catalog", () => {
|
||||
expect(usageOfRawConfig("{ not valid json")).toEqual({
|
||||
installed: [],
|
||||
usedBlocks: [],
|
||||
manifestUnreadable: true,
|
||||
});
|
||||
expect(usageOfRawConfig('{ "registry": "https://example.test" }')).toEqual({
|
||||
installed: [],
|
||||
usedBlocks: [],
|
||||
manifestUnreadable: false,
|
||||
});
|
||||
expect(usageOfRawConfig(null)).toEqual({
|
||||
installed: [],
|
||||
usedBlocks: [],
|
||||
manifestUnreadable: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Which catalog (registry) items a project installed, and which of them the
|
||||
* composition being rendered actually reaches.
|
||||
*
|
||||
* `hyperframes add` is the only place that knows a file came from the registry
|
||||
* — installed files are plain composition HTML and carry no provenance marker —
|
||||
* so it records each item in `hyperframes.json`. Render reads that manifest
|
||||
* back and walks the composition's `data-composition-src` tree, letting the
|
||||
* render event report both halves: what the project pulled in, and what
|
||||
* survived into the video.
|
||||
*
|
||||
* The delta is the part no add-time event can produce. `registry_item_added`
|
||||
* says a block was installed; only this says it was then thrown away.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { isAbsolute, relative, resolve } from "node:path";
|
||||
import { collectSubCompositionSrcs } from "@hyperframes/parsers/asset-resolution";
|
||||
import { type RegistryItemRecord, readProjectConfigWithStatus } from "./projectConfig.js";
|
||||
|
||||
/** Installed catalog items, and the subset the rendered composition reaches. */
|
||||
export interface CatalogUsage {
|
||||
/**
|
||||
* Every item name recorded by `hyperframes add`, sorted, deduped, slug-gated.
|
||||
* Not truncated: the reporting cap belongs to whoever builds the event
|
||||
* string, so a count taken from this array is the real number.
|
||||
*/
|
||||
installed: string[];
|
||||
/**
|
||||
* Installed `hyperframes:block` items whose file is reachable from the render
|
||||
* entry. Always a subset of {@link installed}. Components are excluded: they
|
||||
* are pasted inline into the user's own markup rather than mounted by src, so
|
||||
* a component leaves no trace to match.
|
||||
*/
|
||||
usedBlocks: string[];
|
||||
/**
|
||||
* True when `hyperframes.json` exists but could not be read or parsed.
|
||||
*
|
||||
* A degraded read must not look like a project that never touched the
|
||||
* catalog: the no-catalog cohort is the control this whole feature is
|
||||
* measured against, and quietly enrolling failures into it biases the
|
||||
* comparison toward "the catalog makes no difference".
|
||||
*/
|
||||
manifestUnreadable: boolean;
|
||||
}
|
||||
|
||||
const EMPTY: CatalogUsage = Object.freeze({
|
||||
installed: [],
|
||||
usedBlocks: [],
|
||||
manifestUnreadable: false,
|
||||
});
|
||||
|
||||
const UNREADABLE: CatalogUsage = Object.freeze({
|
||||
installed: [],
|
||||
usedBlocks: [],
|
||||
manifestUnreadable: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Cap on files visited while walking the sub-composition tree. A composition
|
||||
* nests a handful of blocks; anything past this is a pathological or cyclic
|
||||
* project, and telemetry must not turn into an unbounded filesystem crawl.
|
||||
*/
|
||||
const MAX_VISITED_FILES = 250;
|
||||
|
||||
/** Cap on a single file fed to the scanner, mirroring the composition census. */
|
||||
const MAX_HTML_BYTES = 20 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Item names are slug-gated before they reach the anonymous event stream, the
|
||||
* same guard `normalizeSkillSlug` applies to authoring skills: a custom or
|
||||
* hand-edited registry must not be able to push paths, PII, or unbounded
|
||||
* cardinality into telemetry. The two rules share a shape but not an owner —
|
||||
* a registry name and a skill slug are free to diverge.
|
||||
*/
|
||||
const REGISTRY_ITEM_NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||
|
||||
/**
|
||||
* Absolute paths of every composition file reachable from `entryPath` through
|
||||
* `data-composition-src`, entry included.
|
||||
*
|
||||
* Two invariants are borrowed rather than re-derived, because getting either
|
||||
* wrong silently reports a block that renders in every video as abandoned:
|
||||
* references are collected by text scan (`collectSubCompositionSrcs`, so
|
||||
* `<template>`-wrapped sub-compositions are visible), and each one resolves
|
||||
* against the PROJECT ROOT at every nesting level, never the referencing
|
||||
* file's directory. Both mirror the renderer's `parseSubCompositions`.
|
||||
*
|
||||
* Unreadable files are skipped rather than thrown: this feeds a telemetry
|
||||
* property, and a render that produced a video must never fail on the way to
|
||||
* reporting it.
|
||||
*/
|
||||
function reachableCompositions(projectDir: string, entryPath: string): Set<string> {
|
||||
const seen = new Set<string>();
|
||||
const queue = [resolve(entryPath)];
|
||||
while (queue.length > 0 && seen.size < MAX_VISITED_FILES) {
|
||||
const current = queue.shift()!;
|
||||
if (seen.has(current)) continue;
|
||||
seen.add(current);
|
||||
let html: string;
|
||||
try {
|
||||
html = readFileSync(current, "utf-8");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (html.length > MAX_HTML_BYTES) continue;
|
||||
for (const src of collectSubCompositionSrcs(html)) {
|
||||
queue.push(resolve(projectDir, src));
|
||||
}
|
||||
}
|
||||
return seen;
|
||||
}
|
||||
|
||||
/** True when `target` (project-relative, per the manifest) is in `reachable`. */
|
||||
function isReached(projectDir: string, target: string, reachable: Set<string>): boolean {
|
||||
// A manifest target is written project-relative. Guard against an absolute
|
||||
// or escaping one rather than resolving it against the wrong root.
|
||||
if (isAbsolute(target)) return false;
|
||||
const abs = resolve(projectDir, target);
|
||||
if (relative(projectDir, abs).startsWith("..")) return false;
|
||||
return reachable.has(abs);
|
||||
}
|
||||
|
||||
function reportableNames(names: string[]): string[] {
|
||||
return [...new Set(names.filter((name) => REGISTRY_ITEM_NAME.test(name)))].sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the project's catalog manifest and resolve it against the composition
|
||||
* being rendered. Returns empty sets for a project that never ran
|
||||
* `hyperframes add`, which is the honest answer: no catalog items, not unknown.
|
||||
*/
|
||||
export function summarizeCatalogUsage(projectDir: string, entryPath: string): CatalogUsage {
|
||||
const { status, config } = readProjectConfigWithStatus(projectDir);
|
||||
if (status === "unreadable") return UNREADABLE;
|
||||
|
||||
const items: RegistryItemRecord[] = config?.registryItems ?? [];
|
||||
if (items.length === 0) return EMPTY;
|
||||
|
||||
const installed = reportableNames(items.map((item) => item.name));
|
||||
if (installed.length === 0) return EMPTY;
|
||||
|
||||
const reachable = reachableCompositions(projectDir, entryPath);
|
||||
const usedBlocks = reportableNames(
|
||||
items
|
||||
.filter(
|
||||
(item) =>
|
||||
item.type === "hyperframes:block" && isReached(projectDir, item.target, reachable),
|
||||
)
|
||||
.map((item) => item.name),
|
||||
);
|
||||
return { installed, usedBlocks, manifestUnreadable: false };
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
projectConfigPath,
|
||||
readProjectConfig,
|
||||
resolveAutoProxy,
|
||||
recordProjectRegistryItems,
|
||||
seedProjectAuthoringSkill,
|
||||
writeProjectConfig,
|
||||
PROJECT_CONFIG_FILENAME,
|
||||
@@ -364,4 +365,101 @@ describe("projectConfig", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
describe("recordProjectRegistryItems", () => {
|
||||
const BLOCK = {
|
||||
name: "data-chart",
|
||||
type: "hyperframes:block",
|
||||
target: "compositions/data-chart.html",
|
||||
};
|
||||
|
||||
it("appends installed items to an existing config", () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
writeProjectConfig(dir);
|
||||
recordProjectRegistryItems(dir, [BLOCK]);
|
||||
expect(loadProjectConfig(dir).registryItems).toEqual([BLOCK]);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("dedupes by name, so re-adding an item does not grow the manifest", () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
writeProjectConfig(dir);
|
||||
recordProjectRegistryItems(dir, [BLOCK]);
|
||||
recordProjectRegistryItems(dir, [BLOCK, { ...BLOCK, name: "bar-chart-race" }]);
|
||||
expect(loadProjectConfig(dir).registryItems?.map((i) => i.name)).toEqual([
|
||||
"data-chart",
|
||||
"bar-chart-race",
|
||||
]);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// The old guard compared object identity, and `add` always builds fresh
|
||||
// records, so a redundant re-add always rewrote the file. Pinned through
|
||||
// the file's own byte-level formatting: a rewrite would re-serialize it.
|
||||
it("does not rewrite the file when every item is already recorded", () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
const text = `{\n\t"registry": "https://example.com/r",\n\t"registryItems": [\n\t\t{ "name": "data-chart", "type": "hyperframes:block", "target": "compositions/data-chart.html" }\n\t]\n}\n`;
|
||||
writeFileSync(projectConfigPath(dir), text, "utf-8");
|
||||
recordProjectRegistryItems(dir, [{ ...BLOCK }]);
|
||||
expect(readFileSync(projectConfigPath(dir), "utf-8")).toBe(text);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// The config is normally committed, so an install must not rewrite keys it
|
||||
// does not own or reflow the file.
|
||||
it("preserves unknown keys and the file's own indentation", () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
writeFileSync(
|
||||
projectConfigPath(dir),
|
||||
JSON.stringify({ registry: "https://example.com/r", customKey: 42 }, null, 4),
|
||||
"utf-8",
|
||||
);
|
||||
recordProjectRegistryItems(dir, [BLOCK]);
|
||||
const text = readFileSync(projectConfigPath(dir), "utf-8");
|
||||
expect(text).toContain('"customKey": 42');
|
||||
expect(text).toContain('\n "registry"');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not create a config for a project that has none", () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
recordProjectRegistryItems(dir, [BLOCK]);
|
||||
expect(readProjectConfig(dir)).toBeUndefined();
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves a corrupt config untouched", () => {
|
||||
const dir = tmp();
|
||||
try {
|
||||
writeFileSync(projectConfigPath(dir), "{ not valid json", "utf-8");
|
||||
recordProjectRegistryItems(dir, [BLOCK]);
|
||||
expect(readFileSync(projectConfigPath(dir), "utf-8")).toBe("{ not valid json");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// normalizeConfig rebuilds from a whitelist; an omission there would read
|
||||
// as "this project never installed a catalog item".
|
||||
it("survives a normalizeConfig round-trip", () => {
|
||||
expect(normalizeConfig({ registryItems: [BLOCK] }).registryItems).toEqual([BLOCK]);
|
||||
expect(
|
||||
normalizeConfig({ registryItems: [{ name: "x" }] as never }).registryItems,
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,23 @@ export interface ProjectConfigMedia {
|
||||
autoProxy?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* One catalog item installed into this project by `hyperframes add`.
|
||||
*
|
||||
* Installed files are plain composition HTML with no provenance marker, so
|
||||
* this manifest is the only record that a file came from the registry. It is
|
||||
* what lets a render report which catalog items a finished video actually
|
||||
* used, instead of only which ones were once downloaded.
|
||||
*/
|
||||
export interface RegistryItemRecord {
|
||||
/** Registry item name, e.g. "data-chart". */
|
||||
name: string;
|
||||
/** Registry item type, e.g. "hyperframes:block". */
|
||||
type: string;
|
||||
/** Primary installed file, relative to the project root. */
|
||||
target: string;
|
||||
}
|
||||
|
||||
export interface ProjectConfig {
|
||||
$schema?: string;
|
||||
/** Base URL of the registry to pull items from. */
|
||||
@@ -49,6 +66,13 @@ export interface ProjectConfig {
|
||||
* telemetry without the caller re-passing the flag.
|
||||
*/
|
||||
authoringSkill?: string;
|
||||
/**
|
||||
* Catalog items installed by `hyperframes add`, in install order. Append-only
|
||||
* and deduped by name; removing an item from the project does not prune it,
|
||||
* so a later render still reports it as installed-but-unused rather than
|
||||
* silently forgetting it was ever tried.
|
||||
*/
|
||||
registryItems?: RegistryItemRecord[];
|
||||
}
|
||||
|
||||
export const DEFAULT_PROJECT_CONFIG: ProjectConfig = {
|
||||
@@ -69,16 +93,41 @@ export function projectConfigPath(projectDir: string): string {
|
||||
return join(resolve(projectDir), PROJECT_CONFIG_FILENAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a project-config read produced no config.
|
||||
*
|
||||
* Most callers only need "did I get one", but a caller reporting on the config
|
||||
* must not treat a broken file as an absent one: a project whose config cannot
|
||||
* be read is not a project without a config, and collapsing the two enrolls a
|
||||
* failure into whatever the empty case means.
|
||||
*/
|
||||
export type ProjectConfigReadStatus = "ok" | "missing" | "unreadable";
|
||||
|
||||
/** Read `hyperframes.json`, distinguishing an absent file from a broken one. */
|
||||
export function readProjectConfigWithStatus(projectDir: string): {
|
||||
status: ProjectConfigReadStatus;
|
||||
config?: ProjectConfig;
|
||||
} {
|
||||
const path = projectConfigPath(projectDir);
|
||||
let text: string;
|
||||
try {
|
||||
text = readFileSync(path, "utf-8");
|
||||
} catch (error) {
|
||||
// Anything other than "not there" (permissions, I/O) is a real failure to
|
||||
// read a config that may well exist.
|
||||
return { status: isFileNotFound(error) ? "missing" : "unreadable" };
|
||||
}
|
||||
try {
|
||||
return { status: "ok", config: normalizeConfig(JSON.parse(text) as Partial<ProjectConfig>) };
|
||||
} catch {
|
||||
return { status: "unreadable" };
|
||||
}
|
||||
}
|
||||
|
||||
/** Read `hyperframes.json` from a project directory. */
|
||||
export function readProjectConfig(projectDir: string): ProjectConfig | undefined {
|
||||
const path = projectConfigPath(projectDir);
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(path, "utf-8")) as Partial<ProjectConfig>;
|
||||
return normalizeConfig(parsed);
|
||||
} catch {
|
||||
// Missing file or corrupt JSON → no config.
|
||||
return undefined;
|
||||
}
|
||||
// Missing file or corrupt JSON → no config.
|
||||
return readProjectConfigWithStatus(projectDir).config;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,9 +153,31 @@ export function normalizeConfig(partial: Partial<ProjectConfig>): ProjectConfig
|
||||
// Slug-gate on read so a hand-edited or corrupt value never reaches the
|
||||
// telemetry stream; an invalid slug simply drops the attribution.
|
||||
authoringSkill: normalizeSkillSlug(partial.authoringSkill),
|
||||
// Whitelist rebuild - an omission here silently drops the manifest on
|
||||
// every config round-trip, which would read as "this project never
|
||||
// installed a catalog item".
|
||||
registryItems: normalizeRegistryItems(partial.registryItems),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only well-formed records. A hand-edited or partially-written manifest
|
||||
* degrades to the entries that still parse rather than failing a command that
|
||||
* merely wanted to read the registry URL.
|
||||
*/
|
||||
function normalizeRegistryItems(raw: unknown): RegistryItemRecord[] | undefined {
|
||||
if (!Array.isArray(raw)) return undefined;
|
||||
const items = raw.filter(
|
||||
(entry): entry is RegistryItemRecord =>
|
||||
isJsonObject(entry) &&
|
||||
typeof entry.name === "string" &&
|
||||
entry.name !== "" &&
|
||||
typeof entry.type === "string" &&
|
||||
typeof entry.target === "string",
|
||||
);
|
||||
return items.length > 0 ? items : undefined;
|
||||
}
|
||||
|
||||
/** Write `hyperframes.json` to a project directory. Overwrites if present. */
|
||||
export function writeProjectConfig(
|
||||
projectDir: string,
|
||||
@@ -162,9 +233,10 @@ function isFileNotFound(error: unknown): boolean {
|
||||
* empty slug is ignored. Best effort: a read-only or missing project directory
|
||||
* never fails the render it rode in on.
|
||||
*
|
||||
* This is the only writer that touches an ALREADY EXISTING `hyperframes.json`
|
||||
* (every other `writeProjectConfig` call site is guarded to write only when the
|
||||
* file is absent), so it must not round-trip through {@link normalizeConfig}:
|
||||
* One of two writers that touch an ALREADY EXISTING `hyperframes.json` (the
|
||||
* other is {@link recordProjectRegistryItems}; every plain `writeProjectConfig`
|
||||
* call site is guarded to write only when the file is absent), so it must not
|
||||
* round-trip through {@link normalizeConfig}:
|
||||
* that rebuilds the object from a field whitelist, which would drop keys it
|
||||
* does not know about and materialize defaults the user never wrote. The file
|
||||
* is normally committed, so a render must not introduce a diff beyond the one
|
||||
@@ -210,3 +282,64 @@ export function seedProjectAuthoringSkill(projectDir: string, rawSkill: unknown)
|
||||
// never a render blocker.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append installed catalog items to `hyperframes.json` so a later render can
|
||||
* report which of them the finished video actually used.
|
||||
*
|
||||
* Same in-place patch discipline as {@link seedProjectAuthoringSkill}, and for
|
||||
* the same reason: this writes an ALREADY EXISTING, normally committed config,
|
||||
* so it must not round-trip through {@link normalizeConfig} (a whitelist
|
||||
* rebuild would drop unknown keys and materialize defaults the user never
|
||||
* wrote). Existing entries are kept and deduped by name, so re-adding an item
|
||||
* does not grow the file and a manually pruned entry is not resurrected twice.
|
||||
*
|
||||
* Best effort throughout: a read-only project, a missing config, or corrupt
|
||||
* JSON must never fail the `add` it rode in on.
|
||||
*/
|
||||
export function recordProjectRegistryItems(
|
||||
projectDir: string,
|
||||
items: readonly RegistryItemRecord[],
|
||||
): void {
|
||||
if (items.length === 0) return;
|
||||
const path = projectConfigPath(projectDir);
|
||||
|
||||
let text: string;
|
||||
try {
|
||||
text = readFileSync(path, "utf-8");
|
||||
} catch {
|
||||
// No config to patch. `add` outside an initialized project is a valid
|
||||
// flow; it simply leaves no manifest behind.
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(text);
|
||||
if (!isJsonObject(parsed)) return;
|
||||
const existing = normalizeRegistryItems(parsed.registryItems) ?? [];
|
||||
const byName = new Map(existing.map((item) => [item.name, item]));
|
||||
for (const item of items) byName.set(item.name, item);
|
||||
// Compare by value, not identity: `add` always constructs fresh record
|
||||
// objects, so an identity check would never match and re-adding an
|
||||
// unchanged item would rewrite the file on every install.
|
||||
const merged = [...byName.values()];
|
||||
const unchanged =
|
||||
merged.length === existing.length &&
|
||||
merged.every((item, index) => {
|
||||
const prior = existing[index];
|
||||
return (
|
||||
prior !== undefined &&
|
||||
prior.name === item.name &&
|
||||
prior.type === item.type &&
|
||||
prior.target === item.target
|
||||
);
|
||||
});
|
||||
if (unchanged) return;
|
||||
parsed.registryItems = merged;
|
||||
const indent = /\n([ \t]+)"/.exec(text)?.[1] ?? " ";
|
||||
writeFileSync(path, JSON.stringify(parsed, null, indent) + "\n", "utf-8");
|
||||
} catch {
|
||||
// Corrupt JSON or a read-only file: the manifest is telemetry provenance,
|
||||
// never an install blocker.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* `docs/schema/hyperframes.json` is authored by hand (sync-schemas.ts mirrors
|
||||
* only the registry schemas and skips this one), sets `additionalProperties:
|
||||
* false` at both levels, and is the schema every generated `hyperframes.json`
|
||||
* points at. So a new config key that lands in code but not in the schema turns
|
||||
* a valid, committed config into one that fails validation in any schema-aware
|
||||
* editor, with nothing in CI to notice.
|
||||
*
|
||||
* This pins the two together by validating a config that exercises every key
|
||||
* the CLI can write, nested objects included. A key-name check alone is not
|
||||
* enough: `registryItems` is an array of objects with their own
|
||||
* `additionalProperties: false`, so a fourth field on `RegistryItemRecord`
|
||||
* would recreate the same break one level down.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
import {
|
||||
DEFAULT_PROJECT_CONFIG,
|
||||
type ProjectConfig,
|
||||
type RegistryItemRecord,
|
||||
} from "./projectConfig.js";
|
||||
|
||||
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../../..");
|
||||
const SCHEMA_PATH = resolve(REPO_ROOT, "docs/schema/hyperframes.json");
|
||||
|
||||
/**
|
||||
* One record carrying every field the CLI writes. Typed, so a new field on
|
||||
* `RegistryItemRecord` is a compile error here until it is listed, and then a
|
||||
* validation failure until it is declared in the schema.
|
||||
*/
|
||||
const EVERY_RECORD_FIELD: Required<RegistryItemRecord> = {
|
||||
name: "data-chart",
|
||||
type: "hyperframes:block",
|
||||
target: "compositions/data-chart.html",
|
||||
};
|
||||
|
||||
/** A config exercising every key the CLI can write, at every level. */
|
||||
const EVERY_WRITTEN_KEY: Required<ProjectConfig> = {
|
||||
$schema: "https://hyperframes.heygen.com/schema/hyperframes.json",
|
||||
registry: "https://example.test/registry",
|
||||
paths: DEFAULT_PROJECT_CONFIG.paths,
|
||||
media: { autoProxy: true },
|
||||
authoringSkill: "product-launch-video",
|
||||
registryItems: [EVERY_RECORD_FIELD],
|
||||
};
|
||||
|
||||
describe("hyperframes.json schema", () => {
|
||||
const schema = JSON.parse(readFileSync(SCHEMA_PATH, "utf-8")) as Record<string, unknown>;
|
||||
const validate = new Ajv2020({ strict: false, validateFormats: false }).compile(schema);
|
||||
|
||||
it("accepts a config using every key the CLI can write", () => {
|
||||
// Guard the guard: without a closed schema at both levels the assertion
|
||||
// below would pass for an undeclared key rather than failing.
|
||||
expect(schema.additionalProperties).toBe(false);
|
||||
const items = (schema.properties as Record<string, { items?: Record<string, unknown> }>)
|
||||
.registryItems?.items;
|
||||
expect(items?.additionalProperties).toBe(false);
|
||||
|
||||
expect(validate(EVERY_WRITTEN_KEY), JSON.stringify(validate.errors)).toBe(true);
|
||||
});
|
||||
|
||||
// Proves the check above can fail, rather than passing because ajv was
|
||||
// handed something it never rejects.
|
||||
it("rejects a key the schema does not declare, at either level", () => {
|
||||
expect(validate({ ...EVERY_WRITTEN_KEY, unknownTopLevelKey: 1 })).toBe(false);
|
||||
expect(
|
||||
validate({
|
||||
...EVERY_WRITTEN_KEY,
|
||||
registryItems: [{ ...EVERY_RECORD_FIELD, unknownItemKey: 1 }],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { checkSubCompositionUsability } from "@hyperframes/parsers/sub-compositi
|
||||
import { parseHTML } from "linkedom";
|
||||
import {
|
||||
cleanAssetUrl,
|
||||
collectSubCompositionSrcs,
|
||||
isRemoteOrInlineUrl,
|
||||
isUnresolvedAssetPlaceholder,
|
||||
isWithinProjectRoot,
|
||||
@@ -588,14 +589,9 @@ function lintMissingOrEmptySubComposition(
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
const walk = (html: string): void => {
|
||||
const compositionSrcRe = /<[^>]*\bdata-composition-src\s*=\s*["']([^"']+)["'][^>]*>/gi;
|
||||
const scannable = maskNonScannableRanges(html);
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = compositionSrcRe.exec(scannable)) !== null) {
|
||||
const srcPath = (match[1] ?? "").trim();
|
||||
if (!srcPath) continue;
|
||||
if (isUnresolvedAssetPlaceholder(srcPath)) continue; // __UPPER__ placeholder or late-bound templating token
|
||||
|
||||
// Shared scanner — see collectSubCompositionSrcs for why this must be a
|
||||
// text scan rather than a DOM query (template content is inert).
|
||||
for (const srcPath of collectSubCompositionSrcs(html)) {
|
||||
// data-composition-src is always written root-relative (even from a
|
||||
// nested sub-composition) — matches the resolution the renderer uses
|
||||
// in packages/producer/src/services/htmlCompiler.ts (parseSubCompositions
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isUnresolvedAssetPlaceholder, maskNonScannableRanges } from "./assetResolution.js";
|
||||
import {
|
||||
collectSubCompositionSrcs,
|
||||
isUnresolvedAssetPlaceholder,
|
||||
maskNonScannableRanges,
|
||||
} from "./assetResolution.js";
|
||||
|
||||
describe("maskNonScannableRanges", () => {
|
||||
it("masks complete comments without changing offsets", () => {
|
||||
@@ -55,3 +59,57 @@ describe("isUnresolvedAssetPlaceholder", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectSubCompositionSrcs", () => {
|
||||
it("finds mounts inside a template, which a DOM query cannot see", () => {
|
||||
const html =
|
||||
'<!doctype html><html><body><div data-composition-src="compositions/a.html"></div>' +
|
||||
'<template id="t"><div data-composition-src="compositions/b.html"></div></template></body></html>';
|
||||
expect(collectSubCompositionSrcs(html)).toEqual(["compositions/a.html", "compositions/b.html"]);
|
||||
});
|
||||
|
||||
it("skips commented-out, scripted, and styled mounts", () => {
|
||||
const html =
|
||||
'<!-- <div data-composition-src="commented.html"></div> -->' +
|
||||
"<script>const s = '<div data-composition-src=\"scripted.html\"></div>';</script>" +
|
||||
'<style>/* <div data-composition-src="styled.html"></div> */</style>' +
|
||||
'<div data-composition-src="real.html"></div>';
|
||||
expect(collectSubCompositionSrcs(html)).toEqual(["real.html"]);
|
||||
});
|
||||
|
||||
it("skips build-time placeholders and dedupes repeats", () => {
|
||||
const html =
|
||||
'<div data-composition-src="__SCENE__"></div>' +
|
||||
'<div data-composition-src="{{scene}}"></div>' +
|
||||
'<div data-composition-src="a.html"></div><div data-composition-src="a.html"></div>';
|
||||
expect(collectSubCompositionSrcs(html)).toEqual(["a.html"]);
|
||||
});
|
||||
|
||||
// A remote mount names no file on disk, and every caller resolves what comes
|
||||
// back against the project root. Letting one through yields a nonsense path
|
||||
// (`<projectDir>/https:/host/a.html`): a false "does not exist" for lint, and
|
||||
// a wasted slot against the telemetry walk's file budget.
|
||||
it("drops remote and inline mounts, keeping local ones", () => {
|
||||
const html =
|
||||
'<div data-composition-src="https://host/remote.html"></div>' +
|
||||
'<div data-composition-src="//host/protocol-relative.html"></div>' +
|
||||
'<div data-composition-src="data:text/html,inline"></div>' +
|
||||
'<div data-composition-src="compositions/local.html"></div>';
|
||||
expect(collectSubCompositionSrcs(html)).toEqual(["compositions/local.html"]);
|
||||
});
|
||||
|
||||
it("ignores an unterminated final tag and an attribute outside any tag", () => {
|
||||
expect(collectSubCompositionSrcs('<div data-composition-src="a.html"')).toEqual([]);
|
||||
expect(collectSubCompositionSrcs('data-composition-src="a.html"')).toEqual([]);
|
||||
});
|
||||
|
||||
// Regression guard, and it needs no timing assertion to bite: the previous
|
||||
// whole-file regex had two open-ended `[^>]*` spans, which is quadratic on
|
||||
// input full of `<` with no `>`. At 1MB that ran for minutes, so this case
|
||||
// failed on the suite timeout. This scan walks tag by tag and is linear.
|
||||
// The function is on the render-plan path, so a truncated download or a blob
|
||||
// of stray `<` must not be able to hang a render before it starts.
|
||||
it("stays fast on a megabyte of unterminated tag openings", () => {
|
||||
expect(collectSubCompositionSrcs("<".repeat(1024 * 1024))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,6 +42,75 @@ export function isUnresolvedAssetPlaceholder(rawSrc: string): boolean {
|
||||
return /^__[A-Z_]+__$/.test(rawSrc.trim()) || hasUnresolvedTemplatingToken(rawSrc);
|
||||
}
|
||||
|
||||
/** `data-composition-src="..."`, matched within a single already-delimited tag. */
|
||||
const COMPOSITION_SRC_ATTR = /\bdata-composition-src\s*=\s*["']([^"']+)["']/i;
|
||||
|
||||
/**
|
||||
* Every `data-composition-src` reference in one composition file's raw text, in
|
||||
* document order, deduped. The single owner of "which sub-compositions does
|
||||
* this file mount", so lint, telemetry, and any future scanner cannot drift
|
||||
* apart on the answer.
|
||||
*
|
||||
* Text-scanning rather than DOM-walking, and that is the load-bearing choice.
|
||||
* Every sub-composition except the render entry is authored inside a
|
||||
* `<template>` (the root index.html is forbidden from using that wrapper;
|
||||
* everything else prefers it). Template content is inert: it lives under
|
||||
* `template.content`, not the live document, so `document.querySelectorAll`
|
||||
* on a raw sub-composition file finds nothing and every nested reference
|
||||
* disappears. The renderer only gets away with a DOM scan because it recurses
|
||||
* on COMPILED html, where the wrapper is already gone.
|
||||
*
|
||||
* Callers must resolve each value against the PROJECT ROOT, never the
|
||||
* referencing file's directory: `data-composition-src` is root-relative at
|
||||
* every nesting level (see `parseSubCompositions` in htmlCompiler.ts).
|
||||
*
|
||||
* Comments, `<style>`, and `<script>` bodies are masked first so a
|
||||
* commented-out mount is not counted as a real one. Build-time placeholders and
|
||||
* remote or inline URLs are dropped: neither names a file on disk, and every
|
||||
* caller resolves what comes back against the project root.
|
||||
*
|
||||
* The scan walks tag by tag with `indexOf` rather than running one regex with
|
||||
* two open-ended `[^>]*` spans across the whole file. That shape is quadratic:
|
||||
* on input full of `<` with no `>`, every `<` starts a scan to end-of-string
|
||||
* that then backtracks, measured at 41ms / 165ms / 660ms / 2640ms for 10k /
|
||||
* 20k / 40k / 80k characters. This function runs on every render (via the
|
||||
* render plan), so a truncated download or a blob full of stray `<` would hang
|
||||
* the plan step before any video is produced. Bounding each regex to one
|
||||
* already-delimited tag makes the whole scan linear.
|
||||
*/
|
||||
export function collectSubCompositionSrcs(html: string): string[] {
|
||||
const scannable = maskNonScannableRanges(html);
|
||||
const srcs: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
let cursor = 0;
|
||||
while (cursor < scannable.length) {
|
||||
const open = scannable.indexOf("<", cursor);
|
||||
if (open === -1) break;
|
||||
const close = scannable.indexOf(">", open + 1);
|
||||
// An unterminated final tag is not a tag. The previous whole-file regex
|
||||
// also required a closing `>`, so this drops nothing it used to find.
|
||||
if (close === -1) break;
|
||||
cursor = close + 1;
|
||||
|
||||
const match = COMPOSITION_SRC_ATTR.exec(scannable.slice(open, cursor));
|
||||
if (!match) continue;
|
||||
const src = (match[1] ?? "").trim();
|
||||
if (!src || seen.has(src)) continue;
|
||||
// __UPPER__ placeholder or late-bound templating token — not a real reference.
|
||||
if (isUnresolvedAssetPlaceholder(src)) continue;
|
||||
// A remote or inline mount names no file on disk. Every caller resolves
|
||||
// these against the project root, so letting one through produces a
|
||||
// nonsense path (`<projectDir>/https:/host/a.html`) that then reads as a
|
||||
// missing local file: a false "does not exist" for lint, and a wasted
|
||||
// visit against the telemetry walk's file budget.
|
||||
if (isRemoteOrInlineUrl(src)) continue;
|
||||
seen.add(src);
|
||||
srcs.push(src);
|
||||
}
|
||||
return srcs;
|
||||
}
|
||||
|
||||
export function cleanAssetUrl(url: string): string {
|
||||
return url.trim().split(/[?#]/, 1)[0] ?? "";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user