@dickpy/dsh-imagegen 1.2.1 → 1.2.3

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.
@@ -0,0 +1,158 @@
1
+ /** Inline renderer for image-generation tool-result attachments. */
2
+
3
+ import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
4
+ import type { ClientContext, ISessions, SessionId, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
5
+ import { useEffect, useMemo, useState } from 'react'
6
+ import css from './image-toolview.module.css'
7
+
8
+ /** Owner props supplied by the host's keyed tool-call slot. */
9
+ export interface ImageToolViewOwnerProps {
10
+ callId: string
11
+ toolName: string
12
+ block: ToolCallBlock
13
+ cwd?: string
14
+ home?: string
15
+ openFile: (path: string) => void
16
+ inspect?: () => void
17
+ }
18
+
19
+ interface LoadedImage {
20
+ ref: ImageAttachmentRef
21
+ src: string
22
+ }
23
+
24
+ interface ImageToolViewProps extends ImageToolViewOwnerProps {
25
+ sessionId: SessionId
26
+ }
27
+
28
+ function isSettled(block: ToolCallBlock): block is Extract<ToolCallBlock, { kind: 'tool-result' }> {
29
+ return 'kind' in block
30
+ }
31
+
32
+ function imageRefsOf(block: ToolCallBlock): ImageAttachmentRef[] {
33
+ if (!isSettled(block)) return []
34
+ return block.content.flatMap(content => content.type === 'image' ? [content.attachment] : [])
35
+ }
36
+
37
+ function textOf(block: ToolCallBlock): string {
38
+ if (!isSettled(block)) return ''
39
+ return block.content
40
+ .filter(content => content.type === 'text')
41
+ .map(content => content.text)
42
+ .join('\n')
43
+ }
44
+
45
+ function resultInfo(block: ToolCallBlock): { status: string; message: string } {
46
+ if (!isSettled(block)) return { status: 'running', message: '正在生成图片…' }
47
+ const text = textOf(block)
48
+ try {
49
+ const parsed = JSON.parse(text) as { status?: unknown; message?: unknown }
50
+ return {
51
+ status: typeof parsed.status === 'string' ? parsed.status : block.isError ? 'failed' : 'completed',
52
+ message: typeof parsed.message === 'string' ? parsed.message : '',
53
+ }
54
+ } catch {
55
+ return { status: block.isError ? 'failed' : 'completed', message: text }
56
+ }
57
+ }
58
+
59
+ function statusLabel(status: string): string {
60
+ if (status === 'running' || status === 'queued') return '生成中'
61
+ if (status === 'failed') return '生成失败'
62
+ if (status === 'cancelled') return '已取消'
63
+ return '图片结果'
64
+ }
65
+
66
+ function useAttachmentImages(
67
+ sessionId: SessionId,
68
+ refs: ImageAttachmentRef[],
69
+ load: (sessionId: SessionId, ref: ImageAttachmentRef) => Promise<string>,
70
+ ): { images: LoadedImage[]; error: string | null } {
71
+ const key = useMemo(() => refs.map(ref => String(ref.attachmentId)).join('|'), [refs])
72
+ const [images, setImages] = useState<LoadedImage[]>([])
73
+ const [error, setError] = useState<string | null>(null)
74
+
75
+ useEffect(() => {
76
+ let disposed = false
77
+ const urls: string[] = []
78
+ const revoke = (): void => {
79
+ for (const url of urls) URL.revokeObjectURL(url)
80
+ urls.length = 0
81
+ }
82
+
83
+ setImages([])
84
+ setError(null)
85
+ if (refs.length === 0) return () => { /* no attachments to clean up */ }
86
+
87
+ void Promise.all(refs.map(async ref => {
88
+ const src = await load(sessionId, ref)
89
+ urls.push(src)
90
+ return { ref, src }
91
+ }))
92
+ .then(next => {
93
+ if (!disposed) setImages(next)
94
+ })
95
+ .catch(errorValue => {
96
+ revoke()
97
+ if (!disposed) setError(errorValue instanceof Error ? errorValue.message : String(errorValue))
98
+ })
99
+
100
+ return () => {
101
+ disposed = true
102
+ revoke()
103
+ }
104
+ }, [key, load, refs, sessionId])
105
+
106
+ return { images, error }
107
+ }
108
+
109
+ /** Register the inline image result view for all image-generation result tools. */
110
+ export function registerImageToolviews(ctx: ClientContext): void {
111
+ // Older client typings still expose the host-side SessionStore on the
112
+ // generic Context property. The runtime service itself provides the newer
113
+ // binding/session face, so resolve it through Cordis and narrow locally.
114
+ const sessions = ctx.get('sessions') as unknown as ISessions | undefined
115
+ const load = async (sessionId: SessionId, ref: ImageAttachmentRef): Promise<string> => {
116
+ const session = sessions?.binding(sessionId)?.session
117
+ if (session === undefined) throw new Error('当前会话不可用,无法读取图片附件。')
118
+ const result = await session.readAttachment(ref.attachmentId)
119
+ if (!result.ok) throw new Error(result.error.message)
120
+ const blob = new Blob([new Uint8Array(result.value.data)], { type: result.value.attachment.mediaType })
121
+ return URL.createObjectURL(blob)
122
+ }
123
+
124
+ const ImageToolView = (props: ImageToolViewProps): React.JSX.Element => {
125
+ const refs = useMemo(() => imageRefsOf(props.block), [props.block])
126
+ const { status, message } = resultInfo(props.block)
127
+ const { images, error } = useAttachmentImages(props.sessionId, refs, load)
128
+
129
+ return <section className={css.root} data-state={status} data-tool={props.toolName}>
130
+ <header className={css.header}>
131
+ <span className={css.icon} aria-hidden="true">▧</span>
132
+ <strong>{props.toolName}</strong>
133
+ <span className={css.status}>{statusLabel(status)}</span>
134
+ </header>
135
+ {message !== '' && <p className={css.message}>{message}</p>}
136
+ {images.length > 0 && <div className={css.images}>
137
+ {images.map(image => <a
138
+ className={css.imageLink}
139
+ href={image.src}
140
+ key={String(image.ref.attachmentId)}
141
+ rel="noreferrer"
142
+ target="_blank"
143
+ title="打开原图"
144
+ >
145
+ <img className={css.image} src={image.src} alt={image.ref.name ?? '生成图片'} />
146
+ </a>)}
147
+ </div>}
148
+ {refs.length > 0 && images.length === 0 && error === null && <p className={css.loading}>正在加载图片…</p>}
149
+ {error !== null && <p className={css.error}>{error}</p>}
150
+ </section>
151
+ }
152
+
153
+ ctx.slots.inject('tool.call.toolview', function* () {
154
+ for (const key of ['generate_image', 'edit_image', 'get_image_generation_task']) {
155
+ yield ctx.slots.register({ name: 'tool.call.toolview', key }, ImageToolView)
156
+ }
157
+ })
158
+ }
@@ -23,8 +23,9 @@ import { tt } from './helpers.ts'
23
23
  import { en, zh, type ImageGenKey } from './locales.ts'
24
24
  import { mountPanel } from './mount.tsx'
25
25
  import { mountSidebarEntry } from './sidebar-entry.ts'
26
- import { ImageGenSettingsCard, ImageGenSettingsCardController } from './SettingsCard.tsx'
27
- import { bindImageGenScope, type ImageGenScope } from './settings-scope.ts'
26
+ import { ImageGenSettingsCard, ImageGenSettingsCardController } from './SettingsCard.tsx'
27
+ import { bindImageGenScope, type ImageGenScope } from './settings-scope.ts'
28
+ import { registerImageToolviews, type ImageToolViewOwnerProps } from './image-toolview.tsx'
28
29
 
29
30
  /** Locale namespace this plugin owns. */
30
31
  const NS = 'dsh-imagegen'
@@ -35,7 +36,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
35
36
  'dsh-imagegen': ImageGenKey
36
37
  }
