@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.
package/README.md CHANGED
@@ -675,6 +675,14 @@ showImages(done.imageUrls ?? []);
675
675
  KB (262144 bytes) for every step's `input` plus `arguments` together. An
676
676
  unknown key on the body or on a step is rejected. The app's velocity limit
677
677
  counts one unit per step, and a `repeat` step one per iteration instead.
678
+ - **No inline media** in a step `input` or in `arguments` — here or in a
679
+ pass-through `kind: 'step'` body's `input`: no `data:image|video|audio` URI
680
+ and no raw base64 string of 4096+ characters, at any depth. Upload the image,
681
+ video or audio with [`useMediaUpload()`](#usemediaupload) (an image also works
682
+ with `useImageUpload({ purpose: 'generationSource' })` or `useImagePicker()`)
683
+ and pass the returned `url`. A body holding more than
684
+ 262144 values in those fields is refused outright. `createMockHost` refuses
685
+ both as the host does, naming the path.
678
686
  - `suppressOutput: true` marks an intermediate step. Its images are left out of
679
687
  `snapshot.imageUrls` and out of its own `steps[i].imageUrls`. It still runs
680
688
  and is still charged.
@@ -2475,6 +2483,126 @@ an invalid `sources` or `multiple` with an error, and answers
2475
2483
  list the viewer's images, so there `open` rejects with a message saying to use
2476
2484
  the mock host.
2477
2485
 
