Story-as-Merch — print-on-demand posters + apparel
Status: Phase 1 BUILT (poster + apparel storefront; inert until Gelato is configured);
Phase 2 pending. Product task 54663ae8 (Emberkiln, P2). Memory: project_story_as_merch.
See the Build log sections at the bottom for what shipped + env.
Locked decisions (2026-06-27): fan markup split 50% creator / 50% platform (of the
margin, not the gross); Gelato-only for apparel (no Printful — reuse the one client);
guests allowed to buy (nullable buyer_user_id); Phase 1 shipped staged (PR A:
poster storefront + payout + moderation + cron; PR B: apparel SKUs).
Provider note (reconciled 2026-06-27): the repo already ships a Lulu-based POD pipeline — but Lulu prints books only (
print_orders,lulu.ts,print-storefront.ts: 6×9 paperbacks). Lulu has no poster or apparel product, so merch uses Gelato (posters + apparel) as a second provider that coexists with Lulu (books). Merch deliberately mirrors Lulu's proven shapes (theprint_orderstable,quoteRetailmargin math, the Stripekind-dispatch webhook, status-sync) rather than extending them.
"A story as all forms." The Emberkiln render-kit already turns one Work into print / audiobook / reel / screenplay. This adds the next allotrope: the story as a physical object — a poster, a shirt, a mug — printed from cover art the pipeline already generates, fulfilled by a print-on-demand partner with zero inventory.
1. The thesis (why this isn't a swag store)
The sharp reframe is merch-as-creative-ritual, not vanity swag. Two distinct buyers, two motivations:
- Author-for-self — the inspiration poster. Indie writers print a poster of the book or movie they're trying to finish and pin it up to write beneath. It's a commitment / manifestation object, not promo. The author is the buyer; the motivation is warm and non-cringe; intent is high. This is the wedge — lead with it.
- Author-for-fans — the support storefront. A shareable link the author sends to friends, family, and fans who buy a shirt/hat/mug to support them and their stories.
Build the plumbing once; surface both. The self-poster seeds the fan store (an author who pinned up their own poster will share the shop link).
The unlock: the art asset already exists. Every season has poster_url, every chapter
chapter_cover_image_url, every journal song a cover_url — all gpt-image-1. A poster is
that cover at print resolution with title + byline. Zero new creative work is asked of the
author.
2. The one real technical gate: print resolution
Everything else is plumbing we already own. This is the only genuinely new piece.
Covers are generated at 1024×1024 PNG in season-assets/story-covers/{id}.png
(story-cover-images.ts,
chapter-cover-images.ts). For a
13×19" poster at 300 DPI you need ~3900×5700 px — the screen cover is ~7× short on each
axis. Two paths:
| Path | How | Verdict |
|---|---|---|
| A — re-generate at print size | gpt-image-1 supports 1024×1536 / 1536×1024, not arbitrary print dimensions. Still far short of 300 DPI and produces a different image than the cover fans/author already know. | ✗ off-brand + still too small |
| B — upscale the existing cover ✅ | Feed the exact poster_url through a Replicate super-resolution model (Real-ESRGAN / clarity-upscaler) 4× → ~4096 px, enough for an 11×17 at ~240 DPI or 13×19 at ~210 DPI (acceptable for art posters at viewing distance). Uses the already-wired getReplicateClient() in render-kit/providers.ts. | Recommended — preserves the canonical image, reuses an existing provider |
Cache the upscaled asset to season-assets/print/{id}@4x.png so we upscale once per cover,
not per order. A poster-quality gate (reject covers that upscale poorly / contain artifacts)
runs before the storefront offers a print.
3. What we reuse (almost everything)
| Need | Existing infra to mirror | File |
|---|---|---|
| One-time checkout | episode-tips flow — Stripe mode:'payment' + metadata.kind, recorded on checkout.session.completed | routes/payment.ts |
| Creator payout | creator_earnings append-only ledger (source_kind, gross_cents, creator_share_cents, platform_share_cents, stripe_*) + creator_payout_accounts (Stripe Connect Express) | migration 160 |
| Split override | creator_revenue_splits (per-creator %) — add a merch_creator_pct column | migration 160 |
| Provider client | getOpenAiClient() / getReplicateClient() consolidation pattern | render-kit/providers.ts |
| Cost logging | tts_call_log + logTtsCall() (per-call cost rows) | tts-spend.ts |
| Fulfillment polling cron | runStoryCoverTick() + recordHeartbeat() + CRON_REGISTRY + isCronEnabled() | system-heartbeats.ts, cron-toggles.ts |
| Affiliate disclosure pattern | Amazon hivejournal-20 + FTC disclosure component | AmazonAffiliateDisclosure.tsx |
Only genuinely new external dependency: the print-on-demand provider.
4. POD provider
- Gelato for prints (posters, framed prints) — strong global print network + clean REST API + mockup generator. Start here; it's tightest to the inspiration-poster wedge.
- Printful for apparel (tees, hoodies, hats, mugs) — the apparel default; add in Phase 1.
Wrap each behind a provider client mirroring providers.ts:
apps/backend/src/services/gelato-client.ts → getGelatoClient(apiKey?), throwing if
GELATO_API_KEY is unset. New env vars (add to ARCHITECTURE.md "Key environment variables"):
GELATO_API_KEY, GELATO_WEBHOOK_SECRET (+ PRINTFUL_API_KEY in Phase 1).
For v1 the product catalog is a code constant (poster sizes → Gelato product UIDs + base costs), not a table. Promote to a table only when authors customize SKUs.
5. Data model (one new table + one column)
-- migration NNN: story-as-merch
CREATE TABLE merch_orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
buyer_user_id UUID REFERENCES auth.users(id), -- nullable: guest fan checkout
creator_user_id UUID NOT NULL, -- who earns
source_kind TEXT NOT NULL, -- 'season' | 'episode' | 'journal_song'
source_id UUID NOT NULL, -- season_id / episode_id / journal_song_id
item_type TEXT NOT NULL, -- 'poster_13x19' | 'tee' | 'mug' | ...
print_asset_url TEXT NOT NULL, -- the @4x cached upscale used
gelato_order_id TEXT, -- provider order ref
status TEXT NOT NULL DEFAULT 'pending', -- pending|submitted|in_production|shipped|delivered|canceled|failed
gross_cents INT NOT NULL,
pod_cost_cents INT NOT NULL, -- base + shipping quoted at checkout
stripe_session_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE creator_revenue_splits
ADD COLUMN merch_creator_pct NUMERIC; -- nullable; falls back to a platform default
Money: gross_cents = pod_cost_cents + margin. The author's share of margin flows to a
creator_earnings row with source_kind='merch_revenue' on fulfillment, using
merch_creator_pct (default split TBD — suggest 50/50 of margin, since unlike a tip the
platform carries POD ops + provider risk).
Author-for-self at cost: when buyer_user_id == creator_user_id, sell at
pod_cost_cents with zero margin — remove all friction from the author printing their
own inspiration poster. (This is also the cheapest way to dogfood print quality.)
6. Architecture decision: standalone, not an IR render target (yet)
The render-kit IR (Work → Section → Segment,
ir/types.ts) is built for
time-based, segmented outputs (audiobook, reel, scene, screenplay, print). A poster is a
single still + title block — it doesn't need segments. Forcing it through the IR adds
~2 weeks of infra for no v1 benefit.
v1 = standalone apps/backend/src/services/merch.ts: read story_seasons /
story_episodes / journal_songs directly, ensure the print-res asset, call Gelato. Revisit
folding poster layout into the IR (as WorkMeta.poster_url consumers) only once a designed
poster — multi-element layout pulling cast/quote/credits from the IR — is on the table. Noted
in EMBERKILN_PIPELINE_ARCHITECTURE.md as a deferred
render target.
7. Surfaces (frontend)
- Season page — a "Make it real" CTA near the poster in
seasons/[id]/SeasonClient.tsx. - Songs row — reuse the card in
GraphenePublicSongsRow.tsx(a "Print this" affordance alongside the new "Hide from homepage" one). - Storefront —
/seasons/[id]/shop(or/s/<slug>/shop): the cover applied to Gelato mockups, a size/product picker, Stripe checkout. This is the shareable "support me" link. - Author earnings — merch rows surface in the existing creator earnings view (they're
just
creator_earningswith a newsource_kind).
8. Fulfillment cron
runMerchStatusTick() polls Gelato for pending/submitted orders, advances
merch_orders.status, and on shipped flips the matching creator_earnings row to
transferred. Must recordHeartbeat({ name:'merch_status_tick', expectedIntervalSeconds:3600 })
on both paths, register a CRON_LABELS entry + a CRON_REGISTRY key with an
isCronEnabled('merch_status_tick') guard. (Gelato also posts fulfillment webhooks — use the
webhook as the primary signal and the cron as the backstop sweep.)
9. Gates / landmines
- Print rights —
gpt-image-1output is generally OK for commercial physical goods under OpenAI terms, but confirm before selling. Add the equivalent of the Amazon affiliate disclosure for POD. - Content moderation — Gelato/Printful reject recognizable real faces, logos, and
trademarks. AI covers sometimes render face-like figures (cf. the
E42card). A pre-flight moderation pass must gate the storefront, with a clear rejection message. - Margins + shipping — POD margins are thin and shipping can exceed product cost. The
self-inspiration framing tolerates this; the fan store needs honest, all-in pricing shown
before checkout (quote
pod_cost_centslive from Gelato, don't estimate). - Returns / quality — POD handles fulfillment + returns, but a bad upscale ships a bad poster. The §2 quality gate is load-bearing.
10. Phasing
- Phase 0 — prove the pipe. Print-res upscale path (§2) +
getGelatoClient()+ one product (13×19 poster) + author-at-cost self-order from the season page. No public store, no payout split. Validates print quality on real covers cheaply. - Phase 1 — the storefront.
/seasons/[id]/shop+ Stripe checkout (episode-tips pattern)merch_orders+creator_earnings(source_kind='merch_revenue')+ apparel via Printful + the fulfillment cron + content moderation gate.
- Phase 2 — scale + integrate. Designed (multi-element) posters off the IR; curated collections; physical inventory at conventions (ties into EMBERKILN_AUTHOR_NATION_BOOTH.md); per-author SKU customization (promote the code-constant catalog to a table).
11. Open decisions (need sign-off before Phase 1)
- Margin split — default
merch_creator_pct(suggest 50% of margin; tips are 80% but carry no COGS/ops). - Upscaler model — which Replicate model (Real-ESRGAN vs clarity-upscaler) clears the §2 quality bar on real covers — decided empirically in Phase 0.
- Provider — confirm Gelato (prints) + Printful (apparel), or consolidate on one (Gelato does apparel too, fewer integrations vs. Printful's better apparel catalog).
- Guest checkout — do fans need an account to buy, or is
buyer_user_idnullable from day one? (Lower friction = nullable; recommend allowing guests.)
Build log — Phase 0 (shipped 2026-06-27)
Prove the pipe: author-at-cost self-order of a single poster from the season page. The whole
path is gated/inert until GELATO_API_KEY is set — without it an order still takes payment
and parks at awaiting_fulfillment rather than failing (the Lulu/coaching "shipped but inert"
convention).
What shipped:
- migration 426 —
merch_orders(image-shaped, modeled onprint_orders) + a nullablemerch_creator_pctoncreator_revenue_splits(Phase-1 payout split, added now to avoid a second migration). print-asset.ts—ensurePrintAsset(): the one real new step. Upscales a cover 4× via Replicate Real-ESRGAN and caches it toseason-assets/print/<kind>-<id>-4x.png(upscale once per cover, not per order). Gated onREPLICATE_API_TOKEN.gelato-client.ts— Gelato Order API v4 client (isGelatoConfigured,createGelatoOrder,getGelatoOrder), inert without a key.merch.ts— Phase-0 product catalog (code constant),quoteSelfMerch(at-cost: base + shipping, margin 0),createSelfMerchCheckout(Stripe Checkout collects the shipping address),fulfillMerchOrder(webhook → upscale → Gelato or park).routes/merch.ts—GET /api/merch/products,POST /api/merch/:seasonId/self-checkout(owner-gated). Mounted inindex.ts; the Stripe webhook dispatcheskind:'merch_order'inroutes/payment.ts.MakeItRealCTA.tsx— owner-only "Make it real → Order a poster" card on the season page (next to Make another), framed as the inspiration poster, sold at cost.
Env config to activate (add to ARCHITECTURE.md "Key environment variables" when set):
GELATO_API_KEY (required to actually fulfill), GELATO_POSTER_13X19_UID (catalog product
UID — the default is a placeholder, confirm against Gelato's catalog), GELATO_POSTER_13X19_BASE_CENTS,
GELATO_POSTER_SHIPPING_CENTS. Reuses the already-configured REPLICATE_API_TOKEN +
STRIPE_SECRET_KEY/STRIPE_WEBHOOK_SECRET. See setup-guides/GELATO_MERCH.md.
Deliberately deferred to Phase 1 (not in Phase 0): the public/fan storefront, the payout
split (creator_earnings rows via merch_creator_pct), live Gelato price/shipping quoting,
apparel, the content-moderation gate, and the fulfillment-status cron. Not yet tested: no
golden test — the Phase-0 surface is I/O plumbing gated on external services and the only pure
function (quoteSelfMerch) is a trivial sum; the deterministic logic worth a test (live
quoting + margin splits) arrives in Phase 1.
Activation checklist: apply migration 426 to prod → create a Gelato account + API key → confirm the 13×19 poster product UID + set the cost env vars → place one real author-at-cost order and verify print quality on a real upscaled cover (the §2 quality gate).
Build log — Phase 1 PR A: fan storefront + payout + moderation + cron (shipped 2026-06-27)
Opens the public "support the author" storefront. Anyone (guests included) can buy a poster of
a public story; the markup is split 50/50 with the creator; a pre-sale moderation pass
gates covers the POD partner would reject. Still inert without GELATO_API_KEY (paid orders
park at awaiting_fulfillment).
- migration 427 — adds
'merch_revenue'to thecreator_earnings.source_kindCHECK + moderation-cache columns onstory_seasons(merch_moderation_status/_reason/_checked_at). - pricing —
merch-pricing.ts(pure, import-free):computeFanQuote(base + shipping +max(base×MERCH_MARGIN_PERCENT, MERCH_MARGIN_MIN_CENTS), rounded up to 50¢),computeSelfQuote,computeMerchSplit(creator share of the margin). Golden-tested —merch-pricing.test.ts(9 cases: markup/floor/round-up, never-below-cost, split-sums-to-margin). - moderation —
merch-moderation.tsensureMerchSellable(): gpt-4o-mini vision check (recognizable real faces / brand logos / trademarked characters / illegal content → reject), verdict cached on the season; fails open on infra error, never caches a non-verdict. Gates fan checkout only — not author self-orders. - storefront + payout —
merch.tsaddsassertPublicSeason(gate onis_publicly_viewable),getSeasonStorefront,createFanMerchCheckout(guests allowed via soft auth; moderation-gated), andbookMerchEarninginsidefulfillMerchOrder→ writes acreator_earningsrow (source_kind='merch_revenue',status='pending', split viamerch_creator_pct??MERCH_DEFAULT_CREATOR_PCT). At-cost self-orders (margin 0) no-op. - routes —
GET /api/merch/:seasonId/storefront(public; 404 → UI hides) +POST /api/merch/:seasonId/checkout(soft auth;422 merch_cover_rejectedwhen moderation blocks). - fulfillment cron —
merch-order-sync.tssyncInFlightMerchOrders()polls open Gelato orders (backstop for missed webhooks); wired as themerch_orders_synccron (15min) inindex.tswith heartbeat +CRON_REGISTRY+CRON_LABELS+ toggle guard. No-op when Gelato is unconfigured. - UI —
FanPosterStorefront.tsx: public "🖼️ Support this story — order a poster ($X)" card shown to non-owner visitors on the season page; self-hides when the season isn't sellable.
New env knobs: MERCH_MARGIN_PERCENT (default 40), MERCH_MARGIN_MIN_CENTS (default 500),
MERCH_DEFAULT_CREATOR_PCT (default 50). Also fixed: registered the merch_checkout_started /
merch_order_paid analytics events in ServerEvent (Phase 0 left them unregistered — a latent
backend type error that didn't block the Railway build).
Build log — Phase 1 PR B: apparel + drinkware SKUs (shipped 2026-06-27)
Extends the catalog from poster-only to poster · tee · hoodie · mug · cap, fulfilled by the same Gelato client (no Printful, no new integration). Pure catalog + UI work — the checkout, moderation gate, payout split, and fulfillment cron are all product-agnostic and unchanged.
merch.ts—MerchProductgains akind(poster | apparel | drinkware);MERCH_PRODUCTSaddstee/hoodie/mug/hatwith env-overridable Gelato UIDs + base/shipping costs. The cover is reused as the print file as-is (Gelato fits it to each product's print area; a per-garment "centered graphic" treatment is a later refinement). Both product-list endpoints now returnkind.- UI —
FanPosterStorefront.tsxMakeItRealCTA.tsxbecome product pickers (chips with per-item icon + price; one order button for the selection).
- New env (all optional, placeholder defaults):
GELATO_{TEE,HOODIE,MUG,HAT}_UID+_BASE_CENTS+_SHIPPING_CENTS.
Phase 1 complete. Phase 2 — designed (multi-element) posters off the IR, curated
collections, convention inventory (EMBERKILN_AUTHOR_NATION_BOOTH.md),
per-author SKU customization (promote the code-constant catalog to a table). Not yet built:
Stripe Connect transfer automation (earnings book as pending; moving money to the creator's
Connect account is the existing manual/period reconciliation path); live Gelato price/shipping
quoting (catalog is still a code constant); per-garment print-file treatment.
Build log — hardening + marketing (2026-06-27)
Sell-gate fix (important). "Inert until configured" originally meant fulfillment parked
at awaiting_fulfillment — but checkout still took the buyer's money via Stripe first, so the
never-configured state would charge a fan and ship nothing. Fixed in
routes/merch.ts: every sell path now gates on
isGelatoConfigured() (mirroring the Lulu storefront's 503) — GET /products returns empty,
GET /:seasonId/storefront 404s (→ CTAs self-hide), and both checkout endpoints 503. So
without a Gelato key we don't sell at all (no money taken); the awaiting_fulfillment parking
now only covers a transient Gelato outage after it's configured.
Marketing surfaces. Merch is now positioned as the next output form of the "every form"
thesis: a Merch chip in EmberkilnFlow
on /emberkiln, a Story-as-merch — posters & apparel entry in the
/features catalog, and a FAQ entry on the
/studio landing. Safe to ship ahead of
activation because the buy paths self-gate (above) — the copy describes the capability; the
buttons only light up once Gelato is wired.
Build log — money-path audit (2026-06-27)
Adversarial pass over the merch money paths for the same bug class as the sell-gate miss. Two real findings fixed; two limitations noted (matching the existing Lulu storefront, not regressions).
Fixed:
- Double-fulfillment race —
fulfillMerchOrderreadstatus, then wrote much later, so two concurrent/duplicate Stripecheckout.session.completeddeliveries could both pass thestatus === 'pending_payment'guard → double Gelato order + double earnings row + double upscale. Now an atomic compare-and-swap claims the row (pending_payment → processing); only the winning delivery proceeds, the rest no-op. This also makesbookMerchEarningidempotent (it relied entirely on that guard). - Stale moderation verdict on cover change — a regenerated cover kept the old
merch_moderation_status, so a newly-rejectable cover could sell under a priorapproved(or a fixed cover stayrejected). Both poster write-sites inseason-media.tsnow null the moderation cache so the new cover is re-checked before sale.
Known limitations (consistent with the Lulu storefront, not fixed here):
A transient failure during fulfillment marks the order— fixed 2026-07-05, see build log below.paid_unfulfilled… with no auto-retryRefunds don't reverse a— fixed 2026-07-05, see build log below. (An already-transferred earning still needs a manual Connect reversal — surfaced, not silently voided.)pendingearnings row
Build log — stranded-order auto-retry (2026-07-05)
Closes the "took the money, shipped nothing, no auto-retry" gap the audit flagged. A PAID
order could get stuck before reaching Gelato — paid_unfulfilled (fulfillment threw), a
stranded processing (crash mid-fulfill), or awaiting_fulfillment (paid while Gelato was
unconfigured; now it is) — and the merch_orders_sync cron only polled orders that already
had a gelato_order_id, so these sat until a human noticed.
- migration 453 —
merch_orders.fulfillment_attempts(int, default 0) +last_attempt_at. The attempt counter doubles as an optimistic-lock version. - idempotent
bookMerchEarning(merch.ts) — guards on the Stripe payment intent so retrying an already-booked order (e.g. anawaiting_fulfillmentorder that booked its earning before Gelato was configured) can't write a secondmerch_revenuerow. Removes the audit-noted "relied solely on the CAS guard" coupling. performMerchFulfillment(order)— the fulfillment body, extracted fromfulfillMerchOrderso both the webhook path (claimspending_payment→processing) and the retry sweep (optimistic-lock claim) run the exact same code.retryStrandedMerchOrders()(merch-order-sync.ts) — selects due orders via the pure, golden-testedselectRetryableMerchOrders(retryable status, nogelato_order_id, under the 6-attempt cap, past its exponential backoff (10min ×2ⁿ, capped 6h), and forprocessingonly if stale >20min so an in-flight fulfill isn't grabbed), claims each with an optimistic lock onfulfillment_attempts(concurrent sweeps can't both re-fulfill), then re-runsperformMerchFulfillment. No-op when Gelato is unconfigured. Golden test:merch-order-retry.test.ts(9 cases: which states retry, Gelato-id exclusion, processing staleness, attempt cap, backoff).- cron — folded into the existing
merch_orders_synctick (status sync first, then the retry sweep) so no new heartbeat/registry entry; retry counts surface in the heartbeat metadata (retry_candidates/retry_recovered/retry_failed).
After the cap (6 attempts) an order stays paid_unfulfilled for manual ops (a true
dead-letter) — each failure is still logged via recordServiceError.
Build log — refund reverses pending earnings (2026-07-05)
Closes the second audit limitation. A Stripe refund used to leave the
creator_earnings row untouched, so a refunded sale still counted toward a payout the creator
never earned. No migration — creator_earnings.status already allows 'reversed' and the
row carries stripe_payment_intent_id.
charge.refundedwebhook case (routes/payment.ts) — on a refunded charge, matches earnings by payment intent and reverses them.refund-earnings.ts— pure, golden-testedselectEarningsToReverse(only a full refund voids earnings; reversepending, and surfacetransferredones for a manual Connect reversal rather than silently voiding money already paid out; partial refunds punt to manual).reverseEarningsForRefundwritesstatus='reversed'with a.eq('status','pending')race guard, idempotent on redelivery. Test:refund-earnings.test.ts(6 cases). Source-kind agnostic — a refunded tip reverses the same way, so this fixes the gap for allcreator_earnings, not just merch.- Activation: enable the
charge.refundedevent on the Stripe webhook endpoint (dashboard) — the handler is inert until Stripe delivers the event.
Remaining Phase-2 money work: Stripe Connect transfer automation (still moves pending →
transferred out of band; an already-transferred refund needs a manual reversal, now surfaced).