Split long screenshots
A full-page screenshot of a long page can be 1280 x 16,000 pixels or more. That shape is a problem for most consumers. Vision models scale large images down, so the text in a very tall image becomes too small to read. Report tools and PDF pages want fixed-height pages. Chat tools show a thin strip. The answer is to capture once and cut the image into overlapping slices on your side.
RenderScreenshot returns one image per request; it does not slice. This guide shows the capture, then tested slicing code in Node.js (sharp) and Python (Pillow). For the capture itself, the full-page screenshot guide explains scroll simulation and lazy images.
Step 1: capture the full page
With GET, full_page=true is enough:
curl -G "https://api.renderscreenshot.com/v1/screenshot" \ -H "Authorization: Bearer $RS_API_KEY" \ --data-urlencode "url=https://example.com" \ --data-urlencode "full_page=true" \ -o full.png
With POST you can also cap the height and scroll for lazy images. scroll.max_height defaults to 16,000 px. Set it to what your slices will use, so you do not pay to render content you will throw away:
curl -X POST https://api.renderscreenshot.com/v1/screenshot \ -H "Authorization: Bearer $RS_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com", "viewport": { "width": 1280, "scale": 1 }, "capture": { "mode": "full_page", "scroll": { "simulate": true, "max_height": 12000 } }, "output": { "format": "png" } }' \ -o full.png
Keep viewport.scale at 1 for images you will slice. Scale multiplies both sides, so a 6,000 px page at scale 3 becomes an 18,000 px image (see retina screenshots). Capture PNG if the slices will be re-encoded later; every JPEG or WebP round trip loses detail.
With the SDKs
Every SDK has a full-page builder method. The SDKs have no method for scroll.max_height; if you need a cap, send the POST above with your HTTP client.
Node.js
import { writeFile } from 'node:fs/promises'; import { Client, TakeOptions } from 'renderscreenshot'; const client = new Client(process.env.RS_API_KEY); const image = await client.take(TakeOptions.url('https://example.com').fullPage().format('png')); await writeFile('full.png', image);
Python
import os from renderscreenshot import Client, TakeOptions client = Client(os.environ['RS_API_KEY']) image = client.take(TakeOptions.url('https://example.com').full_page().format('png')) with open('full.png', 'wb') as f: f.write(image)
Ruby
require 'renderscreenshot' client = RenderScreenshot.client(ENV.fetch('RS_API_KEY')) image = client.take(RenderScreenshot::TakeOptions.url('https://example.com').full_page.format('png')) File.binwrite('full.png', image)
PHP
use RenderScreenshot\Client; use RenderScreenshot\TakeOptions; $client = new Client(getenv('RS_API_KEY')); $image = $client->take(TakeOptions::url('https://example.com')->fullPage()->format('png')); file_put_contents('full.png', $image);
Java
Client client = new Client(System.getenv("RS_API_KEY")); byte[] image = client.take(TakeOptions.url("https://example.com").fullPage().format("png")); Files.write(Path.of("full.png"), image);
Go
client, err := rs.New(os.Getenv("RS_API_KEY")) if err != nil { log.Fatal(err) } image, err := client.Take(ctx, rs.URL("https://example.com").FullPage().Format(rs.FormatPNG)) if err != nil { log.Fatal(err) } os.WriteFile("full.png", image, 0o644)
Step 2: slice with overlap
Both scripts below do the same thing: walk down the image in steps of sliceHeight - overlap, write each slice as PNG, and write a manifest.json with the top offset of every slice. The last slice is shorter. Keep the manifest: when a model says "the error message is at y=310 in slice 2", the page position is slices[2].top + 310.
Node.js (sharp)
// slice.mjs - split a tall screenshot into overlapping slices. Node 22+, [email protected] // Usage: node slice.mjs <in.png> <outDir> [sliceHeight=2000] [overlap=200] import { mkdir, writeFile } from 'node:fs/promises'; import sharp from 'sharp'; const [input, outDir = 'slices', sliceArg = '2000', overlapArg = '200'] = process.argv.slice(2); const sliceHeight = Number(sliceArg); const overlap = Number(overlapArg); if (!input || !(sliceHeight > overlap && overlap >= 0)) { console.error('usage: node slice.mjs <in.png> <outDir> [sliceHeight] [overlap < sliceHeight]'); process.exit(2); } sharp.cache(false); // reading the same input many times; do not keep decoded copies const { width, height } = await sharp(input, { limitInputPixels: false }).metadata(); await mkdir(outDir, { recursive: true }); const slices = []; for (let top = 0; ; top += sliceHeight - overlap) { const h = Math.min(sliceHeight, height - top); const file = `${outDir}/slice-${String(slices.length).padStart(3, '0')}.png`; await sharp(input, { limitInputPixels: false }) .extract({ left: 0, top, width, height: h }) .png() .toFile(file); slices.push({ index: slices.length, file, top, height: h }); if (top + h >= height) break; } await writeFile(`${outDir}/manifest.json`, JSON.stringify({ input, width, height, sliceHeight, overlap, slices }, null, 2)); console.log(`${input}: ${width}x${height} -> ${slices.length} slices`);
Python (Pillow)
# slice.py - split a tall screenshot into overlapping slices. Python 3.10+, Pillow 12.3.0 # Usage: python slice.py <in.png> <out_dir> [slice_height=2000] [overlap=200] import json import sys from pathlib import Path from PIL import Image Image.MAX_IMAGE_PIXELS = None # full-page captures trip Pillow's decompression-bomb guard def slice_image(src, out_dir, slice_height=2000, overlap=200): if not 0 <= overlap < slice_height: raise ValueError("overlap must be >= 0 and smaller than slice_height") out = Path(out_dir) out.mkdir(parents=True, exist_ok=True) with Image.open(src) as im: width, height = im.size slices, top = [], 0 while True: h = min(slice_height, height - top) path = out / f"slice-{len(slices):03d}.png" im.crop((0, top, width, top + h)).save(path) slices.append({"index": len(slices), "file": str(path), "top": top, "height": h}) if top + h >= height: break top += slice_height - overlap manifest = {"input": str(src), "width": width, "height": height, "slice_height": slice_height, "overlap": overlap, "slices": slices} (out / "manifest.json").write_text(json.dumps(manifest, indent=2)) return manifest if __name__ == "__main__": src, out_dir = sys.argv[1], sys.argv[2] sh = int(sys.argv[3]) if len(sys.argv) > 3 else 2000 ov = int(sys.argv[4]) if len(sys.argv) > 4 else 200 m = slice_image(src, out_dir, sh, ov) print(f"{src}: {m['width']}x{m['height']} -> {len(m['slices'])} slices")
Before and after (tested)
Tested on 2026-10-15 with Node 22.18.0 + sharp 0.35.5 and Python 3.14.8 + Pillow 12.3.0 on an Apple M1 Pro. The inputs were full-page PNGs of a feed test page captured locally with Playwright, not API renders.
| Input | Slice / overlap | Slices (top, height) |
sharp time | Pillow time |
|---|---|---|---|---|
| 1280 x 4,267 PNG | 2,000 / 200 | (0, 2000), (1800, 2000), (3600, 667) | not timed | not timed |
| 1280 x 12,000 PNG, 525,930 bytes | 4,000 / 400 | (0, 4000), (3600, 4000), (7200, 4000), (10800, 1200) | 0.19 s (3 runs) | 0.27 to 0.28 s (3 runs) |
Both scripts produced the same offsets, and the slices were pixel-identical between sharp and Pillow (the PNG bytes differ because the encoders differ). Times are for the whole process, including start-up.
Choosing slice height and overlap
- Slice height: start from what the consumer can read. For a vision model, check the provider's documented image limits and how it resizes large images, and pick a height that keeps body text at a readable size after resizing. For PDF reports, use the page's aspect ratio (a 1280 px wide A4 page is about 1,810 px tall).
- Overlap: large enough for the tallest thing you do not want split, usually a line of text plus margin. 10% of the slice height is a practical start.
- Do not make the last slice tiny. If the last slice would be under about a quarter of the slice height, either merge it into the one before or let it be: a 40 px strip wastes a model call.
Cut at plain rows instead of fixed offsets
Fixed offsets can cut through a line of text even with overlap. A better cut is a row where every pixel has the same colour, such as the gap between two cards. This Pillow helper walks up from each hard limit (up to 300 px) and uses the first plain row it finds:
# smart_cuts.py - move each cut to a nearby plain row so slices do not split a line of text. # Python 3.10+, Pillow 12.3.0 import sys from PIL import Image Image.MAX_IMAGE_PIXELS = None def plain_row(im, y): """True when every pixel in row y has the same colour (a gap between blocks).""" lo, hi = zip(*im.crop((0, y, im.width, y + 1)).getextrema()) return lo == hi def cut_points(im, slice_height=2000, search=300): cuts, top = [], 0 while im.height - top > slice_height: target = top + slice_height # Walk up from the hard limit; take the first plain row we find. y = next((y for y in range(target, target - search, -1) if plain_row(im, y)), target) cuts.append(y) top = y return cuts if __name__ == "__main__": with Image.open(sys.argv[1]) as im: im = im.convert("RGB") cuts = cut_points(im, int(sys.argv[2]) if len(sys.argv) > 2 else 2000) edges = [0, *cuts, im.height] for i, (a, b) in enumerate(zip(edges, edges[1:])): im.crop((0, a, im.width, b)).save(f"smart-{i:03d}.png") print("cuts at", cuts)
On the 1280 x 4,267 test image with a 2,000 px limit, it cut at [1885, 3845], both inside the 12 px gaps between cards, so no slice needed overlap. Pages with a background image or a gradient have no plain rows; the helper then falls back to the hard limit, and you should keep the overlap.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Input image exceeds pixel limit (sharp) |
sharp refuses inputs above 268,402,689 pixels (16,383 x 16,383) by default | Pass limitInputPixels: false for images you produced, or lower viewport.scale |
DecompressionBombError (Pillow) |
Pillow's guard at 89,478,485 pixels | Set Image.MAX_IMAGE_PIXELS = None for trusted images |
| A text line is split between slices | Fixed offset, no overlap | Add overlap, or cut at plain rows |
| Slices look soft after upload to a model | The model downscaled each slice | Use shorter slices, or a narrower viewport.width |
| Lower half of the capture is empty | Lazy content never loaded | capture.scroll.simulate: true, see the full-page guide |
| Capture ends early on a long page | scroll.max_height reached (default 16,000 px) |
Raise it in the POST body, or accept the cap |
| Same rows appear twice in a stitched report | Overlap kept when you join slices again | Use the manifest offsets and drop the overlap when you join |
| Process uses a lot of memory | Decoded image held for each slice | sharp: sharp.cache(false); Pillow: open once, crop many times (as above) |
Limits
- One image per request; slicing is done on your side, as on this page.
capture.scroll.max_heightdefaults to 16,000 px and is set in the POST body; there is no GET parameter for it.viewport.scale1 to 3 multiplies the output height.wait.timeoutis 30 seconds by default and 60 seconds at most; very long pages with scroll simulation need more of it.
Related
- Capture parameters:
mode,scroll.simulate,scroll.step,scroll.delay,scroll.max_height. - Clip screenshots: when you only need one region, crop at capture time instead.
- Infinite scroll pages: deciding where a page without an end should stop.