2486
+ ### `useMediaUpload()`
2487
+
2488
+ **The way to pass any media (image, video or audio) into a workflow step.** The
2489
+ host refuses data URIs and inline base64 in step inputs, so upload the file
2490
+ with this hook and pass the `url` it returns into the step input. Returns
2491
+ `{ open, upload }`.
2492
+
2493
+ - **`open({ kind })`** opens the host's own file picker for that kind. The
2494
+ viewer picks a file, the host uploads it, and `open` resolves with the
2495
+ upload, or `null` if nothing was uploaded.
2496
+ - **`upload({ kind, bytes, mimeType? })`** uploads media your app already
2497
+ holds in the tab, with no picker: a canvas export, a crop, a `MediaRecorder`
2498
+ recording. It resolves with the upload, and never resolves without one.
2499
+
2500
+ ```tsx
2501
+ import { useMediaUpload } from '@civitai/blocks-react';
2502
+
2503
+ function UseRecording({ recording }: { recording: Blob }) {
2504
+ const { upload } = useMediaUpload();
2505
+
2506
+ async function onClick() {
2507
+ const audio = await upload({
2508
+ kind: 'audio',
2509
+ bytes: await recording.arrayBuffer(),
2510
+ mimeType: recording.type, // optional; checked against the content
2511
+ });
2512
+ // audio.url goes into the step's audio input
2513
+ startStep({ audioUrl: audio.url });
2514
+ }
2515
+
2516
+ return <button onClick={onClick}>Use this recording</button>;
2517
+ }
2518
+ ```
2519
+
2520
+ ```tsx
2521
+ const { open } = useMediaUpload();
2522
+
2523
+ const video = await open({ kind: 'video' }); // BlockMediaUploadInfo | null
2524
+ if (video) {
2525
+ // video.url, video.width, video.height, video.durationMs (when known)
2526
+ }
2527
+ ```
2528
+
2529
+ What each kind accepts, decided by the file's **content** (its first bytes),
2530
+ never by its name or declared type:
2531
+
2532
+ | `kind` | Accepted | What you get back |
2533
+ |---|---|---|
2534
+ | `'image'` | PNG, JPEG, WebP | Resized so both sides are 64 to 2048 pixels and re-encoded as JPEG: `mimeType` is always `'image/jpeg'`, and `sizeBytes`, `width` and `height` describe that JPEG. |
2535
+ | `'video'` | MP4, WebM | Uploaded as sent. `width` and `height` are always set (a file with no video track is refused). `durationMs` when the browser reports a duration. |
2536
+ | `'audio'` | MP3, WAV, WebM | Uploaded as sent. No `width` or `height`. `durationMs` when the browser reports a duration: a `MediaRecorder` WebM often reports none, so do not rely on it. |
2537
+
2538
+ - **At most 24 MB per image, and 64 MB per video or audio file.** The image
2539
+ cap is lower because the host decodes an image in full before it resizes it.
2540
+ `upload` refuses a larger buffer before sending it, with the host's own
2541
+ message: `file exceeds the 24 MB image upload limit` or
2542
+ `file exceeds the 64 MB upload limit`.
2543
+ - **`upload` is rate-limited:** at most 8 uploads and 128 MB per 60 seconds
2544
+ per page. Past that the host replies `busy`; wait and retry. A refused upload
2545
+ does not count. `open` is not limited this way, because a person picks each
2546
+ file.
2547
+ - **`mimeType` is optional.** When you pass it (for example `blob.type`) it
2548
+ must match the content, or the upload is refused with
2549
+ `mimeType does not match the file's content`. Case and parameters such as
2550
+ `;codecs=opus` are ignored, and `audio/x-wav` counts as `audio/wav`.
2551
+ - Pass an `ArrayBuffer` (`await blob.arrayBuffer()`). A `Blob`, a typed array
2552
+ or an empty buffer is refused before sending with
2553
+ `invalid media-upload request`. The buffer is copied, not transferred.
2554
+ - A file whose content is not one of its kind's types is refused with
2555
+ `images must be PNG, JPEG or WebP`, `videos must be MP4 or WebM` or
2556
+ `audio must be MP3, WAV or WebM`. The host also refuses a file it cannot
2557
+ read (`could not read this video file`, `could not read this audio file`), a
2558
+ video with no video track (`this file has no video track`), and an image too
2559
+ small to resize to 64 pixels. A failed upload rejects with
2560
+ `media upload failed — please try again`, and a file the upload service
2561
+ refuses with `this video was refused by the upload service` or
2562
+ `this audio was refused by the upload service`. A bytes upload made while
2563
+ the host holds no block token rejects with `no block token`.
2564
+
2565
+ What `open` and `upload` resolve and reject with:
2566
+
2567
+ - **`open` resolves `null` when nothing was uploaded.** Usually the viewer
2568
+ closed the picker. The host also answers that way at once, without showing a
2569
+ picker, while your app is viewed in review mode or is not ready yet. A
2570
+ problem with the picked file is shown in the picker, where the viewer can
2571
+ pick another one.
2572
+ - **`upload` never resolves `null`.** When the host declines without
2573
+ uploading (review mode, not ready), `upload` rejects with
2574
+ `the host did not upload the media (the app is in review mode or not ready)`.
2575
+ - **A refusal rejects** with the host's message, in both modes. On a surface
2576
+ other than a page app the host answers at once with
2577
+ `unsupported on this host`.
2578
+ - **Both wait up to 10 minutes** for the host (a person picking a file, or a
2579
+ large upload), then reject with a `RequestTimeoutError`.
2580
+
2581
+ For images only, `useImageUpload().open({ purpose: 'generationSource' })` still
2582
+ works and returns `{ url, width, height }`. Use `useMediaUpload` for video,
2583
+ audio, or bytes your app made in the tab.
2584
+
2585
+ 🔴 **Needs a host that answers `OPEN_MEDIA_UPLOAD`** (civitai/civitai#6149).
2586
+ On a civitai.com that predates it, the request gets no answer and both calls reject with a
2587
+ `RequestTimeoutError` after 10 minutes.
2588
+
2589
+ With the mock host (`createMockHost` or `Harness` from
2590
+ `@civitai/blocks-react/testing`) there is no picker: `open` resolves at once
2591
+ with a built-in upload for the kind. Set `cannedMediaUploads: { video: {...} }`
2592
+ to choose what a kind returns, or `{ audio: null }` for a viewer who closes
2593
+ that picker. `upload` runs the host's checks in the host's order (kind,
2594
+ purpose, an invalid buffer, the per-kind cap, the rate window, the content type,
2595
+ a mismatching `mimeType`) and answers with that kind's upload, carrying the
2596
+ sniffed `mimeType` and the buffer's size for video and audio. The mock cannot
2597
+ decode media, so it never produces the host's decode-time refusals (an
2598
+ unreadable file, no video track, an image too small, an upload failure): set
2599
+ `mediaUploadError: 'this file has no video track'` to force one.
2600
+ `mediaUploadError: 'no block token'` is checked where the host checks it: a
2601
+ bytes upload is refused before the cap and the window, and is not counted. A model-slot
2602
+ `context` answers `unsupported on this host`. `dev:live` cannot upload
2603
+ generation inputs, so there both calls reject with a message saying to use the
2604
+ mock host.
2605
+
2478
2606
  ### `useSaveImage()`
2479
2607
 
2480
2608
  Ask the host to download a file to the viewer's device. **A block cannot
@@ -3424,6 +3552,7 @@ MockHost
3424
3552
  MockHostFailMode
3425
3553
  MockHostOptions
3426
3554
  MockHostScenarioPatch
3555
+ MockMediaUploads
3427
3556
  MockReactionSeed
3428
3557
  MockSharedScenario
3429
3558
  MockSharedSeed
