@yswgaicx/yswg-img-cli 0.1.0 → 0.1.2

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,6 +32,28 @@ 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
@@ -69,12 +91,67 @@ yswg-img generate \
69
91
  --json
70
92
  ```
71
93
 
94
+ Use an AI tool/template payload:
95
+
96
+ ```bash
97
+ yswg-img generate \
98
+ --group-id 1 \
99
+ --template-code tool_code \
100
+ --template-vars-json '{"color":"red"}' \
101
+ --display-prompt "AI 工具生成" \
102
+ --dry-run \
103
+ --json
104
+ ```
105
+
72
106
  Submit only and return task IDs:
73
107
 
74
108
  ```bash
75
109
  yswg-img generate --prompt "..." --group-id 5 --no-wait --json
76
110
  ```
77
111
 
112
+ If a long generation times out or the WebSocket disconnects, the CLI now checks
113
+ generation history for the returned invoke task IDs before failing. This avoids
114
+ submitting the same prompt again when the image was already produced.
115
+
116
+ Tune the wait timeout:
117
+
118
+ ```bash
119
+ yswg-img generate --prompt "..." --group-id 5 --timeout 900 --json
120
+ ```
121
+
122
+ Query recent task history without creating a new generation task:
123
+
124
+ ```bash
125
+ yswg-img tasks search --size 10 --keyword "兔子" --tab all --json
126
+ ```
127
+
128
+ The CLI intentionally only queries the latest 10 records. If you need older
129
+ records, open the navigation site and query them in the Monkey Genius page.
130
+
131
+ Available history tabs: `all`, `expiring1d`, `expiring2d`, `nightQueue`.
132
+
133
+ Fetch a history record by record ID:
134
+
135
+ ```bash
136
+ yswg-img tasks get --id 2064983569742958594 --json
137
+ ```
138
+
139
+ Recover/download completed images by invoke task ID after a timeout:
140
+
141
+ ```bash
142
+ yswg-img tasks recover \
143
+ --task-id 2065019349651689473 \
144
+ --out outputs/recovered \
145
+ --json
146
+ ```
147
+
148
+ Delete a history record or cancel an off-peak queued task:
149
+
150
+ ```bash
151
+ yswg-img tasks delete --id <record-id> --json
152
+ yswg-img tasks cancel-night --id <record-id> --json
153
+ ```
154
+
78
155
  ## Configuration
79
156
 
80
157
  Flags override environment variables, which override the saved config.
package/bin/yswg-img.js CHANGED
@@ -2,7 +2,21 @@
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
+ downloadImages,
8
+ extractImageUrls,
9
+ findModelByGroupId,
10
+ findRecordsByInvokeTaskIds,
11
+ isTimeoutLikeError,
12
+ normalizeHistoryLimit,
13
+ normalizeTaskSearchParams,
14
+ parseTemplateInput,
15
+ taskImageUrlsFromRecord,
16
+ uploadReferences,
17
+ validateGenerateOptions,
18
+ waitForTask,
19
+ } from "../src/generate.js";
6
20
 
7
21
  function printHelp() {
8
22
  console.log(`yswg-img
@@ -11,7 +25,13 @@ Commands:
11
25
  auth send-code --email <name|email>
12
26
  auth login --email <name|email> --code <6 digits>
13
27
  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]
28
+ templates [--app-id <id>] [--json]
29
+ tasks search [--size 10] [--keyword <text>] [--tab all|expiring1d|expiring2d|nightQueue] [--json]
30
+ tasks get --id <record-id> [--json]
31
+ tasks recover --task-id <invoke-task-id>[,<id>] [--out outputs] [--json]
32
+ tasks delete --id <record-id> [--json]
33
+ tasks cancel-night --id <record-id> [--json]
34
+ generate --prompt <text> --group-id <id> [--model <name>] [--ratio 1:1] [--count 1] [--ref a.png,b.jpg] [--template-code <code>] [--template-vars-json '{}'] [--no-compress] [--out outputs]
15
35
 
16
36
  Environment:
17
37
  YSWG_TOKEN, YSWG_REFRESH_TOKEN, YSWG_APP_ID, YSWG_BASE_URL, YSWG_WS_PATH
@@ -72,21 +92,107 @@ async function main() {
72
92
  return;
73
93
  }
74
94
 
