@civitai/blocks-react 0.72.3 → 0.73.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,6 +7,17 @@ Pairs with [`@civitai/app-sdk`](https://www.npmjs.com/package/@civitai/app-sdk)'
7
7
  `postMessage` contract. This package adds the transport that actually moves bytes
8
8
  and the React hooks block authors call.
9
9
 
10
+ > **Starting a new block app? Start from
11
+ > [`@civitai/sdk`](https://www.npmjs.com/package/@civitai/sdk).** The three
12
+ > packages are layers over one host contract, and all three are supported:
13
+ >
14
+ > - **`@civitai/sdk`** is the client new block apps start on: `initialize()`,
15
+ > then `app.workflows`, `app.storage`, `app.site` and `app.host`.
16
+ > - **`@civitai/app-sdk/blocks`** is the contract itself: the workflow body
17
+ > types, the manifest types and the host message shapes.
18
+ > - **`@civitai/blocks-react`** (this package) is the React hook binding over
19
+ > that same contract.
20
+
10
21
  ## Install
11
22
 
12
23
  ```bash
@@ -2239,6 +2250,128 @@ unstamped) or an already-posted id is refused with
2239
2250
  rule, the scope check, the real scan, or an older host's picker. `dev:live` has
2240
2251
  no bytes path: `upload` rejects with `the host returned no uploaded image`.
2241
2252
 
2253
+ ### `useImagePicker()`
2254
+
2255
+ Let the viewer pick one of their **existing** Civitai images: the ones they have
2256
+ posted, and the ones they made with the Civitai generator. The host opens its own
2257
+ picker over the viewer's library, and your app receives only what the viewer
2258
+ picked. Returns `{ open }`. `open()` resolves with one image, or `null` if
2259
+ nothing was picked.
2260
+
2261
+ ```tsx
2262
+ const { open } = useImagePicker();
2263
+
2264
+ const image = await open(); // BlockPickedImageInfo | null
2265
+ if (image) {
2266
+ // image.source 'posted' | 'generated'
2267
+ // image.url a Civitai-hosted image URL
2268
+ // image.width, image.height
2269
+ // image.nsfwLevel the image's rating; it can be a mature level
2270
+ // image.imageId set for a posted image, undefined for a generated one
2271
+ }
2272
+ ```
2273
+
2274
+ **Several images in one session: `multiple: { max }`.** `open` then resolves with
2275
+ a **list** of up to `max` images instead of a single image, or `[]` if nothing
2276
+ was picked.
2277
+
2278
+ ```tsx
2279
+ const { open } = useImagePicker();
2280
+
2281
+ const images = await open({ multiple: { max: 5 } });
2282
+ // [] when nothing was picked; otherwise 1 to 5 images
2283
+ const urls = images.map((image) => image.url);
2284
+ ```
2285
+
2286
+ **One library only: `sources`.** Pass `['posted']` or `['generated']` to offer
2287
+ just that library. Leave it out to offer both.
2288
+
2289
+ ```tsx
2290
+ const { open } = useImagePicker();
2291
+
2292
+ const output = await open({ sources: ['generated'] });
2293
+ // output?.imageId is always undefined here: a generator output has no image id
2294
+ ```
2295
+
2296
+ If your app decides at runtime whether to ask for one image or several, pass
2297
+ `multiple` as a value that may be `undefined`. The result is then either shape,
2298
+ so narrow it with `Array.isArray`:
2299
+
2300
+ ```tsx
2301
+ const { open } = useImagePicker();
2302
+
2303
+ const picked = await open({ multiple: allowSeveral ? { max: 3 } : undefined });
2304
+ const images = Array.isArray(picked) ? picked : picked ? [picked] : [];
2305
+ ```
2306
+
2307
+ What the viewer can pick, and what you get back:
2308
+
2309
+ - **The host decides what is pickable.** Posted images: the viewer's own, in a
2310
+ published post, fully scanned, not flagged and rated. Generated images: the
2311
+ viewer's own finished generator outputs that have a rating. In both cases the
2312
+ viewer is offered only what their own browsing settings on Civitai allow them
2313
+ to see. An unrated image is never offered. Images only, no video. Your app
2314
+ never sees the rest of the library.
2315
+ - 🔴 **A pick can be a mature image.** `nsfwLevel` is the image's rating, and it
2316
+ is never `0`. The image was offered to this viewer under this viewer's own
2317
+ settings. That says nothing about anyone else. If your app shows a pick to
2318
+ anybody other than the viewer who picked it (a shared board, a gallery, a
2319
+ public post), it is your app's job to check `nsfwLevel` and decide who may
2320
+ see it.
2321
+ - **`imageId` is set only on a posted image.** A generator output that was never
2322
+ posted has no image id, so check `image.source` or `image.imageId` before you
2323
+ use it.
2324
+ - **A posted pick's `url` is a resized copy, not the original file.** `width` and
2325
+ `height` are the size that copy is requested at. The host chooses one with both
2326
+ sides between 64 and 2048 pixels, and a small original is scaled up. The image
2327
+ server normally returns exactly that size, but it can return the original
2328
+ while the resized copy is still being produced. A posted image that has no
2329
+ size in that range (a very long or very wide one) is not offered.
2330
+ - 🔴 **A generated pick's `url` is the generator's own output link and may stop
2331
+ working after a while.** Use it soon after the pick and do not store it for
2332
+ later. Its `width` and `height` are the output's own size.
2333
+
2334
+ What `open` resolves and rejects with:
2335
+
2336
+ - **`null` (or `[]` with `multiple`) means nothing was picked.** Usually the
2337
+ viewer closed the picker. A signed-out viewer sees the picker with a sign-in
2338
+ button, and closing it gives the same result. The host also answers this way
2339
+ immediately, without showing a picker, while your app is being viewed in
2340
+ review mode or is not ready yet.
2341
+ - **`max` is a whole number of at least 1, capped at 20** (exported as
2342
+ `IMAGE_PICKER_MULTIPLE_MAX`). A larger value is clamped to 20. `0`, a negative
2343
+ number or a fraction rejects before anything is sent, and so does an empty
2344
+ `sources` or a value other than `'posted'` and `'generated'`.
2345
+ - **A refusal rejects.** If the host refuses the request, `open` rejects with an
2346
+ `Error` carrying the host's message. It never resolves with `null` for a
2347
+ refusal.
2348
+ - **Page apps only.** On any other surface the host answers at once and `open`
2349
+ rejects with `unsupported on this host`.
2350
+ - **On a host that does not have the picker yet**, the request gets no answer and
2351
+ `open` rejects with a `RequestTimeoutError` after 10 minutes.
2352
+
2353
+ Using a pick as the source image of a generation: a pick's `url` is hosted by
2354
+ Civitai, so `{ url, width, height }` passes the address check a generation body
2355
+ makes on its `sourceImage`. That is the only thing this hook settles. The
2356
+ generation body has its own rules, including 64 to 2048 pixels per side. A posted
2357
+ pick is already within that range. A generated pick can be larger, and a
2358
+ generation body refuses a size above 2048.
2359
+
2360
+ With the mock host (`createMockHost` or `Harness` from
2361
+ `@civitai/blocks-react/testing`) there is no picker to click through: `open`
2362
+ resolves at once. By default the mock holds one posted image and one generated
2363
+ image. Pass `cannedImagePicks: [...]` to set the viewer's library. The mock keeps
2364
+ only the images matching the request's `sources`, then answers a single pick with
2365
+ the first one and a `multiple` pick with the first `max`. `cannedImagePicks: null`
2366
+ is a viewer who always closes the picker. Like the host, the mock offers a
2367
+ repeated image once and drops an `imageId` from a generated image. It does not
2368
+ model browsing settings or ratings: it returns whatever `nsfwLevel` you give an
2369
+ image, so use that to test how your app handles a mature pick. The mock refuses
2370
+ an invalid `sources` or `multiple` with an error, and answers
2371
+ `unsupported on this host` when its `context` is a model slot. `dev:live` cannot
2372
+ list the viewer's images, so there `open` rejects with a message saying to use
2373
+ the mock host.
2374
+
2242
2375
  ### `useSaveImage()`
2243
2376
 
2244
2377
  Ask the host to download a file to the viewer's device. **A block cannot
@@ -0,0 +1,126 @@
1
+ import type { BlockImagePickerSource, BlockPickedImageInfo } from '@civitai/app-sdk/blocks';
2
+ /**
3
+ * The most images ONE multi pick `open({ multiple })` can return. A larger
4
+ * `multiple.max` is CLAMPED to this, here and again by the host.
5
+ */
6
+ export declare const IMAGE_PICKER_MULTIPLE_MAX = 20;
7
+ /** The options every {@link UseImagePicker} `open` call takes. All optional. */
8
+ export interface ImagePickerOpenOptions {
9
+ /**
10
+ * Which of the viewer's libraries the picker offers: `'posted'` (images they
11
+ * posted to Civitai), `'generated'` (outputs they made with the Civitai
12
+ * generator), or both. OMIT it to offer both.
13
+ *
14
+ * An empty array or an unknown member rejects before anything is sent. A
15
+ * repeated member is harmless.
16
+ */
17
+ sources?: BlockImagePickerSource[];
18
+ }
19
+ /**
20
+ * Multi pick: let the viewer pick up to `max` images in ONE picker session.
21
+ *
22
+ * `max` is a whole number ≥ 1. Anything above
23
+ * {@link IMAGE_PICKER_MULTIPLE_MAX} (20) is CLAMPED to it; anything that is not
24
+ * a whole number ≥ 1 rejects before anything is sent.
25
+ */
26
+ export interface ImagePickerMultiple {
27
+ max: number;
28
+ }
29
+ /**
30
+ * What `open({ multiple })` rejects with when the host's reply to a multi pick
31
+ * carries neither an image list nor an error. A multi pick is always answered
32
+ * with a list (`[]` when nothing was picked), so a reply without one is a
33
+ * malformed reply, not a dismiss.
34
+ */
35
+ export declare const IMAGE_PICKER_NO_LIST_ERROR = "the host returned no image list";
36
+ /** What {@link useImagePicker} returns. */
37
+ export interface UseImagePicker {
38
+ open: {
39
+ /**
40
+ * Single pick. Resolves with the chosen image, or `null` when nothing was
41
+ * picked. Rejects when the host refuses the request.
42
+ */
43
+ (opts?: ImagePickerOpenOptions & {
44
+ multiple?: undefined;
45
+ }): Promise<BlockPickedImageInfo | null>;
46
+ /**
47
+ * Multi pick (see {@link ImagePickerMultiple}). Resolves with the picked
48
+ * images, never more than `max`, or `[]` when nothing was picked. Each
49
+ * entry is the same {@link BlockPickedImageInfo} a single pick returns.
50
+ * Rejects when the host refuses the request.
51
+ */
52
+ (opts: ImagePickerOpenOptions & {
53
+ multiple: ImagePickerMultiple;
54
+ }): Promise<BlockPickedImageInfo[]>;
55
+ /**
56
+ * A `multiple` decided at runtime, e.g.
57
+ * `open({ multiple: many ? { max: 3 } : undefined })`. The result is a list
58
+ * when `multiple` was set and a single image or `null` when it was not, so
59
+ * narrow it with `Array.isArray`.
60
+ */
61
+ (opts: ImagePickerOpenOptions & {
62
+ multiple?: ImagePickerMultiple | undefined;
63
+ }): Promise<BlockPickedImageInfo | BlockPickedImageInfo[] | null>;
64
+ };
65
+ }
66
+ /**
67
+ * Lets the viewer pick one or more of their EXISTING Civitai images: the ones
68
+ * they posted, and the ones they made with the Civitai generator.
69
+ *
70
+ * `open` asks the host to open its OWN picker over the viewer's library. The
71
+ * viewer browses in host chrome; the block never lists the library and receives
72
+ * only what the viewer picked, as {@link BlockPickedImageInfo}. That type's
73
+ * docblock is the one place that states what is pickable, what `url`, `width`
74
+ * and `height` mean for each library, and that a pick can be a mature image.
75
+ *
76
+ * - Single pick (the default) resolves with the image, or `null`.
77
+ * - `multiple: { max }` resolves with a LIST of up to `max` images (at most
78
+ * {@link IMAGE_PICKER_MULTIPLE_MAX}), or `[]`.
79
+ * - `sources` narrows the picker to one library. Omit it for both.
80
+ *
81
+ * `null` / `[]` MEAN "NOTHING WAS PICKED", which is usually the viewer closing
82
+ * the picker (a signed-out viewer sees it with a sign-in button, and closing it
83
+ * is the same). The host also answers that way AT ONCE, without showing a
84
+ * picker, while the app is being viewed in review mode or is not ready yet.
85
+ *
86
+ * A REFUSAL REJECTS; it is never `null`. `open` rejects with the host's message
87
+ * when the host refuses the request, and with `unsupported on this host` on a
88
+ * surface that does not offer the picker (it is a page-app affordance). It also
89
+ * rejects, before sending anything, for an empty or unknown `sources` and for a
90
+ * `multiple.max` that is not a whole number ≥ 1. A multi pick whose reply
91
+ * carries no list rejects with {@link IMAGE_PICKER_NO_LIST_ERROR}.
92
+ *
93
+ * A host that predates the picker does not answer the request at all, so the
94
+ * call rejects with `RequestTimeoutError` after the human-interaction timeout
95
+ * ({@link HUMAN_INTERACTION_TIMEOUT_MS}, 10 minutes).
96
+ *
97
+ * 🔴 THE ORDER OF THE `@example` BLOCKS BELOW IS LOAD-BEARING — DO NOT MOVE
98
+ * THE LAST ONE. `<civitai-developer-docs>/scripts/gen-appblocks-hooks.mjs` assigns
99
+ * `jsdocExample` inside its tag loop, so on a hook with several `@example` tags
100
+ * the LAST one wins and becomes the single published example whenever the README
101
+ * fence is unavailable. The plain single pick is what most apps should copy, so
102
+ * it is last.
103
+ *
104
+ * @example
105
+ * // ONE LIBRARY — only the viewer's generator outputs.
106
+ * const { open } = useImagePicker();
107
+ * const output = await open({ sources: ['generated'] });
108
+ * // output?.imageId is undefined: a generator output has no image id
109
+ *
110
+ * @example
111
+ * // MULTI PICK — up to 5 images in one picker session.
112
+ * const { open } = useImagePicker();
113
+ * const images = await open({ multiple: { max: 5 } });
114
+ * // [] when nothing was picked; otherwise 1 to 5 picks
115
+ * const urls = images.map((image) => image.url);
116
+ *
117
+ * @example
118
+ * // THE DEFAULT — one image, from either library.
119
+ * const { open } = useImagePicker();
120
+ * const image = await open();
121
+ * if (image) {
122
+ * // image.url, image.width, image.height, image.nsfwLevel, image.source
123
+ * }
124
+ */
125
+ export declare function useImagePicker(): UseImagePicker;
126
+ //# sourceMappingURL=useImagePicker.d.ts.map
@@ -0,0 +1,133 @@
1
+ import { useCallback } from 'react';
2
+ import { throwOnReplyError } from '../internal/replyError.js';
3
+ import { HUMAN_INTERACTION_TIMEOUT_MS } from '../transport/requestTimeouts.js';
4
+ import { getTransport } from '../transport/singleton.js';
5
+ import { sendTypedRequest } from '../transport/transport.js';
6
+ import { useTransportConnection } from './useTransportConnection.js';
7
+ /**
8
+ * The most images ONE multi pick `open({ multiple })` can return. A larger
9
+ * `multiple.max` is CLAMPED to this, here and again by the host.
10
+ */
11
+ export const IMAGE_PICKER_MULTIPLE_MAX = 20;
12
+ /**
13
+ * What `open({ multiple })` rejects with when the host's reply to a multi pick
14
+ * carries neither an image list nor an error. A multi pick is always answered
15
+ * with a list (`[]` when nothing was picked), so a reply without one is a
16
+ * malformed reply, not a dismiss.
17
+ */
18
+ export const IMAGE_PICKER_NO_LIST_ERROR = 'the host returned no image list';
19
+ /** The two libraries the picker knows. Anything else is refused before sending. */
20
+ const IMAGE_PICKER_SOURCES = new Set([
21
+ 'posted',
22
+ 'generated',
23
+ ]);
24
+ /**
25
+ * Lets the viewer pick one or more of their EXISTING Civitai images: the ones
26
+ * they posted, and the ones they made with the Civitai generator.
27
+ *
28
+ * `open` asks the host to open its OWN picker over the viewer's library. The
29
+ * viewer browses in host chrome; the block never lists the library and receives
30
+ * only what the viewer picked, as {@link BlockPickedImageInfo}. That type's
31
+ * docblock is the one place that states what is pickable, what `url`, `width`
32
+ * and `height` mean for each library, and that a pick can be a mature image.
33
+ *
34
+ * - Single pick (the default) resolves with the image, or `null`.
35
+ * - `multiple: { max }` resolves with a LIST of up to `max` images (at most
36
+ * {@link IMAGE_PICKER_MULTIPLE_MAX}), or `[]`.
37
+ * - `sources` narrows the picker to one library. Omit it for both.
38
+ *
39
+ * `null` / `[]` MEAN "NOTHING WAS PICKED", which is usually the viewer closing
40
+ * the picker (a signed-out viewer sees it with a sign-in button, and closing it
41
+ * is the same). The host also answers that way AT ONCE, without showing a
42
+ * picker, while the app is being viewed in review mode or is not ready yet.
43
+ *
44
+ * A REFUSAL REJECTS; it is never `null`. `open` rejects with the host's message
45
+ * when the host refuses the request, and with `unsupported on this host` on a
46
+ * surface that does not offer the picker (it is a page-app affordance). It also
47
+ * rejects, before sending anything, for an empty or unknown `sources` and for a
48
+ * `multiple.max` that is not a whole number ≥ 1. A multi pick whose reply
49
+ * carries no list rejects with {@link IMAGE_PICKER_NO_LIST_ERROR}.
50
+ *
51
+ * A host that predates the picker does not answer the request at all, so the
52
+ * call rejects with `RequestTimeoutError` after the human-interaction timeout
53
+ * ({@link HUMAN_INTERACTION_TIMEOUT_MS}, 10 minutes).
54
+ *
55
+ * 🔴 THE ORDER OF THE `@example` BLOCKS BELOW IS LOAD-BEARING — DO NOT MOVE
56
+ * THE LAST ONE. `<civitai-developer-docs>/scripts/gen-appblocks-hooks.mjs` assigns
57
+ * `jsdocExample` inside its tag loop, so on a hook with several `@example` tags
58
+ * the LAST one wins and becomes the single published example whenever the README
59
+ * fence is unavailable. The plain single pick is what most apps should copy, so
60
+ * it is last.
61
+ *
62
+ * @example
63
+ * // ONE LIBRARY — only the viewer's generator outputs.
64
+ * const { open } = useImagePicker();
65
+ * const output = await open({ sources: ['generated'] });
66
+ * // output?.imageId is undefined: a generator output has no image id
67
+ *
68
+ * @example
69
+ * // MULTI PICK — up to 5 images in one picker session.
70
+ * const { open } = useImagePicker();
71
+ * const images = await open({ multiple: { max: 5 } });
72
+ * // [] when nothing was picked; otherwise 1 to 5 picks
73
+ * const urls = images.map((image) => image.url);
74
+ *
75
+ * @example
76
+ * // THE DEFAULT — one image, from either library.
77
+ * const { open } = useImagePicker();
78
+ * const image = await open();
79
+ * if (image) {
80
+ * // image.url, image.width, image.height, image.nsfwLevel, image.source
81
+ * }
82
+ */
83
+ export function useImagePicker() {
84
+ useTransportConnection();
85
+ const open = useCallback(async (opts) => {
86
+ // Both checks run BEFORE anything is sent: the caller gets an error naming
87
+ // the mistake instead of the host's refusal a round-trip later.
88
+ const requestedSources = opts?.sources;
89
+ if (requestedSources != null) {
90
+ if (!Array.isArray(requestedSources) || requestedSources.length === 0) {
91
+ throw new Error("useImagePicker: `sources` must be a non-empty array of 'posted' and/or 'generated'. " +
92
+ 'Omit it to offer both.');
93
+ }
94
+ const unknown = requestedSources.find((source) => typeof source !== 'string' || !IMAGE_PICKER_SOURCES.has(source));
95
+ if (unknown !== undefined) {
96
+ throw new Error(`useImagePicker: unknown \`sources\` entry "${String(unknown)}". ` +
97
+ "The picker offers 'posted' and 'generated'.");
98
+ }
99
+ }
100
+ // OMIT the key when it was not given: an absent `sources` is what means
101
+ // "both", and the wire stays the minimum the caller asked for.
102
+ const sources = requestedSources != null ? { sources: [...requestedSources] } : {};
103
+ if (opts?.multiple == null) {
104
+ const result = await sendTypedRequest(getTransport(), { type: 'OPEN_IMAGE_PICKER', payload: { ...sources } }, 'IMAGE_PICKER_RESULT', { timeoutMs: HUMAN_INTERACTION_TIMEOUT_MS });
105
+ // 🔴 BEFORE reading `selected`. A refusal carries no `selected`, so
106
+ // without this a refused request (or `unsupported on this host`) would
107
+ // resolve `null` and read exactly like the viewer dismissing.
108
+ throwOnReplyError(result, 'The host refused the image picker request.');
109
+ // Normalize the "dismissed" case to an explicit null so callers can
110
+ // `if (!image) return;` without an `undefined` ambiguity.
111
+ return result.selected ?? null;
112
+ }
113
+ // ── Multi pick ────────────────────────────────────────────────────────
114
+ const requestedMax = opts.multiple.max;
115
+ if (typeof requestedMax !== 'number' || !Number.isInteger(requestedMax) || requestedMax < 1) {
116
+ throw new Error('useImagePicker: `multiple.max` must be a whole number of at least 1 ' +
117
+ `(values above ${IMAGE_PICKER_MULTIPLE_MAX} are clamped to ${IMAGE_PICKER_MULTIPLE_MAX}).`);
118
+ }
119
+ const max = Math.min(requestedMax, IMAGE_PICKER_MULTIPLE_MAX);
120
+ const result = await sendTypedRequest(getTransport(), { type: 'OPEN_IMAGE_PICKER', payload: { ...sources, multiple: { max } } }, 'IMAGE_PICKER_RESULT', { timeoutMs: HUMAN_INTERACTION_TIMEOUT_MS });
121
+ throwOnReplyError(result, 'The host refused the multi-pick image picker request.');
122
+ // A multi request is always answered with `selectedImages` (`[]` when
123
+ // nothing was picked). A reply without the list is malformed, so it is
124
+ // refused rather than read as "nothing picked".
125
+ const picked = result.selectedImages;
126
+ if (!Array.isArray(picked))
127
+ throw new Error(IMAGE_PICKER_NO_LIST_ERROR);
128
+ // Belt: never hand back more than was asked for.
129
+ return picked.slice(0, max);
130
+ }, []);
131
+ return { open };
132
+ }
133
+ //# sourceMappingURL=useImagePicker.js.map
package/dist/index.d.ts CHANGED
@@ -86,6 +86,8 @@ export { useImageUpload } from './hooks/useImageUpload.js';
86
86
  export type { UseImageUpload, UseImageUploadAsyncScan, UseImageUploadGenerationSource, UseImageUploadOptions, } from './hooks/useImageUpload.js';
