@civitai/blocks-react 0.75.0 → 0.76.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.
@@ -0,0 +1,268 @@
1
+ /**
2
+ * `OPEN_MEDIA_UPLOAD`: the strings and limits both sides of the bridge agree
3
+ * on, the content sniffers, and the mock host's copy of the host's admission
4
+ * rules for BYTES mode.
5
+ *
6
+ * The host owns the real rules. This copy exists so `createMockHost` (and so
7
+ * `dev:harness` and every block test) accepts and refuses the same buffers
8
+ * production does, and so `useMediaUpload` can refuse locally what the host
9
+ * would refuse anyway. Keep it exactly as narrow as the contract: a mock that
10
+ * accepts a GIF as an image teaches a block that GIFs upload.
11
+ *
12
+ * 🔴 The host spells its OWN copy of every constant and string here, and the
13
+ * two MUST match. Nothing in this repository can read the host's values, so a
14
+ * drift is invisible here until a block hits a refusal the SDK did not
15
+ * predict. Kept local to this package (not exported from `@civitai/app-sdk`).
16
+ *
17
+ * WHAT THIS CANNOT MODEL: the host decodes the media before it uploads.
18
+ * Its decode-time refusals (`could not read this video file`,
19
+ * `could not read this audio file`, `this file has no video track`, an image
20
+ * too small to resize to at least 64 px, and upload failures) need a real
21
+ * decoder and network, so the mock never produces them.
22
+ */
23
+ import { sniffImageBytes } from './saveBytes.js';
24
+ /** The cap on one VIDEO or AUDIO upload: 64 MiB of raw bytes (the consumer-blob upload cap). */
25
+ export const MEDIA_UPLOAD_MAX_BYTES = 64 * 1024 * 1024;
26
+ /**
27
+ * The cap on one IMAGE upload: 24 MiB. Lower than {@link MEDIA_UPLOAD_MAX_BYTES}
28
+ * because the host decodes an image in full before it resizes it.
29
+ */
30
+ export const MEDIA_UPLOAD_IMAGE_MAX_BYTES = 24 * 1024 * 1024;
31
+ /** The bytes-mode rolling window: per this many milliseconds, per mounted page host… */
32
+ export const MEDIA_UPLOAD_WINDOW_MS = 60_000;
33
+ /** …at most this many admitted bytes uploads… */
34
+ export const MEDIA_UPLOAD_MAX_PER_WINDOW = 8;
35
+ /** …and this many admitted bytes. */
36
+ export const MEDIA_UPLOAD_MAX_BYTES_PER_WINDOW = 2 * MEDIA_UPLOAD_MAX_BYTES;
37
+ /** `bytes` is not a non-empty `ArrayBuffer`, or `mimeType` is present but not a string. */
38
+ export const MEDIA_UPLOAD_INVALID_ERROR = 'invalid media-upload request';
39
+ export const MEDIA_UPLOAD_KIND_ERROR = "kind must be 'image', 'video' or 'audio'";
40
+ export const MEDIA_UPLOAD_PURPOSE_ERROR = "purpose must be 'generationSource'";
41
+ /** Over {@link MEDIA_UPLOAD_MAX_BYTES} (video, audio). */
42
+ export const MEDIA_UPLOAD_TOO_LARGE_ERROR = 'file exceeds the 64 MB upload limit';
43
+ /** Over {@link MEDIA_UPLOAD_IMAGE_MAX_BYTES} (image). */
44
+ export const MEDIA_UPLOAD_IMAGE_TOO_LARGE_ERROR = 'file exceeds the 24 MB image upload limit';
45
+ /** Bytes mode only: the host holds no block token. Checked before the cap and the window. */
46
+ export const MEDIA_UPLOAD_NO_TOKEN_ERROR = 'no block token';
47
+ /** The sniffed content is not one of the requested kind's types. */
48
+ export const MEDIA_UPLOAD_TYPE_ERROR = {
49
+ image: 'images must be PNG, JPEG or WebP',
50
+ video: 'videos must be MP4 or WebM',
51
+ audio: 'audio must be MP3, WAV or WebM',
52
+ };
53
+ export const MEDIA_UPLOAD_MIME_MISMATCH_ERROR = "mimeType does not match the file's content";
54
+ /** The bytes-mode window is full. Retryable. */
55
+ export const MEDIA_UPLOAD_BUSY_ERROR = 'busy';
56
+ /** The kinds the bridge knows. */
57
+ export const MEDIA_UPLOAD_KINDS = ['image', 'video', 'audio'];
58
+ export function isMediaKind(v) {
59
+ return typeof v === 'string' && MEDIA_UPLOAD_KINDS.includes(v);
60
+ }
61
+ /** The per-file cap for a kind, and the refusal past it. */
62
+ export function mediaUploadCap(kind) {
63
+ return kind === 'image'
64
+ ? { maxBytes: MEDIA_UPLOAD_IMAGE_MAX_BYTES, error: MEDIA_UPLOAD_IMAGE_TOO_LARGE_ERROR }
65
+ : { maxBytes: MEDIA_UPLOAD_MAX_BYTES, error: MEDIA_UPLOAD_TOO_LARGE_ERROR };
66
+ }
67
+ function asciiAt(u8, text, offset = 0) {
68
+ if (u8.length < offset + text.length)
69
+ return false;
70
+ for (let i = 0; i < text.length; i++) {
71
+ if (u8[offset + i] !== text.charCodeAt(i))
72
+ return false;
73
+ }
74
+ return true;
75
+ }
76
+ /** WebM: the EBML magic, and the DocType `webm` within the first 64 bytes (generic Matroska is refused). */
77
+ function isWebm(u8) {
78
+ if (!(u8[0] === 0x1a && u8[1] === 0x45 && u8[2] === 0xdf && u8[3] === 0xa3))
79
+ return false;
80
+ const head = u8.subarray(0, 64);
81
+ for (let i = 0; i + 4 <= head.length; i++) {
82
+ if (asciiAt(head, 'webm', i))
83
+ return true;
84
+ }
85
+ return false;
86
+ }
87
+ /** MP4: `ftyp` at offset 4 of a buffer at least 12 bytes long. */
88
+ function isMp4(u8) {
89
+ return u8.length >= 12 && asciiAt(u8, 'ftyp', 4);
90
+ }
91
+ /** MP3: an ID3 tag, or an MPEG audio frame sync with a valid version and layer. */
92
+ function isMp3(u8) {
93
+ if (asciiAt(u8, 'ID3'))
94
+ return true;
95
+ if (u8.length < 2)
96
+ return false;
97
+ const b0 = u8[0];
98
+ const b1 = u8[1];
99
+ return b0 === 0xff && (b1 & 0xe0) === 0xe0 && ((b1 >> 1) & 0x03) !== 0 && ((b1 >> 3) & 0x03) !== 1;
100
+ }
101
+ /** WAV: `RIFF` at 0 and `WAVE` at 8. */
102
+ function isWav(u8) {
103
+ return asciiAt(u8, 'RIFF') && asciiAt(u8, 'WAVE', 8);
104
+ }
105
+ /**
106
+ * The content type of `bytes` WITHIN the requested kind, by magic bytes, or
107
+ * `null` when the content is none of that kind's types (a PNG sent as `audio`
108
+ * is `null`: a type refusal). WebM is `video/webm` under `video` and
109
+ * `audio/webm` under `audio`.
110
+ */
111
+ export function sniffMediaBytes(kind, u8) {
112
+ switch (kind) {
113
+ case 'image':
114
+ return sniffImageBytes(u8);
115
+ case 'video':
116
+ if (isMp4(u8))
117
+ return 'video/mp4';
118
+ if (isWebm(u8))
119
+ return 'video/webm';
120
+ return null;
121
+ case 'audio':
122
+ if (isMp3(u8))
123
+ return 'audio/mpeg';
124
+ if (isWav(u8))
125
+ return 'audio/wav';
126
+ if (isWebm(u8))
127
+ return 'audio/webm';
128
+ return null;
129
+ }
130
+ }
131
+ /** A declared MIME type as the host compares it: lowercased, `;params` cut, `audio/x-wav` read as `audio/wav`. */
132
+ export function normaliseMediaMimeType(mimeType) {
133
+ const base = mimeType.split(';')[0].trim().toLowerCase();
134
+ return base === 'audio/x-wav' ? 'audio/wav' : base;
135
+ }
136
+ function windowHasRoom(live, size) {
137
+ const total = live.reduce((sum, e) => sum + e.size, 0);
138
+ return live.length < MEDIA_UPLOAD_MAX_PER_WINDOW && total + size <= MEDIA_UPLOAD_MAX_BYTES_PER_WINDOW;
139
+ }
140
+ /**
141
+ * The host's decision for one `OPEN_MEDIA_UPLOAD`, in the host's order, from
142
+ * step 4 of the contract on (the caller has already dropped an unroutable
143
+ * `requestId`; review mode and readiness do not exist in the mock):
144
+ * 1. `kind` not known → kind error; `purpose` not `'generationSource'` →
145
+ * purpose error;
146
+ * 2. no `bytes` (absent or `null`) → PICKER mode (not windowed: a human
147
+ * picks);
148
+ * 3. `bytes` not a non-empty `ArrayBuffer`, or a non-string `mimeType` →
149
+ * invalid;
150
+ * 4. no block token (`opts.noToken`) → `no block token`, before the cap
151
+ * and the window;
152
+ * 5. over the kind's cap ({@link mediaUploadCap}: 24 MiB for an image,
153
+ * 64 MiB for video and audio) → too large, before the window, so an
154
+ * over-cap file is never `busy` (a retry could never succeed);
155
+ * 6. the window, NOT recording, so a full window refuses `busy` before any
156
+ * sniffing;
157
+ * 7. the sniffed content in the kind's set → else the kind's type error;
158
+ * 8. a present `mimeType` (normalised) equal to the sniffed type → else
159
+ * mismatch;
160
+ * 9. record the upload in the window. Only an upload that will run counts.
161
+ * Returns the pruned window to store back.
162
+ */
163
+ export function admitMediaUpload(raw, recent, now, opts = {}) {
164
+ const live = recent.filter((e) => now - e.at < MEDIA_UPLOAD_WINDOW_MS);
165
+ const refuse = (error) => ({ result: { ok: false, error }, recent: live });
166
+ if (!isMediaKind(raw.kind))
167
+ return refuse(MEDIA_UPLOAD_KIND_ERROR);
168
+ const kind = raw.kind;
169
+ if (raw.purpose !== 'generationSource')
170
+ return refuse(MEDIA_UPLOAD_PURPOSE_ERROR);
171
+ // `null` reads as absent, as `OPEN_IMAGE_UPLOAD`'s `bytes` does.
172
+ if (raw.bytes == null)
173
+ return { result: { ok: true, mode: 'picker', kind }, recent: live };
174
+ const bytes = raw.bytes;
175
+ if (!(bytes instanceof ArrayBuffer) || bytes.byteLength === 0)
176
+ return refuse(MEDIA_UPLOAD_INVALID_ERROR);
177
+ if (raw.mimeType !== undefined && typeof raw.mimeType !== 'string') {
178
+ return refuse(MEDIA_UPLOAD_INVALID_ERROR);
179
+ }
180
+ const size = bytes.byteLength;
181
+ if (opts.noToken)
182
+ return refuse(MEDIA_UPLOAD_NO_TOKEN_ERROR);
183
+ const cap = mediaUploadCap(kind);
184
+ if (size > cap.maxBytes)
185
+ return refuse(cap.error);
186
+ if (!windowHasRoom(live, size))
187
+ return refuse(MEDIA_UPLOAD_BUSY_ERROR);
188
+ const sniffed = sniffMediaBytes(kind, new Uint8Array(bytes));
189
+ if (!sniffed)
190
+ return refuse(MEDIA_UPLOAD_TYPE_ERROR[kind]);
191
+ if (typeof raw.mimeType === 'string' && normaliseMediaMimeType(raw.mimeType) !== sniffed) {
192
+ return refuse(MEDIA_UPLOAD_MIME_MISMATCH_ERROR);
193
+ }
194
+ return {
195
+ result: { ok: true, mode: 'bytes', kind, bytes, mimeType: sniffed },
196
+ recent: [...live, { at: now, size }],
197
+ };
198
+ }
199
+ /**
200
+ * What the mock host answers when no canned upload is configured for a kind.
201
+ * Every field differs between kinds, so a block that mixes two up shows it.
202
+ * The URLs are deliberately unresolvable: the mock uploads nothing.
203
+ */
204
+ export const DEFAULT_MOCK_MEDIA_UPLOADS = {
205
+ image: {
206
+ url: 'https://media.mock.invalid/generation-input/dev-image.jpeg',
207
+ kind: 'image',
208
+ mimeType: 'image/jpeg',
209
+ sizeBytes: 182_311,
210
+ width: 1024,
211
+ height: 768,
212
+ },
213
+ video: {
214
+ url: 'https://media.mock.invalid/generation-input/dev-video.mp4',
215
+ kind: 'video',
216
+ mimeType: 'video/mp4',
217
+ sizeBytes: 4_812_330,
218
+ width: 1280,
219
+ height: 720,
220
+ durationMs: 5_000,
221
+ },
222
+ audio: {
223
+ url: 'https://media.mock.invalid/generation-input/dev-audio.mp3',
224
+ kind: 'audio',
225
+ mimeType: 'audio/mpeg',
226
+ sizeBytes: 961_204,
227
+ durationMs: 30_000,
228
+ },
229
+ };
230
+ /**
231
+ * The mock host's whole answer to one routable `OPEN_MEDIA_UPLOAD`, on a page
232
+ * surface: {@link admitMediaUpload}, then
233
+ * - picker mode: the canned upload for the kind, or a bare reply (dismissed)
234
+ * when that kind is canned `null`;
235
+ * - bytes mode: the canned upload for the kind (the default when it is
236
+ * `null`: there is no viewer to dismiss), carrying what the host would
237
+ * report for THESE bytes: the sniffed `mimeType` and the buffer's size for
238
+ * video and audio; `image/jpeg` and the canned size for an image, which the
239
+ * host re-encodes;
240
+ * - `forcedError`, when set, replaces a success: the stand-in for the
241
+ * decode and upload failures the mock cannot produce. A bytes upload it
242
+ * fails has still been admitted, so it still counts against the window, as
243
+ * on the host. ONE value is placed where the host checks it instead:
244
+ * `no block token` refuses a bytes upload right after the buffer check,
245
+ * before the cap and the window, and is not counted.
246
+ */
247
+ export function mockMediaUploadReply(args) {
248
+ const admitted = admitMediaUpload(args.payload, args.recent, args.now, {
249
+ noToken: args.forcedError === MEDIA_UPLOAD_NO_TOKEN_ERROR,
250
+ });
251
+ const { result, recent } = admitted;
252
+ if (!result.ok)
253
+ return { reply: { error: result.error }, recent };
254
+ if (args.forcedError !== undefined)
255
+ return { reply: { error: args.forcedError }, recent };
256
+ // A kind left out of `canned` answers the default; one set to `null` dismisses.
257
+ const configured = args.canned[result.kind];
258
+ const canned = configured === undefined ? DEFAULT_MOCK_MEDIA_UPLOADS[result.kind] : configured;
259
+ if (result.mode === 'picker') {
260
+ return { reply: canned ? { selected: { ...canned, kind: result.kind } } : {}, recent };
261
+ }
262
+ const base = canned ?? DEFAULT_MOCK_MEDIA_UPLOADS[result.kind];
263
+ const selected = result.kind === 'image'
264
+ ? { ...base, kind: 'image', mimeType: 'image/jpeg' }
265
+ : { ...base, kind: result.kind, mimeType: result.mimeType, sizeBytes: result.bytes.byteLength };
266
+ return { reply: { selected }, recent };
267
+ }
268
+ //# sourceMappingURL=mediaUploadBytes.js.map
@@ -38,6 +38,7 @@
38
38
  * this from production code.
