@civitai/blocks-react 0.65.2 → 0.67.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +170 -2
- package/dist/hooks/useCreatePostFromApp.d.ts +2 -1
- package/dist/hooks/useCreatePostFromApp.js +2 -1
- package/dist/hooks/useSaveImage.d.ts +53 -12
- package/dist/hooks/useSaveImage.js +31 -5
- package/dist/hooks/useUploadImageBytes.d.ts +84 -0
- package/dist/hooks/useUploadImageBytes.js +77 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/internal/liveHost.js +22 -4
- package/dist/internal/mockHost.d.ts +198 -38
- package/dist/internal/mockHost.js +197 -40
- package/dist/internal/saveBytes.d.ts +102 -0
- package/dist/internal/saveBytes.js +187 -0
- package/dist/internal/uploadBytes.d.ts +89 -0
- package/dist/internal/uploadBytes.js +102 -0
- package/dist/transport/validate.d.ts +2 -1
- package/dist/transport/validate.js +8 -1
- package/package.json +4 -4
|
@@ -41,6 +41,8 @@ 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';
|
|
45
|
+
import { UPLOAD_BYTES_INVALID_ERROR, admitUploadBytes, resolveUploadBytesRequest, } from './uploadBytes.js';
|
|
44
46
|
import { RUN_TRAINING_ERROR_CODES } from '../hooks/useRunTraining.js';
|
|
45
47
|
import { hostContextWithTheme } from '../transport/transport.js';
|
|
46
48
|
import { isRoutableRequestId } from '../transport/requestId.js';
|
|
@@ -247,6 +249,17 @@ const DEFAULT_IMAGE_UPLOAD = {
|
|
|
247
249
|
* — no imageId/nsfwLevel). `null` simulates a user-dismissed modal. The url is a
|
|
248
250
|
* Civitai-hosted image so a dev can feed it straight into a `sourceImage` body.
|
|
249
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
|
+
};
|
|
250
263
|
const DEFAULT_GENERATION_SOURCE_UPLOAD = {
|
|
251
264
|
url: 'https://image.civitai.com/mock/original=true/dev-generation-source.jpeg',
|
|
252
265
|
width: 1024,
|
|
@@ -279,8 +292,15 @@ const DEFAULT_GENERATION_SOURCE_UPLOAD = {
|
|
|
279
292
|
* `test/blockInitV2.test.ts` — they are one key set in four places.
|
|
280
293
|
*/
|
|
281
294
|
const DEFAULT_VIEWER = { id: 2, username: 'dev-viewer', signedIn: true };
|
|
295
|
+
/**
|
|
296
|
+
* The out-of-Buzz rejection's `error`. Production's text is whatever the
|
|
297
|
+
* orchestrator's 403 said (or the server's generic insufficient-funds default),
|
|
298
|
+
* so neither this string nor the real one is a contract — never branch on it.
|
|
299
|
+
*/
|
|
282
300
|
const INSUFFICIENT_BUZZ_ERROR = 'Insufficient Buzz to run this generation.';
|
|
283
301
|
const GENERIC_GEN_ERROR = 'Generation failed (simulated).';
|
|
302
|
+
/** Default `error` for `generation.submitCapRefusal: true`. */
|
|
303
|
+
const DEFAULT_SUBMIT_CAP_REFUSAL = 'Buzz spend cap reached (simulated).';
|
|
284
304
|
/** Default message for a simulated balance-read failure ({@link MockHostOptions.buzzBalanceError}). */
|
|
285
305
|
const DEFAULT_BUZZ_BALANCE_ERROR = 'balance unavailable';
|
|
286
306
|
/**
|
|
@@ -582,8 +602,9 @@ function jsonByteSize(value) {
|
|
|
582
602
|
* Reads the URL query toggles the gen-matrix dev harness uses, so a starter's
|
|
583
603
|
* dev harness keeps working with `?viewer/?consent/?fail/?theme/?pick/?pickCkpt`.
|
|
584
604
|
* 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.
|
|
605
|
+
* map onto the new scenario groups so a dev can flip out-of-Buzz /
|
|
606
|
+
* failures / latency without editing code. `?capRefusal=1` (or `=<text>`) sets
|
|
607
|
+
* {@link MockGenerationScenario.submitCapRefusal}.
|
|
587
608
|
*
|
|
588
609
|
* Returns a partial overlay applied ON TOP of explicit {@link MockHostOptions}
|
|
589
610
|
* (URL wins — it's the interactive dev knob). No-op outside a browser.
|
|
@@ -680,6 +701,13 @@ export function readMockHostUrlOptions(win = globalThis
|
|
|
680
701
|
if (failRate !== null && Number.isFinite(Number(failRate))) {
|
|
681
702
|
generation.failRate = Number(failRate);
|
|
682
703
|
}
|
|
704
|
+
// ?capRefusal=1|true → the default cap message; any other non-empty value is
|
|
705
|
+
// used as the server text verbatim.
|
|
706
|
+
const capRefusal = params.get('capRefusal');
|
|
707
|
+
if (capRefusal !== null && capRefusal.trim() !== '') {
|
|
708
|
+
generation.submitCapRefusal =
|
|
709
|
+
capRefusal === '1' || capRefusal === 'true' ? true : capRefusal;
|
|
710
|
+
}
|
|
683
711
|
if (Object.keys(generation).length > 0)
|
|
684
712
|
out.generation = generation;
|
|
685
713
|
if (Object.keys(buzz).length > 0)
|
|
@@ -736,7 +764,7 @@ export function readMockHostUrlOptions(win = globalThis
|
|
|
736
764
|
* const host = createMockHost({ generation: { failNext: 1, latencyMs: 1500 }, buzz: { balance: 5 } });
|
|
737
765
|
* const uninstall = host.install();
|
|
738
766
|
* // … drive the block / assertions …
|
|
739
|
-
* host.buzz.setBalance(0); //
|
|
767
|
+
* host.buzz.setBalance(0); // out of Buzz mid-session: submit() now rejects ('exception')
|
|
740
768
|
* host.setScenario({ generation: { failRate: 1 } });
|
|
741
769
|
* uninstall();
|
|
742
770
|
*/
|
|
@@ -813,6 +841,21 @@ export function createMockHost(options = {}) {
|
|
|
813
841
|
let trainingDatasetError = options.trainingDatasetError;
|
|
814
842
|
let trainingQuoteTotal = options.trainingQuoteTotal ?? DEFAULT_TRAINING_QUOTE_TOTAL;
|
|
815
843
|
let runTrainingError = options.runTrainingError;
|
|
844
|
+
// SAVE_IMAGE: a forced-refusal knob (see `saveImageError`).
|
|
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 ?? []);
|
|
816
859
|
let runTrainingCapRefusal = options.runTrainingCapRefusal;
|
|
817
860
|
// App-subqueue bridge data + forced free-text-error knob. `appWorkflows` is
|
|
818
861
|
// MUTABLE — CANCEL_APP_WORKFLOW marks the matching row canceled in place so a
|
|
@@ -1391,8 +1434,36 @@ export function createMockHost(options = {}) {
|
|
|
1391
1434
|
});
|
|
1392
1435
|
return;
|
|
1393
1436
|
}
|
|
1394
|
-
//
|
|
1395
|
-
//
|
|
1437
|
+
// Spend-CAP refusal (`generation.submitCapRefusal`). The server
|
|
1438
|
+
// checks its caps (per-call budget, daily, consent, per-app, dev
|
|
1439
|
+
// session) BEFORE it asks the orchestrator to debit, so this comes
|
|
1440
|
+
// ahead of the out-of-Buzz path below.
|
|
1441
|
+
if (gen.submitCapRefusal !== undefined) {
|
|
1442
|
+
dispatchToBlock({
|
|
1443
|
+
type: 'WORKFLOW_SUBMITTED',
|
|
1444
|
+
payload: {
|
|
1445
|
+
requestId,
|
|
1446
|
+
snapshot: {
|
|
1447
|
+
// The server stamps this `'failed'` sentinel on every cap
|
|
1448
|
+
// refusal (a refused submit has no orchestrator id).
|
|
1449
|
+
workflowId: 'failed',
|
|
1450
|
+
status: 'failed',
|
|
1451
|
+
// 🔴 THE PRICE IS LOAD-BEARING. The real server quotes the
|
|
1452
|
+
// cost it refused to charge at every cap exit, and `cost`
|
|
1453
|
+
// presence is what makes `submit()` RESOLVE this rather than
|
|
1454
|
+
// reject it as an errored submit
|
|
1455
|
+
// (civitai/civitai-app-starters#251). Do not drop it.
|
|
1456
|
+
cost: { total: cost },
|
|
1457
|
+
error: gen.submitCapRefusal === true
|
|
1458
|
+
? DEFAULT_SUBMIT_CAP_REFUSAL
|
|
1459
|
+
: gen.submitCapRefusal,
|
|
1460
|
+
},
|
|
1461
|
+
},
|
|
1462
|
+
});
|
|
1463
|
+
return;
|
|
1464
|
+
}
|
|
1465
|
+
// OUT-OF-BUZZ path: legacy failMode, the buzz scenario's force
|
|
1466
|
+
// flag, OR a simulated balance that can't cover this gen.
|
|
1396
1467
|
const balanceSimulated = typeof buzz.balance === 'number';
|
|
1397
1468
|
const insufficient = failMode === 'all' ||
|
|
1398
1469
|
failMode === 'insufficient' ||
|
|
@@ -1421,25 +1492,24 @@ export function createMockHost(options = {}) {
|
|
|
1421
1492
|
snapshot: {
|
|
1422
1493
|
workflowId: 'failed',
|
|
1423
1494
|
status: 'failed',
|
|
1424
|
-
//
|
|
1425
|
-
//
|
|
1426
|
-
//
|
|
1427
|
-
//
|
|
1428
|
-
//
|
|
1495
|
+
// 🔴 DELIBERATELY NO `cost` — the host's `failureSnapshot(err)`
|
|
1496
|
+
// shape exactly, so `submit()` REJECTS with code
|
|
1497
|
+
// `'exception'`. That is what production sends when a viewer
|
|
1498
|
+
// is out of Buzz. The orchestrator answers the real submit
|
|
1499
|
+
// with a 403. civitai's `throwOrchestratorFailure` maps that
|
|
1500
|
+
// to `throwInsufficientFundsError`, a thrown `BAD_REQUEST`.
|
|
1501
|
+
// `blocks.submitWorkflow`'s catch refunds the reservation and
|
|
1502
|
+
// rethrows it, and the iframe host's SUBMIT_WORKFLOW handler
|
|
1503
|
+
// catches the mutation's rejection into `failureSnapshot(err)`,
|
|
1504
|
+
// which has no `cost`.
|
|
1429
1505
|
//
|
|
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 },
|
|
1506
|
+
// This arm used to carry `cost` and RESOLVE, on the theory
|
|
1507
|
+
// that out-of-Buzz was one of the server's priced cap
|
|
1508
|
+
// refusals. It is not: those caps compare against a budget,
|
|
1509
|
+
// not the wallet, and only they quote a price. A block
|
|
1510
|
+
// tested against the old shape built a "resolved refusal →
|
|
1511
|
+
// top-up" flow that production never reaches. Do not add a
|
|
1512
|
+
// `cost` back; the priced arm is `submitCapRefusal` above.
|
|
1443
1513
|
error: INSUFFICIENT_BUZZ_ERROR,
|
|
1444
1514
|
},
|
|
1445
1515
|
},
|
|
@@ -1467,8 +1537,9 @@ export function createMockHost(options = {}) {
|
|
|
1467
1537
|
// failed". The backend DOES have generic transient submit
|
|
1468
1538
|
// failures — a fail-closed `unavailable` deny and a
|
|
1469
1539
|
// missing-price-quote exit — but it returns those as PRICED,
|
|
1470
|
-
// RESOLVING snapshots
|
|
1471
|
-
//
|
|
1540
|
+
// RESOLVING snapshots. Simulate those with `submitCapRefusal`
|
|
1541
|
+
// and the server's text; do not read this branch as covering
|
|
1542
|
+
// them.
|
|
1472
1543
|
error: GENERIC_GEN_ERROR,
|
|
1473
1544
|
},
|
|
1474
1545
|
},
|
|
@@ -1530,7 +1601,14 @@ export function createMockHost(options = {}) {
|
|
|
1530
1601
|
const newBalance = typeof buzz.balance === 'number' ? buzz.balance + 1000 : 1000;
|
|
1531
1602
|
if (typeof buzz.balance === 'number')
|
|
1532
1603
|
buzz.balance = newBalance;
|
|
1604
|
+
// A purchase ends EVERY forced out-of-Buzz state, not just the `buzz`
|
|
1605
|
+
// group's flag: `failMode: 'insufficient' | 'all'` is documented as
|
|
1606
|
+
// the same knob, and leaving it set made `?fail=insufficient` loop
|
|
1607
|
+
// reject → top-up → reject. `'some'` is a generic failure, not a
|
|
1608
|
+
// wallet, so a purchase leaves it alone.
|
|
1533
1609
|
buzz.insufficient = false;
|
|
1610
|
+
if (failMode === 'insufficient' || failMode === 'all')
|
|
1611
|
+
failMode = 'none';
|
|
1534
1612
|
dispatchToBlock({
|
|
1535
1613
|
type: 'BUZZ_PURCHASE_RESULT',
|
|
1536
1614
|
payload: { requestId, purchased: true, newBalance },
|
|
@@ -1787,6 +1865,32 @@ export function createMockHost(options = {}) {
|
|
|
1787
1865
|
});
|
|
1788
1866
|
return;
|
|
1789
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);
|
|
1790
1894
|
dispatchToBlock({
|
|
1791
1895
|
type: 'CREATE_POST_RESULT',
|
|
1792
1896
|
payload: { requestId, result: { ...createPostResult } },
|
|
@@ -1909,6 +2013,8 @@ export function createMockHost(options = {}) {
|
|
|
1909
2013
|
});
|
|
1910
2014
|
return;
|
|
1911
2015
|
}
|
|
2016
|
+
for (const id of publishImageIds)
|
|
2017
|
+
postableImageIds.add(id);
|
|
1912
2018
|
dispatchToBlock({
|
|
1913
2019
|
type: 'PUBLISH_RESULT',
|
|
1914
2020
|
payload: { requestId, result: { imageIds: [...publishImageIds] } },
|
|
@@ -1972,6 +2078,31 @@ export function createMockHost(options = {}) {
|
|
|
1972
2078
|
// • 'generationSource' → the UNSCANNED source { url, width, height }
|
|
1973
2079
|
// • 'display' (default / absent) → the MODERATED image
|
|
1974
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
|
+
}
|
|
1975
2106
|
const isGenerationSource = typed.payload?.purpose === 'generationSource';
|
|
1976
2107
|
// NON-BLOCKING display path (asyncScan:true): early-resolve with a
|
|
1977
2108
|
// PENDING handle, then stream the canned scan verdict on a later tick.
|
|
@@ -2430,23 +2561,42 @@ export function createMockHost(options = {}) {
|
|
|
2430
2561
|
return;
|
|
2431
2562
|
}
|
|
2432
2563
|
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
|
-
});
|
|
2564
|
+
// The mock host can't trigger a real browser download; it applies
|
|
2565
|
+
// the host's request-shape gate and, for `bytes`, the host's
|
|
2566
|
+
// CONTENT classifier (./saveBytes.ts), then acks so `useSaveImage()`
|
|
2567
|
+
// resolves. Exactly one of url / imageId / bytes must be present —
|
|
2568
|
+
// the same rule and string as the real host.
|
|
2569
|
+
if (!isRoutableRequestId(requestId))
|
|
2443
2570
|
return;
|
|
2571
|
+
const p = typed.payload ?? {};
|
|
2572
|
+
const saveReply = (r) => dispatchToBlock({ type: 'SAVE_IMAGE_RESULT', payload: { requestId, ...r } });
|
|
2573
|
+
// The host's exact shape predicate (./saveBytes.ts) — including that
|
|
2574
|
+
// a non-empty-ArrayBuffer `bytes` is required, and that ANY non-null
|
|
2575
|
+
// url/imageId beside a `bytes` is ambiguous.
|
|
2576
|
+
const kind = saveImageRequestKind(p);
|
|
2577
|
+
if (kind === 'invalid') {
|
|
2578
|
+
return saveReply({ ok: false, error: SAVE_IMAGE_INVALID_REQUEST_ERROR });
|
|
2444
2579
|
}
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2580
|
+
if (saveImageError !== undefined)
|
|
2581
|
+
return saveReply({ ok: false, error: saveImageError });
|
|
2582
|
+
if (kind === 'bytes' && p.bytes instanceof ArrayBuffer) {
|
|
2583
|
+
// The host's `prepareSaveBytes` (./saveBytes.ts): size cap, clean
|
|
2584
|
+
// the name, classify on the CLEANED name, force the extension.
|
|
2585
|
+
const prepared = prepareSaveBytes({
|
|
2586
|
+
bytes: p.bytes,
|
|
2587
|
+
filename: typeof p.filename === 'string' ? p.filename : undefined,
|
|
2588
|
+
});
|
|
2589
|
+
if (!prepared.ok)
|
|
2590
|
+
return saveReply(prepared);
|
|
2591
|
+
// A COPY, as production's structured-clone `postMessage` delivers
|
|
2592
|
+
// it — the block keeps (and may keep mutating) its own buffer.
|
|
2593
|
+
options.onSaveBytes?.({
|
|
2594
|
+
bytes: p.bytes.slice(0),
|
|
2595
|
+
mimeType: prepared.type,
|
|
2596
|
+
filename: prepared.filename,
|
|
2597
|
+
});
|
|
2598
|
+
}
|
|
2599
|
+
return saveReply({ ok: true });
|
|
2450
2600
|
}
|
|
2451
2601
|
case 'SET_USER_CHECKPOINT': {
|
|
2452
2602
|
// Persist the checkpoint override (mirrors the real host's
|
|
@@ -2620,6 +2770,13 @@ export function createMockHost(options = {}) {
|
|
|
2620
2770
|
trainingQuoteTotal = patch.trainingQuoteTotal;
|
|
2621
2771
|
if ('runTrainingError' in patch)
|
|
2622
2772
|
runTrainingError = patch.runTrainingError;
|
|
2773
|
+
if ('saveImageError' in patch)
|
|
2774
|
+
saveImageError = patch.saveImageError;
|
|
2775
|
+
if (patch.uploadImageBytesResult !== undefined) {
|
|
2776
|
+
uploadImageBytesResult = patch.uploadImageBytesResult;
|
|
2777
|
+
}
|
|
2778
|
+
if ('uploadImageBytesError' in patch)
|
|
2779
|
+
uploadImageBytesError = patch.uploadImageBytesError;
|
|
2623
2780
|
if ('runTrainingCapRefusal' in patch)
|
|
2624
2781
|
runTrainingCapRefusal = patch.runTrainingCapRefusal;
|
|
2625
2782
|
if (patch.appWorkflows !== undefined) {
|
|
@@ -0,0 +1,102 @@
|
|
|
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
|
+
/** 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;
|
|
59
|
+
/**
|
|
60
|
+
* Classify a `bytes` payload by CONTENT — the host's `classifySaveBytes`.
|
|
61
|
+
* `filename` is the only hint and must already be CLEANED
|
|
62
|
+
* ({@link sanitizeSaveBytesFilename}): a name ending `.json` (case-insensitive)
|
|
63
|
+
* chooses JSON over text/plain for bytes that already parse as JSON, and it can
|
|
64
|
+
* never make a binary payload acceptable. Returns `null` for anything the host
|
|
65
|
+
* refuses (`file type is not allowed`).
|
|
66
|
+
*/
|
|
67
|
+
export declare function classifySaveBytes(bytes: ArrayBuffer, filename?: string): SaveBytesType | null;
|
|
68
|
+
/**
|
|
69
|
+
* Clean a block-supplied `bytes` filename BEFORE classification reads its
|
|
70
|
+
* `.json` suffix. The name is not a URL, so every `?` and `#` is REPLACED with
|
|
71
|
+
* `_` (not cut at): `issue#42.json` → `issue_42.json` (still JSON),
|
|
72
|
+
* `data.json?v=2` → `data.json_v=2` (no longer `.json`, so text). Then: keep
|
|
73
|
+
* the text after the last `/` or `\`, collapse a repeated trailing extension,
|
|
74
|
+
* trim; absent or empty → `download`.
|
|
75
|
+
*/
|
|
76
|
+
export declare function sanitizeSaveBytesFilename(name: string | undefined | null): string;
|
|
77
|
+
/**
|
|
78
|
+
* Replace the extension of a cleaned name with the classified type's own. First
|
|
79
|
+
* deletes every control and format character (`\p{Cc}` / `\p{Cf}`: bidi
|
|
80
|
+
* overrides, zero-width marks), trims, empty → `download`; then replaces a
|
|
81
|
+
* trailing `.<1–5 ASCII letters/digits>` that follows a non-empty base, or
|
|
82
|
+
* appends the extension when there is none (`.env` → `.env.txt`,
|
|
83
|
+
* `notes.markdown` → `notes.markdown.txt`).
|
|
84
|
+
*/
|
|
85
|
+
export declare function forceSaveBytesExtension(filename: string, type: SaveBytesType): string;
|
|
86
|
+
/**
|
|
87
|
+
* Size-cap, clean the name, classify on the CLEANED name, and force the
|
|
88
|
+
* extension — the host's `prepareSaveBytes`, in its order. Every refusal is the
|
|
89
|
+
* reply's error string.
|
|
90
|
+
*/
|
|
91
|
+
export declare function prepareSaveBytes(req: {
|
|
92
|
+
bytes: ArrayBuffer;
|
|
93
|
+
filename?: string;
|
|
94
|
+
}): {
|
|
95
|
+
ok: true;
|
|
96
|
+
type: SaveBytesType;
|
|
97
|
+
filename: string;
|
|
98
|
+
} | {
|
|
99
|
+
ok: false;
|
|
100
|
+
error: string;
|
|
101
|
+
};
|
|
102
|
+
//# sourceMappingURL=saveBytes.d.ts.map
|
|
@@ -0,0 +1,187 @@
|
|
|
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
|
+
* 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
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Classify a `bytes` payload by CONTENT — the host's `classifySaveBytes`.
|
|
93
|
+
* `filename` is the only hint and must already be CLEANED
|
|
94
|
+
* ({@link sanitizeSaveBytesFilename}): a name ending `.json` (case-insensitive)
|
|
95
|
+
* chooses JSON over text/plain for bytes that already parse as JSON, and it can
|
|
96
|
+
* never make a binary payload acceptable. Returns `null` for anything the host
|
|
97
|
+
* refuses (`file type is not allowed`).
|
|
98
|
+
*/
|
|
99
|
+
export function classifySaveBytes(bytes, filename = '') {
|
|
100
|
+
const view = new Uint8Array(bytes);
|
|
101
|
+
const image = sniffImageBytes(view);
|
|
102
|
+
if (image)
|
|
103
|
+
return image;
|
|
104
|
+
if (view.includes(0))
|
|
105
|
+
return null;
|
|
106
|
+
let text;
|
|
107
|
+
try {
|
|
108
|
+
text = new TextDecoder('utf-8', { fatal: true }).decode(view);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return null; // not valid UTF-8 — random binary, a GIF, a zip, …
|
|
112
|
+
}
|
|
113
|
+
if (filename.toLowerCase().endsWith('.json')) {
|
|
114
|
+
try {
|
|
115
|
+
JSON.parse(text);
|
|
116
|
+
return 'application/json';
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// A `.json` name on text that does not parse saves as text, not a refusal.
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return 'text/plain';
|
|
123
|
+
}
|
|
124
|
+
/*
|
|
125
|
+
* 🔴 The three functions below are a LINE-FOR-LINE port of the host's bytes
|
|
126
|
+
* filename rules (civitai/civitai `src/components/AppBlocks/saveImageDownload.ts`:
|
|
127
|
+
* `sanitizeDownloadFilename`, `sanitizeSaveBytesFilename`,
|
|
128
|
+
* `forceSaveBytesExtension`, `prepareSaveBytes`, as of civitai/civitai#5616 head
|
|
129
|
+
* 745624bc). Change them only together with the host, and keep
|
|
130
|
+
* test/saveBytesParity.test.ts — the host's own cases — green.
|
|
131
|
+
*/
|
|
132
|
+
/** The host's shared url/imageId download-name cleaner, used here only with a string name. */
|
|
133
|
+
function sanitizeDownloadFilename(name) {
|
|
134
|
+
// Drop any directory component / traversal.
|
|
135
|
+
let clean = name.split(/[\\/]/).pop() ?? name;
|
|
136
|
+
// The host's query/fragment cut — a no-op for a bytes name, whose `?`/`#` are already replaced.
|
|
137
|
+
clean = clean.split('?')[0].split('#')[0];
|
|
138
|
+
// Collapse a duplicated trailing extension, preserving base dots.
|
|
139
|
+
const extMatch = clean.match(/\.([a-zA-Z0-9]{2,5})$/);
|
|
140
|
+
if (extMatch) {
|
|
141
|
+
const ext = extMatch[1];
|
|
142
|
+
clean = clean.replace(new RegExp(`(\\.${ext})+$`), `.${ext}`);
|
|
143
|
+
}
|
|
144
|
+
clean = clean.trim();
|
|
145
|
+
return clean.length > 0 ? clean : 'download';
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Clean a block-supplied `bytes` filename BEFORE classification reads its
|
|
149
|
+
* `.json` suffix. The name is not a URL, so every `?` and `#` is REPLACED with
|
|
150
|
+
* `_` (not cut at): `issue#42.json` → `issue_42.json` (still JSON),
|
|
151
|
+
* `data.json?v=2` → `data.json_v=2` (no longer `.json`, so text). Then: keep
|
|
152
|
+
* the text after the last `/` or `\`, collapse a repeated trailing extension,
|
|
153
|
+
* trim; absent or empty → `download`.
|
|
154
|
+
*/
|
|
155
|
+
export function sanitizeSaveBytesFilename(name) {
|
|
156
|
+
return sanitizeDownloadFilename((name ?? 'download').replace(/[?#]/g, '_'));
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Replace the extension of a cleaned name with the classified type's own. First
|
|
160
|
+
* deletes every control and format character (`\p{Cc}` / `\p{Cf}`: bidi
|
|
161
|
+
* overrides, zero-width marks), trims, empty → `download`; then replaces a
|
|
162
|
+
* trailing `.<1–5 ASCII letters/digits>` that follows a non-empty base, or
|
|
163
|
+
* appends the extension when there is none (`.env` → `.env.txt`,
|
|
164
|
+
* `notes.markdown` → `notes.markdown.txt`).
|
|
165
|
+
*/
|
|
166
|
+
export function forceSaveBytesExtension(filename, type) {
|
|
167
|
+
const cleaned = filename.replace(/[\p{Cc}\p{Cf}]/gu, '').trim();
|
|
168
|
+
const name = cleaned.length > 0 ? cleaned : 'download';
|
|
169
|
+
const m = name.match(/^(.+)\.([a-zA-Z0-9]{1,5})$/);
|
|
170
|
+
return `${m ? m[1] : name}${SAVE_BYTES_EXTENSION[type]}`;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Size-cap, clean the name, classify on the CLEANED name, and force the
|
|
174
|
+
* extension — the host's `prepareSaveBytes`, in its order. Every refusal is the
|
|
175
|
+
* reply's error string.
|
|
176
|
+
*/
|
|
177
|
+
export function prepareSaveBytes(req) {
|
|
178
|
+
if (req.bytes.byteLength > SAVE_BYTES_MAX_BYTES) {
|
|
179
|
+
return { ok: false, error: SAVE_BYTES_TOO_LARGE_ERROR };
|
|
180
|
+
}
|
|
181
|
+
const filename = sanitizeSaveBytesFilename(req.filename);
|
|
182
|
+
const type = classifySaveBytes(req.bytes, filename);
|
|
183
|
+
if (!type)
|
|
184
|
+
return { ok: false, error: SAVE_BYTES_TYPE_NOT_ALLOWED_ERROR };
|
|
185
|
+
return { ok: true, type, filename: forceSaveBytesExtension(filename, type) };
|
|
186
|
+
}
|
|
187
|
+
//# sourceMappingURL=saveBytes.js.map
|