@yswgaicx/yswg-img-cli 0.1.0 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +80 -6
- package/bin/yswg-img.js +147 -15
- package/package.json +4 -1
- package/src/api.js +23 -0
- package/src/generate.js +197 -4
- package/src/image-compress.js +89 -0
package/README.md
CHANGED
|
@@ -32,13 +32,33 @@ The token is saved to `~/.yswg-img-cli/config.json`.
|
|
|
32
32
|
|
|
33
33
|
## Generate
|
|
34
34
|
|
|
35
|
+
Supported Monkey Genius options mirrored by the CLI:
|
|
36
|
+
|
|
37
|
+
- Model/group selection: `--group-id`, `--model`
|
|
38
|
+
- Aspect ratio: `--ratio 1:1|3:2|2:3|4:3|3:4|16:9|9:16|5:4|4:5|21:9`
|
|
39
|
+
- Single/double image output: `--count 1` or `--count 2` when the selected model supports it
|
|
40
|
+
- Reference images: `--ref ./a.png,https://example.com/b.jpg`
|
|
41
|
+
- Night/off-peak queue submission: `--night`
|
|
42
|
+
- AI tool/template payloads: `--template-code`, `--template-vars-json`
|
|
43
|
+
- Generation history lookup and timeout recovery: `tasks search`, `tasks recover`
|
|
44
|
+
- History tabs/search/delete/night-cancel: `tasks search --tab ...`, `tasks delete`, `tasks cancel-night`
|
|
45
|
+
|
|
46
|
+
Local reference images are compressed before OSS upload by default, matching the
|
|
47
|
+
Monkey Genius frontend behavior:
|
|
48
|
+
|
|
49
|
+
- Images below 50 KB are kept as-is.
|
|
50
|
+
- Images are resized to fit within 1280 x 1280.
|
|
51
|
+
- Opaque images are converted to JPEG at quality 0.82, then stepped down toward
|
|
52
|
+
0.5 until they are near 300 KB.
|
|
53
|
+
- Images with transparency are kept as PNG.
|
|
54
|
+
|
|
55
|
+
Use `--no-compress` only when the original file must be uploaded unchanged.
|
|
56
|
+
|
|
35
57
|
Dry-run the backend payload:
|
|
36
58
|
|
|
37
59
|
```bash
|
|
38
60
|
yswg-img generate \
|
|
39
61
|
--prompt "一张白底产品图,柔和自然光,高级电商摄影" \
|
|
40
|
-
--group-id 5 \
|
|
41
|
-
--model gemini-2.5-flash-image \
|
|
42
62
|
--ratio 1:1 \
|
|
43
63
|
--count 1 \
|
|
44
64
|
--dry-run \
|
|
@@ -50,8 +70,6 @@ Submit and wait for output images:
|
|
|
50
70
|
```bash
|
|
51
71
|
yswg-img generate \
|
|
52
72
|
--prompt "一张白底产品图,柔和自然光,高级电商摄影" \
|
|
53
|
-
--group-id 5 \
|
|
54
|
-
--model gemini-2.5-flash-image \
|
|
55
73
|
--ratio 1:1 \
|
|
56
74
|
--count 1 \
|
|
57
75
|
--out outputs \
|
|
@@ -63,16 +81,70 @@ Use reference images:
|
|
|
63
81
|
```bash
|
|
64
82
|
yswg-img generate \
|
|
65
83
|
--prompt "参考产品主体,生成室内生活方式场景图" \
|
|
66
|
-
--group-id 5 \
|
|
67
84
|
--ref ./product.png,https://example.com/ref.jpg \
|
|
68
85
|
--out outputs \
|
|
69
86
|
--json
|
|
70
87
|
```
|
|
71
88
|
|
|
89
|
+
Use an AI tool/template payload:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
yswg-img generate \
|
|
93
|
+
--group-id 6 \
|
|
94
|
+
--template-code tool_code \
|
|
95
|
+
--template-vars-json '{"color":"red"}' \
|
|
96
|
+
--display-prompt "AI 工具生成" \
|
|
97
|
+
--dry-run \
|
|
98
|
+
--json
|
|
99
|
+
```
|
|
100
|
+
|
|
72
101
|
Submit only and return task IDs:
|
|
73
102
|
|
|
74
103
|
```bash
|
|
75
|
-
yswg-img generate --prompt "..." --
|
|
104
|
+
yswg-img generate --prompt "..." --no-wait --json
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
If a long generation times out or the WebSocket disconnects, the CLI now checks
|
|
108
|
+
generation history for the returned invoke task IDs before failing. This avoids
|
|
109
|
+
submitting the same prompt again when the image was already produced.
|
|
110
|
+
|
|
111
|
+
Tune the wait timeout:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
yswg-img generate --prompt "..." --timeout 900 --json
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Query recent task history without creating a new generation task:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
yswg-img tasks search --size 10 --keyword "兔子" --tab all --json
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
The CLI intentionally only queries the latest 10 records. If you need older
|
|
124
|
+
records, open the navigation site and query them in the Monkey Genius page.
|
|
125
|
+
|
|
126
|
+
Available history tabs: `all`, `expiring1d`, `expiring2d`, `nightQueue`.
|
|
127
|
+
|
|
128
|
+
Fetch a history record by record ID:
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
yswg-img tasks get --id 2064983569742958594 --json
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Recover/download completed images by invoke task ID after a timeout:
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
yswg-img tasks recover \
|
|
138
|
+
--task-id 2065019349651689473 \
|
|
139
|
+
--out outputs/recovered \
|
|
140
|
+
--json
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Delete a history record or cancel an off-peak queued task:
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
yswg-img tasks delete --id <record-id> --json
|
|
147
|
+
yswg-img tasks cancel-night --id <record-id> --json
|
|
76
148
|
```
|
|
77
149
|
|
|
78
150
|
## Configuration
|
|
@@ -86,3 +158,5 @@ Flags override environment variables, which override the saved config.
|
|
|
86
158
|
- `YSWG_WS_PATH`
|
|
87
159
|
|
|
88
160
|
Default app ID: `2014153035982319618`.
|
|
161
|
+
Default ratio: `1:1`.
|
|
162
|
+
Default generation model: `gemini-3.1-flash-image-preview` (`groupId=6`, 三代).
|
package/bin/yswg-img.js
CHANGED
|
@@ -2,7 +2,22 @@
|
|
|
2
2
|
import { parseArgs, readFlag, splitCsv } from "../src/args.js";
|
|
3
3
|
import { loadConfig, normalizeEmail, resolveConfig, saveConfig, DEFAULT_CONFIG_PATH } from "../src/config.js";
|
|
4
4
|
import { YswgApi } from "../src/api.js";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
buildGeneratePayload,
|
|
7
|
+
DEFAULT_GENERATE_OPTIONS,
|
|
8
|
+
downloadImages,
|
|
9
|
+
extractImageUrls,
|
|
10
|
+
findModelByGroupId,
|
|
11
|
+
findRecordsByInvokeTaskIds,
|
|
12
|
+
isTimeoutLikeError,
|
|
13
|
+
normalizeHistoryLimit,
|
|
14
|
+
normalizeTaskSearchParams,
|
|
15
|
+
parseTemplateInput,
|
|
16
|
+
taskImageUrlsFromRecord,
|
|
17
|
+
uploadReferences,
|
|
18
|
+
validateGenerateOptions,
|
|
19
|
+
waitForTask,
|
|
20
|
+
} from "../src/generate.js";
|
|
6
21
|
|
|
7
22
|
function printHelp() {
|
|
8
23
|
console.log(`yswg-img
|
|
@@ -11,7 +26,13 @@ Commands:
|
|
|
11
26
|
auth send-code --email <name|email>
|
|
12
27
|
auth login --email <name|email> --code <6 digits>
|
|
13
28
|
models [--app-id <id>]
|
|
14
|
-
|
|
29
|
+
templates [--app-id <id>] [--json]
|
|
30
|
+
tasks search [--size 10] [--keyword <text>] [--tab all|expiring1d|expiring2d|nightQueue] [--json]
|
|
31
|
+
tasks get --id <record-id> [--json]
|
|
32
|
+
tasks recover --task-id <invoke-task-id>[,<id>] [--out outputs] [--json]
|
|
33
|
+
tasks delete --id <record-id> [--json]
|
|
34
|
+
tasks cancel-night --id <record-id> [--json]
|
|
35
|
+
generate --prompt <text> [--group-id 6] [--model gemini-3.1-flash-image-preview] [--ratio 1:1] [--count 1] [--ref a.png,b.jpg] [--template-code <code>] [--template-vars-json '{}'] [--no-compress] [--out outputs]
|
|
15
36
|
|
|
16
37
|
Environment:
|
|
17
38
|
YSWG_TOKEN, YSWG_REFRESH_TOKEN, YSWG_APP_ID, YSWG_BASE_URL, YSWG_WS_PATH
|
|
@@ -72,21 +93,107 @@ async function main() {
|
|
|
72
93
|
return;
|
|
73
94
|
}
|
|
74
95
|
|
|
96
|
+
if (command === "templates") {
|
|
97
|
+
if (!resolved.token) throw new Error("missing token; run auth login or set YSWG_TOKEN");
|
|
98
|
+
const templates = await api.templates(String(readFlag(flags, ["appId"], resolved.appId)));
|
|
99
|
+
output(templates, json);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (command === "tasks" && subcommand === "search") {
|
|
104
|
+
if (!resolved.token) throw new Error("missing token; run auth login or set YSWG_TOKEN");
|
|
105
|
+
const result = await api.searchGenTasks(normalizeTaskSearchParams({
|
|
106
|
+
tab: String(readFlag(flags, ["tab"], "all")),
|
|
107
|
+
keyword: String(readFlag(flags, ["keyword", "q"], "")),
|
|
108
|
+
userId: config.user?.userId || config.user?.id,
|
|
109
|
+
appId: String(readFlag(flags, ["appId"], resolved.appId)),
|
|
110
|
+
size: readFlag(flags, ["size"], 10),
|
|
111
|
+
}));
|
|
112
|
+
output(result, json);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (command === "tasks" && subcommand === "get") {
|
|
117
|
+
if (!resolved.token) throw new Error("missing token; run auth login or set YSWG_TOKEN");
|
|
118
|
+
const id = String(readFlag(flags, ["id"], "")).trim();
|
|
119
|
+
if (!id) throw new Error("missing --id");
|
|
120
|
+
const record = await api.genTask(id);
|
|
121
|
+
output(record, json);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (command === "tasks" && subcommand === "delete") {
|
|
126
|
+
if (!resolved.token) throw new Error("missing token; run auth login or set YSWG_TOKEN");
|
|
127
|
+
const id = String(readFlag(flags, ["id"], "")).trim();
|
|
128
|
+
if (!id) throw new Error("missing --id");
|
|
129
|
+
await api.deleteGenTask(id);
|
|
130
|
+
output({ ok: true, id }, json);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (command === "tasks" && subcommand === "cancel-night") {
|
|
135
|
+
if (!resolved.token) throw new Error("missing token; run auth login or set YSWG_TOKEN");
|
|
136
|
+
const id = String(readFlag(flags, ["id"], "")).trim();
|
|
137
|
+
if (!id) throw new Error("missing --id");
|
|
138
|
+
await api.cancelNightTask(id);
|
|
139
|
+
output({ ok: true, id }, json);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (command === "tasks" && subcommand === "recover") {
|
|
144
|
+
if (!resolved.token) throw new Error("missing token; run auth login or set YSWG_TOKEN");
|
|
145
|
+
const taskIds = splitCsv(readFlag(flags, ["taskId", "taskIds", "id"], ""));
|
|
146
|
+
if (!taskIds.length) throw new Error("missing --task-id");
|
|
147
|
+
const records = await findRecordsByInvokeTaskIds(api, {
|
|
148
|
+
taskIds,
|
|
149
|
+
appId: String(readFlag(flags, ["appId"], resolved.appId)),
|
|
150
|
+
userId: config.user?.userId || config.user?.id,
|
|
151
|
+
pageSize: normalizeHistoryLimit(readFlag(flags, ["size"], 10)),
|
|
152
|
+
maxPages: 1,
|
|
153
|
+
});
|
|
154
|
+
const imageUrls = records.flatMap((record) => taskImageUrlsFromRecord(record));
|
|
155
|
+
if (!imageUrls.length) throw new Error("最近 10 条生成记录里还没有找到该任务结果;如果需要查看更多,请去导航站「天才猴子」页面查询。");
|
|
156
|
+
const files = flags.out === "none" ? [] : await downloadImages(imageUrls, String(readFlag(flags, ["out"], "outputs")));
|
|
157
|
+
output({ ok: true, taskIds, recordIds: records.map((record) => record.id), imageUrls, files }, json);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
75
161
|
if (command === "generate") {
|
|
76
162
|
if (!resolved.token) throw new Error("missing token; run auth login or set YSWG_TOKEN");
|
|
77
163
|
const prompt = String(readFlag(flags, ["prompt", "p"], "")).trim();
|
|
78
164
|
const refs = splitCsv(readFlag(flags, ["ref", "refs"], ""));
|
|
79
|
-
const refImages = await uploadReferences(api, refs);
|
|
165
|
+
const refImages = await uploadReferences(api, refs, { compress: !flags.noCompress });
|
|
166
|
+
const groupId = String(readFlag(flags, ["groupId", "group"], DEFAULT_GENERATE_OPTIONS.groupId));
|
|
167
|
+
const model = String(readFlag(flags, ["model", "m"], DEFAULT_GENERATE_OPTIONS.model));
|
|
168
|
+
const ratio = String(readFlag(flags, ["ratio", "r"], DEFAULT_GENERATE_OPTIONS.ratio));
|
|
169
|
+
const imageCount = Number(readFlag(flags, ["count", "imageCount"], DEFAULT_GENERATE_OPTIONS.imageCount));
|
|
170
|
+
const templateInput = parseTemplateInput({
|
|
171
|
+
code: String(readFlag(flags, ["templateCode", "template"], "")),
|
|
172
|
+
varsJson: String(readFlag(flags, ["templateVarsJson", "varsJson"], "")),
|
|
173
|
+
displayPrompt: String(readFlag(flags, ["displayPrompt"], "")),
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
if (!flags.skipValidate) {
|
|
177
|
+
const modelGroups = await api.models(resolved.appId);
|
|
178
|
+
validateGenerateOptions({
|
|
179
|
+
model: findModelByGroupId(modelGroups, groupId),
|
|
180
|
+
ratio,
|
|
181
|
+
imageCount,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
80
185
|
const payload = buildGeneratePayload({
|
|
81
186
|
appId: resolved.appId,
|
|
82
|
-
groupId
|
|
187
|
+
groupId,
|
|
83
188
|
prompt,
|
|
84
|
-
model
|
|
85
|
-
ratio
|
|
86
|
-
imageCount
|
|
189
|
+
model,
|
|
190
|
+
ratio,
|
|
191
|
+
imageCount,
|
|
87
192
|
refImages,
|
|
88
193
|
size: String(readFlag(flags, ["size"], "")),
|
|
89
194
|
night: Boolean(flags.night),
|
|
195
|
+
templates: templateInput.templates,
|
|
196
|
+
displayPrompt: templateInput.displayPrompt,
|
|
90
197
|
});
|
|
91
198
|
|
|
92
199
|
if (flags.dryRun) {
|
|
@@ -104,14 +211,39 @@ async function main() {
|
|
|
104
211
|
}
|
|
105
212
|
|
|
106
213
|
const timeoutMs = Number(readFlag(flags, ["timeoutMs"], Number(readFlag(flags, ["timeout"], 3600)) * 1000));
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
214
|
+
let imageUrls = [];
|
|
215
|
+
let waitError;
|
|
216
|
+
try {
|
|
217
|
+
const messages = await Promise.all(ids.map((taskId) => waitForTask({
|
|
218
|
+
baseUrl: resolved.baseUrl,
|
|
219
|
+
token: resolved.token,
|
|
220
|
+
wsPath: resolved.wsPath,
|
|
221
|
+
taskId,
|
|
222
|
+
timeoutMs,
|
|
223
|
+
})));
|
|
224
|
+
imageUrls = messages.flatMap((message) => extractImageUrls(message.responseJson));
|
|
225
|
+
} catch (error) {
|
|
226
|
+
if (!isTimeoutLikeError(error)) throw error;
|
|
227
|
+
waitError = error;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (!imageUrls.length) {
|
|
231
|
+
const records = await findRecordsByInvokeTaskIds(api, {
|
|
232
|
+
taskIds: ids,
|
|
233
|
+
appId: resolved.appId,
|
|
234
|
+
userId: config.user?.userId || config.user?.id,
|
|
235
|
+
pageSize: 10,
|
|
236
|
+
maxPages: 1,
|
|
237
|
+
});
|
|
238
|
+
imageUrls = records.flatMap((record) => taskImageUrlsFromRecord(record));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (!imageUrls.length && waitError) {
|
|
242
|
+
const error = new Error(`${waitError.message}; 最近 10 条生成记录里还没有找到该任务结果。请稍后用 tasks recover --task-id <id> 查询;如果需要查看更多,请去导航站「天才猴子」页面查询,避免重复提交同一任务。`);
|
|
243
|
+
error.cause = waitError;
|
|
244
|
+
throw error;
|
|
245
|
+
}
|
|
246
|
+
|
|
115
247
|
const files = flags.out === "none" ? [] : await downloadImages(imageUrls, String(readFlag(flags, ["out"], "outputs")));
|
|
116
248
|
output({ ok: true, taskIds: ids, imageUrls, files }, json);
|
|
117
249
|
return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yswgaicx/yswg-img-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "CLI wrapper for YSWG Monkey Genius image generation.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -21,5 +21,8 @@
|
|
|
21
21
|
"license": "UNLICENSED",
|
|
22
22
|
"publishConfig": {
|
|
23
23
|
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"sharp": "^0.35.0"
|
|
24
27
|
}
|
|
25
28
|
}
|
package/src/api.js
CHANGED
|
@@ -69,6 +69,29 @@ export class YswgApi {
|
|
|
69
69
|
return this.request("/web/ai/invoke/tasks", { method: "POST", data: payload });
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
searchGenTasks(params) {
|
|
73
|
+
return this.request("/web/ai-gen-tasks/search", { method: "POST", data: params });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
genTask(id) {
|
|
77
|
+
return this.request(`/web/ai-gen-tasks/${id}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
deleteGenTask(id) {
|
|
81
|
+
return this.request(`/web/ai-gen-tasks/${id}`, { method: "DELETE" });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
cancelNightTask(id) {
|
|
85
|
+
return this.request(`/web/ai-gen-tasks/${id}/night-cancel`, { method: "DELETE" });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
invokeTextService(serviceId, text) {
|
|
89
|
+
return this.request(`/web/ai/services/invoke/${serviceId}`, {
|
|
90
|
+
method: "POST",
|
|
91
|
+
data: { params: { text } },
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
72
95
|
ossToken(scene = "ai_invoke") {
|
|
73
96
|
return this.request("/web/oss-tokens", { params: { scene } });
|
|
74
97
|
}
|
package/src/generate.js
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { basename } from "node:path";
|
|
2
2
|
import { readFile, mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { compressImageBuffer } from "./image-compress.js";
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_GENERATE_OPTIONS = {
|
|
6
|
+
groupId: "6",
|
|
7
|
+
model: "gemini-3.1-flash-image-preview",
|
|
8
|
+
ratio: "1:1",
|
|
9
|
+
imageCount: 1,
|
|
10
|
+
};
|
|
3
11
|
|
|
4
12
|
export function buildGeneratePayload({
|
|
5
13
|
appId,
|
|
@@ -50,16 +58,190 @@ export function extractImageUrls(responseJson) {
|
|
|
50
58
|
return [];
|
|
51
59
|
}
|
|
52
60
|
|
|
53
|
-
export
|
|
61
|
+
export function unique(items) {
|
|
62
|
+
return Array.from(new Set(items.filter(Boolean)));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function taskImageUrlsFromRecord(record) {
|
|
66
|
+
const outputUrls = Array.isArray(record?.outPutFile)
|
|
67
|
+
? record.outPutFile
|
|
68
|
+
: typeof record?.outPutFile === "string"
|
|
69
|
+
? record.outPutFile.split(",").map((item) => item.trim())
|
|
70
|
+
: [];
|
|
71
|
+
const invokeUrls = (record?.invokeTasks || [])
|
|
72
|
+
.filter((task) => Number(task.status) === 2)
|
|
73
|
+
.flatMap((task) => {
|
|
74
|
+
try {
|
|
75
|
+
return extractImageUrls(task.responseJson);
|
|
76
|
+
} catch {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
return unique([...outputUrls, ...invokeUrls]);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const HISTORY_LIMIT_MESSAGE = "CLI 一次最多只能查询最近 10 条生成记录;如果需要查看更多,请去导航站「天才猴子」页面查询。";
|
|
84
|
+
|
|
85
|
+
export function normalizeHistoryLimit(value, fallback = 10) {
|
|
86
|
+
const size = Number(value ?? fallback);
|
|
87
|
+
if (!Number.isFinite(size) || size < 1) return fallback;
|
|
88
|
+
if (size > 10) throw new Error(HISTORY_LIMIT_MESSAGE);
|
|
89
|
+
return Math.floor(size);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function formatDateTime(date) {
|
|
93
|
+
const pad = (value) => String(value).padStart(2, "0");
|
|
94
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function normalizeTaskSearchParams({
|
|
98
|
+
tab = "all",
|
|
99
|
+
keyword = "",
|
|
100
|
+
userId,
|
|
101
|
+
appId,
|
|
102
|
+
size = 10,
|
|
103
|
+
now = new Date(),
|
|
104
|
+
} = {}) {
|
|
105
|
+
const params = {
|
|
106
|
+
current: 1,
|
|
107
|
+
size: normalizeHistoryLimit(size),
|
|
108
|
+
userId,
|
|
109
|
+
appId,
|
|
110
|
+
keyword: keyword.trim() || undefined,
|
|
111
|
+
includeFailedRecords: true,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
if (tab === "nightQueue") {
|
|
115
|
+
params.status = 3;
|
|
116
|
+
} else if (tab === "expiring1d" || tab === "expiring2d") {
|
|
117
|
+
const startOffset = tab === "expiring2d" ? 24 * 60 * 60 * 1000 : 0;
|
|
118
|
+
const endOffset = tab === "expiring2d" ? 2 * 24 * 60 * 60 * 1000 : 24 * 60 * 60 * 1000;
|
|
119
|
+
params.expireTimeStart = formatDateTime(new Date(now.getTime() + startOffset));
|
|
120
|
+
params.expireTimeEnd = formatDateTime(new Date(now.getTime() + endOffset));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return params;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function findModelByGroupId(modelGroups, groupId) {
|
|
127
|
+
const groups = Array.isArray(modelGroups) ? modelGroups : [];
|
|
128
|
+
for (const group of groups) {
|
|
129
|
+
const models = Array.isArray(group?.models) ? group.models : [];
|
|
130
|
+
for (const model of models) {
|
|
131
|
+
if (String(model.groupId || model.value || "") === String(groupId)) {
|
|
132
|
+
return { ...model, group: group.typeCode || model.group };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function unsupportedRatiosForModel(model) {
|
|
140
|
+
const modelName = model?.modelName || "";
|
|
141
|
+
const label = model?.label || "";
|
|
142
|
+
const dreamRatios = ["4:5", "5:4", "4:1", "1:4", "8:1", "1:8"];
|
|
143
|
+
const defaultRatios = ["4:1", "1:4", "8:1", "1:8"];
|
|
144
|
+
if (label.includes("三代")) return [];
|
|
145
|
+
if (modelName === "seedream-5.0") return dreamRatios;
|
|
146
|
+
return defaultRatios;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function validateGenerateOptions({ model, ratio, imageCount }) {
|
|
150
|
+
if (!model) return;
|
|
151
|
+
if (unsupportedRatiosForModel(model).includes(ratio)) {
|
|
152
|
+
throw new Error(`当前模型不支持比例 ${ratio}`);
|
|
153
|
+
}
|
|
154
|
+
const countEnabled = model.imageGenOptions?.imageCountEnabled ?? true;
|
|
155
|
+
if (!countEnabled && Number(imageCount) > 1) {
|
|
156
|
+
throw new Error("当前模型不支持双图生成");
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function parseTemplateInput({ code, varsJson, displayPrompt = "" } = {}) {
|
|
161
|
+
if (!code) return { templates: [], displayPrompt };
|
|
162
|
+
const params = varsJson ? JSON.parse(varsJson) : {};
|
|
163
|
+
return {
|
|
164
|
+
templates: [{ code, params }],
|
|
165
|
+
displayPrompt: displayPrompt || code,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function findRecordsByInvokeTaskIds(api, {
|
|
170
|
+
taskIds,
|
|
171
|
+
appId,
|
|
172
|
+
userId,
|
|
173
|
+
pageSize = 10,
|
|
174
|
+
maxPages = 1,
|
|
175
|
+
}) {
|
|
176
|
+
const parsedPageSize = Number(pageSize);
|
|
177
|
+
const size = Number.isFinite(parsedPageSize) && parsedPageSize > 0
|
|
178
|
+
? Math.min(Math.floor(parsedPageSize), 10)
|
|
179
|
+
: 10;
|
|
180
|
+
const remaining = new Set(taskIds.map(String));
|
|
181
|
+
const matches = [];
|
|
182
|
+
|
|
183
|
+
for (let current = 1; current <= Math.min(maxPages, 1) && remaining.size > 0; current += 1) {
|
|
184
|
+
const result = await api.searchGenTasks({
|
|
185
|
+
current,
|
|
186
|
+
size,
|
|
187
|
+
userId,
|
|
188
|
+
appId,
|
|
189
|
+
includeFailedRecords: true,
|
|
190
|
+
});
|
|
191
|
+
const page = result?.data || result || {};
|
|
192
|
+
const records = page.records || page.list || [];
|
|
193
|
+
|
|
194
|
+
for (const record of records) {
|
|
195
|
+
const invokeTaskIds = (record.invokeTasks || []).map((task) => String(task.id || task.taskId || ""));
|
|
196
|
+
const matched = invokeTaskIds.some((id) => remaining.has(id));
|
|
197
|
+
if (!matched) continue;
|
|
198
|
+
matches.push(record);
|
|
199
|
+
for (const id of invokeTaskIds) remaining.delete(id);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const total = Number(page.total);
|
|
203
|
+
if (Number.isFinite(total) && current * size >= total) break;
|
|
204
|
+
if (!records.length) break;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return matches;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function isTimeoutLikeError(error) {
|
|
211
|
+
const message = String(error?.message || "").toLowerCase();
|
|
212
|
+
return error?.code === "ETIMEDOUT"
|
|
213
|
+
|| message.includes("timed out")
|
|
214
|
+
|| message.includes("timeout")
|
|
215
|
+
|| message.includes("websocket failed")
|
|
216
|
+
|| message.includes("websocket");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function mimeTypeFromFilename(filename) {
|
|
220
|
+
const lower = filename.toLowerCase();
|
|
221
|
+
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
|
|
222
|
+
if (lower.endsWith(".png")) return "image/png";
|
|
223
|
+
if (lower.endsWith(".webp")) return "image/webp";
|
|
224
|
+
if (lower.endsWith(".gif")) return "image/gif";
|
|
225
|
+
return "application/octet-stream";
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export async function uploadReferences(api, refs, { compress = true } = {}) {
|
|
54
229
|
const urls = [];
|
|
55
230
|
for (const ref of refs) {
|
|
56
231
|
if (/^https?:\/\//i.test(ref)) {
|
|
57
232
|
urls.push(ref);
|
|
58
233
|
continue;
|
|
59
234
|
}
|
|
235
|
+
const originalName = basename(ref);
|
|
60
236
|
const bytes = await readFile(ref);
|
|
61
|
-
const
|
|
62
|
-
|
|
237
|
+
const prepared = compress
|
|
238
|
+
? await compressImageBuffer(bytes, {
|
|
239
|
+
filename: originalName,
|
|
240
|
+
mimeType: mimeTypeFromFilename(originalName),
|
|
241
|
+
})
|
|
242
|
+
: { buffer: bytes, filename: originalName, mimeType: mimeTypeFromFilename(originalName) };
|
|
243
|
+
const blob = new Blob([prepared.buffer], { type: prepared.mimeType });
|
|
244
|
+
urls.push(await api.uploadFile(blob, prepared.filename));
|
|
63
245
|
}
|
|
64
246
|
return urls;
|
|
65
247
|
}
|
|
@@ -71,6 +253,7 @@ export function waitForTask({ baseUrl, token, wsPath, taskId, timeoutMs = 360000
|
|
|
71
253
|
|
|
72
254
|
return new Promise((resolve, reject) => {
|
|
73
255
|
const ws = new WebSocket(url);
|
|
256
|
+
let heartbeat;
|
|
74
257
|
const timer = setTimeout(() => {
|
|
75
258
|
try {
|
|
76
259
|
ws.close();
|
|
@@ -78,10 +261,16 @@ export function waitForTask({ baseUrl, token, wsPath, taskId, timeoutMs = 360000
|
|
|
78
261
|
reject(new Error(`task ${taskId} timed out`));
|
|
79
262
|
}, timeoutMs);
|
|
80
263
|
|
|
81
|
-
const cleanup = () =>
|
|
264
|
+
const cleanup = () => {
|
|
265
|
+
clearTimeout(timer);
|
|
266
|
+
if (heartbeat) clearInterval(heartbeat);
|
|
267
|
+
};
|
|
82
268
|
ws.addEventListener("open", () => {
|
|
83
269
|
ws.send(JSON.stringify({ type: 3, taskId }));
|
|
84
270
|
ws.send(JSON.stringify({ type: 8888 }));
|
|
271
|
+
heartbeat = setInterval(() => {
|
|
272
|
+
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 8888 }));
|
|
273
|
+
}, 30000);
|
|
85
274
|
});
|
|
86
275
|
ws.addEventListener("message", (event) => {
|
|
87
276
|
const text = String(event.data).replace(/(?<=[:,[]\s*)(\d{16,})(?=\s*[,}\]])/g, '"$1"');
|
|
@@ -96,6 +285,10 @@ export function waitForTask({ baseUrl, token, wsPath, taskId, timeoutMs = 360000
|
|
|
96
285
|
cleanup();
|
|
97
286
|
reject(new Error(`websocket failed for ${taskId}`));
|
|
98
287
|
});
|
|
288
|
+
ws.addEventListener("close", (event) => {
|
|
289
|
+
cleanup();
|
|
290
|
+
if (event.code !== 1000) reject(new Error(`websocket closed for ${taskId}: ${event.code}`));
|
|
291
|
+
});
|
|
99
292
|
});
|
|
100
293
|
}
|
|
101
294
|
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import sharp from "sharp";
|
|
2
|
+
|
|
3
|
+
export const FRONTEND_UPLOAD_COMPRESSION = {
|
|
4
|
+
maxWidth: 1280,
|
|
5
|
+
maxHeight: 1280,
|
|
6
|
+
jpegQuality: 82,
|
|
7
|
+
minJpegQuality: 50,
|
|
8
|
+
minCompressBytes: 50 * 1024,
|
|
9
|
+
jpegTargetBytes: 300 * 1024,
|
|
10
|
+
supportedTypes: new Set(["image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif"]),
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function shouldCompressImage({ mimeType, size }) {
|
|
14
|
+
return FRONTEND_UPLOAD_COMPRESSION.supportedTypes.has(String(mimeType || "").toLowerCase())
|
|
15
|
+
&& Number(size) >= FRONTEND_UPLOAD_COMPRESSION.minCompressBytes;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function replaceExtension(filename, extension) {
|
|
19
|
+
const base = filename.includes(".") ? filename.slice(0, filename.lastIndexOf(".")) : filename;
|
|
20
|
+
return `${base}.${extension}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function compressImageBuffer(buffer, {
|
|
24
|
+
filename,
|
|
25
|
+
mimeType,
|
|
26
|
+
size = buffer.length,
|
|
27
|
+
options = FRONTEND_UPLOAD_COMPRESSION,
|
|
28
|
+
} = {}) {
|
|
29
|
+
const normalizedMime = String(mimeType || "").toLowerCase();
|
|
30
|
+
if (!shouldCompressImage({ mimeType: normalizedMime, size })) {
|
|
31
|
+
return { buffer, filename, mimeType: normalizedMime, changed: false };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const image = sharp(buffer, { animated: false });
|
|
36
|
+
const metadata = await image.metadata();
|
|
37
|
+
const hasAlpha = Boolean(metadata.hasAlpha);
|
|
38
|
+
const format = hasAlpha ? "png" : "jpeg";
|
|
39
|
+
const resize = {
|
|
40
|
+
width: options.maxWidth,
|
|
41
|
+
height: options.maxHeight,
|
|
42
|
+
fit: "inside",
|
|
43
|
+
withoutEnlargement: true,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
if (format === "png") {
|
|
47
|
+
const compressed = await image
|
|
48
|
+
.resize(resize)
|
|
49
|
+
.png({ compressionLevel: 9, adaptiveFiltering: true })
|
|
50
|
+
.toBuffer();
|
|
51
|
+
if (compressed.length >= buffer.length && metadata.width <= options.maxWidth && metadata.height <= options.maxHeight) {
|
|
52
|
+
return { buffer, filename, mimeType: normalizedMime, changed: false };
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
buffer: compressed,
|
|
56
|
+
filename: replaceExtension(filename, "png"),
|
|
57
|
+
mimeType: "image/png",
|
|
58
|
+
changed: true,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let quality = options.jpegQuality;
|
|
63
|
+
let compressed = await image
|
|
64
|
+
.resize(resize)
|
|
65
|
+
.jpeg({ quality, mozjpeg: true })
|
|
66
|
+
.toBuffer();
|
|
67
|
+
|
|
68
|
+
while (compressed.length > options.jpegTargetBytes && quality > options.minJpegQuality) {
|
|
69
|
+
quality = Math.max(quality - 5, options.minJpegQuality);
|
|
70
|
+
compressed = await image
|
|
71
|
+
.resize(resize)
|
|
72
|
+
.jpeg({ quality, mozjpeg: true })
|
|
73
|
+
.toBuffer();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (compressed.length >= buffer.length && metadata.width <= options.maxWidth && metadata.height <= options.maxHeight) {
|
|
77
|
+
return { buffer, filename, mimeType: normalizedMime, changed: false };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
buffer: compressed,
|
|
82
|
+
filename: replaceExtension(filename, "jpg"),
|
|
83
|
+
mimeType: "image/jpeg",
|
|
84
|
+
changed: true,
|
|
85
|
+
};
|
|
86
|
+
} catch {
|
|
87
|
+
return { buffer, filename, mimeType: normalizedMime, changed: false };
|
|
88
|
+
}
|
|
89
|
+
}
|