immich-app/immich · warning · BadRequestException

Crop action must be the first edit action

Error message

Crop action must be the first edit action

What it means

Thrown by editAsset inside the crop block: when the edits array contains an AssetEditAction.Crop but it is not the first element (edits[0].action !== Crop), the service rejects with 400 BadRequest. Crop must lead the sequence because subsequent rotate/mirror operations are applied in the cropped coordinate space.

Source

Thrown at server/src/services/asset.service.ts:566

      throw new BadRequestException('Editing GIF images is not supported');
    }

    if (asset.originalPath?.toLowerCase().endsWith('.svg')) {
      throw new BadRequestException('Editing SVG images is not supported');
    }

    // check that crop parameters will not go out of bounds
    const { width: assetWidth, height: assetHeight } = getDimensions(asset);

    if (!assetWidth || !assetHeight) {
      throw new BadRequestException('Asset dimensions are not available for editing');
    }

    const edits = dto.edits as AssetEditActionItem[];
    const crop = edits.find((e) => e.action === AssetEditAction.Crop);
    if (crop) {
      if (edits[0].action !== AssetEditAction.Crop) {
        throw new BadRequestException('Crop action must be the first edit action');
      }

      // check that crop parameters will not go out of bounds
      const { width: assetWidth, height: assetHeight } = getDimensions(asset);

      if (!assetWidth || !assetHeight) {
        throw new BadRequestException('Asset dimensions are not available for editing');
      }

      const { x, y, width, height } = crop.parameters;
      if (x + width > assetWidth || y + height > assetHeight) {
        throw new BadRequestException('Crop parameters are out of bounds');
      }
    }

    const newEdits = await this.assetEditRepository.replaceAll(id, edits);
    await this.jobRepository.queue({ name: JobName.AssetEditThumbnailGeneration, data: { id } });

View on GitHub (pinned to 199723261c)

Solutions

  1. When building the edits payload, sort so any Crop action is index 0.
  2. If the UI lets the user rotate then crop, emit [{Crop},{Rotate}] on submit.
  3. Validate the payload client-side: if any edit is Crop, assert edits[0].action === 'Crop'.

Example fix

// before
const edits = [...rotateEdits, cropEdit];

// after
const edits = [...(cropEdit ? [cropEdit] : []), ...rotateEdits, ...mirrorEdits];
Defensive patterns

Strategy: validation

Validate before calling

// Enforce crop-first ordering before submit.
const edits = [...payload.edits];
const cropIdx = edits.findIndex((e) => e.action === 'Crop');
if (cropIdx > 0) {
  const [crop] = edits.splice(cropIdx, 1);
  edits.unshift(crop);
}
if (cropIdx !== -1 && edits[0].action !== 'Crop') {
  throw new Error('Crop must be first');
}
await api.put(`/assets/${id}/edits`, { edits });

Prevention

When it happens

Trigger: PUT /assets/{id}/edits with a body like [{action:'Rotate',...},{action:'Crop',...}] — crop present but not at index 0.

Common situations: Client appends crop after rotate when building the edit stack; user applies rotate first then crops in the UI but the client emits actions in interaction order rather than required order.

Related errors


AI-assisted analysis of immich-app/immich@199723261c (2026-08-12). Data as JSON: /api/errors/88170c04acb271d6. Report an issue: GitHub.