mirror of
https://github.com/hypit-ai/hypit.git
synced 2026-11-06 18:57:21 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -17,6 +17,8 @@ export {
|
||||
browseBuildResults,
|
||||
buildResultDirectory,
|
||||
describeBuildResultOutput,
|
||||
locateBuildResultOutput,
|
||||
locateRepositoryBuildResultOutput,
|
||||
normalizeBuildResultForwards,
|
||||
readBuildResult,
|
||||
resolveBuildResultOutput,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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!),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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: {},
|
||||
};
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
@@ -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,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");
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
})();`;
|
||||
@@ -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.`);
|
||||
|
||||
@@ -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});`;
|
||||
}
|
||||
@@ -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)))}"`);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,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. */
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
@@ -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("./picture.png");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\("\.\/assets\/picture.png"\)/u);
|
||||
assert.match(mapped, /filter:url\("#mask"\)/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);
|
||||
});
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
@@ -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("&", "&").replaceAll(""", '"').replaceAll("<", "<").replaceAll(">", ">");
|
||||
};
|
||||
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("&", "&").replaceAll(""", '"').replaceAll("<", "<").replaceAll(">", ">");
|
||||
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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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>);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user