@@ -0,0 +1,97 @@
1
+ import type { BlockMediaKind, BlockMediaUploadInfo } from '@civitai/app-sdk/blocks';
2
+ /**
3
+ * What `upload()` rejects with when the host answers with neither a file nor
4
+ * an error. For a bytes upload that means the host DECLINED it without trying
5
+ * (the app is viewed in review mode, or is not ready yet). Nothing was
6
+ * uploaded, so it must never read as a success.
7
+ */
8
+ export declare const MEDIA_UPLOAD_DECLINED_ERROR = "the host did not upload the media (the app is in review mode or not ready)";
9
+ /** Options for {@link UseMediaUpload.open}. */
10
+ export interface MediaUploadOpenOptions {
11
+ /** What the viewer picks: `'image'`, `'video'` or `'audio'`. */
12
+ kind: BlockMediaKind;
13
+ }
14
+ /** Options for {@link UseMediaUpload.upload}. */
15
+ export interface MediaUploadBytesOptions {
16
+ /** What the bytes are: `'image'`, `'video'` or `'audio'`. */
17
+ kind: BlockMediaKind;
18
+ /**
19
+ * The file, as an `ArrayBuffer` (`await blob.arrayBuffer()`). At most 24 MB
20
+ * for an image and 64 MB for video or audio.
21
+ * Copied across `postMessage`, never transferred. A `Blob`, a typed array or
22
+ * an empty buffer is refused before sending.
23
+ */
24
+ bytes: ArrayBuffer;
25
+ /**
26
+ * Optional: the type you believe you are sending (`blob.type`). The host
27
+ * decides the type from the content; when this is given and disagrees, the
28
+ * upload is refused with `mimeType does not match the file's content`.
29
+ * Parameters (`;codecs=…`) and case are ignored.
30
+ */
31
+ mimeType?: string;
32
+ }
33
+ /** What {@link useMediaUpload} returns. */
34
+ export interface UseMediaUpload {
35
+ /**
36
+ * PICKER mode: the host opens its own file picker for `kind`, uploads what
37
+ * the viewer picks, and resolves with it. Resolves `null` when nothing was
38
+ * uploaded: the viewer closed the picker, or the host declined at once
39
+ * (review mode, not ready). Rejects with the host's message on a refusal.
40
+ */
41
+ open: (options: MediaUploadOpenOptions) => Promise<BlockMediaUploadInfo | null>;
42
+ /**
43
+ * BYTES mode: upload media the block holds (a canvas export, a crop, a
44
+ * `MediaRecorder` recording) with no picker. Resolves with the uploaded
45
+ * file. Rejects with the host's message on every refusal or failure, and
46
+ * with {@link MEDIA_UPLOAD_DECLINED_ERROR} when the host declined without
47
+ * uploading. It never resolves without a file.
48
+ */
49
+ upload: (options: MediaUploadBytesOptions) => Promise<BlockMediaUploadInfo>;
50
+ }
51
+ /**
52
+ * Upload an image, video or audio file as a GENERATION INPUT, and get back a
53
+ * `url` to pass into a workflow step input.
54
+ *
55
+ * 🔴 THIS IS THE WAY TO PUT MEDIA INTO A STEP. The host refuses data URIs and
56
+ * inline base64 in step inputs, so upload the file here and pass the returned
57
+ * `url`.
58
+ *
59
+ * - `open({ kind })` lets the viewer pick a file in the host's own picker.
60
+ * - `upload({ kind, bytes, mimeType? })` uploads bytes the block made in the
61
+ * tab, with no picker.
62
+ *
63
+ * The type is decided by the file's content: images PNG, JPEG or WebP (the
64
+ * host resizes them to 64–2048 px per side and re-encodes them as JPEG);
65
+ * videos MP4 or WebM; audio MP3, WAV or WebM. At most 24 MB per image (the
66
+ * host decodes it in full before resizing) and 64 MB per video or audio; bytes
67
+ * mode also allows at most 8 uploads and 128 MB per 60 s per page, and
68
+ * rejects `busy` past that (retry later).
69
+ *
70
+ * Refused BEFORE sending, with the host's own strings: an unknown `kind`, an
71
+ * `upload` whose `bytes` is not a non-empty `ArrayBuffer` (or whose `mimeType`
72
+ * is not a string), and a buffer over the kind's cap.
73
+ *
74
+ * Page apps only: a model-slot host rejects with `unsupported on this host`.
75
+ * A host that predates this message does not answer it, so the call rejects
76
+ * with `RequestTimeoutError` after the human-interaction timeout
77
+ * ({@link HUMAN_INTERACTION_TIMEOUT_MS}, 10 minutes).
78
+ *
79
+ * For images, `useImageUpload().open({ purpose: 'generationSource' })` remains
80
+ * valid; this hook is the one that also takes video, audio and in-tab bytes.
81
+ *
82
+ * @example
83
+ * // A recording made in the tab, passed into a step.
84
+ * const { upload } = useMediaUpload();
85
+ * const audio = await upload({ kind: 'audio', bytes: await recording.arrayBuffer(), mimeType: recording.type });
86
+ * // audio.url goes into the step's audio input
87
+ *
88
+ * @example
89
+ * // The viewer picks a video.
90
+ * const { open } = useMediaUpload();
91
+ * const video = await open({ kind: 'video' });
92
+ * if (video) {
93
+ * // video.url, video.width, video.height, video.durationMs
94
+ * }
95
+ */
96
+ export declare function useMediaUpload(): UseMediaUpload;
97
+ //# sourceMappingURL=useMediaUpload.d.ts.map
@@ -0,0 +1,108 @@
1
+ import { useCallback } from 'react';
2
+ import { MEDIA_UPLOAD_INVALID_ERROR, MEDIA_UPLOAD_KIND_ERROR, isMediaKind, mediaUploadCap, } from '../internal/mediaUploadBytes.js';
3
+ import { throwOnReplyError } from '../internal/replyError.js';
4
+ import { HUMAN_INTERACTION_TIMEOUT_MS } from '../transport/requestTimeouts.js';
5
+ import { getTransport } from '../transport/singleton.js';
6
+ import { sendTypedRequest } from '../transport/transport.js';
7
+ import { useTransportConnection } from './useTransportConnection.js';
8
+ /**
9
+ * What `upload()` rejects with when the host answers with neither a file nor
10
+ * an error. For a bytes upload that means the host DECLINED it without trying
11
+ * (the app is viewed in review mode, or is not ready yet). Nothing was
12
+ * uploaded, so it must never read as a success.
13
+ */
14
+ export const MEDIA_UPLOAD_DECLINED_ERROR = 'the host did not upload the media (the app is in review mode or not ready)';
15
+ /**
16
+ * Upload an image, video or audio file as a GENERATION INPUT, and get back a
17
+ * `url` to pass into a workflow step input.
18
+ *
19
+ * 🔴 THIS IS THE WAY TO PUT MEDIA INTO A STEP. The host refuses data URIs and
20
+ * inline base64 in step inputs, so upload the file here and pass the returned
21
+ * `url`.
22
+ *
23
+ * - `open({ kind })` lets the viewer pick a file in the host's own picker.
24
+ * - `upload({ kind, bytes, mimeType? })` uploads bytes the block made in the
25
+ * tab, with no picker.
26
+ *
27
+ * The type is decided by the file's content: images PNG, JPEG or WebP (the
28
+ * host resizes them to 64–2048 px per side and re-encodes them as JPEG);
29
+ * videos MP4 or WebM; audio MP3, WAV or WebM. At most 24 MB per image (the
30
+ * host decodes it in full before resizing) and 64 MB per video or audio; bytes
31
+ * mode also allows at most 8 uploads and 128 MB per 60 s per page, and
32
+ * rejects `busy` past that (retry later).
33
+ *
34
+ * Refused BEFORE sending, with the host's own strings: an unknown `kind`, an
35
+ * `upload` whose `bytes` is not a non-empty `ArrayBuffer` (or whose `mimeType`
36
+ * is not a string), and a buffer over the kind's cap.
37
+ *
38
+ * Page apps only: a model-slot host rejects with `unsupported on this host`.
39
+ * A host that predates this message does not answer it, so the call rejects
40
+ * with `RequestTimeoutError` after the human-interaction timeout
41
+ * ({@link HUMAN_INTERACTION_TIMEOUT_MS}, 10 minutes).
42
+ *
43
+ * For images, `useImageUpload().open({ purpose: 'generationSource' })` remains
44
+ * valid; this hook is the one that also takes video, audio and in-tab bytes.
45
+ *
46
+ * @example
47
+ * // A recording made in the tab, passed into a step.
48
+ * const { upload } = useMediaUpload();
49
+ * const audio = await upload({ kind: 'audio', bytes: await recording.arrayBuffer(), mimeType: recording.type });
50
+ * // audio.url goes into the step's audio input
51
+ *
52
+ * @example
53
+ * // The viewer picks a video.
54
+ * const { open } = useMediaUpload();
55
+ * const video = await open({ kind: 'video' });
56
+ * if (video) {
57
+ * // video.url, video.width, video.height, video.durationMs
58
+ * }
59
+ */
60
+ export function useMediaUpload() {
61
+ useTransportConnection();
62
+ const open = useCallback(async ({ kind }) => {
63
+ if (!isMediaKind(kind))
64
+ throw new Error(MEDIA_UPLOAD_KIND_ERROR);
65
+ const reply = await sendTypedRequest(getTransport(), { type: 'OPEN_MEDIA_UPLOAD', payload: { kind, purpose: 'generationSource' } }, 'MEDIA_UPLOAD_RESULT',
66
+ // A person picks the file, and the reply waits for the upload.
67
+ { timeoutMs: HUMAN_INTERACTION_TIMEOUT_MS });
68
+ // 🔴 BEFORE reading `selected`: a refusal carries none, and would
69
+ // otherwise resolve `null` exactly like a dismiss.
70
+ throwOnReplyError(reply, 'media upload failed');
71
+ return reply.selected ?? null;
72
+ }, []);
73
+ const upload = useCallback(async ({ kind, bytes, mimeType }) => {
74
+ // The host's own refusals, in the host's order, raised here so a doomed
75
+ // buffer is never copied across postMessage.
76
+ if (!isMediaKind(kind))
77
+ throw new Error(MEDIA_UPLOAD_KIND_ERROR);
78
+ if (!(bytes instanceof ArrayBuffer) || bytes.byteLength === 0) {
79
+ throw new Error(MEDIA_UPLOAD_INVALID_ERROR);
80
+ }
81
+ if (mimeType !== undefined && typeof mimeType !== 'string') {
82
+ throw new Error(MEDIA_UPLOAD_INVALID_ERROR);
83
+ }
84
+ const cap = mediaUploadCap(kind);
85
+ if (bytes.byteLength > cap.maxBytes)
86
+ throw new Error(cap.error);
87
+ const reply = await sendTypedRequest(getTransport(), {
88
+ type: 'OPEN_MEDIA_UPLOAD',
89
+ payload: {
90
+ kind,
91
+ purpose: 'generationSource',
92
+ bytes,
93
+ ...(mimeType !== undefined ? { mimeType } : {}),
94
+ },
95
+ }, 'MEDIA_UPLOAD_RESULT',
96
+ // `'human'` class: a 64 MB upload plus the host's retries can outlast
97
+ // the default protocol timeout.
98
+ { timeoutMs: HUMAN_INTERACTION_TIMEOUT_MS });
99
+ throwOnReplyError(reply, 'media upload failed');
100
+ // A bare reply is the host declining (review mode / not ready). Nothing
101
+ // was uploaded, so this is a failure, not a success.
102
+ if (!reply.selected)
103
+ throw new Error(MEDIA_UPLOAD_DECLINED_ERROR);
104
+ return reply.selected;
105
+ }, []);
106
+ return { open, upload };
107
+ }
108
+ //# sourceMappingURL=useMediaUpload.js.map
package/dist/index.d.ts CHANGED
@@ -88,6 +88,8 @@ export { useImageUpload } from './hooks/useImageUpload.js';
88
88
  export type { UseImageUpload, UseImageUploadAsyncScan, UseImageUploadGenerationSource, UseImageUploadOptions, } from './hooks/useImageUpload.js';
