@hypit/hypit 0.1.13 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/README.md +37 -3
  2. package/bin/hypit.mjs +0 -2
  3. package/dist/public/browser-capture.d.ts +9 -3
  4. package/dist/public/caption.d.ts +2 -0
  5. package/dist/public/narrative.d.ts +2 -0
  6. package/dist/public/runtime-kit.d.ts +2 -2
  7. package/dist/public/speech.d.ts +2 -0
  8. package/examples/semantic-composition/packages/responsive-explainer/README.md +1 -1
  9. package/package.json +2 -1
  10. package/packages/browser-capture/README.md +9 -2
  11. package/packages/browser-capture/package.json +5 -0
  12. package/packages/browser-capture/src/browser.ts +22 -8
  13. package/packages/browser-capture/src/index.ts +8 -2
  14. package/packages/caption/README.md +1 -1
  15. package/packages/caption/src/display.ts +1 -0
  16. package/packages/caption-fine/README.md +19 -5
  17. package/packages/caption-fine/package.json +1 -0
  18. package/packages/caption-fine/src/joined-box.ts +114 -0
  19. package/packages/caption-fine/src/manifest.ts +1 -1
  20. package/packages/caption-fine/src/render.ts +99 -92
  21. package/packages/caption-fine/src/spacing.ts +8 -61
  22. package/packages/caption-fine-studio/src/index.ts +2 -4
  23. package/packages/cli/README.md +3 -2
  24. package/packages/cli/package.json +1 -0
  25. package/packages/cli/src/arguments.ts +2 -2
  26. package/packages/cli/src/command.ts +1 -1
  27. package/packages/cli/src/commands/environment.ts +21 -9
  28. package/packages/cli/src/machine-view.ts +1 -1
  29. package/packages/cli/src/main.ts +2 -1
  30. package/packages/cli/src/oauth.ts +50 -7
  31. package/packages/cli/src/output.ts +1 -0
  32. package/packages/credential-store-file/README.md +60 -0
  33. package/packages/credential-store-file/package.json +21 -0
  34. package/packages/credential-store-file/src/activation.ts +29 -0
  35. package/packages/credential-store-file/src/index.ts +1 -0
  36. package/packages/credential-store-file/src/store.ts +89 -0
  37. package/packages/credential-store-os/src/store.ts +5 -1
  38. package/packages/fonts-open/src/surface.ts +8 -2
  39. package/packages/hyperframes/README.md +16 -2
  40. package/packages/hyperframes/src/browser-program.ts +6 -1
  41. package/packages/hyperframes/src/document.ts +31 -21
  42. package/packages/hyperframes/src/project.ts +11 -20
  43. package/packages/media-execution/README.md +7 -0
  44. package/packages/media-execution/src/execute.ts +5 -5
  45. package/packages/media-execution/src/index.ts +1 -1
  46. package/packages/media-execution/src/process-env.ts +20 -0
  47. package/packages/media-execution/src/surface.ts +83 -76
  48. package/packages/narrative/README.md +3 -1
  49. package/packages/narrative/src/schema.ts +1 -0
  50. package/packages/narrative/src/types.ts +2 -0
  51. package/packages/package-loader-node/README.md +10 -0
  52. package/packages/package-loader-node/src/index.ts +1 -0
  53. package/packages/package-loader-node/src/loader.ts +25 -5
  54. package/packages/package-loader-node/src/location.ts +11 -2
  55. package/packages/provider-hyperframes-local/README.md +81 -8
  56. package/packages/provider-hyperframes-local/package.json +13 -3
  57. package/packages/provider-hyperframes-local/src/activation.ts +20 -6
  58. package/packages/provider-hyperframes-local/src/browser-install.ts +5 -0
  59. package/packages/provider-hyperframes-local/src/browser.ts +118 -0
  60. package/packages/provider-hyperframes-local/src/capture-bootstrap.ts +4 -6
  61. package/packages/provider-hyperframes-local/src/capture-exit.ts +24 -0
  62. package/packages/provider-hyperframes-local/src/capture-process.ts +29 -52
  63. package/packages/provider-hyperframes-local/src/capture-worker.ts +2 -0
  64. package/packages/provider-hyperframes-local/src/capture.ts +12 -5
  65. package/packages/provider-hyperframes-local/src/opaque-capture.ts +46 -18
  66. package/packages/provider-hyperframes-local/src/options.ts +2 -2
  67. package/packages/provider-hyperframes-local/src/process-tree.ts +82 -0
  68. package/packages/provider-hyperframes-local/src/process.ts +22 -0
  69. package/packages/provider-hyperframes-local/src/program.ts +25 -33
  70. package/packages/provider-hyperframes-local/src/provider.ts +4 -7
  71. package/packages/provider-hyperframes-local/src/render.ts +9 -4
  72. package/packages/provider-whisperx-local/README.md +26 -10
  73. package/packages/provider-whisperx-local/src/activation.ts +10 -1
  74. package/packages/provider-whisperx-local/src/program.ts +8 -4
  75. package/packages/runtime-host-node/README.md +5 -0
  76. package/packages/runtime-host-node/src/index.ts +8 -2
  77. package/packages/runtime-host-node/src/packages.ts +13 -7
  78. package/packages/runtime-kit/README.md +6 -2
  79. package/packages/runtime-kit/src/index.ts +2 -2
  80. package/packages/runtime-local/README.md +16 -1
  81. package/packages/runtime-local/src/config.ts +1 -1
  82. package/packages/runtime-local/src/credentials.ts +10 -5
  83. package/packages/runtime-local/src/host.ts +2 -0
  84. package/packages/runtime-local/src/index.ts +1 -0
  85. package/packages/runtime-local/src/programs.ts +53 -17
  86. package/packages/script/README.md +81 -34
  87. package/packages/script/bin/migrate-0.2.mjs +90 -0
  88. package/packages/script/src/edit.ts +28 -60
  89. package/packages/script/src/lexical.ts +59 -130
  90. package/packages/script/src/manifest.ts +11 -11
  91. package/packages/script/src/narrative.ts +4 -2
  92. package/packages/script/src/parser.ts +140 -193
  93. package/packages/script/src/types.ts +2 -3
  94. package/packages/studio/src/preview/runtime-shim.ts +17 -3
  95. package/packages/studio/src/ui/code.ts +1 -1
  96. package/packages/studio/src/ui/main.ts +2 -2
  97. package/packages/studio/src/ui/markers.ts +3 -3
  98. package/packages/studio/src/ui/selection.ts +1 -1
  99. package/packages/studio/src/ui/syntax.ts +42 -21
  100. package/packages/temporal-markup/README.md +1 -1
  101. package/packages/video-cli/README.md +14 -6
  102. package/packages/video-cli/package.json +1 -0
  103. package/packages/video-cli/src/capture.ts +17 -2
  104. package/packages/video-cli/src/creation.ts +4 -6
  105. package/packages/video-cli/src/distribution.ts +3 -10
  106. package/packages/video-cli/src/media.ts +10 -3
  107. package/packages/video-cli/src/version.ts +1 -1
  108. package/packages/whisperx/README.md +56 -3
  109. package/packages/whisperx/src/component.ts +2 -5
  110. package/packages/whisperx/src/evidence.ts +4 -3
  111. package/packages/whisperx/src/index.ts +1 -0
  112. package/packages/whisperx/src/manifest.ts +2 -3
  113. package/packages/whisperx/src/surface.ts +3 -6
  114. package/packages/whisperx/src/types.ts +9 -1
  115. package/packages/yt-dlp/README.md +10 -3
  116. package/packages/yt-dlp/package.json +5 -0
  117. package/packages/yt-dlp/src/download.ts +11 -29
  118. package/packages/yt-dlp/src/environment.ts +36 -0
  119. package/packages/yt-dlp/src/index.ts +1 -0
  120. package/services/whisperx/README.md +45 -12
  121. package/services/whisperx/src/hypit_whisperx_service/application.py +3 -0
  122. package/services/whisperx/src/hypit_whisperx_service/check.py +7 -0
  123. package/services/whisperx/src/hypit_whisperx_service/config.py +7 -0
  124. package/services/whisperx/src/hypit_whisperx_service/engine.py +21 -5
  125. package/services/whisperx/src/hypit_whisperx_service/models.py +131 -0
  126. package/services/whisperx/src/hypit_whisperx_service/prepare.py +21 -3
  127. package/services/whisperx/src/hypit_whisperx_service/resources.py +19 -3
  128. package/services/yt-dlp/README.md +14 -15
  129. package/services/yt-dlp/pyproject.toml +1 -1
  130. package/services/yt-dlp/uv.lock +513 -2
