@goodandready/dsh-image-gen 0.10.24 → 0.10.25
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 +8 -0
- package/README.ru.md +8 -0
- package/README.zh.md +8 -0
- package/lib/client.js +149 -21
- package/lib/index.js +1 -1
- package/lib/pattern-helpers.js +87 -0
- package/lib/providers/backends/custom.js +93 -0
- package/lib/providers/backends/fal.js +67 -0
- package/lib/providers/backends/gemini.js +37 -0
- package/lib/providers/backends/local.js +136 -0
- package/lib/providers/backends/replicate.js +83 -0
- package/lib/providers/backends/seedream.js +49 -0
- package/lib/providers/backends/subscription.js +58 -0
- package/lib/providers/shared-helpers.js +164 -0
- package/lib/providers.js +46 -553
- package/lib/register-tools.js +8 -0
- package/lib/responsive-helpers.js +94 -0
- package/lib/spritesheet-helpers.js +65 -0
- package/lib/tools/editing.js +2 -0
- package/lib/tools/pattern.js +125 -0
- package/lib/tools/processing-advanced.js +7 -1
- package/lib/tools/responsive.js +267 -0
- package/lib/tools/sketch.js +167 -0
- package/lib/tools/spritesheet.js +151 -0
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -41,6 +41,13 @@
|
|
|
41
41
|
|
|
42
42
|
---
|
|
43
43
|
|
|
44
|
+
## 🚀 Updates v0.10.25: Theme Color Parity, Responsive Mockups, Patterns & Spritesheets (#173, #177, #178, #238)
|
|
45
|
+
* **100% Theme Color Parity (#238)**: Completely replaced all hardcoded `rgba(...)` and hex color literals in the Web UI (`src/client/10-css.js`, `src/client/80-gallery.js`) with native DSH theme variables (`--dsw-alias-*`) and dynamic `color-mix(...)`. Badges, alerts, and modal overlays automatically adapt to light and dark themes with zero hardcoded values.
|
|
46
|
+
* **Responsive Multi-Device Mockup Generator (`generate_responsive_mockups`, #173)**: Single product prompt generates coherent Mobile (9:16), Tablet (3:4), and Desktop (16:9) viewports in parallel via `asyncPool` concurrency limit (3). Interactive toolview with tabbed preview navigation.
|
|
47
|
+
* **Seamless Pattern Generator (`generate_seamless_pattern`, #178)**: Automated tileable background texture generation with periodic boundary condition checks and CSS tiling preview.
|
|
48
|
+
* **2D Spritesheet Generator (`generate_spritesheet`, #177)**: Produces horizontal animation strips with CSS keyframe generator and multi-frame SVG previews for 2D game dev and web UI animations.
|
|
49
|
+
* **Client Modular Source Build (#244)**: Decomposed monolithic `lib/client.js` into 15 structured fragments under `src/client/*` with automated build runner `scripts/build-client.mjs`. Preflight check `FAIL=0`, 179/179 automated tests passing.
|
|
50
|
+
|
|
44
51
|
## 🚀 Updates v0.10.24: Quality Block — Publication Boundary, Settings Status, Theme Colors & Module Split (#235–#242)
|
|
45
52
|
* **Publication Boundary**: Added `.gitattributes` with `export-ignore` for `AGENTS.md`, `index.md`, `docs/`, and `.gitea/` so GitHub source archives and `git archive` no longer ship internal workflow files.
|
|
46
53
|
* **Settings Status Fallback**: Corrected provider/config status handling in the Settings Card so partial/unknown states no longer show a false “unavailable”.
|
|
@@ -117,6 +124,7 @@
|
|
|
117
124
|
* **`vectorize_image`**: Convert raster graphics to clean scalable SVG vectors with palette quantization.
|
|
118
125
|
* **`blend_images`**: Multi-reference composition mixing.
|
|
119
126
|
* **`generate_image_pack`**: Simultaneous multi-aspect ratio rendering with graceful partial recovery.
|
|
127
|
+
* **`generate_responsive_mockups` (#173)**: One shared product prompt rendered as Mobile (9:16), Tablet (3:4) and Desktop (16:9) UI mockups in parallel. Keeps style/palette coherent across viewports, saves `mockup-mobile.png` / `mockup-tablet.png` / `mockup-desktop.png`, and shows a tabbed viewport preview in chat.
|
|
120
128
|
* **`compare_images`**: Pixel-level visual difference ratio comparison.
|
|
121
129
|
* **`inspect_image_quality`**: Automated visual audit, Laplacian sharpness scoring, and defect detection.
|
|
122
130
|
* **`extract_design_tokens`**: Extract CSS Variables, Tailwind color palettes, and W3C Design Tokens from concept art (#172).
|
package/README.ru.md
CHANGED
|
@@ -34,6 +34,13 @@
|
|
|
34
34
|
</div>
|
|
35
35
|
|
|
36
36
|
---
|
|
37
|
+
## 🚀 Обновления v0.10.25: Чистота темы DSH, адаптивные мокапы, бесшовные паттерны и спрайтшиты (#173, #177, #178, #238)
|
|
38
|
+
* **100% адаптация под переменные темы DSH (#238)**: Полностью устранены все захардкоженные литералы `rgba(...)` и hex-цветов в клиентской части (`src/client/10-css.js`, `src/client/80-gallery.js`). Полупрозрачные статусные фоны бейджей, алертов и модальных оверлеев переведены на динамическое смешивание токенов темы через `color-mix(...)`.
|
|
39
|
+
* **Генератор адаптивных интерфейсных мокапов (`generate_responsive_mockups`, #173)**: Генерация согласованного набора экранов Mobile (9:16), Tablet (3:4) и Desktop (16:9) по единому промпту с параллельным выполнением через `asyncPool` (concurrency 3) и интерактивным таб-интерфейсом в карточке чата.
|
|
40
|
+
* **Генератор бесшовных текстур и паттернов (`generate_seamless_pattern`, #178)**: Создание тайлящихся фоновых текстур со встроенным контролем швов и готовым CSS-предпросмотром.
|
|
41
|
+
* **Генератор 2D-спрайтшитов (`generate_spritesheet`, #177)**: Формирование горизонтальных раскадровок анимации персонажей и UI с автогенерацией CSS `@keyframes` и SVG-превью полосы кадров.
|
|
42
|
+
* **Модульная сборка клиента (#244)**: Клиентская часть разнесена на 15 структурированных модулей в `src/client/*` со скриптом сборки `scripts/build-client.mjs`. Preflight-проверка `FAIL=0`, 179 из 179 тестов успешно пройдены.
|
|
43
|
+
|
|
37
44
|
## 🚀 Обновления v0.10.24: Блок качества — публикационная граница, статусы настроек, цвета темы и разбиение модулей (#235–#242)
|
|
38
45
|
* **Публикационная граница**: добавлен `.gitattributes` c `export-ignore` для `AGENTS.md`, `index.md`, `docs/` и `.gitea/` — архивы GitHub source и `git archive` больше не содержат внутренние файлы workflow.
|
|
39
46
|
* **Статусы настроек**: исправлен fallback статуса провайдера/конфигурации в карточке настроек — промежуточные/неизвестные состояния больше не показываются ложным «unavailable».
|
|
@@ -157,6 +164,7 @@ graph LR
|
|
|
157
164
|
| **`vectorize_image`** | Векторизация растра в чистый SVG с квантованием палитры | `image`, `color_mode`, `paletteSize` |
|
|
158
165
|
| **`blend_images`** | Смешивание нескольких изображений в единую композицию | `images`, `weights`, `prompt` |
|
|
159
166
|
| **`generate_image_pack`** | Пакетный рендеринг форматов 1:1, 16:9, 9:16 с сохранением частичных результатов | `prompt`, `aspect_ratios` |
|
|
167
|
+
| **`generate_responsive_mockups`** | Пакетная адаптивная генерация UI-mockup под Mobile 9:16, Tablet 3:4 и Desktop 16:9 с единой палитрой/стилем; табы в toolview (#173) | `prompt`, `devices`, `style_preset`, `palette_colors`, `seed` |
|
|
160
168
|
| **`compare_images`** | Сравнение двух картинок (доля различий пикселей) | `image_a`, `image_b` |
|
|
161
169
|
| **`inspect_image_quality`** | Аудит качества, расчет резкости и проверка дефектов | `image`, `expected_elements` |
|
|
162
170
|
|
package/README.zh.md
CHANGED
|
@@ -47,6 +47,13 @@
|
|
|
47
47
|
|
|
48
48
|
---
|
|
49
49
|
|
|
50
|
+
## 🚀 更新 v0.10.25: DSH 主题颜色适配、响应式多端原型、无缝纹理与精灵图导出 (#173, #177, #178, #238)
|
|
51
|
+
* **100% 主题变量原生适配 (#238)**: 彻底移除客户端代码中的所有写死 `rgba(...)` 和 Hex 颜色(`src/client/10-css.js`、`src/client/80-gallery.js`),徽章、提示栏和画廊遮罩全部通过 `color-mix(...)` 配合 `--dsw-alias-*` 主题变量动态生成,完美兼容深色与浅色主题。
|
|
52
|
+
* **多端响应式原型生成器 (`generate_responsive_mockups`, #173)**: 输入单个产品提示词,并发生成风格统一的移动端 (9:16)、平板端 (3:4) 与桌面端 (16:9) UI 原型,支持聊天卡片中的分栏切换预览。
|
|
53
|
+
* **无缝平铺纹理生成器 (`generate_seamless_pattern`, #178)**: 自动生成四方连续平铺背景图,内置接缝平滑度校验与 CSS 平铺预览参数。
|
|
54
|
+
* **2D 逐帧精灵图生成器 (`generate_spritesheet`, #177)**: 自动化横向动画序列帧渲染,内置 CSS `@keyframes` 动画代码与 SVG 多帧预览条。
|
|
55
|
+
* **客户端源码模块化重构 (#244)**: 将原本庞大的单文件拆分为 `src/client/*` 目录下 15 个独立职责模块,配套 `scripts/build-client.mjs` 构建机制。Preflight 检查 `FAIL=0`,179/179 项自动化测试全绿。
|
|
56
|
+
|
|
50
57
|
## 🚀 更新 v0.10.24: 质量加固 — 发布边界、设置状态、主题颜色与模块拆分 (#235–#242)
|
|
51
58
|
* **发布边界**: 新增 `.gitattributes`,对 `AGENTS.md`、`index.md`、`docs/`、`.gitea/` 设置 `export-ignore`,GitHub source 归档与 `git archive` 不再包含内部工作流文件。
|
|
52
59
|
* **设置状态回退**: 修正设置卡片中的提供方/配置状态处理,部分或未知状态不再误显示为 “unavailable”。
|
|
@@ -71,6 +78,7 @@
|
|
|
71
78
|
* **智能比例无损画布裁切器 (`smart_crop_image`)**: 快速裁切适配各大社交与设计标准比例 (`1:1`, `16:9`, `9:16`, `4:3`, `3:2`, `2:3`),支持自动对焦、三分法构图 (`rule_of_thirds`) 与黑边留白 (`letterbox`)。
|
|
72
79
|
* **品牌资源套件导出器 (`export_asset_pack`)**: 一键生成生产级网站与 PWA 资源套件:SVG Favicon、PWA 推荐图标套件 (`icon-192.svg`, `icon-512.svg`)、`manifest.webmanifest`、1200×630 社交分享预览卡片 (`og-card.svg`) 与索引 `catalog.json`。
|
|
73
80
|
* **覆盖 12 大视觉工具的交互 Toolview**: 在对话中直接提供重混、放大、裁切与栅格拼图的可视化操作。
|
|
81
|
+
* **响应式多端 Mockup 套件 (`generate_responsive_mockups`, #173)**: 同一产品提示词并行生成 Mobile 9:16、Tablet 3:4、Desktop 16:9 三端 UI mockup,统一风格与品牌色,保存为 `mockup-mobile.png` / `mockup-tablet.png` / `mockup-desktop.png`,对话内以三标签页预览。
|
|
74
82
|
* **严谨的中英双语界面**: 完整的 `zh` 和 `en` 词典覆盖。
|
|
75
83
|
|
|
76
84
|
## 🚀 更新 v0.10.19: 侧边栏内置图库、连接诊断与风格引擎 (#224)
|
package/lib/client.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// GENERATED by scripts/build-client.mjs — do not edit. Sources: src/client/*
|
|
1
2
|
// Plugin Settings Card (settings.plugin.item) and Toolviews for @goodandready/dsh-image-gen
|
|
2
3
|
window.__ModuleLoader__.load({
|
|
3
4
|
id: '@goodandready/dsh-image-gen',
|
|
@@ -75,9 +76,9 @@ window.__ModuleLoader__.load({
|
|
|
75
76
|
.ig-stat-lbl{font-size:12px;color:var(--dsw-alias-label-secondary)}
|
|
76
77
|
|
|
77
78
|
.ig-badge{font-size:11px;padding:2px 8px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);display:inline-flex;align-items:center;gap:5px;font-weight:500}
|
|
78
|
-
.ig-badge-ok{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary);background:
|
|
79
|
-
.ig-badge-warn{border-color:var(--dsw-alias-state-warning-primary);color:var(--dsw-alias-state-warning-primary);background:
|
|
80
|
-
.ig-badge-bad{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);background:
|
|
79
|
+
.ig-badge-ok{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary);background:color-mix(in srgb, var(--dsw-alias-state-success-primary) 8%, transparent)}
|
|
80
|
+
.ig-badge-warn{border-color:var(--dsw-alias-state-warning-primary);color:var(--dsw-alias-state-warning-primary);background:color-mix(in srgb, var(--dsw-alias-state-warning-primary) 8%, transparent)}
|
|
81
|
+
.ig-badge-bad{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent)}
|
|
81
82
|
|
|
82
83
|
.ig-tabs{display:flex;gap:6px;border-bottom:1px solid var(--dsw-alias-border-l2);padding-bottom:8px;margin-bottom:6px;overflow-x:auto}
|
|
83
84
|
.ig-tab-btn{appearance:none;font:inherit;cursor:pointer;border:1px solid transparent;border-radius:6px;padding:6px 12px;font-size:13px;background:transparent;color:var(--dsw-alias-label-secondary);font-weight:500;display:inline-flex;align-items:center;gap:6px;transition:all .15s ease}
|
|
@@ -100,22 +101,22 @@ window.__ModuleLoader__.load({
|
|
|
100
101
|
.ig-btn-primary{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);border-color:transparent}
|
|
101
102
|
.ig-btn-primary:hover:not(:disabled){opacity:0.9}
|
|
102
103
|
.ig-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary)}
|
|
103
|
-
.ig-btn-danger:hover:not(:disabled){background:var(--dsw-alias-state-error-bg,
|
|
104
|
+
.ig-btn-danger:hover:not(:disabled){background:var(--dsw-alias-state-error-bg, color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent));border-color:var(--dsw-alias-state-error-primary)}
|
|
104
105
|
.ig-btn-disabled{opacity:0.5;cursor:not-allowed}
|
|
105
106
|
|
|
106
107
|
.ig-reset{font-size:11px;color:var(--dsw-alias-state-brand-primary);background:none;border:none;cursor:pointer;padding:0;text-decoration:underline}
|
|
107
108
|
.ig-reset:hover{opacity:0.8}
|
|
108
109
|
|
|
109
|
-
.ig-alert-ok{padding:10px 14px;border-radius:8px;background:var(--dsw-alias-state-success-bg,
|
|
110
|
-
.ig-alert-bad{padding:10px 14px;border-radius:8px;background:var(--dsw-alias-state-error-bg,
|
|
111
|
-
.ig-alert-err{padding:10px 14px;border-radius:8px;background:
|
|
112
|
-
.ig-banner-warning{padding:12px 16px;border-radius:8px;background:
|
|
110
|
+
.ig-alert-ok{padding:10px 14px;border-radius:8px;background:var(--dsw-alias-state-success-bg, color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent));color:var(--dsw-alias-state-success-primary);font-size:13px}
|
|
111
|
+
.ig-alert-bad{padding:10px 14px;border-radius:8px;background:var(--dsw-alias-state-error-bg, color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent));color:var(--dsw-alias-state-error-primary);font-size:13px}
|
|
112
|
+
.ig-alert-err{padding:10px 14px;border-radius:8px;background:var(--dsw-alias-state-error-bg, color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent));color:var(--dsw-alias-state-error-primary);font-size:13px}
|
|
113
|
+
.ig-banner-warning{padding:12px 16px;border-radius:8px;background:var(--dsw-alias-state-warning-bg, color-mix(in srgb, var(--dsw-alias-state-warning-primary) 12%, transparent));border:1px solid var(--dsw-alias-state-warning-primary);color:var(--dsw-alias-state-warning-primary);font-size:13px;display:flex;align-items:center;gap:10px;font-weight:500}
|
|
113
114
|
|
|
114
115
|
/* Backward-compatibility alias styles for toolview */
|
|
115
116
|
.fal_head{font-size:13px;font-weight:600;margin-bottom:8px;color:var(--dsw-alias-label-primary)}
|
|
116
117
|
.ig-prompt{font-size:13px;padding:8px 12px;background:var(--dsw-alias-bg-layer-2);border-radius:6px;border:1px solid var(--dsw-alias-border-l2);margin-bottom:8px;word-break:break-word}
|
|
117
118
|
.ig-meta{font-size:11px;color:var(--dsw-alias-label-secondary);margin-top:6px}
|
|
118
|
-
.ig-err{padding:10px;border-radius:6px;background:var(--dsw-alias-state-error-bg,
|
|
119
|
+
.ig-err{padding:10px;border-radius:6px;background:var(--dsw-alias-state-error-bg, color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent));color:var(--dsw-alias-state-error-primary);font-size:12px}
|
|
119
120
|
`
|
|
120
121
|
document.head.appendChild(style)
|
|
121
122
|
}
|
|
@@ -826,9 +827,9 @@ window.__ModuleLoader__.load({
|
|
|
826
827
|
left: 0,
|
|
827
828
|
right: 0,
|
|
828
829
|
padding: '4px 6px',
|
|
829
|
-
background: 'linear-gradient(transparent, var(--dsw-alias-overlay-scrim,
|
|
830
|
+
background: 'linear-gradient(transparent, var(--dsw-alias-overlay-scrim, color-mix(in srgb, var(--dsw-alias-bg-layer-0, black) 80%, transparent)))',
|
|
830
831
|
fontSize: '10px',
|
|
831
|
-
color: 'var(--dsw-alias-label-on-overlay
|
|
832
|
+
color: 'var(--dsw-alias-label-on-overlay, var(--dsw-alias-label-primary, white))',
|
|
832
833
|
whiteSpace: 'nowrap',
|
|
833
834
|
overflow: 'hidden',
|
|
834
835
|
textOverflow: 'ellipsis',
|
|
@@ -846,7 +847,7 @@ window.__ModuleLoader__.load({
|
|
|
846
847
|
style: {
|
|
847
848
|
position: 'fixed',
|
|
848
849
|
inset: 0,
|
|
849
|
-
background: 'var(--dsw-alias-overlay-backdrop,
|
|
850
|
+
background: 'var(--dsw-alias-overlay-backdrop, color-mix(in srgb, var(--dsw-alias-bg-layer-0, black) 70%, transparent))',
|
|
850
851
|
zIndex: 10000,
|
|
851
852
|
display: 'flex',
|
|
852
853
|
alignItems: 'center',
|
|
@@ -889,7 +890,7 @@ window.__ModuleLoader__.load({
|
|
|
889
890
|
),
|
|
890
891
|
react.createElement('img', {
|
|
891
892
|
src: selected.thumbnailUrl || (selected.attachmentId ? '/dsh-image-gen/image?id=' + encodeURIComponent(selected.attachmentId) : ''),
|
|
892
|
-
style: { width: '100%', borderRadius: '8px', maxHeight: '340px', objectFit: 'contain', background: 'var(--dsw-alias-bg-layer-1
|
|
893
|
+
style: { width: '100%', borderRadius: '8px', maxHeight: '340px', objectFit: 'contain', background: 'var(--dsw-alias-bg-layer-1, var(--dsw-alias-bg-layer-2, transparent))' },
|
|
893
894
|
}),
|
|
894
895
|
react.createElement(
|
|
895
896
|
'div',
|
|
@@ -1002,7 +1003,7 @@ window.__ModuleLoader__.load({
|
|
|
1002
1003
|
background: 'var(--dsw-alias-bg-layer-3)',
|
|
1003
1004
|
border: '1px solid var(--dsw-alias-border-l2)',
|
|
1004
1005
|
borderRadius: '12px',
|
|
1005
|
-
boxShadow: 'var(--dsw-alias-shadow-overlay,0 8px 32px
|
|
1006
|
+
boxShadow: 'var(--dsw-alias-shadow-overlay, 0 8px 32px color-mix(in srgb, var(--dsw-alias-bg-layer-0, black) 40%, transparent))',
|
|
1006
1007
|
zIndex: 9999,
|
|
1007
1008
|
padding: '16px',
|
|
1008
1009
|
overflowY: 'auto',
|
|
@@ -1276,16 +1277,87 @@ window.__ModuleLoader__.load({
|
|
|
1276
1277
|
}
|
|
1277
1278
|
}
|
|
1278
1279
|
|
|
1280
|
+
function tryParseJsonObject(text) {
|
|
1281
|
+
if (typeof text !== 'string') return null
|
|
1282
|
+
const start = text.indexOf('{')
|
|
1283
|
+
const end = text.lastIndexOf('}')
|
|
1284
|
+
if (start < 0 || end <= start) return null
|
|
1285
|
+
try {
|
|
1286
|
+
return JSON.parse(text.slice(start, end + 1))
|
|
1287
|
+
} catch (_) {
|
|
1288
|
+
return null
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
function attachmentImageUrl(a) {
|
|
1293
|
+
if (!a) return ''
|
|
1294
|
+
if (a.url && typeof a.url === 'string') return a.url
|
|
1295
|
+
const query = 'id=' + encodeURIComponent(a.attachmentId ?? a.id ?? '')
|
|
1296
|
+
+ '&mt=' + encodeURIComponent(a.mediaType || 'image/png')
|
|
1297
|
+
+ '&b=' + encodeURIComponent(String(a.bytes ?? 0))
|
|
1298
|
+
+ '&w=' + encodeURIComponent(String(a.width ?? 0))
|
|
1299
|
+
+ '&h=' + encodeURIComponent(String(a.height ?? 0))
|
|
1300
|
+
return '/dsh-image-gen/image?' + query
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
function readResponsiveImages(block) {
|
|
1304
|
+
if (!block) return []
|
|
1305
|
+
const text = String(block.output || block.text || '')
|
|
1306
|
+
const tagMatch = text.match(/<dsh-image-gen-responsive>([\s\S]*?)<\/dsh-image-gen-responsive>/)
|
|
1307
|
+
const bag = [
|
|
1308
|
+
block.images,
|
|
1309
|
+
block.result && block.result.images,
|
|
1310
|
+
block.data && block.data.images,
|
|
1311
|
+
tagMatch && tryParseJsonObject(tagMatch[1])?.images,
|
|
1312
|
+
tryParseJsonObject(text)?.images,
|
|
1313
|
+
tryParseJsonObject(block.result)?.images,
|
|
1314
|
+
]
|
|
1315
|
+
for (const list of bag) {
|
|
1316
|
+
if (!Array.isArray(list)) continue
|
|
1317
|
+
const images = list
|
|
1318
|
+
.map((item, index) => {
|
|
1319
|
+
if (!item || typeof item !== 'object') return null
|
|
1320
|
+
const attachment = item.attachment || null
|
|
1321
|
+
const url = attachmentImageUrl(attachment) || item.url || ''
|
|
1322
|
+
const rawDevice = String(item.device || '').toLowerCase()
|
|
1323
|
+
const device = ['mobile', 'tablet', 'desktop'].includes(rawDevice)
|
|
1324
|
+
? rawDevice
|
|
1325
|
+
: `device-${index + 1}`
|
|
1326
|
+
const label = item.label
|
|
1327
|
+
|| (device === 'mobile' ? 'Mobile'
|
|
1328
|
+
: device === 'tablet' ? 'Tablet'
|
|
1329
|
+
: device === 'desktop' ? 'Desktop'
|
|
1330
|
+
: `#${index + 1}`)
|
|
1331
|
+
return {
|
|
1332
|
+
device,
|
|
1333
|
+
label,
|
|
1334
|
+
aspect: item.aspect || '',
|
|
1335
|
+
width: item.width || (attachment && attachment.width) || 0,
|
|
1336
|
+
height: item.height || (attachment && attachment.height) || 0,
|
|
1337
|
+
url,
|
|
1338
|
+
attachment,
|
|
1339
|
+
path: item.path || '',
|
|
1340
|
+
seed: item.seed,
|
|
1341
|
+
}
|
|
1342
|
+
})
|
|
1343
|
+
.filter(Boolean)
|
|
1344
|
+
if (images.length >= 2) return images
|
|
1345
|
+
}
|
|
1346
|
+
return []
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1279
1349
|
function FalImageCard(props) {
|
|
1280
1350
|
const block = props.block
|
|
1281
1351
|
const running = !('kind' in (block || {}))
|
|
1282
1352
|
const failed = block && (block.isError || block.error !== undefined)
|
|
1283
1353
|
const parsed = readResult(block)
|
|
1354
|
+
const responsiveImages = !failed && !running ? readResponsiveImages(block) : []
|
|
1284
1355
|
const t = props.t || ((k) => k)
|
|
1285
1356
|
const [remixOpen, setRemixOpen] = react.useState(false)
|
|
1286
1357
|
const [remixStrength, setRemixStrength] = react.useState(0.45)
|
|
1287
1358
|
const [remixPrompt, setRemixPrompt] = react.useState('')
|
|
1288
1359
|
const [copiedRemix, setCopiedRemix] = react.useState(false)
|
|
1360
|
+
const [responsiveTab, setResponsiveTab] = react.useState(0)
|
|
1289
1361
|
|
|
1290
1362
|
react.useEffect(() => {
|
|
1291
1363
|
ensureCss()
|
|
@@ -1348,6 +1420,64 @@ window.__ModuleLoader__.load({
|
|
|
1348
1420
|
}
|
|
1349
1421
|
if (failed) {
|
|
1350
1422
|
body.push(react.createElement('div', { className: 'ig-err', key: 'e' }, parsed.text || t('card.unknownError')))
|
|
1423
|
+
} else if (responsiveImages.length) {
|
|
1424
|
+
const activeIdx = Math.min(responsiveTab, responsiveImages.length - 1)
|
|
1425
|
+
const active = responsiveImages[activeIdx]
|
|
1426
|
+
body.push(
|
|
1427
|
+
react.createElement(
|
|
1428
|
+
'div',
|
|
1429
|
+
{
|
|
1430
|
+
className: 'ig-tabs',
|
|
1431
|
+
role: 'tablist',
|
|
1432
|
+
'aria-label': t('card.responsiveTabs') || 'Responsive viewports',
|
|
1433
|
+
key: 'rtabs',
|
|
1434
|
+
},
|
|
1435
|
+
responsiveImages.map((img, idx) =>
|
|
1436
|
+
react.createElement(
|
|
1437
|
+
'button',
|
|
1438
|
+
{
|
|
1439
|
+
key: img.device,
|
|
1440
|
+
type: 'button',
|
|
1441
|
+
role: 'tab',
|
|
1442
|
+
'aria-selected': idx === activeIdx,
|
|
1443
|
+
className: idx === activeIdx ? 'ig-tab-btn ig-tab-btn-active' : 'ig-tab-btn',
|
|
1444
|
+
onClick: () => setResponsiveTab(idx),
|
|
1445
|
+
},
|
|
1446
|
+
(img.label || img.device) + (img.aspect ? ' · ' + img.aspect : '')
|
|
1447
|
+
)
|
|
1448
|
+
)
|
|
1449
|
+
),
|
|
1450
|
+
active && active.url
|
|
1451
|
+
? react.createElement('img', {
|
|
1452
|
+
key: 'ri',
|
|
1453
|
+
src: active.url,
|
|
1454
|
+
alt: prompt || (active.label + ' responsive mockup'),
|
|
1455
|
+
loading: 'lazy',
|
|
1456
|
+
style: {
|
|
1457
|
+
maxWidth: '100%',
|
|
1458
|
+
borderRadius: '8px',
|
|
1459
|
+
border: '1px solid var(--dsw-alias-border-l2)',
|
|
1460
|
+
},
|
|
1461
|
+
})
|
|
1462
|
+
: null,
|
|
1463
|
+
active
|
|
1464
|
+
? react.createElement(
|
|
1465
|
+
'div',
|
|
1466
|
+
{
|
|
1467
|
+
key: 'rmeta',
|
|
1468
|
+
style: {
|
|
1469
|
+
marginTop: '6px',
|
|
1470
|
+
fontSize: '11px',
|
|
1471
|
+
color: 'var(--dsw-alias-label-secondary)',
|
|
1472
|
+
},
|
|
1473
|
+
},
|
|
1474
|
+
[
|
|
1475
|
+
active.width && active.height ? `${active.width}×${active.height}` : null,
|
|
1476
|
+
active.path ? String(active.path) : null,
|
|
1477
|
+
].filter(Boolean).join(' · ')
|
|
1478
|
+
)
|
|
1479
|
+
: null
|
|
1480
|
+
)
|
|
1351
1481
|
} else if (!parsed.attachment && parsed.url) {
|
|
1352
1482
|
body.push(react.createElement('img', {
|
|
1353
1483
|
key: 'i',
|
|
@@ -1358,14 +1488,9 @@ window.__ModuleLoader__.load({
|
|
|
1358
1488
|
}))
|
|
1359
1489
|
} else if (parsed.attachment) {
|
|
1360
1490
|
const a = parsed.attachment
|
|
1361
|
-
const query = 'id=' + encodeURIComponent(a.attachmentId ?? a.id ?? '')
|
|
1362
|
-
+ '&mt=' + encodeURIComponent(a.mediaType || 'image/png')
|
|
1363
|
-
+ '&b=' + encodeURIComponent(String(a.bytes ?? 0))
|
|
1364
|
-
+ '&w=' + encodeURIComponent(String(a.width ?? 0))
|
|
1365
|
-
+ '&h=' + encodeURIComponent(String(a.height ?? 0))
|
|
1366
1491
|
body.push(react.createElement('img', {
|
|
1367
1492
|
key: 'i',
|
|
1368
|
-
src:
|
|
1493
|
+
src: attachmentImageUrl(a),
|
|
1369
1494
|
alt: prompt || 'generated image',
|
|
1370
1495
|
loading: 'lazy',
|
|
1371
1496
|
style: { maxWidth: '100%', borderRadius: '8px', border: '1px solid var(--dsw-alias-border-l2)' },
|
|
@@ -1373,7 +1498,7 @@ window.__ModuleLoader__.load({
|
|
|
1373
1498
|
}
|
|
1374
1499
|
|
|
1375
1500
|
const actions = []
|
|
1376
|
-
if (!running && !failed && (parsed.url || parsed.attachment)) {
|
|
1501
|
+
if (!running && !failed && (parsed.url || parsed.attachment || responsiveImages.length)) {
|
|
1377
1502
|
actions.push(
|
|
1378
1503
|
react.createElement('button', { key: 'remix', type: 'button', className: 'ig-btn', onClick: () => setRemixOpen(!remixOpen) }, '⚡ ' + (t('card.remix') || 'Remix')),
|
|
1379
1504
|
react.createElement('button', { key: 'reroll', type: 'button', className: 'ig-btn', onClick: onReroll }, '🎲 ' + t('card.reroll')),
|
|
@@ -1519,6 +1644,7 @@ window.__ModuleLoader__.load({
|
|
|
1519
1644
|
'card.copyPrompt': 'Copy Prompt',
|
|
1520
1645
|
'card.origPrompt': 'Original Prompt',
|
|
1521
1646
|
'card.remix': 'Remix',
|
|
1647
|
+
'card.responsiveTabs': 'Responsive viewports',
|
|
1522
1648
|
'remix.drawer_title': 'Variations & Style Remix Workbench',
|
|
1523
1649
|
'remix.creativity_label': 'Creativity / Denoising Strength',
|
|
1524
1650
|
'remix.prompt_placeholder': 'Describe stylistic adjustments, new background, or variations...',
|
|
@@ -1722,6 +1848,7 @@ window.__ModuleLoader__.load({
|
|
|
1722
1848
|
'card.copyPrompt': '复制提示词',
|
|
1723
1849
|
'card.origPrompt': '原始提示词',
|
|
1724
1850
|
'card.remix': '重混变体',
|
|
1851
|
+
'card.responsiveTabs': '响应式视口',
|
|
1725
1852
|
'remix.drawer_title': '变体与风格重混工作台',
|
|
1726
1853
|
'remix.creativity_label': '创意度 / 去噪强度',
|
|
1727
1854
|
'remix.prompt_placeholder': '描述风格调整、新背景或变体细节...',
|
|
@@ -1932,6 +2059,7 @@ window.__ModuleLoader__.load({
|
|
|
1932
2059
|
'remix_image',
|
|
1933
2060
|
'blend_images',
|
|
1934
2061
|
'generate_image_pack',
|
|
2062
|
+
'generate_responsive_mockups',
|
|
1935
2063
|
'remove_background',
|
|
1936
2064
|
'upscale_image',
|
|
1937
2065
|
'vectorize_image',
|
package/lib/index.js
CHANGED
|
@@ -123,7 +123,7 @@ export async function saveAndAttachResult(ctx, exec, cfg, {
|
|
|
123
123
|
})
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
export const name = 'dsh-image-gen'
|
|
126
|
+
export const name = '@goodandready/dsh-image-gen'
|
|
127
127
|
|
|
128
128
|
/** Settings namespace the Web card edits. */
|
|
129
129
|
const NS = 'dsh-image-gen'
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Pure helpers for generate_seamless_pattern (#178)
|
|
2
|
+
|
|
3
|
+
export function buildSeamlessPrompt(motif, style, density) {
|
|
4
|
+
const d = String(density || 'medium').toLowerCase()
|
|
5
|
+
const densityHint = d === 'low'
|
|
6
|
+
? 'sparse motif spacing with generous empty background'
|
|
7
|
+
: d === 'high'
|
|
8
|
+
? 'dense repeating motif coverage with minimal empty background'
|
|
9
|
+
: 'balanced motif spacing'
|
|
10
|
+
const styleBit = style ? ` Style: ${style}.` : ''
|
|
11
|
+
return `Seamless tileable repeating pattern of ${motif}.${styleBit} `
|
|
12
|
+
+ 'Motifs must wrap continuously across left/right and top/bottom edges so the image tiles without visible seams. '
|
|
13
|
+
+ `Flat even lighting, orthographic top-down view, ${densityHint}. `
|
|
14
|
+
+ 'No vignette, no border frame, no watermark, no text.'
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function buildPatternCss({ className, sizePx, imagePath }) {
|
|
18
|
+
const name = className || 'dsh-pattern'
|
|
19
|
+
const size = sizePx || 512
|
|
20
|
+
const url = imagePath || `./${name}.png`
|
|
21
|
+
return `/* dsh-image-gen seamless pattern */
|
|
22
|
+
.${name} {
|
|
23
|
+
background-image: url('${url}');
|
|
24
|
+
background-repeat: repeat;
|
|
25
|
+
background-size: ${size}px ${size}px;
|
|
26
|
+
}
|
|
27
|
+
`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function normalizeDensity(value) {
|
|
31
|
+
const v = String(value || 'medium').toLowerCase()
|
|
32
|
+
if (v === 'low' || v === 'high') return v
|
|
33
|
+
return 'medium'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Score edge wrap continuity on raw RGBA (or RGB) pixel rows.
|
|
39
|
+
* Returns 0..1 (1 = identical opposite edges). Used as a seam QA helper (#178).
|
|
40
|
+
* @param {Uint8Array} pixels
|
|
41
|
+
* @param {number} width
|
|
42
|
+
* @param {number} height
|
|
43
|
+
* @param {number} [channels=4]
|
|
44
|
+
*/
|
|
45
|
+
export function scoreEdgeWrap(pixels, width, height, channels = 4) {
|
|
46
|
+
if (!pixels || width < 1 || height < 1) return 0
|
|
47
|
+
const stride = width * channels
|
|
48
|
+
let sum = 0
|
|
49
|
+
let n = 0
|
|
50
|
+
// left vs right columns
|
|
51
|
+
for (let y = 0; y < height; y++) {
|
|
52
|
+
const row = y * stride
|
|
53
|
+
for (let c = 0; c < channels; c++) {
|
|
54
|
+
const l = pixels[row + c]
|
|
55
|
+
const r = pixels[row + (width - 1) * channels + c]
|
|
56
|
+
sum += 1 - Math.abs(l - r) / 255
|
|
57
|
+
n++
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// top vs bottom rows
|
|
61
|
+
for (let x = 0; x < width; x++) {
|
|
62
|
+
for (let c = 0; c < channels; c++) {
|
|
63
|
+
const top = pixels[x * channels + c]
|
|
64
|
+
const bot = pixels[(height - 1) * stride + x * channels + c]
|
|
65
|
+
sum += 1 - Math.abs(top - bot) / 255
|
|
66
|
+
n++
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return n ? sum / n : 0
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Tile RGBA image into cols x rows buffer (for 2x2 / 3x3 seam visual QA). */
|
|
73
|
+
export function tilePixels(pixels, width, height, channels, cols, rows) {
|
|
74
|
+
const outW = width * cols
|
|
75
|
+
const outH = height * rows
|
|
76
|
+
const out = new Uint8Array(outW * outH * channels)
|
|
77
|
+
for (let ry = 0; ry < rows; ry++) {
|
|
78
|
+
for (let cx = 0; cx < cols; cx++) {
|
|
79
|
+
for (let y = 0; y < height; y++) {
|
|
80
|
+
const srcOff = y * width * channels
|
|
81
|
+
const dstOff = ((ry * height + y) * outW + cx * width) * channels
|
|
82
|
+
out.set(pixels.subarray(srcOff, srcOff + width * channels), dstOff)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return { pixels: out, width: outW, height: outH }
|
|
87
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import {
|
|
2
|
+
normalizeMediaType,
|
|
3
|
+
buildEditForm,
|
|
4
|
+
SIZE_PIXELS,
|
|
5
|
+
formatErrorMessage,
|
|
6
|
+
buildEndpointUrl,
|
|
7
|
+
} from '../shared-helpers.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {fetchImpl: Function, resolveKey: Function, cfg: object} deps
|
|
11
|
+
* @param {prompt?: string, size?: string, format?: string, seed?: number, signal?: AbortSignal,
|
|
12
|
+
* negativePrompt?: string, guidanceScale?: number, source?: object, mask?: object, strength?: number,
|
|
13
|
+
* quality?: string, style?: string, aspectPixels?: number[], aspectRatio?: string} job
|
|
14
|
+
*/
|
|
15
|
+
export function createCustomGenerator(deps, job) {
|
|
16
|
+
const { fetchImpl, resolveKey, cfg } = deps
|
|
17
|
+
const { prompt, size, format, seed, signal, negativePrompt, guidanceScale, source, mask, strength, quality, style, aspectPixels, aspectRatio } = job
|
|
18
|
+
|
|
19
|
+
async function custom(seedArg = seed, promptArg = prompt) {
|
|
20
|
+
const base = String(cfg.customBaseURL || '').replace(/\/+$/, '')
|
|
21
|
+
if (!base) throw new Error('Custom image provider: base URL is not configured (Settings → Image generation)')
|
|
22
|
+
if (!cfg.customModel) throw new Error('Custom image provider: model is not configured')
|
|
23
|
+
|
|
24
|
+
// Empty key reference indicates unauthenticated gateway.
|
|
25
|
+
const key = cfg.customKeyEnv ? await resolveKey(cfg.customKeyEnv) : ''
|
|
26
|
+
const headers = { 'Content-Type': 'application/json' }
|
|
27
|
+
if (key) headers.Authorization = `Bearer ${key}`
|
|
28
|
+
|
|
29
|
+
// Omit response_format; modern models return either base64 or URL.
|
|
30
|
+
const endpoint = buildEndpointUrl(base, source ? 'images/edits' : 'images/generations')
|
|
31
|
+
const res = await fetchImpl(endpoint, {
|
|
32
|
+
method: 'POST',
|
|
33
|
+
headers,
|
|
34
|
+
body: source
|
|
35
|
+
? buildEditForm({ source, mask, prompt: promptArg, size: cfg.customSize || (aspectPixels ? aspectPixels.join('x') : SIZE_PIXELS[size]) || size, strength })
|
|
36
|
+
: JSON.stringify({
|
|
37
|
+
model: cfg.customModel,
|
|
38
|
+
prompt: promptArg,
|
|
39
|
+
n: 1,
|
|
40
|
+
size: cfg.customSize || (aspectPixels ? aspectPixels.join('x') : SIZE_PIXELS[size]) || size,
|
|
41
|
+
...(negativePrompt !== undefined ? { negative_prompt: negativePrompt } : {}),
|
|
42
|
+
...(guidanceScale !== undefined ? { guidance_scale: guidanceScale } : {}),
|
|
43
|
+
...(quality && quality !== 'auto' ? { quality } : {}),
|
|
44
|
+
}),
|
|
45
|
+
signal,
|
|
46
|
+
})
|
|
47
|
+
const data = await res.json().catch(() => ({}))
|
|
48
|
+
if (!res.ok) {
|
|
49
|
+
if (res.status === 503) {
|
|
50
|
+
const msg = (data?.error?.message || JSON.stringify(data)).toLowerCase()
|
|
51
|
+
if (msg.includes('no available channel') || msg.includes('model_not_found')) {
|
|
52
|
+
throw new Error(`Custom image API has no provisioned channel for "${cfg.customModel}" (HTTP 503); check gateway channel and routing configuration`)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const detail = formatErrorMessage(data?.error || data)
|
|
56
|
+
throw new Error(`Image API failed (HTTP ${res.status}): ${detail}`)
|
|
57
|
+
}
|
|
58
|
+
const item = data?.data?.[0]
|
|
59
|
+
if (!item) {
|
|
60
|
+
throw new Error(`Image API returned no images: ${JSON.stringify(data).slice(0, 600)}`)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (item.b64_json) {
|
|
64
|
+
return {
|
|
65
|
+
bytes: Buffer.from(item.b64_json, 'base64'),
|
|
66
|
+
mediaType: normalizeMediaType(data.output_format || '', format),
|
|
67
|
+
width: 0,
|
|
68
|
+
height: 0,
|
|
69
|
+
seed: seedArg ?? 0,
|
|
70
|
+
sourceUrl: '',
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (!item.url) {
|
|
74
|
+
throw new Error(`Image API returned neither b64_json nor url: ${JSON.stringify(item).slice(0, 300)}`)
|
|
75
|
+
}
|
|
76
|
+
const download = await fetchImpl(item.url, { signal })
|
|
77
|
+
if (!download.ok) {
|
|
78
|
+
throw new Error(`Failed to download generated image (HTTP ${download.status})`)
|
|
79
|
+
}
|
|
80
|
+
const contentType = download.headers && typeof download.headers.get === 'function'
|
|
81
|
+
? download.headers.get('content-type')
|
|
82
|
+
: ''
|
|
83
|
+
return {
|
|
84
|
+
bytes: Buffer.from(await download.arrayBuffer()),
|
|
85
|
+
mediaType: normalizeMediaType(contentType, format),
|
|
86
|
+
width: 0,
|
|
87
|
+
height: 0,
|
|
88
|
+
seed: seedArg ?? 0,
|
|
89
|
+
sourceUrl: item.url,
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return custom
|
|
93
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import {
|
|
2
|
+
falAuthHeader,
|
|
3
|
+
normalizeMediaType,
|
|
4
|
+
submitJob,
|
|
5
|
+
pollStatus,
|
|
6
|
+
} from '../shared-helpers.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {fetchImpl: Function, resolveKey: Function, cfg: object} deps
|
|
10
|
+
* @param {prompt?: string, size?: string, format?: string, seed?: number, signal?: AbortSignal,
|
|
11
|
+
* negativePrompt?: string, guidanceScale?: number, source?: object, mask?: object, strength?: number,
|
|
12
|
+
* quality?: string, style?: string, aspectPixels?: number[], aspectRatio?: string} job
|
|
13
|
+
*/
|
|
14
|
+
export function createFalGenerator(deps, job) {
|
|
15
|
+
const { fetchImpl, resolveKey, cfg } = deps
|
|
16
|
+
const { prompt, size, format, seed, signal, negativePrompt, guidanceScale, source, mask, strength, quality, style, aspectPixels, aspectRatio } = job
|
|
17
|
+
|
|
18
|
+
async function fal(seedArg = seed, promptArg = prompt) {
|
|
19
|
+
const key = await resolveKey(cfg.apiKeyEnv)
|
|
20
|
+
const isEdit = Boolean(source && source.bytes)
|
|
21
|
+
const targetModel = isEdit
|
|
22
|
+
? (mask && mask.bytes ? 'fal-ai/flux-pro/v1/inpaint' : 'fal-ai/flux/dev/image-to-image')
|
|
23
|
+
: cfg.model
|
|
24
|
+
const body = { prompt: promptArg, image_size: size, num_images: 1 }
|
|
25
|
+
if (seedArg !== undefined) body.seed = seedArg
|
|
26
|
+
if (negativePrompt !== undefined) body.negative_prompt = negativePrompt
|
|
27
|
+
if (guidanceScale !== undefined) body.guidance_scale = guidanceScale
|
|
28
|
+
if (format !== 'png') body.output_format = format
|
|
29
|
+
if (isEdit) {
|
|
30
|
+
body.image_url = `data:${source.mediaType || 'image/png'};base64,${Buffer.from(source.bytes).toString('base64')}`
|
|
31
|
+
if (mask && mask.bytes) {
|
|
32
|
+
body.mask_url = `data:${mask.mediaType || 'image/png'};base64,${Buffer.from(mask.bytes).toString('base64')}`
|
|
33
|
+
}
|
|
34
|
+
if (strength !== undefined) {
|
|
35
|
+
body.strength = strength
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const submit = await submitJob(fetchImpl, cfg.baseURL, targetModel, key, body, signal)
|
|
40
|
+
const statusUrl = submit.status_url || `${cfg.baseURL}/${cfg.model}/requests/${submit.request_id}/status`
|
|
41
|
+
const statusBody = await pollStatus(fetchImpl, statusUrl, key, signal, cfg.pollIntervalMs, cfg.timeoutMs)
|
|
42
|
+
|
|
43
|
+
const resultRes = await fetchImpl(statusBody.response_url, {
|
|
44
|
+
headers: { Authorization: falAuthHeader(key) },
|
|
45
|
+
signal,
|
|
46
|
+
})
|
|
47
|
+
const result = await resultRes.json().catch(() => ({}))
|
|
48
|
+
const image = result.images && result.images[0]
|
|
49
|
+
if (!image || !image.url) {
|
|
50
|
+
throw new Error(`FAL returned no images: ${JSON.stringify(result).slice(0, 600)}`)
|
|
51
|
+
}
|
|
52
|
+
const download = await fetchImpl(image.url, { signal })
|
|
53
|
+
if (!download.ok) {
|
|
54
|
+
throw new Error(`Failed to download generated image (HTTP ${download.status})`)
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
bytes: Buffer.from(await download.arrayBuffer()),
|
|
58
|
+
mediaType: normalizeMediaType(image.content_type, format),
|
|
59
|
+
width: image.width ?? 0,
|
|
60
|
+
height: image.height ?? 0,
|
|
61
|
+
seed: result.seed ?? seedArg ?? 0,
|
|
62
|
+
sourceUrl: image.url,
|
|
63
|
+
cost: result.cost,
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return fal
|
|
67
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import {
|
|
2
|
+
normalizeMediaType,
|
|
3
|
+
} from '../shared-helpers.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {fetchImpl: Function, resolveKey: Function, cfg: object} deps
|
|
7
|
+
* @param {prompt?: string, size?: string, format?: string, seed?: number, signal?: AbortSignal,
|
|
8
|
+
* negativePrompt?: string, guidanceScale?: number, source?: object, mask?: object, strength?: number,
|
|
9
|
+
* quality?: string, style?: string, aspectPixels?: number[], aspectRatio?: string} job
|
|
10
|
+
*/
|
|
11
|
+
export function createGeminiGenerator(deps, job) {
|
|
12
|
+
const { fetchImpl, resolveKey, cfg } = deps
|
|
13
|
+
const { prompt, size, format, seed, signal, negativePrompt, guidanceScale, source, mask, strength, quality, style, aspectPixels, aspectRatio } = job
|
|
14
|
+
|
|
15
|
+
async function gemini(seedArg = seed, promptArg = prompt) {
|
|
16
|
+
const key = await resolveKey(cfg.geminiKeyEnv)
|
|
17
|
+
const model = cfg.geminiModel || 'gemini-2.0-flash-exp-image-generation'
|
|
18
|
+
const res = await fetchImpl(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${encodeURIComponent(key)}`, {
|
|
19
|
+
method: 'POST',
|
|
20
|
+
headers: { 'Content-Type': 'application/json' },
|
|
21
|
+
body: JSON.stringify({
|
|
22
|
+
contents: [{ parts: [{ text: promptArg }] }],
|
|
23
|
+
generationConfig: { responseModalities: ['IMAGE'], ...(quality !== undefined ? { imageConfig: { imageQuality: quality } } : {}), ...(style !== undefined ? { imageConfig: { imageStyle: style } } : {}) },
|
|
24
|
+
}),
|
|
25
|
+
signal,
|
|
26
|
+
})
|
|
27
|
+
const data = await res.json().catch(() => ({}))
|
|
28
|
+
if (!res.ok) {
|
|
29
|
+
const detail = data?.error?.message || JSON.stringify(data).slice(0, 600)
|
|
30
|
+
throw new Error(`Gemini failed (HTTP ${res.status}): ${detail}`)
|
|
31
|
+
}
|
|
32
|
+
const part = data?.candidates?.[0]?.content?.parts?.find((p) => p.inlineData?.data)
|
|
33
|
+
if (!part) throw new Error('Gemini returned no image')
|
|
34
|
+
return { bytes: Buffer.from(part.inlineData.data, 'base64'), mediaType: normalizeMediaType(part.inlineData.mimeType || 'image/png', format), width: 0, height: 0, seed: seedArg ?? 0, sourceUrl: '' }
|
|
35
|
+
}
|
|
36
|
+
return gemini
|
|
37
|
+
}
|