@civitai/blocks-react 0.72.4 → 0.73.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +122 -0
- package/dist/hooks/useImagePicker.d.ts +126 -0
- package/dist/hooks/useImagePicker.js +133 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/internal/liveHost.d.ts +9 -1
- package/dist/internal/liveHost.js +29 -1
- package/dist/internal/mockHost.d.ts +29 -2
- package/dist/internal/mockHost.js +123 -0
- package/dist/transport/requestTimeouts.js +1 -0
- package/dist/transport/validate.d.ts +17 -0
- package/dist/transport/validate.js +65 -0
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -2250,6 +2250,128 @@ unstamped) or an already-posted id is refused with
|
|
|
2250
2250
|
rule, the scope check, the real scan, or an older host's picker. `dev:live` has
|
|
2251
2251
|
no bytes path: `upload` rejects with `the host returned no uploaded image`.
|
|
2252
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
|
+
|
|
2253
2375
|
### `useSaveImage()`
|
|
2254
2376
|
|
|
2255
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 ....
|
|
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 ....
|
|
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,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.
|
|
3
|
+
export const VERSION = "0.73.1";
|
|
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.
|
|
3
|
+
"version": "0.73.1",
|
|
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/
|
|
41
|
-
"@civitai/
|
|
40
|
+
"@civitai/components": "^0.10.0",
|
|
41
|
+
"@civitai/theme": "^0.5.2"
|
|
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.
|
|
61
|
+
"@civitai/app-sdk": "^0.70.1"
|
|
62
62
|
},
|
|
63
63
|
"publishConfig": {
|
|
64
64
|
"access": "public"
|