@@ -1,9 +1,10 @@
1
1
  import { spawn } from "node:child_process";
2
- import { mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import { mkdtemp, rm, stat, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
 
6
6
  import { assertCompositableSurfaceRef } from "@hypit/media";
7
+ import { mediaProcessEnv } from "./process-env.js";
7
8
  import type { CompositableSurfaceRef } from "@hypit/media";
8
9
 
9
10
  type JsonObject = Record<string, unknown>;
@@ -57,7 +58,7 @@ async function runProcess(args: {
57
58
  shell: false,
58
59
  windowsHide: true,
59
60
  stdio: ["ignore", "pipe", "pipe"],
60
- env: { PATH: process.env.PATH ?? "" },
61
+ env: mediaProcessEnv(),
61
62
  });
62
63
  const stdout: Buffer[] = [];
63
64
  let bytes = 0;
@@ -192,95 +193,101 @@ async function pixelFormatAlpha(
192
193
  return result;
193
194
  }
194
195
 
195
- function suffix(mediaType: string): string {
196
- if (mediaType === "image/png") return ".png";
197
- if (mediaType === "image/webp") return ".webp";
198
- if (mediaType === "video/webm") return ".webm";
199
- if (mediaType === "video/mp4") return ".mp4";
200
- throw new Error(`Surface media type ${mediaType} is unsupported`);
201
- }
202
-
203
- /**
204
- * Decode and verify one typed Surface before its bytes enter a renderer.
205
- *
206
- * This is an admission gate, not a second media Product. Success returns no
207
- * side-channel facts; anything a later graph step needs must be an explicit output.
208
- */
209
- export async function verifyCompositableSurfaceBytes(options: {
196
+ type SurfaceInspectionOptions = {
210
197
  readonly surface: CompositableSurfaceRef;
211
- readonly bytes: Uint8Array;
212
198
  readonly ffprobePath?: string;
213
199
  readonly processTimeoutMs?: number;
214
200
  readonly maxProbeOutputBytes?: number;
215
201
  readonly signal?: AbortSignal;
202
+ };
203
+
204
+ /** Inspect bytes supplied by callers that do not already own a staged file. */
205
+ export async function verifyCompositableSurfaceBytes(options: SurfaceInspectionOptions & {
206
+ readonly bytes: Uint8Array;
207
+ }): Promise<void> {
208
+ options.signal?.throwIfAborted();
209
+ assertCompositableSurfaceRef(options.surface);
210
+ assert(options.bytes.byteLength === options.surface.artifact.size,
211
+ `Surface ${options.surface.artifact.resource} byte size differs`);
212
+ const directory = await mkdtemp(join(tmpdir(), "hypit-surface-verify-"));
213
+ try {
214
+ // FFprobe detects the encoded media itself; both entry points inspect the same formats.
215
+ const path = join(directory, "surface");
216
+ await writeFile(path, options.bytes, { signal: options.signal });
217
+ await verifyCompositableSurfaceFile({ ...options, path });
218
+ } finally {
219
+ await rm(directory, { recursive: true, force: true }).catch(() => {});
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Inspect an existing execution file without copying it. The caller owns the file
225
+ * and keeps it unchanged until this call settles. Success returns no side-channel facts.
226
+ */
227
+ export async function verifyCompositableSurfaceFile(options: SurfaceInspectionOptions & {
228
+ readonly path: string;
216
229
  }): Promise<void> {
217
230
  const controller = new AbortController();
218
231
  const signal = options.signal === undefined ? controller.signal : AbortSignal.any([controller.signal, options.signal]);
219
232
  signal.throwIfAborted();
220
233
  assertCompositableSurfaceRef(options.surface);
221
- assert(options.bytes.byteLength === options.surface.artifact.size,
234
+ assert((await stat(options.path)).size === options.surface.artifact.size,
222
235
  `Surface ${options.surface.artifact.resource} byte size differs`);
236
+ signal.throwIfAborted();
223
237
  const ffprobePath = options.ffprobePath ?? "ffprobe";
224
238
  const timeoutMs = options.processTimeoutMs ?? 120_000;
225
239
  const maxOutputBytes = options.maxProbeOutputBytes ?? 8 * 1024 * 1024;
226
- const directory = await mkdtemp(join(tmpdir(), "hypit-surface-verify-"));
227
- try {
228
- const path = join(directory, `surface${suffix(options.surface.artifact.mediaType)}`);
229
- await writeFile(path, options.bytes, { signal });
230
- const pending = [
231
- runProcess({
232
- executable: ffprobePath,
233
- argv: ["-v", "error", "-print_format", "json", "-show_streams", "-count_frames", path],
234
- timeoutMs,
235
- maxOutputBytes, signal,
236
- }),
237
- pixelFormatAlpha(ffprobePath, timeoutMs, maxOutputBytes, signal),
238
- ] as const;
239
- await Promise.allSettled(pending.map(async (job) => {
240
- try { return await job; } catch (error) { controller.abort(error); throw error; }
241
- }));
242
- signal.throwIfAborted();
243
- const [probe, formats] = await Promise.all(pending);
244
- const root = json(probe, "ffprobe Surface query");
245
- assert(Array.isArray(root.streams) && root.streams.length === 1,
246
- "Surface must contain exactly one visual stream and no audio or auxiliary streams");
247
- const stream = root.streams[0] as ProbeStream;
248
- assert(stream.codec_type === "video", "Surface stream is not visual");
249
- assert(stream.disposition?.attached_pic !== 1, "Surface cannot be an attached-picture stream");
250
- const width = positiveInteger(stream.width, "Surface width");
251
- const height = positiveInteger(stream.height, "Surface height");
252
- assert(width === options.surface.width && height === options.surface.height,
253
- "Surface decoded dimensions differ from its declaration");
254
- assert(stream.sample_aspect_ratio === undefined || stream.sample_aspect_ratio === "1:1",
255
- "Surface must use square pixels");
256
- assert(rotation(stream) === 0, "Surface must not depend on display rotation metadata");
257
- assert(stream.field_order === undefined || stream.field_order === "progressive" || stream.field_order === "unknown",
258
- "Surface must be progressive");
259
- assertSrgb(stream);
240
+ const pending = [
241
+ runProcess({
242
+ executable: ffprobePath,
243
+ argv: ["-v", "error", "-print_format", "json", "-show_streams", "-count_frames", options.path],
244
+ timeoutMs,
245
+ maxOutputBytes, signal,
246
+ }),
247
+ pixelFormatAlpha(ffprobePath, timeoutMs, maxOutputBytes, signal),
248
+ ] as const;
249
+ await Promise.allSettled(pending.map(async (job) => {
250
+ try { return await job; } catch (error) { controller.abort(error); throw error; }
251
+ }));
252
+ signal.throwIfAborted();
253
+ const [probe, formats] = await Promise.all(pending);
254
+ const root = json(probe, "ffprobe Surface query");
255
+ assert(Array.isArray(root.streams) && root.streams.length === 1,
256
+ "Surface must contain exactly one visual stream and no audio or auxiliary streams");
257
+ const stream = root.streams[0] as ProbeStream;
258
+ assert(stream.codec_type === "video", "Surface stream is not visual");
259
+ assert(stream.disposition?.attached_pic !== 1, "Surface cannot be an attached-picture stream");
260
+ const width = positiveInteger(stream.width, "Surface width");
261
+ const height = positiveInteger(stream.height, "Surface height");
262
+ assert(width === options.surface.width && height === options.surface.height,
263
+ "Surface decoded dimensions differ from its declaration");
264
+ assert(stream.sample_aspect_ratio === undefined || stream.sample_aspect_ratio === "1:1",
265
+ "Surface must use square pixels");
266
+ assert(rotation(stream) === 0, "Surface must not depend on display rotation metadata");
267
+ assert(stream.field_order === undefined || stream.field_order === "progressive" || stream.field_order === "unknown",
268
+ "Surface must be progressive");
269
+ assertSrgb(stream);
260
270
 
261
- assert(typeof stream.pix_fmt === "string" && stream.pix_fmt.length > 0,
262
- "Surface pixel format is absent");
263
- const pixelAlpha = formats.get(stream.pix_fmt);
264
- assert(pixelAlpha !== undefined, `Surface pixel format ${stream.pix_fmt} is unknown to ffprobe`);
265
- const taggedAlpha = stream.tags?.alpha_mode === "1" || stream.tags?.alpha_mode === "straight";
266
- const hasAlpha = pixelAlpha || taggedAlpha;
267
- if (options.surface.alphaMode === "straight") {
268
- assert(hasAlpha, "Surface declares straight alpha but its bytes carry no alpha channel");
269
- } else {
270
- assert(!hasAlpha, "Surface declares opaque pixels but its encoded format carries alpha");
271
- }
271
+ assert(typeof stream.pix_fmt === "string" && stream.pix_fmt.length > 0,
272
+ "Surface pixel format is absent");
273
+ const pixelAlpha = formats.get(stream.pix_fmt);
274
+ assert(pixelAlpha !== undefined, `Surface pixel format ${stream.pix_fmt} is unknown to ffprobe`);
275
+ const taggedAlpha = stream.tags?.alpha_mode === "1" || stream.tags?.alpha_mode === "straight";
276
+ const hasAlpha = pixelAlpha || taggedAlpha;
277
+ if (options.surface.alphaMode === "straight") {
278
+ assert(hasAlpha, "Surface declares straight alpha but its bytes carry no alpha channel");
279
+ } else {
280
+ assert(!hasAlpha, "Surface declares opaque pixels but its encoded format carries alpha");
281
+ }
272
282
 
273
- const frameCount = positiveInteger(stream.nb_read_frames ?? stream.nb_frames, "Surface frame count");
274
- if (options.surface.timing.kind === "still") {
275
- assert(frameCount === 1, "Still Surface must decode to exactly one frame");
276
- } else {
277
- assert(frameCount === options.surface.timing.frameCount,
278
- "Surface decoded frame count differs from its declaration");
279
- const rate = rational(stream.avg_frame_rate ?? stream.r_frame_rate, "Surface frame rate");
280
- assert(sameRational(rate, options.surface.timing.frameRate),
281
- "Surface decoded frame rate differs from its declaration");
282
- }
283
- } finally {
284
- await rm(directory, { recursive: true, force: true }).catch(() => {});
283
+ const frameCount = positiveInteger(stream.nb_read_frames ?? stream.nb_frames, "Surface frame count");
284
+ if (options.surface.timing.kind === "still") {
285
+ assert(frameCount === 1, "Still Surface must decode to exactly one frame");
286
+ } else {
287
+ assert(frameCount === options.surface.timing.frameCount,
288
+ "Surface decoded frame count differs from its declaration");
289
+ const rate = rational(stream.avg_frame_rate ?? stream.r_frame_rate, "Surface frame rate");
290
+ assert(sameRational(rate, options.surface.timing.frameRate),
291
+ "Surface decoded frame rate differs from its declaration");
285
292
  }
286
293
  }
@@ -26,4 +26,6 @@ no Tokens. Authored order is independent of gaps, overlap or placement order in
26
26
  Caption uses this content query and the Narrative's display correspondence to select complete
27
27
  subtitle units. Timeline projection uses the same semantic references to locate events in physical
28
28
  time. Neither operation substitutes for the other. Token lookup returns Tokens, not reconstructed
29
- source prose: whitespace, comments and source-preserving edits remain the author language's concern.
29
+ source prose: comments and source-preserving edits remain the author language's concern. Caption
30
+ Display Words separately carry `separatorBefore` (`""` or `" "`) so consumers can reconstruct the
31
+ authored display spelling without guessing from Token boundaries or importing an author parser.
@@ -29,6 +29,7 @@ export const narrativeExcerptSchema: ValueSchema = object({
29
29
  tokenStart: { schema: integer }, tokenEndExclusive: { schema: integer },
30
30
  });
31
31
  const captionDisplayWord = object({
32
+ separatorBefore: { schema: { kind: "string", enum: ["", " "] } },
32
33
  id: { schema: string }, unitId: { schema: string }, segmentId: { schema: string },
33
34
  turnId: { schema: string }, role: { schema: string, optional: true }, text: { schema: string },
34
35
  attributes: { schema: { kind: "array", items: object({
@@ -50,6 +50,8 @@ export type NarrativeMomentRef = NarrativeMoment & {
50
50
 
51
51
  /** One author-visible word surface. Punctuation owned by the surface is preserved. */
52
52
  export type CaptionDisplayWord = {
53
+ /** Authored display separator from the preceding word; ignored at a displayed line start. */
54
+ readonly separatorBefore: "" | " ";
53
55
  readonly id: string;
54
56
  readonly unitId: string;
55
57
  readonly segmentId: string;
@@ -51,3 +51,13 @@ This is trusted execution, not a sandbox. Builtins, native libraries and process
51
51
  (including `process.cwd()` and `process.env`) remain shared. Existing module bindings do not change
52
52
  under an active Build; a file or dependency first
53
53
  read later uses ordinary filesystem semantics. Scoped loading does not make a project tree immutable.
54
+
55
+ ## Dependency installation options
56
+
57
+ A Distribution package may declare `hypit.dependencyInstallEnv` in its package.json, keyed by its
58
+ ordinary direct external dependency names. Each value is an environment-variable map passed to that
59
+ dependency's explicit npm installation. For example, an owner can disable an SDK's automatic asset
60
+ download and prepare that asset through its ManagedProgram instead. Options do not propagate to
61
+ unrelated installations or the caller's process. Conflicting values from selected packages fail;
62
+ no package wins by discovery order. They are command inputs, not a second dependency inventory,
63
+ readiness record, or new version system. Project packages remain owned by their package manager.
@@ -15,6 +15,7 @@ export {
15
15
  } from "./distribution-resolution.js";
16
16
  export {
17
17
  locateNodePackage,
18
+ NodePackageNotFoundError,
18
19
  externalPackageInstallRoot,
19
20
  resolveNodePackageExecutable,
20
21
  resolveNodePackageResource,
@@ -16,7 +16,7 @@ import { externalPackageRoots, locateNodePackage } from "./location.js";
16
16
  type PackageJson = {
17
17
  readonly name: string;
18
18
  readonly version?: string;
19
- readonly hypit?: { readonly activation?: string };
19
+ readonly hypit?: { readonly activation?: string; readonly dependencyInstallEnv?: Readonly<Record<string, Readonly<Record<string, string>>>> };
20
20
  readonly dependencies: Readonly<Record<string, string>>;
21
21
  };
22
22
 
@@ -43,7 +43,19 @@ function parsePackageJson(value: unknown, subject: string): PackageJson {
43
43
  return {
44
44
  name: text(item.name, `${subject}.name`),
45
45
  ...(item.version === undefined ? {} : { version: text(item.version, `${subject}.version`) }),
46
- ...(hypit?.activation === undefined ? {} : { hypit: { activation: text(hypit.activation, `${subject}.hypit.activation`) } }),
46
+ ...(hypit === undefined ? {} : { hypit: {
47
+ ...(hypit.activation === undefined ? {} : { activation: text(hypit.activation, `${subject}.hypit.activation`) }),
48
+ ...(hypit.dependencyInstallEnv === undefined ? {} : { dependencyInstallEnv: Object.fromEntries(
49
+ Object.entries(object(hypit.dependencyInstallEnv, `${subject}.hypit.dependencyInstallEnv`)).map(([name, raw]) => {
50
+ assert(Object.hasOwn(dependencies, name) && !name.startsWith("@hypit/"), `${subject}: installation environment must name a direct external dependency: ${name}`);
51
+ const env = object(raw, `${subject}.hypit.dependencyInstallEnv.${name}`);
52
+ for (const [key, value] of Object.entries(env)) {
53
+ assert(/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key) && typeof value === "string", `${subject}: invalid installation environment for ${name}`);
54
+ }
55
+ return [name, env as Record<string, string>];
56
+ }),
57
+ ) }),
58
+ } }),
47
59
  dependencies: Object.fromEntries(Object.entries(dependencies).map(([name, version]) => [
48
60
  name,
49
61
  text(version, `${subject}.dependencies.${name}`),
@@ -183,11 +195,11 @@ export class NodePackageSelectionMissingError extends Error {
183
195
  export async function distributionExternalPackageRequirements(
184
196
  specifiers: readonly string[],
185
197
  distributionRoot: string,
186
- ): Promise<readonly { readonly name: string; readonly version: string; readonly specifier: string }[]> {
198
+ ): Promise<readonly { readonly name: string; readonly version: string; readonly specifier: string; readonly env?: Readonly<Record<string, string>> }[]> {
187
199
  const root = resolve(distributionRoot);
188
200
  const queue = [...new Set(specifiers.filter((name) => name.startsWith("@hypit/")))].sort();
189
201
  const visited = new Set<string>();
190
- const external = new Map<string, { readonly name: string; readonly version: string; readonly specifier: string }>();
202
+ const external = new Map<string, { readonly name: string; readonly version: string; readonly specifier: string; readonly env?: Readonly<Record<string, string>> }>();
191
203
  while (queue.length > 0) {
192
204
  const name = queue.shift()!;
193
205
  if (visited.has(name)) continue;
@@ -199,7 +211,15 @@ export async function distributionExternalPackageRequirements(
199
211
  continue;
200
212
  }
201
213
  const specifier = `${dependency}@${version}`;
202
- external.set(specifier, { name: dependency, version, specifier });
214
+ const requested = physical.json.hypit?.dependencyInstallEnv?.[dependency];
215
+ const previous = external.get(specifier);
216
+ const env = { ...previous?.env };
217
+ for (const [key, value] of Object.entries(requested ?? {})) {
218
+ assert(env[key] === undefined || env[key] === value, `Conflicting installation environment ${key} for ${specifier}`);
219
+ env[key] = value;
220
+ }
221
+ external.set(specifier, { name: dependency, version, specifier,
222
+ ...(Object.keys(env).length === 0 ? {} : { env }) });
203
223
  }
204
224
  }
205
225
  return [...external.values()].sort((left, right) => left.specifier.localeCompare(right.specifier));
@@ -2,6 +2,14 @@ import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "n
2
2
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
 
5
+ /** Only absence is repairable by installing a package; malformed manifests and I/O errors are not. */
6
+ export class NodePackageNotFoundError extends Error {
7
+ constructor(name: string) {
8
+ super(`cannot locate installed package ${name}`);
9
+ this.name = "NodePackageNotFoundError";
10
+ }
11
+ }
12
+
5
13
  export type LocatedNodePackage = {
6
14
  readonly root: string;
7
15
  readonly manifest: {
@@ -88,7 +96,8 @@ export function declaredExternalPackageRoot(root: string, from: string | URL, na
88
96
  // An upstream asset a package ships as optional is still selected by one exact version here.
89
97
  // `hypit packages install` places it under the machine npm root named by this selection, so
90
98
  // reading only the required map makes the documented repair unusable for every optional asset.
91
- const version = value.dependencies?.[name] ?? value.optionalDependencies?.[name];
99
+ // npm lets optionalDependencies override dependencies with the same name.
100
+ const version = value.optionalDependencies?.[name] ?? value.dependencies?.[name];
92
101
  return version === undefined ? undefined : externalPackageInstallRoot(root, name, version);
93
102
  }
94
103
  const parent = dirname(cursor);
@@ -223,7 +232,7 @@ export function locateNodePackage(nameValue: string, options: LocateNodePackageO
223
232
  if (found !== undefined) return found;
224
233
  }
225
234
  }
226
- throw new Error(`cannot locate installed package ${name}`);
235
+ throw new NodePackageNotFoundError(name);
227
236
  }
228
237
 
229
238
  function packageFile(
@@ -2,7 +2,7 @@
2
2
 
3
3
  Trusted local Provider for the `@hypit/render-hyperframes#render-visual` capability. It stages the
4
4
  Resource dependencies declared by a `HyperframesDocument`, renders a silent MP4 with the
5
- HyperFrames engine, probes the bytes, and returns a verified `RenderedVisual`. Before staging a typed
5
+ HyperFrames engine, probes the bytes, and returns a verified `RenderedVisual`. Before capturing a typed
6
6
  Surface it decodes the exact bytes and checks declared dimensions, still/frame timing, SDR/sRGB and
7
7
  opaque/straight-alpha facts. These checks validate the typed rendering input; they do not create
8
8
  content identity or hidden output metadata.
@@ -64,12 +64,67 @@ GPU, or `auto` to let the engine decide. Capture uses screenshots and independen
64
64
  the CLI's automatic worker and drawElement policies do not override the count. The opaque screenshot
65
65
  adapter uses the engine's public session, video injector and page seek protocol plus Chrome CDP.
66
66
  It waits for seek completion, dynamic images/fonts and the page compositor before capturing PNGs.
67
+ Image readiness includes CSS class and pseudo-element images, CSS masks and SVG images. A failed
68
+ image decode reports its URL instead of producing a successful frame with missing media. Failed
69
+ declared fonts also fail capture instead of silently leaving fallback glyphs in the output.
67
70
  The pinned engine couples its PNG session setup to transparent export, so this adapter initializes
68
71
  an opaque session and chooses PNG separately at capture. It does not patch engine methods or files.
69
72
 
70
- The Runtime Adapter also declares one managed browser program. `programs up` invokes the pinned
71
- HyperFrames CLI's `browser ensure`; its probe resolves and starts that browser and checks
72
- the FFmpeg/FFprobe toolchain before a Build.
73
+ The Runtime Adapter declares one managed browser program. Prepare it explicitly:
74
+
75
+ ```sh
76
+ hypit programs up --runtime ./hypit.runtime.json --endpoint hyperframes.local
77
+ hypit doctor --runtime ./hypit.runtime.json
78
+ ```
79
+
80
+ Use the instance name from the Profile. `runtime up` also prepares it and starts the Runtime Worker.
81
+ The Provider's `package.json` declares its recommended Chrome Headless Shell version in
82
+ `hypit.renderBrowser.version`, alongside the engine dependency used to test that release. The
83
+ installer consumes that declaration; it does not have a browser version constant or fetch a
84
+ "latest" channel. `config.browserVersion` explicitly selects another exact four-part version.
85
+ Changing the recommendation belongs to a Provider release and requires real rendering tests; a
86
+ Puppeteer recommendation alone is not a HyperFrames compatibility guarantee.
87
+
88
+ The default cache is `~/.cache/hyperframes/chrome`; `config.browserCacheDirectory` selects another
89
+ location. Only the selected version is used, regardless of other cached or system browsers.
90
+ Projects sharing this cache use the existing ManagedProgram preparation lock/logs under
91
+ `.hypit-render-program`. Readiness comes from the executable and its reported version, not a receipt.
92
+ Explicit preparation reuses a healthy installation or repairs only the selected managed version.
93
+ A failed download reports failure without selecting another version or browser.
94
+
95
+ `config.browserDownloadBaseUrl` selects a Chrome for Testing archive mirror for explicit preparation.
96
+ It is an absolute HTTP(S) base URL, without credentials, a query or a fragment. The browser library
97
+ appends the selected version, platform and archive name; the mirror must serve that same layout.
98
+ For example, a base of `https://mirror.example/chrome-for-testing` serves archives beneath
99
+ `<base>/<version>/<platform>/chrome-headless-shell-<platform>.zip`. Choose a source maintained by
100
+ the user or organization; this Provider does not keep a mirror list or choose one by region.
101
+ Omitting the setting uses the browser library's official Chrome for Testing source. A configured
102
+ source replaces it: failed transfers or invalid archives fail preparation without trying the official
103
+ source or another mirror. Normal HTTP redirects supplied by the selected server are handled by the
104
+ download library. Preparation displays the complete archive URL, version and destination before
105
+ download. An npm registry setting does not redirect this binary download.
106
+
107
+ The download source only determines where missing installation bytes come from. It does not change
108
+ the executable selection, and changing it does not invalidate a healthy cached version. No source
109
+ receipt is stored. To install into an empty location, explicitly choose `browserCacheDirectory`.
110
+
111
+ `config.chromePath` selects a user-managed Chrome/Chromium executable. It cannot be combined with
112
+ `browserVersion` or `browserDownloadBaseUrl`; invalid combinations fail instead of assigning precedence. Relative paths resolve
113
+ from the Runtime Profile root. This mode never downloads or repairs a browser. Its version remains
114
+ under the user's control, including system-browser auto-updates. `HYPERFRAMES_BROWSER_PATH` and
115
+ `PRODUCER_HEADLESS_SHELL_PATH` do not select browsers in this Provider; configure `chromePath`.
116
+ On platforms without a supported managed download, explicitly select an installed browser.
117
+
118
+ `doctor` displays the selected path and its source, and only inspects it. Build preflight, rendering
119
+ and previews never install a browser. `programs up` / `runtime up` display the selected managed
120
+ version, installation location and download URL before running preparation. The probe runs `--version` and checks
121
+ FFmpeg/FFprobe; it does not promise GPU or page compatibility. Capture receives that same selected
122
+ path as the engine's `chromePath`, including its GPU probe. Active Workers keep their loaded package
123
+ recommendation; restart them explicitly after changing Profile or package dependencies.
124
+
125
+ The Provider's `hypit.dependencyInstallEnv` disables Puppeteer's browser download while preparing its
126
+ engine/producer npm dependencies. The repository `.puppeteerrc.cjs` does the same for checkout installs.
127
+ No browser postinstall allowlist is required. The former `hyperframesCliPath` option is removed.
73
128
 
74
129
  The same executor is exported for callers with an already compiled document:
75
130
 
@@ -101,6 +156,17 @@ flushes that message and disconnects IPC so it can exit normally. The owner awai
101
156
  diagnostics before returning. Normal completion does not enumerate or forcibly terminate processes.
102
157
  On failure, cleanup may be incomplete: the child reports the error and keeps IPC open while the owner
103
158
  discovers and terminates the remaining process tree, before it can become orphaned.
159
+ The capture child also installs synchronous exit cleanup before loading the engine. Early
160
+ `process.exit()`, uncaught exceptions, and catchable `SIGINT`/`SIGTERM` exits stop descendants
161
+ while their owner still exists, even if browser initialization never returned a session.
162
+ Successful resource closure removes this exit handler. No historical browser PID list is retained.
163
+
164
+ Uncatchable termination (`SIGKILL`, native crashes, or OS termination on Windows) cannot run that
165
+ cleanup. The owner reports the termination and cannot confirm descendant cleanup; it never searches
166
+ by an already-exited root PID. If an orphan keeps the output pipes open, the owner closes its pipe
167
+ ends after five seconds so the failed invocation can settle. Guaranteed cleanup after an OS hard
168
+ kill requires containment supplied by that deployment (for example a process job or container);
169
+ this local Node implementation does not provide that guarantee.
104
170
 
105
171
  At cancellation the child receives a stop request and has up to five seconds to clean up. A child
106
172
  that remains after cancellation or its completion message is forcibly terminated along with its discovered
@@ -118,7 +184,10 @@ more of the shared render budget on a slow initialization or frame.
118
184
  An explicit stage-timeout error names the worker and stage/frame, aborts sibling workers and awaits
119
185
  cleanup. A completed worker closes its Chrome immediately.
120
186
 
121
- One call stages the HTML and every declared asset once. Typed Surface validation includes a complete
187
+ One call stages the HTML and every declared asset once. Typed Surface inspection reads the completed
188
+ staged file directly, without retaining its chunks, assembling another whole-file buffer, or writing
189
+ a second temporary copy. The caller keeps that file until inspection and capture have settled.
190
+ Typed Surface validation includes a complete
122
191
  decoded-frame count, even for a short render interval. The renderer then finds source-frame windows
123
192
  needed by that interval, merges overlapping windows, and extracts them one source/window at a time.
124
193
  Decoded PNGs are shared by all workers in this call. Each worker initializes its own page, then takes
@@ -134,9 +203,13 @@ do not share staged files or decoded PNGs. Exact compiler sampling markers retai
134
203
  fractional playback rates. There is no SVML rewrite, intermediate cut MP4 or repeat normalization.
135
204
  Already compiled video documents need the current compiler's frame markers.
136
205
 
137
- Source extraction uses the pinned engine's FFmpeg/FFprobe resolver. `ffmpegPath` selects the final
138
- H.264 encoder; `ffprobePath` verifies output and typed surfaces. `nodePath` and `hyperframesCliPath`
139
- serve managed browser installation. The requested frame range travels in the Model's Need.
206
+ `ffmpegPath` selects both source decoding and final H.264 encoding; `ffprobePath` selects source,
207
+ output and typed Surface inspection. Bare commands resolve through PATH. The capture child passes
208
+ these selected executables to the engine's public binary overrides; inherited
209
+ `HYPERFRAMES_FFMPEG_PATH` / `HYPERFRAMES_FFPROBE_PATH` cannot select a different decoding toolchain.
210
+ `nodePath` selects the managed
211
+ browser installer's Node executable. Capture uses the current Node process's executable.
212
+ The requested frame range travels in the Model's Need; browser paths stay in the Provider.
140
213
 
141
214
  ```ts
142
215
  import { createLocalHyperframesProvider } from "@hypit/provider-hyperframes-local";
@@ -8,7 +8,18 @@
8
8
  ".": "./src/index.ts"
9
9
  },
10
10
  "hypit": {
11
- "activation": "./src/activation.ts"
11
+ "activation": "./src/activation.ts",
12
+ "renderBrowser": {
13
+ "version": "152.0.7928.2"
14
+ },
15
+ "dependencyInstallEnv": {
16
+ "@hyperframes/engine": {
17
+ "PUPPETEER_SKIP_DOWNLOAD": "true"
18
+ },
19
+ "@hyperframes/producer": {
20
+ "PUPPETEER_SKIP_DOWNLOAD": "true"
21
+ }
22
+ }
12
23
  },
13
24
  "dependencies": {
14
25
  "@hyperframes/engine": "0.7.101",
@@ -17,13 +28,12 @@
17
28
  "@hypit/hyperframes": "workspace:*",
18
29
  "@hypit/media": "workspace:*",
19
30
  "@hypit/media-execution": "workspace:*",
20
- "@hypit/package-loader-node": "workspace:*",
21
31
  "@hypit/protocol": "workspace:*",
22
32
  "@hypit/render-hyperframes": "workspace:*",
23
33
  "@hypit/runtime": "workspace:*",
24
34
  "@hypit/runtime-host-node": "workspace:*",
25
35
  "@hypit/runtime-kit": "workspace:*",
26
- "hyperframes": "0.7.101",
36
+ "@puppeteer/browsers": "3.2.2",
27
37
  "tsx": "4.21.0"
28
38
  },
29
39
  "devDependencies": {
@@ -1,3 +1,4 @@
1
+ import { resolve } from "node:path";
1
2
  import {
2
3
  createRuntimeEndpointAdapterFacet,
3
4
  runtimeConfigExact,
@@ -11,7 +12,7 @@ import {
11
12
  } from "@hypit/runtime-host-node";
12
13
 
13
14
  import { createLocalHyperframesProvider } from "./provider.js";
14
- import { defaultHyperframesCliPath } from "./provider.js";
15
+ import { configuredBrowserPath, browserExecutablePath, selectedBrowserVersion } from "./browser.js";
15
16
  import type { HyperframesBrowserGpu, HyperframesQuality, HyperframesWorkers } from "./provider.js";
16
17
  import { localHyperframesBrowserProgram } from "./program.js";
17
18
 
@@ -21,11 +22,10 @@ const localHyperframesRuntimeAdapter = createRuntimeEndpointAdapterFacet({
21
22
  if (context.pool === undefined) throw new Error("local HyperFrames Provider Pool is required");
22
23
  const config = runtimeConfigObject(context.config, "local HyperFrames");
23
24
  runtimeConfigExact(config, [
24
- "nodePath", "hyperframesCliPath", "ffprobePath", "ffmpegPath", "workers", "maxWorkers", "quality", "browserGpu",
25
+ "nodePath", "chromePath", "browserVersion", "browserCacheDirectory", "browserDownloadBaseUrl", "ffprobePath", "ffmpegPath", "workers", "maxWorkers", "quality", "browserGpu",
25
26
  "defaultConcurrency", "browserCapacity", "initializationTimeoutMs", "frameTimeoutMs", "processTimeoutMs", "maxProcessOutputBytes", "maxRenderedBytes",
26
27
  ], "local HyperFrames");
27
28
  runtimeConfigString(config.nodePath, "HyperFrames nodePath");
28
- runtimeConfigString(config.hyperframesCliPath, "HyperFrames hyperframesCliPath");
29
29
  runtimeConfigString(config.ffprobePath, "HyperFrames ffprobePath");
30
30
  const workers = config.workers;
31
31
  if (workers !== undefined && workers !== "auto") {
@@ -40,10 +40,21 @@ const localHyperframesRuntimeAdapter = createRuntimeEndpointAdapterFacet({
40
40
  throw new Error("HyperFrames browserGpu is invalid");
41
41
  }
42
42
  const configuredNode = runtimeConfigString(config.nodePath, "HyperFrames nodePath");
43
- const configuredCli = runtimeConfigString(config.hyperframesCliPath, "HyperFrames hyperframesCliPath");
43
+ const configuredChrome = runtimeConfigString(config.chromePath, "HyperFrames chromePath");
44
+ const browserVersion = runtimeConfigString(config.browserVersion, "HyperFrames browserVersion");
45
+ const browserDownloadBaseUrl = runtimeConfigString(config.browserDownloadBaseUrl, "HyperFrames browserDownloadBaseUrl");
46
+ const configuredCache = runtimeConfigString(config.browserCacheDirectory, "HyperFrames browserCacheDirectory");
47
+ const chromePath = configuredBrowserPath({ ...(configuredChrome === undefined ? {} : {
48
+ chromePath: resolve(context.dataRoot, configuredChrome),
49
+ }) });
50
+ const browser = {
51
+ ...(browserVersion === undefined ? {} : { browserVersion }),
52
+ ...(browserDownloadBaseUrl === undefined ? {} : { browserDownloadBaseUrl }),
53
+ ...(chromePath === undefined ? {} : { chromePath }),
54
+ ...(configuredCache === undefined ? {} : { browserCacheDirectory: resolve(context.dataRoot, configuredCache) }),
55
+ };
44
56
  const configuredFfprobe = runtimeConfigString(config.ffprobePath, "HyperFrames ffprobePath");
45
57
  const nodePath = resolveRuntimeExecutable(context.dataRoot, configuredNode ?? process.execPath);
46
- const hyperframesCliPath = () => resolveRuntimeExecutable(context.dataRoot, configuredCli ?? defaultHyperframesCliPath());
47
58
  const configuredFfmpeg = runtimeConfigString(config.ffmpegPath, "HyperFrames ffmpegPath");
48
59
  const ffmpegPath = resolveRuntimeExecutable(context.dataRoot, configuredFfmpeg ?? "ffmpeg");
49
60
  const ffprobePath = resolveRuntimeExecutable(context.dataRoot, configuredFfprobe ?? "ffprobe");
@@ -60,6 +71,7 @@ const localHyperframesRuntimeAdapter = createRuntimeEndpointAdapterFacet({
60
71
  instance: context.instance,
61
72
  pool: context.pool,
62
73
  nodePath,
74
+ ...browser,
63
75
  ffprobePath,
64
76
  ffmpegPath,
65
77
  ...(workers === undefined ? {} : { workers: workers as HyperframesWorkers }),
@@ -77,11 +89,13 @@ const localHyperframesRuntimeAdapter = createRuntimeEndpointAdapterFacet({
77
89
  program: localHyperframesBrowserProgram({
78
90
  id: context.instance,
79
91
  nodePath,
80
- hyperframesCliPath,
92
+ ...browser,
81
93
  ffprobePath,
82
94
  ffmpegPath,
83
95
  }),
84
96
  diagnose: async () => [
97
+ { severity: "info", code: "HYPERFRAMES_BROWSER_SELECTION", subject: context.instance,
98
+ message: `${chromePath === undefined ? `Managed Chrome Headless Shell ${selectedBrowserVersion(browser)} (${browserVersion === undefined ? "Provider recommendation" : "Profile version"})` : "Profile browser"}: ${browserExecutablePath(browser)}` },
85
99
  ...await diagnoseRuntimeExecutable({ root: context.dataRoot, configured: configuredFfmpeg, fallback: "ffmpeg", subject: "FFmpeg" }),
86
100
  ...await diagnoseRuntimeExecutable({
87
101
  root: context.dataRoot,
@@ -0,0 +1,5 @@
1
+ import { installRenderBrowser } from "./browser.js";
2
+
3
+ const [cacheDir, version, baseUrl] = process.argv.slice(2);
4
+ if (process.argv.length < 4 || process.argv.length > 5 || !cacheDir || !version) throw new Error("Expected the render browser cache directory, exact version and optional archive base URL");
5
+ await installRenderBrowser(cacheDir, version, baseUrl);