@civitai/blocks-react 0.76.1 → 0.78.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 +125 -52
- package/dist/hooks/useImageUpload.d.ts +40 -6
- package/dist/hooks/useImageUpload.js +19 -3
- package/dist/hooks/useResourceFile.d.ts +71 -0
- package/dist/hooks/useResourceFile.js +113 -0
- package/dist/hooks/useRunTraining.d.ts +31 -10
- package/dist/hooks/useRunTraining.js +32 -7
- package/dist/index.d.ts +5 -5
- package/dist/index.js +1 -1
- package/dist/internal/liveHost.d.ts +7 -8
- package/dist/internal/liveHost.js +43 -28
- package/dist/internal/mockHost.d.ts +53 -44
- package/dist/internal/mockHost.js +219 -84
- package/dist/transport/requestTimeouts.d.ts +32 -7
- package/dist/transport/requestTimeouts.js +36 -12
- package/dist/transport/validate.d.ts +18 -17
- package/dist/transport/validate.js +97 -47
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/package.json +2 -2
- package/dist/hooks/usePrepareTrainingDataset.d.ts +0 -99
- package/dist/hooks/usePrepareTrainingDataset.js +0 -159
package/README.md
CHANGED
|
@@ -1229,6 +1229,67 @@ if (error instanceof WildcardPackError && error.code === 'busy') void refetch();
|
|
|
1229
1229
|
if (!loading && pack) console.log(Object.keys(pack.lists));
|
|
1230
1230
|
```
|
|
1231
1231
|
|
|
1232
|
+
### `useResourceFile(modelVersionId, fileId?)`
|
|
1233
|
+
|
|
1234
|
+
Read a resource file's parsed JSON — for a workflow, its graphs — by model
|
|
1235
|
+
version. The host resolves, fetches, unzips and parses the file **in the
|
|
1236
|
+
viewer's own page session**, enforcing the real download gates, so the iframe
|
|
1237
|
+
never sees the session, a signed URL or the raw bytes. No block scope is needed.
|
|
1238
|
+
Returns `{ file, loading, error, refetch }`.
|
|
1239
|
+
|
|
1240
|
+
- `file.meta` names what was served (`modelId`, `modelVersionId`, `fileId`,
|
|
1241
|
+
`fileName`, `modelName`, `versionName`, `creatorUsername`).
|
|
1242
|
+
- `file.entries` holds one `{ path, format, graph }` per JSON document: one for a
|
|
1243
|
+
plain `.json`, several for a `.zip` (non-JSON entries are skipped). `format`
|
|
1244
|
+
is `'ui'` (a `nodes` array), `'api'` (every top-level value has `class_type`
|
|
1245
|
+
and `inputs`) or `'unknown'`; check it before treating `graph` as a graph.
|
|
1246
|
+
`file.truncated` is `true` when an entry was dropped: past 20 entries, over
|
|
1247
|
+
the 4 MB per-entry or 16 MB total cap, unreadable, or not valid JSON.
|
|
1248
|
+
- Pass `fileId` to pin one file of a multi-file version. Without it the host
|
|
1249
|
+
picks one.
|
|
1250
|
+
- Changing `modelVersionId` or `fileId` resets `file` and `error` to `null`
|
|
1251
|
+
before the new fetch, so a file never shows for ids it does not belong to.
|
|
1252
|
+
- The request waits up to 120 s, because the host downloads and unzips the file.
|
|
1253
|
+
|
|
1254
|
+
On failure `error` is a `ResourceFileError` whose `code` is one of a closed set:
|
|
1255
|
+
|
|
1256
|
+
| `code` | Means | Retry? |
|
|
1257
|
+
|---|---|---|
|
|
1258
|
+
| `not-found` | No such version or file, not a workflow, or not downloadable for free (see below) | No |
|
|
1259
|
+
| `forbidden` | Above the viewer's maturity ceiling on this site, or the app is in review preview | No |
|
|
1260
|
+
| `unauthorized` | The viewer is signed out | No |
|
|
1261
|
+
| `too-large` | Over 32 MB, or every entry was over a size cap (4 MB per entry, 16 MB in total) | No |
|
|
1262
|
+
| `parse-failed` | Not a `.json`/`.zip`, corrupt, or no valid JSON — **or** the host's catch-all: a network error, a failed fetch, no answer within its 110 s deadline, a transient storage failure, or another server error | Once, maybe: it can be transient |
|
|
1263
|
+
| `busy` | Three fetches already in flight in this tab | Yes |
|
|
1264
|
+
| `rate-limited` | The viewer's 30-a-minute budget is spent | Yes, later |
|
|
1265
|
+
|
|
1266
|
+
🔴 **Full-page app hosts only — not the model-page slot, not `dev:live`.** The
|
|
1267
|
+
model-page slot does not answer it (the request times out), and `dev:live`
|
|
1268
|
+
replies `parse-failed`. Develop against `dev:mock`: `createMockHost` takes a
|
|
1269
|
+
`resourceFile` (the canned reply) and a `resourceFileError` (force a code);
|
|
1270
|
+
`setScenario({ resourceFileError: undefined })` clears the forced error.
|
|
1271
|
+
|
|
1272
|
+
🔴 **Only free, public versions are served.** An unpublished draft (the owner's
|
|
1273
|
+
included) and an early-access or paid version answer `not-found` — even for a
|
|
1274
|
+
viewer who bought it. Only the owner gets their own published early-access
|
|
1275
|
+
version. A POI model, or a model or version whose availability is not `Public`
|
|
1276
|
+
or `Unsearchable` (e.g. `Private`), is refused to everyone but the owner,
|
|
1277
|
+
moderators included.
|
|
1278
|
+
|
|
1279
|
+
```tsx
|
|
1280
|
+
const { file, loading, error, refetch } = useResourceFile(modelVersionId);
|
|
1281
|
+
// Retry a retryable code from an effect, after a delay — never during render.
|
|
1282
|
+
const retryable =
|
|
1283
|
+
error instanceof ResourceFileError && (error.code === 'busy' || error.code === 'rate-limited');
|
|
1284
|
+
useEffect(() => {
|
|
1285
|
+
if (!retryable) return;
|
|
1286
|
+
const t = setTimeout(refetch, 2_000);
|
|
1287
|
+
return () => clearTimeout(t);
|
|
1288
|
+
}, [retryable, refetch]);
|
|
1289
|
+
const graphs = file?.entries.filter((e) => e.format !== 'unknown') ?? [];
|
|
1290
|
+
if (!loading && file) console.log(file.meta.fileName, graphs.length, file.truncated);
|
|
1291
|
+
```
|
|
1292
|
+
|
|
1232
1293
|
### `useCollectionFollow()`
|
|
1233
1294
|
|
|
1234
1295
|
Follow / unfollow a collection **for the viewer**, host-mediated over
|
|
@@ -1463,43 +1524,55 @@ options drive both arms (including `declined`). **`dev:live` refuses this bridge
|
|
|
1463
1524
|
it has no civitai chrome to render the server-resolved confirm in, and driving
|
|
1464
1525
|
the write without it would let dev prove out a flow production does not have.
|
|
1465
1526
|
|
|
1466
|
-
### `
|
|
1467
|
-
|
|
1468
|
-
Train a LoRA with the ai-toolkit engine on the
|
|
1469
|
-
inside a page app — the App Blocks `kind: 'training'` flow
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1527
|
+
### `useImageUpload({ purpose: 'trainingData' })` and `useRunTraining()` — LoRA training
|
|
1528
|
+
|
|
1529
|
+
Train a LoRA with the ai-toolkit engine on **images the viewer uploads**, from
|
|
1530
|
+
inside a page app — the App Blocks `kind: 'training'` flow, with the same upload
|
|
1531
|
+
path and `{ air, caption }` dataset shape as Civitai's own training form. Four
|
|
1532
|
+
steps, three hooks:
|
|
1533
|
+
|
|
1534
|
+
1. **Upload** — `useImageUpload({ purpose: 'trainingData' }).open()`
|
|
1535
|
+
(`OPEN_IMAGE_UPLOAD { purpose: 'trainingData' }`). Civitai opens an upload
|
|
1536
|
+
modal in **its own chrome**; the viewer picks up to 50 PNG, JPEG or WebP
|
|
1537
|
+
images, and each is uploaded as a private blob (not published). `open()`
|
|
1538
|
+
resolves with every image that uploaded, `[{ url, air }]`, or `null` when
|
|
1539
|
+
the viewer cancels. Pair each `air` with your caption — captions up to 8,192
|
|
1540
|
+
characters, one blob at most once. The host answers only when its modal
|
|
1541
|
+
closes, after every original has uploaded, so `open()` waits up to an hour,
|
|
1542
|
+
not the ten minutes the other pickers use.
|
|
1481
1543
|
2. **Quote** — `useBuzzWorkflow().estimate(body)` with a `WorkflowBodyTraining`
|
|
1482
|
-
(`kind: 'training'`, no `quoteId`). The reply
|
|
1483
|
-
{ quoteId, total, imageCount, expiresAt }` — the
|
|
1484
|
-
exactly this run, stored server-side for 15
|
|
1544
|
+
(`kind: 'training'`, `items: [{ air, caption }]`, no `quoteId`). The reply
|
|
1545
|
+
carries `trainingQuote: { quoteId, total, imageCount, expiresAt }` — the
|
|
1546
|
+
orchestrator's own price for exactly this run, stored server-side for 15
|
|
1547
|
+
minutes.
|
|
1485
1548
|
3. **Run** — `useRunTraining().runTraining({ ...body, quoteId })` (`RUN_TRAINING`).
|
|
1486
1549
|
**Civitai shows the viewer a consent dialog in its own chrome** — price, base
|
|
1487
1550
|
model, length and dataset size, all read back from the server, never from
|
|
1488
|
-
your body
|
|
1489
|
-
|
|
1551
|
+
your body, with the host's own copies of the uploads as thumbnails — and
|
|
1552
|
+
submits only on their click. Change nothing in the body between the estimate
|
|
1553
|
+
and the run; the server checks it against the quoted one.
|
|
1490
1554
|
4. **Follow** — `useBuzzWorkflow().watch(workflowId)`. Once the run's moderation
|
|
1491
1555
|
status is approved, `trainedEpochs` lists the epochs whose checkpoint is
|
|
1492
1556
|
ready; send the viewer to the publish wizard with one of them (see
|
|
1493
1557
|
`trainedEpochs` on `BlockWorkflowSnapshot`).
|
|
1494
1558
|
|
|
1559
|
+
🔴 **Upload and run in the same page session.** The host remembers the blobs
|
|
1560
|
+
its own upload modal uploaded for this block, and **refuses a quote naming any
|
|
1561
|
+
other** with `training data not uploaded here` — before any dialog, so no run
|
|
1562
|
+
and no charge. A page reload forgets the uploads: ask the viewer to upload
|
|
1563
|
+
again, then estimate again.
|
|
1564
|
+
|
|
1495
1565
|
🔴 **Availability.** Behind the host flag **`app-blocks-training-kind`**, which
|
|
1496
1566
|
ships **off** and is evaluated per viewer. **Page apps only** — the model slot
|
|
1497
|
-
answers
|
|
1567
|
+
answers `RUN_TRAINING` with an error. The manifest must declare
|
|
1498
1568
|
**`ai:write:budgeted`** and the viewer must be signed in and have granted it.
|
|
1499
1569
|
**Refused from `dev:live` and from review sessions** (the server refuses
|
|
1500
1570
|
development and review tokens); build the flow against the mock host. Requires a
|
|
1501
1571
|
civitai.com host carrying civitai/civitai#5434 (`kind: 'training'`,
|
|
1502
|
-
`RUN_TRAINING`) and civitai/civitai#
|
|
1572
|
+
`RUN_TRAINING`) and civitai/civitai#6461 (uploaded blobs as the dataset). The
|
|
1573
|
+
server-prepared dataset this flow used before #6461 (`usePrepareTrainingDataset`,
|
|
1574
|
+
`PREPARE_TRAINING_DATASET`) is **removed**; a host carrying #6461 answers that
|
|
1575
|
+
message with the error `removed`.
|
|
1503
1576
|
|
|
1504
1577
|
🔴 **Money.** There is no `maxBuzz` and no timeout knob — the price is the quote.
|
|
1505
1578
|
A confirmed run may cost more than the token's per-call `buzzBudget` (the viewer
|
|
@@ -1516,32 +1589,26 @@ saying anything about money:
|
|
|
1516
1589
|
| `err.declined` | the viewer dismissed the dialog — **no run was submitted** | revert, say nothing |
|
|
1517
1590
|
| `err.unconfirmed` | `submission-unconfirmed`, or no reply within the 10-min bound (`err.timedOut`) — the run **may be running and charged** | 🔴 **never retry automatically**: check the viewer's trainings first (`useAppWorkflows()` lists this app's runs). Re-running the same body after the server did start it is a **second, separately charged run** |
|
|
1518
1591
|
| `err.refused` (`code: 'refused'`) | the server refused the submit after the viewer confirmed — a **spend cap** (their daily or private-run Buzz cap, the per-app consent budget, the app's daily spend or rate limit, a dev-session cap) or a **temporary-availability** deny. Refunded: **no run, nothing charged**; the quote is used up | show `err.message` (the server's reason, e.g. `daily Buzz cap reached: …`); estimate again before any retry — buying Buzz does not lift these caps |
|
|
1592
|
+
| `err.trainingDataNotUploaded` (`code: 'training data not uploaded here'`) | the quote names an image this host did not upload for this block (typically after a reload) — refused before any dialog: **no run, nothing charged** | ask the viewer to upload the images again, then estimate and run the new quote |
|
|
1519
1593
|
| `err.signInRequired` | no session | route into `useRequestSignIn()` |
|
|
1520
1594
|
| `err.code` set otherwise | a host refusal (`review-mode` / `block is not ready` / `invalid training request` / `no block token`) | show or ignore per case |
|
|
1521
|
-
| `err.code === undefined`, no flag | a server refusal before any submit (expired or used quote, changed body
|
|
1595
|
+
| `err.code === undefined`, no flag | a server refusal before any submit (expired or used quote, changed body) | estimate again, then retry |
|
|
1522
1596
|
|
|
1523
|
-
`
|
|
1524
|
-
|
|
1525
|
-
`invalid training dataset`, `no block token` — plus `.signInRequired` and
|
|
1526
|
-
`.timedOut`). Preparing charges nothing, so none of them cost Buzz. It prompts
|
|
1527
|
-
for `ai:write:budgeted` and retries once on a grant (see the table below);
|
|
1528
|
-
`runTraining` deliberately does not — it carries no idempotency key and has an
|
|
1529
|
-
outcome where a run may exist, so it never re-sends.
|
|
1597
|
+
`runTraining` never prompts for `ai:write:budgeted` or re-sends — it carries no
|
|
1598
|
+
idempotency key and has an outcome where a run may exist.
|
|
1530
1599
|
|
|
1531
1600
|
```tsx
|
|
1532
|
-
const {
|
|
1601
|
+
const { open } = useImageUpload({ purpose: 'trainingData' });
|
|
1533
1602
|
const { estimate, watch } = useBuzzWorkflow();
|
|
1534
1603
|
const { runTraining } = useRunTraining();
|
|
1535
1604
|
|
|
1536
|
-
async function train(
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
const dataset = await prepareDataset(images.map((i) => ({ imageId: i.id, caption: i.caption })));
|
|
1540
|
-
if (dataset.rejected.length > 0) showNotice(`${dataset.rejected.length} image(s) left out`);
|
|
1605
|
+
async function train(captionFor: (image: BlockTrainingDataImage) => string) {
|
|
1606
|
+
const images = await open();
|
|
1607
|
+
if (!images) return; // the viewer cancelled
|
|
1541
1608
|
|
|
1542
1609
|
const body: WorkflowBodyTraining = {
|
|
1543
1610
|
kind: 'training',
|
|
1544
|
-
|
|
1611
|
+
items: images.map((image) => ({ air: image.air, caption: captionFor(image) })),
|
|
1545
1612
|
engine: 'ai-toolkit',
|
|
1546
1613
|
model: baseModelKey, // a key from Civitai's training catalog
|
|
1547
1614
|
params: aiToolkitParams, // AiToolkitTrainingParams
|
|
@@ -1561,6 +1628,7 @@ async function train(images: Array<{ id: number; caption: string }>) {
|
|
|
1561
1628
|
if (err.declined) return; // no run
|
|
1562
1629
|
if (err.unconfirmed) return showCheckYourTrainings(); // may be running — never auto-retry
|
|
1563
1630
|
if (err.refused) return showError(err.message); // a cap / availability refusal; no run, nothing charged
|
|
1631
|
+
if (err.trainingDataNotUploaded) return showError('Upload the images again.'); // no run
|
|
1564
1632
|
if (err.signInRequired) return requestSignIn();
|
|
1565
1633
|
showError('Could not start training. Get a new price and try again.');
|
|
1566
1634
|
}
|
|
@@ -1569,19 +1637,22 @@ async function train(images: Array<{ id: number; caption: string }>) {
|
|
|
1569
1637
|
|
|
1570
1638
|
With the mock host — `createMockHost` or `Harness` from
|
|
1571
1639
|
`@civitai/blocks-react/testing` — the training path is **kind-faithful**: it
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
`ai:write:budgeted` on the
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1640
|
+
remembers the blob keys its uploads handed out and refuses a run naming any
|
|
1641
|
+
other (`training data not uploaded here`, quote kept), refuses items outside the
|
|
1642
|
+
server's schema at the estimate, a quote above 5,000, a quote run twice and a
|
|
1643
|
+
body that differs from the quoted one, and needs `ai:write:budgeted` on the
|
|
1644
|
+
token and a signed-in viewer. Its knobs are `cannedTrainingDataUpload` (the
|
|
1645
|
+
images an upload returns; default three fresh ones per upload, `null` for a
|
|
1646
|
+
cancel), `trainingDataNotUploadedHere` (refuse every run as a host that forgot
|
|
1647
|
+
its uploads would), `trainingQuoteTotal`, `runTrainingError` (`'declined'`,
|
|
1648
|
+
`'submission-unconfirmed'`, or any server message), `runTrainingCapRefusal` (the
|
|
1649
|
+
server's resolved cap / availability refusal, which consumes the quote →
|
|
1579
1650
|
`err.refused`) and `generation.trainedEpochs` for the finished run. It has no
|
|
1580
|
-
consent dialog, so `runTraining`
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
**`dev:live` refuses
|
|
1584
|
-
|
|
1651
|
+
upload modal and no consent dialog, so `open()` and `runTraining` settle at once
|
|
1652
|
+
where the real host waits on the viewer; and it accepts any https blob url as an
|
|
1653
|
+
`air`, where the server accepts only Civitai-hosted ones — name items by `air`.
|
|
1654
|
+
**`dev:live` refuses the run** (the server refuses every training request from a
|
|
1655
|
+
dev token) and answers the upload as a cancel.
|
|
1585
1656
|
|
|
1586
1657
|
### `useRunConfirmedStep()` — one job above the per-call budget
|
|
1587
1658
|
|
|
@@ -2213,8 +2284,10 @@ answers a raw `multiple` + Checkpoint request with the host's error.
|
|
|
2213
2284
|
|
|
2214
2285
|
Host-mediated image upload — the host opens its native upload modal and the
|
|
2215
2286
|
iframe never handles the bytes. Resolves with a moderated image (or `null` on
|
|
2216
|
-
dismiss); pass `{ purpose: 'generationSource' }` for an unscanned img2img source
|
|
2217
|
-
|
|
2287
|
+
dismiss); pass `{ purpose: 'generationSource' }` for an unscanned img2img source,
|
|
2288
|
+
`{ purpose: 'trainingData' }` for several private training images in one call
|
|
2289
|
+
(`[{ url, air }] | null` — see the LoRA training section), or
|
|
2290
|
+
`{ asyncScan: true }` for the early-resolve + `scanStatus()` flow.
|
|
2218
2291
|
|
|
2219
2292
|
```tsx
|
|
2220
2293
|
const { open } = useImageUpload();
|
|
@@ -2863,7 +2936,6 @@ had just worked.
|
|
|
2863
2936
|
|---|---|---|
|
|
2864
2937
|
| `useBuzzWorkflow()` | `submit()` | `ai:write:budgeted` |
|
|
2865
2938
|
| `useCreatePostFromApp()` | `createPost()` | `posts:write:self` |
|
|
2866
|
-
| `usePrepareTrainingDataset()` | `prepareDataset()` | `ai:write:budgeted` |
|
|
2867
2939
|
| `useGoodPurchase()` | `purchase()` | `goods:purchase:self` |
|
|
2868
2940
|
| `useTip()` | `tip()` | `social:tip:self` |
|
|
2869
2941
|
|
|
@@ -3819,6 +3891,7 @@ Runnable, minimal blocks — one per feature, each with its own README:
|
|
|
3819
3891
|
|
|
3820
3892
|
| `@civitai/blocks-react` | pairs with `@civitai/app-sdk` | adds |
|
|
3821
3893
|
|---|---|---|
|
|
3894
|
+
| `0.78.x` | `^0.79.0` | `useResourceFile()` (`GET_RESOURCE_FILE`). Its types (`BlockResourceFile` and friends) come from the SDK's `0.79.0`; the hook imports no SDK value, so the declared peer floor is unchanged. |
|
|
3822
3895
|
| `0.50.x` | `^0.40.0` | `useCreatePostFromApp()` (`CREATE_POST_FROM_APP`) + the `posts:write:self` scope. Pairing `0.50.x` with an SDK below `0.40.0` fails at `tsc` with `Cannot find name 'BlockCreatePostHostError'`, not at install. |
|
|
3823
3896
|
| `0.48.x` | `^0.38.0` | `useCollectionFollow()` + `<FollowButton>` / `<TipButton>` (`SET_COLLECTION_FOLLOW`). Pairing `0.48.x` with an SDK below `0.38.0` fails at `tsc` with `Cannot find name 'BlockCollectionFollowErrorCode'`, not at install — the floor those releases declared was deliberately wide (#206 — a per-minor floor forced a major on consumers). |
|
|
3824
3897
|
| `0.36.x` | `^0.27.0` | auto-installs the SDK's opaque-origin web-storage shim (`@civitai/app-sdk/safe-storage`) on import |
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import type { BlockGenerationSourceImageInfo, BlockImageScanResult, BlockPendingImageInfo, BlockUploadedImageInfo, BlockUploadPurpose } from '@civitai/app-sdk/blocks';
|
|
1
|
+
import type { BlockGenerationSourceImageInfo, BlockImageScanResult, BlockPendingImageInfo, BlockTrainingDataImage, BlockUploadedImageInfo, BlockUploadPurpose } from '@civitai/app-sdk/blocks';
|
|
2
|
+
export type { BlockTrainingDataImage };
|
|
2
3
|
/**
|
|
3
4
|
* What {@link useImageUpload} returns for the DEFAULT (`purpose: 'display'`,
|
|
4
5
|
* blocking-scan) call — `useImageUpload()` with no arguments.
|
|
5
6
|
*
|
|
6
7
|
* 🔴 THIS HOOK IS OVERLOADED, SO ITS RETURN TYPE IS A FAMILY, NOT A SINGLETON
|
|
7
8
|
* (#380). Each overload names its own: {@link UseImageUploadGenerationSource},
|
|
8
|
-
* {@link UseImageUploadAsyncScan}, and this one. A single union would be worse
|
|
9
|
+
* {@link UseImageUploadTrainingData}, {@link UseImageUploadAsyncScan}, and this one. A single union would be worse
|
|
9
10
|
* than the anonymous literals it replaces — it would hand every caller a
|
|
10
11
|
* `scanStatus` that only one overload actually provides.
|
|
11
12
|
*/
|
|
@@ -16,6 +17,14 @@ export interface UseImageUpload {
|
|
|
16
17
|
export interface UseImageUploadGenerationSource {
|
|
17
18
|
open: () => Promise<BlockGenerationSourceImageInfo | null>;
|
|
18
19
|
}
|
|
20
|
+
/** What {@link useImageUpload} returns for `{ purpose: 'trainingData' }`. */
|
|
21
|
+
export interface UseImageUploadTrainingData {
|
|
22
|
+
/**
|
|
23
|
+
* Every image the viewer uploaded, in the host's order, or `null` when they
|
|
24
|
+
* cancelled. Each `air` is what a `BlockTrainingDatasetItem` names.
|
|
25
|
+
*/
|
|
26
|
+
open: () => Promise<BlockTrainingDataImage[] | null>;
|
|
27
|
+
}
|
|
19
28
|
/** What {@link useImageUpload} returns for `{ asyncScan: true }`. */
|
|
20
29
|
export interface UseImageUploadAsyncScan {
|
|
21
30
|
/** Early-resolve handle (image persisted, NOT yet scanned) or `null` (dismissed). */
|
|
@@ -37,7 +46,7 @@ export interface UseImageUploadAsyncScan {
|
|
|
37
46
|
* compatible; the async overload types it required.
|
|
38
47
|
*/
|
|
39
48
|
export interface UseImageUploadImplementation {
|
|
40
|
-
open: () => Promise<BlockUploadedImageInfo | BlockGenerationSourceImageInfo | BlockPendingImageInfo | null>;
|
|
49
|
+
open: () => Promise<BlockUploadedImageInfo | BlockGenerationSourceImageInfo | BlockPendingImageInfo | BlockTrainingDataImage[] | null>;
|
|
41
50
|
scanStatus?: (handle: BlockPendingImageInfo) => Promise<BlockImageScanResult>;
|
|
42
51
|
}
|
|
43
52
|
/** Options for {@link useImageUpload}. */
|
|
@@ -49,14 +58,18 @@ export interface UseImageUploadOptions {
|
|
|
49
58
|
* - `'generationSource'` — a PRIVATE img2img source; UNSCANNED, so the host
|
|
50
59
|
* returns ONLY the source shape {@link BlockGenerationSourceImageInfo}
|
|
51
60
|
* (`{ url, width, height }`).
|
|
61
|
+
* - `'trainingData'` — PRIVATE training images for a `kind: 'training'` run
|
|
62
|
+
* (page apps only): up to 50 picked in one host modal, returned together as
|
|
63
|
+
* {@link BlockTrainingDataImage}`[]`.
|
|
52
64
|
*/
|
|
53
65
|
purpose?: BlockUploadPurpose;
|
|
54
66
|
/**
|
|
55
67
|
* Opt in to the NON-BLOCKING `'display'` flow. When `true`, `open()` resolves
|
|
56
68
|
* EARLY (on persist) with a {@link BlockPendingImageInfo} and the scan verdict
|
|
57
69
|
* is delivered separately via `scanStatus()` (see the async overload). Only
|
|
58
|
-
* meaningful for `'display'`; ignored for `'generationSource'`
|
|
59
|
-
*
|
|
70
|
+
* meaningful for `'display'`; ignored for `'generationSource'` and
|
|
71
|
+
* `'trainingData'` (neither has a host-side scan). Absent/`false` keeps the
|
|
72
|
+
* legacy blocking behavior.
|
|
60
73
|
*/
|
|
61
74
|
asyncScan?: boolean;
|
|
62
75
|
}
|
|
@@ -82,9 +95,20 @@ export interface UseImageUploadOptions {
|
|
|
82
95
|
* {@link BlockGenerationSourceImageInfo} (`{ url, width, height }`, UNSCANNED —
|
|
83
96
|
* the orchestrator scans it at gen time), or `null` on dismiss. Feed it
|
|
84
97
|
* straight into a `WorkflowBody.sourceImage` for an img2img graph.
|
|
98
|
+
* - `'trainingData'` → resolves with EVERY image the viewer uploaded,
|
|
99
|
+
* {@link BlockTrainingDataImage}`[]` (`{ url, air }` each), or `null` on
|
|
100
|
+
* cancel. Step 1 of a `kind: 'training'` run: pair each `air` with a caption
|
|
101
|
+
* for `WorkflowBodyTraining.items`. The host remembers the keys it uploaded
|
|
102
|
+
* for this block and refuses a run naming any other
|
|
103
|
+
* (`training data not uploaded here`), so upload and run in the same page
|
|
104
|
+
* session. A host that predates the purpose answers with one display image,
|
|
105
|
+
* which this resolves as `null` — never as a list it did not upload.
|
|
85
106
|
*
|
|
86
107
|
* Human-interactive, so it uses the same generous {@link HUMAN_INTERACTION_TIMEOUT_MS}
|
|
87
|
-
* as the pickers (the host still resolves earlier on upload/dismiss/close)
|
|
108
|
+
* as the pickers (the host still resolves earlier on upload/dismiss/close) — except
|
|
109
|
+
* `'trainingData'`, which waits up to {@link TRAINING_DATA_UPLOAD_TIMEOUT_MS} (one
|
|
110
|
+
* hour): the host replies only after uploading up to 50 originals, which can take
|
|
111
|
+
* longer than ten minutes.
|
|
88
112
|
* Host-mediated, same trust model as `useResourcePicker` / `useBuzzWorkflow`.
|
|
89
113
|
*
|
|
90
114
|
* Backward-compatible: when `purpose` is omitted the hook sends NO `purpose`
|
|
@@ -117,10 +141,20 @@ export interface UseImageUploadOptions {
|
|
|
117
141
|
* if (!src) return;
|
|
118
142
|
* await submit({ kind: 'textToImage', modelId, modelVersionId,
|
|
119
143
|
* sourceImage: src, params: { prompt } });
|
|
144
|
+
*
|
|
145
|
+
* @example
|
|
146
|
+
* // trainingData (private training images, several in one reply)
|
|
147
|
+
* const { open } = useImageUpload({ purpose: 'trainingData' });
|
|
148
|
+
* const images = await open(); // [{ url, air }, …] | null
|
|
149
|
+
* if (!images) return;
|
|
150
|
+
* const items = images.map((img) => ({ air: img.air, caption: captionFor(img) }));
|
|
120
151
|
*/
|
|
121
152
|
export declare function useImageUpload(options: {
|
|
122
153
|
purpose: 'generationSource';
|
|
123
154
|
}): UseImageUploadGenerationSource;
|
|
155
|
+
export declare function useImageUpload(options: {
|
|
156
|
+
purpose: 'trainingData';
|
|
157
|
+
}): UseImageUploadTrainingData;
|
|
124
158
|
export declare function useImageUpload(options: {
|
|
125
159
|
purpose?: 'display';
|
|
126
160
|
asyncScan: true;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef } from 'react';
|
|
2
|
-
import { HUMAN_INTERACTION_TIMEOUT_MS } from '../transport/requestTimeouts.js';
|
|
2
|
+
import { HUMAN_INTERACTION_TIMEOUT_MS, TRAINING_DATA_UPLOAD_TIMEOUT_MS, } from '../transport/requestTimeouts.js';
|
|
3
3
|
import { getTransport } from '../transport/singleton.js';
|
|
4
4
|
import { sendTypedRequest, subscribeTyped } from '../transport/transport.js';
|
|
5
5
|
import { useTransportConnection } from './useTransportConnection.js';
|
|
@@ -31,7 +31,7 @@ function isTerminalVerdict(v) {
|
|
|
31
31
|
export function useImageUpload(options) {
|
|
32
32
|
useTransportConnection();
|
|
33
33
|
const purpose = options?.purpose;
|
|
34
|
-
const asyncScan = options?.asyncScan === true && purpose
|
|
34
|
+
const asyncScan = options?.asyncScan === true && (purpose === undefined || purpose === 'display');
|
|
35
35
|
// Per-instance scan tracking, keyed by imageId. Persists across renders; a
|
|
36
36
|
// verdict is correlated back to its upload by imageId + the generated requestId.
|
|
37
37
|
const scansRef = useRef(new Map());
|
|
@@ -129,6 +129,19 @@ export function useImageUpload(options) {
|
|
|
129
129
|
// payload) → treat as dismissed rather than fabricate a handle.
|
|
130
130
|
return null;
|
|
131
131
|
}
|
|
132
|
+
if (purpose === 'trainingData') {
|
|
133
|
+
const { selected } = await sendTypedRequest(getTransport(), { type: 'OPEN_IMAGE_UPLOAD', payload: { purpose } }, 'IMAGE_UPLOAD_RESULT',
|
|
134
|
+
// NOT the 10-minute human bound: the host answers only after uploading
|
|
135
|
+
// every original, which can outlast it — see TRAINING_DATA_UPLOAD_TIMEOUT_MS.
|
|
136
|
+
{ timeoutMs: TRAINING_DATA_UPLOAD_TIMEOUT_MS });
|
|
137
|
+
// Only the multi-image shape is an answer to THIS purpose. A host that
|
|
138
|
+
// predates `trainingData` normalises it to `display` and replies with one
|
|
139
|
+
// moderated image — nothing a training item can name — so that, an empty
|
|
140
|
+
// list and a cancel all resolve `null` rather than a fabricated list.
|
|
141
|
+
if (!selected || !('images' in selected) || selected.images.length === 0)
|
|
142
|
+
return null;
|
|
143
|
+
return selected.images.map(({ url, air }) => ({ url, air }));
|
|
144
|
+
}
|
|
132
145
|
// ---- Existing BLOCKING behavior (unchanged) ----
|
|
133
146
|
// Omit `purpose` for the default/display mode so the wire stays
|
|
134
147
|
// byte-compatible with an older host that predates the field.
|
|
@@ -136,7 +149,10 @@ export function useImageUpload(options) {
|
|
|
136
149
|
const { selected } = await sendTypedRequest(getTransport(), { type: 'OPEN_IMAGE_UPLOAD', payload }, 'IMAGE_UPLOAD_RESULT', { timeoutMs: HUMAN_INTERACTION_TIMEOUT_MS });
|
|
137
150
|
// Normalize the "dismissed" case to an explicit null so callers can
|
|
138
151
|
// `if (!img) return;` without an `undefined` ambiguity.
|
|
139
|
-
|
|
152
|
+
// A multi-image training reply is not an answer to a single-image purpose.
|
|
153
|
+
if (!selected || 'images' in selected)
|
|
154
|
+
return null;
|
|
155
|
+
return selected;
|
|
140
156
|
}, [asyncScan, purpose]);
|
|
141
157
|
const scanStatus = useCallback((handle) => {
|
|
142
158
|
const entry = scansRef.current.get(handle.imageId);
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { BlockResourceFile, BlockResourceFileEntry, BlockResourceFileEntryFormat, BlockResourceFileErrorCode, BlockResourceFileMeta } from '@civitai/app-sdk/blocks';
|
|
2
|
+
export type { BlockResourceFile, BlockResourceFileEntry, BlockResourceFileEntryFormat, BlockResourceFileErrorCode, BlockResourceFileMeta, };
|
|
3
|
+
/**
|
|
4
|
+
* A resource-file read failure. The host returns one code from a CLOSED set, and
|
|
5
|
+
* this error carries it on `.code` so a block can branch on the exact reason:
|
|
6
|
+
* `busy` and `rate-limited` are retryable; `not-found`, `forbidden`,
|
|
7
|
+
* `unauthorized`, `too-large` and `parse-failed` are terminal. `.message` equals
|
|
8
|
+
* the code.
|
|
9
|
+
*/
|
|
10
|
+
export declare class ResourceFileError extends Error {
|
|
11
|
+
readonly code: BlockResourceFileErrorCode;
|
|
12
|
+
constructor(code: BlockResourceFileErrorCode);
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* What {@link useResourceFile} returns.
|
|
16
|
+
*/
|
|
17
|
+
export interface UseResourceFile {
|
|
18
|
+
/**
|
|
19
|
+
* The parsed file for the CURRENT `modelVersionId` / `fileId`, or `null` until
|
|
20
|
+
* it loads. Reset to `null` the moment either id changes, so a file is never
|
|
21
|
+
* shown for an id it does not belong to. A `refetch()` of the same ids keeps it
|
|
22
|
+
* while reloading.
|
|
23
|
+
*/
|
|
24
|
+
file: BlockResourceFile | null;
|
|
25
|
+
/** `true` while a fetch (initial or `refetch`) is in flight. */
|
|
26
|
+
loading: boolean;
|
|
27
|
+
/**
|
|
28
|
+
* The last fetch's failure as a {@link ResourceFileError} (`.code` is the
|
|
29
|
+
* reason), or a plain `Error` (a `RequestTimeoutError`) when the host never
|
|
30
|
+
* answered. Cleared at the start of the next fetch, and when either id changes.
|
|
31
|
+
*/
|
|
32
|
+
error: Error | null;
|
|
33
|
+
/** Re-request the file. */
|
|
34
|
+
refetch: () => void;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Read a resource file's parsed JSON (for a workflow: its graphs) by model-version
|
|
38
|
+
* id, through the host-mediated `GET_RESOURCE_FILE` → `RESOURCE_FILE_RESULT`
|
|
39
|
+
* bridge. TOKEN-INDEPENDENT (no block scope): the host resolves, fetches, unzips
|
|
40
|
+
* and parses the file in the viewer's own page session, enforcing the real
|
|
41
|
+
* download gates, and returns only the parsed JSON. The iframe never sees the
|
|
42
|
+
* session, the signed URL or the raw bytes.
|
|
43
|
+
*
|
|
44
|
+
* Pass `fileId` to pin one file of a multi-file version; without it the host
|
|
45
|
+
* picks one. Only free, public versions are served: an unpublished draft or an
|
|
46
|
+
* early-access / paid version answers `not-found`, even for a viewer who bought
|
|
47
|
+
* it. Full-page app hosts only — the model-page slot does not answer it, and
|
|
48
|
+
* `dev:live` replies `parse-failed`; use `dev:mock` to develop against it.
|
|
49
|
+
*
|
|
50
|
+
* Fetches on mount and whenever `modelVersionId` or `fileId` changes; a change
|
|
51
|
+
* clears `file` and `error` first. A non-positive or non-integer
|
|
52
|
+
* `modelVersionId` (or `fileId`, when given) fetches nothing. The request waits
|
|
53
|
+
* up to 120 s (`RESOURCE_FILE_REQUEST_TIMEOUT_MS`), since the host downloads and
|
|
54
|
+
* unzips the file; a host that never answers surfaces as a `RequestTimeoutError`
|
|
55
|
+
* after that. Only the LATEST request may write state: a superseded or
|
|
56
|
+
* post-unmount reply is dropped (#392).
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* const { file, loading, error, refetch } = useResourceFile(modelVersionId);
|
|
60
|
+
* // Retry a retryable code from an effect, after a delay — never during render.
|
|
61
|
+
* const retryable =
|
|
62
|
+
* error instanceof ResourceFileError && (error.code === 'busy' || error.code === 'rate-limited');
|
|
63
|
+
* useEffect(() => {
|
|
64
|
+
* if (!retryable) return;
|
|
65
|
+
* const t = setTimeout(refetch, 2_000);
|
|
66
|
+
* return () => clearTimeout(t);
|
|
67
|
+
* }, [retryable, refetch]);
|
|
68
|
+
* const graphs = file?.entries.filter((e) => e.format !== 'unknown') ?? [];
|
|
69
|
+
*/
|
|
70
|
+
export declare function useResourceFile(modelVersionId: number, fileId?: number): UseResourceFile;
|
|
71
|
+
//# sourceMappingURL=useResourceFile.d.ts.map
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { RESOURCE_FILE_REQUEST_TIMEOUT_MS } from '../transport/requestTimeouts.js';
|
|
3
|
+
import { getTransport } from '../transport/singleton.js';
|
|
4
|
+
import { sendTypedRequest } from '../transport/transport.js';
|
|
5
|
+
import { useRequestSequencer } from './useRequestSequencer.js';
|
|
6
|
+
import { useTransportConnection } from './useTransportConnection.js';
|
|
7
|
+
/**
|
|
8
|
+
* A resource-file read failure. The host returns one code from a CLOSED set, and
|
|
9
|
+
* this error carries it on `.code` so a block can branch on the exact reason:
|
|
10
|
+
* `busy` and `rate-limited` are retryable; `not-found`, `forbidden`,
|
|
11
|
+
* `unauthorized`, `too-large` and `parse-failed` are terminal. `.message` equals
|
|
12
|
+
* the code.
|
|
13
|
+
*/
|
|
14
|
+
export class ResourceFileError extends Error {
|
|
15
|
+
code;
|
|
16
|
+
constructor(code) {
|
|
17
|
+
super(code);
|
|
18
|
+
this.name = 'ResourceFileError';
|
|
19
|
+
this.code = code;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Read a resource file's parsed JSON (for a workflow: its graphs) by model-version
|
|
24
|
+
* id, through the host-mediated `GET_RESOURCE_FILE` → `RESOURCE_FILE_RESULT`
|
|
25
|
+
* bridge. TOKEN-INDEPENDENT (no block scope): the host resolves, fetches, unzips
|
|
26
|
+
* and parses the file in the viewer's own page session, enforcing the real
|
|
27
|
+
* download gates, and returns only the parsed JSON. The iframe never sees the
|
|
28
|
+
* session, the signed URL or the raw bytes.
|
|
29
|
+
*
|
|
30
|
+
* Pass `fileId` to pin one file of a multi-file version; without it the host
|
|
31
|
+
* picks one. Only free, public versions are served: an unpublished draft or an
|
|
32
|
+
* early-access / paid version answers `not-found`, even for a viewer who bought
|
|
33
|
+
* it. Full-page app hosts only — the model-page slot does not answer it, and
|
|
34
|
+
* `dev:live` replies `parse-failed`; use `dev:mock` to develop against it.
|
|
35
|
+
*
|
|
36
|
+
* Fetches on mount and whenever `modelVersionId` or `fileId` changes; a change
|
|
37
|
+
* clears `file` and `error` first. A non-positive or non-integer
|
|
38
|
+
* `modelVersionId` (or `fileId`, when given) fetches nothing. The request waits
|
|
39
|
+
* up to 120 s (`RESOURCE_FILE_REQUEST_TIMEOUT_MS`), since the host downloads and
|
|
40
|
+
* unzips the file; a host that never answers surfaces as a `RequestTimeoutError`
|
|
41
|
+
* after that. Only the LATEST request may write state: a superseded or
|
|
42
|
+
* post-unmount reply is dropped (#392).
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* const { file, loading, error, refetch } = useResourceFile(modelVersionId);
|
|
46
|
+
* // Retry a retryable code from an effect, after a delay — never during render.
|
|
47
|
+
* const retryable =
|
|
48
|
+
* error instanceof ResourceFileError && (error.code === 'busy' || error.code === 'rate-limited');
|
|
49
|
+
* useEffect(() => {
|
|
50
|
+
* if (!retryable) return;
|
|
51
|
+
* const t = setTimeout(refetch, 2_000);
|
|
52
|
+
* return () => clearTimeout(t);
|
|
53
|
+
* }, [retryable, refetch]);
|
|
54
|
+
* const graphs = file?.entries.filter((e) => e.format !== 'unknown') ?? [];
|
|
55
|
+
*/
|
|
56
|
+
export function useResourceFile(modelVersionId, fileId) {
|
|
57
|
+
useTransportConnection();
|
|
58
|
+
const [file, setFile] = useState(null);
|
|
59
|
+
const [loading, setLoading] = useState(true);
|
|
60
|
+
const [error, setError] = useState(null);
|
|
61
|
+
const seq = useRequestSequencer();
|
|
62
|
+
// The ids the current `file` / `error` belong to. A fetch for different ids
|
|
63
|
+
// clears both first, so `{ file: A, error: B }` can never be returned.
|
|
64
|
+
const key = `${modelVersionId}|${fileId ?? ''}`;
|
|
65
|
+
const keyRef = useRef(null);
|
|
66
|
+
const isPositiveInt = (n) => Number.isInteger(n) && n > 0;
|
|
67
|
+
const valid = isPositiveInt(modelVersionId) && (fileId === undefined || isPositiveInt(fileId));
|
|
68
|
+
const refetch = useCallback(() => {
|
|
69
|
+
// `begin()` runs BEFORE the validity gate, as in `useWildcardPack`: moving to
|
|
70
|
+
// an invalid id must also supersede an in-flight request for the old one.
|
|
71
|
+
const token = seq.begin();
|
|
72
|
+
if (keyRef.current !== key) {
|
|
73
|
+
keyRef.current = key;
|
|
74
|
+
setFile(null);
|
|
75
|
+
setError(null);
|
|
76
|
+
}
|
|
77
|
+
if (!valid) {
|
|
78
|
+
setLoading(false);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
setLoading(true);
|
|
82
|
+
setError(null);
|
|
83
|
+
sendTypedRequest(getTransport(), {
|
|
84
|
+
type: 'GET_RESOURCE_FILE',
|
|
85
|
+
payload: fileId === undefined ? { modelVersionId } : { modelVersionId, fileId },
|
|
86
|
+
}, 'RESOURCE_FILE_RESULT', { timeoutMs: RESOURCE_FILE_REQUEST_TIMEOUT_MS })
|
|
87
|
+
.then((result) => {
|
|
88
|
+
if (!seq.isCurrent(token))
|
|
89
|
+
return;
|
|
90
|
+
if (result.error || !result.file) {
|
|
91
|
+
// `??`, as in `useWildcardPack`: `isValidResourceFileResult` admits only
|
|
92
|
+
// the closed code set, so `error: ''` never reaches here. The fallback
|
|
93
|
+
// covers only a reply with neither field, which the validator drops too.
|
|
94
|
+
setError(new ResourceFileError(result.error ?? 'parse-failed'));
|
|
95
|
+
setLoading(false);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
setFile(result.file);
|
|
99
|
+
setLoading(false);
|
|
100
|
+
})
|
|
101
|
+
.catch((err) => {
|
|
102
|
+
if (!seq.isCurrent(token))
|
|
103
|
+
return;
|
|
104
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
105
|
+
setLoading(false);
|
|
106
|
+
});
|
|
107
|
+
}, [valid, key, modelVersionId, fileId, seq]);
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
refetch();
|
|
110
|
+
}, [refetch]);
|
|
111
|
+
return { file, loading, error, refetch };
|
|
112
|
+
}
|
|
113
|
+
//# sourceMappingURL=useResourceFile.js.map
|