@ai-matrx/capture 0.5.4 → 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,10 @@
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
+
3
8
  ## 0.5.4 — 2026-09-01
4
9
 
5
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.
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,
@@ -3088,6 +3089,43 @@ function warmMicDebug() {
3088
3089
  };
3089
3090
  }
3090
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
+
3091
3129
  // src/engine/useDefaultEngine.ts
3092
3130
  var RECORDING_MIME_LADDER = [
3093
3131
  'video/mp4;codecs="avc1.42E01E,mp4a.40.2"',
@@ -3342,16 +3380,15 @@ function useDefaultCaptureEngine(options) {
3342
3380
  if (entry.micHeld) releaseWarmMic();
3343
3381
  recorderRef.current = null;
3344
3382
  setRecording(false);
3345
- const durationMs = Math.round(performance.now() - entry.startedAt);
3346
- const type = recorder.mimeType || entry.chunks[0]?.type || "video/webm";
3347
- const blob = new Blob(entry.chunks, { type });
3348
- const ext = type.includes("mp4") ? "mp4" : "webm";
3349
- callbacksRef.current.onVideo(
3350
- new File([blob], `${fileNamePrefix}-video-${Date.now()}.${ext}`, {
3351
- type
3352
- }),
3353
- durationMs
3354
- );
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);
3355
3392
  };
3356
3393
  recorder.start(1e3);
3357
3394
  setRecordElapsedSeconds(0);