@civitai/blocks-react 0.74.1 → 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 +236 -0
- package/dist/hooks/useMediaUpload.d.ts +97 -0
- package/dist/hooks/useMediaUpload.js +108 -0
- package/dist/hooks/useReactions.d.ts +126 -0
- package/dist/hooks/useReactions.js +384 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +2 -0
- package/dist/internal/liveHost.d.ts +14 -1
- package/dist/internal/liveHost.js +70 -1
- package/dist/internal/mediaUploadBytes.d.ts +169 -0
- package/dist/internal/mediaUploadBytes.js +268 -0
- package/dist/internal/mockHost.d.ts +62 -2
- package/dist/internal/mockHost.js +104 -2
- package/dist/internal/mockHostReactions.d.ts +62 -0
- package/dist/internal/mockHostReactions.js +166 -0
- package/dist/internal/mockHostStepInput.d.ts +62 -0
- package/dist/internal/mockHostStepInput.js +120 -0
- package/dist/internal/mockWorkflowGraph.d.ts +21 -1
- package/dist/internal/mockWorkflowGraph.js +22 -6
- package/dist/internal/stepInputLimits.d.ts +104 -0
- package/dist/internal/stepInputLimits.js +179 -0
- package/dist/testing.d.ts +2 -0
- package/dist/transport/requestTimeouts.js +7 -0
- package/dist/transport/validate.d.ts +35 -0
- package/dist/transport/validate.js +175 -0
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/package.json +3 -3
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.
|
|
@@ -1273,6 +1281,112 @@ async function toggle() {
|
|
|
1273
1281
|
Most blocks want `<FollowButton>` from `@civitai/blocks-react/ui` instead, which
|
|
1274
1282
|
wires all of the above.
|
|
1275
1283
|
|
|
1284
|
+
### `useReactions(entityType, ids, { tickets })`
|
|
1285
|
+
|
|
1286
|
+
Read and set the **viewer's** reactions on images this app was served, over
|
|
1287
|
+
`GET_ENTITY_REACTIONS` / `SET_ENTITY_REACTION`. Returns
|
|
1288
|
+
`{ byId, unavailable, loading, error, toggle, pending }`.
|
|
1289
|
+
|
|
1290
|
+
It reads on mount, then reads again only the images that need it: an id added
|
|
1291
|
+
to `ids`, or one whose ticket changed. An id that leaves `ids` costs nothing.
|
|
1292
|
+
Every read counts against the block instance's catalog rate limit on the host,
|
|
1293
|
+
which every viewer of a page app shares with its other catalog reads.
|
|
1294
|
+
|
|
1295
|
+
A reaction made here **is** a reaction on civitai.com: it pays the reactor and
|
|
1296
|
+
the image owner exactly as the site's own button does, moves rankings, notifies,
|
|
1297
|
+
and lists the viewer among the reactors. It draws on the **same** per-viewer
|
|
1298
|
+
quota as the site. Apps may set `Like`, `Heart`, `Laugh` and `Cry`; never
|
|
1299
|
+
`Dislike`. Image is the only entity type today.
|
|
1300
|
+
|
|
1301
|
+
🔴 **Scope `social:react:self`: consent-gated and sensitive.** Declare it with a
|
|
1302
|
+
`scopeJustifications` entry. Until the viewer grants it, every call fails with
|
|
1303
|
+
`needs-consent` (`err.needsConsent`): ask with `useRequestConsent()`. There is
|
|
1304
|
+
no per-tap confirm.
|
|
1305
|
+
|
|
1306
|
+
🔴 **Tickets are required, and only the REST block endpoints supply them.** Each
|
|
1307
|
+
image that `/api/v1/blocks/images`, `/api/v1/blocks/gated-images` or
|
|
1308
|
+
`/api/v1/blocks/collections/{id}` serves, fetched with the block token for a
|
|
1309
|
+
signed-in viewer, carries a `reactTicket` signed for this app and this viewer
|
|
1310
|
+
for 24 hours. **No hook in this package supplies one yet:** `useGatedImages()`
|
|
1311
|
+
goes through the host bridge, whose reply carries no `reactTicket`
|
|
1312
|
+
([#687](https://github.com/civitai/civitai-app-starters/issues/687)). Pass the
|
|
1313
|
+
tickets as `tickets`, keyed by image id. An image without a valid one is not
|
|
1314
|
+
answered by the read (it lands in `unavailable`, which is **not** "not reacted")
|
|
1315
|
+
and a `toggle` on it is refused locally, before anything is sent.
|
|
1316
|
+
|
|
1317
|
+
`toggle(id, reaction, reacted)` sets a **desired state**, moves `byId[id]`
|
|
1318
|
+
optimistically, and puts it back on failure. It rejects with a `ReactionError`:
|
|
1319
|
+
|
|
1320
|
+
| | meaning | what to do |
|
|
1321
|
+
|---|---|---|
|
|
1322
|
+
| `err.signInRequired` | no session | route into `useRequestSignIn()` |
|
|
1323
|
+
| `err.needsConsent` | the scope is not granted | `useRequestConsent()` |
|
|
1324
|
+
| `err.code === 'rate-limited'` | the viewer's reaction quota is spent | back off |
|
|
1325
|
+
| `err.code === 'entity-unavailable'` | no valid ticket, an image the viewer may not react to, the kill switch, or a network failure | retry later or give up |
|
|
1326
|
+
| `err.code` otherwise | `invalid-request` / `review-mode` / `not-ready` | a block bug, a review sandbox, or retry after `ready` |
|
|
1327
|
+
| `err.timedOut` | no reply within 30s | the write **may** have landed; the hook re-reads that image |
|
|
1328
|
+
| `err.busy` | the same image and reaction already has a request in flight. Rejects the call only: `error` is not set, because the write it collided with is still running | disable the control while `pending(id, reaction)` |
|
|
1329
|
+
|
|
1330
|
+
🔴 **Bind `<civitai-reaction>` from `byId` on every render; never let it keep
|
|
1331
|
+
its own state.** The element flips `reacted` and `count` itself on a click. When
|
|
1332
|
+
the hook makes no optimistic move of its own (an image not read yet, or a
|
|
1333
|
+
refusal such as a missing ticket), `byId` does not change, so an element that
|
|
1334
|
+
only listens would keep showing a reaction that does not exist. Every rejected
|
|
1335
|
+
`toggle` re-renders the owner: a refusal or a failed write sets `error`, and a
|
|
1336
|
+
`busy` refusal is followed by the in-flight write's own settle, which clears
|
|
1337
|
+
`pending`. An element whose `reacted` and `count` come from `byId` on each
|
|
1338
|
+
render is then put back. Outside React, set the two properties back yourself
|
|
1339
|
+
after a rejection.
|
|
1340
|
+
|
|
1341
|
+
```tsx
|
|
1342
|
+
import { useEffect, useState } from 'react';
|
|
1343
|
+
import { isAppReaction } from '@civitai/app-sdk/blocks';
|
|
1344
|
+
import { useBlockToken, useHostOrigin, useReactions } from '@civitai/blocks-react';
|
|
1345
|
+
|
|
1346
|
+
type ServedImage = { id: number; url: string; reactTicket?: string };
|
|
1347
|
+
|
|
1348
|
+
function useReactableImages() {
|
|
1349
|
+
const host = useHostOrigin();
|
|
1350
|
+
const { raw } = useBlockToken();
|
|
1351
|
+
const [images, setImages] = useState<ServedImage[]>([]);
|
|
1352
|
+
|
|
1353
|
+
// The tickets come from the REST endpoint, fetched with the block token.
|
|
1354
|
+
useEffect(() => {
|
|
1355
|
+
if (!host || !raw) return;
|
|
1356
|
+
void fetch(`${host}/api/v1/blocks/images?limit=20`, {
|
|
1357
|
+
headers: { Authorization: `Bearer ${raw}` },
|
|
1358
|
+
})
|
|
1359
|
+
.then((res) => res.json() as Promise<{ items: ServedImage[] }>)
|
|
1360
|
+
.then((body) => setImages(body.items));
|
|
1361
|
+
}, [host, raw]);
|
|
1362
|
+
|
|
1363
|
+
const reactions = useReactions(
|
|
1364
|
+
'image',
|
|
1365
|
+
images.map((image) => image.id),
|
|
1366
|
+
{ tickets: Object.fromEntries(images.map((image) => [image.id, image.reactTicket])) },
|
|
1367
|
+
);
|
|
1368
|
+
|
|
1369
|
+
// Props for one `<civitai-reaction>` (e.g. `CivitaiReaction` from
|
|
1370
|
+
// `@civitai/components-react`), derived from `byId` on every render.
|
|
1371
|
+
const bind = (id: number, reaction: 'Like' | 'Heart' | 'Laugh' | 'Cry') => ({
|
|
1372
|
+
reaction,
|
|
1373
|
+
reacted: reactions.byId[id]?.viewerReactions.includes(reaction) ?? false,
|
|
1374
|
+
count: reactions.byId[id]?.counts[reaction] ?? 0,
|
|
1375
|
+
disabled: !reactions.byId[id] || reactions.pending(id, reaction),
|
|
1376
|
+
onReact: (event: CustomEvent<{ reaction?: string; reacted: boolean }>) => {
|
|
1377
|
+
const { reaction: which, reacted } = event.detail;
|
|
1378
|
+
// `detail.reaction` is any string the element was given; narrow it.
|
|
1379
|
+
if (!isAppReaction(which)) return;
|
|
1380
|
+
// A rejection needs no handling here to put the element back: it
|
|
1381
|
+
// re-renders, and `bind` reads `byId` again.
|
|
1382
|
+
reactions.toggle(id, which, reacted).catch(() => {});
|
|
1383
|
+
},
|
|
1384
|
+
});
|
|
1385
|
+
|
|
1386
|
+
return { images, bind, error: reactions.error };
|
|
1387
|
+
}
|
|
1388
|
+
```
|
|
1389
|
+
|
|
1276
1390
|
### `useCreatePostFromApp()`
|
|
1277
1391
|
|
|
1278
1392
|
Publish a **real, published Post on the viewer's profile** from this app's own
|
|
@@ -2369,6 +2483,126 @@ an invalid `sources` or `multiple` with an error, and answers
|
|
|
2369
2483
|
list the viewer's images, so there `open` rejects with a message saying to use
|
|
2370
2484
|
the mock host.
|
|
2371
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
|
+
|
|
2372
2606
|
### `useSaveImage()`
|
|
2373
2607
|
|
|
2374
2608
|
Ask the host to download a file to the viewer's device. **A block cannot
|
|
@@ -3318,6 +3552,8 @@ MockHost
|
|
|
3318
3552
|
MockHostFailMode
|
|
3319
3553
|
MockHostOptions
|
|
3320
3554
|
MockHostScenarioPatch
|
|
3555
|
+
MockMediaUploads
|
|
3556
|
+
MockReactionSeed
|
|
3321
3557
|
MockSharedScenario
|
|
3322
3558
|
MockSharedSeed
|
|
3323
3559
|
MockStorageScenario
|
|
@@ -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
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { BlockAppReaction, BlockEntityReactionErrorCode, BlockEntityReactionResult, BlockEntityReactionState, BlockReactionCounts, BlockReactionEntityType, BlockStoredReaction } from '@civitai/app-sdk/blocks';
|
|
2
|
+
export type { BlockAppReaction, BlockEntityReactionErrorCode, BlockEntityReactionResult, BlockEntityReactionState, BlockReactionCounts, BlockReactionEntityType, BlockStoredReaction, };
|
|
3
|
+
/**
|
|
4
|
+
* The reactions an app may set, as a runtime value: `Like`, `Heart`, `Laugh`,
|
|
5
|
+
* `Cry`. Never `Dislike` (the host refuses it).
|
|
6
|
+
*
|
|
7
|
+
* Declared here rather than value-imported from `@civitai/app-sdk/blocks` (where
|
|
8
|
+
* `APP_REACTIONS` holds the same list) so this hook does not raise the package's
|
|
9
|
+
* `@civitai/app-sdk` peer floor; see `PEER_FLOOR.md`. `test/useReactions.test.tsx`
|
|
10
|
+
* asserts the two lists are equal.
|
|
11
|
+
*/
|
|
12
|
+
export declare const REACTIONS: readonly ["Like", "Heart", "Laugh", "Cry"];
|
|
13
|
+
/**
|
|
14
|
+
* The CLOSED set of codes `ENTITY_REACTION(S)_RESULT` carries, as a runtime
|
|
15
|
+
* value. Mirrors civitai's `ENTITY_REACTION_ERRORS` (and `@civitai/app-sdk`'s
|
|
16
|
+
* `ENTITY_REACTION_ERROR_CODES`); declared locally for the peer-floor reason on
|
|
17
|
+
* {@link REACTIONS}.
|
|
18
|
+
*/
|
|
19
|
+
export declare const REACTION_ERROR_CODES: readonly ["invalid-request", "sign-in-required", "review-mode", "not-ready", "needs-consent", "entity-unavailable", "rate-limited"];
|
|
20
|
+
/** `true` when `error` is one of the closed {@link REACTION_ERROR_CODES}. */
|
|
21
|
+
export declare function isReactionErrorCode(error: string): error is BlockEntityReactionErrorCode;
|
|
22
|
+
/**
|
|
23
|
+
* A reaction read or write that did not succeed.
|
|
24
|
+
*
|
|
25
|
+
* `.code` is the host's closed code, or `undefined` for a transport timeout, a
|
|
26
|
+
* code a newer host added, or a local {@link ReactionError.busy} refusal.
|
|
27
|
+
* Check the flags before `.message`: only a `.code` is meant to be branched on,
|
|
28
|
+
* and none of these messages is written for a viewer.
|
|
29
|
+
*/
|
|
30
|
+
export declare class ReactionError extends Error {
|
|
31
|
+
readonly code?: BlockEntityReactionErrorCode;
|
|
32
|
+
/**
|
|
33
|
+
* No reply arrived in time. 🔴 The write MAY have landed: the host could have
|
|
34
|
+
* completed it and failed to deliver the reply. The hook reverts its
|
|
35
|
+
* optimistic state and re-reads that image, so `byId` converges on the truth.
|
|
36
|
+
*/
|
|
37
|
+
readonly timedOut: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Refused locally because the same image and reaction already has a request
|
|
40
|
+
* in flight. Nothing was sent. Disable the control while
|
|
41
|
+
* `pending(id, reaction)` is true and this does not happen.
|
|
42
|
+
*/
|
|
43
|
+
readonly busy: boolean;
|
|
44
|
+
/** No session. Route into `useRequestSignIn()`. */
|
|
45
|
+
readonly signInRequired: boolean;
|
|
46
|
+
/** The viewer has not granted `social:react:self`. Ask with `useRequestConsent()`. */
|
|
47
|
+
readonly needsConsent: boolean;
|
|
48
|
+
constructor(error: string, opts?: {
|
|
49
|
+
timedOut?: boolean;
|
|
50
|
+
busy?: boolean;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
/** Options for {@link useReactions}. */
|
|
54
|
+
export interface UseReactionsOptions {
|
|
55
|
+
/**
|
|
56
|
+
* The `reactTicket` each image arrived with, keyed by image id. A block
|
|
57
|
+
* endpoint (`/api/v1/blocks/images`, `/gated-images`, a collection's images)
|
|
58
|
+
* attaches one per image for a signed-in viewer, signed for this app and this
|
|
59
|
+
* viewer, valid 24 hours.
|
|
60
|
+
*
|
|
61
|
+
* 🔴 REQUIRED, for reads and writes alike. The host answers no image without
|
|
62
|
+
* a valid ticket: a read lists it in `unavailable`, and `toggle` on it is
|
|
63
|
+
* refused here with `entity-unavailable` before anything is sent, because
|
|
64
|
+
* the server would refuse it only AFTER spending one of the viewer's
|
|
65
|
+
* reactions from the quota the site's own button shares.
|
|
66
|
+
*/
|
|
67
|
+
tickets: Readonly<Record<number | string, string | undefined>>;
|
|
68
|
+
}
|
|
69
|
+
/** What {@link useReactions} returns. */
|
|
70
|
+
export interface UseReactions {
|
|
71
|
+
/**
|
|
72
|
+
* The known state per image id: counts, the viewer's reactions, and
|
|
73
|
+
* `statsUnknown`. An id is absent until a read answers it, and stays absent
|
|
74
|
+
* if the host listed it in {@link UseReactions.unavailable}.
|
|
75
|
+
*/
|
|
76
|
+
byId: Readonly<Record<number, BlockEntityReactionState>>;
|
|
77
|
+
/**
|
|
78
|
+
* Ids the host would not answer for want of a valid ticket. 🔴 These have NO
|
|
79
|
+
* known state; do not render them as "not reacted".
|
|
80
|
+
*/
|
|
81
|
+
unavailable: readonly number[];
|
|
82
|
+
/** `true` while a read is in flight. */
|
|
83
|
+
loading: boolean;
|
|
84
|
+
/** The last failure of a read or a write, or `null`. Cleared by the next one. */
|
|
85
|
+
error: ReactionError | null;
|
|
86
|
+
/**
|
|
87
|
+
* Set the viewer's `reaction` on image `id` to `reacted`, and resolve with
|
|
88
|
+
* what the host reports.
|
|
89
|
+
*
|
|
90
|
+
* Optimistic: `byId[id]` moves at once and moves back if the write fails.
|
|
91
|
+
* REJECTS with a {@link ReactionError} on every failure. On success `byId[id]`
|
|
92
|
+
* adopts the host's `viewerReactions`, the truth read back after the write.
|
|
93
|
+
* When the host's `reacted` differs from the one asked for, a concurrent
|
|
94
|
+
* change won, and `byId` follows the host.
|
|
95
|
+
*/
|
|
96
|
+
toggle: (id: number, reaction: BlockAppReaction, reacted: boolean) => Promise<BlockEntityReactionResult>;
|
|
97
|
+
/** `true` while a `toggle` for this image and reaction is in flight. */
|
|
98
|
+
pending: (id: number, reaction: BlockAppReaction) => boolean;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Read and set the VIEWER'S reactions on images this app was served, through
|
|
102
|
+
* the host's `GET_ENTITY_REACTIONS` and `SET_ENTITY_REACTION` bridge (scope
|
|
103
|
+
* `social:react:self`).
|
|
104
|
+
*
|
|
105
|
+
* A reaction made here is a reaction on civitai.com: it pays the reactor and
|
|
106
|
+
* the image owner exactly as the site's own button does, moves the image's
|
|
107
|
+
* rankings, notifies, and lists the viewer among the image's reactors. It draws
|
|
108
|
+
* on the SAME per-viewer quota as the site.
|
|
109
|
+
*
|
|
110
|
+
* 🔴 THE SCOPE IS CONSENT-GATED AND SENSITIVE. Declare `social:react:self` in
|
|
111
|
+
* the manifest with a `scopeJustifications` entry; until the viewer grants it,
|
|
112
|
+
* every call rejects with `needs-consent` (`.needsConsent`), which is the cue
|
|
113
|
+
* for `useRequestConsent()`. There is no per-tap confirm.
|
|
114
|
+
*
|
|
115
|
+
* Reads `ids` on mount in chunks of 100, then reads only what needs it: an id
|
|
116
|
+
* added to `ids`, or one whose ticket changed. Changing `entityType` re-reads
|
|
117
|
+
* nothing (image is the only type the host accepts today). After a write fails,
|
|
118
|
+
* an image is read again if the write timed out or the image has no state.
|
|
119
|
+
*
|
|
120
|
+
* @example
|
|
121
|
+
* const { byId, toggle, pending } = useReactions('image', images.map((i) => i.id), {
|
|
122
|
+
* tickets: Object.fromEntries(images.map((i) => [i.id, i.reactTicket])),
|
|
123
|
+
* });
|
|
124
|
+
*/
|
|
125
|
+
export declare function useReactions(entityType: BlockReactionEntityType, ids: readonly number[], options: UseReactionsOptions): UseReactions;
|
|
126
|
+
//# sourceMappingURL=useReactions.d.ts.map
|