slices images for the bsky.app photo carousel (min 5 images)

Files
README.md

slicer #

Slice a wide (or tall) panorama into 5-10 vertical segments and publish them as a single Bluesky post. Swiping through the Bluesky photo carousel reveals the original image as one continuous panorama.

Live: https://slicer.psingletary.com Repo: https://tangled.org/psingletary.com/slicer

Why 5 is the minimum #

Bluesky's photo carousel only appears for posts with at least 5 images; 4 or fewer render as the classic grid. The tool enforces 5-10 slices (10 is the client authoring soft limit; the schema ceiling is 20). The threshold is treated as volatile (Bluesky shipped, rolled back, and re-shipped this behavior within four months) — it lives in src/lib/geometry.js (MIN_SLICES/MAX_SLICES), not scattered inline.

How it works #

Everything runs in the browser. Your image never leaves your device except as slice uploads to your own PDS (resolved from your session — never hardcoded to bsky.social).

  1. Upload — a JPEG/PNG/WebP panorama (up to 50MB).
  2. Crop — pan/zoom with presets (5x4:5, 6x4:5, 8x4:5, 5x1:1) or custom N and aspect. The crop width is made exactly divisible by N so every slice has byte-identical dimensions (a mismatched frame would resize mid-swipe and break the illusion). Arrow keys nudge the crop by 1px (10px with Shift).
  3. Slice + encode — each slice is drawn with a SINGLE drawImage call (no two-pass resampling — that is what causes visible seams) and encoded as WebP under 2MB. If any slice still exceeds the limit at quality 0.6, the SHARED long edge is reduced (max 3 steps) and all N slices re-encode at the same size, so dimensions stay identical.
  4. Preview — a carousel simulator reproduces the Bluesky swipe experience (fixed frame, k/N badge, the 1/N counter safe-zone), plus a "stitch check" view that renders all slices edge-to-edge so you can confirm continuity before publishing.
  5. Alt text — every slice gets non-empty alt text ("{n} of {total} — description", with per-slice overrides). Publishing is blocked otherwise: a 10-slice panorama is completely opaque to screen-reader users without it.
  6. Publish — sequential uploadBlob x N (rate-limited with backoff honoring Retry-After / RateLimit-Reset) then createRecord with an app.bsky.embed.gallery embed. Optional content self-labels (sexual/nudity/porn/graphic-media). The post is then verified by polling getPostThread until the gallery embed hydrates; if it never hydrates, the post is automatically deleted (no broken public posts). Orphaned uploads on failure need no cleanup — the PDS garbage-collects blobs not referenced by a record.

Development #

npm install
npm start          # dev server
npm test           # jest (CI=true npm test -- --watchAll=false for one-shot)
npm run build      # production build to build/

Stack: Create React App (react-scripts 5.0.1, deliberately NOT Vite), JavaScript, @atproto/api 0.13.35 + @atproto/oauth-client-browser 0.3.30 (pinned — newer lines are ESM-only and break CRA; zod 3.24.3 override).

Deploy #

Host = wisp.place (custom domain slicer.psingletary.com). DNS (Porkbun CNAME + TXT) and wisp.place domain verification are already complete — documented here for reference only, nothing pending.

npm run build
wispctl deploy --path ./build --site slicer --spa --yes --db ~/.config/wispctl/state.sqlite psingletary.com

Push to Tangled = source backup only (git push origin main). Tangled Sites is NOT configured for this project.

OAuth caveat #

public/client-metadata.json is the single source of truth — AuthContext fetches it at startup, so there is no second copy to drift. If you ever change the client_id / redirect_uris / scope in that file, the OAuth flow breaks with opaque failures. A Jest test pins the critical values.

Research notes #

docs/RESEARCH.md — pin decision (gallery embed is hand-constructed because api 0.13.35 predates gallery types), schema discovery (gallery items need the $type discriminator), CDN preset observations, jest infra fixes for @atproto deps.

The vendored src/lexicons/gallery.json is the publish contract — it was captured from the live schema (2026-08-19). If Bluesky changes app.bsky.embed.gallery, re-capture the live JSON and re-run the publish tests; publish.js validates against it before every send, so a stale schema shows up as a local validation error rather than a PDS 400.