@mixio-pro/kalaasetu-mcp 2.3.26 → 2.3.28
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 +1 -1
- package/src/tools/fal/dynamic-tools.ts +44 -31
- package/src/tools/fal/generate.ts +46 -13
- package/src/tools/get-status.ts +87 -47
- package/src/tools/image-to-video.ts +1 -8
- package/src/utils/fal-save.ts +122 -0
package/package.json
CHANGED
|
@@ -126,13 +126,14 @@ function buildZodSchema(
|
|
|
126
126
|
// Convert to Zod using Zod 4's fromJSONSchema (experimental)
|
|
127
127
|
let zodSchema = (z as any).fromJSONSchema(jsonSchema) as z.ZodObject<any>;
|
|
128
128
|
|
|
129
|
-
// Always add
|
|
129
|
+
// Always add internal parameters (output_path, resume_endpoint)
|
|
130
130
|
zodSchema = zodSchema.extend({
|
|
131
131
|
output_path: z
|
|
132
132
|
.string()
|
|
133
133
|
.optional()
|
|
134
134
|
.describe(
|
|
135
|
-
"Optional local path/filename to save the
|
|
135
|
+
"Optional local path/filename to save the generated file (e.g., 'path/to/image.png'). " +
|
|
136
|
+
"When status is checked via get_generation_status, the file will be saved to this location.",
|
|
136
137
|
),
|
|
137
138
|
resume_endpoint: z
|
|
138
139
|
.string()
|
|
@@ -177,41 +178,31 @@ export function createToolFromPreset(preset: FalPresetConfig) {
|
|
|
177
178
|
|
|
178
179
|
// Handle resume flow
|
|
179
180
|
if (args.resume_endpoint) {
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
const resumeEndpointWithoutTracking =
|
|
196
|
-
decodedTracking?.toolName && resumeParts.length > 1
|
|
197
|
-
? resumeParts.slice(0, -1).join("||")
|
|
198
|
-
: args.resume_endpoint;
|
|
199
|
-
|
|
200
|
-
if (resumeEndpointWithoutTracking.startsWith("http")) {
|
|
201
|
-
statusUrl = resumeEndpointWithoutTracking;
|
|
202
|
-
responseUrl = resumeEndpointWithoutTracking.replace(/\/status$/, "");
|
|
203
|
-
const urlParts = resumeEndpointWithoutTracking.split("/");
|
|
181
|
+
const parts = args.resume_endpoint.split("||");
|
|
182
|
+
statusUrl = parts[0];
|
|
183
|
+
|
|
184
|
+
// Extract tracking context and output_path if present in composite endpoint
|
|
185
|
+
for (let i = 1; i < parts.length; i++) {
|
|
186
|
+
const part = parts[i];
|
|
187
|
+
if (part && (part.includes("/") || part.includes("\\")) && !args.output_path) {
|
|
188
|
+
args.output_path = part;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (statusUrl.startsWith("http")) {
|
|
193
|
+
responseUrl = statusUrl.replace(/\/status$/, "");
|
|
194
|
+
const urlParts = statusUrl.split("/");
|
|
204
195
|
const lastPart = urlParts[urlParts.length - 1] || "";
|
|
205
196
|
requestId =
|
|
206
197
|
lastPart.replace("/status", "") ||
|
|
207
198
|
urlParts[urlParts.length - 2] ||
|
|
208
199
|
"unknown";
|
|
209
200
|
context?.log?.info(
|
|
210
|
-
`Resuming with FAL URL: ${
|
|
201
|
+
`Resuming with FAL URL: ${statusUrl} (Job ID: ${requestId})`,
|
|
211
202
|
);
|
|
212
203
|
} else {
|
|
213
204
|
// Legacy UUID format - reconstruct URL
|
|
214
|
-
requestId =
|
|
205
|
+
requestId = statusUrl;
|
|
215
206
|
statusUrl = `${FAL_QUEUE_URL}/${preset.modelId}/requests/${requestId}/status`;
|
|
216
207
|
responseUrl = `${FAL_QUEUE_URL}/${preset.modelId}/requests/${requestId}`;
|
|
217
208
|
context?.log?.info(
|
|
@@ -404,7 +395,22 @@ export function createToolFromPreset(preset: FalPresetConfig) {
|
|
|
404
395
|
responseUrl,
|
|
405
396
|
"GET",
|
|
406
397
|
);
|
|
407
|
-
|
|
398
|
+
|
|
399
|
+
// Handle saving to output_path if provided
|
|
400
|
+
const sanitizedResult = sanitizeResponse(finalResult);
|
|
401
|
+
if (args.output_path) {
|
|
402
|
+
try {
|
|
403
|
+
const { saveFalResult } = await import("../../utils/fal-save");
|
|
404
|
+
const savedFiles = await saveFalResult(finalResult, args.output_path);
|
|
405
|
+
if (savedFiles.length > 0) {
|
|
406
|
+
(sanitizedResult as any).saved_files = savedFiles;
|
|
407
|
+
}
|
|
408
|
+
} catch (err: any) {
|
|
409
|
+
context?.log?.info(`Failed to save files to ${args.output_path}: ${err.message}`);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return JSON.stringify(sanitizedResult);
|
|
408
414
|
}
|
|
409
415
|
|
|
410
416
|
if (res.status === "FAILED") {
|
|
@@ -416,15 +422,22 @@ export function createToolFromPreset(preset: FalPresetConfig) {
|
|
|
416
422
|
await wait(POLL_INTERVAL);
|
|
417
423
|
}
|
|
418
424
|
|
|
419
|
-
// Timeout - return resume_endpoint
|
|
425
|
+
// Timeout - return composite resume_endpoint
|
|
426
|
+
const trackingPiece = encodeTrackingContext({
|
|
427
|
+
toolName: toolName,
|
|
428
|
+
toolArgs: extractPrimitiveArgs(args),
|
|
429
|
+
requestId: (context as any)?.requestId,
|
|
430
|
+
});
|
|
431
|
+
|
|
420
432
|
return JSON.stringify({
|
|
421
433
|
status: "IN_PROGRESS",
|
|
422
434
|
request_id: requestId,
|
|
435
|
+
// Encode tracking context and output_path so get-status can issue async refunds/saves
|
|
423
436
|
resume_endpoint: `${statusUrl}||${encodeTrackingContext({
|
|
424
437
|
toolName,
|
|
425
438
|
toolArgs: extractPrimitiveArgs(args),
|
|
426
439
|
requestId: (context as any)?.requestId,
|
|
427
|
-
})}`,
|
|
440
|
+
})}||${args.output_path || ""}`,
|
|
428
441
|
status_url: statusUrl,
|
|
429
442
|
response_url: responseUrl,
|
|
430
443
|
message:
|
|
@@ -113,6 +113,12 @@ export const falGenerate = {
|
|
|
113
113
|
"These override the default values defined in the preset. " +
|
|
114
114
|
"NOTE: For image-to-video or video-to-video tasks, use 'fal_upload_file' first and pass the resulting CDN URL here.",
|
|
115
115
|
),
|
|
116
|
+
output_path: z
|
|
117
|
+
.string()
|
|
118
|
+
.optional()
|
|
119
|
+
.describe(
|
|
120
|
+
"Local path to save the generated file (e.g., 'path/to/image.png')",
|
|
121
|
+
),
|
|
116
122
|
resume_endpoint: z
|
|
117
123
|
.string()
|
|
118
124
|
.optional()
|
|
@@ -127,32 +133,42 @@ export const falGenerate = {
|
|
|
127
133
|
preset_name?: string;
|
|
128
134
|
parameters?: Record<string, any>;
|
|
129
135
|
resume_endpoint?: string;
|
|
136
|
+
output_path?: string;
|
|
130
137
|
},
|
|
131
138
|
context?: ProgressContext,
|
|
132
139
|
) => {
|
|
133
140
|
return safeToolExecute(
|
|
134
141
|
async () => {
|
|
135
|
-
let statusUrl: string;
|
|
136
|
-
let responseUrl: string;
|
|
137
|
-
let requestId: string;
|
|
142
|
+
let statusUrl: string = "";
|
|
143
|
+
let responseUrl: string = "";
|
|
144
|
+
let requestId: string = "";
|
|
138
145
|
const config = loadFalConfig();
|
|
139
146
|
|
|
140
147
|
if (args.resume_endpoint) {
|
|
141
148
|
// Check if resume_endpoint is a full URL (new format) or legacy ID
|
|
142
149
|
if (args.resume_endpoint.startsWith("http")) {
|
|
143
|
-
// NEW: resume_endpoint IS the status/response URL
|
|
144
|
-
|
|
150
|
+
// NEW: resume_endpoint IS the status/response URL (possibly composite with extra metadata)
|
|
151
|
+
if (args.resume_endpoint.includes("||")) {
|
|
152
|
+
const parts = args.resume_endpoint.split("||");
|
|
153
|
+
statusUrl = parts[0] ?? "";
|
|
154
|
+
// Extract output_path if present in composite endpoint
|
|
155
|
+
if (parts.length >= 3 && !args.output_path) {
|
|
156
|
+
args.output_path = parts[2];
|
|
157
|
+
}
|
|
158
|
+
} else {
|
|
159
|
+
statusUrl = args.resume_endpoint;
|
|
160
|
+
}
|
|
145
161
|
// Derive responseUrl by removing /status suffix if present
|
|
146
|
-
responseUrl =
|
|
162
|
+
responseUrl = statusUrl.replace(/\/status$/, "");
|
|
147
163
|
// Extract requestId from URL for logging
|
|
148
|
-
const urlParts =
|
|
164
|
+
const urlParts = statusUrl.split("/");
|
|
149
165
|
const lastPart = urlParts[urlParts.length - 1] || "";
|
|
150
166
|
requestId =
|
|
151
167
|
lastPart.replace("/status", "") ||
|
|
152
168
|
urlParts[urlParts.length - 2] ||
|
|
153
169
|
"unknown";
|
|
154
170
|
context?.log?.info(
|
|
155
|
-
`Resuming with FAL URL: ${
|
|
171
|
+
`Resuming with FAL URL: ${statusUrl} (Job ID: ${requestId})`,
|
|
156
172
|
);
|
|
157
173
|
} else {
|
|
158
174
|
// LEGACY: Try to resolve model from preset_name or parse modelId::requestId
|
|
@@ -233,9 +249,7 @@ export const falGenerate = {
|
|
|
233
249
|
}
|
|
234
250
|
}
|
|
235
251
|
}
|
|
236
|
-
context?.log?.info(
|
|
237
|
-
`Resuming polling for request: ${args.resume_endpoint}`,
|
|
238
|
-
);
|
|
252
|
+
context?.log?.info(`Resuming polling for request: ${statusUrl}`);
|
|
239
253
|
}
|
|
240
254
|
} // Close the LEGACY else block (line 149)
|
|
241
255
|
} else {
|
|
@@ -391,6 +405,25 @@ export const falGenerate = {
|
|
|
391
405
|
}
|
|
392
406
|
// responseUrl is now guaranteed to be correct/fresh from polling
|
|
393
407
|
const finalResult = await authenticatedRequest(responseUrl, "GET");
|
|
408
|
+
|
|
409
|
+
// Handle saving to output_path if provided
|
|
410
|
+
if (args.output_path) {
|
|
411
|
+
try {
|
|
412
|
+
const { saveFalResult } = await import("../../utils/fal-save");
|
|
413
|
+
const savedFiles = await saveFalResult(
|
|
414
|
+
finalResult,
|
|
415
|
+
args.output_path,
|
|
416
|
+
);
|
|
417
|
+
if (savedFiles.length > 0) {
|
|
418
|
+
(finalResult as any).saved_files = savedFiles;
|
|
419
|
+
}
|
|
420
|
+
} catch (err: any) {
|
|
421
|
+
context?.log?.info(
|
|
422
|
+
`Failed to save files to ${args.output_path}: ${err.message}`,
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
394
427
|
return JSON.stringify(finalResult);
|
|
395
428
|
}
|
|
396
429
|
|
|
@@ -430,12 +463,12 @@ export const falGenerate = {
|
|
|
430
463
|
return JSON.stringify({
|
|
431
464
|
status: "IN_PROGRESS",
|
|
432
465
|
request_id: requestId,
|
|
433
|
-
// Encode tracking context so get-status can issue async refunds
|
|
466
|
+
// Encode tracking context and output_path so get-status can issue async refunds/saves
|
|
434
467
|
resume_endpoint: `${statusUrl}||${encodeTrackingContext({
|
|
435
468
|
toolName: "fal_generate",
|
|
436
469
|
toolArgs: extractPrimitiveArgs(args),
|
|
437
470
|
requestId: (context as any)?.requestId,
|
|
438
|
-
})}`,
|
|
471
|
+
})}||${args.output_path || ""}`,
|
|
439
472
|
status_url: statusUrl,
|
|
440
473
|
response_url: responseUrl,
|
|
441
474
|
message:
|
package/src/tools/get-status.ts
CHANGED
|
@@ -314,9 +314,13 @@ export const getGenerationStatus = {
|
|
|
314
314
|
.string()
|
|
315
315
|
.describe(
|
|
316
316
|
"The resume_endpoint returned by the original generation tool. " +
|
|
317
|
-
"For FAL: This is a full URL (starts with 'https://queue.fal.run/...'). " +
|
|
317
|
+
"For FAL: This is a full URL (starts with 'https://queue.fal.run/...') but may include extra metadata via '||' delimiters. " +
|
|
318
318
|
"For Vertex AI: This is typically the composite string 'fetchUrl||operationName||outputPath' with optional tracking context appended.",
|
|
319
319
|
),
|
|
320
|
+
output_path: z
|
|
321
|
+
.string()
|
|
322
|
+
.optional()
|
|
323
|
+
.describe("Optional override for the local path to save the generated file."),
|
|
320
324
|
source: z
|
|
321
325
|
.enum(["fal", "vertex", "auto"])
|
|
322
326
|
.optional()
|
|
@@ -330,6 +334,7 @@ export const getGenerationStatus = {
|
|
|
330
334
|
args: {
|
|
331
335
|
resume_endpoint: string;
|
|
332
336
|
source?: "fal" | "vertex" | "auto";
|
|
337
|
+
output_path?: string;
|
|
333
338
|
},
|
|
334
339
|
context?: any,
|
|
335
340
|
) => {
|
|
@@ -339,34 +344,59 @@ export const getGenerationStatus = {
|
|
|
339
344
|
const project_id = "mixio-pro";
|
|
340
345
|
const location_id = "us-central1";
|
|
341
346
|
|
|
342
|
-
// 1. Decode generic tracking context if present
|
|
347
|
+
// 1. Decode generic tracking context and extra metadata if present
|
|
343
348
|
let trackingContext: any = null;
|
|
344
349
|
let originalEndpoint = resume_endpoint;
|
|
350
|
+
let outputPath = args.output_path;
|
|
351
|
+
|
|
345
352
|
if (resume_endpoint.includes("||")) {
|
|
346
353
|
const parts = resume_endpoint.split("||");
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
if (
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
354
|
+
|
|
355
|
+
// For FAL jobs starting with https://
|
|
356
|
+
if (parts?.[0]?.startsWith("https://")) {
|
|
357
|
+
originalEndpoint = parts[0];
|
|
358
|
+
// Part 1 is tracking context (base64)
|
|
359
|
+
if (parts.length >= 2 && parts[1]) {
|
|
360
|
+
try {
|
|
361
|
+
const { decodeTrackingContext } = await import("../utils/endpoint-encoder");
|
|
362
|
+
trackingContext = decodeTrackingContext(parts[1]);
|
|
363
|
+
// Try to extract output_path from tracking context if not explicitly provided
|
|
364
|
+
if (!outputPath) {
|
|
365
|
+
outputPath = extractOutputPathFromTrackingContext(trackingContext);
|
|
366
|
+
}
|
|
367
|
+
} catch (err) {}
|
|
368
|
+
}
|
|
369
|
+
// Part 2 is output_path in our new URL||TRACKING||PATH format
|
|
370
|
+
if (parts.length >= 3 && parts[2] && !outputPath) {
|
|
371
|
+
outputPath = parts[2];
|
|
372
|
+
}
|
|
373
|
+
} else {
|
|
374
|
+
// Vertex logic (legacy/other)
|
|
375
|
+
const lastPart = parts[parts.length - 1];
|
|
376
|
+
if (
|
|
377
|
+
lastPart &&
|
|
378
|
+
!lastPart.startsWith("http") &&
|
|
379
|
+
!lastPart.includes("/") &&
|
|
380
|
+
!lastPart.includes("mixio-pro") // Ensure it doesn't look like an operation path or URL
|
|
381
|
+
) {
|
|
382
|
+
try {
|
|
383
|
+
const { decodeTrackingContext } =
|
|
384
|
+
await import("../utils/endpoint-encoder");
|
|
385
|
+
trackingContext = decodeTrackingContext(lastPart);
|
|
386
|
+
if (trackingContext?.toolName) {
|
|
387
|
+
// Remove the tracking piece from the endpoint we pass to handlers
|
|
388
|
+
originalEndpoint = parts.slice(0, -1).join("||");
|
|
389
|
+
}
|
|
390
|
+
} catch (err) {}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// For Vertex, if outputPath is not explicitly provided, it might be in the 3rd part of originalEndpoint
|
|
394
|
+
if (!outputPath) {
|
|
395
|
+
const vertexParts = originalEndpoint.split("||");
|
|
396
|
+
if (vertexParts.length >= 3) {
|
|
397
|
+
outputPath = vertexParts[2];
|
|
368
398
|
}
|
|
369
|
-
}
|
|
399
|
+
}
|
|
370
400
|
}
|
|
371
401
|
}
|
|
372
402
|
|
|
@@ -386,31 +416,39 @@ export const getGenerationStatus = {
|
|
|
386
416
|
let result: any;
|
|
387
417
|
|
|
388
418
|
if (detectedSource === "fal") {
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
const
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
419
|
+
result = await checkFalStatus(originalEndpoint);
|
|
420
|
+
|
|
421
|
+
if (result.status === "COMPLETED") {
|
|
422
|
+
const falResultUrl = result.response_url || originalEndpoint.replace(/\/status$/, "");
|
|
423
|
+
const finalResult = await fetchFalResult(falResultUrl);
|
|
424
|
+
|
|
425
|
+
// Prefer direct local saving if outputPath is available
|
|
426
|
+
if (outputPath) {
|
|
427
|
+
try {
|
|
428
|
+
const { saveFalResult } = await import("../utils/fal-save");
|
|
429
|
+
const saved = await saveFalResult(finalResult, outputPath);
|
|
430
|
+
if (saved && saved.length > 0) {
|
|
431
|
+
result.saved_files = saved;
|
|
432
|
+
}
|
|
433
|
+
} catch (err: any) {
|
|
434
|
+
logger.error(`[FAL Status] Failed to save result locally to ${outputPath}: ${err.message}`);
|
|
435
|
+
}
|
|
436
|
+
} else {
|
|
437
|
+
// Fallback to standard storage logic (upstream)
|
|
438
|
+
try {
|
|
439
|
+
const { savedVideos, savedImages } = await saveFalUrls(finalResult);
|
|
440
|
+
if (savedVideos.length > 0) finalResult.saved_videos = savedVideos;
|
|
441
|
+
if (savedImages.length > 0) finalResult.saved_images = savedImages;
|
|
442
|
+
} catch (err: any) {
|
|
443
|
+
logger.error(`[FAL Status] Failed to save result to standard storage: ${err.message}`);
|
|
444
|
+
}
|
|
406
445
|
}
|
|
407
446
|
|
|
408
447
|
result = {
|
|
409
|
-
...
|
|
448
|
+
...finalResult,
|
|
449
|
+
saved_files: result.saved_files,
|
|
410
450
|
status: "COMPLETED",
|
|
411
451
|
};
|
|
412
|
-
} else {
|
|
413
|
-
result = falStatusResult;
|
|
414
452
|
}
|
|
415
453
|
} else {
|
|
416
454
|
result = await checkVertexStatus(originalEndpoint);
|
|
@@ -444,9 +482,11 @@ export const getGenerationStatus = {
|
|
|
444
482
|
result: safeResult,
|
|
445
483
|
message:
|
|
446
484
|
status === "COMPLETED"
|
|
447
|
-
?
|
|
448
|
-
|
|
449
|
-
|
|
485
|
+
? (result.saved_files
|
|
486
|
+
? `Generation completed! Files saved to ${result.saved_files[0].localPath}`
|
|
487
|
+
: (result.saved_videos || result.saved_images
|
|
488
|
+
? "Generation completed! Files saved to storage."
|
|
489
|
+
: "Generation completed! Check 'result' for the output URLs."))
|
|
450
490
|
: status === "FAILED"
|
|
451
491
|
? "Generation failed. Check the 'result' field for error details."
|
|
452
492
|
: "Generation is still in progress. Call this tool again with the same resume_endpoint to check later.",
|
|
@@ -110,13 +110,6 @@ export const imageToVideo = {
|
|
|
110
110
|
"(e.g., 'scenes/intro/robot_walking.mp4'). " +
|
|
111
111
|
"If omitted, a timestamped filename is generated automatically.",
|
|
112
112
|
),
|
|
113
|
-
model_id: z
|
|
114
|
-
.string()
|
|
115
|
-
.optional()
|
|
116
|
-
.default("veo-3.1-fast-generate-001")
|
|
117
|
-
.describe(
|
|
118
|
-
"Specific Vertex Veo model ID to use. Default Value is veo-3.1-fast-generate-001",
|
|
119
|
-
),
|
|
120
113
|
generate_audio: z
|
|
121
114
|
.boolean()
|
|
122
115
|
.optional()
|
|
@@ -168,7 +161,7 @@ export const imageToVideo = {
|
|
|
168
161
|
async () => {
|
|
169
162
|
const projectId = "mixio-pro";
|
|
170
163
|
const location = "us-central1";
|
|
171
|
-
const modelId =
|
|
164
|
+
const modelId = "veo-3.1-lite-generate-001";
|
|
172
165
|
|
|
173
166
|
// Validate and parse duration_seconds - snap to nearest 4, 6, or 8
|
|
174
167
|
let durationSeconds = parseInt(args.duration_seconds || "6");
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { logger } from "./logger";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Result of a file save operation.
|
|
7
|
+
*/
|
|
8
|
+
export interface SavedFile {
|
|
9
|
+
url: string;
|
|
10
|
+
localPath: string;
|
|
11
|
+
filename: string;
|
|
12
|
+
contentType?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Clean and normalize a path, handling Windows-style backslashes on POSIX systems.
|
|
17
|
+
*/
|
|
18
|
+
export function normalizeLocalPath(filePath: string): string {
|
|
19
|
+
// Replace Windows backslashes with forward slashes for Mac/Linux
|
|
20
|
+
let normalized = filePath.replace(/\\/g, "/");
|
|
21
|
+
|
|
22
|
+
// If it starts with a drive letter (e.g., B:/), we might want to handle it
|
|
23
|
+
// For now, we'll keep it as is, which might resolve relative to CWD if not absolute on Mac
|
|
24
|
+
// But usually, these are absolute paths in the user's environment.
|
|
25
|
+
|
|
26
|
+
return path.normalize(normalized);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Download a file from a URL and save it to a local path.
|
|
31
|
+
*/
|
|
32
|
+
async function downloadFile(url: string, destPath: string): Promise<void> {
|
|
33
|
+
const response = await fetch(url);
|
|
34
|
+
if (!response.ok) {
|
|
35
|
+
throw new Error(`Failed to download ${url}: ${response.status} ${response.statusText}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
39
|
+
const buffer = Buffer.from(arrayBuffer);
|
|
40
|
+
|
|
41
|
+
// Ensure directory exists
|
|
42
|
+
const dir = path.dirname(destPath);
|
|
43
|
+
if (!fs.existsSync(dir)) {
|
|
44
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
fs.writeFileSync(destPath, buffer);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Parse a FAL result and save any images/videos to the specified output path.
|
|
52
|
+
*
|
|
53
|
+
* @param result The JSON result from FAL API
|
|
54
|
+
* @param outputPath The base local path to save to (e.g., "path/to/output.png")
|
|
55
|
+
* @returns Array of saved file information
|
|
56
|
+
*/
|
|
57
|
+
export async function saveFalResult(result: any, outputPath: string): Promise<SavedFile[]> {
|
|
58
|
+
const savedFiles: SavedFile[] = [];
|
|
59
|
+
const normalizedPath = normalizeLocalPath(outputPath);
|
|
60
|
+
const ext = path.extname(normalizedPath);
|
|
61
|
+
const dir = path.dirname(normalizedPath);
|
|
62
|
+
const base = path.basename(normalizedPath, ext);
|
|
63
|
+
|
|
64
|
+
// 1. Identify files to download
|
|
65
|
+
const filesToDownload: { url: string; contentType?: string }[] = [];
|
|
66
|
+
|
|
67
|
+
if (Array.isArray(result.images)) {
|
|
68
|
+
for (const img of result.images) {
|
|
69
|
+
if (img.url) filesToDownload.push({ url: img.url, contentType: img.content_type });
|
|
70
|
+
}
|
|
71
|
+
} else if (result.image && result.image.url) {
|
|
72
|
+
filesToDownload.push({ url: result.image.url, contentType: result.image.content_type });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (result.video && result.video.url) {
|
|
76
|
+
filesToDownload.push({ url: result.video.url, contentType: result.video.content_type });
|
|
77
|
+
} else if (Array.isArray(result.videos)) {
|
|
78
|
+
for (const vid of result.videos) {
|
|
79
|
+
if (vid.url) filesToDownload.push({ url: vid.url, contentType: vid.content_type });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Fallback: search for any "url" fields that look like media
|
|
84
|
+
if (filesToDownload.length === 0) {
|
|
85
|
+
const searchUrls = (obj: any) => {
|
|
86
|
+
if (!obj || typeof obj !== "object") return;
|
|
87
|
+
if (obj.url && typeof obj.url === "string" && (obj.url.startsWith("http"))) {
|
|
88
|
+
filesToDownload.push({ url: obj.url, contentType: obj.content_type });
|
|
89
|
+
} else {
|
|
90
|
+
for (const key in obj) searchUrls(obj[key]);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
searchUrls(result);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 2. Download and save
|
|
97
|
+
for (const [i, file] of filesToDownload.entries()) {
|
|
98
|
+
let destPath: string;
|
|
99
|
+
|
|
100
|
+
if (i === 0) {
|
|
101
|
+
destPath = normalizedPath;
|
|
102
|
+
} else {
|
|
103
|
+
// For subsequent files, add an index before the extension
|
|
104
|
+
destPath = path.join(dir, `${base}_${i}${ext}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
logger.info(`[FAL Save] Saving ${file.url} to ${destPath}...`);
|
|
108
|
+
try {
|
|
109
|
+
await downloadFile(file.url, destPath);
|
|
110
|
+
savedFiles.push({
|
|
111
|
+
url: file.url,
|
|
112
|
+
localPath: destPath,
|
|
113
|
+
filename: path.basename(destPath),
|
|
114
|
+
contentType: file.contentType
|
|
115
|
+
});
|
|
116
|
+
} catch (err: any) {
|
|
117
|
+
logger.error(`[FAL Save] Failed to save ${file.url}: ${err.message}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return savedFiles;
|
|
122
|
+
}
|