برنامه نویسی

ایجاد یک تجربه ردیابی چشم در زمان واقعی با Supabase و WebGazer.js

Summarize this content to 400 words in Persian Lang
TL;DR:

ساخته شده با Supabase، React، WebGazer.js، Motion One، anime.js، صدای پایدار
از Supabase Realtime Presence & Broadcast استفاده می کند (اصلاً از جداول پایگاه داده استفاده نمی شود!)
مخزن GitHub
وب سایت
ویدئوی نمایشی

یکی دیگر از هکاتون های هفته راه اندازی Supabase و یک پروژه آزمایشی دیگر به نام به پرتگاه خیره شوید. این در نهایت یکی از ساده ترین و در عین حال پیچیده ترین پروژه ها بود. خوشبختانه اخیراً کمی از مکان‌نما لذت می‌برم، بنابراین چند دست یاری داشتم تا از پس آن بر بیایم! من همچنین می خواستم یک سوال را در ذهن خود تأیید کنم: آیا امکان استفاده وجود دارد؟ فقط ویژگی های بیدرنگ از Supabase بدون هر جداول پایگاه داده؟ پاسخ (شاید تا حدودی واضح) این است: بله، بله این است (دوستت دارم، تیم Realtime ♥️). پس بیایید کمی عمیق تر در پیاده سازی فرو برویم.

من فقط یک روز به طور تصادفی فقط به نقل قول نیچه در مورد پرتگاه فکر می کردم و اینکه خوب (و جالب) است که آن را به نوعی تجسم کنم: شما به یک صفحه تاریک خیره می شوید و چیزی به شما خیره می شود. هیچ چیز خیلی بیشتر از آن!

در ابتدا این ایده را داشتم که از Three.js برای ساختن این پروژه استفاده کنم، با این حال متوجه شدم که این بدان معناست که باید دارایی های رایگان برای چشم(های) سه بعدی ایجاد یا پیدا کنم. من تصمیم گرفتم که کمی زیاد است، به خصوص که زمان زیادی برای کار روی خود پروژه نداشتم و به جای آن تصمیم گرفتم آن را به صورت دو بعدی با SVG انجام دهم.

همچنین نمی‌خواستم فقط بصری باشد: با برخی صداها نیز تجربه بهتری خواهد بود. بنابراین به این فکر افتادم که اگر شرکت‌کنندگان بتوانند با میکروفون صحبت کنند و دیگران بتوانند آن را به‌عنوان زمزمه‌های نامناسب یا بادهایی که در حال عبور هستند بشنوند، بسیار عالی خواهد بود. با این حال، این بسیار چالش برانگیز شد و تصمیم گرفتم آن را به طور کامل کنار بگذارم زیرا نتوانستم WebAudio و WebRTC را به خوبی به هم متصل کنم. من یک جزء باقیمانده در پایگاه کد دارم که به میکروفون محلی گوش می دهد و اگر می خواهید نگاهی بیندازید، “صدای باد” را برای کاربر فعلی ایجاد می کند. شاید در آینده چیزی اضافه شود؟

اتاق های بیدرنگ

قبل از کار روی هر چیز بصری، می‌خواستم تنظیمات بیدرنگ را که در ذهن داشتم آزمایش کنم. از آنجایی که در ویژگی بلادرنگ محدودیت‌هایی وجود دارد، می‌خواستم این ویژگی کار کند:

حداکثر وجود دارد. 10 شرکت کننده در یک کانال در یک زمان

به این معنی که اگر کانال پر است، باید به یک کانال جدید بپیوندید

شما فقط باید چشم سایر شرکت کنندگان را ببینید

برای این من آمدم با یک useEffect راه اندازی جایی که به صورت بازگشتی به یک کانال بیدرنگ مانند این می پیوندد:

وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

این joinRoom در داخل زندگی می کند useEffect قلاب و هنگامی که جزء اتاق نصب شده باشد فراخوانی می شود. یکی از نکاتی که هنگام کار بر روی این ویژگی متوجه شدم این بود که currentPresences param هیچ مقداری در آن ندارد join رویداد حتی اگر در دسترس باشد. من مطمئن نیستم که این یک اشکال در پیاده سازی است یا همانطور که در نظر گرفته شده کار می کند. از این رو نیاز به انجام یک کتابچه راهنمای کاربر room.presenceState واکشی کنید تا هر زمان که کاربر می‌پیوندد، تعداد شرکت‌کنندگان در اتاق را دریافت کنید.

تعداد شرکت‌کنندگان را بررسی می‌کنیم و یا اشتراک اتاق فعلی را لغو می‌کنیم و سعی می‌کنیم به اتاق دیگری بپیوندیم، یا سپس به اتاق فعلی ادامه می‌دهیم. ما این کار را در join رویداد به عنوان sync خیلی دیر می شود (بعد از آن شروع می شود join یا leave رویدادها).

من این پیاده سازی را با باز کردن تعداد زیادی تب در مرورگرم آزمایش کردم و به نظر می رسید که همه متورم شده اند!

بعد از آن می خواستم راه حل را با به روز رسانی موقعیت ماوس اشکال زدایی کنم و به سرعت با مشکلات ارسال پیام های زیاد در کانال مواجه شدم! راه حل: تماس ها را کاهش دهید.

/**
* Creates a throttled version of a function that can only be called at most once
* in the specified time period.
*/
function createThrottledFunction<T extends (…args: unknown[]) => unknown>(
functionToThrottle: T,
waitTimeMs: number
): (…args: Parameters<T>) => void {
let isWaitingToExecute = false

return function throttledFunction(…args: Parameters<T>) {
if (!isWaitingToExecute) {
functionToThrottle.apply(this, args)
isWaitingToExecute = true
setTimeout(() => {
isWaitingToExecute = false
}, waitTimeMs)
}
}
}

وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

مکان نما با این سازنده عملکرد کوچک دریچه گاز آمد و من از آن با پخش های ردیابی چشم مانند زیر استفاده کردم:

const throttledBroadcast = createThrottledFunction((data: EyeTrackingData) => {
if (currentChannel) {
currentChannel.send({
type: ‘broadcast’,
event: ‘eye_tracking’,
payload: data
})
}
}, THROTTLE_MS)

throttledBroadcast({
userId: userId.current,
isBlinking: isCurrentlyBlinking,
gazeX,
gazeY
})

وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

این خیلی کمک کرد! همچنین، در نسخه های اولیه، پیام های ردیابی چشم را داشتم presence با این حال broadcast به پیام‌های بیشتری در ثانیه اجازه می‌دهد، بنابراین به جای آن پیاده‌سازی را به آن تغییر دادم. این به ویژه در ردیابی چشم بسیار مهم است زیرا دوربین همیشه همه چیز را ضبط می کند.

ردیابی چشم

زمانی که برای اولین بار ایده این پروژه را داشتم، با WebGazer.js برخورد کردم. این یک پروژه بسیار جالب است و به طرز شگفت انگیزی خوب کار می کند!

کل قابلیت های ردیابی چشم در یک عملکرد در یک انجام می شود useEffect قلاب:

