@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 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:rgba(16,185,129,0.08)}
79
- .ig-badge-warn{border-color:var(--dsw-alias-state-warning-primary);color:var(--dsw-alias-state-warning-primary);background:rgba(245,158,11,0.08)}
80
- .ig-badge-bad{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);background:rgba(239,68,68,0.08)}
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,rgba(239,68,68,0.12));border-color:var(--dsw-alias-state-error-primary)}
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,rgba(16,185,129,0.1));color:var(--dsw-alias-state-success-primary);font-size:13px}
110
- .ig-alert-bad{padding:10px 14px;border-radius:8px;background:var(--dsw-alias-state-error-bg,rgba(239,68,68,0.1));color:var(--dsw-alias-state-error-primary);font-size:13px}
111
- .ig-alert-err{padding:10px 14px;border-radius:8px;background:rgba(239,68,68,0.1);color:var(--dsw-alias-state-error-primary);font-size:13px}
112
- .ig-banner-warning{padding:12px 16px;border-radius:8px;background:rgba(245,158,11,0.12);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}
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,rgba(239,68,68,0.1));color:var(--dsw-alias-state-error-primary);font-size:12px}
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,rgba(0,0,0,0.8)))',
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,#fff)',
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,rgba(0,0,0,0.7))',
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,#0a0a0c)' },
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 rgba(0,0,0,0.4))',
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: '/dsh-image-gen/image?' + query,
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
+ }