95
+ if (command === "templates") {
96
+ if (!resolved.token) throw new Error("missing token; run auth login or set YSWG_TOKEN");
97
+ const templates = await api.templates(String(readFlag(flags, ["appId"], resolved.appId)));
98
+ output(templates, json);
99
+ return;
100
+ }
101
+
102
+ if (command === "tasks" && subcommand === "search") {
103
+ if (!resolved.token) throw new Error("missing token; run auth login or set YSWG_TOKEN");
104
+ const result = await api.searchGenTasks(normalizeTaskSearchParams({
105
+ tab: String(readFlag(flags, ["tab"], "all")),
106
+ keyword: String(readFlag(flags, ["keyword", "q"], "")),
107
+ userId: config.user?.userId || config.user?.id,
108
+ appId: String(readFlag(flags, ["appId"], resolved.appId)),
109
+ size: readFlag(flags, ["size"], 10),
110
+ }));
111
+ output(result, json);
112
+ return;
113
+ }
114
+
115
+ if (command === "tasks" && subcommand === "get") {
116
+ if (!resolved.token) throw new Error("missing token; run auth login or set YSWG_TOKEN");
117
+ const id = String(readFlag(flags, ["id"], "")).trim();
118
+ if (!id) throw new Error("missing --id");
119
+ const record = await api.genTask(id);
120
+ output(record, json);
121
+ return;
122
+ }
123
+
124
+ if (command === "tasks" && subcommand === "delete") {
125
+ if (!resolved.token) throw new Error("missing token; run auth login or set YSWG_TOKEN");
126
+ const id = String(readFlag(flags, ["id"], "")).trim();
127
+ if (!id) throw new Error("missing --id");
128
+ await api.deleteGenTask(id);
129
+ output({ ok: true, id }, json);
130
+ return;
131
+ }
132
+
133
+ if (command === "tasks" && subcommand === "cancel-night") {
134
+ if (!resolved.token) throw new Error("missing token; run auth login or set YSWG_TOKEN");
135
+ const id = String(readFlag(flags, ["id"], "")).trim();
136
+ if (!id) throw new Error("missing --id");
137
+ await api.cancelNightTask(id);
138
+ output({ ok: true, id }, json);
139
+ return;
140
+ }
141
+
142
+ if (command === "tasks" && subcommand === "recover") {
143
+ if (!resolved.token) throw new Error("missing token; run auth login or set YSWG_TOKEN");
144
+ const taskIds = splitCsv(readFlag(flags, ["taskId", "taskIds", "id"], ""));
145
+ if (!taskIds.length) throw new Error("missing --task-id");
146
+ const records = await findRecordsByInvokeTaskIds(api, {
147
+ taskIds,
148
+ appId: String(readFlag(flags, ["appId"], resolved.appId)),
149
+ userId: config.user?.userId || config.user?.id,
150
+ pageSize: normalizeHistoryLimit(readFlag(flags, ["size"], 10)),
151
+ maxPages: 1,
152
+ });
153
+ const imageUrls = records.flatMap((record) => taskImageUrlsFromRecord(record));
154
+ if (!imageUrls.length) throw new Error("最近 10 条生成记录里还没有找到该任务结果;如果需要查看更多,请去导航站「天才猴子」页面查询。");
155
+ const files = flags.out === "none" ? [] : await downloadImages(imageUrls, String(readFlag(flags, ["out"], "outputs")));
156
+ output({ ok: true, taskIds, recordIds: records.map((record) => record.id), imageUrls, files }, json);
157
+ return;
158
+ }
159
+
75
160
  if (command === "generate") {
76
161
  if (!resolved.token) throw new Error("missing token; run auth login or set YSWG_TOKEN");
77
162
  const prompt = String(readFlag(flags, ["prompt", "p"], "")).trim();
78
163
  const refs = splitCsv(readFlag(flags, ["ref", "refs"], ""));
79
- const refImages = await uploadReferences(api, refs);
164
+ const refImages = await uploadReferences(api, refs, { compress: !flags.noCompress });
165
+ const groupId = String(readFlag(flags, ["groupId", "group"], ""));
166
+ const model = String(readFlag(flags, ["model", "m"], "gemini-2.5-flash-image"));
167
+ const ratio = String(readFlag(flags, ["ratio", "r"], "1:1"));
168
+ const imageCount = Number(readFlag(flags, ["count", "imageCount"], 1));
169
+ const templateInput = parseTemplateInput({
170
+ code: String(readFlag(flags, ["templateCode", "template"], "")),
171
+ varsJson: String(readFlag(flags, ["templateVarsJson", "varsJson"], "")),
172
+ displayPrompt: String(readFlag(flags, ["displayPrompt"], "")),
173
+ });
174
+
175
+ if (!flags.skipValidate) {
176
+ const modelGroups = await api.models(resolved.appId);
177
+ validateGenerateOptions({
178
+ model: findModelByGroupId(modelGroups, groupId),
179
+ ratio,
180
+ imageCount,
181
+ });
182
+ }
183
+
80
184
  const payload = buildGeneratePayload({
81
185
  appId: resolved.appId,
82
- groupId: String(readFlag(flags, ["groupId", "group"], "")),
186
+ groupId,
83
187
  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)),
188
+ model,
189
+ ratio,
190
+ imageCount,
87
191
  refImages,
88
192
  size: String(readFlag(flags, ["size"], "")),
89
193
  night: Boolean(flags.night),
194
+ templates: templateInput.templates,
195
+ displayPrompt: templateInput.displayPrompt,
90
196
  });
