@ai-matrx/capture 0.5.2 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog — @ai-matrx/capture
2
2
 
3
+ ## 0.5.4 — 2026-09-01
4
+
5
+ - Persisted-media editing now has an explicit fresh-byte port (`resolveEditBlob`). The package creates and owns an editor-private object URL instead of reusing a viewer URL that the host byte cache may evict and revoke while the decoded viewer frame remains visible.
6
+ - Both camera chromes revoke the editor-private URL on every close/save/unmount path and surface a visible retry remedy when byte preparation fails. Exact fail-first coverage reproduces a stale viewer URL and proves the editor consumes fresh bytes.
7
+
8
+ ## 0.5.3 — 2026-09-01
9
+
10
+ - `ImageEditSheet` now keeps Save, crop presets, rotate, and flip unavailable while a newly baked bitmap is decoding, then enables them from the image's real `load` event. A transformed image can no longer hit the zero-dimension silent-return race and leave the editor stuck with no persistence or error.
11
+ - Rotate and flip now bake the active crop selection into their output instead of discarding it. Exact fail-first coverage proves Square crop → Rotate → newly loaded bitmap → Save emits one edited blob.
12
+
3
13
  ## 0.5.2 — 2026-09-01
4
14
 
5
15
  - `CameraCapture` no longer wraps the injected recents thumbnail in its Open-library button. Persisted-media error fallbacks may own remedy buttons (for example, Copy URL); those controls now remain valid siblings, act without also opening the library, and the non-interactive thumbnail area still opens the library.
package/dist/react.cjs CHANGED
@@ -727,6 +727,16 @@ function invalidateMedia(key) {
727
727
  if (entry?.revoke && entry.url) URL.revokeObjectURL(entry.url);
728
728
  notify(key);
729
729
  }
