DEV Community

West
West

Posted on

Keeping Photo Editor Previews and Exports in Sync with TypeScript and Sharp

A user positions a portrait inside a frame, checks the preview, and clicks Export. If the downloaded image crops their hair differently, the editor has broken a basic promise: the layout they approved should survive the export.

I build Photocard.ai, a tool for creating custom photocards. Its Maker pipeline combines uploaded photos with template artwork. That means handling photo placement, background removal, decorative layers, and multiple output sizes.

The useful architectural decision was to make those composition decisions explicit and save them in a versioned render plan. Preview and high-resolution rendering can then consume the same decisions.

This article focuses on that template renderer. The TypeScript examples are simplified illustrations of the design, with storage and application plumbing omitted.

The goal is consistent composition across resolutions. A lightweight browser preview can still differ from a final render in hair detail, filtering, and edge quality. Those are separate things to validate.

Save the decisions that produced the image

A template thumbnail shows the intended appearance. It cannot tell the renderer which source belongs in each slot, where to crop it, or which decoration belongs in front of the person.

In the Maker implementation, a template manifest describes those rules. A render plan resolves them for a particular set of uploads.

A reduced representation looks like this:

type Rect = {
  x: number;
  y: number;
  width: number;
  height: number;
};

type RenderSlot = {
  sourceIndex: number;
  sourceCrop: Rect;       // Relative to the oriented source image
  destination: Rect;      // Relative to the output canvas
  zIndex: number;
  subjectAlphaKey?: string;
  refinedForegroundKey?: string;
  effectSeed: number;
};

type RenderPlan = {
  version: 2;
  templateVersionId: string;
  assetPackageVersion: string;
  outputVariant: string;
  slots: RenderSlot[];
};
Enter fullscreen mode Exit fullscreen mode

The application has additional fields for clipping, filters, subject treatments, and edge processing. The principle stays small: save the information needed to explain the composition.

When an export starts, it should load the saved crop. Running an automatic crop detector again creates another opportunity to choose a different face position.

The same applies to decorative effects. If a paper-edge treatment uses randomness, save its seed so another render does not choose a different pattern.

Version numbers also need something concrete behind them. Keep the corresponding artwork and processing behavior available for as long as old cards must remain editable. A version string cannot recover an overwritten asset.

Let each template slot choose its source

A portrait inside a film frame may need the complete original photo. A sticker composition may need a transparent person.

The manifest makes that choice explicit:

type SourceComposition = "original_photo" | "cutout_subject";

type TemplateSlot = {
  sourceIndex: number;
  sourceComposition: SourceComposition;
  frame: Rect;
  zIndex: number;
};
Enter fullscreen mode Exit fullscreen mode

This prevents a subtle failure: applying background removal to a slot simply because a cached mask happens to exist.

For the normal full-photo path, the renderer retains the scene. For a cutout slot, a missing required mask is a validation error. Explicit editing features, such as an outline mode, can introduce their own rules, but the presence of an optional asset should never decide the visual policy by accident.

This matters for group photos, too. A single-person mask and the original group image contain different subjects. Choosing between them changes the content of the card.

Separate source coordinates from canvas coordinates

There are two rectangles in a crop operation:

  • The source crop selects a region of the uploaded image.
  • The destination frame places that region on the output canvas.

Both can use normalized coordinates. An x value of 0.1 means ten percent of the relevant image's width. The source rectangle is measured against the oriented upload; the destination is measured against the canvas.

Consider a destination frame of { x: 0.1, y: 0.1, width: 0.8, height: 0.8 }:

Canvas Left Top Frame width Frame height
600 × 900 60 90 480 720
2400 × 3600 240 360 1920 2880

These are illustrative sizes with the same aspect ratio. The placement scales without a new layout decision.

Convert to pixels at the rendering boundary. This helper covers rectangles fully inside an image:

function toPixels(rect: Rect, width: number, height: number) {
  const values = [rect.x, rect.y, rect.width, rect.height];
  if (
    !Number.isInteger(width) || width <= 0 ||
    !Number.isInteger(height) || height <= 0 ||
    !values.every(Number.isFinite) ||
    rect.x < 0 || rect.y < 0 ||
    rect.width <= 0 || rect.height <= 0 ||
    rect.x + rect.width > 1 || rect.y + rect.height > 1
  ) {
    throw new Error("Invalid image dimensions or rectangle");
  }

  const left = Math.round(rect.x * width);
  const top = Math.round(rect.y * height);
  const right = Math.round((rect.x + rect.width) * width);
  const bottom = Math.round((rect.y + rect.height) * height);

  if (right <= left || bottom <= top) {
    throw new Error("Rectangle is smaller than a renderable pixel region");
  }

  return { left, top, width: right - left, height: bottom - top };
}
Enter fullscreen mode Exit fullscreen mode

Rounding the boundaries keeps the rectangle's edges explicit. Allow for rounding differences when comparing different output resolutions.

There are three other details to settle before this helper runs:

Orientation: normalize EXIF orientation before calculating crop coordinates. Otherwise, the browser and server can interpret the same photograph with different axes.

