Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1005,9 +1005,18 @@ $ civitai app listing add-screenshot ./shot.png --caption "Grid view" # option
Details worth knowing before you start:

- **Source images** are png/jpeg/webp and are size-checked **locally before any
upload** — icon ≤2 MiB, cover ≤4 MiB, screenshot ≤2 MiB. Each attach then
waits for the platform content scan (a blocked image is rejected, not
attached).
upload** — icon ≤2 MiB, cover ≤4 MiB, screenshot ≤2 MiB.
- **Dimension and aspect rules are the platform's, and it states them.** The CLI
does not publish or enforce them (a copied number goes stale and starts
refusing valid images). It uploads, **attaches, and then waits for the content
scan** — in that order, because the platform validates dimensions, aspect and
format at the *attach* step. So a wrongly-shaped image comes back in a couple
of seconds with the platform's own message naming the bound and your value
(e.g. `icon must be square-ish (aspect 2.00 outside 0.9–1.1)`), instead of
after the scan has finished.
- **A blocked image never goes live.** The scan verdict is still waited on, so
these commands never report success on a pending or blocked scan; a failure
tells you what state the listing was left in.
- **The app is resolved from `block.manifest.json`** in the current directory;
pass `--slug <blockId>` (with `--dir` if you prefer) to run it from anywhere.
- **On a listing that is already LIVE**, attaching media does not edit the live
Expand Down
14 changes: 12 additions & 2 deletions internal/appapi/listing.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,18 @@ type ListingEditView struct {
}

