import { useEffect, useRef } from 'react'; import IconUserSVG from '../base/icons/svg/user.svg?raw'; import { IParticipant } from '../base/participants/types'; import { TILE_ASPECT_RATIO } from '../filmstrip/constants'; import { renderAvatarOnCanvas } from './functions'; import logger from './logger'; /** * Canvas dimensions for PiP avatar rendering. */ const CANVAS_WIDTH = 640; const CANVAS_HEIGHT = Math.floor(CANVAS_WIDTH / TILE_ASPECT_RATIO); /** * Frame rate 0 means capture on-demand when canvas changes. * We manually request frames after drawing to ensure capture. */ const CANVAS_FRAME_RATE = 0; /** * Options for the useCanvasAvatar hook. */ interface IUseCanvasAvatarOptions { backgroundColor: string; customAvatarBackgrounds: string[]; displayName: string; displayNameColor: string; fontFamily: string; initialsColor: string; participant: IParticipant | undefined; } /** * Result returned by the useCanvasAvatar hook. */ interface IUseCanvasAvatarResult { canvasStream: MediaStream | null; } /** * Internal refs managed by the hook. */ interface ICanvasRefs { canvas: HTMLCanvasElement | null; defaultIcon: HTMLImageElement | null; stream: MediaStream | null; } /** * Loads and prepares the default user icon SVG as an Image element. * * @returns {HTMLImageElement} The prepared image element. */ function createDefaultIconImage(): HTMLImageElement { let svgText = IconUserSVG; if (!svgText.includes('fill=')) { svgText = svgText.replace('({ canvas: null, stream: null, defaultIcon: null }); /** * Initialize canvas, stream, and default icon on mount. */ useEffect(() => { // Create canvas. const canvas = document.createElement('canvas'); canvas.width = CANVAS_WIDTH; canvas.height = CANVAS_HEIGHT; refs.current.canvas = canvas; // Create stream from canvas. refs.current.stream = canvas.captureStream(CANVAS_FRAME_RATE); // Load default icon. refs.current.defaultIcon = createDefaultIconImage(); logger.log('Canvas avatar initialized'); // Cleanup on unmount. return () => { if (refs.current.stream) { refs.current.stream.getTracks().forEach(track => track.stop()); refs.current.stream = null; } refs.current.canvas = null; refs.current.defaultIcon = null; logger.log('Canvas avatar cleaned up'); }; }, []); /** * Re-render avatar when participant or display name changes. */ useEffect(() => { const { canvas, defaultIcon, stream } = refs.current; if (!canvas) { return; } const ctx = canvas.getContext('2d'); if (!ctx) { logger.error('Failed to get canvas 2D context'); return; } renderAvatarOnCanvas( canvas, ctx, participant, displayName, customAvatarBackgrounds, defaultIcon, backgroundColor, fontFamily, initialsColor, displayNameColor ).then(() => { // Request a frame capture after drawing. // For captureStream(0), we need to manually trigger frame capture. const track = stream?.getVideoTracks()[0] as MediaStreamTrack & { requestFrame?: () => void; }; if (track?.requestFrame) { track.requestFrame(); logger.log('Canvas frame requested after render'); } }).catch((error: Error) => logger.error('Error rendering avatar on canvas:', error)); }, [ participant?.loadableAvatarUrl, participant?.name, displayName, customAvatarBackgrounds, backgroundColor, fontFamily, initialsColor, displayNameColor ]); return { canvasStream: refs.current.stream }; }