Aspect ratio: the crop planner must resolve the fit between the selected source region and the destination. Normalized coordinates alone do not prevent stretching. Sharp exposes distinct cover, contain, and fill behaviors; choose deliberately when materializing the crop. Sharp resize documentation.

Padding: a cutout editor may let someone shrink a person enough to reveal transparent space beyond the original image. Our crop system supports bounded virtual source rectangles for this. The small helper above deliberately handles only in-bounds rectangles; virtual crops need a padding step before extraction.

Make the layer order visible in code

The composition has a readable order:

Background artwork
    ↓
Artwork behind the subjects
    ↓
Prepared photo slots, ordered by zIndex
    ↓
Foreground artwork
Enter fullscreen mode Exit fullscreen mode

A title behind someone's head and a sticker overlapping their shoulder belong at different points in that sequence.

With every slot already cropped, masked, treated, and placed onto a transparent canvas, the final compositing step can stay small:

import sharp from "sharp";

async function compositeCard(input: {
  background: Buffer;
  behindSubject: Buffer;
  subjects: Array<{ png: Buffer; zIndex: number }>;
  foreground: Buffer;
}): Promise<Buffer> {
  // All inputs are already prepared at the same canvas dimensions.
  const subjects = [...input.subjects]
    .sort((a, b) => a.zIndex - b.zIndex);

  return sharp(input.background)
    .composite([
      { input: input.behindSubject, left: 0, top: 0 },
      ...subjects.map(({ png }) => ({ input: png, left: 0, top: 0 })),
      { input: input.foreground, left: 0, top: 0 },
    ])
    .png()
    .toBuffer();
}
Enter fullscreen mode Exit fullscreen mode

The array order is part of the rendering contract. Sharp also requires overlay images to be no larger than the processed base image. Preparing each layer at the target canvas dimensions makes that constraint straightforward to check. Sharp composite documentation.

Filters deserve an equally explicit boundary. If a preset should affect the portrait, apply it to the portrait before composition. Applying it to the flattened result also changes the frame, typography, and decorations.

Keep alpha and foreground color separate

A clean-looking mask can still produce a pale fringe around someone's hair.

The mask describes coverage. The source RGB may still contain color from the old background, especially around partially transparent edges.

The preparation pipeline therefore keeps separate artifacts:

Artifact Purpose
Oriented source Stable coordinates and original image detail
Subject alpha Continuous coverage around hair and soft edges
Refined foreground Foreground color with background contamination reduced
Effect silhouette Shape used for borders, shadows, and cutout treatments

These assets need consistent coordinate alignment, even when a renderer resamples them to different resolutions.

A hard sticker border and a soft strand of hair have different requirements. Deriving an effect silhouette lets the renderer make a stronger outline without hard-thresholding the alpha used for the actual portrait.

The render plan references the prepared artifacts and mask revision. A retry can reuse that preparation instead of silently asking another segmentation pass to interpret the person again.

Define what each preview promises

The current local editor work separates a fast browser preview from higher-quality preparation during generation.

That distinction needs to remain visible in both the architecture and the interface:

Render Main purpose Expected limitation
Interactive browser preview Adjust position and scale Approximate masks and effects
Server-rendered preview Inspect the prepared composition Lower output resolution
High-resolution export Produce the final file Detail remains limited by source quality

The browser draft can use local photo URLs and a lightweight mask. The authoritative render uses prepared assets and the saved composition rules.

In the server renderer, preview and HD output share the composition function. The target canvas and resolution-specific assets change. An HD export recomposes the sources and template layers at the requested size.

That is different from enlarging a flattened preview, but it still cannot manufacture missing detail in a small upload. A large output canvas describes file dimensions; it does not prove that every input contains that much detail.

Shared geometry also does not guarantee pixel-identical results after downscaling. Resampling, edge refinement, and resolution-dependent effects still need visual checks.

Test the rendering promises

Synthetic images make useful tests because their expected pixels are easy to reason about.

A solid blue background, a red subject, and a green foreground patch can verify layer order without depending on a real portrait. A soft alpha edge can check whether filtering preserves transparency.

The repository includes tests for shared crop geometry at preview and HD sizes, original-photo behavior when a matte exists, EXIF orientation, alpha preservation, and behind-subject versus foreground ordering.

For a similar editor, I would start with these checks:

  1. Render one saved crop at two resolutions and compare normalized placement within a rounding tolerance.
  2. Give a full-photo slot an available mask and confirm that its normal rendering path still preserves the complete image.
  3. Place a subject across behind-subject and foreground artwork, then inspect known overlap pixels.
  4. Apply a portrait filter and check that the template artwork stays unchanged.
  5. Remove a required cutout artifact and confirm that rendering fails instead of producing a different composition.

Then inspect real photos: loose hair, dark clothing, busy backgrounds, unusual framing, and groups. Geometry tests can establish where the subject lands. They cannot establish whether the hair edge looks convincing.

If you are building a template editor, start by saving one source crop, one destination frame, and one template version. Render that plan at two sizes before adding more effects. It gives you a concrete contract to preserve as the editor grows.

These are the rendering ideas behind the Maker in Photocard.ai. The templates provide the artwork; the render plan records how each photo belongs inside it.

Top comments (0)