@civitai/blocks-react 0.65.2 → 0.67.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
@@ -106,7 +106,7 @@ the package's type declarations (IDE hover / autocomplete on the package root) a
106
106
  the authoritative list of what exists.
107
107
 
108
108
  Exported from the package root with no snippet in this section, as of this
109
- writing: `useGatedImages()`, `usePublishGenerationOutputs()`, `useSaveImage()`,
109
+ writing: `useGatedImages()`, `usePublishGenerationOutputs()`,
110
110
  `useTip()` and `useTipAllowance()` — for the tip pair, the
111
111
  [`TipButton`](#the-ui-subexport) row documents the intended usage and the
112
112
  allowance-sharing rule. `useDirectLoad()` is covered under
@@ -687,7 +687,8 @@ publish another.
687
687
 
688
688
  🔴 **No arm of `sources` takes a URL.** Name a workflow from this app's own
689
689
  subqueue plus indexes into its outputs, or `Image` ids from a previous
690
- `usePublishGenerationOutputs()` publish. The server re-verifies both — ownership,
690
+ `usePublishGenerationOutputs()` publish or `useUploadImageBytes()` upload. The
691
+ server re-verifies both — ownership,
691
692
  this app's provenance marker, and that the image is not already in a post.
692
693
 
693
694
  ⚠️ **Posting a published image removes it from this app's own grid.** The
@@ -1208,6 +1209,173 @@ if (img) {
1208
1209
  }
1209
1210
  ```
1210
1211
 
1212
+ ### `useUploadImageBytes()`
1213
+
1214
+ Upload an image the block **produced in the tab** (a healed PNG, an edited
1215
+ render), with no picker, so the app can then post it. Returns `{ upload }`.
1216
+ `upload(bytes, { filename? })` resolves with the same moderated image a picked
1217
+ `useImageUpload()` upload returns (`imageId`, `nsfwLevel`, `contentRating`,
1218
+ `url`). That `imageId` is postable by this app through
1219
+ `useCreatePostFromApp()` as a `{ kind: 'published', imageIds }` source.
1220
+
1221
+ - 🔴 **Page apps only.** A slot (model) block's host has no handler for it. The
1222
+ host answers an unhandled request at once with its generic refusal, so on a
1223
+ slot `upload` rejects immediately with `unsupported on this host` rather than
1224
+ waiting out the 10-minute timeout.
1225
+ - 🔴 **Requires `posts:write:self`.** The server refuses the upload without
1226
+ it, and this hook does not prompt. Ask for the scope first with
1227
+ `useRequestConsent()` and upload only once `useBlockToken().scopes` holds it
1228
+ (the example below), because the upload runs before the `createPost()` call
1229
+ that would otherwise prompt.
1230
+ - **Images only:** PNG, WebP or JPEG by magic bytes. Anything else is refused
1231
+ with `file type is not allowed`. The host names the stored file from the
1232
+ sniffed type, whatever `filename` says.
1233
+ - **The cap is 40 MiB.** The hook refuses a larger buffer before sending it,
1234
+ with the host's error `file exceeds the maximum upload size`. The host also
1235
+ allows at most 3 uploads and 80 MiB per 60 seconds per page, and replies
1236
+ `busy` past that.
1237
+ - **The upload is blocking.** The host replies once the image is scanned, so
1238
+ allow for a wait of up to a few minutes. An image above the SFW ceiling or
1239
+ flagged by the scan is refused with the scan's message.
1240
+ - Pass an `ArrayBuffer` (`await blob.arrayBuffer()`). A `Blob`, a
1241
+ `Uint8Array` or an empty buffer is refused with
1242
+ `invalid image-upload request`. The buffer is copied, not transferred.
1243
+
1244
+ `upload` **rejects** with the host's error string on every refusal: the ones
1245
+ above, `no block token`, or a server message passed through verbatim (the
1246
+ missing scope, the posting flag, page-only, a rate limit, the scan). It
1247
+ rejects with `the host returned no uploaded image` if the reply carries
1248
+ neither an image nor an error. If no reply arrives within 10 minutes it
1249
+ rejects with a `RequestTimeoutError`; the host may still have stored the
1250
+ image, so retrying after a timeout can create a duplicate.
1251
+
1252
+ 🔴 **A host that predates this variant ignores `bytes` and opens its upload
1253
+ picker instead**, so the call settles on whatever the viewer picks. The hook
1254
+ needs civitai/civitai#5639 merged and deployed to civitai.com. That PR is still
1255
+ changing, so no intermediate head of it is enough. Do not ship a block that
1256
+ relies on `useUploadImageBytes()` before then.
1257
+
1258
+ ```tsx
1259
+ import {
1260
+ CreatePostError,
1261
+ useBlockToken,
1262
+ useCreatePostFromApp,
1263
+ useRequestConsent,
1264
+ useUploadImageBytes,
1265
+ } from '@civitai/blocks-react';
1266
+
1267
+ function PostHealedButton({ healed }: { healed: Blob }) {
1268
+ const { scopes } = useBlockToken();
1269
+ const { requestConsent } = useRequestConsent();
1270
+ const { upload } = useUploadImageBytes();
1271
+ const { createPost } = useCreatePostFromApp();
1272
+ const canPost = scopes.includes('posts:write:self');
1273
+
1274
+ async function onClick() {
1275
+ if (!canPost) {
1276
+ // Fire-and-forget: this opens the host's consent dialog and returns
1277
+ // nothing. On a grant the host pushes a new token, `scopes` updates and
1278
+ // the button re-renders as "Post". Never upload before that: the server
1279
+ // refuses the upload without the scope.
1280
+ requestConsent({ scopes: ['posts:write:self'] });
1281
+ return;
1282
+ }
1283
+ try {
1284
+ // 1. Bytes made in the tab, uploaded and scanned by the host.
1285
+ const image = await upload(await healed.arrayBuffer(), { filename: 'healed.png' });
1286
+ // 2. Posted through the existing bridge, with its own confirm.
1287
+ const post = await createPost({
1288
+ sources: [{ kind: 'published', imageIds: [image.imageId] }],
1289
+ title: 'Fixed with Metadata Healer',
1290
+ });
1291
+ showToast(`Posted! ${post.url}`);
1292
+ } catch (err) {
1293
+ if (err instanceof CreatePostError && err.declined) return; // the viewer said no
1294
+ showToast((err as Error).message); // e.g. 'busy', 'file type is not allowed'
1295
+ }
1296
+ }
1297
+
1298
+ return <button onClick={onClick}>{canPost ? 'Post' : 'Allow posting'}</button>;
1299
+ }
1300
+ ```
1301
+
1302
+ Under `createMockHost` / `Harness` the host's checks run in the host's order:
1303
+ the cap, then the window, then the type. `uploadImageBytesResult` sets the
1304
+ image an accepted upload returns, `uploadImageBytesError` forces a refusal
1305
+ (for example `block lacks posts:write:self scope`), and `onUploadImageBytes`
1306
+ reports what was accepted. The mock's `CREATE_POST_FROM_APP` checks a
1307
+ `published` source with the server's rule and refusal strings, but not its
1308
+ data. Production accepts the viewer's own images that this app stamped and that
1309
+ are not in a post yet, including ones from earlier sessions. The mock has no
1310
+ earlier sessions, so it accepts only ids it issued as postable in this session
1311
+ (an accepted bytes upload, or a `PUBLISH_GENERATION_OUTPUTS` reply) plus the
1312
+ ids you seed with `createMockHost({ postableImageIds: [...] })`, each in one
1313
+ post only. Any other id, a picked `useImageUpload()` id (production leaves it
1314
+ unstamped) or an already-posted id is refused with
1315
+ `an image is not available to post`. The mock does not model the page-only
1316
+ rule, the scope check, the real scan, or an older host's picker. `dev:live` has
1317
+ no bytes path: `upload` rejects with `the host returned no uploaded image`.
1318
+
1319
+ ### `useSaveImage()`
1320
+
1321
+ Ask the host to download a file to the viewer's device. **A block cannot
1322
+ download anything itself.** Its sandbox lacks `allow-downloads`, and the
1323
+ validator refuses that token for unverified blocks, so an
1324
+ `<a href="blob:…" download>` click silently does nothing. 🔴 **This hook is the
1325
+ sanctioned way to deliver a file**, including one the block produced in the tab.
1326
+ Pass exactly ONE of three inputs, each gated differently by the host:
1327
+
1328
+ - **`{ url }`**: the block's own generation output. The host allowlists the
1329
+ origin to the civitai image CDN and refuses any other.
1330
+ - **`{ imageId }`**: an image from another user, read through the same
1331
+ per-viewer gate as `useGatedImages()`. A withheld image is never saved.
1332
+ - **`{ bytes }`**: an `ArrayBuffer` the block made in the tab, such as a healed
1333
+ image or a JSON sidecar. 🔴 **Page apps only.** The host classifies it by
1334
+ CONTENT. It accepts PNG, WebP and JPEG by magic bytes. Anything else must be
1335
+ valid UTF-8 with no NUL byte: it is saved as JSON when it parses and
1336
+ `filename` ends `.json` (any case) once the host has replaced each `?` and
1337
+ `#` with `_`, and as plain text otherwise. A GIF, a zip
1338
+ or other binary is refused with `file type is not allowed`. The host forces
1339
+ the extension from the classified type. The cap is 50 MiB, and the hook
1340
+ refuses a larger buffer before sending it: `file exceeds the maximum save
1341
+ size`. The buffer is copied, not transferred, so you can keep displaying it.
1342
+
1343
+ 🔴 **On a host that predates the `bytes` variant, `saveImage({ bytes })` rejects
1344
+ with `invalid save-image request`.** The host also replies that when the input
1345
+ is not exactly one variant, or when `bytes` is empty or not an `ArrayBuffer`.
1346
+ Pass `await blob.arrayBuffer()`, not the `Blob` or a `Uint8Array`. The promise
1347
+ resolves once the host has started the download. On any refusal it rejects with
1348
+ the host's error string, which can also be `busy` when too many saves are in
1349
+ flight.
1350
+
1351
+ ```tsx
1352
+ import { useSaveImage } from '@civitai/blocks-react';
1353
+
1354
+ const { saveImage } = useSaveImage();
1355
+
1356
+ // A file healed in the tab. It never leaves the device except to the viewer's disk.
1357
+ const healed: Blob = await healPngMetadata(file);
1358
+ await saveImage({ bytes: await healed.arrayBuffer(), filename: 'healed.png' });
1359
+
1360
+ // Its JSON sidecar. The .json filename makes valid JSON save as .json rather than .txt.
1361
+ const sidecar = new TextEncoder().encode(JSON.stringify(metadata, null, 2));
1362
+ try {
1363
+ await saveImage({ bytes: sidecar.slice().buffer, filename: 'healed.json' });
1364
+ } catch (err) {
1365
+ // 'invalid save-image request' here means this host cannot save bytes yet.
1366
+ setStatus(`Could not save the sidecar: ${(err as Error).message}`);
1367
+ }
1368
+
1369
+ // The block's own generation output, or another user's image.
1370
+ await saveImage({ url: output.url, filename: 'render.png' });
1371
+ await saveImage({ imageId: cell.imageId });
1372
+ ```
1373
+
1374
+ Under `createMockHost` / `Harness` the `bytes` variant is classified the same
1375
+ way. `onSaveBytes` reports what would have been downloaded, and `saveImageError`
1376
+ forces a refusal. Pass `saveImageError: 'invalid save-image request'` to test a
1377
+ host without the variant. `dev:live` refuses every save.
1378
+
1211
1379
  ### `useGenerationResources()`
1212
1380
 
1213
1381
  Rehydrate a saved set of generation resources by version id — WITHOUT re-opening
@@ -113,7 +113,8 @@ export interface UseCreatePostFromApp {
113
113
  *
114
114
  * 🔴 NO ARM OF `sources` TAKES A URL. Name a workflow from this app's own
115
115
  * subqueue plus indexes into its outputs, or `Image` ids from a previous
116
- * `usePublishGenerationOutputs()` publish. The server re-verifies both.
116
+ * `usePublishGenerationOutputs()` publish or `useUploadImageBytes()` upload.
117
+ * The server re-verifies both.
117
118
  *
118
119
  * ⚠️ POSTING A PUBLISHED IMAGE REMOVES IT FROM THIS APP'S OWN GRID. The
119
120
  * app-scoped read behind `useGatedImages()` is conjoined with `postId IS NULL`,
@@ -121,7 +121,8 @@ export class CreatePostError extends Error {
121
121
  *
122
122
  * 🔴 NO ARM OF `sources` TAKES A URL. Name a workflow from this app's own
123
123
  * subqueue plus indexes into its outputs, or `Image` ids from a previous
124
- * `usePublishGenerationOutputs()` publish. The server re-verifies both.
124
+ * `usePublishGenerationOutputs()` publish or `useUploadImageBytes()` upload.
125
+ * The server re-verifies both.
125
126
  *
126
127
  * ⚠️ POSTING A PUBLISHED IMAGE REMOVES IT FROM THIS APP'S OWN GRID. The
127
128
  * app-scoped read behind `useGatedImages()` is conjoined with `postId IS NULL`,
@@ -1,6 +1,7 @@
1
1
  /**
2
- * Input to {@link UseSaveImage.saveImage}. Exactly ONE of `url` / `imageId` is
3
- * required — the two variants map to the host's two download paths:
2
+ * Input to {@link UseSaveImage.saveImage}. Exactly ONE of `url` / `imageId` /
3
+ * `bytes` is required — the three variants map to the host's three download
4
+ * paths, each with its own security gate:
4
5
  *
5
6
  * - `{ url }` — the block's OWN fresh output (e.g. an orchestration blob it has
6
7
  * no `imageId` for yet). The host ALLOWLISTS the URL's origin to the civitai
@@ -9,32 +10,70 @@
9
10
  * - `{ imageId }` — a cross-user grid image (e.g. a benchmark cell). The host
10
11
  * resolves it through the SAME per-viewer gated read that backs
11
12
  * `useGatedImages()`, so a withheld/above-ceiling image can never be saved.
13
+ * - `{ bytes }` — a file the block PRODUCED IN THE VIEWER'S TAB (a healed
14
+ * image, a JSON sidecar, a text export) that exists nowhere else. 🔴 **This
15
+ * is the sanctioned way for an in-tab tool to deliver a file.** A blob-anchor
16
+ * `<a download>` does nothing in a block — its sandbox lacks
17
+ * `allow-downloads`, and the validator refuses that token for unverified
18
+ * blocks — so do not build one. 🔴 **Page apps only**: a slot (model) block's
19
+ * host has no `SAVE_IMAGE` handler, so its generic unhandled-request reply
20
+ * answers at once and the call rejects with `unsupported on this host`.
21
+ *
22
+ * The host classifies by CONTENT and never trusts `filename` to make a file
23
+ * acceptable: PNG / WebP / JPEG by magic bytes; otherwise valid UTF-8 with
24
+ * no NUL byte, saved as JSON when it parses AND `filename` ends `.json`
25
+ * (case-insensitive) after the host replaces each `?` and `#` with `_` (so
26
+ * `data.json?v=2` saves as text), else as text/plain. Anything else — a GIF, a zip,
27
+ * random binary — is refused with `file type is not allowed`, and an empty
28
+ * buffer with `invalid save-image request`. The saved extension is forced
29
+ * from the classified type, whatever `filename` says. The cap is 50 MiB of
30
+ * raw bytes.
31
+ * The buffer is COPIED across `postMessage`, never transferred, so the block
32
+ * can keep displaying it.
12
33
  */
13
34
  export type SaveImageInput = {
14
35
  url: string;
15
36
  imageId?: never;
37
+ bytes?: never;
16
38
  filename?: string;
17
39
  } | {
18
40
  imageId: number;
19
41
  url?: never;
42
+ bytes?: never;
43
+ filename?: string;
44
+ } | {
45
+ bytes: ArrayBuffer;
46
+ url?: never;
47
+ imageId?: never;
20
48
  filename?: string;
21
49
  };
22
50
  export interface UseSaveImage {
23
51
  /**
24
- * Ask the host to DOWNLOAD an image the block already displays — the host
25
- * fetches the blob in its unsandboxed top frame and triggers the browser
26
- * "Save As". A sandboxed opaque-origin block lacks `allow-downloads`, so it
27
- * otherwise can only copy a URL; this bridge is the only way to save a paid
28
- * output. Resolves once the host has started the download; rejects with the
29
- * host's error string on failure (a disallowed URL origin, a withheld image,
30
- * an over-size blob, or a fetch failure), or the transport timeout — the hook
31
- * never hangs.
52
+ * Ask the host to DOWNLOAD a file — an image the block already displays, or
53
+ * bytes it produced in the tab — in the host's unsandboxed top frame, which
54
+ * triggers the browser "Save As". A sandboxed opaque-origin block lacks
55
+ * `allow-downloads`, so this bridge is the only way a block can save a file.
56
+ * Resolves once the host has started the download; rejects with the host's
57
+ * error string on failure (a disallowed URL origin, a withheld image, a
58
+ * disallowed file type, an over-size file, a fetch failure, or `busy`), or
59
+ * the transport timeout — the hook never hangs.
60
+ *
61
+ * 🔴 One call is refused BEFORE sending, with the host's own string: a
62
+ * `bytes` buffer over the 50 MiB cap rejects with `file exceeds the maximum
63
+ * save size`, so it is never copied across `postMessage` just to be refused.
64
+ * Everything else is forwarded and judged by the host, which replies
65
+ * `invalid save-image request` to a request that is not exactly one variant,
66
+ * to a `bytes` that is not a non-empty `ArrayBuffer` (pass
67
+ * `await blob.arrayBuffer()`, or `view.slice().buffer` for a typed array —
68
+ * not the `Uint8Array` or `Blob` itself), and, on a host that predates the
69
+ * `bytes` variant, to every `bytes` save; treat that string as "this host
70
+ * cannot save bytes yet".
32
71
  */
33
72
  saveImage: (input: SaveImageInput) => Promise<void>;
34
73
  }
35
74
  /**
36
- * Download an image via the host-mediated `SAVE_IMAGE` → `SAVE_IMAGE_RESULT`
37
- * bridge. See {@link SaveImageInput} for the url-vs-id security posture.
75
+ * Download a file via the host-mediated `SAVE_IMAGE` → `SAVE_IMAGE_RESULT`
76
+ * bridge. See {@link SaveImageInput} for the security posture of each variant.
38
77
  *
39
78
  * @example
40
79
  * const { saveImage } = useSaveImage();
@@ -42,6 +81,8 @@ export interface UseSaveImage {
42
81
  * await saveImage({ url: output.url, filename: 'my-render.png' });
43
82
  * // a cross-user grid cell (routed through the gated per-viewer read):
44
83
  * await saveImage({ imageId: cell.imageId });
84
+ * // a file produced in the tab (classified by content host-side; page apps only):
85
+ * await saveImage({ bytes: healedPng, filename: 'healed.png' });
45
86
  */
46
87
  export declare function useSaveImage(): UseSaveImage;
47
88
  //# sourceMappingURL=useSaveImage.d.ts.map
@@ -1,10 +1,36 @@
1
1
  import { useCallback } from 'react';
2
2
  import { getTransport } from '../transport/singleton.js';
3
3
  import { throwOnFailedReply } from '../internal/replyError.js';
4
+ import { SAVE_BYTES_MAX_BYTES, SAVE_BYTES_TOO_LARGE_ERROR } from '../internal/saveBytes.js';
4
5
  import { sendTypedRequest } from '../transport/transport.js';
5
6
  /**
6
- * Download an image via the host-mediated `SAVE_IMAGE` → `SAVE_IMAGE_RESULT`
7
- * bridge. See {@link SaveImageInput} for the url-vs-id security posture.
7
+ * Build the wire payload. The ONLY client-side refusal is the cap, so an
8
+ * over-cap buffer is never copied across `postMessage` just to be refused.
9
+ * Everything else is forwarded as given and the host judges it: a `bytes`
10
+ * input is sent with whatever `url` / `imageId` came with it (the host refuses
11
+ * the mix), and a `url` / `imageId` input keeps its pre-`bytes` behaviour
12
+ * (`url` wins when both are set).
13
+ */
14
+ function toWirePayload(input) {
15
+ const raw = input;
16
+ if (raw.bytes !== undefined) {
17
+ if (raw.bytes instanceof ArrayBuffer && raw.bytes.byteLength > SAVE_BYTES_MAX_BYTES) {
18
+ throw new Error(SAVE_BYTES_TOO_LARGE_ERROR);
19
+ }
20
+ const payload = { bytes: raw.bytes, filename: raw.filename };
21
+ if (raw.url !== undefined)
22
+ payload.url = raw.url;
23
+ if (raw.imageId !== undefined)
24
+ payload.imageId = raw.imageId;
25
+ return payload;
26
+ }
27
+ return 'url' in input && input.url !== undefined
28
+ ? { url: input.url, filename: input.filename }
29
+ : { imageId: input.imageId, filename: input.filename };
30
+ }
31
+ /**
32
+ * Download a file via the host-mediated `SAVE_IMAGE` → `SAVE_IMAGE_RESULT`
33
+ * bridge. See {@link SaveImageInput} for the security posture of each variant.
8
34
  *
9
35
  * @example
10
36
  * const { saveImage } = useSaveImage();
@@ -12,12 +38,12 @@ import { sendTypedRequest } from '../transport/transport.js';
12
38
  * await saveImage({ url: output.url, filename: 'my-render.png' });
13
39
  * // a cross-user grid cell (routed through the gated per-viewer read):
14
40
  * await saveImage({ imageId: cell.imageId });
41
+ * // a file produced in the tab (classified by content host-side; page apps only):
42
+ * await saveImage({ bytes: healedPng, filename: 'healed.png' });
15
43
  */
16
44
  export function useSaveImage() {
17
45
  const saveImage = useCallback(async (input) => {
18
- const payload = 'url' in input && input.url !== undefined
19
- ? { url: input.url, filename: input.filename }
20
- : { imageId: input.imageId, filename: input.filename };
46
+ const payload = toWirePayload(input);
21
47
  const reply = await sendTypedRequest(getTransport(), { type: 'SAVE_IMAGE', payload }, 'SAVE_IMAGE_RESULT');
22
48
  throwOnFailedReply(reply, 'failed to save image');
23
49
  }, []);
@@ -0,0 +1,84 @@
1
+ import type { BlockUploadedImageInfo } from '@civitai/app-sdk/blocks';
2
+ /**
3
+ * The SDK's own rejection when a reply carries neither an image nor an
4
+ * `error`. A host that predates the `bytes` variant opens its picker instead,
5
+ * and a dismissed picker replies this way; so does any reply whose `selected`
6
+ * is not the moderated `display` shape.
7
+ */
8
+ export declare const UPLOAD_IMAGE_BYTES_NO_IMAGE_ERROR = "the host returned no uploaded image";
9
+ /** Options for {@link UseUploadImageBytes.upload}. */
10
+ export interface UploadImageBytesOptions {
11
+ /**
12
+ * Advisory name for the stored image. The host sanitises it with the same
13
+ * rules as a `useSaveImage()` bytes name, forces the extension from the
14
+ * sniffed type, and caps it at 255 characters.
15
+ */
16
+ filename?: string;
17
+ }
18
+ /** What {@link useUploadImageBytes} returns. */
19
+ export interface UseUploadImageBytes {
20
+ /**
21
+ * Upload an image the block produced in the tab, with no picker, and resolve
22
+ * with the moderated image: the same {@link BlockUploadedImageInfo} a picked
23
+ * `useImageUpload()` upload returns. Its `imageId` is postable by THIS app
24
+ * through `useCreatePostFromApp()` as `{ kind: 'published', imageIds }`.
25
+ *
26
+ * REJECTS with the host's error string on every refusal or failure:
27
+ * `invalid image-upload request` (not a non-empty `ArrayBuffer`),
28
+ * `file exceeds the maximum upload size`, `file type is not allowed`,
29
+ * `busy` (the host's rolling window is full; retry later), `no block token`,
30
+ * or a server message passed through verbatim (a missing `posts:write:self`
31
+ * scope, the posting flag, page-only, a rate limit, a scan refusal or
32
+ * timeout), or `unsupported on this host` on a slot (model) host, which has
33
+ * no handler and answers at once with its generic refusal. Rejects with
34
+ * `the host returned no uploaded image` when a reply carries neither an image
35
+ * nor an error.
36
+ *
37
+ * 🔴 With NO reply inside the 10-minute bound it rejects with a
38
+ * `RequestTimeoutError` instead, and the work may have completed: the host
39
+ * may have stored and scanned the image. Retrying after a timeout can create
40
+ * a duplicate image.
41
+ *
42
+ * 🔴 One call is refused BEFORE sending, with the host's own string: a buffer
43
+ * over the 40 MiB cap rejects with `file exceeds the maximum upload size`,
44
+ * so it is never copied across `postMessage` just to be refused.
45
+ */
46
+ upload: (bytes: ArrayBuffer, options?: UploadImageBytesOptions) => Promise<BlockUploadedImageInfo>;
47
+ }
48
+ /**
49
+ * Upload an image the block PRODUCED IN THE VIEWER'S TAB (a healed PNG, an
50
+ * edited render) through the host's `OPEN_IMAGE_UPLOAD { bytes }` bridge, so
51
+ * the app can then post it. No picker opens; the host runs the same store
52
+ * upload → persist → scan pipeline as a picked `display` upload and replies
53
+ * only once the scan settles.
54
+ *
55
+ * - 🔴 **Page apps only** (a slot host rejects at once with
56
+ * `unsupported on this host`), and the token needs **`posts:write:self`**
57
+ * (the server refuses the persist without it). This hook does not prompt for the
58
+ * scope, so ask for it first with `useRequestConsent()`: the upload runs
59
+ * before any `useCreatePostFromApp()` call that would otherwise prompt.
60
+ * - **Images only:** PNG, WebP or JPEG by magic bytes. The cap is 40 MiB, and
61
+ * the host also allows at most 3 uploads and 80 MiB per 60 s per page.
62
+ * - The buffer is COPIED across `postMessage`, never transferred, so the block
63
+ * can keep displaying it. Pass an `ArrayBuffer` (`await blob.arrayBuffer()`),
64
+ * not a `Blob` or a typed array, which the host refuses.
65
+ * - 🔴 **A host that predates the variant ignores `bytes` and opens its picker.**
66
+ * Then the promise settles on whatever the viewer picks, or rejects with
67
+ * `the host returned no uploaded image` on a dismiss. It needs
68
+ * civitai/civitai#5639 merged and deployed to civitai.com; that PR is still
69
+ * changing, so no intermediate head of it is enough. Do not ship a block
70
+ * relying on this before then.
71
+ *
72
+ * Under `createMockHost` / `Harness` the host's admission rules are modelled
73
+ * (see `MockHostOptions.uploadImageBytesResult`). `dev:live` has no bytes
74
+ * path: it replies dismissed, so `upload` rejects with
75
+ * `the host returned no uploaded image`.
76
+ *
77
+ * @example
78
+ * const { upload } = useUploadImageBytes();
79
+ * const { createPost } = useCreatePostFromApp();
80
+ * const image = await upload(await healed.arrayBuffer(), { filename: 'healed.png' });
81
+ * await createPost({ sources: [{ kind: 'published', imageIds: [image.imageId] }] });
82
+ */
83
+ export declare function useUploadImageBytes(): UseUploadImageBytes;
84
+ //# sourceMappingURL=useUploadImageBytes.d.ts.map
@@ -0,0 +1,77 @@
1
+ import { useCallback } from 'react';
2
+ import { throwOnReplyError } from '../internal/replyError.js';
3
+ import { UPLOAD_BYTES_MAX_BYTES, UPLOAD_BYTES_TOO_LARGE_ERROR } from '../internal/uploadBytes.js';
4
+ import { HUMAN_INTERACTION_TIMEOUT_MS } from '../transport/requestTimeouts.js';
5
+ import { getTransport } from '../transport/singleton.js';
6
+ import { sendTypedRequest } from '../transport/transport.js';
7
+ /**
8
+ * The SDK's own rejection when a reply carries neither an image nor an
9
+ * `error`. A host that predates the `bytes` variant opens its picker instead,
10
+ * and a dismissed picker replies this way; so does any reply whose `selected`
11
+ * is not the moderated `display` shape.
12
+ */
13
+ export const UPLOAD_IMAGE_BYTES_NO_IMAGE_ERROR = 'the host returned no uploaded image';
14
+ /**
15
+ * Upload an image the block PRODUCED IN THE VIEWER'S TAB (a healed PNG, an
16
+ * edited render) through the host's `OPEN_IMAGE_UPLOAD { bytes }` bridge, so
17
+ * the app can then post it. No picker opens; the host runs the same store
18
+ * upload → persist → scan pipeline as a picked `display` upload and replies
19
+ * only once the scan settles.
20
+ *
21
+ * - 🔴 **Page apps only** (a slot host rejects at once with
22
+ * `unsupported on this host`), and the token needs **`posts:write:self`**
23
+ * (the server refuses the persist without it). This hook does not prompt for the
24
+ * scope, so ask for it first with `useRequestConsent()`: the upload runs
25
+ * before any `useCreatePostFromApp()` call that would otherwise prompt.
26
+ * - **Images only:** PNG, WebP or JPEG by magic bytes. The cap is 40 MiB, and
27
+ * the host also allows at most 3 uploads and 80 MiB per 60 s per page.
28
+ * - The buffer is COPIED across `postMessage`, never transferred, so the block
29
+ * can keep displaying it. Pass an `ArrayBuffer` (`await blob.arrayBuffer()`),
30
+ * not a `Blob` or a typed array, which the host refuses.
31
+ * - 🔴 **A host that predates the variant ignores `bytes` and opens its picker.**
32
+ * Then the promise settles on whatever the viewer picks, or rejects with
33
+ * `the host returned no uploaded image` on a dismiss. It needs
34
+ * civitai/civitai#5639 merged and deployed to civitai.com; that PR is still
35
+ * changing, so no intermediate head of it is enough. Do not ship a block
36
+ * relying on this before then.
37
+ *
38
+ * Under `createMockHost` / `Harness` the host's admission rules are modelled
39
+ * (see `MockHostOptions.uploadImageBytesResult`). `dev:live` has no bytes
40
+ * path: it replies dismissed, so `upload` rejects with
41
+ * `the host returned no uploaded image`.
42
+ *
43
+ * @example
44
+ * const { upload } = useUploadImageBytes();
45
+ * const { createPost } = useCreatePostFromApp();
46
+ * const image = await upload(await healed.arrayBuffer(), { filename: 'healed.png' });
47
+ * await createPost({ sources: [{ kind: 'published', imageIds: [image.imageId] }] });
48
+ */
49
+ export function useUploadImageBytes() {
50
+ const upload = useCallback(async (bytes, options) => {
51
+ // The ONLY client-side refusal, mirroring `useSaveImage`'s cap pre-check:
52
+ // everything else is forwarded and judged by the host.
53
+ if (bytes instanceof ArrayBuffer && bytes.byteLength > UPLOAD_BYTES_MAX_BYTES) {
54
+ throw new Error(UPLOAD_BYTES_TOO_LARGE_ERROR);
55
+ }
56
+ const reply = await sendTypedRequest(getTransport(), {
57
+ type: 'OPEN_IMAGE_UPLOAD',
58
+ payload: {
59
+ bytes,
60
+ ...(options?.filename !== undefined ? { filename: options.filename } : {}),
61
+ },
62
+ }, 'IMAGE_UPLOAD_RESULT',
63
+ // `OPEN_IMAGE_UPLOAD` is bucketed `'human'` (transport/requestTimeouts.ts),
64
+ // and this variant needs the long bound too: the host replies only after
65
+ // the store upload, the persist and a scan poll of up to three minutes,
66
+ // and a host that predates `bytes` opens a picker a person must close.
67
+ { timeoutMs: HUMAN_INTERACTION_TIMEOUT_MS });
68
+ // No `ok` on this reply, so the presence-based helper (internal/replyError.ts).
69
+ throwOnReplyError(reply, 'image upload failed');
70
+ const selected = reply.selected;
71
+ if (selected && !('status' in selected) && 'imageId' in selected)
72
+ return selected;
73
+ throw new Error(UPLOAD_IMAGE_BYTES_NO_IMAGE_ERROR);
74
+ }, []);
75
+ return { upload };
76
+ }
77
+ //# sourceMappingURL=useUploadImageBytes.js.map
package/dist/index.d.ts CHANGED
@@ -78,6 +78,8 @@ export { useResourcePicker } from './hooks/useResourcePicker.js';
78
78
  export type { UseResourcePicker } from './hooks/useResourcePicker.js';
79
79
  export { useImageUpload } from './hooks/useImageUpload.js';
80
80
  export type { UseImageUpload, UseImageUploadAsyncScan, UseImageUploadGenerationSource, UseImageUploadOptions, } from './hooks/useImageUpload.js';
81
+ export { useUploadImageBytes } from './hooks/useUploadImageBytes.js';
82
+ export type { UploadImageBytesOptions, UseUploadImageBytes, } from './hooks/useUploadImageBytes.js';
81
83
  export { useGenerationResources } from './hooks/useGenerationResources.js';
82
84
  export type { UseGenerationResources } from './hooks/useGenerationResources.js';
83
85
  export { GENERATION_RESOURCES_API_BASE, MAX_GENERATION_RESOURCE_IDS, buildGenerationResourcesUrl, responseToResources, } from './api/generationResources.js';
package/dist/index.js CHANGED
@@ -64,6 +64,7 @@ export { useRunTraining, RunTrainingError, RUN_TRAINING_ERROR_CODES, isRunTraini
64
64
  export { useCheckpointPicker } from './hooks/useCheckpointPicker.js';
65
65
  export { useResourcePicker } from './hooks/useResourcePicker.js';
66
66
  export { useImageUpload } from './hooks/useImageUpload.js';
67
+ export { useUploadImageBytes } from './hooks/useUploadImageBytes.js';
67
68
  export { useGenerationResources } from './hooks/useGenerationResources.js';
68
69
  export { GENERATION_RESOURCES_API_BASE, MAX_GENERATION_RESOURCE_IDS, buildGenerationResourcesUrl, responseToResources, } from './api/generationResources.js';
69
70
  export { useCivitaiNavigate } from './hooks/useCivitaiNavigate.js';
@@ -1174,9 +1174,21 @@ export function createLiveHost(options) {
1174
1174
  // the hook resolves to `null`) and log an actionable message. Honest-
1175
1175
  // by-design — never fabricate a moderated image id. Mirrors the
1176
1176
  // OPEN_BUZZ_PURCHASE "no headless contract" pattern.
1177
- logOnce('open-image-upload', 'OPEN_IMAGE_UPLOAD received but live dev mode cannot serve the native upload ' +
1178
- 'modal — resolving the upload as dismissed (null). Test the image-upload flow ' +
1179
- 'against the real site, or use createMockHost({ cannedImageUpload }) in dev:mock.');
1177
+ //
1178
+ // The `bytes` variant (`useUploadImageBytes()`) gets the same reply,
1179
+ // but that hook has no "dismissed" outcome: it REJECTS with its
1180
+ // no-image error. So it gets its own message, naming its mock option.
1181
+ if (typed.payload != null && 'bytes' in typed.payload) {
1182
+ logOnce('open-image-upload-bytes', 'OPEN_IMAGE_UPLOAD { bytes } received but live dev mode has no bytes upload path — ' +
1183
+ 'useUploadImageBytes().upload() REJECTS with "the host returned no uploaded image". ' +
1184
+ 'Test the bytes upload against the real site, or use ' +
1185
+ 'createMockHost({ uploadImageBytesResult }) in dev:mock.');
1186
+ }
1187
+ else {
1188
+ logOnce('open-image-upload', 'OPEN_IMAGE_UPLOAD received but live dev mode cannot serve the native upload ' +
1189
+ 'modal — resolving the upload as dismissed (null). Test the image-upload flow ' +
1190
+ 'against the real site, or use createMockHost({ cannedImageUpload }) in dev:mock.');
1191
+ }
1180
1192
  dispatchToBlock({
1181
1193
  type: 'IMAGE_UPLOAD_RESULT',
1182
1194
  payload: { requestId: requestId ?? '' },
@@ -1640,8 +1652,14 @@ export function createLiveHost(options) {
1640
1652
  // • `imageId` — the host resolves it through the SAME per-viewer
1641
1653
  // gated read that backs GET_IMAGES_BY_IDS, so a withheld or
1642
1654
  // above-ceiling image can never be coerced into a download.
1655
+ // • `bytes` (#583) — refused here too. Its gate (content
1656
+ // classification, the cap) is reproducible, and `createMockHost`
1657
+ // reproduces it; but a dev:live "download" would be this
1658
+ // harness's tab saving a file, which proves nothing about the
1659
+ // production host's page-only rule or its top-frame download.
1660
+ // One honest refusal for every variant keeps this branch simple.
1643
1661
  //
1644
- // This harness has NEITHER gate. The allowlist is the production
1662
+ // This harness has NEITHER of the url / imageId gates. The allowlist is the production
1645
1663
  // host's, not this SDK's — there is no origin set here to check
1646
1664
  // against — so a dev-side "download it anyway" would accept URLs
1647
1665
  // production refuses and let a block ship having never once handled