@hypit/hypit 0.2.2 → 0.2.4

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 (68) hide show
  1. package/README.md +18 -6
  2. package/bin/hypit.mjs +12 -9
  3. package/dist/public/generation.d.ts +4 -5
  4. package/examples/provider-package/README.md +92 -16
  5. package/examples/provider-package/hypit.runtime.json +8 -1
  6. package/examples/provider-package/packages/provider-videos/package.json +11 -0
  7. package/examples/provider-package/packages/provider-videos/src/activation.ts +24 -0
  8. package/examples/provider-package/packages/provider-videos/src/provider.ts +181 -0
  9. package/examples/provider-package/packages/provider-videos/tsconfig.json +9 -0
  10. package/package.json +5 -1
  11. package/packages/build-result/package.json +1 -1
  12. package/packages/build-result/src/store.ts +1 -1
  13. package/packages/credential-store-file/README.md +9 -4
  14. package/packages/credential-store-file/package.json +2 -1
  15. package/packages/credential-store-file/src/activation.ts +2 -2
  16. package/packages/credential-store-file/src/index.ts +0 -1
  17. package/packages/credential-store-file/src/store.ts +13 -40
  18. package/packages/credential-store-os/runtime/windows-credential.ps1 +1 -2
  19. package/packages/credential-store-os/src/store.ts +2 -68
  20. package/packages/credential-store-os/src/windows.ts +47 -0
  21. package/packages/credential-store-platform/README.md +2 -1
  22. package/packages/credential-store-platform/src/activation.ts +2 -2
  23. package/packages/file-io-node/README.md +20 -0
  24. package/packages/file-io-node/package.json +13 -0
  25. package/packages/file-io-node/src/index.ts +1 -0
  26. package/packages/{build-result → file-io-node}/src/replace-file-windows.ts +3 -3
  27. package/packages/generation/README.md +6 -0
  28. package/packages/generation/src/mapping.ts +30 -10
  29. package/packages/media-execution/src/execute.ts +3 -2
  30. package/packages/media-execution/src/toolchain.ts +10 -36
  31. package/packages/provider-hiapi/README.md +75 -0
  32. package/packages/provider-hiapi/package.json +24 -0
  33. package/packages/provider-hiapi/src/activation.ts +62 -0
  34. package/packages/provider-hiapi/src/errors.ts +44 -0
  35. package/packages/provider-hiapi/src/index.ts +2 -0
  36. package/packages/provider-hiapi/src/mapping.ts +129 -0
  37. package/packages/provider-hiapi/src/provider.ts +215 -0
  38. package/packages/provider-hiapi/src/routes.ts +154 -0
  39. package/packages/provider-hyperframes-local/src/program.ts +2 -1
  40. package/packages/provider-media-local/README.md +2 -1
  41. package/packages/provider-monid/README.md +60 -0
  42. package/packages/provider-monid/package.json +19 -0
  43. package/packages/provider-monid/src/activation.ts +62 -0
  44. package/packages/provider-monid/src/errors.ts +55 -0
  45. package/packages/provider-monid/src/index.ts +2 -0
  46. package/packages/provider-monid/src/mapping.ts +33 -0
  47. package/packages/provider-monid/src/provider.ts +242 -0
  48. package/packages/provider-monid/src/routes.ts +103 -0
  49. package/packages/provider-pollo/README.md +59 -0
  50. package/packages/provider-pollo/package.json +22 -0
  51. package/packages/provider-pollo/src/activation.ts +62 -0
  52. package/packages/provider-pollo/src/errors.ts +41 -0
  53. package/packages/provider-pollo/src/index.ts +2 -0
  54. package/packages/provider-pollo/src/mapping.ts +60 -0
  55. package/packages/provider-pollo/src/provider.ts +194 -0
  56. package/packages/provider-pollo/src/routes.ts +120 -0
  57. package/packages/provider-tokendance/README.md +67 -0
  58. package/packages/provider-tokendance/package.json +21 -0
  59. package/packages/provider-tokendance/src/activation.ts +62 -0
  60. package/packages/provider-tokendance/src/errors.ts +47 -0
  61. package/packages/provider-tokendance/src/index.ts +2 -0
  62. package/packages/provider-tokendance/src/mapping.ts +60 -0
  63. package/packages/provider-tokendance/src/provider.ts +268 -0
  64. package/packages/provider-tokendance/src/routes.ts +192 -0
  65. package/packages/runtime-local/src/programs.ts +2 -0
  66. package/packages/video-cli/package.json +4 -0
  67. package/packages/credential-store-file/src/paths.ts +0 -16
  68. /package/packages/{build-result → file-io-node}/src/replace-file.ts +0 -0
