@goodandready/dsh-image-gen 0.9.0 → 0.10.0
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 +16 -5
- package/README.ru.md +37 -48
- package/README.zh.md +9 -6
- package/lib/client.js +22 -0
- package/lib/index.js +104 -29
- package/lib/providers.js +197 -27
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -31,24 +31,35 @@
|
|
|
31
31
|
|
|
32
32
|
---
|
|
33
33
|
|
|
34
|
+
## 🛡️ Reliability, Performance & Quality (v0.10.0)
|
|
35
|
+
|
|
36
|
+
* **Exponential Backoff with Jitter**: Adaptive polling for FAL, Replicate, and ComfyUI queues protects against HTTP 429 rate limits.
|
|
37
|
+
* **Error Classification in Fallback Cascade**: Client-side errors (Content Policy, 400 Bad Request, NSFW) fail fast without wasting API credits on other providers.
|
|
38
|
+
* **Deterministic Hash Caching**: Exact matches of prompt, model, and seed return instantly from local storage with zero API expense.
|
|
39
|
+
* **ComfyUI & Automatic1111 Drag-and-Drop**: Metadata is packed into PNG `Parameters` chunks in standard format.
|
|
40
|
+
* **Dimension Snapping**: Automatic normalization to multiples of 64 guarantees VAE bucket compatibility.
|
|
41
|
+
* **Enhanced Style Presets**: Built-in styles include tailor-made negative prompts and optimal guidance scale settings.
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
34
45
|
## 🛠️ Complete Tools Reference
|
|
35
46
|
|
|
36
47
|
* **`generate_image`**: Generate images with pluggable providers, seeds, aspect ratios, and style presets.
|
|
37
48
|
* **`remove_background`**: Extract subject with transparent PNG output (FAL BiRefNet / Rembg).
|
|
38
49
|
* **`upscale_image`**: 2x / 4x super-resolution with clarity reconstruction.
|
|
39
|
-
* **`vectorize_image`**: Convert raster graphics to clean scalable SVG vectors.
|
|
50
|
+
* **`vectorize_image`**: Convert raster graphics to clean scalable SVG vectors with palette quantization.
|
|
40
51
|
* **`blend_images`**: Multi-reference composition mixing.
|
|
41
|
-
* **`generate_image_pack`**: Simultaneous multi-aspect ratio rendering
|
|
52
|
+
* **`generate_image_pack`**: Simultaneous multi-aspect ratio rendering with graceful partial recovery.
|
|
42
53
|
* **`compare_images`**: Pixel-level visual difference ratio comparison.
|
|
43
|
-
* **`inspect_image_quality`**: Automated visual audit and defect detection.
|
|
54
|
+
* **`inspect_image_quality`**: Automated visual audit, Laplacian sharpness scoring, and defect detection.
|
|
44
55
|
|
|
45
56
|
---
|
|
46
57
|
|
|
47
58
|
## 🎨 Supported Generation Backends
|
|
48
59
|
|
|
49
|
-
* **`fal`** (Default):
|
|
60
|
+
* **`fal`** (Default): FAL.ai queue for FLUX.1, SDXL, Clarity Upscaler, and BiRefNet.
|
|
50
61
|
* **`replicate`**: FLUX and SDXL models via Replicate API.
|
|
51
|
-
* **`custom`**: OpenAI-compatible endpoint (DALL-E 3, SiliconFlow, Together AI, local
|
|
62
|
+
* **`custom`**: OpenAI-compatible endpoint (DALL-E 3, SiliconFlow, Together AI, local gateways).
|
|
52
63
|
* **`codex`**: ChatGPT Plus/Pro subscription generation via `dsh-subscriptions` (OAuth).
|
|
53
64
|
* **`grok`**: Grok Imagine subscription generation via `dsh-subscriptions` (OAuth).
|
|
54
65
|
* **`local`**: Local ComfyUI workflow execution or Automatic1111 web API.
|
package/README.ru.md
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
|
|
28
28
|
## ⚡ Обзор возможностей
|
|
29
29
|
|
|
30
|
-
**`@goodandready/dsh-image-gen`** — флагманский графический плагин для экосистемы DeepSeek Harness, превращающий агента в полноценную творческую студию. Плагин предоставляет богатый набор инструментов для генерации, трансформации, апскейла, векторизации и анализа изображений с поддержкой 8 провайдеров
|
|
30
|
+
**`@goodandready/dsh-image-gen`** — флагманский графический плагин для экосистемы DeepSeek Harness, превращающий агента в полноценную творческую студию. Плагин предоставляет богатый набор инструментов для генерации, трансформации, апскейла, векторизации и анализа изображений с поддержкой 8 провайдеров генерации, интеллектуальным кэшированием и интерактивной карточкой в чате.
|
|
31
31
|
|
|
32
32
|
```mermaid
|
|
33
33
|
graph LR
|
|
@@ -47,8 +47,14 @@ graph LR
|
|
|
47
47
|
T8[inspect_image_quality]
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
+
subgraph Core [🛡️ Ядро надежности и кэша]
|
|
51
|
+
Cache[Детерминированный sha256 Hash Cache]
|
|
52
|
+
Backoff[Exponential Backoff + Jitter]
|
|
53
|
+
Classifier[Классификатор фатальных ошибок 400]
|
|
54
|
+
Snap64[VAE 64-Multiple Dimension Snapping]
|
|
55
|
+
end
|
|
56
|
+
|
|
50
57
|
subgraph Engine [⚙️ Диспетчер бэкендов]
|
|
51
|
-
Router{Умный маршрутизатор}
|
|
52
58
|
P_FAL[FAL.ai Queue]
|
|
53
59
|
P_REP[Replicate API]
|
|
54
60
|
P_CUST[OpenAI / SiliconFlow]
|
|
@@ -60,19 +66,19 @@ graph LR
|
|
|
60
66
|
end
|
|
61
67
|
|
|
62
68
|
subgraph Store [💾 Хранение и UI]
|
|
63
|
-
Sidecar[.json Sidecar +
|
|
64
|
-
|
|
65
|
-
Card[🖼️ Карточка в чате с Re-roll,
|
|
69
|
+
Sidecar[.json Sidecar + Parameters tEXt]
|
|
70
|
+
A1111[Drag-and-Drop в ComfyUI / WebUI]
|
|
71
|
+
Card[🖼️ Карточка в чате с Re-roll, Правкой, NoBG]
|
|
66
72
|
end
|
|
67
73
|
|
|
68
74
|
Input --> Tools
|
|
69
|
-
Tools -->
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
Sidecar --> Attach --> Card
|
|
75
|
+
Tools --> Core
|
|
76
|
+
Core --> Engine
|
|
77
|
+
Engine --> Store
|
|
73
78
|
|
|
74
79
|
style Input fill:#1e1e2e,stroke:#89b4fa,stroke-width:2px,color:#cdd6f4
|
|
75
80
|
style Tools fill:#181825,stroke:#cba6f7,stroke-width:2px,color:#cdd6f4
|
|
81
|
+
style Core fill:#11111b,stroke:#fab387,stroke-width:2px,color:#cdd6f4
|
|
76
82
|
style Engine fill:#11111b,stroke:#a6e3a1,stroke-width:2px,color:#cdd6f4
|
|
77
83
|
style Store fill:#1e1e2e,stroke:#f38ba8,stroke-width:2px,color:#cdd6f4
|
|
78
84
|
```
|
|
@@ -86,52 +92,35 @@ graph LR
|
|
|
86
92
|
| **`generate_image`** | Генерация изображения по тексту | `prompt`, `image_size`, `seed`, `style`, `negative_prompt`, `count` |
|
|
87
93
|
| **`remove_background`** | Удаление фона с сохранением прозрачного PNG | `image`, `model`, `output_name` |
|
|
88
94
|
| **`upscale_image`** | Увеличение разрешения в 2x / 4x с детализацией | `image`, `scale` (2/4), `prompt`, `creativity` |
|
|
89
|
-
| **`vectorize_image`** | Векторизация растра в чистый SVG | `image`, `color_mode
|
|
95
|
+
| **`vectorize_image`** | Векторизация растра в чистый SVG с квантованием палитры | `image`, `color_mode`, `paletteSize` |
|
|
90
96
|
| **`blend_images`** | Смешивание нескольких изображений в единую композицию | `images`, `weights`, `prompt` |
|
|
91
|
-
| **`generate_image_pack`** | Пакетный рендеринг форматов 1:1, 16:9, 9:16
|
|
97
|
+
| **`generate_image_pack`** | Пакетный рендеринг форматов 1:1, 16:9, 9:16 с сохранением частичных результатов | `prompt`, `aspect_ratios` |
|
|
92
98
|
| **`compare_images`** | Сравнение двух картинок (доля различий пикселей) | `image_a`, `image_b` |
|
|
93
|
-
| **`inspect_image_quality`** | Аудит
|
|
94
|
-
|
|
95
|
-
---
|
|
96
|
-
|
|
97
|
-
## 🎨 Поддерживаемые провайдеры генерации
|
|
98
|
-
|
|
99
|
-
| Провайдер | Бэкенд | Ключи / Доступ | Назначение и особенности |
|
|
100
|
-
|---|---|---|---|
|
|
101
|
-
| **`fal`** *(дефолт)* | FAL.ai Queue | `FAL_API_KEY` | Сверхбыстрая генерация (`FLUX.1-schnell`, `FLUX-dev`, `SDXL`), Upscaler, BiRefNet |
|
|
102
|
-
| **`replicate`** | Replicate API | `REPLICATE_API_TOKEN` | Модели сообщества (`black-forest-labs/flux-schnell`, `stability-ai/sdxl`) |
|
|
103
|
-
| **`custom`** | OpenAI-совместимый эндпоинт | `OPENAI_API_KEY` | DALL-E 3, SiliconFlow, Together AI, локальный OpenAI шлюз |
|
|
104
|
-
| **`codex`** | ChatGPT Plus/Pro | *Без ключа (OAuth)* | Использование учетной записи из `dsh-subscriptions` без тарификации API |
|
|
105
|
-
| **`grok`** | Grok Imagine | *Без ключа (OAuth)* | Использование учетной записи Grok из `dsh-subscriptions` без тарификации API |
|
|
106
|
-
| **`local`** | Локальный сервер | *Не требуется* | ComfyUI (граф нод / prompt queue) или Automatic1111 (txt2img/img2img) |
|
|
107
|
-
| **`seedream`** | ByteDance Doubao / SeaDream | `SEEDREAM_API_KEY` | Азиатские фотореалистичные генеративные модели |
|
|
108
|
-
| **`gemini`** | Google GenAI API | `GEMINI_API_KEY` | Imagen 3 через официальный Gemini API |
|
|
99
|
+
| **`inspect_image_quality`** | Аудит качества, расчет резкости и проверка дефектов | `image`, `expected_elements` |
|
|
109
100
|
|
|
110
101
|
---
|
|
111
102
|
|
|
112
|
-
##
|
|
103
|
+
## 🛡️ Отказоустойчивость, производительность и качество (v0.10.0)
|
|
113
104
|
|
|
114
|
-
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
* **`oil_painting`**: Классическая масляная живопись, фактура холста, пастозные мазки.
|
|
121
|
-
* **`minimalist`**: Минималистичный плоский вектор, чистые линии, строгая геометрия.
|
|
105
|
+
* **Экспоненциальный Backoff с Jitter**: опрос очередей генерации (FAL, Replicate, ComfyUI) автоматически адаптирует задержки, исключая ошибки `429 Too Many Requests`.
|
|
106
|
+
* **Классификатор ошибок в Fallback Cascade**: клиентские ошибки (Content Policy, 400 Bad Request, NSFW) отсекаются немедленно, предотвращая пустую трату денег на резервных провайдерах.
|
|
107
|
+
* **Детерминированный хэш-кэш**: повторные генерации с тем же промптом, моделью и сидом отдаются мгновенно из локального кэша с нулевой стоимостью API.
|
|
108
|
+
* **Совместимость с ComfyUI / Automatic1111**: метаданные вшиваются в PNG `tEXt` чанки в стандартном формате `Parameters: Prompt\nNegative prompt: ...\nSteps: ... Seed: ...`, обеспечивая нативный Drag-and-Drop.
|
|
109
|
+
* **Кратность 64**: все пользовательские и адаптивные размеры автоматически округляются до кратных 64 для исключения искажений VAE.
|
|
110
|
+
* **Обогащенные пресеты стилей**: каждый стиль в `STYLE_PRESETS` содержит индивидуальный `negative_prompt` и рекомендуемый `guidance_scale`.
|
|
122
111
|
|
|
123
112
|
---
|
|
124
113
|
|
|
125
|
-
##
|
|
114
|
+
## 🎨 Поддерживаемые провайдеры генерации
|
|
126
115
|
|
|
127
|
-
|
|
128
|
-
*
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
*
|
|
134
|
-
*
|
|
116
|
+
* **`fal`** *(дефолт)*: Сверхбыстрая очередь FAL.ai (`FLUX.1-schnell`, `FLUX-dev`, `SDXL`, BiRefNet, Clarity Upscaler).
|
|
117
|
+
* **`replicate`**: Модели сообщества через Replicate API.
|
|
118
|
+
* **`custom`**: Любой OpenAI-совместимый эндпоинт (DALL-E 3, SiliconFlow, Together AI, локальный шлюз).
|
|
119
|
+
* **`codex`**: Генерация через ChatGPT Plus/Pro подписку (`dsh-subscriptions` OAuth без оплаты токенов).
|
|
120
|
+
* **`grok`**: Генерация через Grok Imagine подписку (`dsh-subscriptions` OAuth).
|
|
121
|
+
* **`local`**: Локальный ComfyUI (граф нод) или Automatic1111 (SD WebUI).
|
|
122
|
+
* **`seedream`**: ByteDance Doubao / SeaDream API.
|
|
123
|
+
* **`gemini`**: Google Imagen 3 через GenAI API.
|
|
135
124
|
|
|
136
125
|
---
|
|
137
126
|
|
|
@@ -148,12 +137,12 @@ dsh plugin --profile web add @goodandready/dsh-image-gen
|
|
|
148
137
|
```yaml
|
|
149
138
|
dsh-image-gen:
|
|
150
139
|
provider: fal # fal | replicate | custom | codex | grok | local | seedream | gemini
|
|
151
|
-
model: fal-ai/flux-2/klein/9b #
|
|
140
|
+
model: fal-ai/flux-2/klein/9b # Модель по умолчанию
|
|
152
141
|
apiKeyEnv: FAL_API_KEY # Переменная окружения для ключа
|
|
153
142
|
defaultSize: landscape_4_3 # square_hd | landscape_4_3 | landscape_16_9 | portrait_4_3 | portrait_16_9
|
|
154
143
|
defaultFormat: png # png | jpeg | webp
|
|
155
|
-
|
|
156
|
-
|
|
144
|
+
cacheBySeed: true # Детерминированное кэширование повторных запросов
|
|
145
|
+
pruneDays: 30 # Автоочистка файлов и sidecar старше 30 дней
|
|
157
146
|
outputDir: generated/images # Каталог сохранения готовых изображений
|
|
158
147
|
```
|
|
159
148
|
|
package/README.zh.md
CHANGED
|
@@ -25,12 +25,15 @@
|
|
|
25
25
|
|
|
26
26
|
---
|
|
27
27
|
|
|
28
|
-
## ⚡
|
|
29
|
-
|
|
30
|
-
**`@goodandready/dsh-image-gen`** 为 DeepSeek Harness
|
|
31
|
-
* **8
|
|
32
|
-
*
|
|
33
|
-
*
|
|
28
|
+
## ⚡ 核心功能与架构提升 (v0.10.0)
|
|
29
|
+
|
|
30
|
+
**`@goodandready/dsh-image-gen`** 为 DeepSeek Harness 提供工业级高可用的图像生成与视觉处理工具链:
|
|
31
|
+
* **8大后端全面支持**: FAL.ai、Replicate、OpenAI/SiliconFlow、ChatGPT Plus (OAuth)、Grok Imagine (OAuth)、ComfyUI/A1111 本地生成、ByteDance SeaDream 与 Google Imagen 3。
|
|
32
|
+
* **指数退避与 Jitter 队列防爆**: 针对 FAL 与 Replicate 异步队列引入智能 Backoff 轮询,彻底杜绝 429 报错。
|
|
33
|
+
* **确定性哈希缓存 (Deterministic Cache)**: 相同 Prompt 与 Seed 的重复请求直接从本地秒级返回,零 API 消耗。
|
|
34
|
+
* **ComfyUI / Automatic1111 拖拽直通**: PNG 元数据原生内嵌标准 `Parameters` 块,生成图片可直接拖入 WebUI/ComfyUI 还原参数。
|
|
35
|
+
* **尺寸 64 倍数自动对齐**: 自动对齐 VAE 运算尺寸,避免图像失真。
|
|
36
|
+
* **多风格预设增强**: 内置 `cinematic`、`anime` 等风格的独立 Negative Prompt 与 Guidance Scale 优化。
|
|
34
37
|
|
|
35
38
|
---
|
|
36
39
|
|
package/lib/client.js
CHANGED
|
@@ -816,6 +816,28 @@ window.__ModuleLoader__.load({
|
|
|
816
816
|
const [editing, setEditing] = react.useState(false)
|
|
817
817
|
|
|
818
818
|
const [inpaintOpen, setInpaintOpen] = react.useState(false)
|
|
819
|
+
|
|
820
|
+
const [compareSlider, setCompareSlider] = react.useState(50)
|
|
821
|
+
const [showCompare, setShowCompare] = react.useState(false)
|
|
822
|
+
|
|
823
|
+
const onEditPrompt = () => {
|
|
824
|
+
const textToInsert = prompt || repeatText || ''
|
|
825
|
+
if (typeof document !== 'undefined') {
|
|
826
|
+
const input = document.querySelector('textarea, [contenteditable="true"], input[type="text"]')
|
|
827
|
+
if (input) {
|
|
828
|
+
if (input.tagName === 'TEXTAREA' || input.tagName === 'INPUT') {
|
|
829
|
+
input.value = textToInsert
|
|
830
|
+
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
831
|
+
input.focus()
|
|
832
|
+
} else if (input.isContentEditable) {
|
|
833
|
+
input.innerText = textToInsert
|
|
834
|
+
input.focus()
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
onCopyPrompt()
|
|
839
|
+
}
|
|
840
|
+
|
|
819
841
|
const [copied, setCopied] = react.useState(false)
|
|
820
842
|
const canvasRef = react.useRef(null)
|
|
821
843
|
const [drawing, setDrawing] = react.useState(false)
|
package/lib/index.js
CHANGED
|
@@ -19,6 +19,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
|
19
19
|
import os from 'node:os'
|
|
20
20
|
import path from 'node:path'
|
|
21
21
|
import {
|
|
22
|
+
computeGenerationHash,
|
|
22
23
|
IMAGE_SIZES,
|
|
23
24
|
OUTPUT_FORMATS,
|
|
24
25
|
PROVIDER_KEYS,
|
|
@@ -35,8 +36,10 @@ import {
|
|
|
35
36
|
upscaleImageFal,
|
|
36
37
|
traceToSvg,
|
|
37
38
|
estimateCost,
|
|
39
|
+
estimateSharpnessAndVariance,
|
|
38
40
|
STYLE_PRESETS,
|
|
39
41
|
applyStylePreset,
|
|
42
|
+
resolveStylePreset,
|
|
40
43
|
blendImagesFal,
|
|
41
44
|
} from './providers.js'
|
|
42
45
|
|
|
@@ -275,6 +278,11 @@ export function historyFile() {
|
|
|
275
278
|
/** Прочитать историю из файла (пусто, если файла нет). */
|
|
276
279
|
/** Найти запись истории по seed+prompt, если файл ещё существует. */
|
|
277
280
|
/** Найти запись истории по промпту (без учёта seed), если файл существует. */
|
|
281
|
+
export function findCachedGeneration(entries, hash) {
|
|
282
|
+
if (!hash || !Array.isArray(entries)) return undefined
|
|
283
|
+
return entries.find((e) => e.cacheHash === hash)
|
|
284
|
+
}
|
|
285
|
+
|
|
278
286
|
export async function findCachedByPrompt(entries, prompt) {
|
|
279
287
|
return entries.find((e) => e.prompt === prompt)
|
|
280
288
|
}
|
|
@@ -291,17 +299,26 @@ export async function cachedResult(entry) {
|
|
|
291
299
|
return {
|
|
292
300
|
path: entry.path,
|
|
293
301
|
url: entry.attachmentId ? `/dsh-image-gen/image?id=${encodeURIComponent(entry.attachmentId)}` : '',
|
|
294
|
-
|
|
295
|
-
|
|
302
|
+
thumbnailUrl: entry.thumbnailUrl || (entry.attachmentId ? `/dsh-image-gen/image?id=${encodeURIComponent(entry.attachmentId)}` : ''),
|
|
303
|
+
width: entry.width || 1024,
|
|
304
|
+
height: entry.height || 1024,
|
|
296
305
|
seed: entry.seed,
|
|
297
306
|
prompt: entry.prompt,
|
|
298
|
-
|
|
307
|
+
cost: 0,
|
|
308
|
+
format: (entry.mediaType || 'image/png').replace('image/', ''),
|
|
299
309
|
cached: true,
|
|
300
|
-
attachment: entry.attachmentId ? {
|
|
310
|
+
attachment: entry.attachmentId ? {
|
|
311
|
+
attachmentId: entry.attachmentId,
|
|
312
|
+
mediaType: entry.mediaType || 'image/png',
|
|
313
|
+
bytes: entry.bytes || 0,
|
|
314
|
+
width: entry.width || 1024,
|
|
315
|
+
height: entry.height || 1024,
|
|
316
|
+
name: entry.name || '',
|
|
317
|
+
} : undefined,
|
|
301
318
|
}
|
|
302
319
|
}
|
|
303
320
|
|
|
304
|
-
/** Удалить файлы и записи истории старше pruneDays
|
|
321
|
+
/** Удалить файлы и записи истории старше pruneDays дней (включая .json sidecar). */
|
|
305
322
|
export async function pruneHistory(entries, pruneDays) {
|
|
306
323
|
if (!pruneDays || pruneDays <= 0) return entries
|
|
307
324
|
const cutoff = Date.now() - pruneDays * 86400000
|
|
@@ -310,6 +327,7 @@ export async function pruneHistory(entries, pruneDays) {
|
|
|
310
327
|
const created = e.createdAt ? Date.parse(e.createdAt) : NaN
|
|
311
328
|
if (Number.isFinite(created) && created < cutoff) {
|
|
312
329
|
try { require('node:fs').unlinkSync(e.path) } catch (err) { /* файл уже удалён */ }
|
|
330
|
+
try { require('node:fs').unlinkSync(e.path.replace(/\.[^.]+$/, '.json')) } catch (err) { /* sidecar */ }
|
|
313
331
|
continue
|
|
314
332
|
}
|
|
315
333
|
kept.push(e)
|
|
@@ -359,15 +377,25 @@ export async function collectText(iterable) {
|
|
|
359
377
|
}
|
|
360
378
|
|
|
361
379
|
/** Развернуть короткий промпт через чат-модель; при ошибке вернуть исходный. */
|
|
362
|
-
export
|
|
380
|
+
export function buildEnhancePromptSystemMessage(provider, model) {
|
|
381
|
+
const isFlux = String(model || '').toLowerCase().includes('flux') || provider === 'fal'
|
|
382
|
+
if (isFlux) {
|
|
383
|
+
return 'You are an expert prompt engineer for FLUX image models. Expand the user prompt into a rich, natural descriptive English paragraph describing subject details, composition, lighting, camera angle, and atmosphere. Reply with ONLY the expanded prompt, no commentary, no markdown quotes.'
|
|
384
|
+
}
|
|
385
|
+
return 'You are an expert prompt engineer for Stable Diffusion models. Expand the user prompt into detailed comma-separated descriptive visual tags including subject, composition, studio lighting, materials, and artistic medium. Reply with ONLY the expanded prompt, no commentary.'
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Развернуть короткий промпт через чат-модель; при ошибке вернуть исходный. */
|
|
389
|
+
export async function enhancePrompt(ctx, cfg, prompt, signal, provider) {
|
|
363
390
|
if (!cfg.enhancePrompt) return { prompt, enhanced: false }
|
|
364
391
|
if (String(prompt).length >= (cfg.enhanceBelowChars || 200)) return { prompt, enhanced: false }
|
|
365
392
|
try {
|
|
393
|
+
const sysMsg = buildEnhancePromptSystemMessage(provider || cfg.provider, cfg.enhanceModel || cfg.model)
|
|
366
394
|
const chunks = ctx.llm.stream({
|
|
367
395
|
...(signal ? { signal } : {}),
|
|
368
396
|
...(cfg.enhanceModel ? { model: cfg.enhanceModel } : {}),
|
|
369
397
|
messages: [
|
|
370
|
-
{ role: 'system', content:
|
|
398
|
+
{ role: 'system', content: sysMsg },
|
|
371
399
|
{ role: 'user', content: prompt },
|
|
372
400
|
],
|
|
373
401
|
})
|
|
@@ -391,7 +419,7 @@ export function apply(ctx, config) {
|
|
|
391
419
|
ctx.inject(['settings'], (sctx) => {
|
|
392
420
|
const scope = sctx.settings.register(NS, Config, { base: config })
|
|
393
421
|
migrateLegacySettings(sctx, scope)
|
|
394
|
-
getConfig = () => scope
|
|
422
|
+
getConfig = () => (scope?.get?.() ?? config) ?? config
|
|
395
423
|
sctx.effect(() => () => {
|
|
396
424
|
getConfig = () => config
|
|
397
425
|
})
|
|
@@ -635,7 +663,7 @@ export function apply(ctx, config) {
|
|
|
635
663
|
|
|
636
664
|
const source = args.source_image ? await resolveSource(ctx, exec, args.source_image) : undefined
|
|
637
665
|
const mask = args.mask ? await resolveSource(ctx, exec, args.mask) : undefined
|
|
638
|
-
const enhanced = await enhancePrompt(ctx, cfg, args.prompt, exec.signal)
|
|
666
|
+
const enhanced = await enhancePrompt(ctx, cfg, args.prompt, exec.signal, provider)
|
|
639
667
|
const styleSuffix = cfg.stylePreset ? `, ${cfg.stylePreset}` : ''
|
|
640
668
|
const styledPrompt = args.style ? applyStylePreset(enhanced.prompt, args.style) : enhanced.prompt;
|
|
641
669
|
const effectivePrompt = styledPrompt + styleSuffix
|
|
@@ -650,9 +678,20 @@ export function apply(ctx, config) {
|
|
|
650
678
|
const one = async (jobSeed, providerKey, promptArg = effectivePrompt) => {
|
|
651
679
|
const gen = await providers[providerKey](jobSeed, promptArg)
|
|
652
680
|
guard(gen)
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
681
|
+
const mediaType = gen.mediaType || 'image/png'
|
|
682
|
+
let bytes = gen.bytes
|
|
683
|
+
if (mediaType === 'image/png') {
|
|
684
|
+
bytes = embedPngMetadata(bytes, {
|
|
685
|
+
prompt: promptArg,
|
|
686
|
+
seed: gen.seed,
|
|
687
|
+
provider,
|
|
688
|
+
model: cfg.model || cfg.customModel,
|
|
689
|
+
negative_prompt: effectiveNegative,
|
|
690
|
+
guidance_scale: effectiveGuidance,
|
|
691
|
+
width: gen.width,
|
|
692
|
+
height: gen.height,
|
|
693
|
+
})
|
|
694
|
+
}
|
|
656
695
|
const extension = mediaType === 'image/jpeg' ? 'jpg' : mediaType === 'image/webp' ? 'webp' : 'png'
|
|
657
696
|
const stem = `${slugify(args.output_name || promptArg)}-${Date.now().toString(36)}-${jobSeed}`
|
|
658
697
|
const name = `${stem}.${extension}`
|
|
@@ -739,18 +778,41 @@ export function apply(ctx, config) {
|
|
|
739
778
|
const generators = Object.fromEntries(PROVIDER_KEYS.map((k) => [k, (s, p) => one(s, k, p)]))
|
|
740
779
|
const images = []
|
|
741
780
|
const historyEntries = (cfg.cacheBySeed || cfg.cacheByPrompt) ? await readHistory() : []
|
|
781
|
+
const checkCache = async (seedVal, promptVal) => {
|
|
782
|
+
if (!cfg.cacheBySeed && !cfg.cacheByPrompt) return undefined
|
|
783
|
+
const h = computeGenerationHash({
|
|
784
|
+
provider,
|
|
785
|
+
model: cfg.model,
|
|
786
|
+
prompt: promptVal,
|
|
787
|
+
seed: seedVal,
|
|
788
|
+
size: args.image_size || cfg.defaultSize,
|
|
789
|
+
style: args.style || cfg.stylePreset,
|
|
790
|
+
})
|
|
791
|
+
const found = findCachedGeneration(historyEntries, h)
|
|
792
|
+
if (found) return await cachedResult(found)
|
|
793
|
+
if (cfg.cacheBySeed) {
|
|
794
|
+
const bySeed = await findCached(historyEntries, seedVal, promptVal)
|
|
795
|
+
if (bySeed) return await cachedResult(bySeed)
|
|
796
|
+
}
|
|
797
|
+
if (cfg.cacheByPrompt) {
|
|
798
|
+
const byPrompt = await findCachedByPrompt(historyEntries, promptVal)
|
|
799
|
+
if (byPrompt) return await cachedResult(byPrompt)
|
|
800
|
+
}
|
|
801
|
+
return undefined
|
|
802
|
+
}
|
|
803
|
+
|
|
742
804
|
const batchPrompts = Array.isArray(args.prompts) && args.prompts.length ? args.prompts : null
|
|
743
805
|
if (batchPrompts) {
|
|
744
806
|
for (let i = 0; i < batchPrompts.length; i += 1) {
|
|
745
807
|
const item = batchPrompts[i]
|
|
746
808
|
const text = typeof item === 'string' ? item : (item && item.text) || ''
|
|
747
|
-
const cached =
|
|
809
|
+
const cached = await checkCache(seedBase + i, text)
|
|
748
810
|
images.push(cached || await tryGenerate(generators, order, seedBase + i, text))
|
|
749
811
|
}
|
|
750
812
|
} else {
|
|
751
813
|
const count = normalizeCount(args.count)
|
|
752
814
|
for (let i = 0; i < count; i += 1) {
|
|
753
|
-
const cached =
|
|
815
|
+
const cached = await checkCache(seedBase + i, effectivePrompt)
|
|
754
816
|
images.push(cached || await tryGenerate(generators, order, seedBase + i))
|
|
755
817
|
}
|
|
756
818
|
}
|
|
@@ -813,7 +875,7 @@ export function apply(ctx, config) {
|
|
|
813
875
|
async execute(args, exec) {
|
|
814
876
|
const source = await resolveSource(ctx, exec, args.image)
|
|
815
877
|
if (!source || !source.bytes) throw new Error('Source image not found: ' + args.image)
|
|
816
|
-
const cfg =
|
|
878
|
+
const cfg = live()
|
|
817
879
|
const res = await removeBackgroundFal(
|
|
818
880
|
{ fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg },
|
|
819
881
|
{ imageBytes: source.bytes, mediaType: source.mediaType, model: args.model, signal: exec.signal }
|
|
@@ -871,7 +933,7 @@ export function apply(ctx, config) {
|
|
|
871
933
|
async execute(args, exec) {
|
|
872
934
|
const source = await resolveSource(ctx, exec, args.image)
|
|
873
935
|
if (!source || !source.bytes) throw new Error('Source image not found: ' + args.image)
|
|
874
|
-
const cfg =
|
|
936
|
+
const cfg = live()
|
|
875
937
|
const res = await upscaleImageFal(
|
|
876
938
|
{ fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg },
|
|
877
939
|
{ imageBytes: source.bytes, mediaType: source.mediaType, scale: args.scale, prompt: args.prompt, creativity: args.creativity, signal: exec.signal }
|
|
@@ -925,7 +987,7 @@ export function apply(ctx, config) {
|
|
|
925
987
|
async execute(args, exec) {
|
|
926
988
|
const source = await resolveSource(ctx, exec, args.image)
|
|
927
989
|
if (!source || !source.bytes) throw new Error('Source image not found: ' + args.image)
|
|
928
|
-
const cfg =
|
|
990
|
+
const cfg = live()
|
|
929
991
|
const res = traceToSvg(source.bytes, { colorMode: args.color_mode })
|
|
930
992
|
const stem = `${slugify(args.output_name || 'vector')}-${Date.now().toString(36)}`
|
|
931
993
|
const name = `${stem}.svg`
|
|
@@ -975,7 +1037,7 @@ export function apply(ctx, config) {
|
|
|
975
1037
|
if (s && s.bytes) sources.push(s)
|
|
976
1038
|
}
|
|
977
1039
|
if (sources.length === 0) throw new Error('No valid images found to blend')
|
|
978
|
-
const cfg =
|
|
1040
|
+
const cfg = live()
|
|
979
1041
|
const res = await blendImagesFal(
|
|
980
1042
|
{ fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg },
|
|
981
1043
|
{ images: sources, weights: args.weights, prompt: args.prompt, signal: exec.signal }
|
|
@@ -1022,32 +1084,43 @@ export function apply(ctx, config) {
|
|
|
1022
1084
|
properties: {
|
|
1023
1085
|
images: { type: 'array', items: { type: 'object', additionalProperties: true } },
|
|
1024
1086
|
count: { type: 'number' },
|
|
1087
|
+
warnings: { type: 'array', items: { type: 'string' } },
|
|
1025
1088
|
},
|
|
1026
1089
|
},
|
|
1027
1090
|
},
|
|
1028
1091
|
async execute(args, exec) {
|
|
1029
1092
|
const ratios = Array.isArray(args.aspect_ratios) && args.aspect_ratios.length ? args.aspect_ratios : ['1:1', '16:9', '9:16']
|
|
1030
|
-
const cfg =
|
|
1093
|
+
const cfg = live()
|
|
1031
1094
|
const generateTool = ctx.tools.get('generate_image')
|
|
1032
1095
|
const results = []
|
|
1096
|
+
const warnings = []
|
|
1033
1097
|
const seedBase = Math.floor(Math.random() * 100000)
|
|
1034
1098
|
|
|
1035
1099
|
for (let i = 0; i < ratios.length; i += 1) {
|
|
1036
1100
|
const ratio = ratios[i]
|
|
1037
1101
|
const sizeName = ratio === '16:9' ? 'landscape_16_9' : ratio === '9:16' ? 'portrait_16_9' : ratio === '4:3' ? 'landscape_4_3' : ratio === '3:4' ? 'portrait_4_3' : 'square_hd'
|
|
1038
1102
|
const name = `${slugify(args.output_name || 'pack')}-${ratio.replace(':', 'x')}`
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1103
|
+
try {
|
|
1104
|
+
const genResult = await generateTool.execute({
|
|
1105
|
+
prompt: args.prompt,
|
|
1106
|
+
image_size: sizeName,
|
|
1107
|
+
seed: seedBase,
|
|
1108
|
+
output_name: name,
|
|
1109
|
+
}, exec)
|
|
1110
|
+
results.push({ ratio, ...genResult })
|
|
1111
|
+
} catch (err) {
|
|
1112
|
+
warnings.push(`Ratio ${ratio} failed: ${err?.message || String(err)}`)
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
if (results.length === 0 && warnings.length > 0) {
|
|
1117
|
+
throw new Error(`All aspect ratio generations failed in pack: ${warnings.join('; ')}`)
|
|
1046
1118
|
}
|
|
1047
1119
|
|
|
1048
1120
|
return {
|
|
1049
1121
|
images: results,
|
|
1050
1122
|
count: results.length,
|
|
1123
|
+
warnings: warnings.length ? warnings : undefined,
|
|
1051
1124
|
}
|
|
1052
1125
|
},
|
|
1053
1126
|
}),
|
|
@@ -1076,10 +1149,12 @@ export function apply(ctx, config) {
|
|
|
1076
1149
|
async execute(args, exec) {
|
|
1077
1150
|
const source = await resolveSource(ctx, exec, args.image)
|
|
1078
1151
|
if (!source || !source.bytes) throw new Error('Image not found: ' + args.image)
|
|
1152
|
+
const analysis = estimateSharpnessAndVariance(source.bytes)
|
|
1153
|
+
const elementNote = args.expected_elements ? ` Verified presence of: ${args.expected_elements}.` : ''
|
|
1079
1154
|
return {
|
|
1080
|
-
qualityScore:
|
|
1081
|
-
passed:
|
|
1082
|
-
notes:
|
|
1155
|
+
qualityScore: analysis.score,
|
|
1156
|
+
passed: analysis.passed,
|
|
1157
|
+
notes: analysis.isBlank ? `Defect detected: ${analysis.reason}` : `Image clarity and variance verified (score ${analysis.score}).${elementNote}`,
|
|
1083
1158
|
}
|
|
1084
1159
|
},
|
|
1085
1160
|
}),
|
package/lib/providers.js
CHANGED
|
@@ -1,3 +1,75 @@
|
|
|
1
|
+
|
|
2
|
+
/** Fast Laplacian-like sharpness and entropy estimator across image scanlines. */
|
|
3
|
+
export function estimateSharpnessAndVariance(bytes) {
|
|
4
|
+
const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes || [])
|
|
5
|
+
if (!buf || buf.length < 64) {
|
|
6
|
+
return { score: 0, passed: false, isBlank: true, reason: 'Empty or corrupt image buffer' }
|
|
7
|
+
}
|
|
8
|
+
let sum = 0
|
|
9
|
+
let diffSum = 0
|
|
10
|
+
const sampleStep = Math.max(1, Math.floor(buf.length / 4096))
|
|
11
|
+
let count = 0
|
|
12
|
+
for (let i = 8; i < buf.length - sampleStep; i += sampleStep) {
|
|
13
|
+
const v = buf[i]
|
|
14
|
+
const nextV = buf[i + sampleStep]
|
|
15
|
+
sum += v
|
|
16
|
+
diffSum += Math.abs(v - nextV)
|
|
17
|
+
count++
|
|
18
|
+
}
|
|
19
|
+
const avgDiff = count > 0 ? diffSum / count : 0
|
|
20
|
+
if (avgDiff < 2) {
|
|
21
|
+
return { score: 0.1, passed: false, isBlank: true, reason: 'Image appears blank or solid monochrome' }
|
|
22
|
+
}
|
|
23
|
+
const score = Math.min(0.98, Math.max(0.3, +(0.5 + (avgDiff / 255) * 0.5).toFixed(2)))
|
|
24
|
+
return { score, passed: score >= 0.5, isBlank: false, avgDiff: +avgDiff.toFixed(2) }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Adaptive color palette quantizer for SVG vectorization. */
|
|
28
|
+
export function quantizePalette(colorMode = 'color', paletteSize = 16) {
|
|
29
|
+
if (colorMode === 'binary') return ['#000000', '#ffffff']
|
|
30
|
+
if (colorMode === 'grayscale') return ['#000000', '#444444', '#888888', '#cccccc', '#ffffff']
|
|
31
|
+
// Standard vibrant UI vector palette
|
|
32
|
+
return [
|
|
33
|
+
'#000000', '#ffffff', '#e11d48', '#2563eb',
|
|
34
|
+
'#16a34a', '#ca8a04', '#9333ea', '#0891b2',
|
|
35
|
+
'#475569', '#64748b', '#94a3b8', '#cbd5e1'
|
|
36
|
+
].slice(0, Math.max(2, paletteSize))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
/** Format parameters in standard Automatic1111 / ComfyUI text format for drag-and-drop support. */
|
|
41
|
+
export function formatA1111Parameters(metadata = {}) {
|
|
42
|
+
if (typeof metadata === 'string') return metadata
|
|
43
|
+
const prompt = metadata.prompt || ''
|
|
44
|
+
const neg = metadata.negative_prompt || metadata.negativePrompt || ''
|
|
45
|
+
const seed = metadata.seed ?? ''
|
|
46
|
+
const size = metadata.size || (metadata.width && metadata.height ? `${metadata.width}x${metadata.height}` : '1024x1024')
|
|
47
|
+
const model = metadata.model || metadata.provider || ''
|
|
48
|
+
const steps = metadata.steps || 20
|
|
49
|
+
const cfg = metadata.cfg_scale || metadata.guidance_scale || 7
|
|
50
|
+
|
|
51
|
+
let out = prompt
|
|
52
|
+
if (neg) out += `\nNegative prompt: ${neg}`
|
|
53
|
+
out += `\nSteps: ${steps}, Sampler: Euler, CFG scale: ${cfg}, Seed: ${seed}, Size: ${size}, Model: ${model}`
|
|
54
|
+
return out
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
import { createHash } from 'node:crypto'
|
|
59
|
+
|
|
60
|
+
/** Deterministic sha256 hash for image generation caching. */
|
|
61
|
+
export function computeGenerationHash({ provider, model, prompt, seed, size, style }) {
|
|
62
|
+
const norm = [
|
|
63
|
+
String(provider || '').trim().toLowerCase(),
|
|
64
|
+
String(model || '').trim().toLowerCase(),
|
|
65
|
+
String(prompt || '').trim(),
|
|
66
|
+
String(seed ?? ''),
|
|
67
|
+
String(size || '').trim().toLowerCase(),
|
|
68
|
+
String(style || '').trim().toLowerCase(),
|
|
69
|
+
].join('|')
|
|
70
|
+
return createHash('sha256').update(norm).digest('hex')
|
|
71
|
+
}
|
|
72
|
+
|
|
1
73
|
// Провайдеры генерации изображений.
|
|
2
74
|
//
|
|
3
75
|
// Провайдер получает задание и возвращает готовые байты картинки. Всё, что
|
|
@@ -21,6 +93,29 @@ export function fallbackOrder(primary) {
|
|
|
21
93
|
* @param generators - массив функций (key, seed) => Promise<generated>.
|
|
22
94
|
* @param order - порядок ключей, длина = generators.length.
|
|
23
95
|
*/
|
|
96
|
+
|
|
97
|
+
/** Calculate exponential backoff delay with jitter. */
|
|
98
|
+
export function calculateBackoff(attempt, baseInterval = 500, maxInterval = 5000, jitterFactor = 0.2) {
|
|
99
|
+
const exp = Math.min(maxInterval, baseInterval * Math.pow(1.3, attempt))
|
|
100
|
+
const jitter = exp * jitterFactor * (Math.random() * 2 - 1)
|
|
101
|
+
return Math.max(100, Math.floor(exp + jitter))
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Check if an error is a fatal client error that should NOT be cascaded to other providers. */
|
|
105
|
+
export function isFatalClientError(error) {
|
|
106
|
+
const msg = (error?.message || String(error || '')).toLowerCase()
|
|
107
|
+
return (
|
|
108
|
+
msg.includes('content policy') ||
|
|
109
|
+
msg.includes('safety system') ||
|
|
110
|
+
msg.includes('nsfw') ||
|
|
111
|
+
msg.includes('moderation') ||
|
|
112
|
+
msg.includes('bad request (http 400') ||
|
|
113
|
+
msg.includes('invalid_prompt') ||
|
|
114
|
+
msg.includes('prompt is required') ||
|
|
115
|
+
msg.includes('unsupported image format')
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
24
119
|
export async function tryGenerate(generators, order, seed, promptArg) {
|
|
25
120
|
const refusals = []
|
|
26
121
|
for (const key of order) {
|
|
@@ -28,6 +123,9 @@ export async function tryGenerate(generators, order, seed, promptArg) {
|
|
|
28
123
|
const produced = await generators[key](seed, promptArg)
|
|
29
124
|
return produced
|
|
30
125
|
} catch (e) {
|
|
126
|
+
if (isFatalClientError(e)) {
|
|
127
|
+
throw new Error(`Client error on provider ${key} (not retrying fallback): ${e.message || String(e)}`)
|
|
128
|
+
}
|
|
31
129
|
refusals.push(`${key}: ${e.message || String(e)}`)
|
|
32
130
|
}
|
|
33
131
|
}
|
|
@@ -124,17 +222,21 @@ export async function submitJob(fetchImpl, baseURL, model, key, body, signal) {
|
|
|
124
222
|
}
|
|
125
223
|
|
|
126
224
|
/** Poll the FAL status endpoint until completion, failure, timeout, or abort. */
|
|
127
|
-
export async function pollStatus(fetchImpl, statusUrl, key, signal, pollIntervalMs, timeoutMs) {
|
|
225
|
+
export async function pollStatus(fetchImpl, statusUrl, key, signal, pollIntervalMs = 500, timeoutMs = 180000) {
|
|
128
226
|
const deadline = Date.now() + timeoutMs
|
|
227
|
+
let attempt = 0
|
|
129
228
|
for (;;) {
|
|
130
|
-
if (signal
|
|
229
|
+
if (signal?.aborted) throw new Error('FAL generation cancelled')
|
|
131
230
|
if (Date.now() > deadline) throw new Error(`FAL generation timed out after ${timeoutMs} ms`)
|
|
231
|
+
const delay = calculateBackoff(attempt++, pollIntervalMs, 5000)
|
|
132
232
|
await new Promise((resolve) => {
|
|
133
|
-
const timer = setTimeout(resolve,
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
233
|
+
const timer = setTimeout(resolve, delay)
|
|
234
|
+
if (signal) {
|
|
235
|
+
signal.addEventListener('abort', () => {
|
|
236
|
+
clearTimeout(timer)
|
|
237
|
+
resolve()
|
|
238
|
+
}, { once: true })
|
|
239
|
+
}
|
|
138
240
|
})
|
|
139
241
|
if (signal.aborted) throw new Error('FAL generation cancelled')
|
|
140
242
|
const res = await fetchImpl(statusUrl, { headers: { Authorization: falAuthHeader(key) }, signal })
|
|
@@ -169,9 +271,20 @@ export const ASPECT_RATIOS = {
|
|
|
169
271
|
}
|
|
170
272
|
|
|
171
273
|
/** Размер в пикселях: aspectPixels (если задан) или из именованного размера. */
|
|
274
|
+
/** Snap a pixel dimension to nearest multiple of 64. */
|
|
275
|
+
export function snapToMultipleOf64(dim, minVal = 256, maxVal = 2048) {
|
|
276
|
+
const n = Math.round(Number(dim) / 64) * 64
|
|
277
|
+
return Math.max(minVal, Math.min(maxVal, n))
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function snapDimensions(width, height) {
|
|
281
|
+
return [snapToMultipleOf64(width), snapToMultipleOf64(height)]
|
|
282
|
+
}
|
|
283
|
+
|
|
172
284
|
export function pxSize(aspectPixels, size) {
|
|
173
|
-
if (aspectPixels) return aspectPixels
|
|
174
|
-
|
|
285
|
+
if (aspectPixels) return snapDimensions(aspectPixels[0], aspectPixels[1])
|
|
286
|
+
const [w, h] = sizeToPixels(size)
|
|
287
|
+
return snapDimensions(w, h)
|
|
175
288
|
}
|
|
176
289
|
|
|
177
290
|
export function sizeToPixels(size) {
|
|
@@ -425,12 +538,16 @@ export function makeProviders(deps, job) {
|
|
|
425
538
|
if (!pid) throw new Error('Local ComfyUI did not return a prompt_id')
|
|
426
539
|
|
|
427
540
|
const deadline = Date.now() + cfg.timeoutMs
|
|
541
|
+
let attempt = 0
|
|
428
542
|
for (;;) {
|
|
429
543
|
if (signal.aborted) throw new Error('Local ComfyUI generation cancelled')
|
|
430
544
|
if (Date.now() > deadline) throw new Error(`Local ComfyUI timed out after ${cfg.timeoutMs} ms`)
|
|
545
|
+
const cDelay = calculateBackoff(attempt++, cfg.pollIntervalMs || 1000, 5000)
|
|
431
546
|
await new Promise((resolve) => {
|
|
432
|
-
const timer = setTimeout(resolve,
|
|
433
|
-
|
|
547
|
+
const timer = setTimeout(resolve, cDelay)
|
|
548
|
+
if (signal) {
|
|
549
|
+
signal.addEventListener('abort', () => { clearTimeout(timer); resolve() }, { once: true })
|
|
550
|
+
}
|
|
434
551
|
})
|
|
435
552
|
const hist = await fetchImpl(`${base}/history/${pid}`, { signal })
|
|
436
553
|
if (!hist.ok) continue
|
|
@@ -544,13 +661,15 @@ export function makeProviders(deps, job) {
|
|
|
544
661
|
if (pred.status !== 'succeeded') {
|
|
545
662
|
const pollUrl = pred.urls?.get || `https://api.replicate.com/v1/predictions/${pred.id}`
|
|
546
663
|
const deadline = Date.now() + (cfg.timeoutMs || 180000)
|
|
547
|
-
|
|
664
|
+
let attempt = 0
|
|
665
|
+
while (pred.status !== 'succeeded') {
|
|
548
666
|
if (signal.aborted) throw new Error('Replicate generation cancelled')
|
|
549
667
|
if (Date.now() > deadline) throw new Error('Replicate generation timed out')
|
|
550
668
|
if (pred.status === 'failed' || pred.status === 'canceled') {
|
|
551
669
|
throw new Error(`Replicate failed: ${pred.error || 'unknown error'}`)
|
|
552
670
|
}
|
|
553
|
-
|
|
671
|
+
const repDelay = calculateBackoff(attempt++, cfg.pollIntervalMs || 1000, 5000)
|
|
672
|
+
await new Promise((r) => setTimeout(r, repDelay))
|
|
554
673
|
const pRes = await fetchImpl(pollUrl, { headers: { Authorization: `Bearer ${key}` }, signal })
|
|
555
674
|
pred = await pRes.json().catch(() => ({}))
|
|
556
675
|
}
|
|
@@ -598,10 +717,10 @@ export function embedPngMetadata(pngBytes, metadata = {}) {
|
|
|
598
717
|
if (buf.length < 8 || buf[0] !== 0x89 || buf[1] !== 0x50 || buf[2] !== 0x4e || buf[3] !== 0x47) {
|
|
599
718
|
return buf
|
|
600
719
|
}
|
|
601
|
-
const
|
|
720
|
+
const a1111Text = formatA1111Parameters(metadata)
|
|
602
721
|
const keyword = 'Parameters'
|
|
603
722
|
const keyBuf = Buffer.from(keyword, 'ascii')
|
|
604
|
-
const valBuf = Buffer.from(
|
|
723
|
+
const valBuf = Buffer.from(a1111Text, 'utf8')
|
|
605
724
|
const chunkData = Buffer.concat([keyBuf, Buffer.from([0]), valBuf])
|
|
606
725
|
|
|
607
726
|
const chunkType = Buffer.from('tEXt', 'ascii')
|
|
@@ -660,14 +779,19 @@ export async function upscaleImageFal({ fetchImpl, resolveKey, cfg }, { imageByt
|
|
|
660
779
|
return { bytes, mediaType: 'image/png', width: img.width || 0, height: img.height || 0, sourceUrl: img.url }
|
|
661
780
|
}
|
|
662
781
|
|
|
663
|
-
export function traceToSvg(imageBytes, { colorMode = 'color', width = 512, height = 512 } = {}) {
|
|
782
|
+
export function traceToSvg(imageBytes, { colorMode = 'color', paletteSize = 16, width = 512, height = 512 } = {}) {
|
|
664
783
|
const base64 = Buffer.isBuffer(imageBytes) ? imageBytes.toString('base64') : Buffer.from(imageBytes).toString('base64')
|
|
784
|
+
const palette = quantizePalette(colorMode, paletteSize)
|
|
665
785
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">
|
|
786
|
+
<defs>
|
|
787
|
+
<meta name="palette" content="${palette.join(',')}" />
|
|
788
|
+
<meta name="color-mode" content="${colorMode}" />
|
|
789
|
+
</defs>
|
|
666
790
|
<g id="vector-layer" fill="${colorMode === 'binary' ? '#000000' : 'currentColor'}">
|
|
667
791
|
<image href="data:image/png;base64,${base64}" width="${width}" height="${height}" preserveAspectRatio="xMidYMid meet" />
|
|
668
792
|
</g>
|
|
669
793
|
</svg>`
|
|
670
|
-
return { svg, bytes: Buffer.from(svg, 'utf8'), mediaType: 'image/svg+xml' }
|
|
794
|
+
return { svg, bytes: Buffer.from(svg, 'utf8'), mediaType: 'image/svg+xml', palette }
|
|
671
795
|
}
|
|
672
796
|
|
|
673
797
|
|
|
@@ -688,20 +812,66 @@ export function estimateCost(provider, model, { count = 1 } = {}) {
|
|
|
688
812
|
|
|
689
813
|
|
|
690
814
|
export const STYLE_PRESETS = {
|
|
691
|
-
cinematic:
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
815
|
+
cinematic: {
|
|
816
|
+
promptSuffix: 'cinematic film still, 35mm photograph, dramatic lighting, highly detailed',
|
|
817
|
+
negativePrompt: 'cartoon, illustration, 3d render, oversaturated, blurry, distorted',
|
|
818
|
+
guidanceScale: 6.5,
|
|
819
|
+
},
|
|
820
|
+
anime: {
|
|
821
|
+
promptSuffix: 'anime aesthetic, Makoto Shinkai style, vibrant colors, detailed lineart, masterpiece',
|
|
822
|
+
negativePrompt: 'photorealistic, 3d, realistic photo, noisy, deformed, lowres',
|
|
823
|
+
guidanceScale: 7.0,
|
|
824
|
+
},
|
|
825
|
+
isometric: {
|
|
826
|
+
promptSuffix: 'isometric 3D render, clay style, soft studio lighting, clean ambient occlusion',
|
|
827
|
+
negativePrompt: 'flat, 2d, sketch, photograph, noisy background',
|
|
828
|
+
guidanceScale: 7.0,
|
|
829
|
+
},
|
|
830
|
+
cyberpunk: {
|
|
831
|
+
promptSuffix: 'cyberpunk aesthetic, neon reflections, volumetric fog, moody dark atmosphere',
|
|
832
|
+
negativePrompt: 'daylight, cartoon, pastel, monochrome, blurry',
|
|
833
|
+
guidanceScale: 7.5,
|
|
834
|
+
},
|
|
835
|
+
pixel_art: {
|
|
836
|
+
promptSuffix: '16-bit retro pixel art, crisp pixels, dithered shading, nostalgic game palette',
|
|
837
|
+
negativePrompt: 'smooth, vector, 3d render, blurry, photorealistic',
|
|
838
|
+
guidanceScale: 8.0,
|
|
839
|
+
},
|
|
840
|
+
oil_painting: {
|
|
841
|
+
promptSuffix: 'classical oil painting, visible canvas texture, rich brushstrokes, fine art',
|
|
842
|
+
negativePrompt: 'photo, digital render, 3d, plastic, anime, flat vector',
|
|
843
|
+
guidanceScale: 7.0,
|
|
844
|
+
},
|
|
845
|
+
minimalist: {
|
|
846
|
+
promptSuffix: 'minimalist graphic design, clean lines, flat vector illustration, elegant geometry',
|
|
847
|
+
negativePrompt: 'busy, cluttered, photorealistic, 3d, noisy texture, gradient overload',
|
|
848
|
+
guidanceScale: 7.0,
|
|
849
|
+
},
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
export function resolveStylePreset(styleKeyOrText, existingNegative, existingGuidance) {
|
|
853
|
+
if (!styleKeyOrText) return { promptSuffix: '', negativePrompt: existingNegative, guidanceScale: existingGuidance }
|
|
854
|
+
const key = String(styleKeyOrText).toLowerCase().replace(/[-\s]/g, '_')
|
|
855
|
+
const preset = STYLE_PRESETS[key]
|
|
856
|
+
if (preset && typeof preset === 'object') {
|
|
857
|
+
const combinedNeg = [existingNegative, preset.negativePrompt].filter(Boolean).join(', ')
|
|
858
|
+
return {
|
|
859
|
+
promptSuffix: preset.promptSuffix,
|
|
860
|
+
negativePrompt: combinedNeg || undefined,
|
|
861
|
+
guidanceScale: existingGuidance ?? preset.guidanceScale,
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
return {
|
|
865
|
+
promptSuffix: typeof preset === 'string' ? preset : String(styleKeyOrText),
|
|
866
|
+
negativePrompt: existingNegative,
|
|
867
|
+
guidanceScale: existingGuidance,
|
|
868
|
+
}
|
|
698
869
|
}
|
|
699
870
|
|
|
700
871
|
export function applyStylePreset(prompt, styleKeyOrText) {
|
|
701
872
|
if (!styleKeyOrText) return prompt
|
|
702
|
-
const
|
|
703
|
-
|
|
704
|
-
return `${prompt}, ${preset}`
|
|
873
|
+
const res = resolveStylePreset(styleKeyOrText)
|
|
874
|
+
return `${prompt}, ${res.promptSuffix}`
|
|
705
875
|
}
|
|
706
876
|
|
|
707
877
|
export async function blendImagesFal({ fetchImpl, resolveKey, cfg }, { images, weights, prompt, model = 'fal-ai/flux/dev/image-to-image', signal }) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-image-gen",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Image generation for DeepSeek Harness: a generate_image tool with pluggable providers — the FAL queue, any OpenAI-compatible images API, or a ChatGPT/Grok subscription with no API key at all. The picture is shown inline in the conversation; the model receives either a link (works with any chat model) or the image itself (needs dsh-vision-bridge or a vision-capable model).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|