Skip to content

Canvas Profiles

Bartłomiej Mucha edited this page Sep 6, 2026 · 1 revision

Canvas profile matching

Which canvas profile a page renders with, how the answer is recorded, and how an edit to a profile is noticed later. This is the most intricate rule in the library, and it grew large enough inside the pipeline chapter to be lifted out of it.

The short version: a page is matched by its exact display size (EXIF-upright W×H). A page that matches nothing is still rendered — implicitly, without margins — and flagged, because determinism is preserved by visibility rather than by dropping work.

Staleness axes


Canvas Profile Matching Algorithm (§7.5)

Goal: maximum determinism. The workspace may hold many profiles, but an image must resolve to exactly one profile (or an unambiguous error) before processing begins. The algorithm is deterministic given the same project state — it never makes silent guesses.

Per-image resolution

Pre-run (once per project), inside CanvasProfileMatcher constructor:

Partition workspace.canvasProfiles() into:
  subA = profiles whose id ∈ project.canvasProfileIds   (preserves priority order)
  subB = all remaining workspace profiles               (fallback pool)

The conflict guard (§7.5.2) guarantees subA contains at most one profile per W×H pair, so the first match in subA is always final — no tie-breaking needed.

Per image (called for each PageItem in the project):

1. Read image width W from the file header (no full pixel decode).

2. Read image height H from the file header.

3. Search subA for first cp where cp.canvasSize.width == W
                                and cp.canvasSize.height == H
   → if found: Matched — use cp (margin-aware pipeline). Done.

4. Search subB for first cp where cp.canvasSize.width == W
                                and cp.canvasSize.height == H

   4a. If not found in subB either:
         → NotFoundAnywhere
         → page is **rendered implicitly**: scaled to the output profile's
           `targetWidth` with no margin crop (the same path a project with no
           canvas profiles uses). `appliedProfiles` records an empty profile id.
         → reported live via `onInput` as `AppendedWithoutProfile` and logged
           (Info): "No canvas profile matches <W>×<H> — rendering <file> without margins".

   4b. If found in subB:
         → FoundInWorkspaceOnly
         → page is **rendered implicitly** exactly as in 4a (no margins), but loudly:
         → reported live via `onInput` as `AppendedProfileNotLinked` carrying the
           matching workspace-profile ids, and logged (Warning):
           "No linked canvas profile matches <W>×<H> — rendering <file> without
            margins; profile '<name>' (<W>×<H>) exists in the workspace but is not
            linked to this project. Link it to apply its margins."
         → linking the profile (`platemaker project add-profile --project <NAME>
           --profile <ID>`) is a one-click fix that applies its margins on the next run.

Design rationale: Neither 4a nor 4b drops the page. Dropping is silent — because slice numbering is continuous, a missing page leaves no visible gap in the output, so a published chapter can lose a page unnoticed. Rendering the page implicitly and flagging the input preserves determinism by visibility rather than by omission: adding a workspace profile still cannot silently change a project's margins (4b renders without them and says so loudly until the user consciously links it), but a page is never lost. This covers quick-start (render right after install, before any profile exists) and late-added, profile-less frames. The SkippedNoProfile / SkippedProfileNotLinked InputStatus values are retained (currently unemitted) so a future opt-in "drop unmatched pages" mode can restore the strict behaviour without a breaking change.

Model effect. An implicitly-rendered page is a normal rendered input: applyProcessingResults() marks it FileStatus::Processed with an empty canvasProfileId — the empty id being the honest record that no profile was applied (and the reason a later link of a matching profile registers as a staleness change). FileStatus::Skipped now covers only a missing file or a load error — the run's skippedPages (ProcessingOutcome::skippedPages) lists exactly those, and ProjectEditor::applyProcessingResults() marks them, so a genuinely-skipped page is flagged instead of masquerading as done. The pipeline reports each input live in phase 1 through ProcessingCallbacks::onInput(InputResult) — Appended (matched a profile, or no profiles in use), AppendedWithoutProfile / AppendedProfileNotLinked (rendered implicitly, the latter carrying the unlinked candidate ids), or SkippedMissing / SkippedError — which the GUI uses to colour input tiles as the strip is built.

Input order & composition (a third staleness axis)

