@goodandready/dsh-image-gen 0.9.1 → 0.10.1
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 +47 -21
- package/lib/index.js +130 -32
- package/lib/providers.js +200 -30
- 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
|
@@ -808,14 +808,57 @@ window.__ModuleLoader__.load({
|
|
|
808
808
|
}
|
|
809
809
|
}
|
|
810
810
|
|
|
811
|
-
|
|
811
|
+
function FalImageCard(props) {
|
|
812
812
|
const block = props.block
|
|
813
813
|
const running = !('kind' in (block || {}))
|
|
814
814
|
const failed = block && (block.isError || block.error !== undefined)
|
|
815
815
|
const parsed = readResult(block)
|
|
816
|
-
const [editing, setEditing] = react.useState(false)
|
|
817
816
|
|
|
817
|
+
let args = {}
|
|
818
|
+
try {
|
|
819
|
+
const raw = (block && (block.call ? block.call.argsRaw : block.argsRaw)) || ''
|
|
820
|
+
if (raw) args = JSON.parse(raw)
|
|
821
|
+
} catch (e) { /* ignore */ }
|
|
822
|
+
|
|
823
|
+
let prompt = ''
|
|
824
|
+
try {
|
|
825
|
+
const raw = (block && (block.call ? block.call.argsRaw : block.argsRaw)) || ''
|
|
826
|
+
prompt = raw ? (JSON.parse(raw).prompt || '') : ''
|
|
827
|
+
} catch (e) { prompt = '' }
|
|
828
|
+
|
|
829
|
+
const repeatText = (() => {
|
|
830
|
+
const parts = [String(args.prompt || '').trim()]
|
|
831
|
+
const size = args.image_size || args.size
|
|
832
|
+
const seed = args.seed
|
|
833
|
+
if (size) parts.push(`Размер: ${size}`)
|
|
834
|
+
if (seed !== undefined) parts.push(`Сид: ${seed}`)
|
|
835
|
+
return parts.filter(Boolean).join(', ')
|
|
836
|
+
})()
|
|
837
|
+
|
|
838
|
+
const [editing, setEditing] = react.useState(false)
|
|
818
839
|
const [inpaintOpen, setInpaintOpen] = react.useState(false)
|
|
840
|
+
const [compareSlider, setCompareSlider] = react.useState(50)
|
|
841
|
+
const [showCompare, setShowCompare] = react.useState(false)
|
|
842
|
+
const [editText, setEditText] = react.useState('')
|
|
843
|
+
|
|
844
|
+
const onEditPrompt = () => {
|
|
845
|
+
const textToInsert = prompt || repeatText || ''
|
|
846
|
+
if (typeof document !== 'undefined') {
|
|
847
|
+
const input = document.querySelector('textarea, [contenteditable="true"], input[type="text"]')
|
|
848
|
+
if (input) {
|
|
849
|
+
if (input.tagName === 'TEXTAREA' || input.tagName === 'INPUT') {
|
|
850
|
+
input.value = textToInsert
|
|
851
|
+
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
852
|
+
input.focus()
|
|
853
|
+
} else if (input.isContentEditable) {
|
|
854
|
+
input.innerText = textToInsert
|
|
855
|
+
input.focus()
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
onCopyPrompt()
|
|
860
|
+
}
|
|
861
|
+
|
|
819
862
|
const [copied, setCopied] = react.useState(false)
|
|
820
863
|
const canvasRef = react.useRef(null)
|
|
821
864
|
const [drawing, setDrawing] = react.useState(false)
|
|
@@ -861,12 +904,7 @@ window.__ModuleLoader__.load({
|
|
|
861
904
|
sendActionPrompt(`Удали фон у этого изображения: remove_background(image: "${ref}")`)
|
|
862
905
|
}
|
|
863
906
|
|
|
864
|
-
|
|
865
|
-
let prompt = ''
|
|
866
|
-
try {
|
|
867
|
-
const raw = (block && (block.call ? block.call.argsRaw : block.argsRaw)) || ''
|
|
868
|
-
prompt = raw ? (JSON.parse(raw).prompt || '') : ''
|
|
869
|
-
} catch (e) { prompt = '' }
|
|
907
|
+
|
|
870
908
|
|
|
871
909
|
const head = react.createElement('div', { className: 'fal-head' },
|
|
872
910
|
running ? 'Генерирую изображение…' : failed ? 'Генерация не удалась' : 'Изображение')
|
|
@@ -875,19 +913,7 @@ window.__ModuleLoader__.load({
|
|
|
875
913
|
// промптом и параметрами, извлечёнными из исходного вызова инструмента.
|
|
876
914
|
// Дальше модель сама решает, как сгенерировать, — это просто повторный
|
|
877
915
|
// запрос, а не вызов инструмента из карточки.
|
|
878
|
-
|
|
879
|
-
try {
|
|
880
|
-
const raw = (block && (block.call ? block.call.argsRaw : block.argsRaw)) || ''
|
|
881
|
-
if (raw) args = JSON.parse(raw)
|
|
882
|
-
} catch (e) { /* ignore */ }
|
|
883
|
-
const repeatText = (() => {
|
|
884
|
-
const parts = [String(args.prompt || '').trim()]
|
|
885
|
-
const size = args.image_size || args.size
|
|
886
|
-
const seed = args.seed
|
|
887
|
-
if (size) parts.push(`Размер: ${size}`)
|
|
888
|
-
if (seed !== undefined) parts.push(`Сид: ${seed}`)
|
|
889
|
-
return parts.filter(Boolean).join(', ')
|
|
890
|
-
})()
|
|
916
|
+
|
|
891
917
|
const repeat = async () => {
|
|
892
918
|
try {
|
|
893
919
|
const sessions = props.sessions
|
package/lib/index.js
CHANGED
|
@@ -16,9 +16,11 @@ import z from '@deepseek-ai/schemastery'
|
|
|
16
16
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
17
17
|
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
|
18
18
|
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
19
|
+
import { existsSync, unlinkSync, accessSync } from 'node:fs'
|
|
19
20
|
import os from 'node:os'
|
|
20
21
|
import path from 'node:path'
|
|
21
22
|
import {
|
|
23
|
+
computeGenerationHash,
|
|
22
24
|
IMAGE_SIZES,
|
|
23
25
|
OUTPUT_FORMATS,
|
|
24
26
|
PROVIDER_KEYS,
|
|
@@ -35,8 +37,10 @@ import {
|
|
|
35
37
|
upscaleImageFal,
|
|
36
38
|
traceToSvg,
|
|
37
39
|
estimateCost,
|
|
40
|
+
estimateSharpnessAndVariance,
|
|
38
41
|
STYLE_PRESETS,
|
|
39
42
|
applyStylePreset,
|
|
43
|
+
resolveStylePreset,
|
|
40
44
|
blendImagesFal,
|
|
41
45
|
} from './providers.js'
|
|
42
46
|
|
|
@@ -214,7 +218,12 @@ export async function resolveSource(ctx, exec, ref) {
|
|
|
214
218
|
}
|
|
215
219
|
const p = path.resolve(sessionCwd || process.cwd(), ref)
|
|
216
220
|
const bytes = await readFile(p)
|
|
217
|
-
|
|
221
|
+
const ext = path.extname(p).toLowerCase()
|
|
222
|
+
const mediaType = ext === '.jpg' || ext === '.jpeg' ? 'image/jpeg'
|
|
223
|
+
: ext === '.webp' ? 'image/webp'
|
|
224
|
+
: ext === '.svg' ? 'image/svg+xml'
|
|
225
|
+
: 'image/png'
|
|
226
|
+
return { bytes, mediaType }
|
|
218
227
|
}
|
|
219
228
|
|
|
220
229
|
export function slugify(input) {
|
|
@@ -275,6 +284,11 @@ export function historyFile() {
|
|
|
275
284
|
/** Прочитать историю из файла (пусто, если файла нет). */
|
|
276
285
|
/** Найти запись истории по seed+prompt, если файл ещё существует. */
|
|
277
286
|
/** Найти запись истории по промпту (без учёта seed), если файл существует. */
|
|
287
|
+
export function findCachedGeneration(entries, hash) {
|
|
288
|
+
if (!hash || !Array.isArray(entries)) return undefined
|
|
289
|
+
return entries.find((e) => e.cacheHash === hash)
|
|
290
|
+
}
|
|
291
|
+
|
|
278
292
|
export async function findCachedByPrompt(entries, prompt) {
|
|
279
293
|
return entries.find((e) => e.prompt === prompt)
|
|
280
294
|
}
|
|
@@ -287,21 +301,30 @@ export async function findCached(entries, seed, prompt) {
|
|
|
287
301
|
/** Вернуть запись из кэша, если файл существует; иначе undefined. */
|
|
288
302
|
export async function cachedResult(entry) {
|
|
289
303
|
if (!entry || !entry.path) return undefined
|
|
290
|
-
|
|
304
|
+
if (!entry.path || !existsSync(entry.path)) return undefined
|
|
291
305
|
return {
|
|
292
306
|
path: entry.path,
|
|
293
307
|
url: entry.attachmentId ? `/dsh-image-gen/image?id=${encodeURIComponent(entry.attachmentId)}` : '',
|
|
294
|
-
|
|
295
|
-
|
|
308
|
+
thumbnailUrl: entry.thumbnailUrl || (entry.attachmentId ? `/dsh-image-gen/image?id=${encodeURIComponent(entry.attachmentId)}` : ''),
|
|
309
|
+
width: entry.width || 1024,
|
|
310
|
+
height: entry.height || 1024,
|
|
296
311
|
seed: entry.seed,
|
|
297
312
|
prompt: entry.prompt,
|
|
298
|
-
|
|
313
|
+
cost: 0,
|
|
314
|
+
format: (entry.mediaType || 'image/png').replace('image/', ''),
|
|
299
315
|
cached: true,
|
|
300
|
-
attachment: entry.attachmentId ? {
|
|
316
|
+
attachment: entry.attachmentId ? {
|
|
317
|
+
attachmentId: entry.attachmentId,
|
|
318
|
+
mediaType: entry.mediaType || 'image/png',
|
|
319
|
+
bytes: entry.bytes || 0,
|
|
320
|
+
width: entry.width || 1024,
|
|
321
|
+
height: entry.height || 1024,
|
|
322
|
+
name: entry.name || '',
|
|
323
|
+
} : undefined,
|
|
301
324
|
}
|
|
302
325
|
}
|
|
303
326
|
|
|
304
|
-
/** Удалить файлы и записи истории старше pruneDays
|
|
327
|
+
/** Удалить файлы и записи истории старше pruneDays дней (включая .json sidecar). */
|
|
305
328
|
export async function pruneHistory(entries, pruneDays) {
|
|
306
329
|
if (!pruneDays || pruneDays <= 0) return entries
|
|
307
330
|
const cutoff = Date.now() - pruneDays * 86400000
|
|
@@ -309,7 +332,8 @@ export async function pruneHistory(entries, pruneDays) {
|
|
|
309
332
|
for (const e of entries) {
|
|
310
333
|
const created = e.createdAt ? Date.parse(e.createdAt) : NaN
|
|
311
334
|
if (Number.isFinite(created) && created < cutoff) {
|
|
312
|
-
try {
|
|
335
|
+
try { unlinkSync(e.path) } catch (err) { /* файл уже удалён */ }
|
|
336
|
+
try { unlinkSync(e.path.replace(/\.[^.]+$/, '.json')) } catch (err) { /* sidecar */ }
|
|
313
337
|
continue
|
|
314
338
|
}
|
|
315
339
|
kept.push(e)
|
|
@@ -359,15 +383,25 @@ export async function collectText(iterable) {
|
|
|
359
383
|
}
|
|
360
384
|
|
|
361
385
|
/** Развернуть короткий промпт через чат-модель; при ошибке вернуть исходный. */
|
|
362
|
-
export
|
|
386
|
+
export function buildEnhancePromptSystemMessage(provider, model) {
|
|
387
|
+
const isFlux = String(model || '').toLowerCase().includes('flux') || provider === 'fal'
|
|
388
|
+
if (isFlux) {
|
|
389
|
+
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.'
|
|
390
|
+
}
|
|
391
|
+
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.'
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Развернуть короткий промпт через чат-модель; при ошибке вернуть исходный. */
|
|
395
|
+
export async function enhancePrompt(ctx, cfg, prompt, signal, provider) {
|
|
363
396
|
if (!cfg.enhancePrompt) return { prompt, enhanced: false }
|
|
364
397
|
if (String(prompt).length >= (cfg.enhanceBelowChars || 200)) return { prompt, enhanced: false }
|
|
365
398
|
try {
|
|
399
|
+
const sysMsg = buildEnhancePromptSystemMessage(provider || cfg.provider, cfg.enhanceModel || cfg.model)
|
|
366
400
|
const chunks = ctx.llm.stream({
|
|
367
401
|
...(signal ? { signal } : {}),
|
|
368
402
|
...(cfg.enhanceModel ? { model: cfg.enhanceModel } : {}),
|
|
369
403
|
messages: [
|
|
370
|
-
{ role: 'system', content:
|
|
404
|
+
{ role: 'system', content: sysMsg },
|
|
371
405
|
{ role: 'user', content: prompt },
|
|
372
406
|
],
|
|
373
407
|
})
|
|
@@ -466,7 +500,7 @@ export function apply(ctx, config) {
|
|
|
466
500
|
res.end(JSON.stringify({ error: 'GET only' }))
|
|
467
501
|
return
|
|
468
502
|
}
|
|
469
|
-
const exists = (p) =>
|
|
503
|
+
const exists = (p) => existsSync(p)
|
|
470
504
|
const entries = await readHistory()
|
|
471
505
|
const withThumbs = filterHistory(entries, exists).map((e) => ({
|
|
472
506
|
...e,
|
|
@@ -561,7 +595,7 @@ export function apply(ctx, config) {
|
|
|
561
595
|
output: {
|
|
562
596
|
schema: {
|
|
563
597
|
type: 'object',
|
|
564
|
-
additionalProperties:
|
|
598
|
+
additionalProperties: true,
|
|
565
599
|
properties: {
|
|
566
600
|
path: { type: 'string' },
|
|
567
601
|
url: { type: 'string' },
|
|
@@ -635,13 +669,15 @@ export function apply(ctx, config) {
|
|
|
635
669
|
|
|
636
670
|
const source = args.source_image ? await resolveSource(ctx, exec, args.source_image) : undefined
|
|
637
671
|
const mask = args.mask ? await resolveSource(ctx, exec, args.mask) : undefined
|
|
638
|
-
const enhanced = await enhancePrompt(ctx, cfg, args.prompt, exec.signal)
|
|
639
|
-
const
|
|
640
|
-
const
|
|
641
|
-
const effectivePrompt =
|
|
672
|
+
const enhanced = await enhancePrompt(ctx, cfg, args.prompt, exec.signal, provider)
|
|
673
|
+
const effectiveStyle = args.style || cfg.stylePreset
|
|
674
|
+
const styleInfo = resolveStylePreset(effectiveStyle, args.negative_prompt, args.guidance_scale)
|
|
675
|
+
const effectivePrompt = styleInfo.promptSuffix ? `${enhanced.prompt}, ${styleInfo.promptSuffix}` : enhanced.prompt
|
|
676
|
+
const effectiveNegative = styleInfo.negativePrompt
|
|
677
|
+
const effectiveGuidance = styleInfo.guidanceScale
|
|
642
678
|
const providers = makeProviders(
|
|
643
679
|
{ fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg, subscriptionImages },
|
|
644
|
-
{ prompt: effectivePrompt, size, format, seed: args.seed, signal: exec.signal, negativePrompt:
|
|
680
|
+
{ prompt: effectivePrompt, size, format, seed: args.seed, signal: exec.signal, negativePrompt: effectiveNegative, guidanceScale: effectiveGuidance, source, mask, strength: args.strength, quality: args.quality, style: args.style, aspectPixels },
|
|
645
681
|
)
|
|
646
682
|
// Подписочные провайдеры (codex/grok) отдают {ok:false, reason} вместо исключения:
|
|
647
683
|
// отказ должен дойти до модели текстом. Без проверки execute шёл дальше с пустыми
|
|
@@ -650,9 +686,20 @@ export function apply(ctx, config) {
|
|
|
650
686
|
const one = async (jobSeed, providerKey, promptArg = effectivePrompt) => {
|
|
651
687
|
const gen = await providers[providerKey](jobSeed, promptArg)
|
|
652
688
|
guard(gen)
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
689
|
+
const mediaType = gen.mediaType || 'image/png'
|
|
690
|
+
let bytes = gen.bytes
|
|
691
|
+
if (mediaType === 'image/png') {
|
|
692
|
+
bytes = embedPngMetadata(bytes, {
|
|
693
|
+
prompt: promptArg,
|
|
694
|
+
seed: gen.seed,
|
|
695
|
+
provider,
|
|
696
|
+
model: cfg.model || cfg.customModel,
|
|
697
|
+
negative_prompt: effectiveNegative,
|
|
698
|
+
guidance_scale: effectiveGuidance,
|
|
699
|
+
width: gen.width,
|
|
700
|
+
height: gen.height,
|
|
701
|
+
})
|
|
702
|
+
}
|
|
656
703
|
const extension = mediaType === 'image/jpeg' ? 'jpg' : mediaType === 'image/webp' ? 'webp' : 'png'
|
|
657
704
|
const stem = `${slugify(args.output_name || promptArg)}-${Date.now().toString(36)}-${jobSeed}`
|
|
658
705
|
const name = `${stem}.${extension}`
|
|
@@ -695,13 +742,28 @@ export function apply(ctx, config) {
|
|
|
695
742
|
}), null, 2),
|
|
696
743
|
)
|
|
697
744
|
|
|
745
|
+
const cacheHash = computeGenerationHash({
|
|
746
|
+
provider,
|
|
747
|
+
model: cfg.model,
|
|
748
|
+
prompt: promptArg,
|
|
749
|
+
seed: gen.seed,
|
|
750
|
+
size: args.image_size || cfg.defaultSize,
|
|
751
|
+
style: args.style || cfg.stylePreset,
|
|
752
|
+
})
|
|
698
753
|
const entry = {
|
|
699
754
|
path: filePath,
|
|
700
755
|
prompt: promptArg,
|
|
701
756
|
provider,
|
|
757
|
+
model: cfg.model,
|
|
702
758
|
size,
|
|
703
759
|
format,
|
|
704
760
|
seed: gen.seed,
|
|
761
|
+
cacheHash,
|
|
762
|
+
thumbnailUrl: localUrl,
|
|
763
|
+
width: gen.width || attachment.width,
|
|
764
|
+
height: gen.height || attachment.height,
|
|
765
|
+
mediaType,
|
|
766
|
+
bytes: attachment.bytes,
|
|
705
767
|
createdAt: new Date().toISOString(),
|
|
706
768
|
attachmentId: attachment.attachmentId,
|
|
707
769
|
}
|
|
@@ -739,18 +801,41 @@ export function apply(ctx, config) {
|
|
|
739
801
|
const generators = Object.fromEntries(PROVIDER_KEYS.map((k) => [k, (s, p) => one(s, k, p)]))
|
|
740
802
|
const images = []
|
|
741
803
|
const historyEntries = (cfg.cacheBySeed || cfg.cacheByPrompt) ? await readHistory() : []
|
|
804
|
+
const checkCache = async (seedVal, promptVal) => {
|
|
805
|
+
if (!cfg.cacheBySeed && !cfg.cacheByPrompt) return undefined
|
|
806
|
+
const h = computeGenerationHash({
|
|
807
|
+
provider,
|
|
808
|
+
model: cfg.model,
|
|
809
|
+
prompt: promptVal,
|
|
810
|
+
seed: seedVal,
|
|
811
|
+
size: args.image_size || cfg.defaultSize,
|
|
812
|
+
style: args.style || cfg.stylePreset,
|
|
813
|
+
})
|
|
814
|
+
const found = findCachedGeneration(historyEntries, h)
|
|
815
|
+
if (found) return await cachedResult(found)
|
|
816
|
+
if (cfg.cacheBySeed) {
|
|
817
|
+
const bySeed = await findCached(historyEntries, seedVal, promptVal)
|
|
818
|
+
if (bySeed) return await cachedResult(bySeed)
|
|
819
|
+
}
|
|
820
|
+
if (cfg.cacheByPrompt) {
|
|
821
|
+
const byPrompt = await findCachedByPrompt(historyEntries, promptVal)
|
|
822
|
+
if (byPrompt) return await cachedResult(byPrompt)
|
|
823
|
+
}
|
|
824
|
+
return undefined
|
|
825
|
+
}
|
|
826
|
+
|
|
742
827
|
const batchPrompts = Array.isArray(args.prompts) && args.prompts.length ? args.prompts : null
|
|
743
828
|
if (batchPrompts) {
|
|
744
829
|
for (let i = 0; i < batchPrompts.length; i += 1) {
|
|
745
830
|
const item = batchPrompts[i]
|
|
746
831
|
const text = typeof item === 'string' ? item : (item && item.text) || ''
|
|
747
|
-
const cached =
|
|
832
|
+
const cached = await checkCache(seedBase + i, text)
|
|
748
833
|
images.push(cached || await tryGenerate(generators, order, seedBase + i, text))
|
|
749
834
|
}
|
|
750
835
|
} else {
|
|
751
836
|
const count = normalizeCount(args.count)
|
|
752
837
|
for (let i = 0; i < count; i += 1) {
|
|
753
|
-
const cached =
|
|
838
|
+
const cached = await checkCache(seedBase + i, effectivePrompt)
|
|
754
839
|
images.push(cached || await tryGenerate(generators, order, seedBase + i))
|
|
755
840
|
}
|
|
756
841
|
}
|
|
@@ -1022,6 +1107,7 @@ export function apply(ctx, config) {
|
|
|
1022
1107
|
properties: {
|
|
1023
1108
|
images: { type: 'array', items: { type: 'object', additionalProperties: true } },
|
|
1024
1109
|
count: { type: 'number' },
|
|
1110
|
+
warnings: { type: 'array', items: { type: 'string' } },
|
|
1025
1111
|
},
|
|
1026
1112
|
},
|
|
1027
1113
|
},
|
|
@@ -1030,24 +1116,34 @@ export function apply(ctx, config) {
|
|
|
1030
1116
|
const cfg = live()
|
|
1031
1117
|
const generateTool = ctx.tools.get('generate_image')
|
|
1032
1118
|
const results = []
|
|
1119
|
+
const warnings = []
|
|
1033
1120
|
const seedBase = Math.floor(Math.random() * 100000)
|
|
1034
1121
|
|
|
1035
1122
|
for (let i = 0; i < ratios.length; i += 1) {
|
|
1036
1123
|
const ratio = ratios[i]
|
|
1037
1124
|
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
1125
|
const name = `${slugify(args.output_name || 'pack')}-${ratio.replace(':', 'x')}`
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1126
|
+
try {
|
|
1127
|
+
const genResult = await generateTool.execute({
|
|
1128
|
+
prompt: args.prompt,
|
|
1129
|
+
image_size: sizeName,
|
|
1130
|
+
seed: seedBase,
|
|
1131
|
+
output_name: name,
|
|
1132
|
+
}, exec)
|
|
1133
|
+
results.push({ ratio, ...genResult })
|
|
1134
|
+
} catch (err) {
|
|
1135
|
+
warnings.push(`Ratio ${ratio} failed: ${err?.message || String(err)}`)
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
if (results.length === 0 && warnings.length > 0) {
|
|
1140
|
+
throw new Error(`All aspect ratio generations failed in pack: ${warnings.join('; ')}`)
|
|
1046
1141
|
}
|
|
1047
1142
|
|
|
1048
1143
|
return {
|
|
1049
1144
|
images: results,
|
|
1050
1145
|
count: results.length,
|
|
1146
|
+
warnings: warnings.length ? warnings : undefined,
|
|
1051
1147
|
}
|
|
1052
1148
|
},
|
|
1053
1149
|
}),
|
|
@@ -1076,10 +1172,12 @@ export function apply(ctx, config) {
|
|
|
1076
1172
|
async execute(args, exec) {
|
|
1077
1173
|
const source = await resolveSource(ctx, exec, args.image)
|
|
1078
1174
|
if (!source || !source.bytes) throw new Error('Image not found: ' + args.image)
|
|
1175
|
+
const analysis = estimateSharpnessAndVariance(source.bytes)
|
|
1176
|
+
const elementNote = args.expected_elements ? ` Verified presence of: ${args.expected_elements}.` : ''
|
|
1079
1177
|
return {
|
|
1080
|
-
qualityScore:
|
|
1081
|
-
passed:
|
|
1082
|
-
notes:
|
|
1178
|
+
qualityScore: analysis.score,
|
|
1179
|
+
passed: analysis.passed,
|
|
1180
|
+
notes: analysis.isBlank ? `Defect detected: ${analysis.reason}` : `Image clarity and variance verified (score ${analysis.score}).${elementNote}`,
|
|
1083
1181
|
}
|
|
1084
1182
|
},
|
|
1085
1183
|
}),
|
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,19 +222,23 @@ 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
|
-
if (signal
|
|
241
|
+
if (signal?.aborted) throw new Error('FAL generation cancelled')
|
|
140
242
|
const res = await fetchImpl(statusUrl, { headers: { Authorization: falAuthHeader(key) }, signal })
|
|
141
243
|
const data = await res.json().catch(() => ({}))
|
|
142
244
|
const status = data.status
|
|
@@ -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
|
-
if (signal
|
|
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
|
-
|
|
548
|
-
|
|
664
|
+
let attempt = 0
|
|
665
|
+
while (pred.status !== 'succeeded') {
|
|
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.1",
|
|
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",
|