perf: localize build and render working sets

Preserve forwarded Result values without eager materialization, advance live Builds from disposable indexes, and bound HyperFrames resource, source-frame, and output work.
This commit is contained in:
kashorin
2026-09-26 06:25:00 +08:00
parent 2c320059c1
commit 089c39a012
77 changed files with 4655 additions and 605 deletions
+1
View File
@@ -265,6 +265,7 @@ export function decodeBuildResultWriterState(
output,
build: buildId(forward.build, `${subject}.forwards[${index}].build`),
sourceOutput: text(forward.sourceOutput, `${subject}.forwards[${index}].sourceOutput`),
type: typeRef(forward.type, `${subject}.forwards[${index}].type`),
};
});
return {
+2
View File
@@ -17,6 +17,8 @@ export {
browseBuildResults,
buildResultDirectory,
describeBuildResultOutput,
locateBuildResultOutput,
locateRepositoryBuildResultOutput,
normalizeBuildResultForwards,
readBuildResult,
resolveBuildResultOutput,
+44 -10
View File
@@ -15,8 +15,8 @@ import {
import { createReadStream } from "node:fs";
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { assertOrderedBuildId, buildIdCreatedAt } from "@hypit/protocol";
import type { BlobRef } from "@hypit/protocol";
import { assertOrderedBuildId, buildIdCreatedAt, sameType } from "@hypit/protocol";
import type { BlobRef, TypeRef } from "@hypit/protocol";
import type {
BuildResultResourceSource,
@@ -32,6 +32,7 @@ import type {
BuildResultRepository,
RepositoryBuildResultOutput,
RepositoryBuildResultOutputDescription,
RepositoryBuildResultOutputLocation,
FinishedBuildResultManifest,
} from "./types.js";
import { assertBuildResultSeed } from "./types.js";
@@ -131,13 +132,48 @@ async function copyArtifactAtomic(
artifact: BlobRef,
destination: string,
): Promise<void> {
if (await exists(destination)) return;
await mkdir(dirname(destination), { recursive: true });
const input = await source.open(artifact);
if (input === undefined) throw new Error(`Build resource ${artifact.resource} is unavailable`);
await writeStreamAtomic(destination, input);
}
export async function locateRepositoryBuildResultOutput(
repository: Pick<BuildResultRepository, "read">,
build: string,
output: string,
): Promise<RepositoryBuildResultOutputLocation | undefined> {
const seen = new Set<string>();
let currentBuild = build;
let currentOutput = output;
while (true) {
const address = `${currentBuild}\u0000${currentOutput}`;
assert(!seen.has(address), `Build Output forwarding repeats ${currentBuild} / ${currentOutput}`);
seen.add(address);
const manifest = await repository.read(currentBuild);
if (manifest === undefined) return undefined;
assert(manifest.outcome !== undefined, `Build ${currentBuild} is not a finished Result`);
const entry = manifest.outputs[currentOutput];
if (entry === undefined) return undefined;
if (entry.value.kind === "build-output") {
currentBuild = entry.value.build;
currentOutput = entry.value.output;
continue;
}
return { build: currentBuild, output: currentOutput, type: entry.type };
}
}
export async function locateBuildResultOutput(
root: string,
build: string,
output: string,
): Promise<RepositoryBuildResultOutputLocation | undefined> {
return await locateRepositoryBuildResultOutput({
read: async (currentBuild) => await readBuildResult(buildResultDirectory(root, currentBuild)),
}, build, output);
}
async function writeStreamAtomic(destination: string, input: AsyncIterable<Uint8Array>): Promise<void> {
await mkdir(dirname(destination), { recursive: true });
const temporary = `${destination}.part-${randomUUID()}`;
@@ -334,27 +370,26 @@ export async function describeBuildResultOutput(
export async function normalizeBuildResultForwards(
repository: {
read(build: string): Promise<BuildResultManifest | undefined>;
resolve(build: string, output: string): Promise<{
readonly build: string;
readonly output: string;
} | undefined>;
},
forwards: readonly BuildResultForward[],
): Promise<readonly BuildResultForward[]> {
): Promise<readonly (BuildResultForward & { readonly type: TypeRef })[]> {
return await Promise.all(forwards.map(async (forward) => {
const source = await repository.read(forward.build);
assert(source?.outcome !== undefined,
`Build ${forward.build} is not a finished Result`);
const resolved = await repository.resolve(forward.build, forward.sourceOutput);
const resolved = await locateRepositoryBuildResultOutput(repository, forward.build, forward.sourceOutput);
assert(resolved !== undefined,
`Build ${forward.build} has no Output ${forward.sourceOutput}`);
const owner = resolved.build === source.id ? source : await repository.read(resolved.build);
assert(owner?.outcome !== undefined,
`Build ${resolved.build} is not a finished Result`);
assert(forward.type === undefined || sameType(forward.type, resolved.type),
`Build ${forward.build} Output ${forward.sourceOutput} has the wrong type for its forwarded Logical Output`);
return {
output: forward.output,
build: resolved.build,
sourceOutput: resolved.output,
type: resolved.type,
};
}));
}
@@ -371,7 +406,6 @@ export class FileBuildResult {
assertBuildResultSeed(seed);
const forwards = await normalizeBuildResultForwards({
read: async (build) => await readBuildResult(buildResultDirectory(root, build)),
resolve: async (build, output) => await resolveBuildResultOutput(root, build, output, externalFiles),
}, seed.forwards ?? []);
const directory = buildResultDirectory(root, seed.id);
await mkdir(dirname(directory), { recursive: true });
+9
View File
@@ -226,6 +226,15 @@ export type BuildResultForward = {
readonly output: string;
readonly build: string;
readonly sourceOutput: string;
/** Expected source type when the compiler has already inspected the historical Output. */
readonly type?: TypeRef;
};
/** One terminal historical Output address found without opening its value document or files. */
export type RepositoryBuildResultOutputLocation = {
readonly build: string;
readonly output: string;
readonly type: TypeRef;
};
export type BuildResultSeed = {
+120 -49
View File
@@ -1,4 +1,4 @@
import type { BlobRef, CanonicalValue, TypedRecord } from "@hypit/protocol";
import type { BlobRef, CanonicalValue, TypeRef, TypedRecord } from "@hypit/protocol";
import type {
BuildResultFileRef,
@@ -16,7 +16,12 @@ export type BuildResultWriterState = {
readonly resourceReferences?: Readonly<Record<string, BuildResultFileRef>>;
readonly values: Readonly<Record<string, string>>;
readonly publishedOutputs: readonly { readonly name: string; readonly output: string; readonly displayName?: string }[];
readonly forwards: readonly { readonly output: string; readonly build: string; readonly sourceOutput: string }[];
readonly forwards: readonly {
readonly output: string;
readonly build: string;
readonly sourceOutput: string;
readonly type: TypeRef;
}[];
};
export type BuildResultWriteTarget = {
@@ -39,20 +44,74 @@ function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
type NumberedPathAllocator = {
next: number;
readonly occupied: Set<string>;
};
function numberedPathAllocator(
directory: "files" | "values",
stem: "file" | "value",
paths: Iterable<string>,
): NumberedPathAllocator {
const occupied = new Set(paths);
const prefix = `${directory}/${stem}-`;
let highest = 0;
for (const path of occupied) {
if (!path.startsWith(prefix)) continue;
const match = /^(\d+)\.[^/]+$/u.exec(path.slice(prefix.length));
if (match === null) continue;
const index = Number(match[1]);
assert(Number.isSafeInteger(index), `Build Result path ${path} has an unsafe generated index`);
highest = Math.max(highest, index);
}
assert(highest < Number.MAX_SAFE_INTEGER, `Build Result ${directory} path index is exhausted`);
return { next: highest + 1, occupied };
}
function numberedPath(
directory: "files" | "values",
stem: "file" | "value",
extension: string,
occupied: ReadonlySet<string>,
allocator: NumberedPathAllocator,
): string {
let index = 1;
while (true) {
const candidate = `${directory}/${stem}-${String(index).padStart(4, "0")}${extension}`;
if (!occupied.has(candidate)) return candidate;
index += 1;
assert(Number.isSafeInteger(allocator.next), `Build Result ${directory} path index is exhausted`);
const candidate = `${directory}/${stem}-${String(allocator.next).padStart(4, "0")}${extension}`;
allocator.next += 1;
if (allocator.occupied.has(candidate)) continue;
allocator.occupied.add(candidate);
return candidate;
}
}
async function runBounded<T>(
values: readonly T[],
concurrency: number,
perform: (value: T) => Promise<void>,
): Promise<void> {
let next = 0;
let failed = false;
let failure: unknown;
const worker = async (): Promise<void> => {
while (!failed) {
const index = next;
next += 1;
if (index >= values.length) return;
try {
await perform(values[index]!);
} catch (error) {
if (!failed) {
failed = true;
failure = error;
}
}
}
};
await Promise.all(Array.from({ length: Math.min(concurrency, values.length) }, worker));
if (failed) throw failure;
}
function mediaExtension(mediaType: string): string {
const subtype = mediaType.split("/", 2)[1]?.split(";", 1)[0]?.trim().toLowerCase();
if (subtype === undefined || subtype.length === 0) return ".bin";
@@ -85,6 +144,13 @@ function outputRecord(input: BuildResultSync, output: string): TypedRecord | und
return binding === undefined ? undefined : input.state.records.find((item) => item.id === binding.record);
}
type ReadyOutput = {
readonly published: BuildResultWriterState["publishedOutputs"][number];
readonly type: TypeRef;
readonly record?: TypedRecord;
readonly forward?: HistoricalBuildOutputRef;
};
/**
* Encode accepted public Outputs exactly once, independently of the physical repository adapter.
* The adapter only writes bytes/documents at the paths chosen here.
@@ -97,77 +163,82 @@ export async function syncBuildResultOutputs(input: {
}): Promise<BuildResultSyncResult> {
const resources = new Map(Object.entries(input.writer.resources));
const values = new Map(Object.entries(input.writer.values));
const filePaths = numberedPathAllocator("files", "file", resources.values());
const valuePaths = numberedPathAllocator("values", "value", values.values());
const outputs: Record<string, BuildResultOutput> = { ...input.manifest.outputs };
const materialize = async (artifact: BlobRef): Promise<BuildResultFileRef> => {
const reference = input.writer.resourceReferences?.[artifact.resource];
if (reference !== undefined) return { ...reference, mediaType: artifact.mediaType };
const identity = artifact.resource;
assert(identity.length > 0, "Build resource has no instance identity");
let path = resources.get(identity);
if (path === undefined) {
path = numberedPath("files", "file", mediaExtension(artifact.mediaType), new Set(resources.values()));
resources.set(identity, path);
const materialize = async (artifacts: readonly BlobRef[]): Promise<readonly BuildResultFileRef[]> => {
const writes: Array<{ readonly path: string; readonly artifact: BlobRef }> = [];
const result = artifacts.map((artifact): BuildResultFileRef => {
const reference = input.writer.resourceReferences?.[artifact.resource];
if (reference !== undefined) return { ...reference, mediaType: artifact.mediaType };
const identity = artifact.resource;
assert(identity.length > 0, "Build resource has no instance identity");
let path = resources.get(identity);
if (path === undefined) {
path = numberedPath("files", "file", mediaExtension(artifact.mediaType), filePaths);
resources.set(identity, path);
writes.push({ path, artifact });
}
return { kind: "build-file", path, size: artifact.size, mediaType: artifact.mediaType };
});
await runBounded(writes, 4, async ({ path, artifact }) => {
await input.target.writeResource(path, artifact, input.sync.resources);
}
return { kind: "build-file", path, size: artifact.size, mediaType: artifact.mediaType };
});
return result;
};
const encodeComposite = async (
const encodeComposite = (
value: unknown,
path: BuildResultValuePath = [],
): Promise<{ readonly value: CanonicalValue; readonly resources: BuildResultValueDocument["resources"] }> => {
path: BuildResultValuePath,
artifacts: Array<{ readonly at: BuildResultValuePath; readonly artifact: BlobRef }>,
): CanonicalValue => {
if (isBlobRef(value)) {
return { value: null, resources: [{ at: path, file: await materialize(value) }] };
artifacts.push({ at: path, artifact: value });
return null;
}
if (scalar(value)) return { value, resources: [] };
if (scalar(value)) return value;
if (Array.isArray(value)) {
const children = await Promise.all(value.map(async (item, index) => await encodeComposite(item, [...path, index])));
return {
value: children.map((child) => child.value),
resources: children.flatMap((child) => child.resources),
};
return value.map((item, index) => encodeComposite(item, [...path, index], artifacts));
}
assert(value !== null && typeof value === "object", "Build Output contains a non-canonical value");
const entries = await Promise.all(Object.entries(value as Readonly<Record<string, unknown>>).map(async ([key, item]) => {
return [key, await encodeComposite(item, [...path, key])] as const;
}));
return {
value: Object.fromEntries(entries.map(([key, child]) => [key, child.value])),
resources: entries.flatMap(([, child]) => child.resources),
};
return Object.fromEntries(Object.entries(value as Readonly<Record<string, unknown>>)
.map(([key, item]) => [key, encodeComposite(item, [...path, key], artifacts)]));
};
const outputValue = async (record: TypedRecord): Promise<BuildResultOutput["value"]> => {
if (record.value.kind === "blob") return await materialize(record.value);
if (record.value.kind === "blob") return (await materialize([record.value]))[0]!;
if (scalar(record.value.value)) return { kind: "inline", value: record.value.value };
const existingPath = values.get(record.id);
if (existingPath !== undefined) return { kind: "value", path: existingPath };
const path = numberedPath("values", "value", ".json", new Set(values.values()));
const path = numberedPath("values", "value", ".json", valuePaths);
values.set(record.id, path);
const artifacts: Array<{ readonly at: BuildResultValuePath; readonly artifact: BlobRef }> = [];
const value = encodeComposite(record.value.value, [], artifacts);
const files = await materialize(artifacts.map((item) => item.artifact));
const document: BuildResultValueDocument = {
format: "hypit.result-value@1",
...await encodeComposite(record.value.value),
value,
resources: artifacts.map((item, index) => ({ at: item.at, file: files[index]! })),
};
await input.target.writeValue(path, document);
return { kind: "value", path };
};
const forwards = new Map(input.writer.forwards.map((item) => [item.output, item]));
const outputRank = (record: TypedRecord, forward: HistoricalBuildOutputRef | undefined): number => forward !== undefined
const outputRank = (record: TypedRecord | undefined, forward: HistoricalBuildOutputRef | undefined): number => forward !== undefined
? 0
: record.value.kind === "blob" ? 1 : scalar(record.value.value) ? 2 : 3;
const ready = input.writer.publishedOutputs.flatMap((published) => {
: record?.value.kind === "blob" ? 1 : record !== undefined && scalar(record.value.value) ? 2 : 3;
const ready: ReadyOutput[] = input.writer.publishedOutputs.flatMap((published): ReadyOutput[] => {
if (outputs[published.name] !== undefined) return [];
const record = outputRecord(input.sync, published.output);
if (record === undefined) return [];
const source = forwards.get(published.output);
const forward = source === undefined ? undefined : {
if (source !== undefined) return [{ published, type: source.type, forward: {
kind: "build-output" as const,
build: source.build,
output: source.sourceOutput,
};
return [{ published, record, forward }];
} }];
const record = outputRecord(input.sync, published.output);
return record === undefined ? [] : [{ published, type: record.type, record }];
}).sort((left, right) => outputRank(left.record, left.forward) - outputRank(right.record, right.forward)
|| left.published.name.localeCompare(right.published.name));
@@ -175,11 +246,11 @@ export async function syncBuildResultOutputs(input: {
return { changed: false, manifest: input.manifest, writer: input.writer };
}
for (const { published, record, forward } of ready) {
for (const { published, type, record, forward } of ready) {
outputs[published.name] = {
...(published.displayName === undefined ? {} : { displayName: published.displayName }),
type: record.type,
value: forward ?? await outputValue(record),
type,
value: forward ?? await outputValue(record!),
};
}
+183 -1
View File
@@ -2,9 +2,10 @@ import assert from "node:assert/strict";
import { mkdir, mkdtemp, open, readFile, readdir, rm, stat, utimes, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import test from "node:test";
import { buildResultDirectory, FileBuildResult, FileBuildResultRepository } from "@hypit/build-result";
import { buildResultDirectory, FileBuildResult, FileBuildResultRepository, syncBuildResultOutputs } from "@hypit/build-result";
import type { BlobRef, BuildState, TypeRef } from "@hypit/protocol";
const videoType: TypeRef = {
@@ -414,6 +415,187 @@ test("an explicitly reused public output is a forward reference and copies no by
}
});
test("a forward-only Composite is published from manifests without opening its missing value document", async () => {
const root = await mkdtemp(join(tmpdir(), "hypit-result-manifest-forward-"));
const priorId = "bld_20260902T100000030Z_0000000001";
const nextId = "bld_20260902T100000031Z_0000000001";
try {
const prior = await FileBuildResult.create(root, {
id: priorId,
source: { path: "/project/main.svml" },
targets: ["layout"],
publishedOutputs: [{ name: "layout", output: "logical:layout" }],
});
await prior.sync({
state: state({
records: [{ id: "record:layout", type: videoType, value: { kind: "inline", value: { title: "ready" } } }],
bindings: [{ output: "logical:layout", record: "record:layout" }],
}),
resources: { async open() { throw new Error("fixture has no files"); } },
});
await prior.finish({ outcome: "complete" });
await rm(join(prior.directory, "values", "value-0001.json"));
const forwarded = await FileBuildResult.create(root, {
id: nextId,
source: { path: "/project/main.svml" },
targets: ["layout"],
publishedOutputs: [{ name: "layout", output: "logical:layout" }],
forwards: [{ output: "logical:layout", build: priorId, sourceOutput: "layout", type: videoType }],
});
const manifest = await forwarded.sync({
state: state({ records: [], bindings: [], status: "complete" }),
resources: { async open() { throw new Error("a forward-only Result has no bytes to open"); } },
});
assert.deepEqual(manifest.outputs.layout, {
type: videoType,
value: { kind: "build-output", build: priorId, output: "layout" },
});
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("a failed filesystem sync cannot make a later resource inherit stale bytes", async () => {
const root = await mkdtemp(join(tmpdir(), "hypit-result-retry-path-"));
try {
const result = await FileBuildResult.create(root, {
id: "bld_20260924T100000000Z_0000000001",
source: { path: "/project/main.svml" },
targets: ["a", "b", "z"],
publishedOutputs: ["a", "b", "z"].map((name) => ({ name, output: name })),
});
const snapshot = (names: readonly string[]) => state({
records: names.map((name) => ({ id: name, type: videoType, value: {
kind: "blob", resource: `res_${name}`, size: 1, mediaType: "video/mp4",
} })),
bindings: names.map((name) => ({ output: name, record: name })),
});
let fail = true;
const resources = { async open(artifact: BlobRef) {
if (fail && artifact.resource === "res_z") throw new Error("intentional source failure");
return (async function* () { yield Buffer.from(artifact.resource.slice(-1).toUpperCase()); })();
} };
await assert.rejects(result.sync({ state: snapshot(["b", "z"]), resources }), /intentional source failure/u);
assert.deepEqual((await result.read()).outputs, {});
fail = false;
await result.sync({ state: snapshot(["a", "b", "z"]), resources });
const outputs = (await result.read()).outputs;
for (const name of ["a", "b", "z"]) {
const value = outputs[name]?.value;
assert.equal(value?.kind, "build-file");
if (value?.kind !== "build-file") throw new Error("expected file Output");
assert.equal(await readFile(join(result.directory, value.path), "utf8"), name.toUpperCase());
}
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("Result writer recovers one numeric cursor across file extensions and the 9999 boundary", async () => {
const artifact: BlobRef = {
kind: "blob", resource: "res_new-image", size: 1, mediaType: "image/png",
};
const resourcePaths: string[] = [];
const valuePaths: string[] = [];
const updated = await syncBuildResultOutputs({
manifest: {
format: "hypit.build-result@1",
id: "bld_20260924T110000000Z_0000000001",
source: { path: "main.svml" },
targets: ["image"],
outputs: {},
},
writer: {
resources: { old: "files/file-9999.mp4" },
values: { old: "values/value-9999.json" },
publishedOutputs: [{ name: "image", output: "logical:image" }],
forwards: [],
},
sync: {
state: state({
records: [{ id: "record:image", type: videoType, value: { kind: "inline", value: { artifact } } }],
bindings: [{ output: "logical:image", record: "record:image" }],
}),
resources: { async open() { throw new Error("target owns this test write"); } },
},
target: {
async writeResource(path) { resourcePaths.push(path); },
async writeValue(path) { valuePaths.push(path); },
},
});
assert.deepEqual(resourcePaths, ["files/file-10000.png"]);
assert.deepEqual(valuePaths, ["values/value-10000.json"]);
assert.equal(updated.manifest.outputs.image?.value.kind, "value");
});
test("Result writer bounds resource writes and drains started work before a failure escapes", async () => {
const artifacts = Array.from({ length: 12 }, (_, index): BlobRef => ({
kind: "blob", resource: `res_${String(index).padStart(2, "0")}`,
size: 1, mediaType: "image/png",
}));
const input = (target: import("@hypit/build-result").BuildResultWriteTarget) => syncBuildResultOutputs({
manifest: {
format: "hypit.build-result@1" as const,
id: "bld_20260924T110000001Z_0000000001",
source: { path: "main.svml" },
targets: ["images"],
outputs: {},
},
writer: {
resources: {}, values: {},
publishedOutputs: [{ name: "images", output: "logical:images" }],
forwards: [],
},
sync: {
state: state({
records: [{ id: "record:images", type: videoType, value: { kind: "inline", value: artifacts } }],
bindings: [{ output: "logical:images", record: "record:images" }],
}),
resources: { async open() { throw new Error("target owns this test write"); } },
},
target,
});
let active = 0;
let maximum = 0;
let completed = 0;
await input({
async writeResource() {
active += 1;
maximum = Math.max(maximum, active);
try { await delay(2); completed += 1; } finally { active -= 1; }
},
async writeValue() {},
});
assert.equal(maximum, 4);
assert.equal(completed, artifacts.length);
active = 0;
maximum = 0;
let started = 0;
let valueWrites = 0;
await assert.rejects(input({
async writeResource(_path, artifact) {
started += 1;
active += 1;
maximum = Math.max(maximum, active);
try {
if (artifact.resource === artifacts[0]!.resource) {
await delay(2);
throw new Error("intentional resource failure");
}
await delay(20);
} finally { active -= 1; }
},
async writeValue() { valueWrites += 1; },
}), /intentional resource failure/u);
assert.equal(maximum, 4);
assert.equal(started, 4, "no queued write starts after the first failure");
assert.equal(active, 0, "all already-started writes settle before sync rejects");
assert.equal(valueWrites, 0, "a value document cannot publish before all of its files exist");
});
test("filesystem Results have one public name per Output and browse newest first", async () => {
const root = await mkdtemp(join(tmpdir(), "hypit-build-result-browse-"));
const repository = new FileBuildResultRepository(root);
+4
View File
@@ -19,6 +19,7 @@ export function createCatalogDescriptor(options: {
readonly source: string;
readonly compilation: NodeCompiledSourceClosure;
readonly run?: { readonly path: string };
readonly targets?: readonly { readonly output: string }[];
}): BuildCatalogDescriptor {
const publishedOutputs = options.compilation.exports.flatMap((item) => {
if (item.ref.kind === "operation-result") {
@@ -44,6 +45,9 @@ export function createCatalogDescriptor(options: {
return {
source: { path: resolve(options.source) },
...(options.run === undefined ? {} : { run: { path: resolve(options.run.path) } }),
...(options.targets === undefined ? {} : {
targets: options.targets.map((target) => ({ kind: "logical-output" as const, id: target.output })),
}),
publishedOutputs,
};
}
+7 -3
View File
@@ -380,6 +380,7 @@ export async function runCli(
run: {
path: loadedRun.path,
},
targets: loadedRun.run.graph.targets,
});
const request = {
// One CLI invocation is one execution instance. Source and Plan identity
@@ -472,10 +473,13 @@ export async function runCli(
const finishedResult = finished
? await buildResults.repository.read(built.id)
: undefined;
const targetOutputs = new Set(built.state.targets.map((target) => target.output));
const presentation = catalog;
const targetPublishedOutputs = presentation.publishedOutputs.filter((published) =>
targetOutputs.has(published.ref.id));
const targetNames = new Set(finishedResult?.targets ?? []);
const targetOutputs = new Set(presentation.targets?.map((target) => target.id)
?? built.state.targets.map((target) => target.output));
const targetPublishedOutputs = presentation.publishedOutputs.filter((published) => finishedResult === undefined
? targetOutputs.has(published.ref.id)
: targetNames.has(published.name));
const targetPresentations = targetPublishedOutputs.flatMap((published) => {
const selection = built.state.plan.outputBindings.find((item) => item.output === published.ref.id);
const record = selection === undefined
+60 -25
View File
@@ -1,4 +1,4 @@
import { fileReferenceIdentity, ownedFileReference } from "@hypit/build-result";
import { fileReferenceIdentity, locateRepositoryBuildResultOutput, ownedFileReference } from "@hypit/build-result";
import { randomUUID } from "node:crypto";
import type {
BuildResultRepository,
@@ -48,33 +48,64 @@ export async function checkRunFile(options: {
return await compiler.checkSource(options.workspace.entry, options.workspace);
}
function replaceValueAtPath(
type ResultValueBindingTree = {
replacement?: BlobRef;
readonly children: Map<string | number, ResultValueBindingTree>;
};
function bindResultResources(
value: CanonicalValue,
path: BuildResultValuePath,
replacement: BlobRef,
bindings: readonly { readonly at: BuildResultValuePath; readonly replacement: BlobRef }[],
): CanonicalValue {
if (path.length === 0) return replacement;
const segment = path[0]!;
const rest = path.slice(1);
if (typeof segment === "number") {
if (!Array.isArray(value) || segment < 0 || segment >= value.length) {
throw new Error(`Result Resource path ${JSON.stringify(path)} does not address an array item`);
const root: ResultValueBindingTree = { children: new Map() };
for (const binding of bindings) {
let node = root;
for (const segment of binding.at) {
let child = node.children.get(segment);
if (child === undefined) {
child = { children: new Map() };
node.children.set(segment, child);
}
node = child;
}
return value.map((item, index) => index === segment
? replaceValueAtPath(item, rest, replacement)
: item);
if (node.replacement !== undefined) {
throw new Error(`Result Resource path ${JSON.stringify(binding.at)} is bound more than once`);
}
node.replacement = binding.replacement;
}
if (value === null || Array.isArray(value) || typeof value !== "object" || !Object.hasOwn(value, segment)) {
throw new Error(`Result Resource path ${JSON.stringify(path)} does not address an object property`);
}
return {
...value,
[segment]: replaceValueAtPath(
(value as Readonly<Record<string, CanonicalValue>>)[segment]!,
rest,
replacement,
),
const rebuild = (current: CanonicalValue, node: ResultValueBindingTree, path: BuildResultValuePath): CanonicalValue => {
if (node.replacement !== undefined) {
if (node.children.size > 0 || current !== null) {
throw new Error(`Result Resource path ${JSON.stringify(path)} does not address a null Resource slot`);
}
return node.replacement;
}
if (node.children.size === 0) return current;
if (Array.isArray(current)) {
for (const segment of node.children.keys()) {
if (typeof segment !== "number" || segment < 0 || segment >= current.length) {
throw new Error(`Result Resource path ${JSON.stringify([...path, segment])} does not address an array item`);
}
}
return current.map((item, index) => {
const child = node.children.get(index);
return child === undefined ? item : rebuild(item, child, [...path, index]);
});
}
if (current === null || typeof current !== "object") {
throw new Error(`Result Resource path ${JSON.stringify(path)} does not address a container`);
}
const record = current as Readonly<Record<string, CanonicalValue>>;
const result: Record<string, CanonicalValue> = { ...record };
for (const [segment, child] of node.children) {
if (typeof segment !== "string" || !Object.hasOwn(record, segment)) {
throw new Error(`Result Resource path ${JSON.stringify([...path, segment])} does not address an object property`);
}
result[segment] = rebuild(record[segment]!, child, [...path, segment]);
}
return result;
};
return rebuild(value, root, []);
}
/** Resolve one historical public Output into an ordinary Run value plus lazy file attachments. */
@@ -132,12 +163,13 @@ export async function resolveBuildResultValue(
} else if (resolved.value.kind === "inline") {
value = { kind: "inline", value: resolved.value.value };
} else {
let composite = resolved.value.document.value;
const bindings: Array<{ readonly at: BuildResultValuePath; readonly replacement: BlobRef }> = [];
for (const binding of resolved.value.document.resources) {
const attachment = await resultAttachment(resolved.build, binding.file);
attachments.set(attachment.artifact.resource, attachment);
composite = replaceValueAtPath(composite, binding.at, attachment.artifact);
bindings.push({ at: binding.at, replacement: attachment.artifact });
}
const composite = bindResultResources(resolved.value.document.value, bindings);
value = { kind: "inline", value: composite };
}
return {
@@ -164,6 +196,9 @@ function createRunCompiler(options: {
frontends,
fragments,
...(options.results === undefined ? {} : {
async locateHistoricalOutput(id: string, output: string) {
return await locateRepositoryBuildResultOutput(options.results!, id, output);
},
async resolveHistoricalOutput(id: string, output: string) {
return await resolveBuildResultValue(options.results!, id, output, resultSession);
},
+90 -19
View File
@@ -6,7 +6,12 @@ import { pathToFileURL } from "node:url";
import { commandHint } from "../src/command-hint.js";
import test from "node:test";
import { FileBuildResult, FileBuildResultRepository } from "@hypit/build-result";
import {
fileReferenceIdentity,
FileBuildResult,
FileBuildResultRepository,
ownedFileReference,
} from "@hypit/build-result";
import { ModulePackageRegistry, NodeCompiler } from "@hypit/compiler-node";
import { AuthorFrontendRegistry, sealGraphFragment } from "@hypit/elaborator";
import { createMarkupAuthorFrontend, MarkupSurfaceRegistry } from "@hypit/markup";
@@ -17,7 +22,7 @@ import { NodeFilesystemWorkspace } from "@hypit/workspace-fs-node";
import type { CliDistribution } from "../src/distribution.js";
import { createCatalogDescriptor } from "../src/build-planning.js";
import { runCli } from "../src/main.js";
import { loadRunFile } from "../src/run-file.js";
import { loadRunFile, resolveBuildResultValue } from "../src/run-file.js";
const valueType: TypeRef = {
module: { name: "example.result", version: "1" },
@@ -462,7 +467,12 @@ test("build-record selects one exact Result Output without leaking its storage a
const workspace = await authorCompiler.openFile(runFile);
const repository = new FileBuildResultRepository(resultsRoot);
let opens = 0;
let resolves = 0;
const countedRepository: FileBuildResultRepository = Object.create(repository) as FileBuildResultRepository;
countedRepository.resolve = async (build, output) => {
resolves += 1;
return await repository.resolve(build, output);
};
countedRepository.openFile = async (build, file) => {
opens += 1;
return await repository.openFile(build, file);
@@ -485,31 +495,61 @@ test("build-record selects one exact Result Output without leaking its storage a
assert.equal(candidate?.root.kind, "value");
const value = candidate?.root.kind === "value" ? candidate.root.value.value : undefined;
assert.equal(value?.kind, "inline");
assert.equal(value?.kind === "inline"
? (value.value as { readonly artifact?: { readonly kind?: string } }).artifact?.kind
: undefined, "blob");
assert.equal(value?.kind === "inline" ? value.value : undefined, null,
"a forward-only Candidate remains structural and is never materialized into Core");
const logicalOutput = loaded.author.exports.find((item) => item.name === "shot.video")?.ref;
assert.equal(logicalOutput?.kind, "logical-output");
assert.deepEqual(loaded.compiler.planCompilation(loaded).resultForwards, [{
output: logicalOutput!.id,
build: "bld_20260902T110000001Z_0000000001",
sourceOutput: "shot.video",
type: videoType,
}]);
assert.equal(loaded.attachments.length, 1);
assert.deepEqual(Object.values(loaded.resultResourceReferences), [{
kind: "build-file", build: "bld_20260902T110000001Z_0000000001",
path: "files/file-0001.mp4", size: bytes.byteLength, mediaType: "video/mp4",
}]);
assert.equal(opens, 0, "compilation does not read or summarize historical bytes");
const reused: number[] = [];
for await (const chunk of await loaded.attachments[0]!.open()) reused.push(...chunk);
assert.deepEqual(Uint8Array.from(reused), bytes);
assert.equal(opens, 1, "Runtime staging opens the historical Result exactly once");
assert.equal(loaded.compiler.planCompilation(loaded).state.plan.steps.length, 0);
assert.equal(resolves, 0, "forward-only compilation never opens the historical value document");
assert.equal(loaded.attachments.length, 0);
assert.deepEqual(loaded.resultResourceReferences, {});
const forwardedPlan = loaded.compiler.planCompilation(loaded);
assert.equal(forwardedPlan.state.plan.steps.length, 0);
assert.equal(forwardedPlan.state.status, "complete");
assert.deepEqual(forwardedPlan.state.targets, []);
assert.equal(opens, 0);
const materializeImport = async (base: typeof loaded, build: string): Promise<typeof loaded> => {
const [resolved, stored] = await Promise.all([
resolveBuildResultValue(repository, build, "shot.video"),
repository.resolve(build, "shot.video"),
]);
assert(resolved !== undefined && resolved.value.kind === "inline");
assert(stored?.value.kind === "value");
if (stored?.value.kind !== "value") throw new Error("expected composite");
const files = new Map<string, typeof stored.value.document.resources[number]["file"]>();
for (const binding of stored.value.document.resources) {
files.set(fileReferenceIdentity(stored.build, binding.file), binding.file);
}
const references = Object.fromEntries((resolved.attachments ?? []).map((attachment, index) => {
const file = [...files.values()][index];
if (file === undefined) throw new Error("resolved attachment has no Result file");
return [attachment.artifact.resource, ownedFileReference(stored.build, file)];
}));
return {
...base,
run: {
...base.run,
graph: {
...base.run.graph,
candidates: base.run.graph.candidates.map((item, index) => index === 0 && item.root.kind === "value"
? { ...item, root: { ...item.root, value: { ...item.root.value, value: resolved.value } } }
: item),
},
},
attachments: resolved.attachments ?? [],
resultResourceReferences: references,
};
};
// New Composite records may wrap imported values at arbitrary depth. Their Resource ownership
// must survive multiple Builds, including a mix of old media and newly produced bytes.
let imported = loaded;
let imported = await materializeImport(loaded, "bld_20260902T110000001Z_0000000001");
const owner = "bld_20260902T110000001Z_0000000001";
for (const id of ["bld_20260902T110000002Z_0000000001", "bld_20260902T110000003Z_0000000001"]) {
const rootValue = imported.run.graph.candidates[0]!.root;
@@ -542,8 +582,8 @@ test("build-record selects one exact Result Output without leaking its storage a
for await (const chunk of (await repository.openFile(id, original.file))!) received.push(...chunk);
assert.deepEqual(Uint8Array.from(received), bytes);
await writeFile(runFile, (await readFile(runFile, "utf8")).replace(/build="[^"]+"/, `build="${id}"`));
imported = await loadRunFile({ workspace: await authorCompiler.openFile(runFile), authorCompiler,
frontends: [runMarkupFrontend], packageContributions: [], results: repository });
imported = await materializeImport(await loadRunFile({ workspace: await authorCompiler.openFile(runFile), authorCompiler,
frontends: [runMarkupFrontend], packageContributions: [], results: repository }), id);
}
// Explicit export collects a standalone bundle, including same-named files with different owners.
@@ -599,6 +639,37 @@ test("build-record selects one exact Result Output without leaking its storage a
}
});
test("Composite Result resources rebuild each shared container once", async () => {
const count = 400;
let visited = 0;
class CountedArray extends Array<null> {
override map<U>(callback: (value: null, index: number, array: null[]) => U, thisArg?: unknown): U[] {
visited += this.length;
return super.map(callback, thisArg);
}
}
const value = new CountedArray(count).fill(null);
const file = { kind: "build-file" as const, path: "files/one.mp4", size: 1, mediaType: "video/mp4" };
const repository = {
read: async () => ({ outcome: "complete" as const }),
resolve: async () => ({ build: "owner", output: "composite", type: valueType, value: {
kind: "value" as const,
path: "values/value-0001.json",
document: {
format: "hypit.result-value@1" as const,
value,
resources: Array.from({ length: count }, (_, index) => ({ at: [index], file })),
},
} }),
describeFile: async () => file,
openFile: async () => undefined,
} as unknown as import("@hypit/build-result").BuildResultRepository;
const resolved = await resolveBuildResultValue(repository, "owner", "composite");
assert.equal(resolved?.value.kind, "inline");
assert.equal(visited, count, "the root array is rebuilt once rather than once per Resource binding");
assert.equal(resolved?.attachments?.length, 1, "one historical file identity remains one lazy attachment");
});
test("a failed Result exposes task receipts and credential references without a Runtime", async () => {
const root = await mkdtemp(join(tmpdir(), "hypit-receipts-"));
const id = "bld_20260906T110000000Z_0000000001";
+73 -7
View File
@@ -37,6 +37,20 @@ export type NodeRunCompilerOptions = {
readonly authorCompiler: NodeCompiler;
readonly frontends: RunFrontendRegistryLike;
readonly fragments: RunFragmentRegistryLike;
readonly locateHistoricalOutput?: (
build: string,
output: string,
) => Promise<{
readonly build: string;
readonly output: string;
readonly type: TypeRef;
} | undefined>
| {
readonly build: string;
readonly output: string;
readonly type: TypeRef;
}
| undefined;
readonly resolveHistoricalOutput?: (
build: string,
output: string,
@@ -60,6 +74,12 @@ export type NodeCompiledRun = {
/** Author program rebound to the union closure required by Author and selected Run code. */
readonly program: LinkedProgram;
readonly run: RunCompilation;
/** Terminal historical addresses selected during structural planning, keyed by Candidate id. */
readonly historicalOutputs: Readonly<Record<string, {
readonly build: string;
readonly output: string;
readonly type: TypeRef;
}>>;
readonly attachments: readonly ArtifactAttachment[];
};
@@ -86,6 +106,7 @@ export type PlannedBuild = {
readonly output: string;
readonly build: string;
readonly sourceOutput: string;
readonly type: TypeRef;
}[];
/** Immutable authority persisted once for every fresh Runtime Build. */
readonly definition: BuildDefinition;
@@ -252,6 +273,7 @@ export class NodeRunCompiler {
collectRunModuleRequests(decoded.document, this.#options.fragments),
);
const executionCompilation = program === author.program ? author : { ...author, program };
const locateHistoricalOutput = this.#options.locateHistoricalOutput;
const resolveHistoricalOutput = this.#options.resolveHistoricalOutput;
const buildAttachments: ArtifactAttachment[] = [];
const structuralRun = await resolveRunDocument(decoded.document, {
@@ -262,6 +284,12 @@ export class NodeRunCompiler {
const selectedSources = new Set(structuralPlan.selections
.filter((selection) => structuralRun.candidateSources[selection.candidate] !== undefined)
.map((selection) => selection.candidate));
const consumedRecords = new Set(structuralPlan.plan.steps.flatMap((step) => Object.values(step.inputs)));
const historicalOutputs = new Map<string, {
readonly build: string;
readonly output: string;
readonly type: TypeRef;
}>();
const resolvedValues = new Map<string, StoredValue>();
for (const candidate of selectedSources) {
const candidateSource = structuralRun.candidateSources[candidate]!;
@@ -278,6 +306,21 @@ export class NodeRunCompiler {
));
continue;
}
if (locateHistoricalOutput === undefined) {
throw new Error(`Historical Build Candidate ${candidateSource.build}/${candidateSource.output} requires a project Result Store`);
}
const located = await locateHistoricalOutput(candidateSource.build, candidateSource.output);
if (located === undefined) {
throw new Error(`Build ${candidateSource.build} has no Output ${candidateSource.output}`);
}
const declaration = structuralRun.graph.candidates.find((item) => item.id === candidate)!;
if (!sameType(located.type, declaration.type)) {
throw new Error(`Build ${candidateSource.build} Output ${candidateSource.output} has the wrong type for its satisfied Logical Output`);
}
historicalOutputs.set(candidate, located);
const consumed = structuralPlan.selections.some((selection) =>
selection.candidate === candidate && consumedRecords.has(selection.record));
if (!consumed) continue;
if (resolveHistoricalOutput === undefined) {
throw new Error(`Historical Build Candidate ${candidateSource.build}/${candidateSource.output} requires a project Result Store`);
}
@@ -285,7 +328,6 @@ export class NodeRunCompiler {
if (resolved === undefined) {
throw new Error(`Build ${candidateSource.build} has no Output ${candidateSource.output}`);
}
const declaration = structuralRun.graph.candidates.find((item) => item.id === candidate)!;
if (!sameType(resolved.type, declaration.type)) {
throw new Error(`Build ${candidateSource.build} Output ${candidateSource.output} has the wrong type for its satisfied Logical Output`);
}
@@ -310,7 +352,14 @@ export class NodeRunCompiler {
}),
}),
};
const admitted = planBuild(program, author.graph, run.graph);
const forwardOnlyOutputs = new Set(structuralPlan.selections.flatMap((selection) => {
const source = structuralRun.candidateSources[selection.candidate];
return source?.kind === "build-output" && !consumedRecords.has(selection.record) ? [selection.output] : [];
}));
const admitted = planBuild(program, author.graph, {
...run.graph,
targets: run.graph.targets.filter((target) => !forwardOnlyOutputs.has(target.output)),
});
const admitRecord = (this.#options.authorCompiler as NodeCompiler & {
readonly admitRecord?: NodeCompiler["admitRecord"];
}).admitRecord;
@@ -325,12 +374,23 @@ export class NodeRunCompiler {
author,
program,
run,
historicalOutputs: Object.fromEntries(historicalOutputs),
attachments: mergeAttachments([author.attachments, await workspace.attachments(), buildAttachments]),
};
}
planCompilation(compilation: NodeCompiledRun): PlannedBuild {
const sliced = sliceExecution(compilation.program, compilation.author.graph, compilation.run.graph);
const preliminary = planBuild(compilation.program, compilation.author.graph, compilation.run.graph);
const consumedRecords = new Set(preliminary.plan.steps.flatMap((step) => Object.values(step.inputs)));
const forwardOnlyOutputs = new Set(preliminary.selections.flatMap((selection) => {
const source = compilation.run.candidateSources[selection.candidate];
return source?.kind === "build-output" && !consumedRecords.has(selection.record) ? [selection.output] : [];
}));
const executionRun = {
...compilation.run.graph,
targets: compilation.run.graph.targets.filter((target) => !forwardOnlyOutputs.has(target.output)),
};
const sliced = sliceExecution(compilation.program, compilation.author.graph, executionRun);
const definition = defineBuild({
program: sliced.program,
initialRecords: sliced.initialRecords,
@@ -338,12 +398,18 @@ export class NodeRunCompiler {
targets: sliced.targets,
});
const state = materializeBuild(definition, []);
const resultForwards = sliced.selections.flatMap((selection) => {
const resultForwards = preliminary.selections.flatMap((selection) => {
const source = compilation.run.candidateSources[selection.candidate];
return source?.kind !== "build-output" ? [] : [{
if (source?.kind !== "build-output") return [];
const located = compilation.historicalOutputs[selection.candidate];
if (located === undefined) {
throw new Error(`Historical Candidate ${selection.candidate} was not located during Run compilation`);
}
return [{
output: selection.output,
build: source.build,
sourceOutput: source.output,
build: located.build,
sourceOutput: located.output,
type: located.type,
}];
});
return { compilation, selections: sliced.selections, resultForwards, definition, state };
@@ -184,6 +184,7 @@ function compiler(
const previewModule = { name: "example.compiler-preview", version: "1" } as const;
const previewProducer = { module: previewModule, name: "preview" } satisfies ProducerRef;
const consumeProducer = { module: previewModule, name: "consume" } satisfies ProducerRef;
const previewManifest: ModuleManifest = {
...emptyManifest(previewModule.name),
dependencies: [{
@@ -194,6 +195,11 @@ const previewManifest: ModuleManifest = {
inputs: [],
outputs: [{ name: "result", type: resultType }],
needs: [],
}, {
name: consumeProducer.name,
inputs: [{ name: "source", type: resultType }],
outputs: [{ name: "result", type: resultType }],
needs: [],
}],
};
const previewFragment = sealGraphFragment({
@@ -210,6 +216,20 @@ const previewFragment = sealGraphFragment({
root: { kind: "fragment-operation", operation: "preview" },
}],
});
const consumeFragment = sealGraphFragment({
inputs: [{ name: "source", type: resultType }],
operations: [{
id: "consume",
producer: consumeProducer,
inputs: { source: { kind: "fragment-input", name: "source" } },
result: { kind: "output", name: "result" },
}],
exports: [{
name: "result",
type: resultType,
root: { kind: "fragment-operation", operation: "consume" },
}],
});
function assetCompiler(environment: { readonly root: string } | { readonly workspace: Workspace }): NodeCompiler {
const modules = new ModulePackageRegistry();
@@ -483,6 +503,72 @@ test("static Run checking accepts a future BuildRecord without opening project R
assert.deepEqual(selected.definition.initialRecords.map((record) => record.value.kind).sort(), ["blob", "inline"]);
});
test("a historical Output is materialized only when a selected Producer consumes it", async () => {
const root = await mkdtemp(join(tmpdir(), "hypit-consumed-build-record-"));
const authorFile = join(root, "main.svml");
const runFile = join(root, "reuse.svrun");
await writeFile(authorFile, `<?svml using="@hypit/markup@1"?>
<svml>
<import as="lab" from="example.compiler-lab@1"/>
<lab:Result id="source"/><lab:Result id="final"/>
</svml>`, "utf8");
await writeFile(runFile, `<?svml using="@hypit/run-markup@1"?>
<svrun version="1">
<author source="./main.svml"/>
<import from="@example/preview" as="preview"/>
<target output="final.result"/>
<build-record id="prior" build="old-build" output="source.result"/>
<fragment id="consumer" using="preview:consume"><input name="source" from="source.result"/></fragment>
<satisfy output="source.result" candidate="prior"/>
<satisfy output="final.result" candidate="consumer.result"/>
</svrun>`, "utf8");
const frontends = new RunFrontendRegistry();
frontends.register(runMarkupFrontend);
const fragments = new RunFragmentRegistry();
fragments.register({
name: "@example/preview",
fragments: { result: previewFragment, consume: consumeFragment },
});
let located = 0;
let resolved = 0;
const runCompiler = new NodeRunCompiler({
authorCompiler: compiler(root, [previewManifest]),
frontends,
fragments,
locateHistoricalOutput(build, output) {
located += 1;
assert.equal(build, "old-build");
assert.equal(output, "source.result");
return { build: "value-owner", output: "original.result", type: resultType };
},
resolveHistoricalOutput(build, output) {
resolved += 1;
assert.equal(build, "old-build");
assert.equal(output, "source.result");
return { type: resultType, value: { kind: "inline", value: "historical" } };
},
});
const planned = await runCompiler.planFile(runFile);
assert.equal(located, 1, "the selected historical address is validated from its manifest");
assert.equal(resolved, 1, "a Producer dependency materializes the historical value exactly once");
assert.equal(planned.state.plan.steps.length, 1);
assert.equal(planned.state.plan.steps[0]?.producer.name, consumeProducer.name);
assert.deepEqual(planned.definition.initialRecords.map((record) => record.value), [{
kind: "inline",
value: "historical",
}]);
assert.equal(planned.resultForwards.length, 1);
assert.match(planned.resultForwards[0]!.output, /::output::source\.result$/u);
assert.deepEqual({ ...planned.resultForwards[0], output: "source.result" }, {
output: "source.result",
build: "value-owner",
sourceOutput: "original.result",
type: resultType,
});
});
test("source assets become graph values and a Host transfer bundle without closure metadata", async () => {
const root = await mkdtemp(join(tmpdir(), "hypit-source-assets-"));
const file = join(root, "main.svml");
+10
View File
@@ -10,6 +10,16 @@ graph.
Execution is one immutable `BuildDefinition` followed by accepted `BuildFact` values. `BuildMachine`
reconstructs the current view, emits the next commands and accepts their results. The materialized
`BuildState` is a disposable view rather than durable authority.
Within one live Build, the machine keeps disposable Record/Need/Command indexes, missing-input
counts, reverse Record dependencies and the remaining Goals. A committed Fact therefore wakes only
the steps whose inputs changed. These indexes are neither serialized nor shared across Builds and
can always be reconstructed from the Definition and Facts. `evaluate` still derives the exact Fact
before mutation; `commit` advances the indexes only after the caller has durably appended it.
The pure `reduce(BuildState)` path remains the standalone reference transition for detached or
deserialized views. Runtime execution can use the machine's indexed Record lookup directly; a Store
snapshot whose state was already materialized is adopted once instead of replaying the same Facts a
second time.
`BuildDefinition` contains only the selected Program, initial Records, Producer steps,
`Output -> Record` bindings and Targets. Graphs, satisfactions and Candidate identities end at the
planning boundary.
-2
View File
@@ -329,8 +329,6 @@ export function verifyBuildRequest(
"UNSUPPORTED_BUILD_REQUEST",
"unsupported BuildRequest format",
);
invariant(request.targets.length > 0, "EMPTY_BUILD_TARGETS", "BuildRequest has no Targets");
const targets = new Set<string>();
for (const target of request.targets) {
resolveLogicalOutput(graph, target.output);
+157 -12
View File
@@ -4,24 +4,92 @@ import type {
BuildState,
CoreCommand,
CommandResult,
Need,
TypedRecord,
} from "@hypit/protocol";
import { admitBuildResult, materializeBuild } from "./reducer.js";
import { invariant } from "./error.js";
import { producerStep } from "./plan.js";
import {
buildNeedCommand,
buildProducerCommand,
factForBuildResult,
materializeBuild,
} from "./reducer.js";
function insertDependency(dependents: Map<string, string[]>, record: string, step: string): void {
const found = dependents.get(record);
if (found === undefined) dependents.set(record, [step]);
else found.push(step);
}
function mergeCommands(
current: readonly CoreCommand[],
removed: string,
added: readonly CoreCommand[],
): readonly CoreCommand[] {
const retained = current.filter((command) => command.id !== removed);
const additions = [...added].sort((left, right) => left.id.localeCompare(right.id));
const merged: CoreCommand[] = [];
let left = 0;
let right = 0;
while (left < retained.length || right < additions.length) {
const existing = retained[left];
const addition = additions[right];
if (addition === undefined || existing !== undefined && existing.id.localeCompare(addition.id) < 0) {
merged.push(existing!);
left++;
} else {
merged.push(addition);
right++;
}
}
return merged;
}
/**
* Non-serializable incremental Core machine.
*
* Definition + Facts are the stored form. The machine is only their disposable execution view.
* Definition + Facts are the stored form. All indexes belong to this one Build execution and are
* reconstructed from them; none is protocol or Store state.
*/
export class BuildMachine {
readonly definition: BuildDefinition;
#state: BuildState;
#proposal: { readonly fact: BuildFact; readonly state: BuildState } | undefined;
#proposal: BuildFact | undefined;
readonly #records = new Map<string, TypedRecord>();
readonly #needs = new Map<string, Need>();
readonly #commands = new Map<string, CoreCommand>();
readonly #stepIndexes = new Map<string, number>();
readonly #stepStatuses = new Map<string, "pending" | "complete">();
readonly #missingInputs = new Map<string, number>();
readonly #dependents = new Map<string, string[]>();
readonly #missingGoals = new Set<string>();
constructor(definition: BuildDefinition, facts: readonly BuildFact[] = []) {
/** Adopt a view just materialized from this Definition's accepted Facts without replaying them again. */
static fromMaterialized(definition: BuildDefinition, state: BuildState): BuildMachine {
return new BuildMachine(definition, [], state);
}
constructor(definition: BuildDefinition, facts: readonly BuildFact[] = [], materializedState?: BuildState) {
this.definition = structuredClone(definition);
this.#state = materializeBuild(this.definition, facts);
this.#state = materializedState ?? materializeBuild(this.definition, facts);
invariant(this.#state.format === "hypit.build@1", "UNSUPPORTED_BUILD", this.#state.format);
for (const record of this.#state.records) this.#records.set(record.id, record);
for (const need of this.#state.needs) this.#needs.set(need.id, need);
for (const command of this.#state.outstanding) this.#commands.set(command.id, command);
for (const [index, stepState] of this.#state.steps.entries()) {
this.#stepIndexes.set(stepState.id, index);
this.#stepStatuses.set(stepState.id, stepState.status);
if (stepState.status !== "pending") continue;
const step = producerStep(this.definition.plan, stepState.id);
const missing = new Set(Object.values(step.inputs).filter((record) => !this.#records.has(record)));
this.#missingInputs.set(step.id, missing.size);
for (const record of missing) insertDependency(this.#dependents, record, step.id);
}
for (const goal of this.definition.plan.goals) {
if (!this.#records.has(goal.record)) this.#missingGoals.add(goal.record);
}
}
get status(): BuildState["status"] {
@@ -33,7 +101,7 @@ export class BuildMachine {
}
record(id: string): TypedRecord | undefined {
return this.#state.records.find((item) => item.id === id);
return this.#records.get(id);
}
/** Materialized read view. It is never the durable Store representation. */
@@ -44,17 +112,94 @@ export class BuildMachine {
/** Validate one result without mutating the machine before durable persistence succeeds. */
evaluate(result: CommandResult): BuildFact | undefined {
if (this.#proposal !== undefined) {
throw new Error(`Build result ${this.#proposal.fact.command} has not been durably applied`);
throw new Error(`Build result ${this.#proposal.command} has not been durably applied`);
}
const admitted = admitBuildResult(this.#state, result);
this.#proposal = { fact: admitted.fact, state: admitted.state };
return admitted.fact;
const fact = factForBuildResult(this.#state, result, {
command: (id) => this.#commands.get(id),
stepStatus: (id) => this.#stepStatuses.get(id),
need: (id) => this.#needs.get(id),
record: (id) => this.#records.get(id),
});
this.#proposal = fact;
return fact;
}
/** Advance memory only after BuildStore.append has committed the proposed Fact. */
commit(): void {
if (this.#proposal === undefined) throw new Error("Build machine has no evaluated result to commit");
this.#state = this.#proposal.state;
const fact = this.#proposal;
if (fact === undefined) throw new Error("Build machine has no evaluated result to commit");
this.#proposal = undefined;
const command = this.#commands.get(fact.command);
invariant(command !== undefined, "UNKNOWN_COMMAND", `Fact references ${fact.command}`, fact.command);
this.#commands.delete(fact.command);
let records = this.#state.records;
let needs = this.#state.needs;
let steps = this.#state.steps;
let diagnostics = this.#state.diagnostics;
let status = this.#state.status;
const addedCommands: CoreCommand[] = [];
if (fact.kind === "command-failed") {
status = "failed";
diagnostics = [...diagnostics, fact.diagnostic];
this.#commands.clear();
} else {
const addedRecords = fact.kind === "producer-applied" ? fact.records : [fact.record];
if (fact.kind === "producer-applied") {
const stepIndex = this.#stepIndexes.get(fact.step);
invariant(stepIndex !== undefined, "UNKNOWN_STEP", fact.step, fact.step);
this.#stepStatuses.set(fact.step, "complete");
const updatedSteps = [...steps];
updatedSteps[stepIndex] = { id: fact.step, status: "complete" };
steps = updatedSteps;
for (const need of fact.needs) {
this.#needs.set(need.id, need);
const next = buildNeedCommand(need);
this.#commands.set(next.id, next);
addedCommands.push(next);
}
if (fact.needs.length > 0) needs = [...needs, ...fact.needs];
}
for (const record of addedRecords) {
invariant(!this.#records.has(record.id), "DUPLICATE_RECORD", record.id, record.id);
this.#records.set(record.id, record);
this.#missingGoals.delete(record.id);
for (const stepId of this.#dependents.get(record.id) ?? []) {
const remaining = (this.#missingInputs.get(stepId) ?? 0) - 1;
invariant(remaining >= 0, "STEP_INPUT_UNDERFLOW", stepId, stepId);
this.#missingInputs.set(stepId, remaining);
if (remaining === 0 && this.#stepStatuses.get(stepId) === "pending") {
const next = buildProducerCommand(producerStep(this.definition.plan, stepId));
this.#commands.set(next.id, next);
addedCommands.push(next);
}
}
this.#dependents.delete(record.id);
}
if (addedRecords.length > 0) records = [...records, ...addedRecords];
if (this.#missingGoals.size === 0) {
status = "complete";
this.#commands.clear();
} else if (this.#commands.size === 0) {
status = "failed";
diagnostics = [...diagnostics, {
code: "BUILD_DEADLOCK",
message: "no command can make progress toward the requested goals",
}];
}
}
this.#state = {
...this.#state,
status,
records,
steps,
needs,
outstanding: status === "active" ? mergeCommands(this.#state.outstanding, fact.command, addedCommands) : [],
diagnostics,
};
}
}
-1
View File
@@ -331,7 +331,6 @@ export function verifyBuildPlan(
plan: BuildPlan,
): void {
invariant(plan.format === "hypit.plan@1", "UNSUPPORTED_PLAN", "unsupported build plan format");
invariant(plan.goals.length > 0, "EMPTY_PLAN_GOALS", "build plan has no goals");
const records = new Map<RecordId, ProducedRecord>();
for (const record of program.records) records.set(record.id, { type: record.type });
+121 -53
View File
@@ -26,14 +26,24 @@ function commandId(kind: "producer" | "need", subject: string): string {
return `${kind}:${subject}`;
}
function needCommand(need: Need): FulfillNeedCommand {
export function buildNeedCommand(need: Need): FulfillNeedCommand {
return { kind: "fulfill-need", id: commandId("need", need.id), need };
}
export function buildProducerCommand(step: BuildState["plan"]["steps"][number]): InvokeProducerCommand {
return {
kind: "invoke-producer",
id: commandId("producer", step.id),
step: step.id,
producer: step.producer,
inputs: step.inputs,
};
}
/** Resolve an issued Need's Command even after the Build has stopped scheduling work. */
export function resolveNeedCommand(state: BuildState, id: CommandId): FulfillNeedCommand | undefined {
const need = state.needs.find((item) => needCommand(item).id === id);
return need === undefined ? undefined : needCommand(need);
const need = state.needs.find((item) => buildNeedCommand(item).id === id);
return need === undefined ? undefined : buildNeedCommand(need);
}
function withoutCommand(state: BuildState, id: string): readonly CoreCommand[] {
@@ -209,7 +219,7 @@ function schedule(state: BuildState): BuildState {
const commands: CoreCommand[] = [...state.outstanding];
for (const need of state.needs) {
if (records.has(need.result)) continue;
const command = needCommand(need);
const command = buildNeedCommand(need);
if (issued.has(command.id)) continue;
commands.push(command);
}
@@ -220,15 +230,9 @@ function schedule(state: BuildState): BuildState {
if (!Object.values(step.inputs).every((id) => records.has(id))) {
continue;
}
const id = commandId("producer", step.id);
if (issued.has(id)) continue;
commands.push({
kind: "invoke-producer",
id,
step: step.id,
producer: step.producer,
inputs: step.inputs,
});
const command = buildProducerCommand(step);
if (issued.has(command.id)) continue;
commands.push(command);
}
commands.sort((left, right) => left.id.localeCompare(right.id));
@@ -264,12 +268,12 @@ export function start(
satisfactions: [],
targets: request.targets,
});
return initialBuildView(defineBuild({
return schedule(initialBuildView(defineBuild({
program,
initialRecords: planned.initialRecords,
plan: planned.plan,
targets: request.targets,
}));
})));
}
export function reduce(state: BuildState, event?: CommandResult): BuildState {
@@ -282,7 +286,6 @@ export function defineBuild(
input: Omit<BuildDefinition, "format">,
): BuildDefinition {
verifyBuildPlan(input.program, input.initialRecords, input.plan);
invariant(input.targets.length > 0, "EMPTY_BUILD_TARGETS", "Build has no Targets");
const bindings = new Set(input.plan.outputBindings.map((binding) => binding.output));
const targets = new Set<string>();
for (const target of input.targets) {
@@ -310,6 +313,106 @@ function initialBuildView(definition: BuildDefinition): BuildState {
};
}
export type BuildAdmissionLookup = {
readonly command: (id: CommandId) => CoreCommand | undefined;
readonly stepStatus: (id: string) => "pending" | "complete" | undefined;
readonly need: (id: string) => Need | undefined;
readonly record: (id: string) => TypedRecord | undefined;
};
/**
* Validate one result and derive its durable Fact without advancing scheduling.
* BuildMachine supplies its private indexes; the pure reducer uses array lookups.
*/
export function factForBuildResult(
state: Pick<BuildState, "program" | "plan" | "steps" | "needs" | "records" | "outstanding">,
event: CommandResult,
indexed?: BuildAdmissionLookup,
): BuildFact {
const lookup: BuildAdmissionLookup = indexed ?? {
command: (id) => state.outstanding.find((item) => item.id === id),
stepStatus: (id) => state.steps.find((item) => item.id === id)?.status,
need: (id) => state.needs.find((item) => item.id === id),
record: (id) => state.records.find((item) => item.id === id),
};
const command = lookup.command(event.command);
invariant(command !== undefined, "UNKNOWN_COMMAND", `event references ${event.command}`, event.command);
if (event.kind === "command-failed") {
return {
format: "hypit.build-fact@1",
kind: "command-failed",
command: event.command,
diagnostic: { code: event.code, message: event.message, subject: event.command },
};
}
if (command.kind === "invoke-producer" && event.kind === "producer-completed") {
const step = producerStep(state.plan, command.step);
invariant(lookup.stepStatus(step.id) === "pending", "STEP_ALREADY_COMPLETE",
`${step.id} is already complete`, step.id);
const producer = resolveProducer(state.program.closure, step.producer);
exactPortKeys(event.outputs, producer.outputs.map((port) => port.name), `${step.id}.outputs`);
exactPortKeys(event.needs, producer.needs.map((port) => port.name), `${step.id}.needs`);
const records: TypedRecord[] = producer.outputs
.filter((port) => step.outputs[port.name] !== undefined)
.map((port) => {
const id = step.outputs[port.name];
const rawValue = event.outputs[port.name];
invariant(id !== undefined, "MISSING_OUTPUT_BINDING", `${step.id}.${port.name} is not bound`);
invariant(rawValue !== undefined, "MISSING_OUTPUT_VALUE", `${step.id}.${port.name} returned no value`);
return { id, type: port.type, value: normalizeStoredValue(rawValue) };
});
const needs: Need[] = producer.needs
.filter((port) => step.needs[port.name] !== undefined)
.map((port) => {
const binding = step.needs[port.name];
const constraints = event.needs[port.name];
invariant(binding !== undefined, "MISSING_NEED_BINDING", `${step.id}.${port.name} is not bound`);
invariant(constraints !== undefined, "MISSING_NEED_VALUE", `${step.id}.${port.name} returned no constraints`);
return {
id: binding.id,
capability: port.capability,
returns: port.returns,
constraints: canonicalize(constraints),
result: binding.result,
};
});
records.forEach((record) => verifyRecordStructure(state.program.closure, record));
return {
format: "hypit.build-fact@1",
kind: "producer-applied",
command: event.command,
step: command.step,
records,
needs,
};
}
if (command.kind === "fulfill-need" && event.kind === "need-fulfilled") {
const need = lookup.need(command.need.id);
invariant(need !== undefined, "UNKNOWN_NEED", `unknown need ${command.need.id}`, command.need.id);
invariant(lookup.record(need.result) === undefined, "NEED_ALREADY_FULFILLED",
`${need.id} is already fulfilled`, need.id);
const record: TypedRecord = {
id: need.result,
type: need.returns,
value: normalizeStoredValue(event.value),
};
verifyRecordStructure(state.program.closure, record);
return {
format: "hypit.build-fact@1",
kind: "need-applied",
command: event.command,
need: need.id,
record,
};
}
throw new SvmlError(
"EVENT_COMMAND_MISMATCH",
`${event.kind} cannot complete ${command.kind}`,
command.id,
);
}
/**
* Admit one untrusted Command result and return the fixed Core Fact it establishes.
* The returned next view is provisional until the Store durably appends `fact`.
@@ -318,44 +421,9 @@ export function admitBuildResult(
state: BuildState,
event: CommandResult,
): { readonly fact: BuildFact; readonly state: BuildState } {
const fact = factForBuildResult(state, event);
const next = reduce(state, event);
if (event.kind === "producer-completed") {
const command = state.outstanding.find((item) => item.id === event.command);
invariant(command?.kind === "invoke-producer", "EVENT_COMMAND_MISMATCH", event.command, event.command);
const content = {
format: "hypit.build-fact@1",
kind: "producer-applied",
command: event.command,
step: command.step,
records: next.records.slice(state.records.length),
needs: next.needs.slice(state.needs.length),
} as const;
return { fact: content, state: next };
}
if (event.kind === "need-fulfilled") {
const command = state.outstanding.find((item) => item.id === event.command);
invariant(command?.kind === "fulfill-need", "EVENT_COMMAND_MISMATCH", event.command, event.command);
const record = next.records.at(-1);
invariant(record?.id === command.need.result, "NEED_RECORD_MISSING", command.need.id, command.need.id);
const content = {
format: "hypit.build-fact@1",
kind: "need-applied",
command: event.command,
need: command.need.id,
record,
} as const;
return { fact: content, state: next };
}
const diagnostic = next.diagnostics.at(-1);
invariant(diagnostic?.subject === event.command, "FAILURE_DIAGNOSTIC_MISSING", event.command, event.command);
const content = {
format: "hypit.build-fact@1",
kind: "command-failed",
command: event.command,
diagnostic,
} as const;
return { fact: content, state: next };
return { fact, state: next };
}
function verifyFactShape(fact: BuildFact): void {
+70
View File
@@ -0,0 +1,70 @@
import {
createResolvedClosure,
defineBuild,
link,
sealRecord,
} from "@hypit/core";
import type {
BuildDefinition,
CommandResult,
InvokeProducerCommand,
ModuleManifest,
ProducerRef,
TypeRef,
} from "@hypit/protocol";
const moduleRef = { name: "example.core-chain", version: "0.0.0" } as const;
const valueType: TypeRef = { module: moduleRef, name: "Value" };
const advanceProducer: ProducerRef = { module: moduleRef, name: "advance" };
const manifest: ModuleManifest = {
format: "hypit.module@1",
name: moduleRef.name,
version: moduleRef.version,
dependencies: [],
types: [{ name: valueType.name }],
capabilities: [],
producers: [{
name: advanceProducer.name,
inputs: [{ name: "previous", type: valueType }],
outputs: [{ name: "next", type: valueType }],
needs: [],
}],
};
export function createChainDefinition(length: number): BuildDefinition {
if (!Number.isSafeInteger(length) || length < 1) throw new Error("chain length must be positive");
const program = link(createResolvedClosure([manifest]), [sealRecord({
id: "record:0",
type: valueType,
value: { kind: "inline", value: 0 },
})]);
const finalRecord = `record:${length}`;
return defineBuild({
program,
initialRecords: [],
plan: {
format: "hypit.plan@1",
steps: Array.from({ length }, (_, index) => ({
id: `step:${index}`,
producer: advanceProducer,
inputs: { previous: `record:${index}` },
outputs: { next: `record:${index + 1}` },
needs: {},
})),
goals: [{ record: finalRecord, type: valueType }],
outputBindings: [{ output: "result", record: finalRecord, type: valueType }],
},
targets: [{ output: "result" }],
});
}
export function completeChainStep(command: InvokeProducerCommand): CommandResult {
const index = Number(command.step.slice("step:".length));
return {
kind: "producer-completed",
command: command.id,
outputs: { next: { kind: "inline", value: index + 1 } },
needs: {},
};
}
+46
View File
@@ -2,7 +2,9 @@ import assert from "node:assert/strict";
import test from "node:test";
import {
BuildMachine,
admitBuildResult,
defineBuild,
materializeBuild,
reduce,
resolveNeedCommand,
} from "@hypit/core";
@@ -126,6 +128,50 @@ test("Build Definition plus admitted Facts restores the same next Command", () =
);
assert.deepEqual(restored.commands(), machine.commands());
assert.equal(restored.commands()[0]?.kind, "fulfill-need");
const materialized = materializeBuild(definition, facts);
const adopted = BuildMachine.fromMaterialized(definition, materialized);
assert.equal(adopted.view(), materialized);
assert.deepEqual(adopted.commands(), restored.commands());
});
test("BuildMachine incremental indexes stay identical to the pure reducer after every Fact", () => {
const initial = createGreetingBuild({ includeSideTarget: true });
const authored = new Set(initial.program.records.map((record) => record.id));
const definition = defineBuild({
program: initial.program,
initialRecords: initial.records.filter((record) => !authored.has(record.id)),
plan: initial.plan,
targets: initial.targets,
});
const machine = new BuildMachine(definition);
let reference = materializeBuild(definition, []);
const apply = (event: CommandResult): void => {
const before = machine.view();
const expected = admitBuildResult(reference, event);
assert.deepEqual(machine.evaluate(event), expected.fact);
assert.equal(machine.view(), before, "evaluation must not advance memory before durable commit");
machine.commit();
reference = expected.state;
assert.deepEqual(machine.view(), reference);
};
const producer = (step: string): InvokeProducerCommand => {
const command = machine.commands().find((item): item is InvokeProducerCommand =>
item.kind === "invoke-producer" && item.step === step);
assert.ok(command, `missing ${step}`);
return command;
};
apply(producerEvent(producer("make-prompt"), { prompt: { kind: "inline", value: "Greet Ada" } }));
apply(producerEvent(producer("request-text"), {}, { generation: { prompt: "Greet Ada" } }));
apply(producerEvent(producer("side-placeholder"), { generated: { kind: "inline", value: "Hi, Ada" } }));
apply(producerEvent(producer("side-assemble"), { document: { kind: "inline", value: { text: "Hi, Ada" } } }));
const need = machine.commands().find((item): item is FulfillNeedCommand => item.kind === "fulfill-need");
assert.ok(need);
apply({ kind: "need-fulfilled", command: need.id, value: { kind: "inline", value: "Hello, Ada!" } });
apply(producerEvent(producer("assemble"), {
document: { kind: "inline", value: { text: "Hello, Ada!" } },
}));
assert.equal(machine.status, "complete");
});
test("scheduling is incremental: a ready Producer runs while an unrelated Need is still outstanding", () => {
@@ -0,0 +1,30 @@
import assert from "node:assert/strict";
import test from "node:test";
import { BuildMachine } from "@hypit/core";
import type { BuildFact, InvokeProducerCommand } from "@hypit/protocol";
import { completeChainStep, createChainDefinition } from "./chain-fixture.js";
test("BuildMachine advances a long chain through only its newly ready command and restores from Facts", () => {
const definition = createChainDefinition(512);
const machine = new BuildMachine(definition);
const facts: BuildFact[] = [];
for (let index = 0; index < 512; index += 1) {
assert.equal(machine.commands().length, 1);
const command = machine.commands()[0];
assert.equal(command?.kind, "invoke-producer");
assert.equal(command.step, `step:${index}`);
const fact = machine.evaluate(completeChainStep(command as InvokeProducerCommand));
assert.ok(fact);
facts.push(fact);
machine.commit();
}
assert.equal(machine.status, "complete");
assert.equal(machine.commands().length, 0);
assert.deepEqual(machine.record("record:512")?.value, { kind: "inline", value: 512 });
const restored = new BuildMachine(definition, facts);
assert.equal(restored.status, "complete");
assert.deepEqual(restored.view(), machine.view());
});
+39 -10
View File
@@ -24,6 +24,7 @@ import type {
OperationSnapshot,
OperationProgress,
OperationStore,
RuntimeBuildExecution,
RuntimeExecutionContext,
RuntimeExecutionResult,
RuntimePreparation,
@@ -147,11 +148,15 @@ export class NodeDriver {
return resolved;
}
#producerInputs(state: BuildState, command: InvokeProducerCommand): Record<string, TypedRecord> {
#producerInputs(
state: BuildState,
command: InvokeProducerCommand,
indexedRecord?: (id: string) => TypedRecord | undefined,
): Record<string, TypedRecord> {
const inputs: Record<string, TypedRecord> = {};
const records = new Map(state.records.map((item) => [item.id, item]));
const records = indexedRecord === undefined ? new Map(state.records.map((item) => [item.id, item])) : undefined;
for (const [port, id] of Object.entries(command.inputs)) {
const record = records.get(id);
const record = indexedRecord?.(id) ?? records?.get(id);
if (record === undefined) throw new Error(`${command.id} input ${id} is missing`);
Object.defineProperty(inputs, port, {
value: structuredClone(record),
@@ -166,6 +171,7 @@ export class NodeDriver {
#classify(
state: BuildState,
command: CoreCommand,
indexedRecord?: (id: string) => TypedRecord | undefined,
): { readonly executable?: Executable; readonly blocked?: BlockedCommand } {
if (command.kind === "invoke-producer") {
const registration = this.producers.producer(command.producer);
@@ -186,7 +192,7 @@ export class NodeDriver {
registration.handler({
command: structuredClone(command),
producer: structuredClone(command.producer),
inputs: this.#producerInputs(state, command),
inputs: this.#producerInputs(state, command, indexedRecord),
}),
},
};
@@ -529,15 +535,17 @@ export class NodeDriver {
}
}
/** Regenerate Core commands, then classify only what this Host can execute. */
prepare(initial: BuildState): RuntimePreparation {
const state = reduce(initial);
#prepare(
state: BuildState,
commands: readonly CoreCommand[],
indexedRecord?: (id: string) => TypedRecord | undefined,
): RuntimePreparation {
if (state.status === "complete" || state.status === "failed") {
return { state, runnable: [], blocked: [] };
}
const classifications = state.outstanding.map((command) => ({
const classifications = commands.map((command) => ({
command,
...this.#classify(state, command),
...this.#classify(state, command, indexedRecord),
}));
return {
state,
@@ -554,6 +562,15 @@ export class NodeDriver {
};
}
/** Classify the commands already generated by Core for what this Host can execute. */
prepare(state: BuildState): RuntimePreparation {
return this.#prepare(state, state.outstanding);
}
prepareExecution(execution: RuntimeBuildExecution): RuntimePreparation {
return this.#prepare(execution.view(), execution.commands(), (id) => execution.record(id));
}
/** Execute one command emitted by `prepare` in the same scheduling turn. */
async executeCommand(
state: BuildState,
@@ -567,6 +584,19 @@ export class NodeDriver {
return await this.#execute(state, classified.executable, context);
}
async executeExecutionCommand(
execution: RuntimeBuildExecution,
descriptor: RuntimeRunnableCommand,
context: RuntimeExecutionContext,
): Promise<RuntimeExecutionResult> {
const state = execution.view();
const classified = this.#classify(state, descriptor.command, (id) => execution.record(id));
if (classified.executable === undefined) {
throw new Error(`command ${descriptor.command.id} is no longer executable`);
}
return await this.#execute(state, classified.executable, context);
}
/** Stop observing this Operation and make at most one explicit cancellation request. */
async cancelOperation(initial: BuildState, operation: OperationSnapshot): Promise<OperationSnapshot> {
const operations = this.operations;
@@ -599,7 +629,6 @@ export class NodeDriver {
const outcomes: DriverExecutionOutcome[] = [];
while (true) {
state = reduce(state);
if (state.status === "complete") {
return { status: "complete", state, outcomes, blocked: [] };
}
+1 -2
View File
@@ -1,7 +1,6 @@
import assert from "node:assert/strict";
import test from "node:test";
import { reduce } from "@hypit/core";
import {
ProducerRegistry,
MemoryResourceStore,
@@ -259,7 +258,7 @@ test("Core still owns scheduling when Driver has every implementation", async ()
value: { kind: "inline", value: "Hello, Ada!" },
}));
const start = createGreetingBuild();
assert.equal(reduce(start).outstanding[0]?.kind, "invoke-producer");
assert.equal(start.outstanding[0]?.kind, "invoke-producer");
const result = await new NodeDriver({ producers, endpoints }).run(start);
assert.equal(result.status, "complete");
});
+47 -1
View File
@@ -7,11 +7,15 @@ import {
EndpointRegistry,
} from "@hypit/driver-node";
import type { AsyncEndpoint } from "@hypit/endpoint-kit";
import {
materializeBuild,
} from "@hypit/core";
import {
LocalBuildScheduler,
} from "@hypit/runtime";
import type { OperationSnapshot, OperationStore, OperationUpdate, RuntimeExecutionContext } from "@hypit/runtime";
import type { BuildStore, OperationSnapshot, OperationStore, OperationUpdate, RuntimeCommandExecutor, RuntimeExecutionContext } from "@hypit/runtime";
import { capabilities, createGreetingBuild, createParallelGreetingBuild, producers as greetingProducers, types } from "../../core/test/greeting-fixture.js";
import { completeChainStep, createChainDefinition } from "../../core/test/chain-fixture.js";
function memoryOperations(): OperationStore {
const values = new Map<string, OperationSnapshot>();
@@ -49,6 +53,48 @@ function memoryOperations(): OperationStore {
};
}
test("a durable Scheduler keeps preparation and Record lookup on the indexed Build execution path", async () => {
const definition = createChainDefinition(1);
const state = materializeBuild(definition, []);
let appends = 0;
const builds: BuildStore = {
async create() { throw new Error("unused"); },
async read() { throw new Error("unused"); },
async append(_build, fact) {
appends++;
assert.equal(fact.kind, "producer-applied");
},
};
let preparations = 0;
let executions = 0;
const executor: RuntimeCommandExecutor = {
prepare() { throw new Error("durable scheduling rebuilt a detached BuildState"); },
executeCommand() { throw new Error("durable execution rebuilt a detached Record index"); },
prepareExecution(execution) {
preparations++;
return {
state: execution.view(),
runnable: execution.commands().map((command) => ({ command, resources: [] })),
blocked: [],
};
},
async executeExecutionCommand(execution, descriptor) {
executions++;
assert.equal(execution.record("record:0")?.value.kind, "inline");
assert.equal(descriptor.command.kind, "invoke-producer");
return { status: "completed", event: completeChainStep(descriptor.command) };
},
};
const [result] = await new LocalBuildScheduler(executor, { buildStore: builds }).run([{
id: "indexed-chain",
snapshot: { build: "indexed-chain", definition, facts: [], state },
}]);
assert.equal(result?.status, "complete");
assert.equal(preparations, 1);
assert.equal(executions, 1);
assert.equal(appends, 1);
});
function configuredExecutor(options: {
readonly resource: string;
+44 -8
View File
@@ -19,9 +19,12 @@ It deliberately ignores `AudioTrack`. HyperFrames produces a silent visual fact;
compiles and renders program audio separately, then an explicit mux Provider joins the two. Changing
audio can therefore never be implemented by secretly changing HyperFrames HTML or its renderer.
Media remains resource-referenced in compiled HTML as `hypit-resource://` placeholders. The
document separately carries each dependency's complete `BlobRef` (Resource id, byte count and MIME), so
a Provider can verify and name staged bytes without guessing from the hash. A local or hosted
Media remains resource-referenced in compiled HTML as `hypit-resource://` placeholders. Each
document Artifact declaration carries its complete `BlobRef` (Resource id, byte count and MIME) and
the compiler's conservative usage proof: `always`, or ordered absolute half-open frame spans. Direct
image, video and typed Surface uses inherit their Present spans; repeated spans merge. Document-level
fonts and opaque Browser Program dependencies remain `always`. This fact belongs to the compiled
HyperFrames document, not Core, a worker partition or a cross-render registry. A local or hosted
render Runtime calls `materializeHyperframesHtml()` with its own Artifact URL resolver before
handing the HTML to HyperFrames. That environment-specific materialization is not a new compiled
Record and does not change the compiled document.
@@ -37,7 +40,18 @@ paths or filenames, so their length and punctuation do not restrict the host fil
`stageHyperframesProject` streams each asset into its staged file. Its `validateSurface(surface, path,
signal)` callback borrows that completed file during inspection; it receives neither a whole-file
byte copy nor ownership of the file. The staging caller keeps the directory alive until all work
has settled, including cancellation.
has settled, including cancellation. `maxConcurrentArtifacts` bounds complete read/write/inspection
lifecycles and defaults to one; a deployment Provider may supply its own local policy. Failure stops
new claims and drains the already-started lifecycles before cleanup.
When `frameSelection` is supplied, staging reads only `always` Artifacts and frame-scoped Artifacts
whose usage intersects the whole render selection. All workers share that one staged project. Stable
local names still derive from the complete document order, so selection does not renumber resources.
Unselected structural media remain inert and are removed by the compiled page before their missing
local paths could be activated. Unknown or opaque usage is never guessed into a narrower scope.
`selectHyperframesArtifacts(document, selection)` exposes the same pure projection to callers that
must transport bytes before staging; the Studio snapshot CLI uses it rather than eagerly fetching
every Document resource. Selection validation is one ordered pass, and each Artifact's canonical
usage spans query a sparse selection by binary search instead of scanning every requested frame.
The document exposes its render domain directly rather than asking an Endpoint to scrape HTML:
exact rational `frameRate`, integer `frameCount`, and canvas dimensions are explicit document
@@ -47,6 +61,27 @@ hosted renderer may independently evaluate any legal frame or half-open chunk; p
worker count are Runtime policy, not author intent and not Core graph nodes. The emitted root also
uses the rational HyperFrames `data-fps` form, so NTSC rates do not drift through a decimal guess.
A renderer may install `hyperframesFrameSelectionPrelude()` before the compiled page scripts to
describe this render's ordered union of absolute half-open frame spans. The page-private animation,
Terminal Text, visibility and Browser Program adapters use that hint to avoid initializing work
whose Present cannot be sampled. Absence of the hint means the whole document, so ordinary browser
preview remains unchanged. The hint contains no Worker identity or batch cursor: every Worker can
start at any selected frame, and correctness still comes from absolute-frame evaluation.
The compiled page also derives a capture scope from the same selection. Compiler-owned image,
video, Surface and SVG-image URLs remain inert while HTML is parsed. After the page has classified
Presents, only resources inside selected roots receive live `src`/`href` attributes; unselected
roots are made non-painting, stripped of any remaining direct media URL attributes and detached
from the live document before DOMContentLoaded. Opaque Browser Program HTML/CSS is not rewritten.
Selected Present roots and document-level shared definitions are exposed to a capture adapter as
generic DOM roots; the adapter does not need
to understand Present or frame semantics. Shared glyph filter definitions live outside temporal
Present roots so detaching one Present cannot break another Present's `url(#id)` reference. Local
masks, paths and Browser Program structure remain within their owning Present. This is an
execution-local projection of the complete compiled document, not a partial Composition, a Worker
partition or a Core protocol. Without an injected selection, every Present remains live and every
compiler-owned media URL is activated, preserving ordinary browser preview.
Exact `FontArtifactRef` dependencies lower to generated `@font-face` declarations with font
synthesis disabled; a Unicode-range-sharded logical face emits one rule per exact source. Terminal
text without a non-empty exact Font stack is invalid rather than falling back to machine fonts.
@@ -74,10 +109,11 @@ SVG needs an ID for `url(#...)` or `href`, derive it from the unique `root.id` i
the definition and its references there. Reusable instances must not repeat hard-coded SVG IDs.
The optional `setup` string is a JavaScript function body with `root` and `data` arguments. It returns a synchronous
`render(localFrame)` function, evaluated on initial load and active `hf-seek` events. Outside the
Present's lifetime, the dispatcher settles the boundary pose once instead of repeatedly updating
an invisible program. Active seeks always redraw, including the same frame after resources become
ready, and seeking back into the Present computes its requested pose directly. Prepare stable
`render(localFrame)` function. Initial load establishes every selected root's boundary pose; later `hf-seek`
events use the page-private absolute-frame index to visit only active Programs and Present spans
crossed by that seek. Outside the Present's lifetime, the dispatcher settles the boundary pose once
instead of repeatedly scanning and updating every invisible Program. Active seeks always redraw,
including the same frame after resources become ready, and seeking back into the Present computes its requested pose directly. Prepare stable
objects in `setup`; express animation state as a function of frame and inputs so any worker can
begin at any frame. Async work belongs to
material preparation before rendering; browser resources belong in the program's declared artifacts.
+46 -22
View File
@@ -56,36 +56,60 @@ export function browserProgramScript(entries: readonly {
}[], numerator: number, denominator: number): string {
return `(() => {
const fail = error => { window.__hypitBrowserProgramError = String(error?.stack || error); };
const entries = ${scriptJson(entries)};
const renders = entries.map(entry => {
const entries = ${scriptJson(entries)}.filter(entry =>
hyperframesSelectionOverlaps(entry.startFrame, entry.startFrame + entry.durationFrames));
const renders = entries.map((entry, order) => {
const root = document.getElementById(entry.id);
try {
const render = entry.program.setup === undefined ? () => {} :
new Function('root', 'data', entry.program.setup)(root, entry.program.data);
if (typeof render !== 'function') throw new Error('Browser program setup must return render(localFrame).');
return { ...entry, render, region: undefined };
} catch (error) { fail(error); return { ...entry, render: () => {} }; }
return { ...entry, order, render, region: undefined };
} catch (error) { fail(error); return { ...entry, order, render: () => {}, region: undefined }; }
});
const apply = time => {
const frame = Math.round(Number(time || 0) * ${numerator} / ${denominator});
for (const entry of renders) {
const local = frame - entry.startFrame;
const region = local < 0 ? 'before' : local >= entry.durationFrames ? 'after' : 'active';
// Preserve the initial/boundary pose, but stop updating an invisible
// program at the same clamped endpoint. Active seeks always render,
// including repeated frame 0 after images/fonts finish loading.
if (region !== 'active' && entry.region === region) continue;
try {
const result = entry.render(Math.max(0, Math.min(entry.durationFrames, local)));
if (result != null && typeof result.then === 'function') {
// Observe a later rejection, but never let asynchronous drawing race frame capture.
Promise.resolve(result).catch(fail);
throw new Error('Browser program render(localFrame) must be synchronous; prepare asynchronous resources before rendering.');
}
entry.region = region;
const workIndex = hyperframesCreateFrameWorkIndex(renders.map(entry => ({
startFrame: entry.startFrame,
endFrameExclusive: entry.startFrame + entry.durationFrames,
order: entry.order,
payload: entry,
})));
let previousFrame;
const renderAt = (entry, frame) => {
const local = frame - entry.startFrame;
const region = local < 0 ? 'before' : local >= entry.durationFrames ? 'after' : 'active';
// Preserve initial and crossed boundary poses. Active seeks always redraw,
// including repeated frames after an asynchronously prepared image became ready.
if (region !== 'active' && entry.region === region) return;
try {
const result = entry.render(Math.max(0, Math.min(entry.durationFrames, local)));
if (result != null && typeof result.then === 'function') {
// Observe a later rejection, but never let asynchronous drawing race frame capture.
Promise.resolve(result).catch(fail);
throw new Error('Browser program render(localFrame) must be synchronous; prepare asynchronous resources before rendering.');
}
catch (error) { fail(error); }
entry.region = region;
}
catch (error) { fail(error); }
};
const apply = time => {
const frame = Math.max(0, Math.round(Number(time || 0) * ${numerator} / ${denominator}));
if (previousFrame === undefined) {
// Establish every compiler-owned root's boundary pose once. Later work
// is limited to active programs and spans crossed by an arbitrary seek.
for (const entry of renders) renderAt(entry, frame);
} else {
const work = new Map();
for (const item of workIndex.at(frame)) work.set(item.serial, item);
if (frame !== previousFrame) {
const startFrame = Math.min(previousFrame, frame);
const endFrameExclusive = Math.max(previousFrame, frame) + 1;
for (const item of workIndex.overlapping({ startFrame, endFrameExclusive })) work.set(item.serial, item);
}
for (const item of [...work.values()].sort((left, right) => left.order - right.order || left.serial - right.serial)) {
renderAt(item.payload, frame);
}
}
previousFrame = frame;
};
apply(0);
window.addEventListener('hf-seek', event => apply(event.detail?.time));
+61
View File
@@ -0,0 +1,61 @@
/**
* Publish the DOM roots which can paint this render's selected absolute frames.
* The consumer sees only DOM roots; Present and frame semantics stay here.
*/
export const presentationCaptureScopeRuntime = String.raw`
(() => {
const selected = [];
const presents = [...document.querySelectorAll('[data-hypit-present-start-frame][data-hypit-present-end-frame]')];
const media = (root) => [root, ...root.querySelectorAll('img,source,video,audio,image')];
const activate = (root) => {
for (const element of media(root)) {
for (const attribute of ['src', 'href']) {
const deferred = 'data-hypit-resource-' + attribute;
const value = element.getAttribute(deferred);
if (value === null) continue;
element.setAttribute(attribute, value);
element.removeAttribute(deferred);
}
}
};
const exclude = (root) => {
root.setAttribute('data-hypit-capture-excluded', '');
root.style.setProperty('display', 'none', 'important');
for (const element of media(root)) {
const name = element.localName;
const attributes = name === 'img' || name === 'source' ? ['src', 'srcset']
: name === 'video' ? ['src', 'poster']
: name === 'audio' ? ['src']
: name === 'image' ? ['href'] : [];
for (const attribute of attributes) element.removeAttribute(attribute);
element.removeAttribute('data-hypit-resource-src');
element.removeAttribute('data-hypit-resource-href');
if (name === 'image') element.removeAttributeNS('http://www.w3.org/1999/xlink', 'href');
}
// The complete document has already been parsed, but a render Worker will
// never request this Present. Detach it before DOMContentLoaded so generic
// browser adapters discover only live selected work. Compiler-owned shared
// definitions live outside Present roots; Present-local masks, paths and
// Browser Program structure leave with their owner.
root.remove();
};
for (const root of presents) {
const startFrame = Number(root.getAttribute('data-hypit-present-start-frame'));
const endFrameExclusive = Number(root.getAttribute('data-hypit-present-end-frame'));
if (!Number.isSafeInteger(startFrame) || !Number.isSafeInteger(endFrameExclusive)
|| startFrame < 0 || endFrameExclusive <= startFrame) {
throw new Error('HyperFrames Present has an invalid capture span.');
}
if (hyperframesSelectionOverlaps(startFrame, endFrameExclusive)) selected.push(root);
else exclude(root);
}
// Compiler-owned direct media URLs become observable to the browser only
// after unrelated Present roots are gone. Opaque author HTML remains opaque.
for (const root of selected) activate(root);
const definitions = [...document.querySelectorAll('[data-hypit-document-definitions]')];
Object.defineProperty(globalThis, '__hypitCaptureRoots', {
value: Object.freeze([...definitions, ...selected]),
writable: false,
configurable: false,
});
})();`;
+195 -95
View File
@@ -19,9 +19,13 @@ import { canonicalStringify, isResourceId } from "@hypit/protocol";
import type { BlobRef, ResourceId } from "@hypit/protocol";
import { VISUAL_IR_V1 } from "@hypit/visual-ir";
import { readBrowserProgram, browserProgramHtml, browserProgramScript } from "./browser-program.js";
import { presentationCaptureScopeRuntime } from "./capture-scope.js";
import { presentationVisibilityScript } from "./visibility.js";
import { frameSelectionRuntime, frameWorkIndexRuntime } from "./frame-work.js";
import type {
ResourceUrlResolver,
HyperframesArtifact,
HyperframesDocument,
HyperframesFrameSpan,
} from "./types.js";
@@ -115,20 +119,16 @@ function samplingRuns(segment: VisualSamplingSegment): Array<{
}> {
const length = segment.target.endFrameExclusive - segment.target.startFrame;
const runs: Array<{ startFrame: number; endFrameExclusive: number; sourceFrame: VisualSamplingRational }> = [];
// HyperFrames and HTMLMediaElement require a positive playback rate. A held
// visual frame is therefore represented as one independently addressed
// target-frame clip per Program frame. Every clip starts at the same exact
// source position; no browser-specific zero-rate behavior is assumed.
// Keep a hold as the interval it is. HTMLMediaElement cannot accept a zero
// playbackRate, but that browser limitation belongs to the render/preview
// adapters; expanding the shared sampling description here would turn one
// authored relation into O(target frames) DOM and Provider work.
if (segment.rate.numerator === 0) {
const source = sourcePosition(segment, 0).position;
for (let offset = 0; offset < length; offset += 1) {
runs.push({
startFrame: segment.target.startFrame + offset,
endFrameExclusive: segment.target.startFrame + offset + 1,
sourceFrame: source,
});
}
return runs;
return [{
startFrame: segment.target.startFrame,
endFrameExclusive: segment.target.endFrameExclusive,
sourceFrame: sourcePosition(segment, 0).position,
}];
}
let runStart = 0;
let runSource = sourcePosition(segment, 0);
@@ -158,9 +158,9 @@ function sampledPlaybackRate(
programNumerator: number,
programDenominator: number,
): string {
// Zero-rate segments have already been split into one-frame clips above.
// Their media clock may advance inside that single frame, while every
// independently rendered target frame still starts at the exact held source.
// The exact zero rate remains in data-hypit-source-rate for adapters that
// place discrete frames. This positive fallback is only the legal native
// HTMLMediaElement value; preview adapters pause and seek held elements.
if (rate.numerator === 0) return "1";
return rationalDecimal(
BigInt(rate.numerator) * BigInt(programNumerator) * BigInt(sourceFrameRate.denominator),
@@ -182,6 +182,11 @@ export function hyperframesResourceUri(resource: ResourceId): string {
return `hypit-resource://${resource}`;
}
/** Keep compiler-owned media inert until the page has applied its render selection. */
function deferredResource(attribute: "src" | "href", resource: ResourceId): string {
return `data-hypit-resource-${attribute}="${escapeHtml(hyperframesResourceUri(resource))}"`;
}
function css(style: readonly VisualStyleDeclaration[]): string {
return style.map(({ name, value }) => `${name}:${String(value)}`).join(";");
}
@@ -240,6 +245,7 @@ type ElementContext = {
readonly programDenominator: number;
readonly stackIndex: number;
readonly emittedFilterIds: Set<string>;
readonly sharedGlyphFilterDefinitions: string[];
readonly stableId: StableDomId;
};
@@ -249,9 +255,6 @@ function elementPresentation(element: VisualElement, context: ElementContext) {
const animationName = element.animation === undefined
? undefined
: context.stableId(["animation", context.trackId, context.presentId, element.id]);
const animationProperties = element.animation === undefined
? []
: [...new Set(element.animation.keyframes.flatMap((keyframe) => keyframe.style.map((declaration) => declaration.name)))].sort();
const animationDurationFrames = element.animation === undefined
? context.presentDurationFrames
: Math.max(context.presentDurationFrames, element.animation.keyframes.at(-1)?.atFrame ?? 0);
@@ -274,7 +277,7 @@ function elementPresentation(element: VisualElement, context: ElementContext) {
const commonAttributes = `id="${id}" data-hypit-element-id="${escapeHtml(element.id)}"${attributes(element.attributes)}`;
const animationAttributes = animationName === undefined
? ""
: ` data-hypit-frame-animation data-hypit-animation-start-frame="${context.presentStartFrame}" data-hypit-animation-duration-frames="${animationDurationFrames}" data-hypit-animation-sample-frames="${context.presentDurationFrames}" data-hypit-animation-properties="${animationProperties.join(",")}"`;
: ` data-hypit-frame-animation data-hypit-animation-start-frame="${context.presentStartFrame}" data-hypit-animation-sample-frames="${context.presentDurationFrames}"`;
const common = `${commonAttributes}${animationAttributes} style="${escapeHtml(inlineStyle)}"`;
return { inlineStyle, commonAttributes: `${commonAttributes}${animationAttributes}`, common };
}
@@ -327,10 +330,10 @@ function renderElement(
return `<foreignObject x="0" y="0" width="100%" height="100%"><div xmlns="http://www.w3.org/1999/xhtml" style="position:relative;width:100%;height:100%">${renderOwned(maskRoot)}</div></foreignObject>`;
}
if (maskRoot.kind === "image") {
return `<image ${sourcePresentation.common} x="0" y="0" width="100%" height="100%" preserveAspectRatio="none" href="${escapeHtml(hyperframesResourceUri(maskRoot.artifact.resource))}"/>`;
return `<image ${sourcePresentation.common} x="0" y="0" width="100%" height="100%" preserveAspectRatio="none" ${deferredResource("href", maskRoot.artifact.resource)}/>`;
}
if (maskRoot.kind === "surface" && maskRoot.surface.timing.kind === "still") {
return `<image ${sourcePresentation.common} data-hypit-surface-resource="${maskRoot.surface.artifact.resource}" x="0" y="0" width="100%" height="100%" preserveAspectRatio="none" href="${escapeHtml(hyperframesResourceUri(maskRoot.surface.artifact.resource))}"/>`;
return `<image ${sourcePresentation.common} data-hypit-surface-resource="${maskRoot.surface.artifact.resource}" x="0" y="0" width="100%" height="100%" preserveAspectRatio="none" ${deferredResource("href", maskRoot.surface.artifact.resource)}/>`;
}
throw new Error(`Local mask ${element.id} requires a terminal owned text, image or still Surface mask source.`);
})();
@@ -359,6 +362,7 @@ function renderElement(
baseStyle: inlineStyle,
commonAttributes,
emittedFilterIds: context.emittedFilterIds,
sharedGlyphFilterDefinitions: context.sharedGlyphFilterDefinitions,
};
if (element.kind === "text") {
const body = element.paints === undefined
@@ -371,7 +375,6 @@ function renderElement(
}
if ((element.kind === "video" || element.kind === "surface") && element.sampling !== undefined) {
const artifact = element.kind === "surface" ? element.surface.artifact : element.artifact;
const source = escapeHtml(hyperframesResourceUri(artifact.resource));
let part = 0;
return element.sampling.segments.flatMap((segment) => samplingRuns(segment).map((run) => {
part += 1;
@@ -404,11 +407,10 @@ function renderElement(
`height="${element.surface.height}"`,
] : []),
].filter(Boolean).join(" ");
return `<video ${media} src="${source}"></video>`;
return `<video ${media} ${deferredResource("src", artifact.resource)}></video>`;
})).join("");
}
if (element.kind === "surface") {
const source = escapeHtml(hyperframesResourceUri(element.surface.artifact.resource));
const surface = [
`data-hypit-surface-resource="${element.surface.artifact.resource}"`,
`data-start="${context.presentStart}"`,
@@ -419,10 +421,10 @@ function renderElement(
`width="${element.surface.width}"`,
`height="${element.surface.height}"`,
].join(" ");
if (element.surface.timing.kind === "still") return `<img ${common} ${surface} src="${source}"/>`;
if (element.surface.timing.kind === "still") return `<img ${common} ${surface} ${deferredResource("src", element.surface.artifact.resource)}/>`;
const timing = element.surface.timing;
const exact = `data-hypit-start-frame="${context.presentStartFrame}" data-hypit-end-frame="${context.presentStartFrame + timing.frameCount}" data-hypit-source-frame="0/1" data-hypit-source-rate="1/1" data-hypit-source-fps="${timing.frameRate.numerator}/${timing.frameRate.denominator}"`;
return `<video ${common} ${surface} ${exact} muted playsinline src="${source}"></video>`;
return `<video ${common} ${surface} ${exact} muted playsinline ${deferredResource("src", element.surface.artifact.resource)}></video>`;
}
const media = [
@@ -432,10 +434,9 @@ function renderElement(
element.kind === "video" && element.muted !== false ? "muted" : "",
element.kind === "video" ? "playsinline" : "",
].filter(Boolean).join(" ");
const source = escapeHtml(hyperframesResourceUri(element.artifact.resource));
if (element.kind === "image") return `<img ${common} ${media} src="${source}"/>`;
if (element.kind === "image") return `<img ${common} ${media} ${deferredResource("src", element.artifact.resource)}/>`;
const exact = `data-hypit-start-frame="${context.presentStartFrame}" data-hypit-end-frame="${context.presentStartFrame + context.presentDurationFrames}" data-hypit-source-frame="0/1" data-hypit-source-rate="1/1" data-hypit-source-fps="${context.programNumerator}/${context.programDenominator}"`;
return `<video ${common} ${media} ${exact} src="${source}">${descendants}</video>`;
return `<video ${common} ${media} ${exact} ${deferredResource("src", element.artifact.resource)}>${descendants}</video>`;
}
function renderVisualPresent(
@@ -445,6 +446,7 @@ function renderVisualPresent(
numerator: number,
denominator: number,
emittedFilterIds: Set<string>,
sharedGlyphFilterDefinitions: string[],
stableId: StableDomId,
): string {
const start = frameSeconds(present.span.startFrame, numerator, denominator);
@@ -469,9 +471,10 @@ function renderVisualPresent(
programDenominator: denominator,
stackIndex,
emittedFilterIds,
sharedGlyphFilterDefinitions,
stableId,
});
return `<div class="clip hypit-visual-present" data-hypit-track-id="${escapeHtml(track.id)}" data-hypit-present-id="${escapeHtml(present.id)}" data-hypit-stack-order="${present.stacking.order}" data-hypit-stack-tie="${escapeHtml(present.stacking.tieBreak)}" data-track-index="${stackIndex}" data-start="${start}" data-duration="${duration}" style="position:absolute;inset:0;z-index:${stackIndex};overflow:hidden;pointer-events:none">${present.visibility === undefined ? contents : `<div data-hypit-visibility="${escapeHtml(JSON.stringify(present.visibility))}" style="position:absolute;inset:0">${contents}</div>`}</div>`;
return `<div class="clip hypit-visual-present" data-hypit-track-id="${escapeHtml(track.id)}" data-hypit-present-id="${escapeHtml(present.id)}" data-hypit-present-start-frame="${present.span.startFrame}" data-hypit-present-end-frame="${present.span.endFrameExclusive}" data-hypit-stack-order="${present.stacking.order}" data-hypit-stack-tie="${escapeHtml(present.stacking.tieBreak)}" data-track-index="${stackIndex}" data-start="${start}" data-duration="${duration}" style="position:absolute;inset:0;z-index:${stackIndex};overflow:hidden;pointer-events:none">${present.visibility === undefined ? contents : `<div data-hypit-visibility="${escapeHtml(JSON.stringify(present.visibility))}" style="position:absolute;inset:0">${contents}</div>`}</div>`;
}
function renderAnimationRules(track: VisualTrack, present: VisualPresent, stableId: StableDomId): string[] {
@@ -505,9 +508,25 @@ function orderedVisualPresents(tracks: readonly Track[]): Array<{ readonly track
|| left.present.id.localeCompare(right.present.id));
}
function collectArtifacts(composition: Composition): BlobRef[] {
const artifacts = new Map<ResourceId, BlobRef>();
const add = (artifact: Pick<BlobRef, "resource" | "size" | "mediaType">): void => {
function mergeFrameSpans(spans: readonly HyperframesFrameSpan[]): HyperframesFrameSpan[] {
const result: HyperframesFrameSpan[] = [];
for (const span of [...spans].sort((left, right) => left.startFrame - right.startFrame
|| left.endFrameExclusive - right.endFrameExclusive)) {
const previous = result.at(-1);
if (previous !== undefined && span.startFrame <= previous.endFrameExclusive) {
result[result.length - 1] = {
startFrame: previous.startFrame,
endFrameExclusive: Math.max(previous.endFrameExclusive, span.endFrameExclusive),
};
} else result.push({ ...span });
}
return result;
}
function collectArtifacts(composition: Composition): HyperframesArtifact[] {
const artifacts = new Map<ResourceId, { artifact: BlobRef; always: boolean; spans: HyperframesFrameSpan[] }>();
const add = (artifact: Pick<BlobRef, "resource" | "size" | "mediaType">,
usage: "always" | HyperframesFrameSpan): void => {
const next: BlobRef = {
kind: "blob",
resource: artifact.resource,
@@ -515,32 +534,43 @@ function collectArtifacts(composition: Composition): BlobRef[] {
mediaType: artifact.mediaType,
};
const existing = artifacts.get(artifact.resource);
if (existing !== undefined && (existing.size !== next.size || existing.mediaType !== next.mediaType)) {
if (existing !== undefined && (existing.artifact.size !== next.size || existing.artifact.mediaType !== next.mediaType)) {
throw new Error(`HyperFrames Artifact ${artifact.resource} has conflicting metadata.`);
}
artifacts.set(artifact.resource, next);
const entry = existing ?? { artifact: next, always: false, spans: [] };
if (usage === "always") entry.always = true;
else if (!entry.always) entry.spans.push({ ...usage });
artifacts.set(artifact.resource, entry);
};
for (const track of composition.tracks) {
if (track.kind === "audio") continue;
for (const present of track.presents) {
for (const element of present.elements) {
if (element.kind === "image" || element.kind === "video") add(element.artifact);
if (element.kind === "surface") add(element.surface.artifact);
if (element.kind === "program") for (const artifact of element.program.artifacts) add(artifact);
if (element.kind === "image" || element.kind === "video") add(element.artifact, present.span);
if (element.kind === "surface") add(element.surface.artifact, present.span);
// Browser Program HTML, CSS and setup are intentionally opaque. Their
// declared dependencies stay conservative even though the Program root
// itself has a Present span.
if (element.kind === "program") for (const artifact of element.program.artifacts) add(artifact, "always");
if (element.kind === "text") {
for (const font of element.fonts ?? []) {
for (const source of font.sources) add(source.artifact);
// @font-face declarations live at document scope. Keep them whole
// rather than infer browser font demand from text structure.
for (const source of font.sources) add(source.artifact, "always");
}
}
if (element.kind === "text-flow" || element.kind === "path-text") {
for (const font of collectTerminalTextFonts(element)) {
for (const source of font.sources) add(source.artifact);
for (const source of font.sources) add(source.artifact, "always");
}
}
}
}
}
return [...artifacts.values()].sort((left, right) => left.resource.localeCompare(right.resource));
return [...artifacts.values()].map(({ artifact, always, spans }) => ({
artifact,
usage: always ? { kind: "always" as const } : { kind: "frames" as const, spans: mergeFrameSpans(spans) },
})).sort((left, right) => left.artifact.resource.localeCompare(right.artifact.resource));
}
function collectSurfaces(composition: Composition): CompositableSurfaceRef[] {
@@ -623,38 +653,54 @@ function frameAnimationRuntime(numerator: number, denominator: number): string {
const numerator = ${numerator};
const denominator = ${denominator};
const millisecondsPerFrame = denominator * 1000 / numerator;
const timelines = [];
const groupsBySpan = new Map();
for (const element of document.querySelectorAll("[data-hypit-frame-animation]")) {
const start = Number(element.getAttribute("data-hypit-animation-start-frame"));
const duration = Number(element.getAttribute("data-hypit-animation-sample-frames"));
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(duration) || start < 0 || duration <= 0) {
throw new Error("Visual IR frame animation has an invalid Present span.");
}
const end = start + duration;
if (!Number.isSafeInteger(end)) throw new Error("Visual IR frame animation Present span exceeds safe arithmetic.");
// CSS already keeps every authored animation paused. Only materialize the
// browser Animation objects which this render can actually sample.
if (!hyperframesSelectionOverlaps(start, end)) continue;
void element.getBoundingClientRect();
const animation = element.getAnimations()[0];
if (animation === undefined) throw new Error("Visual IR frame animation did not materialize.");
const start = Number(element.getAttribute("data-hypit-animation-start-frame"));
const duration = Number(element.getAttribute("data-hypit-animation-duration-frames"));
const sampleDuration = Number(element.getAttribute("data-hypit-animation-sample-frames"));
const properties = String(element.getAttribute("data-hypit-animation-properties") || "")
.split(",").filter(Boolean);
const frames = [];
for (let frame = 0; frame <= sampleDuration; frame += 1) {
animation.currentTime = frame * millisecondsPerFrame;
animation.pause();
const style = getComputedStyle(element);
frames.push(properties.map((property) => [property, style.getPropertyValue(property)]));
animation.pause();
animation.currentTime = 0;
// The authored keyframes are already the compact state function. Keep the
// browser's paused evaluator instead of expanding every Present frame into
// a second table of computed-style strings in every render Worker.
const key = start + ":" + end;
let work = groupsBySpan.get(key);
if (work === undefined) {
work = {
startFrame: start,
endFrameExclusive: end,
order: groupsBySpan.size,
payload: { animations: [] },
};
groupsBySpan.set(key, work);
}
animation.cancel();
element.style.animationName = "none";
timelines.push({ element, start, duration: sampleDuration, frames });
work.payload.animations.push(animation);
}
// The shared index owns only frame-span lookup. Animation materialization and
// absolute currentTime evaluation remain private to this adapter.
const workIndex = hyperframesCreateFrameWorkIndex([...groupsBySpan.values()]);
const applyFrame = (time) => {
const programFrame = Math.max(0, Math.round(Number(time || 0) * numerator / denominator));
for (const timeline of timelines) {
const localFrame = Math.max(0, Math.min(timeline.duration, programFrame - timeline.start));
for (const [property, value] of timeline.frames[localFrame]) {
timeline.element.style.setProperty(property, value);
}
for (const work of workIndex.at(programFrame)) {
const localTime = (programFrame - work.startFrame) * millisecondsPerFrame;
// Always derive the pose from the absolute requested frame. Worker
// partitioning, seek order and the previously rendered frame are not
// inputs to animation state.
for (const animation of work.payload.animations) animation.currentTime = localTime;
}
void document.documentElement.getBoundingClientRect();
};
applyFrame(0);
void document.documentElement.getBoundingClientRect();
window.addEventListener("hf-seek", (event) => applyFrame(event.detail?.time));
})();`;
}
@@ -662,11 +708,16 @@ function frameAnimationRuntime(numerator: number, denominator: number): string {
function emitHtml(composition: Composition, programSpace: ProgramSpace): string {
const { numerator, denominator } = programSpace.frameRate;
const visuals = orderedVisualPresents(composition.tracks);
// One document, one set of glyph filter definitions: every Present writes only what is not
// already there, and references resolve across the document regardless of where they landed.
// One document, one set of glyph filter definitions. Keep definitions outside
// temporal Present roots so a render can make an unrelated Present inert
// without invalidating a selected Present's document-wide url(#id) reference.
const emittedFilterIds = new Set<string>();
const sharedGlyphFilterDefinitions: string[] = [];
const stableId = createStableDomId();
const visualHtml = visuals.map(({ track, present }, index) => renderVisualPresent(track, present, index, numerator, denominator, emittedFilterIds, stableId)).join("\n ");
const visualHtml = visuals.map(({ track, present }, index) => renderVisualPresent(track, present, index,
numerator, denominator, emittedFilterIds, sharedGlyphFilterDefinitions, stableId)).join("\n ");
const sharedDefinitionsHtml = sharedGlyphFilterDefinitions.length === 0 ? ""
: `<svg data-hypit-document-definitions aria-hidden="true" width="0" height="0" style="position:absolute;overflow:hidden"><defs>${sharedGlyphFilterDefinitions.join("")}</defs></svg>`;
const animationCss = visuals.flatMap(({ track, present }) => renderAnimationRules(track, present, stableId)).join("\n ");
const fontCss = renderFontFaces(composition, stableId);
const programs = visuals.flatMap(({ track, present }) => present.elements.flatMap(element => element.kind !== "program" ? [] : [{
@@ -676,28 +727,17 @@ function emitHtml(composition: Composition, programSpace: ProgramSpace): string
program: readBrowserProgram(element.program),
}]));
const programCss = programs.map(entry => `@scope (#${entry.id}) { ${entry.program.css ?? ""} }`).join("\n");
const hasVisibility = visuals.some(({ present }) => present.visibility !== undefined);
const programRuntime = programs.length === 0 ? "" : `<script>${browserProgramScript(programs, numerator, denominator)}</script>`;
const visibilityRuntime = visuals.some(({ present }) => present.visibility !== undefined) ? `<script>
(() => {
const entries = [...document.querySelectorAll('[data-hypit-visibility]')].map(element => ({ element, spans: JSON.parse(element.dataset.hypitVisibility) }));
const apply = time => {
const frame = Math.round(Number(time || 0) * ${numerator} / ${denominator});
// A presentation mask controls painting, not layout or animation existence.
// display:none removes CSS animations and prevents hidden text from being measured.
for (const { element, spans } of entries) element.style.opacity = spans.some(s => frame >= s.startFrame && frame < s.endFrameExclusive) ? '' : '0';
};
apply(0);
window.addEventListener('hf-seek', event => apply(event.detail?.time));
})();</script>` : "";
const visibilityRuntime = hasVisibility ? `<script>${presentationVisibilityScript(numerator, denominator)}</script>` : "";
const duration = frameSeconds(programSpaceFrameCount(programSpace), numerator, denominator);
const fps = fpsRational(numerator, denominator);
const frameCount = programSpaceFrameCount(programSpace);
const textRuntime = hasTerminalText(composition)
? `\n <script>\n ${terminalTextLayoutScript}\n </script>`
: "";
const animationRuntime = hasFrameAnimations(composition)
? `\n <script>\n ${frameAnimationRuntime(numerator, denominator)}\n </script>`
: "";
const needsFrameWork = hasFrameAnimations(composition) || hasTerminalText(composition) || programs.length > 0 || hasVisibility;
const frameRuntime = `\n <script>\n ${needsFrameWork ? frameWorkIndexRuntime : frameSelectionRuntime}\n </script>
<script>${presentationCaptureScopeRuntime}</script>${hasFrameAnimations(composition) || hasTerminalText(composition)
? `\n <script>${hasFrameAnimations(composition) ? `\n ${frameAnimationRuntime(numerator, denominator)}` : ""}${hasTerminalText(composition) ? `\n ${terminalTextLayoutScript}` : ""}\n </script>`
: ""}`;
return `<!doctype html>
<html>
<head>
@@ -714,9 +754,9 @@ function emitHtml(composition: Composition, programSpace: ProgramSpace): string
</head>
<body>
<div data-composition-id="${escapeHtml(composition.id)}" data-start="0" data-no-timeline data-width="${composition.canvas.width}" data-height="${composition.canvas.height}" data-duration="${duration}" data-fps="${fps}" data-hypit-frame-count="${frameCount}">
${visualHtml}
</div>${programRuntime}
${visibilityRuntime}${animationRuntime}${textRuntime}
${sharedDefinitionsHtml}${sharedDefinitionsHtml.length === 0 ? "" : "\n "}${visualHtml}
</div>${frameRuntime}${programRuntime}
${visibilityRuntime}
</body>
</html>
`;
@@ -729,8 +769,10 @@ function normalizedDocument(value: HyperframesDocument): HyperframesDocument {
frameCount: value.frameCount,
canvas: { ...value.canvas },
artifacts: [...value.artifacts]
.map((artifact) => ({ ...artifact }))
.sort((left, right) => left.resource.localeCompare(right.resource)),
.map((entry) => ({ artifact: { ...entry.artifact }, usage: entry.usage.kind === "always"
? { kind: "always" as const }
: { kind: "frames" as const, spans: entry.usage.spans.map((span) => ({ ...span })) } }))
.sort((left, right) => left.artifact.resource.localeCompare(right.artifact.resource)),
surfaces: [...value.surfaces]
.map((surface) => structuredClone(surface))
.sort((left, right) => left.artifact.resource.localeCompare(right.artifact.resource)),
@@ -773,14 +815,23 @@ export function assertHyperframesDocument(document: HyperframesDocument): void {
}
if (!document.html.startsWith("<!doctype html>")) throw new Error("HyperframesDocument HTML is invalid.");
const declared = [...document.artifacts];
if (declared.some((item) => item.kind !== "blob" || !isResourceId(item.resource)
|| !Number.isSafeInteger(item.size) || item.size < 0 || item.mediaType.length === 0)
|| new Set(declared.map((item) => item.resource)).size !== declared.length) {
if (declared.some((entry) => {
const item = entry?.artifact;
if (item?.kind !== "blob" || !isResourceId(item.resource)
|| !Number.isSafeInteger(item.size) || item.size < 0 || item.mediaType.length === 0) return true;
if (entry.usage?.kind === "always") return Object.keys(entry.usage).length !== 1;
if (entry.usage?.kind !== "frames" || !Array.isArray(entry.usage.spans) || entry.usage.spans.length === 0) return true;
if (entry.usage.spans.some((span) => span === null || typeof span !== "object"
|| !Number.isSafeInteger(span.startFrame) || !Number.isSafeInteger(span.endFrameExclusive)
|| span.startFrame < 0 || span.endFrameExclusive <= span.startFrame
|| span.endFrameExclusive > document.frameCount)) return true;
return JSON.stringify(mergeFrameSpans(entry.usage.spans)) !== JSON.stringify(entry.usage.spans);
}) || new Set(declared.map((entry) => entry.artifact.resource)).size !== declared.length) {
throw new Error("HyperframesDocument Artifact set is invalid.");
}
const referenced = [...document.html.matchAll(RESOURCE_URI)].map((match) => match[1] as ResourceId);
const actual = [...new Set(referenced)].sort();
if (JSON.stringify(actual) !== JSON.stringify(declared.map((item) => item.resource).sort())) {
if (JSON.stringify(actual) !== JSON.stringify(declared.map((entry) => entry.artifact.resource).sort())) {
throw new Error("HyperframesDocument Artifact placeholders do not match its declared dependencies.");
}
if (!Array.isArray(document.surfaces)) throw new Error("HyperframesDocument Surface set is invalid.");
@@ -791,7 +842,7 @@ export function assertHyperframesDocument(document: HyperframesDocument): void {
throw new Error("HyperframesDocument Surface set repeats an Artifact.");
}
surfaceArtifacts.add(surface.artifact.resource);
const artifact = declared.find((item) => item.resource === surface.artifact.resource);
const artifact = declared.find((entry) => entry.artifact.resource === surface.artifact.resource)?.artifact;
if (artifact === undefined
|| artifact.size !== surface.artifact.size
|| artifact.mediaType !== surface.artifact.mediaType) {
@@ -833,13 +884,62 @@ export function assertHyperframesFrameSpan(
}
}
function assertHyperframesFrameSelection(
document: HyperframesDocument,
selection: readonly HyperframesFrameSpan[],
): void {
if (selection.length === 0) throw new Error("HyperFrames Artifact selection must not be empty");
let previousEnd = 0;
for (const [index, span] of selection.entries()) {
if (span === null || typeof span !== "object"
|| !Number.isSafeInteger(span.startFrame) || !Number.isSafeInteger(span.endFrameExclusive)
|| span.startFrame < 0 || span.endFrameExclusive <= span.startFrame
|| span.endFrameExclusive > document.frameCount
|| (index > 0 && span.startFrame < previousEnd)) {
throw new Error("HyperFrames Artifact selection must contain ordered, disjoint spans inside the document frame domain");
}
previousEnd = span.endFrameExclusive;
}
}
function artifactUsageOverlaps(
usage: readonly HyperframesFrameSpan[],
selection: readonly HyperframesFrameSpan[],
): boolean {
return usage.some((span) => {
let low = 0;
let high = selection.length;
while (low < high) {
const middle = low + Math.floor((high - low) / 2);
if (selection[middle]!.endFrameExclusive <= span.startFrame) low = middle + 1;
else high = middle;
}
return low < selection.length && selection[low]!.startFrame < span.endFrameExclusive;
});
}
/**
* Project one whole render selection onto the compiler's conservative byte dependencies.
* The result is shared by every worker; no worker or traversal state enters the proof.
*/
export function selectHyperframesArtifacts(
document: HyperframesDocument,
frameSelection?: readonly HyperframesFrameSpan[],
): readonly HyperframesArtifact[] {
assertHyperframesDocument(document);
if (frameSelection === undefined) return document.artifacts;
assertHyperframesFrameSelection(document, frameSelection);
return document.artifacts.filter((entry) => entry.usage.kind === "always"
|| artifactUsageOverlaps(entry.usage.spans, frameSelection));
}
/** Runtime-only URL materialization. The returned HTML is intentionally not a new compiled Record. */
export function materializeHyperframesHtml(
document: HyperframesDocument,
resolve: ResourceUrlResolver,
): string {
assertHyperframesDocument(document);
const artifacts = new Map(document.artifacts.map((artifact) => [artifact.resource, artifact]));
const artifacts = new Map(document.artifacts.map((entry) => [entry.artifact.resource, entry.artifact]));
return document.html.replace(RESOURCE_URI, (_uri, resource: ResourceId) => {
const artifact = artifacts.get(resource);
if (artifact === undefined) throw new Error(`HyperFrames Resource ${resource} is undeclared.`);
+148
View File
@@ -0,0 +1,148 @@
import type { HyperframesFrameSpan } from "./types.js";
/**
* Page-local absolute-frame work lookup shared by HyperFrames runtime features.
*
* The index deliberately knows only half-open frame spans, stable order and an
* opaque payload. Text, animation and media semantics remain with their own
* evaluators. Every render page derives and discards its own index.
*/
export const frameSelectionRuntime = String.raw`
const hyperframesRequestedFrameSpans = (() => {
const source = globalThis.__hyperframesRequestedFrameSpans;
if (source === undefined) return null;
if (!Array.isArray(source)) throw new Error('HyperFrames requested frame spans must be an array.');
let previousEnd = -1;
return Object.freeze(source.map((span) => {
if (!Array.isArray(span) || span.length !== 2) {
throw new Error('HyperFrames requested frame span must be a [start, end) pair.');
}
const startFrame = Number(span[0]);
const endFrameExclusive = Number(span[1]);
if (!Number.isSafeInteger(startFrame) || !Number.isSafeInteger(endFrameExclusive)
|| startFrame < 0 || endFrameExclusive <= startFrame || startFrame < previousEnd) {
throw new Error('HyperFrames requested frame spans must be ordered, disjoint and valid.');
}
previousEnd = endFrameExclusive;
return Object.freeze({ startFrame, endFrameExclusive });
}));
})();
const hyperframesSelectionOverlaps = (startFrame, endFrameExclusive) => {
if (!Number.isSafeInteger(startFrame) || !Number.isSafeInteger(endFrameExclusive)
|| startFrame < 0 || endFrameExclusive <= startFrame) {
throw new Error('HyperFrames selection query span is invalid.');
}
if (hyperframesRequestedFrameSpans === null) return true;
let low = 0;
let high = hyperframesRequestedFrameSpans.length;
while (low < high) {
const middle = (low + high) >>> 1;
if (hyperframesRequestedFrameSpans[middle].endFrameExclusive <= startFrame) low = middle + 1;
else high = middle;
}
const candidate = hyperframesRequestedFrameSpans[low];
return candidate !== undefined && candidate.startFrame < endFrameExclusive;
};
`;
export const frameWorkIndexRuntime = String.raw`${frameSelectionRuntime}
const hyperframesCreateFrameWorkIndex = (sourceEntries) => {
const entries = sourceEntries.map((entry, serial) => {
const startFrame = Number(entry.startFrame);
const endFrameExclusive = Number(entry.endFrameExclusive);
const order = entry.order === undefined ? serial : Number(entry.order);
if (!Number.isSafeInteger(startFrame) || !Number.isSafeInteger(endFrameExclusive)
|| startFrame < 0 || endFrameExclusive <= startFrame || !Number.isSafeInteger(order)) {
throw new Error('HyperFrames frame work has an invalid span or order.');
}
return Object.freeze({ startFrame, endFrameExclusive, order, serial, payload: entry.payload });
});
const build = (items) => {
if (items.length === 0) return null;
const centers = items
.map((entry) => entry.startFrame + (entry.endFrameExclusive - entry.startFrame) / 2)
.sort((left, right) => left - right);
const center = centers[Math.floor(centers.length / 2)];
const left = [];
const right = [];
const overlaps = [];
for (const entry of items) {
if (entry.endFrameExclusive <= center) left.push(entry);
else if (entry.startFrame > center) right.push(entry);
else overlaps.push(entry);
}
return Object.freeze({
center,
byStart: Object.freeze([...overlaps].sort((a, b) =>
a.startFrame - b.startFrame || a.endFrameExclusive - b.endFrameExclusive || a.serial - b.serial)),
byEnd: Object.freeze([...overlaps].sort((a, b) =>
b.endFrameExclusive - a.endFrameExclusive || a.startFrame - b.startFrame || a.serial - b.serial)),
left: build(left),
right: build(right),
});
};
const root = build(entries);
const collect = (node, frame, result) => {
if (node === null) return;
if (frame < node.center) {
for (const entry of node.byStart) {
if (entry.startFrame > frame) break;
result.push(entry);
}
collect(node.left, frame, result);
return;
}
for (const entry of node.byEnd) {
if (entry.endFrameExclusive <= frame) break;
result.push(entry);
}
collect(node.right, frame, result);
};
const collectOverlapping = (node, span, result) => {
if (node === null) return;
for (const entry of node.byStart) {
if (entry.startFrame >= span.endFrameExclusive) break;
if (entry.endFrameExclusive > span.startFrame) result.push(entry);
}
if (span.startFrame < node.center) collectOverlapping(node.left, span, result);
if (span.endFrameExclusive > node.center) collectOverlapping(node.right, span, result);
};
const ordered = (result) => result.sort((left, right) => left.order - right.order || left.serial - right.serial);
return Object.freeze({
at(frame) {
if (!Number.isSafeInteger(frame) || frame < 0) throw new Error('HyperFrames frame work query is invalid.');
const result = [];
collect(root, frame, result);
return ordered(result);
},
overlapping(span) {
const startFrame = Number(span?.startFrame);
const endFrameExclusive = Number(span?.endFrameExclusive);
if (!Number.isSafeInteger(startFrame) || !Number.isSafeInteger(endFrameExclusive)
|| startFrame < 0 || endFrameExclusive <= startFrame) {
throw new Error('HyperFrames frame work query span is invalid.');
}
const result = [];
collectOverlapping(root, { startFrame, endFrameExclusive }, result);
return ordered(result);
},
});
};`;
/**
* A render-local hint installed before the compiled page runs. It describes
* only absolute frame spans; worker assignment and capture machinery remain
* private to the Provider.
*/
export function hyperframesFrameSelectionPrelude(spans: readonly HyperframesFrameSpan[]): string {
let previousEnd = -1;
const encoded = spans.map((span) => {
if (!Number.isSafeInteger(span.startFrame) || !Number.isSafeInteger(span.endFrameExclusive)
|| span.startFrame < 0 || span.endFrameExclusive <= span.startFrame || span.startFrame < previousEnd) {
throw new Error("HyperFrames requested frame spans must be ordered, disjoint and valid.");
}
previousEnd = span.endFrameExclusive;
return [span.startFrame, span.endFrameExclusive];
});
return `Object.defineProperty(globalThis,"__hyperframesRequestedFrameSpans",{value:${JSON.stringify(encoded)},writable:false,configurable:false});`;
}
+1 -1
View File
@@ -64,7 +64,7 @@ export function mapHyperframesHtmlUrls(html: string, map: (url: string) => strin
(_all, attributes: string, css: string) => `<style${attributes}>${cssUrls(css)}</style>`)
.replace(/<[a-z][^>]*>/gu, tag => {
const attributes = /^(?:<(?:img|video|source|image|use|script|link)\b)/u.test(tag)
? tag.replace(/\s(src|poster|href)="([^"]*)"/gu,
? tag.replace(/\s(src|poster|href|data-hypit-resource-src|data-hypit-resource-href)="([^"]*)"/gu,
(_all, key: string, url: string) => ` ${key}="${escape(map(unescape(url)))}"`) : tag;
return attributes.replace(/\sstyle="([^"]*)"/gu,
(_all, css: string) => ` style="${escape(cssUrls(unescape(css)))}"`);
+2 -1
View File
@@ -1,6 +1,6 @@
export { hyperframesDocumentFragment } from "./fragment.js";
export { hyperframesComponent } from "./component.js";
export { assertHyperframesDocument, assertHyperframesFrameIndex, assertHyperframesFrameSpan, compileHyperframesDocument, hyperframesResourceUri, hyperframesTime, materializeHyperframesHtml } from "./document.js";
export { assertHyperframesDocument, assertHyperframesFrameIndex, assertHyperframesFrameSpan, compileHyperframesDocument, hyperframesResourceUri, hyperframesTime, materializeHyperframesHtml, selectHyperframesArtifacts } from "./document.js";
export {
hyperframesDocumentSchema,
hyperframesManifest,
@@ -11,5 +11,6 @@ export {
export type * from "./types.js";
export { browserProgram, BROWSER_PROGRAM_FORMAT } from "./browser-program.js";
export type { BrowserProgram } from "./browser-program.js";
export { hyperframesFrameSelectionPrelude } from "./frame-work.js";
export { assertHyperframesHtmlProject, hyperframesHtmlDomain, hyperframesHtmlAssetUrls, mapHyperframesHtmlUrls } from "./html-project.js";
export type { HyperframesHtmlProject } from "./html-project.js";
+16 -4
View File
@@ -37,10 +37,22 @@ export const hyperframesDocumentSchema: ValueSchema = {
artifacts: { schema: { kind: "array", items: {
kind: "object",
fields: {
kind: { schema: { kind: "literal", value: "blob" } },
resource: { schema: resource },
size: { schema: nonNegativeInteger },
mediaType: { schema: { kind: "string", minLength: 1 } },
artifact: { schema: { kind: "object", fields: {
kind: { schema: { kind: "literal", value: "blob" } },
resource: { schema: resource },
size: { schema: nonNegativeInteger },
mediaType: { schema: { kind: "string", minLength: 1 } },
} } },
usage: { schema: { kind: "oneOf", variants: [
{ kind: "object", fields: { kind: { schema: { kind: "literal", value: "always" } } } },
{ kind: "object", fields: {
kind: { schema: { kind: "literal", value: "frames" } },
spans: { schema: { kind: "array", minItems: 1, items: { kind: "object", fields: {
startFrame: { schema: nonNegativeInteger },
endFrameExclusive: { schema: positiveInteger },
} } } },
} },
] } },
},
} } },
surfaces: { schema: { kind: "array", items: compositableSurfaceSchema } },
+93 -49
View File
@@ -4,8 +4,8 @@ import { join } from "node:path";
import type { CompositableSurfaceRef } from "@hypit/media";
import type { BlobRef } from "@hypit/protocol";
import { assertHyperframesDocument, materializeHyperframesHtml } from "./document.js";
import type { HyperframesDocument } from "./types.js";
import { materializeHyperframesHtml, selectHyperframesArtifacts } from "./document.js";
import type { HyperframesDocument, HyperframesFrameSpan } from "./types.js";
import { assertHyperframesHtmlProject, mapHyperframesHtmlUrls } from "./html-project.js";
import type { HyperframesHtmlProject } from "./html-project.js";
@@ -49,16 +49,26 @@ export async function stageHyperframesHtmlProject(options: {
assertHyperframesHtmlProject(options.project);
await mkdir(join(options.directory, "artifacts"), { recursive: true });
const paths = new Map<string, string>();
for (const [index, asset] of options.project.assets.entries()) {
const resourcePaths = new Map<string, { readonly artifact: BlobRef; readonly path: string }>();
for (const asset of options.project.assets) {
options.signal?.throwIfAborted();
const path = `artifacts/asset-${index}${extension(asset.artifact.mediaType)}`;
const bytes = await options.read(asset.artifact, options.signal);
const file = await open(join(options.directory, path), "w");
try {
if (bytes instanceof Uint8Array) await file.writeFile(bytes);
else for await (const chunk of bytes) { options.signal?.throwIfAborted(); await file.writeFile(chunk); }
} finally { await file.close(); }
paths.set(asset.url, `./${path}`);
let staged = resourcePaths.get(asset.artifact.resource);
if (staged !== undefined) {
if (staged.artifact.size !== asset.artifact.size || staged.artifact.mediaType !== asset.artifact.mediaType) {
throw new Error(`HyperFrames Artifact ${asset.artifact.resource} has conflicting metadata`);
}
} else {
const path = `artifacts/asset-${resourcePaths.size}${extension(asset.artifact.mediaType)}`;
const bytes = await options.read(asset.artifact, options.signal);
const file = await open(join(options.directory, path), "w");
try {
if (bytes instanceof Uint8Array) await file.writeFile(bytes);
else for await (const chunk of bytes) { options.signal?.throwIfAborted(); await file.writeFile(chunk); }
} finally { await file.close(); }
staged = { artifact: asset.artifact, path };
resourcePaths.set(asset.artifact.resource, staged);
}
paths.set(asset.url, `./${staged.path}`);
}
await writeFile(join(options.directory, "index.html"), mapHyperframesHtmlUrls(options.project.html, url => paths.get(url) ?? url), {
encoding: "utf8", ...(options.signal === undefined ? {} : { signal: options.signal }),
@@ -76,61 +86,95 @@ export async function stageHyperframesProject(options: {
readonly directory: string;
readonly read: HyperframesArtifactReader;
readonly validateSurface?: HyperframesSurfaceValidator;
/** Whole-request absolute selection. Omit only when every frame may be rendered. */
readonly frameSelection?: readonly HyperframesFrameSpan[];
/** Maximum complete Artifact read/write/validation lifecycles in flight. */
readonly maxConcurrentArtifacts?: number;
readonly signal?: AbortSignal;
}): Promise<void> {
const { document, directory, read } = options;
const controller = new AbortController();
const signal = options.signal === undefined ? controller.signal : AbortSignal.any([options.signal, controller.signal]);
signal.throwIfAborted();
assertHyperframesDocument(document);
if (document.surfaces.length > 0 && options.validateSurface === undefined) {
throw new Error("HyperFrames Runtime has no Surface-byte validator");
const selectedResources = new Set(selectHyperframesArtifacts(document, options.frameSelection)
.map((entry) => entry.artifact.resource));
const maxConcurrentArtifacts = options.maxConcurrentArtifacts ?? 1;
if (!Number.isSafeInteger(maxConcurrentArtifacts) || maxConcurrentArtifacts < 1) {
throw new Error("HyperFrames Artifact staging concurrency must be a positive safe integer");
}
const artifactDirectory = join(directory, "artifacts");
await mkdir(artifactDirectory, { recursive: true });
const paths = new Map<string, string>();
const selected = document.artifacts.map((entry, index) => ({ entry, index }))
.filter(({ entry }) => selectedResources.has(entry.artifact.resource));
// Paths remain a deterministic function of the complete compiled Document.
// Unselected compiler-owned media keep a missing local URL in inert markup;
// selected roots can only activate Artifacts admitted above.
const paths = new Map(document.artifacts.map((entry, index) => [entry.artifact.resource,
`./artifacts/asset-${index}${extension(entry.artifact.mediaType)}`]));
const surfaces = new Map(document.surfaces.map((surface) => [surface.artifact.resource, surface]));
await Promise.allSettled(document.artifacts.map(async (artifact, index) => {
try {
signal.throwIfAborted();
// Resource identities are portable protocol values, not filesystem names.
const name = `asset-${index}${extension(artifact.mediaType)}`;
const opened = await read(artifact, signal);
signal.throwIfAborted();
const chunks = opened instanceof Uint8Array
? (async function* () { yield opened; })()
: opened;
const surface = surfaces.get(artifact.resource);
let size = 0;
const path = join(artifactDirectory, name);
const target = await open(path, "w");
if (options.validateSurface === undefined
&& selected.some(({ entry }) => surfaces.has(entry.artifact.resource))) {
throw new Error("HyperFrames Runtime has no Surface-byte validator");
}
let nextArtifact = 0;
let failure: unknown;
const stageNext = async (): Promise<void> => {
while (failure === undefined) {
const index = nextArtifact;
if (index >= selected.length) return;
nextArtifact += 1;
const selectedArtifact = selected[index]!;
const artifact = selectedArtifact.entry.artifact;
try {
for await (const chunk of chunks) {
signal.throwIfAborted();
await target.writeFile(chunk);
size += chunk.byteLength;
signal.throwIfAborted();
// Resource identities are portable protocol values, not filesystem names.
const name = `asset-${selectedArtifact.index}${extension(artifact.mediaType)}`;
const opened = await read(artifact, signal);
signal.throwIfAborted();
const chunks = opened instanceof Uint8Array
? (async function* () { yield opened; })()
: opened;
const surface = surfaces.get(artifact.resource);
let size = 0;
const path = join(artifactDirectory, name);
const target = await open(path, "w");
try {
for await (const chunk of chunks) {
signal.throwIfAborted();
await target.writeFile(chunk);
size += chunk.byteLength;
}
} finally {
await target.close();
}
} finally {
await target.close();
if (size !== artifact.size) {
throw new Error(`HyperFrames Artifact ${artifact.resource} size differs`);
}
if (surface !== undefined) {
// The validator borrows the completed staged file for this call only.
await options.validateSurface!(structuredClone(surface), path, signal);
}
} catch (error) {
if (failure === undefined) {
failure = error;
controller.abort(error);
}
return;
}
if (size !== artifact.size) {
throw new Error(`HyperFrames Artifact ${artifact.resource} size differs`);
}
if (surface !== undefined) {
// The validator borrows the completed staged file for this call only.
await options.validateSurface!(structuredClone(surface), path, signal);
}
paths.set(artifact.resource, `./artifacts/${name}`);
} catch (error) {
controller.abort(error);
throw error;
}
}));
// All writes have settled before the caller can remove the staged directory.
};
const workers = Array.from({ length: Math.min(maxConcurrentArtifacts, selected.length) }, () => stageNext());
// A failure stops new claims, but every already-started reader, writer and
// validator settles before the caller may remove the staging directory.
await Promise.all(workers);
// Preserve whichever abort reached the combined signal first, including an
// external caller's exact reason rather than a later stream AbortError.
signal.throwIfAborted();
if (failure !== undefined) throw failure;
// All writes have settled before the caller can remove the staged directory.
const html = materializeHyperframesHtml(document, (artifact) => {
const path = paths.get(artifact.resource);
if (path === undefined) throw new Error(`HyperFrames Artifact ${artifact.resource} was not staged`);
if (path === undefined) throw new Error(`HyperFrames Artifact ${artifact.resource} has no materialized path`);
return path;
});
await writeFile(join(directory, "index.html"), html, { encoding: "utf8", signal });
+100 -70
View File
@@ -34,6 +34,8 @@ export type TextRenderContext = {
* to remember that, each word writes the whole set again.
*/
readonly emittedFilterIds?: Set<string>;
/** Document-owned destination for definitions shared by otherwise independent Presents. */
readonly sharedGlyphFilterDefinitions?: string[];
};
function number(value: number): string {
@@ -335,7 +337,10 @@ export function renderGlyphPaintedString(
return true;
})
.map((paint) => glyphFilterDefinition(paint, context)).join("");
const defs = definitions.length === 0
if (definitions.length > 0 && context.sharedGlyphFilterDefinitions !== undefined) {
context.sharedGlyphFilterDefinitions.push(definitions);
}
const defs = definitions.length === 0 || context.sharedGlyphFilterDefinitions !== undefined
? ""
: `<svg aria-hidden="true" width="0" height="0" style="position:absolute;overflow:hidden"><defs>${definitions}</defs></svg>`;
// Paint layers share one grid cell. The wrapper stacks them but does not absorb their ink into
@@ -754,8 +759,8 @@ function renderPathDocument(element: VisualPathTextElement, context: TextRenderC
export function renderTerminalTextElement(element: TerminalTextElement, context: TextRenderContext): string {
assertTextUnitRanges(element);
const clockAttributes = `data-hypit-text-clock data-hypit-text-start-frame="${context.presentStartFrame}" data-hypit-text-frame-numerator="${context.programNumerator}" data-hypit-text-frame-denominator="${context.programDenominator}"`;
const sequenceAttributes = element.sequences.length === 0 ? "" : ` data-hypit-text-sequences="${context.escape(JSON.stringify(element.sequences))}" data-hypit-text-duration-frames="${context.durationFrames}"`;
const clockAttributes = `data-hypit-text-clock data-hypit-text-start-frame="${context.presentStartFrame}" data-hypit-text-duration-frames="${context.durationFrames}" data-hypit-text-frame-numerator="${context.programNumerator}" data-hypit-text-frame-denominator="${context.programDenominator}"`;
const sequenceAttributes = element.sequences.length === 0 ? "" : ` data-hypit-text-sequences="${context.escape(JSON.stringify(element.sequences))}"`;
if (element.kind === "path-text") {
const pathId = context.stableId([context.trackId, context.presentId, element.id, "path"]);
const anchor = element.align === "start" ? "start" : element.align === "end" ? "end" : "middle";
@@ -824,6 +829,20 @@ export function collectTerminalTextFonts(element: TerminalTextElement): FontArti
/** Renderer-owned deterministic post-layout step for physical-line selectors. */
export const terminalTextLayoutScript = String.raw`
const svmlTextRootSelected = (root) => {
const startFrame = Number(root.getAttribute('data-hypit-text-start-frame'));
const durationFrames = Number(root.getAttribute('data-hypit-text-duration-frames'));
if (!Number.isSafeInteger(startFrame) || !Number.isSafeInteger(durationFrames)
|| startFrame < 0 || durationFrames <= 0 || !Number.isSafeInteger(startFrame + durationFrames)) {
throw new Error('Invalid terminal Text Present span.');
}
return hyperframesSelectionOverlaps(startFrame, startFrame + durationFrames);
};
const svmlSelectedTextElements = (selector) => [...document.querySelectorAll(selector)].filter((element) => {
const root = element.closest('[data-hypit-text-clock]');
if (!root) throw new Error('Terminal Text layout work is outside its frame clock root.');
return svmlTextRootSelected(root);
});
const svmlTextEase = (name, value) => {
if (name === 'ease-in') return value * value;
if (name === 'ease-out') return 1 - (1 - value) * (1 - value);
@@ -831,7 +850,7 @@ const svmlTextEase = (name, value) => {
return value;
};
const svmlUpdateUprightTextPaths = () => {
for (const path of document.querySelectorAll('[data-hypit-text-path-upright]')) {
for (const path of svmlSelectedTextElements('[data-hypit-text-path-upright]')) {
for (const glyph of path.querySelectorAll('[data-hypit-text-unit-grapheme]')) {
try {
glyph.removeAttribute('rotate');
@@ -844,7 +863,7 @@ const svmlUpdateUprightTextPaths = () => {
}
};
const svmlUpdateStaticTextPathPlacements = () => {
for (const textPath of document.querySelectorAll('[data-hypit-text-path-placement]')) {
for (const textPath of svmlSelectedTextElements('[data-hypit-text-path-placement]')) {
const data = JSON.parse(textPath.getAttribute('data-hypit-text-path-placement'));
const href = textPath.getAttribute('href');
const path = href && document.getElementById(href.slice(1));
@@ -857,7 +876,7 @@ const svmlUpdateStaticTextPathPlacements = () => {
}
};
const svmlUpdateTextPathMargins = (time) => {
for (const path of document.querySelectorAll('[data-hypit-text-path-margin]')) {
for (const path of svmlSelectedTextElements('[data-hypit-text-path-margin]')) {
const data = JSON.parse(path.getAttribute('data-hypit-text-path-margin'));
const frames = data.keyframes;
// HyperFrames may reach the same source frame through an incremental seek
@@ -886,8 +905,6 @@ const svmlUpdateTextPathMargins = (time) => {
}
svmlUpdateUprightTextPaths();
};
const svmlTextUnitBaseStyles = new WeakMap();
const svmlTextAnimatedStyleNames = ['-webkit-text-fill-color', '-webkit-text-stroke-color', 'background-color', 'color', 'filter', 'opacity', 'transform'];
const svmlTextPropertyName = (name) => {
if (name === '-webkit-text-fill-color') return 'webkitTextFillColor';
if (name === '-webkit-text-stroke-color') return 'webkitTextStrokeColor';
@@ -904,13 +921,29 @@ const svmlTextSeededOrder = (length, seed) => {
}
return result;
};
const svmlTextPrepareSequence = (sequence) => {
const length = sequence.range.endExclusive - sequence.range.start;
let rankByLocalIndex;
if (sequence.order !== 'forward' && sequence.order !== 'reverse') {
rankByLocalIndex = new Array(length);
for (const [rank, local] of svmlTextSeededOrder(length, sequence.seed || 0).entries()) {
rankByLocalIndex[local] = rank;
}
}
const keyframes = sequence.keyframes.map((point) => {
const frame = { offset: point.atProgress, ...(point.easing ? { easing: point.easing } : {}) };
for (const declaration of point.style) frame[svmlTextPropertyName(declaration.name)] = declaration.value;
return frame;
});
return { ...sequence, rankByLocalIndex, nativeKeyframes: keyframes };
};
const svmlTextSequenceRank = (sequence, index) => {
if (index < sequence.range.start || index >= sequence.range.endExclusive) return null;
const length = sequence.range.endExclusive - sequence.range.start;
const local = index - sequence.range.start;
if (sequence.order === 'forward') return local;
if (sequence.order === 'reverse') return length - 1 - local;
return svmlTextSeededOrder(length, sequence.seed || 0).indexOf(local);
return sequence.rankByLocalIndex[local];
};
const svmlTextSequenceClock = (sequence, rank, frame) => {
const start = sequence.startFrame + rank * sequence.staggerFrames;
@@ -924,26 +957,35 @@ const svmlTextSequenceClock = (sequence, rank, frame) => {
// across fresh and incrementally-seeked browser animation instances.
return (frame - start) % sequence.unitDurationFrames;
};
const svmlTextSequenceProgress = (sequence, clock) => {
const progress = clock / sequence.unitDurationFrames;
let left = sequence.keyframes[0];
let right = sequence.keyframes[sequence.keyframes.length - 1];
for (let index = 0; index < sequence.keyframes.length - 1; index += 1) {
if (progress < sequence.keyframes[index].atProgress || progress > sequence.keyframes[index + 1].atProgress) continue;
left = sequence.keyframes[index];
right = sequence.keyframes[index + 1];
break;
}
const width = right.atProgress - left.atProgress;
return {
left,
right,
progress: svmlTextEase(left.easing || 'linear', width <= 0 ? 1 : (progress - left.atProgress) / width),
};
const svmlTextUnitSelector = '[data-hypit-text-unit-paragraph],[data-hypit-text-unit-run],[data-hypit-text-unit-word],[data-hypit-text-unit-grapheme]';
let svmlTextAnimationIndex = hyperframesCreateFrameWorkIndex([]);
let svmlTextAnimationFrameRate;
const svmlPrepareTextRootApplications = (prepared) => {
if (prepared.units !== undefined) return prepared.units;
const sequences = JSON.parse(prepared.encodedSequences || '[]').map(svmlTextPrepareSequence);
const nativeAnimate = Element.prototype.__hfOriginalAnimate;
prepared.units = [...prepared.root.querySelectorAll(svmlTextUnitSelector)].map((unit) => {
const applications = [];
for (const sequence of sequences) {
const attribute = sequence.unit === 'line' ? 'data-hypit-text-physical-line' : 'data-hypit-text-unit-' + sequence.unit;
if (!unit.hasAttribute(attribute)) continue;
const index = Number(unit.getAttribute(attribute));
const rank = Number.isSafeInteger(index) ? svmlTextSequenceRank(sequence, index) : null;
if (rank === null) continue;
const animation = typeof nativeAnimate === 'function'
? nativeAnimate.call(unit, sequence.nativeKeyframes, { duration: sequence.unitDurationFrames, fill: 'both', iterations: 1 })
: unit.animate(sequence.nativeKeyframes, { duration: sequence.unitDurationFrames, fill: 'both', iterations: 1 });
animation.pause();
animation.currentTime = 0;
applications.push({ sequence, rank, animation });
}
return { applications };
}).filter((unit) => unit.applications.length > 0);
return prepared.units;
};
const svmlSeekTextUnitAnimations = (time) => {
const unitSelector = '[data-hypit-text-unit-paragraph],[data-hypit-text-unit-run],[data-hypit-text-unit-word],[data-hypit-text-unit-grapheme]';
for (const root of document.querySelectorAll('[data-hypit-text-clock][data-hypit-text-sequences]')) {
const svmlPrepareTextUnitAnimations = () => {
const work = [...document.querySelectorAll('[data-hypit-text-clock][data-hypit-text-sequences]')]
.filter(svmlTextRootSelected).map((root, order) => {
const startFrame = Number(root.getAttribute('data-hypit-text-start-frame'));
const numerator = Number(root.getAttribute('data-hypit-text-frame-numerator'));
const denominator = Number(root.getAttribute('data-hypit-text-frame-denominator'));
@@ -952,47 +994,34 @@ const svmlSeekTextUnitAnimations = (time) => {
|| !Number.isSafeInteger(durationFrames) || numerator <= 0 || denominator <= 0 || durationFrames <= 0) {
throw new Error('Invalid terminal Text animation clock.');
}
const sequences = JSON.parse(root.getAttribute('data-hypit-text-sequences') || '[]');
const localFrame = Math.max(0, Math.min(durationFrames, Math.round(time * numerator / denominator - startFrame)));
for (const unit of root.querySelectorAll(unitSelector)) {
let base = svmlTextUnitBaseStyles.get(unit);
if (!base) {
base = Object.fromEntries(svmlTextAnimatedStyleNames.map((name) => [name, unit.style.getPropertyValue(name)]));
svmlTextUnitBaseStyles.set(unit, base);
}
for (const name of svmlTextAnimatedStyleNames) {
if (base[name]) unit.style.setProperty(name, base[name]);
else unit.style.removeProperty(name);
}
for (const sequence of sequences) {
const attribute = sequence.unit === 'line' ? 'data-hypit-text-physical-line' : 'data-hypit-text-unit-' + sequence.unit;
if (!unit.hasAttribute(attribute)) continue;
const index = Number(unit.getAttribute(attribute));
const rank = Number.isSafeInteger(index) ? svmlTextSequenceRank(sequence, index) : null;
if (rank === null) continue;
if (svmlTextAnimationFrameRate === undefined) svmlTextAnimationFrameRate = { numerator, denominator };
else if (svmlTextAnimationFrameRate.numerator !== numerator || svmlTextAnimationFrameRate.denominator !== denominator) {
throw new Error('Terminal Text roots disagree on the Program frame rate.');
}
return {
startFrame,
endFrameExclusive: startFrame + durationFrames,
order,
payload: {
root,
encodedSequences: root.getAttribute('data-hypit-text-sequences') || '[]',
units: undefined,
},
};
});
svmlTextAnimationIndex = hyperframesCreateFrameWorkIndex(work);
};
const svmlSeekTextUnitAnimations = (time) => {
if (svmlTextAnimationFrameRate === undefined) return;
const programFrame = Math.max(0, Math.round(time * svmlTextAnimationFrameRate.numerator / svmlTextAnimationFrameRate.denominator));
for (const work of svmlTextAnimationIndex.at(programFrame)) {
const units = svmlPrepareTextRootApplications(work.payload);
const localFrame = programFrame - work.startFrame;
for (const preparedUnit of units) {
for (const application of preparedUnit.applications) {
const { sequence, rank, animation } = application;
const clock = svmlTextSequenceClock(sequence, rank, localFrame);
const keyframes = sequence.keyframes.map((point) => {
const frame = { offset: point.atProgress, ...(point.easing ? { easing: point.easing } : {}) };
for (const declaration of point.style) frame[svmlTextPropertyName(declaration.name)] = declaration.value;
return frame;
});
const nativeAnimate = Element.prototype.__hfOriginalAnimate;
const animation = typeof nativeAnimate === 'function'
? nativeAnimate.call(unit, keyframes, { duration: sequence.unitDurationFrames, fill: 'both', iterations: 1 })
: unit.animate(keyframes, { duration: sequence.unitDurationFrames, fill: 'both', iterations: 1 });
animation.pause();
animation.currentTime = clock;
const animatedNames = [...new Set(sequence.keyframes.flatMap((keyframe) => keyframe.style.map((declaration) => declaration.name)))];
const computed = getComputedStyle(unit);
const sampled = Object.fromEntries(animatedNames.map((name) => [name, computed.getPropertyValue(name)]));
animation.cancel();
if (animatedNames.includes('opacity')) {
const { left, right, progress } = svmlTextSequenceProgress(sequence, clock);
const leftValue = left.style.find((declaration) => declaration.name === 'opacity')?.value;
const rightValue = right.style.find((declaration) => declaration.name === 'opacity')?.value;
if (typeof leftValue === 'number' && typeof rightValue === 'number') sampled.opacity = String(leftValue + (rightValue - leftValue) * progress);
}
for (const name of animatedNames) unit.style.setProperty(name, sampled[name]);
}
}
}
@@ -1040,7 +1069,7 @@ const svmlTextLayoutReady = document.fonts.ready.then(() => {
}
return values;
};
for (const flow of document.querySelectorAll('[data-hypit-text-flow][data-hypit-text-overflow="ellipsis"]')) {
for (const flow of svmlSelectedTextElements('[data-hypit-text-flow][data-hypit-text-overflow="ellipsis"]')) {
if (flow.getAttribute('data-hypit-text-max-lines') || getComputedStyle(flow).whiteSpace === 'pre') continue;
const parent = flow.parentElement;
if (!parent) throw new Error('Ellipsis Text has no placement frame.');
@@ -1059,7 +1088,7 @@ const svmlTextLayoutReady = document.fonts.ready.then(() => {
: rect.top >= boundary.top - 0.5 && rect.bottom <= boundary.bottom + 0.5)).length;
flow.style.webkitLineClamp = String(Math.max(1, visible));
}
for (const flow of document.querySelectorAll('[data-hypit-text-flow][data-hypit-text-overflow="shrink"]')) {
for (const flow of svmlSelectedTextElements('[data-hypit-text-flow][data-hypit-text-overflow="shrink"]')) {
const minimum = Number(flow.getAttribute('data-hypit-text-minimum-scale'));
const maximumLines = Number(flow.getAttribute('data-hypit-text-max-lines') || '0');
const inlineSize = flow.getAttribute('data-hypit-text-inline-size');
@@ -1133,7 +1162,7 @@ const svmlTextLayoutReady = document.fonts.ready.then(() => {
apply(low);
flow.setAttribute('data-hypit-text-shrink-scale', low.toFixed(8));
}
for (const flow of document.querySelectorAll('[data-hypit-text-flow][data-hypit-text-line-sequences]')) {
for (const flow of svmlSelectedTextElements('[data-hypit-text-flow][data-hypit-text-line-sequences]')) {
const sequences = JSON.parse(flow.getAttribute('data-hypit-text-line-sequences') || '[]');
const glyphs = [...flow.querySelectorAll('[data-hypit-text-unit-grapheme]')];
const vertical = flow.getAttribute('data-hypit-text-writing-mode') !== 'horizontal-tb';
@@ -1163,6 +1192,7 @@ const svmlTextLayoutReady = document.fonts.ready.then(() => {
}
}
}
svmlPrepareTextUnitAnimations();
svmlTextReady = true;
svmlTextTimeline.totalTime(svmlTextAbsoluteTime);
});
+16 -2
View File
@@ -16,12 +16,26 @@ export type HyperframesCanvas = {
readonly height: number;
};
export type HyperframesArtifactUsage =
| { readonly kind: "always" }
| {
readonly kind: "frames";
/** Ordered, disjoint absolute half-open spans where this Artifact can be observed. */
readonly spans: readonly HyperframesFrameSpan[];
};
/** One byte dependency and the compiler's conservative proof of when it can be observed. */
export type HyperframesArtifact = {
readonly artifact: BlobRef;
readonly usage: HyperframesArtifactUsage;
};
/** Portable input to a local or remote HyperFrames renderer. */
export type HyperframesDocument = HyperframesFrameDomain & {
readonly visualIr: typeof VISUAL_IR_V1;
readonly canvas: HyperframesCanvas;
/** Every byte resource referenced by the HTML template. */
readonly artifacts: readonly BlobRef[];
/** Every byte resource referenced by the HTML template, with its conservative temporal usage. */
readonly artifacts: readonly HyperframesArtifact[];
/** Typed Surface dependencies that a Runtime must verify before rendering. */
readonly surfaces: readonly CompositableSurfaceRef[];
/** Media URLs remain hypit-resource:// placeholders until a Runtime materializes them. */
+30
View File
@@ -0,0 +1,30 @@
/** Page-private Present visibility. The shared index owns only interval lookup. */
export function presentationVisibilityScript(numerator: number, denominator: number): string {
return `(() => {
const entries = [...document.querySelectorAll('[data-hypit-visibility]')].map((element, order) => ({
element,
order,
spans: JSON.parse(element.dataset.hypitVisibility),
}));
const workIndex = hyperframesCreateFrameWorkIndex(entries.flatMap(entry => entry.spans
.filter(span => hyperframesSelectionOverlaps(span.startFrame, span.endFrameExclusive)).map(span => ({
startFrame: span.startFrame,
endFrameExclusive: span.endFrameExclusive,
order: entry.order,
payload: entry.element,
}))));
let visible = new Set();
for (const entry of entries) entry.element.style.opacity = '0';
const apply = time => {
const frame = Math.max(0, Math.round(Number(time || 0) * ${numerator} / ${denominator}));
const next = new Set(workIndex.at(frame).map(work => work.payload));
// A presentation mask controls painting, not layout or animation existence.
// Only roots whose absolute visibility changed need a DOM write.
for (const element of visible) if (!next.has(element)) element.style.opacity = '0';
for (const element of next) if (!visible.has(element)) element.style.opacity = '';
visible = next;
};
apply(0);
window.addEventListener('hf-seek', event => apply(event.detail?.time));
})();`;
}
@@ -0,0 +1,74 @@
import assert from "node:assert/strict";
import { runInNewContext } from "node:vm";
import test from "node:test";
import { parseHTML } from "linkedom";
import { presentationCaptureScopeRuntime } from "../src/capture-scope.js";
import { frameSelectionRuntime, hyperframesFrameSelectionPrelude } from "../src/frame-work.js";
test("compiled capture scope retains selected roots and makes unrelated media inert", () => {
const { document } = parseHTML(`<!doctype html><body>
<svg id="definitions" data-hypit-document-definitions></svg>
<div id="early" data-hypit-present-start-frame="0" data-hypit-present-end-frame="30">
<img id="early-image" data-hypit-resource-src="early.png" srcset="early-2x.png 2x"/>
<video id="early-video" data-hypit-resource-src="early.mp4" poster="early.jpg"></video>
<svg><image id="early-svg" data-hypit-resource-href="early.svg"/></svg>
</div>
<div id="selected" data-hypit-present-start-frame="90" data-hypit-present-end-frame="120">
<img id="selected-image" data-hypit-resource-src="selected.png"/>
<svg><image id="selected-svg" data-hypit-resource-href="selected.svg"/></svg>
</div>
</body>`);
const early = document.getElementById("early")!;
const earlyImage = document.getElementById("early-image")!;
const earlyVideo = document.getElementById("early-video")!;
const earlySvg = document.getElementById("early-svg")!;
const context: { document: Document; __hypitCaptureRoots?: readonly Element[] } = { document };
runInNewContext(`${hyperframesFrameSelectionPrelude([{ startFrame: 100, endFrameExclusive: 101 }])}
${frameSelectionRuntime}\n${presentationCaptureScopeRuntime}`, context);
assert.deepEqual(JSON.parse(JSON.stringify(context.__hypitCaptureRoots?.map(root => root.id))), ["definitions", "selected"]);
assert.equal(early.style.getPropertyValue("display"), "none");
assert.equal(early.isConnected, false);
assert.equal(document.getElementById("early"), null);
for (const element of [earlyImage, earlyVideo, earlySvg]) {
assert.equal(element.hasAttribute("src"), false);
assert.equal(element.hasAttribute("href"), false);
}
assert.equal(earlyImage.hasAttribute("srcset"), false);
assert.equal(earlyVideo.hasAttribute("poster"), false);
assert.equal(document.getElementById("selected-image")!.getAttribute("src"), "selected.png");
assert.equal(document.getElementById("selected-image")!.hasAttribute("data-hypit-resource-src"), false);
assert.equal(document.getElementById("selected-svg")!.getAttribute("href"), "selected.svg");
assert.equal(document.getElementById("selected-svg")!.hasAttribute("data-hypit-resource-href"), false);
});
test("compiled capture scope removes unselected Present descendants from generic live-DOM discovery", () => {
const { document } = parseHTML(`<!doctype html><body>
<div id="selected" data-hypit-present-start-frame="0" data-hypit-present-end-frame="30"><span></span></div>
<div id="unselected" data-hypit-present-start-frame="30" data-hypit-present-end-frame="60">
${"<span><i></i></span>".repeat(200)}
</div>
</body>`);
const before = document.querySelectorAll("*").length;
const context: { document: Document; __hypitCaptureRoots?: readonly Element[] } = { document };
runInNewContext(`${hyperframesFrameSelectionPrelude([{ startFrame: 0, endFrameExclusive: 1 }])}
${frameSelectionRuntime}\n${presentationCaptureScopeRuntime}`, context);
assert.ok(before >= 400);
assert.equal(document.getElementById("unselected"), null);
assert.deepEqual(JSON.parse(JSON.stringify(context.__hypitCaptureRoots?.map(root => root.id))), ["selected"]);
assert.ok(document.querySelectorAll("*").length <= 5);
});
test("compiled capture scope defaults to every Present outside a render selection", () => {
const { document } = parseHTML(`<!doctype html><body>
<div id="one" data-hypit-present-start-frame="0" data-hypit-present-end-frame="30"><img data-hypit-resource-src="one.png"/></div>
<div id="two" data-hypit-present-start-frame="30" data-hypit-present-end-frame="60"><img data-hypit-resource-src="two.png"/></div>
</body>`);
const context: { document: Document; __hypitCaptureRoots?: readonly Element[] } = { document };
runInNewContext(`${frameSelectionRuntime}\n${presentationCaptureScopeRuntime}`, context);
assert.deepEqual(JSON.parse(JSON.stringify(context.__hypitCaptureRoots?.map(root => root.id))), ["one", "two"]);
assert.equal(document.querySelector("[data-hypit-capture-excluded]"), null);
assert.deepEqual(Array.from(document.querySelectorAll("img")).map(image => image.getAttribute("src")), ["one.png", "two.png"]);
});
+169 -16
View File
@@ -99,8 +99,15 @@ test("HyperFrames flattens generic peer visual Track Presents without absorbing
const document = compileHyperframesDocument(composition, programSpace);
assert.doesNotThrow(() => assertHyperframesDocument(document));
assert.equal(document.visualIr, VISUAL_IR_V1);
assert.deepEqual(new Set(document.artifacts.map((artifact) => artifact.resource)),
assert.deepEqual(new Set(document.artifacts.map(({ artifact }) => artifact.resource)),
new Set([picture.resource, fixtureFont.sources[0]!.artifact.resource]));
assert.deepEqual(document.artifacts.find(({ artifact }) => artifact.resource === picture.resource)?.usage,
{ kind: "frames", spans: [{ startFrame: 0, endFrameExclusive: 30 }] });
assert.deepEqual(document.artifacts.find(({ artifact }) => artifact.resource === fixtureFont.sources[0]!.artifact.resource)?.usage,
{ kind: "always" });
assert.ok(document.html.includes(`data-hypit-resource-src="hypit-resource://${picture.resource}"`));
assert.doesNotMatch(document.html, /<img\b[^>]*\ssrc=/u,
"compiler-owned images stay inert until the page applies its render selection");
assert.ok(document.html.indexOf('data-hypit-track-id="lower"') < document.html.indexOf('data-hypit-track-id="upper"'));
assert.equal((document.html.match(/class="clip hypit-visual-present"/gu) ?? []).length, 2);
assert.doesNotMatch(document.html, /<audio/u);
@@ -114,6 +121,25 @@ test("HyperFrames flattens generic peer visual Track Presents without absorbing
assert.doesNotMatch(document.html, /speech-visual-track|caption-track/u);
});
test("shared glyph filter definitions live outside temporal Present capture roots", () => {
const { composition, programSpace } = fixture();
const changed = structuredClone(composition);
const upper = changed.tracks.find(track => track.kind === "visual" && track.id === "upper");
assert.ok(upper?.kind === "visual");
const text = upper.presents[0]!.elements.find(element => element.kind === "text");
assert.ok(text?.kind === "text");
Object.assign(text, { paints: [
{ kind: "glow", paint: { kind: "solid", color: "#ff00ff" }, blurPx: 4, spreadPx: 1 },
{ kind: "fill", paint: { kind: "solid", color: "#ffffff" } },
] });
const html = compileHyperframesDocument(changed, programSpace).html;
const definitions = html.indexOf("data-hypit-document-definitions");
const present = html.indexOf('class="clip hypit-visual-present"');
assert.ok(definitions >= 0 && definitions < present);
assert.equal((html.match(/<filter id="hypit-/gu) ?? []).length, 1);
assert.equal((html.match(/filter:url\(#hypit-/gu) ?? []).length, 1);
});
test("Text shrink preserves authored hug sizing and trims metrics inside the content box", () => {
const programSpace = sealProgramSpace({
id: "text-space",
@@ -269,7 +295,7 @@ test("any legal frame and Provider-owned chunk can be addressed without traversi
);
});
test("HyperFrames emits frame-bound local animation without creating a Track stacking context", () => {
test("HyperFrames emits compact absolute-frame animation without creating a Track stacking context", async () => {
const { composition, programSpace } = fixture();
const lower = composition.tracks.find((track) => track.id === "lower");
assert(lower?.kind === "visual");
@@ -298,7 +324,68 @@ test("HyperFrames emits frame-bound local animation without creating a Track sta
assert.match(document.html, /33\.333333333%\{opacity:1;transform:translateY\(0%\)/u);
assert.match(document.html, /animation-duration:1\.001s/u);
assert.match(document.html, /100%\{opacity:1;transform:translateY\(0%\)\}/u);
assert.match(document.html, /hyperframesCreateFrameWorkIndex/u);
assert.match(document.html, /for \(const animation of work\.payload\.animations\) animation\.currentTime = localTime/u);
assert.doesNotMatch(document.html, /const frames = \[\]|getComputedStyle\(element\)|animation\.cancel\(\)/u);
assert.doesNotMatch(document.html, /isolation:isolate/u);
const marker = document.html.indexOf("const millisecondsPerFrame");
assert.ok(marker >= 0);
const scriptStart = document.html.lastIndexOf("<script>", marker) + "<script>".length;
const scriptEnd = document.html.indexOf("</script>", marker);
const { frameWorkIndexRuntime, hyperframesFrameSelectionPrelude } = await import("../src/frame-work.js");
const runtime = `${frameWorkIndexRuntime}\n${document.html.slice(scriptStart, scriptEnd)}`;
const evaluate = async (
spans: readonly { readonly start: number; readonly duration: number }[],
frames: readonly number[],
selection?: readonly { readonly startFrame: number; readonly endFrameExclusive: number }[],
) => {
const { runInNewContext } = await import("node:vm");
const positions = spans.map((): number[] => []);
let pauses = 0;
let seek: (event: { detail: { time: number } }) => void = () => {};
const elements = spans.map((span, index) => ({
getBoundingClientRect() {},
getAnimations: () => [{
set currentTime(value: number) { positions[index]!.push(Math.round(value * 30 / 1_001)); },
pause() { pauses += 1; },
}],
getAttribute: (name: string) => String(name.endsWith("start-frame") ? span.start : span.duration),
}));
runInNewContext(`${selection === undefined ? "" : hyperframesFrameSelectionPrelude(selection)}\n${runtime}`, {
document: { querySelectorAll: () => elements, documentElement: { getBoundingClientRect() {} } },
window: { addEventListener(_name: string, callback: typeof seek) { seek = callback; } },
});
for (const frame of frames) seek({ detail: { time: frame * 1_001 / 30_000 } });
return { pauses, positions };
};
assert.deepEqual(await evaluate([{ start: 15, duration: 30 }], [30, 44, 15, 45, 60]), {
pauses: 1,
positions: [[0, 15, 29, 0]],
});
assert.deepEqual(await evaluate([{ start: 15, duration: 30 }], [30]), { pauses: 1, positions: [[0, 15]] },
"a fresh worker derives the same middle pose without visiting preceding frames");
assert.deepEqual(await evaluate([
{ start: 0, duration: 30 },
{ start: 90, duration: 30 },
], [100], [{ startFrame: 100, endFrameExclusive: 101 }]), {
pauses: 1,
positions: [[], [0, 10]],
}, "a selected render does not materialize animations from unrelated Presents");
assert.deepEqual(await evaluate([
{ start: 0, duration: 5 },
{ start: 5, duration: 5 },
{ start: 3, duration: 5 },
{ start: 100, duration: 2 },
], [0, 3, 4, 5, 7, 8, 100, 101, 102, 6]), {
pauses: 4,
positions: [
[0, 0, 3, 4],
[0, 0, 2, 3, 1],
[0, 0, 1, 2, 4, 3],
[0, 0, 1],
],
}, "the point index preserves overlap, half-open boundaries, distant spans and reverse seeks");
});
test("HyperFrames clips a long animation by Present visibility instead of rejecting it", () => {
@@ -318,7 +405,8 @@ test("HyperFrames clips a long animation by Present visibility instead of reject
tracks: composition.tracks.map((track) => track.id === "lower" ? animated : track),
}), programSpace);
assert.match(document.html, /animation-duration:1\.5015s/u);
assert.match(document.html, /data-hypit-animation-duration-frames="45" data-hypit-animation-sample-frames="30"/u);
assert.match(document.html, /data-hypit-animation-sample-frames="30"/u);
assert.doesNotMatch(document.html, /data-hypit-animation-properties|data-hypit-animation-duration-frames/u);
assert.match(document.html, /100%\{opacity:1\}/u);
});
@@ -383,7 +471,11 @@ test("content-bound fonts and typed compositable Surfaces cross the same Artifac
tracks: [track],
}), space);
assert.doesNotThrow(() => assertHyperframesDocument(document));
assert.deepEqual(document.artifacts.map((artifact) => artifact.resource), [font.sources[0]!.artifact.resource, surfaceDigest].sort());
assert.deepEqual(document.artifacts.map(({ artifact }) => artifact.resource), [font.sources[0]!.artifact.resource, surfaceDigest].sort());
assert.deepEqual(document.artifacts.find(({ artifact }) => artifact.resource === font.sources[0]!.artifact.resource)?.usage,
{ kind: "always" });
assert.deepEqual(document.artifacts.find(({ artifact }) => artifact.resource === surfaceDigest)?.usage,
{ kind: "frames", spans: [{ startFrame: 0, endFrameExclusive: 30 }] });
assert.match(document.html, /@font-face\{/u);
assert.match(document.html, /format\("woff2"\)/u);
assert.match(document.html, /font-synthesis:none/u);
@@ -396,7 +488,7 @@ test("content-bound fonts and typed compositable Surfaces cross the same Artifac
assert.match(materialized, new RegExp(surfaceDigest, "u"));
});
test("exact timed sampling lowers loop boundaries and held frames without zero-rate browser media", () => {
test("exact timed sampling keeps a held frame as one compact interval without zero-rate browser media", () => {
const programSpace = sealProgramSpace({ id: "test-space", durationSec: 8 / 30,
frameRate: { numerator: 30, denominator: 1 },
});
@@ -445,11 +537,15 @@ test("exact timed sampling lowers loop boundaries and held frames without zero-r
canvas: { width: 100, height: 100, clearColor: "#000000" },
tracks: [track],
}), programSpace);
assert.equal((document.html.match(/data-hypit-sampling-part=/gu) ?? []).length, 4);
assert.equal((document.html.match(/data-hypit-sampling-part=/gu) ?? []).length, 3);
assert.match(document.html, /data-media-start="0\.066666666666"/u);
assert.match(document.html, /data-media-start="0"/u);
assert.equal((document.html.match(/data-playback-rate="1"/gu) ?? []).length, 4);
assert.equal((document.html.match(/data-playback-rate="1"/gu) ?? []).length, 3);
assert.doesNotMatch(document.html, /data-playback-rate="0"/u);
assert.equal((document.html.match(/data-hypit-resource-src="hypit-resource:\/\//gu) ?? []).length, 3);
assert.doesNotMatch(document.html, /<video\b[^>]*\ssrc=/u,
"compiler-owned video stays inert while its exact slots remain statically readable");
assert.match(document.html, /data-hypit-source-rate="0\/1"[^>]*data-hypit-start-frame="6"[^>]*data-hypit-end-frame="8"/u);
});
test("a Track naming five takes still lowers to DOM identities a Windows path can hold", () => {
@@ -522,17 +618,27 @@ test("a Track naming five takes still lowers to DOM identities a Windows path ca
test("browser programs own local HTML while retaining typed child resources and format boundaries", async () => {
const { browserProgram } = await import("../src/browser-program.js");
const { programSpace, picture } = fixture();
const opaqueArtifact = { kind: "blob" as const, resource: fixtureResource("hyperframes:opaque-program"),
size: 12, mediaType: "image/png" };
const visual = sealVisualTrack({ id: "scene", programSpaceId: programSpace.id, visualIr: VISUAL_IR_V1,
presents: [{ id: "scene", span: { startFrame: 0, endFrameExclusive: 30 }, stacking: { order: 0, tieBreak: "scene" },
elements: [{ id: "root", kind: "program", order: 0, style: [], program: browserProgram({
html: '<section class="viewport">{{photo}}<svg><path d="M0 0H20"/></svg></section>',
html: `<section class="viewport">{{photo}}<img class="opaque" src="hypit-resource://${opaqueArtifact.resource}"><svg><path d="M0 0H20"/></svg></section>`,
css: '.viewport { backdrop-filter:blur(4px); display:grid; }',
setup: 'return frame => { root.dataset.frame = String(frame); };',
}) }, { id: "photo", parent: "root", kind: "image", order: 1, artifact: picture, style: [] }] }] });
}, [opaqueArtifact]) }, { id: "photo", parent: "root", kind: "image", order: 1, artifact: picture, style: [] }] }] });
const composition = sealComposition({ id: "scene", canvas: { width: 200, height: 200, clearColor: "#000000" }, tracks: [visual] });
const document = compileHyperframesDocument(composition, programSpace);
assert.equal(document.artifacts.length, 1);
assert.equal(document.artifacts.length, 2);
assert.deepEqual(document.artifacts.find(({ artifact }) => artifact.resource === opaqueArtifact.resource)?.usage,
{ kind: "always" }, "opaque Browser Program dependencies remain conservative");
assert.deepEqual(document.artifacts.find(({ artifact }) => artifact.resource === picture.resource)?.usage,
{ kind: "frames", spans: [{ startFrame: 0, endFrameExclusive: 30 }] });
assert.ok(document.html.includes('<section class="viewport">'));
assert.ok(document.html.includes(`class="opaque" src="hypit-resource://${opaqueArtifact.resource}"`),
"opaque Browser Program markup is not rewritten by the structural compiler");
assert.ok(document.html.includes(`data-hypit-resource-src="hypit-resource://${picture.resource}"`),
"a typed child keeps the structural compiler's selection-gated resource behavior");
assert.ok(document.html.includes('@scope'));
const missing = structuredClone(composition);
const root = missing.tracks[0]!;
@@ -545,14 +651,15 @@ test("browser programs own local HTML while retaining typed child resources and
test("browser program state follows direct seeks and reports authored evaluation failures", async () => {
const { runInNewContext } = await import("node:vm");
const { browserProgramScript } = await import("../src/browser-program.js");
const { frameWorkIndexRuntime } = await import("../src/frame-work.js");
const root = { frame: -1 };
let seek: (event: { detail: { time: number } }) => void = () => {};
const window: { addEventListener: (name: string, callback: typeof seek) => void; __hypitBrowserProgramError?: string } = {
addEventListener: (_name, callback) => { seek = callback; },
};
runInNewContext(browserProgramScript([{ id: "scene", startFrame: 30, durationFrames: 90,
runInNewContext(`${frameWorkIndexRuntime}\n${browserProgramScript([{ id: "scene", startFrame: 30, durationFrames: 90,
program: { html: "", setup: 'return frame => { if(frame===60) throw new Error("bad pose"); root.frame=frame; };' },
}], 30, 1), { window, document: { getElementById: () => root } });
}], 30, 1)}`, { window, document: { getElementById: () => root } });
seek({ detail: { time: 2.5 } });
assert.equal(root.frame, 45);
seek({ detail: { time: 1.2 } });
@@ -564,11 +671,12 @@ test("browser program state follows direct seeks and reports authored evaluation
test("inactive programs settle once, while re-entry and repeated active seeks still redraw ready assets", async () => {
const { runInNewContext } = await import("node:vm");
const { browserProgramScript } = await import("../src/browser-program.js");
const { frameWorkIndexRuntime } = await import("../src/frame-work.js");
const root = { frames: [] as number[], ready: false, painted: false };
let seek: (event: { detail: { time: number } }) => void = () => {};
runInNewContext(browserProgramScript([{ id: "scene", startFrame: 30, durationFrames: 30,
runInNewContext(`${frameWorkIndexRuntime}\n${browserProgramScript([{ id: "scene", startFrame: 30, durationFrames: 30,
program: { html: "", setup: 'return frame => { root.frames.push(frame); root.painted=root.ready; };' },
}], 30, 1), { window: { addEventListener: (_: string, callback: typeof seek) => { seek = callback; } },
}], 30, 1)}`, { window: { addEventListener: (_: string, callback: typeof seek) => { seek = callback; } },
document: { getElementById: () => root } });
seek({ detail: { time: 0.5 } });
assert.deepEqual(root.frames, [0]);
@@ -584,13 +692,58 @@ test("inactive programs settle once, while re-entry and repeated active seeks st
assert.deepEqual(root.frames, [0, 0, 0, 30, 15, 0]);
});
test("sequential Browser Program work follows active and crossed spans instead of all Programs", async () => {
const { runInNewContext } = await import("node:vm");
const { browserProgramScript } = await import("../src/browser-program.js");
const { frameWorkIndexRuntime } = await import("../src/frame-work.js");
const entries = Array.from({ length: 100 }, (_, index) => ({
id: `scene-${index}`,
startFrame: index * 10,
durationFrames: 10,
program: { html: "", setup: "return () => { root.calls += 1; };" },
}));
const roots = new Map(entries.map(entry => [entry.id, { calls: 0 }]));
let seek: (event: { detail: { time: number } }) => void = () => {};
runInNewContext(`${frameWorkIndexRuntime}\n${browserProgramScript(entries, 30, 1)}`, {
window: { addEventListener: (_: string, callback: typeof seek) => { seek = callback; } },
document: { getElementById: (id: string) => roots.get(id) },
});
for (const root of roots.values()) root.calls = 0;
for (let frame = 0; frame < 1_000; frame++) seek({ detail: { time: frame / 30 } });
assert.equal([...roots.values()].reduce((sum, root) => sum + root.calls, 0), 1_099);
});
test("browser program setup is limited to Presents overlapping the render selection", async () => {
const { runInNewContext } = await import("node:vm");
const { browserProgramScript } = await import("../src/browser-program.js");
const { frameWorkIndexRuntime, hyperframesFrameSelectionPrelude } = await import("../src/frame-work.js");
const roots = new Map([
["early", { setups: 0, renders: 0 }],
["selected", { setups: 0, renders: 0 }],
]);
const entries = [
{ id: "early", startFrame: 0, durationFrames: 30,
program: { html: "", setup: "root.setups += 1; return () => { root.renders += 1; };" } },
{ id: "selected", startFrame: 90, durationFrames: 30,
program: { html: "", setup: "root.setups += 1; return () => { root.renders += 1; };" } },
];
runInNewContext(`${hyperframesFrameSelectionPrelude([{ startFrame: 100, endFrameExclusive: 101 }])}
${frameWorkIndexRuntime}\n${browserProgramScript(entries, 30, 1)}`, {
window: { addEventListener() {} },
document: { getElementById: (id: string) => roots.get(id) },
});
assert.deepEqual(roots.get("early"), { setups: 0, renders: 0 });
assert.deepEqual(roots.get("selected"), { setups: 1, renders: 1 });
});
test("an asynchronous browser program reports its unsupported frame behavior instead of racing capture", async () => {
const { runInNewContext } = await import("node:vm");
const { browserProgramScript } = await import("../src/browser-program.js");
const { frameWorkIndexRuntime } = await import("../src/frame-work.js");
const window = { addEventListener() {}, __hypitBrowserProgramError: undefined as string | undefined };
runInNewContext(browserProgramScript([{ id: "scene", startFrame: 0, durationFrames: 30,
runInNewContext(`${frameWorkIndexRuntime}\n${browserProgramScript([{ id: "scene", startFrame: 0, durationFrames: 30,
program: { html: "", setup: 'return async frame => { await Promise.resolve(); throw new Error("late drawing failure"); };' },
}], 30, 1), { window, document: { getElementById: () => ({}) } });
}], 30, 1)}`, { window, document: { getElementById: () => ({}) } });
assert.match(window.__hypitBrowserProgramError!, /must be synchronous/u);
await new Promise(resolve => setImmediate(resolve));
assert.match(window.__hypitBrowserProgramError!, /late drawing failure/u);
@@ -0,0 +1,95 @@
import assert from "node:assert/strict";
import { runInNewContext } from "node:vm";
import test from "node:test";
import { frameWorkIndexRuntime, hyperframesFrameSelectionPrelude } from "../src/frame-work.js";
test("render-local frame selection uses compact absolute spans and defaults to the whole document", () => {
const context: { result?: boolean[] } = {};
const prelude = hyperframesFrameSelectionPrelude([
{ startFrame: 10, endFrameExclusive: 20 },
{ startFrame: 40, endFrameExclusive: 41 },
]);
runInNewContext(`${prelude}\n${frameWorkIndexRuntime}
result = [
hyperframesSelectionOverlaps(0, 10),
hyperframesSelectionOverlaps(9, 11),
hyperframesSelectionOverlaps(20, 40),
hyperframesSelectionOverlaps(40, 41),
hyperframesSelectionOverlaps(41, 50),
];`, context);
assert.deepEqual(JSON.parse(JSON.stringify(context.result)), [false, true, false, true, false]);
const unrestricted: { result?: boolean } = {};
runInNewContext(`${frameWorkIndexRuntime}\nresult = hyperframesSelectionOverlaps(100, 200);`, unrestricted);
assert.equal(unrestricted.result, true);
assert.throws(() => hyperframesFrameSelectionPrelude([
{ startFrame: 5, endFrameExclusive: 10 },
{ startFrame: 4, endFrameExclusive: 6 },
]), /ordered, disjoint/u);
});
test("page-local frame work lookup preserves half-open spans and stable order", () => {
const context: {
entries: object[];
queryFrames: number[];
result?: string[][];
} = {
entries: [
{ startFrame: 5, endFrameExclusive: 10, order: 2, payload: "late" },
{ startFrame: 0, endFrameExclusive: 5, order: 0, payload: "early" },
{ startFrame: 3, endFrameExclusive: 8, order: 1, payload: "overlap-a" },
{ startFrame: 3, endFrameExclusive: 8, order: 1, payload: "overlap-b" },
{ startFrame: 100, endFrameExclusive: 102, order: 3, payload: "distant" },
],
queryFrames: [0, 3, 4, 5, 7, 8, 100, 101, 102, 6],
};
runInNewContext(`${frameWorkIndexRuntime}
const index = hyperframesCreateFrameWorkIndex(entries);
result = queryFrames.map((frame) => index.at(frame).map((entry) => entry.payload));`, context);
assert.deepEqual(JSON.parse(JSON.stringify(context.result)), [
["early"],
["early", "overlap-a", "overlap-b"],
["early", "overlap-a", "overlap-b"],
["overlap-a", "overlap-b", "late"],
["overlap-a", "overlap-b", "late"],
["late"],
["distant"],
["distant"],
[],
["overlap-a", "overlap-b", "late"],
]);
});
test("page-local frame work lookup returns only spans overlapping an arbitrary range", () => {
const context: { result?: string[][] } = {};
runInNewContext(`${frameWorkIndexRuntime}
const index = hyperframesCreateFrameWorkIndex([
{ startFrame: 0, endFrameExclusive: 5, order: 0, payload: 'early' },
{ startFrame: 3, endFrameExclusive: 8, order: 1, payload: 'overlap' },
{ startFrame: 8, endFrameExclusive: 10, order: 2, payload: 'adjacent' },
{ startFrame: 100, endFrameExclusive: 102, order: 3, payload: 'distant' },
]);
result = [
index.overlapping({ startFrame: 5, endFrameExclusive: 8 }).map(entry => entry.payload),
index.overlapping({ startFrame: 8, endFrameExclusive: 9 }).map(entry => entry.payload),
index.overlapping({ startFrame: 4, endFrameExclusive: 101 }).map(entry => entry.payload),
];`, context);
assert.deepEqual(JSON.parse(JSON.stringify(context.result)), [
["overlap"],
["adjacent"],
["early", "overlap", "adjacent", "distant"],
]);
});
test("frame work lookup rejects invalid spans and query frames", () => {
const evaluate = (entries: object[], frame = 0): unknown => {
const context: { entries: object[]; frame: number; result?: unknown } = { entries, frame };
return assert.throws(() => runInNewContext(`${frameWorkIndexRuntime}
result = hyperframesCreateFrameWorkIndex(entries).at(frame);`, context));
};
evaluate([{ startFrame: 2, endFrameExclusive: 2, payload: null }]);
evaluate([{ startFrame: 0, endFrameExclusive: 1, order: 0, payload: null }], -1);
assert.throws(() => runInNewContext(`${frameWorkIndexRuntime}
hyperframesCreateFrameWorkIndex([]).overlapping({ startFrame: 2, endFrameExclusive: 2 });`));
});
@@ -14,11 +14,14 @@ test("materialized composition HTML retains its exact frame domain", () => {
test("materialized HTML preserves CSS attribute quoting and internal SVG references while relocating media", () => {
const html = '<style>@font-face{src:url("./font.woff2")}</style><div style="background-image:url(&quot;./picture.png&quot;);filter:url(#mask)">'
+ '<img src="./picture.png"><a href="https://example.org">link</a></div>';
assert.deepEqual(hyperframesHtmlAssetUrls(html), ["./font.woff2", "./picture.png"]);
+ '<img src="./picture.png"><img data-hypit-resource-src="./later.png">'
+ '<svg><image data-hypit-resource-href="./mask.png"/></svg><a href="https://example.org">link</a></div>';
assert.deepEqual(hyperframesHtmlAssetUrls(html), ["./font.woff2", "./picture.png", "./later.png", "./mask.png"]);
const mapped = mapHyperframesHtmlUrls(html, url => url.startsWith("./") ? `./assets/${url.slice(2)}` : url);
assert.match(mapped, /background-image:url\(&quot;\.\/assets\/picture.png&quot;\)/u);
assert.match(mapped, /filter:url\(&quot;#mask&quot;\)/u);
assert.match(mapped, /src="\.\/assets\/picture.png"/u);
assert.match(mapped, /data-hypit-resource-src="\.\/assets\/later.png"/u);
assert.match(mapped, /data-hypit-resource-href="\.\/assets\/mask.png"/u);
assert.match(mapped, /href="https:\/\/example.org"/u);
});
+194 -3
View File
@@ -5,7 +5,8 @@ import { join } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import test from "node:test";
import { compileHyperframesDocument } from "../src/document.js";
import { stageHyperframesProject } from "../src/project.js";
import { browserProgram } from "../src/browser-program.js";
import { stageHyperframesHtmlProject, stageHyperframesProject } from "../src/project.js";
import { sealComposition, sealVisualTrack } from "@hypit/composition";
import { sealProgramSpace } from "@hypit/program-space";
@@ -48,22 +49,141 @@ test("staging cancels and joins sibling reads before exposing a failure", async
const space = sealProgramSpace({ id: "space", durationSec: 1,
frameRate: { numerator: 30, denominator: 1 } });
const track = sealVisualTrack({ id: "images", visualIr: "hypit.visual-ir@1", programSpaceId: space.id,
presents: ["a", "b"].map((id, order) => ({ id, span: { startFrame: 0, endFrameExclusive: 30 }, stacking: { order, tieBreak: id },
presents: ["a", "b", "c", "d"].map((id, order) => ({ id, span: { startFrame: 0, endFrameExclusive: 30 }, stacking: { order, tieBreak: id },
elements: [{ id, order: 0, kind: "image" as const, style: [],
artifact: { kind: "blob" as const, resource: `res_${id}` as const, size: 1, mediaType: "image/png" } }],
})) });
const document = compileHyperframesDocument(sealComposition({ id: "main", canvas: { width: 64, height: 64, clearColor: "#000000" }, tracks: [track] }), space);
const directory = await mkdtemp(join(tmpdir(), "hypit-stage-cancel-"));
let active = 0;
let started = 0;
try {
await assert.rejects(stageHyperframesProject({ document, directory, read: async (artifact, signal) => {
active++;
started++;
try {
if (artifact.resource === "res_a") { await delay(10); throw new Error("source unavailable"); }
await delay(60_000, undefined, { signal });
return new Uint8Array([0]);
} finally { active--; }
} }), /source unavailable/u);
}, maxConcurrentArtifacts: 2 }), /source unavailable/u);
assert.equal(active, 0);
assert.equal(started, 2, "a failure must stop unclaimed Artifact work");
} finally { await rm(directory, { recursive: true, force: true }); }
});
test("document staging bounds complete Artifact lifecycles without changing deterministic paths", async () => {
const space = sealProgramSpace({ id: "space", durationSec: 1,
frameRate: { numerator: 30, denominator: 1 } });
const ids = Array.from({ length: 12 }, (_, index) => String(index).padStart(2, "0"));
const track = sealVisualTrack({ id: "images", visualIr: "hypit.visual-ir@1", programSpaceId: space.id,
presents: ids.map((id, order) => ({ id, span: { startFrame: 0, endFrameExclusive: 30 },
stacking: { order, tieBreak: id }, elements: [{ id, order: 0, kind: "image" as const, style: [],
artifact: { kind: "blob" as const, resource: `res_${id}` as const, size: 1, mediaType: "image/png" } }],
})) });
const document = compileHyperframesDocument(sealComposition({ id: "main",
canvas: { width: 64, height: 64, clearColor: "#000000" }, tracks: [track] }), space);
const directory = await mkdtemp(join(tmpdir(), "hypit-stage-bounded-"));
let active = 0;
let peak = 0;
let reads = 0;
try {
await stageHyperframesProject({ document, directory, maxConcurrentArtifacts: 3, read: async () => {
active++;
reads++;
peak = Math.max(peak, active);
try {
await delay(5);
return new Uint8Array([1]);
} finally { active--; }
} });
assert.equal(reads, ids.length);
assert.equal(peak, 3);
assert.deepEqual(await readdir(join(directory, "artifacts")),
ids.map((_, index) => `asset-${index}.png`).sort());
const html = await readFile(join(directory, "index.html"), "utf8");
for (let index = 0; index < ids.length; index++) assert.ok(html.includes(`./artifacts/asset-${index}.png`));
} finally { await rm(directory, { recursive: true, force: true }); }
});
test("document staging reads only Artifacts whose compiler usage overlaps the whole render selection", async () => {
const space = sealProgramSpace({ id: "space", durationSec: 1, frameRate: { numerator: 30, denominator: 1 } });
const first = { kind: "blob" as const, resource: "res_first-selection" as const, size: 1, mediaType: "image/png" };
const late = { kind: "blob" as const, resource: "res_late-selection" as const, size: 1, mediaType: "image/png" };
const always = { kind: "blob" as const, resource: "res_always-selection" as const, size: 1, mediaType: "image/png" };
const track = sealVisualTrack({ id: "images", visualIr: "hypit.visual-ir@1", programSpaceId: space.id,
presents: [
{ id: "first-a", span: { startFrame: 0, endFrameExclusive: 5 }, stacking: { order: 0, tieBreak: "a" },
elements: [{ id: "first-a", order: 0, kind: "image" as const, style: [], artifact: first }] },
{ id: "first-b", span: { startFrame: 5, endFrameExclusive: 10 }, stacking: { order: 1, tieBreak: "b" },
elements: [{ id: "first-b", order: 0, kind: "image" as const, style: [], artifact: first }] },
{ id: "late", span: { startFrame: 20, endFrameExclusive: 30 }, stacking: { order: 2, tieBreak: "late" },
elements: [{ id: "late", order: 0, kind: "image" as const, style: [], artifact: late }] },
{ id: "opaque", span: { startFrame: 20, endFrameExclusive: 30 }, stacking: { order: 3, tieBreak: "opaque" },
elements: [{ id: "opaque", order: 0, kind: "program" as const, style: [],
program: browserProgram({ html: `<img src="hypit-resource://${always.resource}">` }, [always]) }] },
] });
const document = compileHyperframesDocument(sealComposition({ id: "main",
canvas: { width: 64, height: 64, clearColor: "#000000" }, tracks: [track] }), space);
assert.deepEqual(document.artifacts.find(({ artifact }) => artifact.resource === first.resource)?.usage,
{ kind: "frames", spans: [{ startFrame: 0, endFrameExclusive: 10 }] }, "adjacent uses merge canonically");
const directory = await mkdtemp(join(tmpdir(), "hypit-stage-selection-"));
const reads: string[] = [];
try {
await stageHyperframesProject({ document, directory,
frameSelection: [{ startFrame: 2, endFrameExclusive: 3 }],
read: async artifact => { reads.push(artifact.resource); return new Uint8Array([1]); } });
assert.deepEqual(reads, [always.resource, first.resource], "opaque dependencies remain conservative");
assert.deepEqual(await readdir(join(directory, "artifacts")), ["asset-0.png", "asset-1.png"]);
const html = await readFile(join(directory, "index.html"), "utf8");
assert.match(html, /data-hypit-resource-src="\.\/artifacts\/asset-1\.png"/u);
assert.match(html, /data-hypit-resource-src="\.\/artifacts\/asset-2\.png"/u,
"unselected compiler media keep an inert deterministic path without reading bytes");
} finally { await rm(directory, { recursive: true, force: true }); }
});
test("document staging rejects an invalid concurrency bound", async () => {
const { composition, programSpace } = (() => {
const programSpace = sealProgramSpace({ id: "space", durationSec: 1, frameRate: { numerator: 30, denominator: 1 } });
return { programSpace, composition: sealComposition({ id: "empty",
canvas: { width: 64, height: 64, clearColor: "#000000" }, tracks: [] }) };
})();
const directory = await mkdtemp(join(tmpdir(), "hypit-stage-invalid-bound-"));
try {
await assert.rejects(stageHyperframesProject({
document: compileHyperframesDocument(composition, programSpace), directory,
maxConcurrentArtifacts: 0, read: async () => new Uint8Array(),
}), /concurrency must be a positive safe integer/u);
} finally { await rm(directory, { recursive: true, force: true }); }
});
test("document staging drains readers and preserves an external cancellation reason", async () => {
const space = sealProgramSpace({ id: "space", durationSec: 1, frameRate: { numerator: 30, denominator: 1 } });
const track = sealVisualTrack({ id: "images", visualIr: "hypit.visual-ir@1", programSpaceId: space.id,
presents: ["a", "b"].map((id, order) => ({ id, span: { startFrame: 0, endFrameExclusive: 30 },
stacking: { order, tieBreak: id }, elements: [{ id, order: 0, kind: "image" as const, style: [],
artifact: { kind: "blob" as const, resource: `res_${id}` as const, size: 1, mediaType: "image/png" } }],
})) });
const document = compileHyperframesDocument(sealComposition({ id: "main",
canvas: { width: 64, height: 64, clearColor: "#000000" }, tracks: [track] }), space);
const directory = await mkdtemp(join(tmpdir(), "hypit-stage-external-cancel-"));
const controller = new AbortController();
const reason = new Error("caller stopped staging");
let active = 0;
let started = 0;
try {
const staging = stageHyperframesProject({ document, directory, maxConcurrentArtifacts: 2,
signal: controller.signal, read: async (_artifact, signal) => {
active++;
started++;
try {
await delay(60_000, undefined, { signal });
return new Uint8Array([1]);
} finally { active--; }
} });
while (started < 2) await delay(1);
controller.abort(reason);
await assert.rejects(staging, error => error === reason);
assert.equal(active, 0);
} finally { await rm(directory, { recursive: true, force: true }); }
});
@@ -91,3 +211,74 @@ test("staging gives opaque Resource identities portable local names and writes c
assert.ok(document.html.includes(resource), "materialization does not rewrite the portable document");
} finally { await rm(directory, { recursive: true, force: true }); }
});
test("HTML staging writes one file when several URLs name the same Resource", async () => {
const directory = await mkdtemp(join(tmpdir(), "hypit-stage-html-alias-"));
const artifact = {
kind: "blob" as const,
resource: "res_shared-html-image" as const,
size: 3,
mediaType: "image/png",
};
let reads = 0;
try {
await stageHyperframesHtmlProject({
directory,
project: {
html: '<div data-composition-id="main" data-fps="30/1" data-hypit-frame-count="30" data-width="64" data-height="64"><img src="first.png"><img src="second.png"></div>',
assets: [
{ url: "first.png", artifact },
{ url: "second.png", artifact: { ...artifact } },
],
},
read: async () => {
reads += 1;
return new Uint8Array([1, 2, 3]);
},
});
assert.equal(reads, 1);
assert.deepEqual(await readdir(join(directory, "artifacts")), ["asset-0.png"]);
const html = await readFile(join(directory, "index.html"), "utf8");
assert.equal(html.match(/\.\/artifacts\/asset-0\.png/gu)?.length, 2);
} finally { await rm(directory, { recursive: true, force: true }); }
});
test("HTML staging relocates compiler-deferred media URLs without activating them", async () => {
const directory = await mkdtemp(join(tmpdir(), "hypit-stage-html-deferred-"));
const artifact = {
kind: "blob" as const,
resource: "res_deferred-html-image" as const,
size: 3,
mediaType: "image/png",
};
try {
await stageHyperframesHtmlProject({
directory,
project: {
html: '<div data-composition-id="main" data-fps="30/1" data-hypit-frame-count="30" data-width="64" data-height="64"><img data-hypit-resource-src="picture.png"></div>',
assets: [{ url: "picture.png", artifact }],
},
read: async () => new Uint8Array([1, 2, 3]),
});
const html = await readFile(join(directory, "index.html"), "utf8");
assert.match(html, /data-hypit-resource-src="\.\/artifacts\/asset-0\.png"/u);
assert.doesNotMatch(html, /<img\b[^>]*\ssrc=/u);
} finally { await rm(directory, { recursive: true, force: true }); }
});
test("HTML staging rejects conflicting metadata for one Resource identity", async () => {
const directory = await mkdtemp(join(tmpdir(), "hypit-stage-html-conflict-"));
try {
await assert.rejects(stageHyperframesHtmlProject({
directory,
project: {
html: '<div data-composition-id="main" data-fps="30" data-hypit-frame-count="30" data-width="64" data-height="64"><img src="first.png"><img src="second.png"></div>',
assets: [
{ url: "first.png", artifact: { kind: "blob", resource: "res_conflicting-html-image", size: 3, mediaType: "image/png" } },
{ url: "second.png", artifact: { kind: "blob", resource: "res_conflicting-html-image", size: 4, mediaType: "image/png" } },
],
},
read: async () => new Uint8Array([1, 2, 3]),
}), /conflicting metadata/u);
} finally { await rm(directory, { recursive: true, force: true }); }
});
@@ -0,0 +1,165 @@
import assert from "node:assert/strict";
import { runInNewContext } from "node:vm";
import test from "node:test";
import { frameWorkIndexRuntime, hyperframesFrameSelectionPrelude } from "../src/frame-work.js";
import { terminalTextLayoutScript } from "../src/text.js";
test("terminal Text lazily prepares one persistent Animation per active Unit × Sequence", async () => {
class Style {
readonly values = new Map<string, string>();
getPropertyValue(name: string) { return this.values.get(name) ?? ""; }
setProperty(name: string, value: string) { this.values.set(name, value); }
removeProperty(name: string) { this.values.delete(name); }
}
class MockElement {
readonly style = new Style();
readonly attributes = new Map<string, string>();
readonly animationKeyframes: object[] = [];
readonly animationPositions: number[][] = [];
pauses = 0;
cancels = 0;
constructor(index: number) { this.attributes.set("data-hypit-text-unit-grapheme", String(index)); }
hasAttribute(name: string) { return this.attributes.has(name); }
getAttribute(name: string) { return this.attributes.get(name) ?? null; }
animate(keyframes: object) {
this.animationKeyframes.push(keyframes);
const positions: number[] = [];
this.animationPositions.push(positions);
const owner = this;
return {
pause() { owner.pauses += 1; },
set currentTime(value: number) { positions.push(value); },
cancel() { owner.cancels += 1; },
};
}
}
const units = [7, 8, 9].map((index) => new MockElement(index));
const futureUnits = [7, 8, 9].map((index) => new MockElement(index));
const sequences = JSON.stringify([{
unit: "grapheme", range: { start: 7, endExclusive: 10 }, order: "random", seed: 41,
startFrame: 0, staggerFrames: 1, unitDurationFrames: 10, cycles: 1,
keyframes: [
{ atProgress: 0, style: [{ name: "opacity", value: 0 }] },
{ atProgress: 1, style: [{ name: "opacity", value: 1 }] },
],
}]);
const root = (startFrame: number, elements: MockElement[]) => {
const attributes = new Map([
["data-hypit-text-start-frame", String(startFrame)],
["data-hypit-text-frame-numerator", "30"],
["data-hypit-text-frame-denominator", "1"],
["data-hypit-text-duration-frames", "30"],
["data-hypit-text-sequences", sequences],
]);
return {
getAttribute(name: string) { return attributes.get(name) ?? null; },
querySelectorAll() { return elements; },
};
};
const roots = [root(0, units), root(30, futureUnits)];
const composition = { getAttribute: (name: string) => name === "data-composition-id" ? "main" : null };
let seekListener: ((event: { detail: { waitUntil(value: Promise<void>): void } }) => void) | undefined;
const window = {
addEventListener(name: string, listener: typeof seekListener) {
if (name === "hf-seek") seekListener = listener;
},
} as { addEventListener(name: string, listener: typeof seekListener): void; __timelines?: Record<string, { totalTime(time: number): void }> };
let seededSteps = 0;
const countedMath = Object.create(Math) as Math;
countedMath.imul = (left, right) => {
seededSteps += 1;
return Math.imul(left, right);
};
const document = {
fonts: { ready: Promise.resolve() },
documentElement: { getBoundingClientRect() { return {}; } },
querySelector(selector: string) { return selector === "[data-composition-id]" ? composition : null; },
querySelectorAll(selector: string) {
return selector === "[data-hypit-text-clock][data-hypit-text-sequences]" ? roots : [];
},
getElementById() { return null; },
};
runInNewContext(`${frameWorkIndexRuntime}\n${terminalTextLayoutScript}`, {
document,
Element: MockElement,
Math: countedMath,
window,
});
assert.ok(seekListener);
let ready: Promise<void> | undefined;
seekListener({ detail: { waitUntil(value) { ready = value; } } });
assert.ok(ready);
await ready;
assert.equal(seededSteps, 2, "only the initially active root prepares its Fisher-Yates order");
const preparedKeyframes = units[0]!.animationKeyframes[0];
assert.ok(preparedKeyframes);
for (const unit of units) {
assert.equal(unit.animationKeyframes.length, 1, "each selected Unit × Sequence creates one browser Animation");
assert.equal(unit.pauses, 1);
assert.equal(unit.cancels, 0);
}
assert.ok(units.every((unit) => unit.animationPositions[0]!.length === 2), "the active root receives the initial absolute seek");
assert.ok(futureUnits.every((unit) => unit.animationKeyframes.length === 0), "the future root is not prepared in this Worker");
for (let frame = 1; frame <= 20; frame += 1) window.__timelines!.main!.totalTime(frame / 30);
assert.equal(seededSteps, 2, "seeks reuse the prepared inverse rank map");
for (const unit of units) {
assert.equal(unit.animationKeyframes.length, 1);
assert.equal(unit.animationKeyframes[0], preparedKeyframes, "Units in one root reuse prepared native keyframes");
assert.equal(unit.animationPositions[0]!.length, 22);
}
window.__timelines!.main!.totalTime(1);
assert.ok(units.every((unit) => unit.animationPositions[0]!.length === 22), "the expired Text root remains untouched");
assert.ok(futureUnits.every((unit) => unit.animationPositions[0]!.length === 2), "the newly active Text root is evaluated");
assert.equal(seededSteps, 4, "the newly active root prepares its random order once on first use");
assert.ok(futureUnits.every((unit) => unit.animationKeyframes.length === 1 && unit.pauses === 1 && unit.cancels === 0));
});
test("terminal Text static layout ignores roots outside the render selection", async () => {
class MockElement {}
const root = (startFrame: number) => ({
getAttribute(name: string) {
if (name === "data-hypit-text-start-frame") return String(startFrame);
if (name === "data-hypit-text-duration-frames") return "30";
return null;
},
});
const earlyRoot = root(0);
const selectedRoot = root(90);
const rotations = [0, 0];
const path = (owner: ReturnType<typeof root>, index: number) => ({
closest() { return owner; },
querySelectorAll() {
return [{
removeAttribute() {},
getRotationOfChar() { rotations[index]! += 1; return 0; },
setAttribute() {},
}];
},
});
const paths = [path(earlyRoot, 0), path(selectedRoot, 1)];
const composition = { getAttribute: () => "main" };
let ready: Promise<void> | undefined;
const window = {
addEventListener(name: string, listener: (event: { detail: { waitUntil(value: Promise<void>): void } }) => void) {
if (name === "hf-seek") listener({ detail: { waitUntil(value) { ready = value; } } });
},
} as { addEventListener(name: string, listener: (event: { detail: { waitUntil(value: Promise<void>): void } }) => void): void;
__timelines?: Record<string, unknown> };
const document = {
fonts: { ready: Promise.resolve() },
documentElement: { getBoundingClientRect() {} },
querySelector: () => composition,
querySelectorAll(selector: string) {
if (selector === "[data-hypit-text-path-upright]") return paths;
return [];
},
getElementById() { return null; },
};
runInNewContext(`${hyperframesFrameSelectionPrelude([{ startFrame: 100, endFrameExclusive: 101 }])}
${frameWorkIndexRuntime}\n${terminalTextLayoutScript}`, { document, Element: MockElement, window });
assert.ok(ready);
await ready;
assert.deepEqual(rotations, [0, 2]);
});
@@ -0,0 +1,35 @@
import assert from "node:assert/strict";
import { runInNewContext } from "node:vm";
import test from "node:test";
import { frameWorkIndexRuntime } from "../src/frame-work.js";
import { presentationVisibilityScript } from "../src/visibility.js";
test("Present visibility updates only absolute active-set changes across arbitrary seeks", () => {
const writes: string[] = [];
const element = (name: string, spans: object[]) => {
let opacity = "initial";
return {
dataset: { hypitVisibility: JSON.stringify(spans) },
style: {
get opacity() { return opacity; },
set opacity(value: string) { opacity = value; writes.push(`${name}:${value}`); },
},
};
};
const a = element("a", [{ startFrame: 2, endFrameExclusive: 5 }, { startFrame: 8, endFrameExclusive: 10 }]);
const b = element("b", [{ startFrame: 4, endFrameExclusive: 9 }]);
let seek: (event: { detail: { time: number } }) => void = () => {};
runInNewContext(`${frameWorkIndexRuntime}\n${presentationVisibilityScript(30, 1)}`, {
window: { addEventListener: (_: string, callback: typeof seek) => { seek = callback; } },
document: { querySelectorAll: () => [a, b] },
Set,
});
assert.deepEqual(writes, ["a:0", "b:0"]);
seek({ detail: { time: 2 / 30 } });
seek({ detail: { time: 4 / 30 } });
seek({ detail: { time: 6 / 30 } });
seek({ detail: { time: 8 / 30 } });
seek({ detail: { time: 1 / 30 } });
assert.deepEqual(writes, ["a:0", "b:0", "a:", "b:", "a:0", "a:", "a:0", "b:0"]);
});
+74 -9
View File
@@ -10,9 +10,40 @@ content identity or hidden output metadata.
`render-frames` uses the same staging, source-frame mapping, readiness and opaque PNG capture as
video export, returning the PNGs before encoding. It accepts a compiled document or a materialized
HTML project. For selected frames it merges only their required source-frame windows; continuous
windows decode sequentially. A complete batch shares one staged project and browser lifetime.
windows decode sequentially inside a render-local working set. A complete batch shares one staged
project and browser lifetime.
The existing browser selection, explicit preparation, worker settings and cancellation apply.
Document staging runs at most `artifactStagingConcurrency` complete Artifact lifecycles at once
(4 by default). A lifecycle includes Resource Store reading, local-file writing and typed Surface
inspection. The limit is local to one render and is independent of Chrome workers and Runtime request
capacity. The first failure stops new Artifact claims; already-started lifecycles settle before the
temporary directory can be removed. Existing HTML-project staging is already sequential.
For a compiled Document, the Provider intersects the whole continuous range or sparse frame list
with each Artifact's compiler-owned usage proof before claiming bytes. Direct image, video and typed
Surface dependencies outside the selection are not opened or written. `always` dependencies—such as
document fonts and opaque Browser Program resources—remain conservative. This selection happens once
per render; every Chrome worker shares the same staged project and may still evaluate any requested
frame in any order. Materialized HTML-project input has no such structural proof and remains complete.
`maxRenderedBytes` bounds the total returned PNG bytes, or the encoded MP4 for a video request.
For MP4 requests, completed PNGs are submitted individually to one ordered FFmpeg pipe instead of
being retained as a complete frame directory. `maxPendingFrameBytes` (256 MiB by default) bounds
completed out-of-order PNGs waiting for an earlier frame. When that budget is full, the affected
worker stops claiming work. The exact next frame may always pass, and one otherwise-empty waiting
set accepts a single oversized PNG, so the byte budget cannot deadlock ordered progress. This limit
does not change quality, drop frames, reduce an explicit worker count, or bound the encoded artifact.
`maxDecodedSourceBytes` (1 GiB by default) separately bounds decoded source PNGs retained for browser
injection. A screenshot leases only the exact source frames it is currently reading and releases
them before its output PNG can wait on the ordered encoder. Missing contiguous frames are grouped into
FFmpeg `image2pipe` jobs of at most 16 frames; each PNG is parsed and admitted as soon as it is complete instead of
waiting for the whole extraction. Remaining, not-yet-consumed frames in each live Worker's short batch
are soft preferences: they are reused when space permits, never displace another live soft preference,
and remain evictable for an exact screenshot requirement. Admission uses each completed PNG's actual
byte size. One exact screenshot requirement may occupy the set alone when it exceeds the budget. The
reported peak covers resident decoded PNG files; one bounded PNG may additionally be in the decoder
pipe/parser. It does not cover source assets, Chrome memory or FFmpeg's internal buffers.
Resident accounting is an attempt-local weighted-capacity lease that knows only integer units; source
identity, frame choice, eviction and prefetch remain in the SourceFrameStore. It is not Runtime capacity
and is never shared across renders.
`render-frames` has its own capability binding; it does not inherit a `render-visual` binding.
For immediate CLI invocation, the host calls the handler directly: Build admission reservations do
not coordinate separate CLI processes. Worker limits still bound browsers inside each invocation.
@@ -77,6 +108,11 @@ It waits for seek completion, dynamic images/fonts and the page compositor befor
Image readiness includes CSS class and pseudo-element images, CSS masks and SVG images. A failed
image decode reports its URL instead of producing a successful frame with missing media. Failed
declared fonts also fail capture instead of silently leaving fallback glyphs in the output.
When a page publishes capture roots, initial computed-style and image discovery is limited to those
roots. Descendant changes stay local, while an ancestor class/id change is routed back to the roots
it can affect. A real CSSOM stylesheet edit remains an explicit whole-document fallback because an
opaque rule can select anything. Pages that publish no roots, including arbitrary materialized HTML,
retain whole-document discovery.
The pinned engine couples its PNG session setup to transparent export, so this adapter initializes
an opaque session and chooses PNG separately at capture. It does not patch engine methods or files.
@@ -199,16 +235,45 @@ cleanup. A completed worker closes its Chrome immediately.
One call stages the HTML and every declared asset once. Typed Surface inspection reads the completed
staged file directly, without retaining its chunks, assembling another whole-file buffer, or writing
a second temporary copy. The caller keeps that file until inspection and capture have settled.
Typed Surface validation includes a complete
decoded-frame count, even for a short render interval. The renderer then finds source-frame windows
needed by that interval, merges overlapping windows, and extracts them one source/window at a time.
Decoded PNGs are shared by all workers in this call. Each worker initializes its own page, then takes
short contiguous frame batches from the render's in-memory queue. A free worker can continue with
another batch instead of waiting for a worker assigned a more expensive passage. Batches span at
Typed Surface validation includes a complete decoded-frame count, even for a short render interval.
Capture probes the dimensions of source media needed by the requested range, but does not decode
the range before launching browsers. Each Worker initializes its own page, then takes short
contiguous output-frame batches from the render's in-memory queue. Before one screenshot it leases that frame's exact source inputs;
missing inputs and live unconsumed batch preferences are merged into bounded continuous decode windows.
One FFmpeg process streams multiple PNGs for such a window, while the render-local store applies
backpressure and actual-byte admission between frames.
Decoded source PNGs are shared by all workers in this call. A free worker can continue with another
batch instead of waiting for a worker assigned a more expensive passage. Batches span at
most about one second and preserve the original absolute frame times. Output numbering starts at
zero; final H.264 encoding runs once after all workers finish. This division is internal to one
zero. Every completed output PNG is submitted immediately; a render-local ordered sink writes each
consecutive frame to FFmpeg and releases it, while later frames wait within the configured byte
budget. H.264 encoding overlaps capture before final verification. This division is internal to one
render call; it does not create or resume Builds. Extra workers help only while aggregate throughput
improves. Staging, validation, source extraction and final encoding still contribute their own cost.
improves. Staging, validation, source extraction and final verification still contribute their own cost.
The requested continuous range or sparse frame list is compacted into ordered half-open spans and
injected into every page before compiled scripts run. HyperFrames uses this render-local selection
to skip Browser Program setup, browser Animation materialization, Terminal Text static layout and
visibility-span indexing for Presents that cannot contribute. All Workers receive the same absolute
selection because batches may move between them; no Worker order or previous-frame cursor is part
of the result. Compiler-owned direct media URLs stay inert during parsing and are activated only
inside intersecting Present roots. The compiled page makes other Present roots non-painting, removes
any remaining direct media URL attributes, detaches them from the live DOM and publishes only
selected roots plus shared document definitions to opaque readiness. In a 200-unrelated-node browser
probe, this reduced initial computed-style reads
from at least 600 for an unscoped document to at most 6 for one selected root. These counts describe
the exercised DOM shape, not a machine-wide timing promise.
The complete staged HTML is still prepared, while declared Artifacts with a compiler-proven disjoint
frame usage are omitted from byte staging. Selection-gated URL activation also avoids browser requests
for that unselected compiler-owned media; arbitrary HTML/CSS, Browser Program resources and
document-level fonts remain conservative. The pinned engine's
document-level animation discovery now sees only selected live Present roots and document-level
definitions. Font readiness remains a browser-wide settlement
boundary, although fonts used only by hidden unselected Presents are no longer demanded in the
compiled-page path. Arbitrary HTML without the page-owned root protocol retains safe full-document
resource discovery. This is therefore execution-local loading/readiness projection rather than a
partial-document rewrite or a claim that every browser startup cost is proportional to the selection.
Each call has its own temporary directory, local server port and Chrome processes. Separate renders
do not share staged files or decoded PNGs. Exact compiler sampling markers retain loops, holds and
@@ -23,7 +23,7 @@ const localHyperframesRuntimeAdapter = createRuntimeEndpointAdapterFacet({
const config = runtimeConfigObject(context.config, "local HyperFrames");
runtimeConfigExact(config, [
"nodePath", "chromePath", "browserVersion", "browserCacheDirectory", "browserDownloadBaseUrl", "ffprobePath", "ffmpegPath", "workers", "maxWorkers", "quality", "browserGpu",
"defaultConcurrency", "browserCapacity", "initializationTimeoutMs", "frameTimeoutMs", "processTimeoutMs", "maxProcessOutputBytes", "maxRenderedBytes",
"defaultConcurrency", "browserCapacity", "initializationTimeoutMs", "frameTimeoutMs", "processTimeoutMs", "maxProcessOutputBytes", "artifactStagingConcurrency", "maxPendingFrameBytes", "maxDecodedSourceBytes", "maxRenderedBytes",
], "local HyperFrames");
runtimeConfigString(config.nodePath, "HyperFrames nodePath");
runtimeConfigString(config.ffprobePath, "HyperFrames ffprobePath");
@@ -65,6 +65,9 @@ const localHyperframesRuntimeAdapter = createRuntimeEndpointAdapterFacet({
const frameTimeoutMs = runtimeConfigPositiveInteger(config.frameTimeoutMs, "HyperFrames frameTimeoutMs");
const processTimeoutMs = runtimeConfigPositiveInteger(config.processTimeoutMs, "HyperFrames processTimeoutMs");
const maxProcessOutputBytes = runtimeConfigPositiveInteger(config.maxProcessOutputBytes, "HyperFrames maxProcessOutputBytes");
const artifactStagingConcurrency = runtimeConfigPositiveInteger(config.artifactStagingConcurrency, "HyperFrames artifactStagingConcurrency");
const maxPendingFrameBytes = runtimeConfigPositiveInteger(config.maxPendingFrameBytes, "HyperFrames maxPendingFrameBytes");
const maxDecodedSourceBytes = runtimeConfigPositiveInteger(config.maxDecodedSourceBytes, "HyperFrames maxDecodedSourceBytes");
const maxRenderedBytes = runtimeConfigPositiveInteger(config.maxRenderedBytes, "HyperFrames maxRenderedBytes");
return {
endpoint: createLocalHyperframesProvider({
@@ -84,6 +87,9 @@ const localHyperframesRuntimeAdapter = createRuntimeEndpointAdapterFacet({
...(frameTimeoutMs === undefined ? {} : { frameTimeoutMs }),
...(processTimeoutMs === undefined ? {} : { processTimeoutMs }),
...(maxProcessOutputBytes === undefined ? {} : { maxProcessOutputBytes }),
...(artifactStagingConcurrency === undefined ? {} : { artifactStagingConcurrency }),
...(maxPendingFrameBytes === undefined ? {} : { maxPendingFrameBytes }),
...(maxDecodedSourceBytes === undefined ? {} : { maxDecodedSourceBytes }),
...(maxRenderedBytes === undefined ? {} : { maxRenderedBytes }),
}),
program: localHyperframesBrowserProgram({
@@ -1,14 +1,21 @@
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import { join, relative, resolve, sep } from "node:path";
import { hyperframesFrameSelectionPrelude } from "@hypit/hyperframes";
import type { HyperframesDocument } from "@hypit/hyperframes";
import type { MediaFrameRange } from "@hypit/media";
import type { HyperframesRenderProgress, resolveExecutionOptions } from "./render.js";
import { assert, mediaExecutablePath, runProcess } from "./process.js";
import { assert, mediaExecutablePath, openProcessInput } from "./process.js";
import { verifyOutput } from "./output.js";
import { distributeFrameRange, sourceFrameAt, sourceWindows, videoSlots } from "./sampling.js";
import { distributeFrameRange, requestedFrameRanges, sourceFrameAt, sourceWindows, videoSlots } from "./sampling.js";
import type { VideoSlot } from "./sampling.js";
import { renderWorkerLimit } from "./render.js";
import { CaptureConcurrency } from "./concurrency.js";
import { createOpaqueFrameCapture } from "./opaque-capture.js";
import { FrameSpanIndex } from "./frame-span-index.js";
import { OrderedFrameSink } from "./ordered-frame-sink.js";
import { decodeSourceFrameWindow } from "./source-frame-decoder.js";
import { SourceFrameStore } from "./source-frame-store.js";
import type { SourceFrameWindow } from "./source-frame-store.js";
export type CaptureInput = {
readonly document: Pick<HyperframesDocument, "frameRate" | "frameCount" | "canvas">;
@@ -75,68 +82,94 @@ export async function captureStagedVisual(input: CaptureInput, controller: Abort
}
};
let server: Awaited<ReturnType<typeof createFileServer>> | undefined;
let encoderCompletion: Promise<unknown> | undefined;
let sourceFrameStore: SourceFrameStore | undefined;
// Closing pages interrupts an in-flight capture as well as the next loop iteration.
const abort = () => { for (const session of sessions) void close(session).catch(() => {}); };
signal.addEventListener("abort", abort, { once: true });
try {
signal.throwIfAborted();
const slots = videoSlots(await readFile(join(work, "index.html"), "utf8"));
const sources = new Map<string, { frames: Map<number, string>; width: number; height: number }>();
let sourceFrames = 0;
// One source at a time bounds decoder pressure independently of browser concurrency.
const windows = sourceWindows(slots, input.frames === undefined ? [range]
: input.frames.map(frame => ({ startFrame: frame, endFrameExclusive: frame + 1 })));
const sourceTotal = windows.reduce((sum, source) => sum + source.windows.reduce((count, window) =>
const slotIndex = new FrameSpanIndex(slots);
const requestedRanges = requestedFrameRanges(range, input.frames);
const plannedSources = sourceWindows(slots, requestedRanges);
const sourceTotal = plannedSources.reduce((sum, source) => sum + source.windows.reduce((count, window) =>
count + window.endFrameExclusive - window.startFrame, 0), 0);
if (sourceTotal > 0) onProgress({ phase: "decoding", completed: 0, total: sourceTotal, elapsedMs: elapsedMs() });
for (const source of windows) {
const sources = new Map<string, { path: string; fps: VideoSlot["sourceFps"];
metadata: Awaited<ReturnType<typeof engine.extractMediaMetadata>> }>();
// Chrome needs dimensions before initialization. Pixel frames themselves
// are decoded lazily for the screenshots that lease them.
for (const source of plannedSources) {
const path = resolve(work, source.src);
assert(path.startsWith(`${work}${sep}`), "HyperFrames source is outside the staged project");
const frames = new Map<number, string>();
let width = 0, height = 0;
for (const window of source.windows) {
signal.throwIfAborted();
const count = window.endFrameExclusive - window.startFrame;
const extracted = await engine.extractVideoFramesRange(path, `source-${sources.size}-${window.startFrame}`,
window.startFrame * source.fps.den / source.fps.num, count * source.fps.den / source.fps.num,
{ fps: source.fps, outputDir: join(work, "decoded"), format: "png", ...(count === 1 ? { finalFrameOnly: true } : {}) },
signal, { ffmpegProcessTimeout: config.processTimeoutMs });
assert(extracted.totalFrames === count, `HyperFrames expected ${count} source frames, decoded ${extracted.totalFrames}`);
for (let index = 0; index < count; index++) {
const framePath = extracted.framePaths.get(index);
assert(framePath !== undefined, "HyperFrames extraction omitted a requested source frame");
frames.set(window.startFrame + index, framePath);
}
width = extracted.metadata.width;
height = extracted.metadata.height;
sourceFrames += count;
onProgress({ phase: "decoding", completed: sourceFrames, total: sourceTotal, elapsedMs: elapsedMs() });
}
sources.set(source.src, { frames, width, height });
const metadata = await engine.extractMediaMetadata(path);
signal.throwIfAborted();
sources.set(source.src, { path, fps: source.fps, metadata });
}
let extraction = 0;
sourceFrameStore = new SourceFrameStore({ maxBytes: config.maxDecodedSourceBytes, signal,
decode: (src, window) => {
const source = sources.get(src);
assert(source !== undefined, `HyperFrames source ${src} is outside the requested render`);
return decodeSourceFrameWindow({ executable: ffmpegPath, path: source.path, outputDir: join(work, "decoded"),
outputPrefix: `source-${extraction++}`, window, fps: source.fps, metadata: source.metadata,
timeoutMs: config.processTimeoutMs, maxProcessOutputBytes: config.maxProcessOutputBytes, signal });
},
remove: async path => { await rm(path, { force: true }); },
});
const sourceDemand = (ranges: readonly MediaFrameRange[]): SourceFrameWindow[] => sourceWindows(slots, ranges, slotIndex)
.flatMap(source => source.windows.map(window => ({ source: source.src, ...window })));
const frameRanges = (startIndex: number, endIndex: number): MediaFrameRange[] => input.frames === undefined
? [{ startFrame: at(startIndex), endFrameExclusive: at(endIndex - 1) + 1 }]
: input.frames.slice(startIndex, endIndex).map(frame => ({ startFrame: frame, endFrameExclusive: frame + 1 }));
class SelectedFrameLookup extends engine.FrameLookupTable {
constructor(readonly strict: () => boolean) { super(); }
override getActiveFramePayloads(time: number) {
const frame = Math.round(time * fps.num / fps.den);
const payloads = new Map<string, { framePath: string; frameIndex: number }>();
for (const slot of slots) {
if (frame < slot.startFrame || frame >= slot.endFrameExclusive) continue;
for (const slot of slotIndex.at(frame)) {
const frameIndex = sourceFrameAt(slot, frame);
const framePath = sources.get(slot.src)?.frames.get(frameIndex);
// Browser initialization may seek outside the requested interval.
if (selected === undefined ? frame < range.startFrame || frame >= range.endFrameExclusive : !selected.has(frame)) continue;
const framePath = sourceFrameStore!.path(slot.src, frameIndex);
if (framePath === undefined && !this.strict()) continue;
assert(framePath !== undefined, `HyperFrames has no decoded frame ${frameIndex} for ${slot.id}`);
payloads.set(slot.id, { framePath, frameIndex });
}
return payloads;
}
}
server = await createFileServer({ projectDir: work, port: 0, fps });
server = await createFileServer({ projectDir: work, port: 0, fps,
preHeadScripts: [hyperframesFrameSelectionPrelude(requestedRanges)] });
const serverUrl = server.url;
const outputFrames = join(work, "frames");
await mkdir(outputFrames);
onProgress?.({ phase: "prepared", workers: concurrency.target, sourceFrames, elapsedMs: elapsedMs() });
const output = join(work, "visual.mp4");
const crf = { draft: 28, standard: 23, high: 18 }[config.quality];
const encoder = input.frames === undefined ? openProcessInput({ executable: ffmpegPath,
argv: ["-v", "error", "-y", "-f", "image2pipe", "-framerate", `${fps.num}/${fps.den}`, "-vcodec", "png", "-i", "pipe:0",
"-frames:v", String(frameCount), "-an", "-c:v", "libx264", "-crf", String(crf),
"-preset", config.quality === "draft" ? "veryfast" : "medium",
// Chromium composites in sRGB: convert with the BT.709 matrix and tag the stream so players decode it the same way.
"-vf", "scale=out_color_matrix=bt709:out_range=tv,format=yuv420p,setparams=color_primaries=bt709:color_trc=bt709:colorspace=bt709:range=tv",
"-movflags", "+faststart", output],
timeoutMs: config.processTimeoutMs, maxOutputBytes: config.maxProcessOutputBytes, signal,
}) : undefined;
const orderedFrames = encoder === undefined ? undefined : new OrderedFrameSink({
frameCount, maxPendingBytes: config.maxPendingFrameBytes, signal,
writer: { write: encoder.write, close: async () => { await encoder.close(); } },
});
if (encoder === undefined) await mkdir(outputFrames);
else {
encoderCompletion = encoder.completed;
void encoder.completed.catch((error) => {
orderedFrames!.fail(error);
controller.abort(error);
});
}
onProgress?.({ phase: "prepared", workers: concurrency.target, sourceFrames: sourceTotal, elapsedMs: elapsedMs() });
const jobs: Promise<void>[] = [];
let active = 0, open = 0, initializing = 0, capturedFrames = 0, capturedBytes = 0;
let sourceAcquireWorkerMs = 0, outputSubmitWorkerMs = 0;
let startupMs = 0;
const launch = (): void => {
const worker = jobs.length;
@@ -147,6 +180,7 @@ export async function captureStagedVisual(input: CaptureInput, controller: Abort
const runWorker = async (worker: number): Promise<void> => {
let session: Session | undefined;
let ready = false;
let capturing = false;
active++;
open++;
initializing++;
@@ -156,7 +190,7 @@ export async function captureStagedVisual(input: CaptureInput, controller: Abort
signal.throwIfAborted();
const directory = join(work, `worker-${worker}`);
await mkdir(directory);
const injector = engine.createVideoFrameInjector(slots.length === 0 ? null : new SelectedFrameLookup(), {
const injector = engine.createVideoFrameInjector(slots.length === 0 ? null : new SelectedFrameLookup(() => capturing), {
frameSrcResolver: (path) => new URL(relative(work, path).split(sep).map(encodeURIComponent).join("/"), `${serverUrl}/`).href,
});
session = await engine.createCaptureSession(serverUrl, directory, {
@@ -167,7 +201,7 @@ export async function captureStagedVisual(input: CaptureInput, controller: Abort
skipReadinessVideoIds: slots.map((slot) => slot.id),
videoMetadataHints: slots.flatMap((slot) => {
const source = sources.get(slot.src);
return source === undefined ? [] : [{ id: slot.id, width: source.width, height: source.height }];
return source === undefined ? [] : [{ id: slot.id, width: source.metadata.width, height: source.metadata.height }];
}),
}, injector, { chromePath: config.chromePath, browserGpuMode: config.browserGpu, enableBrowserPool: false, forceScreenshot: true, useDrawElement: false });
sessions.add(session);
@@ -186,6 +220,7 @@ export async function captureStagedVisual(input: CaptureInput, controller: Abort
let completed = 0;
let lastProgressAt = performance.now();
const timing = { seekMs: 0, prepareMs: 0, screenshotMs: 0 };
capturing = true;
while (true) {
// Retire only between complete batches, before claiming more work.
if (active > concurrency.target) break;
@@ -194,7 +229,21 @@ export async function captureStagedVisual(input: CaptureInput, controller: Abort
for (let index = batch.startFrame; index < batch.endFrameExclusive; index++) {
const frame = at(index);
signal.throwIfAborted();
const captured = await stage(`worker ${worker} frame ${frame}`, config.frameTimeoutMs, () => captureFrame(frame));
const required = sourceDemand(frameRanges(index, index + 1));
// A preference is the unconsumed suffix, not the whole batch: frames
// behind this Worker must become ordinary eviction candidates.
const preferred = sourceDemand(frameRanges(index, batch.endFrameExclusive));
const sourceAcquireStarted = performance.now();
const sourceLease = await sourceFrameStore!.acquire(required, preferred);
sourceAcquireWorkerMs += performance.now() - sourceAcquireStarted;
let captured: Awaited<ReturnType<typeof captureFrame>>;
try {
captured = await stage(`worker ${worker} frame ${frame}`, config.frameTimeoutMs, () => captureFrame(frame));
} finally {
// Output submission may block behind an earlier frame. Never
// retain source leases across that independent backpressure.
sourceLease.release();
}
if (input.frames !== undefined) {
capturedBytes += captured.buffer.byteLength;
assert(capturedBytes <= config.maxRenderedBytes, "HyperFrames PNG output exceeds its byte limit");
@@ -202,7 +251,13 @@ export async function captureStagedVisual(input: CaptureInput, controller: Abort
timing.seekMs += captured.seekMs;
timing.prepareMs += captured.prepareMs;
timing.screenshotMs += captured.screenshotMs;
await writeFile(join(outputFrames, `${String(index).padStart(9, "0")}.png`), captured.buffer);
if (orderedFrames === undefined) {
await writeFile(join(outputFrames, `${String(index).padStart(9, "0")}.png`), captured.buffer);
} else {
const outputSubmitStarted = performance.now();
await orderedFrames.submit(index, captured.buffer);
outputSubmitWorkerMs += performance.now() - outputSubmitStarted;
}
completed++;
capturedFrames++;
if (performance.now() - lastProgressAt >= 1_000) {
@@ -248,27 +303,28 @@ export async function captureStagedVisual(input: CaptureInput, controller: Abort
if (failure !== undefined) throw failure;
signal.throwIfAborted();
await Promise.all(closing.values());
console.log(`Capture source working set: ${sourceTotal} unique frames demanded; decoded ${sourceFrameStore.decodedFrames} frames; `
+ `redecoded ${Math.max(0, sourceFrameStore.decodedFrames - sourceTotal)}; decoder runs ${extraction}; `
+ `decoded bytes ${sourceFrameStore.decodedBytes}; largest frame ${sourceFrameStore.largestDecodedFrameBytes} bytes; `
+ `peak ${sourceFrameStore.peakResidentBytes} bytes; budget ${config.maxDecodedSourceBytes} bytes; `
+ `source acquire ${Math.round(sourceAcquireWorkerMs)} worker-ms; output submit ${Math.round(outputSubmitWorkerMs)} worker-ms`);
if (input.frames !== undefined) return outputFrames;
const output = join(work, "visual.mp4");
onProgress({ phase: "encoding", elapsedMs: elapsedMs() });
const crf = { draft: 28, standard: 23, high: 18 }[config.quality];
await runProcess({ executable: ffmpegPath,
argv: ["-v", "error", "-y", "-framerate", `${fps.num}/${fps.den}`, "-i", join(outputFrames, "%09d.png"),
"-frames:v", String(frameCount), "-an", "-c:v", "libx264", "-crf", String(crf),
"-preset", config.quality === "draft" ? "veryfast" : "medium",
// Chromium composites in sRGB: convert with the BT.709 matrix and tag the stream so players decode it the same way.
"-vf", "scale=out_color_matrix=bt709:out_range=tv,format=yuv420p,setparams=color_primaries=bt709:color_trc=bt709:colorspace=bt709:range=tv",
"-movflags", "+faststart", output],
timeoutMs: config.processTimeoutMs, maxOutputBytes: config.maxProcessOutputBytes, signal });
await orderedFrames!.close();
const outputStat = await stat(output);
assert(outputStat.size > 0 && outputStat.size <= config.maxRenderedBytes, "HyperFrames output is empty or exceeds its byte limit");
await verifyOutput({ path: output, document: { ...document, frameCount }, ffprobePath,
timeoutMs: config.processTimeoutMs, maxOutputBytes: config.maxProcessOutputBytes, signal });
signal.throwIfAborted();
return output;
} catch (error) {
controller.abort(error);
throw error;
} finally {
signal.removeEventListener("abort", abort);
await Promise.allSettled([...sessions].map(close));
if (sourceFrameStore !== undefined) await sourceFrameStore.close();
if (encoderCompletion !== undefined) await Promise.allSettled([encoderCompletion]);
if (server !== undefined) await server.close();
}
}
@@ -0,0 +1,109 @@
/**
* Immutable absolute-frame lookup for opaque payloads.
*
* This utility owns no media or rendering semantics. Callers keep those in the
* payload and may query frames or ranges in any order, from any Worker.
*/
export type FrameSpan = {
readonly startFrame: number;
readonly endFrameExclusive: number;
};
type Entry<T> = FrameSpan & { readonly serial: number; readonly payload: T };
type Node<T> = {
readonly center: number;
readonly byStart: readonly Entry<T>[];
readonly byEnd: readonly Entry<T>[];
readonly left: Node<T> | undefined;
readonly right: Node<T> | undefined;
};
function assertSpan(span: FrameSpan): void {
if (!Number.isSafeInteger(span.startFrame) || !Number.isSafeInteger(span.endFrameExclusive)
|| span.startFrame < 0 || span.endFrameExclusive <= span.startFrame) {
throw new Error("Frame span index entry is invalid");
}
}
function build<T>(entries: readonly Entry<T>[]): Node<T> | undefined {
if (entries.length === 0) return undefined;
const centers = entries
.map((entry) => entry.startFrame + (entry.endFrameExclusive - entry.startFrame) / 2)
.sort((left, right) => left - right);
const center = centers[Math.floor(centers.length / 2)]!;
const left: Entry<T>[] = [];
const right: Entry<T>[] = [];
const overlaps: Entry<T>[] = [];
for (const entry of entries) {
if (entry.endFrameExclusive <= center) left.push(entry);
else if (entry.startFrame > center) right.push(entry);
else overlaps.push(entry);
}
return Object.freeze({
center,
byStart: Object.freeze([...overlaps].sort((a, b) =>
a.startFrame - b.startFrame || a.endFrameExclusive - b.endFrameExclusive || a.serial - b.serial)),
byEnd: Object.freeze([...overlaps].sort((a, b) =>
b.endFrameExclusive - a.endFrameExclusive || a.startFrame - b.startFrame || a.serial - b.serial)),
left: build(left),
right: build(right),
});
}
function collectAt<T>(node: Node<T> | undefined, frame: number, result: Entry<T>[]): void {
if (node === undefined) return;
if (frame < node.center) {
for (const entry of node.byStart) {
if (entry.startFrame > frame) break;
result.push(entry);
}
collectAt(node.left, frame, result);
return;
}
for (const entry of node.byEnd) {
if (entry.endFrameExclusive <= frame) break;
result.push(entry);
}
collectAt(node.right, frame, result);
}
function collectOverlapping<T>(node: Node<T> | undefined, span: FrameSpan, result: Entry<T>[]): void {
if (node === undefined) return;
for (const entry of node.byStart) {
if (entry.startFrame >= span.endFrameExclusive) break;
if (entry.endFrameExclusive > span.startFrame) result.push(entry);
}
if (span.startFrame < node.center) collectOverlapping(node.left, span, result);
if (span.endFrameExclusive > node.center) collectOverlapping(node.right, span, result);
}
export class FrameSpanIndex<T extends FrameSpan> {
readonly #root: Node<T> | undefined;
constructor(values: readonly T[]) {
const entries = values.map((payload, serial): Entry<T> => {
assertSpan(payload);
return Object.freeze({
startFrame: payload.startFrame,
endFrameExclusive: payload.endFrameExclusive,
serial,
payload,
});
});
this.#root = build(entries);
}
at(frame: number): T[] {
if (!Number.isSafeInteger(frame) || frame < 0) throw new Error("Frame span index query is invalid");
const result: Entry<T>[] = [];
collectAt(this.#root, frame, result);
return result.sort((left, right) => left.serial - right.serial).map((entry) => entry.payload);
}
overlapping(span: FrameSpan): T[] {
assertSpan(span);
const result: Entry<T>[] = [];
collectOverlapping(this.#root, span, result);
return result.sort((left, right) => left.serial - right.serial).map((entry) => entry.payload);
}
}
@@ -9,6 +9,7 @@ type RenderWindow = Window & {
__hf_page_composite_prepare?: () => Promise<void>;
__hf_page_composite_resolve?: () => void;
__hypitBrowserProgramError?: string;
__hypitCaptureRoots?: readonly Element[];
};
/**
@@ -26,16 +27,24 @@ export async function createOpaqueFrameCapture(session: CaptureSession) {
// attributes, a CSS class or a pseudo-element after initial page readiness.
await page.evaluate(() => {
const decoded = new Map<string, Promise<void>>();
const dirtyRoots = new Set<Element>();
const animatedRoots = new Set<Element>();
const inspectedAnimations = new WeakSet<Animation>();
const pendingSheetChanges = new Set<Promise<unknown>>();
const announcedRoots = (window as unknown as RenderWindow).__hypitCaptureRoots;
const captureRoots = Array.isArray(announcedRoots)
&& announcedRoots.every(root => root instanceof Element) ? [...announcedRoots] : null;
const images = {
load(url: string): Promise<void> {
let ready = decoded.get(url);
const absolute = new URL(url, document.baseURI).href;
let ready = decoded.get(absolute);
if (ready === undefined) {
const image = new Image();
image.src = url;
image.src = absolute;
ready = image.decode().catch(error => {
throw new Error(`HyperFrames image could not be decoded: ${url}`, { cause: error });
throw new Error(`HyperFrames image could not be decoded: ${absolute}`, { cause: error });
});
decoded.set(url, ready);
decoded.set(absolute, ready);
}
return ready;
},
@@ -50,29 +59,183 @@ export async function createOpaqueFrameCapture(session: CaptureSession) {
this.background(value, pending);
}
},
inlineSignature(value: string): string {
const style = document.createElement("span").style;
style.cssText = value;
const candidates = [style.backgroundImage, style.maskImage, style.borderImageSource, style.listStyleImage, style.content];
for (let index = 0; index < style.length; index += 1) {
const name = style.item(index);
if (name.startsWith("--")) candidates.push(style.getPropertyValue(name));
}
return candidates.filter(candidate => /url\(|image-set\(|cross-fade\(|var\(/iu.test(candidate)).join("\n");
},
};
(window as unknown as { __hypitPrepareImages: () => Promise<void> }).__hypitPrepareImages = async () => {
const pending: Promise<unknown>[] = [];
for (const image of Array.from(document.images)) {
if ((image.currentSrc || image.getAttribute("src") || image.getAttribute("srcset"))
&& (!image.complete || image.naturalWidth === 0)) {
pending.push(image.decode().catch(error => {
throw new Error(`HyperFrames image could not be decoded: ${image.currentSrc || image.src}`, { cause: error });
const readiness = {
markDirty(node: Node | null): void {
if (node === document) {
// A real global stylesheet edit has opaque whole-document reach.
// This is the explicit escape hatch, not the initial scoped path.
if (document.documentElement !== null) dirtyRoots.add(document.documentElement);
return;
}
if (!(node instanceof Element)) return;
if (captureRoots === null) {
dirtyRoots.add(node);
return;
}
for (const root of captureRoots) {
if (root === node || root.contains(node)) {
dirtyRoots.add(node);
return;
}
if (node.contains(root)) dirtyRoots.add(root);
}
},
markAnimatedImageRoots(): void {
for (const animation of document.getAnimations()) {
if (inspectedAnimations.has(animation)) continue;
inspectedAnimations.add(animation);
const effect = animation.effect;
if (!(effect instanceof KeyframeEffect) || !(effect.target instanceof Element)) continue;
const changesImage = effect.getKeyframes().some(keyframe => Object.entries(keyframe).some(([name, value]) =>
name !== "easing" && typeof value === "string" && /url\(|image-set\(|cross-fade\(|var\(/iu.test(value)));
if (changesImage) animatedRoots.add(effect.target);
}
// Browser animation state can change without a DOM mutation. Only those
// targets whose keyframes can select an image remain frame-active.
for (const root of animatedRoots) {
if (root.isConnected) this.markDirty(root);
else animatedRoots.delete(root);
}
},
rootsForPass(): Element[] {
const roots = [...dirtyRoots].filter(root => root.isConnected);
dirtyRoots.clear();
// Do not scan a nested dirty subtree when an ancestor is already dirty.
return roots.filter(root => !roots.some(other => other !== root && other.contains(root)));
},
appendElementImages(element: Element, pending: Promise<unknown>[]): void {
if (element instanceof HTMLImageElement
&& (element.currentSrc || element.getAttribute("src") || element.getAttribute("srcset"))
&& (!element.complete || element.naturalWidth === 0)) {
pending.push(element.decode().catch(error => {
throw new Error(`HyperFrames image could not be decoded: ${element.currentSrc || element.src}`, { cause: error });
}));
}
}
for (const element of Array.from(document.querySelectorAll("*"))) {
images.style(getComputedStyle(element), pending);
for (const pseudo of ["::before", "::after"]) {
const style = getComputedStyle(element, pseudo);
if (style.content !== "none" && style.content !== "normal") images.style(style, pending);
}
if (element instanceof SVGImageElement) {
const href = element.getAttribute("href") ?? element.getAttributeNS("http://www.w3.org/1999/xlink", "href");
if (href) pending.push(images.load(href));
}
},
async preparePass(): Promise<void> {
if (pendingSheetChanges.size > 0) await Promise.all([...pendingSheetChanges]);
this.markAnimatedImageRoots();
const pending: Promise<unknown>[] = [];
for (const root of this.rootsForPass()) {
this.appendElementImages(root, pending);
for (const element of Array.from(root.querySelectorAll("*"))) this.appendElementImages(element, pending);
}
await Promise.all(pending);
},
};
// CSSOM edits have no DOM mutation record and can change image selection
// anywhere in the document. Keep this as the explicit opaque-page fallback:
// only an actual global stylesheet edit dirties the complete page.
const sheetPrototype = CSSStyleSheet.prototype;
const originalInsertRule = sheetPrototype.insertRule;
const originalDeleteRule = sheetPrototype.deleteRule;
const originalReplace = sheetPrototype.replace;
const originalReplaceSync = sheetPrototype.replaceSync;
const sheetEdits = {
insertRule(this: CSSStyleSheet, rule: string, index?: number): number {
const result = Reflect.apply(originalInsertRule, this, index === undefined ? [rule] : [rule, index]) as number;
readiness.markDirty(document);
return result;
},
deleteRule(this: CSSStyleSheet, index: number): void {
Reflect.apply(originalDeleteRule, this, [index]);
readiness.markDirty(document);
},
replace(this: CSSStyleSheet, text: string): Promise<CSSStyleSheet> {
const result = Reflect.apply(originalReplace, this, [text]) as Promise<CSSStyleSheet>;
const pending = result.then(sheet => {
readiness.markDirty(document);
return sheet;
});
pendingSheetChanges.add(pending);
void pending.then(() => pendingSheetChanges.delete(pending), () => pendingSheetChanges.delete(pending));
return result;
},
replaceSync(this: CSSStyleSheet, text: string): void {
Reflect.apply(originalReplaceSync, this, [text]);
readiness.markDirty(document);
},
};
sheetPrototype.insertRule = sheetEdits.insertRule;
sheetPrototype.deleteRule = sheetEdits.deleteRule;
sheetPrototype.replace = sheetEdits.replace;
sheetPrototype.replaceSync = sheetEdits.replaceSync;
const observer = new MutationObserver(records => {
for (const record of records) {
if (record.type === "childList") {
// New subtrees can own resources. The parent itself can gain a
// :empty/:has-driven pseudo image, but unchanged siblings do not need
// to be rediscovered for the ordinary insertion case.
const added = Array.from(record.addedNodes);
const changesStylesheet = (record.target instanceof Element
&& (record.target.tagName === "STYLE" || record.target.tagName === "LINK"))
|| added.some(node => node instanceof Element && (node.tagName === "STYLE" || node.tagName === "LINK"));
readiness.markDirty(changesStylesheet ? document : record.target);
for (const node of added) readiness.markDirty(node);
continue;
}
if (record.type === "characterData") {
const parent = record.target.parentElement;
// A changed <style> can affect any existing element.
readiness.markDirty(parent?.tagName === "STYLE" ? document : parent);
continue;
}
const target = record.target;
if (!(target instanceof Element)) continue;
const name = record.attributeName ?? "";
if (target.tagName === "STYLE" || target.tagName === "LINK") {
readiness.markDirty(document);
} else if (name === "style") {
// Layout/visibility writes are common during every seek and cannot
// introduce a resource. Resource-bearing declarations and custom
// properties may affect this complete subtree.
if (images.inlineSignature(record.oldValue ?? "") !== images.inlineSignature(target.getAttribute("style") ?? "")) {
readiness.markDirty(target);
}
} else {
// class/id/arbitrary data attributes can select descendant and pseudo
// styles; src/srcset/href also arrive here.
readiness.markDirty(target);
}
}
for (const image of Array.from(document.querySelectorAll("svg image"))) {
const href = image.getAttribute("href") ?? image.getAttributeNS("http://www.w3.org/1999/xlink", "href");
if (href) pending.push(images.load(new URL(href, document.baseURI).href));
}
await Promise.all(pending);
});
if (document.documentElement !== null) {
if (captureRoots === null) dirtyRoots.add(document.documentElement);
else for (const root of captureRoots) if (root.isConnected) dirtyRoots.add(root);
observer.observe(document.documentElement, {
subtree: true,
childList: true,
characterData: true,
attributes: true,
attributeOldValue: true,
});
}
(window as unknown as { __hypitPrepareImages: () => Promise<void> }).__hypitPrepareImages = async () => {
// MutationObserver delivery and Promise continuations are microtasks. A
// pass may itself allow a waiting program to publish another subtree, so
// drain until the page has no newly dirtied resource scope.
await Promise.resolve();
do await readiness.preparePass(); while (dirtyRoots.size > 0);
await document.fonts.ready;
document.fonts.forEach(font => {
if (font.status === "error") throw new Error(`HyperFrames font could not be loaded: ${font.family}`);
@@ -17,5 +17,11 @@ export type HyperframesExecutionOptions = import("./browser.js").BrowserOptions
readonly initializationTimeoutMs?: number;
readonly frameTimeoutMs?: number;
readonly maxProcessOutputBytes?: number;
/** Artifact staging lifecycles in flight inside one render. */
readonly artifactStagingConcurrency?: number;
/** Bytes of completed, out-of-order PNGs retained while an MP4 waits for its next frame. */
readonly maxPendingFrameBytes?: number;
/** Bytes of decoded source PNGs resident in one render's reusable working set. */
readonly maxDecodedSourceBytes?: number;
readonly maxRenderedBytes?: number;
};
@@ -0,0 +1,139 @@
import { assert } from "./process.js";
export type OrderedFrameWriter = {
readonly write: (bytes: Uint8Array) => Promise<void>;
readonly close: () => Promise<void>;
};
/**
* Joins independently captured frames into one ordered stream. This state belongs
* to one render and knows only output ordinals and PNG bytes.
*/
export class OrderedFrameSink {
readonly #frameCount: number;
readonly #maxPendingBytes: number;
readonly #writer: OrderedFrameWriter;
readonly #pending = new Map<number, Uint8Array>();
readonly #waiting = new Set<number>();
readonly #changes = new Set<() => void>();
readonly #signal: AbortSignal | undefined;
readonly #abort: () => void;
#pendingBytes = 0;
#nextOrdinal = 0;
#draining: Promise<void> | undefined;
#failure: unknown;
#closed = false;
constructor(args: {
readonly frameCount: number;
readonly maxPendingBytes: number;
readonly writer: OrderedFrameWriter;
readonly signal?: AbortSignal;
}) {
assert(Number.isSafeInteger(args.frameCount) && args.frameCount > 0, "Ordered frame count must be a positive integer");
assert(Number.isSafeInteger(args.maxPendingBytes) && args.maxPendingBytes > 0,
"Ordered frame byte budget must be a positive integer");
this.#frameCount = args.frameCount;
this.#maxPendingBytes = args.maxPendingBytes;
this.#writer = args.writer;
this.#signal = args.signal;
this.#abort = () => this.fail(args.signal?.reason ?? new Error("Ordered frame sink aborted"));
args.signal?.addEventListener("abort", this.#abort, { once: true });
if (args.signal?.aborted) this.#abort();
}
/** Resolves once this frame has entered the bounded waiting set or was written. */
async submit(ordinal: number, bytes: Uint8Array): Promise<void> {
this.#throwIfStopped();
assert(Number.isSafeInteger(ordinal) && ordinal >= 0 && ordinal < this.#frameCount,
`Ordered frame ordinal ${ordinal} is outside the render`);
assert(bytes.byteLength > 0, `Ordered frame ${ordinal} is empty`);
assert(ordinal >= this.#nextOrdinal && !this.#pending.has(ordinal) && !this.#waiting.has(ordinal),
`Ordered frame ${ordinal} was submitted more than once`);
this.#waiting.add(ordinal);
try {
// A future frame waits for byte capacity. The exact frontier always passes;
// an otherwise-empty set also accepts one oversized frame, so neither the
// order dependency nor a single large PNG can deadlock this render.
while (ordinal !== this.#nextOrdinal && this.#pendingBytes > 0
&& this.#pendingBytes + bytes.byteLength > this.#maxPendingBytes) {
await this.#changed();
this.#throwIfStopped();
}
this.#waiting.delete(ordinal);
this.#pending.set(ordinal, bytes);
this.#pendingBytes += bytes.byteLength;
await this.#flush();
} catch (error) {
this.#waiting.delete(ordinal);
throw error;
}
}
/** Finish the ordered input after every producer has settled. */
async close(): Promise<void> {
this.#throwIfStopped();
assert(!this.#closed, "Ordered frame sink is already closed");
await this.#flush();
this.#throwIfStopped();
assert(this.#nextOrdinal === this.#frameCount && this.#pending.size === 0 && this.#waiting.size === 0,
`Ordered frame sink closed after ${this.#nextOrdinal} of ${this.#frameCount} frames`);
this.#closed = true;
this.#signal?.removeEventListener("abort", this.#abort);
await this.#writer.close();
}
/** Stop blocked submitters when the encoder or another render participant fails. */
fail(error: unknown): void {
if (this.#failure !== undefined || this.#closed) return;
this.#failure = error;
this.#notify();
}
async #flush(): Promise<void> {
while (this.#pending.has(this.#nextOrdinal)) {
if (this.#draining === undefined) {
const draining = this.#drainReady();
this.#draining = draining;
void draining.finally(() => {
if (this.#draining === draining) this.#draining = undefined;
}).catch(() => {});
}
await this.#draining;
this.#throwIfStopped();
}
}
async #drainReady(): Promise<void> {
try {
while (true) {
const ordinal = this.#nextOrdinal;
const bytes = this.#pending.get(ordinal);
if (bytes === undefined) return;
await this.#writer.write(bytes);
this.#pending.delete(ordinal);
this.#pendingBytes -= bytes.byteLength;
this.#nextOrdinal++;
this.#notify();
}
} catch (error) {
this.fail(error);
throw error;
}
}
#changed(): Promise<void> {
return new Promise(resolve => this.#changes.add(resolve));
}
#notify(): void {
const changes = [...this.#changes];
this.#changes.clear();
for (const change of changes) change();
}
#throwIfStopped(): void {
if (this.#failure !== undefined) throw this.#failure;
assert(!this.#closed, "Ordered frame sink is already closed");
}
}
@@ -8,6 +8,14 @@ type ProcessResult = {
readonly stderr: string;
};
type ProcessArguments = {
readonly executable: string;
readonly argv: readonly string[];
readonly timeoutMs: number;
readonly maxOutputBytes: number;
readonly signal?: AbortSignal;
};
export function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
@@ -46,21 +54,56 @@ export function processEnvironment(platform: NodeJS.Platform = process.platform)
: [[name, process.env[name]]])) as NodeJS.ProcessEnv;
}
export async function runProcess(args: {
readonly executable: string;
readonly argv: readonly string[];
readonly timeoutMs: number;
readonly maxOutputBytes: number;
readonly signal?: AbortSignal;
}): Promise<ProcessResult> {
export async function runProcess(args: ProcessArguments): Promise<ProcessResult> {
args.signal?.throwIfAborted();
return await new Promise((resolve, reject) => {
return await startProcess(args, false).completed;
}
/** Start one process whose stdin is written incrementally by a single caller. */
export function openProcessInput(args: ProcessArguments): {
readonly write: (bytes: Uint8Array) => Promise<void>;
readonly close: () => Promise<ProcessResult>;
readonly completed: Promise<ProcessResult>;
} {
args.signal?.throwIfAborted();
const process = startProcess(args, true);
const input = process.input!;
let ended = false;
let closing: Promise<ProcessResult> | undefined;
return {
completed: process.completed,
write: async (bytes) => {
assert(!ended, `${args.executable} input is already closed`);
args.signal?.throwIfAborted();
await new Promise<void>((resolve, reject) => {
input.write(bytes, (error) => error === null || error === undefined ? resolve() : reject(error));
});
args.signal?.throwIfAborted();
},
close: () => {
closing ??= (() => {
ended = true;
input.end();
return process.completed;
})();
return closing;
},
};
}
function startProcess(args: ProcessArguments, pipeInput: boolean): {
readonly input: import("node:stream").Writable | undefined;
readonly completed: Promise<ProcessResult>;
} {
let input: import("node:stream").Writable | undefined;
const completed = new Promise<ProcessResult>((resolve, reject) => {
const child = spawn(args.executable, [...args.argv], {
shell: false,
windowsHide: true,
stdio: ["ignore", "pipe", "pipe"],
stdio: [pipeInput ? "pipe" : "ignore", "pipe", "pipe"],
env: processEnvironment(),
});
input = child.stdin ?? undefined;
const stdout: Buffer[] = [];
let outputBytes = 0;
let stderr = "";
@@ -83,7 +126,7 @@ export async function runProcess(args: {
const timer = setTimeout(() => {
stop(new Error(`${args.executable} timed out`));
}, args.timeoutMs);
child.stdout.on("data", (chunk: Buffer) => {
child.stdout!.on("data", (chunk: Buffer) => {
outputBytes += chunk.byteLength;
if (outputBytes > args.maxOutputBytes) {
stop(new Error(`${args.executable} output exceeded the configured limit`));
@@ -91,7 +134,7 @@ export async function runProcess(args: {
}
stdout.push(chunk);
});
child.stderr.on("data", (chunk: Buffer) => {
child.stderr!.on("data", (chunk: Buffer) => {
outputBytes += chunk.byteLength;
stderr = `${stderr}${chunk.toString()}`.slice(-32_000);
if (outputBytes > args.maxOutputBytes) {
@@ -99,6 +142,9 @@ export async function runProcess(args: {
}
});
child.on("error", (error) => finish(error));
// Writes receive their own EPIPE error. Keep the process result focused on
// its exit code and stderr, which carry the useful encoder failure.
child.stdin?.on("error", () => {});
if (args.signal?.aborted) abort();
child.on("close", (code) => {
if (failure !== undefined) finish(failure);
@@ -106,4 +152,6 @@ export async function runProcess(args: {
else finish(new Error(`${args.executable} exited ${String(code)}: ${stderr}`));
});
});
assert(!pipeInput || input !== undefined, `${args.executable} input pipe is unavailable`);
return { input, completed };
}
@@ -15,6 +15,7 @@ import { assert, positiveInteger } from "./process.js";
import { runCaptureProcess } from "./capture-process.js";
import { finished } from "node:stream/promises";
import { autoWorkerLimit } from "./concurrency.js";
import { requestedFrameRanges } from "./sampling.js";
import { browserCacheDirectory, browserExecutablePath, configuredBrowserPath, requireBrowserExecutable, selectedBrowserVersion } from "./browser.js";
export type HyperframesRenderProgress =
@@ -60,6 +61,9 @@ export function resolveExecutionOptions(options: HyperframesExecutionOptions) {
: positiveInteger(options.frameTimeoutMs, "frameTimeoutMs"),
processTimeoutMs: positiveInteger(options.processTimeoutMs ?? 30 * 60_000, "processTimeoutMs"),
maxProcessOutputBytes: positiveInteger(options.maxProcessOutputBytes ?? 4 * 1024 * 1024, "maxProcessOutputBytes"),
artifactStagingConcurrency: positiveInteger(options.artifactStagingConcurrency ?? 4, "artifactStagingConcurrency"),
maxPendingFrameBytes: positiveInteger(options.maxPendingFrameBytes ?? 256 * 1024 * 1024, "maxPendingFrameBytes"),
maxDecodedSourceBytes: positiveInteger(options.maxDecodedSourceBytes ?? 1024 * 1024 * 1024, "maxDecodedSourceBytes"),
maxRenderedBytes: positiveInteger(options.maxRenderedBytes ?? 16 * 1024 * 1024 * 1024, "maxRenderedBytes"),
};
}
@@ -116,7 +120,9 @@ async function capture(request: HyperframesVisualRequest | HyperframesFramesRequ
await requireBrowserExecutable(config.chromePath, config.browserVersion);
await options.onDiagnostic?.({ level: "info", message:
`Capture ${frameCount} frames; workers ${config.workers} (limit ${renderWorkerLimit(config, frameCount, document.frameRate.numerator / document.frameRate.denominator)}); opaque fast PNG; GPU ${config.browserGpu}; browser ${config.chromePath}`
+ (frames === undefined ? `; quality ${config.quality}; encoder ${config.ffmpegPath}` : "; PNG output") });
+ `; artifact staging concurrency ${config.artifactStagingConcurrency}`
+ `; decoded source budget ${config.maxDecodedSourceBytes} bytes`
+ (frames === undefined ? `; quality ${config.quality}; ordered FFmpeg pipe; pending PNG budget ${config.maxPendingFrameBytes} bytes; encoder ${config.ffmpegPath}` : "; PNG output") });
options.onProgress?.({ phase: "staging", elapsedMs: 0 });
work = await mkdtemp(join(tmpdir(), "hypit-hyperframes-local-"));
const read: import("@hypit/hyperframes/project").HyperframesArtifactReader = async (artifact, readSignal) => {
@@ -128,11 +134,13 @@ async function capture(request: HyperframesVisualRequest | HyperframesFramesRequ
};
if ("project" in request) await stageHyperframesHtmlProject({ project: request.project, directory: work, signal, read });
else await stageHyperframesProject({ document: request.document, directory: work, signal, read,
frameSelection: requestedFrameRanges(range, frames),
maxConcurrentArtifacts: config.artifactStagingConcurrency,
validateSurface: (surface, path, probeSignal) => verifyCompositableSurfaceFile({ surface, path,
ffprobePath: config.ffprobePath, processTimeoutMs: config.processTimeoutMs,
maxProbeOutputBytes: config.maxProcessOutputBytes, signal: probeSignal! }),
});
changePhase("decoding source frames");
changePhase("preparing source media");
await runCaptureProcess({ document, range, ...(frames === undefined ? {} : { frames }), config, directory: work,
engineModule: import.meta.resolve("@hyperframes/engine"),
producerModule: import.meta.resolve("@hyperframes/producer"),
@@ -1,4 +1,5 @@
import type { MediaFrameRange } from "@hypit/media";
import { FrameSpanIndex } from "./frame-span-index.js";
import { assert } from "./process.js";
type Rational = { readonly numerator: bigint; readonly denominator: bigint };
@@ -10,6 +11,25 @@ export type VideoSlot = MediaFrameRange & {
readonly sourceFps: { readonly num: number; readonly den: number };
};
/** Compact an ordered frame selection into the half-open spans shared with the page runtime. */
export function requestedFrameRanges(range: MediaFrameRange, frames?: readonly number[]): MediaFrameRange[] {
if (frames === undefined) return [{ ...range }];
const result: MediaFrameRange[] = [];
for (const frame of frames) {
assert(Number.isSafeInteger(frame) && frame >= 0, "HyperFrames requested frame is invalid");
const previous = result.at(-1);
if (previous !== undefined && previous.endFrameExclusive === frame) {
result[result.length - 1] = { startFrame: previous.startFrame, endFrameExclusive: frame + 1 };
} else {
assert(previous === undefined || previous.endFrameExclusive < frame,
"HyperFrames requested frames must be strictly increasing");
result.push({ startFrame: frame, endFrameExclusive: frame + 1 });
}
}
assert(result.length > 0, "HyperFrames requested frames must not be empty");
return result;
}
function rational(value: string, label: string): Rational {
assert(/^\d+\/[1-9]\d*$/u.test(value), `HyperFrames ${label} must be a non-negative rational`);
const [a, b] = value.split("/");
@@ -19,14 +39,18 @@ function rational(value: string, label: string): Rational {
/** Read the compiler's exact frame markers, without reconstructing author intent from seconds. */
export function videoSlots(html: string): VideoSlot[] {
return [...html.matchAll(/<video\b[^>]*>/gu)].map(([tag]) => {
const attribute = (name: string): string => {
const optionalAttribute = (name: string): string | undefined => {
const value = new RegExp(`(?:\\s)${name}="([^"]*)"`, "u").exec(tag)?.[1];
return value?.replaceAll("&amp;", "&").replaceAll("&quot;", '"').replaceAll("&lt;", "<").replaceAll("&gt;", ">");
};
const attribute = (name: string): string => {
const value = optionalAttribute(name);
assert(value !== undefined, `HyperFrames video lacks ${name}; compile its frame sampling before rendering`);
return value.replaceAll("&amp;", "&").replaceAll("&quot;", '"').replaceAll("&lt;", "<").replaceAll("&gt;", ">");
return value;
};
const sourceFps = rational(attribute("data-hypit-source-fps"), "source frame rate");
const slot = {
id: attribute("id"), src: attribute("src"),
id: attribute("id"), src: optionalAttribute("data-hypit-resource-src") ?? attribute("src"),
startFrame: Number(attribute("data-hypit-start-frame")),
endFrameExclusive: Number(attribute("data-hypit-end-frame")),
sourceFrame: rational(attribute("data-hypit-source-frame"), "source frame"),
@@ -51,10 +75,11 @@ export function sourceFrameAt(slot: VideoSlot, frame: number): number {
return value;
}
export function sourceWindows(slots: readonly VideoSlot[], selection: MediaFrameRange | readonly MediaFrameRange[]) {
export function sourceWindows(slots: readonly VideoSlot[], selection: MediaFrameRange | readonly MediaFrameRange[],
index = new FrameSpanIndex(slots)) {
const ranges: readonly MediaFrameRange[] = Array.isArray(selection) ? selection : [selection as MediaFrameRange];
const sources = new Map<string, { fps: VideoSlot["sourceFps"]; windows: MediaFrameRange[] }>();
for (const range of ranges) for (const slot of slots) {
for (const range of ranges) for (const slot of index.overlapping(range)) {
const first = Math.max(range.startFrame, slot.startFrame);
const last = Math.min(range.endFrameExclusive, slot.endFrameExclusive) - 1;
if (last < first) continue;
@@ -0,0 +1,151 @@
import { spawn } from "node:child_process";
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { VideoMetadata } from "@hyperframes/engine";
import { assert, processEnvironment } from "./process.js";
import type { DecodedSourceFrame, SourceFrameWindow } from "./source-frame-store.js";
const pngSignature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
const alphaCodecs = new Set(["vp9", "vp8", "prores"]);
/** Incremental parser for concatenated PNGs from FFmpeg image2pipe. */
export class PngFrameParser {
readonly #maxFrameBytes: number;
#buffer = Buffer.alloc(0);
#offset = 0;
constructor(maxFrameBytes: number) {
assert(Number.isSafeInteger(maxFrameBytes) && maxFrameBytes > 0, "PNG frame byte limit must be positive");
this.#maxFrameBytes = maxFrameBytes;
}
push(chunk: Uint8Array): Buffer[] {
if (chunk.byteLength === 0) return [];
this.#buffer = this.#offset === 0
? Buffer.concat([this.#buffer, chunk])
: Buffer.concat([this.#buffer.subarray(this.#offset), chunk]);
this.#offset = 0;
const frames: Buffer[] = [];
while (true) {
if (this.#buffer.length - this.#offset < pngSignature.length) break;
assert(this.#buffer.subarray(this.#offset, this.#offset + pngSignature.length).equals(pngSignature),
"FFmpeg source decoder emitted invalid PNG data");
let cursor = this.#offset + pngSignature.length;
let complete = false;
while (this.#buffer.length - cursor >= 12) {
const length = this.#buffer.readUInt32BE(cursor);
const end = cursor + 12 + length;
assert(end - this.#offset <= this.#maxFrameBytes, "FFmpeg decoded source PNG exceeds its frame byte limit");
if (this.#buffer.length < end) break;
const type = this.#buffer.toString("ascii", cursor + 4, cursor + 8);
cursor = end;
if (type !== "IEND") continue;
frames.push(Buffer.from(this.#buffer.subarray(this.#offset, cursor)));
this.#offset = cursor;
complete = true;
break;
}
if (!complete) break;
}
if (this.#offset > 0 && this.#offset === this.#buffer.length) {
this.#buffer = Buffer.alloc(0);
this.#offset = 0;
} else {
assert(this.#buffer.length - this.#offset <= this.#maxFrameBytes,
"FFmpeg decoded source PNG exceeds its frame byte limit");
}
return frames;
}
finish(): void {
assert(this.#buffer.length === this.#offset, "FFmpeg source decoder ended inside a PNG frame");
}
}
export async function* decodeSourceFrameWindow(args: {
readonly executable: string;
readonly path: string;
readonly outputDir: string;
readonly outputPrefix: string;
readonly window: Omit<SourceFrameWindow, "source">;
readonly fps: { readonly num: number; readonly den: number };
readonly metadata: VideoMetadata;
readonly timeoutMs: number;
readonly maxProcessOutputBytes: number;
readonly signal?: AbortSignal;
}): AsyncGenerator<DecodedSourceFrame> {
args.signal?.throwIfAborted();
const count = args.window.endFrameExclusive - args.window.startFrame;
assert(Number.isSafeInteger(count) && count > 0, "Source decode window is empty");
await mkdir(args.outputDir, { recursive: true });
const startTime = args.window.startFrame * args.fps.den / args.fps.num;
const duration = count * args.fps.den / args.fps.num;
const codec = args.metadata.videoCodec.toLowerCase();
const hdr = args.metadata.colorSpace?.colorTransfer === "smpte2084"
|| args.metadata.colorSpace?.colorTransfer === "arib-std-b67";
const ffmpegFps = `${args.fps.num}/${args.fps.den}`;
const argv = ["-v", "error"];
if (hdr && process.platform === "darwin") argv.push("-hwaccel", "videotoolbox");
if (alphaCodecs.has(codec)) argv.push("-c:v", codec === "vp9" ? "libvpx-vp9" : codec === "vp8" ? "libvpx" : codec);
if (count === 1) argv.push("-i", args.path, "-ss", String(startTime));
else argv.push("-ss", String(startTime), "-i", args.path, "-t", String(duration));
const filters: string[] = [];
if (hdr && process.platform === "darwin") filters.push("format=nv12");
if (count > 1 && !args.metadata.isVFR) filters.push(`fps=${ffmpegFps}`);
if (filters.length > 0) argv.push("-vf", filters.join(","));
if (count > 1 && args.metadata.isVFR) argv.push("-fps_mode", "cfr", "-r", ffmpegFps);
argv.push("-frames:v", String(count), "-q:v", "0", "-compression_level", "1",
"-f", "image2pipe", "-vcodec", "png", "pipe:1");
const child = spawn(args.executable, argv, {
shell: false, windowsHide: true, stdio: ["ignore", "pipe", "pipe"], env: processEnvironment(),
});
let stopped: Error | undefined;
let stderr = "";
let stderrBytes = 0;
let closed = false;
const stop = (error: Error) => {
stopped ??= error;
if (!closed) child.kill("SIGKILL");
};
const abort = () => stop(args.signal?.reason instanceof Error ? args.signal.reason : new Error(String(args.signal?.reason)));
args.signal?.addEventListener("abort", abort, { once: true });
const timer = setTimeout(() => stop(new Error(`HyperFrames source decoder timed out after ${args.timeoutMs} ms`)), args.timeoutMs);
child.stderr!.on("data", (chunk: Buffer) => {
stderrBytes += chunk.byteLength;
stderr = `${stderr}${chunk.toString()}`.slice(-32_000);
if (stderrBytes > args.maxProcessOutputBytes) stop(new Error("HyperFrames source decoder output exceeded its byte limit"));
});
const outcome = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => {
child.once("error", reject);
child.once("close", (code, signal) => { closed = true; resolve({ code, signal }); });
});
const maxFrameBytes = args.metadata.width * args.metadata.height * 10 + 64 * 1024;
assert(Number.isSafeInteger(maxFrameBytes), "HyperFrames decoded source frame limit exceeds safe arithmetic");
const parser = new PngFrameParser(maxFrameBytes);
let produced = 0;
let completed = false;
try {
for await (const chunk of child.stdout!) {
for (const png of parser.push(chunk)) {
assert(produced < count, "FFmpeg source decoder emitted too many frames");
const frame = args.window.startFrame + produced++;
const path = join(args.outputDir, `${args.outputPrefix}-${frame}.png`);
await writeFile(path, png);
yield { frame, path, bytes: png.byteLength };
}
}
parser.finish();
const result = await outcome;
if (stopped !== undefined) throw stopped;
args.signal?.throwIfAborted();
assert(result.code === 0, `FFmpeg source decoder exited ${result.signal ?? `code ${String(result.code)}`}: ${stderr}`);
assert(produced === count, `HyperFrames expected ${count} source frames, decoded ${produced}`);
completed = true;
} finally {
clearTimeout(timer);
args.signal?.removeEventListener("abort", abort);
if (!completed && !closed) child.kill("SIGKILL");
await outcome.catch(() => {});
}
}
@@ -0,0 +1,326 @@
import { assert } from "./process.js";
import { WeightedCapacity } from "./weighted-capacity.js";
import type { CapacityLease } from "./weighted-capacity.js";
// Bound speculative work per process without making decoder tuning a public
// rendering concept. Actual byte admission can stop the stream earlier.
const defaultDecodeWindowFrames = 16;
export type SourceFrameWindow = {
readonly source: string;
readonly startFrame: number;
readonly endFrameExclusive: number;
};
export type DecodedSourceFrame = {
readonly frame: number;
readonly path: string;
readonly bytes: number;
};
export type SourceFrameLease = {
readonly release: () => void;
};
type ResidentFrame = DecodedSourceFrame & { capacity: CapacityLease; leases: number; lastUse: number };
type FrameSets = Map<string, Set<number>>;
type Waiter = {
readonly required: FrameSets;
readonly preferred: FrameSets;
readonly resolve: (lease: SourceFrameLease) => void;
readonly reject: (error: unknown) => void;
};
/**
* Render-local decoded-source working set. Acquisitions register demand; one
* pump sees all waiting Workers, while numeric capacity owns resident bytes.
*/
export class SourceFrameStore {
readonly #maxDecodeFrames: number;
readonly #decode: (source: string, window: Omit<SourceFrameWindow, "source">) => AsyncIterable<DecodedSourceFrame>;
readonly #remove: (path: string) => Promise<void>;
readonly #resident = new Map<string, Map<number, ResidentFrame>>();
readonly #waiters: Waiter[] = [];
readonly #activePreferred = new Set<FrameSets>();
readonly #changes = new Set<() => void>();
readonly #bytes: WeightedCapacity;
readonly #signal: AbortSignal | undefined;
readonly #abort: () => void;
#pump: Promise<void> | undefined;
#decodedFrames = 0;
#decodedBytes = 0;
#largestDecodedFrameBytes = 0;
#activeLeases = 0;
#clock = 0;
#overflowOwner: Waiter | undefined;
#failure: unknown;
#closed = false;
constructor(args: {
readonly maxBytes: number;
readonly maxDecodeFrames?: number;
readonly decode: (source: string, window: Omit<SourceFrameWindow, "source">) => AsyncIterable<DecodedSourceFrame>;
readonly remove: (path: string) => Promise<void>;
readonly signal?: AbortSignal;
}) {
assert(args.maxDecodeFrames === undefined
|| Number.isSafeInteger(args.maxDecodeFrames) && args.maxDecodeFrames > 0,
"Decoded source window must contain a positive number of frames");
this.#bytes = new WeightedCapacity(args.maxBytes);
this.#maxDecodeFrames = args.maxDecodeFrames ?? defaultDecodeWindowFrames;
this.#decode = args.decode;
this.#remove = args.remove;
this.#signal = args.signal;
this.#abort = () => this.fail(args.signal?.reason ?? new Error("Decoded source frame store aborted"));
args.signal?.addEventListener("abort", this.#abort, { once: true });
if (args.signal?.aborted) this.#abort();
}
get residentBytes(): number { return this.#bytes.used; }
get peakResidentBytes(): number { return this.#bytes.peak; }
get decodedFrames(): number { return this.#decodedFrames; }
get decodedBytes(): number { return this.#decodedBytes; }
get largestDecodedFrameBytes(): number { return this.#largestDecodedFrameBytes; }
path(source: string, frame: number): string | undefined {
return this.#resident.get(source)?.get(frame)?.path;
}
/** Required frames remain through one screenshot; preferred frames guide and protect only soft prefetch. */
acquire(requiredWindows: readonly SourceFrameWindow[], preferredWindows: readonly SourceFrameWindow[] = []): Promise<SourceFrameLease> {
this.#throwIfStopped();
const required = frameSets(requiredWindows);
if (required.size === 0) return Promise.resolve({ release: () => {} });
const preferred = frameSets(preferredWindows);
return new Promise<SourceFrameLease>((resolve, reject) => {
this.#waiters.push({ required, preferred, resolve, reject });
this.#startPump();
});
}
fail(error: unknown): void {
if (this.#failure !== undefined || this.#closed) return;
this.#failure = error;
for (const waiter of this.#waiters.splice(0)) waiter.reject(error);
this.#notify();
}
async close(): Promise<void> {
if (this.#closed) return;
this.#closed = true;
this.#signal?.removeEventListener("abort", this.#abort);
const error = new Error("Decoded source frame store is closed");
for (const waiter of this.#waiters.splice(0)) waiter.reject(error);
this.#notify();
await this.#pump?.catch(() => {});
const frames = [...this.#resident.values()].flatMap(sourceFrames => [...sourceFrames.values()]);
this.#resident.clear();
this.#activePreferred.clear();
for (const frame of frames) frame.capacity.release();
await Promise.all(frames.map(frame => this.#remove(frame.path)));
}
#startPump(): void {
if (this.#pump !== undefined || this.#closed || this.#failure !== undefined) return;
const pump = Promise.resolve().then(() => this.#run()).catch((error) => { this.fail(error); }).finally(() => {
if (this.#pump === pump) this.#pump = undefined;
if (this.#waiters.length > 0) this.#startPump();
});
this.#pump = pump;
}
async #run(): Promise<void> {
while (true) {
this.#throwIfStopped();
this.#grantReady();
const target = this.#waiters[0];
if (target === undefined) return;
const job = this.#decodeWindow(target, this.#missing(target.required));
let expected = job.startFrame;
for await (const frame of this.#decode(job.source, {
startFrame: job.startFrame, endFrameExclusive: job.endFrameExclusive,
})) {
this.#decodedFrames++;
this.#decodedBytes += frame.bytes;
this.#largestDecodedFrameBytes = Math.max(this.#largestDecodedFrameBytes, frame.bytes);
let retained = false;
try {
this.#throwIfStopped();
assert(frame.frame === expected, `HyperFrames source decoder returned frame ${frame.frame}, expected ${expected}`);
expected++;
assert(Number.isSafeInteger(frame.bytes) && frame.bytes > 0, "HyperFrames decoded source frame is empty");
assert(!this.#resident.get(job.source)?.has(frame.frame),
`HyperFrames decoded source frame ${frame.frame} more than once concurrently`);
const hard = target.required.get(job.source)?.has(frame.frame) === true;
retained = await this.#admit(job.source, frame, this.#requiredDemand(target),
this.#preferredDemand(target), target, hard);
if (!retained) break;
this.#grantReady();
} finally {
if (!retained) await this.#remove(frame.path);
}
}
}
}
#decodeWindow(target: Waiter, missing: FrameSets): SourceFrameWindow {
const first = missing.entries().next().value as [string, Set<number>] | undefined;
assert(first !== undefined, "Decoded source waiter has no missing frame");
const [source, missingFrames] = first;
const startFrame = Math.min(...missingFrames);
const desired = new Set<number>(target.required.get(source));
for (const frame of target.preferred.get(source) ?? []) desired.add(frame);
for (const waiter of this.#waiters) {
for (const frame of waiter.required.get(source) ?? []) desired.add(frame);
for (const frame of waiter.preferred.get(source) ?? []) desired.add(frame);
}
for (const preferred of this.#activePreferred) {
for (const frame of preferred.get(source) ?? []) desired.add(frame);
}
let endFrameExclusive = startFrame + 1;
while (endFrameExclusive - startFrame < this.#maxDecodeFrames
&& desired.has(endFrameExclusive)
&& !this.#resident.get(source)?.has(endFrameExclusive)) endFrameExclusive++;
return { source, startFrame, endFrameExclusive };
}
async #admit(source: string, frame: DecodedSourceFrame, required: FrameSets,
preferred: FrameSets, target: Waiter, hard: boolean): Promise<boolean> {
while (true) {
await this.#evictFor(frame.bytes, required, hard ? undefined : preferred);
let capacity = this.#bytes.tryAcquire(frame.bytes);
if (capacity === undefined) {
if (!hard) return false;
if (this.#activeLeases === 0 && (this.#overflowOwner === undefined || this.#overflowOwner === target)) {
await this.#evictFor(frame.bytes, target.required);
capacity = this.#bytes.tryAcquire(frame.bytes);
if (capacity === undefined) {
this.#overflowOwner = target;
capacity = this.#bytes.tryAcquire(frame.bytes, true);
}
} else {
await this.#changed();
this.#throwIfStopped();
continue;
}
}
assert(capacity !== undefined, "Decoded source capacity was not acquired");
let resident = this.#resident.get(source);
if (resident === undefined) { resident = new Map(); this.#resident.set(source, resident); }
resident.set(frame.frame, { ...frame, capacity, leases: 0, lastUse: ++this.#clock });
return true;
}
}
#grantReady(): void {
for (let index = 0; index < this.#waiters.length;) {
const waiter = this.#waiters[index]!;
if (!this.#contains(waiter.required)) { index++; continue; }
this.#waiters.splice(index, 1);
const leased: ResidentFrame[] = [];
for (const [source, frames] of waiter.required) for (const frame of frames) {
const resident = this.#resident.get(source)?.get(frame);
assert(resident !== undefined, `HyperFrames has no decoded source frame ${frame}`);
resident.leases++;
resident.lastUse = ++this.#clock;
leased.push(resident);
}
this.#activeLeases++;
this.#activePreferred.add(waiter.preferred);
let released = false;
waiter.resolve({ release: () => {
if (released) return;
released = true;
for (const frame of leased) {
assert(frame.leases > 0, "HyperFrames source frame lease underflow");
frame.leases--;
}
this.#activeLeases--;
this.#activePreferred.delete(waiter.preferred);
if (this.#overflowOwner === waiter) this.#overflowOwner = undefined;
this.#notify();
} });
}
}
#contains(frames: FrameSets): boolean {
for (const [source, requested] of frames) for (const frame of requested) {
if (!this.#resident.get(source)?.has(frame)) return false;
}
return true;
}
#missing(frames: FrameSets): FrameSets {
const missing = new Map<string, Set<number>>();
for (const [source, requested] of frames) for (const frame of requested) {
if (this.#resident.get(source)?.has(frame)) continue;
let values = missing.get(source);
if (values === undefined) { values = new Set(); missing.set(source, values); }
values.add(frame);
}
return missing;
}
#requiredDemand(target: Waiter): FrameSets {
return mergeFrameSets(target.required, ...this.#waiters.map(waiter => waiter.required));
}
#preferredDemand(target: Waiter): FrameSets {
return mergeFrameSets(target.preferred, ...this.#waiters.map(waiter => waiter.preferred), ...this.#activePreferred);
}
async #evictFor(incomingBytes: number, required: FrameSets, preferred?: FrameSets): Promise<void> {
const candidates = [...this.#resident.entries()].flatMap(([source, frames]) => [...frames.entries()]
.filter(([frame, resident]) => resident.leases === 0
&& !required.get(source)?.has(frame) && !preferred?.get(source)?.has(frame))
.map(([frame, resident]) => ({ source, frame, resident })))
.sort((left, right) => left.resident.lastUse - right.resident.lastUse);
const removed: ResidentFrame[] = [];
for (const candidate of candidates) {
if (this.#bytes.fits(incomingBytes)) break;
this.#resident.get(candidate.source)!.delete(candidate.frame);
candidate.resident.capacity.release();
removed.push(candidate.resident);
}
if (removed.length > 0) this.#notify();
await Promise.all(removed.map(frame => this.#remove(frame.path)));
}
#changed(): Promise<void> {
return new Promise(resolve => this.#changes.add(resolve));
}
#notify(): void {
const changes = [...this.#changes];
this.#changes.clear();
for (const change of changes) change();
}
#throwIfStopped(): void {
if (this.#failure !== undefined) throw this.#failure;
assert(!this.#closed, "Decoded source frame store is closed");
}
}
function frameSets(windows: readonly SourceFrameWindow[]): FrameSets {
const result = new Map<string, Set<number>>();
for (const window of windows) {
assert(window.source.length > 0 && Number.isSafeInteger(window.startFrame) && window.startFrame >= 0
&& Number.isSafeInteger(window.endFrameExclusive) && window.endFrameExclusive > window.startFrame,
"Decoded source frame window is invalid");
let frames = result.get(window.source);
if (frames === undefined) { frames = new Set(); result.set(window.source, frames); }
for (let frame = window.startFrame; frame < window.endFrameExclusive; frame++) frames.add(frame);
}
return result;
}
function mergeFrameSets(...inputs: readonly FrameSets[]): FrameSets {
const result: FrameSets = new Map();
for (const input of inputs) for (const [source, inputFrames] of input) {
let frames = result.get(source);
if (frames === undefined) { frames = new Set(); result.set(source, frames); }
for (const frame of inputFrames) frames.add(frame);
}
return result;
}
@@ -0,0 +1,50 @@
import { assert } from "./process.js";
export type CapacityLease = {
readonly units: number;
readonly release: () => void;
};
/** Attempt-local weighted capacity. It knows only integer units and lease lifetime. */
export class WeightedCapacity {
readonly #limit: number;
#used = 0;
#peak = 0;
constructor(limit: number) {
assert(Number.isSafeInteger(limit) && limit > 0, "Capacity limit must be a positive integer");
this.#limit = limit;
}
get limit(): number { return this.#limit; }
get used(): number { return this.#used; }
get peak(): number { return this.#peak; }
fits(units: number): boolean {
this.#assertUnits(units);
return this.#used + units <= this.#limit;
}
tryAcquire(units: number, allowOverflow = false): CapacityLease | undefined {
this.#assertUnits(units);
if (!allowOverflow && !this.fits(units)) return undefined;
return this.#lease(units);
}
#lease(units: number): CapacityLease {
this.#used += units;
this.#peak = Math.max(this.#peak, this.#used);
let released = false;
return { units, release: () => {
if (released) return;
released = true;
assert(this.#used >= units, "Capacity lease underflow");
this.#used -= units;
} };
}
#assertUnits(units: number): void {
assert(Number.isSafeInteger(units) && units > 0, "Capacity units must be a positive integer");
assert(Number.isSafeInteger(this.#used + units), "Capacity usage exceeds safe arithmetic");
}
}
@@ -0,0 +1,44 @@
import assert from "node:assert/strict";
import test from "node:test";
import { FrameSpanIndex } from "../src/frame-span-index.js";
test("frame span lookup is absolute, half-open and preserves source order", () => {
const values = [
{ startFrame: 5, endFrameExclusive: 10, id: "late" },
{ startFrame: 0, endFrameExclusive: 5, id: "early" },
{ startFrame: 3, endFrameExclusive: 8, id: "overlap-a" },
{ startFrame: 3, endFrameExclusive: 8, id: "overlap-b" },
{ startFrame: 100, endFrameExclusive: 102, id: "distant" },
];
const index = new FrameSpanIndex(values);
const ids = (frame: number) => index.at(frame).map((value) => value.id);
assert.deepEqual(ids(0), ["early"]);
assert.deepEqual(ids(5), ["late", "overlap-a", "overlap-b"]);
assert.deepEqual(ids(8), ["late"]);
assert.deepEqual(ids(102), []);
// Queries have no cursor: reverse and distant access return the same facts.
assert.deepEqual(ids(101), ["distant"]);
assert.deepEqual(ids(3), ["early", "overlap-a", "overlap-b"]);
assert.deepEqual(ids(101), ["distant"]);
});
test("range lookup returns only intersecting half-open spans", () => {
const index = new FrameSpanIndex([
{ startFrame: 0, endFrameExclusive: 3, id: "before" },
{ startFrame: 3, endFrameExclusive: 8, id: "first" },
{ startFrame: 6, endFrameExclusive: 9, id: "second" },
{ startFrame: 9, endFrameExclusive: 12, id: "after" },
]);
assert.deepEqual(index.overlapping({ startFrame: 3, endFrameExclusive: 9 }).map((value) => value.id),
["first", "second"]);
assert.deepEqual(index.overlapping({ startFrame: 8, endFrameExclusive: 9 }).map((value) => value.id),
["second"]);
});
test("frame span lookup rejects invalid entries and queries", () => {
assert.throws(() => new FrameSpanIndex([{ startFrame: 2, endFrameExclusive: 2 }]), /entry is invalid/u);
const index = new FrameSpanIndex([{ startFrame: 0, endFrameExclusive: 1 }]);
assert.throws(() => index.at(-1), /query is invalid/u);
assert.throws(() => index.overlapping({ startFrame: 1, endFrameExclusive: 1 }), /entry is invalid/u);
});
@@ -0,0 +1,61 @@
import assert from "node:assert/strict";
import { setImmediate as turn } from "node:timers/promises";
import test from "node:test";
import { OrderedFrameSink } from "../src/ordered-frame-sink.js";
const bytes = (value: number, length = 1) => new Uint8Array(length).fill(value);
test("ordered sink lets the missing frontier pass a full future-frame budget", async () => {
const written: number[] = [];
let closed = false;
const sink = new OrderedFrameSink({ frameCount: 5, maxPendingBytes: 2,
writer: {
write: async (frame) => { written.push(frame[0]!); },
close: async () => { closed = true; },
},
});
await Promise.all([sink.submit(2, bytes(2)), sink.submit(3, bytes(3))]);
let admitted = false;
const later = sink.submit(4, bytes(4)).then(() => { admitted = true; });
await turn();
assert.equal(admitted, false, "a future frame must stop when the byte budget is full");
await sink.submit(0, bytes(0));
assert.deepEqual(written, [0], "the required frontier must pass even while future frames fill the budget");
await sink.submit(1, bytes(1));
await later;
await sink.close();
assert.deepEqual(written, [0, 1, 2, 3, 4]);
assert.equal(closed, true);
});
test("ordered sink accepts one oversized future frame without disabling its byte bound", async () => {
const written: number[] = [];
const sink = new OrderedFrameSink({ frameCount: 3, maxPendingBytes: 2,
writer: { write: async (frame) => { written.push(frame[0]!); }, close: async () => {} },
});
await sink.submit(1, bytes(1, 8));
let admitted = false;
const last = sink.submit(2, bytes(2)).then(() => { admitted = true; });
await turn();
assert.equal(admitted, false, "only one oversized future frame may occupy an empty waiting set");
await sink.submit(0, bytes(0));
await last;
await sink.close();
assert.deepEqual(written, [0, 1, 2]);
});
test("ordered sink rejects every blocked producer after its writer fails", async () => {
const failure = new Error("encoder stopped");
const sink = new OrderedFrameSink({ frameCount: 4, maxPendingBytes: 1,
writer: { write: async () => { throw failure; }, close: async () => {} },
});
await sink.submit(2, bytes(2));
const blocked = sink.submit(3, bytes(3));
await assert.rejects(sink.submit(0, bytes(0)), /encoder stopped/u);
await assert.rejects(blocked, /encoder stopped/u);
});
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import { processEnvironment, runProcess } from "../src/process.js";
import { openProcessInput, processEnvironment, runProcess } from "../src/process.js";
test("Windows HyperFrames children receive TEMP and process-creation variables, not Host secrets", () => {
const previous = process.env.HYPIT_HF_ENV_PROBE;
@@ -45,3 +45,26 @@ test("POSIX HyperFrames children receive TMPDIR without Host secrets", async ()
else process.env.HYPIT_HF_ENV_PROBE = previous;
}
});
test("an input process consumes bounded incremental writes and settles after EOF", async () => {
const child = openProcessInput({
executable: process.execPath,
argv: ["-e", "const chunks=[]; process.stdin.on('data', chunk => chunks.push(chunk)); process.stdin.on('end', () => process.stdout.write(Buffer.concat(chunks)))"],
timeoutMs: 5_000,
maxOutputBytes: 4_096,
});
await child.write(Buffer.from("ordered "));
await child.write(Buffer.from("input"));
const result = await child.close();
assert.equal(Buffer.from(result.stdout).toString(), "ordered input");
});
test("an input process reports encoder exit instead of leaving writers pending", async () => {
const child = openProcessInput({
executable: process.execPath,
argv: ["-e", "process.stderr.write('encoder failure'); process.exit(7)"],
timeoutMs: 5_000,
maxOutputBytes: 4_096,
});
await assert.rejects(child.completed, /exited 7: encoder failure/u);
});
@@ -22,7 +22,7 @@ import { renderHyperframesCapabilities } from "@hypit/render-hyperframes";
import { mediaTypes } from "@hypit/media";
import { canonicalize } from "@hypit/protocol";
import type { HyperframesRenderProgress } from "../src/index.js";
import { distributeFrameRange, sourceFrameAt, sourceWindows, videoSlots } from "../src/sampling.js";
import { distributeFrameRange, requestedFrameRanges, sourceFrameAt, sourceWindows, videoSlots } from "../src/sampling.js";
function documentFor(artifact: BlobRef) {
const space = sealProgramSpace({ id: "range-space", durationSec: 12 / 30, frameRate: { numerator: 30, denominator: 1 } });
@@ -67,15 +67,31 @@ test("the render deadline is global while stage deadlines are explicit deploymen
assert.equal(defaults.processTimeoutMs, 30 * 60_000);
assert.equal(defaults.initializationTimeoutMs, undefined);
assert.equal(defaults.frameTimeoutMs, undefined);
assert.equal(defaults.artifactStagingConcurrency, 4);
assert.equal(defaults.maxPendingFrameBytes, 256 * 1024 * 1024);
assert.equal(defaults.maxDecodedSourceBytes, 1024 * 1024 * 1024);
const explicit = resolveExecutionOptions({ initializationTimeoutMs: 31_000, frameTimeoutMs: 16_000 });
const explicit = resolveExecutionOptions({ initializationTimeoutMs: 31_000, frameTimeoutMs: 16_000,
artifactStagingConcurrency: 3, maxPendingFrameBytes: 12_345, maxDecodedSourceBytes: 54_321 });
assert.equal(explicit.initializationTimeoutMs, 31_000);
assert.equal(explicit.frameTimeoutMs, 16_000);
assert.equal(explicit.artifactStagingConcurrency, 3);
assert.equal(explicit.maxPendingFrameBytes, 12_345);
assert.equal(explicit.maxDecodedSourceBytes, 54_321);
});
test("source selection retains loop, hold and fractional-speed sampling and shares decoded frames", () => {
const document = documentFor({ kind: "blob", resource: "res_range_source", size: 1, mediaType: "video/mp4" });
const slots = videoSlots(document.html);
assert.ok(slots.every(slot => slot.src === "hypit-resource://res_range_source"));
assert.equal(slots.length, 4, "the two-frame hold remains one slot instead of one slot per target frame");
const hold = slots.find((slot) => slot.sourceRate.numerator === 0n);
assert.ok(hold !== undefined);
assert.deepEqual({ startFrame: hold.startFrame, endFrameExclusive: hold.endFrameExclusive,
sourceFrame: hold.sourceFrame, sourceRate: hold.sourceRate }, {
startFrame: 6, endFrameExclusive: 8,
sourceFrame: { numerator: 3n, denominator: 1n }, sourceRate: { numerator: 0n, denominator: 1n },
});
const range = { startFrame: 3, endFrameExclusive: 11 };
const sampled = Array.from({ length: 8 }, (_, i) => {
const frame = range.startFrame + i;
@@ -89,6 +105,30 @@ test("source selection retains loop, hold and fractional-speed sampling and shar
]);
});
test("video slot inspection accepts direct HTML sources without requiring compiler-owned activation", () => {
const [slot] = videoSlots('<video id="direct" src="direct.mp4" data-hypit-start-frame="2" data-hypit-end-frame="4" data-hypit-source-frame="1/1" data-hypit-source-rate="1/1" data-hypit-source-fps="30/1"></video>');
assert.deepEqual(slot, {
id: "direct",
src: "direct.mp4",
startFrame: 2,
endFrameExclusive: 4,
sourceFrame: { numerator: 1n, denominator: 1n },
sourceRate: { numerator: 1n, denominator: 1n },
sourceFps: { num: 30, den: 1 },
});
});
test("page selection compacts arbitrary requested frames without inventing coverage across gaps", () => {
assert.deepEqual(requestedFrameRanges({ startFrame: 3, endFrameExclusive: 12 }), [
{ startFrame: 3, endFrameExclusive: 12 },
]);
assert.deepEqual(requestedFrameRanges({ startFrame: 3, endFrameExclusive: 12 }, [3, 4, 7, 8, 9, 11]), [
{ startFrame: 3, endFrameExclusive: 5 },
{ startFrame: 7, endFrameExclusive: 10 },
{ startFrame: 11, endFrameExclusive: 12 },
]);
});
test("real selected renders sample video correctly across loop, hold and stretch with independent browsers", {
skip: process.env.HYPIT_BROWSER_TESTS !== "1",
}, async (t) => {
@@ -115,7 +155,9 @@ test("real selected renders sample video correctly across loop, hold and stretch
workers: number) => {
const warnings: string[] = [];
const visual = await renderHyperframesVisual({ document, ...(range === undefined ? {} : { range }) },
{ resources, workers, quality: "high", processTimeoutMs: 120000, onProgress: (e) => events.push(e),
{ resources, workers, quality: "high", processTimeoutMs: 120000,
maxPendingFrameBytes: 1, maxDecodedSourceBytes: 1,
onProgress: (e) => events.push(e),
onDiagnostic: async (event) => { if (event.level === "warning") warnings.push(event.message); } });
assert.deepEqual(warnings, [], "a completed render should close its resources and exit without forced cleanup");
const file = join(root, `${name}.mp4`);
@@ -0,0 +1,25 @@
import assert from "node:assert/strict";
import test from "node:test";
import sharp from "sharp";
import { PngFrameParser } from "../src/source-frame-decoder.js";
test("image2pipe parser yields complete PNGs across arbitrary stream chunks", async () => {
const first = await sharp({ create: { width: 3, height: 2, channels: 4, background: "#ff0000ff" } }).png().toBuffer();
const second = await sharp({ create: { width: 2, height: 3, channels: 4, background: "#00ff00ff" } }).png().toBuffer();
const bytes = Buffer.concat([first, second]);
const parser = new PngFrameParser(Math.max(first.byteLength, second.byteLength));
const frames: Buffer[] = [];
for (let offset = 0; offset < bytes.byteLength;) {
const end = Math.min(bytes.byteLength, offset + (offset % 17) + 1);
frames.push(...parser.push(bytes.subarray(offset, end)));
offset = end;
}
parser.finish();
assert.deepEqual(frames, [first, second]);
});
test("image2pipe parser bounds an incomplete or oversized PNG", async () => {
const png = await sharp({ create: { width: 3, height: 3, channels: 4, background: "#0000ffff" } }).png().toBuffer();
const parser = new PngFrameParser(png.byteLength - 1);
assert.throws(() => parser.push(png), /exceeds its frame byte limit/u);
});
@@ -0,0 +1,103 @@
import assert from "node:assert/strict";
import { setImmediate as turn } from "node:timers/promises";
import test from "node:test";
import { SourceFrameStore } from "../src/source-frame-store.js";
const window = (startFrame: number, endFrameExclusive: number) => ({ source: "video", startFrame, endFrameExclusive });
test("source frame store leases exact capture inputs and reuses bounded batch prefetch", async () => {
const decodes: Array<{ startFrame: number; endFrameExclusive: number }> = [];
const removed: string[] = [];
const store = new SourceFrameStore({ maxBytes: 4,
decode: async function* (_source, span) {
decodes.push(span);
for (let frame = span.startFrame; frame < span.endFrameExclusive; frame++) {
yield { frame, path: `/video/${frame}.png`, bytes: 1 };
}
},
remove: async path => { removed.push(path); },
});
const first = await store.acquire([window(0, 1)], [window(0, 4)]);
while (store.residentBytes < 4) await turn();
assert.deepEqual(decodes, [{ startFrame: 0, endFrameExclusive: 4 }]);
assert.equal(store.residentBytes, 4);
assert.equal(store.decodedBytes, 4);
assert.equal(store.largestDecodedFrameBytes, 1);
first.release();
const reused = await store.acquire([window(2, 3)]);
reused.release();
assert.equal(decodes.length, 1, "a prefetched frame must not start another decoder");
const next = await store.acquire([window(4, 5)]);
next.release();
assert.equal(decodes.length, 2);
assert.equal(store.residentBytes, 4);
assert.equal(removed.length, 1, "only enough least-recently-used data is evicted to admit the required frame");
await store.close();
});
test("source frame store waits for live screenshots and lets one oversized requirement proceed alone", async () => {
const decodes: number[][] = [];
const store = new SourceFrameStore({ maxBytes: 2,
decode: async function* (_source, span) {
const frames = Array.from({ length: span.endFrameExclusive - span.startFrame },
(_, offset) => span.startFrame + offset);
decodes.push(frames);
for (const frame of frames) yield { frame, path: `/video/${frame}.png`, bytes: 1 };
},
remove: async () => {},
});
const active = await store.acquire([window(0, 2)]);
let admitted = false;
const waiting = store.acquire([window(2, 3)]).then(lease => { admitted = true; return lease; });
await turn();
assert.equal(admitted, false);
active.release();
const next = await waiting;
next.release();
const oversized = await store.acquire([window(3, 6)]);
assert.equal(store.residentBytes, 3);
assert.equal(store.peakResidentBytes, 3);
oversized.release();
assert.deepEqual(decodes, [[0, 1], [2], [3, 4, 5]]);
await store.close();
});
test("source frame store does not replace another live screenshot's remaining prefetch with soft data", async () => {
const store = new SourceFrameStore({ maxBytes: 3,
decode: async function* (_source, span) {
for (let frame = span.startFrame; frame < span.endFrameExclusive; frame++) {
yield { frame, path: `/video/${frame}.png`, bytes: 1 };
}
},
remove: async () => {},
});
const first = await store.acquire([window(0, 1)], [window(0, 3)]);
while (store.path("video", 2) === undefined) await turn();
const second = await store.acquire([window(10, 11)], [window(10, 13)]);
while (store.decodedFrames < 5) await turn();
assert.notEqual(store.path("video", 2), undefined,
"soft decoding must not evict an active Worker's still-relevant prefetch");
assert.equal(store.path("video", 11), undefined,
"soft decoding stops when only another active preference could make room");
first.release();
second.release();
await store.close();
});
test("source frame store propagates one decoder failure to queued acquisitions", async () => {
const store = new SourceFrameStore({ maxBytes: 2,
decode: async function* () { throw new Error("source decoder failed"); }, remove: async () => {},
});
const first = store.acquire([window(0, 1)]);
const second = store.acquire([window(1, 2)]);
await assert.rejects(first, /source decoder failed/u);
await assert.rejects(second, /source decoder failed/u);
await store.close();
});
@@ -4,22 +4,25 @@ import test from "node:test";
import puppeteer from "puppeteer-core";
import sharp from "sharp";
import type { CaptureSession } from "@hyperframes/engine";
import { MemoryResourceStore } from "@hypit/driver-node";
import { sealComposition, sealVisualTrack } from "@hypit/composition";
import type { VisualElement, VisualTextTypography } from "@hypit/composition";
import { compileHyperframesDocument, materializeHyperframesHtml } from "@hypit/hyperframes";
import { sealProgramSpace } from "@hypit/program-space";
import { browserExecutablePath } from "../src/browser.js";
import { createOpaqueFrameCapture } from "../src/opaque-capture.js";
import { renderHyperframesFrames } from "../src/render.js";
const live = { skip: process.env.HYPIT_BROWSER_TESTS !== "1" };
test("capture waits for dynamic images and rejects broken images or exact fonts", live, async (t) => {
const png = await sharp({ create: { width: 64, height: 64, channels: 3, background: "#ff0000" } }).png().toBuffer();
const server = createServer((_request, response) => {
const blue = await sharp({ create: { width: 64, height: 64, channels: 3, background: "#0000ff" } }).png().toBuffer();
const server = createServer((request, response) => {
setTimeout(() => {
response.setHeader("Content-Type", "image/png");
response.setHeader("Access-Control-Allow-Origin", "*");
response.end(png);
response.end(request.url?.includes("blue") ? blue : png);
}, 300);
});
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
@@ -60,13 +63,156 @@ test("capture waits for dynamic images and rejects broken images or exact fonts"
}
} finally { await page.close(); }
});
await t.test("stable pages are discovered once and later inline resource changes stay local", async () => {
const page = await browser.newPage();
try {
await page.setViewport({ width: 64, height: 64 });
await page.setContent(`<style>body{margin:0}</style><div id="target" style="width:64px;height:64px;background:#000"></div>${"<span></span>".repeat(200)}`);
await page.evaluate(origin => {
const original = window.getComputedStyle.bind(window);
const state = window as unknown as { __styleReads: number; __hf: unknown };
state.__styleReads = 0;
Object.defineProperty(window, "getComputedStyle", { configurable: true, value(...args: Parameters<typeof getComputedStyle>) {
state.__styleReads += 1;
return original(...args);
} });
state.__hf = { async seek(time: number) {
if (time >= 1 / 30) document.getElementById("target")!.style.opacity = "0.999";
if (time >= 2 / 30) document.getElementById("target")!.style.backgroundImage = `url(${origin}/local.png)`;
} };
}, origin);
const capture = await createOpaqueFrameCapture({ page,
options: { fps: { num: 30, den: 1 }, width: 64, height: 64 } } as unknown as CaptureSession);
await capture(0);
const initialReads = await page.evaluate(() => (window as unknown as { __styleReads: number }).__styleReads);
assert.ok(initialReads >= 600, "the initial pass should discover the complete existing document");
await capture(1);
assert.equal(await page.evaluate(() => (window as unknown as { __styleReads: number }).__styleReads), initialReads,
"an unchanged frame must not rescan computed styles");
const frame = await capture(2);
const finalReads = await page.evaluate(() => (window as unknown as { __styleReads: number }).__styleReads);
assert.ok(finalReads - initialReads <= 6, `one changed element unexpectedly caused ${finalReads - initialReads} style reads`);
const { data, info } = await sharp(frame.buffer).raw().toBuffer({ resolveWithObject: true });
const center = (32 * info.width + 32) * info.channels;
assert.deepEqual([...data.subarray(center, center + 3)], [255, 0, 0]);
} finally { await page.close(); }
});
await t.test("a page-owned capture scope limits initial opaque resource discovery", async () => {
const page = await browser.newPage();
try {
await page.setViewport({ width: 64, height: 64 });
await page.setContent(`<style>body{margin:0}</style><div id="selected" style="width:64px;height:64px;background:#000"></div><div id="unselected">${"<span></span>".repeat(200)}</div>`);
await page.evaluate(origin => {
const original = window.getComputedStyle.bind(window);
const state = window as unknown as { __styleReads: number; __hf: unknown; __hypitCaptureRoots: readonly Element[] };
state.__styleReads = 0;
Object.defineProperty(window, "getComputedStyle", { configurable: true, value(...args: Parameters<typeof getComputedStyle>) {
state.__styleReads += 1;
return original(...args);
} });
const selected = document.getElementById("selected")!;
state.__hypitCaptureRoots = [selected];
state.__hf = { async seek(time: number) {
if (time >= 1 / 30) selected.style.backgroundImage = `url(${origin}/scoped.png)`;
} };
}, origin);
const capture = await createOpaqueFrameCapture({ page,
options: { fps: { num: 30, den: 1 }, width: 64, height: 64 } } as unknown as CaptureSession);
await capture(0);
const initialReads = await page.evaluate(() => (window as unknown as { __styleReads: number }).__styleReads);
assert.ok(initialReads <= 6, `one capture root unexpectedly caused ${initialReads} style reads`);
const frame = await capture(1);
const { data, info } = await sharp(frame.buffer).raw().toBuffer({ resolveWithObject: true });
const center = (32 * info.width + 32) * info.channels;
assert.deepEqual([...data.subarray(center, center + 3)], [255, 0, 0]);
} finally { await page.close(); }
});
await t.test("native image-style animations remain frame-active without global discovery", async () => {
const page = await browser.newPage();
try {
await page.setViewport({ width: 64, height: 64 });
await page.setContent('<style>body{margin:0}</style><div id="target" style="width:64px;height:64px"></div>');
await page.evaluate(origin => {
const target = document.getElementById("target")!;
const animation = target.animate([
{ backgroundImage: `url(${origin}/red.png)` },
{ backgroundImage: `url(${origin}/blue.png)` },
], { duration: 1_000, fill: "both" });
animation.pause();
(window as unknown as { __hf: unknown }).__hf = { async seek(time: number) { animation.currentTime = time * 1_000; } };
}, origin);
const capture = await createOpaqueFrameCapture({ page,
options: { fps: { num: 30, den: 1 }, width: 64, height: 64 } } as unknown as CaptureSession);
const first = await capture(0);
const last = await capture(30);
for (const [frame, expected] of [[first, [255, 0, 0]], [last, [0, 0, 255]]] as const) {
const { data, info } = await sharp(frame.buffer).raw().toBuffer({ resolveWithObject: true });
const center = (32 * info.width + 32) * info.channels;
assert.deepEqual([...data.subarray(center, center + 3)], expected);
}
} finally { await page.close(); }
});
await t.test("an opaque CSSOM edit takes the explicit whole-page fallback", async () => {
const page = await browser.newPage();
try {
await page.setViewport({ width: 64, height: 64 });
await page.setContent('<style>body{margin:0}</style><div id="target" style="width:64px;height:64px"></div>');
await page.evaluate(origin => {
let changed = false;
(window as unknown as { __hf: unknown }).__hf = { async seek(time: number) {
if (!changed && time >= 1 / 30) {
changed = true;
document.styleSheets[0]!.insertRule(`#target{background-image:url(${origin}/cssom.png)}`);
}
} };
}, origin);
const capture = await createOpaqueFrameCapture({ page,
options: { fps: { num: 30, den: 1 }, width: 64, height: 64 } } as unknown as CaptureSession);
await capture(0);
const frame = await capture(1);
const { data, info } = await sharp(frame.buffer).raw().toBuffer({ resolveWithObject: true });
const center = (32 * info.width + 32) * info.channels;
assert.deepEqual([...data.subarray(center, center + 3)], [255, 0, 0]);
} finally { await page.close(); }
});
} finally {
await browser.close();
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
}
});
test("terminal text and SVG mask sources retain their Present frame animations on direct seeks", live, async () => {
test("compiled frame selection neither stages nor waits for unrelated image resources", live, async () => {
const resources = new MemoryResourceStore();
const unavailable = { kind: "blob" as const, resource: "res_unavailable-unselected-image" as const,
size: 123, mediaType: "image/png" };
const broken = await resources.put(new TextEncoder().encode("not an image"), "image/png");
const red = await resources.put(await sharp({ create: { width: 64, height: 64, channels: 3, background: "#ff0000" } })
.png().toBuffer(), "image/png");
const space = sealProgramSpace({ id: "capture-scope", durationSec: 2, frameRate: { numerator: 30, denominator: 1 } });
const track = sealVisualTrack({ id: "visual", programSpaceId: space.id, visualIr: "hypit.visual-ir@1", presents: [
{ id: "unavailable-early", span: { startFrame: 0, endFrameExclusive: 10 }, stacking: { order: 0, tieBreak: "unavailable" },
elements: [{ id: "unavailable", order: 0, kind: "image", artifact: unavailable,
style: [{ name: "width", value: "64px" }, { name: "height", value: "64px" }] }] },
{ id: "broken-early", span: { startFrame: 10, endFrameExclusive: 30 }, stacking: { order: 1, tieBreak: "early" },
elements: [{ id: "broken", order: 0, kind: "image", artifact: broken,
style: [{ name: "width", value: "64px" }, { name: "height", value: "64px" }] }] },
{ id: "red-late", span: { startFrame: 30, endFrameExclusive: 60 }, stacking: { order: 2, tieBreak: "late" },
elements: [{ id: "red", order: 0, kind: "image", artifact: red,
style: [{ name: "width", value: "64px" }, { name: "height", value: "64px" }] }] },
] });
const document = compileHyperframesDocument(sealComposition({ id: "capture-scope",
canvas: { width: 64, height: 64, clearColor: "#000000" }, tracks: [track] }), space);
const [artifact] = await renderHyperframesFrames({ document, frames: [45] },
{ resources, workers: 1, processTimeoutMs: 120_000 });
const pixels = await sharp((await resources.get(artifact!.resource))!).removeAlpha().raw().toBuffer();
const center = (32 * 64 + 32) * 3;
assert.deepEqual([...pixels.subarray(center, center + 3)], [255, 0, 0]);
await assert.rejects(renderHyperframesFrames({ document, frames: [15] },
{ resources, workers: 1, processTimeoutMs: 120_000 }), /image could not be decoded/u);
});
test("terminal text and SVG mask sources retain their Present frame animations across direct seeks and fresh workers", live, async () => {
const space = sealProgramSpace({ id: "animations", durationSec: 2, frameRate: { numerator: 30, denominator: 1 } });
const image = { kind: "blob", resource: "res_mask", size: 1, mediaType: "image/png" } as const;
const font = { sources: [{ artifact: { kind: "blob", resource: "res_font", size: 1, mediaType: "font/woff2" } }],
@@ -78,8 +224,16 @@ test("terminal text and SVG mask sources retain their Present frame animations o
cjk: { textSpacing: "normal", punctuationTrim: "none" } };
const animation = { keyframes: [{ atFrame: 0, style: [{ name: "opacity", value: 0 }] },
{ atFrame: 30, style: [{ name: "opacity", value: 1 }] }] };
const sequences = [{ id: "letters-in", unit: "grapheme" as const, range: { start: 0, endExclusive: 1 }, order: "forward" as const,
startFrame: 0, unitDurationFrames: 30, staggerFrames: 0, cycles: 1,
keyframes: [{ atProgress: 0, style: [{ name: "opacity", value: 0 }] },
{ atProgress: 1, style: [{ name: "opacity", value: 1 }] }] },
{ id: "letters-out", unit: "grapheme" as const, range: { start: 0, endExclusive: 1 }, order: "forward" as const,
startFrame: 0, unitDurationFrames: 30, staggerFrames: 0, cycles: 1,
keyframes: [{ atProgress: 0, style: [{ name: "opacity", value: 1 }] },
{ atProgress: 1, style: [{ name: "opacity", value: 0 }] }] }];
const text = { style: [], animation, document: { paragraphs: [{ id: "p", inlines: [{ kind: "text" as const, id: "t", text: "A" }] }] },
typography, paints: [{ kind: "fill" as const, paint: { kind: "solid" as const, color: "#ffffff" } }], sequences: [] };
typography, paints: [{ kind: "fill" as const, paint: { kind: "solid" as const, color: "#ffffff" } }], sequences };
const elements: VisualElement[] = [
{ id: "root", kind: "box", order: 0, style: [] },
{ ...text, id: "flow", parent: "root", order: 1, kind: "text-flow", flow: {
@@ -97,19 +251,44 @@ test("terminal text and SVG mask sources retain their Present frame animations o
const track = sealVisualTrack({ id: "visual", programSpaceId: space.id, visualIr: "hypit.visual-ir@1",
presents: [{ id: "later", span: { startFrame: 15, endFrameExclusive: 60 }, stacking: { order: 0, tieBreak: "later" }, elements }] });
const compiled = compileHyperframesDocument(sealComposition({ id: "animation", canvas: { width: 128, height: 128, clearColor: "#000000" }, tracks: [track] }), space);
const html = materializeHyperframesHtml(compiled, artifact => artifact.resource === "res_font"
? "data:font/woff2;base64,AA==" : "data:image/png;base64,AA==");
const browser = await puppeteer.launch({ executablePath: browserExecutablePath({}), headless: true, args: ["--no-sandbox"] });
try {
const page = await browser.newPage();
// Glyph appearance is irrelevant to this element-opacity test; the tiny invalid font
// settles immediately. Font loading and painted text have separate integration coverage.
await page.setContent(materializeHyperframesHtml(compiled, artifact => artifact.resource === "res_font"
? "data:font/woff2;base64,AA==" : "data:image/png;base64,AA=="));
await page.setContent(html);
const valuesAt = async (target: typeof page, frame: number) => await target.evaluate(async frame => {
const waits: Promise<void>[] = [];
window.dispatchEvent(new CustomEvent("hf-seek", { detail: {
time: frame / 30,
waitUntil(value: Promise<void>) { waits.push(value); },
} }));
await Promise.all(waits);
const timelines = (window as typeof window & { __timelines?: Record<string, { totalTime(time: number): void }> }).__timelines;
timelines?.animation?.totalTime(frame / 30);
const roots = ["flow", "path", "mask-image"].map(id => Number(getComputedStyle(document.querySelector(`[data-hypit-element-id="${id}"]`)!).opacity));
const units = ["flow", "path"].map(id => Number(getComputedStyle(document.querySelector(`[data-hypit-element-id="${id}"] [data-hypit-text-unit-grapheme="0"]`)!).opacity));
return { roots, units };
}, frame);
for (const frame of [30, 45, 15, 40, 30]) {
const values = await page.evaluate(frame => {
window.dispatchEvent(new CustomEvent("hf-seek", { detail: { time: frame / 30 } }));
return ["flow", "path", "mask-image"].map(id => Number(getComputedStyle(document.querySelector(`[data-hypit-element-id="${id}"]`)!).opacity));
}, frame);
for (const value of values) assert.ok(Math.abs(value - (frame - 15) / 30) < 0.001, `frame ${frame}: ${values}`);
const values = await valuesAt(page, frame);
for (const value of values.roots) assert.ok(Math.abs(value - (frame - 15) / 30) < 0.001,
`frame ${frame}: ${JSON.stringify(values)}`);
for (const value of values.units) assert.ok(Math.abs(value - (1 - (frame - 15) / 30)) < 0.001,
`frame ${frame}: ${JSON.stringify(values)}`);
}
for (const frame of [15, 30, 45]) {
const fresh = await browser.newPage();
try {
await fresh.setContent(html);
const values = await valuesAt(fresh, frame);
for (const value of values.roots) assert.ok(Math.abs(value - (frame - 15) / 30) < 0.001,
`fresh worker frame ${frame}: ${JSON.stringify(values)}`);
for (const value of values.units) assert.ok(Math.abs(value - (1 - (frame - 15) / 30)) < 0.001,
`fresh worker frame ${frame}: ${JSON.stringify(values)}`);
} finally { await fresh.close(); }
}
} finally { await browser.close(); }
});
@@ -0,0 +1,18 @@
import assert from "node:assert/strict";
import test from "node:test";
import { WeightedCapacity } from "../src/weighted-capacity.js";
test("weighted capacity owns only numeric attempt-local leases", () => {
const capacity = new WeightedCapacity(5);
const first = capacity.tryAcquire(3)!;
assert.equal(capacity.used, 3);
assert.equal(capacity.tryAcquire(3), undefined);
const overflow = capacity.tryAcquire(3, true)!;
assert.equal(capacity.used, 6);
assert.equal(capacity.peak, 6);
first.release();
first.release();
assert.equal(capacity.used, 3);
overflow.release();
assert.equal(capacity.used, 0);
});
+8 -4
View File
@@ -30,11 +30,15 @@ function buildView(input: {
? "submitting"
: runtimeActivity!;
const names = new Map(input.catalog?.publishedOutputs.map((item) => [item.ref.id, item.name]) ?? []);
const targets = input.snapshot?.state.targets.map((target) => {
const name = names.get(target.output);
if (name === undefined) throw new Error(`Build ${input.build} target ${target.output} has no published Output name`);
const targetRefs = input.catalog?.targets ?? input.snapshot?.state.targets.map((target) => ({
kind: "logical-output" as const,
id: target.output,
})) ?? [];
const targets = targetRefs.map((target) => {
const name = names.get(target.id);
if (name === undefined) throw new Error(`Build ${input.build} target ${target.id} has no published Output name`);
return name;
}) ?? [];
});
const requests = input.snapshot === undefined ? undefined : {
total: input.snapshot.definition.plan.steps.reduce(
(total, step) => total + Object.keys(step.needs).length,
+1 -1
View File
@@ -79,7 +79,7 @@ export function createLocalResultWriter(
const acceptStoredResults = async (build: string): Promise<BuildSnapshot> => {
const snapshot = await options.buildStore.read(build);
assert(snapshot !== undefined, `Build ${build} has no durable execution state`);
const machine = new BuildMachine(snapshot.definition, snapshot.facts);
const machine = BuildMachine.fromMaterialized(snapshot.definition, snapshot.state);
const accept = async (event: CommandResult): Promise<void> => {
if (!machine.view().outstanding.some((command) => command.id === event.command)) return;
const fact = machine.evaluate(event);
+6 -3
View File
@@ -282,9 +282,12 @@ export async function createLocalRuntime(
`Logical Output ${published.output} has more than one public name`);
names.set(published.output, published.name);
}
const targets = request.definition.targets.map((target) => {
const name = names.get(target.output);
assert(name !== undefined, `Target ${target.output} is not a published Author Output`);
const targets = (request.catalog.targets ?? request.definition.targets.map((target) => ({
kind: "logical-output" as const,
id: target.output,
}))).map((target) => {
const name = names.get(target.id);
assert(name !== undefined, `Target ${target.id} is not a published Author Output`);
return name;
});
await opened.repository.create({
+34 -7
View File
@@ -4,6 +4,7 @@ import type {
ResourceStore,
BuildCompletion,
BuildExecutionSnapshot,
RuntimeBuildExecution,
RuntimeCommandExecutor,
RuntimeExecutionResult,
RuntimePreparation,
@@ -51,6 +52,10 @@ class CapacityExecutor implements RuntimeCommandExecutor {
return this.#delegate.prepare(state);
}
prepareExecution(execution: RuntimeBuildExecution): RuntimePreparation {
return this.#delegate.prepareExecution?.(execution) ?? this.#delegate.prepare(execution.view());
}
async #assertRunning(build: string): Promise<void> {
const execution = await this.#options.stores.execution.read(build);
assert(execution?.turn?.owner === this.#owner && execution.decision === undefined,
@@ -62,14 +67,19 @@ class CapacityExecutor implements RuntimeCommandExecutor {
state: BuildState,
descriptor: RuntimeRunnableCommand,
context: { readonly build: string },
execution?: RuntimeBuildExecution,
): Promise<RuntimeExecutionResult> {
const store = this.#options.stores.executions;
const recordExecution = this.#options.executionLogs === undefined ? {} : {
recordExecution: (event: import("@hypit/runtime").ExecutionLogEvent) =>
this.#options.executionLogs!.record(context.build, descriptor.command.id, event),
};
const execute = (executionContext: import("@hypit/runtime").RuntimeExecutionContext) =>
execution !== undefined && this.#delegate.executeExecutionCommand !== undefined
? this.#delegate.executeExecutionCommand(execution, descriptor, executionContext)
: this.#delegate.executeCommand(state, descriptor, executionContext);
if (descriptor.capacityMode === "asynchronous") {
return await this.#delegate.executeCommand(state, descriptor, {
return await execute({
...context, ...recordExecution, releaseOperationCapacity: () => this.#options.stores.execution.releaseCapacity(context.build, descriptor.command.id),
});
}
@@ -88,7 +98,7 @@ class CapacityExecutor implements RuntimeCommandExecutor {
return { status: "completed", event };
}
try {
const result = await this.#delegate.executeCommand(state, descriptor, {
const result = await execute({
...context, ...recordExecution,
reportProgress: (activity) => store.reportProgress(context.build, descriptor.command.id, activity),
});
@@ -114,19 +124,20 @@ class CapacityExecutor implements RuntimeCommandExecutor {
return operation?.remoteEnded === true || operation?.submission === "queued";
}
async executeCommand(
async #execute(
state: BuildState,
descriptor: RuntimeRunnableCommand,
context: { readonly build: string },
execution?: RuntimeBuildExecution,
): Promise<RuntimeExecutionResult> {
const resources = descriptor.resources;
await this.#assertRunning(context.build);
if (descriptor.capacityMode === "asynchronous") {
const [operation] = await this.#options.stores.operations.list({ build: context.build, command: descriptor.command.id });
if (operation?.remoteEnded) return await this.#executeOnce(state, descriptor, context);
if (operation?.remoteEnded) return await this.#executeOnce(state, descriptor, context, execution);
}
if (resources.length === 0) {
return await this.#executeOnce(state, descriptor, context);
return await this.#executeOnce(state, descriptor, context, execution);
}
// acquireCapacity also returns the reservation already held by this command.
const acquired = await this.#options.stores.execution.acquireCapacity({
@@ -143,7 +154,7 @@ class CapacityExecutor implements RuntimeCommandExecutor {
};
}
try {
const result = await this.#executeOnce(state, descriptor, context);
const result = await this.#executeOnce(state, descriptor, context, execution);
if (await this.#releaseAfter(result, context.build, descriptor.command.id)) {
await this.#options.stores.execution.releaseCapacity(
acquired.reservation.build,
@@ -160,6 +171,22 @@ class CapacityExecutor implements RuntimeCommandExecutor {
}
}
async executeCommand(
state: BuildState,
descriptor: RuntimeRunnableCommand,
context: { readonly build: string },
): Promise<RuntimeExecutionResult> {
return await this.#execute(state, descriptor, context);
}
async executeExecutionCommand(
execution: RuntimeBuildExecution,
descriptor: RuntimeRunnableCommand,
context: { readonly build: string },
): Promise<RuntimeExecutionResult> {
return await this.#execute(execution.view(), descriptor, context, execution);
}
async cancelOperation(
state: BuildState,
operation: import("@hypit/runtime").OperationSnapshot,
@@ -213,7 +240,7 @@ class DurableLocalWorker {
if (received.length > 0 || execution.stop.cause === "user-cancelled") {
const snapshot = await this.#readBuild(execution.build);
assert(snapshot !== undefined, `Build ${execution.build} has no execution state`);
const machine = new BuildMachine(snapshot.definition, snapshot.facts);
const machine = BuildMachine.fromMaterialized(snapshot.definition, snapshot.state);
for (const operation of received) {
let event: import("@hypit/protocol").CommandResult | undefined;
try {
+66
View File
@@ -136,6 +136,72 @@ test("a project directory alias admits its Source and stores a project-relative
} finally { await rm(parent, { recursive: true, force: true }); }
});
test("a forward-only Build completes with no Core records or Producer work", async () => {
const directory = await mkdtemp(join(tmpdir(), "hypit-local-forward-only-"));
const priorId = "bld_20260924T120000000Z_0000000001";
const buildId = "bld_20260924T120000001Z_0000000001";
try {
await writeFile(join(directory, "main.svml"), "source");
const repository = new FileBuildResultRepository(join(directory, "results"));
const prior = await repository.create({
id: priorId,
source: { path: "main.svml" },
targets: ["document"],
publishedOutputs: [{ name: "document", output: "document" }],
});
await prior.sync({
state: {
status: "complete",
records: [{ id: "record:document", type: types.document, value: { kind: "inline", value: { text: "ready" } } }],
plan: { outputBindings: [{ output: "document", record: "record:document", type: types.document }] },
} as unknown as import("@hypit/protocol").BuildState,
resources: { async open() { throw new Error("fixture has no files"); } },
});
await prior.finish({ outcome: "complete" });
const template = createGreetingBuild();
const empty = defineBuild({
program: template.program,
initialRecords: [],
plan: { format: "hypit.plan@1", steps: [], goals: [], outputBindings: [] },
targets: [],
});
const runtime = await createLocalRuntime(projectRuntimeFixture(directory));
try {
await runtime.build({
id: buildId,
definition: empty,
catalog: {
source: { path: join(directory, "main.svml") },
targets: [{ kind: "logical-output", id: "forwarded-document" }],
publishedOutputs: [{ name: "document", ref: { kind: "logical-output", id: "forwarded-document" } }],
},
result: {
...resultDestination(directory),
forwards: [{
output: "forwarded-document",
build: priorId,
sourceOutput: "document",
type: types.document,
}],
},
});
const completion = await finishClaimedBuild(runtime);
assert.equal(completion.outcome, "complete");
const result = await repository.read(buildId);
assert.deepEqual(result?.targets, ["document"]);
assert.deepEqual(result?.outputs.document, {
type: types.document,
value: { kind: "build-output", build: priorId, output: "document" },
});
} finally {
await runtime.close();
}
} finally {
await rm(directory, { recursive: true, force: true });
}
});
async function finishClaimedBuild(
runtime: Awaited<ReturnType<typeof createLocalRuntime>>,
): Promise<BuildCompletion> {
+2
View File
@@ -16,6 +16,8 @@ export type BuildCatalogDescriptor = {
readonly run?: {
readonly path: string;
};
/** Author-facing targets requested by the Run, including Outputs forwarded outside Core execution. */
readonly targets?: readonly LogicalOutputRef[];
readonly publishedOutputs: readonly BuildPublishedOutput[];
};
+8 -3
View File
@@ -83,7 +83,7 @@ export class LocalBuildScheduler {
throw new Error(`scheduled Build snapshot ${snapshot.build} does not belong to ${request.id}`);
}
state = snapshot.state;
machine = new BuildMachine(snapshot.definition, snapshot.facts);
machine = BuildMachine.fromMaterialized(snapshot.definition, snapshot.state);
}
builds.push({
id: request.id,
@@ -134,7 +134,9 @@ export class LocalBuildScheduler {
ready.set(build.id, []);
continue;
}
const prepared = this.#executor.prepare(build.state);
const prepared = build.machine !== undefined && this.#executor.prepareExecution !== undefined
? this.#executor.prepareExecution(build.machine)
: this.#executor.prepare(build.state);
build.state = prepared.state;
build.blocked = prepared.blocked;
ready.set(build.id, prepared.runnable.filter((item) =>
@@ -156,7 +158,10 @@ export class LocalBuildScheduler {
const launch = (build: MutableBuild, command: RuntimeRunnableCommand): void => {
const key = buildCommandKey(build.id, command.command.id);
const startedFrom = build.state;
const promise = this.#executor.executeCommand(startedFrom, command, { build: build.id }).then(
const execution = build.machine !== undefined && this.#executor.executeExecutionCommand !== undefined
? this.#executor.executeExecutionCommand(build.machine, command, { build: build.id })
: this.#executor.executeCommand(startedFrom, command, { build: build.id });
const promise = execution.then(
(execution) => ({ key, build, command, execution }),
(error: unknown) => ({ key, build, command, error }),
);
+16 -1
View File
@@ -57,14 +57,29 @@ export type RuntimeExecutionResult =
| { readonly status: "pending"; readonly operation: string; readonly wakeAt?: number }
| { readonly status: "deferred"; readonly wakeAt?: number; readonly reason: string };
/** Minimal execution port used by a Scheduler. `prepare` is the sole command-generation boundary. */
/** Build-local indexed execution view. Definition + Facts remain the only durable representation. */
export type RuntimeBuildExecution = {
view(): BuildState;
commands(): readonly CoreCommand[];
record(id: string): import("@hypit/protocol").TypedRecord | undefined;
};
/** Minimal execution port used by a Scheduler. Core has already generated `state.outstanding`; prepare classifies it for this Host. */
export type RuntimeCommandExecutor = {
prepare(state: BuildState): RuntimePreparation;
/** Optional indexed path used while one BuildMachine is alive. */
prepareExecution?(execution: RuntimeBuildExecution): RuntimePreparation;
executeCommand(
state: BuildState,
command: RuntimeRunnableCommand,
context: RuntimeExecutionContext,
): Promise<RuntimeExecutionResult>;
/** Optional indexed counterpart to executeCommand; it avoids rebuilding Record lookup tables. */
executeExecutionCommand?(
execution: RuntimeBuildExecution,
command: RuntimeRunnableCommand,
context: RuntimeExecutionContext,
): Promise<RuntimeExecutionResult>;
cancelOperation?(
state: BuildState,
operation: OperationSnapshot,
+8 -1
View File
@@ -1,4 +1,4 @@
import { fileReferenceIdentity } from "@hypit/build-result";
import { fileReferenceIdentity, locateRepositoryBuildResultOutput } from "@hypit/build-result";
import { isAbsolute, relative, resolve, sep } from "node:path";
import { buildIdCreatedAt } from "@hypit/protocol";
@@ -26,6 +26,10 @@ export type StudioBuildLibrary = {
readonly transientExecution?: RuntimeHostTransientExecution;
readonly library: (request: StudioLibraryRequest) => Promise<StudioLibraryView>;
readonly renameArtifact: (build: string, output: string, displayName: string | null) => Promise<string | undefined>;
readonly locateHistoricalOutput: (
build: string,
output: string,
) => Promise<import("@hypit/build-result").RepositoryBuildResultOutputLocation | undefined>;
readonly resolveHistoricalOutput: (
build: string,
output: string,
@@ -273,6 +277,9 @@ export async function openStudioBuildLibrary(
presentationWrite = saved.catch(() => undefined);
return await saved;
},
async locateHistoricalOutput(build, output) {
return await locateRepositoryBuildResultOutput(results, build, output);
},
async resolveHistoricalOutput(build, output) {
return await resolveBuildResultValue(results, build, output);
},
@@ -162,6 +162,19 @@ function shim(): string {
// comes from the exact AudioTrack below rather than from a video sidecar.
element.muted = true;
if (!inside) { element.pause(); continue; }
// A hold remains one compact target interval. Its exact zero source rate
// cannot be assigned to HTMLMediaElement.playbackRate, so keep the native
// decoder paused at the authored source frame instead.
if (record.sourceRate[0] === 0n) {
var heldFrame = Number(record.sourceFrame[0] / record.sourceFrame[1]);
var heldTarget = (heldFrame + 0.5) * record.sourceFps[1] / record.sourceFps[0];
if (Number.isFinite(element.duration) && element.duration > 0) {
heldTarget = Math.min(heldTarget, Math.max(0, element.duration - 0.001));
}
element.pause();
waits.push(seekDecoded(element, heldTarget));
continue;
}
var target = record.mediaStart + (local + frameSeconds / 2) * record.rate;
var discrete = scrubbing || programFrame === record.endFrame - 1;
if (discrete) {
+1
View File
@@ -56,6 +56,7 @@ export async function loadStudioRun(input: {
fragments,
frontends,
...(input.buildLibrary === undefined ? {} : {
locateHistoricalOutput: input.buildLibrary.locateHistoricalOutput,
resolveHistoricalOutput: input.buildLibrary.resolveHistoricalOutput,
}),
});
@@ -34,3 +34,37 @@ test("preview scrubs to the same source frame as rendering at fractional sample
assert.equal(video.currentTime, (sourceFrame + 0.5) / 30, `preview frame ${frame}`);
}
});
test("preview keeps a compact zero-rate sampling interval paused on its source frame", async () => {
const attrs: Record<string, string> = {
"data-start": "2", "data-duration": "10", "data-media-start": "0.1", "data-playback-rate": "1",
"data-hypit-start-frame": "60", "data-hypit-end-frame": "360", "data-hypit-source-frame": "3/1",
"data-hypit-source-rate": "0/1", "data-hypit-source-fps": "30/1",
};
const listeners = new Map<string, () => void>();
let currentTime = 0;
let pauses = 0;
let plays = 0;
const video = { style: {}, readyState: 4, duration: 10, paused: true,
getAttribute: (name: string) => attrs[name] ?? null, closest: () => null,
get currentTime() { return currentTime; },
set currentTime(value: number) { currentTime = value; queueMicrotask(() => listeners.get("seeked")?.()); },
pause() { pauses += 1; }, play() { plays += 1; return Promise.resolve(); },
addEventListener(name: string, listener: () => void) { listeners.set(name, listener); },
removeEventListener(name: string) { listeners.delete(name); },
};
const root = { style: {}, getAttribute: (name: string) => ({ "data-fps": "30", "data-composition-id": "test" })[name] ?? null };
const window: Record<string, any> = { addEventListener() {}, dispatchEvent() {} };
const html = injectRuntimeShim("").trim();
vm.runInNewContext(html.slice("<script>".length, -"</script>".length), {
window, document: { querySelector: () => root,
querySelectorAll: (selector: string) => selector === ".hypit-visual-present video" ? [video] : [] },
CustomEvent: class {},
});
await window.__hypitSeekFrame(60);
await window.__hypitPlayFrame(211);
await window.__hypitSeekFrame(359);
assert.equal(currentTime, 3.5 / 30);
assert.equal(plays, 0, "a held interval never hands its clock to native video playback");
assert.ok(pauses >= 3);
});
+3 -2
View File
@@ -7,7 +7,7 @@ import { Readable } from "node:stream";
import type { ReadableStream as NodeReadableStream } from "node:stream/web";
import { pipeline } from "node:stream/promises";
import type { CliIo } from "@hypit/cli";
import { assertHyperframesDocument, hyperframesHtmlAssetUrls, hyperframesHtmlDomain } from "@hypit/hyperframes";
import { assertHyperframesDocument, hyperframesHtmlAssetUrls, hyperframesHtmlDomain, selectHyperframesArtifacts } from "@hypit/hyperframes";
import type { HyperframesDocument, HyperframesHtmlProject } from "@hypit/hyperframes";
import { FileResourceStore } from "@hypit/resource-store-fs";
import { hyperframesFramesRequest, renderHyperframesCapabilities, renderHyperframesTypes, verifyHyperframesFrames } from "@hypit/render-hyperframes";
@@ -143,7 +143,8 @@ export async function runSnapshotCli(argv: readonly string[], io: CliIo, environ
const resources = new FileResourceStore(temporary);
let input: { document: HyperframesDocument } | { project: HyperframesHtmlProject };
if (document !== undefined) {
for (const artifact of document.artifacts) {
const selection = frames.map((frame) => ({ startFrame: frame, endFrameExclusive: frame + 1 }));
for (const { artifact } of selectHyperframesArtifacts(document, selection)) {
const response = await fetch(new URL(`/__studio/material/${artifact.resource}`, studio));
if (!response.ok || response.body === null) throw new Error(`Studio material ${artifact.resource}: HTTP ${response.status}`);
const stream = Readable.fromWeb(response.body as NodeReadableStream<Uint8Array>);
+6 -2
View File
@@ -106,9 +106,13 @@ test("Studio snapshots carry declared resources used only inside JavaScript data
const directory = await mkdtemp(join(tmpdir(), "hypit-studio-snapshot-"));
const bytes = await sharp({ create: { width: 96, height: 64, channels: 3, background: "red" } }).png().toBuffer();
const artifact = { kind: "blob" as const, resource: "res_program_image" as const, size: bytes.length, mediaType: "image/png" };
const late = { kind: "blob" as const, resource: "res_late_image" as const, size: 123, mediaType: "image/png" };
const document = { visualIr: "hypit.visual-ir@1", frameRate: { numerator: 30000, denominator: 1001 }, frameCount: 12,
canvas: { width: 96, height: 64 }, artifacts: [artifact], surfaces: [],
html: html.replace('<img src="still.png">', '<script>const input="hypit-resource://res_program_image";</script>') };
canvas: { width: 96, height: 64 }, artifacts: [
{ artifact: late, usage: { kind: "frames" as const, spans: [{ startFrame: 10, endFrameExclusive: 12 }] } },
{ artifact, usage: { kind: "always" as const } },
], surfaces: [],
html: html.replace('<img src="still.png">', '<script>const input="hypit-resource://res_program_image";</script><img data-hypit-resource-src="hypit-resource://res_late_image">') };
const fetched: string[] = [];
t.mock.method(globalThis, "fetch", async (input: string | URL) => {
const url = String(input);