@@ -1,5 +1,6 @@
1
+ import { replaceFile } from "@hypit/file-io-node";
1
2
  import { randomUUID } from "node:crypto";
2
- import { mkdir, open, rename, rm, stat, unlink } from "node:fs/promises";
3
+ import { mkdir, open, rm, stat, unlink } from "node:fs/promises";
3
4
  import { join } from "node:path";
4
5
  import { verifyCredentialRef } from "@hypit/runtime";
5
6
  import type { CredentialRef, CredentialValue, WritableCredentialStore } from "@hypit/runtime";
@@ -8,26 +9,6 @@ function missing(error: unknown): boolean {
8
9
  return error instanceof Error && "code" in error && error.code === "ENOENT";
9
10
  }
10
11
 
11
- /**
12
- * One process reads a credential for one Endpoint action while another action's refresh replaces
13
- * it, so a read handle and a replacement can reach the same document at the same instant. Windows
14
- * refuses to replace a file any handle still holds. Each document's work takes its turn instead,
15
- * keyed by path so separate Store instances over one directory share the same order. Keys are
16
- * independent: a turn on one document never delays another.
17
- */
18
- const documentTurns = new Map<string, Promise<unknown>>();
19
-
20
- function onDocument<T>(path: string, work: () => Promise<T>): Promise<T> {
21
- const turn = (documentTurns.get(path) ?? Promise.resolve()).then(work, work);
22
- // The successor waits for this turn to settle, not to succeed, and the last turn clears the path.
23
- const settled = turn.then(() => undefined, () => undefined);
24
- documentTurns.set(path, settled);
25
- void settled.then(() => {
26
- if (documentTurns.get(path) === settled) documentTurns.delete(path);
27
- });
28
- return turn;
29
- }
30
-
31
12
  function credentialValue(value: unknown): CredentialValue {
32
13
  if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid credential value");
33
14
  const record = value as Record<string, unknown>;
@@ -64,10 +45,6 @@ export class FileCredentialStore implements WritableCredentialStore {
64
45
  async resolve(ref: CredentialRef): Promise<CredentialValue | undefined> {
65
46
  if (!this.owns(ref)) return undefined;
66
47
  const path = this.#path(ref);
67
- return await onDocument(path, async () => await this.#read(path));
68
- }
69
-
70
- async #read(path: string): Promise<CredentialValue | undefined> {
71
48
  try { await this.#privateDirectory(false); } catch (error) {
72
49
  if (missing(error)) return undefined;
73
50
  throw error;
@@ -93,25 +70,21 @@ export class FileCredentialStore implements WritableCredentialStore {
93
70
  async put(ref: CredentialRef, value: CredentialValue): Promise<void> {
94
71
  const path = this.#path(ref);
95
72
  const contents = JSON.stringify(credentialValue(value));
96
- await onDocument(path, async () => {
97
- await this.#privateDirectory(true);
98
- const temporary = join(this.directory, `.write-${randomUUID()}.tmp`);
99
- const file = await open(temporary, "wx", 0o600);
100
- try {
101
- try { await file.writeFile(`${contents}\n`, "utf8"); } finally { await file.close(); }
102
- await rename(temporary, path);
103
- } finally { await rm(temporary, { force: true }); }
104
- });
73
+ await this.#privateDirectory(true);
74
+ const temporary = join(this.directory, `.write-${randomUUID()}.tmp`);
75
+ const file = await open(temporary, "wx", 0o600);
76
+ try {
77
+ try { await file.writeFile(`${contents}\n`, "utf8"); } finally { await file.close(); }
78
+ await replaceFile(temporary, path);
79
+ } finally { await rm(temporary, { force: true }); }
105
80
  }
106
81
 
107
82
  async delete(ref: CredentialRef): Promise<boolean> {
108
83
  const path = this.#path(ref);
109
84
  // Deletion never opens or decodes the old document.
110
- return await onDocument(path, async () => {
111
- try { await unlink(path); return true; } catch (error) {
112
- if (missing(error)) return false;
113
- throw error;
114
- }
115
- });
85
+ try { await unlink(path); return true; } catch (error) {
86
+ if (missing(error)) return false;
87
+ throw error;
88
+ }
116
89
  }
117
90
  }
@@ -35,8 +35,7 @@ switch ($Operation) {
35
35
  @{ found = $true; secret = [Convert]::ToBase64String($bytes) } | ConvertTo-Json -Compress
36
36
  }
37
37
  "write" {
38
- $existing = Find-Credential
39
- if ($null -ne $existing) { $vault.Remove($existing) }
38
+ # Add replaces the same resource/account; do not destroy the old value before it succeeds.
40
39
  $bytes = [Convert]::FromBase64String([string]$request.secret)
41
40
  $secret = [System.Text.Encoding]::UTF8.GetString($bytes)
42
41
  $credential = New-Object Windows.Security.Credentials.PasswordCredential(
@@ -1,5 +1,5 @@
1
- import { execFile, spawn } from "node:child_process";
2
- import { fileURLToPath } from "node:url";
1
+ import { execFile } from "node:child_process";
2
+ import { windowsCredential } from "./windows.js";
3
3
 
4
4
  import { verifyCredentialRef } from "@hypit/runtime";
5
5
  import type {
@@ -59,72 +59,6 @@ function macosDeleter(service: string): OsCredentialDeleter {
59
59
  });
60
60
  }
61
61
 
62
- type WindowsCredentialResult = {
63
- readonly found?: boolean;
64
- readonly deleted?: boolean;
65
- readonly secret?: string;
66
- };
67
-
68
- const windowsScript = fileURLToPath(new URL("../runtime/windows-credential.ps1", import.meta.url));
69
-
70
- const windowsCredentialTimeoutMs = 10_000;
71
-
72
- function windowsCredential(
73
- operation: "read" | "write" | "delete",
74
- service: string,
75
- account: string,
76
- secret?: string,
77
- ): Promise<WindowsCredentialResult> {
78
- return new Promise((resolve, reject) => {
79
- const child = spawn("powershell.exe", [
80
- "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass",
81
- "-File", windowsScript, "-Operation", operation,
82
- ], { shell: false, windowsHide: true, stdio: ["pipe", "pipe", "pipe"] });
83
- let stdout = "";
84
- let stderr = "";
85
- let settled = false;
86
- const fail = (error: Error): void => {
87
- if (settled) return;
88
- settled = true;
89
- clearTimeout(timer);
90
- reject(error);
91
- };
92
- const timer = setTimeout(() => {
93
- fail(new Error(`Windows credential ${operation} for ${account} timed out`));
94
- child.kill("SIGKILL");
95
- }, windowsCredentialTimeoutMs);
96
- child.on("error", fail);
97
- child.stdout.on("data", (chunk: Buffer) => {
98
- stdout += chunk.toString("utf8");
99
- if (stdout.length > 4 * 1024 * 1024) fail(new Error("Windows credential response is too large"));
100
- });
101
- child.stderr.on("data", (chunk: Buffer) => {
102
- stderr += chunk.toString("utf8");
103
- if (stderr.length > 64 * 1024) fail(new Error("Windows credential error is too large"));
104
- });
105
- child.on("close", (code) => {
106
- if (settled) return;
107
- clearTimeout(timer);
108
- if (code !== 0) {
109
- fail(new Error(`Windows credential ${operation} for ${account} failed${stderr.trim().length === 0 ? "" : `: ${stderr.trim()}`}`));
110
- return;
111
- }
112
- try {
113
- const result = JSON.parse(stdout.replace(/^\uFEFF/u, "").trim()) as WindowsCredentialResult;
114
- settled = true;
115
- resolve(result);
116
- } catch {
117
- fail(new Error(`Windows credential ${operation} returned an invalid response`));
118
- }
119
- });
120
- child.stdin.end(JSON.stringify({
121
- service,
122
- account,
123
- ...(secret === undefined ? {} : { secret: Buffer.from(secret, "utf8").toString("base64") }),
124
- }));
125
- });
126
- }
127
-
128
62
  function windowsBackend(): {
129
63
  readonly read: OsCredentialReader;
130
64
  readonly write: OsCredentialWriter;
@@ -0,0 +1,47 @@
1
+ import { execFile } from "node:child_process";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ type WindowsCredentialResult = {
5
+ readonly found?: boolean;
6
+ readonly deleted?: boolean;
7
+ readonly secret?: string;
8
+ };
9
+
10
+ const windowsScript = fileURLToPath(new URL("../runtime/windows-credential.ps1", import.meta.url));
11
+
12
+ /** The OS adapter owns this one child; Node owns its timeout, bounded output and termination. */
13
+ export function windowsCredential(
14
+ operation: "read" | "write" | "delete",
15
+ service: string,
16
+ account: string,
17
+ secret?: string,
18
+ ): Promise<WindowsCredentialResult> {
19
+ return new Promise((resolve, reject) => {
20
+ const child = execFile("powershell.exe", [
21
+ "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass",
22
+ "-File", windowsScript, "-Operation", operation,
23
+ ], {
24
+ shell: false, windowsHide: true, encoding: "utf8", timeout: 10_000,
25
+ maxBuffer: 4 * 1024 * 1024,
26
+ }, (error, stdout, stderr) => {
27
+ if (error !== null) {
28
+ reject(new Error(`Windows credential ${operation} for ${account} failed`
29
+ + (error.code === undefined ? "" : ` (${error.code})`)
30
+ + (error.killed ? " (child terminated)" : "")
31
+ + (stderr.trim().length === 0 ? "" : `: ${stderr.trim()}`)));
32
+ return;
33
+ }
34
+ try { resolve(JSON.parse(stdout.replace(/^\uFEFF/u, "").trim()) as WindowsCredentialResult); }
35
+ catch { reject(new Error(`Windows credential ${operation} returned an invalid response`)); }
36
+ });
37
+ // A closed input pipe is an operation failure too. Do not leave the child waiting for a request.
38
+ child.stdin!.on("error", () => {
39
+ child.kill();
40
+ reject(new Error(`Windows credential ${operation} request could not be written`));
41
+ });
42
+ child.stdin!.end(JSON.stringify({
43
+ service, account,
44
+ ...(secret === undefined ? {} : { secret: Buffer.from(secret, "utf8").toString("base64") }),
45
+ }));
46
+ });
47
+ }
@@ -53,7 +53,8 @@ credential stored in a locker stays in that locker; a credential stored in a fil
53
53
 
54
54
  On macOS and Windows the credential is held by the platform locker, with exactly the rules of
55
55
  `@hypit/credential-store-os`: it is encrypted and access-controlled by the operating system, and it
56
- is not readable from another machine.
56
+ follows that locker’s access and synchronization policy. Windows Credential Locker may roam
57
+ credentials through the user’s Microsoft account.
57
58
 
58
59
  On Linux the credential is held by an owner-private document with exactly the rules of
59
60
  `@hypit/credential-store-file`: unencrypted JSON, one document per key, directory mode `0700`, file
@@ -1,10 +1,10 @@
1
+ import { join, resolve } from "node:path";
1
2
  import {
2
3
  createRuntimeCredentialStoreAdapterFacet,
3
4
  runtimeConfigExact,
4
5
  runtimeConfigObject,
5
6
  runtimeConfigString,
6
7
  } from "@hypit/runtime-kit";
7
- import { resolveCredentialDirectory } from "@hypit/credential-store-file";
8
8
  import { PlatformCredentialStore } from "./store.js";
9
9
 
10
10
  const platformCredentialStoreAdapter = createRuntimeCredentialStoreAdapterFacet({
@@ -23,7 +23,7 @@ const platformCredentialStoreAdapter = createRuntimeCredentialStoreAdapterFacet(
23
23
  value: new PlatformCredentialStore({
24
24
  // Linux uses the same directory the file Store uses by default, so a Profile
25
25
  // that switches between them on Linux finds the credential it stored.
26
- directory: resolveCredentialDirectory(context.hostStateRoot, path, "platform CredentialStore"),
26
+ directory: path === undefined ? join(context.hostStateRoot, "credentials") : resolve(context.hostStateRoot, path),
27
27
  ...(service === undefined ? {} : { service }),
28
28
  }),
29
29
  };
@@ -0,0 +1,20 @@
1
+ # `@hypit/file-io-node`
2
+
3
+ Node filesystem operations shared by file-backed adapters. This package has no knowledge of
4
+ credentials, Build Results, Runtime or repository formats.
5
+
6
+ `replaceFile(from, to)` publishes a complete file at a destination on the same filesystem. The
7
+ caller owns creating and closing the source file, its contents and permissions, and temporary-file
8
+ cleanup. Existing readers retain the old file; subsequent opens see the new file. The destination
9
+ may be absent. Concurrent replacements are ordered by the filesystem, with the last replacement
10
+ supplying the current value.
11
+
12
+ POSIX uses Node's `rename`. Windows uses `SetFileInformationByHandle(FileRenameInfoEx)` with
13
+ `REPLACE_IF_EXISTS | POSIX_SEMANTICS`, the same primitive previously owned by `build-result`.
14
+ This avoids `MoveFileExW` rejecting replacement while another process has the destination open.
15
+ The native structure layout comes from Koffi; constants are Windows API values.
16
+
17
+ There is no pre-read, queue, lock file, retry or fallback. Windows filesystems must support this
18
+ operation; unsupported operations, access denials and other I/O errors are reported to the caller.
19
+ Read-only destinations are not overridden. The package does not promise power-loss durability or
20
+ coordinate transactions across multiple files.
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "@hypit/file-io-node",
3
+ "version": "0.0.0-dev",
4
+ "license": "SEE LICENSE IN LICENSE",
5
+ "private": true,
6
+ "type": "module",
7
+ "exports": {
8
+ ".": "./src/index.ts"
9
+ },
10
+ "dependencies": {
11
+ "koffi": "3.2.1"
12
+ }
13
+ }
@@ -0,0 +1 @@
1
+ export { replaceFile } from "./replace-file.js";
@@ -1,4 +1,4 @@
1
- import { toNamespacedPath } from "node:path";
1
+ import { resolve, toNamespacedPath } from "node:path";
2
2
  import koffi from "koffi";
3
3
 
4
4
  const kernel = koffi.load("kernel32.dll");
@@ -35,7 +35,7 @@ function failure(syscall: string, from: string, to: string): Error {
35
35
  * https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-fscc/4217551b-d2c0-42cb-9dc1-69a716cf6d0c
36
36
  */
37
37
  export function replaceWindowsFile(from: string, to: string): void {
38
- const filename = Buffer.from(toNamespacedPath(to), "utf16le");
38
+ const filename = Buffer.from(toNamespacedPath(resolve(to)), "utf16le");
39
39
  const nameOffset = koffi.offsetof(renameInfo, "FileName");
40
40
  // Win32 requires a NUL-terminated FileName even though FileNameLength excludes
41
41
  // that terminator. Buffer.alloc leaves the final WCHAR zero-initialized.
@@ -45,7 +45,7 @@ export function replaceWindowsFile(from: string, to: string): void {
45
45
  info.writeUInt32LE(filename.length, koffi.offsetof(renameInfo, "FileNameLength"));
46
46
  filename.copy(info, nameOffset);
47
47
 
48
- const handle = createFile(toNamespacedPath(from), DELETE, SHARE_READ_WRITE_DELETE,
48
+ const handle = createFile(toNamespacedPath(resolve(from)), DELETE, SHARE_READ_WRITE_DELETE,
49
49
  null, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, 0) as number | bigint;
50
50
  if (handle === -1 || handle === -1n) throw failure("CreateFileW", from, to);
51
51
  try {
@@ -37,3 +37,9 @@ Media wire mappings (`url`, `urlArray`, `itemObject`) may declare `resourceField
37
37
  paths against the declared media fields. False, zero and empty strings remain values; omitted fields
38
38
  remain absent. The resolver implements the service protocol; this package knows no particular
39
39
  service or classification. Providers using custom transports carry those fields through that boundary.
40
+
41
+ A Model item field being optional means authors may omit it. If a request supplies that field,
42
+ `mappingSupportsRequest` and final wire compilation require the mapping to carry it through
43
+ `fieldKeys` or `resourceFields`, including an explicit `false`. A service may support requests
44
+ without a particular optional field and refuse requests that supply it; no field is silently dropped.
45
+ Final compilation checks the whole request before resolving or uploading any reference.
@@ -89,8 +89,9 @@ export function selectWireModelForRequest(
89
89
  request: GenerationRequest,
90
90
  pendingPorts: readonly string[] = [],
91
91
  ): string {
92
- assert(mappingSupportsRequest(mapping, request),
93
- `${mapping.capability.name} request contains a port this Provider cannot map`);
92
+ const unsupported = unsupportedRequestField(mapping, request);
93
+ assert(unsupported === undefined,
94
+ `${mapping.capability.name} request contains ${unsupported} this Provider cannot map`);
94
95
  const present = new Set(presentPorts(request));
95
96
  for (const port of pendingPorts) {
96
97
  assert(mapping.fields[port] !== undefined,
@@ -149,19 +150,38 @@ export async function compileWireRequest(
149
150
  return { model, input: canonicalize(input) };
150
151
  }
151
152
 
152
- /** Does this request only use ports this mapping can write? Needs no model package. */
153
- export function mappingSupportsRequest(mapping: GenerationWireMapping, value: unknown): boolean {
153
+ /** Describe only a supplied value the mapping cannot carry; no model table or saved verdict. */
154
+ function unsupportedRequestField(mapping: GenerationWireMapping, value: unknown): string | undefined {
154
155
  const request = value as GenerationRequest | undefined;
155
- if (request === undefined || request.ports === null || typeof request.ports !== "object") return false;
156
- return Object.keys(request.ports).every((port) => mapping.fields[port] !== undefined);
156
+ if (request === undefined || request === null || request.ports === null || typeof request.ports !== "object") {
157
+ return "invalid ports";
158
+ }
159
+ for (const [port, supplied] of Object.entries(request.ports)) {
160
+ const field = mapping.fields[port];
161
+ if (field === undefined) return `port ${port}`;
162
+ if (field.as !== "url" && field.as !== "urlArray" && field.as !== "itemObject") continue;
163
+ for (const item of supplied as readonly GenerationMediaValue[]) {
164
+ for (const name of Object.keys(item.fields ?? {})) {
165
+ if (!field.resourceFields?.includes(name)
166
+ && !(field.as === "itemObject" && Object.hasOwn(field.fieldKeys, name))) {
167
+ return `field ${port}.${name}`;
168
+ }
169
+ }
170
+ }
171
+ }
172
+ return undefined;
173
+ }
174
+
175
+ /** Optional fields may be omitted by authors, but supplied fields must reach the service. */
176
+ export function mappingSupportsRequest(mapping: GenerationWireMapping, value: unknown): boolean {
177
+ return unsupportedRequestField(mapping, value) === undefined;
157
178
  }
158
179
 
159
180
  /**
160
- * Prove one service mapping covers every port the model declares.
181
+ * Check structural port coverage and required item fields against the model declaration.
161
182
  *
162
- * This is the check the old hand-written per-model translators could not have:
163
- * a forgotten reference role or item field used to surface only after a paid
164
- * generation returned the wrong result.
183
+ * A service may omit optional item capabilities. mappingSupportsRequest and final compilation
184
+ * additionally refuse requests that actually supply such an unmapped field.
165
185
  */
166
186
  export function assertMappingCoversPorts(
167
187
  table: GenerationPortTable,
@@ -689,7 +689,7 @@ export async function executeNormalizeMedia(
689
689
  await runProcess({
690
690
  executable: env.ffmpegPath,
691
691
  argv: ["-y", ...visualInput, "-an", "-vf", filter,
692
- "-frames:v", String(plan.frameCount), "-fps_mode", "cfr", ...encoderArgs, output],
692
+ "-frames:v", String(plan.frameCount), "-r", fps, "-fps_mode", "cfr", ...encoderArgs, output],
693
693
  timeoutMs: env.processTimeoutMs,
694
694
  maxStdoutBytes: 64 * 1024,
695
695
  ...(env.sharedLibraryPath === undefined ? {} : { sharedLibraryPath: env.sharedLibraryPath }),
@@ -951,7 +951,8 @@ export async function executeTransformMedia(
951
951
  argv.push("-map", "0:v:0", "-an", "-vf", plan.videoFilters.join(","));
952
952
  }
953
953
  argv.push(
954
- "-frames:v", String(plan.frameCount), "-fps_mode", "cfr",
954
+ "-frames:v", String(plan.frameCount),
955
+ "-r", `${media.timeline.frameRate.numerator}/${media.timeline.frameRate.denominator}`, "-fps_mode", "cfr",
955
956
  "-c:v", "libx264", "-preset", "veryfast", "-pix_fmt", "yuv420p",
956
957
  "-movflags", "+faststart", output,
957
958
  );
@@ -1,18 +1,14 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { mediaProcessEnv } from "./process-env.js";
2
3
 
3
4
  export type MediaToolchainState =
4
5
  | { readonly state: "ready"; readonly ffprobeVersion: string; readonly ffmpegVersion?: string }
5
6
  | { readonly state: "down" | "mismatch"; readonly detail: string };
6
7
 
7
- const REQUIRED_ENCODERS = ["aac", "libx264", "pcm_s16le"] as const;
8
- const REQUIRED_FILTERS = [
9
- "aformat", "amix", "aresample", "asetpts", "atempo", "atrim",
10
- "loop", "pad", "scale", "select", "setpts", "setsar", "trim",
11
- ] as const;
12
-
13
- function run(executable: string, args: readonly string[]): Promise<{ ok: boolean; output: string }> {
8
+ function run(executable: string, args: readonly string[], env: NodeJS.ProcessEnv): Promise<{ ok: boolean; output: string }> {
14
9
  return new Promise((resolve) => {
15
10
  execFile(executable, [...args], {
11
+ env,
16
12
  timeout: 15_000,
17
13
  shell: false,
18
14
  windowsHide: true,
@@ -29,22 +25,18 @@ function firstLine(value: string): string {
29
25
  return value.split(/\r?\n/u, 1)[0]?.trim() ?? "";
30
26
  }
31
27
 
32
- function missingNames(output: string, required: readonly string[]): readonly string[] {
33
- return required.filter((name) => !new RegExp(`(?:^|\\s)${name.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")}(?:\\s|$)`, "mu")
34
- .test(output));
35
- }
36
-
37
28
  /**
38
- * Compatibility probe for a deployment-owned FFmpeg toolchain. It deliberately
39
- * checks the commands the shared execution body uses instead of requiring one
40
- * blessed version number. A managed distribution may pin binaries separately;
41
- * a custom installation remains valid when it supplies the same capability.
29
+ * Check availability of the selected executables in their execution environment.
30
+ * This does not predict compatibility with every future media command: the execution
31
+ * implementation owns those commands and reports the tool's actual failure.
42
32
  */
43
33
  export async function probeMediaToolchain(options: {
44
34
  readonly ffprobePath: string;
45
35
  readonly ffmpegPath?: string;
36
+ readonly environment?: NodeJS.ProcessEnv;
46
37
  }): Promise<MediaToolchainState> {
47
- const probeVersion = await run(options.ffprobePath, ["-version"]);
38
+ const env = options.environment ?? mediaProcessEnv();
39
+ const probeVersion = await run(options.ffprobePath, ["-version"], env);
48
40
  if (!probeVersion.ok) {
49
41
  return { state: "down", detail: `${options.ffprobePath} is unavailable: ${probeVersion.output}` };
50
42
  }
@@ -52,28 +44,10 @@ export async function probeMediaToolchain(options: {
52
44
  if (ffprobeVersion.length === 0) return { state: "mismatch", detail: "ffprobe returned no version" };
53
45
  if (options.ffmpegPath === undefined) return { state: "ready", ffprobeVersion };
54
46
 
55
- const [ffmpegVersionResult, encoders, filters] = await Promise.all([
56
- run(options.ffmpegPath, ["-version"]),
57
- run(options.ffmpegPath, ["-hide_banner", "-encoders"]),
58
- run(options.ffmpegPath, ["-hide_banner", "-filters"]),
59
- ]);
47
+ const ffmpegVersionResult = await run(options.ffmpegPath, ["-version"], env);
60
48
  if (!ffmpegVersionResult.ok) {
61
49
  return { state: "down", detail: `${options.ffmpegPath} is unavailable: ${ffmpegVersionResult.output}` };
62
50
  }
63
- if (!encoders.ok || !filters.ok) {
64
- return { state: "mismatch", detail: "ffmpeg could not enumerate its encoders and filters" };
65
- }
66
- const missingEncoders = missingNames(encoders.output, REQUIRED_ENCODERS);
67
- const missingFilters = missingNames(filters.output, REQUIRED_FILTERS);
68
- if (missingEncoders.length > 0 || missingFilters.length > 0) {
69
- return {
70
- state: "mismatch",
71
- detail: [
72
- missingEncoders.length === 0 ? "" : `missing encoders ${missingEncoders.join(", ")}`,
73
- missingFilters.length === 0 ? "" : `missing filters ${missingFilters.join(", ")}`,
74
- ].filter(Boolean).join("; "),
75
- };
76
- }
77
51
  const ffmpegVersion = firstLine(ffmpegVersionResult.output);
78
52
  return ffmpegVersion.length === 0
79
53
  ? { state: "mismatch", detail: "ffmpeg returned no version" }
@@ -0,0 +1,75 @@
1
+ # `@hypit/provider-hiapi`
2
+
3
+ Hypit Runtime Provider for a [HiAPI](https://www.hiapi.ai) account. Every capability submits one
4
+ task to `POST /v1/tasks` with the model's documented `input` fields, polls
5
+ `GET /v1/tasks/{taskId}` until the task is terminal, downloads `data.output[].url` and stores the
6
+ files in the current Build. Submission carries an `Idempotency-Key`, so a retried submission returns
7
+ the original task.
8
+
9
+ | Capability | HiAPI model |
10
+ | --- | --- |
11
+ | `@hypit/seedance@1#seedance-2` | `seedance-2.0` |
12
+ | `@hypit/seedance@1#seedance-2-fast` | `seedance-2.0-fast` |
13
+ | `@hypit/seedance@1#seedance-2-mini` | `seedance-2.0-mini` |
14
+ | `@hypit/seedance@1#seedance-2.5` | `seedance-2.5/reference-to-video` with a reference video, `seedance-2.5/image-to-video` with frames, reference images or reference audio, otherwise `seedance-2.5/text-to-video` |
15
+ | `@hypit/seedream@1#seedream-5-lite` | `seedream-5.0-lite/image-to-image` with references, otherwise `seedream-5.0-lite/text-to-image` |
16
+ | `@hypit/minimax-h3@1#minimax-h3` | `minimax-h3` |
17
+ | `@hypit/gpt-image@1#gpt-image-2` | `gpt-image-2/image-to-image` with references, otherwise `gpt-image-2/text-to-image` |
18
+ | `@hypit/nano-banana@1#nano-banana-2` | `Nano-Banana-2` |
19
+ | `@hypit/nano-banana@1#nano-banana-pro` | `Nano-Banana-Pro` |
20
+ | `@hypit/grok-imagine@1#grok-imagine-video` | `grok-imagine/image-to-video` with images, otherwise `grok-imagine/text-to-video` |
21
+ | `@hypit/grok-imagine@1#grok-imagine-video-1.5-preview` | `grok-imagine-1.5/image-to-video` |
22
+
23
+ HiAPI's model index at `https://www.hiapi.ai/docs/models.json` lists further models and routes;
24
+ this Provider maps only the models the Distribution already describes, on each model's standard
25
+ route.
26
+
27
+ Service limits this Provider reports as unsupported before submitting:
28
+
29
+ - `seedance-2.0-mini` has no `web_search` field; `web-search="true"` is unsupported there.
30
+ - `seedance-2.5/image-to-video` takes only `aspect-ratio="adaptive"`; `text-to-video` and
31
+ `image-to-video` render 720p or 1080p, `reference-to-video` also 480p.
32
+ - Seedream 5.0 lite renders 2K (`quality="basic"`) or 4K (`quality="ultra"`); `high` (3K) is
33
+ unsupported. `output-format` and `nsfw-check` have no HiAPI field and are not sent.
34
+ - `minimax-h3` renders 2K only and accepts up to five reference images; requests send
35
+ `watermark: false`.
36
+ - GPT Image 2: `background` only at 1K; `auto` ratio only at 1K; 2K excludes `5:4`, `4:5`, `3:1`,
37
+ `1:3`, `9:21`; 4K excludes `1:1`, `3:1`, `1:3`, `9:21`; image-to-image takes up to six references.
38
+ - Grok Imagine renders 480p or 720p; `grok-imagine-1.5/image-to-video` animates exactly one image.
39
+
40
+ Seedance visual references may carry `person-reference`; the Provider accepts the declaration and
41
+ transmits nothing for it, since HiAPI has no field for it. Seedance rejects reference images and
42
+ videos that contain a real human face; HiAPI offers no way to register authorized portrait material,
43
+ so such a request fails with the service's moderation error.
44
+
45
+ Reference images and audio travel inline as `data:` URLs, which HiAPI documents for its Seedance
46
+ inputs, within the per-file sizes each model page states: 30 MB images and 15 MB audio for
47
+ `seedance-2.0-mini` and the Seedance 2.5 models (2.5 also caps combined images at 120 MB), 10 MB
48
+ images for `seedream-5.0-lite/image-to-image` and `grok-imagine/image-to-video`, 20 MB for
49
+ `grok-imagine-1.5/image-to-video`, 30 MB for `Nano-Banana-Pro`. The Provider checks these before
50
+ submitting. A reference video must be a public HTTPS URL; configure `publicAssetUrl` when embedding
51
+ the Provider, otherwise such a request fails before submission.
52
+
53
+ Runtime Profile example:
54
+
55
+ ```json
56
+ {
57
+ "endpoints": {
58
+ "hiapi.default": {
59
+ "use": "@hypit/provider-hiapi",
60
+ "pool": "hiapi.default",
61
+ "config": {
62
+ "apiKey": { "store": "platform", "key": "hiapi.api-key" },
63
+ "defaultConcurrency": 3,
64
+ "pollIntervalMs": 10000
65
+ }
66
+ }
67
+ }
68
+ }
69
+ ```
70
+
71
+ `baseUrl` defaults to `https://api.hiapi.ai`. Store the API key with
72
+ `hypit auth login hiapi.default --runtime hypit.runtime.json`. Optional `requestTimeoutMs`,
73
+ `operationTimeoutMs` and `actionLimits` bound single HTTP calls, the whole remote task and action
74
+ concurrency. HTTP failures keep HiAPI's `error_code` and message; failed tasks keep
75
+ `data.error.code` and message, with any signed URL in the message redacted.
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@hypit/provider-hiapi",
3
+ "version": "0.0.0-dev",
4
+ "license": "SEE LICENSE IN LICENSE",
5
+ "private": true,
6
+ "type": "module",
7
+ "exports": { ".": "./src/index.ts" },
8
+ "hypit": { "activation": "./src/activation.ts" },
9
+ "dependencies": {
10
+ "@hypit/endpoint-kit": "workspace:*",
11
+ "@hypit/generation": "workspace:*",
12
+ "@hypit/protocol": "workspace:*",
13
+ "@hypit/runtime": "workspace:*",
14
+ "@hypit/runtime-kit": "workspace:*"
15
+ },
16
+ "devDependencies": {
17
+ "@hypit/gpt-image": "workspace:*",
18
+ "@hypit/grok-imagine": "workspace:*",
19
+ "@hypit/minimax-h3": "workspace:*",
20
+ "@hypit/nano-banana": "workspace:*",
21
+ "@hypit/seedance": "workspace:*",
22
+ "@hypit/seedream": "workspace:*"
23
+ }
24
+ }