89
89
  export { useUploadImageBytes } from './hooks/useUploadImageBytes.js';
90
90
  export type { UploadImageBytesOptions, UseUploadImageBytes, } from './hooks/useUploadImageBytes.js';
91
+ export { useMediaUpload } from './hooks/useMediaUpload.js';
92
+ export type { UseMediaUpload, MediaUploadOpenOptions, MediaUploadBytesOptions, } from './hooks/useMediaUpload.js';
91
93
  export { useImagePicker, IMAGE_PICKER_MULTIPLE_MAX } from './hooks/useImagePicker.js';
92
94
  export type { UseImagePicker, ImagePickerOpenOptions, ImagePickerMultiple, } from './hooks/useImagePicker.js';
93
95
  export { useGenerationResources } from './hooks/useGenerationResources.js';
package/dist/index.js CHANGED
@@ -69,6 +69,7 @@ export { useCheckpointPicker } from './hooks/useCheckpointPicker.js';
69
69
  export { useResourcePicker, RESOURCE_PICKER_MULTIPLE_MAX } from './hooks/useResourcePicker.js';
70
70
  export { useImageUpload } from './hooks/useImageUpload.js';
71
71
  export { useUploadImageBytes } from './hooks/useUploadImageBytes.js';
72
+ export { useMediaUpload } from './hooks/useMediaUpload.js';
72
73
  export { useImagePicker, IMAGE_PICKER_MULTIPLE_MAX } from './hooks/useImagePicker.js';
