diff method

Future<PatchbayInvocation> diff(
  1. PatchbayCaptureDiffRequestWire request, {
  2. required String requestId,
})

Compares two capture artifacts after decoding both to RGBA8888.

The comparison is deliberately a fact, not a verdict: callers own any pass/fail threshold. Both inputs remain bounded by the same limits as capture and the result stays in this command payload rather than adding fields to the strictly decoded blob metadata wire type.

Implementation

Future<PatchbayInvocation> diff(
  PatchbayCaptureDiffRequestWire request, {
  required String requestId,
}) async {
  final PatchbayGateRejection? gate = await _gates.evaluate(_gateIds);
  if (gate != null) {
    return _reject(
      requestId,
      gate.code,
      details: <String, Object?>{'gateId': gate.gateId},
      notice: gate.notice,
    );
  }
  final PatchbayBlobMetadataWire beforeMetadata;
  final PatchbayBlobMetadataWire afterMetadata;
  try {
    beforeMetadata = _artifacts.blobs.metadata(request.beforeBlobId);
    afterMetadata = _artifacts.blobs.metadata(request.afterBlobId);
  } on PatchbayBlobFailure catch (failure) {
    return _reject(requestId, switch (failure.code) {
      PatchbayBlobFailureCode.expired => 'captureDiffArtifactExpired',
      PatchbayBlobFailureCode.notFound => 'captureDiffArtifactNotFound',
      _ => 'captureDiffArtifactUnavailable',
    }, details: failure.details);
  }
  for (final PatchbayBlobMetadataWire metadata in <PatchbayBlobMetadataWire>[
    beforeMetadata,
    afterMetadata,
  ]) {
    if (metadata.kind != PatchbayBlobSourceWire.flutterCapture ||
        metadata.contentType != 'image/png') {
      return _reject(
        requestId,
        'captureDiffArtifactUnsupported',
        details: <String, Object?>{'blobId': metadata.blobId},
      );
    }
    if (metadata.length > maxBytes) {
      return _reject(
        requestId,
        'captureDiffByteLimitExceeded',
        details: <String, Object?>{
          'blobId': metadata.blobId,
          'length': metadata.length,
          'maxBytes': maxBytes,
        },
      );
    }
  }

  final PatchbayDecodedCapture before;
  final PatchbayDecodedCapture after;
  try {
    before = await _decoder(_readBlob(beforeMetadata));
    after = await _decoder(_readBlob(afterMetadata));
  } on PatchbayBlobFailure catch (failure) {
    return _reject(
      requestId,
      'captureDiffArtifactUnavailable',
      details: failure.details,
    );
  } on Object {
    return _reject(requestId, 'captureDiffDecodeFailed');
  }
  for (final PatchbayDecodedCapture image in <PatchbayDecodedCapture>[
    before,
    after,
  ]) {
    final int pixels = image.width * image.height;
    if (image.width <= 0 ||
        image.height <= 0 ||
        pixels > maxPixels ||
        image.bytesPerPixel <= 0 ||
        image.bytes.length != pixels * image.bytesPerPixel) {
      return _reject(
        requestId,
        'captureDiffPixelLimitExceeded',
        details: <String, Object?>{
          'width': image.width,
          'height': image.height,
          'maxPixels': maxPixels,
        },
      );
    }
  }
  if (before.width != after.width ||
      before.height != after.height ||
      before.pixelFormat != after.pixelFormat ||
      before.bytesPerPixel != after.bytesPerPixel) {
    return _reject(
      requestId,
      'captureDiffSpecMismatch',
      details: <String, Object?>{'before': before.spec, 'after': after.spec},
    );
  }

  final int totalPixels = before.width * before.height;
  var changedPixels = 0;
  for (var pixel = 0; pixel < totalPixels; pixel += 1) {
    final int offset = pixel * before.bytesPerPixel;
    var changed = false;
    for (var byte = 0; byte < before.bytesPerPixel; byte += 1) {
      if (before.bytes[offset + byte] != after.bytes[offset + byte]) {
        changed = true;
        break;
      }
    }
    if (changed) changedPixels += 1;
  }
  return PatchbayInvocation.accepted(
    requestId: requestId,
    payload: PatchbayCaptureDiffResultWire(
      outcome: 'compared',
      source: PatchbayFactSourceWire.uiObserved,
      beforeBlobId: request.beforeBlobId,
      afterBlobId: request.afterBlobId,
      width: before.width,
      height: before.height,
      pixelFormat: before.pixelFormat,
      changedPixels: changedPixels,
      totalPixels: totalPixels,
      differenceRatio: changedPixels / totalPixels,
      comparedAt: DateTime.now().toUtc().toIso8601String(),
      maxPixels: maxPixels,
      maxBytes: maxBytes,
      warnings: const <PatchbayCaptureWarningWire>[
        PatchbayCaptureWarningWire.flutterSubtreeOnly,
        PatchbayCaptureWarningWire.platformViewsMayBeMissing,
        PatchbayCaptureWarningWire.systemUiNotIncluded,
      ],
    ).toJson(),
  );
}