@hypit/hypit 0.1.12 → 0.1.14

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 (74) hide show
  1. package/README.md +36 -2
  2. package/bin/hypit.mjs +0 -2
  3. package/dist/public/model-kit.d.ts +16 -0
  4. package/package.json +3 -1
  5. package/packages/cli/README.md +3 -0
  6. package/packages/cli/package.json +1 -0
  7. package/packages/cli/src/commands/environment.ts +12 -7
  8. package/packages/cli/src/commands/results.ts +19 -3
  9. package/packages/cli/src/main.ts +2 -1
  10. package/packages/cli/src/oauth.ts +50 -7
  11. package/packages/cli/src/output.ts +2 -2
  12. package/packages/cli/src/source-discovery.ts +2 -2
  13. package/packages/credential-store-file/README.md +60 -0
  14. package/packages/credential-store-file/package.json +21 -0
  15. package/packages/credential-store-file/src/activation.ts +29 -0
  16. package/packages/credential-store-file/src/index.ts +1 -0
  17. package/packages/credential-store-file/src/store.ts +89 -0
  18. package/packages/fonts-open/src/surface.ts +8 -2
  19. package/packages/hyperframes/README.md +16 -2
  20. package/packages/hyperframes/src/browser-program.ts +6 -1
  21. package/packages/hyperframes/src/document.ts +31 -21
  22. package/packages/hyperframes/src/project.ts +11 -20
  23. package/packages/media-execution/README.md +7 -0
  24. package/packages/media-execution/src/execute.ts +5 -5
  25. package/packages/media-execution/src/index.ts +1 -1
  26. package/packages/media-execution/src/process-env.ts +20 -0
  27. package/packages/media-execution/src/surface.ts +83 -76
  28. package/packages/model-kit/README.md +28 -0
  29. package/packages/model-kit/src/index.ts +58 -19
  30. package/packages/package-loader-node/README.md +10 -0
  31. package/packages/package-loader-node/src/index.ts +1 -0
  32. package/packages/package-loader-node/src/loader.ts +25 -5
  33. package/packages/package-loader-node/src/location.ts +11 -2
  34. package/packages/project-context-node/README.md +2 -0
  35. package/packages/project-context-node/src/project-context.ts +5 -3
  36. package/packages/provider-hyperframes-local/README.md +95 -13
  37. package/packages/provider-hyperframes-local/package.json +13 -3
  38. package/packages/provider-hyperframes-local/src/activation.ts +20 -6
  39. package/packages/provider-hyperframes-local/src/browser-install.ts +5 -0
  40. package/packages/provider-hyperframes-local/src/browser.ts +118 -0
  41. package/packages/provider-hyperframes-local/src/capture-bootstrap.ts +4 -6
  42. package/packages/provider-hyperframes-local/src/capture-exit.ts +24 -0
  43. package/packages/provider-hyperframes-local/src/capture-process.ts +54 -59
  44. package/packages/provider-hyperframes-local/src/capture-worker.ts +28 -7
  45. package/packages/provider-hyperframes-local/src/capture.ts +12 -5
  46. package/packages/provider-hyperframes-local/src/opaque-capture.ts +46 -18
  47. package/packages/provider-hyperframes-local/src/options.ts +2 -2
  48. package/packages/provider-hyperframes-local/src/process-tree.ts +82 -0
  49. package/packages/provider-hyperframes-local/src/process.ts +22 -0
  50. package/packages/provider-hyperframes-local/src/program.ts +25 -33
  51. package/packages/provider-hyperframes-local/src/provider.ts +4 -7
  52. package/packages/provider-hyperframes-local/src/render.ts +9 -4
  53. package/packages/provider-hypihub/README.md +3 -0
  54. package/packages/runtime-host-node/README.md +5 -0
  55. package/packages/runtime-host-node/src/index.ts +7 -2
  56. package/packages/runtime-host-node/src/packages.ts +13 -7
  57. package/packages/runtime-local/README.md +10 -0
  58. package/packages/runtime-local/src/config.ts +1 -1
  59. package/packages/runtime-local/src/credentials.ts +10 -5
  60. package/packages/runtime-local/src/runtime.ts +1 -1
  61. package/packages/seedance/README.md +42 -0
  62. package/packages/seedance/src/index.ts +5 -22
  63. package/packages/seedance/src/surface.ts +2 -3
  64. package/packages/seedance/src/validation.ts +16 -0
  65. package/packages/studio/src/preview/runtime-shim.ts +17 -3
  66. package/packages/studio/src/server.ts +2 -2
  67. package/packages/video-cli/README.md +5 -1
  68. package/packages/video-cli/package.json +1 -0
  69. package/packages/video-cli/src/distribution.ts +3 -10
  70. package/packages/video-cli/src/version.ts +1 -1
  71. package/packages/workspace-fs-node/src/workspace.ts +2 -2
  72. package/packages/yt-dlp/README.md +3 -2
  73. package/packages/yt-dlp/package.json +4 -0
  74. package/packages/yt-dlp/src/download.ts +10 -14
