@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 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 "..." --group-id 5 --no-wait --json
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 { buildGeneratePayload, downloadImages, extractImageUrls, uploadReferences, waitForTask } from "../src/generate.js";
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
- generate --prompt <text> --group-id <id> [--model <name>] [--ratio 1:1] [--count 1] [--ref a.png,b.jpg] [--out outputs]
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: String(readFlag(flags, ["groupId", "group"], "")),
187
+ groupId,
83
188
  prompt,
84
- model: String(readFlag(flags, ["model", "m"], "gemini-2.5-flash-image")),
85
- ratio: String(readFlag(flags, ["ratio", "r"], "1:1")),
86
- imageCount: Number(readFlag(flags, ["count", "imageCount"], 1)),
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
- const messages = await Promise.all(ids.map((taskId) => waitForTask({
108
- baseUrl: resolved.baseUrl,
109
- token: resolved.token,
110
- wsPath: resolved.wsPath,
111
- taskId,
112
- timeoutMs,
113
- })));
114
- const imageUrls = messages.flatMap((message) => extractImageUrls(message.responseJson));
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.0",
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 async function uploadReferences(api, refs) {
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 blob = new Blob([bytes]);
62
- urls.push(await api.uploadFile(blob, basename(ref)));
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 = () => clearTimeout(timer);
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
+ }