@ai-matrx/capture 0.4.2 → 0.5.0

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.js CHANGED
@@ -72,6 +72,23 @@ function ImagesIcon(props) {
72
72
  function Loader2Icon(props) {
73
73
  return /* @__PURE__ */ jsx("svg", { ...svgProps(props), children: /* @__PURE__ */ jsx("path", { d: "M21 12a9 9 0 1 1-6.219-8.56" }) });
74
74
  }
75
+ function UploadIcon(props) {
76
+ return /* @__PURE__ */ jsxs("svg", { ...svgProps(props), children: [
77
+ /* @__PURE__ */ jsx("path", { d: "M12 3v12" }),
78
+ /* @__PURE__ */ jsx("path", { d: "m17 8-5-5-5 5" }),
79
+ /* @__PURE__ */ jsx("path", { d: "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" })
80
+ ] });
81
+ }
82
+ function ImageOffIcon(props) {
83
+ return /* @__PURE__ */ jsxs("svg", { ...svgProps(props), children: [
84
+ /* @__PURE__ */ jsx("line", { x1: "2", x2: "22", y1: "2", y2: "22" }),
85
+ /* @__PURE__ */ jsx("path", { d: "M10.41 10.41a2 2 0 1 1-2.83-2.83" }),
86
+ /* @__PURE__ */ jsx("line", { x1: "13.5", x2: "6", y1: "13.5", y2: "21" }),
87
+ /* @__PURE__ */ jsx("line", { x1: "18", x2: "21", y1: "12", y2: "15" }),
88
+ /* @__PURE__ */ jsx("path", { d: "M3.59 3.59A1.99 1.99 0 0 0 3 5v14a2 2 0 0 0 2 2h14c.55 0 1.052-.22 1.41-.59" }),
89
+ /* @__PURE__ */ jsx("path", { d: "M21 15V5a2 2 0 0 0-2-2H9" })
90
+ ] });
91
+ }
75
92
  function LockIcon(props) {
76
93
  return /* @__PURE__ */ jsxs("svg", { ...svgProps(props), children: [
77
94
  /* @__PURE__ */ jsx("rect", { width: "18", height: "11", x: "3", y: "11", rx: "2", ry: "2" }),
@@ -720,7 +737,7 @@ function FilmstripThumb({ item }) {
720
737
  /* @__PURE__ */ jsx9(
721
738
  "video",
722
739
  {
723
- src: url,
740
+ src: `${url}#t=0.01`,
724
741
  muted: true,
725
742
  playsInline: true,
726
743
  preload: "metadata",
@@ -2536,13 +2553,350 @@ function CameraFeed({
2536
2553
  }
2537
2554
 
2538
2555
  // src/engine/useDefaultEngine.ts
2539
- import {
2540
- useCallback as useCallback8,
2541
- useEffect as useEffect10,
2542
- useMemo as useMemo3,
2543
- useRef as useRef7,
2544
- useState as useState10
2545
- } from "react";
2556
+ import { useCallback as useCallback8, useEffect as useEffect10, useMemo as useMemo3, useRef as useRef7, useState as useState10 } from "react";
2557
+
2558
+ // src/engine/crop.ts
2559
+ var PHOTO_JPEG_QUALITY = 0.92;
2560
+ async function cropBlobToAspect(blob, aspect, quality = PHOTO_JPEG_QUALITY) {
2561
+ if (aspect === "full") return blob;
2562
+ const [wRatio, hRatio] = aspect === "1:1" ? [1, 1] : aspect === "4:3" ? [4, 3] : [16, 9];
2563
+ const bitmap = await createImageBitmap(blob);
2564
+ try {
2565
+ const srcW = bitmap.width;
2566
+ const srcH = bitmap.height;
2567
+ const target = srcW >= srcH ? wRatio / hRatio : hRatio / wRatio;
2568
+ let cropW = srcW;
2569
+ let cropH = Math.round(srcW / target);
2570
+ if (cropH > srcH) {
2571
+ cropH = srcH;
2572
+ cropW = Math.round(srcH * target);
2573
+ }
2574
+ if (cropW === srcW && cropH === srcH) return blob;
2575
+ const canvas = document.createElement("canvas");
2576
+ canvas.width = cropW;
2577
+ canvas.height = cropH;
2578
+ const ctx = canvas.getContext("2d");
2579
+ if (!ctx) return blob;
2580
+ ctx.drawImage(
2581
+ bitmap,
2582
+ Math.round((srcW - cropW) / 2),
2583
+ Math.round((srcH - cropH) / 2),
2584
+ cropW,
2585
+ cropH,
2586
+ 0,
2587
+ 0,
2588
+ cropW,
2589
+ cropH
2590
+ );
2591
+ return await new Promise(
2592
+ (resolve) => canvas.toBlob((out) => resolve(out ?? blob), "image/jpeg", quality)
2593
+ );
2594
+ } finally {
2595
+ bitmap.close();
2596
+ }
2597
+ }
2598
+ function nextCameraDevice(cameras, currentDeviceId) {
2599
+ if (cameras.length < 2) return null;
2600
+ const currentIdx = currentDeviceId ? cameras.findIndex((c) => c.deviceId === currentDeviceId) : -1;
2601
+ return cameras[(Math.max(currentIdx, 0) + 1) % cameras.length] ?? null;
2602
+ }
2603
+
2604
+ // src/engine/permissions.ts
2605
+ async function queryPermission(name) {
2606
+ if (typeof navigator === "undefined" || !navigator.permissions) {
2607
+ return "unknown";
2608
+ }
2609
+ try {
2610
+ const status = await navigator.permissions.query({
2611
+ name
2612
+ });
2613
+ if (status.state === "granted") return "granted";
2614
+ if (status.state === "denied") return "denied";
2615
+ return "prompt";
2616
+ } catch {
2617
+ return "unknown";
2618
+ }
2619
+ }
2620
+ function queryCameraPermission() {
2621
+ return queryPermission("camera");
2622
+ }
2623
+ function queryMicPermission() {
2624
+ return queryPermission("microphone");
2625
+ }
2626
+ function shouldCombineMicPrompt(micPermissionState) {
2627
+ return micPermissionState === "prompt" || micPermissionState === "unknown";
2628
+ }
2629
+ function isMediaDenialError(err) {
2630
+ const name = err && typeof err === "object" && "name" in err ? String(err.name) : "";
2631
+ return name === "NotAllowedError" || name === "SecurityError";
2632
+ }
2633
+ function classifyCameraBlockReason(err) {
2634
+ return isMediaDenialError(err) ? "permission-denied" : "not-supported";
2635
+ }
2636
+
2637
+ // src/engine/warm-mic.ts
2638
+ var DEFAULT_KEEPALIVE_MS = 6e3;
2639
+ var STATE_SLOT2 = /* @__PURE__ */ Symbol.for("ai-matrx.capture.warm-mic-state");
2640
+ function getManager() {
2641
+ const holder = globalThis;
2642
+ let m = holder[STATE_SLOT2];
2643
+ if (!m) {
2644
+ m = {
2645
+ stream: null,
2646
+ inFlight: null,
2647
+ refCount: 0,
2648
+ releaseTimer: null,
2649
+ state: "idle",
2650
+ keepAliveMs: DEFAULT_KEEPALIVE_MS,
2651
+ preferredInputDeviceId: null,
2652
+ listeners: /* @__PURE__ */ new Set(),
2653
+ interruptionListeners: /* @__PURE__ */ new Set(),
2654
+ pageLifecycleWatched: false,
2655
+ permissionWatched: false
2656
+ };
2657
+ holder[STATE_SLOT2] = m;
2658
+ }
2659
+ return m;
2660
+ }
2661
+ function emitInterruption(reason) {
2662
+ for (const l of getManager().interruptionListeners) {
2663
+ try {
2664
+ l(reason);
2665
+ } catch {
2666
+ }
2667
+ }
2668
+ }
2669
+ function subscribeWarmMicInterruption(listener) {
2670
+ const m = getManager();
2671
+ m.interruptionListeners.add(listener);
2672
+ return () => {
2673
+ m.interruptionListeners.delete(listener);
2674
+ };
2675
+ }
2676
+ function setState(next) {
2677
+ const m = getManager();
2678
+ m.state = next;
2679
+ for (const l of m.listeners) {
2680
+ try {
2681
+ l(next);
2682
+ } catch {
2683
+ }
2684
+ }
2685
+ }
2686
+ function clearReleaseTimer() {
2687
+ const m = getManager();
2688
+ if (m.releaseTimer) {
2689
+ clearTimeout(m.releaseTimer);
2690
+ m.releaseTimer = null;
2691
+ }
2692
+ }
2693
+ function attachTrackHealth(stream) {
2694
+ const m = getManager();
2695
+ for (const track of stream.getAudioTracks()) {
2696
+ track.onended = () => {
2697
+ console.error(
2698
+ "[capture:warm-mic] mic track ENDED (OS interruption / device change). The warm grant is gone; the next recording will re-prompt."
2699
+ );
2700
+ if (m.stream === stream) {
2701
+ m.stream = null;
2702
+ setState("error");
2703
+ }
2704
+ emitInterruption("ended");
2705
+ };
2706
+ track.onmute = () => {
2707
+ console.warn(
2708
+ "[capture:warm-mic] mic track MUTED (transient interruption \u2014 e.g. a call). The grant survives; it should unmute when the interruption ends."
2709
+ );
2710
+ emitInterruption("muted");
2711
+ };
2712
+ track.onunmute = () => {
2713
+ emitInterruption("unmuted");
2714
+ };
2715
+ }
2716
+ }
2717
+ function notifyMicPermissionRevoked() {
2718
+ console.error("[capture:warm-mic] microphone permission REVOKED.");
2719
+ hardStopWarmMic();
2720
+ emitInterruption("permission-revoked");
2721
+ }
2722
+ function watchMicPermission() {
2723
+ const m = getManager();
2724
+ if (m.permissionWatched) return;
2725
+ if (typeof navigator === "undefined" || !navigator.permissions) return;
2726
+ m.permissionWatched = true;
2727
+ void (async () => {
2728
+ try {
2729
+ const status = await navigator.permissions.query({
2730
+ name: "microphone"
2731
+ });
2732
+ status.onchange = () => {
2733
+ if (status.state === "denied") notifyMicPermissionRevoked();
2734
+ };
2735
+ } catch {
2736
+ }
2737
+ })();
2738
+ }
2739
+ function watchPageLifecycle() {
2740
+ const m = getManager();
2741
+ if (m.pageLifecycleWatched) return;
2742
+ if (typeof window === "undefined") return;
2743
+ m.pageLifecycleWatched = true;
2744
+ window.addEventListener("pagehide", () => {
2745
+ if (m.stream || m.refCount > 0) {
2746
+ if (m.refCount > 0) {
2747
+ console.error(
2748
+ `[capture:warm-mic] page hiding with ${m.refCount} unreleased mic holder(s) \u2014 forcing release. A surface leaked acquireWarmMic without a matching releaseWarmMic on unmount.`
2749
+ );
2750
+ }
2751
+ hardStopWarmMic();
2752
+ }
2753
+ });
2754
+ }
2755
+ function setPreferredMicDeviceId(id) {
2756
+ getManager().preferredInputDeviceId = id;
2757
+ }
2758
+ function streamIsLive(stream) {
2759
+ if (!stream) return false;
2760
+ const tracks = stream.getAudioTracks();
2761
+ return tracks.length > 0 && tracks.every((t) => t.readyState === "live");
2762
+ }
2763
+ function buildWarmMicConstraints() {
2764
+ const m = getManager();
2765
+ const audio = {
2766
+ echoCancellation: true,
2767
+ noiseSuppression: true,
2768
+ autoGainControl: true
2769
+ };
2770
+ if (m.preferredInputDeviceId) {
2771
+ audio.deviceId = { ideal: m.preferredInputDeviceId };
2772
+ }
2773
+ return audio;
2774
+ }
2775
+ function adoptWarmAudioStream(stream) {
2776
+ const m = getManager();
2777
+ if (streamIsLive(m.stream) || m.inFlight) {
2778
+ for (const t of stream.getTracks()) {
2779
+ try {
2780
+ t.stop();
2781
+ } catch {
2782
+ }
2783
+ }
2784
+ return;
2785
+ }
2786
+ if (!streamIsLive(stream)) return;
2787
+ clearReleaseTimer();
2788
+ m.stream = stream;
2789
+ attachTrackHealth(stream);
2790
+ watchPageLifecycle();
2791
+ watchMicPermission();
2792
+ if (m.refCount > 0) {
2793
+ setState("active");
2794
+ } else {
2795
+ armKeepaliveStop();
2796
+ }
2797
+ }
2798
+ async function acquireWarmMic(constraints) {
2799
+ const m = getManager();
2800
+ clearReleaseTimer();
2801
+ m.refCount += 1;
2802
+ if (streamIsLive(m.stream)) {
2803
+ setState("active");
2804
+ return m.stream;
2805
+ }
2806
+ m.stream = null;
2807
+ if (m.inFlight) {
2808
+ return m.inFlight;
2809
+ }
2810
+ setState("acquiring");
2811
+ const audio = constraints ?? buildWarmMicConstraints();
2812
+ if (m.preferredInputDeviceId && audio.deviceId == null) {
2813
+ audio.deviceId = { ideal: m.preferredInputDeviceId };
2814
+ }
2815
+ m.inFlight = (async () => {
2816
+ try {
2817
+ const stream = await navigator.mediaDevices.getUserMedia({ audio });
2818
+ m.stream = stream;
2819
+ attachTrackHealth(stream);
2820
+ watchPageLifecycle();
2821
+ watchMicPermission();
2822
+ if (m.refCount === 0) {
2823
+ armKeepaliveStop();
2824
+ return stream;
2825
+ }
2826
+ setState("active");
2827
+ return stream;
2828
+ } catch (err) {
2829
+ m.refCount = Math.max(0, m.refCount - 1);
2830
+ setState("error");
2831
+ throw err;
2832
+ } finally {
2833
+ m.inFlight = null;
2834
+ }
2835
+ })();
2836
+ return m.inFlight;
2837
+ }
2838
+ function releaseWarmMic() {
2839
+ const m = getManager();
2840
+ if (m.refCount === 0) {
2841
+ console.error(
2842
+ "[capture:warm-mic] releaseWarmMic() with zero holders \u2014 unbalanced release. Some caller released twice (or released a hold it never acquired)."
2843
+ );
2844
+ return;
2845
+ }
2846
+ m.refCount -= 1;
2847
+ if (m.refCount > 0) return;
2848
+ if (!m.stream) {
2849
+ setState(m.inFlight ? "keepalive" : "idle");
2850
+ return;
2851
+ }
2852
+ armKeepaliveStop();
2853
+ }
2854
+ function armKeepaliveStop() {
2855
+ const m = getManager();
2856
+ clearReleaseTimer();
2857
+ setState("keepalive");
2858
+ m.releaseTimer = setTimeout(() => {
2859
+ m.releaseTimer = null;
2860
+ if (m.refCount > 0) return;
2861
+ hardStopWarmMic();
2862
+ }, m.keepAliveMs);
2863
+ }
2864
+ function hardStopWarmMic() {
2865
+ const m = getManager();
2866
+ clearReleaseTimer();
2867
+ if (m.stream) {
2868
+ for (const t of m.stream.getTracks()) {
2869
+ try {
2870
+ t.stop();
2871
+ } catch {
2872
+ }
2873
+ }
2874
+ m.stream = null;
2875
+ }
2876
+ m.refCount = 0;
2877
+ setState("idle");
2878
+ }
2879
+ function getWarmMicState() {
2880
+ return getManager().state;
2881
+ }
2882
+ function subscribeWarmMic(listener) {
2883
+ const m = getManager();
2884
+ m.listeners.add(listener);
2885
+ return () => {
2886
+ m.listeners.delete(listener);
2887
+ };
2888
+ }
2889
+ function warmMicDebug() {
2890
+ const m = getManager();
2891
+ return {
2892
+ state: m.state,
2893
+ refCount: m.refCount,
2894
+ live: streamIsLive(m.stream),
2895
+ keepAliveMs: m.keepAliveMs
2896
+ };
2897
+ }
2898
+
2899
+ // src/engine/useDefaultEngine.ts
2546
2900
  var RECORDING_MIME_LADDER = [
2547
2901
  'video/mp4;codecs="avc1.42E01E,mp4a.40.2"',
2548
2902
  "video/mp4",
@@ -2550,39 +2904,38 @@ var RECORDING_MIME_LADDER = [
2550
2904
  'video/webm;codecs="vp8,opus"',
2551
2905
  "video/webm"
2552
2906
  ];
2553
- function cropRect(w, h, aspect) {
2554
- if (aspect === "full") return { x: 0, y: 0, w, h };
2555
- const [aw, ah] = aspect === "1:1" ? [1, 1] : aspect === "4:3" ? [3, 4] : [9, 16];
2556
- const [rw, rh] = w >= h ? [ah, aw] : [aw, ah];
2557
- const target = rw / rh;
2558
- let cw = w;
2559
- let ch = w / target;
2560
- if (ch > h) {
2561
- ch = h;
2562
- cw = h * target;
2563
- }
2564
- return { x: (w - cw) / 2, y: (h - ch) / 2, w: cw, h: ch };
2565
- }
2566
2907
  function useDefaultCaptureEngine(options) {
2567
2908
  const {
2568
2909
  onPhoto,
2569
2910
  onVideo,
2570
2911
  onFiles,
2571
2912
  withAudio = true,
2913
+ mode,
2572
2914
  facingMode: initialFacing = "environment",
2573
- photoQuality = 0.92
2915
+ photoQuality = 0.92,
2916
+ fileNamePrefix = "capture",
2917
+ onError
2574
2918
  } = options;
2575
2919
  const [stream, setStream] = useState10(null);
2576
2920
  const [blocked, setBlocked] = useState10(null);
2577
- const [facing, setFacing] = useState10(initialFacing);
2578
- const [multipleCameras, setMultipleCameras] = useState10(false);
2921
+ const [deviceId, setDeviceId] = useState10(null);
2922
+ const [cameras, setCameras] = useState10([]);
2579
2923
  const [recording, setRecording] = useState10(false);
2580
2924
  const [recordElapsedSeconds, setRecordElapsedSeconds] = useState10(0);
2925
+ const [flash, setFlash] = useState10(false);
2926
+ const [acquireToken, setAcquireToken] = useState10(0);
2581
2927
  const videoRef = useRef7(null);
2582
2928
  const streamRef = useRef7(null);
2583
2929
  const recorderRef = useRef7(null);
2584
- const callbacksRef = useRef7({ onPhoto, onVideo, onFiles });
2585
- callbacksRef.current = { onPhoto, onVideo, onFiles };
2930
+ const captureBusyRef = useRef7(false);
2931
+ const reacquiredOnceRef = useRef7(false);
2932
+ const callbacksRef = useRef7({ onPhoto, onVideo, onFiles, onError });
2933
+ callbacksRef.current = { onPhoto, onVideo, onFiles, onError };
2934
+ const reportError = useCallback8((message, err) => {
2935
+ const sink = callbacksRef.current.onError;
2936
+ if (sink) sink(message, err);
2937
+ else console.error(`[capture:engine] ${message}`, err);
2938
+ }, []);
2586
2939
  useEffect10(() => {
2587
2940
  let cancelled = false;
2588
2941
  let acquired = null;
@@ -2590,110 +2943,176 @@ function useDefaultCaptureEngine(options) {
2590
2943
  setBlocked({ reason: "not-supported" });
2591
2944
  return;
2592
2945
  }
2593
- navigator.mediaDevices.getUserMedia({
2594
- video: {
2595
- facingMode: facing,
2596
- width: { ideal: 4096 },
2597
- height: { ideal: 4096 }
2598
- },
2599
- audio: false
2600
- }).then((s) => {
2601
- if (cancelled) {
2602
- s.getTracks().forEach((t) => t.stop());
2946
+ void (async () => {
2947
+ const known = await queryCameraPermission();
2948
+ if (cancelled) return;
2949
+ if (known === "denied") {
2950
+ setBlocked({ reason: "permission-denied" });
2603
2951
  return;
2604
2952
  }
2605
- acquired = s;
2606
- streamRef.current = s;
2607
- setStream(s);
2608
- setBlocked(null);
2609
- return navigator.mediaDevices.enumerateDevices().then((devices) => {
2610
- if (!cancelled)
2611
- setMultipleCameras(
2612
- devices.filter((d) => d.kind === "videoinput").length > 1
2613
- );
2614
- });
2615
- }).catch((err) => {
2953
+ const includeMic = withAudio && shouldCombineMicPrompt(await queryMicPermission());
2616
2954
  if (cancelled) return;
2617
- const name = err && typeof err === "object" && "name" in err ? String(err.name) : "";
2618
- setBlocked({
2619
- reason: name === "NotAllowedError" || name === "SecurityError" ? "permission-denied" : "not-supported"
2620
- });
2621
- });
2955
+ const video = {
2956
+ width: { ideal: 4096 },
2957
+ height: { ideal: 4096 },
2958
+ ...deviceId ? { deviceId: { exact: deviceId } } : { facingMode: initialFacing }
2959
+ };
2960
+ const acquire = async (mic) => {
2961
+ try {
2962
+ return await navigator.mediaDevices.getUserMedia(
2963
+ mic ? { video, audio: buildWarmMicConstraints() } : { video, audio: false }
2964
+ );
2965
+ } catch (err) {
2966
+ if (mic && classifyCameraBlockReason(err) === "permission-denied") {
2967
+ return navigator.mediaDevices.getUserMedia({ video, audio: false });
2968
+ }
2969
+ throw err;
2970
+ }
2971
+ };
2972
+ try {
2973
+ const s = await acquire(includeMic);
2974
+ if (cancelled) {
2975
+ s.getTracks().forEach((t) => t.stop());
2976
+ return;
2977
+ }
2978
+ const audioTracks = s.getAudioTracks();
2979
+ if (audioTracks.length > 0) {
2980
+ for (const t of audioTracks) s.removeTrack(t);
2981
+ adoptWarmAudioStream(new MediaStream(audioTracks));
2982
+ }
2983
+ acquired = s;
2984
+ streamRef.current = s;
2985
+ setStream(s);
2986
+ setBlocked(null);
2987
+ reacquiredOnceRef.current = false;
2988
+ for (const track of s.getVideoTracks()) {
2989
+ track.onended = () => {
2990
+ if (cancelled || streamRef.current !== s) return;
2991
+ console.error(
2992
+ "[capture:engine] camera track ENDED (OS interruption / device change) \u2014 reacquiring."
2993
+ );
2994
+ if (reacquiredOnceRef.current) {
2995
+ setBlocked({ reason: "not-supported" });
2996
+ return;
2997
+ }
2998
+ reacquiredOnceRef.current = true;
2999
+ setAcquireToken((n) => n + 1);
3000
+ };
3001
+ }
3002
+ const devices = await navigator.mediaDevices.enumerateDevices();
3003
+ if (!cancelled) {
3004
+ setCameras(devices.filter((d) => d.kind === "videoinput"));
3005
+ }
3006
+ } catch (err) {
3007
+ if (cancelled) return;
3008
+ setBlocked({ reason: classifyCameraBlockReason(err) });
3009
+ }
3010
+ })();
2622
3011
  return () => {
2623
3012
  cancelled = true;
2624
3013
  acquired?.getTracks().forEach((t) => t.stop());
2625
3014
  if (streamRef.current === acquired) streamRef.current = null;
2626
3015
  setStream(null);
2627
3016
  };
2628
- }, [facing]);
3017
+ }, [deviceId, initialFacing, withAudio, acquireToken]);
3018
+ const cameraBlocked = blocked !== null;
3019
+ useEffect10(() => {
3020
+ if (mode !== "video" || cameraBlocked || !withAudio) return;
3021
+ let cancelled = false;
3022
+ let held = false;
3023
+ void (async () => {
3024
+ if (await queryMicPermission() === "denied") return;
3025
+ if (cancelled) return;
3026
+ acquireWarmMic().then(() => {
3027
+ if (cancelled) {
3028
+ releaseWarmMic();
3029
+ return;
3030
+ }
3031
+ held = true;
3032
+ }).catch(() => {
3033
+ });
3034
+ })();
3035
+ return () => {
3036
+ cancelled = true;
3037
+ if (held) releaseWarmMic();
3038
+ };
3039
+ }, [mode, cameraBlocked, withAudio]);
2629
3040
  useEffect10(() => {
2630
3041
  return () => {
2631
3042
  const r = recorderRef.current;
2632
3043
  if (r) {
2633
3044
  clearInterval(r.timer);
2634
3045
  if (r.recorder.state !== "inactive") r.recorder.stop();
2635
- r.micTracks.forEach((t) => t.stop());
2636
3046
  }
2637
3047
  };
2638
3048
  }, []);
2639
- const onCapturePhoto = useCallback8(
3049
+ const capturePhotoWith = useCallback8(
2640
3050
  (opts) => {
2641
3051
  const video = videoRef.current;
2642
3052
  if (!video || video.videoWidth === 0) return;
2643
- const rect = cropRect(
2644
- video.videoWidth,
2645
- video.videoHeight,
2646
- opts?.aspect ?? "full"
2647
- );
2648
- const canvas = document.createElement("canvas");
2649
- canvas.width = Math.round(rect.w);
2650
- canvas.height = Math.round(rect.h);
2651
- const ctx = canvas.getContext("2d");
2652
- if (!ctx) return;
2653
- ctx.drawImage(
2654
- video,
2655
- rect.x,
2656
- rect.y,
2657
- rect.w,
2658
- rect.h,
2659
- 0,
2660
- 0,
2661
- canvas.width,
2662
- canvas.height
2663
- );
2664
- canvas.toBlob(
2665
- (blob) => {
2666
- if (!blob) return;
3053
+ if (captureBusyRef.current) return;
3054
+ captureBusyRef.current = true;
3055
+ void (async () => {
3056
+ try {
3057
+ const canvas = document.createElement("canvas");
3058
+ canvas.width = video.videoWidth;
3059
+ canvas.height = video.videoHeight;
3060
+ const ctx = canvas.getContext("2d");
3061
+ if (!ctx) throw new Error("2d canvas context unavailable");
3062
+ ctx.drawImage(video, 0, 0);
3063
+ const full = await new Promise(
3064
+ (resolve, reject) => canvas.toBlob(
3065
+ (b) => b ? resolve(b) : reject(new Error("toBlob returned null")),
3066
+ "image/jpeg",
3067
+ photoQuality
3068
+ )
3069
+ );
3070
+ const blob = await cropBlobToAspect(
3071
+ full,
3072
+ opts.aspect ?? "full",
3073
+ photoQuality
3074
+ );
2667
3075
  callbacksRef.current.onPhoto(
2668
- new File([blob], `capture-${(/* @__PURE__ */ new Date()).toISOString()}.jpg`, {
2669
- type: "image/jpeg"
2670
- })
3076
+ new File(
3077
+ [blob],
3078
+ `${opts.fileNamePrefix}-${(/* @__PURE__ */ new Date()).toISOString()}.jpg`,
3079
+ { type: "image/jpeg" }
3080
+ )
2671
3081
  );
2672
- },
2673
- "image/jpeg",
2674
- photoQuality
3082
+ setFlash(true);
3083
+ window.setTimeout(() => setFlash(false), 120);
3084
+ } catch (err) {
3085
+ reportError("The photo could not be captured \u2014 try again.", err);
3086
+ } finally {
3087
+ captureBusyRef.current = false;
3088
+ }
3089
+ })();
3090
+ },
3091
+ [photoQuality, reportError]
3092
+ );
3093
+ const onCapturePhoto = useCallback8(
3094
+ (opts) => {
3095
+ capturePhotoWith(
3096
+ opts?.aspect ? { fileNamePrefix, aspect: opts.aspect } : { fileNamePrefix }
2675
3097
  );
2676
3098
  },
2677
- [photoQuality]
3099
+ [capturePhotoWith, fileNamePrefix]
2678
3100
  );
2679
3101
  const onStartRecording = useCallback8(() => {
2680
3102
  const base = streamRef.current;
2681
3103
  if (!base || recorderRef.current) return;
2682
3104
  void (async () => {
2683
- let micTracks = [];
3105
+ let micClones = [];
3106
+ let micHeld = false;
2684
3107
  if (withAudio) {
2685
3108
  try {
2686
- const mic = await navigator.mediaDevices.getUserMedia({
2687
- audio: true
2688
- });
2689
- micTracks = mic.getAudioTracks();
3109
+ const mic = await acquireWarmMic();
3110
+ micHeld = true;
3111
+ micClones = mic.getAudioTracks().map((t) => t.clone());
2690
3112
  } catch {
2691
3113
  }
2692
3114
  }
2693
- const composed = new MediaStream([
2694
- ...base.getVideoTracks(),
2695
- ...micTracks
2696
- ]);
3115
+ const composed = new MediaStream([...base.getVideoTracks(), ...micClones]);
2697
3116
  const mime = RECORDING_MIME_LADDER.find(
2698
3117
  (m) => typeof MediaRecorder !== "undefined" && MediaRecorder.isTypeSupported(m)
2699
3118
  );
@@ -2703,15 +3122,18 @@ function useDefaultCaptureEngine(options) {
2703
3122
  composed,
2704
3123
  mime ? { mimeType: mime } : void 0
2705
3124
  );
2706
- } catch {
2707
- micTracks.forEach((t) => t.stop());
3125
+ } catch (err) {
3126
+ micClones.forEach((t) => t.stop());
3127
+ if (micHeld) releaseWarmMic();
3128
+ reportError("Could not start the video recording.", err);
2708
3129
  return;
2709
3130
  }
2710
3131
  const entry = {
2711
3132
  recorder,
2712
3133
  chunks: [],
2713
3134
  startedAt: performance.now(),
2714
- micTracks,
3135
+ micClones,
3136
+ micHeld,
2715
3137
  timer: setInterval(() => {
2716
3138
  setRecordElapsedSeconds(
2717
3139
  Math.floor((performance.now() - entry.startedAt) / 1e3)
@@ -2724,7 +3146,8 @@ function useDefaultCaptureEngine(options) {
2724
3146
  };
2725
3147
  recorder.onstop = () => {
2726
3148
  clearInterval(entry.timer);
2727
- entry.micTracks.forEach((t) => t.stop());
3149
+ entry.micClones.forEach((t) => t.stop());
3150
+ if (entry.micHeld) releaseWarmMic();
2728
3151
  recorderRef.current = null;
2729
3152
  setRecording(false);
2730
3153
  const durationMs = Math.round(performance.now() - entry.startedAt);
@@ -2732,7 +3155,7 @@ function useDefaultCaptureEngine(options) {
2732
3155
  const blob = new Blob(entry.chunks, { type });
2733
3156
  const ext = type.includes("mp4") ? "mp4" : "webm";
2734
3157
  callbacksRef.current.onVideo(
2735
- new File([blob], `capture-${(/* @__PURE__ */ new Date()).toISOString()}.${ext}`, {
3158
+ new File([blob], `${fileNamePrefix}-video-${Date.now()}.${ext}`, {
2736
3159
  type
2737
3160
  }),
2738
3161
  durationMs
@@ -2742,7 +3165,7 @@ function useDefaultCaptureEngine(options) {
2742
3165
  setRecordElapsedSeconds(0);
2743
3166
  setRecording(true);
2744
3167
  })();
2745
- }, [withAudio]);
3168
+ }, [withAudio, fileNamePrefix, reportError]);
2746
3169
  const onStopRecording = useCallback8(() => {
2747
3170
  const r = recorderRef.current;
2748
3171
  if (r && r.recorder.state !== "inactive") r.recorder.stop();
@@ -2759,8 +3182,12 @@ function useDefaultCaptureEngine(options) {
2759
3182
  input.click();
2760
3183
  }, []);
2761
3184
  const onFlipCamera = useCallback8(() => {
2762
- setFacing((f) => f === "environment" ? "user" : "environment");
2763
- }, []);
3185
+ if (recorderRef.current) return;
3186
+ const current = deviceId ?? streamRef.current?.getVideoTracks()[0]?.getSettings().deviceId ?? null;
3187
+ const next = nextCameraDevice(cameras, current);
3188
+ if (next) setDeviceId(next.deviceId);
3189
+ }, [cameras, deviceId]);
3190
+ const canFlip = cameras.length > 1 && !cameraBlocked && !recording;
2764
3191
  return useMemo3(
2765
3192
  () => ({
2766
3193
  stream,
@@ -2772,7 +3199,9 @@ function useDefaultCaptureEngine(options) {
2772
3199
  recording,
2773
3200
  recordElapsedSeconds,
2774
3201
  onUpload,
2775
- onFlipCamera: multipleCameras ? onFlipCamera : null
3202
+ onFlipCamera: canFlip ? onFlipCamera : null,
3203
+ flash,
3204
+ capturePhotoWith
2776
3205
  }),
2777
3206
  [
2778
3207
  stream,
@@ -2784,10 +3213,131 @@ function useDefaultCaptureEngine(options) {
2784
3213
  recordElapsedSeconds,
2785
3214
  onUpload,
2786
3215
  onFlipCamera,
2787
- multipleCameras
3216
+ canFlip,
3217
+ flash,
3218
+ capturePhotoWith
2788
3219
  ]
2789
3220
  );
2790
3221
  }
3222
+
3223
+ // src/components/CloudLibrarySheet.tsx
3224
+ import { useEffect as useEffect11, useState as useState11 } from "react";
3225
+ import { jsx as jsx18, jsxs as jsxs15 } from "react/jsx-runtime";
3226
+ var PAGE_SIZE = 60;
3227
+ function CloudLibrarySheet({
3228
+ open,
3229
+ onClose,
3230
+ items,
3231
+ loading,
3232
+ busy = false,
3233
+ onOpenItem,
3234
+ onUpload,
3235
+ title = "Your media"
3236
+ }) {
3237
+ const [visibleCount, setVisibleCount] = useState11(PAGE_SIZE);
3238
+ useEffect11(() => {
3239
+ if (open) setVisibleCount(PAGE_SIZE);
3240
+ }, [open]);
3241
+ if (!open) return null;
3242
+ const uploadThenClose = onUpload ? () => {
3243
+ onClose();
3244
+ onUpload();
3245
+ } : null;
3246
+ return /* @__PURE__ */ jsxs15("div", { className: "absolute inset-0 z-50 flex flex-col bg-black", children: [
3247
+ /* @__PURE__ */ jsxs15(
3248
+ "div",
3249
+ {
3250
+ className: "flex shrink-0 items-center justify-between bg-black/80 px-4",
3251
+ style: safeTop,
3252
+ children: [
3253
+ /* @__PURE__ */ jsx18("h2", { className: "py-4 text-[17px] font-semibold text-white", children: title }),
3254
+ /* @__PURE__ */ jsxs15("div", { className: "flex items-center gap-2", children: [
3255
+ uploadThenClose ? /* @__PURE__ */ jsxs15(
3256
+ "button",
3257
+ {
3258
+ type: "button",
3259
+ onClick: uploadThenClose,
3260
+ className: "flex h-10 touch-manipulation items-center gap-1.5 rounded-full bg-white/10 px-3.5 text-[13px] font-medium text-white transition-colors hover:bg-white/20",
3261
+ children: [
3262
+ /* @__PURE__ */ jsx18(UploadIcon, { className: "h-4 w-4" }),
3263
+ "Upload"
3264
+ ]
3265
+ }
3266
+ ) : null,
3267
+ /* @__PURE__ */ jsx18(
3268
+ "button",
3269
+ {
3270
+ type: "button",
3271
+ onClick: onClose,
3272
+ "aria-label": "Close library",
3273
+ className: "flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/20",
3274
+ children: /* @__PURE__ */ jsx18(XIcon, { className: "h-5 w-5" })
3275
+ }
3276
+ )
3277
+ ] })
3278
+ ]
3279
+ }
3280
+ ),
3281
+ /* @__PURE__ */ jsxs15(
3282
+ "div",
3283
+ {
3284
+ className: "min-h-0 flex-1 overflow-y-auto overscroll-contain px-1",
3285
+ style: safeBottom,
3286
+ children: [
3287
+ loading ? /* @__PURE__ */ jsx18("div", { className: "flex h-full items-center justify-center", children: /* @__PURE__ */ jsx18(Loader2Icon, { className: "h-6 w-6 animate-spin text-white/60" }) }) : items.length === 0 ? /* @__PURE__ */ jsxs15("div", { className: "flex h-full flex-col items-center justify-center gap-3 text-white/60", children: [
3288
+ /* @__PURE__ */ jsx18(ImageOffIcon, { className: "h-8 w-8" }),
3289
+ /* @__PURE__ */ jsx18("p", { className: "text-sm", children: "No photos or videos in your cloud yet." }),
3290
+ uploadThenClose ? /* @__PURE__ */ jsxs15(
3291
+ "button",
3292
+ {
3293
+ type: "button",
3294
+ onClick: uploadThenClose,
3295
+ className: "flex h-11 touch-manipulation items-center gap-2 rounded-full bg-white/10 px-5 text-sm font-medium text-white transition-colors hover:bg-white/20",
3296
+ children: [
3297
+ /* @__PURE__ */ jsx18(UploadIcon, { className: "h-4 w-4" }),
3298
+ "Upload from this device"
3299
+ ]
3300
+ }
3301
+ ) : null
3302
+ ] }) : /* @__PURE__ */ jsx18("div", { className: "grid grid-cols-3 gap-0.5 sm:grid-cols-5 md:grid-cols-7", children: items.slice(0, visibleCount).map((item) => /* @__PURE__ */ jsxs15(
3303
+ "button",
3304
+ {
3305
+ type: "button",
3306
+ onClick: () => onOpenItem(item.id),
3307
+ "aria-label": `Open ${item.fileName}`,
3308
+ className: "relative aspect-square overflow-hidden bg-white/5 transition-opacity active:opacity-70",
3309
+ children: [
3310
+ item.thumbnail,
3311
+ item.kind === "video" && /* @__PURE__ */ jsx18(
3312
+ PlayIcon,
3313
+ {
3314
+ className: "absolute bottom-1.5 left-1.5 h-4 w-4 text-white drop-shadow",
3315
+ fill: "currentColor"
3316
+ }
3317
+ )
3318
+ ]
3319
+ },
3320
+ item.id
3321
+ )) }),
3322
+ !loading && items.length > visibleCount && /* @__PURE__ */ jsx18("div", { className: "flex justify-center py-4", children: /* @__PURE__ */ jsxs15(
3323
+ "button",
3324
+ {
3325
+ type: "button",
3326
+ onClick: () => setVisibleCount((n) => n + PAGE_SIZE),
3327
+ className: "h-11 touch-manipulation rounded-full bg-white/10 px-6 text-sm font-medium text-white",
3328
+ children: [
3329
+ "Show more (",
3330
+ items.length - visibleCount,
3331
+ " left)"
3332
+ ]
3333
+ }
3334
+ ) }),
3335
+ busy && /* @__PURE__ */ jsx18("div", { className: "pointer-events-none absolute inset-0 flex items-center justify-center bg-black/40", children: /* @__PURE__ */ jsx18(Loader2Icon, { className: "h-6 w-6 animate-spin text-white" }) })
3336
+ ]
3337
+ }
3338
+ )
3339
+ ] });
3340
+ }
2791
3341
  export {
2792
3342
  CameraCapture,
2793
3343
  CameraCaptureV3,
@@ -2796,6 +3346,7 @@ export {
2796
3346
  CaptureFilmstrip,
2797
3347
  CaptureRail,
2798
3348
  CaptureSheet,
3349
+ CloudLibrarySheet,
2799
3350
  CountdownOverlay,
2800
3351
  GridOverlay,
2801
3352
  HoldShutter,
@@ -2803,13 +3354,32 @@ export {
2803
3354
  MediaViewer,
2804
3355
  ModeSelector,
2805
3356
  OptionsGridPanel,
3357
+ PHOTO_JPEG_QUALITY,
2806
3358
  ShutterButton,
2807
3359
  ZoomRow,
3360
+ acquireWarmMic,
3361
+ adoptWarmAudioStream,
3362
+ buildWarmMicConstraints,
3363
+ classifyCameraBlockReason,
3364
+ cropBlobToAspect,
2808
3365
  getMediaUrl,
3366
+ getWarmMicState,
3367
+ hardStopWarmMic,
2809
3368
  invalidateMedia,
3369
+ isMediaDenialError,
3370
+ nextCameraDevice,
3371
+ notifyMicPermissionRevoked,
2810
3372
  primeMedia,
3373
+ queryCameraPermission,
3374
+ queryMicPermission,
3375
+ releaseWarmMic,
3376
+ setPreferredMicDeviceId,
3377
+ shouldCombineMicPrompt,
3378
+ subscribeWarmMic,
3379
+ subscribeWarmMicInterruption,
2811
3380
  useDefaultCaptureEngine,
2812
3381
  useMediaUrl,
2813
- useTrackControls
3382
+ useTrackControls,
3383
+ warmMicDebug
2814
3384
  };
2815
3385
  //# sourceMappingURL=react.js.map