The virtual strip is a continuous concatenation built in InputFile::order sequence (via ProjectItem::inputsInOrder()), independent of the stored-vector order. Reordering an input — or adding or removing one — shifts pixels across every downstream slice while leaving each input and output file byte-identical, so neither the SHA-256 pass nor the canvas/output-signature axes can see it.

ProjectEditor::applyProcessingResults() therefore records inputOrderAtRender (the input uid sequence, in order, at render — the sibling of canvasProfileIdsAtRender, keyed by uid so a rename does not false-invalidate). sanitize() compares it via detectInputCompositionChange() and, on a mismatch, marks every output Desynchronized (the whole strip moved); callers fold the same signal into their "config changed" decision so the full render path runs and refreshes the baseline. A project rendered before this field existed has its baseline backfilled by WorkspaceSerializer::load() from the outputs' sourceMap provenance, so a pre-existing reorder is caught without a spurious re-render.

Reordering goes through Infrastructure::ProjectEditor (the project-content twin of WorkspaceEditor), which rewrites only the order field — a reorder never physically moves m_input_images.

Staleness detection — per-input dimensions and precise re-match

Editing a canvas profile — or adding, removing, or reordering one — changes neither the input files nor the output files, so no SHA-256 pass can notice that a page's output went stale. ProjectItem::detectCanvasConfigChange() is what notices, and it must be precise: marking a whole project stale whenever the profile list merely grows turns every tile amber and raises an alarming prompt even when the new profile matches no page in the project.

Because matching is purely by canvas W×H (§7.5.1), the question "which profile would this page match now?" is answerable offline only if the page's dimensions are known. Each InputFile therefore records the display width/height the render resolved against — the same post-autorot dimensions the pipeline fed to CanvasProfileMatcher::resolveForSize(), captured via AppliedCanvasProfile and stored by applyProcessingResults() (serialised additively; 0 = unknown).

detectCanvasConfigChange() then, for each input with known dimensions, resolves the profile it would match now — the first profile of that W×H in the project's effective list (effectiveCanvasProfileIds(), identical to the matcher's subA order) — and flags the page only if that profile's id or canvasRenderFingerprint() differs from what was recorded. This catches every case precisely: a new profile that now matches a previously-unmatched page ("" → id), a removed or reordered one, and an in-place margin edit (same id, different fingerprint). Crucially the re-match considers only the project's assigned (effective) profiles, never workspace-only ones — an unlinked same-size profile returns FoundInWorkspaceOnly at render (the page is rendered without margins), so adding it must not, and does not, desync the project. sanitize() marks exactly the affected inputs and the slices they fed (Desynchronized).

The single W×H rule is Models::canvasSizeMatches(), shared by the matcher and this re-match so the two can never drift. A page with no recorded dimensions (a legacy record rendered before dimensions were tracked) cannot be re-matched, so it falls back to the coarse listChanged comparison (effectiveCanvasProfileIds() != canvasProfileIdsAtRender) — the honest outcome, degrading to one full re-render that records the dimensions and makes the project precise thereafter.

Profile identity

CanvasProfile::id and OutputProfile::id are random and unique within a workspace. They are minted by Infrastructure::makeUniqueCanvasProfileId() / makeUniqueOutputProfileId() (platemaker/infrastructure/id_generator/id_generator.hpp), which draw 128 random bits and re-draw on collision with an existing id. An identifier is opaque: nothing parses it or derives meaning from it. Presets carry fixed, well-known ids so a project can reference one across sessions, but preset-ness is not derived from the id — it is provenance (catalogue membership), see below.

The generator lives in Infrastructure, not Models or Core. It is not part of the data model — it produces values rather than describing them — and it is not Core either, because Core is deterministic domain logic (CanvasProfileMatcher, MarginCropper, Scaler all return the same answer for the same input). Drawing on std::random_device makes it a platform service in the same sense as the clock or the filesystem; CancellationToken sets the precedent that Infrastructure here means platform-facing plumbing, not file I/O.

Two rules were dropped in 0.2.1 and must not be reintroduced:

  • Timestamps as identifiers. Ids used to be a millisecond timestamp, so profiles minted inside one loop shared an id. A shared id makes the second profile unreachable — every lookup resolves to the first — which surfaces as a profile that cannot be assigned to a project (it counts as already assigned) and silently disappears from the assign list.
  • Deriving an id from the name ("cp-" + name). A second identity scheme, and not unique either: two profiles with the same name collide.