730
+ async function prepareMediaForEdit(item) {
731
+ if (item.resolveEditBlob) {
732
+ const blob = await item.resolveEditBlob();
733
+ if (blob.size === 0) throw new Error("media snapshot was empty");
734
+ return { url: URL.createObjectURL(blob), revoke: true };
735
+ }
736
+ const url = item.src ?? getMediaUrl(item);
737
+ if (!url) throw new Error("media item has no editable source");
738
+ return { url, revoke: false };
739
+ }
730
740
  function useMediaUrl(item) {
731
741
  const [, bump] = (0, import_react2.useState)(0);
732
742
  const key = item?.key ?? null;
@@ -1230,6 +1240,8 @@ function ImageEditSheet({
1230
1240
  const [workingSrc, setWorkingSrc] = (0, import_react4.useState)(null);
1231
1241
  const [aspect, setAspect] = (0, import_react4.useState)("free");
1232
1242
  const [crop, setCrop] = (0, import_react4.useState)(FULL_CROP);
1243
+ const [imageReady, setImageReady] = (0, import_react4.useState)(false);
1244
+ const [transforming, setTransforming] = (0, import_react4.useState)(false);
1233
1245
  const [saving, setSaving] = (0, import_react4.useState)(false);
1234
1246
  const [editError, setEditError] = (0, import_react4.useState)(null);
1235
1247
  const stageRef = (0, import_react4.useRef)(null);
@@ -1245,6 +1257,8 @@ function ImageEditSheet({
1245
1257
  setWorkingSrc(null);
1246
1258
  setAspect("free");
1247
1259
  setCrop(FULL_CROP);
1260
+ setImageReady(false);
1261
+ setTransforming(false);
1248
1262
  setSaving(false);
1249
1263
  setEditError(null);
1250
1264
  }
@@ -1253,21 +1267,53 @@ function ImageEditSheet({
1253
1267
  const bake = (0, import_react4.useCallback)(
1254
1268
  (op) => {
1255
1269
  const img = imgRef.current;
1256
- if (!img || img.naturalWidth === 0 || saving) return;
1270
+ if (!img || !imageReady || img.naturalWidth === 0 || saving || transforming) {
1271
+ setEditError("The image is still loading \u2014 wait a moment and try again.");
1272
+ return;
1273
+ }
1274
+ setTransforming(true);
1275
+ setEditError(null);
1257
1276
  try {
1277
+ const sourceX = Math.round(crop.x * img.naturalWidth);
1278
+ const sourceY = Math.round(crop.y * img.naturalHeight);
1279
+ const sourceWidth = Math.max(
1280
+ 1,
1281
+ Math.min(
1282
+ img.naturalWidth - sourceX,
1283
+ Math.round(crop.w * img.naturalWidth)
1284
+ )
1285
+ );
1286
+ const sourceHeight = Math.max(
1287
+ 1,
1288
+ Math.min(
1289
+ img.naturalHeight - sourceY,
1290
+ Math.round(crop.h * img.naturalHeight)
1291
+ )
1292
+ );
1258
1293
  const swap = op === "rotate-left";
1259
1294
  const canvas = document.createElement("canvas");
1260
- canvas.width = swap ? img.naturalHeight : img.naturalWidth;
1261
- canvas.height = swap ? img.naturalWidth : img.naturalHeight;
1295
+ canvas.width = swap ? sourceHeight : sourceWidth;
1296
+ canvas.height = swap ? sourceWidth : sourceHeight;
1262
1297
  const ctx = canvas.getContext("2d");
1263
1298
  if (!ctx) throw new Error("no 2d context");
1264
1299
  ctx.translate(canvas.width / 2, canvas.height / 2);
1265
1300
  if (swap) ctx.rotate(-Math.PI / 2);
1266
1301
  else ctx.scale(-1, 1);
1267
- ctx.drawImage(img, -img.naturalWidth / 2, -img.naturalHeight / 2);
1302
+ ctx.drawImage(
1303
+ img,
1304
+ sourceX,
1305
+ sourceY,
1306
+ sourceWidth,
1307
+ sourceHeight,
1308
+ -sourceWidth / 2,
1309
+ -sourceHeight / 2,
1310
+ sourceWidth,
1311
+ sourceHeight
1312
+ );
1268
1313
  canvas.toBlob(
1269
1314
  (blob) => {
1270
1315
  if (!blob) {
1316
+ setTransforming(false);
1271
1317
  setEditError(
1272
1318
  "This image is too large to process on this device."
1273
1319
  );
@@ -1276,26 +1322,32 @@ function ImageEditSheet({
1276
1322
  revokeOwned();
1277
1323
  const url = URL.createObjectURL(blob);
1278
1324
  ownedUrlsRef.current.push(url);
1325
+ setImageReady(false);
1279
1326
  setWorkingSrc(url);
1280
1327
  setCrop(FULL_CROP);
1281
1328
  setAspect("free");
1329
+ setTransforming(false);
1282
1330
  setEditError(null);
1283
1331
  },
1284
1332
  "image/jpeg",
1285
1333
  0.95
1286
1334
  );
1287
1335
  } catch {
1336
+ setTransforming(false);
1288
1337
  setEditError("This image could not be edited on this device.");
1289
1338
  }
1290
1339
  },
1291
- [saving, revokeOwned]
1340
+ [crop, imageReady, saving, transforming, revokeOwned]
1292
1341
  );
1293
1342
  const applyAspect = (0, import_react4.useCallback)((preset) => {
1343
+ const img = imgRef.current;
1344
+ if (!img || img.naturalWidth === 0) {
1345
+ setEditError("The image is still loading \u2014 wait a moment and try again.");
1346
+ return;
1347
+ }
1294
1348
  setAspect(preset);
1295
1349
  const ratio = ASPECTS.find((a) => a.id === preset)?.ratio ?? null;
1296
1350
  if (ratio === null) return;
1297
- const img = imgRef.current;
1298
- if (!img || img.naturalWidth === 0) return;
1299
1351
  const imageRatio = img.naturalWidth / img.naturalHeight;
1300
1352
  let w = 1;
1301
1353
  let h = 1;
@@ -1360,7 +1412,10 @@ function ImageEditSheet({
1360
1412
  }, []);
1361
1413
  const save = (0, import_react4.useCallback)(() => {
1362
1414
  const img = imgRef.current;
1363
- if (!img || img.naturalWidth === 0) return;
1415
+ if (!img || !imageReady || img.naturalWidth === 0) {
1416
+ setEditError("The image is still loading \u2014 wait a moment and try again.");
1417
+ return;
1418
+ }
1364
1419
  setSaving(true);
1365
1420
  setEditError(null);
1366
1421
  try {
@@ -1390,7 +1445,7 @@ function ImageEditSheet({
1390
1445
  setSaving(false);
1391
1446
  setEditError("Saving failed on this device \u2014 try again.");
1392
1447
  }
1393
- }, [crop, onSave, onClose]);
1448
+ }, [crop, imageReady, onSave, onClose]);
1394
1449
  if (!open || !src) return null;
1395
1450
  return /* @__PURE__ */ (0, import_jsx_runtime11.jsxs)("div", { className: "absolute inset-0 z-50 flex flex-col bg-black", children: [
1396
1451
  /* @__PURE__ */ (0, import_jsx_runtime11.jsxs)(
@@ -1418,12 +1473,13 @@ function ImageEditSheet({
1418
1473
  {
1419
1474
  type: "button",
1420
1475
  onClick: save,
1421
- disabled: saving,
1476
+ disabled: saving || transforming || !imageReady,
1422
1477
  "aria-label": "Save edited image",
1478
+ "aria-busy": saving || transforming || !imageReady,
1423
1479
  className: "flex h-11 items-center gap-1.5 rounded-full px-3 text-[15px] font-semibold text-[#FFCC00] disabled:opacity-50",
1424
1480
  children: [
1425
1481
  /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(CheckIcon, { className: "h-5 w-5" }),
1426
- "Save"
1482
+ saving ? "Saving\u2026" : transforming || !imageReady ? "Loading\u2026" : "Save"
1427
1483
  ]
1428
1484
  }
1429
1485
  )
@@ -1446,6 +1502,12 @@ function ImageEditSheet({
1446
1502
  src: workingSrc ?? src,
1447
1503
  alt: "Image being edited",
1448
1504
  draggable: false,
1505
+ onLoad: () => setImageReady(true),
1506
+ onError: () => {
1507
+ setImageReady(false);
1508
+ setTransforming(false);
1509
+ setEditError("This image could not be loaded on this device.");
1510
+ },
1449
1511
  className: "max-h-[62dvh] max-w-full select-none object-contain"
1450
1512
  }
1451
1513
  ),
@@ -1489,6 +1551,7 @@ function ImageEditSheet({
1489
1551
  {
1490
1552
  type: "button",
1491
1553
  onClick: () => applyAspect(a.id),
1554
+ disabled: saving || transforming || !imageReady,
1492
1555
  "aria-pressed": aspect === a.id,
1493
1556
  className: cn(
1494
1557
  "touch-manipulation rounded-full px-3.5 py-1.5 text-[12px] font-semibold uppercase tracking-wide transition-colors",
@@ -1504,6 +1567,7 @@ function ImageEditSheet({
1504
1567
  {
1505
1568
  type: "button",
1506
1569
  onClick: () => bake("rotate-left"),
1570
+ disabled: saving || transforming || !imageReady,
1507
1571
  "aria-label": "Rotate left",
1508
1572
  className: "flex h-12 w-12 touch-manipulation items-center justify-center rounded-full bg-white/10 text-white",
1509
1573
  children: /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(RotateCcwIcon, { className: "h-5 w-5" })
@@ -1514,6 +1578,7 @@ function ImageEditSheet({
1514
1578
  {
1515
1579
  type: "button",
1516
1580
  onClick: () => bake("flip"),
1581
+ disabled: saving || transforming || !imageReady,
1517
1582
  "aria-label": "Flip horizontally",
1518
1583
  className: "flex h-12 w-12 touch-manipulation items-center justify-center rounded-full bg-white/10 text-white",
1519
1584
  children: /* @__PURE__ */ (0, import_jsx_runtime11.jsx)(FlipHorizontal2Icon, { className: "h-5 w-5" })
@@ -1549,6 +1614,10 @@ function CameraCapture({
1549
1614
  const [optionsOpen, setOptionsOpen] = (0, import_react5.useState)(false);
1550
1615
  const [viewerKey, setViewerKey] = (0, import_react5.useState)(null);
1551
1616
  const [editTarget, setEditTarget] = (0, import_react5.useState)(null);
1617
+ const [editPreparing, setEditPreparing] = (0, import_react5.useState)(false);
1618
+ const [editPreparationError, setEditPreparationError] = (0, import_react5.useState)(
1619
+ null
1620
+ );
1552
1621
  const [gridOn, setGridOn] = (0, import_react5.useState)(false);
1553
1622
  const [timerSetting, setTimerSetting] = (0, import_react5.useState)(0);
1554
1623
  const [aspect, setAspect] = (0, import_react5.useState)("full");
@@ -1573,6 +1642,12 @@ function CameraCapture({
1573
1642
  (0, import_react5.useEffect)(() => {
1574
1643
  onReviewOpenChange?.(reviewOpen);
1575
1644
  }, [reviewOpen, onReviewOpenChange]);
1645
+ (0, import_react5.useEffect)(
1646
+ () => () => {
1647
+ if (editTarget?.revoke) URL.revokeObjectURL(editTarget.url);
1648
+ },
1649
+ [editTarget]
1650
+ );
1576
1651
  const clearCountdown = (0, import_react5.useCallback)(() => {
1577
1652
  if (countdownRef.current) clearInterval(countdownRef.current);
1578
1653
  countdownRef.current = null;
@@ -1886,8 +1961,16 @@ function CameraCapture({
1886
1961
  invalidateMedia(item.key);
1887
1962
  } : void 0,
1888
1963
  onEdit: media.onReplacePhoto ? (item) => {
1889
- const url = getMediaUrl(item);
1890
- if (url) setEditTarget({ key: item.key, url });
1964
+ if (editPreparing) return;
1965
+ setEditPreparing(true);
1966
+ setEditPreparationError(null);
1967
+ void prepareMediaForEdit(item).then(
1968
+ (prepared) => setEditTarget({ key: item.key, ...prepared })
1969
+ ).catch(
1970
+ () => setEditPreparationError(
1971
+ "This photo could not be prepared for editing. Try again."
1972
+ )
1973
+ ).finally(() => setEditPreparing(false));
1891
1974
  } : void 0
1892
1975
  }
1893
1976
  ),
@@ -1908,6 +1991,14 @@ function CameraCapture({
1908
1991
  }
1909
1992
  }
1910
1993
  ),
1994
+ editPreparationError && /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(
1995
+ "p",
1996
+ {
1997
+ role: "alert",
1998
+ className: "fixed inset-x-4 bottom-24 z-[70] rounded-xl bg-black/85 px-4 py-3 text-center text-sm text-[#FF6961]",
1999
+ children: editPreparationError
2000
+ }
2001
+ ),
1911
2002
  slots.overlays
1912
2003
  ] });
1913
2004
  }
@@ -2206,7 +2297,9 @@ function CameraCaptureV3({
2206
2297
  slots = {}
2207
2298
  }) {
2208
2299
  const [viewerKey, setViewerKey] = (0, import_react8.useState)(null);
2209
- const [editTarget, setEditTarget] = (0, import_react8.useState)(
2300
+ const [editTarget, setEditTarget] = (0, import_react8.useState)(null);
2301
+ const [editPreparing, setEditPreparing] = (0, import_react8.useState)(false);
2302
+ const [editPreparationError, setEditPreparationError] = (0, import_react8.useState)(
2210
2303
  null
2211
2304
  );
2212
2305
  const [gridOn, setGridOn] = (0, import_react8.useState)(false);
@@ -2220,6 +2313,12 @@ function CameraCaptureV3({
2220
2313
  (0, import_react8.useEffect)(() => {
2221
2314
  onReviewOpenChange?.(reviewOpen);
2222
2315
  }, [reviewOpen, onReviewOpenChange]);
2316
+ (0, import_react8.useEffect)(
2317
+ () => () => {
2318
+ if (editTarget?.revoke) URL.revokeObjectURL(editTarget.url);
2319
+ },
2320
+ [editTarget]
2321
+ );
2223
2322
  const clearCountdown = (0, import_react8.useCallback)(() => {
2224
2323
  if (countdownRef.current) clearInterval(countdownRef.current);
2225
2324
  countdownRef.current = null;
@@ -2438,8 +2537,16 @@ function CameraCaptureV3({
2438
2537
  invalidateMedia(item.key);
2439
2538
  } : void 0,
2440
2539
  onEdit: media.onReplacePhoto ? (item) => {
2441
- const url = getMediaUrl(item);
2442
- if (url) setEditTarget({ key: item.key, url });
2540
+ if (editPreparing) return;
2541
+ setEditPreparing(true);
2542
+ setEditPreparationError(null);
2543
+ void prepareMediaForEdit(item).then(
2544
+ (prepared) => setEditTarget({ key: item.key, ...prepared })
2545
+ ).catch(
2546
+ () => setEditPreparationError(
2547
+ "This photo could not be prepared for editing. Try again."
2548
+ )
2549
+ ).finally(() => setEditPreparing(false));
2443
2550
  } : void 0
2444
2551
  }
2445
2552
  ),
@@ -2460,6 +2567,14 @@ function CameraCaptureV3({
2460
2567
  }
2461
2568
  }
2462
2569
  ),
2570
+ editPreparationError && /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(
2571
+ "p",
2572
+ {
2573
+ role: "alert",
2574
+ className: "fixed inset-x-4 bottom-24 z-[70] rounded-xl bg-black/85 px-4 py-3 text-center text-sm text-[#FF6961]",
2575
+ children: editPreparationError
2576
+ }
2577
+ ),
2463
2578
  slots.overlays
2464
2579
  ] });
2465
2580
  }