@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.
Files changed (57) hide show
  1. package/README.md +36 -2
  2. package/bin/hypit.mjs +0 -2
  3. package/package.json +2 -1
  4. package/packages/cli/package.json +1 -0
  5. package/packages/cli/src/commands/environment.ts +12 -7
  6. package/packages/cli/src/main.ts +2 -1
  7. package/packages/cli/src/oauth.ts +50 -7
  8. package/packages/credential-store-file/README.md +60 -0
  9. package/packages/credential-store-file/package.json +21 -0
  10. package/packages/credential-store-file/src/activation.ts +29 -0
  11. package/packages/credential-store-file/src/index.ts +1 -0
  12. package/packages/credential-store-file/src/store.ts +89 -0
  13. package/packages/fonts-open/src/surface.ts +8 -2
  14. package/packages/hyperframes/README.md +16 -2
  15. package/packages/hyperframes/src/browser-program.ts +6 -1
  16. package/packages/hyperframes/src/document.ts +31 -21
  17. package/packages/hyperframes/src/project.ts +11 -20
  18. package/packages/media-execution/README.md +7 -0
  19. package/packages/media-execution/src/execute.ts +5 -5
  20. package/packages/media-execution/src/index.ts +1 -1
  21. package/packages/media-execution/src/process-env.ts +20 -0
  22. package/packages/media-execution/src/surface.ts +83 -76
  23. package/packages/package-loader-node/README.md +10 -0
  24. package/packages/package-loader-node/src/index.ts +1 -0
  25. package/packages/package-loader-node/src/loader.ts +25 -5
  26. package/packages/package-loader-node/src/location.ts +11 -2
  27. package/packages/provider-hyperframes-local/README.md +81 -8
  28. package/packages/provider-hyperframes-local/package.json +13 -3
  29. package/packages/provider-hyperframes-local/src/activation.ts +20 -6
  30. package/packages/provider-hyperframes-local/src/browser-install.ts +5 -0
  31. package/packages/provider-hyperframes-local/src/browser.ts +118 -0
  32. package/packages/provider-hyperframes-local/src/capture-bootstrap.ts +4 -6
  33. package/packages/provider-hyperframes-local/src/capture-exit.ts +24 -0
  34. package/packages/provider-hyperframes-local/src/capture-process.ts +29 -52
  35. package/packages/provider-hyperframes-local/src/capture-worker.ts +2 -0
  36. package/packages/provider-hyperframes-local/src/capture.ts +12 -5
  37. package/packages/provider-hyperframes-local/src/opaque-capture.ts +46 -18
  38. package/packages/provider-hyperframes-local/src/options.ts +2 -2
  39. package/packages/provider-hyperframes-local/src/process-tree.ts +82 -0
  40. package/packages/provider-hyperframes-local/src/process.ts +22 -0
  41. package/packages/provider-hyperframes-local/src/program.ts +25 -33
  42. package/packages/provider-hyperframes-local/src/provider.ts +4 -7
  43. package/packages/provider-hyperframes-local/src/render.ts +9 -4
  44. package/packages/runtime-host-node/README.md +5 -0
  45. package/packages/runtime-host-node/src/index.ts +7 -2
  46. package/packages/runtime-host-node/src/packages.ts +13 -7
  47. package/packages/runtime-local/README.md +10 -0
  48. package/packages/runtime-local/src/config.ts +1 -1
  49. package/packages/runtime-local/src/credentials.ts +10 -5
  50. package/packages/studio/src/preview/runtime-shim.ts +17 -3
  51. package/packages/video-cli/README.md +5 -1
  52. package/packages/video-cli/package.json +1 -0
  53. package/packages/video-cli/src/distribution.ts +3 -10
  54. package/packages/video-cli/src/version.ts +1 -1
  55. package/packages/yt-dlp/README.md +3 -2
  56. package/packages/yt-dlp/package.json +4 -0
  57. package/packages/yt-dlp/src/download.ts +10 -14
@@ -1,4 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { access, stat } from "node:fs/promises";
3
+ import { constants } from "node:fs";
4
+ import { delimiter, isAbsolute, resolve } from "node:path";
2
5
 