73
74
  export { useGenerationResources } from './hooks/useGenerationResources.js';
74
75
  export { GENERATION_RESOURCES_API_BASE, MAX_GENERATION_RESOURCE_IDS, buildGenerationResourcesUrl, responseToResources, } from './api/generationResources.js';
@@ -47,7 +47,7 @@
47
47
  * fetch chokepoints ..... 2
48
48
  * REST endpoints ........ GET /api/v1/blocks/me
49
49
  * tRPC procedures ....... 29 = blocks.* (14) + apps.shared.* (10) + apps.storage.* (5)
50
- * switch case labels .... 52, of which 14 REFUSE (enumerated under SCOPE below)
50
+ * switch case labels .... 53, of which 15 REFUSE (enumerated under SCOPE below)
51
51
  * --- END DERIVED ---
52
52
  *
53
53
  * PICKERS (Phase 1 of "make dev:live a faithful local host"): the live host
@@ -135,6 +135,9 @@
135
135
  * • OPEN_IMAGE_PICKER — needs the host's picker over the viewer's own posted
136
136
  * images and generator outputs, read off their signed-in session; no
137
137
  * block-token endpoint lists either. Replies with a refusal.
138
+ * • OPEN_MEDIA_UPLOAD — the generation-input upload runs in the host with the
139
+ * viewer's signed-in session, which the local harness does not have.
140
+ * Replies with a refusal.
138
141
  * • SAVE_IMAGE — the download bridge is the production host's unsandboxed top