87
87
  export { useUploadImageBytes } from './hooks/useUploadImageBytes.js';
88
88
  export type { UploadImageBytesOptions, UseUploadImageBytes, } from './hooks/useUploadImageBytes.js';
89
+ export { useImagePicker, IMAGE_PICKER_MULTIPLE_MAX } from './hooks/useImagePicker.js';
90
+ export type { UseImagePicker, ImagePickerOpenOptions, ImagePickerMultiple, } from './hooks/useImagePicker.js';
89
91
  export { useGenerationResources } from './hooks/useGenerationResources.js';
90
92
  export type { UseGenerationResources } from './hooks/useGenerationResources.js';
91
93
  export { GENERATION_RESOURCES_API_BASE, MAX_GENERATION_RESOURCE_IDS, buildGenerationResourcesUrl, responseToResources, } from './api/generationResources.js';
package/dist/index.js CHANGED
@@ -68,6 +68,7 @@ export { useCheckpointPicker } from './hooks/useCheckpointPicker.js';
68
68
  export { useResourcePicker, RESOURCE_PICKER_MULTIPLE_MAX } from './hooks/useResourcePicker.js';
69
69
  export { useImageUpload } from './hooks/useImageUpload.js';
70
70
  export { useUploadImageBytes } from './hooks/useUploadImageBytes.js';