@@ -0,0 +1,118 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { access, stat } from "node:fs/promises";
4
+ import { constants, readFileSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { join, resolve } from "node:path";
7
+ import { Browser, computeExecutablePath, detectBrowserPlatform, getDownloadUrl, install, uninstall } from "@puppeteer/browsers";
8
+
9
+ // The Provider's release owns this recommendation alongside its engine dependencies.
10
+ // It is installation input, not a second browser discovery policy in executable code.
11
+ const manifest = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
12
+ export const recommendedBrowserVersion: string = exactBrowserVersion(manifest.hypit?.renderBrowser?.version);
13
+
14
+ function exactBrowserVersion(value: unknown): string {
15
+ if (typeof value !== "string" || !/^\d+\.\d+\.\d+\.\d+$/u.test(value)) {
16
+ throw new Error("HyperFrames browserVersion must be an exact four-part Chrome version; channels such as latest are not selections");
17
+ }
18
+ return value;
19
+ }
20
+
21
+ export type BrowserOptions = {
22
+ readonly chromePath?: string;
23
+ readonly browserVersion?: string;
24
+ readonly browserCacheDirectory?: string;
25
+ /** Chrome for Testing archive base URL; used only during explicit preparation. */
26
+ readonly browserDownloadBaseUrl?: string;
27
+ };
28
+
29
+ export function browserDownloadBaseUrl(options: BrowserOptions): string | undefined {
30
+ const value = options.browserDownloadBaseUrl;
31
+ if (value === undefined) return undefined;
32
+ if (options.chromePath !== undefined) {
33
+ throw new Error("HyperFrames chromePath and browserDownloadBaseUrl are mutually exclusive; a user-managed browser is not downloaded");
34
+ }
35
+ let url: URL;
36
+ try { url = new URL(value); } catch {
37
+ throw new Error("HyperFrames browserDownloadBaseUrl must be an absolute HTTP(S) archive base URL");
38
+ }
39
+ if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
40
+ throw new Error("HyperFrames browserDownloadBaseUrl must be an HTTP(S) archive base URL without credentials, query or fragment");
41
+ }
42
+ return url.href.replace(/\/+$/u, "");
43
+ }
44
+
45
+ export function browserDownloadUrl(options: BrowserOptions): string {
46
+ const baseUrl = browserDownloadBaseUrl(options);
47
+ const version = selectedBrowserVersion(options);
48
+ if (version === undefined) throw new Error("A user-managed browser has no download URL");
49
+ const platform = detectBrowserPlatform();
50
+ if (platform === undefined) throw new Error("The managed render browser is unavailable for this host; select chromePath");
51
+ return getDownloadUrl(Browser.CHROMEHEADLESSSHELL, platform, version, baseUrl).href;
52
+ }
53
+
54
+ /** Only Profile/caller configuration selects a browser; environment hints are not inputs. */
55
+ export function configuredBrowserPath(options: BrowserOptions): string | undefined {
56
+ browserDownloadBaseUrl(options);
57
+ if (options.chromePath !== undefined && options.browserVersion !== undefined) {
58
+ throw new Error("HyperFrames chromePath and browserVersion are mutually exclusive");
59
+ }
60
+ if (options.chromePath === "") throw new Error("HyperFrames chromePath must not be empty");
61
+ return options.chromePath === undefined ? undefined : resolve(options.chromePath);
62
+ }
63
+
64
+ export function selectedBrowserVersion(options: BrowserOptions): string | undefined {
65
+ return configuredBrowserPath(options) === undefined
66
+ ? exactBrowserVersion(options.browserVersion ?? recommendedBrowserVersion) : undefined;
67
+ }
68
+
69
+ export function browserCacheDirectory(options: BrowserOptions): string {
70
+ return resolve(options.browserCacheDirectory ?? join(homedir(), ".cache", "hyperframes", "chrome"));
71
+ }
72
+
73
+ export function browserExecutablePath(options: BrowserOptions): string {
74
+ const configured = configuredBrowserPath(options);
75
+ if (configured !== undefined) return configured;
76
+ const version = selectedBrowserVersion(options)!;
77
+ try {
78
+ return computeExecutablePath({
79
+ browser: Browser.CHROMEHEADLESSSHELL, buildId: version, cacheDir: browserCacheDirectory(options),
80
+ });
81
+ } catch (error) {
82
+ throw new Error("The managed render browser is unavailable for this host. Select an installed Chrome/Chromium with the Provider's chromePath.", { cause: error });
83
+ }
84
+ }
85
+
86
+ export async function requireBrowserExecutable(path: string, version?: string): Promise<void> {
87
+ try {
88
+ if (!(await stat(path)).isFile()) throw new Error("Browser path is not a file");
89
+ await access(path, constants.X_OK);
90
+ const { stdout } = await promisify(execFile)(path, ["--version"], { timeout: 15_000, windowsHide: true });
91
+ if (!stdout.trim()) throw new Error("Browser returned no version");
92
+ if (version !== undefined && !stdout.trim().split(/\s+/u).includes(version)) {
93
+ throw new Error(`Expected Chrome Headless Shell ${version}, received ${stdout.trim()}`);
94
+ }
95
+ }
96
+ catch (error) {
97
+ throw new Error(`Render browser is unavailable at ${path}: ${error instanceof Error ? error.message : String(error)}. Prepare the selected Runtime with hypit runtime up --runtime <profile>, or correct its chromePath.`, { cause: error });
98
+ }
99
+ }
100
+
101
+ /** Called only by the selected ManagedProgram's explicit installation command. */
102
+ export async function installRenderBrowser(cacheDir: string, version: string, downloadBaseUrl?: string): Promise<void> {
103
+ const buildId = exactBrowserVersion(version);
104
+ const options = { browserVersion: buildId, ...(downloadBaseUrl === undefined ? {} : { browserDownloadBaseUrl: downloadBaseUrl }) };
105
+ const baseUrl = browserDownloadBaseUrl(options);
106
+ const url = browserDownloadUrl(options);
107
+ const path = computeExecutablePath({ browser: Browser.CHROMEHEADLESSSHELL, buildId, cacheDir });
108
+ process.stdout.write(`Preparing Chrome Headless Shell ${buildId} at ${path}\nDownload source: ${url}\n`);
109
+ try { await requireBrowserExecutable(path, buildId); return; } catch { /* Explicit preparation repairs this selection only. */ }
110
+ await uninstall({ browser: Browser.CHROMEHEADLESSSHELL, buildId, cacheDir });
111
+ try {
112
+ // A single baseUrl uses the library's DefaultProvider without its provider-chain fallback.
113
+ await install({ browser: Browser.CHROMEHEADLESSSHELL, buildId, cacheDir, ...(baseUrl === undefined ? {} : { baseUrl }) });
114
+ await requireBrowserExecutable(path, buildId);
115
+ } catch (error) {
116
+ throw new Error(`Could not prepare Chrome Headless Shell ${buildId} from ${url} at ${path}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
117
+ }
118
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Preloaded into the disposable capture process, which never enters `bin/hypit.mjs`.
2
+ * Preloaded into capture and browser-install processes, which never enter `bin/hypit.mjs`.
3
3
  *
4
4
  * `module.registerHooks` is process-local, so the resolver the launcher installs does not
5
5
  * reach a child that Node starts directly. This registers the same resolution environment
@@ -11,11 +11,9 @@
11
11
  */
12
12
  import { resolve } from "node:path";
13
13
 
14
- // `bin/hypit.mjs` publishes this for every process it starts; the fallback mirrors
15
- // `packages/video-cli/src/distribution.ts` for callers that import a Distribution directly.
16
- const distributionRoot = resolve(
17
- process.env.HYPIT_DISTRIBUTION_ROOT ?? resolve(import.meta.dirname, "../../.."),
18
- );
14
+ // This preload belongs to the Provider the parent actually loaded. Inherited shell
15
+ // hints must not redirect its dependencies to a different Hypit installation.
16
+ const distributionRoot = resolve(import.meta.dirname, "../../..");
19
17
 
20
18
  const { installDistributionPackageResolution, installExternalPackageResolution } =
21
19
  await import("../../package-loader-node/src/distribution-resolution.js");
@@ -0,0 +1,24 @@
1
+ import { writeSync } from "node:fs";
2
+ import { killRenderDescendantsSync } from "./process-tree.js";
3
+
4
+ // Installed before loading the engine: process.exit and uncaught exceptions can
5
+ // bypass async finally blocks, including during engine import/browser launch.
6
+ // Keep cleanup inside the still-live owner, not a history of browser PIDs.
7
+ const cleanup = () => {
8
+ try { killRenderDescendantsSync(process.pid); }
9
+ catch (error) {
10
+ writeSync(2, `Render exit cleanup could not be confirmed: ${String(error)}\n`);
11
+ }
12
+ };
13
+ const terminate = () => process.exit(143);
14
+ const interrupt = () => process.exit(130);
15
+ process.once("exit", cleanup);
16
+ process.once("SIGTERM", terminate);
17
+ process.once("SIGINT", interrupt);
18
+
19
+ /** Call only after capture has closed every resource successfully. */
20
+ export function releaseCaptureExitCleanup(): void {
21
+ process.off("exit", cleanup);
22
+ process.off("SIGTERM", terminate);
23
+ process.off("SIGINT", interrupt);
24
+ }
@@ -1,58 +1,12 @@
1
1
  import type { ExecutionDiagnostic } from "@hypit/runtime";
2
- import { execFile, spawn } from "node:child_process";
3
- import { promisify } from "node:util";
2
+ import { spawn } from "node:child_process";
4
3
  import { fileURLToPath } from "node:url";
5
- import { setTimeout as delay } from "node:timers/promises";
4
+ import { killRenderTree } from "./process-tree.js";
6
5
  import type { CaptureInput } from "./capture.js";
7
6
  import type { HyperframesRenderProgress } from "./render.js";
8
7
 
9
- const exec = promisify(execFile);
10
8
  const cleanupMs = 5_000;
11
9
 
12
- /** Chrome starts its own process group, so stopping only the Node child is insufficient. */
13
- async function killRenderTree(pid: number): Promise<void> {
14
- if (process.platform === "win32") {
15
- try {
16
- await exec("taskkill", ["/PID", String(pid), "/T", "/F"], { windowsHide: true, timeout: cleanupMs });
17
- } catch (error) {
18
- // The worker may exit after reporting completion, before taskkill opens it.
19
- // Ask the OS whether it is gone; localized taskkill output is not an API.
20
- try { process.kill(pid, 0); }
21
- catch (probeError) {
22
- if ((probeError as NodeJS.ErrnoException).code === "ESRCH") return;
23
- }
24
- throw error;
25
- }
26
- return;
27
- }
28
- const descendants = [pid];
29
- for (let i = 0; i < descendants.length; i++) {
30
- const children = await exec("pgrep", ["-P", String(descendants[i])], { timeout: cleanupMs })
31
- .then(({ stdout }) => stdout.trim().split(/\s+/u).filter(Boolean).map(Number),
32
- (error) => { if ((error as { code?: unknown }).code === 1) return []; throw error; });
33
- for (const child of children) if (!descendants.includes(child)) descendants.push(child);
34
- }
35
- // Kill the whole tree before waiting: only after every ancestor is dead are
36
- // orphaned descendants reparented and reaped, making kill(pid, 0) read ESRCH.
37
- for (const child of descendants.reverse()) {
38
- for (const target of [-child, child]) {
39
- try { process.kill(target, "SIGKILL"); }
40
- catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; }
41
- }
42
- }
43
- const deadline = Date.now() + cleanupMs;
44
- let remaining = descendants;
45
- while (remaining.length > 0) {
46
- remaining = remaining.filter((child) => {
47
- try { process.kill(child, 0); return true; }
48
- catch (error) { if ((error as NodeJS.ErrnoException).code === "ESRCH") return false; throw error; }
49
- });
50
- if (remaining.length === 0) break;
51
- if (Date.now() >= deadline) throw new Error(`Render process ${remaining[0]} did not stop after SIGKILL`);
52
- await delay(20);
53
- }
54
- }
55
-
56
10
  /** One disposable execution, with no persisted state or resubmission behavior. */
57
11
  export async function runCaptureProcess(
58
12
  input: CaptureInput,
@@ -68,6 +22,7 @@ export async function runCaptureProcess(
68
22
  const child = spawn(process.execPath, [
69
23
  "--import", import.meta.resolve("tsx"),
70
24
  "--import", new URL("./capture-bootstrap.ts", import.meta.url).href,
25
+ "--import", new URL("./capture-exit.ts", import.meta.url).href,
71
26
  fileURLToPath(entry),
72
27
  ], {
73
28
  detached: process.platform !== "win32", windowsHide: true,
@@ -75,6 +30,8 @@ export async function runCaptureProcess(
75
30
  });
76
31
  let failure: Error | undefined;
77
32
  let completed = false;
33
+ let closed = false;
34
+ let exited = false;
78
35
  let outputBytes = 0;
79
36
  let stderr = "";
80
37
  let diagnostics = Promise.resolve();
@@ -82,16 +39,39 @@ export async function runCaptureProcess(
82
39
  let killing: Promise<void> | undefined;
83
40
  const kill = () => {
84
41
  if (grace !== undefined) clearTimeout(grace);
42
+ grace = undefined;
43
+ // A PID is no longer ours after exit. Inherited pipes can outlive it, but
44
+ // looking up that old PID cannot recover the former process tree safely.
45
+ if (exited) {
46
+ child.stdout?.destroy();
47
+ child.stderr?.destroy();
48
+ return Promise.resolve();
49
+ }
85
50
  killing ??= (child.pid === undefined ? Promise.resolve() : killRenderTree(child.pid)).catch((error) => {
86
51
  if (!completed) failure = new Error(`${failure?.message ?? "Render cleanup failed"}; ${String(error)}`);
52
+ diagnostic({ level: "warning", message: `Render process-tree cleanup could not be confirmed: ${String(error)}` });
87
53
  child.kill("SIGKILL");
88
54
  });
89
55
  return killing;
90
56
  };
91
57
  const stop = (error: Error) => {
92
58
  failure ??= error;
59
+ if (closed) return;
93
60
  if (child.connected) child.send({ type: "abort", error: failure.message }, () => {});
94
- grace ??= setTimeout(() => { void kill(); }, cleanupMs);
61
+ awaitExit();
62
+ };
63
+ const diagnostic = (value: ExecutionDiagnostic) => {
64
+ if (onDiagnostic === undefined) return;
65
+ diagnostics = diagnostics.then(() => onDiagnostic(value))
66
+ .catch((error) => stop(error instanceof Error ? error : new Error(String(error))));
67
+ };
68
+ const awaitExit = () => {
69
+ grace ??= setTimeout(() => {
70
+ diagnostic({ level: "warning", message: exited
71
+ ? `Render process exited but its output pipes did not close within ${cleanupMs} ms; closing this execution's pipes`
72
+ : `Render process did not exit within ${cleanupMs} ms; terminating its remaining process tree` });
73
+ void kill();
74
+ }, cleanupMs);
95
75
  };