39
39
  */
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 BlockModel3dOutput, type BlockPublishedModel, type BlockRunConfirmedStepHostError, type BlockGatedImage, type BlockPickedImageInfo, type BlockCollectionFollowErrorCode, type BlockEntityReactionErrorCode, 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
+ import { type MockMediaUploads } from './mediaUploadBytes.js';
41
42
  import { type MockWorkflowQuoteFailure } from './mockWorkflowGraph.js';
42
43
  import { type MockReactionSeed } from './mockHostReactions.js';
43
44
  /**
@@ -1220,6 +1221,37 @@ export interface MockHostOptions {
1220
1221
  mimeType: string;
1221
1222
  filename: string;
1222
1223
  }) => void;
1224
+ /**
1225
+ * What `OPEN_MEDIA_UPLOAD` (`useMediaUpload()`) answers, per kind. A kind
1226
+ * left out answers a built-in default for that kind; `null` makes the viewer
1227
+ * close that kind's picker (`open()` resolves `null`). A bytes `upload()`
1228
+ * answers the kind's entry (or the default) with the sniffed `mimeType` and
1229
+ * the buffer's `sizeBytes` for video and audio, and `image/jpeg` for an
1230
+ * image, as the host re-encodes it.
1231
+ *
1232
+ * The host's checks run in the host's order (`./mediaUploadBytes.ts`): kind,
1233
+ * purpose, then for bytes an invalid buffer, the cap (24 MB for an image,
1234
+ * 64 MB for video and audio), the bytes-mode
1235
+ * window (8 uploads or 128 MB per 60 s, per install) → `busy`, the content
1236
+ * sniff per kind, and a declared `mimeType` that disagrees. A model-slot
1237
+ * `context` answers `unsupported on this host`. 🔴 The mock cannot decode
1238
+ * media, so the host's decode-time refusals (an unreadable file, a video with
1239
+ * no video track, an image too small to resize) never happen here: force one
1240
+ * with {@link mediaUploadError}. Live-tunable via {@link MockHost.setScenario}.
1241
+ */
1242
+ cannedMediaUploads?: MockMediaUploads;
1243
+ /**
1244
+ * Force an `OPEN_MEDIA_UPLOAD` that passed the checks above to reply with
1245
+ * this `error`: a host-only refusal such as `this file has no video track`,
1246
+ * `media upload failed — please try again`, or
1247
+ * `this video was refused by the upload service`. A bytes upload it fails
1248
+ * still counts against the window, as on the host. `no block token` is the
1249
+ * exception: like the host, the mock refuses a bytes upload with it right
1250
+ * after the buffer check, before the cap and the window, and does not count
1251
+ * it. Absent → it succeeds.
1252
+ * Live-tunable via {@link MockHost.setScenario}; `undefined` clears it.
1253
+ */
1254
+ mediaUploadError?: string;
1223
1255
  /**
1224
1256
  * Image ids the mock treats as already issued and postable when the host is
1225
1257
  * created, as production treats an image this app stamped for the viewer in
@@ -1398,7 +1430,7 @@ export interface MockHostOptions {
1398
1430
  * {@link MockHost.setTheme}, which pushes a real `THEME_CHANGE` — the mock of
1399
1431
  * the viewer toggling dark mode with the block already mounted.)
1400
1432
  */
