@mixio-pro/kalaasetu-mcp 2.3.32 → 2.3.33

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/fal-config.json CHANGED
@@ -143,6 +143,40 @@
143
143
  "default": "replace_audio_and_video"
144
144
  }
145
145
  }
146
+ },
147
+ {
148
+ "presetName": "multi_angle",
149
+ "intent": "Generate a single multi-angle image edit using Qwen Image Edit with the Mixio multi-angle LoRA.",
150
+ "modelId": "fal-ai/qwen-image-edit-2509-lora",
151
+ "inputType": "image",
152
+ "outputType": "image",
153
+ "enabled": false,
154
+ "pricing": {
155
+ "baseCredits": 15,
156
+ "rounding": "ceil"
157
+ }
158
+ },
159
+ {
160
+ "presetName": "multi_angle_batch",
161
+ "intent": "Generate multiple multi-angle image edits using Qwen Image Edit with the Mixio multi-angle LoRA.",
162
+ "modelId": "fal-ai/qwen-image-edit-2509-lora",
163
+ "inputType": "image",
164
+ "outputType": "image",
165
+ "enabled": false,
166
+ "pricing": {
167
+ "baseCredits": 15,
168
+ "modifiers": [
169
+ {
170
+ "field": "batch_count",
171
+ "mode": "step",
172
+ "operation": "add",
173
+ "step": 1,
174
+ "offset": 1,
175
+ "valuePerStep": 15
176
+ }
177
+ ],
178
+ "rounding": "ceil"
179
+ }
146
180
  }
147
181
  ]
148
182
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mixio-pro/kalaasetu-mcp",
3
- "version": "2.3.32",
3
+ "version": "2.3.33",
4
4
  "description": "A powerful Model Context Protocol server providing AI tools for content generation and analysis",
5
5
  "type": "module",
6
6
  "module": "src/index.ts",