window.webgazer
.setGazeListener(async (data: any) => {
if (data == null || !currentChannel || !ctxRef.current) return

try {
// Get normalized gaze coordinates
const gazeX = data.x / windowSize.width
const gazeY = data.y / windowSize.height

// Get video element
const videoElement = document.getElementById(‘webgazerVideoFeed’) as HTMLVideoElement
if (!videoElement) {
console.error(‘WebGazer video element not found’)
return
}

// Set canvas size to match video
imageCanvasRef.current.width = videoElement.videoWidth
imageCanvasRef.current.height = videoElement.videoHeight

// Draw current frame to canvas
ctxRef.current?.drawImage(videoElement, 0, 0)

// Get eye patches
const tracker = window.webgazer.getTracker()
const patches = await tracker.getEyePatches(
videoElement,
imageCanvasRef.current,
videoElement.videoWidth,
videoElement.videoHeight
)

if (!patches?.right?.patch?.data || !patches?.left?.patch?.data) {
console.error(‘No eye patches detected’)
return
}

// Calculate brightness for each eye
const calculateBrightness = (imageData: ImageData) => {
let total = 0

for (let i = 0; i < imageData.data.length; i += 16) {
// Convert RGB to grayscale
const r = imageData.data[i] const g = imageData.data[i + 1] const b = imageData.data[i + 2] total += (r + g + b) / 3
}
return total / (imageData.width * imageData.height / 4)
}

const rightEyeBrightness = calculateBrightness(patches.right.patch)
const leftEyeBrightness = calculateBrightness(patches.left.patch)
const avgBrightness = (rightEyeBrightness + leftEyeBrightness) / 2

// Update rolling average
if (brightnessSamples.current.length >= SAMPLES_SIZE) {
brightnessSamples.current.shift() // Remove oldest sample
}
brightnessSamples.current.push(avgBrightness)

// Calculate dynamic threshold from rolling average
const rollingAverage = brightnessSamples.current.reduce((a, b) => a + b, 0) / brightnessSamples.current.length
const dynamicThreshold = rollingAverage * THRESHOLD_MULTIPLIER
// Detect blink using dynamic threshold
const blinkDetected = avgBrightness > dynamicThreshold

// Debounce blink detection to avoid rapid changes
if (blinkDetected !== isCurrentlyBlinking) {
const now = Date.now()
if (now – lastBlinkTime > 100) { // Minimum time between blink state changes
isCurrentlyBlinking = blinkDetected
lastBlinkTime = now
}
}

// Use throttled broadcast instead of direct send
throttledBroadcast({
userId: userId.current,
isBlinking: isCurrentlyBlinking,
gazeX,
gazeY
})

} catch (error) {
console.error(‘Error processing gaze data:’, error)
}
})

وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

دریافت اطلاعات از جایی که کاربر به آن نگاه می کند ساده است و مانند گرفتن موقعیت های ماوس روی صفحه عمل می کند. با این حال، من همچنین می‌خواستم تشخیص پلک زدن را به عنوان (یک ویژگی جالب) اضافه کنم، که نیاز به پرش از میان حلقه‌ها داشت.

هنگامی که اطلاعات مربوط به WebGazer را در گوگل جستجو می کنید و تشخیص چشمک می زنید، می توانید برخی از بازمانده های اجرای اولیه را مشاهده کنید. مثل اینکه کدهای نظر داده شده در منبع حتی وجود دارد. متأسفانه این نوع قابلیت ها در کتابخانه خارج نمی شوند. شما باید این کار را به صورت دستی انجام دهید.

پس از آزمون و خطای فراوان، من و مکان‌نما توانستیم راه‌حلی ارائه کنیم که پیکسل‌ها و سطوح روشنایی را از داده‌های پچ چشم محاسبه می‌کند تا مشخص شود کاربر چه زمانی چشمک می‌زند. همچنین دارای برخی تنظیمات نور پویا است زیرا متوجه شدم که (حداقل برای من) وب کم بسته به نور شما همیشه تشخیص نمی دهد که شما چه زمانی چشمک می زنید. برای من، هر چه تصویر/اتاق من روشن‌تر باشد، بدتر کار می‌کند و در نور تاریک‌تر بهتر عمل می‌کند.

هنگام اشکال زدایی قابلیت های ردیابی چشم (WebGazer دارای ویژگی های بسیار خوبی است .setPredictionPoints تماسی که یک نقطه قرمز را روی صفحه نمایش می دهد تا جایی که شما به آن نگاه می کنید تجسم کنید)، متوجه شدم که ردیابی خیلی دقیق نیست. مگر اینکه آن را کالیبره کنید کاری که پروژه از شما می خواهد قبل از پیوستن به هر اتاقی انجام دهید.

const startCalibration = useCallback(() => {
const points: CalibrationPoint[] = [
{ x: 0.1, y: 0.1 },
{ x: 0.9, y: 0.1 },
{ x: 0.5, y: 0.5 },
{ x: 0.1, y: 0.9 },
{ x: 0.9, y: 0.9 },
] setCalibrationPoints(points)
setCurrentPoint(0)
setIsCalibrating(true)

window.webgazer.clearData()
}, [])

const handleCalibrationClick = useCallback((event: React.MouseEvent) => {
if (!isCalibrating) return

// Record click location for calibration
const x = event.clientX
const y = event.clientY
window.webgazer.recordScreenPosition(x, y, ‘click’)

if (currentPoint < calibrationPoints.length – 1) {
setCurrentPoint(prev => prev + 1)
} else {
setIsCalibrating(false)
setHasCalibrated(true)
}
}, [isCalibrating, currentPoint, calibrationPoints.length])

وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

{calibrationPoints.map((point, index) => (

))}

Click the red dot to calibrate ({currentPoint + 1}/{calibrationPoints.length})

وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

اساساً ما 5 نقطه را روی صفحه نمایش می‌دهیم: یکی در هر گوشه و دیگری در مرکز. با کلیک کردن روی آنها موقعیت صفحه در WebGazer ثبت می شود تا بتواند مدل را کمی بهتر تنظیم کند تا جایی که به دنبال آن هستید را پیش بینی کند. ممکن است تعجب کنید که این کلیک در واقع چه کاری انجام می دهد و قسمت خنده دار این است که شما در واقع به جایی که کلیک می کنید نگاه می کنید، درست است؟ و با انجام این کار، WebGazer می تواند حرکات چشم شما را کمی بهتر پردازش کند و نتایج دقیق تری ارائه دهد. خیلی باحاله

چشم

من قبلاً یک پیاده سازی SVG ساده برای چشم اضافه کرده بودم و آن را به ردیابی متصل کرده بودم، با این حال باید کمی بیشتر تلطیف شود. در زیر کمی نحوه ظاهر آن را مشاهده می کنید. الهام بخش Alucard Eyes توسط MIKELopez بود.

این یک نسخه قبلی از چشم است، اما تقریباً 95٪ در آنجا وجود دارد. من این ویدیو را برای زوج دوستانم فرستادم و آنها فکر کردند بسیار جالب است، به خصوص وقتی می دانستند که در واقع حرکات چشم شما را دنبال می کند! همچنین می‌توانید نقطه پیش‌بینی WebGazer را در حال حرکت روی صفحه مشاهده کنید.

کامپوننت چشم خود یک SVG با تعدادی انیمیشن مسیر از طریق Motion است.

