Ministral_3B_WebGPU / src /components /WebcamPermissionDialog.tsx
Jofthomas's picture
Add demo files (#1)
d5c6d34 verified
raw
history blame
11.4 kB
import { useState, useEffect, useCallback, useMemo } from "react";
import Button from "./Button";
import { THEME } from "../constants";
const ERROR_TYPES = {
HTTPS: "https",
NOT_SUPPORTED: "not-supported",
PERMISSION: "permission",
GENERAL: "general",
} as const;
const VIDEO_CONSTRAINTS = {
video: {
width: { ideal: 1920, max: 1920 },
height: { ideal: 1080, max: 1080 },
facingMode: "user",
},
};
interface ErrorInfo {
type: (typeof ERROR_TYPES)[keyof typeof ERROR_TYPES];
message: string;
}
interface WebcamPermissionDialogProps {
onPermissionGranted: (stream: MediaStream) => void;
}
export default function WebcamPermissionDialog({
onPermissionGranted,
}: WebcamPermissionDialogProps) {
const [isRequesting, setIsRequesting] = useState(false);
const [error, setError] = useState<ErrorInfo | null>(null);
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const getErrorInfo = (err: unknown): ErrorInfo => {
if (!navigator.mediaDevices) {
return {
type: ERROR_TYPES.HTTPS,
message: "Camera access requires a secure connection (HTTPS)",
};
}
if (!navigator.mediaDevices.getUserMedia) {
return {
type: ERROR_TYPES.NOT_SUPPORTED,
message: "Camera access not supported in this browser",
};
}
if (err instanceof DOMException) {
switch (err.name) {
case "NotAllowedError":
return {
type: ERROR_TYPES.PERMISSION,
message: "Camera access denied",
};
case "NotFoundError":
return {
type: ERROR_TYPES.GENERAL,
message: "No camera found",
};
case "NotReadableError":
return {
type: ERROR_TYPES.GENERAL,
message: "Camera is in use by another application",
};
case "OverconstrainedError":
return {
type: ERROR_TYPES.GENERAL,
message: "Camera doesn't meet requirements",
};
case "SecurityError":
return {
type: ERROR_TYPES.HTTPS,
message: "Security error accessing camera",
};
default:
return {
type: ERROR_TYPES.GENERAL,
message: `Camera error: ${err.name}`,
};
}
}
return {
type: ERROR_TYPES.GENERAL,
message: "Failed to access camera",
};
};
const requestWebcamAccess = useCallback(async () => {
setIsRequesting(true);
setError(null);
try {
if (!navigator.mediaDevices?.getUserMedia) {
throw new Error("NOT_SUPPORTED");
}
const stream =
await navigator.mediaDevices.getUserMedia(VIDEO_CONSTRAINTS);
onPermissionGranted(stream);
} catch (err) {
const errorInfo = getErrorInfo(err);
setError(errorInfo);
console.error("Error accessing webcam:", err, errorInfo);
} finally {
setIsRequesting(false);
}
}, [onPermissionGranted]);
useEffect(() => {
requestWebcamAccess();
}, [requestWebcamAccess]);
const troubleshootingData = useMemo(
() => ({
[ERROR_TYPES.HTTPS]: {
title: "HTTPS Required",
items: [
"Access this app via https:// instead of http://",
"If developing locally, use localhost",
"Deploy to a secure hosting service (Hugging Face spaces)",
],
},
[ERROR_TYPES.NOT_SUPPORTED]: {
title: "Browser Compatibility",
items: [
"Update your browser to the latest version",
"Try Chrome, Edge, or Firefox",
"Enable JavaScript if disabled",
],
},
[ERROR_TYPES.PERMISSION]: {
title: "Permission Issues",
items: [
"Click the camera icon in your address bar",
'Select "Always allow" for camera access',
"Check System Preferences → Security & Privacy",
"Refresh the page after changing settings",
],
},
[ERROR_TYPES.GENERAL]: {
title: "General Troubleshooting",
items: [
"Ensure no other apps (Zoom, Teams) are using the camera",
"Try unplugging and replugging your webcam",
"Try using a different browser",
],
},
}),
[],
);
const getTroubleshootingContent = () => {
if (!error) return null;
const content = troubleshootingData[error.type];
return (
<div
className="bg-white border p-5 mt-6"
style={{ borderColor: THEME.beigeDark }}
>
<h4 className="text-sm font-bold uppercase tracking-wider text-gray-500 mb-3 flex items-center gap-2">
<span
className="w-2 h-2 rounded-full"
style={{ backgroundColor: THEME.mistralOrange }}
></span>
Troubleshooting
</h4>
<div className="space-y-2">
<p className="font-semibold" style={{ color: THEME.textBlack }}>
{content.title}
</p>
<ul className="space-y-2">
{content.items.map((item, index) => (
<li
key={index}
className="text-sm text-gray-600 flex items-start"
>
<span
className="mr-2 font-mono"
style={{ color: THEME.beigeDark }}
>
/
</span>{" "}
{item}
</li>
))}
</ul>
</div>
{/* Technical Details Footer */}
<div className="mt-4 pt-4 border-t border-gray-100 flex flex-col gap-1">
<p className="text-xs text-gray-400 font-mono uppercase">
Diagnostics
</p>
<div className="bg-gray-50 p-2 rounded text-xs font-mono text-gray-600 break-all border border-gray-100">
LOC: {window.location.protocol}//{window.location.host}
<br />
ERR: {error.type.toUpperCase()}
</div>
</div>
</div>
);
};
const getTitle = () => {
if (isRequesting) return "Initialize Camera";
if (error) return "Connection Failed";
return "Permission Required";
};
const getDescription = () => {
if (isRequesting) return "Requesting access to video input device...";
if (error) return error.message;
return "Ministral WebGPU requires local camera access for real-time inference.";
};
return (
<>
<div
className="fixed inset-0 flex items-center justify-center p-4 z-50"
style={{
backgroundColor: THEME.beigeLight,
backgroundImage: `
linear-gradient(${THEME.beigeDark} 1px, transparent 1px),
linear-gradient(90deg, ${THEME.beigeDark} 1px, transparent 1px)
`,
backgroundSize: "40px 40px",
}}
role="dialog"
aria-labelledby="webcam-dialog-title"
>
<div
className={`max-w-lg w-full backdrop-blur-sm border shadow-2xl transition-all duration-700 ${mounted ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4"}`}
style={{
backgroundColor: `${THEME.beigeLight}F2`,
borderColor: THEME.beigeDark,
}}
>
{/* Header Bar */}
<div
className="h-1 w-full"
style={{ backgroundColor: THEME.mistralOrange }}
></div>
<div className="p-8 md:p-10">
{/* Icon Area */}
<div className="flex justify-center mb-8">
{isRequesting ? (
<div className="relative">
<div
className="absolute inset-0 blur-lg opacity-20 rounded-full animate-pulse"
style={{ backgroundColor: THEME.mistralOrange }}
></div>
<div
className="w-16 h-16 border-4 rounded-full animate-spin"
style={{
borderColor: THEME.beigeDark,
borderTopColor: THEME.mistralOrange,
}}
></div>
</div>
) : (
<div
className={`w-16 h-16 flex items-center justify-center border-2`}
style={{
borderColor: error ? THEME.errorRed : THEME.mistralOrange,
backgroundColor: error
? `${THEME.errorRed}0D`
: `${THEME.mistralOrange}1A`,
}}
>
{error ? (
<svg
className="w-8 h-8"
style={{ color: THEME.errorRed }}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="square"
strokeLinejoin="miter"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
) : (
<svg
className="w-8 h-8"
style={{ color: THEME.mistralOrange }}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="square"
strokeLinejoin="miter"
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
)}
</div>
)}
</div>
{/* Text Content */}
<div className="text-center space-y-4 mb-8">
<h2
id="webcam-dialog-title"
className="text-2xl font-bold tracking-tight"
style={{ color: THEME.textBlack }}
>
{getTitle()}
</h2>
<p className="text-gray-600 leading-relaxed font-light text-lg">
{getDescription()}
</p>
</div>
{/* Error Actions */}
{error && (
<div className="animate-enter">
<div className="flex justify-center mb-6">
<Button
onClick={requestWebcamAccess}
disabled={isRequesting}
className="px-8 py-3 text-white shadow-lg hover:shadow-xl transition-all font-semibold tracking-wide hover:bg-[var(--mistral-orange-dark)]"
>
Try Again
</Button>
</div>
{getTroubleshootingContent()}
</div>
)}
{/* Loading State Helper */}
{isRequesting && (
<p className="text-center text-sm text-gray-400 font-mono animate-pulse">
Waiting for browser permission...
</p>
)}
</div>
</div>
</div>
</>
);
}