package/src/index.ts CHANGED
@@ -100,6 +100,8 @@ async function main() {
100
100
  server.addTool(falGetPresetDetails);
101
101
  server.addTool(falUploadFile);
102
102
  server.addTool(falGenerate);
103
+ server.addTool(mixioMultiAngle);
104
+ server.addTool(mixioMultiAngleBatch);
103
105
  // 4. Register Dynamic FAL AI Tools
104
106
  // These are now based on potentially synced remote config
105
107
  const falTools = createAllFalTools();
@@ -109,8 +111,6 @@ async function main() {
109
111
  }
110
112
 
111
113
  // 4. Add Mixio Tools
112
- // server.addTool(mixioMultiAngle);
113
- // server.addTool(mixioMultiAngleBatch);
114
114
  // server.addTool(mixioNextScene);
115
115
  // server.addTool(mixioFaceSwap);
116
116
  if (process.env.GROK_ENABLED === "true") {
@@ -1,77 +1,299 @@
1
1
  import { z } from "zod";
2
+ import * as path from "path";
2
3
  import {
3
4
  safeToolExecute,
4
5
  extractPrimitiveArgs,
5
6
  } from "../../utils/tool-wrapper";
6
7
  import {
7
- appendImageToFormData,
8
- MIXIO_IMAGE_EDIT_URL,
9
- handleMixioResponse,
10
- } from "./common";
11
-
12
- const azimuthEnum = z.enum([
13
- "front view",
14
- "front-right quarter view",
15
- "right side view",
16
- "back-right quarter view",
17
- "back view",
18
- "back-left quarter view",
19
- "left side view",
20
- "front-left quarter view",
21
- ]);
22
-
23
- const elevationEnum = z.enum([
24
- "low-angle shot",
25
- "eye-level shot",
26
- "elevated shot",
27
- "high-angle shot",
28
- ]);
29
-
30
- const distanceEnum = z.enum([
31
- "close-up",
32
- "medium shot",
33
- "wide shot",
34
- ]);
8
+ FAL_QUEUE_URL,
9
+ FAL_REST_URL,
10
+ AUTHENTICATED_TIMEOUT,
11
+ getApiKey,
12
+ } from "../fal/config";
13
+ import { getStorage } from "../../storage";
14
+ import { saveMixioImage } from "./common";
15
+
16
+ const QWEN_MULTI_ANGLE_MODEL_ID = "fal-ai/qwen-image-edit-2509-lora";
17
+ const QWEN_MULTI_ANGLE_LORA_PATH =
18
+ "https://huggingface.co/dx8152/Qwen-Edit-2509-Multiple-angles/resolve/main/%E9%95%9C%E5%A4%B4%E8%BD%AC%E6%8D%A2.safetensors";
19
+ const DEFAULT_LORA_SCALE = 1.0;
20
+ const POLL_INTERVAL_MS = 3000;
21
+ const MAX_POLL_TIME_MS = 90000;
22
+
23
+ function wait(ms: number): Promise<void> {
24
+ return new Promise((resolve) => setTimeout(resolve, ms));
25
+ }
26
+
27
+ function getMimeType(filePath: string): string {
28
+ const ext = path.extname(filePath).toLowerCase();
29
+ const mimeTypes: Record<string, string> = {
30
+ ".jpg": "image/jpeg",
31
+ ".jpeg": "image/jpeg",
32
+ ".png": "image/png",
33
+ ".webp": "image/webp",
34
+ ".gif": "image/gif",
35
+ ".avif": "image/avif",
36
+ ".heif": "image/heif",
37
+ };
38
+
39
+ return mimeTypes[ext] || "application/octet-stream";
40
+ }
41
+
42
+ function extractRequestIdFromStatusUrl(statusUrl: string): string {
43
+ const parts = statusUrl.split("/").filter(Boolean);
44
+ const statusIndex = parts.lastIndexOf("status");
45
+
46
+ const fromStatus = statusIndex > 0 ? parts[statusIndex - 1] : undefined;
47
+ if (fromStatus) {
48
+ return fromStatus;
49
+ }
50
+
51
+ const lastPart = parts[parts.length - 1];
52
+ return lastPart ?? "";
53
+ }
54
+
55
+ async function uploadImageToFalCdn(
56
+ imagePath: string,
57
+ apiKey: string,
58
+ ): Promise<string> {
59
+ if (imagePath.startsWith("http://") || imagePath.startsWith("https://")) {
60
+ return imagePath;
61
+ }
62
+
63
+ const storage = getStorage();
64
+ if (!(await storage.exists(imagePath))) {
65
+ throw new Error(`File not found: ${imagePath}`);
66
+ }
67
+
68
+ const fileBuffer = Buffer.from(await storage.readFile(imagePath));
69
+ const fileName = path.basename(imagePath);
70
+ const contentType = getMimeType(fileName);
71
+
72
+ const initiateResponse = await fetch(
73
+ `${FAL_REST_URL}/storage/upload/initiate?storage_type=fal-cdn-v3`,
74
+ {
75
+ method: "POST",
76
+ headers: {
77
+ Authorization: `Key ${apiKey}`,
78
+ "Content-Type": "application/json",
79
+ },
80
+ body: JSON.stringify({
81
+ content_type: contentType,
82
+ file_name: fileName,
83
+ }),
84
+ signal: AbortSignal.timeout(AUTHENTICATED_TIMEOUT),
85
+ },
86
+ );
87
+
88
+ if (!initiateResponse.ok) {
89
+ const errorText = await initiateResponse.text();
90
+ throw new Error(
91
+ `Failed to initiate FAL upload [${initiateResponse.status}]: ${errorText}`,
92
+ );
93
+ }
94
+
95
+ const initiateData = (await initiateResponse.json()) as {
96
+ file_url?: string;
97
+ upload_url?: string;
98
+ };
99
+
100
+ if (!initiateData.file_url || !initiateData.upload_url) {
101
+ throw new Error("FAL upload initiation did not return file_url/upload_url");
102
+ }
103
+
104
+ const uploadResponse = await fetch(initiateData.upload_url, {
105
+ method: "PUT",
106
+ headers: {
107
+ "Content-Type": contentType,
108
+ },
109
+ body: fileBuffer,
110
+ signal: AbortSignal.timeout(AUTHENTICATED_TIMEOUT),
111
+ });
112
+
113
+ if (!uploadResponse.ok) {
114
+ const errorText = await uploadResponse.text();
115
+ throw new Error(
116
+ `Failed to upload file to FAL CDN [${uploadResponse.status}]: ${errorText}`,
117
+ );
118
+ }
119
+
120
+ return initiateData.file_url;
121
+ }
122
+
123
+ async function submitQwenMultiAngleRequest(
124
+ payload: Record<string, any>,
125
+ apiKey: string,
126
+ ): Promise<{
127
+ requestId: string;
128
+ statusUrl: string;
129
+ responseUrl: string;
130
+ }> {
131
+ const submitResponse = await fetch(`${FAL_QUEUE_URL}/${QWEN_MULTI_ANGLE_MODEL_ID}`, {
132
+ method: "POST",
133
+ headers: {
134
+ Authorization: `Key ${apiKey}`,
135
+ "Content-Type": "application/json",
136
+ },
137
+ body: JSON.stringify(payload),
138
+ signal: AbortSignal.timeout(AUTHENTICATED_TIMEOUT),
139
+ });
140
+
141
+ if (!submitResponse.ok) {
142
+ const errorText = await submitResponse.text();
143
+ throw new Error(`FAL submit error [${submitResponse.status}]: ${errorText}`);
144
+ }
145
+
146
+ const submitJson = (await submitResponse.json()) as {
147
+ request_id?: string;
148
+ status_url?: string;
149
+ response_url?: string;
150
+ };
151
+
152
+ const requestId =
153
+ submitJson.request_id ||
154
+ (submitJson.status_url
155
+ ? extractRequestIdFromStatusUrl(submitJson.status_url)
156
+ : "");
157
+
158
+ if (!requestId) {
159
+ throw new Error("Could not extract FAL request ID from submit response");
160
+ }
161
+
162
+ return {
163
+ requestId,
164
+ statusUrl:
165
+ submitJson.status_url ||
166
+ `${FAL_QUEUE_URL}/${QWEN_MULTI_ANGLE_MODEL_ID}/requests/${requestId}/status`,
167
+ responseUrl:
168
+ submitJson.response_url ||
169
+ `${FAL_QUEUE_URL}/${QWEN_MULTI_ANGLE_MODEL_ID}/requests/${requestId}`,
170
+ };
171
+ }
172
+
173
+ async function waitForQwenMultiAngleResult(
174
+ statusUrl: string,
175
+ responseUrl: string,
176
+ apiKey: string,
177
+ ): Promise<any> {
178
+ const startTime = Date.now();
179
+ let currentStatusUrl = statusUrl;
180
+ let currentResponseUrl = responseUrl;
181
+
182
+ while (Date.now() - startTime < MAX_POLL_TIME_MS) {
183
+ const statusResponse = await fetch(currentStatusUrl, {
184
+ method: "GET",
185
+ headers: {
186
+ Authorization: `Key ${apiKey}`,
187
+ },
188
+ signal: AbortSignal.timeout(AUTHENTICATED_TIMEOUT),
189
+ });
190
+
191
+ if (!statusResponse.ok) {
192
+ const errorText = await statusResponse.text();
193
+ if (statusResponse.status === 404) {
194
+ await wait(POLL_INTERVAL_MS);
195
+ continue;
196
+ }
197
+ throw new Error(
198
+ `FAL status error [${statusResponse.status}]: ${errorText}`,
199
+ );
200
+ }
201
+
202
+ const statusJson = (await statusResponse.json()) as {
203
+ status?: string;
204
+ status_url?: string;
205
+ response_url?: string;
206
+ error?: unknown;
207
+ };
208
+
209
+ if (statusJson.status_url) currentStatusUrl = statusJson.status_url;
210
+ if (statusJson.response_url) currentResponseUrl = statusJson.response_url;
211
+
212
+ if (statusJson.status === "COMPLETED") {
213
+ const resultResponse = await fetch(currentResponseUrl, {
214
+ method: "GET",
215
+ headers: {
216
+ Authorization: `Key ${apiKey}`,
217
+ },
218
+ signal: AbortSignal.timeout(AUTHENTICATED_TIMEOUT),
219
+ });
220
+
221
+ if (!resultResponse.ok) {
222
+ const errorText = await resultResponse.text();
223
+ throw new Error(
224
+ `FAL result error [${resultResponse.status}]: ${errorText}`,
225
+ );
226
+ }
227
+
228
+ return resultResponse.json();
229
+ }
230
+
231
+ if (statusJson.status === "FAILED") {
232
+ throw new Error(
233
+ `FAL generation failed: ${JSON.stringify(statusJson.error || statusJson)}`,
234
+ );
235
+ }
236
+
237
+ await wait(POLL_INTERVAL_MS);
238
+ }
239
+
240
+ throw new Error(
241
+ "FAL generation timed out. Please reduce batch size or retry with shorter prompts.",
242
+ );
243
+ }
35
244
 
36
245
  export const mixioMultiAngleBatch = {
37
246
  name: "mixio_multi_angle_batch",
38
247
  description:
39
- "Precise 3D-like camera control for image editing using Qwen-Image-Edit-2511. " +
40
- "Generates multiple images in a single batch process and returns a ZIP file. " +
41
- "\n\n**Prompt Format**: Each batch prompt optionally takes [azimuth] [elevation] [distance] [instruction]",
248
+ "多角度镜头批量编辑(Qwen-Image-Edit-2509 + dx8152/Qwen-Edit-2509-Multiple-angles LoRA)。" +
249
+ "\n\n每条提示词请直接用中文描述镜头意图,不再使用 azimuth/elevation/zoom 枚举。" +
250
+ "\n\n**每条 Prompt 推荐格式(中文)**:" +
251
+ "\n1) 镜头动作(移动/旋转/俯视/特写等)" +
252
+ "\n2) 保持不变的主体信息(人物、服装、场景)" +
253
+ "\n3) 构图约束(如居中、近景、广角)" +
254
+ "\n\n**示例 Prompt**:" +
255
+ "\n- 将镜头向左旋转45度,保持人物身份与服装一致。" +
256
+ "\n- 将镜头转为俯视,主体保持居中,背景光照风格不变。" +
257
+ "\n- 将镜头转为特写并轻微前移,突出面部细节。",
42
258
  parameters: z.object({
43
259
  image_path: z
44
260
  .string()
45
261
  .describe("Absolute local path or URL to the base image."),
46
262
  batch_prompts: z
47
263
  .array(
48
- z.object({
49
- azimuth: azimuthEnum,
50
- elevation: elevationEnum,
51
- distance: distanceEnum,
52
- }),
264
+ z.string().describe("Chinese camera-intent prompt for one output image."),
53
265
  )
266
+ .min(1)
267
+ .max(4)
54
268
  .describe(
55
- "Array of configurations for batch processing. Returns a .zip containing all generated images.",
269
+ "Array of Chinese prompts. The tool returns one image per prompt and saves them with indexed filenames.",
56
270
  ),
57
271
  negative_prompt: z
58
272
  .string()
59
273
  .optional()
60
274
  .default("")
61
275
  .describe("Elements to avoid."),
276
+ lora_scale: z
277
+ .number()
278
+ .min(0)
279
+ .max(4)
280
+ .optional()
281
+ .default(DEFAULT_LORA_SCALE)
282
+ .describe("LoRA weight scale for the multi-angle adapter (0 to 4)."),
62
283
  seed: z.number().optional().default(42).describe("Random seed."),
63
- output_path: z.string().describe("Local path to save the resulting ZIP archive."),
284
+ output_path: z
285
+ .string()
286
+ .describe(
287
+ "Local path for output images. The first image uses this path, additional ones use _1, _2, ... suffixes.",
288
+ ),
64
289
  }),
65
290
  timeoutMs: 180000,
66
291
  async execute(
67
292
  args: {
68
293
  image_path: string;
69
- batch_prompts: Array<{
70
- azimuth: string;
71
- elevation: string;
72
- distance: string;
73
- }>;
294
+ batch_prompts: string[];
74
295
  negative_prompt?: string;
296
+ lora_scale?: number;
75
297
  seed?: number;
76
298
  output_path: string;
77
299
  },
@@ -83,48 +305,121 @@ export const mixioMultiAngleBatch = {
83
305
  throw new Error("batch_prompts array cannot be empty.");
84
306
  }
85
307
 
86
- const formData = new FormData();
87
- await appendImageToFormData(formData, "image", args.image_path);
308
+ const normalizedPrompts = args.batch_prompts
309
+ .map((prompt) => prompt.trim())
310
+ .filter((prompt) => prompt.length > 0);
88
311
 
89
- for (const bp of args.batch_prompts) {
90
- const parts = [bp.azimuth, bp.elevation, bp.distance];
91
- formData.append("prompt", parts.join(" ").trim());
312
+ if (normalizedPrompts.length === 0) {
313
+ throw new Error("At least one non-empty prompt is required.");
92
314
  }
93
315
 
94
- formData.append("lora_type", "multi_angle");
95
- formData.append("negative_prompt", args.negative_prompt || "");
96
- formData.append("seed", (args.seed ?? 42).toString());
97
-
98
316
  if (context?.streamContent) {
99
317
  await context.streamContent({
100
318
  type: "text" as const,
101
- text: `[Mixio] Submitting Multi-Angle Batch edit request for ${args.batch_prompts.length} prompts...`,
319
+ text: `[Mixio] Preparing image for FAL multi-angle batch (${normalizedPrompts.length} prompts)...`,
102
320
  });
103
321
  }
104
322
 
105
- const response = await fetch(MIXIO_IMAGE_EDIT_URL, {
106
- method: "POST",
107
- body: formData,
108
- });
323
+ const apiKey = getApiKey();
324
+ const imageUrl = await uploadImageToFalCdn(args.image_path, apiKey);
109
325
 
110
- if (!response.ok) {
111
- const errorText = await response.text();
112
- throw new Error(`Mixio API error [${response.status}]: ${errorText}`);
113
- }
326
+ const savedImages: Array<{
327
+ path: string;
328
+ url: string;
329
+ index: number;
330
+ request_id: string;
331
+ }> = [];
332
+
333
+ for (let index = 0; index < normalizedPrompts.length; index++) {
334
+ const prompt = normalizedPrompts[index];
335
+
336
+ if (context?.streamContent) {
337
+ await context.streamContent({
338
+ type: "text" as const,
339
+ text: `[Mixio] Running batch item ${index + 1}/${normalizedPrompts.length}...`,
340
+ });
341
+ }
342
+
343
+ const payload = {
344
+ prompt,
345
+ image_urls: [imageUrl],
346
+ negative_prompt: args.negative_prompt || " ",
347
+ seed: (args.seed ?? 42) + index,
348
+ output_format: "png",
349
+ loras: [
350
+ {
351
+ path: QWEN_MULTI_ANGLE_LORA_PATH,
352
+ scale: args.lora_scale ?? DEFAULT_LORA_SCALE,
353
+ },
354
+ ],
355
+ };
356
+
357
+ const { requestId, statusUrl, responseUrl } =
358
+ await submitQwenMultiAngleRequest(payload, apiKey);
114
359
 
115
- const result = await handleMixioResponse(response, args.output_path);
360
+ const finalResult = await waitForQwenMultiAngleResult(
361
+ statusUrl,
362
+ responseUrl,
363
+ apiKey,
364
+ );
365
+
366
+ const resultImageUrl =
367
+ finalResult?.images?.[0]?.url ||
368
+ finalResult?.image?.url ||
369
+ finalResult?.data?.url ||
370
+ finalResult?.url;
371
+
372
+ if (!resultImageUrl) {
373
+ throw new Error(
374
+ `Batch item ${index + 1} has no output image URL: ${JSON.stringify(finalResult)}`,
375
+ );
376
+ }
377
+
378
+ const ext = path.extname(args.output_path);
379
+ const base = ext
380
+ ? args.output_path.slice(0, -ext.length)
381
+ : args.output_path;
382
+ const finalOutputPath =
383
+ index === 0
384
+ ? args.output_path
385
+ : `${base}_${index}${ext || ".png"}`;
386
+
387
+ const saved = await saveMixioImage(
388
+ resultImageUrl,
389
+ finalOutputPath,
390
+ `mixio_multi_angle_batch_${index + 1}.png`,
391
+ );
392
+
393
+ savedImages.push({
394
+ path: saved.path,
395
+ url: saved.url,
396
+ index,
397
+ request_id: requestId,
398
+ });
399
+ }
116
400
 
117
401
  return JSON.stringify({
118
402
  status: "COMPLETED",
119
403
  message: "Multi-angle batch edit completed.",
120
- ...result,
404
+ images: savedImages,
405
+ total: savedImages.length,
121
406
  seed: args.seed ?? 42,
407
+ model_id: QWEN_MULTI_ANGLE_MODEL_ID,
408
+ lora: {
409
+ path: QWEN_MULTI_ANGLE_LORA_PATH,
410
+ scale: args.lora_scale ?? DEFAULT_LORA_SCALE,
411
+ },
122
412
  });
123
413
  },
124
414
  "mixioMultiAngleBatch",
125
415
  {
126
416
  toolName: "mixio_multi_angle_batch",
127
- toolArgs: extractPrimitiveArgs(args),
417
+ toolArgs: {
418
+ ...extractPrimitiveArgs(args),
419
+ batch_count: args.batch_prompts
420
+ .map((prompt) => prompt.trim())
421
+ .filter((prompt) => prompt.length > 0).length,
422
+ },
128
423
  requestId: (context as any)?.requestId,
129
424
  },
130
425
  );
@@ -1,63 +1,305 @@
1
1
  import { z } from "zod";
2
+ import * as path from "path";
2
3
  import {
3
4
  safeToolExecute,
4
5
  extractPrimitiveArgs,
5
6
  } from "../../utils/tool-wrapper";
6
7
  import {
7
- appendImageToFormData,
8
- MIXIO_IMAGE_EDIT_URL,
9
- handleMixioResponse,
10
- } from "./common";
11
-
12
- const azimuthEnum = z.enum([
13
- "front view",
14
- "front-right quarter view",
15
- "right side view",
16
- "back-right quarter view",
17
- "back view",
18
- "back-left quarter view",
19
- "left side view",
20
- "front-left quarter view",
21
- ]);
22
-
23
- const elevationEnum = z.enum([
24
- "low-angle shot",
25
- "eye-level shot",
26
- "elevated shot",
27
- "high-angle shot",
28
- ]);
29
-
30
- const distanceEnum = z.enum([
31
- "close-up",
32
- "medium shot",
33
- "wide shot",
34
- ]);
8
+ FAL_QUEUE_URL,
9
+ FAL_REST_URL,
10
+ AUTHENTICATED_TIMEOUT,
11
+ getApiKey,
12
+ } from "../fal/config";
13
+ import { getStorage } from "../../storage";
14
+ import { saveMixioImage } from "./common";
15
+
16
+ const QWEN_MULTI_ANGLE_MODEL_ID = "fal-ai/qwen-image-edit-2509-lora";
17
+ const QWEN_MULTI_ANGLE_LORA_PATH =
18
+ "https://huggingface.co/dx8152/Qwen-Edit-2509-Multiple-angles/resolve/main/%E9%95%9C%E5%A4%B4%E8%BD%AC%E6%8D%A2.safetensors";
19
+ const DEFAULT_LORA_SCALE = 1.0;
20
+ const POLL_INTERVAL_MS = 3000;
21
+ const MAX_POLL_TIME_MS = 90000;
22
+
23
+ function wait(ms: number): Promise<void> {
24
+ return new Promise((resolve) => setTimeout(resolve, ms));
25
+ }
26
+
27
+ function getMimeType(filePath: string): string {
28
+ const ext = path.extname(filePath).toLowerCase();
29
+ const mimeTypes: Record<string, string> = {
30
+ ".jpg": "image/jpeg",
31
+ ".jpeg": "image/jpeg",
32
+ ".png": "image/png",
33
+ ".webp": "image/webp",
34
+ ".gif": "image/gif",
35
+ ".avif": "image/avif",
36
+ ".heif": "image/heif",
37
+ };
38
+
39
+ return mimeTypes[ext] || "application/octet-stream";
40
+ }
41
+
42
+ function extractRequestIdFromStatusUrl(statusUrl: string): string {
43
+ const parts = statusUrl.split("/").filter(Boolean);
44
+ const statusIndex = parts.lastIndexOf("status");
45
+
46
+ const fromStatus = statusIndex > 0 ? parts[statusIndex - 1] : undefined;
47
+ if (fromStatus) {
48
+ return fromStatus;
49
+ }
50
+
51
+ const lastPart = parts[parts.length - 1];
52
+ return lastPart ?? "";
53
+ }
54
+
55
+ async function uploadImageToFalCdn(
56
+ imagePath: string,
57
+ apiKey: string,
58
+ ): Promise<string> {
59
+ if (imagePath.startsWith("http://") || imagePath.startsWith("https://")) {
60
+ return imagePath;
61
+ }
62
+
63
+ const storage = getStorage();
64
+ if (!(await storage.exists(imagePath))) {
65
+ throw new Error(`File not found: ${imagePath}`);
66
+ }
67
+
68
+ const fileBuffer = Buffer.from(await storage.readFile(imagePath));
69
+ const fileName = path.basename(imagePath);
70
+ const contentType = getMimeType(fileName);
71
+
72
+ const initiateResponse = await fetch(
73
+ `${FAL_REST_URL}/storage/upload/initiate?storage_type=fal-cdn-v3`,
74
+ {
75
+ method: "POST",
76
+ headers: {
77
+ Authorization: `Key ${apiKey}`,
78
+ "Content-Type": "application/json",
79
+ },
80
+ body: JSON.stringify({
81
+ content_type: contentType,
82
+ file_name: fileName,
83
+ }),
84
+ signal: AbortSignal.timeout(AUTHENTICATED_TIMEOUT),
85
+ },
86
+ );
87
+
88
+ if (!initiateResponse.ok) {
89
+ const errorText = await initiateResponse.text();
90
+ throw new Error(
91
+ `Failed to initiate FAL upload [${initiateResponse.status}]: ${errorText}`,
92
+ );
93
+ }
94
+
95
+ const initiateData = (await initiateResponse.json()) as {
96
+ file_url?: string;
97
+ upload_url?: string;
98
+ };
99
+
100
+ if (!initiateData.file_url || !initiateData.upload_url) {
101
+ throw new Error("FAL upload initiation did not return file_url/upload_url");
102
+ }
103
+
104
+ const uploadResponse = await fetch(initiateData.upload_url, {
105
+ method: "PUT",
106
+ headers: {
107
+ "Content-Type": contentType,
108
+ },
109
+ body: fileBuffer,
110
+ signal: AbortSignal.timeout(AUTHENTICATED_TIMEOUT),
111
+ });
112
+
113
+ if (!uploadResponse.ok) {
114
+ const errorText = await uploadResponse.text();
115
+ throw new Error(
116
+ `Failed to upload file to FAL CDN [${uploadResponse.status}]: ${errorText}`,
117
+ );
118
+ }
119
+
120
+ return initiateData.file_url;
121
+ }
122
+
123
+ async function submitQwenMultiAngleRequest(
124
+ payload: Record<string, any>,
125
+ apiKey: string,
126
+ ): Promise<{
127
+ requestId: string;
128
+ statusUrl: string;
129
+ responseUrl: string;
130
+ }> {
131
+ const submitResponse = await fetch(`${FAL_QUEUE_URL}/${QWEN_MULTI_ANGLE_MODEL_ID}`, {
132
+ method: "POST",
133
+ headers: {
134
+ Authorization: `Key ${apiKey}`,
135
+ "Content-Type": "application/json",
136
+ },
137
+ body: JSON.stringify(payload),
138
+ signal: AbortSignal.timeout(AUTHENTICATED_TIMEOUT),
139
+ });
140
+
141
+ if (!submitResponse.ok) {
142
+ const errorText = await submitResponse.text();
143
+ throw new Error(`FAL submit error [${submitResponse.status}]: ${errorText}`);
144
+ }
145
+
146
+ const submitJson = (await submitResponse.json()) as {
147
+ request_id?: string;
148
+ status_url?: string;
149
+ response_url?: string;
150
+ };
151
+
152
+ const requestId =
153
+ submitJson.request_id ||
154
+ (submitJson.status_url
155
+ ? extractRequestIdFromStatusUrl(submitJson.status_url)
156
+ : "");
157
+
158
+ if (!requestId) {
159
+ throw new Error("Could not extract FAL request ID from submit response");
160
+ }
161
+
162
+ return {
163
+ requestId,
164
+ statusUrl:
165
+ submitJson.status_url ||
166
+ `${FAL_QUEUE_URL}/${QWEN_MULTI_ANGLE_MODEL_ID}/requests/${requestId}/status`,
167
+ responseUrl:
168
+ submitJson.response_url ||
169
+ `${FAL_QUEUE_URL}/${QWEN_MULTI_ANGLE_MODEL_ID}/requests/${requestId}`,
170
+ };
171
+ }
172
+
173
+ async function waitForQwenMultiAngleResult(
174
+ statusUrl: string,
175
+ responseUrl: string,
176
+ apiKey: string,
177
+ context?: any,
178
+ ): Promise<any> {
179
+ const startTime = Date.now();
180
+ let currentStatusUrl = statusUrl;
181
+ let currentResponseUrl = responseUrl;
182
+ let pollCount = 0;
183
+
184
+ while (Date.now() - startTime < MAX_POLL_TIME_MS) {
185
+ pollCount++;
186
+
187
+ const statusResponse = await fetch(currentStatusUrl, {
188
+ method: "GET",
189
+ headers: {
190
+ Authorization: `Key ${apiKey}`,
191
+ },
192
+ signal: AbortSignal.timeout(AUTHENTICATED_TIMEOUT),
193
+ });
194
+
195
+ if (!statusResponse.ok) {
196
+ const errorText = await statusResponse.text();
197
+ if (statusResponse.status === 404) {
198
+ await wait(POLL_INTERVAL_MS);
199
+ continue;
200
+ }
201
+ throw new Error(
202
+ `FAL status error [${statusResponse.status}]: ${errorText}`,
203
+ );
204
+ }
205
+
206
+ const statusJson = (await statusResponse.json()) as {
207
+ status?: string;
208
+ status_url?: string;
209
+ response_url?: string;
210
+ error?: unknown;
211
+ };
212
+
213
+ if (statusJson.status_url) currentStatusUrl = statusJson.status_url;
214
+ if (statusJson.response_url) currentResponseUrl = statusJson.response_url;
215
+
216
+ if (context?.reportProgress) {
217
+ const elapsed = Date.now() - startTime;
218
+ const progress = Math.min(
219
+ Math.round((elapsed / MAX_POLL_TIME_MS) * 100),
220
+ 99,
221
+ );
222
+ await context.reportProgress({ progress, total: 100 });
223
+ }
224
+
225
+ if (context?.streamContent && pollCount % 5 === 0) {
226
+ await context.streamContent({
227
+ type: "text" as const,
228
+ text: `[Mixio] FAL request still running... (${Math.round((Date.now() - startTime) / 1000)}s elapsed, status: ${statusJson.status ?? "UNKNOWN"})`,
229
+ });
230
+ }
231
+
232
+ if (statusJson.status === "COMPLETED") {
233
+ const resultResponse = await fetch(currentResponseUrl, {
234
+ method: "GET",
235
+ headers: {
236
+ Authorization: `Key ${apiKey}`,
237
+ },
238
+ signal: AbortSignal.timeout(AUTHENTICATED_TIMEOUT),
239
+ });
240
+
241
+ if (!resultResponse.ok) {
242
+ const errorText = await resultResponse.text();
243
+ throw new Error(
244
+ `FAL result error [${resultResponse.status}]: ${errorText}`,
245
+ );
246
+ }
247
+
248
+ if (context?.reportProgress) {
249
+ await context.reportProgress({ progress: 100, total: 100 });
250
+ }
251
+
252
+ return resultResponse.json();
253
+ }
254
+
255
+ if (statusJson.status === "FAILED") {
256
+ throw new Error(
257
+ `FAL generation failed: ${JSON.stringify(statusJson.error || statusJson)}`,
258
+ );
259
+ }
260
+
261
+ await wait(POLL_INTERVAL_MS);
262
+ }
263
+
264
+ throw new Error(
265
+ "FAL generation timed out. Please retry the request with a shorter prompt.",
266
+ );
267
+ }
35
268
 
36
269
  export const mixioMultiAngle = {
37
270
  name: "mixio_multi_angle",
38
271
  description:
39
- "Precise 3D-like camera control for image editing using Qwen-Image-Edit-2511. " +
40
- "Use this for steering azimuth, elevation, and distance of the camera relative to the subject." +
41
- "\n\n**Prompt Format**: [azimuth] [elevation] [distance] [instruction]" +
42
- "\n- **Azimuth**: front view, front-right, right, back-right, back, back-left, left, front-left." +
43
- "\n- **Elevation**: high-angle shot, eye-level shot, low-angle shot, bird-view, worm-view." +
44
- "\n- **Distance**: extreme wide shot, wide shot, medium shot, close up, macro shot." +
45
- "\n\nExample: 'front view eye-level shot medium shot turn the car red'",
272
+ "多角度镜头控制(Qwen-Image-Edit-2509 + dx8152/Qwen-Edit-2509-Multiple-angles LoRA)。" +
273
+ "\n\n请直接用中文描述用户意图,不再使用 azimuth/elevation/zoom 枚举。" +
274
+ "\n\n**推荐提示词格式(中文)**:" +
275
+ "\n1) 镜头动作(移动/旋转/俯视/特写等)" +
276
+ "\n2) 保持不变的主体信息(人物、服装、场景)" +
277
+ "\n3) 构图约束(如居中、近景、广角)" +
278
+ "\n\n**示例 Prompt**:" +
279
+ "\n- 将镜头向左旋转45度,保持人物身份、发型和服装不变。" +
280
+ "\n- 将镜头转为俯视角度,主体保持画面中心,场景与光照风格不变。" +
281
+ "\n- 将镜头转为特写镜头并轻微前移,突出面部细节,整体色调保持一致。",
46
282
  parameters: z.object({
47
283
  image_path: z
48
284
  .string()
49
285
  .describe("Absolute local path or URL to the base image."),
50
- azimuth: azimuthEnum
51
- .describe("Horizontal angle (e.g., 'front view', 'right side view')."),
52
- elevation: elevationEnum
53
- .describe("Vertical angle (e.g., 'eye-level shot', 'high-angle shot')."),
54
- distance: distanceEnum
55
- .describe("Camera distance or zoom (e.g., 'medium shot', 'wide shot')."),
286
+ prompt: z
287
+ .string()
288
+ .describe(
289
+ "请用中文描述镜头变化与编辑意图。例如:将镜头向左旋转45度,保持人物和服装不变。",
290
+ ),
56
291
  negative_prompt: z
57
292
  .string()
58
293
  .optional()
59
294
  .default("")
60
295
  .describe("Elements to avoid."),
296
+ lora_scale: z
297
+ .number()
298
+ .min(0)
299
+ .max(4)
300
+ .optional()
301
+ .default(DEFAULT_LORA_SCALE)
302
+ .describe("LoRA weight scale for the multi-angle adapter (0 to 4)."),
61
303
  seed: z.number().optional().default(42).describe("Random seed."),
62
304
  output_path: z.string().describe("Local path to save the resulting PNG."),
63
305
  }),
@@ -65,10 +307,9 @@ export const mixioMultiAngle = {
65
307
  async execute(
66
308
  args: {
67
309
  image_path: string;
68
- azimuth: string;
69
- elevation: string;
70
- distance: string;
310
+ prompt: string;
71
311
  negative_prompt?: string;
312
+ lora_scale?: number;
72
313
  seed?: number;
73
314
  output_path: string;
74
315
  },
@@ -76,41 +317,77 @@ export const mixioMultiAngle = {
76
317
  ) {
77
318
  return safeToolExecute(
78
319
  async () => {
79
- const formData = new FormData();
80
- await appendImageToFormData(formData, "image", args.image_path);
320
+ const apiKey = getApiKey();
321
+
322
+ if (context?.streamContent) {
323
+ await context.streamContent({
324
+ type: "text" as const,
325
+ text: "[Mixio] Preparing image for FAL Qwen multi-angle edit...",
326
+ });
327
+ }
81
328
 
82
- const promptParts = [args.azimuth, args.elevation, args.distance];
83
- const finalPrompt = promptParts.join(" ").trim();
329
+ const imageUrl = await uploadImageToFalCdn(args.image_path, apiKey);
84
330
 
85
- formData.append("prompt", finalPrompt);
86
- formData.append("lora_type", "multi_angle");
87
- formData.append("negative_prompt", args.negative_prompt || "");
88
- formData.append("seed", (args.seed ?? 42).toString());
331
+ const payload = {
332
+ prompt: args.prompt.trim(),
333
+ image_urls: [imageUrl],
334
+ negative_prompt: args.negative_prompt || " ",
335
+ seed: args.seed ?? 42,
336
+ output_format: "png",
337
+ loras: [
338
+ {
339
+ path: QWEN_MULTI_ANGLE_LORA_PATH,
340
+ scale: args.lora_scale ?? DEFAULT_LORA_SCALE,
341
+ },
342
+ ],
343
+ };
89
344
 
90
345
  if (context?.streamContent) {
91
346
  await context.streamContent({
92
347
  type: "text" as const,
93
- text: `[Mixio] Submitting Multi-Angle edit request...`,
348
+ text: "[Mixio] Submitting Qwen multi-angle request to FAL...",
94
349
  });
95
350
  }
96
351
 
97
- const response = await fetch(MIXIO_IMAGE_EDIT_URL, {
98
- method: "POST",
99
- body: formData,
100
- });
352
+ const { requestId, statusUrl, responseUrl } =
353
+ await submitQwenMultiAngleRequest(payload, apiKey);
354
+
355
+ const finalResult = await waitForQwenMultiAngleResult(
356
+ statusUrl,
357
+ responseUrl,
358
+ apiKey,
359
+ context,
360
+ );
361
+
362
+ const resultImageUrl =
363
+ finalResult?.images?.[0]?.url ||
364
+ finalResult?.image?.url ||
365
+ finalResult?.data?.url ||
366
+ finalResult?.url;
101
367
 
102
- if (!response.ok) {
103
- const errorText = await response.text();
104
- throw new Error(`Mixio API error [${response.status}]: ${errorText}`);
368
+ if (!resultImageUrl) {
369
+ throw new Error(
370
+ `FAL response did not contain an output image URL: ${JSON.stringify(finalResult)}`,
371
+ );
105
372
  }
106
373
 
107
- const result = await handleMixioResponse(response, args.output_path);
374
+ const saved = await saveMixioImage(
375
+ resultImageUrl,
376
+ args.output_path,
377
+ "mixio_multi_angle.png",
378
+ );
108
379
 
109
380
  return JSON.stringify({
110
381
  status: "COMPLETED",
111
382
  message: "Multi-angle edit completed.",
112
- ...result,
383
+ ...saved,
113
384
  seed: args.seed ?? 42,
385
+ request_id: requestId,
386
+ model_id: QWEN_MULTI_ANGLE_MODEL_ID,
387
+ lora: {
388
+ path: QWEN_MULTI_ANGLE_LORA_PATH,
389
+ scale: args.lora_scale ?? DEFAULT_LORA_SCALE,
390
+ },
114
391
  });
115
392
  },
116
393
  "mixioMultiAngle",