@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 +34 -0
- package/package.json +1 -1
- package/src/index.ts +2 -2
- package/src/tools/mixio/multi-angle-batch.ts +359 -64
- package/src/tools/mixio/multi-angle.ts +339 -62
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
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
"
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"\n\n
|
|
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.
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
87
|
-
|
|
308
|
+
const normalizedPrompts = args.batch_prompts
|
|
309
|
+
.map((prompt) => prompt.trim())
|
|
310
|
+
.filter((prompt) => prompt.length > 0);
|
|
88
311
|
|
|
89
|
-
|
|
90
|
-
|
|
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]
|
|
319
|
+
text: `[Mixio] Preparing image for FAL multi-angle batch (${normalizedPrompts.length} prompts)...`,
|
|
102
320
|
});
|
|
103
321
|
}
|
|
104
322
|
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
body: formData,
|
|
108
|
-
});
|
|
323
|
+
const apiKey = getApiKey();
|
|
324
|
+
const imageUrl = await uploadImageToFalCdn(args.image_path, apiKey);
|
|
109
325
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
"
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"\n\n
|
|
42
|
-
"\
|
|
43
|
-
"\
|
|
44
|
-
"\
|
|
45
|
-
"\n\
|
|
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
|
-
|
|
51
|
-
.
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
|
80
|
-
|
|
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
|
|
83
|
-
const finalPrompt = promptParts.join(" ").trim();
|
|
329
|
+
const imageUrl = await uploadImageToFalCdn(args.image_path, apiKey);
|
|
84
330
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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:
|
|
348
|
+
text: "[Mixio] Submitting Qwen multi-angle request to FAL...",
|
|
94
349
|
});
|
|
95
350
|
}
|
|
96
351
|
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
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 (!
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
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
|
-
...
|
|
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",
|