71
+ export { useImagePicker, IMAGE_PICKER_MULTIPLE_MAX } from './hooks/useImagePicker.js';
71
72
  export { useGenerationResources } from './hooks/useGenerationResources.js';
72
73
  export { GENERATION_RESOURCES_API_BASE, MAX_GENERATION_RESOURCE_IDS, buildGenerationResourcesUrl, responseToResources, } from './api/generationResources.js';
73
74
  export { useCivitaiNavigate } from './hooks/useCivitaiNavigate.js';
@@ -47,7 +47,7 @@
47
47
  * fetch chokepoints ..... 2
48
48
  * REST endpoints ........ GET /api/v1/blocks/me
49
49
  * tRPC procedures ....... 29 = blocks.* (14) + apps.shared.* (10) + apps.storage.* (5)
50
- * switch case labels .... 49, of which 11 REFUSE (enumerated under SCOPE below)
50
+ * switch case labels .... 50, of which 12 REFUSE (enumerated under SCOPE below)
51
51
  * --- END DERIVED ---
52
52
  *
53
53
  * PICKERS (Phase 1 of "make dev:live a faithful local host"): the live host
@@ -127,6 +127,9 @@
127
127
  * zip/yaml parse that lives in civitai, not this SDK. Replies `parse-failed`.