3
6
  type ProcessResult = {
4
7
  readonly stdout: Uint8Array;
@@ -13,6 +16,25 @@ export function positiveInteger(value: number, subject: string): number {
13
16
  return value;
14
17
  }
15
18
 
19
+ /** The engine's binary override requires a path; resolve our selected command without its fallback search. */
20
+ export async function mediaExecutablePath(value: string): Promise<string> {
21
+ const pathLike = isAbsolute(value) || value.includes("/") || value.includes("\\");
22
+ const bases = pathLike ? [resolve(value)]
23
+ : (process.env.PATH ?? "").split(delimiter).filter(Boolean).map(directory => resolve(directory, value));
24
+ const extensions = process.platform === "win32" && !/\.[^\\/]+$/u.test(value) ? [".exe", ".com", ""] : [""];
25
+ for (const base of bases) for (const extension of extensions) {
26
+ const candidate = `${base}${extension}`;
27
+ try {
28
+ if (!(await stat(candidate)).isFile()) continue;
29
+ await access(candidate, process.platform === "win32" ? constants.F_OK : constants.X_OK);
30
+ return candidate;
31
+ } catch (error) {
32
+ if (!["ENOENT", "ENOTDIR", "EACCES"].includes((error as NodeJS.ErrnoException).code ?? "")) throw error;
33
+ }
34
+ }
35
+ throw new Error(`HyperFrames media executable ${value} is unavailable; correct the Provider's ffmpegPath or ffprobePath.`);
36
+ }
37
+
16
38
  function processEnvironment(): NodeJS.ProcessEnv {
17
39
  const names = ["PATH", "HOME", "TMPDIR", "LANG", "LC_ALL"] as const;
18
40
  return Object.fromEntries(names.flatMap((name) => process.env[name] === undefined
@@ -1,54 +1,46 @@
1
- import { execFile } from "node:child_process";
1
+ import { fileURLToPath } from "node:url";
2
+ import { join } from "node:path";
2
3
 
3
4
  import { probeMediaToolchain } from "@hypit/media-execution";
4
5
  import type { ManagedProgram, ManagedProgramState } from "@hypit/runtime-kit";
5
-
6
- function run(executable: string, args: readonly string[]): Promise<{ ok: boolean; output: string }> {
7
- return new Promise((resolve) => {
8
- execFile(executable, [...args], { timeout: 15_000, shell: false, windowsHide: true }, (error, stdout, stderr) => {
9
- resolve(error === null
10
- ? { ok: true, output: stdout.trim() }
11
- : { ok: false, output: (stderr.trim() || error.message).split("\n").at(-1) ?? "" });
12
- });
13
- });
14
- }
6
+ import { browserCacheDirectory, browserDownloadBaseUrl, browserDownloadUrl, browserExecutablePath, configuredBrowserPath, requireBrowserExecutable, selectedBrowserVersion } from "./browser.js";
7
+ import type { BrowserOptions } from "./browser.js";
15
8
 
16
9
  /**
17
- * HyperFrames owns the browser cache behind its CLI. It is deployment state,
18
- * not an author dependency, so the Endpoint lifecycle prepares it only
19
- * when this Provider is selected.
10
+ * This Provider owns browser selection and preparation. Probes never install;
11
+ * rendering receives the same selected executable instead of invoking engine discovery.
20
12
  */
21
13
  export function localHyperframesBrowserProgram(
22
- input: {
14
+ input: BrowserOptions & {
23
15
  readonly id: string;
24
16
  readonly nodePath: string;
25
- readonly hyperframesCliPath: string | (() => string);
26
17
  readonly ffprobePath: string;
27
18
  readonly ffmpegPath?: string;
28
19
  },
29
20
  ): ManagedProgram {
30
- const cliPath = () => typeof input.hyperframesCliPath === "string" ? input.hyperframesCliPath : input.hyperframesCliPath();
21
+ const version = selectedBrowserVersion(input);
22
+ const selectedPath = browserExecutablePath(input);
23
+ const baseUrl = browserDownloadBaseUrl(input);
31
24
  const probeBrowser = async (): Promise<ManagedProgramState> => {
32
- let executable: string;
33
- try { executable = cliPath(); } catch (error) { return { state: "down", detail: error instanceof Error ? error.message : String(error) }; }
34
- const located = await run(input.nodePath, [executable, "browser", "path"]);
35
- if (!located.ok) return { state: "down", detail: `HyperFrames browser is unavailable: ${located.output}` };
36
- const path = located.output.trim();
37
- if (path.length === 0 || path.includes("\n") || path.includes("\r")) {
38
- return { state: "mismatch", detail: "HyperFrames returned an invalid browser path" };
39
- }
40
- const version = await run(path, ["--version"]);
41
- if (!version.ok || version.output.length === 0) {
42
- return { state: "mismatch", detail: `HyperFrames browser cannot start: ${version.output}` };
43
- }
25
+ try { await requireBrowserExecutable(selectedPath, version); }
26
+ catch (error) { return { state: "down", detail: error instanceof Error ? error.message : String(error) }; }
44
27
  return { state: "ready" };
45
28
  };
46
29
  return {
47
30
  id: input.id,
48
- installation: {
49
- probe: probeBrowser,
50
- get commands() { return [{ command: input.nodePath, args: [cliPath(), "browser", "ensure"] }]; },
51
- },
31
+ ...(configuredBrowserPath(input) === undefined ? {
32
+ // Projects sharing this installation also share the existing Program lifecycle lock/logs.
33
+ stateRoot: join(browserCacheDirectory(input), ".hypit-render-program"),
34
+ installation: {
35
+ probe: probeBrowser,
36
+ commands: [{ label: `Install Chrome Headless Shell ${version} at ${selectedPath} from ${browserDownloadUrl(input)}`, command: input.nodePath, args: [
37
+ "--import", import.meta.resolve("tsx"),
38
+ "--import", new URL("./capture-bootstrap.ts", import.meta.url).href,
39
+ fileURLToPath(new URL("./browser-install.ts", import.meta.url)), browserCacheDirectory(input), version!,
40
+ ...(baseUrl === undefined ? [] : [baseUrl]),
41
+ ] }],
42
+ },
43
+ } : {}),
52
44
  async probe(): Promise<ManagedProgramState> {
53
45
  const browser = await probeBrowser();
54
46
  if (browser.state !== "ready") return browser;
@@ -2,7 +2,6 @@ import { defineEndpointPackage } from "@hypit/endpoint-kit";
2
2
  import { mediaTypes } from "@hypit/media";
3
3
  import { renderHyperframesCapabilities, verifyHyperframesVisualRequest } from "@hypit/render-hyperframes";
4
4
  import { canonicalize } from "@hypit/protocol";
5
- import { resolveNodePackageExecutable } from "@hypit/package-loader-node";
6
5
  import { renderHyperframesVisual, resolveExecutionOptions, renderWorkerLimit } from "./render.js";
7
6
  import type { HyperframesExecutionOptions } from "./options.js";
8
7
  import { renderProgressReporter } from "./progress.js";
@@ -14,17 +13,12 @@ export type CreateLocalHyperframesProviderOptions = HyperframesExecutionOptions
14
13
  readonly pool?: string;
15
14
  /** Used by managed browser installation, not frame capture. */
16
15
  readonly nodePath?: string;
17
- readonly hyperframesCliPath?: string;
18
16
  /** Whole render requests admitted concurrently; independent of frame workers. */
19
17
  readonly defaultConcurrency?: number;
20
18
  /** Shared Chrome slots across Need executions using this pool. */
21
19
  readonly browserCapacity?: number;
22
20
  };
23
21
 
24
- export function defaultHyperframesCliPath(): string {
25
- return resolveNodePackageExecutable("hyperframes", "hyperframes", { from: import.meta.url });
26
- }
27
-
28
22
  export function createLocalHyperframesProvider(config: CreateLocalHyperframesProviderOptions) {
29
23
  const execution = resolveExecutionOptions(config);
30
24
  const pool = config.pool ?? config.instance ?? "hyperframes.local";
@@ -62,7 +56,10 @@ export function createLocalHyperframesProvider(config: CreateLocalHyperframesPro
62
56
  : request.range.endFrameExclusive - request.range.startFrame;
63
57
  const progress = renderProgressReporter(context.reportProgress, frameCount);
64
58
  try {
65
- const visual = await renderHyperframesVisual(request, { ...config, workers: execution.workers, maxWorkers,
59
+ const visual = await renderHyperframesVisual(request, { ...config,
60
+ ...(execution.chromePath === undefined ? { browserVersion: execution.browserVersion! } : { chromePath: execution.chromePath }),
61
+ browserCacheDirectory: execution.browserCacheDirectory,
62
+ workers: execution.workers, maxWorkers,
66
63
  resources: context.resources, onProgress: progress.onProgress,
67
64
  ...(context.reportDiagnostic === undefined ? {} : { onDiagnostic: context.reportDiagnostic }) });
68
65
  return { value: { kind: "inline", value: canonicalize(visual) } };
@@ -6,7 +6,7 @@ import type { EndpointInvocationContext } from "@hypit/endpoint-kit";
6
6
  import { stageHyperframesProject } from "@hypit/hyperframes/project";
7
7
  import { sealRenderedVisual } from "@hypit/media";
8
8
  import type { MediaFrameRange, RenderedVisual } from "@hypit/media";
9
- import { verifyCompositableSurfaceBytes } from "@hypit/media-execution";
9
+ import { verifyCompositableSurfaceFile } from "@hypit/media-execution";
10
10
  import { verifyHyperframesVisualRequest } from "@hypit/render-hyperframes";
11
11
  import type { HyperframesVisualRequest } from "@hypit/render-hyperframes";
12
12
  import { isStreamingResourceStore } from "@hypit/runtime";
@@ -15,6 +15,7 @@ import { assert, positiveInteger } from "./process.js";
15
15
  import { runCaptureProcess } from "./capture-process.js";
16
16
  import { finished } from "node:stream/promises";
17
17
  import { autoWorkerLimit } from "./concurrency.js";
18
+ import { browserCacheDirectory, browserExecutablePath, configuredBrowserPath, requireBrowserExecutable, selectedBrowserVersion } from "./browser.js";
18
19
 
19
20
  export type HyperframesRenderProgress =
20
21
  | { readonly phase: "staging" | "encoding" | "storing"; readonly elapsedMs: number }
@@ -43,6 +44,9 @@ export function resolveExecutionOptions(options: HyperframesExecutionOptions) {
43
44
  const browserGpu = options.browserGpu ?? "hardware";
44
45
  assert(["auto", "software", "hardware"].includes(browserGpu), "HyperFrames browserGpu is invalid");
45
46
  return {
47
+ chromePath: configuredBrowserPath(options),
48
+ browserVersion: selectedBrowserVersion(options),
49
+ browserCacheDirectory: browserCacheDirectory(options),
46
50
  workers,
47
51
  maxWorkers: workers === "auto" ? positiveInteger(options.maxWorkers ?? autoWorkerLimit(), "maxWorkers") : workers,
48
52
  quality, browserGpu,
@@ -71,7 +75,7 @@ export async function renderHyperframesVisual(
71
75
  options: RenderHyperframesVisualOptions,
72
76
  ): Promise<RenderedVisual> {
73
77
  verifyHyperframesVisualRequest(request);
74
- const config = resolveExecutionOptions(options);
78
+ const config = { ...resolveExecutionOptions(options), chromePath: browserExecutablePath(options) };
75
79
  const { document } = request;
76
80
  const range = request.range ?? { startFrame: 0, endFrameExclusive: document.frameCount };
77
81
  const frameCount = range.endFrameExclusive - range.startFrame;
@@ -97,8 +101,9 @@ export async function renderHyperframesVisual(
97
101
  };
98
102
  try {
99
103
  signal.throwIfAborted();
104
+ await requireBrowserExecutable(config.chromePath, config.browserVersion);
100
105
  await options.onDiagnostic?.({ level: "info", message:
101
- `Render ${frameCount} frames; workers ${config.workers} (limit ${renderWorkerLimit(config, frameCount, document.frameRate.numerator / document.frameRate.denominator)}); opaque fast PNG; quality ${config.quality}; GPU ${config.browserGpu}; encoder ${config.ffmpegPath}` });
106
+ `Render ${frameCount} frames; workers ${config.workers} (limit ${renderWorkerLimit(config, frameCount, document.frameRate.numerator / document.frameRate.denominator)}); opaque fast PNG; quality ${config.quality}; GPU ${config.browserGpu}; browser ${config.chromePath}; encoder ${config.ffmpegPath}` });
102
107
  options.onProgress?.({ phase: "staging", elapsedMs: 0 });
103
108
  work = await mkdtemp(join(tmpdir(), "hypit-hyperframes-local-"));
104
109
  await stageHyperframesProject({ document, directory: work, signal,
@@ -109,7 +114,7 @@ export async function renderHyperframesVisual(
109
114
  assert(bytes !== undefined, `HyperFrames Artifact ${artifact.resource} is unavailable`);
110
115
  return bytes;
111
116
  },
112
- validateSurface: (surface, bytes, probeSignal) => verifyCompositableSurfaceBytes({ surface, bytes,
117
+ validateSurface: (surface, path, probeSignal) => verifyCompositableSurfaceFile({ surface, path,
113
118
  ffprobePath: config.ffprobePath, processTimeoutMs: config.processTimeoutMs,
114
119
  maxProbeOutputBytes: config.maxProcessOutputBytes, signal: probeSignal! }),
115
120
  });
@@ -53,3 +53,8 @@ to start execution, then `const runtime = await host.createRuntime(); await runt
53
53
  to submit work. `RuntimeHostExecution` does not run a second embedded Worker loop. A local carrier
54
54
  may stop accepting new Builds and drain; its assigned Builds stay in place and all carriers continue
55
55
  using the same resource accounting. Process policy belongs to the local Runtime, not this Host ABI.
56
+
57
+ `prepareHostPackages` accepts exact specifier strings or `{ specifier, env }` installation inputs.
58
+ The optional environment is applied only to that npm child process, merged over its inherited
59
+ environment. It is neither persisted as installation status nor returned in package reports.
60
+ The shared installer does not interpret SDK-specific variables; their owning packages supply them.
@@ -70,17 +70,20 @@ export type BuildView = {
70
70
  }[];
71
71
  };
72
72
 
73
- export type RuntimeHostCredentialStatus = {
73
+ export type RuntimeHostCredentialDescription = {
74
74
  readonly endpoint: string;
75
75
  readonly slot: string;
76
76
  readonly label: string;
77
77
  readonly kind: "secret" | "json";
78
78
  readonly ref: CredentialRef;
79
79
  readonly acquisition?: CredentialAcquisition;
80
- readonly configured: boolean;
81
80
  readonly writable: boolean;
82
81
  };
83
82
 
83
+ export type RuntimeHostCredentialStatus = RuntimeHostCredentialDescription & {
84
+ readonly configured: boolean;
85
+ };
86
+
84
87
  export type RuntimeHostControl = {
85
88
  /** Build evidence while active. Finished evidence is read through its Result Repository. */
86
89
  logs?(build: string, lines: number): Promise<import("@hypit/runtime").ExecutionLogView | undefined>;
@@ -105,6 +108,8 @@ export type RuntimeHostResultControl = {
105
108
  };
106
109
 
107
110
  export type RuntimeHostCredentialControl = {
111
+ /** Endpoint declarations and Store write capability; never reads an existing secret. */
112
+ describeCredentials(endpoint?: string): Promise<readonly RuntimeHostCredentialDescription[]>;
108
113
  credentials(endpoint?: string): Promise<readonly RuntimeHostCredentialStatus[]>;
109
114
  putCredential(endpoint: string, slot: string, secret: string): Promise<RuntimeHostCredentialStatus>;
110
115
  deleteCredential(endpoint: string, slot: string): Promise<{
@@ -73,7 +73,7 @@ export async function inspectHostPackage(
73
73
  : undefined;
74
74
  }
75
75
 
76
- async function runNpm(root: string, specifiers: readonly string[], logPath: string): Promise<void> {
76
+ async function runNpm(root: string, specifiers: readonly string[], logPath: string, env?: Readonly<Record<string, string>>): Promise<void> {
77
77
  // npm's prefix and cwd must denote the same physical project (not /tmp vs /private/tmp).
78
78
  const cwd = await realpath(root);
79
79
  const npmArgs = [
@@ -96,6 +96,7 @@ async function runNpm(root: string, specifiers: readonly string[], logPath: stri
96
96
  cwd,
97
97
  shell: false,
98
98
  windowsHide: true,
99
+ env: { ...process.env, ...env },
99
100
  stdio: ["ignore", log.fd, log.fd],
100
101
  });
101
102
  child.on("error", (error) => reject(new Error(`Cannot start npm: ${error.message}. Log: ${logPath}`, { cause: error })));
@@ -114,21 +115,26 @@ async function runNpm(root: string, specifiers: readonly string[], logPath: stri
114
115
  * package.json, lockfile and dependencies; Hypit keeps no parallel inventory.
115
116
  */
116
117
  export async function prepareHostPackages(
117
- specifiers: readonly string[],
118
+ specifiers: readonly (string | { readonly specifier: string; readonly env?: Readonly<Record<string, string>> })[],
118
119
  options: {
119
120
  readonly root: string;
120
121
  readonly onProgress?: (event: HostPackageProgress) => void;
121
122
  },
122
123
  ): Promise<readonly HostPackageReport[]> {
123
124
  const root = resolve(options.root);
124
- const bySpecifier = new Map<string, RegistryPackageSpec>();
125
+ const bySpecifier = new Map<string, RegistryPackageSpec & { readonly env?: Readonly<Record<string, string>> }>();
125
126
  for (const specifier of specifiers) {
126
- const parsed = parseRegistryPackageSpec(specifier);
127
- bySpecifier.set(parsed.specifier, parsed);
127
+ const parsed = parseRegistryPackageSpec(typeof specifier === "string" ? specifier : specifier.specifier);
128
+ const env = { ...bySpecifier.get(parsed.specifier)?.env };
129
+ for (const [key, value] of Object.entries(typeof specifier === "string" ? {} : specifier.env ?? {})) {
130
+ if (env[key] !== undefined && env[key] !== value) throw new Error(`Conflicting installation environment ${key} for ${parsed.specifier}`);
131
+ env[key] = value;
132
+ }
133
+ bySpecifier.set(parsed.specifier, { ...parsed, ...(Object.keys(env).length === 0 ? {} : { env }) });
128
134
  }
129
135
  const required = [...bySpecifier.values()].sort((left, right) => left.specifier.localeCompare(right.specifier));
130
136
  const reports: HostPackageReport[] = [];
131
- for (const item of required) {
137
+ for (const { env, ...item } of required) {
132
138
  const installation = externalPackageInstallRoot(root, item.name, item.version);
133
139
  options.onProgress?.({ ...item, phase: "checking" });
134
140
  const missing = await installedVersion(installation, item.name) !== item.version;
@@ -142,7 +148,7 @@ export async function prepareHostPackages(
142
148
  dependencies: { [item.name]: item.version },
143
149
  }, null, 2)}\n`, "utf8");
144
150
  options.onProgress?.({ ...item, phase: "installing", logPath });
145
- await runNpm(installation, [item.specifier], logPath);
151
+ await runNpm(installation, [item.specifier], logPath, env);
146
152
  }
147
153
  const version = await installedVersion(installation, item.name);
148
154
  if (version !== item.version) {
@@ -258,3 +258,13 @@ capacity release and every completed Result. They use the production execution p
258
258
  This is a load experiment rather than part of every package regression. It reports fixture preparation,
259
259
  local progress, completion, executor RSS and cleanup separately; total test time also includes creating
260
260
  and removing the temporary Result repositories. Neither suite uses a completion-time performance target.
261
+
262
+
263
+ ### Credential management without reading the old value
264
+
265
+ `openCredentials(endpoint)` opens only the selected Endpoint's credential control.
266
+ `describeCredentials(endpoint)` returns its declared slots and each Store's write capability without
267
+ resolving secrets. `credentials(endpoint)` also reads current values to report status and propagates
268
+ read failures. Login and logout use the former: a damaged old credential cannot prevent replacement
269
+ or deletion. Successful writes and deletions report their operation's result without rereading the
270
+ secret. Execution still resolves credentials normally and reports Store errors.
@@ -462,7 +462,7 @@ export async function prepareRuntimeConfigPackages(
462
462
  ...document.endpoints.map((item) => item.use),
463
463
  ...credentials.credentials.map((item) => item.use),
464
464
  ], options.distributionPackageRoot);
465
- return await prepareHostPackages(requirements.map((item) => item.specifier), {
465
+ return await prepareHostPackages(requirements, {
466
466
  root: hypitHostPackageRoot(options.hostStateRoot),
467
467
  ...(options.onProgress === undefined ? {} : { onProgress: options.onProgress }),
468
468
  });
@@ -28,16 +28,21 @@ export function createLocalCredentialControl(
28
28
  : `Endpoint ${endpoint} repeats credential slot ${slot}`);
29
29
  return matches[0]!;
30
30
  };
31
- const status = async (item: typeof descriptions[number]) => ({
31
+ const describe = async (item: typeof descriptions[number]) => ({
32
32
  ...structuredClone(item),
33
- configured: await options.credentialStore.resolve(item.ref) !== undefined,
34
33
  writable: await writableCredentialStore(options.credentialStore, item.ref) !== undefined,
35
34
  });
36
35
 
37
36
  return {
37
+ async describeCredentials(endpoint) {
38
+ return await Promise.all(descriptions.filter((item) => endpoint === undefined || item.endpoint === endpoint).map(describe));
39
+ },
38
40
  async credentials(endpoint) {
39
41
  const selected = descriptions.filter((item) => endpoint === undefined || item.endpoint === endpoint);
40
- return await Promise.all(selected.map(status));
42
+ return await Promise.all(selected.map(async (item) => ({
43
+ ...await describe(item),
44
+ configured: await options.credentialStore.resolve(item.ref) !== undefined,
45
+ })));
41
46
  },
42
47
  async putCredential(endpoint, slot, secret) {
43
48
  assert(secret.length > 0, "credential secret is empty");
@@ -45,14 +50,14 @@ export function createLocalCredentialControl(
45
50
  const store = await writableCredentialStore(options.credentialStore, item.ref);
46
51
  assert(store !== undefined, `CredentialStore ${item.ref.store} is not writable`);
47
52
  await store.put(item.ref, { secret });
48
- return await status(item);
53
+ return { ...structuredClone(item), writable: true, configured: true };
49
54
  },
50
55
  async deleteCredential(endpoint, slot) {
51
56
  const item = credential(endpoint, slot);
52
57
  const store = await writableCredentialStore(options.credentialStore, item.ref);
53
58
  assert(store !== undefined, `CredentialStore ${item.ref.store} is not writable`);
54
59
  const deleted = await store.delete(item.ref);
55
- return { deleted, credential: await status(item) };
60
+ return { deleted, credential: { ...structuredClone(item), writable: true, configured: false } };
56
61
  },
57
62
  close() {
58
63
  return options.close?.();
@@ -47,7 +47,12 @@ function shim(): string {
47
47
  start: parseFloat(ownStart || (present && present.getAttribute('data-start')) || '0') || 0,
48
48
  duration: parseFloat(ownDuration || (present && present.getAttribute('data-duration')) || '0') || 0,
49
49
  mediaStart: parseFloat(element.getAttribute('data-media-start') || '0') || 0,
50
- rate: parseFloat(element.getAttribute('data-playback-rate') || '1') || 1
50
+ rate: parseFloat(element.getAttribute('data-playback-rate') || '1') || 1,
51
+ startFrame: Number(element.getAttribute('data-hypit-start-frame')),
52
+ endFrame: Number(element.getAttribute('data-hypit-end-frame')),
53
+ sourceFrame: element.getAttribute('data-hypit-source-frame').split('/').map(BigInt),
54
+ sourceRate: element.getAttribute('data-hypit-source-rate').split('/').map(BigInt),
55
+ sourceFps: element.getAttribute('data-hypit-source-fps').split('/').map(Number)
51
56
  });
52
57
  }
53
58
  var audioContext;
@@ -149,7 +154,8 @@ function shim(): string {
149
154
  }
150
155
  for (var record of media) {
151
156
  var local = currentSeconds - record.start;
152
- var inside = local >= 0 && local < record.duration;
157
+ var programFrame = Math.round(currentSeconds * fps);
158
+ var inside = programFrame >= record.startFrame && programFrame < record.endFrame;
153
159
  var element = record.element;
154
160
  element.style.visibility = inside ? 'visible' : 'hidden';
155
161
  // Normalized picture Artifacts are deliberately silent. Programme sound
@@ -157,10 +163,18 @@ function shim(): string {
157
163
  element.muted = true;
158
164
  if (!inside) { element.pause(); continue; }
159
165
  var target = record.mediaStart + (local + frameSeconds / 2) * record.rate;
166
+ var discrete = scrubbing || programFrame === record.endFrame - 1;
167
+ if (discrete) {
168
+ // The renderer floors exact source-frame coordinates. The midpoint of a
169
+ // programme frame can cross that source boundary at fractional speeds.
170
+ var a = record.sourceFrame, r = record.sourceRate;
171
+ var sourceFrame = Number((a[0] * r[1] + BigInt(programFrame - record.startFrame) * r[0] * a[1]) / (a[1] * r[1]));
172
+ target = (sourceFrame + 0.5) * record.sourceFps[1] / record.sourceFps[0];
173
+ }
160
174
  if (Number.isFinite(element.duration) && element.duration > 0) {
161
175
  target = Math.min(target, Math.max(0, element.duration - 0.001));
162
176
  }
163
- if (scrubbing || local >= record.duration - frameSeconds - 0.000001) {
177
+ if (discrete) {
164
178
  element.pause();
165
179
  waits.push(seekDecoded(element, target));
166
180
  } else {
@@ -34,7 +34,11 @@ an editable starter; its Endpoint entries describe available routes, not choices
34
34
  Keep an existing chosen service, or configure the chosen local or hosted Provider and its capability
35
35
  bindings. HypiHub is the recommended integrated hosted route in the official Distribution; other
36
36
  services use project Provider packages. If the user chooses HypiHub,
37
- `hypit auth login hypihub.default` connects that account.
37
+ `hypit auth login hypihub.default` connects that account after choosing its CredentialStore.
38
+ The starter selects the OS store for macOS Keychain / Windows Credential Locker. On Linux, or when
39
+ explicitly choosing file storage, edit the Profile's `credentials` and Endpoint reference as shown
40
+ in [file CredentialStore](../credential-store-file/README.md#select-it-before-login) before `auth` or
41
+ `runtime up`. This selection is configuration; execution never switches stores automatically.
38
42
  `hypit doctor --endpoint <name>` checks a selected Endpoint;
39
43
  `hypit runtime up --endpoint <name>` prepares that Endpoint and starts the Worker. Repeat the flag
40
44
  for several chosen Endpoints; omitting it prepares the whole Profile. `hypit programs up --endpoint
@@ -13,6 +13,7 @@
13
13
  "@hypit/cli": "workspace:*",
14
14
  "@hypit/compiler-markup-node": "workspace:*",
15
15
  "@hypit/composition": "workspace:*",
16
+ "@hypit/credential-store-file": "workspace:*",
16
17
  "@hypit/credential-store-os": "workspace:*",
17
18
  "@hypit/driver-node": "workspace:*",
18
19
  "@hypit/estimate": "workspace:*",
@@ -12,8 +12,7 @@ import {
12
12
 
13
13
  // The Distribution root is the replaceable Hypit tool checkout, not this
14
14
  // package's source directory and never the author's project.
15
- const packageRoot = resolve(process.env.HYPIT_DISTRIBUTION_ROOT ?? resolve(import.meta.dirname, "../../.."));
16
- const installedLauncher = process.env.HYPIT_CLI_LAUNCHER;
15
+ const packageRoot = resolve(import.meta.dirname, "../../..");
17
16
  const defaultBuildResultRepository = {
18
17
  use: "@hypit/build-result-fs",
19
18
  config: { path: ".hypit/results" },
@@ -57,14 +56,8 @@ export const videoCliDistribution: CliDistribution = {
57
56
  : { distributionPackageRoot: options.distributionPackageRoot }),
58
57
  workerLaunch: {
59
58
  command: process.execPath,
60
- // The repository launcher registers TypeScript support before entering the CLI.
61
- // Tests that import this Distribution directly keep the current Node arguments.
62
- args: installedLauncher === undefined
63
- ? [
64
- ...process.execArgv.filter((item) => !item.startsWith("--test")),
65
- fileURLToPath(new URL("./cli.ts", import.meta.url)),
66
- ]
67
- : [installedLauncher],
59
+ // The loaded Distribution owns its Worker entry and TypeScript/package resolution.
60
+ args: [fileURLToPath(new URL("../../../bin/hypit.mjs", import.meta.url))],
68
61
  },
69
62
  }),
70
63
  openProjectResults: async (projectRoot, options) => {
@@ -20,7 +20,7 @@ export function writeVersionHelp(io: CliIo): void {
20
20
  /** Installation discovery belongs to the executable, independent of video execution and Skill installers. */
21
21
  export async function runVersionCli(argv: readonly string[], io: CliIo, environment: VersionEnvironment = {
22
22
  packageRoot: resolve(import.meta.dirname, "../../.."),
23
- ...(process.env.HYPIT_CLI_LAUNCHER === undefined ? {} : { launcher: process.env.HYPIT_CLI_LAUNCHER }),
23
+ launcher: resolve(import.meta.dirname, "../../../bin/hypit.mjs"),
24
24
  fetch: globalThis.fetch,
25
25
  }): Promise<void> {
26
26
  let check = false;
@@ -17,8 +17,9 @@ Runtime Profile and creates no Build.
17
17
  - `downloadVideo(url, target)` downloads one video to the given path. Its caller owns destination
18
18
  preparation and overwrite policy. The target extension must be `.mp4`, `.mkv`, `.webm` or `.mov`.
19
19
 
20
- The downloader invokes the locked Python project in `services/yt-dlp` through `uv`, located relative
21
- to the installed Distribution. Ship its `pyproject.toml` and `uv.lock` with this package's source.
20
+ The downloader locates its declared `@hypit/yt-dlp-service-runtime` package through the active
21
+ Distribution/package resolver and invokes that package's locked Python project through `uv`.
22
+ Ship its `pyproject.toml` and `uv.lock`; the caller's location and a `services/` ancestor are irrelevant.
22
23
  `uv` and `ffmpeg` must be available on PATH; the CLI also uses `ffprobe` to report the saved media.
23
24
 
24
25
  The request selects `bv*+ba/b`, with `res:1080,vcodec:h264` format preferences and `--no-playlist`.
@@ -6,5 +6,9 @@
6
6
  "type": "module",
7
7
  "exports": {
8
8
  ".": "./src/index.ts"
9
+ },
10
+ "dependencies": {
11
+ "@hypit/package-loader-node": "workspace:*",
12
+ "@hypit/yt-dlp-service-runtime": "workspace:*"
9
13
  }
10
14
  }
@@ -11,11 +11,11 @@
11
11
  * the same shape WhisperX and OpenCV already use for their Python programs.
12
12
  */
13
13
  import { spawnSync } from "node:child_process";
14
- import { existsSync } from "node:fs";
15
14
  import { copyFile, mkdtemp, readdir, rename, rm } from "node:fs/promises";
16
15
  import { tmpdir } from "node:os";
17
16
  import { dirname, extname, join } from "node:path";
18
- import { fileURLToPath } from "node:url";
17
+
18
+ import { resolveNodePackageResource } from "@hypit/package-loader-node";
19
19
 
20
20
  /**
21
21
  * Whether this is a link to fetch rather than a path to open.
@@ -34,20 +34,16 @@ export function isVideoUrl(value: string): boolean {
34
34
  }
35
35
 
36
36
  /**
37
- * The uv project holding the pinned `yt-dlp`, found by walking up from this module rather than from
38
- * the working directory, which is wherever the author happened to run the command from.
37
+ * The locked uv project is a Distribution package asset, the same way WhisperX and
38
+ * OpenCV locate their Python programs. Walking parents of this file only works
39
+ * while the module still sits above `services/` in a contributor checkout.
39
40
  */
40
41
  function serviceProject(): string {
41
- let directory = dirname(fileURLToPath(import.meta.url));
42
- while (true) {
43
- const candidate = join(directory, "services", "yt-dlp", "pyproject.toml");
44
- if (existsSync(candidate)) return dirname(candidate);
45
- const parent = dirname(directory);
46
- if (parent === directory) {
47
- throw new Error("no services/yt-dlp project above this module; the Distribution is incomplete");
48
- }
49
- directory = parent;
50
- }
42
+ return dirname(resolveNodePackageResource(
43
+ "@hypit/yt-dlp-service-runtime",
44
+ "pyproject.toml",
45
+ { from: import.meta.url },
46
+ ));
51
47
  }
52
48
 
53
49
  /**