// AttachResult is the (loosely-parsed) union result of setIcon/setCover/
// addScreenshot. `scanPending` means the image was stored while still scanning —
// the CLI polls before attaching, so it should already be false.
// addScreenshot.
//
// 🔴 `ScanPending` is LOAD-BEARING, not diagnostic. Since issue #270 the CLI
// attaches BEFORE polling the scan (the server validates geometry/aspect/MIME/
// bytes at attach), so this flag is what tells it whether a poll is still owed.
// The server sets `scanPending: true` only on the still-scanning branch of
// `loadValidatedImage` and OMITS the key once `ingestion == Scanned` — so absent
// and false mean the same thing here, "the server already saw a clean scan", and
// a plain bool is the honest shape.
//
// `Status == "pending"` is the legacy `allowPending: false` variant and means
// NOTHING was written; the live listing-media procs never return it.
type AttachResult struct {
Status string `json:"status"`
IconID *int `json:"iconId,omitempty"`
Expand Down
109 changes: 91 additions & 18 deletions internal/cmd/app_listing.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,11 @@ func newAppListingCmd() *cobra.Command {
a listing needs before it can publish.

A store listing must have an ICON and a COVER before it can go live; screenshots
are optional. These commands ingest a local image, wait for the content scan, and
attach it to your listing — the same pipeline the web submit form uses.
are optional. These commands ingest a local image, attach it to your listing, and
then wait for the content scan — the same pipeline the web submit form uses. The
platform checks the image's dimensions, aspect and format when it is ATTACHED, so
a wrongly-shaped image is reported in a couple of seconds rather than after the
scan.

For a listing that is already LIVE (approved), attaching media opens a REVISION
that goes back to moderator review (the live listing is untouched until the
Expand Down Expand Up @@ -313,7 +316,7 @@ func bindRevisionFlags(cmd *cobra.Command, changelog *string, assumeYes *bool) {
cmd.Flags().BoolVarP(assumeYes, "yes", "y", false, "skip the live-listing revision confirmation")
}

// runSetMedia is the shared resolve -> validate -> ingest -> scan -> attach flow
// runSetMedia is the shared resolve -> validate -> ingest -> attach -> scan flow
// for set-icon / set-cover / add-screenshot, branching on the listing status for
// the live-listing shadow-revision path.
func runSetMedia(cmd *cobra.Command, kind mediaKind, file, caption string, lc listingCommon, changelog string, assumeYes bool) error {
Expand Down Expand Up @@ -372,12 +375,21 @@ func runSetMedia(cmd *cobra.Command, kind mediaKind, file, caption string, lc li
return err
}

// 5. Poll the scan until Scanned (or Blocked / timeout).
if err := pollScan(ctx, out, client, imageID); err != nil {
return err
}

// 6. Attach — direct for draft/pending, via a shadow revision when live.
// 5. Attach — direct for draft/pending, via a shadow revision when live.
//
// 🔴 ATTACH BEFORE THE SCAN POLL, AND THE ORDER IS THE FIX (issue #270). The
// server validates GEOMETRY, ASPECT, MIME and BYTE SIZE at ATTACH, not at
// ingest: `validateListingImage` runs inside `loadValidatedImage`
// (civitai/civitai → src/server/services/blocks/app-listing-assets.service.ts)
// BEFORE the ingestion-status gate, and all three attach procs pass
// `allowPending: true`, so an image whose scan is still in flight is written
// and flagged `scanPending` rather than refused. Polling first therefore made
// an author with a 512x256 icon wait out the whole scan — up to
// scanPollTimeout — before hearing `icon must be square-ish (aspect 2.00
// outside 0.9–1.1)`. Asking first gets the server's own verdict in one
// round-trip, and the CLI still vendors none of those bounds: it relays what
// the server said. The scan is polled below, so nothing reports success while
// a scan is pending or blocked.
targetID := ref.AppListingID
var shadowID string
if live {
Expand All @@ -387,10 +399,40 @@ func runSetMedia(cmd *cobra.Command, kind mediaKind, file, caption string, lc li
}
targetID = shadowID
}
if err := attachMedia(ctx, client, kind, targetID, imageID, caption); err != nil {
res, err := attachMedia(ctx, client, kind, targetID, imageID, caption)
if err != nil {
return err
}

// 6. Poll the scan AFTER the attach, so success is still never reported while
// the scan is pending or blocked.
//
// `scanPending` is the server's own answer and is what decides this — the
// attach proc sets it ONLY on the still-scanning branch and omits the key
// entirely once `ingestion == Scanned`, so absent and false both mean "the
// server already saw a clean scan" and there is nothing to wait for.
//
// `status: "pending"` is the legacy `allowPending: false` shape: NOTHING was
// written. Today's procs never return it, but if that ever changed, attaching
// first would silently no-op and still print success — so fall back to the
// pre-#270 order (wait out the scan, then attach) rather than lie.
switch {
case res.Status == attachStatusPending:
if err := pollScan(ctx, out, client, imageID); err != nil {
return scanFailure(out, err, kind, live, res)
}
if res, err = attachMedia(ctx, client, kind, targetID, imageID, caption); err != nil {
return err
}
if res.Status == attachStatusPending {
return fmt.Errorf("the server did not attach the %s — it still reports the image as scanning; try again shortly", kind)
}
case res.ScanPending:
if err := pollScan(ctx, out, client, imageID); err != nil {
return scanFailure(out, err, kind, live, res)
}
}

// 7. For a live listing, submit the revision for moderator re-review.
if live {
rev, err := client.SubmitListingRevision(ctx, shadowID, changelog)
Expand All @@ -411,20 +453,47 @@ func runSetMedia(cmd *cobra.Command, kind mediaKind, file, caption string, lc li
return nil
}

func attachMedia(ctx context.Context, client *appapi.Client, kind mediaKind, listingID string, imageID int, caption string) error {
// attachStatusPending is the server's legacy `allowPending: false` attach result:
// the image was still scanning and NOTHING was written. The live listing-media
// procs all pass `allowPending: true` and never return it; see runSetMedia for
// why the CLI handles it anyway.
const attachStatusPending = "pending"

// attachMedia attaches the ingested image and returns the server's attach result
// (never nil on a nil error), which carries the `scanPending` flag the caller
// uses to decide whether the scan still has to be waited on.
func attachMedia(ctx context.Context, client *appapi.Client, kind mediaKind, listingID string, imageID int, caption string) (*appapi.AttachResult, error) {
switch kind {
case kindIcon:
_, err := client.SetIcon(ctx, listingID, imageID)
return err
return client.SetIcon(ctx, listingID, imageID)
case kindCover:
_, err := client.SetCover(ctx, listingID, imageID)
return err
return client.SetCover(ctx, listingID, imageID)
default:
_, err := client.AddScreenshot(ctx, listingID, imageID, caption)
return err
return client.AddScreenshot(ctx, listingID, imageID, caption)
}
}

// scanFailure returns the scan error unchanged — it is the ONE diagnosis the user
// gets — after printing the state the attach-before-scan order leaves behind.
//
// The line is context, never a second verdict: attaching first means a blocked or
// never-settling image can already be written to the DRAFT when the scan verdict
// arrives, which was impossible when the poll ran first. For a live listing the
// revision is simply not submitted; for a screenshot the row exists and needs an
// explicit removal, so its id is handed over.
func scanFailure(out io.Writer, err error, kind mediaKind, live bool, res *appapi.AttachResult) error {
switch {
case live:
fmt.Fprintln(out, "The revision was not submitted — your live listing is unchanged.")
case kind == kindScreenshot && res != nil && res.ID != "":
fmt.Fprintf(out, "The screenshot was added to your draft — remove it with %s\n",
ui.Code("civitai app listing rm-screenshot "+res.ID))
case res != nil && res.Status != attachStatusPending:
fmt.Fprintf(out, "The %s is attached to your draft but cannot go live — re-run this command with a different image.\n", kind)
}
return err
}

// printFloorAfter reads the listing media and prints the remaining floor gap.
// Best-effort: a read failure after a successful attach is not fatal.
func printFloorAfter(ctx context.Context, out io.Writer, client *appapi.Client, appListingID string) {
Expand Down Expand Up @@ -629,6 +698,10 @@ func kindByteCap(kind mediaKind) int {
}

// pollScan polls the image scan until Scanned, erroring on Blocked or timeout.
//
// It runs AFTER the attach (see runSetMedia), so its messages are deliberately
// position-neutral: they say what is wrong with the IMAGE, not whether it reached
// the listing. The caller owns the "what that leaves behind" line.
func pollScan(ctx context.Context, out io.Writer, client *appapi.Client, imageID int) error {
deadline := time.Now().Add(scanPollTimeout)
start := time.Now()
Expand All @@ -647,7 +720,7 @@ func pollScan(ctx context.Context, out io.Writer, client *appapi.Client, imageID
case "scanned":
return nil
case "blocked":
return fmt.Errorf("the image was blocked by the content scan — it can't be attached; use a different image")
return fmt.Errorf("the image was blocked by the content scan — a blocked image can never go live; use a different image")
}
if time.Now().After(deadline) {
return fmt.Errorf("timed out after %s waiting for the image scan (still %s) — check `civitai app listing status` shortly", scanPollTimeout, st)
Expand Down
Loading
Loading