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>
8.3 KiB
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:
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.