139
142
  * frame plus its CDN origin allowlist / gated per-viewer read. Replies with
140
143
  * a refusal (#386).
@@ -153,6 +156,11 @@ import { type PickerOverlayHandle } from './pickerOverlay.js';
153
156
  * rejects with it, so it says what to do instead.
154
157
  */
155
158
  export declare const IMAGE_PICKER_UNSUPPORTED_ERROR = "the image picker is not supported in dev:live \u2014 use dev:mock";
159
+ /**
160
+ * The `error` the live host answers `OPEN_MEDIA_UPLOAD` with. `useMediaUpload()`
161
+ * rejects with it, so it says what to do instead.
162
+ */
163
+ export declare const MEDIA_UPLOAD_UNSUPPORTED_ERROR = "media upload is not supported in dev:live \u2014 use dev:mock";
156
164
  /**
157
165
  * Options for {@link createLiveHost}. Mirrors the {@link MockHost}-relevant
158
166
  * subset of `MockHostOptions`, plus the live-only `blockToken` / `backendBaseUrl`
@@ -47,7 +47,7 @@
47
47
  * fetch chokepoints ..... 2
48
48
  * REST endpoints ........ GET /api/v1/blocks/me
49
49
  * tRPC procedures ....... 29 = blocks.* (14) + apps.shared.* (10) + apps.storage.* (5)
50
- * switch case labels .... 52, of which 14 REFUSE (enumerated under SCOPE below)
50
+ * switch case labels .... 53, of which 15 REFUSE (enumerated under SCOPE below)
51
51
  * --- END DERIVED ---
52
52
  *
53
53
  * PICKERS (Phase 1 of "make dev:live a faithful local host"): the live host
@@ -135,6 +135,9 @@
135
135
  * • OPEN_IMAGE_PICKER — needs the host's picker over the viewer's own posted
136
136
  * images and generator outputs, read off their signed-in session; no
137
137
  * block-token endpoint lists either. Replies with a refusal.
138
+ * • OPEN_MEDIA_UPLOAD — the generation-input upload runs in the host with the
139
+ * viewer's signed-in session, which the local harness does not have.
140
+ * Replies with a refusal.
138
141
  * • SAVE_IMAGE — the download bridge is the production host's unsandboxed top
139
142
  * frame plus its CDN origin allowlist / gated per-viewer read. Replies with
140
143
  * a refusal (#386).
@@ -161,6 +164,11 @@ const REACTIONS_UNSUPPORTED_LOG = 'SET_ENTITY_REACTION / GET_ENTITY_REACTIONS ar
161
164
  * rejects with it, so it says what to do instead.
162
165
  */
163
166
  export const IMAGE_PICKER_UNSUPPORTED_ERROR = 'the image picker is not supported in dev:live — use dev:mock';
167
+ /**
168
+ * The `error` the live host answers `OPEN_MEDIA_UPLOAD` with. `useMediaUpload()`
169
+ * rejects with it, so it says what to do instead.
170
+ */
171
+ export const MEDIA_UPLOAD_UNSUPPORTED_ERROR = 'media upload is not supported in dev:live — use dev:mock';
164
172
  /** Default civitai backend the live host forwards to. */
165
173
  const DEFAULT_BACKEND_BASE_URL = 'https://civitai.com';
166
174
  /** The budgeted-spend scope the workflow procedures require. */
@@ -1414,6 +1422,24 @@ export function createLiveHost(options) {
1414
1422
  });
1415
1423
  return;
1416
1424
  }
