@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 +10 -0
- package/dist/react.cjs +131 -16
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.cts +3 -0
- package/dist/react.d.ts +3 -0
- package/dist/react.js +131 -16
- package/dist/react.js.map +1 -1
- package/package.json +1 -1
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)
|
|
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 ?
|
|
1261
|
-
canvas.height = swap ?
|
|
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(
|
|
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)
|
|
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
|
-
|
|
1890
|
-
|
|
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
|
-
|
|
2442
|
-
|
|
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
|
}
|