@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 +170 -2
- package/dist/hooks/useCreatePostFromApp.d.ts +2 -1
- package/dist/hooks/useCreatePostFromApp.js +2 -1
- package/dist/hooks/useSaveImage.d.ts +53 -12
- package/dist/hooks/useSaveImage.js +31 -5
- package/dist/hooks/useUploadImageBytes.d.ts +84 -0
- package/dist/hooks/useUploadImageBytes.js +77 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/internal/liveHost.js +22 -4
- package/dist/internal/mockHost.d.ts +198 -38
- package/dist/internal/mockHost.js +197 -40
- package/dist/internal/saveBytes.d.ts +102 -0
- package/dist/internal/saveBytes.js +187 -0
- package/dist/internal/uploadBytes.d.ts +89 -0
- package/dist/internal/uploadBytes.js +102 -0
- package/dist/transport/validate.d.ts +2 -1
- package/dist/transport/validate.js +8 -1
- package/package.json +4 -4
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()`,
|
|
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
|
|
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
|
|
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
|
|
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`
|
|
3
|
-
* required — the
|
|
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
|
|
25
|
-
*
|
|
26
|
-
* "Save As". A sandboxed opaque-origin block lacks
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* an over-size
|
|
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
|
|
37
|
-
* bridge. See {@link SaveImageInput} for the
|
|
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
|
-
*
|
|
7
|
-
*
|
|
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 =
|
|
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
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
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
|
|
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
|