@ai-matrx/capture 0.5.3 → 0.5.5
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/README.md +5 -1
- package/dist/react.cjs +112 -15
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.cts +33 -1
- package/dist/react.d.ts +33 -1
- package/dist/react.js +112 -15
- package/dist/react.js.map +1 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog — @ai-matrx/capture
|
|
2
2
|
|
|
3
|
+
## 0.5.5 — 2026-09-01
|
|
4
|
+
|
|
5
|
+
- Added the package-owned `finalizeCapturedVideo` terminal contract for default engines and host adapters. Emitted/final Blob MIME outranks the recorder-request MIME, filename extension is derived from that same authority, and delivered duration is a positive integer.
|
|
6
|
+
- The default engine now uses the shared finalizer, preventing a WebM payload from being mislabeled as MP4 when `MediaRecorder.mimeType` disagrees with emitted chunks.
|
|
7
|
+
|
|
8
|
+
## 0.5.4 — 2026-09-01
|
|
9
|
+
|
|
10
|
+
- 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.
|
|
11
|
+
- 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.
|
|
12
|
+
|
|
3
13
|
## 0.5.3 — 2026-09-01
|
|
4
14
|
|
|
5
15
|
- `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.
|
package/README.md
CHANGED
|
@@ -76,12 +76,16 @@ instead of the default one; persisted items provide `resolve()` instead of
|
|
|
76
76
|
|
|
77
77
|
## What's NOT inside (by design)
|
|
78
78
|
|
|
79
|
-
- **No
|
|
79
|
+
- **No forced host runtime.** The package includes `useDefaultCaptureEngine` (its sole `getUserMedia` implementation under `src/engine/`) for runtime-less hosts. Apps with an existing cross-feature camera runtime inject a `CaptureCameraEngine`; AI Matrx does this for lease management, recorder journaling, and diagnostics.
|
|
80
80
|
- **No network, no storage** — enforced by `src/laws.test.ts`.
|
|
81
81
|
- Persisted-media rendering (thumbnails, lightboxes, durable refs) — that's `@ai-matrx/media`.
|
|
82
82
|
- Audio-only recording UX — `@ai-matrx/browser-audio`.
|
|
83
83
|
- QR decoding — `@ai-matrx/kit/qr`.
|
|
84
84
|
|
|
85
|
+
Both the default engine and injected host adapters finalize recordings through
|
|
86
|
+
`finalizeCapturedVideo`: emitted/final Blob MIME is authoritative, the filename
|
|
87
|
+
extension matches it, and delivered duration is a positive integer.
|
|
88
|
+
|
|
85
89
|
## Usage sketch
|
|
86
90
|
|
|
87
91
|
```tsx
|
package/dist/react.cjs
CHANGED
|
@@ -44,6 +44,7 @@ __export(react_exports, {
|
|
|
44
44
|
buildWarmMicConstraints: () => buildWarmMicConstraints,
|
|
45
45
|
classifyCameraBlockReason: () => classifyCameraBlockReason,
|
|
46
46
|
cropBlobToAspect: () => cropBlobToAspect,
|
|
47
|
+
finalizeCapturedVideo: () => finalizeCapturedVideo,
|
|
47
48
|
getMediaUrl: () => getMediaUrl,
|
|
48
49
|
getWarmMicState: () => getWarmMicState,
|
|
49
50
|
hardStopWarmMic: () => hardStopWarmMic,
|
|
@@ -727,6 +728,16 @@ function invalidateMedia(key) {
|
|
|
727
728
|
if (entry?.revoke && entry.url) URL.revokeObjectURL(entry.url);
|
|
728
729
|
notify(key);
|
|
729
730
|
}
|
|
731
|
+
async function prepareMediaForEdit(item) {
|
|
732
|
+
if (item.resolveEditBlob) {
|
|
733
|
+
const blob = await item.resolveEditBlob();
|
|
734
|
+
if (blob.size === 0) throw new Error("media snapshot was empty");
|
|
735
|
+
return { url: URL.createObjectURL(blob), revoke: true };
|
|
736
|
+
}
|
|
737
|
+
const url = item.src ?? getMediaUrl(item);
|
|
738
|
+
if (!url) throw new Error("media item has no editable source");
|
|
739
|
+
return { url, revoke: false };
|
|
740
|
+
}
|
|
730
741
|
function useMediaUrl(item) {
|
|
731
742
|
const [, bump] = (0, import_react2.useState)(0);
|
|
732
743
|
const key = item?.key ?? null;
|
|
@@ -1604,6 +1615,10 @@ function CameraCapture({
|
|
|
1604
1615
|
const [optionsOpen, setOptionsOpen] = (0, import_react5.useState)(false);
|
|
1605
1616
|
const [viewerKey, setViewerKey] = (0, import_react5.useState)(null);
|
|
1606
1617
|
const [editTarget, setEditTarget] = (0, import_react5.useState)(null);
|
|
1618
|
+
const [editPreparing, setEditPreparing] = (0, import_react5.useState)(false);
|
|
1619
|
+
const [editPreparationError, setEditPreparationError] = (0, import_react5.useState)(
|
|
1620
|
+
null
|
|
1621
|
+
);
|
|
1607
1622
|
const [gridOn, setGridOn] = (0, import_react5.useState)(false);
|
|
1608
1623
|
const [timerSetting, setTimerSetting] = (0, import_react5.useState)(0);
|
|
1609
1624
|
const [aspect, setAspect] = (0, import_react5.useState)("full");
|
|
@@ -1628,6 +1643,12 @@ function CameraCapture({
|
|
|
1628
1643
|
(0, import_react5.useEffect)(() => {
|
|
1629
1644
|
onReviewOpenChange?.(reviewOpen);
|
|
1630
1645
|
}, [reviewOpen, onReviewOpenChange]);
|
|
1646
|
+
(0, import_react5.useEffect)(
|
|
1647
|
+
() => () => {
|
|
1648
|
+
if (editTarget?.revoke) URL.revokeObjectURL(editTarget.url);
|
|
1649
|
+
},
|
|
1650
|
+
[editTarget]
|
|
1651
|
+
);
|
|
1631
1652
|
const clearCountdown = (0, import_react5.useCallback)(() => {
|
|
1632
1653
|
if (countdownRef.current) clearInterval(countdownRef.current);
|
|
1633
1654
|
countdownRef.current = null;
|
|
@@ -1941,8 +1962,16 @@ function CameraCapture({
|
|
|
1941
1962
|
invalidateMedia(item.key);
|
|
1942
1963
|
} : void 0,
|
|
1943
1964
|
onEdit: media.onReplacePhoto ? (item) => {
|
|
1944
|
-
|
|
1945
|
-
|
|
1965
|
+
if (editPreparing) return;
|
|
1966
|
+
setEditPreparing(true);
|
|
1967
|
+
setEditPreparationError(null);
|
|
1968
|
+
void prepareMediaForEdit(item).then(
|
|
1969
|
+
(prepared) => setEditTarget({ key: item.key, ...prepared })
|
|
1970
|
+
).catch(
|
|
1971
|
+
() => setEditPreparationError(
|
|
1972
|
+
"This photo could not be prepared for editing. Try again."
|
|
1973
|
+
)
|
|
1974
|
+
).finally(() => setEditPreparing(false));
|
|
1946
1975
|
} : void 0
|
|
1947
1976
|
}
|
|
1948
1977
|
),
|
|
@@ -1963,6 +1992,14 @@ function CameraCapture({
|
|
|
1963
1992
|
}
|
|
1964
1993
|
}
|
|
1965
1994
|
),
|
|
1995
|
+
editPreparationError && /* @__PURE__ */ (0, import_jsx_runtime12.jsx)(
|
|
1996
|
+
"p",
|
|
1997
|
+
{
|
|
1998
|
+
role: "alert",
|
|
1999
|
+
className: "fixed inset-x-4 bottom-24 z-[70] rounded-xl bg-black/85 px-4 py-3 text-center text-sm text-[#FF6961]",
|
|
2000
|
+
children: editPreparationError
|
|
2001
|
+
}
|
|
2002
|
+
),
|
|
1966
2003
|
slots.overlays
|
|
1967
2004
|
] });
|
|
1968
2005
|
}
|
|
@@ -2261,7 +2298,9 @@ function CameraCaptureV3({
|
|
|
2261
2298
|
slots = {}
|
|
2262
2299
|
}) {
|
|
2263
2300
|
const [viewerKey, setViewerKey] = (0, import_react8.useState)(null);
|
|
2264
|
-
const [editTarget, setEditTarget] = (0, import_react8.useState)(
|
|
2301
|
+
const [editTarget, setEditTarget] = (0, import_react8.useState)(null);
|
|
2302
|
+
const [editPreparing, setEditPreparing] = (0, import_react8.useState)(false);
|
|
2303
|
+
const [editPreparationError, setEditPreparationError] = (0, import_react8.useState)(
|
|
2265
2304
|
null
|
|
2266
2305
|
);
|
|
2267
2306
|
const [gridOn, setGridOn] = (0, import_react8.useState)(false);
|
|
@@ -2275,6 +2314,12 @@ function CameraCaptureV3({
|
|
|
2275
2314
|
(0, import_react8.useEffect)(() => {
|
|
2276
2315
|
onReviewOpenChange?.(reviewOpen);
|
|
2277
2316
|
}, [reviewOpen, onReviewOpenChange]);
|
|
2317
|
+
(0, import_react8.useEffect)(
|
|
2318
|
+
() => () => {
|
|
2319
|
+
if (editTarget?.revoke) URL.revokeObjectURL(editTarget.url);
|
|
2320
|
+
},
|
|
2321
|
+
[editTarget]
|
|
2322
|
+
);
|
|
2278
2323
|
const clearCountdown = (0, import_react8.useCallback)(() => {
|
|
2279
2324
|
if (countdownRef.current) clearInterval(countdownRef.current);
|
|
2280
2325
|
countdownRef.current = null;
|
|
@@ -2493,8 +2538,16 @@ function CameraCaptureV3({
|
|
|
2493
2538
|
invalidateMedia(item.key);
|
|
2494
2539
|
} : void 0,
|
|
2495
2540
|
onEdit: media.onReplacePhoto ? (item) => {
|
|
2496
|
-
|
|
2497
|
-
|
|
2541
|
+
if (editPreparing) return;
|
|
2542
|
+
setEditPreparing(true);
|
|
2543
|
+
setEditPreparationError(null);
|
|
2544
|
+
void prepareMediaForEdit(item).then(
|
|
2545
|
+
(prepared) => setEditTarget({ key: item.key, ...prepared })
|
|
2546
|
+
).catch(
|
|
2547
|
+
() => setEditPreparationError(
|
|
2548
|
+
"This photo could not be prepared for editing. Try again."
|
|
2549
|
+
)
|
|
2550
|
+
).finally(() => setEditPreparing(false));
|
|
2498
2551
|
} : void 0
|
|
2499
2552
|
}
|
|
2500
2553
|
),
|
|
@@ -2515,6 +2568,14 @@ function CameraCaptureV3({
|
|
|
2515
2568
|
}
|
|
2516
2569
|
}
|
|
2517
2570
|
),
|
|
2571
|
+
editPreparationError && /* @__PURE__ */ (0, import_jsx_runtime15.jsx)(
|
|
2572
|
+
"p",
|
|
2573
|
+
{
|
|
2574
|
+
role: "alert",
|
|
2575
|
+
className: "fixed inset-x-4 bottom-24 z-[70] rounded-xl bg-black/85 px-4 py-3 text-center text-sm text-[#FF6961]",
|
|
2576
|
+
children: editPreparationError
|
|
2577
|
+
}
|
|
2578
|
+
),
|
|
2518
2579
|
slots.overlays
|
|
2519
2580
|
] });
|
|
2520
2581
|
}
|
|
@@ -3028,6 +3089,43 @@ function warmMicDebug() {
|
|
|
3028
3089
|
};
|
|
3029
3090
|
}
|
|
3030
3091
|
|
|
3092
|
+
// src/engine/video-result.ts
|
|
3093
|
+
var VIDEO_EXTENSIONS = {
|
|
3094
|
+
"video/mp4": "mp4",
|
|
3095
|
+
"video/webm": "webm",
|
|
3096
|
+
"video/ogg": "ogv",
|
|
3097
|
+
"video/quicktime": "mov"
|
|
3098
|
+
};
|
|
3099
|
+
function firstNonEmpty(...values) {
|
|
3100
|
+
for (const value of values) {
|
|
3101
|
+
const normalized = value?.trim();
|
|
3102
|
+
if (normalized) return normalized;
|
|
3103
|
+
}
|
|
3104
|
+
return "video/webm";
|
|
3105
|
+
}
|
|
3106
|
+
function finalizeCapturedVideo(options) {
|
|
3107
|
+
if (!Number.isFinite(options.durationMs)) {
|
|
3108
|
+
throw new Error("finalizeCapturedVideo requires a finite duration.");
|
|
3109
|
+
}
|
|
3110
|
+
const mime = firstNonEmpty(options.emittedMime, options.recorderMime);
|
|
3111
|
+
const container = mime.split(";")[0]?.trim().toLowerCase() ?? "";
|
|
3112
|
+
const extension = VIDEO_EXTENSIONS[container];
|
|
3113
|
+
if (!extension) {
|
|
3114
|
+
throw new Error(
|
|
3115
|
+
`finalizeCapturedVideo requires a recognized video MIME; received "${mime}".`
|
|
3116
|
+
);
|
|
3117
|
+
}
|
|
3118
|
+
const durationMs = Math.max(1, Math.round(options.durationMs));
|
|
3119
|
+
const blob = new Blob([...options.parts], { type: mime });
|
|
3120
|
+
const timestampMs = options.timestampMs ?? Date.now();
|
|
3121
|
+
const file = new File(
|
|
3122
|
+
[blob],
|
|
3123
|
+
`${options.fileNamePrefix}-video-${timestampMs}.${extension}`,
|
|
3124
|
+
{ type: mime }
|
|
3125
|
+
);
|
|
3126
|
+
return { file, mime, durationMs };
|
|
3127
|
+
}
|
|
3128
|
+
|
|
3031
3129
|
// src/engine/useDefaultEngine.ts
|
|
3032
3130
|
var RECORDING_MIME_LADDER = [
|
|
3033
3131
|
'video/mp4;codecs="avc1.42E01E,mp4a.40.2"',
|
|
@@ -3282,16 +3380,15 @@ function useDefaultCaptureEngine(options) {
|
|
|
3282
3380
|
if (entry.micHeld) releaseWarmMic();
|
|
3283
3381
|
recorderRef.current = null;
|
|
3284
3382
|
setRecording(false);
|
|
3285
|
-
const
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
);
|
|
3383
|
+
const captured = finalizeCapturedVideo({
|
|
3384
|
+
parts: entry.chunks,
|
|
3385
|
+
// Emitted chunk MIME is stronger evidence than recorder.mimeType.
|
|
3386
|
+
emittedMime: entry.chunks.find((chunk) => chunk.type)?.type,
|
|
3387
|
+
recorderMime: recorder.mimeType,
|
|
3388
|
+
durationMs: performance.now() - entry.startedAt,
|
|
3389
|
+
fileNamePrefix
|
|
3390
|
+
});
|
|
3391
|
+
callbacksRef.current.onVideo(captured.file, captured.durationMs);
|
|
3295
3392
|
};
|
|
3296
3393
|
recorder.start(1e3);
|
|
3297
3394
|
setRecordElapsedSeconds(0);
|