96
76
  const abort = () => stop(signal.reason instanceof Error ? signal.reason : new Error(String(signal.reason)));
97
77
  signal.addEventListener("abort", abort, { once: true });
@@ -104,10 +84,7 @@ export async function runCaptureProcess(
104
84
  pipe?.setEncoding("utf8");
105
85
  pipe?.on("data", (text: string) => {
106
86
  log(Buffer.from(text), stream === "stderr");
107
- if (onDiagnostic !== undefined && text.trim()) {
108
- diagnostics = diagnostics.then(() => onDiagnostic({ stream, level: "info", message: text.trimEnd() }))
109
- .catch((error) => stop(error instanceof Error ? error : new Error(String(error))));
110
- }
87
+ if (text.trim()) diagnostic({ stream, level: "info", message: text.trimEnd() });
111
88
  });
112
89
  }
113
90
  child.on("message", (value: { type: string; event?: HyperframesRenderProgress; error?: string }) => {
@@ -117,19 +94,37 @@ export async function runCaptureProcess(
117
94
  stop(new Error(value.error));
118
95
  } else if (value.type === "completed" || value.type === "failed") {
119
96
  completed = value.type === "completed";
120
- if (!completed) failure ??= new Error(value.error);
121
- // The worker has finished cleanup. Terminate any leftover descendants before releasing capacity.
122
- void kill();
97
+ if (completed) {
98
+ // Successful capture has closed its resources and will disconnect after this message.
99
+ awaitExit();
100
+ } else {
101
+ failure ??= new Error(value.error);
102
+ // Resource cleanup may itself have failed. Keep the worker alive until
103
+ // its remaining descendants have been discovered and terminated.
104
+ void kill();
105
+ }
123
106
  }
124
107
  });
