Skip to content
Bartłomiej Mucha edited this page Oct 11, 2026 · 3 revisions

Foundation 3 — Rendering a project

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.

Render flow

The page domain, drawn per entry point: render · layout · decode · and the class structure.


Image Processing Pipeline Details

Render output contract (lib ↔ consumer)

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, so onSliceSaved(path) fires only once the file is complete and closed. A reader opening path after that signal sees the whole slice or nothing — never a partially-written frame.
  • G2 — No hidden threads or lingering handles. ProcessingCallbacks fire synchronously on the caller's thread; the pipeline spawns no threads and holds no output handle after onSliceSaved.
  • G3 — (opt-in) Live previews without a re-read. When RenderRequest::thumbnailCacheDir is set, the pipeline warms ThumbnailCache from the in-RAM slice — via ThumbnailCache::generateFromImage(outputPath, pixelBuffer), sharing the one shrink+write path with the file-reading generate — before onSliceSaved. A consumer's later getOrGenerate(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() throws OutputLockedError, which the pipeline reports as ProcessingErrorCode::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 read path before 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::getOrGenerate only 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 OutputLocked means to the user (warn "close the program holding the file", or retry).

Standard pipeline (no margins)

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)

Margin-aware pipeline

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)

Orientation & output metadata

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.

Band-count normalisation

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.

Template generation pipeline

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.

Incremental processing logic

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

Cancellation

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.


Performance Strategy

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

Clone this wiki locally