The Canvas That Could Not Fit
On its first evening Ariandel let you make a 16384×16384 canvas on any iPad. Do the arithmetic the 6479f1f review did. That canvas is 268 million pixels. One layer's slice of the 8-bit GPU mirror is four bytes a pixel, exactly 1 GiB, and the layer array starts with at least four slices: 4 GiB before a single stroke. The rgba16Float composite adds 2 GiB more, plus a third again for its mipmaps. The tiles and the undo history come on top of that. The review's line was blunt: canvas size "allowed 16384x16384 regardless of device memory (GPU mirror alone would exceed RAM)."
The fix makes the canvas a function of the machine (first code block). CanvasSize.maximumPixels(memory:) reads the device's RAM, from ProcessInfo.processInfo.physicalMemory in the app, and answers 16 million pixels up to 4 GiB, 36 million up to 8 GiB, and 64 million above. Independently, no edge may exceed 16,384 pixels, the largest 2D texture Metal allows on every supported iPad. limited(toPixels:) scales a request down proportionally until both caps hold, rounding each side down.
The cap has a twin that is easy to miss: maximumLayers. Every layer costs one mirror slice, so the layer count is a budget of 1,400 MiB divided by the bytes one 8-bit slice takes, clamped between 4 and 200. A 16-megapixel canvas holds 22 layers. A 64-megapixel canvas holds 5. An A4 page at 300 dpi holds 42. The second code block runs the same rules for three device sizes so you can see the trade: a bigger canvas is a shallower stack, and Ariandel says so with a toast ("This canvas holds at most 5 layers") instead of failing later.
The cap is applied where a canvas is made or imported from another format, each door with its own policy, and two doors are not as strict as the rest:
- New Canvas shows the result before you commit, for example "This iPad holds up to 36 megapixels, so the canvas will be 6000 × 6000" for a 16384 × 16384 request.
- A photo or picture is decoded directly at the fitted size, so a huge image never sits in memory at full size first.
- A PSD or PSB larger than the cap is scaled down whole, each pixel the average of the ones it covers, built a tile row at a time.
- A
.procreatefile with more pixels than the cap is refused with its size and the limit, rather than resampled. Only the pixel count is checked there: a long strip under the pixel cap but past 16,384 on one edge is not refused at that door. - An
.ariandelpackage, yours or one shared from another iPad, opens at the size it was saved. Nothing checks it against this iPad's cap, so a canvas made on an iPad with more memory arrives at its full size.
Two budgets stay fixed while the canvas cap moves: the layer mirror's 1,400 MiB and the undo history's 768 MiB (this track's first lesson). When memory runs short anyway, the warning path from that lesson sheds history and never touches layers.
Mipmaps, but Only When Smaller
Zoomed far out, a canvas pixel covers less than one screen pixel, and a plain bilinear sample skips most of the picture: fine lines flicker, hatching turns to moiré, and a pan makes the whole canvas shimmer. Mipmaps fix that with pre-shrunk copies the sampler blends between. The review added them, and the rule (third code block) is narrow: generate them only when transform.scale × contentScale < 1, that is, when one canvas pixel covers less than one device pixel, and only when the composite changed since the last build (every composite pass marks them stale). Then one blit, generateMipmaps(for:), before the display pass.
Watch the units. The zoom readout counts points, and an iPad's display has two device pixels per point, so this threshold is 50% on the readout, not 100%. The review's own record says "below 100 %"; the code says device pixels, and the code is right about where minification starts. Between 50% and 100% each canvas pixel still covers at least one device pixel, and mipmaps would only cost time.
Files Out
Every export is file-format work over the two readers from Track 3: tiles directly, or a flatten synchronized from tiles.
| Export | What it is |
|---|---|
| PNG, transparent PNG, JPEG | The flattened picture. |
| TIFF | LZW-compressed, tagged with the canvas DPI. |
| A page sized from the canvas DPI (300 by default), so a 300 dpi A4 canvas is an A4 page; with Page Assist, one page per page. | |
| PSD (layers) | Layered, with the flattened picture as its composite. |
| PNG Files (each layer) | Every layer as its own canvas-sized PNG, through its mask, straight from tiles. |
| Animated GIF, animated PNG, video | Animation Assist's frames in playback order; GIF at 1080 px, video at 1920 px on the long edge. |
| Time-lapse | The recorded segments joined into one MP4, in full or condensed to thirty seconds. |
The time-lapse records through AVAssetWriter into fragmented movies at 1080p, 2K or 4K. The codec is chosen by frame size: H.264 up to a 1920-pixel long edge, HEVC past it, because H.264's largest frames cannot hold every canvas (a 4K square is beyond them) and HEVC carries the detail in fewer bits. The bit rate follows the pixel count: three bits per pixel per second for H.264, about 6 Mb/s at 1080p, and two for HEVC.
Files In
Ariandel reads other apps' formats as files. It is not affiliated with their makers, and it reads only what a file contains.
- PSD and PSB: RGB or grayscale, 8 or 16 bits, raw, PackBits or ZIP channels, with names, opacity, blend modes, clipping and masks. Folders become groups one level deep; adjustment layers have no pixels and are left out, and counted.
.procreateartworks: a zip holdingDocument.archive, a keyed archive, and one folder of LZ4-compressed tiles per layer. Only the entries the layers need are read, from a memory-mapped file. Older files whose tiles are LZO are refused.- Brushes: MyPaint
.mybpresets (alone or zipped into a pack, previews alongside), Procreate's.brushand.brushset, and Photoshop's.abr, versions 6 to 10. What maps onto the engine is carried over; what does not (wet mix, color dynamics, most other dynamics) is left out, never guessed.
The keyed archive is the instructive one. NSKeyedUnarchiver would rebuild the archive's objects by instantiating the classes it names, and those classes belong to another app. KeyedArchive (third code block) never does that: it parses the property list and walks $objects by reference index, so strings, numbers and dates come out as values and everything else stays a dictionary. No code from the file runs, and a hostile file is just a strange plist.