1401
- export type MockHostScenarioPatch = Pick<MockHostOptions, 'consentGrantable' | 'failMode' | 'cost' | 'pollsUntilDone' | 'cannedPicks' | 'cannedMultiPicks' | 'cannedImagePicks' | 'cannedImageUpload' | 'cannedGenerationSourceUpload' | 'cannedImageScan' | 'generation' | 'buzz' | 'storage' | 'shared' | 'buzzBalanceError' | 'viewerResult' | 'viewerError' | 'buzzTransactions' | 'buzzAccounts' | 'dailyCompensation' | 'buzzReadError' | 'wildcardPack' | 'wildcardPackError' | 'collectionFollowError' | 'reactionStates' | 'reactionError' | 'createPostResult' | 'createPostError' | 'trainingDatasetRejected' | 'trainingDatasetError' | 'trainingQuoteTotal' | 'runTrainingError' | 'runTrainingCapRefusal' | 'runConfirmedStepError' | 'runConfirmedStepCapRefusal' | 'appWorkflows' | 'appWorkflowsError' | 'publishImageIds' | 'publishError' | 'gatedImages' | 'gatedImagesError' | 'disallowedAccountTypes' | 'saveImageError' | 'uploadImageBytesResult' | 'uploadImageBytesError'>;
1433
+ export type MockHostScenarioPatch = Pick<MockHostOptions, 'consentGrantable' | 'failMode' | 'cost' | 'pollsUntilDone' | 'cannedPicks' | 'cannedMultiPicks' | 'cannedImagePicks' | 'cannedImageUpload' | 'cannedGenerationSourceUpload' | 'cannedImageScan' | 'generation' | 'buzz' | 'storage' | 'shared' | 'buzzBalanceError' | 'viewerResult' | 'viewerError' | 'buzzTransactions' | 'buzzAccounts' | 'dailyCompensation' | 'buzzReadError' | 'wildcardPack' | 'wildcardPackError' | 'collectionFollowError' | 'reactionStates' | 'reactionError' | 'createPostResult' | 'createPostError' | 'trainingDatasetRejected' | 'trainingDatasetError' | 'trainingQuoteTotal' | 'runTrainingError' | 'runTrainingCapRefusal' | 'runConfirmedStepError' | 'runConfirmedStepCapRefusal' | 'appWorkflows' | 'appWorkflowsError' | 'publishImageIds' | 'publishError' | 'gatedImages' | 'gatedImagesError' | 'disallowedAccountTypes' | 'saveImageError' | 'uploadImageBytesResult' | 'uploadImageBytesError' | 'cannedMediaUploads' | 'mediaUploadError'>;
1402
1434
  /** Runtime Buzz-balance handle exposed on {@link MockHost.buzz}. */