128
128
  * • OPEN_IMAGE_UPLOAD — needs the host's native modal + session-authed byte
129
129
  * pipeline. Replies DISMISSED (the hook resolves `null`).
130
+ * • OPEN_IMAGE_PICKER — needs the host's picker over the viewer's own posted
131
+ * images and generator outputs, read off their signed-in session; no
132
+ * block-token endpoint lists either. Replies with a refusal.
130
133
  * • SAVE_IMAGE — the download bridge is the production host's unsandboxed top
131
134
  * frame plus its CDN origin allowlist / gated per-viewer read. Replies with
132
135
  * a refusal (#386).
@@ -140,6 +143,11 @@
140
143
  import { type BlockContext, type ColorDomain, type Theme, type ViewerInfo } from '@civitai/app-sdk/blocks';
141
144
  import type { MockHost } from './mockHost.js';
142
145
  import { type PickerOverlayHandle } from './pickerOverlay.js';
146
+ /**
147
+ * The `error` the live host answers `OPEN_IMAGE_PICKER` with. `useImagePicker()`
148
+ * rejects with it, so it says what to do instead.
149
+ */
150
+ export declare const IMAGE_PICKER_UNSUPPORTED_ERROR = "the image picker is not supported in dev:live \u2014 use dev:mock";
143
151
  /**
144
152
  * Options for {@link createLiveHost}. Mirrors the {@link MockHost}-relevant
145
153
  * subset of `MockHostOptions`, plus the live-only `blockToken` / `backendBaseUrl`
@@ -47,7 +47,7 @@
47
47
  * fetch chokepoints ..... 2
48
48
  * REST endpoints ........ GET /api/v1/blocks/me
49
49
  * tRPC procedures ....... 29 = blocks.* (14) + apps.shared.* (10) + apps.storage.* (5)
50
- * switch case labels .... 49, of which 11 REFUSE (enumerated under SCOPE below)
50
+ * switch case labels .... 50, of which 12 REFUSE (enumerated under SCOPE below)
51
51
  * --- END DERIVED ---
52
52
  *
53
53
  * PICKERS (Phase 1 of "make dev:live a faithful local host"): the live host
@@ -127,6 +127,9 @@
127
127
  * zip/yaml parse that lives in civitai, not this SDK. Replies `parse-failed`.
128
128
  * • OPEN_IMAGE_UPLOAD — needs the host's native modal + session-authed byte
129
129
  * pipeline. Replies DISMISSED (the hook resolves `null`).
130
+ * • OPEN_IMAGE_PICKER — needs the host's picker over the viewer's own posted
131
+ * images and generator outputs, read off their signed-in session; no
132
+ * block-token endpoint lists either. Replies with a refusal.
130
133
  * • SAVE_IMAGE — the download bridge is the production host's unsandboxed top
131
134
  * frame plus its CDN origin allowlist / gated per-viewer read. Replies with
132
135
  * a refusal (#386).
@@ -144,6 +147,11 @@ import { isRoutableRequestId } from '../transport/requestId.js';
144
147
  import { parseSharedStorageRateLimit } from '../transport/validate.js';
145
148
  import { createTrackEventChecker } from './trackEventCheck.js';
146
149
  import { openPickerOverlay, } from './pickerOverlay.js';
150
+ /**
151
+ * The `error` the live host answers `OPEN_IMAGE_PICKER` with. `useImagePicker()`
152
+ * rejects with it, so it says what to do instead.
153
+ */
154
+ export const IMAGE_PICKER_UNSUPPORTED_ERROR = 'the image picker is not supported in dev:live — use dev:mock';
147
155
  /** Default civitai backend the live host forwards to. */
148
156
  const DEFAULT_BACKEND_BASE_URL = 'https://civitai.com';
149
157
  /** The budgeted-spend scope the workflow procedures require. */
@@ -1343,6 +1351,26 @@ export function createLiveHost(options) {
1343
1351
  });
