@vgai/fal-client-compat 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,112 @@
1
+ # @vgai/fal-client-compat
2
+
3
+ Fal execution modes without a VGAI generation API.
4
+
5
+ ```sh
6
+ npm install --save-dev @fal-ai/client@1.10.1 @vgai/fal-client-compat
7
+ ```
8
+
9
+ The package follows VGAI's source-readable package doctrine and publishes its
10
+ TypeScript `src/` directly. It is author-time tooling; projects that do not
11
+ generate through Fal do not install it.
12
+
13
+ Game code continues to use the upstream package and upstream endpoint types:
14
+
15
+ ```ts
16
+ import { fal } from '@fal-ai/client';
17
+
18
+ const result = await fal.subscribe('fal-ai/nano-banana-2', {
19
+ input: { prompt: 'a brass owl game icon', output_format: 'png', resolution: '1K' },
20
+ });
21
+ ```
22
+
23
+ Reference-conditioned editing uses Fal's native edit endpoint and passes the
24
+ reference images directly:
25
+
26
+ ```ts
27
+ const result = await fal.run('fal-ai/nano-banana-2/edit', {
28
+ input: {
29
+ prompt: 'preserve the composition and apply the material reference',
30
+ image_urls: [compositionDataUrl, materialReferenceUrl],
31
+ output_format: 'png',
32
+ resolution: '1K',
33
+ },
34
+ });
35
+ ```
36
+
37
+ The host selects execution before game setup. Mock mode replaces only standard
38
+ Fetch and performs no network I/O:
39
+
40
+ ```ts
41
+ import { fal } from '@fal-ai/client';
42
+ import { createMockFalFetch } from '@vgai/fal-client-compat/mock';
43
+
44
+ fal.config({ credentials: 'vgai-mock', fetch: createMockFalFetch() });
45
+ ```
46
+
47
+ Direct/BYOK mode is Fal's normal `credentials` configuration. An optional
48
+ Fetch observer makes its request id automatic provenance without touching the
49
+ credential or authored call:
50
+
51
+ ```ts
52
+ import { createFalClient } from '@fal-ai/client';
53
+ import { createDirectFalFetch } from '@vgai/fal-client-compat/direct';
54
+
55
+ const fal = createFalClient({
56
+ credentials: userFalKey,
57
+ fetch: createDirectFalFetch(),
58
+ });
59
+ ```
60
+
61
+ Managed mode is also configured through Fal's native client options:
62
+
63
+ ```ts
64
+ import { createFalClient } from '@fal-ai/client';
65
+ import { createManagedFalFetch } from '@vgai/fal-client-compat/managed';
66
+
67
+ const gatewayUrl = 'https://generation.vgai.example';
68
+ const fal = createFalClient({
69
+ credentials: undefined,
70
+ proxyUrl: { url: `${gatewayUrl}/fal/proxy`, when: 'always' },
71
+ fetch: createManagedFalFetch({
72
+ gatewayUrl,
73
+ accessToken: () => vgaiSession.accessToken(),
74
+ }),
75
+ });
76
+ ```
77
+
78
+ The helper does not add a generation API. It only authenticates Fal's normal
79
+ proxy and artifact requests and observes the gateway's audit headers. When
80
+ generated bytes are committed through the project output writer, those facts
81
+ are consumed automatically into the one `.vgai/provenance.json` transaction.
82
+
83
+ The mock compatibility surface currently implements these native Fal endpoints
84
+ for `fal.run`, `fal.subscribe`, and the corresponding queue calls:
85
+
86
+ - `fal-ai/nano-banana-2` emits deterministic PNGs containing readable request
87
+ parameters and a hash-derived visual pattern. `fal-ai/nano-banana-2/edit`
88
+ does the same while requiring at least one reference image and including the
89
+ ordered `image_urls` list in artifact identity. The original Nano Banana
90
+ endpoints remain supported for existing authored calls, but are not VGAI's
91
+ first-party defaults.
92
+ - `fal-ai/stable-audio` emits deterministic PCM WAV files. The request is
93
+ embedded as standard WAV metadata and encoded into an audible hash-derived
94
+ tone pattern, so parameter changes produce visibly and audibly distinct
95
+ artifacts.
96
+ - `fal-ai/sam-3/3d-objects` emits deterministic 3DGS-style ASCII PLY with
97
+ position, color, opacity, scale, and rotation properties. Three.js's real
98
+ `PLYLoader` parses the same bytes used by project output.
99
+ - `fal-ai/minimax/hailuo-02/standard/text-to-video` emits a genuine VP8/WebM
100
+ motion clip. Request hash selects its visible palette/motion design and the
101
+ canonical request is retained in an ignorable EBML element; Chromium decode
102
+ and playback are exercised by the heavy integration proof.
103
+ - `fal-ai/hunyuan_world/image-to-world` returns the endpoint's native
104
+ `world_file` record. Its bytes mirror the live Hunyuan ZIP: deterministic
105
+ source/full/sky PNGs plus indexed, vertex-colored `mesh_layer0.ply` and
106
+ `mesh_layer1.ply`, both parsed by Three.js's real `PLYLoader`. World-to-scene
107
+ conversion remains an explicit author-time import step; the mock does not
108
+ pretend Fal returns a VGAI scene document.
109
+ - `fal-ai/hunyuan_world` returns the endpoint's native `image` record as a
110
+ deterministic 2:1 equirectangular PNG panorama.
111
+
112
+ Unsupported endpoints and unsupported compatibility subsets fail loudly.
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@vgai/fal-client-compat",
3
+ "author": "Volter AI, Inc.",
4
+ "license": "Apache-2.0",
5
+ "version": "0.1.0",
6
+ "type": "module",
7
+ "description": "Native @fal-ai/client execution across direct, VGAI-managed, and deterministic mock transports.",
8
+ "homepage": "https://github.com/volter-ai/vgai-engine#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/volter-ai/vgai-engine.git",
12
+ "directory": "packages/fal-client-compat"
13
+ },
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "files": [
18
+ "src",
19
+ "README.md"
20
+ ],
21
+ "exports": {
22
+ "./direct": "./src/direct.ts",
23
+ "./mock": "./src/mock.ts",
24
+ "./managed": "./src/managed.ts"
25
+ },
26
+ "dependencies": {
27
+ "@fal-ai/client": "1.10.1",
28
+ "fflate": "^0.8.2"
29
+ },
30
+ "devDependencies": {
31
+ "three": "^0.180.0"
32
+ }
33
+ }
package/src/direct.ts ADDED
@@ -0,0 +1,64 @@
1
+ const EXECUTION_RECORDER = Symbol.for('vgai.generative-execution-recorder.v1');
2
+
3
+ export interface DirectFalFetchOptions {
4
+ fetch?: typeof globalThis.fetch;
5
+ }
6
+
7
+ function recordExecution(facts: Record<string, unknown>): void {
8
+ const recorder = (globalThis as Record<symbol, unknown>)[EXECUTION_RECORDER];
9
+ if (typeof recorder === 'function') recorder(facts);
10
+ }
11
+
12
+ function isFalExecution(url: URL): boolean {
13
+ return (
14
+ url.protocol === 'https:' && (url.hostname === 'fal.run' || url.hostname === 'queue.fal.run')
15
+ );
16
+ }
17
+
18
+ function endpointFrom(url: URL): string | undefined {
19
+ const path = url.pathname.replace(/^\/+|\/+$/g, '');
20
+ const requestIndex = path.indexOf('/requests/');
21
+ const endpoint = requestIndex === -1 ? path : path.slice(0, requestIndex);
22
+ return endpoint.includes('/') ? endpoint : undefined;
23
+ }
24
+
25
+ async function responseRequestId(response: Response): Promise<string | undefined> {
26
+ const header = response.headers.get('x-fal-request-id');
27
+ if (header) return header;
28
+ try {
29
+ const body = (await response.clone().json()) as { request_id?: unknown };
30
+ return typeof body.request_id === 'string' ? body.request_id : undefined;
31
+ } catch {
32
+ return undefined;
33
+ }
34
+ }
35
+
36
+ /** Observes Fal's ordinary BYOK Fetch traffic without changing or authenticating it. */
37
+ export function createDirectFalFetch(options: DirectFalFetchOptions = {}): typeof fetch {
38
+ const baseFetch = options.fetch ?? globalThis.fetch;
39
+ return async (input, init) => {
40
+ const request = new Request(input, init);
41
+ const url = new URL(request.url);
42
+ const response = await baseFetch(request);
43
+ const model = endpointFrom(url);
44
+ if (
45
+ (request.method === 'POST' ||
46
+ (request.method === 'GET' && url.pathname.includes('/requests/'))) &&
47
+ isFalExecution(url) &&
48
+ model &&
49
+ response.ok
50
+ ) {
51
+ const requestId = await responseRequestId(response);
52
+ const pathRequestId = /\/requests\/([^/]+)/.exec(url.pathname)?.[1];
53
+ recordExecution({
54
+ mode: 'direct',
55
+ provider: 'fal',
56
+ model,
57
+ ...(requestId || pathRequestId
58
+ ? { requestId: requestId ?? decodeURIComponent(pathRequestId!) }
59
+ : {}),
60
+ });
61
+ }
62
+ return response;
63
+ };
64
+ }
package/src/managed.ts ADDED
@@ -0,0 +1,70 @@
1
+ const EXECUTION_RECORDER = Symbol.for('vgai.generative-execution-recorder.v1');
2
+
3
+ export interface ManagedFalFetchOptions {
4
+ gatewayUrl: string;
5
+ accessToken: string | (() => string | Promise<string>);
6
+ fetch?: typeof globalThis.fetch;
7
+ }
8
+
9
+ function recordExecution(facts: Record<string, unknown>): void {
10
+ const recorder = (globalThis as Record<symbol, unknown>)[EXECUTION_RECORDER];
11
+ if (typeof recorder === 'function') recorder(facts);
12
+ }
13
+
14
+ async function accessToken(value: ManagedFalFetchOptions['accessToken']): Promise<string> {
15
+ return typeof value === 'function' ? value() : value;
16
+ }
17
+
18
+ /**
19
+ * Authenticates Fal's normal proxy requests and observes gateway execution
20
+ * headers. Configure it through the upstream client's standard `fetch` and
21
+ * `proxyUrl` options; authored Fal calls remain unchanged.
22
+ */
23
+ export function createManagedFalFetch(options: ManagedFalFetchOptions): typeof fetch {
24
+ const baseFetch = options.fetch ?? globalThis.fetch;
25
+ const gateway = new URL(options.gatewayUrl);
26
+ const falProxy = new URL('/fal/proxy', gateway);
27
+ const managedFetch: typeof fetch = async (input, init) => {
28
+ const request = new Request(input, init);
29
+ const url = new URL(request.url);
30
+ const managed =
31
+ url.origin === gateway.origin &&
32
+ (url.pathname === falProxy.pathname || url.pathname.startsWith('/artifacts/'));
33
+ if (!managed) return baseFetch(input, init);
34
+ const headers = new Headers(request.headers);
35
+ headers.set('Authorization', `Bearer ${await accessToken(options.accessToken)}`);
36
+ const response = await baseFetch(new Request(request, { headers }));
37
+ if (response.headers.get('X-VGAI-Generation-Created') === 'true') {
38
+ recordExecution({
39
+ mode: 'managed',
40
+ provider: 'fal',
41
+ ...(response.headers.get('X-VGAI-Model')
42
+ ? { model: response.headers.get('X-VGAI-Model')! }
43
+ : {}),
44
+ ...(response.headers.get('x-fal-request-id')
45
+ ? { requestId: response.headers.get('x-fal-request-id')! }
46
+ : {}),
47
+ ...(response.headers.get('X-VGAI-Managed-Job-ID')
48
+ ? { managedJobId: response.headers.get('X-VGAI-Managed-Job-ID')! }
49
+ : {}),
50
+ });
51
+ }
52
+ const target = request.headers.get('x-fal-target-url');
53
+ const targetUrl = target ? new URL(target) : null;
54
+ const requestMatch = targetUrl ? /\/requests\/([^/]+)/.exec(targetUrl.pathname) : null;
55
+ if (request.method === 'GET' && requestMatch && response.ok) {
56
+ const model = targetUrl!.pathname.replace(/^\//, '').split('/requests/')[0];
57
+ recordExecution({
58
+ mode: 'managed',
59
+ provider: 'fal',
60
+ ...(model ? { model } : {}),
61
+ requestId: decodeURIComponent(requestMatch[1] ?? ''),
62
+ ...(response.headers.get('X-VGAI-Managed-Job-ID')
63
+ ? { managedJobId: response.headers.get('X-VGAI-Managed-Job-ID')! }
64
+ : {}),
65
+ });
66
+ }
67
+ return response;
68
+ };
69
+ return managedFetch;
70
+ }
package/src/mock.ts ADDED
@@ -0,0 +1,1092 @@
1
+ import { createFalClient, type FalClient } from '@fal-ai/client';
2
+ import type {
3
+ HunyuanWorldImageToWorldInput,
4
+ HunyuanWorldImageToWorldOutput,
5
+ HunyuanWorldInput,
6
+ HunyuanWorldOutput,
7
+ MinimaxHailuo02StandardTextToVideoInput,
8
+ MinimaxHailuo02StandardTextToVideoOutput,
9
+ NanoBanana2EditInput,
10
+ NanoBanana2Input,
11
+ NanoBananaEditInput,
12
+ NanoBananaInput,
13
+ NanoBananaOutput,
14
+ Sam33dObjectsInput,
15
+ Sam33dObjectsOutput,
16
+ Seedance2R2VInput,
17
+ Seedance2VideoOutput,
18
+ StableAudioInput,
19
+ StableAudioOutput,
20
+ } from '@fal-ai/client/endpoints';
21
+ import { zipSync } from 'fflate';
22
+
23
+ const NANO_BANANA_ENDPOINT = 'fal-ai/nano-banana';
24
+ const NANO_BANANA_EDIT_ENDPOINT = 'fal-ai/nano-banana/edit';
25
+ const NANO_BANANA_2_ENDPOINT = 'fal-ai/nano-banana-2';
26
+ const NANO_BANANA_2_EDIT_ENDPOINT = 'fal-ai/nano-banana-2/edit';
27
+ const STABLE_AUDIO_ENDPOINT = 'fal-ai/stable-audio';
28
+ const SAM_3D_OBJECTS_ENDPOINT = 'fal-ai/sam-3/3d-objects';
29
+ const HAILUO_TEXT_TO_VIDEO_ENDPOINT = 'fal-ai/minimax/hailuo-02/standard/text-to-video';
30
+ const SEEDANCE_2_REFERENCE_TO_VIDEO_ENDPOINT = 'bytedance/seedance-2.0/reference-to-video';
31
+ const HUNYUAN_IMAGE_TO_WORLD_ENDPOINT = 'fal-ai/hunyuan_world/image-to-world';
32
+ const HUNYUAN_PANORAMA_ENDPOINT = 'fal-ai/hunyuan_world';
33
+ const SUPPORTED_ENDPOINTS = new Set([
34
+ NANO_BANANA_ENDPOINT,
35
+ NANO_BANANA_EDIT_ENDPOINT,
36
+ NANO_BANANA_2_ENDPOINT,
37
+ NANO_BANANA_2_EDIT_ENDPOINT,
38
+ STABLE_AUDIO_ENDPOINT,
39
+ SAM_3D_OBJECTS_ENDPOINT,
40
+ HAILUO_TEXT_TO_VIDEO_ENDPOINT,
41
+ SEEDANCE_2_REFERENCE_TO_VIDEO_ENDPOINT,
42
+ HUNYUAN_IMAGE_TO_WORLD_ENDPOINT,
43
+ HUNYUAN_PANORAMA_ENDPOINT,
44
+ ]);
45
+ const EXECUTION_RECORDER = Symbol.for('vgai.generative-execution-recorder.v1');
46
+
47
+ type NanoBananaEndpoint =
48
+ | typeof NANO_BANANA_ENDPOINT
49
+ | typeof NANO_BANANA_EDIT_ENDPOINT
50
+ | typeof NANO_BANANA_2_ENDPOINT
51
+ | typeof NANO_BANANA_2_EDIT_ENDPOINT;
52
+ type NanoBananaAnyInput =
53
+ | NanoBananaInput
54
+ | NanoBananaEditInput
55
+ | NanoBanana2Input
56
+ | NanoBanana2EditInput;
57
+
58
+ type MockTask = {
59
+ endpoint: string;
60
+ output:
61
+ | NanoBananaOutput
62
+ | StableAudioOutput
63
+ | Sam33dObjectsOutput
64
+ | MinimaxHailuo02StandardTextToVideoOutput
65
+ | Seedance2VideoOutput
66
+ | HunyuanWorldImageToWorldOutput
67
+ | HunyuanWorldOutput;
68
+ };
69
+
70
+ // Four tiny, real VP8/WebM motion designs. Request hash chooses the visible
71
+ // palette/motion; an EBML Void element added below carries canonical input.
72
+ const MOCK_WEBM_TEMPLATES = [
73
+ 'GkXfo59ChoEBQveBAULygQRC84EIQoKEd2VibUKHgQJChYECGFOAZwH/////////EU2bdKtNu4tTq4QVSalmU6yBoU27i1OrhBZUrmtTrIHNTbuMU6uEElTDZ1OsggEa7AEAAAAAAABoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAVSalmpyrXsYMPQkBNgI1MYXZmNjIuMTIuMTAyV0GNTGF2ZjYyLjEyLjEwMhZUrmvIrgEAAAAAAAA/14EBc8WIhaX3Yf4bew6cgQAitZyDdW5kiIEAhoVWX1ZQOIOBASPjg4QO5rKA4JCwgWC6gTaagQJVsIRVuYEBElTDZ9hzc6BjwIBnyJpFo4dFTkNPREVSRIeNTGF2ZjYyLjEyLjEwMnNzsmPAi2PFiIWl92H+G3sOZ8ihRaOHRU5DT0RFUkSHlExhdmM2Mi4yOC4xMDIgbGlidnB4H0O2dUFX54EAo0D0gQAAgNAIAJ0BKmAANgAARwiFhYiZhIgCAgK5VQrpf6B+KtKB/qv2A1wD/AekB4AH9AfRV/6P8c9gH0ARGicISY8bLJTiAIBSID2+jIQkRbH750QA/v29oP2+H7fD9vh/+3w/7dn12fXZ/1h9//ycH/4PkI//701YywfxNNmGndnm0hcR6CTkBzEfcUyk4JhZPsvsfKiX//8chz/3bWMJg6Hj97gd93EtaStwAK6J7a25mBDJh85pm+evm0mDQxJv9wTp+4HfrsqrErbUXaehWvqmECcvGF3u/6d/Mv/T4ONyve3Jfrn5aWm7wcthAyGxhCe2QKOsgQD6AFECAAEQOAAYABk0B/QAAacr7mbmgP73DW/tofWh9aH/Ud/IJa7jWkCjrYEB9ABRAgABECgAGAAZNAf0AAGnK+5m5oD++iGX+BO8Cd4E7/wJ381/zCfzoA==',
74
+ 'GkXfo59ChoEBQveBAULygQRC84EIQoKEd2VibUKHgQJChYECGFOAZwH/////////EU2bdKtNu4tTq4QVSalmU6yBoU27i1OrhBZUrmtTrIHNTbuMU6uEElTDZ1OsggEa7AEAAAAAAABoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAVSalmpyrXsYMPQkBNgI1MYXZmNjIuMTIuMTAyV0GNTGF2ZjYyLjEyLjEwMhZUrmvIrgEAAAAAAAA/14EBc8WInmA/l8KNRcycgQAitZyDdW5kiIEAhoVWX1ZQOIOBASPjg4QO5rKA4JCwgWC6gTaagQJVsIRVuYEBElTDZ9hzc6BjwIBnyJpFo4dFTkNPREVSRIeNTGF2ZjYyLjEyLjEwMnNzsmPAi2PFiJ5gP5fCjUXMZ8ihRaOHRU5DT0RFUkSHlExhdmM2Mi4yOC4xMDIgbGlidnB4H0O2dUDl54EAo0CMgQAAgFAIAJ0BKmAANgAARwiFhYiZhIgCAgJ11Qv5X+M3KVMR53+Jv5Jc4zseQB/gP5Ab3/n/+6A/wEMOiTH7CMeUNdUQe2Oxm5m+IUo3NED+9+NvROjelX0ZUvqD//0r4AAlmFJEoAB0rJ+4jCw1N32zZ0Q81NSdhVoRYgD6e5CcQAFCbLgCfH4dwACjp4EA+gCxAgABEDgAGAAZPv/0AAX8s40YV198fwD+7ycJamax4kFZQKOogQH0ALECAAEQKAAYABk+//QABfyzjRhXX3x/AP7wwxmYi1BTNy8zgA==',
75
+ 'GkXfo59ChoEBQveBAULygQRC84EIQoKEd2VibUKHgQJChYECGFOAZwH/////////EU2bdKtNu4tTq4QVSalmU6yBoU27i1OrhBZUrmtTrIHNTbuMU6uEElTDZ1OsggEa7AEAAAAAAABoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAVSalmpyrXsYMPQkBNgI1MYXZmNjIuMTIuMTAyV0GNTGF2ZjYyLjEyLjEwMhZUrmvIrgEAAAAAAAA/14EBc8WI0xd3qFV5e0ScgQAitZyDdW5kiIEAhoVWX1ZQOIOBASPjg4QO5rKA4JCwgWC6gTaagQJVsIRVuYEBElTDZ9hzc6BjwIBnyJpFo4dFTkNPREVSRIeNTGF2ZjYyLjEyLjEwMnNzsmPAi2PFiNMXd6hVeXtEZ8ihRaOHRU5DT0RFUkSHlExhdmM2Mi4yOC4xMDIgbGlidnB4H0O2dUDJ54EAo/mBAACA8AQAnQEqYAA2AABHCIWFiJmEiAICAsuzZsAIaNo8/tQJRFOqCagIh7jKnQU9svKEAP5vH160tzcBeknf+aT/6wxP/06CKG7dIVPRXjtS2P//+rRT/9RrApgFu/zpOrcMWpnwjB5GzQmkr8c7X+6g0TGs4GFAo6OBAPoAsQIAARA4ABgAGUAH9AAFL8S8pJ1Kl+KA/MpyfojAAKOkgQH0ALECAAEQKAAYABlAB/QABS/EvKSdSpfigPzK3uWigEAA',
76
+ 'GkXfo59ChoEBQveBAULygQRC84EIQoKEd2VibUKHgQJChYECGFOAZwH/////////EU2bdKtNu4tTq4QVSalmU6yBoU27i1OrhBZUrmtTrIHNTbuMU6uEElTDZ1OsggEa7AEAAAAAAABoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAVSalmpyrXsYMPQkBNgI1MYXZmNjIuMTIuMTAyV0GNTGF2ZjYyLjEyLjEwMhZUrmvIrgEAAAAAAAA/14EBc8WIFnQmGsn+MxacgQAitZyDdW5kiIEAhoVWX1ZQOIOBASPjg4QO5rKA4JCwgWC6gTaagQJVsIRVuYEBElTDZ9hzc6BjwIBnyJpFo4dFTkNPREVSRIeNTGF2ZjYyLjEyLjEwMnNzsmPAi2PFiBZ0JhrJ/jMWZ8ihRaOHRU5DT0RFUkSHlExhdmM2Mi4yOC4xMDIgbGlidnB4H0O2dUDn54EAo/iBAACAUAYAnQEqYAA2AABHCIWFiJmEiAICAnXVC/gP4gcpUxHgP4m/slxgYa0SmqSORnLVQqSEqrMINFPbLxYA/vdFLpmFZ/ihknVeSv//ocAABW5PgAJIhR/N8QAAAAAAAAA/wk8GRGHw/gRqz+7UAAAAAAAAAACjtoEA+gCxAgABEDgAGAAbSP/0AAnPHkz3h2iD8QD+7rqQnV1qRZYEAAAAAABf58AAAAAPgAAAAKOwgQH0AJECAAEQKAAYABpA//QABlELLgahsDqI/vCPdLzxC9H0M/ps3gAAAAEDAAAA',
77
+ ] as const;
78
+
79
+ function recordExecution(facts: Record<string, unknown>): void {
80
+ const recorder = (globalThis as Record<symbol, unknown>)[EXECUTION_RECORDER];
81
+ if (typeof recorder === 'function') recorder(facts);
82
+ }
83
+
84
+ export type MockFalClientOptions = {
85
+ /** Called for observability only; the mock never delegates to the network. */
86
+ onRequest?: (request: { method: string; url: string }) => void;
87
+ };
88
+
89
+ const FONT: Readonly<Record<string, readonly string[]>> = {
90
+ ' ': ['00000', '00000', '00000', '00000', '00000', '00000', '00000'],
91
+ '-': ['00000', '00000', '00000', '11111', '00000', '00000', '00000'],
92
+ '.': ['00000', '00000', '00000', '00000', '00000', '01100', '01100'],
93
+ '/': ['00001', '00010', '00100', '01000', '10000', '00000', '00000'],
94
+ ':': ['00000', '01100', '01100', '00000', '01100', '01100', '00000'],
95
+ '?': ['01110', '10001', '00001', '00010', '00100', '00000', '00100'],
96
+ '0': ['01110', '10001', '10011', '10101', '11001', '10001', '01110'],
97
+ '1': ['00100', '01100', '00100', '00100', '00100', '00100', '01110'],
98
+ '2': ['01110', '10001', '00001', '00010', '00100', '01000', '11111'],
99
+ '3': ['11110', '00001', '00001', '01110', '00001', '00001', '11110'],
100
+ '4': ['00010', '00110', '01010', '10010', '11111', '00010', '00010'],
101
+ '5': ['11111', '10000', '10000', '11110', '00001', '00001', '11110'],
102
+ '6': ['01110', '10000', '10000', '11110', '10001', '10001', '01110'],
103
+ '7': ['11111', '00001', '00010', '00100', '01000', '01000', '01000'],
104
+ '8': ['01110', '10001', '10001', '01110', '10001', '10001', '01110'],
105
+ '9': ['01110', '10001', '10001', '01111', '00001', '00001', '01110'],
106
+ A: ['01110', '10001', '10001', '11111', '10001', '10001', '10001'],
107
+ B: ['11110', '10001', '10001', '11110', '10001', '10001', '11110'],
108
+ C: ['01111', '10000', '10000', '10000', '10000', '10000', '01111'],
109
+ D: ['11110', '10001', '10001', '10001', '10001', '10001', '11110'],
110
+ E: ['11111', '10000', '10000', '11110', '10000', '10000', '11111'],
111
+ F: ['11111', '10000', '10000', '11110', '10000', '10000', '10000'],
112
+ G: ['01111', '10000', '10000', '10111', '10001', '10001', '01111'],
113
+ H: ['10001', '10001', '10001', '11111', '10001', '10001', '10001'],
114
+ I: ['01110', '00100', '00100', '00100', '00100', '00100', '01110'],
115
+ J: ['00111', '00010', '00010', '00010', '10010', '10010', '01100'],
116
+ K: ['10001', '10010', '10100', '11000', '10100', '10010', '10001'],
117
+ L: ['10000', '10000', '10000', '10000', '10000', '10000', '11111'],
118
+ M: ['10001', '11011', '10101', '10101', '10001', '10001', '10001'],
119
+ N: ['10001', '11001', '10101', '10011', '10001', '10001', '10001'],
120
+ O: ['01110', '10001', '10001', '10001', '10001', '10001', '01110'],
121
+ P: ['11110', '10001', '10001', '11110', '10000', '10000', '10000'],
122
+ Q: ['01110', '10001', '10001', '10001', '10101', '10010', '01101'],
123
+ R: ['11110', '10001', '10001', '11110', '10100', '10010', '10001'],
124
+ S: ['01111', '10000', '10000', '01110', '00001', '00001', '11110'],
125
+ T: ['11111', '00100', '00100', '00100', '00100', '00100', '00100'],
126
+ U: ['10001', '10001', '10001', '10001', '10001', '10001', '01110'],
127
+ V: ['10001', '10001', '10001', '10001', '10001', '01010', '00100'],
128
+ W: ['10001', '10001', '10001', '10101', '10101', '10101', '01010'],
129
+ X: ['10001', '10001', '01010', '00100', '01010', '10001', '10001'],
130
+ Y: ['10001', '10001', '01010', '00100', '00100', '00100', '00100'],
131
+ Z: ['11111', '00001', '00010', '00100', '01000', '10000', '11111'],
132
+ };
133
+
134
+ function stableJson(value: unknown): string {
135
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
136
+ if (value !== null && typeof value === 'object') {
137
+ return `{${Object.entries(value)
138
+ .sort(([left], [right]) => left.localeCompare(right))
139
+ .map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`)
140
+ .join(',')}}`;
141
+ }
142
+ return JSON.stringify(value);
143
+ }
144
+
145
+ function hashBytes(value: string): Uint8Array {
146
+ // Four independent FNV-1a lanes are expanded with xorshift. This is a
147
+ // visual identity, not a security primitive, and works identically in a
148
+ // browser, Node, workers, and tests.
149
+ const lanes = new Uint32Array([0x811c9dc5, 0x9e3779b9, 0x85ebca6b, 0xc2b2ae35]);
150
+ const bytes = new TextEncoder().encode(value);
151
+ for (const byte of bytes) {
152
+ for (let index = 0; index < lanes.length; index += 1) {
153
+ const lane = lanes[index];
154
+ if (lane === undefined) continue;
155
+ lanes[index] = Math.imul(lane ^ (byte + index * 41), 0x01000193) >>> 0;
156
+ }
157
+ }
158
+ const output = new Uint8Array(32);
159
+ let state = (lanes[0] ?? 0) ^ (lanes[1] ?? 0) ^ (lanes[2] ?? 0) ^ (lanes[3] ?? 0);
160
+ for (let index = 0; index < output.length; index += 1) {
161
+ state ^= state << 13;
162
+ state ^= state >>> 17;
163
+ state ^= state << 5;
164
+ state = (state + (lanes[index % lanes.length] ?? 0) + index * 0x9e3779b9) >>> 0;
165
+ output[index] = state & 0xff;
166
+ }
167
+ return output;
168
+ }
169
+
170
+ function hex(bytes: Uint8Array): string {
171
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
172
+ }
173
+
174
+ function concatBytes(parts: readonly Uint8Array[]): Uint8Array {
175
+ const output = new Uint8Array(parts.reduce((total, part) => total + part.byteLength, 0));
176
+ let offset = 0;
177
+ for (const part of parts) {
178
+ output.set(part, offset);
179
+ offset += part.byteLength;
180
+ }
181
+ return output;
182
+ }
183
+
184
+ function base64(bytes: Uint8Array): string {
185
+ const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
186
+ let output = '';
187
+ for (let index = 0; index < bytes.length; index += 3) {
188
+ const first = bytes[index] ?? 0;
189
+ const second = bytes[index + 1] ?? 0;
190
+ const third = bytes[index + 2] ?? 0;
191
+ const packed = (first << 16) | (second << 8) | third;
192
+ output += alphabet[(packed >>> 18) & 63] ?? '';
193
+ output += alphabet[(packed >>> 12) & 63] ?? '';
194
+ output += index + 1 < bytes.length ? (alphabet[(packed >>> 6) & 63] ?? '') : '=';
195
+ output += index + 2 < bytes.length ? (alphabet[packed & 63] ?? '') : '=';
196
+ }
197
+ return output;
198
+ }
199
+
200
+ function imageDimensions(aspectRatio = '1:1'): [number, number] {
201
+ const [wide, high] = aspectRatio.split(':').map(Number);
202
+ if (!wide || !high) return [320, 320];
203
+ if (wide === high) return [320, 320];
204
+ if (wide > high) return [384, Math.max(192, Math.round((384 * high) / wide))];
205
+ return [Math.max(192, Math.round((384 * wide) / high)), 384];
206
+ }
207
+
208
+ function setPixel(
209
+ pixels: Uint8Array,
210
+ width: number,
211
+ height: number,
212
+ x: number,
213
+ y: number,
214
+ color: readonly [number, number, number, number],
215
+ ): void {
216
+ if (x < 0 || y < 0 || x >= width || y >= height) return;
217
+ const offset = (y * width + x) * 4;
218
+ pixels.set(color, offset);
219
+ }
220
+
221
+ function fillRect(
222
+ pixels: Uint8Array,
223
+ width: number,
224
+ height: number,
225
+ x: number,
226
+ y: number,
227
+ rectWidth: number,
228
+ rectHeight: number,
229
+ color: readonly [number, number, number, number],
230
+ ): void {
231
+ for (let row = y; row < y + rectHeight; row += 1) {
232
+ for (let column = x; column < x + rectWidth; column += 1) {
233
+ setPixel(pixels, width, height, column, row, color);
234
+ }
235
+ }
236
+ }
237
+
238
+ function drawGlyph(
239
+ pixels: Uint8Array,
240
+ width: number,
241
+ height: number,
242
+ glyph: readonly string[],
243
+ x: number,
244
+ y: number,
245
+ scale: number,
246
+ color: readonly [number, number, number, number],
247
+ ): void {
248
+ for (let row = 0; row < glyph.length; row += 1) {
249
+ const line = glyph[row] ?? '';
250
+ for (let column = 0; column < line.length; column += 1) {
251
+ if (line[column] === '1') {
252
+ fillRect(pixels, width, height, x + column * scale, y + row * scale, scale, scale, color);
253
+ }
254
+ }
255
+ }
256
+ }
257
+
258
+ function drawText(
259
+ pixels: Uint8Array,
260
+ width: number,
261
+ height: number,
262
+ text: string,
263
+ x: number,
264
+ y: number,
265
+ scale: number,
266
+ color: readonly [number, number, number, number],
267
+ ): void {
268
+ let cursor = x;
269
+ for (const rawCharacter of text.toUpperCase()) {
270
+ drawGlyph(
271
+ pixels,
272
+ width,
273
+ height,
274
+ FONT[rawCharacter] ?? FONT['?'] ?? [],
275
+ cursor,
276
+ y,
277
+ scale,
278
+ color,
279
+ );
280
+ cursor += 6 * scale;
281
+ if (cursor + 5 * scale >= width - 12) break;
282
+ }
283
+ }
284
+
285
+ function crc32(data: Uint8Array): number {
286
+ let crc = 0xffffffff;
287
+ for (const byte of data) {
288
+ crc ^= byte;
289
+ for (let bit = 0; bit < 8; bit += 1) {
290
+ crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
291
+ }
292
+ }
293
+ return (crc ^ 0xffffffff) >>> 0;
294
+ }
295
+
296
+ function uint32(value: number): Uint8Array {
297
+ return new Uint8Array([
298
+ (value >>> 24) & 0xff,
299
+ (value >>> 16) & 0xff,
300
+ (value >>> 8) & 0xff,
301
+ value & 0xff,
302
+ ]);
303
+ }
304
+
305
+ function pngChunk(kind: string, data: Uint8Array): Uint8Array {
306
+ const type = new TextEncoder().encode(kind);
307
+ return concatBytes([
308
+ uint32(data.byteLength),
309
+ type,
310
+ data,
311
+ uint32(crc32(concatBytes([type, data]))),
312
+ ]);
313
+ }
314
+
315
+ function adler32(data: Uint8Array): number {
316
+ let first = 1;
317
+ let second = 0;
318
+ for (const byte of data) {
319
+ first = (first + byte) % 65521;
320
+ second = (second + first) % 65521;
321
+ }
322
+ return ((second << 16) | first) >>> 0;
323
+ }
324
+
325
+ function storeDeflate(data: Uint8Array): Uint8Array {
326
+ const parts: Uint8Array[] = [new Uint8Array([0x78, 0x01])];
327
+ for (let offset = 0; offset < data.length; offset += 65535) {
328
+ const block = data.subarray(offset, Math.min(offset + 65535, data.length));
329
+ const length = block.byteLength;
330
+ const inverse = ~length & 0xffff;
331
+ const final = offset + length >= data.length ? 1 : 0;
332
+ parts.push(
333
+ new Uint8Array([
334
+ final,
335
+ length & 0xff,
336
+ (length >>> 8) & 0xff,
337
+ inverse & 0xff,
338
+ (inverse >>> 8) & 0xff,
339
+ ]),
340
+ block,
341
+ );
342
+ }
343
+ parts.push(uint32(adler32(data)));
344
+ return concatBytes(parts);
345
+ }
346
+
347
+ function encodePng(
348
+ width: number,
349
+ height: number,
350
+ pixels: Uint8Array,
351
+ canonicalInput: string,
352
+ ): Uint8Array {
353
+ const header = new Uint8Array(13);
354
+ new DataView(header.buffer).setUint32(0, width);
355
+ new DataView(header.buffer).setUint32(4, height);
356
+ header[8] = 8;
357
+ header[9] = 6;
358
+ const scanlines = new Uint8Array(height * (width * 4 + 1));
359
+ for (let row = 0; row < height; row += 1) {
360
+ const target = row * (width * 4 + 1);
361
+ scanlines[target] = 0;
362
+ scanlines.set(pixels.subarray(row * width * 4, (row + 1) * width * 4), target + 1);
363
+ }
364
+ const metadata = new TextEncoder().encode(
365
+ `vgai-mock-input\0${base64(new TextEncoder().encode(canonicalInput))}`,
366
+ );
367
+ return concatBytes([
368
+ new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]),
369
+ pngChunk('IHDR', header),
370
+ pngChunk('tEXt', metadata),
371
+ pngChunk('IDAT', storeDeflate(scanlines)),
372
+ pngChunk('IEND', new Uint8Array()),
373
+ ]);
374
+ }
375
+
376
+ function fillBackground(pixels: Uint8Array, width: number, height: number, hash: Uint8Array): void {
377
+ const primary: [number, number, number, number] = [
378
+ 40 + (hash[0] ?? 0) / 2,
379
+ 40 + (hash[1] ?? 0) / 2,
380
+ 40 + (hash[2] ?? 0) / 2,
381
+ 255,
382
+ ];
383
+ const secondary: [number, number, number, number] = [
384
+ 80 + (hash[3] ?? 0) / 3,
385
+ 80 + (hash[4] ?? 0) / 3,
386
+ 80 + (hash[5] ?? 0) / 3,
387
+ 255,
388
+ ];
389
+
390
+ for (let y = 0; y < height; y += 1) {
391
+ for (let x = 0; x < width; x += 1) {
392
+ const band = Math.floor((x + y + (hash[6] ?? 0)) / (12 + ((hash[7] ?? 0) % 24)));
393
+ const color = band % 2 === 0 ? primary : secondary;
394
+ setPixel(pixels, width, height, x, y, color);
395
+ }
396
+ }
397
+ }
398
+
399
+ function drawHashGrid(pixels: Uint8Array, width: number, height: number, hash: Uint8Array): void {
400
+ const cell = 5;
401
+ const grid = 17;
402
+ const gridX = width - grid * cell - 14;
403
+ const gridY = height - grid * cell - 14;
404
+ fillRect(
405
+ pixels,
406
+ width,
407
+ height,
408
+ gridX - 5,
409
+ gridY - 5,
410
+ grid * cell + 10,
411
+ grid * cell + 10,
412
+ [245, 248, 255, 255],
413
+ );
414
+ for (let row = 0; row < grid; row += 1) {
415
+ for (let column = 0; column < grid; column += 1) {
416
+ const bitIndex = (row * grid + column) % 256;
417
+ const filled = ((hash[Math.floor(bitIndex / 8)] ?? 0) >> (bitIndex % 8)) & 1;
418
+ if (filled) {
419
+ fillRect(
420
+ pixels,
421
+ width,
422
+ height,
423
+ gridX + column * cell,
424
+ gridY + row * cell,
425
+ cell,
426
+ cell,
427
+ [8, 12, 20, 255],
428
+ );
429
+ }
430
+ }
431
+ }
432
+ }
433
+
434
+ function mockImage(
435
+ endpoint: NanoBananaEndpoint,
436
+ input: NanoBananaAnyInput,
437
+ index: number,
438
+ ): Uint8Array {
439
+ const canonicalInput = stableJson({ endpoint, input, index });
440
+ const hash = hashBytes(canonicalInput);
441
+ const [width, height] = imageDimensions(input.aspect_ratio);
442
+ const pixels = new Uint8Array(width * height * 4);
443
+ fillBackground(pixels, width, height, hash);
444
+ fillRect(pixels, width, height, 10, 10, width - 20, Math.min(118, height - 20), [8, 12, 20, 224]);
445
+ drawText(pixels, width, height, 'VGAI MOCK', 20, 20, 3, [255, 255, 255, 255]);
446
+ drawText(
447
+ pixels,
448
+ width,
449
+ height,
450
+ endpoint.includes('nano-banana-2') ? 'FAL NANO BANANA 2' : 'FAL NANO BANANA',
451
+ 20,
452
+ 48,
453
+ 2,
454
+ [190, 235, 255, 255],
455
+ );
456
+ drawText(pixels, width, height, `PROMPT ${input.prompt}`, 20, 68, 2, [255, 255, 255, 255]);
457
+ drawText(
458
+ pixels,
459
+ width,
460
+ height,
461
+ `SEED ${input.seed ?? 'AUTO'} HASH ${hex(hash).slice(0, 10)}`,
462
+ 20,
463
+ 88,
464
+ 2,
465
+ [255, 230, 150, 255],
466
+ );
467
+ drawHashGrid(pixels, width, height, hash);
468
+ return encodePng(width, height, pixels, canonicalInput);
469
+ }
470
+
471
+ function createNanoBananaOutput(
472
+ endpoint: NanoBananaEndpoint,
473
+ input: NanoBananaAnyInput,
474
+ ): NanoBananaOutput {
475
+ if (!input || typeof input.prompt !== 'string' || input.prompt.trim().length === 0) {
476
+ throw new Error(`fal mock: ${endpoint} requires a non-empty input.prompt.`);
477
+ }
478
+ if (
479
+ (endpoint === NANO_BANANA_EDIT_ENDPOINT || endpoint === NANO_BANANA_2_EDIT_ENDPOINT) &&
480
+ (!Array.isArray((input as NanoBananaEditInput | NanoBanana2EditInput).image_urls) ||
481
+ (input as NanoBananaEditInput | NanoBanana2EditInput).image_urls.length === 0 ||
482
+ (input as NanoBananaEditInput | NanoBanana2EditInput).image_urls.some(
483
+ (url) => typeof url !== 'string' || url.trim().length === 0,
484
+ ))
485
+ ) {
486
+ throw new Error(
487
+ `fal mock: ${endpoint} requires at least one non-empty input.image_urls entry.`,
488
+ );
489
+ }
490
+ if (input.output_format && input.output_format !== 'png') {
491
+ throw new Error(
492
+ `fal mock: output_format ${JSON.stringify(input.output_format)} is not implemented; use "png".`,
493
+ );
494
+ }
495
+ const count = Math.min(4, Math.max(1, Math.trunc(input.num_images ?? 1)));
496
+ const [width, height] = imageDimensions(input.aspect_ratio);
497
+ return {
498
+ description: `Deterministic VGAI mock for: ${input.prompt}`,
499
+ images: Array.from({ length: count }, (_, index) => {
500
+ const png = mockImage(endpoint, input, index);
501
+ const digest = hex(hashBytes(base64(png))).slice(0, 16);
502
+ return {
503
+ url: `data:image/png;base64,${base64(png)}`,
504
+ content_type: 'image/png',
505
+ file_name: `vgai-mock-${digest}.png`,
506
+ file_size: png.byteLength,
507
+ width,
508
+ height,
509
+ };
510
+ }),
511
+ };
512
+ }
513
+
514
+ function ascii(value: string): Uint8Array {
515
+ return new TextEncoder().encode(value);
516
+ }
517
+
518
+ function paddedEven(bytes: Uint8Array): Uint8Array {
519
+ return bytes.byteLength % 2 === 0 ? bytes : concatBytes([bytes, new Uint8Array(1)]);
520
+ }
521
+
522
+ function riffChunk(kind: string, data: Uint8Array): Uint8Array {
523
+ const header = new Uint8Array(8);
524
+ header.set(ascii(kind), 0);
525
+ new DataView(header.buffer).setUint32(4, data.byteLength, true);
526
+ return concatBytes([header, paddedEven(data)]);
527
+ }
528
+
529
+ function encodeMockWav(input: StableAudioInput): Uint8Array {
530
+ const canonicalInput = stableJson({ endpoint: STABLE_AUDIO_ENDPOINT, input });
531
+ const hash = hashBytes(canonicalInput);
532
+ const requestedSeconds = input.seconds_total ?? 30;
533
+ const seconds = Math.min(30, Math.max(0.1, requestedSeconds));
534
+ const sampleRate = 16_000;
535
+ const sampleCount = Math.ceil(seconds * sampleRate);
536
+ const samples = new Uint8Array(sampleCount * 2);
537
+ const view = new DataView(samples.buffer);
538
+ const segmentSeconds = 0.09 + ((hash[0] ?? 0) / 255) * 0.05;
539
+
540
+ for (let index = 0; index < sampleCount; index += 1) {
541
+ const time = index / sampleRate;
542
+ const segment = Math.floor(time / segmentSeconds);
543
+ const segmentPhase = (time % segmentSeconds) / segmentSeconds;
544
+ const byte = hash[segment % hash.length] ?? 0;
545
+ const frequency = 180 + byte * 3 + (segment % 5) * 37;
546
+ const envelope = segmentPhase < 0.08 ? segmentPhase / 0.08 : Math.max(0, 1 - segmentPhase);
547
+ const gate = segmentPhase < 0.76 ? 1 : 0;
548
+ const fundamental = Math.sin(2 * Math.PI * frequency * time);
549
+ const overtone = Math.sin(2 * Math.PI * frequency * 1.5 * time) * 0.28;
550
+ const sample = Math.max(-1, Math.min(1, (fundamental + overtone) * envelope * gate * 0.32));
551
+ view.setInt16(index * 2, Math.round(sample * 0x7fff), true);
552
+ }
553
+
554
+ const format = new Uint8Array(16);
555
+ const formatView = new DataView(format.buffer);
556
+ formatView.setUint16(0, 1, true);
557
+ formatView.setUint16(2, 1, true);
558
+ formatView.setUint32(4, sampleRate, true);
559
+ formatView.setUint32(8, sampleRate * 2, true);
560
+ formatView.setUint16(12, 2, true);
561
+ formatView.setUint16(14, 16, true);
562
+
563
+ const comment = paddedEven(concatBytes([ascii(canonicalInput), new Uint8Array(1)]));
564
+ const infoComment = riffChunk('ICMT', comment);
565
+ const info = riffChunk('LIST', concatBytes([ascii('INFO'), infoComment]));
566
+ const chunks = concatBytes([riffChunk('fmt ', format), info, riffChunk('data', samples)]);
567
+ const header = new Uint8Array(12);
568
+ header.set(ascii('RIFF'), 0);
569
+ new DataView(header.buffer).setUint32(4, chunks.byteLength + 4, true);
570
+ header.set(ascii('WAVE'), 8);
571
+ return concatBytes([header, chunks]);
572
+ }
573
+
574
+ function createStableAudioOutput(input: StableAudioInput): StableAudioOutput {
575
+ if (!input || typeof input.prompt !== 'string' || input.prompt.trim().length === 0) {
576
+ throw new Error('fal mock: fal-ai/stable-audio requires a non-empty input.prompt.');
577
+ }
578
+ if (
579
+ input.seconds_total !== undefined &&
580
+ (!Number.isFinite(input.seconds_total) || input.seconds_total <= 0 || input.seconds_total > 30)
581
+ ) {
582
+ throw new Error('fal mock: seconds_total must be greater than 0 and no more than 30.');
583
+ }
584
+ const wav = encodeMockWav(input);
585
+ const digest = hex(hashBytes(base64(wav))).slice(0, 16);
586
+ return {
587
+ audio_file: {
588
+ url: `data:audio/wav;base64,${base64(wav)}`,
589
+ content_type: 'audio/wav',
590
+ file_name: `vgai-mock-${digest}.wav`,
591
+ file_size: wav.byteLength,
592
+ },
593
+ };
594
+ }
595
+
596
+ function finiteUnit(value: number): string {
597
+ return Number.isFinite(value) ? value.toFixed(6) : '0.000000';
598
+ }
599
+
600
+ function encodeMockGaussianSplatFromCanonical(canonicalInput: string): Uint8Array {
601
+ const hash = hashBytes(canonicalInput);
602
+ const count = 256;
603
+ const rows: string[] = [];
604
+ for (let index = 0; index < count; index += 1) {
605
+ const angle = index * 2.3999632297 + ((hash[0] ?? 0) / 255) * Math.PI;
606
+ const ring = 0.35 + ((hash[index % hash.length] ?? 0) / 255) * 0.8;
607
+ const layer = (index % 17) / 16 - 0.5;
608
+ const x = Math.cos(angle) * ring;
609
+ const y = layer * (1.2 + ((hash[1] ?? 0) / 255) * 0.8);
610
+ const z = Math.sin(angle) * ring;
611
+ const red = hash[(index * 3) % hash.length] ?? 0;
612
+ const green = hash[(index * 3 + 1) % hash.length] ?? 0;
613
+ const blue = hash[(index * 3 + 2) % hash.length] ?? 0;
614
+ const scale = -2.6 + ((hash[(index + 7) % hash.length] ?? 0) / 255) * 0.8;
615
+ rows.push(
616
+ [
617
+ finiteUnit(x),
618
+ finiteUnit(y),
619
+ finiteUnit(z),
620
+ red,
621
+ green,
622
+ blue,
623
+ finiteUnit(2.2),
624
+ finiteUnit(scale),
625
+ finiteUnit(scale),
626
+ finiteUnit(scale),
627
+ finiteUnit(1),
628
+ finiteUnit(0),
629
+ finiteUnit(0),
630
+ finiteUnit(0),
631
+ ].join(' '),
632
+ );
633
+ }
634
+ const header = [
635
+ 'ply',
636
+ 'format ascii 1.0',
637
+ `comment vgai_mock_input_base64 ${base64(new TextEncoder().encode(canonicalInput))}`,
638
+ `element vertex ${count}`,
639
+ 'property float x',
640
+ 'property float y',
641
+ 'property float z',
642
+ 'property uchar red',
643
+ 'property uchar green',
644
+ 'property uchar blue',
645
+ 'property float opacity',
646
+ 'property float scale_0',
647
+ 'property float scale_1',
648
+ 'property float scale_2',
649
+ 'property float rot_0',
650
+ 'property float rot_1',
651
+ 'property float rot_2',
652
+ 'property float rot_3',
653
+ 'end_header',
654
+ ];
655
+ return new TextEncoder().encode(`${[...header, ...rows].join('\n')}\n`);
656
+ }
657
+
658
+ function encodeMockGaussianSplat(input: Sam33dObjectsInput): Uint8Array {
659
+ return encodeMockGaussianSplatFromCanonical(
660
+ stableJson({ endpoint: SAM_3D_OBJECTS_ENDPOINT, input }),
661
+ );
662
+ }
663
+
664
+ function encodeMockColoredMeshFromCanonical(canonicalInput: string): Uint8Array {
665
+ const hash = hashBytes(canonicalInput);
666
+ const columns = 18;
667
+ const rows = 12;
668
+ const vertices: string[] = [];
669
+ const faces: string[] = [];
670
+ for (let row = 0; row < rows; row += 1) {
671
+ for (let column = 0; column < columns; column += 1) {
672
+ const u = column / (columns - 1);
673
+ const v = row / (rows - 1);
674
+ const x = (u - 0.5) * 4;
675
+ const z = (v - 0.5) * 2.5;
676
+ const wave = Math.sin(u * Math.PI * 3 + (hash[0] ?? 0)) * 0.14;
677
+ const y = wave + ((hash[(row + column) % hash.length] ?? 0) / 255 - 0.5) * 0.08;
678
+ const red = 80 + ((hash[(column * 3) % hash.length] ?? 0) % 176);
679
+ const green = 80 + ((hash[(row * 5 + 1) % hash.length] ?? 0) % 176);
680
+ const blue = 80 + ((hash[(row + column + 2) % hash.length] ?? 0) % 176);
681
+ vertices.push([finiteUnit(x), finiteUnit(y), finiteUnit(z), red, green, blue].join(' '));
682
+ }
683
+ }
684
+ for (let row = 0; row < rows - 1; row += 1) {
685
+ for (let column = 0; column < columns - 1; column += 1) {
686
+ const topLeft = row * columns + column;
687
+ const topRight = topLeft + 1;
688
+ const bottomLeft = topLeft + columns;
689
+ const bottomRight = bottomLeft + 1;
690
+ faces.push(`3 ${topLeft} ${bottomLeft} ${topRight}`);
691
+ faces.push(`3 ${topRight} ${bottomLeft} ${bottomRight}`);
692
+ }
693
+ }
694
+ const header = [
695
+ 'ply',
696
+ 'format ascii 1.0',
697
+ `comment vgai_mock_input_base64 ${base64(new TextEncoder().encode(canonicalInput))}`,
698
+ `element vertex ${vertices.length}`,
699
+ 'property float x',
700
+ 'property float y',
701
+ 'property float z',
702
+ 'property uchar red',
703
+ 'property uchar green',
704
+ 'property uchar blue',
705
+ `element face ${faces.length}`,
706
+ 'property list uchar uint vertex_indices',
707
+ 'end_header',
708
+ ];
709
+ return new TextEncoder().encode(`${[...header, ...vertices, ...faces].join('\n')}\n`);
710
+ }
711
+
712
+ function createSam3dObjectsOutput(input: Sam33dObjectsInput): Sam33dObjectsOutput {
713
+ if (!input || typeof input.image_url !== 'string' || input.image_url.trim().length === 0) {
714
+ throw new Error(
715
+ 'fal mock: fal-ai/sam-3/3d-objects requires input.image_url as a non-empty URL or data URI.',
716
+ );
717
+ }
718
+ const ply = encodeMockGaussianSplat(input);
719
+ const digest = hex(hashBytes(base64(ply))).slice(0, 16);
720
+ return {
721
+ gaussian_splat: {
722
+ url: `data:application/octet-stream;base64,${base64(ply)}`,
723
+ content_type: 'application/octet-stream',
724
+ file_name: `vgai-mock-${digest}.ply`,
725
+ file_size: ply.byteLength,
726
+ },
727
+ metadata: [{ object_index: 0 }],
728
+ };
729
+ }
730
+
731
+ function decodeBase64(value: string): Uint8Array {
732
+ const binary = atob(value);
733
+ return Uint8Array.from(binary, (character) => character.charCodeAt(0));
734
+ }
735
+
736
+ function ebmlSize(value: number): Uint8Array {
737
+ for (let width = 1; width <= 4; width += 1) {
738
+ if (value >= 2 ** (7 * width) - 1) continue;
739
+ const encoded = new Uint8Array(width);
740
+ let remaining = value;
741
+ for (let index = width - 1; index >= 0; index -= 1) {
742
+ encoded[index] = remaining & 0xff;
743
+ remaining >>>= 8;
744
+ }
745
+ encoded[0] = (encoded[0] ?? 0) | (1 << (8 - width));
746
+ return encoded;
747
+ }
748
+ throw new Error('fal mock: video request metadata is too large.');
749
+ }
750
+
751
+ function encodeMockWebm(endpoint: string, input: Record<string, unknown>): Uint8Array {
752
+ const canonicalInput = stableJson({ endpoint, input });
753
+ const hash = hashBytes(canonicalInput);
754
+ const template = decodeBase64(MOCK_WEBM_TEMPLATES[(hash[0] ?? 0) % MOCK_WEBM_TEMPLATES.length]!);
755
+ const metadata = new TextEncoder().encode(`vgai-mock-input\0${canonicalInput}`);
756
+ // The Segment has unknown length, so a trailing standard EBML Void element
757
+ // remains inside it and is ignored by decoders while preserving mock input.
758
+ return concatBytes([template, new Uint8Array([0xec]), ebmlSize(metadata.byteLength), metadata]);
759
+ }
760
+
761
+ function createHailuoTextToVideoOutput(
762
+ input: MinimaxHailuo02StandardTextToVideoInput,
763
+ ): MinimaxHailuo02StandardTextToVideoOutput {
764
+ if (!input || typeof input.prompt !== 'string' || input.prompt.trim().length === 0) {
765
+ throw new Error(
766
+ 'fal mock: fal-ai/minimax/hailuo-02/standard/text-to-video requires a non-empty input.prompt.',
767
+ );
768
+ }
769
+ const video = encodeMockWebm(HAILUO_TEXT_TO_VIDEO_ENDPOINT, input);
770
+ const digest = hex(hashBytes(base64(video))).slice(0, 16);
771
+ return {
772
+ video: {
773
+ url: `data:video/webm;base64,${base64(video)}`,
774
+ content_type: 'video/webm',
775
+ file_name: `vgai-mock-${digest}.webm`,
776
+ file_size: video.byteLength,
777
+ },
778
+ };
779
+ }
780
+
781
+ function createSeedanceReferenceToVideoOutput(input: Seedance2R2VInput): Seedance2VideoOutput {
782
+ if (!input || typeof input.prompt !== 'string' || input.prompt.trim().length === 0) {
783
+ throw new Error(
784
+ 'fal mock: bytedance/seedance-2.0/reference-to-video requires a non-empty input.prompt.',
785
+ );
786
+ }
787
+ if (!Array.isArray(input.video_urls) || input.video_urls.length === 0) {
788
+ throw new Error(
789
+ 'fal mock: bytedance/seedance-2.0/reference-to-video requires at least one input.video_urls entry.',
790
+ );
791
+ }
792
+ const video = encodeMockWebm(
793
+ SEEDANCE_2_REFERENCE_TO_VIDEO_ENDPOINT,
794
+ input as unknown as Record<string, unknown>,
795
+ );
796
+ const digest = hex(hashBytes(base64(video))).slice(0, 16);
797
+ return {
798
+ seed: input.seed ?? hashBytes(stableJson(input))[0]!,
799
+ video: {
800
+ url: `data:video/webm;base64,${base64(video)}`,
801
+ content_type: 'video/webm',
802
+ file_name: `vgai-mock-${digest}.webm`,
803
+ file_size: video.byteLength,
804
+ },
805
+ };
806
+ }
807
+
808
+ function validateWorldInput(input: HunyuanWorldImageToWorldInput): string {
809
+ if (!input || typeof input.image_url !== 'string' || input.image_url.trim().length === 0) {
810
+ throw new Error(
811
+ 'fal mock: fal-ai/hunyuan_world/image-to-world requires input.image_url as a non-empty URL or data URI.',
812
+ );
813
+ }
814
+ for (const field of ['classes', 'labels_fg1', 'labels_fg2'] as const) {
815
+ if (typeof input[field] !== 'string' || input[field].trim().length === 0) {
816
+ throw new Error(
817
+ `fal mock: fal-ai/hunyuan_world/image-to-world requires non-empty input.${field}.`,
818
+ );
819
+ }
820
+ }
821
+ return stableJson({ endpoint: HUNYUAN_IMAGE_TO_WORLD_ENDPOINT, input });
822
+ }
823
+
824
+ function encodeMockWorldImage(canonicalInput: string, label: string): Uint8Array {
825
+ const hash = hashBytes(`${canonicalInput}:${label}`);
826
+ const width = 384;
827
+ const height = 216;
828
+ const pixels = new Uint8Array(width * height * 4);
829
+ fillBackground(pixels, width, height, hash);
830
+ fillRect(pixels, width, height, 10, 10, width - 20, 82, [8, 12, 20, 224]);
831
+ drawText(pixels, width, height, 'VGAI MOCK HUNYUAN WORLD', 20, 20, 2, [255, 255, 255, 255]);
832
+ drawText(pixels, width, height, label, 20, 48, 2, [190, 235, 255, 255]);
833
+ drawHashGrid(pixels, width, height, hash);
834
+ return encodePng(width, height, pixels, canonicalInput);
835
+ }
836
+
837
+ function createMockWorldArchive(input: HunyuanWorldImageToWorldInput): Uint8Array {
838
+ const canonicalInput = validateWorldInput(input);
839
+ const image = encodeMockWorldImage(canonicalInput, 'SOURCE IMAGE');
840
+ const fullImage = encodeMockWorldImage(canonicalInput, 'FULL IMAGE');
841
+ const fullImageSr = encodeMockWorldImage(canonicalInput, 'FULL IMAGE SR');
842
+ const skyImage = encodeMockWorldImage(canonicalInput, 'SKY IMAGE');
843
+ const skyImageSr = encodeMockWorldImage(canonicalInput, 'SKY IMAGE SR');
844
+ const skyMask = encodeMockWorldImage(canonicalInput, 'SKY MASK');
845
+ const layer0 = encodeMockColoredMeshFromCanonical(`${canonicalInput}:mesh_layer0`);
846
+ const layer1 = encodeMockColoredMeshFromCanonical(`${canonicalInput}:mesh_layer1`);
847
+ return zipSync(
848
+ {
849
+ 'image.png': image,
850
+ 'full_image_sr.png': fullImageSr,
851
+ 'full_image.png': fullImage,
852
+ 'mesh_layer1.ply': layer1,
853
+ 'mesh_layer0.ply': layer0,
854
+ 'sky_image_sr.png': skyImageSr,
855
+ 'sky_mask.png': skyMask,
856
+ 'sky_image.png': skyImage,
857
+ },
858
+ { level: 6, mtime: new Date('1980-01-02T00:00:00.000Z') },
859
+ );
860
+ }
861
+
862
+ function createHunyuanImageToWorldOutput(
863
+ input: HunyuanWorldImageToWorldInput,
864
+ ): HunyuanWorldImageToWorldOutput {
865
+ const world = createMockWorldArchive(input);
866
+ const digest = hex(hashBytes(base64(world))).slice(0, 16);
867
+ return {
868
+ world_file: {
869
+ url: `data:application/zip;base64,${base64(world)}`,
870
+ content_type: 'application/zip',
871
+ file_name: `vgai-mock-${digest}.zip`,
872
+ file_size: world.byteLength,
873
+ },
874
+ };
875
+ }
876
+
877
+ function createHunyuanPanoramaOutput(input: HunyuanWorldInput): HunyuanWorldOutput {
878
+ if (!input || typeof input.image_url !== 'string' || input.image_url.trim().length === 0) {
879
+ throw new Error(
880
+ 'fal mock: fal-ai/hunyuan_world requires input.image_url as a non-empty URL or data URI.',
881
+ );
882
+ }
883
+ if (typeof input.prompt !== 'string' || input.prompt.trim().length === 0) {
884
+ throw new Error('fal mock: fal-ai/hunyuan_world requires a non-empty input.prompt.');
885
+ }
886
+ const canonicalInput = stableJson({ endpoint: HUNYUAN_PANORAMA_ENDPOINT, input });
887
+ const hash = hashBytes(canonicalInput);
888
+ const width = 384;
889
+ const height = 192;
890
+ const pixels = new Uint8Array(width * height * 4);
891
+ fillBackground(pixels, width, height, hash);
892
+ fillRect(pixels, width, height, 10, 10, width - 20, 82, [8, 12, 20, 224]);
893
+ drawText(pixels, width, height, 'VGAI MOCK HUNYUAN', 20, 20, 2, [255, 255, 255, 255]);
894
+ drawText(pixels, width, height, 'EQUIRECTANGULAR PANORAMA', 20, 48, 2, [190, 235, 255, 255]);
895
+ drawHashGrid(pixels, width, height, hash);
896
+ const png = encodePng(width, height, pixels, canonicalInput);
897
+ const digest = hex(hashBytes(base64(png))).slice(0, 16);
898
+ return {
899
+ image: {
900
+ url: `data:image/png;base64,${base64(png)}`,
901
+ content_type: 'image/png',
902
+ file_name: `vgai-mock-${digest}.png`,
903
+ file_size: png.byteLength,
904
+ width,
905
+ height,
906
+ },
907
+ };
908
+ }
909
+
910
+ function createOutput(
911
+ endpoint: string,
912
+ input:
913
+ | NanoBananaInput
914
+ | NanoBananaEditInput
915
+ | NanoBanana2Input
916
+ | NanoBanana2EditInput
917
+ | StableAudioInput
918
+ | Sam33dObjectsInput
919
+ | MinimaxHailuo02StandardTextToVideoInput
920
+ | Seedance2R2VInput
921
+ | HunyuanWorldImageToWorldInput
922
+ | HunyuanWorldInput,
923
+ ):
924
+ | NanoBananaOutput
925
+ | StableAudioOutput
926
+ | Sam33dObjectsOutput
927
+ | MinimaxHailuo02StandardTextToVideoOutput
928
+ | Seedance2VideoOutput
929
+ | HunyuanWorldImageToWorldOutput
930
+ | HunyuanWorldOutput {
931
+ if (
932
+ endpoint === NANO_BANANA_ENDPOINT ||
933
+ endpoint === NANO_BANANA_EDIT_ENDPOINT ||
934
+ endpoint === NANO_BANANA_2_ENDPOINT ||
935
+ endpoint === NANO_BANANA_2_EDIT_ENDPOINT
936
+ ) {
937
+ return createNanoBananaOutput(endpoint, input as NanoBananaAnyInput);
938
+ }
939
+ if (endpoint === STABLE_AUDIO_ENDPOINT) {
940
+ return createStableAudioOutput(input as StableAudioInput);
941
+ }
942
+ if (endpoint === SAM_3D_OBJECTS_ENDPOINT) {
943
+ return createSam3dObjectsOutput(input as Sam33dObjectsInput);
944
+ }
945
+ if (endpoint === HAILUO_TEXT_TO_VIDEO_ENDPOINT) {
946
+ return createHailuoTextToVideoOutput(input as MinimaxHailuo02StandardTextToVideoInput);
947
+ }
948
+ if (endpoint === SEEDANCE_2_REFERENCE_TO_VIDEO_ENDPOINT) {
949
+ return createSeedanceReferenceToVideoOutput(input as Seedance2R2VInput);
950
+ }
951
+ if (endpoint === HUNYUAN_IMAGE_TO_WORLD_ENDPOINT) {
952
+ return createHunyuanImageToWorldOutput(input as HunyuanWorldImageToWorldInput);
953
+ }
954
+ if (endpoint === HUNYUAN_PANORAMA_ENDPOINT) {
955
+ return createHunyuanPanoramaOutput(input as HunyuanWorldInput);
956
+ }
957
+ throw new Error(`fal mock: endpoint ${JSON.stringify(endpoint)} is not implemented.`);
958
+ }
959
+
960
+ function jsonResponse(body: unknown, status = 200, requestId?: string): Response {
961
+ return new Response(JSON.stringify(body), {
962
+ status,
963
+ headers: {
964
+ 'content-type': 'application/json',
965
+ ...(requestId ? { 'x-fal-request-id': requestId } : {}),
966
+ },
967
+ });
968
+ }
969
+
970
+ function endpointFromPath(pathname: string): string {
971
+ const requestIndex = pathname.indexOf('/requests/');
972
+ return pathname.slice(1, requestIndex === -1 ? undefined : requestIndex).replace(/\/$/, '');
973
+ }
974
+
975
+ function queueBase(endpoint: string, requestId: string): string {
976
+ return `https://queue.fal.run/${endpoint}/requests/${requestId}`;
977
+ }
978
+
979
+ function submitTask(
980
+ tasks: Map<string, MockTask>,
981
+ url: URL,
982
+ bodyValue: BodyInit | null | undefined,
983
+ ): Response {
984
+ const endpoint = endpointFromPath(url.pathname);
985
+ const body = typeof bodyValue === 'string' ? JSON.parse(bodyValue) : {};
986
+ const output = createOutput(
987
+ endpoint,
988
+ body as
989
+ | NanoBananaInput
990
+ | NanoBananaEditInput
991
+ | StableAudioInput
992
+ | Sam33dObjectsInput
993
+ | MinimaxHailuo02StandardTextToVideoInput
994
+ | Seedance2R2VInput
995
+ | HunyuanWorldImageToWorldInput
996
+ | HunyuanWorldInput,
997
+ );
998
+ const requestId = `mock_${hex(hashBytes(stableJson({ endpoint, body }))).slice(0, 24)}`;
999
+ tasks.set(requestId, { endpoint, output });
1000
+ recordExecution({ mode: 'mock', provider: 'fal', model: endpoint, requestId });
1001
+ if (url.hostname !== 'queue.fal.run') return jsonResponse(output, 200, requestId);
1002
+ const base = queueBase(endpoint, requestId);
1003
+ return jsonResponse({
1004
+ request_id: requestId,
1005
+ response_url: base,
1006
+ status_url: `${base}/status`,
1007
+ cancel_url: `${base}/cancel`,
1008
+ });
1009
+ }
1010
+
1011
+ function existingTaskResponse(tasks: Map<string, MockTask>, url: URL, method: string): Response {
1012
+ const match = url.pathname.match(/\/requests\/([^/]+)(?:\/(status|cancel))?$/);
1013
+ const requestId = match?.[1];
1014
+ const action = match?.[2];
1015
+ const task = requestId ? tasks.get(requestId) : undefined;
1016
+ if (!requestId || !task) {
1017
+ return jsonResponse({ message: 'fal mock: request was not found.' }, 404, requestId);
1018
+ }
1019
+ const base = queueBase(task.endpoint, requestId);
1020
+ if (method === 'GET') {
1021
+ recordExecution({ mode: 'mock', provider: 'fal', model: task.endpoint, requestId });
1022
+ }
1023
+ if (method === 'GET' && action === 'status') {
1024
+ return jsonResponse({
1025
+ status: 'COMPLETED',
1026
+ request_id: requestId,
1027
+ response_url: base,
1028
+ status_url: `${base}/status`,
1029
+ cancel_url: `${base}/cancel`,
1030
+ logs: [],
1031
+ metrics: { inference_time: 0 },
1032
+ });
1033
+ }
1034
+ if (method === 'GET' && !action) return jsonResponse(task.output, 200, requestId);
1035
+ if (method === 'PUT' && action === 'cancel') return jsonResponse({ status: 'OK' });
1036
+ return jsonResponse({ message: `fal mock: unsupported ${method} request.` }, 400, requestId);
1037
+ }
1038
+
1039
+ function routeRequest(
1040
+ tasks: Map<string, MockTask>,
1041
+ url: URL,
1042
+ method: string,
1043
+ body: BodyInit | null | undefined,
1044
+ ): Response {
1045
+ if (url.pathname.includes('/requests/')) {
1046
+ return existingTaskResponse(tasks, url, method);
1047
+ }
1048
+ const endpoint = endpointFromPath(url.pathname);
1049
+ if (!SUPPORTED_ENDPOINTS.has(endpoint)) {
1050
+ return jsonResponse(
1051
+ { message: `fal mock: endpoint ${JSON.stringify(endpoint)} is not implemented.` },
1052
+ 400,
1053
+ );
1054
+ }
1055
+ if (method === 'POST') return submitTask(tasks, url, body);
1056
+ return jsonResponse({ message: `fal mock: unsupported ${method} request.` }, 400);
1057
+ }
1058
+
1059
+ /**
1060
+ * A standard Fetch implementation suitable for `fal.config({ fetch })` or
1061
+ * `createFalClient({ fetch })`. Keeping the seam at Fetch lets a host select
1062
+ * mock mode without changing the provider call authored by a game.
1063
+ */
1064
+ export function createMockFalFetch(options: MockFalClientOptions = {}): typeof fetch {
1065
+ const tasks = new Map<string, MockTask>();
1066
+ const mockFetch: typeof fetch = async (input, init) => {
1067
+ const url = new URL(input instanceof Request ? input.url : input.toString());
1068
+ const method = (
1069
+ init?.method ?? (input instanceof Request ? input.method : 'GET')
1070
+ ).toUpperCase();
1071
+ options.onRequest?.({ method, url: url.toString() });
1072
+ try {
1073
+ return routeRequest(tasks, url, method, init?.body);
1074
+ } catch (error) {
1075
+ return jsonResponse(
1076
+ { message: error instanceof Error ? error.message : 'fal mock: invalid request.' },
1077
+ 422,
1078
+ );
1079
+ }
1080
+ };
1081
+
1082
+ return mockFetch;
1083
+ }
1084
+
1085
+ /** Returns the real Fal client preconfigured with `createMockFalFetch()`. */
1086
+ export function createMockFalClient(options: MockFalClientOptions = {}): FalClient {
1087
+ return createFalClient({
1088
+ credentials: 'vgai-mock',
1089
+ fetch: createMockFalFetch(options),
1090
+ retry: { maxRetries: 0 },
1091
+ });
1092
+ }