@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 +6 -4
- package/src/accept.ts +86 -3
- package/src/contracts.ts +57 -1
- package/src/glb-measure.ts +106 -0
- package/src/pricing.ts +28 -8
- package/src/retexture.ts +88 -0
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.
|
|
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.
|
|
35
|
-
"@vgai/sdk": "0.5.
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
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
|
|
131
|
-
|
|
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:
|
|
137
|
-
...(
|
|
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:
|
|
146
|
-
...(
|
|
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
|
}
|
package/src/retexture.ts
ADDED
|
@@ -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
|
+
}
|