1344
1352
  return;
1345
1353
  }
1354
+ case 'OPEN_IMAGE_PICKER': {
1355
+ // REFUSED. The picker lists the viewer's own posted images and
1356
+ // generator outputs in host chrome, off the viewer's signed-in
1357
+ // session. A dev block token has no endpoint that lists either, and
1358
+ // this harness has no session, so there is nothing real to pick
1359
+ // from. Rather than let `useImagePicker()` hang to its 10-minute
1360
+ // timeout, or hand back an invented image, answer with an error
1361
+ // (the hook rejects) and log what to use instead.
1362
+ logOnce('open-image-picker', 'OPEN_IMAGE_PICKER received but live dev mode cannot list the viewer\'s own images ' +
1363
+ '— useImagePicker().open() REJECTS. Test the image picker against the real site, ' +
1364
+ 'or use createMockHost({ cannedImagePicks }) in dev:mock.');
1365
+ // A refusal nobody can route is not worth sending.
1366
+ if (!isRoutableRequestId(requestId))
1367
+ return;
1368
+ dispatchToBlock({
1369
+ type: 'IMAGE_PICKER_RESULT',
1370
+ payload: { requestId, error: IMAGE_PICKER_UNSUPPORTED_ERROR },
1371
+ });
1372
+ return;
1373
+ }
1346
1374
  case 'SET_USER_CHECKPOINT': {
1347
1375
  // 🔴 A PAGE REFUSES, ALWAYS, WITHOUT A BACKEND CALL — and the default
1348
1376
  // context is a page. That is the production page host exactly: the
@@ -37,7 +37,7 @@
37
37
  * called). Only the postMessage bridge round-trips are exercised. Never import
38
38
  * this from production code.
39
39
  */
40
- import { type BlockContext, type BlockResourceInfo, type BlockResourcePickerType, type BlockUploadedImageInfo, type BlockGenerationSourceImageInfo, type BuzzAccountType, type BlockBuzzTransaction, type BlockBuzzAccount, type BlockDailyCompensationResource, type BlockViewer, type BlockWildcardPack, type BlockWildcardPackErrorCode, type AppWorkflow, type BlockModel3dOutput, type BlockPublishedModel, type BlockRunConfirmedStepHostError, type BlockGatedImage, type BlockCollectionFollowErrorCode, type BlockCreatePostHostError, type BlockCreatePostResult, type BlockPrepareTrainingDatasetHostError, type BlockRunTrainingHostError, type BlockTrainingRejectionReason, type ColorDomain, type SharedStorageValue, type Theme, type ViewerInfo, type WorkflowBody } from '@civitai/app-sdk/blocks';
40
+ import { type BlockContext, type BlockResourceInfo, type BlockResourcePickerType, type BlockUploadedImageInfo, type BlockGenerationSourceImageInfo, type BuzzAccountType, type BlockBuzzTransaction, type BlockBuzzAccount, type BlockDailyCompensationResource, type BlockViewer, type BlockWildcardPack, type BlockWildcardPackErrorCode, type AppWorkflow, type BlockModel3dOutput, type BlockPublishedModel, type BlockRunConfirmedStepHostError, type BlockGatedImage, type BlockPickedImageInfo, type BlockCollectionFollowErrorCode, type BlockCreatePostHostError, type BlockCreatePostResult, type BlockPrepareTrainingDatasetHostError, type BlockRunTrainingHostError, type BlockTrainingRejectionReason, type ColorDomain, type SharedStorageValue, type Theme, type ViewerInfo, type WorkflowBody } from '@civitai/app-sdk/blocks';
41
41
  import { type MockWorkflowQuoteFailure } from './mockWorkflowGraph.js';
42
42
  /**
43
43
  * How submits behave. `'none'` = everything succeeds; `'all'` / `'insufficient'`
@@ -716,6 +716,33 @@ export interface MockHostOptions {
716
716
  * or a `max` that is not a whole number ≥ 1, is answered with an `error`.
717
717
  */
718
718
  cannedMultiPicks?: CannedPick[] | null;
