@vgai/fal-client-compat 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/mock.ts +203 -4
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@vgai/fal-client-compat",
3
3
  "author": "Volter AI, Inc.",
4
4
  "license": "Apache-2.0",
5
- "version": "0.1.0",
5
+ "version": "0.1.1",
6
6
  "type": "module",
7
7
  "description": "Native @fal-ai/client execution across direct, VGAI-managed, and deterministic mock transports.",
8
8
  "homepage": "https://github.com/volter-ai/vgai-engine#readme",
package/src/mock.ts CHANGED
@@ -4,6 +4,8 @@ import type {
4
4
  HunyuanWorldImageToWorldOutput,
5
5
  HunyuanWorldInput,
6
6
  HunyuanWorldOutput,
7
+ MeshyV5RetextureInput,
8
+ MeshyV5RetextureOutput,
7
9
  MinimaxHailuo02StandardTextToVideoInput,
8
10
  MinimaxHailuo02StandardTextToVideoOutput,
9
11
  NanoBanana2EditInput,
@@ -30,6 +32,7 @@ const HAILUO_TEXT_TO_VIDEO_ENDPOINT = 'fal-ai/minimax/hailuo-02/standard/text-to
30
32
  const SEEDANCE_2_REFERENCE_TO_VIDEO_ENDPOINT = 'bytedance/seedance-2.0/reference-to-video';
31
33
  const HUNYUAN_IMAGE_TO_WORLD_ENDPOINT = 'fal-ai/hunyuan_world/image-to-world';
32
34
  const HUNYUAN_PANORAMA_ENDPOINT = 'fal-ai/hunyuan_world';
35
+ const MESHY_V5_RETEXTURE_ENDPOINT = 'fal-ai/meshy/v5/retexture';
33
36
  const SUPPORTED_ENDPOINTS = new Set([
34
37
  NANO_BANANA_ENDPOINT,
35
38
  NANO_BANANA_EDIT_ENDPOINT,
@@ -41,6 +44,7 @@ const SUPPORTED_ENDPOINTS = new Set([
41
44
  SEEDANCE_2_REFERENCE_TO_VIDEO_ENDPOINT,
42
45
  HUNYUAN_IMAGE_TO_WORLD_ENDPOINT,
43
46
  HUNYUAN_PANORAMA_ENDPOINT,
47
+ MESHY_V5_RETEXTURE_ENDPOINT,
44
48
  ]);
45
49
  const EXECUTION_RECORDER = Symbol.for('vgai.generative-execution-recorder.v1');
46
50
 
@@ -64,7 +68,8 @@ type MockTask = {
64
68
  | MinimaxHailuo02StandardTextToVideoOutput
65
69
  | Seedance2VideoOutput
66
70
  | HunyuanWorldImageToWorldOutput
67
- | HunyuanWorldOutput;
71
+ | HunyuanWorldOutput
72
+ | MeshyV5RetextureOutput;
68
73
  };
69
74
 
70
75
  // Four tiny, real VP8/WebM motion designs. Request hash chooses the visible
@@ -907,6 +912,194 @@ function createHunyuanPanoramaOutput(input: HunyuanWorldInput): HunyuanWorldOutp
907
912
  };
908
913
  }
909
914
 
915
+ function pad(bytes: Uint8Array, multiple: number, fill = 0): Uint8Array {
916
+ const paddedLength = Math.ceil(bytes.byteLength / multiple) * multiple;
917
+ if (paddedLength === bytes.byteLength) return bytes;
918
+ const output = new Uint8Array(paddedLength);
919
+ output.fill(fill);
920
+ output.set(bytes);
921
+ return output;
922
+ }
923
+
924
+ function uint32LittleEndian(value: number): Uint8Array {
925
+ const bytes = new Uint8Array(4);
926
+ new DataView(bytes.buffer).setUint32(0, value, true);
927
+ return bytes;
928
+ }
929
+
930
+ function glbChunk(type: number, data: Uint8Array): Uint8Array {
931
+ return concatBytes([uint32LittleEndian(data.byteLength), uint32LittleEndian(type), data]);
932
+ }
933
+
934
+ function floatBytes(values: Float32Array): Uint8Array {
935
+ return new Uint8Array(values.buffer, values.byteOffset, values.byteLength);
936
+ }
937
+
938
+ function uint16Bytes(values: Uint16Array): Uint8Array {
939
+ return new Uint8Array(values.buffer, values.byteOffset, values.byteLength);
940
+ }
941
+
942
+ function mockTextureImage(canonicalInput: string, hash: Uint8Array): Uint8Array {
943
+ const size = 64;
944
+ const pixels = new Uint8Array(size * size * 4);
945
+ fillBackground(pixels, size, size, hash);
946
+ drawText(pixels, size, size, 'MESHY', 4, 6, 1, [255, 255, 255, 255]);
947
+ return encodePng(size, size, pixels, canonicalInput);
948
+ }
949
+
950
+ /**
951
+ * A retexture result the accept path can actually check: a hash-derived box
952
+ * carrying a TEXCOORD_0 set, a real PNG image, and a baseColorTexture. Its
953
+ * geometry is deliberately the mock's OWN and not the submitted model's — a
954
+ * mock that echoed the input topology would answer, with fabricated data, the
955
+ * one question only a real Meshy run can answer.
956
+ */
957
+ function createMockRetexturedGlb(canonicalInput: string): Uint8Array {
958
+ const hash = hashBytes(canonicalInput);
959
+ const halfX = 0.35 + ((hash[0] ?? 0) / 255) * 0.45;
960
+ const halfY = 0.35 + ((hash[1] ?? 0) / 255) * 0.65;
961
+ const halfZ = 0.35 + ((hash[2] ?? 0) / 255) * 0.45;
962
+ const corners: Array<[number, number, number]> = [
963
+ [-halfX, -halfY, -halfZ],
964
+ [halfX, -halfY, -halfZ],
965
+ [halfX, -halfY, halfZ],
966
+ [-halfX, -halfY, halfZ],
967
+ [-halfX, halfY, -halfZ],
968
+ [halfX, halfY, -halfZ],
969
+ [halfX, halfY, halfZ],
970
+ [-halfX, halfY, halfZ],
971
+ ];
972
+ const positions = new Float32Array(corners.flat());
973
+ const uvs = new Float32Array(
974
+ corners.map(([x, , z]) => [(x + halfX) / (2 * halfX), (z + halfZ) / (2 * halfZ)]).flat(),
975
+ );
976
+ const indices = new Uint16Array([
977
+ 0, 2, 1, 0, 3, 2, 4, 5, 6, 4, 6, 7, 0, 1, 5, 0, 5, 4, 1, 2, 6, 1, 6, 5, 2, 3, 7, 2, 7, 6, 3, 0,
978
+ 4, 3, 4, 7,
979
+ ]);
980
+ const bufferViews: Array<Record<string, number>> = [];
981
+ const parts: Uint8Array[] = [];
982
+ let byteOffset = 0;
983
+ const addView = (bytes: Uint8Array, target?: number): number => {
984
+ const index = bufferViews.length;
985
+ bufferViews.push({
986
+ buffer: 0,
987
+ byteOffset,
988
+ byteLength: bytes.byteLength,
989
+ ...(target ? { target } : {}),
990
+ });
991
+ const padded = pad(bytes, 4);
992
+ parts.push(padded);
993
+ byteOffset += padded.byteLength;
994
+ return index;
995
+ };
996
+ const positionView = addView(floatBytes(positions), 34962);
997
+ const uvView = addView(floatBytes(uvs), 34962);
998
+ const indexView = addView(uint16Bytes(indices), 34963);
999
+ const imageView = addView(mockTextureImage(canonicalInput, hash));
1000
+ const binary = concatBytes(parts);
1001
+ const document = {
1002
+ asset: {
1003
+ version: '2.0',
1004
+ generator: '@vgai/fal-client-compat deterministic retexture mock',
1005
+ extras: {
1006
+ vgaiMock: true,
1007
+ provider: 'fal',
1008
+ canonicalInput,
1009
+ hash: hex(hash),
1010
+ geometryIsSubstitute: true,
1011
+ },
1012
+ },
1013
+ scene: 0,
1014
+ scenes: [{ name: 'VGAI Meshy Retexture Mock', nodes: [0] }],
1015
+ nodes: [{ mesh: 0, name: 'VGAI MOCK retextured', extras: { vgaiMock: true } }],
1016
+ meshes: [
1017
+ {
1018
+ name: 'Hash-derived textured mock geometry',
1019
+ primitives: [{ attributes: { POSITION: 0, TEXCOORD_0: 1 }, indices: 2, material: 0 }],
1020
+ },
1021
+ ],
1022
+ materials: [
1023
+ {
1024
+ name: 'Hash-derived base color texture',
1025
+ pbrMetallicRoughness: {
1026
+ baseColorTexture: { index: 0, texCoord: 0 },
1027
+ metallicFactor: 0,
1028
+ roughnessFactor: 0.8,
1029
+ },
1030
+ },
1031
+ ],
1032
+ textures: [{ source: 0, sampler: 0 }],
1033
+ samplers: [{ magFilter: 9729, minFilter: 9987, wrapS: 10497, wrapT: 10497 }],
1034
+ images: [{ bufferView: imageView, mimeType: 'image/png', name: 'vgai-mock-basecolor' }],
1035
+ accessors: [
1036
+ {
1037
+ bufferView: positionView,
1038
+ componentType: 5126,
1039
+ count: corners.length,
1040
+ type: 'VEC3',
1041
+ min: [-halfX, -halfY, -halfZ],
1042
+ max: [halfX, halfY, halfZ],
1043
+ },
1044
+ { bufferView: uvView, componentType: 5126, count: corners.length, type: 'VEC2' },
1045
+ { bufferView: indexView, componentType: 5123, count: indices.length, type: 'SCALAR' },
1046
+ ],
1047
+ bufferViews,
1048
+ buffers: [{ byteLength: binary.byteLength }],
1049
+ };
1050
+ const json = pad(new TextEncoder().encode(JSON.stringify(document)), 4, 0x20);
1051
+ const jsonChunk = glbChunk(0x4e4f534a, json);
1052
+ const binaryChunk = glbChunk(0x004e4942, binary);
1053
+ return concatBytes([
1054
+ uint32LittleEndian(0x46546c67),
1055
+ uint32LittleEndian(2),
1056
+ uint32LittleEndian(12 + jsonChunk.byteLength + binaryChunk.byteLength),
1057
+ jsonChunk,
1058
+ binaryChunk,
1059
+ ]);
1060
+ }
1061
+
1062
+ function createMeshyRetextureOutput(input: MeshyV5RetextureInput): MeshyV5RetextureOutput {
1063
+ if (!input || typeof input.model_url !== 'string' || input.model_url.trim().length === 0) {
1064
+ throw new Error(
1065
+ 'fal mock: fal-ai/meshy/v5/retexture requires input.model_url as a non-empty URL or data URI.',
1066
+ );
1067
+ }
1068
+ const styled =
1069
+ (typeof input.text_style_prompt === 'string' && input.text_style_prompt.trim().length > 0) ||
1070
+ (typeof input.image_style_url === 'string' && input.image_style_url.trim().length > 0);
1071
+ if (!styled) {
1072
+ throw new Error(
1073
+ 'fal mock: fal-ai/meshy/v5/retexture requires input.text_style_prompt or input.image_style_url.',
1074
+ );
1075
+ }
1076
+ // The submitted model is usually a multi-megabyte data URI. Identify it by
1077
+ // digest so the deterministic identity stays cheap and the embedded record
1078
+ // stays small, while still differing for different models.
1079
+ const canonicalInput = stableJson({
1080
+ endpoint: MESHY_V5_RETEXTURE_ENDPOINT,
1081
+ input: { ...input, model_url: `sha:${hex(hashBytes(input.model_url))}` },
1082
+ });
1083
+ const glb = createMockRetexturedGlb(canonicalInput);
1084
+ const digest = hex(hashBytes(base64(glb))).slice(0, 16);
1085
+ const model = {
1086
+ url: `data:application/octet-stream;base64,${base64(glb)}`,
1087
+ content_type: 'model/gltf-binary',
1088
+ file_name: `vgai-mock-${digest}.glb`,
1089
+ file_size: glb.byteLength,
1090
+ };
1091
+ return {
1092
+ model_glb: model,
1093
+ model_urls: { glb: model },
1094
+ ...(typeof input.text_style_prompt === 'string'
1095
+ ? { text_style_prompt: input.text_style_prompt }
1096
+ : {}),
1097
+ ...(typeof input.image_style_url === 'string'
1098
+ ? { image_style_url: input.image_style_url }
1099
+ : {}),
1100
+ };
1101
+ }
1102
+
910
1103
  function createOutput(
911
1104
  endpoint: string,
912
1105
  input:
@@ -919,7 +1112,8 @@ function createOutput(
919
1112
  | MinimaxHailuo02StandardTextToVideoInput
920
1113
  | Seedance2R2VInput
921
1114
  | HunyuanWorldImageToWorldInput
922
- | HunyuanWorldInput,
1115
+ | HunyuanWorldInput
1116
+ | MeshyV5RetextureInput,
923
1117
  ):
924
1118
  | NanoBananaOutput
925
1119
  | StableAudioOutput
@@ -927,7 +1121,8 @@ function createOutput(
927
1121
  | MinimaxHailuo02StandardTextToVideoOutput
928
1122
  | Seedance2VideoOutput
929
1123
  | HunyuanWorldImageToWorldOutput
930
- | HunyuanWorldOutput {
1124
+ | HunyuanWorldOutput
1125
+ | MeshyV5RetextureOutput {
931
1126
  if (
932
1127
  endpoint === NANO_BANANA_ENDPOINT ||
933
1128
  endpoint === NANO_BANANA_EDIT_ENDPOINT ||
@@ -954,6 +1149,9 @@ function createOutput(
954
1149
  if (endpoint === HUNYUAN_PANORAMA_ENDPOINT) {
955
1150
  return createHunyuanPanoramaOutput(input as HunyuanWorldInput);
956
1151
  }
1152
+ if (endpoint === MESHY_V5_RETEXTURE_ENDPOINT) {
1153
+ return createMeshyRetextureOutput(input as MeshyV5RetextureInput);
1154
+ }
957
1155
  throw new Error(`fal mock: endpoint ${JSON.stringify(endpoint)} is not implemented.`);
958
1156
  }
959
1157
 
@@ -993,7 +1191,8 @@ function submitTask(
993
1191
  | MinimaxHailuo02StandardTextToVideoInput
994
1192
  | Seedance2R2VInput
995
1193
  | HunyuanWorldImageToWorldInput
996
- | HunyuanWorldInput,
1194
+ | HunyuanWorldInput
1195
+ | MeshyV5RetextureInput,
997
1196
  );
998
1197
  const requestId = `mock_${hex(hashBytes(stableJson({ endpoint, body }))).slice(0, 24)}`;
999
1198
  tasks.set(requestId, { endpoint, output });