91
197
 
92
198
  if (flags.dryRun) {
@@ -104,14 +210,39 @@ async function main() {
104
210
  }
105
211
 
106
212
  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));
213
+ let imageUrls = [];
214
+ let waitError;
215
+ try {
216
+ const messages = await Promise.all(ids.map((taskId) => waitForTask({
217
+ baseUrl: resolved.baseUrl,
218
+ token: resolved.token,
219
+ wsPath: resolved.wsPath,
220
+ taskId,
221
+ timeoutMs,
222
+ })));
223
+ imageUrls = messages.flatMap((message) => extractImageUrls(message.responseJson));
224
+ } catch (error) {
225
+ if (!isTimeoutLikeError(error)) throw error;
226
+ waitError = error;
227
+ }
228
+
229
+ if (!imageUrls.length) {
230
+ const records = await findRecordsByInvokeTaskIds(api, {
231
+ taskIds: ids,
232
+ appId: resolved.appId,
233
+ userId: config.user?.userId || config.user?.id,
234
+ pageSize: 10,
235
+ maxPages: 1,
236
+ });
237
+ imageUrls = records.flatMap((record) => taskImageUrlsFromRecord(record));
238
+ }
239
+
240
+ if (!imageUrls.length && waitError) {
241
+ const error = new Error(`${waitError.message}; 最近 10 条生成记录里还没有找到该任务结果。请稍后用 tasks recover --task-id <id> 查询;如果需要查看更多,请去导航站「天才猴子」页面查询,避免重复提交同一任务。`);
242
+ error.cause = waitError;
243
+ throw error;
244
+ }
245
+
115
246
  const files = flags.out === "none" ? [] : await downloadImages(imageUrls, String(readFlag(flags, ["out"], "outputs")));
116
247
  output({ ok: true, taskIds: ids, imageUrls, files }, json);
117
248
  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.2",
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,6 @@
1
1
  import { basename } from "node:path";
2
2
  import { readFile, mkdir, writeFile } from "node:fs/promises";
3
+ import { compressImageBuffer } from "./image-compress.js";
3
4
 