<svg
className={`w-full h-full self-${alignment} max-w-[350px] max-h-[235px]`}
viewBox=”-50 0 350 235″
preserveAspectRatio=”xMidYMid meet”
>
{/* Definitions for gradients and filters */}
<defs>
<filter id=”pupil-blur”>
<feGaussianBlur stdDeviation=”0.75″ />
filter>
<radialGradient id=”eyeball-gradient”>
<stop offset=”60%” stopColor=”#dcdae0″ />
<stop offset=”100%” stopColor=”#a8a7ad” />
radialGradient>
<radialGradient
id=”pupil-gradient”
cx=”0.35″
cy=”0.35″
r=”0.65″
>
<stop offset=”0%” stopColor=”#444″ />
<stop offset=”75%” stopColor=”#000″ />
<stop offset=”100%” stopColor=”#000″ />
radialGradient>
<radialGradient
id=”corner-gradient-left”
cx=”0.3″
cy=”0.5″
r=”0.25″
gradientUnits=”objectBoundingBox”
>
<stop offset=”0%” stopColor=”rgba(0,0,0,0.75)” />
<stop offset=”100%” stopColor=”rgba(0,0,0,0)” />
radialGradient>

<radialGradient
id=”corner-gradient-right”
cx=”0.7″
cy=”0.5″
r=”0.25″
gradientUnits=”objectBoundingBox”
>
<stop offset=”0%” stopColor=”rgba(0,0,0,0.75)” />
<stop offset=”100%” stopColor=”rgba(0,0,0,0)” />
radialGradient>

<filter id=”filter0_f_302_14″ x=”-25″ y=”0″ width=”320″ height=”150″ filterUnits=”userSpaceOnUse” colorInterpolationFilters=”sRGB”>
<feGaussianBlur stdDeviation=”4.1″ result=”effect1_foregroundBlur_302_14″/>
filter>
<filter id=”filter1_f_302_14″ x=”-25″ y=”85″ width=”320″ height=”150″ filterUnits=”userSpaceOnUse” colorInterpolationFilters=”sRGB”>
<feGaussianBlur stdDeviation=”4.1″ result=”effect1_foregroundBlur_302_14″/>
filter>
<filter id=”filter2_f_302_14″ x=”-50″ y=”-30″ width=”400″ height=”170″ filterUnits=”userSpaceOnUse” colorInterpolationFilters=”sRGB”>
<feGaussianBlur stdDeviation=”7.6″ result=”effect1_foregroundBlur_302_14″/>
filter>
<filter id=”filter3_f_302_14″ x=”-50″ y=”95″ width=”400″ height=”170″ filterUnits=”userSpaceOnUse” colorInterpolationFilters=”sRGB”>
<feGaussianBlur stdDeviation=”7.6″ result=”effect1_foregroundBlur_302_14″/>
filter>
<filter id=”filter4_f_302_14″ x=”0″ y=”-20″ width=”260″ height=”150″ filterUnits=”userSpaceOnUse” colorInterpolationFilters=”sRGB”>
<feGaussianBlur stdDeviation=”3.35″ result=”effect1_foregroundBlur_302_14″/>
filter>
<filter id=”filter5_f_302_14″ x=”0″ y=”105″ width=”260″ height=”150″ filterUnits=”userSpaceOnUse” colorInterpolationFilters=”sRGB”>
<feGaussianBlur stdDeviation=”3.35″ result=”effect1_foregroundBlur_302_14″/>
filter>
defs>

