@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 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 getUserMedia** the host injects a `CaptureCameraEngine` (stream, capture/record callbacks). In AI Matrx that host is the `features/media-capture` runtime (lease manager, recorder, chunk journal); those move here in a later major once extracted.
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
- const url = getMediaUrl(item);
1945
- if (url) setEditTarget({ key: item.key, url });
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
- const url = getMediaUrl(item);
2497
- if (url) setEditTarget({ key: item.key, url });
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 durationMs = Math.round(performance.now() - entry.startedAt);
3286
- const type = recorder.mimeType || entry.chunks[0]?.type || "video/webm";
3287
- const blob = new Blob(entry.chunks, { type });
3288
- const ext = type.includes("mp4") ? "mp4" : "webm";
3289
- callbacksRef.current.onVideo(
3290
- new File([blob], `${fileNamePrefix}-video-${Date.now()}.${ext}`, {
3291
- type
3292
- }),
3293
- durationMs
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);