Export
Save what a sketch draws. The output comes in four families:
- Raster, meaning PNG frames and image sequences.
- Motion, meaning a video file or an animated GIF, encoded directly.
- Vector, meaning SVG for pen plotters and any vector pipeline, and PDF for print.
- Linear, meaning an OpenEXR frame for a compositor: the light before the tone map, with the scene's depth beside it.
Export runs the sketch headlessly. It calls setup(), advances the clock to the frame you ask for, runs draw(), and writes the result. No window opens, so the same call works from a script or a render farm. A sketch that stops its loop is held where it stopped, as a window holds it. After noLoop(), the frame it stopped on is the picture from then on, drawn once.
Most exports are reached by a command-line flag on any example's executable. The same work is available as functions on OllinApp when you drive the export yourself. A loose sketch file has no target of its own, so you run it through the live host, OllinLive, which takes the same flags. OllinLive compiles the file and runs the export headlessly instead of opening a window:
swift run OllinLive MySketches/Loop.swift --export-gif loop.gif --seconds 4 --gif-width 540
swift run OllinLive MySketches/Loop.swift --export poster.png --frame 90
Contents
- Raster: PNG and sequences -
--export(PNG, or HEIC to keep highlights),--export-sequence - Linear frames: EXR -
--export-exr,--export-sequence --exr, the frame before the tone map, with depth - Render quality -
--render-quality, the live vs. export default - Render scale -
--render-scale, drawing a frame more finely than it is saved - Path-traced render -
--path-traced, the offline light-tracing mode for 3D scenes (its own page) - Sound - a sketch's own music, in the file
- Video -
--export-video,OllinApp.exportVideo - Frame rates -
--fpsby number or by name,FrameRate, the broadcast fractions kept exact - Transparent output -
background(.clear)kept as alpha in the PNG, the sequence, the GIF, and aproRes4444/hevcWithAlphaclip - Spatial video -
--export-spatial, a stereo pair per frame for a headset (its own page) - Web page -
--export-web, a page that plays what the sketch drew back in a browser, standalone or inline (its own page) - Animated GIF -
--export-gif,--gif-palette,OllinApp.exportGIF, andGIFWriterfor frames of your own - Perfect loops -
--export-loop,Sketch.loopDuration - Slow motion -
--slow-motion,--made-frames, a file that plays slower than the sketch ran - Settled frames -
--settle, each written frame drawn several times with the clock held, for a picture that converges - Vector: SVG -
--export-svg,OllinApp.svg/exportSVG - Vector: PDF -
--export-pdf,OllinApp.pdf/exportPDF, paper sizes - What SVG export records - the shape mapping and the limits
- Hatching: solid fills for a pen plotter -
--hatch,Hatching - When an export fails -
ExportError, what everyOllinApp.export…call throws - Reproducibility metadata - the regeneration recipe every export carries
- Captures that know their source -
--capture-source, keeping the exact code a file came from - Rendering a chosen variation -
--seed, on every export path - Setting a parameter for the run -
--param name=value, a declared@Paramset from the command line - Saying what a sketch declares -
--list-params, every@Paramwith its kind, what it accepts, and what it holds - Driving the parameters from a file -
--automation, keyframed parameters on every export path - Contact sheets -
--export-grid(seeds) and--export-sweep(a@Param),OllinApp.contactSheet/exportContactSheet - Print separations -
--export-separations, per-ink masters for risograph and screen printing (its own page) - Recording - keep a live run instead of re-rendering one: real-time capture with sound (its own page)
Raster: PNG and sequences
Write one frame to a PNG:
swift run --package-path Examples Example-Basic-HelloCircle --export /tmp/frame.png # frame 0
swift run --package-path Examples Example-Basic-HelloCircle --export /tmp/frame.png --frame 90 # a later frame
--frame counts at 60 frames a second unless --fps says otherwise, so --frame 30 --fps 10 is the moment a 10 fps sequence puts at its thirty-first frame, three seconds in. Every one-frame export reads the two flags the same way: the still, the EXR, the plates, and the vector files.
Write a deterministic, fixed-timestep PNG sequence (ready for ffmpeg):
swift run --package-path Examples Example-Basic-HelloCircle --export-sequence /tmp/out --seconds 5 --fps 60
Both render through Metal off-screen, with MSAA and then a resolve, so the pixels match the live window. The same capability is available in code as OllinApp.image(of:frame:), OllinApp.export(_:to:frame:), and OllinApp.exportSequence(...). The first of these returns a CGImage, and all three are called with try (see When an export fails). For a reproducible sequence, seed the sketch with seed(…) in setup(). Sources that follow the export clock stay reproducible too. A VideoPlayer decodes by the sketch clock, so frame k shows the clip at k / fps. An AudioPlayer feeds its analyzer the same slice of its file each frame. So an audio-reactive sketch exports with its beats in the same places every run.
Use the sequence when you want raw material. It keeps every frame as a lossless PNG for an external encoder or an edit. When the goal is only a file to share, the next two sections encode a file directly and skip the stitching step.
The file name picks the format for a single frame. A .heic (or .heif) path writes HEIC instead of PNG:
swift run --package-path Examples Example-Rendering-ColorOutput --export /tmp/frame.heic
For an ordinary sketch, that is the same picture in a smaller file. For a sketch that declared colorOutput .extended, HEIC is the only still format that keeps brightness above white. HEIC stores that brightness in a gain map beside the picture, and PNG has nowhere to put it.
Linear frames: EXR
Every other still export ends at the same place. The frame is tone-mapped into the range a screen can show, then dithered, then quantized to eight bits per channel. A .wide or .extended frame keeps more: 16 bits in a PNG and 10 in a HEIC, with no dither needed at that depth. That is the right file to look at and the wrong one to work on. --export-exr writes the frame one step earlier, in linear light, as an OpenEXR file:
swift run --package-path Examples Example-Export-LinearFrame --export-exr /tmp/frame.exr
swift run --package-path Examples Example-Export-LinearFrame --export-exr /tmp/frame.exr --frame 90
swift run --package-path Examples Example-Export-LinearFrame --export-sequence /tmp/frames --seconds 2 --exr
The sequence form writes frame-00001.exr, frame-00002.exr, and so on, from the same fixed-timestep drive the PNG sequence uses, so it takes --frames / --seconds, --fps, --skip, and --start unchanged. In code the same two calls are OllinApp.exportEXR(_:to:frame:) and OllinApp.exportSequence(..., writesEXR: true).
What the file holds. Five channels at most:
| Channel | Type | What it is |
|---|---|---|
R, G, B | half | The light the renderer composited, in linear light with Rec. 709 primaries, premultiplied by the coverage in A. Free to run above 1, which is where a highlight lives. |
A | half | The frame's own coverage, the same alpha transparent output writes into a PNG. 1 everywhere on an opaque canvas, and never above 1: additive marks sum their alpha along with their light, and the file stops it at 1. |
Z | float | Distance from the eye in the sketch's own world units, for a frame drawn through a 3D camera. A pixel nothing drew to carries the camera's far distance. |
A flat sketch writes no Z. Its depth buffer holds a sort key rather than a distance, and a compositor reading that as depth would be wrong about the whole frame.
The export prints what came through, which is worth reading before taking the file anywhere:
Ollin: exported frame 0 → /tmp/frame.exr (1080×1080, ABGRZ linear, 14.0 MB, peak 10.41× white, depth 3.358…40.000)
What it is for. Grading, glow, haze, and depth of field are all done better on the light than on the picture of it. A highlight ten times brighter than white is one number in this file and a flat 255 in the PNG. Pull the exposure down on each: the first has detail, the second a gray disk. The Z channel lets a compositor put fog or a defocus in afterward, at a distance chosen later. The alpha lets the frame sit over other footage. None of this needs the sketch re-rendered.
What it does not hold. There is no surface-normal channel and no per-object matte. The renderer shades in one forward pass and keeps no geometry buffer to write them from. Producing either means a second pass that does not exist today. Beauty, coverage, and depth are what a frame can say about itself here.
The file is large. It is written uncompressed, one scanline per block, which every reader opens and no reader has to guess at. That is about 14 MB for a 1080 square frame with depth, so a hundred-frame sequence runs well over a gigabyte. Keep sequences short, or export the moments you need.
Two notes. Under --render-scale, the color is averaged down to canvas size as usual. Each canvas pixel's depth is then the nearest of the samples that covered it, which is the front-most surface. --made-frames cannot be combined with --exr: a made frame is built from two finished frames, and the linear frame is what comes before that.
The recipe that reproduces the frame is stored in the file's standard comments attribute, the same text the PNG carries in its own chunks. See Reproducibility metadata.
Render quality
A few features trade visual quality for frame rate through the shared RenderQuality setting. They are soft shadows, depth of field, ambient occlusion, and the render resolution of the raymarched 3D SDF (drawSDF3D). The tiers are .performance, .default, and .detail. Export and the live window pick a different default, because they work under different constraints:
- The live window defaults to
.default, the tier that protects the frame rate. For example, the raymarcher runs at half resolution there, so a busy field stays smooth. - Export and headless rendering (
--export,--export-sequence,--export-video,--export-gif,OllinApp.image(of:)) default to.detail, the best quality. There is no frame-rate pressure when writing a file, and exported art should never be downscaled. So an export is full resolution with the richest samples by default.
Override the export default with --render-quality:
swift run --package-path Examples Example-3D-Raymarching-RaymarchedSDF --export field.png # .detail (default, full quality)
swift run --package-path Examples Example-3D-Raymarching-RaymarchedSDF --export field.png --render-quality performance # fast/low: quarter-res raymarch, fewer samples
swift run --package-path Examples Example-Effects-Defocus --export-video dof.mp4 --seconds 6 --render-quality default
The flag takes performance, default, or detail (or the aliases fast, balanced, and high) and applies to every raster, video, and GIF export. It is distinct from --quality, which sets the video encoding quality (0…1) for --export-video.
The sketch's own quality setting always takes precedence. --render-quality and the automatic live and export defaults only fill in the features the sketch left alone. If a sketch sets a feature's quality explicitly (raymarchQuality(.performance), shadowQuality(.detail), .defocus(…, quality: .performance)), that choice holds on every path, live and export alike. In code the same control is the quality: argument on OllinApp.image(of:frame:fps:quality:), export, and exportSequence. On exportVideo and exportGIF it is renderQuality:. In both places it defaults to .detail.
Render scale
--render-scale N draws each exported frame at N times the canvas resolution in each direction. It then averages every block of N by N pixels back into one. The picture keeps its canvas size. What changes is how finely it was sampled:
swift run --package-path Examples Example-Text-TypeAsGeometry --export poster.png --render-scale 2
swift run --package-path Examples Example-Shapes-Polygons --export poster.png --render-scale 4
It is the quality-over-speed control for a frame that has no frame-rate deadline. The work grows with the square of the number: four times the pixels at 2, sixteen at 4. That is why the live window never uses it, and why 4 is the ceiling. If you ask for more, the export renders at the ceiling and prints a line saying so.
What it sharpens. It sharpens filled shapes and polygons, the fill of an elliptical or full-turn arc, the glyph outlines that text is made of, and fine dense detail. Those are the things that reach the screen as triangles. It leaves the analytic shapes (circles, rectangles, circular arcs under a full turn, the marker catalog), every stroked path, and atlas text (textMode(.atlas)) as they were. Each of those carries its own coverage, so it is already crisp at 1.
What it does not change. Everything that treats the finished frame as a picture runs at canvas size at every scale. That is motion blur, the lens flare, postProcess filters, and the tone map. So a .gaussianBlur(radius: 12) is twelve canvas pixels wide whatever the scale is. Two other things stay at 1 as well. A layer built with renderTarget keeps the size it was made at. A piling canvas (noClear) carries one surface across frames, so it renders at 1 and prints a line saying so.
In code the same control is OllinApp.exportRenderScale, set before the export call:
OllinApp.exportRenderScale = 2
try OllinApp.export(sketch, to: "poster.png")
Video
Encode an animated sketch straight to a .mp4 or .mov. One command takes a sketch to a file you can post, with no external tool:
swift run --package-path Examples Example-Basic-HelloCircle --export-video breathing.mp4 --seconds 6
swift run --package-path Examples Example-Motion-Orbits --export-video orbits.mov --seconds 10 --codec hevc --bitrate 8
It runs on the same deterministic fixed-timestep drive as --export-sequence, so --seconds or --frames, --fps, and --skip work the same way. A render that takes ten minutes still writes a file that plays smoothly at the requested rate. In code it is OllinApp.exportVideo(_:to:frames:fps:codec:bitsPerSecond:encodeQuality:renderQuality:skipSeconds:slowMotion:).
The flags:
| Flag | Effect |
|---|---|
--codec h264 \ | hevc \ |
--bitrate MBPS | average bitrate in Mbit/s, the file-size control |
--quality 0..1 | constant-quality rate control instead of a bitrate (Apple silicon only) |
Picking a codec. h264 plays everywhere and is the safe default for posting. hevc gives better quality per byte, and it encodes 10-bit, which keeps smooth gradients smoother. So it is a good first switch when a file needs to be smaller, at a small compatibility cost on older players. The two ProRes profiles are mastering codecs. They are visually lossless and an order of magnitude larger. They are meant for an edit timeline or a later re-encode rather than for sharing, and they need a .mov path.
Size and quality. Without --bitrate the encoder picks its own generous rate. With it, the file size is predictable, because a clip's size is roughly bitrate × seconds. As a starting point, a 1080×1080 clip at 60 fps looks clean at about 10 to 15 Mbit/s in h264. In hevc it is 6 to 9. Halve those for slow, flat-color motion, and raise them for full-frame noise or grain. On Apple silicon, --quality (0…1) targets a constant quality and lets the rate float instead, which is closer to how crf works in ffmpeg. The encoders are the hardware ones, which are fast and power-efficient. If you want a specific software encoder or two-pass tuning, --export-sequence still gives you lossless frames and prints the ffmpeg line.
Exported tracks are tagged Rec. 709, so what players show matches what the canvas rendered.
HDR. A sketch that declares colorOutput .extended (which is the setting isHighDynamicRange reads true for) is written as HDR10 instead, with no extra flag to pass. ColorOutput.referenceWhiteNits, 203, is the luminance 1.0 stands for in that file, the standard's own reference white, so an exported clip's paper white lands where every other HDR video's does, and ColorOutput.peakNits, 1000, is the brightest highlight it carries and the figure the file declares it was mastered for. That means Rec. 2020 primaries, the PQ transfer, 10-bit HEVC, and the mastering-display and content-light metadata the format expects. If the codec was left at the h264 default, it is forced to hevc, because eight bits cannot carry HDR. A .wide sketch's track is tagged P3-D65, which is the same standard range through wider primaries.
Spatial video. A 3D sketch can be exported as spatial video instead. It uses the same fixed-clock drive, but each frame is rendered from two eyes. The two views are muxed into the stereo format that Apple's platforms play with real depth.
Frame rates
Every export that takes --fps takes a number, a broadcast name, or a fraction: --fps 24, --fps ntsc, --fps 30000/1001. In code the same parameter is a FrameRate, and a plain number still stands in for one, so fps: 60 reads as it always did. The names are for the rates whose number is awkward to type and easy to get slightly wrong. .film is 24 and .pal is 25. .ntsc is 30000/1001, the 29.97 of broadcast. .ntscFilm is 24000/1001, which is 23.976, and .ntscDouble is 60000/1001, which is 59.94.
A named rate is the exact fraction, not its decimal. The video writers put every frame on it. At .ntsc the frames sit at multiples of 1001/30000 of a second rather than of 1/29.97. That is the grid a broadcast timeline expects. FrameRate(30000, per: 1001) spells any other fraction. FrameRate(29.97) is the decimal as written, 2997/100, which differs from .ntsc by one part in a million. rate.frameDuration is one frame's length in seconds, and rate.frames(in: 10) is the count a ten-second export writes.
The loupe shows NTSC frame 30 landing a millisecond past the one-second mark, because that is where the fraction puts it. A broadcast timeline has the same grid. The foot shows what goes wrong when a writer is not told the fraction. It rounds the frames onto a plain 30, and a clip meant to run at 29.97 frames a second drifts off its timeline. By the end of an hour it is 3.6 seconds, or 108 frames, off. The decimal rate, FrameRate(29.97), is much closer, a tenth of a frame an hour away from .ntsc. It is still a different rate, so name the broadcast rate when that is the one you mean.
try OllinApp.exportVideo(sketch, to: "spot.mp4", frames: FrameRate.ntsc.frames(in: 30), fps: .ntsc)
try OllinApp.exportVideo(sketch, to: "reel.mp4", frames: 240, fps: .film)
The rate a live window runs at is the display's, not one of these. frameRate on the sketch reads what it measured.
Transparent output
A canvas whose background is see-through stays see-through in the file. Call background(.clear), or any color with an alpha under 1, and every export keeps the frame's coverage as its alpha. --export writes a PNG with an alpha channel and --export-sequence a folder of them. --export-gif writes a GIF with its one transparent index, a hard edge, since GIF has no partial alpha. --export-video writes a clip with an alpha channel through the two codecs that carry one:
swift run --package-path Examples Example-Export-Cutout --export /tmp/cutout.png
swift run --package-path Examples Example-Export-Cutout --export-video /tmp/cutout.mov --codec proRes4444 --seconds 4
swift run --package-path Examples Example-Export-Cutout --export-video /tmp/cutout.mp4 --codec hevcWithAlpha --seconds 4
proRes4444 keeps the alpha losslessly and is the choice for an edit timeline. hevcWithAlpha is HEVC with an alpha channel beside the picture, a fraction of the size, and it plays wherever HEVC plays. A player that ignores the alpha shows the picture over black. The other codecs have no alpha channel. A see-through canvas through h264, hevc, or proRes422 is composited over black, which is what the window shows for it too. The export says so as it starts. In code, VideoCodec.carriesAlpha tells the two kinds apart.
A still stores each pixel's color as it was drawn, beside its coverage. This is straight alpha, the form a PNG defines, and the dither goes on the stored color. So a faint fill keeps every level it was drawn at. Sixty-four grays drawn at a tenth of full alpha are sixty-four values in the file. Scaled by its coverage first, the same fill would have room for only 26. A clip is premultiplied and says so in its metadata. The tone map sees the straight color, so a half-covered pixel keeps the color the sketch drew at half the coverage rather than turning darker. A frame filter runs before that, on the premultiplied frame, so a blur spreads the coverage along with the color. A live feed keeps the alpha as well, with the color premultiplied, the form a shared texture is read in. A Syphon client and a recording of a see-through canvas receive the frame with its coverage. A VJ program then layers it as it would any other source. The window itself is opaque and paints the frame over black. Open the PNG to see the transparency. An opaque canvas keeps its opaque tag and its bytes.
An extended (HDR) clip carries no alpha channel, and hevcWithAlpha on such a sketch falls back to hevc.
Sound
A sketch that plays sound carries that sound into the video. Nothing needs to be switched on. If the sketch holds an instrument and plays it, --export-video and --export-loop write an audio track beside the picture. A sketch that holds no instrument writes exactly the file it always wrote, with no audio track.
The sound reproduces the way the picture does. The exporters drive the sketch on a fixed clock with nothing playing aloud, and the notes are written down as the frames are drawn. Then the soundtrack is rendered through the same code that would have fed the speakers, with one difference: the mixed soundtrack bends anything above 0.7 so that several instruments at once never clip, and a single instrument that loud meets the same bend. Export twice, and the audio comes back identical sample for sample.
A sound the sketch placed in the scene is placed in the file as well. The position of each instrument, and the position it was heard from, are written down alongside the notes. So something that moves past you on screen also moves past you in the audio.
GIF has no way to hold sound. The details, and what stays out of the file, are on the Synthesis page.
Animated GIF
Write a short, infinitely looping GIF:
swift run --package-path Examples Example-Basic-HelloCircle --export-gif breathing.gif --seconds 4 --gif-width 540
In code it is OllinApp.exportGIF(_:to:frames:fps:width:skipSeconds:). GIF is limited to a palette of 256 colors a frame, and it is heavy per second next to video. So the format suits short loops at modest sizes. --gif-width downscales the output, and the height follows the canvas aspect. Downscaling is usually the difference between a few hundred kilobytes and many megabytes. For anything long or subtle, --export-video is the better tool.
Watch out for full-frame motion. A sketch where every pixel moves every frame, such as a drifting field or a full-canvas texture, defeats GIF's frame-to-frame compression. Even at a modest width, such a file can reach tens of megabytes. Lowering --fps cuts the file roughly in proportion, and sparse motion over a stable background compresses better.
A GIF is written as it is drawn. Each frame goes into the file the moment it is rendered, so what an export asks of memory does not rise with its length: a 600-frame loop at 1080 by 1080 costs the same as a 20-frame one. The system's own image writer keeps every frame until the file is laid down, at about ten bytes per pixel per frame, which is why Ollin writes the format itself.
The format holds 256 colors a frame. By default one table is chosen from the first frame and shared by every frame after it, so a color never shifts between frames. Colors that arrive later join that table as they come, so a sketch that draws itself in over time keeps every color it gains. Only when the format's 256 are spent does a frame that needs more get a table of its own, which the frames after it share. A sketch that fades in from black spends them on its first frames, so --skip past the fade. --gif-palette per-frame chooses a table for every frame from that frame alone. It is truer to a sketch where every frame's colors are its own, at a table's worth of bytes a frame. An area that changes slowly can then shimmer as the table under it moves. In code it is palette: .perFrame on exportGIF.
Pixels that did not change since the frame before are left alone and each frame is cropped to what moved, which is what keeps a still background cheap. A picture of few colors is coded in fewer bits per pixel.
The writer is yours to drive too. GIFWriter takes any CGImage, drawn into its own size on the way in, so an extension or a sketch can write a GIF of frames it made itself:
let gif = try GIFWriter(path: "out.gif", width: 540, height: 540)
for image in images {
try gif.append(image, delay: 0.04)
}
try gif.finish()
palette: takes .shared or .perFrame, and loops: says how many times the file plays, 0 forever and 1 once through.
One timing limit comes from the format itself. GIF stores each frame's delay in whole centiseconds, so the achievable rates are 50, 33.3, 25, 20, … fps. The requested --fps (default 25, which is exact) is rounded to the closest achievable rate, and the sketch's clock runs at that rate. So motion always plays back at true speed, and the clip keeps its requested duration.
Perfect loops
A sketch whose motion repeats exactly can declare its period. The export can then render exactly one period. You no longer need to match --seconds to the loop length by hand, or trim the seam by eye. Declare the period once:
override var loopDuration: Double? { 6 } // this sketch repeats every 6 seconds
and export one seamless lap:
swift run --package-path Examples Example-Motion-PerfectLoop --export-loop loop.gif
swift run --package-path Examples Example-Motion-PerfectLoop --export-loop loop.mp4 --fps 30
The frame count is loopDuration × fps. The output format follows the file extension. A .gif takes the GIF options, which means --gif-width. Any other extension encodes video with --codec, --bitrate, and --quality. --fps (default 25 for GIF, 60 for video), --skip, and --render-quality work as they do everywhere else. Under motionBlur(), skip one frame's time, 1 / fps seconds: the first frame drawn has no previous one to blur against, so a lap that starts there is the only unblurred frame of the loop. For a GIF, the lap is computed against the centisecond-rounded rate the format can actually play (see the timing note above). So the loop stays exact at that rate. If loopDuration × fps is not a whole number of frames, the export warns and rounds, so pick a rate that divides the loop.
To make a sketch loop cleanly, drive every moving part from a phase that repeats over the period. That means loopProgress(over:) or pingPong(over:), looping noise with noise(loop:) or signedNoise(loop:), or an angle built as phase * .tau. One term of plain time, or a sin(time * k) whose period does not divide the loop, breaks the seam. The Motion/PerfectLoop example is the worked reference. It declares loopDuration, and the frame one period after frame zero renders pixel-identical to it.
There is no separate entry point in code. In Swift, derive the frame count yourself and call the encoder: OllinApp.exportGIF(sketch, to: path, frames: Int(duration * fps), fps: fps).
Slow motion
An export writes the frames a sketch drew, at the rate it drew them, so a video plays at the speed you watched. --slow-motion is the one place where those two rates separate:
swift run --package-path Examples Example-Basic-HelloCircle --export-video slow.mp4 --seconds 4 --slow-motion 4
That renders four seconds of the sketch's own time and writes sixteen seconds of video. The file keeps its --fps. The run is covered by four times as many frames, so the motion takes four times as long to play. The flag works on --export-sequence, --export-video, --export-gif, and --export-loop. For a loop, one lap still closes, but it takes longer to watch.
Two numbers are easy to confuse, and the export prints both:
--secondscounts the sketch's own time, as it always has. Four seconds at factor 4 is a sixteen-second file.--framescounts the frames written to the file. Ninety-six frames at 30 fps is 3.2 seconds of video whatever the factor is.
In code the same setting is a SlowMotion, whose factor is how much longer the file runs than the sketch's own time.
By default every one of those frames is drawn. The clock steps factor times finer, deltaTime shrinks to match, and the sketch is asked to draw each moment in between. That is exact and it works for any sketch, but it costs the factor's worth of render time.
Motion measured in seconds slows down. Motion measured in frames does not. A radius built from sin(time), or a position advanced by speed * deltaTime, is a function of the clock. So a finer clock slows it down. A position advanced by a fixed step once per draw(), with no deltaTime in it, is not a function of the clock. It moves the same amount per frame however fast the clock runs. A finer clock hands that sketch more frames, so it arrives at the same place at the same time. Made frames are the answer for that sketch.
Frames the GPU makes
--made-frames draws only the frames it would have drawn anyway. It then asks the GPU to build each frame between them out of the pair on either side:
swift run --package-path Examples Example-3D-Effects-FrameInterpolation --export-video half.mp4 --seconds 4 --slow-motion 2 --made-frames
It uses the same platform interpolator that frameInterpolation() puts on the live window, pointed at a file instead. It exists for two reasons. First, drawing half the pictures costs less than half the render time. Second, it is the only form of slow motion that slows a sketch whose motion is measured in frames.
It writes pictures the sketch never drew, so you have to ask for it by name. It also records that fact twice: on the console while it runs, and in the reproduction recipe the file carries, as "madeFrames":true.
What it needs, and what it costs:
| Drawn (the default) | Made (--made-frames) | |
|---|---|---|
| Works on | any sketch | a 3D scene under a perspective camera |
| Factors | any number above 1 | 2, since the platform fills one frame per gap |
| Every frame is | drawn by the sketch | half drawn, half built from the pair around it |
| Cost | the factor's worth of render time | measured at 1.9 s where drawing every frame took 2.4 s |
The saving grows with the cost of drawing a frame. On a busy 3D scene at 1080 square, a made frame took about 11 ms against 20 ms for a drawn one. At nine times the sampling (--render-scale 3), the same clip took 3.3 seconds against 5.1.
Know three limits before you use it:
- The first gap of a clip repeats. The interpolator needs history, and one pair of frames is not enough to build a frame from. So the start of a made clip holds one picture twice. The live window does the same thing when it starts.
- Flat marks over the scene can smear. The interpolator reads the depth buffer. A caption or a HUD drawn in 2D has no depth, so it can be warped where the scene behind it moves. You notice it first on text.
- A sketch with no 3D camera is refused before anything is written, and the message says why. Drop the flag, and every frame is drawn instead, which costs more time and is never worse.
What else moves with it
The recipe each file carries records "slowMotion". Its "fps" is the rate the clock ran at, not the rate the file plays at. So a still re-renders from the recipe unchanged, and --export --frame N --fps F lands on the same moment.
The sound a sketch makes is laid on the file's own timeline. So a slow-motion clip has sound across its full length rather than falling silent early, and each note arrives with the picture it belongs to.
A take cannot be replayed in slow motion. A take carries one frame of recorded input per drawn frame, so a finer clock has nothing to read between them. --replay beside --slow-motion is therefore refused rather than played wrong.
In code it is one argument, slowMotion:, on exportSequence, exportVideo, and exportGIF:
try OllinApp.exportVideo(sketch, to: "slow.mp4", frames: 480, fps: 60, slowMotion: .drawn(4))
try OllinApp.exportSequence(sketch, to: "frames", frames: 240, fps: 30, slowMotion: .made(2))
Settled frames
Some pictures are not finished by one draw. A LineSpray, or anything drawn into an Accumulator, is a running mean. It starts grainy, settles as passes accumulate, and starts over whenever the camera moves. In a live window that is fine, because the camera rests and the picture catches up. In a video of a turning scene, every frame has a new camera, so every frame is the grainy first one. --settle N draws each written frame N times with the clock held and writes the last draw:
swift run --package-path Examples Example-Rendering-LineSpray --export-video turn.mp4 --seconds 10 --settle 40
swift run --package-path Examples Example-Rendering-LineSpray --export still.png --frame 0 --settle 120
The first draw of a frame advances the sketch's clock as always. The next N−1 draws repeat the same moment: time does not move, deltaTime is zero, and only frameCount counts on. So a camera or a motion written against the clock holds still while the layers underneath keep stepping. The mean converges, the frame is written, and the clock moves to the next frame. It costs N times a plain export. The example runs five passes a draw, so 40 draws is 200 passes of the ring sphere, which looks clean. At 120 draws it is the 600-pass still that depth of field from light opens with.
It applies to --export, --export-sequence, --export-video, --export-gif, and --export-loop, and the recipe each file carries records "settle". Three things do not work with it. A take cannot be replayed or recorded, because a take carries one frame of input per drawn frame. You cannot ask for made frames, because they are built from the motion between two drawn frames, and a held clock has no motion. A piling canvas (noClear) keeps adding through the held draws, so it brightens N times faster instead of settling. A pile has no mean to converge to.
In code the same control is OllinApp.exportSettle, set before the export call:
OllinApp.exportSettle = 40
try OllinApp.exportVideo(sketch, to: "turn.mp4", frames: 300, fps: 30)
Motion blur from subframes
A camera's shutter stays open for part of each frame, and anything that moves while it is open smears. --subframes N makes that picture the slow way. It draws each exported frame at N moments across the open shutter and averages them in linear light:
swift run --package-path Examples Example-3D-Effects-MotionBlur --export-video streak.mp4 --seconds 4 --subframes 16
swift run --package-path Examples Example-Motion-Orbits --export still.png --frame 60 --subframes 32 --shutter 1
It blurs anything a sketch draws: 2D shapes, text, layers, and shaders as well as a 3D scene. It costs N times the render time. motionBlur() is cheap enough for the live window, but it works on the 3D scene alone and builds the streak from one frame's picture. The subframe average is the streak itself, which is why motionBlur() is measured against it.
The shutter. --shutter F is the share of a frame interval the shutter stays open: 0.5 is the half-open shutter of film, and 1 is open the whole frame. Left out, it is the shutter the sketch gives motionBlur(shutter:), or 0.5 if the sketch gives none. The opening is centered on the frame's own instant, as the streak of motionBlur() is, so trading one for the other moves nothing. The moments sit evenly across the opening.
How many moments. Each moment is a separate picture. Something that moves more than about a pixel between two moments shows up as separate copies rather than a smooth streak. So take N at least as large as the distance, in pixels, that the fastest thing travels while the shutter is open. A ball crossing 20 pixels a frame under a 0.5 shutter travels 10 pixels, so it wants 10 moments or more.
Every moment is a draw. time steps through the opening, and deltaTime is the time since the draw before, so a simulation stepped by deltaTime stays exact. frameCount counts every draw, as it does under --settle and slow motion. So motion counted in frames, a fixed step once per draw(), runs N times as fast. Write it against time or deltaTime and it blurs instead. The first frame is half blurred: the run begins at time 0, so the moments that would fall before it are drawn at 0.
What stands down, and what is refused. The sketch's own motionBlur() stands down, since the moments are the blur. A picture the GPU carries from one draw to the next steps once per draw: a piling canvas (noClear), a feedback layer, an Accumulator or a LineSpray, a simulation field, and the history of screen-space reflections. N moments would run it N times as fast, so the export refuses such a sketch before it writes anything, and the message names what stopped it. A take cannot be replayed or recorded, and --settle and --made-frames do not combine with it.
It applies to --export, --export-exr, --export-sequence, --export-video, --export-gif, --export-loop, --export-grid, and --export-sweep. Slow motion drawn on a finer clock combines with it, and the shutter is then a share of the finer frame. The recipe each file carries records "subframes" and "shutter". A linear-light export (--export-exr, or --exr on a sequence) writes the averaged color beside the depth of the last moment.
In code the same controls are OllinApp.exportSubframes and OllinApp.exportShutter, set before the export call:
OllinApp.exportSubframes = 16
OllinApp.exportShutter = 0.5 // nil takes the sketch's own shutter, else 0.5
try OllinApp.exportVideo(sketch, to: "streak.mp4", frames: 240, fps: 60)
Vector: SVG
Write one frame's geometry as an SVG document instead of pixels:
swift run --package-path Examples Example-Export-VectorExport --export-svg /tmp/shapes.svg # frame 0
swift run --package-path Examples Example-Export-VectorExport --export-svg /tmp/shapes.svg --frame 30 # a later frame
Unlike raster export, SVG export records the draw calls on the CPU. So it never touches the GPU, runs anywhere, and is deterministic. The same export is available as functions:
let document = try OllinApp.svg(of: MySketch(), frame: 0) // -> String
try OllinApp.exportSVG(MySketch(), to: "/tmp/shapes.svg") // writes the file
The output is standard, general-purpose SVG. Native <circle>, <ellipse>, <rect>, <polygon>, and <path> elements keep their fills, opacity, and transforms. So the file opens cleanly in a browser, Inkscape, or Illustrator, and works as scalable vector art in its own right. A pen plotter is one common destination for the file, and an AxiDraw plots an SVG through its own tooling. Stroke-based sketches map most naturally to a plotter, because a pen has no fill. The single-line stroke fonts and stroked geometry are exactly what a plotter draws, though the export is not limited to plotting.
Vector: PDF
Write the same frame as a single-page PDF, the print-ready form of the vector output:
swift run --package-path Examples Example-Export-VectorExport --export-pdf /tmp/shapes.pdf # frame 0
swift run --package-path Examples Example-Export-VectorExport --export-pdf /tmp/shapes.pdf --frame 30 # a later frame
In code it is try OllinApp.pdf(of:frame:), which returns Data, and OllinApp.exportPDF(_:to:frame:). PDF export replays the same recorded geometry as the SVG export, so the two documents of a frame always agree. Everything under What SVG export records, including the limits, applies equally, and --hatch works the same way. Both flags can be given in one invocation (--export-svg a.svg --export-pdf b.pdf). Like the SVG path, it runs on the CPU with no GPU. Gradients, clipping, transforms, and alpha all come through as native PDF constructs.
Paper sizes. One canvas pixel maps to one PDF point, at 72 per inch. So the paper presets on CanvasSize produce true-to-size pages. A sketch on .a4 (595×842) exports as an actual A4 page, ready to print with no scaling. .usLetter, .usLegal, .a3, .a4, and .a5 are built in. Each is portrait, like the physical sheet, and .landscape turns one on its side:
override var canvasSize: CanvasSize { .usLetter } // 8.5×11 in, portrait
override var canvasSize: CanvasSize { .a4.landscape } // 297×210 mm
Any other canvas exports at its pixel size in points. Because the geometry is vector, it prints sharp at any scale either way. When the raster export should be print resolution too, add .dpi(_:) to the preset. .usLetter.dpi(300) renders and --exports at 2550×3300 pixels, and --export-pdf still writes a true 8.5×11 in page, with the geometry scaled back onto it.
What SVG export records
The exporter captures each draw call at its semantic level, before tessellation. So the output is clean vector primitives rather than a mesh of triangles. The PDF export replays the same recording, so this table and its limits describe both formats. Each SVG element maps to the equivalent PDF path:
| You draw | SVG element |
|---|---|
drawCircle / drawEllipse | <circle> / <ellipse> |
drawRect (with cornerRadius) | <rect> (with rx); a radius per corner (cornerRadii) is a <path> with an arc at each rounded corner |
drawLine, drawBezier | <line>, <path> |
drawPolyline / drawPolygon | <polyline> / <polygon> |
drawShape, drawCurve, curve-builder, outline text | <path> (with fill-rule) |
drawTriangle / drawNgon / drawStar and the polygonal shapes | <polygon> |
| the curved analytic shapes (heart, egg, moon, vesica, …) | <path> (traced to a fine outline) |
drawArc (open / chord / pie) | <polyline> / <polygon> |
| bitmap text | a grid of <rect> |
| stroke text | <polyline> per glyph |
The current transform is written as each element's transform="matrix(…)". Fills and strokes become fill and stroke, with *-opacity for alpha, and the background is a full-canvas <rect>. Coordinates use the same top-left, y-down system as the canvas, so positions match what you see on screen.
The vector format cannot express a few things exactly. This is how each one is handled:
- Images are skipped, because raster pixels have no place in a vector file. The omission is noted as a comment in the output.
strokeAlign(.inside/.outside)falls back to a centered stroke, because SVG strokes are always centered on the path.hollow(_:)band fills are approximated by a centered stroke of the band width.- Curves and the analytic SDF-only shapes are written as fine polyline or path approximations of their outline, which look identical at print scale. A sharp corner on a traced shape (the chord meeting the circle on a cut disk, a horseshoe's square arm ends) is placed where its two sides meet, not cut across. A traced boundary with more than one loop exports as a single even-odd path, with its holes intact. A moon whose cut disk sits fully inside is one such boundary. The Cool S's interior lines are exported as stroke line work.
Hatching: solid fills for a pen plotter
A pen plotter draws with a pen, so it has no fill. A solid shape would plot as a bare outline. To fix that, hatching turns each fill into line work. The lines are parallel, or cross-hatched, and they are clipped to the shape's outline and spaced by the fill's tone. The plotter then shades the shape with those lines. Add --hatch to the SVG export (the PDF export takes the same flags):
swift run --package-path Examples Example-Export-Hatching --export-svg /tmp/hatched.svg --hatch
swift run --package-path Examples Example-Export-Hatching --export-svg /tmp/hatched.svg --cross-hatch --hatch-angle 30
The flags:
| Flag | Effect |
|---|---|
--hatch | turn fills into hatch lines (defaults: 4 pt spacing, 45°) |
--cross-hatch | add a second, perpendicular set of lines |
--hatch-spacing N | line spacing in canvas points for a solid black fill |
--hatch-angle DEG | hatch direction in degrees |
Darker, more opaque fills hatch densely, lighter ones hatch sparsely, and a near-white fill gets no hatch lines at all. Each shape keeps its outline as a stroke, so the border stays clean. Stroke-only geometry (lines, curves, stroke fonts) passes through unchanged, because it is already what a plotter draws.
The same control is available in code as a value:
try OllinApp.exportSVG(MySketch(), to: "/tmp/hatched.svg",
hatching: Hatching(spacing: 6, angle: .pi / 4, crossHatches: true))
penWidth is the width the emitted hatch strokes are written at, the plotter's pen. keepsOutline strokes each hatched shape's border in its fill color as well, so the region keeps a clean edge rather than only interior lines. usesToneDensity scales the spacing by the fill's tone as the exporter hatches it, so a dark fill hatches densely, a light one sparsely, and a near-white one drops out; the export path alone reads it, since lines(filling:) always uses spacing. Hatching transforms geometry rather than the rendered picture, so a sketch can ask for the lines directly. You can draw them on the canvas or feed them anywhere else:
for line in Hatching(spacing: 8, penWidth: 0.5).lines(filling: someShape) {
drawPolyline(line)
}
lines(filling:) takes a Shape, Rectangle, or Circle. It returns the hatch lines as open polylines in that shape's coordinates. The shape's winding rule decides which regions are interior, so holes and concavities are respected. The Hatching example draws these lines live.
When an export fails
Every export call on OllinApp throws an ExportError when it cannot finish, so a call in code is written with try. The process keeps running, and nothing half-written is left at the path: a video, a GIF, or a spatial video stopped partway is removed. A sequence keeps the frames it wrote before the one that failed. The calls that hand back a picture instead of writing one, image(of:), contactSheet(of:), plates(of:), and separations(of:), throw the same error with an empty path.
do {
try OllinApp.exportVideo(sketch, to: "loop.mp4", frames: 240)
} catch let error as ExportError {
print(error) // loop.mp4: the encoder refused frame 17: …
}
The error says three things. path is the file the export was writing, or the folder for the calls that write several. frame is the written frame it had reached, counted from 0, or nil when it stopped before the first frame or after the last. problem is what went wrong, in a sentence. Printing the error prints the path and the sentence together.
Its kind sorts the failures three ways:
| Kind | What it means |
|---|---|
unsupported | The request cannot be met as asked. A file extension no writer takes, a codec the container cannot carry, zero frames, or a parameter the sketch does not declare. |
unrendered | A frame was not drawn. No Metal device, a renderer that would not start, or a frame that did not come back from the GPU. |
unwritable | The frame was drawn and the file would not take it. The writer would not start, stopped taking frames, refused one, or could not finish. |
The GPU can throw a frame away when another program's work faults it, most often a second export or render sharing the machine. A frame whose picture builds on nothing from the frame before is drawn again, up to twice, with a line on standard error saying so, and the file holds the frame it would have held. A frame that does build on the one before cannot be drawn again, since the discarded work may have left part of it behind: a noClear() pile, a persistent layer, a simulation, or a moment of a --subframes average. That export stops with an unrendered error carrying the GPU's own reason, at the frame that failed or at the next frame written after a warmup frame that failed. Running one export at a time keeps this from happening.
From the command line, an export flag that fails prints the sentence and exits with status 1, so a script running the export sees it fail. A web page that cannot carry the sketch is an unsupported failure whose sentence names the call that stopped it. An extension's own export can throw one too, through ExportError(_:path:frame:problem:).
Reproducibility metadata
Every export carries the recipe to regenerate itself, embedded as one compact JSON line:
{"tool":"Ollin","seed":42,"params":{"radius":120},"git":"8167de3","ollin":"dde11f8a","frame":0,"fps":60}
The fields are:
- the sketch's
variation, the seed both generators started from, - every
@Param's value at export time, - the short git commit of the directory the export ran in,
- and the frame and fps that produced the file.
When randomSeed and noiseSeed were set individually, they appear as separate fields instead. git is the sketch package's commit and ollin the framework's, read from the checkout the sketch depends on (a package SwiftPM fetched sits at its tag's commit); a sketch that depends on the framework by path can change its frame with no change of its own, so the pair is what reproduces a frame. A -dirty suffix marks uncommitted changes on either, and a commit field is absent where its repository cannot be read. Every sketch starts from a seed, so even an unseeded run records the number that reproduces it. Each format keeps the recipe in a different place:
| Format | Where |
|---|---|
PNG (--export, --export-sequence) | the Description text chunk (Software reads "Ollin") |
| SVG | a comment right after the opening tag |
| the document's Subject field | |
| Video | a description metadata item (ffprobe or any tag inspector shows it) |
GIF is the one format without a writable slot. For a quick look at a PNG, run exiftool frame.png or strings frame.png | grep tool. A file found months later names its own seed and parameter values, so the same sketch source plus --seed and --frame regenerates it exactly. Seeding is covered in Random.
Captures that know their source
The recipe names the commit, and marks it -dirty when the tree carried uncommitted edits. That marker is accurate, but you cannot recover the edits from it, because they are gone. --capture-source saves them instead. Add the flag beside any export flag:
swift run --package-path Examples Example-Randomness-Variations --export keeper.png --capture-source
Ollin: source captured as 93ae989, uncommitted work included. Read it with: git show 93ae989
Ollin: exported frame 0 → keeper-93ae989.png (1080×1080)
Three things happen. First, the working tree is written into the repository as a commit, tracked edits and new files alike. Then the exported file is named after that commit. Finally, the recipe gains a capture field holding the same hash, so the name and the metadata agree.
Nothing of yours moves. Your index, your working tree, HEAD, and every branch stay exactly as they were. The capture sits on no branch. It is reachable only through a ref under refs/ollin/captures/, and that ref is what keeps git gc from collecting it.
To get the code back, you need only the file name:
git show 93ae989:./Sketch.swift # read one file as it was, run from its folder
git checkout 93ae989 # stand the whole tree up, detached
git diff HEAD 93ae989 # see what was uncommitted at the time
A clean tree needs no capture, because the commit is already HEAD. In that case the flag only names the file after HEAD and says which commit that is.
Know the rest before you rely on it:
- Ignored files stay out. The capture honors
.gitignore, so build products never travel with it. Exports are not ignored by default, so write them outside the repository or add them to.gitignore. Otherwise a capture carries your last render as well. - The same tree captures once. The tree identifies the source. So a second export of unchanged files reuses the first commit instead of writing a near twin.
- The name suffix travels. A directory takes it too, so
--export-sequence frameswrites intoframes-93ae989. For print separations, the file stem takes the suffix before the ink names. - Listing and clearing.
git for-each-ref refs/ollin/captureslists the captures. To drop them all, rungit for-each-ref --format='%(refname)' refs/ollin/captures | xargs -n1 git update-ref -d. - Outside a repository the flag prints a note, and the files keep the names you gave.
Rendering a chosen variation
--seed N reseeds the sketch before its setup() on every export path above. A variation you found in the inspector or on a contact sheet comes back exactly:
swift run --package-path Examples Example-Randomness-Variations --export keeper.png --seed 10
swift run --package-path Examples Example-Randomness-Variations --export-video keeper.mp4 --seconds 6 --seed 10
The same seed always renders the same pixels. A sketch that sets its own seed in setup() ignores the flag, just as it ignores every other way of setting a seed.
A seed holds still only what comes from the sketch's own generator and clock. A Date() read, a system random call, the order a Set walks, and live input all reach past it; Why a run repeats lists them with the fix for each.
Setting a parameter for the run
--param <name>=<value> sets one declared @Param for this run, on every export path above. It works on a standalone window too, where it is a launch value, and the inspector keeps whatever you change it to afterwards. The flag can be repeated, so a run carries as many parameters as it needs:
swift run --package-path Examples Example-Live-Parameters --export keeper.png --param radius=40 --param rings=2
swift run --package-path Examples Example-Live-Parameters --export dark.png --param paper=#101018 --param style=dots
The value is on the sketch before its canvas is sized, so a canvasSize that reads a parameter exports at the run's size. A sketch that renders once per aspect for two feeds can pick its canvas that way, with frame == .photo ? .photo1080 : .vertical1080. The value is there before setup() too, and the Parameters page has the order.
The recipe and --param work as a pair. Every export writes the parameter values it rendered with into the file, and --param puts those values back. A frame re-renders from its own recipe, with the sketch untouched.
The value is read against the parameter's own kind:
| Kind | Written as |
|---|---|
Double, Int | radius=40, rings=2 |
Bool | breathe=true (yes, no, on, off, 1 and 0 read too) |
| a menu choice | style=dots, matched loosely, so easeOut, ease-out and "Ease Out" all land on the same one |
Color | paper=#101018 (#RGB, #RGBA, #RRGGBB and #RRGGBBAA), or paper=0,0.5,1 in numbers from 0 to 1 |
Vector2, Vector3 | anchor=100,900, pull=1,-2,3 |
Rectangle | plate=10,20,30,40, as x, y, width, height |
Insets | margin=12 for every edge, or margin=12,8,12,8 as top, right, bottom, left |
ClosedRange<Double> | band=0.25...0.75, or band=0.25,0.75 |
String | "caption=a longer line", quoted for the shell when it holds a space |
Palette, Ramp | inks=#000,#FFF,#F06, spread evenly, or #000@0,#F06@0.75 to place a stop |
The value is on the parameter before setup() runs, so a count built there or an extension registered behind a switch reads it. It is applied again after setup() and before the first frame, so a value the sketch set for itself in setup() does not undo it, and the recipe names what the frame was drawn with. Both passes go through the same restore path the live hosts use across a reload. So it clamps to the declared range the way dragging the row does. Beside a --replay, it overrides the take's recorded parameters, the way --seed overrides the take's seed. So the same gestures land on a different setting. A parameter named twice ends on the last value given. A name the sketch does not have, or a value its kind cannot read, stops the run. The error says what value it expected, so the run never renders something you did not ask for.
--cue <name> lands a saved cue the same way, every parameter it holds at once, before and after setup() like --param and under the --param values, so a value given by hand still wins. --cues <file> names the sheet when the sketch is not carrying one; under OllinLive the Sketch.cues.json beside the sketch is installed first, so --cue alone finds it.
Saying what a sketch declares
--list-params prints the sketch's parameters and stops. It is the other half of --param: one sets a value, the other says what there is to set.
swift run --package-path Examples Example-Live-Parameters --list-params
14 parameters, as --param takes them
Rings
radius Double 175 10...375
rings Int 5 1...12
style menu rings Rings, Dots, Beads
anchor Vector2 540,540 x 0...1080, y 0...1080
Motion
speed Double 1 0...4
breathe Bool true
spacing menu linear Linear, Ease In Sine, Ease Out Sine, Ease In Out Sine, Ease In Quad, Ease Out Quad, Ease In Out Quad, Ease In Cubic, Ease Out Cubic, Ease In Out Cubic, Ease In Quart, Ease Out Quart, Ease In Out Quart, Ease In Quint, Ease Out Quint, Ease In Out Quint, Ease In Expo, Ease Out Expo, Ease In Out Expo, Ease In Circ, Ease Out Circ, Ease In Out Circ, Ease In Back, Ease Out Back, Ease In Out Back, Ease In Elastic, Ease Out Elastic, Ease In Out Elastic, Ease In Bounce, Ease Out Bounce, Ease In Out Bounce, Smoothstep
Look
paper Color #FFFFFF
inks colors #1B1B1B,#E4572E,#2E86AB 2 to 8 colors
fade colors #E4572E,#2E86AB 2 to 6 colors
weight Double 2.5 0.5...12, by 0.5
caption String rings
Advanced
jitter Double 0 0...30
orbit Double 0 -2...2, by 0.25
The columns are the name, the kind of value it holds, the value it holds, and what it accepts. Groups are blocks under their own names, the way the inspector stacks its cards. A parameter a show-rule is currently hiding says so.
The values are the ones the first frame would be drawn with. The sketch's setup() runs first, and any --param, --cue, and --seed given beside it land too. So the listing describes that run, not the file it was written in.
Every value is printed in the spelling --param reads back, so a line can be pasted into a flag. That is exact for every kind but a color, which is said in hex and so comes back to the nearest eight-bit step.
Driving the parameters from a file
--automation <file> attaches keyframed parameters to the run, on every export path above and on a standalone window as well:
swift run --package-path Examples Example-Motion-Automation --automation slow.json --export-video out.mp4 --seconds 12
Every export drives the clock at a fixed step, so the curves land on the same frames every time. The file is applied before setup(), so a sketch that writes its own track for the same parameter takes precedence.
Contact sheets: proofing a variation space
--export-grid renders one frame at each seed in a run of seeds and tiles them into a single labeled proof sheet. A photographer contact-prints a roll the same way before choosing an enlargement:
swift run --package-path Examples Example-Randomness-Variations --export-grid sheet.png --seeds 25
swift run --package-path Examples Example-Randomness-Variations --export-grid sheet.png --seeds 12 --columns 4 --tile 400
| Flag | Meaning |
|---|---|
--export-grid <path.png> | the sheet to write |
--seeds N | how many variations (default 16) |
--seed FIRST | the first seed (default 1), with seeds running consecutively |
--columns C | grid columns (default: the squarest fit) |
--tile PX | each thumbnail's width, floored at 64 (default 320) |
--frame N | which frame of the sketch to render (default 0) |
--fps F | the clock rate that frame is timed against |
Each tile is a fresh instance of the sketch, seeded before setup() runs. So a stateful sketch, one with accumulation, feedback, or a growing simulation, cannot leak from one tile into the next. One renderer serves the whole sheet, so a 25-seed sheet costs about what 25 --export calls would, minus the startup. The sheet's PNG carries the seed list in its own recipe.
From code:
try OllinApp.exportContactSheet({ MySketch() }, to: "sheet.png", seeds: Array(1...25))
let sheet: CGImage = try OllinApp.contactSheet(of: { MySketch() }, seeds: [3, 17, 92], columns: 3)
Pick a tile you like, then render it at full size with --export … --seed N. The whole loop, including the live inspector half of it, is in Variations.
--export-sweep is the same sheet used as a tuning tool. Instead of stepping through the sketch's seeds, it steps through one of its @Param parameters. Name the parameter and a range, or explicit values. Every tile renders at the same seed with only that parameter changing, which is what makes the sheet a fair comparison. A seed is still what a sketch reproduces from. Use a sweep to choose a parameter's value before you commit to it:
swift run --package-path Examples Example-Live-Parameters --export-sweep sweep.png --sweep-param radius --from 40 --to 360 --steps 9
swift run --package-path Examples Example-Live-Parameters --export-sweep sweep.png --sweep-param rings --values "2,3,5,8" --seed 7
| Flag | Meaning |
|---|---|
--export-sweep <path.png> | the sheet to write |
--sweep-param <name> | the @Param property to sweep, by its Swift name (radius, not Radius) |
--from A --to B | the range, spread evenly over --steps (default 9) |
--values "a,b,c" | explicit values instead of a range |
--seed N | the seed every tile is pinned to (one is rolled and recorded if omitted) |
--columns, --tile, --frame, --fps | as on --export-grid |
The swept parameter is named with --sweep-param rather than --param, because --param sets a value on every tile alike. That is how the rest of the sketch is held still while one parameter moves. Naming the same parameter both ways asks for both at once, so the run stops. Values apply through the same restore path the live hosts use to carry parameters across reloads. So a tile matches what dragging the parameter to that value would show. Only numeric parameters (Double, Int) can be swept. An unknown name stops the run, and the error lists the parameters the sketch has. The sheet's PNG records the parameter, its values, and the pinned seed.
From code:
try OllinApp.exportContactSheet({ MySketch() }, to: "sweep.png",
sweeping: "radius", values: [40, 120, 360], seed: 7)
// Or name the parameter by its own handle, which the compiler checks:
try OllinApp.exportContactSheet({ MySketch() }, to: "sweep.png",
sweeping: \.$radius, values: [40, 120, 360], seed: 7)
\.$radius is the key path to the parameter itself, the projected value of a @Param. So a typo is a build error rather than an empty sheet. An Int parameter takes whole values the same way.