I build a tool that turns text prompts into wall art you can actually hang β canvas prints, posters, 4Γ6 ft tapestries. The generation part is the easy part. The part nobody warns you about is this:
SDXL hands you a 1024Γ1024 image. A four-foot tapestry needs roughly 3,500 pixels on the long edge. A poster needs more.
For about a month I brute-forced this by upscaling everything 4Γ and hoping. It was slow, it blew my function memory limits, and it produced 60 MB files for products that didn't need them. Then I actually did the math, and it turned out I'd been solving the wrong problem.
Here's what I wish I'd read first.
The "300 DPI or it's garbage" rule comes from offset printing β magazines, books, brochures. Things you hold about 12 inches from your face.
Nobody holds a four-foot tapestry twelve inches from their face.
Required resolution is a function of viewing distance, not print size. The human eye resolves roughly one arcminute of detail. There are ~3438 arcminutes in a radian, which gives you a genuinely useful one-liner:
// Pixels per inch needed for a print to look "sharp"
// at a given viewing distance. 3438 = arcminutes per radian.
const requiredPPI = (viewingDistanceInches) => 3438 / viewingDistanceInches;
requiredPPI(12); // 286 PPI β a book in your hands. Hence "300 DPI".
requiredPPI(24); // 143 PPI β a small canvas on a desk shelf.
requiredPPI(72); // 48 PPI β a big tapestry across the room.
That last number is the one that changed my pipeline. A tapestry viewed from six feet away needs 48 PPI, not 300. That is a 6Γ difference in linear resolution and a 36Γ difference in pixel count.
const pixelsNeeded = (printInches, viewingDistanceInches) =>
Math.ceil(printInches * requiredPPI(viewingDistanceInches));
// A 4ft Γ 6ft tapestry, viewed from ~6ft
pixelsNeeded(72, 72); // 3438px on the long edge
// A 12" Γ 16" canvas, viewed from ~2ft
pixelsNeeded(16, 24); // 2292px on the long edge
// A 24" Γ 36" poster, viewed from ~4ft
pixelsNeeded(36, 48); // 2579px on the long edge
Here's the thing that surprised me most: the giant tapestry and the small canvas need almost the same number of pixels. The tapestry is 20Γ the surface area, but you stand 3Γ further back, and those cancel out almost exactly.
So the naive rule β bigger product, bigger upscale β is just wrong. I had it backwards for a month.
| Product | Print size | Typical viewing | Required PPI | Long-edge px |
|---|---|---|---|---|
| Small canvas | 12Γ16 in | 24 in | 143 | 2292 |
| Large canvas | 24Γ36 in | 48 in | 72 | 2579 |
| Poster | 24Γ36 in | 48 in | 72 | 2579 |
| Tapestry | 48Γ72 in | 72 in | 48 | 3438 |
One 4Γ upscale from 1024px gets me to 4096px, which clears every single row in that table. I don't need a tiered pipeline at all. I need one upscale and a downsample.
Knowing the target is half of it. The other half is not exploding.
An RGBA buffer is width Γ height Γ 4
bytes, uncompressed, in memory:
const bufferMB = (w, h) => (w * h * 4) / 1024 / 1024;
bufferMB(1024, 1024); // 4 MB β fine
bufferMB(4096, 4096); // 64 MB β fine, but...
bufferMB(8192, 8192); // 256 MB β and sharp may hold 2-3 of these at once
That last one is what killed me. Image pipelines hold the source, the destination, and often an intermediate simultaneously. An 8192px "just to be safe" upscale would spike past 700 MB and get OOM-killed, intermittently, only on large orders β the worst kind of bug.
Two things fixed it:
1. Stop upscaling past what the table says. Obvious in hindsight. 4096px covers everything.
2. Never do this in the request path. Upscaling is a queue job, not an HTTP handler. The user gets their preview immediately from the raw generation; the print-resolution render happens after checkout, when you actually know which size they bought. No reason to burn GPU seconds upscaling art nobody purchased.
// Don't render print files on generate. Render on purchase.
async function onCheckoutComplete(order) {
const target = PRINT_TARGETS[order.productType]; // from the table above
await printQueue.enqueue({
imageId: order.imageId,
longEdgePx: target.longEdgePx,
bleedInches: target.bleed,
});
}
Canvas wraps around a wooden frame. Tapestries get hemmed. Both eat your edges.
A 12Γ16 canvas on a 1.5" stretcher bar loses 1.5 inches on every side β so you're feeding a 15Γ19 inch image and only 12Γ16 survives on the front face. If you compose to the exact print size, your subject's head gets folded around the back of the frame.
const withBleed = (inches, bleedInches) => inches + bleedInches * 2;
withBleed(12, 1.5); // 15
withBleed(16, 1.5); // 19
Generate wide, crop late. Outpainting is very useful here β it's often better to extend the AI image outward than to upscale-and-crop a composition that was framed tightly to begin with.
Cut my per-order compute by roughly 70%, and the OOM errors went away entirely because nothing allocates a quarter-gigabyte buffer anymore.
If you're putting generated images onto physical objects, write down the viewing distance before you write any code. It determines your resolution target, which determines your memory ceiling, which determines whether this runs in a serverless function or needs a real worker.
I got this wrong in the most expensive possible direction β over-engineering for a quality level no human eye could resolve from across a room.
If you want to see the output side of this, the generator is free to try here β the print-resolution pipeline above is what runs behind it. The tapestry sizing is the case where the math is most counterintuitive: biggest product, lowest PPI requirement.
Happy to answer questions on the upscaling or the bleed math β the bleed thing in particular cost me a batch of misprints before I understood it.