WorkspaceSerializer::load() repairs both on the way in. Profiles with no id are given one and the legacy "cp-<name>" / "op-<name>" references are relinked; profiles sharing an id are separated, with the first keeping it so existing project references stay valid and resolve to the same profile as before. The second overload reports the collisions it fixed (WorkspaceRepairReport) so a GUI can explain the change; minting an absent id is not reported, being unambiguous.

After a separation, which of the two profiles a project actually rendered with is unknowable from the id alone. It is not guessed: ProjectEditor::sanitize() settles it exactly, by comparing the canvasRenderFingerprint() recorded per input at render time.

Output profile presets

Presets are baked into the build and never written to a workspace. The single source of truth is a compile-time table, Models::k_outputPresetDefs in platemaker/models/output_profile.hpp, from which full OutputProfile objects are materialised on demand (webtoonStandardPreset(), outputProfilePresets()). Each preset has a fixed, well-known id (e.g. op-preset-webtoon-standard) so a ProjectItem::outputProfileId can reference one across sessions and app updates; the id is resolved against the catalogue at runtime by Models::resolveOutputProfile(), which unions the workspace's own profiles with the presets.

A preset is an OutputProfile — not a distinct type, and carrying no "is a preset" field. Preset-ness is provenance: a profile is a preset exactly when it comes from the catalogue. A consumer holding a bare id asks Models::outputPresetDefById(id) (a zero-copy membership test over the compile-time table); a consumer listing profiles knows it from which source it read them (outputProfilePresets() vs Workspace::outputProfiles). That is the deliberate reason the two are kept separate rather than merged into one list, and why no flag or reserved id-prefix is needed to tell them apart.

Because presets are code, not data:

  • Immutability is the library's own guarantee, not consumer discipline. There is no shared mutable preset state to corrupt (the catalogue is rebuilt from code every run), and the write path refuses preset identity — WorkspaceSerializer never serialises an outputProfiles entry whose id is a preset id, so a preset can be neither redefined nor smuggled into a workspace regardless of how a consumer drives the library. The GUI additionally disables Edit/Delete on a preset row and offers Duplicate; the CLI refuses mod/rm on a preset id.
  • The developer may change a preset's (experimental) content in a later release without affecting a user's own profiles — those are separate persisted data. A project that referenced a preset directly renders with the new definition, and the existing outputProfileSignature() staleness check flags its on-disk output as out of date; nothing changes silently.
  • Growth is a pure code change: add a row to k_outputPresetDefs. No migration, no stored-workspace impact.

Customising a preset is a duplicate into an ordinary, user-owned profile (a fresh random id).

load() migrates workspaces written by older builds that did persist presets: a stored profile carrying a preset id (or the legacy "op-<name>" form) that still matches the preset is dropped, and any project referencing it is relinked to the canonical id (resolved from the catalogue); a profile that kept a preset id but has diverged is given a fresh id so no user profile can masquerade as a preset. A user profile whose settings merely coincide with a preset is left untouched. This migration is silent — unambiguous bookkeeping, not a collision — so it is not reported in WorkspaceRepairReport.

Implemented API — and the invariant is now enforced, not merely guarded. The "future WorkspaceEditor / ProjectEditor type" anticipated here exists as Infrastructure::WorkspaceEditor, the single authority that mutates workspace profile state. The invariant-bearing fields are private in the model, read through const accessors and written only through the editor / the guarded ProjectItem methods:

  • Workspace::canvasProfiles() / outputProfiles() — the palettes (unique ids, no persisted presets);
  • ProjectItem::canvasProfileIds() / outputProfileId() — the project links.

So a raw pi.canvasProfileIds.push_back() (bypassing the dimension guard) or pi.outputProfileId = "garbage" (bypassing id validation) no longer compiles. Linking is WorkspaceEditor::addCanvasProfileToProject(project, profileId), which delegates to ProjectItem::addCanvasProfile() and applies the dimension-collision guard; setProjectOutputProfile() validates the id resolves (user profile or preset). WorkspaceSerializer (a friend) installs both palettes and the project links at load time, running the same repair rules an in-session edit obeys. (Workspace::projectItems and ProjectItem::name/uid/inputDirectory/outputSignature stay public — they carry no cross-cutting invariant.)

