@civitai/blocks-react 0.65.2 → 0.66.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 +61 -1
- package/dist/hooks/useSaveImage.d.ts +53 -12
- package/dist/hooks/useSaveImage.js +31 -5
- package/dist/internal/liveHost.js +7 -1
- package/dist/internal/mockHost.d.ts +122 -36
- package/dist/internal/mockHost.js +114 -40
- package/dist/internal/saveBytes.d.ts +93 -0
- package/dist/internal/saveBytes.js +175 -0
- package/dist/transport/validate.d.ts +1 -1
- package/dist/transport/validate.js +1 -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
|
|
@@ -1208,6 +1208,66 @@ if (img) {
|
|
|
1208
1208
|
}
|
|
1209
1209
|
```
|
|
1210
1210
|
|
|
1211
|
+
### `useSaveImage()`
|
|
1212
|
+
|
|
1213
|
+
Ask the host to download a file to the viewer's device. **A block cannot
|
|
1214
|
+
download anything itself.** Its sandbox lacks `allow-downloads`, and the
|
|
1215
|
+
validator refuses that token for unverified blocks, so an
|
|
1216
|
+
`<a href="blob:…" download>` click silently does nothing. 🔴 **This hook is the
|
|
1217
|
+
sanctioned way to deliver a file**, including one the block produced in the tab.
|
|
1218
|
+
Pass exactly ONE of three inputs, each gated differently by the host:
|
|
1219
|
+
|
|
1220
|
+
- **`{ url }`**: the block's own generation output. The host allowlists the
|
|
1221
|
+
origin to the civitai image CDN and refuses any other.
|
|
1222
|
+
- **`{ imageId }`**: an image from another user, read through the same
|
|
1223
|
+
per-viewer gate as `useGatedImages()`. A withheld image is never saved.
|
|
1224
|
+
- **`{ bytes }`**: an `ArrayBuffer` the block made in the tab, such as a healed
|
|
1225
|
+
image or a JSON sidecar. 🔴 **Page apps only.** The host classifies it by
|
|
1226
|
+
CONTENT. It accepts PNG, WebP and JPEG by magic bytes. Anything else must be
|
|
1227
|
+
valid UTF-8 with no NUL byte: it is saved as JSON when it parses and
|
|
1228
|
+
`filename` ends `.json` (any case) once the host has replaced each `?` and
|
|
1229
|
+
`#` with `_`, and as plain text otherwise. A GIF, a zip
|
|
1230
|
+
or other binary is refused with `file type is not allowed`. The host forces
|
|
1231
|
+
the extension from the classified type. The cap is 50 MiB, and the hook
|
|
1232
|
+
refuses a larger buffer before sending it: `file exceeds the maximum save
|
|
1233
|
+
size`. The buffer is copied, not transferred, so you can keep displaying it.
|
|
1234
|
+
|
|
1235
|
+
🔴 **On a host that predates the `bytes` variant, `saveImage({ bytes })` rejects
|
|
1236
|
+
with `invalid save-image request`.** The host also replies that when the input
|
|
1237
|
+
is not exactly one variant, or when `bytes` is empty or not an `ArrayBuffer`.
|
|
1238
|
+
Pass `await blob.arrayBuffer()`, not the `Blob` or a `Uint8Array`. The promise
|
|
1239
|
+
resolves once the host has started the download. On any refusal it rejects with
|
|
1240
|
+
the host's error string, which can also be `busy` when too many saves are in
|
|
1241
|
+
flight.
|
|
1242
|
+
|
|
1243
|
+
```tsx
|
|
1244
|
+
import { useSaveImage } from '@civitai/blocks-react';
|
|
1245
|
+
|
|
1246
|
+
const { saveImage } = useSaveImage();
|
|
1247
|
+
|
|
1248
|
+
// A file healed in the tab. It never leaves the device except to the viewer's disk.
|
|
1249
|
+
const healed: Blob = await healPngMetadata(file);
|
|
1250
|
+
await saveImage({ bytes: await healed.arrayBuffer(), filename: 'healed.png' });
|
|
1251
|
+
|
|
1252
|
+
// Its JSON sidecar. The .json filename makes valid JSON save as .json rather than .txt.
|
|
1253
|
+
const sidecar = new TextEncoder().encode(JSON.stringify(metadata, null, 2));
|
|
1254
|
+
try {
|
|
1255
|
+
await saveImage({ bytes: sidecar.slice().buffer, filename: 'healed.json' });
|
|
1256
|
+
} catch (err) {
|
|
1257
|
+
// 'invalid save-image request' here means this host cannot save bytes yet.
|
|
1258
|
+
setStatus(`Could not save the sidecar: ${(err as Error).message}`);
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
// The block's own generation output, or another user's image.
|
|
1262
|
+
await saveImage({ url: output.url, filename: 'render.png' });
|
|
1263
|
+
await saveImage({ imageId: cell.imageId });
|
|
1264
|
+
```
|
|
1265
|
+
|
|
1266
|
+
Under `createMockHost` / `Harness` the `bytes` variant is classified the same
|
|
1267
|
+
way. `onSaveBytes` reports what would have been downloaded, and `saveImageError`
|
|
1268
|
+
forces a refusal. Pass `saveImageError: 'invalid save-image request'` to test a
|
|
1269
|
+
host without the variant. `dev:live` refuses every save.
|
|
1270
|
+
|
|
1211
1271
|
### `useGenerationResources()`
|
|
1212
1272
|
|
|
1213
1273
|
Rehydrate a saved set of generation resources by version id — WITHOUT re-opening
|
|
@@ -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
|
}, []);
|
|
@@ -1640,8 +1640,14 @@ export function createLiveHost(options) {
|
|
|
1640
1640
|
// • `imageId` — the host resolves it through the SAME per-viewer
|
|
1641
1641
|
// gated read that backs GET_IMAGES_BY_IDS, so a withheld or
|
|
1642
1642
|
// above-ceiling image can never be coerced into a download.
|
|
1643
|
+
// • `bytes` (#583) — refused here too. Its gate (content
|
|
1644
|
+
// classification, the cap) is reproducible, and `createMockHost`
|
|
1645
|
+
// reproduces it; but a dev:live "download" would be this
|
|
1646
|
+
// harness's tab saving a file, which proves nothing about the
|
|
1647
|
+
// production host's page-only rule or its top-frame download.
|
|
1648
|
+
// One honest refusal for every variant keeps this branch simple.
|
|
1643
1649
|
//
|
|
1644
|
-
// This harness has NEITHER
|
|
1650
|
+
// This harness has NEITHER of the url / imageId gates. The allowlist is the production
|
|
1645
1651
|
// host's, not this SDK's — there is no origin set here to check
|
|
1646
1652
|
// against — so a dev-side "download it anyway" would accept URLs
|
|
1647
1653
|
// production refuses and let a block ship having never once handled
|
|
@@ -40,14 +40,17 @@
|
|
|
40
40
|
import { type BlockContext, type BlockResourceInfo, type BlockResourcePickerType, type BlockUploadedImageInfo, type BlockGenerationSourceImageInfo, type BuzzAccountType, type BlockBuzzTransaction, type BlockBuzzAccount, type BlockDailyCompensationResource, type BlockViewer, type BlockWildcardPack, type BlockWildcardPackErrorCode, type AppWorkflow, type BlockPublishedModel, type BlockGatedImage, type BlockCollectionFollowErrorCode, type BlockCreatePostHostError, type BlockCreatePostResult, type BlockPrepareTrainingDatasetHostError, type BlockRunTrainingHostError, type BlockTrainingRejectionReason, type ColorDomain, type SharedStorageValue, type Theme, type ViewerInfo, type WorkflowBody } from '@civitai/app-sdk/blocks';
|
|
41
41
|
/**
|
|
42
42
|
* How submits behave. `'none'` = everything succeeds; `'all'` / `'insufficient'`
|
|
43
|
-
* = every submit
|
|
44
|
-
*
|
|
43
|
+
* = every submit fails as if the viewer were OUT OF BUZZ, until an
|
|
44
|
+
* `OPEN_BUZZ_PURCHASE` resets the mode to `'none'`; `'some'` = ~1 in 3
|
|
45
|
+
* submits fail with a generic error.
|
|
45
46
|
*
|
|
46
|
-
* 🔴
|
|
47
|
-
* `'
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
47
|
+
* 🔴 EVERY FAILING MODE **REJECTS** with `WorkflowSubmitError` code
|
|
48
|
+
* `'exception'` — each emits the host's cost-less `failureSnapshot(err)`. That
|
|
49
|
+
* includes `'all'`/`'insufficient'`, which until this was fixed RESOLVED a
|
|
50
|
+
* priced `failed` snapshot. Production never does that for an empty wallet: the
|
|
51
|
+
* orchestrator refuses the debit, the server throws, and the host replies with
|
|
52
|
+
* `failureSnapshot(err)` (no `cost`). See {@link MockBuzzScenario}. A priced,
|
|
53
|
+
* RESOLVING refusal is a spend CAP — {@link MockGenerationScenario.submitCapRefusal}.
|
|
51
54
|
*/
|
|
52
55
|
export type MockHostFailMode = 'none' | 'some' | 'all' | 'insufficient';
|
|
53
56
|
/**
|
|
@@ -152,15 +155,15 @@ export interface MockGenerationScenario {
|
|
|
152
155
|
* error }` with **no `cost`**. `useBuzzWorkflow().submit()` rejects with a
|
|
153
156
|
* `WorkflowSubmitError` whose `code` is `'exception'`.
|
|
154
157
|
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
158
|
+
* This knob was added for civitai/civitai-app-starters#251, when no other submit
|
|
159
|
+
* knob produced a rejection at all. The out-of-Buzz knobs
|
|
160
|
+
* ({@link MockBuzzScenario}) now emit this same shape, because production does;
|
|
161
|
+
* this one stays as the way to inject an arbitrary server message with the
|
|
162
|
+
* wallet untouched. The `failEstimate` knob is the estimate-side twin of this
|
|
163
|
+
* one.
|
|
161
164
|
*
|
|
162
|
-
* Checked FIRST, before the disallowed-account /
|
|
163
|
-
* paths: a host-side throw pre-empts every server-side decision.
|
|
165
|
+
* Checked FIRST, before the disallowed-account / spend-cap / out-of-Buzz /
|
|
166
|
+
* generic paths: a host-side throw pre-empts every server-side decision.
|
|
164
167
|
*
|
|
165
168
|
* Default: unset (submits behave normally).
|
|
166
169
|
*/
|
|
@@ -171,6 +174,28 @@ export interface MockGenerationScenario {
|
|
|
171
174
|
* check how your block's developer-facing error surface renders one.
|
|
172
175
|
*/
|
|
173
176
|
failSubmitExceptionMessage?: string;
|
|
177
|
+
/**
|
|
178
|
+
* Make every SUBMIT come back as a server SPEND-CAP refusal — the priced,
|
|
179
|
+
* RESOLVING shape `{ workflowId:'failed', status:'failed', cost:{ total }, error }`,
|
|
180
|
+
* where `total` is this generation's cost. `useBuzzWorkflow().submit()`
|
|
181
|
+
* RESOLVES it (no rejection); the block reads `status === 'failed'`.
|
|
182
|
+
*
|
|
183
|
+
* This is what the real submit returns when a cap stops the run before
|
|
184
|
+
* anything is spent: the app's per-generation budget, the viewer's daily (or
|
|
185
|
+
* private-run) cap, the per-app consent budget, the app's spend or rate limit,
|
|
186
|
+
* or a dev-session cap. 🔴 **Buying Buzz fixes none of them**, so a block must
|
|
187
|
+
* not offer a top-up for this reply. Running OUT of Buzz is a different shape —
|
|
188
|
+
* a rejection; see {@link MockBuzzScenario}.
|
|
189
|
+
*
|
|
190
|
+
* Pass the server's text (e.g. `'insufficient buzz budget: estimate 600 exceeds
|
|
191
|
+
* budget 200'`), or `true` for a default message. Checked after the
|
|
192
|
+
* disallowed-account path and before the out-of-Buzz path, matching the real
|
|
193
|
+
* ordering: the caps are checked before the orchestrator is asked to debit.
|
|
194
|
+
* Live-tunable via `setScenario({ generation: { submitCapRefusal } })`; set it
|
|
195
|
+
* to `undefined` there to clear it. URL: `?capRefusal=1` (or `=<text>`).
|
|
196
|
+
* Default: unset.
|
|
197
|
+
*/
|
|
198
|
+
submitCapRefusal?: string | true;
|
|
174
199
|
/** A single result image url (or `(body) => url`). */
|
|
175
200
|
image?: ImageSpec;
|
|
176
201
|
/**
|
|
@@ -248,24 +273,43 @@ export declare const MOCK_TRAINING_DATASET_GONE_ERROR = "training dataset not fo
|
|
|
248
273
|
export declare const MOCK_TRAINING_NONE_ELIGIBLE_ERROR = "none of the requested images can be used for training";
|
|
249
274
|
export declare const MOCK_TRAINING_NONE_IMPORTED_ERROR = "none of the requested images could be prepared for training";
|
|
250
275
|
/**
|
|
251
|
-
* BUZZ scenario controls — simulate a
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
276
|
+
* BUZZ scenario controls — simulate a viewer who runs out of Buzz. The mock host
|
|
277
|
+
* treats `balance` as a spendable wallet: each succeeding generation DEBITS its
|
|
278
|
+
* cost, a submit whose cost would exceed the remaining balance fails as
|
|
279
|
+
* OUT OF BUZZ, and `OPEN_BUZZ_PURCHASE` REFILLS the balance.
|
|
280
|
+
*
|
|
281
|
+
* 🔴 OUT OF BUZZ **REJECTS** — `useBuzzWorkflow().submit()` throws a
|
|
282
|
+
* `WorkflowSubmitError` with code `'exception'`, the reason on
|
|
283
|
+
* `err.snapshot.error`. That is what production does. The orchestrator refuses
|
|
284
|
+
* the debit (HTTP 403), the server turns that into a thrown `BAD_REQUEST`, and
|
|
285
|
+
* the host replies with its cost-less `failureSnapshot(err)`. These knobs used to
|
|
286
|
+
* RESOLVE a priced `failed` snapshot instead, so a block tested against the mock
|
|
287
|
+
* could build a "resolved refusal → top-up" flow that production never reaches.
|
|
288
|
+
*
|
|
289
|
+
* Nothing structural tells this rejection apart from other `'exception'`s (the
|
|
290
|
+
* server message is upstream text, not a contract). To decide whether to offer
|
|
291
|
+
* a top-up, compare the viewer's spendable balance (`useBuzzBalance()`) with the
|
|
292
|
+
* quoted cost — never the error text. In the mock, that balance is
|
|
293
|
+
* {@link MockHostOptions.buzzBalance}, which is separate from `balance` below.
|
|
294
|
+
*
|
|
295
|
+
* A priced, RESOLVING refusal is a spend CAP, which a top-up cannot fix — see
|
|
296
|
+
* {@link MockGenerationScenario.submitCapRefusal}.
|
|
256
297
|
*/
|
|
257
298
|
export interface MockBuzzScenario {
|
|
258
299
|
/**
|
|
259
300
|
* Simulated spendable balance. When set, generations debit against it and a
|
|
260
|
-
* gen that would exceed it
|
|
261
|
-
* `undefined`, balance is NOT simulated (back-compat: only the legacy
|
|
262
|
-
* `failMode`
|
|
301
|
+
* gen that would exceed it is refused as out of Buzz (a REJECTION, see above).
|
|
302
|
+
* When `undefined`, balance is NOT simulated (back-compat: only the legacy
|
|
303
|
+
* `failMode` and {@link insufficient} drive the out-of-Buzz path).
|
|
263
304
|
*/
|
|
264
305
|
balance?: number;
|
|
265
306
|
/**
|
|
266
|
-
* Force every submit down the
|
|
267
|
-
*
|
|
268
|
-
* insufficient
|
|
307
|
+
* Force every submit down the out-of-Buzz path regardless of balance — it
|
|
308
|
+
* REJECTS with code `'exception'` (see above). Equivalent to the legacy
|
|
309
|
+
* `failMode: 'insufficient'`; provided here so the path is reachable from the
|
|
310
|
+
* `buzz` group alone. `OPEN_BUZZ_PURCHASE` clears it — and clears
|
|
311
|
+
* `failMode: 'insufficient' | 'all'` too (back to `'none'`), so a top-up
|
|
312
|
+
* followed by a retry succeeds whichever knob forced the out-of-Buzz state.
|
|
269
313
|
*/
|
|
270
314
|
insufficient?: boolean;
|
|
271
315
|
}
|
|
@@ -423,8 +467,8 @@ export interface MockHostOptions {
|
|
|
423
467
|
consentGrantable?: boolean;
|
|
424
468
|
/**
|
|
425
469
|
* How submits behave. Default `'none'` (all succeed). See
|
|
426
|
-
* {@link MockHostFailMode} —
|
|
427
|
-
*
|
|
470
|
+
* {@link MockHostFailMode} — every failing mode REJECTS with code
|
|
471
|
+
* `'exception'`; `'all'`/`'insufficient'` model an out-of-Buzz viewer.
|
|
428
472
|
*/
|
|
429
473
|
failMode?: MockHostFailMode;
|
|
430
474
|
/**
|
|
@@ -476,8 +520,8 @@ export interface MockHostOptions {
|
|
|
476
520
|
*/
|
|
477
521
|
generation?: MockGenerationScenario;
|
|
478
522
|
/**
|
|
479
|
-
* BUZZ scenario: simulated balance + force-
|
|
480
|
-
* {@link MockBuzzScenario}.
|
|
523
|
+
* BUZZ scenario: simulated balance + force-out-of-Buzz. Both REJECT the
|
|
524
|
+
* submit, as production does. See {@link MockBuzzScenario}.
|
|
481
525
|
*/
|
|
482
526
|
buzz?: MockBuzzScenario;
|
|
483
527
|
/**
|
|
@@ -485,8 +529,9 @@ export interface MockHostOptions {
|
|
|
485
529
|
* block via the host-mediated `GET_BUZZ_BALANCE` → `BUZZ_BALANCE_RESULT`
|
|
486
530
|
* bridge (what the `useBuzzBalance` hook reads). Distinct from the
|
|
487
531
|
* {@link MockBuzzScenario.balance} spendable-wallet knob, which only drives
|
|
488
|
-
* the
|
|
489
|
-
*
|
|
532
|
+
* the out-of-Buzz SUBMIT rejection — this is the displayable per-pool balance,
|
|
533
|
+
* and the number a block should compare with the quoted cost to decide on a
|
|
534
|
+
* top-up. Set both when exercising that flow. Absent → {@link DEFAULT_BUZZ_BALANCE} (a plausible
|
|
490
535
|
* non-zero wallet, so a block shows a balance out of the box).
|
|
491
536
|
*/
|
|
492
537
|
buzzBalance?: MockBuzzBalance;
|
|
@@ -731,6 +776,46 @@ export interface MockHostOptions {
|
|
|
731
776
|
* `setScenario({ runTrainingCapRefusal: undefined })` clears it.
|
|
732
777
|
*/
|
|
733
778
|
runTrainingCapRefusal?: string;
|
|
779
|
+
/**
|
|
780
|
+
* Force `SAVE_IMAGE` to reply with this `error` instead of saving — any of
|
|
781
|
+
* the host's free-text refusals (`'image url is not allowed'`,
|
|
782
|
+
* `'image is not available'`, `'busy'`, …), or
|
|
783
|
+
* `'invalid save-image request'` to model a host that PREDATES the `bytes`
|
|
784
|
+
* variant (what production replies to `saveImage({ bytes })` until the host
|
|
785
|
+
* ships it). Applies to every variant; checked after the request-shape gate,
|
|
786
|
+
* so an invalid request still gets `invalid save-image request`. Absent →
|
|
787
|
+
* the save succeeds (subject to the `bytes` classification below).
|
|
788
|
+
* Live-tunable via {@link MockHost.setScenario}; `undefined` clears it.
|
|
789
|
+
*
|
|
790
|
+
* The `bytes` variant is classified exactly as the host's contract says
|
|
791
|
+
* (civitai/civitai-app-starters#583): PNG / WebP / JPEG by magic bytes, else
|
|
792
|
+
* UTF-8 text with no NUL (JSON when it parses and the CLEANED `filename` —
|
|
793
|
+
* `?`/`#` replaced with `_`, path dropped — ends `.json`, case-insensitive),
|
|
794
|
+
* else `file type is not allowed`; an empty buffer,
|
|
795
|
+
* `invalid save-image request`; over the host's byte cap,
|
|
796
|
+
* `file exceeds the maximum save size`. 🔴 What the mock cannot model: that
|
|
797
|
+
* the variant is PAGE-ONLY (it does not know which surface the block renders
|
|
798
|
+
* on; a slot host has no `SAVE_IMAGE` handler and rejects it with
|
|
799
|
+
* `unsupported on this host`),
|
|
800
|
+
* the host's concurrency and per-window rate limits (`busy` — force it
|
|
801
|
+
* here), and the real browser
|
|
802
|
+
* download. {@link onSaveBytes} reports what would have been saved.
|
|
803
|
+
*/
|
|
804
|
+
saveImageError?: string;
|
|
805
|
+
/**
|
|
806
|
+
* Called with each `bytes` save the mock ACCEPTS — the classified `mimeType`
|
|
807
|
+
* (one of `image/png`, `image/webp`, `image/jpeg`, `application/json`,
|
|
808
|
+
* `text/plain`), the download `filename` exactly as the host cleans it (the
|
|
809
|
+
* same character rules, then the extension forced from that type), and the
|
|
810
|
+
* `bytes` themselves.
|
|
811
|
+
* The mock cannot trigger a browser download, so this is the observable a
|
|
812
|
+
* test or harness asserts on.
|
|
813
|
+
*/
|
|
814
|
+
onSaveBytes?: (file: {
|
|
815
|
+
bytes: ArrayBuffer;
|
|
816
|
+
mimeType: string;
|
|
817
|
+
filename: string;
|
|
818
|
+
}) => void;
|
|
734
819
|
/**
|
|
735
820
|
* Buzz pools a `SUBMIT_WORKFLOW` must REJECT when named in `body.accountType`
|
|
736
821
|
* — simulates the real backend's content-rating clamp. The real host throws a
|
|
@@ -895,7 +980,7 @@ export interface MockHostOptions {
|
|
|
895
980
|
* {@link MockHost.setTheme}, which pushes a real `THEME_CHANGE` — the mock of
|
|
896
981
|
* the viewer toggling dark mode with the block already mounted.)
|
|
897
982
|
*/
|
|
898
|
-
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'>;
|
|
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'>;
|
|
899
984
|
/** Runtime Buzz-balance handle exposed on {@link MockHost.buzz}. */
|
|
900
985
|
export interface MockBuzzHandle {
|
|
901
986
|
/** Current simulated balance, or `undefined` when balance isn't simulated. */
|
|
@@ -941,8 +1026,9 @@ export declare function disallowedAccountError(accountType: BuzzAccountType): st
|
|
|
941
1026
|
* Reads the URL query toggles the gen-matrix dev harness uses, so a starter's
|
|
942
1027
|
* dev harness keeps working with `?viewer/?consent/?fail/?theme/?pick/?pickCkpt`.
|
|
943
1028
|
* Layer-1 additions: `?balance/?latency/?costPerGen/?failNext/?failRate/?seed`
|
|
944
|
-
* map onto the new scenario groups so a dev can flip
|
|
945
|
-
* failures / latency without editing code.
|
|
1029
|
+
* map onto the new scenario groups so a dev can flip out-of-Buzz /
|
|
1030
|
+
* failures / latency without editing code. `?capRefusal=1` (or `=<text>`) sets
|
|
1031
|
+
* {@link MockGenerationScenario.submitCapRefusal}.
|
|
946
1032
|
*
|
|
947
1033
|
* Returns a partial overlay applied ON TOP of explicit {@link MockHostOptions}
|
|
948
1034
|
* (URL wins — it's the interactive dev knob). No-op outside a browser.
|
|
@@ -987,7 +1073,7 @@ export declare function readMockHostUrlOptions(win?: (Window & typeof globalThis
|
|
|
987
1073
|
* const host = createMockHost({ generation: { failNext: 1, latencyMs: 1500 }, buzz: { balance: 5 } });
|
|
988
1074
|
* const uninstall = host.install();
|
|
989
1075
|
* // … drive the block / assertions …
|
|
990
|
-
* host.buzz.setBalance(0); //
|
|
1076
|
+
* host.buzz.setBalance(0); // out of Buzz mid-session: submit() now rejects ('exception')
|
|
991
1077
|
* host.setScenario({ generation: { failRate: 1 } });
|
|
992
1078
|
* uninstall();
|
|
993
1079
|
*/
|
|
@@ -41,6 +41,7 @@ import { APP_STORAGE_ERROR_REQUEST_FAILED, APP_STORAGE_ERROR_USER_QUOTA_EXCEEDED
|
|
|
41
41
|
import { consentUnavailablePayload, isKnownBlockScope, resolveUngrantableConsentNotice, } from './consent.js';
|
|
42
42
|
import { requiredStorageScope, storageResultType, storageScopeDeniedMessage, storageScopeDeniedPayload, } from './mockHostScopes.js';
|
|
43
43
|
import { governsIdempotencyKey, idempotencyKeyDeniedMessage, idempotencyKeyRefusal, } from './mockHostIdempotency.js';
|
|
44
|
+
import { SAVE_IMAGE_INVALID_REQUEST_ERROR, prepareSaveBytes, saveImageRequestKind, } from './saveBytes.js';
|
|
44
45
|
import { RUN_TRAINING_ERROR_CODES } from '../hooks/useRunTraining.js';
|
|
45
46
|
import { hostContextWithTheme } from '../transport/transport.js';
|
|
46
47
|
import { isRoutableRequestId } from '../transport/requestId.js';
|
|
@@ -279,8 +280,15 @@ const DEFAULT_GENERATION_SOURCE_UPLOAD = {
|
|
|
279
280
|
* `test/blockInitV2.test.ts` — they are one key set in four places.
|
|
280
281
|
*/
|
|
281
282
|
const DEFAULT_VIEWER = { id: 2, username: 'dev-viewer', signedIn: true };
|
|
283
|
+
/**
|
|
284
|
+
* The out-of-Buzz rejection's `error`. Production's text is whatever the
|
|
285
|
+
* orchestrator's 403 said (or the server's generic insufficient-funds default),
|
|
286
|
+
* so neither this string nor the real one is a contract — never branch on it.
|
|
287
|
+
*/
|
|
282
288
|
const INSUFFICIENT_BUZZ_ERROR = 'Insufficient Buzz to run this generation.';
|
|
283
289
|
const GENERIC_GEN_ERROR = 'Generation failed (simulated).';
|
|
290
|
+
/** Default `error` for `generation.submitCapRefusal: true`. */
|
|
291
|
+
const DEFAULT_SUBMIT_CAP_REFUSAL = 'Buzz spend cap reached (simulated).';
|
|
284
292
|
/** Default message for a simulated balance-read failure ({@link MockHostOptions.buzzBalanceError}). */
|
|
285
293
|
const DEFAULT_BUZZ_BALANCE_ERROR = 'balance unavailable';
|
|
286
294
|
/**
|
|
@@ -582,8 +590,9 @@ function jsonByteSize(value) {
|
|
|
582
590
|
* Reads the URL query toggles the gen-matrix dev harness uses, so a starter's
|
|
583
591
|
* dev harness keeps working with `?viewer/?consent/?fail/?theme/?pick/?pickCkpt`.
|
|
584
592
|
* Layer-1 additions: `?balance/?latency/?costPerGen/?failNext/?failRate/?seed`
|
|
585
|
-
* map onto the new scenario groups so a dev can flip
|
|
586
|
-
* failures / latency without editing code.
|
|
593
|
+
* map onto the new scenario groups so a dev can flip out-of-Buzz /
|
|
594
|
+
* failures / latency without editing code. `?capRefusal=1` (or `=<text>`) sets
|
|
595
|
+
* {@link MockGenerationScenario.submitCapRefusal}.
|
|
587
596
|
*
|
|
588
597
|
* Returns a partial overlay applied ON TOP of explicit {@link MockHostOptions}
|
|
589
598
|
* (URL wins — it's the interactive dev knob). No-op outside a browser.
|
|
@@ -680,6 +689,13 @@ export function readMockHostUrlOptions(win = globalThis
|
|
|
680
689
|
if (failRate !== null && Number.isFinite(Number(failRate))) {
|
|
681
690
|
generation.failRate = Number(failRate);
|
|
682
691
|
}
|
|
692
|
+
// ?capRefusal=1|true → the default cap message; any other non-empty value is
|
|
693
|
+
// used as the server text verbatim.
|
|
694
|
+
const capRefusal = params.get('capRefusal');
|
|
695
|
+
if (capRefusal !== null && capRefusal.trim() !== '') {
|
|
696
|
+
generation.submitCapRefusal =
|
|
697
|
+
capRefusal === '1' || capRefusal === 'true' ? true : capRefusal;
|
|
698
|
+
}
|
|
683
699
|
if (Object.keys(generation).length > 0)
|
|
684
700
|
out.generation = generation;
|
|
685
701
|
if (Object.keys(buzz).length > 0)
|
|
@@ -736,7 +752,7 @@ export function readMockHostUrlOptions(win = globalThis
|
|
|
736
752
|
* const host = createMockHost({ generation: { failNext: 1, latencyMs: 1500 }, buzz: { balance: 5 } });
|
|
737
753
|
* const uninstall = host.install();
|
|
738
754
|
* // … drive the block / assertions …
|
|
739
|
-
* host.buzz.setBalance(0); //
|
|
755
|
+
* host.buzz.setBalance(0); // out of Buzz mid-session: submit() now rejects ('exception')
|
|
740
756
|
* host.setScenario({ generation: { failRate: 1 } });
|
|
741
757
|
* uninstall();
|
|
742
758
|
*/
|
|
@@ -813,6 +829,8 @@ export function createMockHost(options = {}) {
|
|
|
813
829
|
let trainingDatasetError = options.trainingDatasetError;
|
|
814
830
|
let trainingQuoteTotal = options.trainingQuoteTotal ?? DEFAULT_TRAINING_QUOTE_TOTAL;
|
|
815
831
|
let runTrainingError = options.runTrainingError;
|
|
832
|
+
// SAVE_IMAGE: a forced-refusal knob (see `saveImageError`).
|
|
833
|
+
let saveImageError = options.saveImageError;
|
|
816
834
|
let runTrainingCapRefusal = options.runTrainingCapRefusal;
|
|
817
835
|
// App-subqueue bridge data + forced free-text-error knob. `appWorkflows` is
|
|
818
836
|
// MUTABLE — CANCEL_APP_WORKFLOW marks the matching row canceled in place so a
|
|
@@ -1391,8 +1409,36 @@ export function createMockHost(options = {}) {
|
|
|
1391
1409
|
});
|
|
1392
1410
|
return;
|
|
1393
1411
|
}
|
|
1394
|
-
//
|
|
1395
|
-
//
|
|
1412
|
+
// Spend-CAP refusal (`generation.submitCapRefusal`). The server
|
|
1413
|
+
// checks its caps (per-call budget, daily, consent, per-app, dev
|
|
1414
|
+
// session) BEFORE it asks the orchestrator to debit, so this comes
|
|
1415
|
+
// ahead of the out-of-Buzz path below.
|
|
1416
|
+
if (gen.submitCapRefusal !== undefined) {
|
|
1417
|
+
dispatchToBlock({
|
|
1418
|
+
type: 'WORKFLOW_SUBMITTED',
|
|
1419
|
+
payload: {
|
|
1420
|
+
requestId,
|
|
1421
|
+
snapshot: {
|
|
1422
|
+
// The server stamps this `'failed'` sentinel on every cap
|
|
1423
|
+
// refusal (a refused submit has no orchestrator id).
|
|
1424
|
+
workflowId: 'failed',
|
|
1425
|
+
status: 'failed',
|
|
1426
|
+
// 🔴 THE PRICE IS LOAD-BEARING. The real server quotes the
|
|
1427
|
+
// cost it refused to charge at every cap exit, and `cost`
|
|
1428
|
+
// presence is what makes `submit()` RESOLVE this rather than
|
|
1429
|
+
// reject it as an errored submit
|
|
1430
|
+
// (civitai/civitai-app-starters#251). Do not drop it.
|
|
1431
|
+
cost: { total: cost },
|
|
1432
|
+
error: gen.submitCapRefusal === true
|
|
1433
|
+
? DEFAULT_SUBMIT_CAP_REFUSAL
|
|
1434
|
+
: gen.submitCapRefusal,
|
|
1435
|
+
},
|
|
1436
|
+
},
|
|
1437
|
+
});
|
|
1438
|
+
return;
|
|
1439
|
+
}
|
|
1440
|
+
// OUT-OF-BUZZ path: legacy failMode, the buzz scenario's force
|
|
1441
|
+
// flag, OR a simulated balance that can't cover this gen.
|
|
1396
1442
|
const balanceSimulated = typeof buzz.balance === 'number';
|
|
1397
1443
|
const insufficient = failMode === 'all' ||
|
|
1398
1444
|
failMode === 'insufficient' ||
|
|
@@ -1421,25 +1467,24 @@ export function createMockHost(options = {}) {
|
|
|
1421
1467
|
snapshot: {
|
|
1422
1468
|
workflowId: 'failed',
|
|
1423
1469
|
status: 'failed',
|
|
1424
|
-
//
|
|
1425
|
-
//
|
|
1426
|
-
//
|
|
1427
|
-
//
|
|
1428
|
-
//
|
|
1470
|
+
// 🔴 DELIBERATELY NO `cost` — the host's `failureSnapshot(err)`
|
|
1471
|
+
// shape exactly, so `submit()` REJECTS with code
|
|
1472
|
+
// `'exception'`. That is what production sends when a viewer
|
|
1473
|
+
// is out of Buzz. The orchestrator answers the real submit
|
|
1474
|
+
// with a 403. civitai's `throwOrchestratorFailure` maps that
|
|
1475
|
+
// to `throwInsufficientFundsError`, a thrown `BAD_REQUEST`.
|
|
1476
|
+
// `blocks.submitWorkflow`'s catch refunds the reservation and
|
|
1477
|
+
// rethrows it, and the iframe host's SUBMIT_WORKFLOW handler
|
|
1478
|
+
// catches the mutation's rejection into `failureSnapshot(err)`,
|
|
1479
|
+
// which has no `cost`.
|
|
1429
1480
|
//
|
|
1430
|
-
//
|
|
1431
|
-
//
|
|
1432
|
-
//
|
|
1433
|
-
// the
|
|
1434
|
-
//
|
|
1435
|
-
//
|
|
1436
|
-
//
|
|
1437
|
-
// (civitai/civitai-app-starters#251). Omitting it here made
|
|
1438
|
-
// the mock's top-up path indistinguishable from a server
|
|
1439
|
-
// exception, so `submit()` would reject it and the top-up UX
|
|
1440
|
-
// would be unreachable in the harness that exists to
|
|
1441
|
-
// exercise it. Do not drop this field.
|
|
1442
|
-
cost: { total: cost },
|
|
1481
|
+
// This arm used to carry `cost` and RESOLVE, on the theory
|
|
1482
|
+
// that out-of-Buzz was one of the server's priced cap
|
|
1483
|
+
// refusals. It is not: those caps compare against a budget,
|
|
1484
|
+
// not the wallet, and only they quote a price. A block
|
|
1485
|
+
// tested against the old shape built a "resolved refusal →
|
|
1486
|
+
// top-up" flow that production never reaches. Do not add a
|
|
1487
|
+
// `cost` back; the priced arm is `submitCapRefusal` above.
|
|
1443
1488
|
error: INSUFFICIENT_BUZZ_ERROR,
|
|
1444
1489
|
},
|
|
1445
1490
|
},
|
|
@@ -1467,8 +1512,9 @@ export function createMockHost(options = {}) {
|
|
|
1467
1512
|
// failed". The backend DOES have generic transient submit
|
|
1468
1513
|
// failures — a fail-closed `unavailable` deny and a
|
|
1469
1514
|
// missing-price-quote exit — but it returns those as PRICED,
|
|
1470
|
-
// RESOLVING snapshots
|
|
1471
|
-
//
|
|
1515
|
+
// RESOLVING snapshots. Simulate those with `submitCapRefusal`
|
|
1516
|
+
// and the server's text; do not read this branch as covering
|
|
1517
|
+
// them.
|
|
1472
1518
|
error: GENERIC_GEN_ERROR,
|
|
1473
1519
|
},
|
|
1474
1520
|
},
|
|
@@ -1530,7 +1576,14 @@ export function createMockHost(options = {}) {
|
|
|
1530
1576
|
const newBalance = typeof buzz.balance === 'number' ? buzz.balance + 1000 : 1000;
|
|
1531
1577
|
if (typeof buzz.balance === 'number')
|
|
1532
1578
|
buzz.balance = newBalance;
|
|
1579
|
+
// A purchase ends EVERY forced out-of-Buzz state, not just the `buzz`
|
|
1580
|
+
// group's flag: `failMode: 'insufficient' | 'all'` is documented as
|
|
1581
|
+
// the same knob, and leaving it set made `?fail=insufficient` loop
|
|
1582
|
+
// reject → top-up → reject. `'some'` is a generic failure, not a
|
|
1583
|
+
// wallet, so a purchase leaves it alone.
|
|
1533
1584
|
buzz.insufficient = false;
|
|
1585
|
+
if (failMode === 'insufficient' || failMode === 'all')
|
|
1586
|
+
failMode = 'none';
|
|
1534
1587
|
dispatchToBlock({
|
|
1535
1588
|
type: 'BUZZ_PURCHASE_RESULT',
|
|
1536
1589
|
payload: { requestId, purchased: true, newBalance },
|
|
@@ -2430,23 +2483,42 @@ export function createMockHost(options = {}) {
|
|
|
2430
2483
|
return;
|
|
2431
2484
|
}
|
|
2432
2485
|
case 'SAVE_IMAGE': {
|
|
2433
|
-
// The mock host can't trigger a real browser download; it
|
|
2434
|
-
//
|
|
2435
|
-
//
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
if (
|
|
2439
|
-
dispatchToBlock({
|
|
2440
|
-
type: 'SAVE_IMAGE_RESULT',
|
|
2441
|
-
payload: { requestId, ok: false, error: 'INVALID_REQUEST' },
|
|
2442
|
-
});
|
|
2486
|
+
// The mock host can't trigger a real browser download; it applies
|
|
2487
|
+
// the host's request-shape gate and, for `bytes`, the host's
|
|
2488
|
+
// CONTENT classifier (./saveBytes.ts), then acks so `useSaveImage()`
|
|
2489
|
+
// resolves. Exactly one of url / imageId / bytes must be present —
|
|
2490
|
+
// the same rule and string as the real host.
|
|
2491
|
+
if (!isRoutableRequestId(requestId))
|
|
2443
2492
|
return;
|
|
2493
|
+
const p = typed.payload ?? {};
|
|
2494
|
+
const saveReply = (r) => dispatchToBlock({ type: 'SAVE_IMAGE_RESULT', payload: { requestId, ...r } });
|
|
2495
|
+
// The host's exact shape predicate (./saveBytes.ts) — including that
|
|
2496
|
+
// a non-empty-ArrayBuffer `bytes` is required, and that ANY non-null
|
|
2497
|
+
// url/imageId beside a `bytes` is ambiguous.
|
|
2498
|
+
const kind = saveImageRequestKind(p);
|
|
2499
|
+
if (kind === 'invalid') {
|
|
2500
|
+
return saveReply({ ok: false, error: SAVE_IMAGE_INVALID_REQUEST_ERROR });
|
|
2444
2501
|
}
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2502
|
+
if (saveImageError !== undefined)
|
|
2503
|
+
return saveReply({ ok: false, error: saveImageError });
|
|
2504
|
+
if (kind === 'bytes' && p.bytes instanceof ArrayBuffer) {
|
|
2505
|
+
// The host's `prepareSaveBytes` (./saveBytes.ts): size cap, clean
|
|
2506
|
+
// the name, classify on the CLEANED name, force the extension.
|
|
2507
|
+
const prepared = prepareSaveBytes({
|
|
2508
|
+
bytes: p.bytes,
|
|
2509
|
+
filename: typeof p.filename === 'string' ? p.filename : undefined,
|
|
2510
|
+
});
|
|
2511
|
+
if (!prepared.ok)
|
|
2512
|
+
return saveReply(prepared);
|
|
2513
|
+
// A COPY, as production's structured-clone `postMessage` delivers
|
|
2514
|
+
// it — the block keeps (and may keep mutating) its own buffer.
|
|
2515
|
+
options.onSaveBytes?.({
|
|
2516
|
+
bytes: p.bytes.slice(0),
|
|
2517
|
+
mimeType: prepared.type,
|
|
2518
|
+
filename: prepared.filename,
|
|
2519
|
+
});
|
|
2520
|
+
}
|
|
2521
|
+
return saveReply({ ok: true });
|
|
2450
2522
|
}
|
|
2451
2523
|
case 'SET_USER_CHECKPOINT': {
|
|
2452
2524
|
// Persist the checkpoint override (mirrors the real host's
|
|
@@ -2620,6 +2692,8 @@ export function createMockHost(options = {}) {
|
|
|
2620
2692
|
trainingQuoteTotal = patch.trainingQuoteTotal;
|
|
2621
2693
|
if ('runTrainingError' in patch)
|
|
2622
2694
|
runTrainingError = patch.runTrainingError;
|
|
2695
|
+
if ('saveImageError' in patch)
|
|
2696
|
+
saveImageError = patch.saveImageError;
|
|
2623
2697
|
if ('runTrainingCapRefusal' in patch)
|
|
2624
2698
|
runTrainingCapRefusal = patch.runTrainingCapRefusal;
|
|
2625
2699
|
if (patch.appWorkflows !== undefined) {
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `bytes` variant of `SAVE_IMAGE` — the strings both sides of the bridge
|
|
3
|
+
* agree on, and the mock host's copy of the host's CONTENT classifier.
|
|
4
|
+
*
|
|
5
|
+
* The contract is civitai/civitai-app-starters#583. The host owns the real
|
|
6
|
+
* classifier; this one exists so `createMockHost` (and so `dev:harness` and
|
|
7
|
+
* every block test) accepts and refuses the same buffers production does. Keep
|
|
8
|
+
* it exactly as narrow as the contract — a mock that accepts a GIF teaches a
|
|
9
|
+
* block that GIFs save.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* The cap on a `bytes` payload: 50 MiB of raw bytes (`ArrayBuffer.byteLength`,
|
|
13
|
+
* so a text or JSON payload is counted after UTF-8 encoding).
|
|
14
|
+
*
|
|
15
|
+
* 🔴 The host spells its OWN copy of this number (civitai/civitai,
|
|
16
|
+
* `saveImageDownload.ts`), and the two MUST match. Nothing in this repository
|
|
17
|
+
* can read the host's value, so a drift is invisible here until a block hits
|
|
18
|
+
* the host's refusal on a buffer this hook let through, or the hook refuses one
|
|
19
|
+
* the host would have saved. Kept local to this package (not exported from
|
|
20
|
+
* `@civitai/app-sdk`) so the hook needs no newer app-sdk peer.
|
|
21
|
+
*/
|
|
22
|
+
export declare const SAVE_BYTES_MAX_BYTES: number;
|
|
23
|
+
/** Host refusal for a buffer over `SAVE_BYTES_MAX_BYTES`. Also the hook's own pre-send refusal. */
|
|
24
|
+
export declare const SAVE_BYTES_TOO_LARGE_ERROR = "file exceeds the maximum save size";
|
|
25
|
+
/** Host refusal for bytes that classify as none of PNG / WebP / JPEG / UTF-8 text. */
|
|
26
|
+
export declare const SAVE_BYTES_TYPE_NOT_ALLOWED_ERROR = "file type is not allowed";
|
|
27
|
+
/**
|
|
28
|
+
* Host refusal for a request that is not exactly one of `url` / `imageId` /
|
|
29
|
+
* `bytes`, for a `bytes` that is not a non-empty `ArrayBuffer`, and what a host
|
|
30
|
+
* that predates the `bytes` variant replies to one.
|
|
31
|
+
*/
|
|
32
|
+
export declare const SAVE_IMAGE_INVALID_REQUEST_ERROR = "invalid save-image request";
|
|
33
|
+
/**
|
|
34
|
+
* Which `SAVE_IMAGE` variant a payload is, by the host's exact predicate
|
|
35
|
+
* (civitai/civitai `saveImageDownload.ts`, `resolveSaveImageRequest`). A
|
|
36
|
+
* non-null `bytes` wins the dispatch: ANY non-null `url` / `imageId` beside it
|
|
37
|
+
* (even `''` or `'5'`) is ambiguous and invalid, and `bytes` must be a
|
|
38
|
+
* non-empty `ArrayBuffer`. Without `bytes` (`null` or absent) exactly one of a
|
|
39
|
+
* non-empty string `url` / a positive-integer `imageId` must be present.
|
|
40
|
+
*/
|
|
41
|
+
export declare function saveImageRequestKind(p: {
|
|
42
|
+
url?: unknown;
|
|
43
|
+
imageId?: unknown;
|
|
44
|
+
bytes?: unknown;
|
|
45
|
+
}): 'url' | 'id' | 'bytes' | 'invalid';
|
|
46
|
+
/** The five types the host can classify a `bytes` payload as. */
|
|
47
|
+
export type SaveBytesType = 'image/png' | 'image/webp' | 'image/jpeg' | 'application/json' | 'text/plain';
|
|
48
|
+
/** The extension the host forces for each classified type. */
|
|
49
|
+
export declare const SAVE_BYTES_EXTENSION: Record<SaveBytesType, string>;
|
|
50
|
+
/**
|
|
51
|
+
* Classify a `bytes` payload by CONTENT — the host's `classifySaveBytes`.
|
|
52
|
+
* `filename` is the only hint and must already be CLEANED
|
|
53
|
+
* ({@link sanitizeSaveBytesFilename}): a name ending `.json` (case-insensitive)
|
|
54
|
+
* chooses JSON over text/plain for bytes that already parse as JSON, and it can
|
|
55
|
+
* never make a binary payload acceptable. Returns `null` for anything the host
|
|
56
|
+
* refuses (`file type is not allowed`).
|
|
57
|
+
*/
|
|
58
|
+
export declare function classifySaveBytes(bytes: ArrayBuffer, filename?: string): SaveBytesType | null;
|
|
59
|
+
/**
|
|
60
|
+
* Clean a block-supplied `bytes` filename BEFORE classification reads its
|
|
61
|
+
* `.json` suffix. The name is not a URL, so every `?` and `#` is REPLACED with
|
|
62
|
+
* `_` (not cut at): `issue#42.json` → `issue_42.json` (still JSON),
|
|
63
|
+
* `data.json?v=2` → `data.json_v=2` (no longer `.json`, so text). Then: keep
|
|
64
|
+
* the text after the last `/` or `\`, collapse a repeated trailing extension,
|
|
65
|
+
* trim; absent or empty → `download`.
|
|
66
|
+
*/
|
|
67
|
+
export declare function sanitizeSaveBytesFilename(name: string | undefined | null): string;
|
|
68
|
+
/**
|
|
69
|
+
* Replace the extension of a cleaned name with the classified type's own. First
|
|
70
|
+
* deletes every control and format character (`\p{Cc}` / `\p{Cf}`: bidi
|
|
71
|
+
* overrides, zero-width marks), trims, empty → `download`; then replaces a
|
|
72
|
+
* trailing `.<1–5 ASCII letters/digits>` that follows a non-empty base, or
|
|
73
|
+
* appends the extension when there is none (`.env` → `.env.txt`,
|
|
74
|
+
* `notes.markdown` → `notes.markdown.txt`).
|
|
75
|
+
*/
|
|
76
|
+
export declare function forceSaveBytesExtension(filename: string, type: SaveBytesType): string;
|
|
77
|
+
/**
|
|
78
|
+
* Size-cap, clean the name, classify on the CLEANED name, and force the
|
|
79
|
+
* extension — the host's `prepareSaveBytes`, in its order. Every refusal is the
|
|
80
|
+
* reply's error string.
|
|
81
|
+
*/
|
|
82
|
+
export declare function prepareSaveBytes(req: {
|
|
83
|
+
bytes: ArrayBuffer;
|
|
84
|
+
filename?: string;
|
|
85
|
+
}): {
|
|
86
|
+
ok: true;
|
|
87
|
+
type: SaveBytesType;
|
|
88
|
+
filename: string;
|
|
89
|
+
} | {
|
|
90
|
+
ok: false;
|
|
91
|
+
error: string;
|
|
92
|
+
};
|
|
93
|
+
//# sourceMappingURL=saveBytes.d.ts.map
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `bytes` variant of `SAVE_IMAGE` — the strings both sides of the bridge
|
|
3
|
+
* agree on, and the mock host's copy of the host's CONTENT classifier.
|
|
4
|
+
*
|
|
5
|
+
* The contract is civitai/civitai-app-starters#583. The host owns the real
|
|
6
|
+
* classifier; this one exists so `createMockHost` (and so `dev:harness` and
|
|
7
|
+
* every block test) accepts and refuses the same buffers production does. Keep
|
|
8
|
+
* it exactly as narrow as the contract — a mock that accepts a GIF teaches a
|
|
9
|
+
* block that GIFs save.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* The cap on a `bytes` payload: 50 MiB of raw bytes (`ArrayBuffer.byteLength`,
|
|
13
|
+
* so a text or JSON payload is counted after UTF-8 encoding).
|
|
14
|
+
*
|
|
15
|
+
* 🔴 The host spells its OWN copy of this number (civitai/civitai,
|
|
16
|
+
* `saveImageDownload.ts`), and the two MUST match. Nothing in this repository
|
|
17
|
+
* can read the host's value, so a drift is invisible here until a block hits
|
|
18
|
+
* the host's refusal on a buffer this hook let through, or the hook refuses one
|
|
19
|
+
* the host would have saved. Kept local to this package (not exported from
|
|
20
|
+
* `@civitai/app-sdk`) so the hook needs no newer app-sdk peer.
|
|
21
|
+
*/
|
|
22
|
+
export const SAVE_BYTES_MAX_BYTES = 50 * 1024 * 1024;
|
|
23
|
+
/** Host refusal for a buffer over `SAVE_BYTES_MAX_BYTES`. Also the hook's own pre-send refusal. */
|
|
24
|
+
export const SAVE_BYTES_TOO_LARGE_ERROR = 'file exceeds the maximum save size';
|
|
25
|
+
/** Host refusal for bytes that classify as none of PNG / WebP / JPEG / UTF-8 text. */
|
|
26
|
+
export const SAVE_BYTES_TYPE_NOT_ALLOWED_ERROR = 'file type is not allowed';
|
|
27
|
+
/**
|
|
28
|
+
* Host refusal for a request that is not exactly one of `url` / `imageId` /
|
|
29
|
+
* `bytes`, for a `bytes` that is not a non-empty `ArrayBuffer`, and what a host
|
|
30
|
+
* that predates the `bytes` variant replies to one.
|
|
31
|
+
*/
|
|
32
|
+
export const SAVE_IMAGE_INVALID_REQUEST_ERROR = 'invalid save-image request';
|
|
33
|
+
/**
|
|
34
|
+
* Which `SAVE_IMAGE` variant a payload is, by the host's exact predicate
|
|
35
|
+
* (civitai/civitai `saveImageDownload.ts`, `resolveSaveImageRequest`). A
|
|
36
|
+
* non-null `bytes` wins the dispatch: ANY non-null `url` / `imageId` beside it
|
|
37
|
+
* (even `''` or `'5'`) is ambiguous and invalid, and `bytes` must be a
|
|
38
|
+
* non-empty `ArrayBuffer`. Without `bytes` (`null` or absent) exactly one of a
|
|
39
|
+
* non-empty string `url` / a positive-integer `imageId` must be present.
|
|
40
|
+
*/
|
|
41
|
+
export function saveImageRequestKind(p) {
|
|
42
|
+
if (p.bytes != null) {
|
|
43
|
+
if (p.url != null || p.imageId != null)
|
|
44
|
+
return 'invalid';
|
|
45
|
+
if (!(p.bytes instanceof ArrayBuffer) || p.bytes.byteLength === 0)
|
|
46
|
+
return 'invalid';
|
|
47
|
+
return 'bytes';
|
|
48
|
+
}
|
|
49
|
+
const hasUrl = typeof p.url === 'string' && p.url.length > 0;
|
|
50
|
+
const hasId = typeof p.imageId === 'number' && Number.isInteger(p.imageId) && p.imageId > 0;
|
|
51
|
+
if (hasUrl === hasId)
|
|
52
|
+
return 'invalid';
|
|
53
|
+
return hasUrl ? 'url' : 'id';
|
|
54
|
+
}
|
|
55
|
+
/** The extension the host forces for each classified type. */
|
|
56
|
+
export const SAVE_BYTES_EXTENSION = {
|
|
57
|
+
'image/png': '.png',
|
|
58
|
+
'image/webp': '.webp',
|
|
59
|
+
'image/jpeg': '.jpg',
|
|
60
|
+
'application/json': '.json',
|
|
61
|
+
'text/plain': '.txt',
|
|
62
|
+
};
|
|
63
|
+
function startsWith(view, sig, offset = 0) {
|
|
64
|
+
if (view.length < offset + sig.length)
|
|
65
|
+
return false;
|
|
66
|
+
for (let i = 0; i < sig.length; i++) {
|
|
67
|
+
if (view[offset + i] !== sig[i])
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
const PNG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
73
|
+
const JPEG = [0xff, 0xd8, 0xff];
|
|
74
|
+
const RIFF = [0x52, 0x49, 0x46, 0x46]; // "RIFF"
|
|
75
|
+
const WEBP = [0x57, 0x45, 0x42, 0x50]; // "WEBP" at offset 8
|
|
76
|
+
/**
|
|
77
|
+
* Classify a `bytes` payload by CONTENT — the host's `classifySaveBytes`.
|
|
78
|
+
* `filename` is the only hint and must already be CLEANED
|
|
79
|
+
* ({@link sanitizeSaveBytesFilename}): a name ending `.json` (case-insensitive)
|
|
80
|
+
* chooses JSON over text/plain for bytes that already parse as JSON, and it can
|
|
81
|
+
* never make a binary payload acceptable. Returns `null` for anything the host
|
|
82
|
+
* refuses (`file type is not allowed`).
|
|
83
|
+
*/
|
|
84
|
+
export function classifySaveBytes(bytes, filename = '') {
|
|
85
|
+
const view = new Uint8Array(bytes);
|
|
86
|
+
if (startsWith(view, PNG))
|
|
87
|
+
return 'image/png';
|
|
88
|
+
if (startsWith(view, RIFF) && startsWith(view, WEBP, 8))
|
|
89
|
+
return 'image/webp';
|
|
90
|
+
if (startsWith(view, JPEG))
|
|
91
|
+
return 'image/jpeg';
|
|
92
|
+
if (view.includes(0))
|
|
93
|
+
return null;
|
|
94
|
+
let text;
|
|
95
|
+
try {
|
|
96
|
+
text = new TextDecoder('utf-8', { fatal: true }).decode(view);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return null; // not valid UTF-8 — random binary, a GIF, a zip, …
|
|
100
|
+
}
|
|
101
|
+
if (filename.toLowerCase().endsWith('.json')) {
|
|
102
|
+
try {
|
|
103
|
+
JSON.parse(text);
|
|
104
|
+
return 'application/json';
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// A `.json` name on text that does not parse saves as text, not a refusal.
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return 'text/plain';
|
|
111
|
+
}
|
|
112
|
+
/*
|
|
113
|
+
* 🔴 The three functions below are a LINE-FOR-LINE port of the host's bytes
|
|
114
|
+
* filename rules (civitai/civitai `src/components/AppBlocks/saveImageDownload.ts`:
|
|
115
|
+
* `sanitizeDownloadFilename`, `sanitizeSaveBytesFilename`,
|
|
116
|
+
* `forceSaveBytesExtension`, `prepareSaveBytes`, as of civitai/civitai#5616 head
|
|
117
|
+
* 745624bc). Change them only together with the host, and keep
|
|
118
|
+
* test/saveBytesParity.test.ts — the host's own cases — green.
|
|
119
|
+
*/
|
|
120
|
+
/** The host's shared url/imageId download-name cleaner, used here only with a string name. */
|
|
121
|
+
function sanitizeDownloadFilename(name) {
|
|
122
|
+
// Drop any directory component / traversal.
|
|
123
|
+
let clean = name.split(/[\\/]/).pop() ?? name;
|
|
124
|
+
// The host's query/fragment cut — a no-op for a bytes name, whose `?`/`#` are already replaced.
|
|
125
|
+
clean = clean.split('?')[0].split('#')[0];
|
|
126
|
+
// Collapse a duplicated trailing extension, preserving base dots.
|
|
127
|
+
const extMatch = clean.match(/\.([a-zA-Z0-9]{2,5})$/);
|
|
128
|
+
if (extMatch) {
|
|
129
|
+
const ext = extMatch[1];
|
|
130
|
+
clean = clean.replace(new RegExp(`(\\.${ext})+$`), `.${ext}`);
|
|
131
|
+
}
|
|
132
|
+
clean = clean.trim();
|
|
133
|
+
return clean.length > 0 ? clean : 'download';
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Clean a block-supplied `bytes` filename BEFORE classification reads its
|
|
137
|
+
* `.json` suffix. The name is not a URL, so every `?` and `#` is REPLACED with
|
|
138
|
+
* `_` (not cut at): `issue#42.json` → `issue_42.json` (still JSON),
|
|
139
|
+
* `data.json?v=2` → `data.json_v=2` (no longer `.json`, so text). Then: keep
|
|
140
|
+
* the text after the last `/` or `\`, collapse a repeated trailing extension,
|
|
141
|
+
* trim; absent or empty → `download`.
|
|
142
|
+
*/
|
|
143
|
+
export function sanitizeSaveBytesFilename(name) {
|
|
144
|
+
return sanitizeDownloadFilename((name ?? 'download').replace(/[?#]/g, '_'));
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Replace the extension of a cleaned name with the classified type's own. First
|
|
148
|
+
* deletes every control and format character (`\p{Cc}` / `\p{Cf}`: bidi
|
|
149
|
+
* overrides, zero-width marks), trims, empty → `download`; then replaces a
|
|
150
|
+
* trailing `.<1–5 ASCII letters/digits>` that follows a non-empty base, or
|
|
151
|
+
* appends the extension when there is none (`.env` → `.env.txt`,
|
|
152
|
+
* `notes.markdown` → `notes.markdown.txt`).
|
|
153
|
+
*/
|
|
154
|
+
export function forceSaveBytesExtension(filename, type) {
|
|
155
|
+
const cleaned = filename.replace(/[\p{Cc}\p{Cf}]/gu, '').trim();
|
|
156
|
+
const name = cleaned.length > 0 ? cleaned : 'download';
|
|
157
|
+
const m = name.match(/^(.+)\.([a-zA-Z0-9]{1,5})$/);
|
|
158
|
+
return `${m ? m[1] : name}${SAVE_BYTES_EXTENSION[type]}`;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Size-cap, clean the name, classify on the CLEANED name, and force the
|
|
162
|
+
* extension — the host's `prepareSaveBytes`, in its order. Every refusal is the
|
|
163
|
+
* reply's error string.
|
|
164
|
+
*/
|
|
165
|
+
export function prepareSaveBytes(req) {
|
|
166
|
+
if (req.bytes.byteLength > SAVE_BYTES_MAX_BYTES) {
|
|
167
|
+
return { ok: false, error: SAVE_BYTES_TOO_LARGE_ERROR };
|
|
168
|
+
}
|
|
169
|
+
const filename = sanitizeSaveBytesFilename(req.filename);
|
|
170
|
+
const type = classifySaveBytes(req.bytes, filename);
|
|
171
|
+
if (!type)
|
|
172
|
+
return { ok: false, error: SAVE_BYTES_TYPE_NOT_ALLOWED_ERROR };
|
|
173
|
+
return { ok: true, type, filename: forceSaveBytesExtension(filename, type) };
|
|
174
|
+
}
|
|
175
|
+
//# sourceMappingURL=saveBytes.js.map
|
|
@@ -474,7 +474,7 @@ export declare function isValidSharedReportResult(p: unknown): boolean;
|
|
|
474
474
|
/**
|
|
475
475
|
* Reply to `SAVE_IMAGE` (host download bridge). On success `ok` is a boolean;
|
|
476
476
|
* `error` is a free-text host-side failure (disallowed origin / withheld image
|
|
477
|
-
* / over-size / fetch failure). Uniform `{ ok, error }` contract (module
|
|
477
|
+
* / disallowed `bytes` type / over-size / fetch failure / `busy`). Uniform `{ ok, error }` contract (module
|
|
478
478
|
* header) — same shape as `isValidSharedReportResult`.
|
|
479
479
|
*/
|
|
480
480
|
export declare function isValidSaveImageResult(p: unknown): boolean;
|
|
@@ -1584,7 +1584,7 @@ export function isValidSharedReportResult(p) {
|
|
|
1584
1584
|
/**
|
|
1585
1585
|
* Reply to `SAVE_IMAGE` (host download bridge). On success `ok` is a boolean;
|
|
1586
1586
|
* `error` is a free-text host-side failure (disallowed origin / withheld image
|
|
1587
|
-
* / over-size / fetch failure). Uniform `{ ok, error }` contract (module
|
|
1587
|
+
* / disallowed `bytes` type / over-size / fetch failure / `busy`). Uniform `{ ok, error }` contract (module
|
|
1588
1588
|
* header) — same shape as `isValidSharedReportResult`.
|
|
1589
1589
|
*/
|
|
1590
1590
|
export function isValidSaveImageResult(p) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@civitai/blocks-react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.66.0",
|
|
4
4
|
"description": "React hooks and iframe transport for Civitai Apps. Pairs with @civitai/app-sdk/blocks.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"node": ">=20"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@civitai/
|
|
41
|
-
"@civitai/
|
|
40
|
+
"@civitai/components": "^0.9.3",
|
|
41
|
+
"@civitai/theme": "^0.5.2"
|
|
42
42
|
},
|
|
43
43
|
"comment-peerDependencies": "This floor is DERIVED, not chosen — the lowest published app-sdk that exports every symbol this package imports. Do not edit it without reading ./PEER_FLOOR.md (in-repo, unpublished) and tests/guards/blocks-react-peer-floor.test.mjs.",
|
|
44
44
|
"peerDependencies": {
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"typescript": "^5.9.2",
|
|
59
59
|
"vite": "^8.0.14",
|
|
60
60
|
"vitest": "^4.1.11",
|
|
61
|
-
"@civitai/app-sdk": "^0.
|
|
61
|
+
"@civitai/app-sdk": "^0.61.0"
|
|
62
62
|
},
|
|
63
63
|
"publishConfig": {
|
|
64
64
|
"access": "public"
|