Skip to content

Workspace

Bartłomiej Mucha edited this page Oct 11, 2026 · 8 revisions

Foundation 1 — Building a workspace

A workspace is the unit a user opens and saves: one JSON file holding the profile palettes and every project made with them. It is deliberately self-contained and path-agnostic — the library never decides where it lives, which is what lets a consumer put it on a synced drive, beside the artwork, or anywhere else.

This page covers what a workspace holds, how it is serialised and repaired on load, and the file and directory conventions around it.

Workspace and project model


WorkspaceSerializer

  • Reads and writes Workspace to/from JSON via nlohmann/json
  • File includes a "version" field (start at 1) for future migration
  • On load: if version < current, run migration chain
  • Persists processedFiles map (SHA-256 + sourceMap) for incremental processing
  • Paths relative to the workspace file. The model holds every path absolute; the relative form exists only in the file, translated by the serializer — the one piece of code that knows where the file is — so a workspace folder can be moved, renamed or zipped and still open:
    • An overlay whose asset lies inside the file's folder is written with both assetPath (absolute, as always) and assetPathRelative. On load the relative one wins when it names a file that exists; otherwise the absolute path stands. A reader from before the key ignores it and keeps using the absolute path, so the change is additive and the schema version is unchanged.
    • A path with no root at all (overlays/x.svg, pages/001.png) is read against the file's folder, in an overlay's assetPath, an input's filePath and an output's sourceMap (which records the input's filePath verbatim and is matched against it as a string, so the two are read alike). Neither this library nor its consumers write such paths — the CLI and the GUI store absolute ones — so this is for hand-written or packaged files. A root-relative path (/tmp/x) is left alone.
    • outputDirectory is taken as written: a CLI --output given relative is resolved against the working directory at render time, and reading it against the folder instead would move the output.
    • serialize() carries no relative keys — it has no location — so change detection, which compares two serialize() results, is the same wherever the workspace is saved.

WorkspacePackager

  • Plans a package: one root holding the workspace and every file it references, so it opens on another machine with no path fixing. The copy is the same workspace with its paths rewritten, not a new one: project, input and overlay uids stay (overlays anchor to input uids, so a page renamed on a clash moves no bubble), and every sha256 stays, since the bytes are copied, not re-encoded.
  • plan(workspace, workspaceFilePath) → PackagePlan: the copy's file name and JSON, the files to copy (source absolute → target in the package), and the referenced files that are not on disk.
    • Layout: the workspace folder, mirrored. The library knows no subfolder of a workspace by name, so a file in the folder keeps its path there. Only files from outside the folder are given a place — the only names the library chooses: inputs/ for pages, external/ for anything else, flat, a file name kept and suffixed (2) only on a case-insensitive clash. The folder's own files are placed first, so nothing from elsewhere displaces one; package.json and the workspace file's own name are reserved at the root. A file referenced twice is packed once. An unpacked package is the folder again, so a change of layout is migrated in one place — where the consumer opens a folder.
    • "In the folder" is decided by relativeInside() (src/infrastructure/file/folder_paths.hpp), the same function save() uses for assetPathRelative, on canonical paths.
    • Every path in the copy is rootless, which load() reads against the package root — no loader of its own.
    • Nothing a render reproduces: no output files, no output directory (workspace or project), no input directory, none of the render baselines (outputSignature, processingSignature, canvasProfileIdsAtRender, inputOrderAtRender), and each input is Pending with no sha256, contributesTo, lastProcessed or thumbnailPath. An input's sha256 is the hash its last render saw — sanitize() reads a matching one as processed — so kept, a copy with no outputs would look rendered and up to date. An overlay's sha256 names its content and stays. A package opens as a workspace not yet rendered.
    • A missing file does not stop the plan. It stays declared in the copy, pointing at the place it would have had (so dropping the file there brings it back), and is listed in missing.
  • Two phases: a consumer appends its own PackageFiles to the plan — files the model does not name — before it is archived. The library never decides which of a consumer's files belong in a package.
  • write(plan, zipPath, progress, cancel) archives it (libarchive):
    • Checked first: every target is relative and stays inside the package (no root, no .., no \), no two collide (case-insensitively), and the consumer's manifest part is a JSON object.
    • UTF-8 names, flagged as such; ZIP64 where needed. PNG, JPEG and WebP are stored, the rest deflated.
    • Written to <zip>.tmp and renamed over the target only when whole; an error or a cancel removes the temporary file and leaves an existing package untouched. Cancel returns false.
    • package.json last: format, library (name, version), application (the consumer's name, version and details — its own JSON object, recorded as is), exported (UTC), workspace (the copy's file name) and missing.
  • unpack(zipPath, folder, progress, cancel) → the workspace file and the PackageManifest read back:
    • A new folder only — refused if it exists, so nothing is overwritten and it holds one workspace.
    • Whole or not at all: into <folder>.partial, renamed at the end; a failure or a cancel removes it (cancel returns nullopt). A .partial left by a crash is refused, not reused.
    • Nothing outside the folder: an entry with a root or a .., one that is not an ordinary file, or two for one name (without case) is refused.
    • It must be a package: package.json names a workspace file at the root that was unpacked too. The workspace is not loaded — that is the caller's next step, through load() as usual.

CanvasProfile

id            : uid       // stable identifier — never changes after creation
name          : string    // e.g. "Webtoon 4-page" or "Marvel Standard" (future)
canvasSize    : Size      // full canvas including margins, e.g. 1600×10240
margins       : Margins   // top/right/bottom/left in px
safeArea      : Size      // computed: canvasSize minus margins (read-only)
dpi           : double    // resolution the artist draws at, default 300; the template records it,
                          // the render ignores it (it works in pixels)
visualColour  : RGBA      // template overlay colour, e.g. #FF69B4 at 50% alpha
templateSettings          // how the template is written:
  format      : enum      //   PSD (default) | PNG
  emptyLayer  : bool      //   PSD only: an empty "Layer" above the template, default false
templates     : [CanvasTemplateInfo]  // the generated files, at most one per format (PNG beside PSD)

CanvasTemplateInfo

path          : string    // relative to the workspace folder
format        : enum      // PNG | PSD — what the file is
fingerprint   : string    // the template's signature when it was generated (canvasSignature)
sha256        : string    // hash of the bytes written; empty = unknown (migrated from version 2)
generatedAt   : string    // ISO 8601

OutputProfile

id              : uid         // stable identifier — never changes after creation
name            : string      // e.g. "Webtoon Standard"
targetWidth     : int         // e.g. 800
sliceHeight     : int         // e.g. 1280
lastSlicePolicy : enum        // CROP | PAD_WHITE | KEEP_AS_IS
outputFormat    : enum        // PNG | JPEG | WEBP
jpegOptions     : JpegOptions // only used when outputFormat == JPEG
startIndex      : int         // output file numbering start, default 1
dpi             : double      // resolution written into every slice, default 72 (all presets: 72);
                              // PNG pHYs, EXIF for JPEG and WebP — never the inputs' resolution

JpegOptions

quality       : int   // 1–95, default 90
subsampling   : enum  // YUV_444 | YUV_422 | YUV_420
optimize      : bool  // default true
progressive   : bool  // default false

Per-format options

Format and its encoding options belong to the OutputProfile (the reusable, per-project-selected output config referenced by ProjectItem.outputProfileId) — not a separate per-project field. OutputProfile carries one option struct per format; ImageIO::encode() applies the one matching outputFormat.

PngOptions

compression  : int   // 0–9 zlib level, default 6
interlaced   : bool  // Adam7 interlacing, default false

WebpOptions

quality      : int   // 0–100, default 80 (ignored when lossless)
lossless     : bool  // default false
effort       : int   // 0–6 compression/method effort, default 4

The GUI Output tab edits these on the project's selected profile inline (groupBoxPNG/groupBoxJPG/groupBoxWebP); the same profile is also editable via Manage Output Profiles.

Output size estimation / platform limits (proposed)

Distribution platforms cap output size — e.g. Webtoon: ≤ 2 MB per slice, ≤ 25 MB per chapter. The pipeline should be able to estimate average and maximum slice size and total batch size before a run (from canvas/scaling/slice geometry and the chosen encoder settings) and/or report actual sizes after. The lib is the natural owner of the estimate (it knows geometry + encoding); the GUI surfaces it and warns when a cap would be exceeded. Tracked in both docs/TODO.md files.

Workspace

version        : int              // schema version, start at 1
canvasProfiles : CanvasProfile[]  // global palette — all defined profiles
outputProfiles : OutputProfile[]  // global palette — all defined profiles
projects       : ProjectItem[]    // each project has its own profile selection
outputDirectory: string           // workspace-level default (overridden per project)

Workspace file format: UTF-8 JSON, saved as <project-name>.platemaker.json.

Migration note (schema v1 → v2): Earlier builds stored activeCanvasProfileName and activeOutputProfileName at workspace level, plus a flat pages: PageItem[] list and a top-level stripDirty/processedFiles. These fields are replaced by ProjectItem in schema v2. The WorkspaceSerializer migration chain must convert old documents by wrapping the flat page list into a single ProjectItem and resolving the active profile names to profile ids.

ProjectItem.stripDirty is set to true whenever: page order changes, a page is added/removed, or the project's outputProfileId changes. When true, the entire strip for that project is reprocessed regardless of individual file hashes.


File & Directory Conventions

platemaker/
├── CMakeLists.txt
├── CMakePresets.json              # windows-msvc, linux-gcc, windows-mingw presets
├── vcpkg.json                     # manifest: vips, nlohmann-json, gtest
├── lib/                           # libplatemaker — Core + Infrastructure (zero Qt)
│   ├── cmake/                     # CMake package config template
│   ├── include/platemaker/
│   └── src/
├── cli/                           # platemaker binary
├── tests/                         # Unit tests (mock ImageIO)
└── docs/
    └── SPECIFICATION.md

Output files:

<outputDirectory>/
  output_001.png
  output_002.png
  ...

Workspace: <project-name>.platemaker.json (user-chosen location).
Cache: .platemaker-cache/ sibling to .platemaker.json (auto-created, safe to delete).



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

Clone this wiki locally