1403
1435
  export interface MockBuzzHandle {
1404
1436
  /** Current simulated balance, or `undefined` when balance isn't simulated. */
@@ -42,8 +42,10 @@ import { consentUnavailablePayload, isKnownBlockScope, resolveUngrantableConsent
42
42
  import { PAGE_CHECKPOINT_PERSIST_ERROR, emulatesPageHost } from './hostSurface.js';
43
43
  import { requiredStorageScope, storageResultType, storageScopeDeniedMessage, storageScopeDeniedPayload, } from './mockHostScopes.js';
44
44
  import { governsIdempotencyKey, idempotencyKeyDeniedMessage, idempotencyKeyRefusal, } from './mockHostIdempotency.js';
45
+ import { stepInputRefusal } from './mockHostStepInput.js';
45
46
  import { SAVE_IMAGE_INVALID_REQUEST_ERROR, prepareSaveBytes, saveImageRequestKind, } from './saveBytes.js';
46
47
  import { UPLOAD_BYTES_INVALID_ERROR, admitUploadBytes, resolveUploadBytesRequest, } from './uploadBytes.js';
48
+ import { mockMediaUploadReply, } from './mediaUploadBytes.js';
47
49
  import { mockUnpricedWorkflowRefusal, mockUnquotedWorkflowEstimateRefusal, mockWorkflowBodyRefusal, mockWorkflowStepGenerations, mockWorkflowStepName, } from './mockWorkflowGraph.js';
48
50
  import { BATCH_ESTIMATE_CELL_TIMEOUT_ERROR } from '../hooks/useBatchEstimate.js';
49
51
  import { RUN_CONFIRMED_STEP_ERROR_CODES, STEP_QUOTE_ID_PATTERN, } from '../hooks/useRunConfirmedStep.js';
@@ -1119,6 +1121,11 @@ export function createMockHost(options = {}) {
1119
1121
  let uploadImageBytesResult = options.uploadImageBytesResult ?? DEFAULT_IMAGE_BYTES_UPLOAD;
1120
1122
  let uploadImageBytesError = options.uploadImageBytesError;
1121
1123
  let uploadBytesWindow = [];
1124
+ // OPEN_MEDIA_UPLOAD: canned per-kind answers, a forced refusal, and the
1125
+ // bytes-mode window (per install, like the host's per mount).
1126
+ let cannedMediaUploads = options.cannedMediaUploads ?? {};
1127
+ let mediaUploadError = options.mediaUploadError;
1128
+ let mediaUploadWindow = [];
1122
1129
  // The ids a `CREATE_POST_FROM_APP` `{ kind: 'published' }` source may name:
1123
1130
  // ONLY ids this mock itself issued as postable (an accepted bytes upload, or a
1124
1131
  // PUBLISH_GENERATION_OUTPUTS reply) plus the seeded `postableImageIds` (an
@@ -1646,6 +1653,15 @@ export function createMockHost(options = {}) {
1646
1653
  // The batch path refuses the WHOLE call before it reaches this function
1647
1654
  // (see ESTIMATE_WORKFLOW_BATCH), so this branch is the single-estimate
1648
1655
  // one in practice.
1656
+ //
1657
+ // 🔴 THE STEP-INPUT GATE comes first of the server's checks — see
1658
+ // ./mockHostStepInput.ts. Its rules are zod refinements of the body,
1659
+ // so they refuse at the procedure's `.input()`, before the handler's
1660
+ // scope check. The host catches that into `failureSnapshot(err)`.
1661
+ const inputRefusal = stepInputRefusal(body, opts.issuePathPrefix);
1662
+ if (inputRefusal !== null) {
1663
+ return { workflowId: 'failed', status: 'failed', error: inputRefusal };
1664
+ }
1649
1665
  if (!consentGranted) {
1650
1666
  return { workflowId: 'failed', status: 'failed', error: MOCK_SPEND_SCOPE_ERROR };
1651
1667
  }
@@ -1868,7 +1884,10 @@ export function createMockHost(options = {}) {
1868
1884
  const body = typed.payload?.body ?? {};
1869
1885
  dispatchToBlock({
1870
1886
  type: 'ESTIMATE_RESULT',
1871
- payload: { requestId, snapshot: estimateSnapshotFor(body, { storeQuotes: true }) },
1887
+ payload: {
1888
+ requestId,
1889
+ snapshot: estimateSnapshotFor(body, { storeQuotes: true, issuePathPrefix: ['body'] }),
1890
+ },
1872
1891
  });
1873
1892
  return;
1874
1893
  }
@@ -1941,7 +1960,10 @@ export function createMockHost(options = {}) {
1941
1960
  error: MOCK_TRAINING_IN_BATCH_ERROR,
1942
1961
  };
1943
1962
  }
1944
- return estimateSnapshotFor(cell ?? {}, { storeQuotes: false });
1963
+ return estimateSnapshotFor(cell ?? {}, {
1964
+ storeQuotes: false,
1965
+ issuePathPrefix: [],
1966
+ });
1945
1967
  });
1946
1968
  // The server's aggregate rule: the cells that priced, i.e. not
1947
1969
  // `'failed'` and carrying a numeric `cost.total`.
@@ -1994,6 +2016,23 @@ export function createMockHost(options = {}) {
1994
2016
  });