1425
+ case 'OPEN_MEDIA_UPLOAD': {
1426
+ // REFUSED. The host uploads the file with the viewer's signed-in
1427
+ // session; this harness has none, and nothing a dev block token can
1428
+ // call takes the upload. Rather than let `useMediaUpload()` hang to
1429
+ // its 10-minute timeout, or hand back an invented URL a step would
1430
+ // then fail on, answer with an error (the hook rejects).
1431
+ logOnce('open-media-upload', 'OPEN_MEDIA_UPLOAD received but live dev mode cannot upload generation inputs ' +
1432
+ '— useMediaUpload() REJECTS. Test it against the real site, ' +
1433
+ 'or use createMockHost({ cannedMediaUploads }) in dev:mock.');
1434
+ // A refusal nobody can route is not worth sending.
1435
+ if (!isRoutableRequestId(requestId))
1436
+ return;
1437
+ dispatchToBlock({
1438
+ type: 'MEDIA_UPLOAD_RESULT',
1439
+ payload: { requestId, error: MEDIA_UPLOAD_UNSUPPORTED_ERROR },
1440
+ });
1441
+ return;
1442
+ }
1417
1443
  case 'SET_USER_CHECKPOINT': {
1418
1444
  // 🔴 A PAGE REFUSES, ALWAYS, WITHOUT A BACKEND CALL — and the default
1419
1445
  // context is a page. That is the production page host exactly: the
@@ -0,0 +1,169 @@
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 type { BlockMediaKind, BlockMediaUploadInfo } from '@civitai/app-sdk/blocks';
24
+ /** The cap on one VIDEO or AUDIO upload: 64 MiB of raw bytes (the consumer-blob upload cap). */
25
+ export declare const MEDIA_UPLOAD_MAX_BYTES: number;
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 declare const MEDIA_UPLOAD_IMAGE_MAX_BYTES: number;
31
+ /** The bytes-mode rolling window: per this many milliseconds, per mounted page host… */
32
+ export declare const MEDIA_UPLOAD_WINDOW_MS = 60000;
33
+ /** …at most this many admitted bytes uploads… */
34
+ export declare const MEDIA_UPLOAD_MAX_PER_WINDOW = 8;
35
+ /** …and this many admitted bytes. */
36
+ export declare const MEDIA_UPLOAD_MAX_BYTES_PER_WINDOW: number;
37
+ /** `bytes` is not a non-empty `ArrayBuffer`, or `mimeType` is present but not a string. */
38
+ export declare const MEDIA_UPLOAD_INVALID_ERROR = "invalid media-upload request";
39
+ export declare const MEDIA_UPLOAD_KIND_ERROR = "kind must be 'image', 'video' or 'audio'";
40
+ export declare const MEDIA_UPLOAD_PURPOSE_ERROR = "purpose must be 'generationSource'";
41
+ /** Over {@link MEDIA_UPLOAD_MAX_BYTES} (video, audio). */
42
+ export declare const MEDIA_UPLOAD_TOO_LARGE_ERROR = "file exceeds the 64 MB upload limit";
43
+ /** Over {@link MEDIA_UPLOAD_IMAGE_MAX_BYTES} (image). */
44
+ export declare 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 declare const MEDIA_UPLOAD_NO_TOKEN_ERROR = "no block token";
47
+ /** The sniffed content is not one of the requested kind's types. */
48
+ export declare const MEDIA_UPLOAD_TYPE_ERROR: Readonly<Record<BlockMediaKind, string>>;
49
+ export declare const MEDIA_UPLOAD_MIME_MISMATCH_ERROR = "mimeType does not match the file's content";
50
+ /** The bytes-mode window is full. Retryable. */
51
+ export declare const MEDIA_UPLOAD_BUSY_ERROR = "busy";
52
+ /** The kinds the bridge knows. */
53
+ export declare const MEDIA_UPLOAD_KINDS: readonly BlockMediaKind[];
54
+ export declare function isMediaKind(v: unknown): v is BlockMediaKind;
55
+ /** The per-file cap for a kind, and the refusal past it. */
56
+ export declare function mediaUploadCap(kind: BlockMediaKind): {
57
+ maxBytes: number;
58
+ error: string;
59
+ };
60
+ /** One admitted bytes upload in the window: when, and how many bytes. */
61
+ export type MediaUploadWindowEntry = {
62
+ at: number;
63
+ size: number;
64
+ };
65
+ /**
66
+ * The content type of `bytes` WITHIN the requested kind, by magic bytes, or
67
+ * `null` when the content is none of that kind's types (a PNG sent as `audio`
68
+ * is `null`: a type refusal). WebM is `video/webm` under `video` and
69
+ * `audio/webm` under `audio`.
70
+ */
71
+ export declare function sniffMediaBytes(kind: BlockMediaKind, u8: Uint8Array): string | null;
72
+ /** A declared MIME type as the host compares it: lowercased, `;params` cut, `audio/x-wav` read as `audio/wav`. */
73
+ export declare function normaliseMediaMimeType(mimeType: string): string;
74
+ /**
75
+ * The host's decision for one `OPEN_MEDIA_UPLOAD`, in the host's order, from
76
+ * step 4 of the contract on (the caller has already dropped an unroutable
77
+ * `requestId`; review mode and readiness do not exist in the mock):
78
+ * 1. `kind` not known → kind error; `purpose` not `'generationSource'` →
79
+ * purpose error;
80
+ * 2. no `bytes` (absent or `null`) → PICKER mode (not windowed: a human
81
+ * picks);
82
+ * 3. `bytes` not a non-empty `ArrayBuffer`, or a non-string `mimeType` →
83
+ * invalid;
84
+ * 4. no block token (`opts.noToken`) → `no block token`, before the cap
85
+ * and the window;
86
+ * 5. over the kind's cap ({@link mediaUploadCap}: 24 MiB for an image,
87
+ * 64 MiB for video and audio) → too large, before the window, so an
88
+ * over-cap file is never `busy` (a retry could never succeed);
89
+ * 6. the window, NOT recording, so a full window refuses `busy` before any
90
+ * sniffing;
91
+ * 7. the sniffed content in the kind's set → else the kind's type error;
92
+ * 8. a present `mimeType` (normalised) equal to the sniffed type → else
93
+ * mismatch;
94
+ * 9. record the upload in the window. Only an upload that will run counts.
95
+ * Returns the pruned window to store back.
96
+ */
97
+ export declare function admitMediaUpload(raw: {
98
+ kind?: unknown;
99
+ purpose?: unknown;
100
+ bytes?: unknown;
101
+ mimeType?: unknown;
102
+ }, recent: readonly MediaUploadWindowEntry[], now: number, opts?: {
103
+ noToken?: boolean;
104
+ }): {
105
+ result: {
106
+ ok: true;
107
+ mode: 'picker';
108
+ kind: BlockMediaKind;
109
+ } | {
110
+ ok: true;
111
+ mode: 'bytes';
112
+ kind: BlockMediaKind;
113
+ bytes: ArrayBuffer;
114
+ mimeType: string;
115
+ } | {
116
+ ok: false;
117
+ error: string;
118
+ };
119
+ recent: MediaUploadWindowEntry[];
120
+ };
121
+ /**
122
+ * The mock host's canned uploads, one per kind. A kind left out answers
123
+ * {@link DEFAULT_MOCK_MEDIA_UPLOADS}; `null` for a kind = the viewer closes that
124
+ * picker (bytes mode, which has no picker, then answers the default).
125
+ */
126
+ export type MockMediaUploads = Partial<Record<BlockMediaKind, BlockMediaUploadInfo | null>>;
127
+ /**
128
+ * What the mock host answers when no canned upload is configured for a kind.
129
+ * Every field differs between kinds, so a block that mixes two up shows it.
130
+ * The URLs are deliberately unresolvable: the mock uploads nothing.
131
+ */
132
+ export declare const DEFAULT_MOCK_MEDIA_UPLOADS: Readonly<Record<BlockMediaKind, BlockMediaUploadInfo>>;
133
+ /**
134
+ * The mock host's whole answer to one routable `OPEN_MEDIA_UPLOAD`, on a page
135
+ * surface: {@link admitMediaUpload}, then
136
+ * - picker mode: the canned upload for the kind, or a bare reply (dismissed)
137
+ * when that kind is canned `null`;
138
+ * - bytes mode: the canned upload for the kind (the default when it is
139
+ * `null`: there is no viewer to dismiss), carrying what the host would
140
+ * report for THESE bytes: the sniffed `mimeType` and the buffer's size for
141
+ * video and audio; `image/jpeg` and the canned size for an image, which the
142
+ * host re-encodes;
143
+ * - `forcedError`, when set, replaces a success: the stand-in for the
144
+ * decode and upload failures the mock cannot produce. A bytes upload it
145
+ * fails has still been admitted, so it still counts against the window, as
146
+ * on the host. ONE value is placed where the host checks it instead:
147
+ * `no block token` refuses a bytes upload right after the buffer check,
148
+ * before the cap and the window, and is not counted.
149
+ */
150
+ export declare function mockMediaUploadReply(args: {
151
+ payload: {
152
+ kind?: unknown;
153
+ purpose?: unknown;
154
+ bytes?: unknown;
155
+ mimeType?: unknown;
156
+ };
157
+ canned: MockMediaUploads;
158
+ forcedError: string | undefined;
159
+ recent: readonly MediaUploadWindowEntry[];
160
+ now: number;
161
+ }): {
162
+ reply: {
163
+ selected: BlockMediaUploadInfo;
164
+ } | {
165
+ error: string;
166
+ } | Record<string, never>;
167
+ recent: MediaUploadWindowEntry[];
168
+ };
169
+ //# sourceMappingURL=mediaUploadBytes.d.ts.map