mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
9ceb92e0d6
Creator Studio could set a version's pricing but not whether that version was downloadable at all — usage control was read-only context, echoed into the paid-access form as a hidden field. It's now an editable picker with its own save, sitting above Early & paid access because it describes the version rather than its gate: a creator can flip a version to generation-only without also filling in pricing. Written the way the spoke already writes licensingFee -- kysely, with ownership enforced in the WHERE so a version you don't own updates zero rows. That skips the main app's cross-validation, so the rule it would have applied is enforced here instead: a version that isn't downloadable can't be charging for downloads. When the picker is saved alongside a gate, usage control is persisted FIRST, because the main-app endpoint validates terms against the stored value. Placing the picker inside the paid-access form would also have hidden it behind that section's two banner branches -- a creator at their permanent-access limit, or past publish, could never have reached it. Those direct kysely writes were invisible to the main app's caches, so a fee or usage-control change could take up to a day to surface on-site. bustMvCache spans six caches, a CDN purge and the search-index queue, and a second copy of that list in the spoke would drift the first time one is added -- so it stays where it's defined, behind a narrow ownership-scoped endpoint the spoke calls after writing. Wired into all four direct-write paths, which fixes the pre-existing licensingFee staleness alongside the new field. Fire-and-forget: a failed bust must not turn a saved change into an error. Fixes the drawer's unreadable help text. The palette runs light-to-dark, so text-dark-3 reads as "muted" but is #5C5F66 on a #25262B card -- 2.36:1, against the 4.5:1 WCAG AA needs, and all of it 12px explanatory copy. text-dark-2 clears it at 4.73:1 while still reading as secondary. 44 occurrences across the drawer, the models page and the caps table. Adds two plan docs: the monetization cap API cleanup, and the bulk grid that replaces the CSV round-trip for multi-field editing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
147 lines
8.3 KiB
Markdown
147 lines
8.3 KiB
Markdown
# Creator Studio — bulk monetization grid
|
||
|
||
Status: proposal, nothing implemented.
|
||
Goal: **make it easy for a power user to bulk manage licensing fees and paid access across their catalog.**
|
||
|
||
## Decision: editable grid, not CSV round-trip
|
||
|
||
@dev: an inline table can naturally cap values, show tooltips, and disable cells — a CSV can't.
|
||
|
||
Agreed, and the deciding argument is the one that kills the CSV on its own terms: **if we export every column we
|
||
must import every column**, and a self-describing CSV needs roughly 19 of them —
|
||
|
||
- ~8 editable: `usageControl`, fee buzz + denominator, access price, generation price, free generation,
|
||
trial limit, timeframe/permanent
|
||
- ~6 identity: versionId, model, version, baseModel, modelType, status
|
||
- ~5 hints: fee cap, suggested fee, access-price cap, permanent slots left, and _why_ the cap is that value
|
||
(image vs video)
|
||
|
||
Five of those are read-only decoration **in a format with no concept of read-only**, and the caps are
|
||
per-row — a video checkpoint and an image LoRA have different ceilings, so `feeCap` can't live in a header.
|
||
You would ship a spreadsheet that needs a legend, then validate that nobody edited the legend.
|
||
|
||
The structural reason the grid wins: **a disabled cell makes an invalid state unreachable; a CSV cell can
|
||
only make it detectable** — after upload, as a row error the creator must map back to a model.
|
||
`freeGeneration = true` alongside `generationPrice = 200` is a contradiction a form prevents and a CSV can
|
||
only report. That is the same principle as Phase 3 of [monetization-cap-api-plan.md](./monetization-cap-api-plan.md):
|
||
make the wrong thing unrepresentable.
|
||
|
||
**The CSV stays, narrowed and symmetric.** It remains a licensing-fee tool — export fee, import fee, in and
|
||
out matching. Fee is the one field that is genuinely row-independent, and `bulkSetLicensingFeeVaried` already
|
||
handles varied per-row values at scale. Everything multi-field belongs to the grid.
|
||
|
||
## Scale — measured, not assumed
|
||
|
||
@dev: "a power user will have hundreds of models… pull in all their models and not worry about pagination."
|
||
|
||
Actual distribution of non-deleted versions per creator:
|
||
|
||
| Creators with… | Count |
|
||
| -------------- | ---------- |
|
||
| > 100 versions | 1,657 |
|
||
| > 500 versions | 259 |
|
||
| > 2,000 | 30 |
|
||
| **max** | **15,342** |
|
||
| p99 | 191 |
|
||
|
||
So "no pagination" is right for the overwhelming majority (p99 is 191 rows) but the tail reaches 15k. That
|
||
rules out rendering every row, and rules **in** loading every row:
|
||
|
||
- **Load all rows, render a window.** One query, no pagination, virtualized rendering. At ~120 bytes/row a
|
||
15k-version payload is ~1.8 MB uncompressed — acceptable once, on an explicit "bulk edit" entry, not on
|
||
every page view.
|
||
- **Keep the paginated view as the default.** The grid is a mode you enter, not the landing state. Today's
|
||
page (20/50/100) stays the browse/scan surface.
|
||
|
||
## Shape
|
||
|
||
### Entry
|
||
|
||
Bulk edit is a mode toggle on `/models`, carried in the URL (`?bulk=grid`) so it survives reload and shares
|
||
the existing filter state. Entering it swaps the paginated card list for the grid and switches the loader to
|
||
the unpaginated query.
|
||
|
||
### Columns
|
||
|
||
| Column | Editable | Notes |
|
||
| -------------------- | -------- | ---------------------------------------------------------------- |
|
||
| Model / Version | no | sticky left, links out |
|
||
| Base model, Type | no | drives the row's caps; shown because it explains them |
|
||
| Status | no | Draft rows can't take early access |
|
||
| Usage control | **yes** | select — Download / Generation; hidden for moderator-only values |
|
||
| Licensing fee | **yes** | buzz + denominator, `max` = this row's cap |
|
||
| Access price | **yes** | `max` = this row's cap; label changes for gen-only |
|
||
| Generation price | **yes** | disabled when free generation is on |
|
||
| Free generation | **yes** | checkbox; disables generation price + trial limit |
|
||
| Trial limit | **yes** | disabled unless there's a paid generation tier |
|
||
| Duration / Permanent | **yes** | permanent is count-capped — see Save semantics |
|
||
|
||
Caps are per row (`tier × modelType × mediaType`), so each row derives its own from `monetizationLimits`
|
||
once that exists — or from the same helpers `PaidAccessEditor` uses today.
|
||
|
||
### Save semantics
|
||
|
||
**Explicit save, not per-cell autosave.** Edits mark rows dirty; one "Save N changes" commits. Autosave over
|
||
hundreds of rows means hundreds of writes and no way to review before committing money-affecting changes.
|
||
|
||
Two rules that fall out of the existing code and must survive here:
|
||
|
||
- **Increase-only cap checks.** The grid resubmits stored values for untouched fields, so a hard clamp would
|
||
make an over-cap row unsavable (`82f64846ba`). Only raises are rejected — per row, per component.
|
||
- **Permanent access is count-capped, so rows are _not_ independent.** Free tier allows 3. A grid marking 50
|
||
rows permanent must reject the batch up front against remaining slots, not partially succeed. This is the
|
||
one field where a bulk edit cannot be modelled as N independent row writes.
|
||
|
||
Failed rows stay dirty and marked, rather than the whole save rolling back — a creator editing 200 rows
|
||
should not lose 199 good edits to one bad one.
|
||
|
||
### Reuse
|
||
|
||
Most of the pieces exist:
|
||
|
||
| Need | Already have |
|
||
| --------------------- | --------------------------------------------------------------- |
|
||
| Per-version cap logic | `PaidAccessEditor` derives per-version caps from `capMediaType` |
|
||
| Number input + max | `NumberInput` |
|
||
| Apply-to-selection | `PaidAccessBulkBar`, `BulkLicensingFeesBar` |
|
||
| Varied per-row write | `bulkSetLicensingFeeVaried` |
|
||
| Usage-control write | `setUsageControl` + `?/setUsageControl` |
|
||
| Cache invalidation | `bustVersionCache` → `/api/v1/model-versions/bust-cache` |
|
||
|
||
What is genuinely new: the virtualized table, dirty-row tracking, and a batched multi-field write action.
|
||
|
||
## Phases
|
||
|
||
**Phase 1 — read-only grid.** All rows loaded, virtualized, every column rendered read-only, with the
|
||
existing filters. Ships the "see my whole catalog at once" value on its own and de-risks the loading and
|
||
virtualization work before any write path exists. Also answers whether auditing was the real need.
|
||
|
||
**Phase 2 — editable licensing fee.** One editable column, dirty tracking, batched save through the existing
|
||
varied-fee write. Smallest possible proof of the edit loop.
|
||
|
||
**Phase 3 — editable usage control + access pricing.** The interdependent fields, with disabled-cell rules.
|
||
Needs the batched multi-field action and the permanent-count pre-check.
|
||
|
||
**Phase 4 — retire the fee CSV import** if grid adoption makes it redundant. Keep export.
|
||
|
||
## Open questions
|
||
|
||
@ai: Is the real pain **auditing** ("which of my 200 versions are over cap?") or **varied bulk editing**? If
|
||
auditing, Phase 1 alone may close it and Phases 2–3 can wait. Worth asking a power user before building the
|
||
edit loop.
|
||
|
||
@ai: Should "select all" in the grid mean all _loaded_ rows or all _filtered_ rows? Today's `bulkFeeCap`
|
||
already has to reason about versions beyond the current page; loading everything makes them the same thing,
|
||
which is a simplification worth taking deliberately.
|
||
|
||
@ai: Does the grid need to show versions the creator can't monetize at all (non-commercial base models,
|
||
moderator-only usage controls)? Hiding them is cleaner; showing them greyed explains why a model is absent.
|
||
|
||
## Risks
|
||
|
||
- **15k rows is a real payload.** Needs measuring against the actual query before committing — the current
|
||
loader joins model + paid-access + fee data per version.
|
||
- **Row count × editable columns.** 10 columns of live inputs over a virtualized list means input state must
|
||
live outside the rendered nodes, or values will detach as rows recycle.
|
||
- **Money-affecting bulk writes need a confirmation step** showing what changes and for how many versions.
|