Repository navigation
Render
The pipeline: input pages in, output slices out. It runs in two domains — the page domain (each page, before it joins the strip) and the strip domain (the assembled chapter, being cut) — and the distinction decides where every processing step can go.
ProcessingPipeline knows nothing about ProjectItem, Workspace or any serializer: it takes a
RenderRequest and returns records the caller applies. That is what makes it safe to run on a worker
thread while a GUI holds the workspace.
The page domain, drawn per entry point: render · layout · decode · and the class structure.
The pipeline produces output slice files; a consumer (the Qt GUI, the CLI, a future web backend)
reacts to them. This is the boundary that keeps a consumer from racing the writer — e.g. reading a
slice to build a thumbnail while a re-render overwrites it (which on Windows fails the write with
unable to open for write). The split:
The library guarantees
-
G1 — Atomic publish.
ImageIO::encode()encodes to a temp sibling and renames it over the destination, soonSliceSaved(path)fires only once the file is complete and closed. A reader openingpathafter that signal sees the whole slice or nothing — never a partially-written frame. -
G2 — No hidden threads or lingering handles.
ProcessingCallbacksfire synchronously on the caller's thread; the pipeline spawns no threads and holds no output handle afteronSliceSaved. -
G3 — (opt-in) Live previews without a re-read. When
RenderRequest::thumbnailCacheDiris set, the pipeline warmsThumbnailCachefrom the in-RAM slice — viaThumbnailCache::generateFromImage(outputPath, pixelBuffer), sharing the one shrink+write path with the file-readinggenerate— beforeonSliceSaved. A consumer's latergetOrGenerate(outputPath)is then a cache hit that never opens the output. Empty dir → no thumbnails (CLI default). -
G4 — Locked destination → typed error, no poll. If another process holds the destination so the
publish cannot complete,
save()throwsOutputLockedError, which the pipeline reports asProcessingErrorCode::OutputLocked. The library does not retry — retry policy belongs to the consumer.
The consumer is responsible for
-
C1 — Treating
onSliceSaved(path)as the single "ready" signal: never readpathbefore it, or during a run that may rewrite it. - C2 — All threading/marshalling (run the pipeline on a worker thread; marshal callbacks to the UI).
-
C3 — Not building live previews by reading output files: use the warmed cache (G3) during a run, and
the file-reading
ThumbnailCache::getOrGenerateonly at rest (no active run), where reading is safe. - C4 — Serialising its own reads against re-renders, and applying per-slice UI updates in order.
-
C5 — Deciding what
OutputLockedmeans to the user (warn "close the program holding the file", or retry).
For each PageItem in order:
load(filePath) // lazy, sequential access via libvips
→ scale(targetWidth, LANCZOS3) // Scaler
→ strip.append(scaledImage) // ScaledStrip — frees memory as it goes
strip.sliceAll(sliceHeight, lastSlicePolicy)
→ saveEach(outputDir, "output_NNN.png", startIndex)
For each PageItem in order:
load(filePath)
→ cropMargins(canvasProfile.margins) // MarginCropper
→ scale(targetWidth, LANCZOS3) // Scaler
→ strip.append(scaledImage) // ScaledStrip
strip.sliceAll(sliceHeight, lastSlicePolicy)
→ saveEach(outputDir, "output_NNN.png", startIndex)
Both pipelines normalise to display orientation on load: Scaler::scale(filePath) (standard) and
ImageIO::decode() (margin‑aware) apply vips_autorot, which rotates a camera JPEG's pixels per its EXIF
Orientation tag and drops the tag. It is idempotent for the untagged / Orientation 1 case, so
Procreate‑style exports (the main workflow) are unaffected; only rotated photos change. So matching sees
the same size the render produces, headerGeometry() reports display dimensions (width/height
transposed for the 90°/270° tags 5–8). On save, ImageIO::encode() strips source EXIF/XMP/IPTC (keeping the
ICC colour profile), so a rendered slice never carries the source's orientation, camera fields, GPS or
embedded thumbnail — a viewer shows it exactly as built. A genuinely portrait page needs no special
handling: once autorotated it simply scales to targetWidth and contributes its taller height to the
vertical strip.
vips_join, which ScaledStrip::buildSlice() uses to stack the parts of a multi-source slice, requires all
inputs to share a band count. A strip mixing RGB (3-band) and RGBA (4-band) sources — e.g. photos next to a
screenshot — would otherwise abort the whole render at the first slice straddling that boundary. Before any
slice is built, ScaledStrip::sliceAll() normalises the strip by promotion, never by flattening: any
non-RGB colourspace (grayscale, CMYK) is converted to sRGB (lossless for grayscale; already-RGB/RGBA pixels
untouched), then any entry short of the strip's widest band count gains a fully-opaque alpha channel. The
user's pixels are therefore never composited onto a background during assembly, and a uniform strip is left
exactly as-is. Alpha is dropped only at save, and only for JPEG, which cannot represent it
(ImageIO::encode() flattens over white); PNG and WebP preserve the alpha end-to-end.
Resolution and metadata at save. Every slice records OutputProfile::dpi, whatever its inputs said:
PNG in pHYs, JPEG and WebP in EXIF (the only place those formats carry it). EXIF is therefore kept on
save, but the slice is first stripped of every field from its source — camera data, thumbnail,
orientation — so the EXIF written holds only the resolution and an Orientation of 1. Colour (ICC) is
kept; XMP and IPTC are not.
createBlank(canvasProfile.canvasSize, backgroundColour)
→ drawMarginOverlay(margins, visualColour)
→ drawSafeAreaBorder() // compiled out for now
→ drawSliceLines(outputProfile.sliceHeight, scaleFactor) // compiled out for now
→ save(outputPath, TemplateGenerator::optionsFor(canvasProfile))
PNG: one image at canvasProfile.dpi
PSD: layer "Template" (+ empty "Layer" if templateSettings.emptyLayer), canvasProfile.dpi,
written by minipsd; a side is capped at 30000 px
A template's signature (canvasSignature()) covers the canvas, the template colours, dpi and the
template settings (the format; for a PSD, the empty layer), so a template generated at another
resolution reads as outdated. A profile tracks one file per format (CanvasProfile::templates), each
checked as its own format by TemplateGenerator::check(): the file missing, a different profile
tracking the same file (an identical one may share it), the bytes changed since they were written
(sha256, from the bytes generate() wrote — never read back), and each signature field that changed,
with its old and new value.
On run (per project):
if project.stripDirty:
→ full reprocess, rebuild project.processedFiles map
else:
for each PageItem:
currentHash = sha256(filePath)
if currentHash != processedFiles[filePath].sha256:
mark all entries in contributesTo as dirty
if any dirty outputs:
reprocess only the affected strip segments
rebuild sourceMap for changed outputs
update processedFiles records
Processing is cancellable at any point via a std::atomic<bool> token. The pipeline checks the token between slices. On cancellation: already-written output files are kept (partial output), the workspace is not updated, and the log reports how many slices were completed before cancellation.
| Bottleneck | Solution |
|---|---|
| Loading large input directory | File paths and thumbnails only — no pixel data loaded until processing starts |
| Per-file RAM usage | libvips sequential access — source image pixels loaded on demand, not upfront |
| Strip RAM footprint |
ScaledStrip retains only the minimum scaled images needed for the current output slice; typically ≤ 2 images in memory at any time (e.g. when a slice boundary falls mid-file) |
| Scaling |
vips_thumbnail() Lanczos3 — multi-threaded, no intermediate full-size buffers |
| Batch processing |
std::async pool (CLI) — saturates all CPU cores |
| Thumbnail generation | Synchronous, blocking (ThumbnailCache); GUI wraps in background thread |
| Incremental reprocessing | SHA-256 + sourceMap — skip unchanged outputs entirely |
Expected gain vs Clip2l prototype (Python/Pillow): 10–20× on a multi-core machine (e.g. Intel i9-14900 with 24 threads).
See also: Home · Workspace · Project · Render · Preview · Canvas-Profiles · Processing-Steps · Components · CLI · Platform · Roadmap · Diagrams