719
+ /**
720
+ * The viewer's pickable images, as `OPEN_IMAGE_PICKER` answers from them
721
+ * (what `useImagePicker().open()` resolves with). The mock has no picker UI:
722
+ * it answers at once, as if the viewer picked from the top of this list.
723
+ *
724
+ * The list is first FILTERED to the request's `sources` (all of it when the
725
+ * request names none). A single pick then answers with the first image left,
726
+ * and a multi pick (`multiple: { max }`) with the first `max` of them (`max`
727
+ * itself clamped to 20). When nothing is left the reply is a dismiss. `null`
728
+ * or `[]` simulates a viewer who dismisses every time.
729
+ *
730
+ * As on the real host, an entry repeating an earlier one (same `source` and
731
+ * `url`) is offered once, and an `imageId` on a `'generated'` entry is
732
+ * dropped. Nothing else is checked: the mock does not model browsing levels
733
+ * or ratings, so it hands back whatever `nsfwLevel` an entry carries.
734
+ *
735
+ * Defaults to one posted image (with an `imageId`) and one generated image
736
+ * (without one), so both libraries answer out of the box.
737
+ *
738
+ * The mock applies the real host's refusals too: a `sources` that is not a
739
+ * non-empty array of `'posted'` / `'generated'`, or a `multiple` that is
740
+ * not `{ max }` with a whole number ≥ 1, is answered with an `error`, and so
741
+ * is any request on a
742
+ * model-slot `context` (`unsupported on this host`: the picker is a page-app
743
+ * affordance). Live-tunable via {@link MockHost.setScenario}.
744
+ */
745
+ cannedImagePicks?: BlockPickedImageInfo[] | null;
719
746
  /**
720
747
  * The canned moderated image returned from `OPEN_IMAGE_UPLOAD` when the block
721
748
  * requests `purpose:'display'` (the default) — what `useImageUpload().open()`
@@ -1341,7 +1368,7 @@ export interface MockHostOptions {
1341
1368
  * {@link MockHost.setTheme}, which pushes a real `THEME_CHANGE` — the mock of
1342
1369
  * the viewer toggling dark mode with the block already mounted.)
1343
1370
  */
1344
- export type MockHostScenarioPatch = Pick<MockHostOptions, 'consentGrantable' | 'failMode' | 'cost' | 'pollsUntilDone' | 'cannedPicks' | 'cannedMultiPicks' | 'cannedImageUpload' | 'cannedGenerationSourceUpload' | 'cannedImageScan' | 'generation' | 'buzz' | 'storage' | 'shared' | 'buzzBalanceError' | 'viewerResult' | 'viewerError' | 'buzzTransactions' | 'buzzAccounts' | 'dailyCompensation' | 'buzzReadError' | 'wildcardPack' | 'wildcardPackError' | 'collectionFollowError' | 'createPostResult' | 'createPostError' | 'trainingDatasetRejected' | 'trainingDatasetError' | 'trainingQuoteTotal' | 'runTrainingError' | 'runTrainingCapRefusal' | 'runConfirmedStepError' | 'runConfirmedStepCapRefusal' | 'appWorkflows' | 'appWorkflowsError' | 'publishImageIds' | 'publishError' | 'gatedImages' | 'gatedImagesError' | 'disallowedAccountTypes' | 'saveImageError' | 'uploadImageBytesResult' | 'uploadImageBytesError'>;
1371
+ export type MockHostScenarioPatch = Pick<MockHostOptions, 'consentGrantable' | 'failMode' | 'cost' | 'pollsUntilDone' | 'cannedPicks' | 'cannedMultiPicks' | 'cannedImagePicks' | 'cannedImageUpload' | 'cannedGenerationSourceUpload' | 'cannedImageScan' | 'generation' | 'buzz' | 'storage' | 'shared' | 'buzzBalanceError' | 'viewerResult' | 'viewerError' | 'buzzTransactions' | 'buzzAccounts' | 'dailyCompensation' | 'buzzReadError' | 'wildcardPack' | 'wildcardPackError' | 'collectionFollowError' | 'createPostResult' | 'createPostError' | 'trainingDatasetRejected' | 'trainingDatasetError' | 'trainingQuoteTotal' | 'runTrainingError' | 'runTrainingCapRefusal' | 'runConfirmedStepError' | 'runConfirmedStepCapRefusal' | 'appWorkflows' | 'appWorkflowsError' | 'publishImageIds' | 'publishError' | 'gatedImages' | 'gatedImagesError' | 'disallowedAccountTypes' | 'saveImageError' | 'uploadImageBytesResult' | 'uploadImageBytesError'>;
1345
1372
  /** Runtime Buzz-balance handle exposed on {@link MockHost.buzz}. */