Linking currently returns bool (linked / rejected); the richer result below — naming the specific conflicting profile — remains a possible enhancement rather than a shipped shape:

struct AddCanvasProfileResult {          // possible future enrichment of the bool return
    enum class Status { Ok, Conflict };
    Status status;
    // Non-null only when status == Conflict.
    const CanvasProfile* conflictingProfile = nullptr;
};

// Today: bool WorkspaceEditor::addCanvasProfileToProject(ProjectItem&, const std::string& profileId);
//   Appends profileId to project.canvasProfileIds only when there is no dimension
//   collision with any already-linked profile.

CLI response on Conflict:

Error: canvas profile '<new-name>' (WxH) conflicts with already-linked
       profile '<existing-name>' (WxH) [id: <existing-id>].
Hint:  to replace it, run:
         platemaker project rm-profile --project <NAME> --profile <existing-id>
       then re-run the add command.

GUI response on Conflict: display an error and highlight the conflicting profile entry in the project's canvas-profile list.

WorkspaceSerializer::save() may additionally assert the invariant as a safety net (debug builds), but the primary enforcement is at add-time — save must never be the first place a conflict is discovered.

Profiles at workspace level are allowed to share dimensions — the workspace is a palette, not a processing list. The invariant only applies inside ProjectItem.canvasProfileIds.

Library API (planned — Core::CanvasProfileMatcher)

CanvasProfileMatcher is an object, not a bag of static functions. The constructor performs the workspace partition once per project run; resolve() is then called once per input image in O(N_project_ids) with no further allocation.

namespace Platemaker::Core {

struct ProfileMatchResult {
    enum class Status {
        Matched,              // profile found in project list — use it
        NotFoundAnywhere,     // no matching profile in project or workspace
        FoundInWorkspaceOnly, // found in workspace but not linked to project
    };
    Status status;
    const CanvasProfile* profile = nullptr;             // non-null only when Matched
    std::vector<const CanvasProfile*> workspaceCandidates; // non-empty for FoundInWorkspaceOnly
};

class PLATEMAKER_EXPORT CanvasProfileMatcher {
public:
    /// Partitions allWorkspaceProfiles into two ordered subsets once:
    ///   m_projectProfiles      — profiles whose id ∈ projectProfileIds (priority order)
    ///   m_workspaceOnlyProfiles — the remainder
    /// Construction is O(N_workspace × N_project_ids).
    CanvasProfileMatcher(
        const std::vector<CanvasProfile>& allWorkspaceProfiles,
        const std::vector<std::string>&   projectProfileIds); // ordered profile ids

    /// Resolves the canvas profile for one input image of size \p width × \p height.
    ///
    /// Search order:
    ///   1. m_projectProfiles      — O(N_project_ids); conflict guard guarantees
    ///                               at most one match, so the first hit is final.
    ///   2. m_workspaceOnlyProfiles — O(N_workspace - N_project_ids); only reached
    ///                               when step 1 finds nothing.
    ///
    /// Returns Matched / FoundInWorkspaceOnly / NotFoundAnywhere.
    [[nodiscard]] ProfileMatchResult resolveForSize(int width, int height) const;

private:
    std::vector<const CanvasProfile*> m_projectProfiles;        // subA — priority order
    std::vector<const CanvasProfile*> m_workspaceOnlyProfiles;  // subB — fallback
};

} // namespace Platemaker::Core

Typical usage per processing run:

// Constructed once per project, before the page loop.
CanvasProfileMatcher matcher(workspace.canvasProfiles(), project.canvasProfileIds());

for (const auto& page : project.pages) {
    auto [w, h] = readImageDimensions(page.filePath);
    auto result = matcher.resolve(w, h);
    // translate result.status into error / pipeline selection
}

The CLI and GUI translate ProfileMatchResult::status into the user-facing messages and suggested commands described in §7.5.1.



See also: Home · Workspace · Project · Render · Preview · Canvas-Profiles · Processing-Steps · Components · CLI · Platform · Roadmap · Diagrams

Clone this wiki locally