125
108
  child.on("error", (error) => { failure ??= error; void kill(); });
126
- child.on("close", () => {
109
+ child.once("exit", (code, exitSignal) => {
110
+ exited = true;
111
+ if (!completed && failure === undefined) {
112
+ failure = new Error(`HyperFrames process exited before completion (${exitSignal ?? `code ${String(code)}`})`);
113
+ }
114
+ if (exitSignal !== null && killing === undefined) {
115
+ diagnostic({ level: "warning", message: `Render process was terminated by ${exitSignal}; descendant cleanup could not be confirmed` });
116
+ }
117
+ // An abruptly orphaned descendant may still hold stdout/stderr open.
118
+ awaitExit();
119
+ });
120
+ child.on("close", (code, exitSignal) => {
121
+ closed = true;
127
122
  if (grace !== undefined) clearTimeout(grace);
128
123
  signal.removeEventListener("abort", abort);
129
124
  void (killing ?? Promise.resolve()).then(async () => {
130
125
  await diagnostics;
131
- if (failure !== undefined) reject(failure);
132
- else if (!completed) reject(new Error(`HyperFrames process exited before completion: ${stderr}`));
126
+ if (failure !== undefined) reject(new Error(`${failure.message}${stderr ? `\n${stderr}` : ""}`, { cause: failure }));
127
+ else if (!completed) reject(new Error(`HyperFrames process exited before completion (${exitSignal ?? `code ${String(code)}`}): ${stderr}`));
133
128
  else resolve();
134
129
  });
135
130
  });
@@ -1,17 +1,38 @@
1
+ import { releaseCaptureExitCleanup } from "./capture-exit.js";
1
2
  import { captureStagedVisual } from "./capture.js";
2
3
  import type { CaptureInput } from "./capture.js";
3
4
 
4
5
  const controller = new AbortController();
5
6
  const message = (error: unknown) => error instanceof Error ? error.message : String(error);
7
+ const disconnected = () => controller.abort(new Error("Render owner disconnected"));
8
+ const report = (value: object) => {
9
+ if (process.connected) process.send?.(value, undefined, undefined, (error) => { if (error) controller.abort(error); });
10
+ };
11
+ const finish = (value: { type: "completed" } | { type: "failed"; error: string }) => {
12
+ // Failure can leave descendants behind. Keep the owner connected until it has
13
+ // discovered and stopped that tree, rather than orphaning it by exiting first.
14
+ if (value.type === "failed") { report(value); return; }
15
+ // Successful capture has closed its resources. Flush the result before closing our IPC handle;
16
+ // our own disconnect is not a cancellation by the owner.
17
+ releaseCaptureExitCleanup();
18
+ process.off("disconnect", disconnected);
19
+ process.off("message", receive);
20
+ if (!process.connected) return;
21
+ process.send?.(value, undefined, undefined, (error) => {
22
+ if (error) { console.error(message(error)); process.exitCode = 1; }
23
+ if (process.connected) process.disconnect();
24
+ });
25
+ };
6
26
  controller.signal.addEventListener("abort", () => {
7
- process.send?.({ type: "stopping", error: message(controller.signal.reason) });
27
+ report({ type: "stopping", error: message(controller.signal.reason) });
8
28
  }, { once: true });
9
- process.on("message", (value: { type: "start"; input: CaptureInput } | { type: "abort"; error: string }) => {
29
+ const receive = (value: { type: "start"; input: CaptureInput } | { type: "abort"; error: string }) => {
10
30
  if (value.type === "abort") { controller.abort(new Error(value.error)); return; }
11
31
  void captureStagedVisual(value.input, controller,
12
- (event) => process.send?.({ type: "progress", event })).then(
13
- () => process.send?.({ type: "completed" }),
14
- (error) => process.send?.({ type: "failed", error: message(error) }),
32
+ (event) => report({ type: "progress", event })).then(
33
+ () => finish({ type: "completed" }),
34
+ (error) => finish({ type: "failed", error: message(error) }),
15
35
  );
16
- });
17
- process.on("disconnect", () => controller.abort(new Error("Render owner disconnected")));
36
+ };
37
+ process.on("message", receive);
38
+ process.on("disconnect", disconnected);
@@ -3,7 +3,7 @@ import { join, relative, resolve, sep } from "node:path";
3
3
  import type { HyperframesDocument } from "@hypit/hyperframes";
4
4
  import type { MediaFrameRange } from "@hypit/media";
5
5
  import type { HyperframesRenderProgress, resolveExecutionOptions } from "./render.js";
6
- import { assert, runProcess } from "./process.js";
6
+ import { assert, mediaExecutablePath, runProcess } from "./process.js";
7
7
  import { verifyOutput } from "./output.js";
8
8
  import { distributeFrameRange, sourceFrameAt, sourceWindows, videoSlots } from "./sampling.js";
9
9
  import { renderWorkerLimit } from "./render.js";
@@ -13,7 +13,7 @@ import { createOpaqueFrameCapture } from "./opaque-capture.js";
13
13
  export type CaptureInput = {
14
14
  readonly document: HyperframesDocument;
15
15
  readonly range: MediaFrameRange;
16
- readonly config: ReturnType<typeof resolveExecutionOptions>;
16
+ readonly config: ReturnType<typeof resolveExecutionOptions> & { readonly chromePath: string };
17
17
  readonly directory: string;
18
18
  readonly engineModule: string;
19
19
  readonly producerModule: string;
@@ -36,6 +36,13 @@ export async function captureStagedVisual(input: CaptureInput, controller: Abort
36
36
  const started = performance.now();
37
37
  const elapsedMs = () => Math.round(performance.now() - started);
38
38
  const engine = await import(input.engineModule) as typeof import("@hyperframes/engine");
39
+ const [ffmpegPath, ffprobePath] = await Promise.all([
40
+ mediaExecutablePath(config.ffmpegPath), mediaExecutablePath(config.ffprobePath),
41
+ ]);
42
+ // This is one disposable render process. Use the engine's public override
43
+ // ports here without changing the Runtime owner's environment or other renders.
44
+ process.env[engine.FFMPEG_PATH_ENV] = ffmpegPath;
45
+ process.env[engine.FFPROBE_PATH_ENV] = ffprobePath;
39
46
  const { createFileServer } = await import(input.producerModule) as typeof import("@hyperframes/producer");
40
47
  type Session = Awaited<ReturnType<typeof engine.createCaptureSession>>;
41
48
  const sessions = new Set<Session>();
@@ -158,7 +165,7 @@ export async function captureStagedVisual(input: CaptureInput, controller: Abort
158
165
  const source = sources.get(slot.src);
159
166
  return source === undefined ? [] : [{ id: slot.id, width: source.width, height: source.height }];
160
167
  }),
161
- }, injector, { browserGpuMode: config.browserGpu, enableBrowserPool: false, forceScreenshot: true, useDrawElement: false });
168
+ }, injector, { chromePath: config.chromePath, browserGpuMode: config.browserGpu, enableBrowserPool: false, forceScreenshot: true, useDrawElement: false });
162
169
  sessions.add(session);