37
38
 
38
- interface SlotMap {
39
+ interface SlotMap {
39
40
  /**
40
41
  * The official plugin-configuration slot the Settings → Plugins →
41
42
  * Configurable tab declares and renders. This card registers there as its
@@ -44,9 +45,11 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
44
45
  * same shape so this package can register without depending on the
45
46
  * sibling UI package.
46
47
  */
47
- 'settings.plugin.item': { kind: 'keyed'; scope: 'root'; owner: ImageGenPluginItemOwnerProps }
48
- }
49
- }
48
+ 'settings.plugin.item': { kind: 'keyed'; scope: 'root'; owner: ImageGenPluginItemOwnerProps }
49
+ /** Image-generation results render their durable image blocks inline. */
50
+ 'tool.call.toolview': { kind: 'keyed'; scope: 'session'; owner: ImageToolViewOwnerProps }
51
+ }
52
+ }
50
53
 
51
54
  /** Owner share of a plugin card (the section supplies nothing). */
52
55
  export interface ImageGenPluginItemOwnerProps {
@@ -55,14 +58,15 @@ export interface ImageGenPluginItemOwnerProps {
55
58
  }
56
59
 
57
60
  /** Required services (fiber inject waiting — the runtime must be up first). */
58
- export const inject = ['slots', 'locale', 'connection']
61
+ export const inject = ['slots', 'locale', 'connection', 'sessions']
59
62
 
60
63
  /**
61
64
  * Mount the studio, its sidebar entry, and the settings card.
62
65
  * @param ctx - client root context (services: slots, locale, connection).
63
66
  */
64
- export function apply(ctx: ClientContext): void {
65
- ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-imagegen: dictionaries')
67
+ export function apply(ctx: ClientContext): void {
68
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-imagegen: dictionaries')
69
+ registerImageToolviews(ctx)
66
70
 
67
71
  const connection = ctx.get('connection') as ConnectionHandle | undefined
68
72
  const loopback = connection?.isLoopback === true
@@ -4,7 +4,7 @@
4
4
 
5
5
  export const zh = {
6
6
  'entry.label': 'AI 生图',
7
- 'entry.tooltip': 'AI 生图面板(gpt-image-2 / grok-imagine-image',
7
+ 'entry.tooltip': 'AI 生图面板(gpt-image-2 / grok-imagine-image / nanobanana / seedream 系列)',
8
8
  'panel.title': 'AI 生图',
9
9
  'panel.githubTip': '觉得好用或有建议?欢迎来 GitHub 提 issues、点个 star 支持一下!',
10
10
  // mode
@@ -253,7 +253,7 @@ export const zh = {
253
253
 
254
254
  export const en: Record<keyof typeof zh, string> = {
255
255
  'entry.label': 'AI Image',
256
- 'entry.tooltip': 'AI image generation studio (gpt-image-2 / grok-imagine-image)',
256
+ 'entry.tooltip': 'AI image generation studio (gpt-image-2 / grok-imagine-image / nanobanana / seedream family)',
257
257
  'panel.title': 'AI Image',
258
258
  'panel.githubTip': 'Like it or have suggestions? Head to GitHub to open issues and star us!',
259
259
  'mode.text': 'Text to Image',
package/src/engine.ts CHANGED
@@ -50,6 +50,31 @@ function isGrokImagine(model: string): boolean {
50
50
  return /^grok-imagine(?:-|$)/.test(model)
51
51
  }
52
52
 
53
+ /** Whether the model belongs to the Google Nano Banana family (nanobanana2 /
54
+ * nanobanana2-lite / nanobanana-pro, plus the official Gemini image IDs the
55
+ * gateways expose). OpenAI-compatible gateways serve these with their own
56
+ * aspect_ratio / image_size vocabulary instead of the OpenAI size/quality
57
+ * passthrough. */
58
+ function isNanoBanana(model: string): boolean {
59
+ if (/^nanobanana/i.test(model)) return true
60
+ return (
61
+ model === 'gemini-3-pro-image' || model === 'gemini-3-pro-image-preview' ||
62
+ model === 'gemini-3.1-flash-image' || model === 'gemini-3.1-flash-image-preview' ||
63
+ model === 'gemini-3.1-flash-lite-image' ||
64
+ model === 'gemini-2.5-flash-image'
65
+ )
66
+ }
67
+
68
+ /** Whether the model belongs to the ByteDance Seedream family (seedream-5.0-pro,
69
+ * seedream-5.0, seedream-4.x, doubao-seedream-…). OpenAI-compatible gateways
70
+ * serve Seedream through a unified generate-and-edit architecture:
71
+ * generation AND editing both go to /images/generations, reference images are
72
+ * a JSON URL / data-URL array, and the clarity tier is `resolution` while
73
+ * `size` carries the aspect ratio (or exact pixels). */
74
+ function isSeedream(model: string): boolean {
75
+ return /^(?:doubao-)?seedream/i.test(model)
76
+ }
77
+
53
78
  /** The panel's aspect ratios mapped to the closest OpenAI pixel size
54
79
  * (gpt-image-2 / generic OpenAI-compatible endpoints). */
55
80
  const OPENAI_SIZE_BY_RATIO: Readonly<Record<string, string>> = {
@@ -136,6 +161,7 @@ function effectiveParams(request: GenerateRequest): {
136
161
  quality?: string
137
162
  detail?: string
138
163
  aspect_ratio?: string
164
+ image_size?: string
139
165
  resolution?: string
140
166
  response_format?: string
141
167
  } {
@@ -163,6 +189,39 @@ function effectiveParams(request: GenerateRequest): {
163
189
  response_format: 'b64_json',
164
190
  }
165
191
  }
192
+ // Google Nano Banana: the panel's aspect ratios are sent as-is (the family
193
+ // documents 1:1 … 21:9 natively), the clarity tiers become image_size
194
+ // (1K / 2K / 4K — Gen 1 and 2-Lite are 1K-only upstream, but which gateway
195
+ // rejects higher tiers is its own call), and base64 output keeps any signed
196
+ // result URLs from expiring before the host downloads them.
197
+ if (isNanoBanana(model)) {
198
+ return {
199
+ model,
200
+ ...request.size !== '' && request.size !== 'auto'
201
+ ? { aspect_ratio: request.size }
202
+ : {},
203
+ ...request.quality !== '' && request.quality !== 'auto'
204
+ ? { image_size: request.quality.toUpperCase() }
205
+ : {},
206
+ response_format: 'b64_json',
207
+ }
208
+ }
209
+ // ByteDance Seedream: OpenAI-compatible gateways accept the aspect ratio in
210
+ // `size` directly and the clarity tiers as `resolution` (1K / 2K; the 5.0-pro
211
+ // tier caps at 2K, so 4k falls back to 2K). There is no quality/detail knob,
212
+ // and edits reuse /images/generations with an image array (see below).
213
+ if (isSeedream(model)) {
214
+ return {
215
+ model,
216
+ ...request.size !== '' && request.size !== 'auto'
217
+ ? { size: request.size }
218
+ : {},
219
+ ...request.quality !== '' && request.quality !== 'auto'
220
+ ? { resolution: request.quality === '4k' ? '2K' : request.quality.toUpperCase() }
221
+ : {},
222
+ response_format: 'b64_json',
223
+ }
224
+ }
166
225
  // OpenAI-compatible endpoints: nearest pixel size, clarity tiers mapped to
167
226
  // the quality levels (1k→low / 2k→medium / 4k→high), detail passthrough.
168
227
  return {
@@ -268,6 +327,28 @@ async function requestOneImage(
268
327
  ...params.aspect_ratio !== undefined ? { aspect_ratio: params.aspect_ratio } : {},
269
328
  response_format: 'b64_json',
270
329
  })
330
+ } else if (isNanoBanana(params.model)) {
331
+ // Nano Banana OpenAI-compatible gateways accept the standard multipart
332
+ // edit upload, with the family's own aspect_ratio / image_size knobs.
333
+ const form = new FormData()
334
+ form.append('image', new Blob([bytes], { type: parsed.mime }), `reference.${extensionOf(parsed.mime)}`)
335
+ form.append('prompt', request.prompt)
336
+ form.append('model', params.model)
337
+ if (params.aspect_ratio !== undefined) form.append('aspect_ratio', params.aspect_ratio)
338
+ if (params.image_size !== undefined) form.append('image_size', params.image_size)
339
+ body = form
340
+ } else if (isSeedream(params.model)) {
341
+ // Seedream unifies generation and editing on /images/generations; the
342
+ // reference image is a JSON URL / data-URL array, never multipart.
343
+ headers['content-type'] = 'application/json'
344
+ body = JSON.stringify({
345
+ model: params.model,
346
+ prompt: request.prompt,
347
+ image: [request.image],
348
+ ...params.size !== undefined ? { size: params.size } : {},
349
+ ...params.resolution !== undefined ? { resolution: params.resolution } : {},
350
+ response_format: 'b64_json',
351
+ })
271
352
  } else {
272
353
  const form = new FormData()
273
354
  form.append('image', new Blob([bytes], { type: parsed.mime }), `reference.${extensionOf(parsed.mime)}`)
@@ -286,7 +367,11 @@ async function requestOneImage(
286
367
  const budget = requestSignal(signal, UPSTREAM_TIMEOUT_MS)
287
368
  let response: Response
288
369
  try {
289
- response = await fetch(`${baseUrl}/images/${request.mode === 'edit' ? 'edits' : 'generations'}`, {
370
+ // Seedream has no /images/edits endpoint: both modes hit generations.
371
+ const endpoint = request.mode === 'edit' && !isSeedream(params.model)
372
+ ? '/images/edits'
373
+ : '/images/generations'
374
+ response = await fetch(`${baseUrl}${endpoint}`, {
290
375
  method: 'POST',
291
376
  headers,
292
377
  body,
@@ -4,7 +4,7 @@
4
4
  * allow-list because OpenAI-compatible gateways rarely advertise modalities.
5
5
  */
6
6
 
7
- export const DEFAULT_IMAGE_MODELS = ['gpt-image-2', 'grok-imagine-image'] as const
7
+ export const DEFAULT_IMAGE_MODELS = ['gpt-image-2', 'grok-imagine-image', 'nanobanana2', 'nanobanana2-lite', 'nanobanana-pro', 'seedream-5.0-pro'] as const
8
8
 
9
9
  /** Normalize user-entered model identifiers and retain a usable legacy default. */
10
10
  export function normalizeImageModels(value: unknown): string[] {
package/src/index.ts CHANGED
@@ -83,7 +83,7 @@ const DEFAULT_ALLOW_AGENT_IMAGE_GENERATION = true
83
83
  const SECTION_ORDER = 150
84
84
 
85
85
  /** Model-facing announcement: plugin presence, capabilities, and limits. */
86
- export const IMAGEGEN_GUIDANCE = '本机已安装 dsh-imagegen 插件(DSH AI 生图):侧边栏「AI 生图」入口。能力:对接 OpenAI 兼容图像生成 API,模型由用户在「设置 → 插件 → AI 生图」中检测或手动配置的生图模型列表决定;支持文生图(/images/generations)与图生图(/images/edits,上传参考图,grok-imagine 模型按官方 JSON image_url 协议发送)。API 地址与密钥在 GUI 设置中配置,密钥仅存于本机设置文档;生成请求由本地宿主代理转发,结果以 base64 返回面板,可预览与下载。模型只能使用已配置的生图模型;模型出现在 /models 中不等于其网关原生支持生图协议,遇到 Qwen、Gemini 等非 OpenAI 生图协议时应如实说明上游兼容性。可一键把满意的图片加入「画廊」。内置「提示词模板库」(面板提示词框左下角「模板库」按钮):打包 awesome-gpt-image-2 的数百条提示词案例,可搜索、筛选与复用。Agent 可直接调用 `generate_image` 提交文生图,也可用 `edit_image` 图生图;任务后台异步执行,完成后插件会自动唤醒原对话,并以可直接查看和复用的图片附件回贴结果,因此不要反复轮询。仅在用户明确要求进度或需要恢复任务时,才使用 `get_image_generation_task` 查询状态。限制:生成消耗上游 API 额度;图片内容由上游模型生成,可能不符合预期或包含不适宜内容;api_key 以明文存储在设置文档中;参考图会发送至所配置的 API 服务;模板库在线刷新与参考图首次加载需要访问 vibeui.top。用户提到「生图 / 绘画 / 生成图片 / 文生图 / 图生图 / 画廊 / 提示词模板」时即指本插件,请据此协作。'
86
+ export const IMAGEGEN_GUIDANCE = '本机已安装 dsh-imagegen 插件(DSH AI 生图):侧边栏「AI 生图」入口。能力:对接 OpenAI 兼容图像生成 API,模型由用户在「设置 → 插件 → AI 生图」中检测或手动配置的生图模型列表决定;支持文生图(/images/generations)与图生图(/images/edits,上传参考图,grok-imagine 模型按官方 JSON image_url 协议发送,nanobanana 系列按 aspect_ratio / image_size 参数协议发送;seedream 系列统一走 /images/generations,参考图以 JSON image 数组发送)。API 地址与密钥在 GUI 设置中配置,密钥仅存于本机设置文档;生成请求由本地宿主代理转发,结果以 base64 返回面板,可预览与下载。模型只能使用已配置的生图模型;模型出现在 /models 中不等于其网关原生支持生图协议,遇到 Qwen、Gemini 等非 OpenAI 生图协议时应如实说明上游兼容性。可一键把满意的图片加入「画廊」。内置「提示词模板库」(面板提示词框左下角「模板库」按钮):打包 awesome-gpt-image-2 的数百条提示词案例,可搜索、筛选与复用。Agent 可直接调用 `generate_image` 提交文生图,也可用 `edit_image` 图生图;默认保持工具调用等待直到任务完成,完成图片直接作为工具结果附件返回,不会额外伪造用户消息。若明确需要后台执行,可传 `wait_for_completion: false`,之后再用 `get_image_generation_task` 查询;不要反复轮询。限制:生成消耗上游 API 额度;图片内容由上游模型生成,可能不符合预期或包含不适宜内容;api_key 以明文存储在设置文档中;参考图会发送至所配置的 API 服务;模板库在线刷新与参考图首次加载需要访问 vibeui.top。用户提到「生图 / 绘画 / 生成图片 / 文生图 / 图生图 / 画廊 / 提示词模板」时即指本插件,请据此协作。'
87
87
 
88
88
  /** Add the live allow-list so an Agent can honor a user's model choice. */
89
89
  function guidanceFor(imageModels: string[]): string {
@@ -129,7 +129,8 @@ export function apply(ctx: Context, config?: Config): void {
129
129
 
130
130
  // Browser endpoints and Agent tools share the exact same serial queue. This
131
131
  // keeps image persistence, cancellation, and retries coherent across both
132
- // entry points while a tool call itself returns immediately with a task id.
132
+ // entry points; Agent tools wait for their task result by default and render
133
+ // images in the tool result instead of injecting a synthetic user message.
133
134
  const runtime = new ImageGenerationRuntime(() => {
134
135
  const value = resolve()
135
136
  return { apiUrl: value.apiUrl, apiKey: value.apiKey }
package/src/protocol.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  export const IMAGEGEN_SETTINGS_NAMESPACE = 'dsh-imagegen'
9
9
 
10
10
  /** Published package version shared by the host updater and the client UI. */
11
- export const PLUGIN_VERSION = '1.2.1'
11
+ export const PLUGIN_VERSION = '1.2.3'
12
12
 
13
13
  /** Same-origin route family (loopback-only, mirroring the dsh-ssh fence). */
14
14
  export const SETTINGS_API = {
@@ -145,11 +145,13 @@ export interface GenerateRequest {
145
145
  /** The prompt. Upstream providers may impose their own length limits. */
146
146
  prompt: string
147
147
  /** Canvas size as an aspect ratio: 'auto' or e.g. '1:1' / '16:9' / '21:9'.
148
- * The host maps it onto each model's own vocabulary (aspect_ratio for Grok,
149
- * the closest pixel size for OpenAI-compatible endpoints). */
148
+ * The host maps it onto each model's own vocabulary (aspect_ratio for Grok
149
+ * and Nano Banana, size-aspect for Seedream, the closest pixel size for
150
+ * OpenAI-compatible endpoints). */
150
151
  size: string
151
152
  /** Clarity tier: 'auto' | '1k' | '2k' | '4k'. The host maps it onto the
152
- * model's own vocabulary (resolution for Grok, quality for OpenAI). */
153
+ * model's own vocabulary (resolution for Grok / Seedream, image_size for
154
+ * Nano Banana, quality for OpenAI). */
153
155
  quality: string
154
156
  /** Number of images, 1-4. */
155
157
  n: number