@vgai/fal 0.1.1 → 0.1.3

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/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@vgai/fal",
3
3
  "author": "Volter AI, Inc.",
4
4
  "license": "Apache-2.0",
5
- "version": "0.1.1",
5
+ "version": "0.1.3",
6
6
  "type": "module",
7
7
  "description": "Optional registered Fal provider boundary for VGAI projects: quote, submit, poll and accept behind guarded pricing and credentials.",
8
8
  "homepage": "https://github.com/volter-ai/vgai-engine#readme",
@@ -22,17 +22,19 @@
22
22
  "./accept": "./src/accept.ts",
23
23
  "./cancel": "./src/cancel.ts",
24
24
  "./contracts": "./src/contracts.ts",
25
+ "./glb-measure": "./src/glb-measure.ts",
25
26
  "./package.json": "./package.json",
26
27
  "./poll": "./src/poll.ts",
27
28
  "./pricing": "./src/pricing.ts",
28
29
  "./provider": "./src/provider.ts",
30
+ "./retexture": "./src/retexture.ts",
29
31
  "./submit": "./src/submit.ts",
30
32
  "./tool-error": "./src/tool-error.ts"
31
33
  },
32
34
  "dependencies": {
33
35
  "@fal-ai/client": "1.10.1",
34
- "@vgai/fal-client-compat": "0.1.0",
35
- "@vgai/sdk": "0.5.0",
36
+ "@vgai/fal-client-compat": "0.1.1",
37
+ "@vgai/sdk": "^0.5.1",
36
38
  "fflate": "^0.8.2",
37
39
  "three": "^0.180.0",
38
40
  "zod": "^4.3.6"
@@ -41,7 +43,7 @@
41
43
  "@types/node": "^25.3.0",
42
44
  "@types/react": "^19.2.14",
43
45
  "@types/three": "^0.180.0",
44
- "@vgai/editor-sdk": "0.5.0",
46
+ "@vgai/editor-sdk": "^0.5.1",
45
47
  "react": "^19.2.4"
46
48
  }
47
49
  }
package/src/accept.ts CHANGED
@@ -1,9 +1,14 @@
1
1
  import { posix } from 'node:path';
2
2
  import type { ToolContext } from '@vgai/sdk/tools';
3
3
  import { unzipSync } from 'fflate';
4
+ import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
4
5
  import { PLYLoader } from 'three/addons/loaders/PLYLoader.js';
5
- import type { AcceptInput, FalEndpoint } from './contracts.js';
6
- import { SEEDANCE_2_REFERENCE_TO_VIDEO_ENDPOINT } from './contracts.js';
6
+ import type { AcceptInput, FalEndpoint, GlbMeasurement } from './contracts.js';
7
+ import {
8
+ MESHY_V5_RETEXTURE_ENDPOINT,
9
+ SEEDANCE_2_REFERENCE_TO_VIDEO_ENDPOINT,
10
+ } from './contracts.js';
11
+ import { compareGlb, measureGlb } from './glb-measure.js';
7
12
  import { quoteFalPrice } from './pricing.js';
8
13
  import { falArtifactFetch, falBilling, falClient } from './provider.js';
9
14
 
@@ -16,6 +21,7 @@ interface AcceptedFile {
16
21
  filename: string;
17
22
  mediaType: string;
18
23
  bytes: Uint8Array;
24
+ geometry?: GlbMeasurement;
19
25
  }
20
26
 
21
27
  function directory(value: string): string {
@@ -102,6 +108,73 @@ function ply(bytes: Uint8Array): void {
102
108
  }
103
109
  }
104
110
 