163
170
  signal.throwIfAborted();
164
171
  const activeSession = session;
@@ -235,14 +242,14 @@ export async function captureStagedVisual(input: CaptureInput, controller: Abort
235
242
  const output = join(work, "visual.mp4");
236
243
  onProgress({ phase: "encoding", elapsedMs: elapsedMs() });
237
244
  const crf = { draft: 28, standard: 23, high: 18 }[config.quality];
238
- await runProcess({ executable: config.ffmpegPath,
245
+ await runProcess({ executable: ffmpegPath,
239
246
  argv: ["-v", "error", "-y", "-framerate", `${fps.num}/${fps.den}`, "-i", join(outputFrames, "%09d.png"),
240
247
  "-frames:v", String(frameCount), "-an", "-c:v", "libx264", "-crf", String(crf),
241
248
  "-preset", config.quality === "draft" ? "veryfast" : "medium", "-pix_fmt", "yuv420p", "-movflags", "+faststart", output],
242
249
  timeoutMs: config.processTimeoutMs, maxOutputBytes: config.maxProcessOutputBytes, signal });
243
250
  const outputStat = await stat(output);
244
251
  assert(outputStat.size > 0 && outputStat.size <= config.maxRenderedBytes, "HyperFrames output is empty or exceeds its byte limit");
245
- await verifyOutput({ path: output, document: { ...document, frameCount }, ffprobePath: config.ffprobePath,
252
+ await verifyOutput({ path: output, document: { ...document, frameCount }, ffprobePath,
246
253
  timeoutMs: config.processTimeoutMs, maxOutputBytes: config.maxProcessOutputBytes, signal });
247
254
  signal.throwIfAborted();
248
255
  return output;
@@ -22,33 +22,61 @@ export async function createOpaqueFrameCapture(session: CaptureSession) {
22
22
  const cdp = await getCdpSession(page);
23
23
  // MP4 has no alpha channel. The authored Canvas paints over this final matte.
24
24
  await cdp.send("Emulation.setDefaultBackgroundColorOverride", { color: { r: 0, g: 0, b: 0, a: 1 } });
25
- // These decoded image handles belong to this page, just like its DOM. Retain
26
- // only URLs used by inline background images; changing a style can need a load
27
- // even after initializeSession has finished its initial resource readiness.
25
+ // Image readiness belongs to this page. A seek can select a new image through
26
+ // attributes, a CSS class or a pseudo-element after initial page readiness.
28
27
  await page.evaluate(() => {
29
28
  const decoded = new Map<string, Promise<void>>();
29
+ const images = {
30
+ load(url: string): Promise<void> {
31
+ let ready = decoded.get(url);
32
+ if (ready === undefined) {
33
+ const image = new Image();
34
+ image.src = url;
35
+ ready = image.decode().catch(error => {
36
+ throw new Error(`HyperFrames image could not be decoded: ${url}`, { cause: error });
37
+ });
38
+ decoded.set(url, ready);
39
+ }
40
+ return ready;
41
+ },
42
+ background(value: string, pending: Promise<unknown>[]) {
43
+ for (const match of value.matchAll(/url\(\s*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|([^\s"'()]+))\s*\)/gu)) {
44
+ const url = (match[1] ?? match[2] ?? match[3] ?? "").trim();
45
+ if (url) pending.push(this.load(url));
46
+ }
47
+ },
48
+ style(style: CSSStyleDeclaration, pending: Promise<unknown>[]) {
49
+ for (const value of [style.backgroundImage, style.maskImage, style.borderImageSource, style.listStyleImage, style.content]) {
50
+ this.background(value, pending);
51
+ }
52
+ },
53
+ };
30
54
  (window as unknown as { __hypitPrepareImages: () => Promise<void> }).__hypitPrepareImages = async () => {
31
55
  const pending: Promise<unknown>[] = [];
32
- if (document.fonts.status === "loading") pending.push(document.fonts.ready);
33
56
  for (const image of Array.from(document.images)) {
34
- if (!image.complete) pending.push(image.decode());
57
+ if ((image.currentSrc || image.getAttribute("src") || image.getAttribute("srcset"))
58
+ && (!image.complete || image.naturalWidth === 0)) {
59
+ pending.push(image.decode().catch(error => {
60
+ throw new Error(`HyperFrames image could not be decoded: ${image.currentSrc || image.src}`, { cause: error });
61
+ }));
62
+ }
35
63
  }
36
- for (const element of Array.from(document.querySelectorAll<HTMLElement>('[style*="background"]'))) {
37
- const background = element.style.backgroundImage;
38
- for (const match of background.matchAll(/url\(\s*(?:"((?:\\.|[^"])*)"|'((?:\\.|[^'])*)'|([^)]*))\s*\)/gu)) {
39
- const url = (match[1] ?? match[2] ?? match[3] ?? "").trim();
40
- if (!url) continue;
41
- let ready = decoded.get(url);
42
- if (ready === undefined) {
43
- const image = new Image();
44
- image.src = url;
45
- ready = image.decode();
46
- decoded.set(url, ready);
47
- }
48
- pending.push(ready);
64
+ for (const element of Array.from(document.querySelectorAll("*"))) {
65
+ images.style(getComputedStyle(element), pending);
66
+ for (const pseudo of ["::before", "::after"]) {
67
+ const style = getComputedStyle(element, pseudo);
68
+ if (style.content !== "none" && style.content !== "normal") images.style(style, pending);
49
69
  }
50
70
  }
71
+ for (const image of Array.from(document.querySelectorAll("svg image"))) {
72
+ const href = image.getAttribute("href") ?? image.getAttributeNS("http://www.w3.org/1999/xlink", "href");
73
+ if (href) pending.push(images.load(new URL(href, document.baseURI).href));
74
+ }
51
75
  await Promise.all(pending);
76
+ await document.fonts.ready;
77
+ document.fonts.forEach(font => {
78
+ if (font.status === "error") throw new Error(`HyperFrames font could not be loaded: ${font.family}`);
79
+ });
52
80
  };
53
81
  });
54
82
  return async (frame: number) => {
@@ -2,9 +2,9 @@ export type HyperframesWorkers = number | "auto";
2
2
  export type HyperframesQuality = "draft" | "standard" | "high";
3
3
  export type HyperframesBrowserGpu = "auto" | "software" | "hardware";
4
4
 
5
- export type HyperframesExecutionOptions = {
5
+ export type HyperframesExecutionOptions = import("./browser.js").BrowserOptions & {
6
6
  readonly ffprobePath?: string;
7
- /** Final H.264 encoder. Source extraction uses the pinned engine's binary resolver. */
7
+ /** Selected FFmpeg command for both source extraction and final H.264 encoding. */
8
8
  readonly ffmpegPath?: string;
9
9
  /** Parallel Chrome workers inside one render. This is separate from Provider request concurrency. */
10
10
  readonly workers?: HyperframesWorkers;
@@ -0,0 +1,82 @@
1
+ import { execFile, execFileSync } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { setTimeout as delay } from "node:timers/promises";
4
+
5
+ const exec = promisify(execFile);
6
+ const cleanupMs = 5_000;
7
+
8
+ /** Chrome starts its own process group, so stopping only the Node child is insufficient. */
9
+ export async function killRenderTree(pid: number): Promise<void> {
10
+ if (process.platform === "win32") {
11
+ try {
12
+ await exec("taskkill", ["/PID", String(pid), "/T", "/F"], { windowsHide: true, timeout: cleanupMs });
13
+ } catch (error) {
14
+ // The worker may exit after reporting completion, before taskkill opens it.
15
+ // Ask the OS whether it is gone; localized taskkill output is not an API.
16
+ try { process.kill(pid, 0); }
17
+ catch (probeError) {
18
+ if ((probeError as NodeJS.ErrnoException).code === "ESRCH") return;
19
+ }
20
+ throw error;
21
+ }
22
+ return;
23
+ }
24
+ const descendants = [pid];
25
+ for (let i = 0; i < descendants.length; i++) {
26
+ const children = await exec("pgrep", ["-P", String(descendants[i])], { timeout: cleanupMs })
27
+ .then(({ stdout }) => stdout.trim().split(/\s+/u).filter(Boolean).map(Number),
28
+ (error) => { if ((error as { code?: unknown }).code === 1) return []; throw error; });
29
+ for (const child of children) if (!descendants.includes(child)) descendants.push(child);
30
+ }
31
+ // Kill the whole tree before waiting: only after every ancestor is dead are
32
+ // orphaned descendants reparented and reaped, making kill(pid, 0) read ESRCH.
33
+ for (const child of descendants.reverse()) {
34
+ for (const target of [-child, child]) {
35
+ try { process.kill(target, "SIGKILL"); }
36
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; }
37
+ }
38
+ }
39
+ const deadline = Date.now() + cleanupMs;
40
+ let remaining = descendants;
41
+ while (remaining.length > 0) {
42
+ remaining = remaining.filter((child) => {
43
+ try { process.kill(child, 0); return true; }
44
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ESRCH") return false; throw error; }
45
+ });
46
+ if (remaining.length === 0) break;
47
+ if (Date.now() >= deadline) throw new Error(`Render process ${remaining[0]} did not stop after SIGKILL`);
48
+ await delay(20);
49
+ }
50
+ }
51
+
52
+ /** Exit callbacks cannot await: stop live descendants before the owner is reparented. */
53
+ export function killRenderDescendantsSync(pid: number): void {
54
+ if (process.platform === "win32") {
55
+ // taskkill owns the traversal, including this exiting process, on Windows.
56
+ execFileSync("taskkill", ["/PID", String(pid), "/T", "/F"], {
57
+ windowsHide: true, timeout: cleanupMs, stdio: "pipe",
58
+ });
59
+ return;
60
+ }
61
+ const descendants = [pid];
62
+ for (let i = 0; i < descendants.length; i++) {
63
+ let output: string;
64
+ try {
65
+ output = execFileSync("pgrep", ["-P", String(descendants[i])], {
66
+ encoding: "utf8", timeout: cleanupMs, stdio: ["ignore", "pipe", "pipe"],
67
+ });
68
+ } catch (error) {
69
+ if ((error as { status?: unknown }).status === 1) continue;
70
+ throw error;
71
+ }
72
+ for (const child of output.trim().split(/\s+/u).filter(Boolean).map(Number)) {
73
+ if (!descendants.includes(child)) descendants.push(child);
74
+ }
75
+ }
76
+ for (const child of descendants.slice(1).reverse()) {
77
+ for (const target of [-child, child]) {
78
+ try { process.kill(target, "SIGKILL"); }
79
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; }
80
+ }
81
+ }
82
+ }