{/* Eyeball */}
<ellipse
cx=”131″
cy=”117.5″
rx=”100″
ry=”65″
fill=”url(#eyeball-gradient)”
/>

{/* After the main eyeball ellipse but before the eyelids, add the corner shadows */}
<ellipse
cx=”50″
cy=”117.5″
rx=”50″
ry=”90″
fill=”url(#corner-gradient-left)”
/>

<ellipse
cx=”205″
cy=”117.5″
rx=”50″
ry=”90″
fill=”url(#corner-gradient-right)”
/>

{/* Corner reflections – repositioned diagonally */}
<circle
cx={45}
cy={135}
r=”1.5″
fill=”white”
className=”opacity-60″
/>
<circle
cx={215}
cy={100}
r=”2″
fill=”white”
className=”opacity-60″
/>

{/* Smaller companion reflections – repositioned diagonally */}
<circle
cx={35}
cy={120}
r=”1″
fill=”white”
className=”opacity-40″
/>
<circle
cx={222}
cy={110}
r=”1.5″
fill=”white”
className=”opacity-40″
/>

{/* Pupil group with animations */}
<motion.g
variants={pupilVariants}
animate={isBlinking ? “hidden” : “visible”}
>
{/* Pupil */}
<motion.ellipse
cx={131}
cy={117.5}
rx=”50″
ry=”50″
fill=”url(#pupil-gradient)”
filter=”url(#pupil-blur)”
animate={{
cx: 131 + pupilOffsetX,
cy: 117.5 + pupilOffsetY
}}
transition={{
type: “spring”,
stiffness: 400,
damping: 30
}}
/>

{/* Light reflections */}
<motion.circle
cx={111}
cy={102.5}
r=”5″
fill=”white”
animate={{
cx: 111 + pupilOffsetX,
cy: 102.5 + pupilOffsetY
}}
transition={{
type: “spring”,
stiffness: 400,
damping: 30
}}
/>
<motion.circle
cx={124}
cy={102.5}
r=”3″
fill=”white”
animate={{
cx: 124 + pupilOffsetX,
cy: 102.5 + pupilOffsetY
}}
transition={{
type: “spring”,
stiffness: 400,
damping: 30
}}
/>
motion.g>

{/* Upper eyelid */}
<motion.path
custom={true}
variants={eyelidVariants}
animate={isBlinking ? “closed” : “open”}
fill=”#000″
/>

{/* Lower eyelid */}
<motion.path
custom={false}
variants={eyelidVariants}
animate={isBlinking ? “closed” : “open”}
fill=”#000″
/>

{/* Top blurred lines */}
<g filter=”url(#filter0_f_302_14)”>
<motion.path
custom={true}
variants={blurredLineVariants}
animate={isBlinking ? “closed” : “open”}
stroke=”#2A2A2A”
strokeWidth=”5″
strokeLinecap=”round”
/>
g>
<g filter=”url(#filter2_f_302_14)”>
<motion.path
custom={true}
variants={outerBlurredLineVariants}
animate={isBlinking ? “closed” : “open”}
stroke=”#777777″
strokeWidth=”5″
strokeLinecap=”round”
/>
g>
<g filter=”url(#filter4_f_302_14)”>
<motion.path
custom={true}
variants={arcLineVariants}
animate={isBlinking ? “closed” : “open”}
stroke=”#838383″
strokeWidth=”5″
strokeLinecap=”round”
/>
g>

{/* Bottom blurred lines */}
<g filter=”url(#filter1_f_302_14)”>
<motion.path
variants={bottomBlurredLineVariants}
animate={isBlinking ? “closed” : “open”}
stroke=”#2A2A2A”
strokeWidth=”5″
strokeLinecap=”round”
/>
g>
<g filter=”url(#filter3_f_302_14)”>
<motion.path
variants={bottomOuterBlurredLineVariants}
animate={isBlinking ? “closed” : “open”}
stroke=”#777777″
strokeWidth=”5″
strokeLinecap=”round”
/>
g>
<g filter=”url(#filter5_f_302_14)”>
<motion.path
variants={bottomArcLineVariants}
animate={isBlinking ? “closed” : “open”}
stroke=”#838383″
strokeWidth=”5″
strokeLinecap=”round”
/>
g>
svg>

وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

مکان نما به طرز شگفت انگیزی با مسیرهای SVG کار می کند. به عنوان مثال، انیمیشن بستن پلک اساساً با صاف کردن یک مسیر منحنی انجام می شود. من به تازگی مسیر را در ویرایشگر هایلایت کرده بودم، آن را در Composer چسبانده بودم و درخواست کردم انیمیشنی اضافه کنم که نقاط را صاف کند تا چشم در حال بسته شدن/پلک زدن به نظر برسد.

// Define the open and closed states for both eyelids
const upperLidOpen = “M128.5 53.5C59.3 55.5 33 99.6667 28.5 121.5H0V0L261.5 0V121.5H227.5C214.7 65.1 156.167 52.6667 128.5 53.5Z”
const upperLidClosed = “M128.5 117.5C59.3 117.5 33 117.5 28.5 117.5H0V0L261.5 0V117.5H227.5C214.7 117.5 156.167 117.5 128.5 117.5Z”

const lowerLidOpen = “M128.5 181C59.3 179 33 134.833 28.5 113H0V234.5H261.5V113H227.5C214.7 169.4 156.167 181.833 128.5 181Z”
const lowerLidClosed = “M128.5 117.5C59.3 117.5 33 117.5 28.5 117.5H0V234.5H261.5V117.5H227.5C214.7 117.5 156.167 117.5 128.5 117.5Z”

// Animation variants for the eyelids
const eyelidVariants = {
open: (isUpper: boolean) => ({
d: isUpper ? upperLidOpen : lowerLidOpen,
transition: {
duration: 0.4,
ease: “easeOut”
}
}),
closed: (isUpper: boolean) => ({
d: isUpper ? upperLidClosed : lowerLidClosed,
transition: {
duration: 0.15,
ease: “easeIn”
}
})
}

وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

دیدن این در عمل تجربه بسیار جالبی بود! من همین رویکرد را برای خطوط اطراف اعمال کردم و به مکان‌نما دستور دادم که آنها را به سمت مرکز “جمع کند”: که تقریباً با یک حرکت انجام شد!

سپس چشم‌ها در یک شبکه CSS ساده با سلول‌های تراز شده به‌گونه‌ای نمایش داده می‌شوند که یک اتاق کامل شبیه یک چشم بزرگ به نظر برسد.

<div className=”fixed inset-0 grid grid-cols-3 grid-rows-3 gap-4 p-8 md:gap-2 md:p-4 lg:max-w-6xl lg:mx-auto”>
{Object.entries(roomState.participants).map(([key, presences]) => {
const participant = presences[0] const eyeData = eyeTrackingState[key] if (key === userId.current) return null

return (
<div
key={key}
className={`flex items-center justify-center ${getGridClass(participant.position)}`}
>
<Eyes
isBlinking={eyeData?.isBlinking ?? false}
gazeX={eyeData?.gazeX ?? 0.5}
gazeY={eyeData?.gazeY ?? 0.5}
alignment={getEyeAlignment(participant.position)}
/>
</div>
)
})}
</div>

// Helper function to convert position to Tailwind grid classes
function getGridClass(position: string): string {
switch (position) {
case ‘center’: return ‘col-start-2 row-start-2’
case ‘middleLeft’: return ‘col-start-1 row-start-2’
case ‘middleRight’: return ‘col-start-3 row-start-2’
case ‘topCenter’: return ‘col-start-2 row-start-1’
case ‘bottomCenter’: return ‘col-start-2 row-start-3’
case ‘topLeft’: return ‘col-start-1 row-start-1’
case ‘topRight’: return ‘col-start-3 row-start-1’
case ‘bottomLeft’: return ‘col-start-1 row-start-3’
case ‘bottomRight’: return ‘col-start-3 row-start-3’
default: return ‘col-start-2 row-start-2’
}
}

function getEyeAlignment(position: string): ‘start’ | ‘center’ | ‘end’ {
switch (position) {
case ‘topLeft’:
case ‘topRight’:
return ‘end’
case ‘bottomLeft’:
case ‘bottomRight’:
return ‘start’
default:
return ‘center’
}
}

وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

لمس های نهایی

سپس چند صفحه نمایش مقدماتی زیبا و موسیقی پس‌زمینه بزنید و پروژه خوب است!

هنگامی که شما روی چیزهایی مانند این کار می کنید، صدا همیشه تجربه را بهبود می بخشد، بنابراین من از Stable Audio برای تولید موسیقی پس زمینه زمانی که کاربر “وارد پرتگاه” می شود استفاده کردم. درخواستی که برای موسیقی استفاده کردم این بود:

محیط، خزنده، موسیقی پس‌زمینه، صداهای زمزمه‌کننده، بادها، سرعت آهسته، وهم‌آور، پرتگاه

من همچنین فکر می کردم که فقط صفحه سیاه و سفید ساده کمی خسته کننده است، بنابراین چند فیلتر SVG متحرک را در پس زمینه اضافه کردم. علاوه بر این، یک دایره تیره و تار در مرکز صفحه اضافه کردم تا جلوه محو شدن خوبی داشته باشد. احتمالاً می‌توانستم این کار را با فیلترهای SVG انجام دهم، اما نمی‌خواستم زمان زیادی را برای این کار صرف کنم. سپس برای داشتن مقداری حرکت بیشتر، پس‌زمینه را روی محور خود می‌چرخانم. گاهی اوقات انجام انیمیشن با فیلترهای SVG کمی بد است، بنابراین تصمیم گرفتم به جای آن این کار را انجام دهم.

<div style={{ width: ‘100vw’, height: ‘100vh’ }}>
{/* Background Elements */}
<svg className=”fixed inset-0 w-full h-full -z-10″>
<defs>
<filter id=”noise”>
<feTurbulence
id=”turbFreq”
type=”fractalNoise”
baseFrequency=”0.01″
seed=”5″
numOctaves=”1″
>
</feTurbulence>
<feGaussianBlur stdDeviation=”10″>
<animate
attributeName=”stdDeviation”
values=”10;50;10″
dur=”20s”
repeatCount=”indefinite”
/>
</feGaussianBlur>
<feColorMatrix
type=”matrix”
values=”1 0 0 0 1
0 1 0 0 1
0 0 1 0 1
0 0 0 25 -13″
/>
</filter>
</defs>
<rect width=”200%” height=”200%” filter=”url(#noise)” className=”rotation-animation” />
</svg>
<div className=”fixed inset-0 w-[95vw] h-[95vh] bg-black rounded-full blur-[128px] m-auto” />

وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

بنابراین شما آن را دارید: نسبتاً مستقیم به نحوه اجرای یک ردیابی چشم سبک با قابلیت های بلادرنگ Supabase نگاه کنید. شخصاً این آزمایش بسیار جالبی بود و در حین کار بر روی آن مشکل زیادی نداشتم. و با کمال تعجب من مجبور نبودم شب آخر قبل از ارسال پروژه یک شب کامل انجام دهم!

به راحتی می توانید پروژه یا ویدئوی نمایشی را بررسی کنید که چگونه به نتیجه رسیده است. اگر تعداد زیادی از افراد همزمان از آن استفاده کنند ممکن است مشکلاتی وجود داشته باشد (آزمایش آن بسیار سخت است زیرا برای انجام درست آن به چندین دستگاه و وب کم نیاز دارد)، اما حدس می‌زنم که این در مد پروژه‌های هکاتون باشد؟ و اگر آن را آزمایش کردید، به یاد داشته باشید که اگر یک چشم دیدید، آن شخص دیگری است که شما را در جایی از طریق اینترنت تماشا می کند!

TL;DR:

  • ساخته شده با Supabase، React، WebGazer.js، Motion One، anime.js، صدای پایدار
  • از Supabase Realtime Presence & Broadcast استفاده می کند (اصلاً از جداول پایگاه داده استفاده نمی شود!)
  • مخزن GitHub
  • وب سایت
  • ویدئوی نمایشی

یکی دیگر از هکاتون های هفته راه اندازی Supabase و یک پروژه آزمایشی دیگر به نام به پرتگاه خیره شوید. این در نهایت یکی از ساده ترین و در عین حال پیچیده ترین پروژه ها بود. خوشبختانه اخیراً کمی از مکان‌نما لذت می‌برم، بنابراین چند دست یاری داشتم تا از پس آن بر بیایم! من همچنین می خواستم یک سوال را در ذهن خود تأیید کنم: آیا امکان استفاده وجود دارد؟ فقط ویژگی های بیدرنگ از Supabase بدون هر جداول پایگاه داده؟ پاسخ (شاید تا حدودی واضح) این است: بله، بله این است (دوستت دارم، تیم Realtime ♥️). پس بیایید کمی عمیق تر در پیاده سازی فرو برویم.

من فقط یک روز به طور تصادفی فقط به نقل قول نیچه در مورد پرتگاه فکر می کردم و اینکه خوب (و جالب) است که آن را به نوعی تجسم کنم: شما به یک صفحه تاریک خیره می شوید و چیزی به شما خیره می شود. هیچ چیز خیلی بیشتر از آن!

در ابتدا این ایده را داشتم که از Three.js برای ساختن این پروژه استفاده کنم، با این حال متوجه شدم که این بدان معناست که باید دارایی های رایگان برای چشم(های) سه بعدی ایجاد یا پیدا کنم. من تصمیم گرفتم که کمی زیاد است، به خصوص که زمان زیادی برای کار روی خود پروژه نداشتم و به جای آن تصمیم گرفتم آن را به صورت دو بعدی با SVG انجام دهم.

همچنین نمی‌خواستم فقط بصری باشد: با برخی صداها نیز تجربه بهتری خواهد بود. بنابراین به این فکر افتادم که اگر شرکت‌کنندگان بتوانند با میکروفون صحبت کنند و دیگران بتوانند آن را به‌عنوان زمزمه‌های نامناسب یا بادهایی که در حال عبور هستند بشنوند، بسیار عالی خواهد بود. با این حال، این بسیار چالش برانگیز شد و تصمیم گرفتم آن را به طور کامل کنار بگذارم زیرا نتوانستم WebAudio و WebRTC را به خوبی به هم متصل کنم. من یک جزء باقیمانده در پایگاه کد دارم که به میکروفون محلی گوش می دهد و اگر می خواهید نگاهی بیندازید، “صدای باد” را برای کاربر فعلی ایجاد می کند. شاید در آینده چیزی اضافه شود؟

اتاق های بیدرنگ

قبل از کار روی هر چیز بصری، می‌خواستم تنظیمات بیدرنگ را که در ذهن داشتم آزمایش کنم. از آنجایی که در ویژگی بلادرنگ محدودیت‌هایی وجود دارد، می‌خواستم این ویژگی کار کند:

  • حداکثر وجود دارد. 10 شرکت کننده در یک کانال در یک زمان
    • به این معنی که اگر کانال پر است، باید به یک کانال جدید بپیوندید
  • شما فقط باید چشم سایر شرکت کنندگان را ببینید

برای این من آمدم با یک useEffect راه اندازی جایی که به صورت بازگشتی به یک کانال بیدرنگ مانند این می پیوندد:


وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

این joinRoom در داخل زندگی می کند useEffect قلاب و هنگامی که جزء اتاق نصب شده باشد فراخوانی می شود. یکی از نکاتی که هنگام کار بر روی این ویژگی متوجه شدم این بود که currentPresences param هیچ مقداری در آن ندارد join رویداد حتی اگر در دسترس باشد. من مطمئن نیستم که این یک اشکال در پیاده سازی است یا همانطور که در نظر گرفته شده کار می کند. از این رو نیاز به انجام یک کتابچه راهنمای کاربر room.presenceState واکشی کنید تا هر زمان که کاربر می‌پیوندد، تعداد شرکت‌کنندگان در اتاق را دریافت کنید.

تعداد شرکت‌کنندگان را بررسی می‌کنیم و یا اشتراک اتاق فعلی را لغو می‌کنیم و سعی می‌کنیم به اتاق دیگری بپیوندیم، یا سپس به اتاق فعلی ادامه می‌دهیم. ما این کار را در join رویداد به عنوان sync خیلی دیر می شود (بعد از آن شروع می شود join یا leave رویدادها).

من این پیاده سازی را با باز کردن تعداد زیادی تب در مرورگرم آزمایش کردم و به نظر می رسید که همه متورم شده اند!

بعد از آن می خواستم راه حل را با به روز رسانی موقعیت ماوس اشکال زدایی کنم و به سرعت با مشکلات ارسال پیام های زیاد در کانال مواجه شدم! راه حل: تماس ها را کاهش دهید.

/**
 * Creates a throttled version of a function that can only be called at most once 
 * in the specified time period.
 */
function createThrottledFunction<T extends (...args: unknown[]) => unknown>(
  functionToThrottle: T,
  waitTimeMs: number
): (...args: Parameters<T>) => void {
  let isWaitingToExecute = false

  return function throttledFunction(...args: Parameters<T>) {
    if (!isWaitingToExecute) {
      functionToThrottle.apply(this, args)
      isWaitingToExecute = true
      setTimeout(() => {
        isWaitingToExecute = false
      }, waitTimeMs)
    }
  }
}

وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

مکان نما با این سازنده عملکرد کوچک دریچه گاز آمد و من از آن با پخش های ردیابی چشم مانند زیر استفاده کردم:


const throttledBroadcast = createThrottledFunction((data: EyeTrackingData) => {
  if (currentChannel) {
    currentChannel.send({
      type: 'broadcast',
      event: 'eye_tracking',
      payload: data
    })
  }
}, THROTTLE_MS)

throttledBroadcast({
 userId: userId.current,
 isBlinking: isCurrentlyBlinking,
 gazeX,
 gazeY
})
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

این خیلی کمک کرد! همچنین، در نسخه های اولیه، پیام های ردیابی چشم را داشتم presence با این حال broadcast به پیام‌های بیشتری در ثانیه اجازه می‌دهد، بنابراین به جای آن پیاده‌سازی را به آن تغییر دادم. این به ویژه در ردیابی چشم بسیار مهم است زیرا دوربین همیشه همه چیز را ضبط می کند.

ردیابی چشم

زمانی که برای اولین بار ایده این پروژه را داشتم، با WebGazer.js برخورد کردم. این یک پروژه بسیار جالب است و به طرز شگفت انگیزی خوب کار می کند!

کل قابلیت های ردیابی چشم در یک عملکرد در یک انجام می شود useEffect قلاب:

    window.webgazer
      .setGazeListener(async (data: any) => {
        if (data == null || !currentChannel || !ctxRef.current) return

        try {
          // Get normalized gaze coordinates
          const gazeX = data.x / windowSize.width
          const gazeY = data.y / windowSize.height

          // Get video element
          const videoElement = document.getElementById('webgazerVideoFeed') as HTMLVideoElement
          if (!videoElement) {
            console.error('WebGazer video element not found')
            return
          }

          // Set canvas size to match video
          imageCanvasRef.current.width = videoElement.videoWidth
          imageCanvasRef.current.height = videoElement.videoHeight

          // Draw current frame to canvas
          ctxRef.current?.drawImage(videoElement, 0, 0)

          // Get eye patches
          const tracker = window.webgazer.getTracker()
          const patches = await tracker.getEyePatches(
            videoElement,
            imageCanvasRef.current,
            videoElement.videoWidth,
            videoElement.videoHeight
          )

          if (!patches?.right?.patch?.data || !patches?.left?.patch?.data) {
            console.error('No eye patches detected')
            return
          }

          // Calculate brightness for each eye
          const calculateBrightness = (imageData: ImageData) => {
            let total = 0

            for (let i = 0; i < imageData.data.length; i += 16) {
              // Convert RGB to grayscale
              const r = imageData.data[i]
              const g = imageData.data[i + 1]
              const b = imageData.data[i + 2]
              total += (r + g + b) / 3
            }
            return total / (imageData.width * imageData.height / 4)
          }

          const rightEyeBrightness = calculateBrightness(patches.right.patch)
          const leftEyeBrightness = calculateBrightness(patches.left.patch)
          const avgBrightness = (rightEyeBrightness + leftEyeBrightness) / 2

          // Update rolling average
          if (brightnessSamples.current.length >= SAMPLES_SIZE) {
            brightnessSamples.current.shift() // Remove oldest sample
          }
          brightnessSamples.current.push(avgBrightness)

          // Calculate dynamic threshold from rolling average
          const rollingAverage = brightnessSamples.current.reduce((a, b) => a + b, 0) / brightnessSamples.current.length
          const dynamicThreshold = rollingAverage * THRESHOLD_MULTIPLIER
          // Detect blink using dynamic threshold
          const blinkDetected = avgBrightness > dynamicThreshold

          // Debounce blink detection to avoid rapid changes
          if (blinkDetected !== isCurrentlyBlinking) {
            const now = Date.now()
            if (now - lastBlinkTime > 100) { // Minimum time between blink state changes
              isCurrentlyBlinking = blinkDetected
              lastBlinkTime = now
            }
          }

          // Use throttled broadcast instead of direct send
          throttledBroadcast({
            userId: userId.current,
            isBlinking: isCurrentlyBlinking,
            gazeX,
            gazeY
          })

        } catch (error) {
          console.error('Error processing gaze data:', error)
        }
      })
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

دریافت اطلاعات از جایی که کاربر به آن نگاه می کند ساده است و مانند گرفتن موقعیت های ماوس روی صفحه عمل می کند. با این حال، من همچنین می‌خواستم تشخیص پلک زدن را به عنوان (یک ویژگی جالب) اضافه کنم، که نیاز به پرش از میان حلقه‌ها داشت.

هنگامی که اطلاعات مربوط به WebGazer را در گوگل جستجو می کنید و تشخیص چشمک می زنید، می توانید برخی از بازمانده های اجرای اولیه را مشاهده کنید. مثل اینکه کدهای نظر داده شده در منبع حتی وجود دارد. متأسفانه این نوع قابلیت ها در کتابخانه خارج نمی شوند. شما باید این کار را به صورت دستی انجام دهید.

پس از آزمون و خطای فراوان، من و مکان‌نما توانستیم راه‌حلی ارائه کنیم که پیکسل‌ها و سطوح روشنایی را از داده‌های پچ چشم محاسبه می‌کند تا مشخص شود کاربر چه زمانی چشمک می‌زند. همچنین دارای برخی تنظیمات نور پویا است زیرا متوجه شدم که (حداقل برای من) وب کم بسته به نور شما همیشه تشخیص نمی دهد که شما چه زمانی چشمک می زنید. برای من، هر چه تصویر/اتاق من روشن‌تر باشد، بدتر کار می‌کند و در نور تاریک‌تر بهتر عمل می‌کند.

هنگام اشکال زدایی قابلیت های ردیابی چشم (WebGazer دارای ویژگی های بسیار خوبی است .setPredictionPoints تماسی که یک نقطه قرمز را روی صفحه نمایش می دهد تا جایی که شما به آن نگاه می کنید تجسم کنید)، متوجه شدم که ردیابی خیلی دقیق نیست. مگر اینکه آن را کالیبره کنید کاری که پروژه از شما می خواهد قبل از پیوستن به هر اتاقی انجام دهید.

const startCalibration = useCallback(() => {
    const points: CalibrationPoint[] = [
      { x: 0.1, y: 0.1 },
      { x: 0.9, y: 0.1 },
      { x: 0.5, y: 0.5 },
      { x: 0.1, y: 0.9 },
      { x: 0.9, y: 0.9 },
    ]
    setCalibrationPoints(points)
    setCurrentPoint(0)
    setIsCalibrating(true)

    window.webgazer.clearData()
  }, [])

  const handleCalibrationClick = useCallback((event: React.MouseEvent) => {
    if (!isCalibrating) return

    // Record click location for calibration
    const x = event.clientX
    const y = event.clientY
    window.webgazer.recordScreenPosition(x, y, 'click')

    if (currentPoint < calibrationPoints.length - 1) {
      setCurrentPoint(prev => prev + 1)
    } else {
      setIsCalibrating(false)
      setHasCalibrated(true)
    }
  }, [isCalibrating, currentPoint, calibrationPoints.length])
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

          
{calibrationPoints.map((point, index) => ( ))}

Click the red dot to calibrate ({currentPoint + 1}/{calibrationPoints.length})

وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

اساساً ما 5 نقطه را روی صفحه نمایش می‌دهیم: یکی در هر گوشه و دیگری در مرکز. با کلیک کردن روی آنها موقعیت صفحه در WebGazer ثبت می شود تا بتواند مدل را کمی بهتر تنظیم کند تا جایی که به دنبال آن هستید را پیش بینی کند. ممکن است تعجب کنید که این کلیک در واقع چه کاری انجام می دهد و قسمت خنده دار این است که شما در واقع به جایی که کلیک می کنید نگاه می کنید، درست است؟ و با انجام این کار، WebGazer می تواند حرکات چشم شما را کمی بهتر پردازش کند و نتایج دقیق تری ارائه دهد. خیلی باحاله

چشم

من قبلاً یک پیاده سازی SVG ساده برای چشم اضافه کرده بودم و آن را به ردیابی متصل کرده بودم، با این حال باید کمی بیشتر تلطیف شود. در زیر کمی نحوه ظاهر آن را مشاهده می کنید. الهام بخش Alucard Eyes توسط MIKELopez بود.

توضیحات تصویر

این یک نسخه قبلی از چشم است، اما تقریباً 95٪ در آنجا وجود دارد. من این ویدیو را برای زوج دوستانم فرستادم و آنها فکر کردند بسیار جالب است، به خصوص وقتی می دانستند که در واقع حرکات چشم شما را دنبال می کند! همچنین می‌توانید نقطه پیش‌بینی WebGazer را در حال حرکت روی صفحه مشاهده کنید.

کامپوننت چشم خود یک SVG با تعدادی انیمیشن مسیر از طریق Motion است.

      <svg
        className={`w-full h-full self-${alignment} max-w-[350px] max-h-[235px]`}
        viewBox="-50 0 350 235"
        preserveAspectRatio="xMidYMid meet"
      >
        {/* Definitions for gradients and filters */}
        <defs>
          <filter id="pupil-blur">
            <feGaussianBlur stdDeviation="0.75" />
          filter>
          <radialGradient id="eyeball-gradient">
            <stop offset="60%" stopColor="#dcdae0" />
            <stop offset="100%" stopColor="#a8a7ad" />
          radialGradient>
          <radialGradient 
            id="pupil-gradient"
            cx="0.35"
            cy="0.35"
            r="0.65"
          >
            <stop offset="0%" stopColor="#444" />
            <stop offset="75%" stopColor="#000" />
            <stop offset="100%" stopColor="#000" />
          radialGradient>
          <radialGradient 
            id="corner-gradient-left"
            cx="0.3"
            cy="0.5"
            r="0.25"
            gradientUnits="objectBoundingBox"
          >
            <stop offset="0%" stopColor="rgba(0,0,0,0.75)" />
            <stop offset="100%" stopColor="rgba(0,0,0,0)" />
          radialGradient>

          <radialGradient 
            id="corner-gradient-right"
            cx="0.7"
            cy="0.5"
            r="0.25"
            gradientUnits="objectBoundingBox"
          >
            <stop offset="0%" stopColor="rgba(0,0,0,0.75)" />
            <stop offset="100%" stopColor="rgba(0,0,0,0)" />
          radialGradient>

          <filter id="filter0_f_302_14" x="-25" y="0" width="320" height="150" filterUnits="userSpaceOnUse" colorInterpolationFilters="sRGB">
            <feGaussianBlur stdDeviation="4.1" result="effect1_foregroundBlur_302_14"/>
          filter>
          <filter id="filter1_f_302_14" x="-25" y="85" width="320" height="150" filterUnits="userSpaceOnUse" colorInterpolationFilters="sRGB">
            <feGaussianBlur stdDeviation="4.1" result="effect1_foregroundBlur_302_14"/>
          filter>
          <filter id="filter2_f_302_14" x="-50" y="-30" width="400" height="170" filterUnits="userSpaceOnUse" colorInterpolationFilters="sRGB">
            <feGaussianBlur stdDeviation="7.6" result="effect1_foregroundBlur_302_14"/>
          filter>
          <filter id="filter3_f_302_14" x="-50" y="95" width="400" height="170" filterUnits="userSpaceOnUse" colorInterpolationFilters="sRGB">
            <feGaussianBlur stdDeviation="7.6" result="effect1_foregroundBlur_302_14"/>
          filter>
          <filter id="filter4_f_302_14" x="0" y="-20" width="260" height="150" filterUnits="userSpaceOnUse" colorInterpolationFilters="sRGB">
            <feGaussianBlur stdDeviation="3.35" result="effect1_foregroundBlur_302_14"/>
          filter>
          <filter id="filter5_f_302_14" x="0" y="105" width="260" height="150" filterUnits="userSpaceOnUse" colorInterpolationFilters="sRGB">
            <feGaussianBlur stdDeviation="3.35" result="effect1_foregroundBlur_302_14"/>
          filter>
        defs>

        {/* Eyeball */}
        <ellipse
          cx="131"
          cy="117.5"
          rx="100"
          ry="65"
          fill="url(#eyeball-gradient)"
        />

        {/* After the main eyeball ellipse but before the eyelids, add the corner shadows */}
        <ellipse
          cx="50"
          cy="117.5"
          rx="50"
          ry="90"
          fill="url(#corner-gradient-left)"
        />

        <ellipse
          cx="205"
          cy="117.5"
          rx="50"
          ry="90"
          fill="url(#corner-gradient-right)"
        />

        {/* Corner reflections - repositioned diagonally */}
        <circle
          cx={45}
          cy={135}
          r="1.5"
          fill="white"
          className="opacity-60"
        />
        <circle
          cx={215}
          cy={100}
          r="2"
          fill="white"
          className="opacity-60"
        />

        {/* Smaller companion reflections - repositioned diagonally */}
        <circle
          cx={35}
          cy={120}
          r="1"
          fill="white"
          className="opacity-40"
        />
        <circle
          cx={222}
          cy={110}
          r="1.5"
          fill="white"
          className="opacity-40"
        />

        {/* Pupil group with animations */}
        <motion.g
          variants={pupilVariants}
          animate={isBlinking ? "hidden" : "visible"}
        >
          {/* Pupil */}
          <motion.ellipse
            cx={131}
            cy={117.5}
            rx="50"
            ry="50"
            fill="url(#pupil-gradient)"
            filter="url(#pupil-blur)"
            animate={{
              cx: 131 + pupilOffsetX,
              cy: 117.5 + pupilOffsetY
            }}
            transition={{
              type: "spring",
              stiffness: 400,
              damping: 30
            }}
          />

          {/* Light reflections */}
          <motion.circle
            cx={111}
            cy={102.5}
            r="5"
            fill="white"
            animate={{
              cx: 111 + pupilOffsetX,
              cy: 102.5 + pupilOffsetY
            }}
            transition={{
              type: "spring",
              stiffness: 400,
              damping: 30
            }}
          />
          <motion.circle
            cx={124}
            cy={102.5}
            r="3"
            fill="white"
            animate={{
              cx: 124 + pupilOffsetX,
              cy: 102.5 + pupilOffsetY
            }}
            transition={{
              type: "spring",
              stiffness: 400,
              damping: 30
            }}
          />
        motion.g>

        {/* Upper eyelid */}
        <motion.path 
          custom={true}
          variants={eyelidVariants}
          animate={isBlinking ? "closed" : "open"}
          fill="#000"
        />

        {/* Lower eyelid */}
        <motion.path 
          custom={false}
          variants={eyelidVariants}
          animate={isBlinking ? "closed" : "open"}
          fill="#000"
        />

        {/* Top blurred lines */}
        <g filter="url(#filter0_f_302_14)">
          <motion.path
            custom={true}
            variants={blurredLineVariants}
            animate={isBlinking ? "closed" : "open"}
            stroke="#2A2A2A"
            strokeWidth="5"
            strokeLinecap="round"
          />
        g>
        <g filter="url(#filter2_f_302_14)">
          <motion.path
            custom={true}
            variants={outerBlurredLineVariants}
            animate={isBlinking ? "closed" : "open"}
            stroke="#777777"
            strokeWidth="5"
            strokeLinecap="round"
          />
        g>
        <g filter="url(#filter4_f_302_14)">
          <motion.path
            custom={true}
            variants={arcLineVariants}
            animate={isBlinking ? "closed" : "open"}
            stroke="#838383"
            strokeWidth="5"
            strokeLinecap="round"
          />
        g>

        {/* Bottom blurred lines */}
        <g filter="url(#filter1_f_302_14)">
          <motion.path
            variants={bottomBlurredLineVariants}
            animate={isBlinking ? "closed" : "open"}
            stroke="#2A2A2A"
            strokeWidth="5"
            strokeLinecap="round"
          />
        g>
        <g filter="url(#filter3_f_302_14)">
          <motion.path
            variants={bottomOuterBlurredLineVariants}
            animate={isBlinking ? "closed" : "open"}
            stroke="#777777"
            strokeWidth="5"
            strokeLinecap="round"
          />
        g>
        <g filter="url(#filter5_f_302_14)">
          <motion.path
            variants={bottomArcLineVariants}
            animate={isBlinking ? "closed" : "open"}
            stroke="#838383"
            strokeWidth="5"
            strokeLinecap="round"
          />
        g>
      svg>
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

مکان نما به طرز شگفت انگیزی با مسیرهای SVG کار می کند. به عنوان مثال، انیمیشن بستن پلک اساساً با صاف کردن یک مسیر منحنی انجام می شود. من به تازگی مسیر را در ویرایشگر هایلایت کرده بودم، آن را در Composer چسبانده بودم و درخواست کردم انیمیشنی اضافه کنم که نقاط را صاف کند تا چشم در حال بسته شدن/پلک زدن به نظر برسد.

توضیحات تصویر

  // Define the open and closed states for both eyelids
  const upperLidOpen = "M128.5 53.5C59.3 55.5 33 99.6667 28.5 121.5H0V0L261.5 0V121.5H227.5C214.7 65.1 156.167 52.6667 128.5 53.5Z"
  const upperLidClosed = "M128.5 117.5C59.3 117.5 33 117.5 28.5 117.5H0V0L261.5 0V117.5H227.5C214.7 117.5 156.167 117.5 128.5 117.5Z"

  const lowerLidOpen = "M128.5 181C59.3 179 33 134.833 28.5 113H0V234.5H261.5V113H227.5C214.7 169.4 156.167 181.833 128.5 181Z"
  const lowerLidClosed = "M128.5 117.5C59.3 117.5 33 117.5 28.5 117.5H0V234.5H261.5V117.5H227.5C214.7 117.5 156.167 117.5 128.5 117.5Z"

  // Animation variants for the eyelids
  const eyelidVariants = {
    open: (isUpper: boolean) => ({
      d: isUpper ? upperLidOpen : lowerLidOpen,
      transition: {
        duration: 0.4,
        ease: "easeOut"
      }
    }),
    closed: (isUpper: boolean) => ({
      d: isUpper ? upperLidClosed : lowerLidClosed,
      transition: {
        duration: 0.15,
        ease: "easeIn"
      }
    })
  }
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

دیدن این در عمل تجربه بسیار جالبی بود! من همین رویکرد را برای خطوط اطراف اعمال کردم و به مکان‌نما دستور دادم که آنها را به سمت مرکز “جمع کند”: که تقریباً با یک حرکت انجام شد!

سپس چشم‌ها در یک شبکه CSS ساده با سلول‌های تراز شده به‌گونه‌ای نمایش داده می‌شوند که یک اتاق کامل شبیه یک چشم بزرگ به نظر برسد.

<div className="fixed inset-0 grid grid-cols-3 grid-rows-3 gap-4 p-8 md:gap-2 md:p-4 lg:max-w-6xl lg:mx-auto">
          {Object.entries(roomState.participants).map(([key, presences]) => {
            const participant = presences[0]
            const eyeData = eyeTrackingState[key]
            if (key === userId.current) return null

            return (
              <div 
                key={key}
                className={`flex items-center justify-center ${getGridClass(participant.position)}`}
              >
                <Eyes
                  isBlinking={eyeData?.isBlinking ?? false}
                  gazeX={eyeData?.gazeX ?? 0.5}
                  gazeY={eyeData?.gazeY ?? 0.5}
                  alignment={getEyeAlignment(participant.position)}
                />
              </div>
            )
          })}
        </div>

// Helper function to convert position to Tailwind grid classes
function getGridClass(position: string): string {
  switch (position) {
    case 'center': return 'col-start-2 row-start-2'
    case 'middleLeft': return 'col-start-1 row-start-2'
    case 'middleRight': return 'col-start-3 row-start-2'
    case 'topCenter': return 'col-start-2 row-start-1'
    case 'bottomCenter': return 'col-start-2 row-start-3'
    case 'topLeft': return 'col-start-1 row-start-1'
    case 'topRight': return 'col-start-3 row-start-1'
    case 'bottomLeft': return 'col-start-1 row-start-3'
    case 'bottomRight': return 'col-start-3 row-start-3'
    default: return 'col-start-2 row-start-2'
  }
}

function getEyeAlignment(position: string): 'start' | 'center' | 'end' {
  switch (position) {
    case 'topLeft':
    case 'topRight':
      return 'end'
    case 'bottomLeft':
    case 'bottomRight':
      return 'start'
    default:
      return 'center'
  }
}
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

لمس های نهایی

سپس چند صفحه نمایش مقدماتی زیبا و موسیقی پس‌زمینه بزنید و پروژه خوب است!

هنگامی که شما روی چیزهایی مانند این کار می کنید، صدا همیشه تجربه را بهبود می بخشد، بنابراین من از Stable Audio برای تولید موسیقی پس زمینه زمانی که کاربر “وارد پرتگاه” می شود استفاده کردم. درخواستی که برای موسیقی استفاده کردم این بود:

محیط، خزنده، موسیقی پس‌زمینه، صداهای زمزمه‌کننده، بادها، سرعت آهسته، وهم‌آور، پرتگاه

من همچنین فکر می کردم که فقط صفحه سیاه و سفید ساده کمی خسته کننده است، بنابراین چند فیلتر SVG متحرک را در پس زمینه اضافه کردم. علاوه بر این، یک دایره تیره و تار در مرکز صفحه اضافه کردم تا جلوه محو شدن خوبی داشته باشد. احتمالاً می‌توانستم این کار را با فیلترهای SVG انجام دهم، اما نمی‌خواستم زمان زیادی را برای این کار صرف کنم. سپس برای داشتن مقداری حرکت بیشتر، پس‌زمینه را روی محور خود می‌چرخانم. گاهی اوقات انجام انیمیشن با فیلترهای SVG کمی بد است، بنابراین تصمیم گرفتم به جای آن این کار را انجام دهم.

    <div style={{ width: '100vw', height: '100vh' }}>
      {/* Background Elements */}
      <svg className="fixed inset-0 w-full h-full -z-10">
        <defs>
          <filter id="noise">
            <feTurbulence 
              id="turbFreq"
              type="fractalNoise" 
              baseFrequency="0.01"
              seed="5"
              numOctaves="1"
            >
            </feTurbulence>
            <feGaussianBlur stdDeviation="10">
              <animate
                attributeName="stdDeviation"
                values="10;50;10"
                dur="20s"
                repeatCount="indefinite"
              />
            </feGaussianBlur>
            <feColorMatrix
              type="matrix"
              values="1 0 0 0 1
                      0 1 0 0 1
                      0 0 1 0 1
                      0 0 0 25 -13"
            />
          </filter>
        </defs>
        <rect width="200%" height="200%" filter="url(#noise)" className="rotation-animation" />
      </svg>
      <div className="fixed inset-0 w-[95vw] h-[95vh] bg-black rounded-full blur-[128px] m-auto" />
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

بنابراین شما آن را دارید: نسبتاً مستقیم به نحوه اجرای یک ردیابی چشم سبک با قابلیت های بلادرنگ Supabase نگاه کنید. شخصاً این آزمایش بسیار جالبی بود و در حین کار بر روی آن مشکل زیادی نداشتم. و با کمال تعجب من مجبور نبودم شب آخر قبل از ارسال پروژه یک شب کامل انجام دهم!

به راحتی می توانید پروژه یا ویدئوی نمایشی را بررسی کنید که چگونه به نتیجه رسیده است. اگر تعداد زیادی از افراد همزمان از آن استفاده کنند ممکن است مشکلاتی وجود داشته باشد (آزمایش آن بسیار سخت است زیرا برای انجام درست آن به چندین دستگاه و وب کم نیاز دارد)، اما حدس می‌زنم که این در مد پروژه‌های هکاتون باشد؟ و اگر آن را آزمایش کردید، به یاد داشته باشید که اگر یک چشم دیدید، آن شخص دیگری است که شما را در جایی از طریق اینترنت تماشا می کند!

نوشته های مشابه

دیدگاهتان را بنویسید

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *

دکمه بازگشت به بالا