4
5
  export function buildGeneratePayload({
5
6
  appId,
@@ -50,16 +51,190 @@ export function extractImageUrls(responseJson) {
50
51
  return [];
51
52
  }
52
53
 
53
- export async function uploadReferences(api, refs) {
54
+ export function unique(items) {
55
+ return Array.from(new Set(items.filter(Boolean)));
56
+ }
57
+
58
+ export function taskImageUrlsFromRecord(record) {
59
+ const outputUrls = Array.isArray(record?.outPutFile)
60
+ ? record.outPutFile
61
+ : typeof record?.outPutFile === "string"
62
+ ? record.outPutFile.split(",").map((item) => item.trim())
63
+ : [];
64
+ const invokeUrls = (record?.invokeTasks || [])
65
+ .filter((task) => Number(task.status) === 2)
66
+ .flatMap((task) => {
67
+ try {
68
+ return extractImageUrls(task.responseJson);
69
+ } catch {
70
+ return [];
71
+ }
72
+ });
73
+ return unique([...outputUrls, ...invokeUrls]);
74
+ }
75
+
76
+ export const HISTORY_LIMIT_MESSAGE = "CLI 一次最多只能查询最近 10 条生成记录;如果需要查看更多,请去导航站「天才猴子」页面查询。";
77
+
78
+ export function normalizeHistoryLimit(value, fallback = 10) {
79
+ const size = Number(value ?? fallback);
80
+ if (!Number.isFinite(size) || size < 1) return fallback;
81
+ if (size > 10) throw new Error(HISTORY_LIMIT_MESSAGE);
82
+ return Math.floor(size);
83
+ }
84
+
85
+ function formatDateTime(date) {
86
+ const pad = (value) => String(value).padStart(2, "0");
87
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
88
+ }
89
+
90
+ export function normalizeTaskSearchParams({
91
+ tab = "all",
92
+ keyword = "",
93
+ userId,
94
+ appId,
95
+ size = 10,
96
+ now = new Date(),
97
+ } = {}) {
98
+ const params = {
99
+ current: 1,
100
+ size: normalizeHistoryLimit(size),
101
+ userId,
102
+ appId,
103
+ keyword: keyword.trim() || undefined,
104
+ includeFailedRecords: true,
105
+ };
106
+
107
+ if (tab === "nightQueue") {
108
+ params.status = 3;
109
+ } else if (tab === "expiring1d" || tab === "expiring2d") {
110
+ const startOffset = tab === "expiring2d" ? 24 * 60 * 60 * 1000 : 0;
111
+ const endOffset = tab === "expiring2d" ? 2 * 24 * 60 * 60 * 1000 : 24 * 60 * 60 * 1000;
112
+ params.expireTimeStart = formatDateTime(new Date(now.getTime() + startOffset));
113
+ params.expireTimeEnd = formatDateTime(new Date(now.getTime() + endOffset));
114
+ }
115
+
116
+ return params;
117
+ }
118
+
119
+ export function findModelByGroupId(modelGroups, groupId) {
120
+ const groups = Array.isArray(modelGroups) ? modelGroups : [];
121
+ for (const group of groups) {
122
+ const models = Array.isArray(group?.models) ? group.models : [];
123
+ for (const model of models) {
124
+ if (String(model.groupId || model.value || "") === String(groupId)) {
125
+ return { ...model, group: group.typeCode || model.group };
126
+ }
127
+ }
128
+ }
129
+ return null;
130
+ }
131
+
132
+ export function unsupportedRatiosForModel(model) {
133
+ const modelName = model?.modelName || "";
134
+ const label = model?.label || "";
135
+ const dreamRatios = ["4:5", "5:4", "4:1", "1:4", "8:1", "1:8"];
136
+ const defaultRatios = ["4:1", "1:4", "8:1", "1:8"];
137
+ if (label.includes("三代")) return [];
138
+ if (modelName === "seedream-5.0") return dreamRatios;
139
+ return defaultRatios;
140
+ }
141
+
142
+ export function validateGenerateOptions({ model, ratio, imageCount }) {
143
+ if (!model) return;
144
+ if (unsupportedRatiosForModel(model).includes(ratio)) {
145
+ throw new Error(`当前模型不支持比例 ${ratio}`);
146
+ }
147
+ const countEnabled = model.imageGenOptions?.imageCountEnabled ?? true;
148
+ if (!countEnabled && Number(imageCount) > 1) {
149
+ throw new Error("当前模型不支持双图生成");
150
+ }
151
+ }
152
+
153
+ export function parseTemplateInput({ code, varsJson, displayPrompt = "" } = {}) {
154
+ if (!code) return { templates: [], displayPrompt };
155
+ const params = varsJson ? JSON.parse(varsJson) : {};
156
+ return {
157
+ templates: [{ code, params }],
158
+ displayPrompt: displayPrompt || code,
159
+ };
160
+ }
161
+
162
+ export async function findRecordsByInvokeTaskIds(api, {
163
+ taskIds,
164
+ appId,
165
+ userId,
166
+ pageSize = 10,
167
+ maxPages = 1,
168
+ }) {
169
+ const parsedPageSize = Number(pageSize);
170
+ const size = Number.isFinite(parsedPageSize) && parsedPageSize > 0
171
+ ? Math.min(Math.floor(parsedPageSize), 10)
172
+ : 10;
173
+ const remaining = new Set(taskIds.map(String));
174
+ const matches = [];
175
+
176
+ for (let current = 1; current <= Math.min(maxPages, 1) && remaining.size > 0; current += 1) {
177
+ const result = await api.searchGenTasks({
178
+ current,
179
+ size,
180
+ userId,
181
+ appId,
182
+ includeFailedRecords: true,
183
+ });
184
+ const page = result?.data || result || {};
185
+ const records = page.records || page.list || [];
186
+
187
+ for (const record of records) {
188
+ const invokeTaskIds = (record.invokeTasks || []).map((task) => String(task.id || task.taskId || ""));
189
+ const matched = invokeTaskIds.some((id) => remaining.has(id));
190
+ if (!matched) continue;
191
+ matches.push(record);
192
+ for (const id of invokeTaskIds) remaining.delete(id);
193
+ }
194
+
195
+ const total = Number(page.total);
196
+ if (Number.isFinite(total) && current * size >= total) break;
197
+ if (!records.length) break;
198
+ }
199
+
200
+ return matches;
201
+ }
202
+
203
+ export function isTimeoutLikeError(error) {
204
+ const message = String(error?.message || "").toLowerCase();
205
+ return error?.code === "ETIMEDOUT"
206
+ || message.includes("timed out")
207
+ || message.includes("timeout")
208
+ || message.includes("websocket failed")
209
+ || message.includes("websocket");
210
+ }
211
+
212
+ function mimeTypeFromFilename(filename) {
213
+ const lower = filename.toLowerCase();
214
+ if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
215
+ if (lower.endsWith(".png")) return "image/png";
216
+ if (lower.endsWith(".webp")) return "image/webp";
217
+ if (lower.endsWith(".gif")) return "image/gif";
218
+ return "application/octet-stream";
219
+ }
220
+
221
+ export async function uploadReferences(api, refs, { compress = true } = {}) {
54
222
  const urls = [];
55
223
  for (const ref of refs) {
56
224
  if (/^https?:\/\//i.test(ref)) {
57
225
  urls.push(ref);
58
226
  continue;
59
227
  }
228
+ const originalName = basename(ref);
60
229
  const bytes = await readFile(ref);
61
- const blob = new Blob([bytes]);
62
- urls.push(await api.uploadFile(blob, basename(ref)));
230
+ const prepared = compress
231
+ ? await compressImageBuffer(bytes, {
232
+ filename: originalName,
233
+ mimeType: mimeTypeFromFilename(originalName),
234
+ })
235
+ : { buffer: bytes, filename: originalName, mimeType: mimeTypeFromFilename(originalName) };
236
+ const blob = new Blob([prepared.buffer], { type: prepared.mimeType });
237
+ urls.push(await api.uploadFile(blob, prepared.filename));
63
238
  }
64
239
  return urls;
65
240
  }
@@ -71,6 +246,7 @@ export function waitForTask({ baseUrl, token, wsPath, taskId, timeoutMs = 360000
71
246
 
72
247
  return new Promise((resolve, reject) => {
73
248
  const ws = new WebSocket(url);
249
+ let heartbeat;
74
250
  const timer = setTimeout(() => {
75
251
  try {
76
252
  ws.close();
@@ -78,10 +254,16 @@ export function waitForTask({ baseUrl, token, wsPath, taskId, timeoutMs = 360000
78
254
  reject(new Error(`task ${taskId} timed out`));
79
255
  }, timeoutMs);
80
256
 
81
- const cleanup = () => clearTimeout(timer);
257
+ const cleanup = () => {
258
+ clearTimeout(timer);
259
+ if (heartbeat) clearInterval(heartbeat);
260
+ };
82
261
  ws.addEventListener("open", () => {
83
262
  ws.send(JSON.stringify({ type: 3, taskId }));
84
263
  ws.send(JSON.stringify({ type: 8888 }));
264
+ heartbeat = setInterval(() => {
265
+ if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 8888 }));
266
+ }, 30000);
85
267
  });
86
268
  ws.addEventListener("message", (event) => {
87
269
  const text = String(event.data).replace(/(?<=[:,[]\s*)(\d{16,})(?=\s*[,}\]])/g, '"$1"');
@@ -96,6 +278,10 @@ export function waitForTask({ baseUrl, token, wsPath, taskId, timeoutMs = 360000
96
278
  cleanup();
97
279
  reject(new Error(`websocket failed for ${taskId}`));
98
280
  });
281
+ ws.addEventListener("close", (event) => {
282
+ cleanup();
283
+ if (event.code !== 1000) reject(new Error(`websocket closed for ${taskId}: ${event.code}`));
284
+ });
99
285
  });
100
286
  }
101
287
 
@@ -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
+ }