@civitai/blocks-react 0.66.0 → 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 +109 -1
- package/dist/hooks/useCreatePostFromApp.d.ts +2 -1
- package/dist/hooks/useCreatePostFromApp.js +2 -1
- 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 +15 -3
- package/dist/internal/mockHost.d.ts +77 -3
- package/dist/internal/mockHost.js +83 -0
- package/dist/internal/saveBytes.d.ts +9 -0
- package/dist/internal/saveBytes.js +18 -6
- package/dist/internal/uploadBytes.d.ts +89 -0
- package/dist/internal/uploadBytes.js +102 -0
- package/dist/transport/validate.d.ts +1 -0
- package/dist/transport/validate.js +7 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -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,113 @@ 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
|
+
|
|
1211
1319
|
### `useSaveImage()`
|
|
1212
1320
|
|
|
1213
1321
|
Ask the host to download a file to the viewer's device. **A block cannot
|
|
@@ -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`,
|
|
@@ -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 ?? '' },
|
|
@@ -619,8 +619,9 @@ export interface MockHostOptions {
|
|
|
619
619
|
/**
|
|
620
620
|
* The bare (post-less) scanned `Image` row ids returned from
|
|
621
621
|
* `PUBLISH_GENERATION_OUTPUTS` (what `usePublishGenerationOutputs().publish()`
|
|
622
|
-
* resolves with). Absent → {@link DEFAULT_PUBLISH_IMAGE_IDS}.
|
|
623
|
-
*
|
|
622
|
+
* resolves with). Absent → {@link DEFAULT_PUBLISH_IMAGE_IDS}. Each reply makes
|
|
623
|
+
* these ids postable once in a `CREATE_POST_FROM_APP` `published` source (see
|
|
624
|
+
* {@link createPostResult}). Live-tunable via {@link MockHost.setScenario}.
|
|
624
625
|
*/
|
|
625
626
|
publishImageIds?: number[];
|
|
626
627
|
/**
|
|
@@ -681,6 +682,21 @@ export interface MockHostOptions {
|
|
|
681
682
|
* `useCreatePostFromApp().createPost()` resolves with). Absent →
|
|
682
683
|
* {@link DEFAULT_CREATE_POST_RESULT}. Ignored when {@link createPostError} is
|
|
683
684
|
* set. Live-tunable via {@link MockHost.setScenario}.
|
|
685
|
+
*
|
|
686
|
+
* A `{ kind: 'published', imageIds }` source is checked as the server's
|
|
687
|
+
* `resolveAppPublishedImages` checks it (civitai/civitai#5639). Production
|
|
688
|
+
* accepts the viewer's own images that this app stamped and that are not in a
|
|
689
|
+
* post yet, from this session or an earlier one. The mock has no earlier
|
|
690
|
+
* sessions, so it accepts ONLY ids issued as postable on this mock instance
|
|
691
|
+
* (an accepted `OPEN_IMAGE_UPLOAD { bytes }` reply,
|
|
692
|
+
* {@link uploadImageBytesResult}, or a `PUBLISH_GENERATION_OUTPUTS` reply,
|
|
693
|
+
* {@link publishImageIds}) plus the ids seeded with {@link postableImageIds}.
|
|
694
|
+
* Any other id, a picked `useImageUpload()` id (production leaves it
|
|
695
|
+
* unstamped) or an id a previous post already adopted is refused with
|
|
696
|
+
* `an image is not available to post`, and a source with no positive integer
|
|
697
|
+
* id with `no valid image ids in a published source`. A refused post adopts
|
|
698
|
+
* nothing. `workflow` sources are not checked. The mock does not model the
|
|
699
|
+
* scan wait, the viewer's ceiling or the server's input schema.
|
|
684
700
|
*/
|
|
685
701
|
createPostResult?: BlockCreatePostResult;
|
|
686
702
|
/**
|
|
@@ -816,6 +832,64 @@ export interface MockHostOptions {
|
|
|
816
832
|
mimeType: string;
|
|
817
833
|
filename: string;
|
|
818
834
|
}) => void;
|
|
835
|
+
/**
|
|
836
|
+
* The moderated image an ACCEPTED `OPEN_IMAGE_UPLOAD { bytes }` replies with
|
|
837
|
+
* (what `useUploadImageBytes().upload()` resolves with). Absent →
|
|
838
|
+
* {@link DEFAULT_IMAGE_BYTES_UPLOAD}, whose `imageId` differs from
|
|
839
|
+
* {@link DEFAULT_IMAGE_UPLOAD}'s so a block that confuses a picked upload
|
|
840
|
+
* with an uploaded one is visible. Each accepted upload makes its `imageId`
|
|
841
|
+
* postable ONCE in a `CREATE_POST_FROM_APP` `{ kind: 'published', imageIds }`
|
|
842
|
+
* source on this same mock instance, as the real host allows for the app that
|
|
843
|
+
* uploaded it; see {@link createPostResult} for what the mock refuses.
|
|
844
|
+
* Live-tunable via {@link MockHost.setScenario}.
|
|
845
|
+
*
|
|
846
|
+
* The `bytes` variant runs the host's admission rules
|
|
847
|
+
* (civitai/civitai#5639, `imageUploadBytes.ts`) in the host's order: not a
|
|
848
|
+
* non-empty `ArrayBuffer`, or `purpose: 'generationSource'` →
|
|
849
|
+
* `invalid image-upload request`; over 40 MiB →
|
|
850
|
+
* `file exceeds the maximum upload size` (checked FIRST); a full rolling
|
|
851
|
+
* window (3 uploads or 80 MiB per 60 s, per install) → `busy`; not PNG /
|
|
852
|
+
* WebP / JPEG by magic bytes → `file type is not allowed`; then
|
|
853
|
+
* {@link uploadImageBytesError}, standing in for the server persist and the
|
|
854
|
+
* scan. 🔴 What the mock cannot model: that the variant is PAGE-ONLY, the
|
|
855
|
+
* `posts:write:self` scope check (force `block lacks posts:write:self scope`
|
|
856
|
+
* with {@link uploadImageBytesError}), the real scan, the no-reply for a
|
|
857
|
+
* `requestId` reused while in flight (the mock replies at once), and that a
|
|
858
|
+
* host OLDER than the variant opens its picker instead.
|
|
859
|
+
*/
|
|
860
|
+
uploadImageBytesResult?: BlockUploadedImageInfo;
|
|
861
|
+
/**
|
|
862
|
+
* Force an `OPEN_IMAGE_UPLOAD { bytes }` that passed the admission rules
|
|
863
|
+
* above to reply with this `error`: a server refusal the real host forwards
|
|
864
|
+
* (`block lacks posts:write:self scope`,
|
|
865
|
+
* `Rate limit exceeded, please retry shortly.`, a scan refusal, …), or
|
|
866
|
+
* `busy` / `no block token`. The upload still counts against the window, as
|
|
867
|
+
* on the host. Absent → it succeeds. Live-tunable via
|
|
868
|
+
* {@link MockHost.setScenario}; `undefined` clears it.
|
|
869
|
+
*/
|
|
870
|
+
uploadImageBytesError?: string;
|
|
871
|
+
/**
|
|
872
|
+
* Called with each `bytes` upload the mock ACCEPTS: the sniffed `mimeType`
|
|
873
|
+
* (`image/png`, `image/webp` or `image/jpeg`), the `filename` as the host
|
|
874
|
+
* stores it (the `useSaveImage()` bytes cleaning, the sniffed extension, at
|
|
875
|
+
* most 255 characters), and a copy of the `bytes`.
|
|
876
|
+
*/
|
|
877
|
+
onUploadImageBytes?: (file: {
|
|
878
|
+
bytes: ArrayBuffer;
|
|
879
|
+
mimeType: string;
|
|
880
|
+
filename: string;
|
|
881
|
+
}) => void;
|
|
882
|
+
/**
|
|
883
|
+
* Image ids the mock treats as already issued and postable when the host is
|
|
884
|
+
* created, as production treats an image this app stamped for the viewer in
|
|
885
|
+
* an EARLIER session that is not in a post yet. Each seeded id may be posted
|
|
886
|
+
* ONCE in a `CREATE_POST_FROM_APP` `{ kind: 'published', imageIds }` source;
|
|
887
|
+
* see {@link createPostResult} for what the mock refuses. Use it for a test
|
|
888
|
+
* that posts an image the block uploaded before the test started, instead of
|
|
889
|
+
* driving an upload or `PUBLISH_GENERATION_OUTPUTS` first. Absent → none.
|
|
890
|
+
* Read once at creation; not live-tunable.
|
|
891
|
+
*/
|
|
892
|
+
postableImageIds?: number[];
|
|
819
893
|
/**
|
|
820
894
|
* Buzz pools a `SUBMIT_WORKFLOW` must REJECT when named in `body.accountType`
|
|
821
895
|
* — simulates the real backend's content-rating clamp. The real host throws a
|
|
@@ -980,7 +1054,7 @@ export interface MockHostOptions {
|
|
|
980
1054
|
* {@link MockHost.setTheme}, which pushes a real `THEME_CHANGE` — the mock of
|
|
981
1055
|
* the viewer toggling dark mode with the block already mounted.)
|
|
982
1056
|
*/
|
|
983
|
-
export type MockHostScenarioPatch = Pick<MockHostOptions, 'consentGrantable' | 'failMode' | 'cost' | 'pollsUntilDone' | 'cannedPicks' | 'cannedImageUpload' | 'cannedGenerationSourceUpload' | 'cannedImageScan' | 'generation' | 'buzz' | 'storage' | 'shared' | 'buzzBalanceError' | 'viewerResult' | 'viewerError' | 'buzzTransactions' | 'buzzAccounts' | 'dailyCompensation' | 'buzzReadError' | 'wildcardPack' | 'wildcardPackError' | 'collectionFollowError' | 'createPostResult' | 'createPostError' | 'trainingDatasetRejected' | 'trainingDatasetError' | 'trainingQuoteTotal' | 'runTrainingError' | 'runTrainingCapRefusal' | 'appWorkflows' | 'appWorkflowsError' | 'publishImageIds' | 'publishError' | 'gatedImages' | 'gatedImagesError' | 'disallowedAccountTypes' | 'saveImageError'>;
|
|
1057
|
+
export type MockHostScenarioPatch = Pick<MockHostOptions, 'consentGrantable' | 'failMode' | 'cost' | 'pollsUntilDone' | 'cannedPicks' | 'cannedImageUpload' | 'cannedGenerationSourceUpload' | 'cannedImageScan' | 'generation' | 'buzz' | 'storage' | 'shared' | 'buzzBalanceError' | 'viewerResult' | 'viewerError' | 'buzzTransactions' | 'buzzAccounts' | 'dailyCompensation' | 'buzzReadError' | 'wildcardPack' | 'wildcardPackError' | 'collectionFollowError' | 'createPostResult' | 'createPostError' | 'trainingDatasetRejected' | 'trainingDatasetError' | 'trainingQuoteTotal' | 'runTrainingError' | 'runTrainingCapRefusal' | 'appWorkflows' | 'appWorkflowsError' | 'publishImageIds' | 'publishError' | 'gatedImages' | 'gatedImagesError' | 'disallowedAccountTypes' | 'saveImageError' | 'uploadImageBytesResult' | 'uploadImageBytesError'>;
|
|
984
1058
|
/** Runtime Buzz-balance handle exposed on {@link MockHost.buzz}. */
|
|
985
1059
|
export interface MockBuzzHandle {
|
|
986
1060
|
/** Current simulated balance, or `undefined` when balance isn't simulated. */
|
|
@@ -42,6 +42,7 @@ import { consentUnavailablePayload, isKnownBlockScope, resolveUngrantableConsent
|
|
|
42
42
|
import { requiredStorageScope, storageResultType, storageScopeDeniedMessage, storageScopeDeniedPayload, } from './mockHostScopes.js';
|
|
43
43
|
import { governsIdempotencyKey, idempotencyKeyDeniedMessage, idempotencyKeyRefusal, } from './mockHostIdempotency.js';
|
|
44
44
|
import { SAVE_IMAGE_INVALID_REQUEST_ERROR, prepareSaveBytes, saveImageRequestKind, } from './saveBytes.js';
|
|
45
|
+
import { UPLOAD_BYTES_INVALID_ERROR, admitUploadBytes, resolveUploadBytesRequest, } from './uploadBytes.js';
|
|
45
46
|
import { RUN_TRAINING_ERROR_CODES } from '../hooks/useRunTraining.js';
|
|
46
47
|
import { hostContextWithTheme } from '../transport/transport.js';
|
|
47
48
|
import { isRoutableRequestId } from '../transport/requestId.js';
|
|
@@ -248,6 +249,17 @@ const DEFAULT_IMAGE_UPLOAD = {
|
|
|
248
249
|
* — no imageId/nsfwLevel). `null` simulates a user-dismissed modal. The url is a
|
|
249
250
|
* Civitai-hosted image so a dev can feed it straight into a `sourceImage` body.
|
|
250
251
|
*/
|
|
252
|
+
/**
|
|
253
|
+
* The moderated image the mock host returns from an accepted
|
|
254
|
+
* `OPEN_IMAGE_UPLOAD { bytes }` when {@link MockHostOptions.uploadImageBytesResult}
|
|
255
|
+
* is omitted. Its `imageId` deliberately differs from {@link DEFAULT_IMAGE_UPLOAD}'s.
|
|
256
|
+
*/
|
|
257
|
+
const DEFAULT_IMAGE_BYTES_UPLOAD = {
|
|
258
|
+
imageId: 12345701,
|
|
259
|
+
nsfwLevel: 1,
|
|
260
|
+
contentRating: 'pg',
|
|
261
|
+
url: 'https://image.civitai.com/mock/original=true/dev-bytes-upload.png',
|
|
262
|
+
};
|
|
251
263
|
const DEFAULT_GENERATION_SOURCE_UPLOAD = {
|
|
252
264
|
url: 'https://image.civitai.com/mock/original=true/dev-generation-source.jpeg',
|
|
253
265
|
width: 1024,
|
|
@@ -831,6 +843,19 @@ export function createMockHost(options = {}) {
|
|
|
831
843
|
let runTrainingError = options.runTrainingError;
|
|
832
844
|
// SAVE_IMAGE: a forced-refusal knob (see `saveImageError`).
|
|
833
845
|
let saveImageError = options.saveImageError;
|
|
846
|
+
// OPEN_IMAGE_UPLOAD { bytes }: the canned result, a forced-refusal knob, and
|
|
847
|
+
// the host's rolling window (per install, like the host's per mount).
|
|
848
|
+
let uploadImageBytesResult = options.uploadImageBytesResult ?? DEFAULT_IMAGE_BYTES_UPLOAD;
|
|
849
|
+
let uploadImageBytesError = options.uploadImageBytesError;
|
|
850
|
+
let uploadBytesWindow = [];
|
|
851
|
+
// The ids a `CREATE_POST_FROM_APP` `{ kind: 'published' }` source may name:
|
|
852
|
+
// ONLY ids this mock itself issued as postable (an accepted bytes upload, or a
|
|
853
|
+
// PUBLISH_GENERATION_OUTPUTS reply) plus the seeded `postableImageIds` (an
|
|
854
|
+
// earlier session's stamped, unposted images), and only until a post adopts
|
|
855
|
+
// them. Mirrors the host's `resolveAppPublishedImages` (civitai/civitai#5639):
|
|
856
|
+
// this app's provenance stamp and `postId IS NULL`. A picked `useImageUpload()`
|
|
857
|
+
// image is never added: production leaves it unstamped, so it is not postable.
|
|
858
|
+
const postableImageIds = new Set(options.postableImageIds ?? []);
|
|
834
859
|
let runTrainingCapRefusal = options.runTrainingCapRefusal;
|
|
835
860
|
// App-subqueue bridge data + forced free-text-error knob. `appWorkflows` is
|
|
836
861
|
// MUTABLE — CANCEL_APP_WORKFLOW marks the matching row canceled in place so a
|
|
@@ -1840,6 +1865,32 @@ export function createMockHost(options = {}) {
|
|
|
1840
1865
|
});
|
|
1841
1866
|
return;
|
|
1842
1867
|
}
|
|
1868
|
+
// `published` sources: the server's `resolveAppPublishedImages`
|
|
1869
|
+
// refusals, verbatim. Ids are de-duplicated and non-positive /
|
|
1870
|
+
// non-integer ones dropped first; none left → `no valid image ids
|
|
1871
|
+
// in a published source`. Then ONE message for "unknown", "not this
|
|
1872
|
+
// app's" (a picked upload is unstamped) and "already in a post", so
|
|
1873
|
+
// the reply is no existence oracle. Refused, never skipped.
|
|
1874
|
+
const adopted = [];
|
|
1875
|
+
for (const source of sources) {
|
|
1876
|
+
if (source?.kind !== 'published')
|
|
1877
|
+
continue;
|
|
1878
|
+
const raw = Array.isArray(source.imageIds) ? source.imageIds : [];
|
|
1879
|
+
const ids = [...new Set(raw)].filter((n) => typeof n === 'number' && Number.isInteger(n) && n > 0);
|
|
1880
|
+
const refusal = ids.length === 0
|
|
1881
|
+
? 'no valid image ids in a published source'
|
|
1882
|
+
: ids.some((id) => !postableImageIds.has(id))
|
|
1883
|
+
? 'an image is not available to post'
|
|
1884
|
+
: undefined;
|
|
1885
|
+
if (refusal !== undefined) {
|
|
1886
|
+
dispatchToBlock({ type: 'CREATE_POST_RESULT', payload: { requestId, error: refusal } });
|
|
1887
|
+
return;
|
|
1888
|
+
}
|
|
1889
|
+
adopted.push(...ids);
|
|
1890
|
+
}
|
|
1891
|
+
// Posted: the adopted images now have a `postId`, so never again.
|
|
1892
|
+
for (const id of adopted)
|
|
1893
|
+
postableImageIds.delete(id);
|
|
1843
1894
|
dispatchToBlock({
|
|
1844
1895
|
type: 'CREATE_POST_RESULT',
|
|
1845
1896
|
payload: { requestId, result: { ...createPostResult } },
|
|
@@ -1962,6 +2013,8 @@ export function createMockHost(options = {}) {
|
|
|
1962
2013
|
});
|
|
1963
2014
|
return;
|
|
1964
2015
|
}
|
|
2016
|
+
for (const id of publishImageIds)
|
|
2017
|
+
postableImageIds.add(id);
|
|
1965
2018
|
dispatchToBlock({
|
|
1966
2019
|
type: 'PUBLISH_RESULT',
|
|
1967
2020
|
payload: { requestId, result: { imageIds: [...publishImageIds] } },
|
|
@@ -2025,6 +2078,31 @@ export function createMockHost(options = {}) {
|
|
|
2025
2078
|
// • 'generationSource' → the UNSCANNED source { url, width, height }
|
|
2026
2079
|
// • 'display' (default / absent) → the MODERATED image
|
|
2027
2080
|
// `null` → dismissed (no `selected`), so the hook resolves to null.
|
|
2081
|
+
// The `bytes` variant (civitai/civitai#5639): no picker, the host's
|
|
2082
|
+
// admission rules in the host's order (./uploadBytes.ts), then the
|
|
2083
|
+
// forced refusal standing in for the server persist + scan.
|
|
2084
|
+
const bytesReq = resolveUploadBytesRequest(typed.payload ?? {});
|
|
2085
|
+
if (bytesReq.kind !== 'none') {
|
|
2086
|
+
if (!isRoutableRequestId(requestId))
|
|
2087
|
+
return;
|
|
2088
|
+
const reply = (p) => dispatchToBlock({ type: 'IMAGE_UPLOAD_RESULT', payload: { requestId, ...p } });
|
|
2089
|
+
if (bytesReq.kind === 'invalid')
|
|
2090
|
+
return reply({ error: UPLOAD_BYTES_INVALID_ERROR });
|
|
2091
|
+
const admitted = admitUploadBytes(bytesReq, uploadBytesWindow, Date.now());
|
|
2092
|
+
uploadBytesWindow = admitted.recent;
|
|
2093
|
+
if (!admitted.result.ok)
|
|
2094
|
+
return reply({ error: admitted.result.error });
|
|
2095
|
+
if (uploadImageBytesError !== undefined)
|
|
2096
|
+
return reply({ error: uploadImageBytesError });
|
|
2097
|
+
// A COPY, as production's structured-clone `postMessage` delivers it.
|
|
2098
|
+
options.onUploadImageBytes?.({
|
|
2099
|
+
bytes: bytesReq.bytes.slice(0),
|
|
2100
|
+
mimeType: admitted.result.type,
|
|
2101
|
+
filename: admitted.result.filename,
|
|
2102
|
+
});
|
|
2103
|
+
postableImageIds.add(uploadImageBytesResult.imageId);
|
|
2104
|
+
return reply({ selected: { ...uploadImageBytesResult } });
|
|
2105
|
+
}
|
|
2028
2106
|
const isGenerationSource = typed.payload?.purpose === 'generationSource';
|
|
2029
2107
|
// NON-BLOCKING display path (asyncScan:true): early-resolve with a
|
|
2030
2108
|
// PENDING handle, then stream the canned scan verdict on a later tick.
|
|
@@ -2694,6 +2772,11 @@ export function createMockHost(options = {}) {
|
|
|
2694
2772
|
runTrainingError = patch.runTrainingError;
|
|
2695
2773
|
if ('saveImageError' in patch)
|
|
2696
2774
|
saveImageError = patch.saveImageError;
|
|
2775
|
+
if (patch.uploadImageBytesResult !== undefined) {
|
|
2776
|
+
uploadImageBytesResult = patch.uploadImageBytesResult;
|
|
2777
|
+
}
|
|
2778
|
+
if ('uploadImageBytesError' in patch)
|
|
2779
|
+
uploadImageBytesError = patch.uploadImageBytesError;
|
|
2697
2780
|
if ('runTrainingCapRefusal' in patch)
|
|
2698
2781
|
runTrainingCapRefusal = patch.runTrainingCapRefusal;
|
|
2699
2782
|
if (patch.appWorkflows !== undefined) {
|
|
@@ -47,6 +47,15 @@ export declare function saveImageRequestKind(p: {
|
|
|
47
47
|
export type SaveBytesType = 'image/png' | 'image/webp' | 'image/jpeg' | 'application/json' | 'text/plain';
|
|
48
48
|
/** The extension the host forces for each classified type. */
|
|
49
49
|
export declare const SAVE_BYTES_EXTENSION: Record<SaveBytesType, string>;
|
|
50
|
+
/** The three image types the host recognises by magic bytes. */
|
|
51
|
+
export type SniffedImageType = 'image/png' | 'image/webp' | 'image/jpeg';
|
|
52
|
+
/**
|
|
53
|
+
* PNG / WebP / JPEG by magic bytes — the host's shared sniffer
|
|
54
|
+
* (`sniffSaveBytesImage`: the full 8-byte PNG signature, no GIF). Both `bytes`
|
|
55
|
+
* bridges use it: `SAVE_IMAGE` before its text fallback, and
|
|
56
|
+
* `OPEN_IMAGE_UPLOAD`, which accepts images only.
|
|
57
|
+
*/
|
|
58
|
+
export declare function sniffImageBytes(view: Uint8Array): SniffedImageType | null;
|
|
50
59
|
/**
|
|
51
60
|
* Classify a `bytes` payload by CONTENT — the host's `classifySaveBytes`.
|
|
52
61
|
* `filename` is the only hint and must already be CLEANED
|
|
@@ -73,6 +73,21 @@ const PNG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
|
73
73
|
const JPEG = [0xff, 0xd8, 0xff];
|
|
74
74
|
const RIFF = [0x52, 0x49, 0x46, 0x46]; // "RIFF"
|
|
75
75
|
const WEBP = [0x57, 0x45, 0x42, 0x50]; // "WEBP" at offset 8
|
|
76
|
+
/**
|
|
77
|
+
* PNG / WebP / JPEG by magic bytes — the host's shared sniffer
|
|
78
|
+
* (`sniffSaveBytesImage`: the full 8-byte PNG signature, no GIF). Both `bytes`
|
|
79
|
+
* bridges use it: `SAVE_IMAGE` before its text fallback, and
|
|
80
|
+
* `OPEN_IMAGE_UPLOAD`, which accepts images only.
|
|
81
|
+
*/
|
|
82
|
+
export function sniffImageBytes(view) {
|
|
83
|
+
if (startsWith(view, PNG))
|
|
84
|
+
return 'image/png';
|
|
85
|
+
if (startsWith(view, RIFF) && startsWith(view, WEBP, 8))
|
|
86
|
+
return 'image/webp';
|
|
87
|
+
if (startsWith(view, JPEG))
|
|
88
|
+
return 'image/jpeg';
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
76
91
|
/**
|
|
77
92
|
* Classify a `bytes` payload by CONTENT — the host's `classifySaveBytes`.
|
|
78
93
|
* `filename` is the only hint and must already be CLEANED
|
|
@@ -83,12 +98,9 @@ const WEBP = [0x57, 0x45, 0x42, 0x50]; // "WEBP" at offset 8
|
|
|
83
98
|
*/
|
|
84
99
|
export function classifySaveBytes(bytes, filename = '') {
|
|
85
100
|
const view = new Uint8Array(bytes);
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
return 'image/webp';
|
|
90
|
-
if (startsWith(view, JPEG))
|
|
91
|
-
return 'image/jpeg';
|
|
101
|
+
const image = sniffImageBytes(view);
|
|
102
|
+
if (image)
|
|
103
|
+
return image;
|
|
92
104
|
if (view.includes(0))
|
|
93
105
|
return null;
|
|
94
106
|
let text;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `bytes` variant of `OPEN_IMAGE_UPLOAD`: the strings both sides of the
|
|
3
|
+
* bridge agree on, and the mock host's copy of the host's admission rules.
|
|
4
|
+
*
|
|
5
|
+
* The contract is civitai/civitai#5639 (`imageUploadBytes.ts` in the host). The
|
|
6
|
+
* host owns the real rules. This copy exists so `createMockHost` (and so
|
|
7
|
+
* `dev:harness` and every block test) accepts and refuses the same buffers
|
|
8
|
+
* production does. Keep it exactly as narrow as the contract: a mock that
|
|
9
|
+
* accepts a GIF teaches a block that GIFs upload.
|
|
10
|
+
*/
|
|
11
|
+
import { type SniffedImageType } from './saveBytes.js';
|
|
12
|
+
/**
|
|
13
|
+
* The cap on one `bytes` upload: 40 MiB of raw bytes (`ArrayBuffer.byteLength`).
|
|
14
|
+
*
|
|
15
|
+
* 🔴 The host spells its OWN copy of this number (civitai/civitai
|
|
16
|
+
* `BLOCK_IMAGE_MAX_BYTES`, the image pipeline's server cap), and the two MUST
|
|
17
|
+
* match. Nothing in this repository can read the host's value, so a drift is
|
|
18
|
+
* invisible here until a block hits the host's refusal on a buffer this hook let
|
|
19
|
+
* through, or the hook refuses one the host would have accepted. Kept local to
|
|
20
|
+
* this package (not exported from `@civitai/app-sdk`) so the hook needs no newer
|
|
21
|
+
* app-sdk peer.
|
|
22
|
+
*/
|
|
23
|
+
export declare const UPLOAD_BYTES_MAX_BYTES: number;
|
|
24
|
+
/** The host's rolling window: at most this many admitted uploads… */
|
|
25
|
+
export declare const UPLOAD_BYTES_MAX_PER_WINDOW = 3;
|
|
26
|
+
/** …and this many admitted bytes… */
|
|
27
|
+
export declare const UPLOAD_BYTES_MAX_BYTES_PER_WINDOW: number;
|
|
28
|
+
/** …per this many milliseconds, per mounted page host. */
|
|
29
|
+
export declare const UPLOAD_BYTES_WINDOW_MS = 60000;
|
|
30
|
+
/** Not a non-empty `ArrayBuffer`, or `purpose: 'generationSource'` with `bytes`. */
|
|
31
|
+
export declare const UPLOAD_BYTES_INVALID_ERROR = "invalid image-upload request";
|
|
32
|
+
/** Over {@link UPLOAD_BYTES_MAX_BYTES}. Also the hook's own pre-send refusal. */
|
|
33
|
+
export declare const UPLOAD_BYTES_TOO_LARGE_ERROR = "file exceeds the maximum upload size";
|
|
34
|
+
/** Not PNG / WebP / JPEG by magic bytes. */
|
|
35
|
+
export declare const UPLOAD_BYTES_TYPE_NOT_ALLOWED_ERROR = "file type is not allowed";
|
|
36
|
+
/** The host's rolling window is full. Retryable. */
|
|
37
|
+
export declare const UPLOAD_BYTES_BUSY_ERROR = "busy";
|
|
38
|
+
/** The host holds no block token. */
|
|
39
|
+
export declare const UPLOAD_BYTES_NO_TOKEN_ERROR = "no block token";
|
|
40
|
+
/** One admitted upload in the window: when, and how many bytes. */
|
|
41
|
+
export type UploadBytesWindowEntry = {
|
|
42
|
+
at: number;
|
|
43
|
+
size: number;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Which `OPEN_IMAGE_UPLOAD` variant a raw payload is, by the host's predicate
|
|
47
|
+
* (`resolveImageUploadBytes`). `none`: no `bytes` (null or absent), so the
|
|
48
|
+
* picker paths run unchanged. `invalid`: `bytes` is not an `ArrayBuffer` (a
|
|
49
|
+
* typed-array view is refused, not unwrapped), is empty, or comes with
|
|
50
|
+
* `purpose: 'generationSource'`.
|
|
51
|
+
*/
|
|
52
|
+
export declare function resolveUploadBytesRequest(raw: {
|
|
53
|
+
bytes?: unknown;
|
|
54
|
+
purpose?: unknown;
|
|
55
|
+
filename?: unknown;
|
|
56
|
+
}): {
|
|
57
|
+
kind: 'none';
|
|
58
|
+
} | {
|
|
59
|
+
kind: 'invalid';
|
|
60
|
+
} | {
|
|
61
|
+
kind: 'bytes';
|
|
62
|
+
bytes: ArrayBuffer;
|
|
63
|
+
filename?: string;
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* The host's admission decision (`processUploadBytes`), in its order:
|
|
67
|
+
* 1. the per-file cap, FIRST, so an over-cap file is always too large and
|
|
68
|
+
* never `busy` (a retry could never succeed);
|
|
69
|
+
* 2. the window, NOT recording, so a full window refuses `busy` before any
|
|
70
|
+
* sniffing;
|
|
71
|
+
* 3. PNG / WebP / JPEG by magic bytes;
|
|
72
|
+
* 4. record the upload in the window. Only an upload that will run counts.
|
|
73
|
+
* Returns the pruned window to store back.
|
|
74
|
+
*/
|
|
75
|
+
export declare function admitUploadBytes(req: {
|
|
76
|
+
bytes: ArrayBuffer;
|
|
77
|
+
filename?: string;
|
|
78
|
+
}, recent: readonly UploadBytesWindowEntry[], now: number): {
|
|
79
|
+
result: {
|
|
80
|
+
ok: true;
|
|
81
|
+
type: SniffedImageType;
|
|
82
|
+
filename: string;
|
|
83
|
+
} | {
|
|
84
|
+
ok: false;
|
|
85
|
+
error: string;
|
|
86
|
+
};
|
|
87
|
+
recent: UploadBytesWindowEntry[];
|
|
88
|
+
};
|
|
89
|
+
//# sourceMappingURL=uploadBytes.d.ts.map
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `bytes` variant of `OPEN_IMAGE_UPLOAD`: the strings both sides of the
|
|
3
|
+
* bridge agree on, and the mock host's copy of the host's admission rules.
|
|
4
|
+
*
|
|
5
|
+
* The contract is civitai/civitai#5639 (`imageUploadBytes.ts` in the host). The
|
|
6
|
+
* host owns the real rules. This copy exists so `createMockHost` (and so
|
|
7
|
+
* `dev:harness` and every block test) accepts and refuses the same buffers
|
|
8
|
+
* production does. Keep it exactly as narrow as the contract: a mock that
|
|
9
|
+
* accepts a GIF teaches a block that GIFs upload.
|
|
10
|
+
*/
|
|
11
|
+
import { forceSaveBytesExtension, sanitizeSaveBytesFilename, sniffImageBytes, } from './saveBytes.js';
|
|
12
|
+
/**
|
|
13
|
+
* The cap on one `bytes` upload: 40 MiB of raw bytes (`ArrayBuffer.byteLength`).
|
|
14
|
+
*
|
|
15
|
+
* 🔴 The host spells its OWN copy of this number (civitai/civitai
|
|
16
|
+
* `BLOCK_IMAGE_MAX_BYTES`, the image pipeline's server cap), and the two MUST
|
|
17
|
+
* match. Nothing in this repository can read the host's value, so a drift is
|
|
18
|
+
* invisible here until a block hits the host's refusal on a buffer this hook let
|
|
19
|
+
* through, or the hook refuses one the host would have accepted. Kept local to
|
|
20
|
+
* this package (not exported from `@civitai/app-sdk`) so the hook needs no newer
|
|
21
|
+
* app-sdk peer.
|
|
22
|
+
*/
|
|
23
|
+
export const UPLOAD_BYTES_MAX_BYTES = 40 * 1024 * 1024;
|
|
24
|
+
/** The host's rolling window: at most this many admitted uploads… */
|
|
25
|
+
export const UPLOAD_BYTES_MAX_PER_WINDOW = 3;
|
|
26
|
+
/** …and this many admitted bytes… */
|
|
27
|
+
export const UPLOAD_BYTES_MAX_BYTES_PER_WINDOW = 2 * UPLOAD_BYTES_MAX_BYTES;
|
|
28
|
+
/** …per this many milliseconds, per mounted page host. */
|
|
29
|
+
export const UPLOAD_BYTES_WINDOW_MS = 60_000;
|
|
30
|
+
/** Not a non-empty `ArrayBuffer`, or `purpose: 'generationSource'` with `bytes`. */
|
|
31
|
+
export const UPLOAD_BYTES_INVALID_ERROR = 'invalid image-upload request';
|
|
32
|
+
/** Over {@link UPLOAD_BYTES_MAX_BYTES}. Also the hook's own pre-send refusal. */
|
|
33
|
+
export const UPLOAD_BYTES_TOO_LARGE_ERROR = 'file exceeds the maximum upload size';
|
|
34
|
+
/** Not PNG / WebP / JPEG by magic bytes. */
|
|
35
|
+
export const UPLOAD_BYTES_TYPE_NOT_ALLOWED_ERROR = 'file type is not allowed';
|
|
36
|
+
/** The host's rolling window is full. Retryable. */
|
|
37
|
+
export const UPLOAD_BYTES_BUSY_ERROR = 'busy';
|
|
38
|
+
/** The host holds no block token. */
|
|
39
|
+
export const UPLOAD_BYTES_NO_TOKEN_ERROR = 'no block token';
|
|
40
|
+
/** `Image.name`'s bound in the host's persist input. */
|
|
41
|
+
const UPLOAD_BYTES_MAX_FILENAME_LENGTH = 255;
|
|
42
|
+
/**
|
|
43
|
+
* Which `OPEN_IMAGE_UPLOAD` variant a raw payload is, by the host's predicate
|
|
44
|
+
* (`resolveImageUploadBytes`). `none`: no `bytes` (null or absent), so the
|
|
45
|
+
* picker paths run unchanged. `invalid`: `bytes` is not an `ArrayBuffer` (a
|
|
46
|
+
* typed-array view is refused, not unwrapped), is empty, or comes with
|
|
47
|
+
* `purpose: 'generationSource'`.
|
|
48
|
+
*/
|
|
49
|
+
export function resolveUploadBytesRequest(raw) {
|
|
50
|
+
if (raw.bytes == null)
|
|
51
|
+
return { kind: 'none' };
|
|
52
|
+
if (!(raw.bytes instanceof ArrayBuffer) || raw.bytes.byteLength === 0)
|
|
53
|
+
return { kind: 'invalid' };
|
|
54
|
+
if (raw.purpose === 'generationSource')
|
|
55
|
+
return { kind: 'invalid' };
|
|
56
|
+
return {
|
|
57
|
+
kind: 'bytes',
|
|
58
|
+
bytes: raw.bytes,
|
|
59
|
+
filename: typeof raw.filename === 'string' ? raw.filename : undefined,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function windowHasRoom(live, size) {
|
|
63
|
+
const total = live.reduce((sum, e) => sum + e.size, 0);
|
|
64
|
+
return live.length < UPLOAD_BYTES_MAX_PER_WINDOW && total + size <= UPLOAD_BYTES_MAX_BYTES_PER_WINDOW;
|
|
65
|
+
}
|
|
66
|
+
/** The host's stored name: the save-bytes cleaning, the sniffed extension, then 255 characters. */
|
|
67
|
+
function uploadBytesFilename(raw, type) {
|
|
68
|
+
const name = forceSaveBytesExtension(sanitizeSaveBytesFilename(raw), type);
|
|
69
|
+
if (name.length <= UPLOAD_BYTES_MAX_FILENAME_LENGTH)
|
|
70
|
+
return name;
|
|
71
|
+
const ext = name.slice(name.lastIndexOf('.'));
|
|
72
|
+
return name.slice(0, UPLOAD_BYTES_MAX_FILENAME_LENGTH - ext.length) + ext;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The host's admission decision (`processUploadBytes`), in its order:
|
|
76
|
+
* 1. the per-file cap, FIRST, so an over-cap file is always too large and
|
|
77
|
+
* never `busy` (a retry could never succeed);
|
|
78
|
+
* 2. the window, NOT recording, so a full window refuses `busy` before any
|
|
79
|
+
* sniffing;
|
|
80
|
+
* 3. PNG / WebP / JPEG by magic bytes;
|
|
81
|
+
* 4. record the upload in the window. Only an upload that will run counts.
|
|
82
|
+
* Returns the pruned window to store back.
|
|
83
|
+
*/
|
|
84
|
+
export function admitUploadBytes(req, recent, now) {
|
|
85
|
+
const size = req.bytes.byteLength;
|
|
86
|
+
const live = recent.filter((e) => now - e.at < UPLOAD_BYTES_WINDOW_MS);
|
|
87
|
+
if (size > UPLOAD_BYTES_MAX_BYTES) {
|
|
88
|
+
return { result: { ok: false, error: UPLOAD_BYTES_TOO_LARGE_ERROR }, recent: live };
|
|
89
|
+
}
|
|
90
|
+
if (!windowHasRoom(live, size)) {
|
|
91
|
+
return { result: { ok: false, error: UPLOAD_BYTES_BUSY_ERROR }, recent: live };
|
|
92
|
+
}
|
|
93
|
+
const type = sniffImageBytes(new Uint8Array(req.bytes));
|
|
94
|
+
if (!type) {
|
|
95
|
+
return { result: { ok: false, error: UPLOAD_BYTES_TYPE_NOT_ALLOWED_ERROR }, recent: live };
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
result: { ok: true, type, filename: uploadBytesFilename(req.filename, type) },
|
|
99
|
+
recent: [...live, { at: now, size }],
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
//# sourceMappingURL=uploadBytes.js.map
|
|
@@ -657,6 +657,13 @@ export function isValidImageUploadResult(p) {
|
|
|
657
657
|
return false;
|
|
658
658
|
if (!isWireRequestIdShape(p.requestId))
|
|
659
659
|
return false;
|
|
660
|
+
// `error` (civitai/civitai#5639): a refused or failed `OPEN_IMAGE_UPLOAD
|
|
661
|
+
// { bytes }`. Free text (host strings plus forwarded server messages), so
|
|
662
|
+
// only its TYPE is checked. 🔴 Accepting it is load-bearing: a validator that
|
|
663
|
+
// dropped `{ requestId, error }` would leave `useUploadImageBytes()` waiting
|
|
664
|
+
// out its whole timeout on a refusal the host already sent.
|
|
665
|
+
if (p.error !== undefined && typeof p.error !== 'string')
|
|
666
|
+
return false;
|
|
660
667
|
// `selected` absent → cancelled upload (valid). When present, it must match
|
|
661
668
|
// one of the purpose-keyed shapes (moderated / generationSource / pending).
|
|
662
669
|
if (p.selected !== undefined) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@civitai/blocks-react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.67.0",
|
|
4
4
|
"description": "React hooks and iframe transport for Civitai Apps. Pairs with @civitai/app-sdk/blocks.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -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.62.0"
|
|
62
62
|
},
|
|
63
63
|
"publishConfig": {
|
|
64
64
|
"access": "public"
|