1346
1373
  export interface MockBuzzHandle {
1347
1374
  /** Current simulated balance, or `undefined` when balance isn't simulated. */
@@ -415,6 +415,53 @@ const DEFAULT_SECOND_LORA_PICK = {
415
415
  };
416
416
  /** The multi-select cap — the real host's, and `additionalResources`'. */
417
417
  const MOCK_RESOURCE_PICKER_MULTI_MAX = 5;
418
+ /** The multi-pick cap of `OPEN_IMAGE_PICKER`, the real host's. */
419
+ const MOCK_IMAGE_PICKER_MULTI_MAX = 20;
420
+ /** The libraries `OPEN_IMAGE_PICKER` knows. */
421
+ const MOCK_IMAGE_PICKER_SOURCES = ['posted', 'generated'];
422
+ /** The host's refusal for a bad `sources`, and for a bad `multiple`. */
423
+ const MOCK_IMAGE_PICKER_SOURCES_ERROR = 'OPEN_IMAGE_PICKER: sources must be a non-empty list of "posted" and/or "generated".';
424
+ const MOCK_IMAGE_PICKER_MULTIPLE_ERROR = 'OPEN_IMAGE_PICKER: multiple.max must be a whole number of at least 1.';
425
+ /**
426
+ * A canned pick as the host puts it on the wire: the contract's fields only,
427
+ * and `imageId` only on a posted pick (a generator output has no image id, so
428
+ * one set on a canned `'generated'` entry is dropped).
429
+ */
430
+ function toWireImagePick(image) {
431
+ const { source, url, width, height, nsfwLevel, imageId } = image;
432
+ return {
433
+ source,
434
+ url,
435
+ width,
436
+ height,
437
+ nsfwLevel,
438
+ ...(source === 'posted' && typeof imageId === 'number' ? { imageId } : {}),
439
+ };
440
+ }
441
+ /**
442
+ * The images the mock host answers `OPEN_IMAGE_PICKER` from when
443
+ * {@link MockHostOptions.cannedImagePicks} is omitted: one per library, and
444
+ * every field differs between them so a block that mixes two picks up shows
445
+ * it. The generated one has NO `imageId`, as on the real host, and the posted
446
+ * one's `url` is a resized rendition, not the original file.
447
+ */
448
+ const DEFAULT_IMAGE_PICKS = [
449
+ {
450
+ source: 'posted',
451
+ url: 'https://image.civitai.com/mock/width=832/dev-posted-image.jpeg',
452
+ width: 832,
453
+ height: 1216,
454
+ nsfwLevel: 1,
455
+ imageId: 12345801,
456
+ },
457
+ {
458
+ source: 'generated',
459
+ url: 'https://image.civitai.com/mock/original=true/dev-generated-image.jpeg',
460
+ width: 1024,
461
+ height: 768,
462
+ nsfwLevel: 2,
463
+ },
464
+ ];
418
465
  /**
419
466
  * The canned image the mock host "returns" from `OPEN_IMAGE_UPLOAD`. Mirrors the
420
467
  * host's moderated {@link BlockUploadedImageInfo} projection (imageId/nsfwLevel/
@@ -1010,6 +1057,8 @@ export function createMockHost(options = {}) {
1010
1057
  // Canned MULTI-select picks. `undefined` = derive from the single LoRA pick
1011
1058
  // (see the option's docs); `null` = dismissed.
1012
1059
  let cannedMultiPicks = options.cannedMultiPicks;
1060
+ // The viewer's pickable images for OPEN_IMAGE_PICKER. `null` = dismisses.
1061
+ let cannedImagePicks = options.cannedImagePicks === undefined ? DEFAULT_IMAGE_PICKS : options.cannedImagePicks;
1013
1062
  // Canned image-upload result. `null` = dismissed; undefined = default image.
1014
1063
  let cannedImageUpload = options.cannedImageUpload === undefined ? DEFAULT_IMAGE_UPLOAD : options.cannedImageUpload;
1015
1064
  // Canned generationSource-upload result (purpose:'generationSource').
@@ -2876,6 +2925,75 @@ export function createMockHost(options = {}) {
2876
2925
  });
2877
2926
  return;
2878
2927
  }
2928
+ case 'OPEN_IMAGE_PICKER': {
2929
+ // The viewer picking from their own images. No picker UI here: the
2930
+ // mock answers at once from `cannedImagePicks`, with the real
2931
+ // host's rules in the real host's order. A request nobody can
2932
+ // route is dropped, as the host drops it.
2933
+ if (!isRoutableRequestId(requestId))
2934
+ return;
2935
+ const refuse = (error) => dispatchToBlock({ type: 'IMAGE_PICKER_RESULT', payload: { requestId, error } });
2936
+ // A page-app affordance: every other surface answers the generic
2937
+ // refusal.
2938
+ if (!emulatesPageHost(options.context)) {
2939
+ refuse('unsupported on this host');
2940
+ return;
2941
+ }
2942
+ const pickerPayload = (typed.payload ?? {});
2943
+ // `sources`: omitted = both. Otherwise a non-empty array of known
2944
+ // members; repeats are harmless.
2945
+ let wanted = MOCK_IMAGE_PICKER_SOURCES;
2946
+ if (pickerPayload.sources !== undefined) {
2947
+ const requested = pickerPayload.sources;
2948
+ if (!Array.isArray(requested) ||
2949
+ requested.length === 0 ||
2950
+ !requested.every((s) => MOCK_IMAGE_PICKER_SOURCES.includes(s))) {
2951
+ refuse(MOCK_IMAGE_PICKER_SOURCES_ERROR);
2952
+ return;
2953
+ }
2954
+ wanted = requested;
2955
+ }
2956
+ // `multiple`: ANY present value asks for a multi pick, so `null` and
2957
+ // a non-object are refused, as the host refuses them. Checked
2958
+ // before the library is read, in the host's order.
2959
+ const multiple = pickerPayload.multiple;
2960
+ let max = null;
2961
+ if (multiple !== undefined) {
2962
+ const requestedMax = multiple && typeof multiple === 'object'
2963
+ ? multiple.max
2964
+ : undefined;
2965
+ if (typeof requestedMax !== 'number' ||
2966
+ !Number.isInteger(requestedMax) ||
2967
+ requestedMax < 1) {
2968
+ refuse(MOCK_IMAGE_PICKER_MULTIPLE_ERROR);
2969
+ return;
2970
+ }
2971
+ max = Math.min(requestedMax, MOCK_IMAGE_PICKER_MULTI_MAX);
2972
+ }
2973
+ // The library, narrowed to the requested sources, with a repeated
2974
+ // image (same source and url) collapsed onto its first occurrence.
2975
+ const available = [];
2976
+ for (const image of cannedImagePicks ?? []) {
2977
+ if (!wanted.includes(image.source))
2978
+ continue;
2979
+ if (available.some((kept) => kept.source === image.source && kept.url === image.url))
2980
+ continue;
2981
+ available.push(image);
2982
+ }
2983
+ if (max !== null) {
2984
+ dispatchToBlock({
2985
+ type: 'IMAGE_PICKER_RESULT',
2986
+ payload: { requestId, selectedImages: available.slice(0, max).map(toWireImagePick) },
2987
+ });
2988
+ return;
2989
+ }
2990
+ const first = available[0];
2991
+ dispatchToBlock({
2992
+ type: 'IMAGE_PICKER_RESULT',
2993
+ payload: { requestId, ...(first ? { selected: toWireImagePick(first) } : {}) },
2994
+ });
2995
+ return;
2996
+ }
2879
2997
  case 'OPEN_IMAGE_UPLOAD': {
2880
2998
  // Host-chrome image upload: return the canned result keyed by the
2881
2999
  // requested `purpose` (mirrors the real host's IMAGE_UPLOAD_RESULT).
@@ -3598,6 +3716,11 @@ export function createMockHost(options = {}) {
3598
3716
  // `null` is a meaningful value (dismissed), so check for the KEY's presence.
3599
3717
  if ('cannedMultiPicks' in patch)
3600
3718
  cannedMultiPicks = patch.cannedMultiPicks;
3719
+ // `null` is a meaningful value (dismisses), and `undefined` restores the
3720
+ // defaults, so check for the KEY's presence.
3721
+ if ('cannedImagePicks' in patch)
3722
+ cannedImagePicks =
3723
+ patch.cannedImagePicks === undefined ? DEFAULT_IMAGE_PICKS : patch.cannedImagePicks;
3601
3724
  // `null` is a meaningful value (dismissed), so check for the KEY's presence.
3602
3725
  if ('cannedImageUpload' in patch)
3603
3726
  cannedImageUpload = patch.cannedImageUpload ?? null;
@@ -68,6 +68,7 @@ const REQUEST_TIMEOUT_CLASS = {
68
68
  // ── Gated on a human ────────────────────────────────────────────────────
69
69
  OPEN_BUZZ_PURCHASE: 'human', // viewer walks a purchase flow
70
70
  OPEN_CHECKPOINT_PICKER: 'human', // viewer browses + picks
71
+ OPEN_IMAGE_PICKER: 'human', // viewer browses their images + picks
71
72
  OPEN_IMAGE_UPLOAD: 'human', // viewer chooses a file
72
73
  OPEN_RESOURCE_PICKER: 'human', // viewer browses + picks
73
74
  PUBLISH_GENERATION_OUTPUTS: 'human', // viewer answers a consent confirm (#4158)
@@ -564,6 +564,23 @@ export declare function isValidCheckpointPickerResult(p: unknown): boolean;
564
564
  * a reply carrying only `{ requestId, error }` must reach it.
565
565
  */
566
566
  export declare function isValidResourcePickerResult(p: unknown): boolean;
567
+ /**
568
+ * Reply to `OPEN_IMAGE_PICKER`.
569
+ *
570
+ * Single pick: `selected` is ABSENT on dismiss (the hook resolves `null`); when
571
+ * present it is a `BlockPickedImageInfo`.
572
+ *
573
+ * Multi pick: `selectedImages` is an array of the same shape (`[]` on dismiss).
574
+ * ONE malformed entry drops the whole reply: handing the caller the
575
+ * well-formed remainder would silently change which images it goes on to use.
576
+ *
577
+ * `error` (a refusal) must be a string when present, and a reply carrying one
578
+ * is accepted without looking further: `useImagePicker` throws on a PRESENT
579
+ * `error` before it reads `selected` / `selectedImages`. 🔴 Accepting it is
580
+ * load-bearing: dropping `{ requestId, error }` would leave the hook waiting
581
+ * out its whole human-interaction timeout on a refusal the host already sent.
582
+ */
583
+ export declare function isValidImagePickerResult(p: unknown): boolean;
567
584
  /**
568
585
  * Reply to `SET_USER_CHECKPOINT`. `ok` is required on the success path; the
569
586
  * hook throws on `!ok || error !== undefined` — presence, not truthiness, so an
@@ -1978,6 +1978,69 @@ export function isValidResourcePickerResult(p) {
1978
1978
  }
1979
1979
  return true;
1980
1980
  }
1981
+ const IMAGE_PICKER_SOURCES = new Set(['posted', 'generated']);
1982
+ /**
1983
+ * One `BlockPickedImageInfo`: `source` one of the two libraries, a non-empty
1984
+ * `url`, finite positive `width`/`height`, a finite `nsfwLevel`, and an
1985
+ * `imageId` that is either ABSENT (a generated output has none) or a positive
1986
+ * integer. Shared by the single-pick `selected` and every entry of the
1987
+ * multi-pick `selectedImages`, so the two cannot drift on what a well-formed
1988
+ * image is.
1989
+ */
1990
+ function isValidPickedImage(s) {
1991
+ if (!isObject(s))
1992
+ return false;
1993
+ if (typeof s.source !== 'string' || !IMAGE_PICKER_SOURCES.has(s.source))
1994
+ return false;
1995
+ if (!isNonEmptyString(s.url))
1996
+ return false;
1997
+ if (!isFiniteNumber(s.width) || s.width <= 0)
1998
+ return false;
1999
+ if (!isFiniteNumber(s.height) || s.height <= 0)
2000
+ return false;
2001
+ if (!isFiniteNumber(s.nsfwLevel))
2002
+ return false;
2003
+ if (s.imageId !== undefined) {
2004
+ if (typeof s.imageId !== 'number' || !Number.isInteger(s.imageId) || s.imageId <= 0)
2005
+ return false;
2006
+ }
2007
+ return true;
2008
+ }
2009
+ /**
2010
+ * Reply to `OPEN_IMAGE_PICKER`.
2011
+ *
2012
+ * Single pick: `selected` is ABSENT on dismiss (the hook resolves `null`); when
2013
+ * present it is a `BlockPickedImageInfo`.
2014
+ *
2015
+ * Multi pick: `selectedImages` is an array of the same shape (`[]` on dismiss).
2016
+ * ONE malformed entry drops the whole reply: handing the caller the
2017
+ * well-formed remainder would silently change which images it goes on to use.
2018
+ *
2019
+ * `error` (a refusal) must be a string when present, and a reply carrying one
2020
+ * is accepted without looking further: `useImagePicker` throws on a PRESENT
2021
+ * `error` before it reads `selected` / `selectedImages`. 🔴 Accepting it is
2022
+ * load-bearing: dropping `{ requestId, error }` would leave the hook waiting
2023
+ * out its whole human-interaction timeout on a refusal the host already sent.
2024
+ */
2025
+ export function isValidImagePickerResult(p) {
2026
+ if (!isObject(p))
2027
+ return false;
2028
+ if (!isWireRequestIdShape(p.requestId))
2029
+ return false;
2030
+ if (p.error !== undefined && typeof p.error !== 'string')
2031
+ return false;
2032
+ if (p.error !== undefined)
2033
+ return true; // hook throws before reading a pick
2034
+ if (p.selected !== undefined && !isValidPickedImage(p.selected))
2035
+ return false;
2036
+ if (p.selectedImages !== undefined) {
2037
+ if (!Array.isArray(p.selectedImages))
2038
+ return false;
2039
+ if (!p.selectedImages.every(isValidPickedImage))
2040
+ return false;
2041
+ }
2042
+ return true;
2043
+ }
1981
2044
  /**
1982
2045
  * Reply to `SET_USER_CHECKPOINT`. `ok` is required on the success path; the
1983
2046
  * hook throws on `!ok || error !== undefined` — presence, not truthiness, so an
@@ -2158,6 +2221,8 @@ export function payloadValidatorFor(type) {
2158
2221
  return isValidCheckpointPickerResult;
2159
2222
  case 'RESOURCE_PICKER_RESULT':
2160
2223
  return isValidResourcePickerResult;
2224
+ case 'IMAGE_PICKER_RESULT':
2225
+ return isValidImagePickerResult;
2161
2226
  case 'USER_CHECKPOINT_SET':
2162
2227
  return isValidUserCheckpointSetResult;
2163
2228
  case 'SUSPEND':
@@ -1,3 +1,3 @@
1
1
  /** This package's version, reported to the host as `sdkVersion` in `BLOCK_READY`. */
2
- export declare const VERSION = "0.72.3";
2
+ export declare const VERSION = "0.73.0";
3
3
  //# sourceMappingURL=version.generated.d.ts.map
@@ -1,4 +1,4 @@
1
1
  /* AUTOGENERATED from package.json by scripts/gen-version.mjs. DO NOT EDIT. */
2
2
  /** This package's version, reported to the host as `sdkVersion` in `BLOCK_READY`. */
3
- export const VERSION = "0.72.3";
3
+ export const VERSION = "0.73.0";
4
4
  //# sourceMappingURL=version.generated.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@civitai/blocks-react",
3
- "version": "0.72.3",
3
+ "version": "0.73.0",
4
4
  "description": "React hooks and iframe transport for Civitai Apps. Pairs with @civitai/app-sdk/blocks.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -37,8 +37,8 @@
37
37
  "node": ">=20"
38
38
  },
39
39
  "dependencies": {
40
- "@civitai/components": "^0.9.3",
41
- "@civitai/theme": "^0.5.2"
40
+ "@civitai/theme": "^0.5.2",
41
+ "@civitai/components": "^0.9.3"
42
42
  },
43
43
  "comment-peerDependencies": "This floor is DERIVED, not chosen — the lowest published app-sdk that exports every symbol this package imports. Do not edit it without reading ./PEER_FLOOR.md (in-repo, unpublished) and tests/guards/blocks-react-peer-floor.test.mjs.",
44
44
  "peerDependencies": {
@@ -58,7 +58,7 @@
58
58
  "typescript": "^5.9.2",
59
59
  "vite": "^8.0.14",
60
60
  "vitest": "^4.1.11",
61
- "@civitai/app-sdk": "^0.69.0"
61
+ "@civitai/app-sdk": "^0.70.0"
62
62
  },
63
63
  "publishConfig": {
64
64
  "access": "public"