1995
2017
  return;
1996
2018
  }
2019
+ // 🔴 THE STEP-INPUT GATE (./mockHostStepInput.ts): inline media in a
2020
+ // pass-through step input, then its byte cap. zod refinements of the
2021
+ // body, so the server refuses at `.input()` — ahead of the spend-scope
2022
+ // gate below, which is the handler's first act. Nothing is debited.
2023
+ {
2024
+ const inputRefusal = stepInputRefusal(body, ['body']);
2025
+ if (inputRefusal !== null) {
2026
+ dispatchToBlock({
2027
+ type: 'WORKFLOW_SUBMITTED',
2028
+ payload: {
2029
+ requestId,
2030
+ snapshot: { workflowId: 'failed', status: 'failed', error: inputRefusal },
2031
+ },
2032
+ });
2033
+ return;
2034
+ }
2035
+ }
1997
2036
  // 🔴 THE SPEND-SCOPE GATE. civitai's `submitWorkflow` throws
1998
2037
  // `FORBIDDEN` with this message as its first act after
1999
2038
  // authenticating the token — before the budget, the training
@@ -3026,6 +3065,30 @@ export function createMockHost(options = {}) {
3026
3065
  });
3027
3066
  return;
3028
3067
  }
3068
+ case 'OPEN_MEDIA_UPLOAD': {
3069
+ // Generation-input media upload. The rules live in
3070
+ // ./mediaUploadBytes.ts, in the host's order; there is no picker
3071
+ // UI, so picker mode answers at once from `cannedMediaUploads`.
3072
+ if (!isRoutableRequestId(requestId))
3073
+ return;
3074
+ if (!emulatesPageHost(options.context)) {
3075
+ dispatchToBlock({
3076
+ type: 'MEDIA_UPLOAD_RESULT',
3077
+ payload: { requestId, error: 'unsupported on this host' },
3078
+ });
3079
+ return;
3080
+ }
3081
+ const answer = mockMediaUploadReply({
3082
+ payload: typed.payload ?? {},
3083
+ canned: cannedMediaUploads,
3084
+ forcedError: mediaUploadError,
3085
+ recent: mediaUploadWindow,
3086
+ now: Date.now(),
3087
+ });
3088
+ mediaUploadWindow = answer.recent;
3089
+ dispatchToBlock({ type: 'MEDIA_UPLOAD_RESULT', payload: { requestId, ...answer.reply } });
3090
+ return;
3091
+ }
3029
3092
  case 'OPEN_IMAGE_UPLOAD': {
3030
3093
  // Host-chrome image upload: return the canned result keyed by the
3031
3094
  // requested `purpose` (mirrors the real host's IMAGE_UPLOAD_RESULT).
@@ -3811,6 +3874,10 @@ export function createMockHost(options = {}) {
3811
3874
  }
3812
3875
  if ('uploadImageBytesError' in patch)
3813
3876
  uploadImageBytesError = patch.uploadImageBytesError;
3877
+ if ('cannedMediaUploads' in patch)
3878
+ cannedMediaUploads = patch.cannedMediaUploads ?? {};
3879
+ if ('mediaUploadError' in patch)
3880
+ mediaUploadError = patch.mediaUploadError;
3814
3881
  if ('runTrainingCapRefusal' in patch)
3815
3882
  runTrainingCapRefusal = patch.runTrainingCapRefusal;
3816
3883
  if ('runConfirmedStepError' in patch)
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The mock host's STEP-INPUT GATE: the inline-media rule and the byte cap the
3
+ * real host applies to a pass-through step body, on the estimate, each
4
+ * batch-estimate cell and the submit.
5
+ *
6
+ * WHAT THE REAL HOST SENDS BACK. Both rules are zod refinements of the body
7
+ * schema (civitai/civitai `src/server/schema/blocks/workflow.schema.ts`), so a
8
+ * refusal is a tRPC `BAD_REQUEST` thrown at the procedure's `.input()` — before
9
+ * the handler, and so before the spend-scope check, the budget and any debit.
10
+ * tRPC uses the `ZodError`'s message as the error message, which is the issue
11
+ * list as pretty-printed JSON, and the page host catches the rejected mutation
12
+ * into `failureSnapshot(err)` (`src/components/AppBlocks/failureSnapshot.ts`):
13
+ *
14
+ * { workflowId: 'failed', status: 'failed', error: '[\n {\n "code": "custom", … }\n]' }
15
+ *
16
+ * with NO `cost`, so `estimate()` rejects with `WorkflowEstimateError` code
17
+ * `'failed'` and `submit()` with `WorkflowSubmitError` code `'exception'`. A
18
+ * batch cell is parsed against the same schema inside the procedure and gets
19
+ * the same snapshot, its issue path relative to the cell rather than `body`.
20
+ *
21
+ * MIRRORS the server's `refuseInlineMedia` superRefine and the byte-cap refine
22
+ * after it, over the same fields (the step `input`; or each workflow step
23
+ * `input` and then `arguments`, which a step can `$ref`):
24
+ * - one value budget SHARED by every field of the body; a spent budget refuses
25
+ * the whole body with the arm's own message;
26
+ * - the inline-media issue path is the field prefix plus the offending path,
27
+ * and the message names the same path WITHOUT the procedure's `body` segment;
28
+ * - the search counts the JSON length of the strings it passes, and once that
29
+ * is over the byte cap it reads no further string — so a photo AFTER a string
30
+ * that already crossed the cap gets the BYTE-CAP refusal, while the string
31
+ * that crosses it is still searched (an oversized photo gets the media one);
32
+ * - an inline-media or budget issue is added with `continue: false`, so the
33
+ * byte cap never also reports — the error is always ONE issue.
34
+ * The issue shape (`code`, `path`, `message`, in that order, `continue`
35
+ * stripped) and the byte-cap message were measured against zod 4 with the
36
+ * server's schema shape.
37
+ */
38
+ /** The server's byte-cap message for a pass-through step `input` (issue path `input`). */
39
+ export declare const MOCK_STEP_INPUT_BYTES_ERROR = "step input exceeds the 262144-byte limit";
40
+ /** The server's summed byte-cap message for a `kind: 'workflow'` body (issue path `steps`). */
41
+ export declare const MOCK_WORKFLOW_INPUT_BYTES_ERROR = "workflow step inputs and arguments exceed the 262144-byte limit";
42
+ /** The server's value-budget refusal on the `kind: 'step'` arm (issue path `input`). */
43
+ export declare const MOCK_STEP_INPUT_TOO_MANY_VALUES_ERROR = "step input holds more than 262144 values";
44
+ /** The server's value-budget refusal on the `kind: 'workflow'` arm (issue path `steps`). */
45
+ export declare const MOCK_WORKFLOW_INPUT_TOO_MANY_VALUES_ERROR = "step inputs and arguments hold more than 262144 values in total";
46
+ /** `ZodError.message` for a single custom issue — what tRPC hands the host as `err.message`. */
47
+ export declare function zodIssueMessage(path: ReadonlyArray<string | number>, message: string): string;
48
+ /**
49
+ * The refusal text for `body`'s step input(s), or `null` to proceed.
50
+ * `issuePathPrefix` is `['body']` for an estimate or submit (the field of the
51
+ * procedure input) and `[]` for a batch cell (parsed on its own).
52
+ *
53
+ * - A pass-through `kind: 'step'` body (no `step` id): its `input`.
54
+ * - A `kind: 'workflow'` body: each step's `input`, then `arguments`; its byte
55
+ * cap is their SUM.
56
+ *
57
+ * Any other body, a step whose `input` is not an object, or a workflow whose
58
+ * wire shape is wrong, is left to the checks that already handle it — zod
59
+ * refuses the shape before these refinements run.
60
+ */
61
+ export declare function stepInputRefusal(body: unknown, issuePathPrefix: ReadonlyArray<string | number>): string | null;
62
+ //# sourceMappingURL=mockHostStepInput.d.ts.map
@@ -0,0 +1,120 @@
1
+ /**
2
+ * The mock host's STEP-INPUT GATE: the inline-media rule and the byte cap the
3
+ * real host applies to a pass-through step body, on the estimate, each
4
+ * batch-estimate cell and the submit.
5
+ *
6
+ * WHAT THE REAL HOST SENDS BACK. Both rules are zod refinements of the body
7
+ * schema (civitai/civitai `src/server/schema/blocks/workflow.schema.ts`), so a
8
+ * refusal is a tRPC `BAD_REQUEST` thrown at the procedure's `.input()` — before
9
+ * the handler, and so before the spend-scope check, the budget and any debit.
10
+ * tRPC uses the `ZodError`'s message as the error message, which is the issue
11
+ * list as pretty-printed JSON, and the page host catches the rejected mutation
12
+ * into `failureSnapshot(err)` (`src/components/AppBlocks/failureSnapshot.ts`):
13
+ *
14
+ * { workflowId: 'failed', status: 'failed', error: '[\n {\n "code": "custom", … }\n]' }
15
+ *
16
+ * with NO `cost`, so `estimate()` rejects with `WorkflowEstimateError` code
17
+ * `'failed'` and `submit()` with `WorkflowSubmitError` code `'exception'`. A
18
+ * batch cell is parsed against the same schema inside the procedure and gets
19
+ * the same snapshot, its issue path relative to the cell rather than `body`.
20
+ *
21
+ * MIRRORS the server's `refuseInlineMedia` superRefine and the byte-cap refine
22
+ * after it, over the same fields (the step `input`; or each workflow step
23
+ * `input` and then `arguments`, which a step can `$ref`):
24
+ * - one value budget SHARED by every field of the body; a spent budget refuses
25
+ * the whole body with the arm's own message;
26
+ * - the inline-media issue path is the field prefix plus the offending path,
27
+ * and the message names the same path WITHOUT the procedure's `body` segment;
28
+ * - the search counts the JSON length of the strings it passes, and once that
29
+ * is over the byte cap it reads no further string — so a photo AFTER a string
30
+ * that already crossed the cap gets the BYTE-CAP refusal, while the string
31
+ * that crosses it is still searched (an oversized photo gets the media one);
32
+ * - an inline-media or budget issue is added with `continue: false`, so the
33
+ * byte cap never also reports — the error is always ONE issue.
34
+ * The issue shape (`code`, `path`, `message`, in that order, `continue`
35
+ * stripped) and the byte-cap message were measured against zod 4 with the
36
+ * server's schema shape.
37
+ */
38
+ import { isWellFormedWorkflowShape } from './mockWorkflowGraph.js';
39
+ import { INLINE_MEDIA_MAX_VISITS, INLINE_MEDIA_OVER_BYTE_LIMIT, INLINE_MEDIA_TOO_MANY_VALUES, PASS_THROUGH_INPUT_BYTES_MAX, findInlineMediaPath, inlineMediaMessage, } from './stepInputLimits.js';
40
+ /** The server's byte-cap message for a pass-through step `input` (issue path `input`). */
41
+ export const MOCK_STEP_INPUT_BYTES_ERROR = `step input exceeds the ${PASS_THROUGH_INPUT_BYTES_MAX}-byte limit`;
42
+ /** The server's summed byte-cap message for a `kind: 'workflow'` body (issue path `steps`). */
43
+ export const MOCK_WORKFLOW_INPUT_BYTES_ERROR = `workflow step inputs and arguments exceed the ${PASS_THROUGH_INPUT_BYTES_MAX}-byte limit`;
44
+ /** The server's value-budget refusal on the `kind: 'step'` arm (issue path `input`). */
45
+ export const MOCK_STEP_INPUT_TOO_MANY_VALUES_ERROR = `step input holds more than ${INLINE_MEDIA_MAX_VISITS} values`;
46
+ /** The server's value-budget refusal on the `kind: 'workflow'` arm (issue path `steps`). */
47
+ export const MOCK_WORKFLOW_INPUT_TOO_MANY_VALUES_ERROR = `step inputs and arguments hold more than ${INLINE_MEDIA_MAX_VISITS} values in total`;
48
+ /** `ZodError.message` for a single custom issue — what tRPC hands the host as `err.message`. */
49
+ export function zodIssueMessage(path, message) {
50
+ return JSON.stringify([{ code: 'custom', path, message }], null, 2);
51
+ }
52
+ const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
53
+ /** The server's `refuseInlineMedia`: the first hit across `fields`, sharing one budget. */
54
+ function inlineMediaIssue(fields, overflow) {
55
+ const budget = { visits: 0 };
56
+ for (const [prefix, value] of fields) {
57
+ const hit = findInlineMediaPath(value, budget, PASS_THROUGH_INPUT_BYTES_MAX);
58
+ if (!hit || hit === INLINE_MEDIA_OVER_BYTE_LIMIT)
59
+ continue;
60
+ if (hit === INLINE_MEDIA_TOO_MANY_VALUES)
61
+ return overflow;
62
+ const path = [...prefix, ...hit];
63
+ return { path, message: inlineMediaMessage(path) };
64
+ }
65
+ return null;
66
+ }
67
+ /**
68
+ * The summed `JSON.stringify(value).length` of `fields` (a value it writes
69
+ * nothing for, like an absent `arguments`, counts 0) — the verdict the server's
70
+ * `jsonLengthWithin` gives. `null` when JSON cannot serialize one (a `BigInt`,
71
+ * a cycle): a structured-clone payload can carry either, and such an input is
72
+ * left to the checks that already model it, not reported as a size.
73
+ */
74
+ function serializedLength(fields) {
75
+ try {
76
+ return fields.reduce((sum, [, value]) => sum + (JSON.stringify(value)?.length ?? 0), 0);
77
+ }
78
+ catch {
79
+ return null;
80
+ }
81
+ }
82
+ /** Inline media / value budget first; the byte cap only when that found nothing. */
83
+ function fieldsIssue(fields, overflow, overCap) {
84
+ const media = inlineMediaIssue(fields, overflow);
85
+ if (media !== null)
86
+ return media;
87
+ const size = serializedLength(fields);
88
+ return size !== null && size > PASS_THROUGH_INPUT_BYTES_MAX ? overCap : null;
89
+ }
90
+ /**
91
+ * The refusal text for `body`'s step input(s), or `null` to proceed.
92
+ * `issuePathPrefix` is `['body']` for an estimate or submit (the field of the
93
+ * procedure input) and `[]` for a batch cell (parsed on its own).
94
+ *
95
+ * - A pass-through `kind: 'step'` body (no `step` id): its `input`.
96
+ * - A `kind: 'workflow'` body: each step's `input`, then `arguments`; its byte
97
+ * cap is their SUM.
98
+ *
99
+ * Any other body, a step whose `input` is not an object, or a workflow whose
100
+ * wire shape is wrong, is left to the checks that already handle it — zod
101
+ * refuses the shape before these refinements run.
102
+ */
103
+ export function stepInputRefusal(body, issuePathPrefix) {
104
+ if (!isPlainObject(body))
105
+ return null;
106
+ let issue = null;
107
+ if (body.kind === 'step' && body.step === undefined) {
108
+ if (!isPlainObject(body.input))
109
+ return null;
110
+ issue = fieldsIssue([[['input'], body.input]], { path: ['input'], message: MOCK_STEP_INPUT_TOO_MANY_VALUES_ERROR }, { path: ['input'], message: MOCK_STEP_INPUT_BYTES_ERROR });
111
+ }
112
+ else if (body.kind === 'workflow' && isWellFormedWorkflowShape(body)) {
113
+ issue = fieldsIssue([
114
+ ...body.steps.map((step, i) => [['steps', i, 'input'], step.input]),
115
+ [['arguments'], body.arguments],
116
+ ], { path: ['steps'], message: MOCK_WORKFLOW_INPUT_TOO_MANY_VALUES_ERROR }, { path: ['steps'], message: MOCK_WORKFLOW_INPUT_BYTES_ERROR });
117
+ }
118
+ return issue === null ? null : zodIssueMessage([...issuePathPrefix, ...issue.path], issue.message);
119
+ }
120
+ //# sourceMappingURL=mockHostStepInput.js.map