@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/dist/react.d.cts CHANGED
@@ -231,6 +231,9 @@ interface CaptureMediaItem {
231
231
  /** Async URL resolution for persisted items. Called at most once per key
232
232
  * while cached; concurrent callers share the in-flight promise. */
233
233
  resolve?: () => Promise<ResolvedMedia>;
234
+ /** Fresh bytes for pixel editing. Persisted hosts should provide this
235
+ * instead of asking the editor to borrow a cache-owned viewer URL. */
236
+ resolveEditBlob?: () => Promise<Blob>;
234
237
  /** Upload/processing state — the filmstrip renders it honestly. */
235
238
  status?: "ready" | "uploading" | "error";
236
239
  /** Accent ring on the filmstrip tile (e.g. a delineator frame). */
package/dist/react.d.ts CHANGED
@@ -231,6 +231,9 @@ interface CaptureMediaItem {
231
231
  /** Async URL resolution for persisted items. Called at most once per key
232
232
  * while cached; concurrent callers share the in-flight promise. */
233
233
  resolve?: () => Promise<ResolvedMedia>;
234
+ /** Fresh bytes for pixel editing. Persisted hosts should provide this
235
+ * instead of asking the editor to borrow a cache-owned viewer URL. */
236
+ resolveEditBlob?: () => Promise<Blob>;
234
237
  /** Upload/processing state — the filmstrip renders it honestly. */
235
238
  status?: "ready" | "uploading" | "error";
236
239
  /** Accent ring on the filmstrip tile (e.g. a delineator frame). */
package/dist/react.js CHANGED
@@ -661,6 +661,16 @@ function invalidateMedia(key) {
661
661
  if (entry?.revoke && entry.url) URL.revokeObjectURL(entry.url);
662
662
  notify(key);
663
663
  }
664
+ async function prepareMediaForEdit(item) {
665
+ if (item.resolveEditBlob) {
666
+ const blob = await item.resolveEditBlob();
667
+ if (blob.size === 0) throw new Error("media snapshot was empty");
668
+ return { url: URL.createObjectURL(blob), revoke: true };
669
+ }
670
+ const url = item.src ?? getMediaUrl(item);
671
+ if (!url) throw new Error("media item has no editable source");
672
+ return { url, revoke: false };
673
+ }
664
674
  function useMediaUrl(item) {
665
675
  const [, bump] = useState2(0);
666
676
  const key = item?.key ?? null;
@@ -1175,6 +1185,8 @@ function ImageEditSheet({
1175
1185
  const [workingSrc, setWorkingSrc] = useState4(null);
1176
1186
  const [aspect, setAspect] = useState4("free");
1177
1187
  const [crop, setCrop] = useState4(FULL_CROP);
1188
+ const [imageReady, setImageReady] = useState4(false);
1189
+ const [transforming, setTransforming] = useState4(false);
1178
1190
  const [saving, setSaving] = useState4(false);
1179
1191
  const [editError, setEditError] = useState4(null);
1180
1192
  const stageRef = useRef2(null);
@@ -1190,6 +1202,8 @@ function ImageEditSheet({
1190
1202
  setWorkingSrc(null);
1191
1203
  setAspect("free");
1192
1204
  setCrop(FULL_CROP);
1205
+ setImageReady(false);
1206
+ setTransforming(false);
1193
1207
  setSaving(false);
1194
1208
  setEditError(null);
1195
1209
  }
@@ -1198,21 +1212,53 @@ function ImageEditSheet({
1198
1212
  const bake = useCallback3(
1199
1213
  (op) => {
1200
1214
  const img = imgRef.current;
1201
- if (!img || img.naturalWidth === 0 || saving) return;
1215
+ if (!img || !imageReady || img.naturalWidth === 0 || saving || transforming) {
1216
+ setEditError("The image is still loading \u2014 wait a moment and try again.");
1217
+ return;
1218
+ }
1219
+ setTransforming(true);
1220
+ setEditError(null);
1202
1221
  try {
1222
+ const sourceX = Math.round(crop.x * img.naturalWidth);
1223
+ const sourceY = Math.round(crop.y * img.naturalHeight);
1224
+ const sourceWidth = Math.max(
1225
+ 1,
1226
+ Math.min(
1227
+ img.naturalWidth - sourceX,
1228
+ Math.round(crop.w * img.naturalWidth)
1229
+ )
1230
+ );
1231
+ const sourceHeight = Math.max(
1232
+ 1,
1233
+ Math.min(
1234
+ img.naturalHeight - sourceY,
1235
+ Math.round(crop.h * img.naturalHeight)
1236
+ )
1237
+ );
1203
1238
  const swap = op === "rotate-left";
1204
1239
  const canvas = document.createElement("canvas");
1205
- canvas.width = swap ? img.naturalHeight : img.naturalWidth;
1206
- canvas.height = swap ? img.naturalWidth : img.naturalHeight;
1240
+ canvas.width = swap ? sourceHeight : sourceWidth;
1241
+ canvas.height = swap ? sourceWidth : sourceHeight;
1207
1242
  const ctx = canvas.getContext("2d");
1208
1243
  if (!ctx) throw new Error("no 2d context");
1209
1244
  ctx.translate(canvas.width / 2, canvas.height / 2);
1210
1245
  if (swap) ctx.rotate(-Math.PI / 2);
1211
1246
  else ctx.scale(-1, 1);
1212
- ctx.drawImage(img, -img.naturalWidth / 2, -img.naturalHeight / 2);
1247
+ ctx.drawImage(
1248
+ img,
1249
+ sourceX,
1250
+ sourceY,
1251
+ sourceWidth,
1252
+ sourceHeight,
1253
+ -sourceWidth / 2,
1254
+ -sourceHeight / 2,
1255
+ sourceWidth,
1256
+ sourceHeight
1257
+ );
1213
1258
  canvas.toBlob(
1214
1259
  (blob) => {
1215
1260
  if (!blob) {
1261
+ setTransforming(false);
1216
1262
  setEditError(
1217
1263
  "This image is too large to process on this device."
1218
1264
  );
@@ -1221,26 +1267,32 @@ function ImageEditSheet({
1221
1267
  revokeOwned();
1222
1268
  const url = URL.createObjectURL(blob);
1223
1269
  ownedUrlsRef.current.push(url);
1270
+ setImageReady(false);
1224
1271
  setWorkingSrc(url);
1225
1272
  setCrop(FULL_CROP);
1226
1273
  setAspect("free");
1274
+ setTransforming(false);
1227
1275
  setEditError(null);
1228
1276
  },
1229
1277
  "image/jpeg",
1230
1278
  0.95
1231
1279
  );
1232
1280
  } catch {
1281
+ setTransforming(false);
1233
1282
  setEditError("This image could not be edited on this device.");
1234
1283
  }
1235
1284
  },
1236
- [saving, revokeOwned]
1285
+ [crop, imageReady, saving, transforming, revokeOwned]
1237
1286
  );
1238
1287
  const applyAspect = useCallback3((preset) => {
1288
+ const img = imgRef.current;
1289
+ if (!img || img.naturalWidth === 0) {
1290
+ setEditError("The image is still loading \u2014 wait a moment and try again.");
1291
+ return;
1292
+ }
1239
1293
  setAspect(preset);
1240
1294
  const ratio = ASPECTS.find((a) => a.id === preset)?.ratio ?? null;
1241
1295
  if (ratio === null) return;
1242
- const img = imgRef.current;
1243
- if (!img || img.naturalWidth === 0) return;
1244
1296
  const imageRatio = img.naturalWidth / img.naturalHeight;
1245
1297
  let w = 1;
1246
1298
  let h = 1;
@@ -1305,7 +1357,10 @@ function ImageEditSheet({
1305
1357
  }, []);
1306
1358
  const save = useCallback3(() => {
1307
1359
  const img = imgRef.current;
1308
- if (!img || img.naturalWidth === 0) return;
1360
+ if (!img || !imageReady || img.naturalWidth === 0) {
1361
+ setEditError("The image is still loading \u2014 wait a moment and try again.");
1362
+ return;
1363
+ }
1309
1364
  setSaving(true);
1310
1365
  setEditError(null);
1311
1366
  try {
@@ -1335,7 +1390,7 @@ function ImageEditSheet({
1335
1390
  setSaving(false);
1336
1391
  setEditError("Saving failed on this device \u2014 try again.");
1337
1392
  }
1338
- }, [crop, onSave, onClose]);
1393
+ }, [crop, imageReady, onSave, onClose]);
1339
1394
  if (!open || !src) return null;
1340
1395
  return /* @__PURE__ */ jsxs9("div", { className: "absolute inset-0 z-50 flex flex-col bg-black", children: [
1341
1396
  /* @__PURE__ */ jsxs9(
@@ -1363,12 +1418,13 @@ function ImageEditSheet({
1363
1418
  {
1364
1419
  type: "button",
1365
1420
  onClick: save,
1366
- disabled: saving,
1421
+ disabled: saving || transforming || !imageReady,
1367
1422
  "aria-label": "Save edited image",
1423
+ "aria-busy": saving || transforming || !imageReady,
1368
1424
  className: "flex h-11 items-center gap-1.5 rounded-full px-3 text-[15px] font-semibold text-[#FFCC00] disabled:opacity-50",
1369
1425
  children: [
1370
1426
  /* @__PURE__ */ jsx11(CheckIcon, { className: "h-5 w-5" }),
1371
- "Save"
1427
+ saving ? "Saving\u2026" : transforming || !imageReady ? "Loading\u2026" : "Save"
1372
1428
  ]
1373
1429
  }
1374
1430
  )
@@ -1391,6 +1447,12 @@ function ImageEditSheet({
1391
1447
  src: workingSrc ?? src,
1392
1448
  alt: "Image being edited",
1393
1449
  draggable: false,
1450
+ onLoad: () => setImageReady(true),
1451
+ onError: () => {
1452
+ setImageReady(false);
1453
+ setTransforming(false);
1454
+ setEditError("This image could not be loaded on this device.");
1455
+ },
1394
1456
  className: "max-h-[62dvh] max-w-full select-none object-contain"
1395
1457
  }
1396
1458
  ),
@@ -1434,6 +1496,7 @@ function ImageEditSheet({
1434
1496
  {
1435
1497
  type: "button",
1436
1498
  onClick: () => applyAspect(a.id),
1499
+ disabled: saving || transforming || !imageReady,
1437
1500
  "aria-pressed": aspect === a.id,
1438
1501
  className: cn(
1439
1502
  "touch-manipulation rounded-full px-3.5 py-1.5 text-[12px] font-semibold uppercase tracking-wide transition-colors",
@@ -1449,6 +1512,7 @@ function ImageEditSheet({
1449
1512
  {
1450
1513
  type: "button",
1451
1514
  onClick: () => bake("rotate-left"),
1515
+ disabled: saving || transforming || !imageReady,
1452
1516
  "aria-label": "Rotate left",
1453
1517
  className: "flex h-12 w-12 touch-manipulation items-center justify-center rounded-full bg-white/10 text-white",
1454
1518
  children: /* @__PURE__ */ jsx11(RotateCcwIcon, { className: "h-5 w-5" })
@@ -1459,6 +1523,7 @@ function ImageEditSheet({
1459
1523
  {
1460
1524
  type: "button",
1461
1525
  onClick: () => bake("flip"),
1526
+ disabled: saving || transforming || !imageReady,
1462
1527
  "aria-label": "Flip horizontally",
1463
1528
  className: "flex h-12 w-12 touch-manipulation items-center justify-center rounded-full bg-white/10 text-white",
1464
1529
  children: /* @__PURE__ */ jsx11(FlipHorizontal2Icon, { className: "h-5 w-5" })
@@ -1494,6 +1559,10 @@ function CameraCapture({
1494
1559
  const [optionsOpen, setOptionsOpen] = useState5(false);
1495
1560
  const [viewerKey, setViewerKey] = useState5(null);
1496
1561
  const [editTarget, setEditTarget] = useState5(null);
1562
+ const [editPreparing, setEditPreparing] = useState5(false);
1563
+ const [editPreparationError, setEditPreparationError] = useState5(
1564
+ null
1565
+ );
1497
1566
  const [gridOn, setGridOn] = useState5(false);
1498
1567
  const [timerSetting, setTimerSetting] = useState5(0);
1499
1568
  const [aspect, setAspect] = useState5("full");
@@ -1518,6 +1587,12 @@ function CameraCapture({
1518
1587
  useEffect5(() => {
1519
1588
  onReviewOpenChange?.(reviewOpen);
1520
1589
  }, [reviewOpen, onReviewOpenChange]);
1590
+ useEffect5(
1591
+ () => () => {
1592
+ if (editTarget?.revoke) URL.revokeObjectURL(editTarget.url);
1593
+ },
1594
+ [editTarget]
1595
+ );
1521
1596
  const clearCountdown = useCallback4(() => {
1522
1597
  if (countdownRef.current) clearInterval(countdownRef.current);
1523
1598
  countdownRef.current = null;
@@ -1831,8 +1906,16 @@ function CameraCapture({
1831
1906
  invalidateMedia(item.key);
1832
1907
  } : void 0,
1833
1908
  onEdit: media.onReplacePhoto ? (item) => {
1834
- const url = getMediaUrl(item);
1835
- if (url) setEditTarget({ key: item.key, url });
1909
+ if (editPreparing) return;
1910
+ setEditPreparing(true);
1911
+ setEditPreparationError(null);
1912
+ void prepareMediaForEdit(item).then(
1913
+ (prepared) => setEditTarget({ key: item.key, ...prepared })
1914
+ ).catch(
1915
+ () => setEditPreparationError(
1916
+ "This photo could not be prepared for editing. Try again."
1917
+ )
1918
+ ).finally(() => setEditPreparing(false));
1836
1919
  } : void 0
1837
1920
  }
1838
1921
  ),
@@ -1853,6 +1936,14 @@ function CameraCapture({
1853
1936
  }
1854
1937
  }
1855
1938
  ),
1939
+ editPreparationError && /* @__PURE__ */ jsx12(
1940
+ "p",
1941
+ {
1942
+ role: "alert",
1943
+ className: "fixed inset-x-4 bottom-24 z-[70] rounded-xl bg-black/85 px-4 py-3 text-center text-sm text-[#FF6961]",
1944
+ children: editPreparationError
1945
+ }
1946
+ ),
1856
1947
  slots.overlays
1857
1948
  ] });
1858
1949
  }
@@ -2151,7 +2242,9 @@ function CameraCaptureV3({
2151
2242
  slots = {}
2152
2243
  }) {
2153
2244
  const [viewerKey, setViewerKey] = useState8(null);
2154
- const [editTarget, setEditTarget] = useState8(
2245
+ const [editTarget, setEditTarget] = useState8(null);
2246
+ const [editPreparing, setEditPreparing] = useState8(false);
2247
+ const [editPreparationError, setEditPreparationError] = useState8(
2155
2248
  null
2156
2249
  );
2157
2250
  const [gridOn, setGridOn] = useState8(false);
@@ -2165,6 +2258,12 @@ function CameraCaptureV3({
2165
2258
  useEffect7(() => {
2166
2259
  onReviewOpenChange?.(reviewOpen);
2167
2260
  }, [reviewOpen, onReviewOpenChange]);
2261
+ useEffect7(
2262
+ () => () => {
2263
+ if (editTarget?.revoke) URL.revokeObjectURL(editTarget.url);
2264
+ },
2265
+ [editTarget]
2266
+ );
2168
2267
  const clearCountdown = useCallback6(() => {
2169
2268
  if (countdownRef.current) clearInterval(countdownRef.current);
2170
2269
  countdownRef.current = null;
@@ -2383,8 +2482,16 @@ function CameraCaptureV3({
2383
2482
  invalidateMedia(item.key);
2384
2483
  } : void 0,
2385
2484
  onEdit: media.onReplacePhoto ? (item) => {
2386
- const url = getMediaUrl(item);
2387
- if (url) setEditTarget({ key: item.key, url });
2485
+ if (editPreparing) return;
2486
+ setEditPreparing(true);
2487
+ setEditPreparationError(null);
2488
+ void prepareMediaForEdit(item).then(
2489
+ (prepared) => setEditTarget({ key: item.key, ...prepared })
2490
+ ).catch(
2491
+ () => setEditPreparationError(
2492
+ "This photo could not be prepared for editing. Try again."
2493
+ )
2494
+ ).finally(() => setEditPreparing(false));
2388
2495
  } : void 0
2389
2496
  }
2390
2497
  ),
@@ -2405,6 +2512,14 @@ function CameraCaptureV3({
2405
2512
  }
2406
2513
  }
2407
2514
  ),
2515
+ editPreparationError && /* @__PURE__ */ jsx15(
2516
+ "p",
2517
+ {
2518
+ role: "alert",
2519
+ className: "fixed inset-x-4 bottom-24 z-[70] rounded-xl bg-black/85 px-4 py-3 text-center text-sm text-[#FF6961]",
2520
+ children: editPreparationError
2521
+ }
2522
+ ),
2408
2523
  slots.overlays
2409
2524
  ] });
2410
2525
  }