@hypit/hypit 0.1.13 → 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.
- package/README.md +36 -2
- package/bin/hypit.mjs +0 -2
- package/package.json +2 -1
- package/packages/cli/package.json +1 -0
- package/packages/cli/src/commands/environment.ts +12 -7
- package/packages/cli/src/main.ts +2 -1
- package/packages/cli/src/oauth.ts +50 -7
- package/packages/credential-store-file/README.md +60 -0
- package/packages/credential-store-file/package.json +21 -0
- package/packages/credential-store-file/src/activation.ts +29 -0
- package/packages/credential-store-file/src/index.ts +1 -0
- package/packages/credential-store-file/src/store.ts +89 -0
- package/packages/fonts-open/src/surface.ts +8 -2
- package/packages/hyperframes/README.md +16 -2
- package/packages/hyperframes/src/browser-program.ts +6 -1
- package/packages/hyperframes/src/document.ts +31 -21
- package/packages/hyperframes/src/project.ts +11 -20
- package/packages/media-execution/README.md +7 -0
- package/packages/media-execution/src/execute.ts +5 -5
- package/packages/media-execution/src/index.ts +1 -1
- package/packages/media-execution/src/process-env.ts +20 -0
- package/packages/media-execution/src/surface.ts +83 -76
- package/packages/package-loader-node/README.md +10 -0
- package/packages/package-loader-node/src/index.ts +1 -0
- package/packages/package-loader-node/src/loader.ts +25 -5
- package/packages/package-loader-node/src/location.ts +11 -2
- package/packages/provider-hyperframes-local/README.md +81 -8
- package/packages/provider-hyperframes-local/package.json +13 -3
- package/packages/provider-hyperframes-local/src/activation.ts +20 -6
- package/packages/provider-hyperframes-local/src/browser-install.ts +5 -0
- package/packages/provider-hyperframes-local/src/browser.ts +118 -0
- package/packages/provider-hyperframes-local/src/capture-bootstrap.ts +4 -6
- package/packages/provider-hyperframes-local/src/capture-exit.ts +24 -0
- package/packages/provider-hyperframes-local/src/capture-process.ts +29 -52
- package/packages/provider-hyperframes-local/src/capture-worker.ts +2 -0
- package/packages/provider-hyperframes-local/src/capture.ts +12 -5
- package/packages/provider-hyperframes-local/src/opaque-capture.ts +46 -18
- package/packages/provider-hyperframes-local/src/options.ts +2 -2
- package/packages/provider-hyperframes-local/src/process-tree.ts +82 -0
- package/packages/provider-hyperframes-local/src/process.ts +22 -0
- package/packages/provider-hyperframes-local/src/program.ts +25 -33
- package/packages/provider-hyperframes-local/src/provider.ts +4 -7
- package/packages/provider-hyperframes-local/src/render.ts +9 -4
- package/packages/runtime-host-node/README.md +5 -0
- package/packages/runtime-host-node/src/index.ts +7 -2
- package/packages/runtime-host-node/src/packages.ts +13 -7
- package/packages/runtime-local/README.md +10 -0
- package/packages/runtime-local/src/config.ts +1 -1
- package/packages/runtime-local/src/credentials.ts +10 -5
- package/packages/studio/src/preview/runtime-shim.ts +17 -3
- package/packages/video-cli/README.md +5 -1
- package/packages/video-cli/package.json +1 -0
- package/packages/video-cli/src/distribution.ts +3 -10
- package/packages/video-cli/src/version.ts +1 -1
- package/packages/yt-dlp/README.md +3 -2
- package/packages/yt-dlp/package.json +4 -0
- package/packages/yt-dlp/src/download.ts +10 -14
|
@@ -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 {
|
|
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", "
|
|
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
|
|
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
|
-
|
|
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);
|
|
@@ -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
|
|
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
|
-
//
|
|
15
|
-
//
|
|
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 {
|
|
3
|
-
import { promisify } from "node:util";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
4
3
|
import { fileURLToPath } from "node:url";
|
|
5
|
-
import {
|
|
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,
|
|
@@ -76,6 +31,7 @@ export async function runCaptureProcess(
|
|
|
76
31
|
let failure: Error | undefined;
|
|
77
32
|
let completed = false;
|
|
78
33
|
let closed = false;
|
|
34
|
+
let exited = false;
|
|
79
35
|
let outputBytes = 0;
|
|
80
36
|
let stderr = "";
|
|
81
37
|
let diagnostics = Promise.resolve();
|
|
@@ -83,6 +39,14 @@ export async function runCaptureProcess(
|
|
|
83
39
|
let killing: Promise<void> | undefined;
|
|
84
40
|
const kill = () => {
|
|
85
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
|
+
}
|
|
86
50
|
killing ??= (child.pid === undefined ? Promise.resolve() : killRenderTree(child.pid)).catch((error) => {
|
|
87
51
|
if (!completed) failure = new Error(`${failure?.message ?? "Render cleanup failed"}; ${String(error)}`);
|
|
88
52
|
diagnostic({ level: "warning", message: `Render process-tree cleanup could not be confirmed: ${String(error)}` });
|
|
@@ -103,7 +67,9 @@ export async function runCaptureProcess(
|
|
|
103
67
|
};
|
|
104
68
|
const awaitExit = () => {
|
|
105
69
|
grace ??= setTimeout(() => {
|
|
106
|
-
diagnostic({ level: "warning", message:
|
|
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` });
|
|
107
73
|
void kill();
|
|
108
74
|
}, cleanupMs);
|
|
109
75
|
};
|
|
@@ -140,14 +106,25 @@ export async function runCaptureProcess(
|
|
|
140
106
|
}
|
|
141
107
|
});
|
|
142
108
|
child.on("error", (error) => { failure ??= error; void kill(); });
|
|
143
|
-
child.
|
|
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) => {
|
|
144
121
|
closed = true;
|
|
145
122
|
if (grace !== undefined) clearTimeout(grace);
|
|
146
123
|
signal.removeEventListener("abort", abort);
|
|
147
124
|
void (killing ?? Promise.resolve()).then(async () => {
|
|
148
125
|
await diagnostics;
|
|
149
|
-
if (failure !== undefined) reject(failure);
|
|
150
|
-
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}`));
|
|
151
128
|
else resolve();
|
|
152
129
|
});
|
|
153
130
|
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { releaseCaptureExitCleanup } from "./capture-exit.js";
|
|
1
2
|
import { captureStagedVisual } from "./capture.js";
|
|
2
3
|
import type { CaptureInput } from "./capture.js";
|
|
3
4
|
|
|
@@ -13,6 +14,7 @@ const finish = (value: { type: "completed" } | { type: "failed"; error: string }
|
|
|
13
14
|
if (value.type === "failed") { report(value); return; }
|
|
14
15
|
// Successful capture has closed its resources. Flush the result before closing our IPC handle;
|
|
15
16
|
// our own disconnect is not a cancellation by the owner.
|
|
17
|
+
releaseCaptureExitCleanup();
|
|
16
18
|
process.off("disconnect", disconnected);
|
|
17
19
|
process.off("message", receive);
|
|
18
20
|
if (!process.connected) return;
|
|
@@ -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:
|
|
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
|
|
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
|
-
//
|
|
26
|
-
//
|
|
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 (
|
|
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
|
|
37
|
-
|
|
38
|
-
for (const
|
|
39
|
-
const
|
|
40
|
-
if (
|
|
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
|
-
/**
|
|
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
|
+
}
|