111
+ let scopedSelfUsers = 0;
112
+ let installedScopedSelf = false;
113
+
114
+ async function withNodeSelf<T>(run: () => Promise<T>): Promise<T> {
115
+ const globals = globalThis as typeof globalThis & { self?: typeof globalThis };
116
+ if (!('self' in globals)) {
117
+ Object.defineProperty(globals, 'self', {
118
+ value: globalThis,
119
+ configurable: true,
120
+ writable: true,
121
+ });
122
+ installedScopedSelf = true;
123
+ }
124
+ scopedSelfUsers += 1;
125
+ try {
126
+ return await run();
127
+ } finally {
128
+ scopedSelfUsers -= 1;
129
+ if (scopedSelfUsers === 0) {
130
+ if (installedScopedSelf && globals.self === globalThis) delete globals.self;
131
+ installedScopedSelf = false;
132
+ }
133
+ }
134
+ }
135
+
136
+ /** Proves the accepted bytes load in the production glTF reader. */
137
+ export async function validateGlb(bytes: Uint8Array): Promise<void> {
138
+ if (
139
+ bytes.byteLength < 12 ||
140
+ new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(0, true) !== 0x46546c67
141
+ ) {
142
+ throw new Error('Fal model is not a GLB container.');
143
+ }
144
+ // GLTFLoader uses the browser-standard `self.URL` while resolving embedded
145
+ // textures. Node exposes URL/Blob on globalThis but does not define `self`.
146
+ // Scope the alias to validation and reference-count it so parallel accepts
147
+ // cannot remove it while another parse is still running.
148
+ const parsed = await withNodeSelf(() =>
149
+ new GLTFLoader().parseAsync(Uint8Array.from(bytes).buffer, ''),
150
+ );
151
+ if (!parsed.scene) throw new Error('Fal GLB has no scene.');
152
+ parsed.scene.traverse((object) => {
153
+ const mesh = object as {
154
+ geometry?: { dispose(): void };
155
+ material?: { dispose(): void } | Array<{ dispose(): void }>;
156
+ };
157
+ mesh.geometry?.dispose();
158
+ if (Array.isArray(mesh.material))
159
+ mesh.material.forEach((material) => {
160
+ material.dispose();
161
+ });
162
+ else mesh.material?.dispose();
163
+ });
164
+ }
165
+
166
+ async function retexturedModel(
167
+ data: Record<string, unknown>,
168
+ fetcher: typeof fetch,
169
+ ): Promise<AcceptedFile> {
170
+ const bytes = await download(nativeFile(data['model_glb'], 'retextured model'), fetcher);
171
+ await validateGlb(bytes);
172
+ const geometry = measureGlb(bytes);
173
+ // A retexture that comes back with no textures did not do its job.
174
+ if (geometry.textures === 0) throw new Error('Fal retexture output carries no textures.');
175
+ return { filename: 'model.glb', mediaType: 'model/gltf-binary', bytes, geometry };
176
+ }
177
+
105
178
  async function acceptedFiles(
106
179
  endpoint: FalEndpoint,
107
180
  data: Record<string, unknown>,
@@ -139,6 +212,9 @@ async function acceptedFiles(
139
212
  ) {
140
213
  return [await video(nativeFile(data['video'], 'video'), fetcher)];
141
214
  }
215
+ if (endpoint === MESHY_V5_RETEXTURE_ENDPOINT) {
216
+ return [await retexturedModel(data, fetcher)];
217
+ }
142
218
  if (endpoint === 'fal-ai/hunyuan_world') {
143
219
  const bytes = await download(nativeFile(data['image'], 'panorama'), fetcher);
144
220
  png(bytes, true);
@@ -177,14 +253,21 @@ export async function acceptFal(input: AcceptInput, ctx: ToolContext) {
177
253
  );
178
254
  if (!committed.provenanceOperationId)
179
255
  throw new Error('Fal output commit returned no provenance operation ID.');
256
+ if (committed.files.length !== files.length)
257
+ throw new Error('Fal output commit returned a different file count than it was given.');
258
+ const outputGeometry = files.length === 1 ? files[0]?.geometry : undefined;
180
259
  return {
181
260
  requestId: input.requestId,
182
- files: committed.files.map((file) => ({
261
+ files: committed.files.map((file, index) => ({
183
262
  path: file.path,
184
263
  bytes: file.bytes,
185
264
  ...(file.mediaType ? { mediaType: file.mediaType } : {}),
265
+ ...(files[index]?.geometry ? { geometry: files[index]!.geometry! } : {}),
186
266
  })),
187
267
  provenanceOperationId: committed.provenanceOperationId,
268
+ ...(input.sourceGeometry && outputGeometry
269
+ ? { comparison: compareGlb(input.sourceGeometry, outputGeometry) }
270
+ : {}),
188
271
  ...(quote && usage?.billableUnits !== undefined
189
272
  ? { settledAmount: quote.unitPrice * usage.billableUnits }
190
273
  : {}),
package/src/contracts.ts CHANGED
@@ -6,6 +6,7 @@ export const DEFAULT_FAL_IMAGE_EDIT_ENDPOINT = 'fal-ai/nano-banana-2/edit' as co
6
6
  export const NANO_BANANA_2_UNDERLYING_ENDPOINT = 'fal-ai/gemini-3.1-flash-image-preview' as const;
7
7
  export const SEEDANCE_2_REFERENCE_TO_VIDEO_ENDPOINT =
8
8
  'bytedance/seedance-2.0/reference-to-video' as const;
9
+ export const MESHY_V5_RETEXTURE_ENDPOINT = 'fal-ai/meshy/v5/retexture' as const;
9
10
  export const FAL_IMAGE_CONTRIBUTION_DEFAULTS = {
10
11
  'generate-image': DEFAULT_FAL_IMAGE_ENDPOINT,
11
12
  'edit-image': DEFAULT_FAL_IMAGE_EDIT_ENDPOINT,
@@ -22,6 +23,7 @@ export const FalEndpointSchema = z.enum([
22
23
  SEEDANCE_2_REFERENCE_TO_VIDEO_ENDPOINT,
23
24
  'fal-ai/hunyuan_world/image-to-world',
24
25
  'fal-ai/hunyuan_world',
26
+ MESHY_V5_RETEXTURE_ENDPOINT,
25
27
  ]);
26
28
  export const SubmissionSchema = z.object({
27
29
  mode: ExecutionModeSchema.default('mock'),
@@ -79,7 +81,29 @@ export const CancelResultSchema = z.object({
79
81
  requestId: z.string(),
80
82
  status: z.literal('cancelled'),
81
83
  });
82
- export const AcceptInputSchema = SubmissionSchema.extend({ requestId: z.string().min(1) });
84
+ /** Geometry and material facts a texturing round-trip can be compared against. */
85
+ export const GlbMeasurementSchema = z.object({
86
+ meshes: z.number().int().nonnegative(),
87
+ primitives: z.number().int().nonnegative(),
88
+ materials: z.number().int().nonnegative(),
89
+ textures: z.number().int().nonnegative(),
90
+ vertexCount: z.number().int().nonnegative(),
91
+ indexCount: z.number().int().nonnegative(),
92
+ uvSets: z.array(z.string()),
93
+ });
94
+ export const GlbComparisonSchema = z.object({
95
+ source: GlbMeasurementSchema,
96
+ output: GlbMeasurementSchema,
97
+ vertexCountPreserved: z.boolean(),
98
+ indexCountPreserved: z.boolean(),
99
+ uvSetsPreserved: z.boolean(),
100
+ topologyPreserved: z.boolean(),
101
+ });
102
+ export const AcceptInputSchema = SubmissionSchema.extend({
103
+ requestId: z.string().min(1),
104
+ /** Measured input geometry, when the submitting presentation had one on disk. */
105
+ sourceGeometry: GlbMeasurementSchema.optional(),
106
+ });
83
107
  export const AcceptResultSchema = z.object({
84
108
  requestId: z.string(),
85
109
  files: z.array(
@@ -87,11 +111,40 @@ export const AcceptResultSchema = z.object({
87
111
  path: z.string(),
88
112
  bytes: z.number().int().nonnegative(),
89
113
  mediaType: z.string().optional(),
114
+ geometry: GlbMeasurementSchema.optional(),
90
115
  }),
91
116
  ),
92
117
  provenanceOperationId: z.string(),
93
118
  settledAmount: z.number().nonnegative().optional(),
94
119
  settledCredits: z.number().nonnegative().optional(),
120
+ comparison: GlbComparisonSchema.optional(),
121
+ });
122
+ export const RetextureInputSchema = z
123
+ .object({
124
+ mode: ExecutionModeSchema.default('mock'),
125
+ /** Project-relative path to the GLB being retextured. */
126
+ input: z.string().min(1),
127
+ /** Meshy's `text_style_prompt`; the endpoint caps it at 600 characters. */
128
+ textStylePrompt: z.string().min(1).max(600).optional(),
129
+ /** Meshy's `image_style_url`; a publicly reachable URL or a data URI. */
130
+ imageStyleUrl: z.string().min(1).optional(),
131
+ enableOriginalUv: z.boolean().optional(),
132
+ enablePbr: z.boolean().optional(),
133
+ enableSafetyChecker: z.boolean().optional(),
134
+ outputDirectory: z.string().min(1).default('public/generated/fal/retexture'),
135
+ })
136
+ .refine(
137
+ (value) =>
138
+ [value.textStylePrompt, value.imageStyleUrl].filter((guidance) => guidance !== undefined)
139
+ .length === 1,
140
+ { message: 'Retexture takes exactly one of textStylePrompt or imageStyleUrl.' },
141
+ );
142
+ export const RetextureResultSchema = SubmitResultSchema.extend({
143
+ source: z.object({
144
+ path: z.string(),
145
+ bytes: z.number().int().positive(),
146
+ geometry: GlbMeasurementSchema,
147
+ }),
95
148
  });
96
149
  export type ExecutionMode = z.infer<typeof ExecutionModeSchema>;
97
150
  export type FalEndpoint = z.infer<typeof FalEndpointSchema>;
@@ -100,3 +153,6 @@ export type QuoteInput = z.infer<typeof QuoteInputSchema>;
100
153
  export type PollInput = z.infer<typeof PollInputSchema>;
101
154
  export type CancelInput = z.infer<typeof CancelInputSchema>;
102
155
  export type AcceptInput = z.infer<typeof AcceptInputSchema>;
156
+ export type GlbMeasurement = z.infer<typeof GlbMeasurementSchema>;
157
+ export type GlbComparison = z.infer<typeof GlbComparisonSchema>;
158
+ export type RetextureInput = z.infer<typeof RetextureInputSchema>;
@@ -0,0 +1,106 @@
1
+ import type { GlbComparison, GlbMeasurement } from './contracts.js';
2
+
3
+ const GLB_MAGIC = 0x46546c67;
4
+ const JSON_CHUNK = 0x4e4f534a;
5
+
6
+ interface GltfJson {
7
+ meshes?: Array<{
8
+ primitives?: Array<{ attributes?: Record<string, number>; indices?: number }>;
9
+ }>;
10
+ accessors?: Array<{ count?: number }>;
11
+ materials?: unknown[];
12
+ textures?: unknown[];
13
+ images?: unknown[];
14
+ }
15
+
16
+ /**
17
+ * Reads the GLB container's own glTF JSON chunk.
18
+ *
19
+ * Deliberately NOT a second glTF runtime: `validateGlb` already proves the bytes
20
+ * load in the production reader (three's GLTFLoader), and everything measured
21
+ * here — accessor counts, primitive semantics, the texture list — is metadata
22
+ * the container states directly. Headless three cannot answer the texture
23
+ * question at all: in Node it resolves the parse while logging "Couldn't load
24
+ * texture" and leaves every material map null, so a texture count taken from
25
+ * the parsed scene would read 0 for a genuinely textured GLB.
26
+ *
27
+ * NEW here, and deliberately not shared. The report SHAPE intentionally matches
28
+ * what `project.tripo.texture` reports so the two retexture providers' accept
29
+ * results read side by side, but each provider boundary owns its own
30
+ * implementation: `@vgai/fal` and `@vgai/tripo` are separate published spend
31
+ * boundaries with no import edge in either direction, and adding one to share
32
+ * a hundred lines would couple two vendor contracts to each other.
33
+ */
34
+ function readGltfJson(bytes: Uint8Array): GltfJson {
35
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
36
+ if (bytes.byteLength < 20 || view.getUint32(0, true) !== GLB_MAGIC) {
37
+ throw new Error('Fal model is not a GLB container.');
38
+ }
39
+ let offset = 12;
40
+ while (offset + 8 <= bytes.byteLength) {
41
+ const chunkLength = view.getUint32(offset, true);
42
+ const chunkType = view.getUint32(offset + 4, true);
43
+ const start = offset + 8;
44
+ if (start + chunkLength > bytes.byteLength) {
45
+ throw new Error('GLB chunk table is truncated.');
46
+ }
47
+ if (chunkType === JSON_CHUNK) {
48
+ return JSON.parse(
49
+ new TextDecoder().decode(bytes.subarray(start, start + chunkLength)),
50
+ ) as GltfJson;
51
+ }
52
+ offset = start + chunkLength;
53
+ }
54
+ throw new Error('GLB container has no glTF JSON chunk.');
55
+ }
56
+
57
+ /** Geometry and material facts a texturing round-trip can be compared against. */
58
+ export function measureGlb(bytes: Uint8Array): GlbMeasurement {
59
+ const json = readGltfJson(bytes);
60
+ const accessorCount = (index: number | undefined): number =>
61
+ typeof index === 'number' ? (json.accessors?.[index]?.count ?? 0) : 0;
62
+ const primitives = (json.meshes ?? []).flatMap((mesh) => mesh.primitives ?? []);
63
+ const uvSets = new Set(
64
+ primitives
65
+ .flatMap((primitive) => Object.keys(primitive.attributes ?? {}))
66
+ .filter((semantic) => /^TEXCOORD_\d+$/.test(semantic)),
67
+ );
68
+ return {
69
+ meshes: json.meshes?.length ?? 0,
70
+ primitives: primitives.length,
71
+ materials: json.materials?.length ?? 0,
72
+ textures: json.textures?.length ?? 0,
73
+ vertexCount: primitives.reduce(
74
+ (total, primitive) => total + accessorCount(primitive.attributes?.['POSITION']),
75
+ 0,
76
+ ),
77
+ indexCount: primitives.reduce(
78
+ (total, primitive) => total + accessorCount(primitive.indices),
79
+ 0,
80
+ ),
81
+ uvSets: [...uvSets].sort(),
82
+ };
83
+ }
84
+
85
+ function sameSets(left: readonly string[], right: readonly string[]): boolean {
86
+ return left.length === right.length && left.every((value, index) => value === right[index]);
87
+ }
88
+
89
+ /**
90
+ * Input-vs-output report for a provider retexture. It records what happened; it
91
+ * decides nothing. Whether Meshy preserves topology and UVs is an open question
92
+ * that only a REAL run can answer — mock mode returns provider-shaped substitute
93
+ * geometry, so a mock comparison measures the mock.
94
+ */
95
+ export function compareGlb(source: GlbMeasurement, output: GlbMeasurement): GlbComparison {
96
+ const vertexCountPreserved = source.vertexCount === output.vertexCount;
97
+ const indexCountPreserved = source.indexCount === output.indexCount;
98
+ return {
99
+ source,
100
+ output,
101
+ vertexCountPreserved,
102
+ indexCountPreserved,
103
+ uvSetsPreserved: sameSets(source.uvSets, output.uvSets),
104
+ topologyPreserved: vertexCountPreserved && indexCountPreserved,
105
+ };
106
+ }
package/src/pricing.ts CHANGED
@@ -72,6 +72,22 @@ function nanoBanana2UnitPrice(
72
72
  return baseUnitPrice * resolutionMultiplier + webSearchSurcharge + highThinkingSurcharge;
73
73
  }
74
74
 
75
+ /** Converts Fal's native pricing row plus the native request into the exact
76
+ * dollar-bearing quote used by both direct tools and the managed gateway. */
77
+ export function falProviderPrice(
78
+ endpoint: FalEndpoint,
79
+ input: Record<string, unknown>,
80
+ price: { unitPrice: number; unit: string },
81
+ ): { unitPrice: number; unit: string; quantity?: number; amount?: number } {
82
+ const unitPrice = nanoBanana2UnitPrice(endpoint, price.unitPrice, input);
83
+ const count = quantity(price.unit, input);
84
+ return {
85
+ unitPrice,
86
+ unit: price.unit,
87
+ ...(count === undefined ? {} : { quantity: count, amount: unitPrice * count }),
88
+ };
89
+ }
90
+
75
91
  async function managedCreditEstimate(
76
92
  options: { endpoint: FalEndpoint; input: Record<string, unknown> },
77
93
  price: { unitPrice: number; unit: string; quantity?: number },
@@ -127,23 +143,27 @@ export async function quoteFalPrice(options: {
127
143
  if (price['currency'] !== undefined && price['currency'] !== 'USD') {
128
144
  throw new Error(`Fal returned unsupported pricing currency ${String(price['currency'])}.`);
129
145
  }
130
- const unitPrice = nanoBanana2UnitPrice(options.endpoint, price['unit_price'], options.input);
131
- const count = quantity(price['unit'], options.input);
146
+ const providerPrice = falProviderPrice(options.endpoint, options.input, {
147
+ unitPrice: price['unit_price'],
148
+ unit: price['unit'],
149
+ });
132
150
  const estimatedCredits =
133
151
  options.mode === 'managed'
134
152
  ? await managedCreditEstimate(options, {
135
- unitPrice,
136
- unit: price['unit'],
137
- ...(count === undefined ? {} : { quantity: count }),
153
+ unitPrice: providerPrice.unitPrice,
154
+ unit: providerPrice.unit,
155
+ ...(providerPrice.quantity === undefined ? {} : { quantity: providerPrice.quantity }),
138
156
  })
139
157
  : undefined;
140
158
  return {
141
159
  mode: options.mode,
142
160
  endpoint: options.endpoint,
143
161
  currency: 'USD',
144
- unitPrice,
145
- unit: price['unit'],
146
- ...(count === undefined ? {} : { quantity: count, estimatedAmount: unitPrice * count }),
162
+ unitPrice: providerPrice.unitPrice,
163
+ unit: providerPrice.unit,
164
+ ...(providerPrice.quantity === undefined
165
+ ? {}
166
+ : { quantity: providerPrice.quantity, estimatedAmount: providerPrice.amount }),
147
167
  ...(estimatedCredits === undefined ? {} : { estimatedCredits }),
148
168
  };
149
169
  }
@@ -0,0 +1,88 @@
1
+ import { readFile, realpath, stat } from 'node:fs/promises';
2
+ import { extname, resolve, sep } from 'node:path';
3
+ import type { ToolContext } from '@vgai/sdk/tools';
4
+ import { MESHY_V5_RETEXTURE_ENDPOINT, type RetextureInput } from './contracts.js';
5
+ import { measureGlb } from './glb-measure.js';
6
+ import { submitFal } from './submit.js';
7
+
8
+ /**
9
+ * The model travels as a base64 data URI inside the JSON request body, which is
10
+ * the file-input path this package already speaks on both legs: every mock
11
+ * artifact comes back as one, and `accept` decodes them. Base64 costs a third
12
+ * again in transit, so the ceiling is stated here rather than discovered as a
13
+ * provider-side rejection.
14
+ */
15
+ const MAX_MODEL_BYTES = 32 * 1024 * 1024;
16
+
17
+ function base64(bytes: Uint8Array): string {
18
+ return Buffer.from(bytes).toString('base64');
19
+ }
20
+
21
+ /**
22
+ * Resolves one project-relative GLB to bytes, refusing absolute paths, symlink
23
+ * escapes, non-GLB extensions, and empty or oversized files.
24
+ */
25
+ async function projectModel(projectRoot: string, projectPath: string) {
26
+ if (projectPath.startsWith('/') || projectPath.includes('\0')) {
27
+ throw new Error(`Fal retexture path must be project-relative: ${JSON.stringify(projectPath)}.`);
28
+ }
29
+ const root = await realpath(projectRoot);
30
+ const candidate = resolve(root, projectPath);
31
+ const absolute = await realpath(candidate).catch(() => {
32
+ // A raw ENOENT names a resolved absolute path the caller never wrote.
33
+ throw new Error(
34
+ `Fal retexture path was not found in the project: ${JSON.stringify(projectPath)}.`,
35
+ );
36
+ });
37
+ if (!absolute.startsWith(`${root}${sep}`)) {
38
+ throw new Error(`Fal retexture path escapes the project: ${JSON.stringify(projectPath)}.`);
39
+ }
40
+ if (extname(absolute).toLowerCase() !== '.glb') {
41
+ throw new Error(`Fal retexture accepts GLB: ${JSON.stringify(projectPath)}.`);
42
+ }
43
+ const info = await stat(absolute);
44
+ if (!info.isFile() || info.size <= 0 || info.size > MAX_MODEL_BYTES) {
45
+ const megabytes = Math.round(MAX_MODEL_BYTES / (1024 * 1024));
46
+ throw new Error(
47
+ `Fal retexture model must be a non-empty file no larger than ${megabytes} MB: ${projectPath}.`,
48
+ );
49
+ }
50
+ return { path: projectPath, bytes: new Uint8Array(await readFile(absolute)) };
51
+ }
52
+
53
+ /**
54
+ * Translates the reviewed authoring input into Meshy's own retexture body. Only
55
+ * fields the caller set are sent, so every provider default stays the
56
+ * provider's.
57
+ */
58
+ function stated(fields: Record<string, unknown>): Record<string, unknown> {
59
+ return Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined));
60
+ }
61
+
62
+ /**
63
+ * Retextures an existing project GLB through Meshy on fal: measure it, carry it
64
+ * in as a data URI, then submit the native queue request. The measurement rides
65
+ * into acceptance so the accepted bytes can be compared against what went in.
66
+ */
67
+ export async function retextureFalModel(input: RetextureInput, ctx: ToolContext) {
68
+ if (!ctx.projectRoot) throw new Error('Fal retexture requires a project root.');
69
+ const model = await projectModel(ctx.projectRoot, input.input);
70
+ const geometry = measureGlb(model.bytes);
71
+ const submitted = await submitFal({
72
+ mode: input.mode,
73
+ endpoint: MESHY_V5_RETEXTURE_ENDPOINT,
74
+ input: stated({
75
+ model_url: `data:application/octet-stream;base64,${base64(model.bytes)}`,
76
+ text_style_prompt: input.textStylePrompt,
77
+ image_style_url: input.imageStyleUrl,
78
+ enable_original_uv: input.enableOriginalUv,
79
+ enable_pbr: input.enablePbr,
80
+ enable_safety_checker: input.enableSafetyChecker,
81
+ }),
82
+ outputDirectory: input.outputDirectory,
83
+ });
84
+ return {
85
+ ...submitted,
86
+ source: { path: model.path, bytes: model.bytes.byteLength, geometry },
87
+ };
88
+ }