기본 콘텐츠로 건너뛰기

웹 사이트용 어안렌즈 효과 feat. Gemini

 

웹 사이트용 어안렌즈 효과







이 스레드를 보고 궁금해져서 Gemini 에게 시켜봤더니 
이렇게 까지는 만들어 준다.


근데 성능 이슈는 조금 생길 듯..? 

https://g.co/gemini/share/bf9b1d13bd5b

  
<프롬프트>

"드래그 가능한 500x500px 크기의 원형 패널이 있는 웹사이트를 만들어줘. 이 패널은 투명한 어안 렌즈 효과를 적용하여 웹사이트의 전체 배경을 왜곡시켜 보여줘야 해. 렌즈의 중앙 영역은 왜곡 없이 원본 그대로 보여야 하며, 왜곡 효과는 렌즈의 테두리 부분에만 적용되어야 해.

페이지 상단에는 다음 세 가지 값을 조절할 수 있는 슬라이더를 추가해줘:

  1. 왜곡 강도: 어안 렌즈 효과의 강도를 조절합니다.
  2. 렌즈 반경: 왜곡이 적용되는 전체 렌즈 영역의 크기를 조절합니다.
  3. 왜곡 범위: 렌즈 테두리에서 왜곡이 실제로 적용되는 두께(픽셀)를 조절합니다.

렌즈 패널은 웹페이지 위에서 자유롭게 드래그 가능해야 해. html2canvas 라이브러리를 사용하여 페이지를 캡처하고 캔버스에 왜곡 효과를 구현해야 하며, CORS 문제로 인해 렌즈 영역이 검게 나오지 않도록 처리해야 해. 모든 HTML, CSS, JavaScript 코드를 하나의 파일로 제공해줘."

<code>

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>어안 렌즈 효과 패널</title>
    <!-- Google Fonts (Inter) 대신 시스템 폰트 스택 사용 -->
    <!-- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" rel="stylesheet"> -->
    <!-- html2canvas 라이브러리 CDN 추가 -->
    <script src="https://html2canvas.hertzen.com/dist/html2canvas.min.js"></script>
    <style>
        body {
            margin: 0;
            overflow-x: hidden; /* Prevent horizontal scroll */
            /* Google Fonts (Inter) 대신 시스템 폰트 스택 사용 */
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
            background: linear-gradient(to bottom right, #f0f4f8, #e0e8f0); /* Soft gradient background */
            min-height: 150vh; /* Make body scrollable to demonstrate effect on content */
            width: 100vw;
            color: #333;
            line-height: 1.6;
            padding-top: 80px; /* Space for controls */
            box-sizing: border-box;
        }

        /* 캔버스가 이제 전체 화면을 덮습니다 */
        #main-canvas {
            position: fixed;
            top: 0;
            left: 0;
            width: 100vw;
            height: 100vh;
            z-index: 998; /* 컨트롤과 렌즈 표시기보다 아래 */
            display: block;
        }

        /* 드래그 가능한 렌즈 표시기 (왜곡의 중심을 나타냄) */
        #lens-indicator {
            position: absolute; /* fixed 대신 absolute로 변경하여 body의 스크롤에 따라 움직이도록 */
            width: 500px; /* 렌즈 크기 */
            height: 500px; /* 렌즈 크기 */
            border: 3px solid #3498db;
            border-radius: 50%; /* 원형 */
            background: rgba(255, 255, 255, 0.1); /* 약간 투명한 배경 */
            box-shadow: 0 5px 25px rgba(0, 0, 0, 0.4);
            cursor: grab;
            top: 100px; /* 초기 위치 */
            left: 100px; /* 초기 위치 */
            z-index: 999; /* 캔버스 위에 위치 */
            display: flex;
            justify-content: center;
            align-items: center;
            pointer-events: auto; /* 드래그 가능하도록 설정 */
        }

        #lens-indicator:active {
            cursor: grabbing;
            box-shadow: 0 5px 35px rgba(0, 0, 0, 0.6);
        }

        .content-area {
            position: relative; /* 컨텐츠가 캔버스 아래에 위치하도록 */
            z-index: 1; /* 캔버스보다 낮게 설정 */
            padding: 20px;
            max-width: 800px;
            margin: 0 auto;
            text-align: center;
            pointer-events: none; /* 캔버스 아래에 있으므로 마우스 이벤트 비활성화 */
        }

        h1 {
            color: #2c3e50;
            font-weight: 600;
            margin-bottom: 20px;
        }

        p {
            margin-bottom: 15px;
            color: #555;
        }

        /* 이미지 대신 사용할 플레이스홀더 div 스타일 */
        .placeholder-div {
            width: 100%;
            height: 150px; /* 고정 높이 */
            background: linear-gradient(45deg, #FF5733, #FFC300); /* 그라데이션 배경 */
            border-radius: 8px;
            margin-bottom: 20px;
            display: flex;
            justify-content: center;
            align-items: center;
            color: white;
            font-size: 1.5em;
            font-weight: 600;
            text-shadow: 1px 1px 3px rgba(0,0,0,0.3);
            box-shadow: 0 4px 10px rgba(0,0,0,0.1);
        }

        .placeholder-div.two {
            background: linear-gradient(45deg, #33A3FF, #00CFFF);
        }


        .controls {
            position: fixed;
            top: 10px;
            left: 50%;
            transform: translateX(-50%);
            background: rgba(255, 255, 255, 0.95);
            padding: 15px 25px;
            border-radius: 12px;
            box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
            z-index: 1000; /* 가장 위에 위치 */
            display: flex;
            flex-wrap: wrap; /* Allow wrapping on small screens */
            align-items: center;
            gap: 15px 25px; /* Adjust gap for better spacing */
            border: 1px solid #e0e0e0;
            pointer-events: auto; /* 컨트롤은 클릭 가능하도록 */
        }

        .controls label {
            font-weight: 600;
            color: #444;
            white-space: nowrap; /* Prevent label text from wrapping */
        }

        .controls input[type="range"] {
            width: 150px;
            -webkit-appearance: none;
            height: 8px;
            background: #ddd;
            border-radius: 5px;
            outline: none;
            opacity: 0.9;
            transition: opacity .2s;
        }

        .controls input[type="range"]::-webkit-slider-thumb {
            -webkit-appearance: none;
            appearance: none;
            width: 20px;
            height: 20px;
            background: #3498db;
            border-radius: 50%;
            cursor: pointer;
            box-shadow: 0 0 5px rgba(0, 0, 0, 0.2);
        }

        /* Responsive adjustments */
        @media (max-width: 768px) {
            .controls {
                flex-direction: column;
                gap: 10px;
                padding: 10px 15px;
                width: calc(100% - 40px); /* Adjust width for mobile */
                top: 5px;
            }
            .controls input[type="range"] {
                width: calc(100% - 20px);
            }
            #lens-indicator {
                width: 300px; /* Smaller panel on mobile */
                height: 300px;
                top: 150px; /* Adjust initial position */
                left: 50%;
                transform: translateX(-50%);
            }
        }
    </style>
</head>
<body>
    <!-- 전체 화면을 덮는 캔버스 -->
    <canvas id="main-canvas"></canvas>

    <!-- 드래그 가능한 렌즈 표시기 (실제 왜곡의 중심) -->
    <div id="lens-indicator" class="draggable">
        <!-- 렌즈 중심을 나타내는 크로스헤어 (선택 사항) -->
        <div style="width: 2px; height: 50px; background: white; position: absolute; margin: auto; border-radius: 1px;"></div>
        <div style="width: 50px; height: 2px; background: white; position: absolute; margin: auto; border-radius: 1px;"></div>
    </div>

    <div class="controls">
        <label for="magnification">왜곡 강도:</label>
        <input type="range" id="magnification" min="0.1" max="2" step="0.05" value="0.5">
        <label for="radius">렌즈 반경:</label>
        <input type="range" id="radius" min="100" max="250" step="10" value="200">
        <label for="distortionRange">왜곡 범위 (px):</label>
        <input type="range" id="distortionRange" min="1" max="150" step="1" value="100">
    </div>

    <!-- 캔버스 아래에 실제 웹 페이지 콘텐츠 -->
    <div class="content-area">
        <h1>어안 렌즈 효과 데모</h1>
        <p>이 웹 페이지는 드래그 가능한 어안 렌즈 패널을 보여줍니다. 렌즈 표시기를 움직여 페이지 전체 콘텐츠가 어떻게 왜곡되는지 확인해보세요.</p>
        <p>상단의 슬라이더를 사용하여 왜곡 강도와 렌즈 반경 값을 조절할 수 있습니다.</p>
        <p>새로 추가된 '왜곡 범위' 슬라이더를 사용하여 렌즈 테두리에서 왜곡이 적용될 두께를 조절할 수 있습니다.</p>
        <p>스크롤하여 더 많은 콘텐츠를 확인하고 렌즈 효과가 어떻게 작동하는지 시험해볼 수 있습니다.</p>
        <!-- 외부 이미지 대신 내부 스타일링된 div 사용 -->
        <div class="placeholder-div">Placeholder Image 1</div>
        <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p>
        <p>Curabitur pretium, libero et tempor sollicitudin, arcu tellus interdum diam, in dictum sapien ligula eget sem. Proin facilisis, urna at consectetur scelerisque, sapien leo tincidunt metus, ac rhoncus felis sem in ex.</p>
        <p>Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Donec vel elit id elit dapibus pellentesque in nec ipsum. Aliquam erat volutpat.</p>
        <!-- 외부 이미지 대신 내부 스타일링된 div 사용 -->
        <div class="placeholder-div two">Placeholder Image 2</div>
        <p>Nullam in tempor felis. Integer luctus in ante a tincidunt. Quisque sollicitudin nisi eget nulla convallis, non feugiat elit consectetur. Sed id sem ut ipsum tristique varius. Maecenas sed nisi ut mi scelerisque malesuada nec non nulla.</p>
        <p>Fusce et velit a dolor dapibus vestibulum. Ut varius efficitur ipsum, at fermentum tortor laoreet nec. Quisque ac tortor eu mauris tincidunt feugiat. Integer congue magna a nibh iaculis, vitae ultrices turpis maximus.</p>
    </div>

    <script>
        document.addEventListener('DOMContentLoaded', () => {
            const mainCanvas = document.getElementById('main-canvas');
            const ctx = mainCanvas.getContext('2d', { willReadFrequently: true });
            const lensIndicator = document.getElementById('lens-indicator');
            const magnificationSlider = document.getElementById('magnification');
            const radiusSlider = document.getElementById('radius');
            const distortionRangeSlider = document.getElementById('distortionRange');

            let isDragging = false;
            let offsetX, offsetY;

            // 캔버스 크기를 뷰포트에 맞게 설정
            const setCanvasSize = () => {
                mainCanvas.width = window.innerWidth;
                mainCanvas.height = window.innerHeight;
            };
            setCanvasSize();

            // 렌즈 표시기 드래그 기능
            lensIndicator.addEventListener('mousedown', (e) => {
                isDragging = true;
                const rect = lensIndicator.getBoundingClientRect();
                offsetX = e.clientX - rect.left;
                offsetY = e.clientY - rect.top;
                lensIndicator.style.cursor = 'grabbing';
                document.body.style.userSelect = 'none'; // 드래그 중 텍스트 선택 방지
            });

            document.addEventListener('mousemove', (e) => {
                if (!isDragging) return;

                let newX = e.clientX - offsetX;
                let newY = e.clientY - offsetY;

                // 뷰포트 내로 렌즈 표시기 제한
                const maxX = window.innerWidth - lensIndicator.offsetWidth;
                const maxY = window.innerHeight - lensIndicator.offsetHeight;

                newX = Math.max(0, Math.min(newX, maxX));
                newY = Math.max(0, Math.min(newY, maxY));

                lensIndicator.style.left = `${newX}px`;
                lensIndicator.style.top = `${newY}px`;

                // 드래그 중에도 왜곡 효과 실시간 업데이트 (성능 저하 가능성 있음)
                // requestAnimationFrame을 사용하여 부드럽게 업데이트
                requestAnimationFrame(updateFisheyeEffect);
            });

            document.addEventListener('mouseup', () => {
                if (isDragging) {
                    isDragging = false;
                    lensIndicator.style.cursor = 'grab';
                    document.body.style.userSelect = ''; // 텍스트 선택 다시 활성화
                    updateFisheyeEffect(); // 드래그 완료 후 최종 업데이트
                }
            });

            // 어안 렌즈 효과 업데이트 함수
            function updateFisheyeEffect() {
                // html2canvas로 전체 웹 페이지 캡처
                html2canvas(document.body, {
                    allowTaint: true, // CORS 문제 회피 시도
                    useCORS: true,    // CORS 이미지 사용 시도
                    logging: true,     // html2canvas 디버깅 정보 출력 (콘솔 확인)
                    ignoreElements: (element) => {
                        // 컨트롤 패널과 렌즈 표시기, 그리고 캔버스 자체는 캡처에서 제외
                        return element.classList.contains('controls') || element.id === 'lens-indicator' || element.id === 'main-canvas';
                    },
                    // 전체 문서의 스크롤 영역을 포함하여 캡처
                    width: document.documentElement.scrollWidth,
                    height: document.documentElement.scrollHeight,
                    x: window.scrollX, // 현재 스크롤 위치에서 캡처 시작
                    y: window.scrollY,
                }).then(function(screenshotCanvas) {
                    // 캡처된 스크린샷의 모든 픽셀 데이터 가져오기
                    let sourceImageData;
                    try {
                        sourceImageData = screenshotCanvas.getContext('2d').getImageData(0, 0, screenshotCanvas.width, screenshotCanvas.height);
                    } catch (e) {
                        console.error("Failed to get image data from screenshot (CORS or security restriction):", e);
                        ctx.clearRect(0, 0, mainCanvas.width, mainCanvas.height); // 캔버스 초기화
                        ctx.fillStyle = 'rgba(255, 0, 0, 0.5)'; // 반투명 빨간색 오버레이
                        ctx.fillRect(0, 0, mainCanvas.width, mainCanvas.height);
                        ctx.fillStyle = 'white'; // 텍스트 색상
                        ctx.font = '20px Arial';
                        ctx.textAlign = 'center';
                        ctx.fillText('WARNING: 웹 페이지 콘텐츠를 읽을 수 없습니다.', mainCanvas.width / 2, mainCanvas.height / 2 - 20);
                        ctx.fillText('이것은 주로 브라우저 보안 제약(CORS) 때문입니다.', mainCanvas.width / 2, mainCanvas.height / 2 + 10);
                        ctx.fillText('콘솔을 확인하여 HTML2Canvas 오류를 확인하세요.', mainCanvas.width / 2, mainCanvas.height / 2 + 40);
                        return;
                    }

                    // 렌더링할 새로운 이미지 데이터 생성
                    const distortedImageData = ctx.createImageData(mainCanvas.width, mainCanvas.height);
                    const distortedData = distortedImageData.data;

                    const lensRect = lensIndicator.getBoundingClientRect();
                    // 렌즈 효과의 실제 중심점 (뷰포트 기준)
                    const lensCenterX = lensRect.left + lensRect.width / 2;
                    const lensCenterY = lensRect.top + lensRect.height / 2;

                    const lensRadius = parseFloat(radiusSlider.value); // 렌즈 효과 전체 반경
                    const distortionStrength = parseFloat(magnificationSlider.value); // 왜곡 강도 (0.1 ~ 2)
                    const distortionBorderWidth = parseFloat(distortionRangeSlider.value); // 왜곡이 적용될 테두리 두께

                    // 왜곡이 시작될 렌즈 내부의 반경 (렌즈 반경 - 왜곡 두께)
                    const innerLensRadius = Math.max(0, lensRadius - distortionBorderWidth);

                    // 전체 캔버스 픽셀 순회
                    for (let y = 0; y < mainCanvas.height; y++) {
                        for (let x = 0; x < mainCanvas.width; x++) {
                            // 렌즈 중심으로부터 현재 픽셀까지의 상대 거리 및 각도
                            const dx = x - lensCenterX;
                            const dy = y - lensCenterY;
                            const distance = Math.sqrt(dx * dx + dy * dy);

                            const destPixelIndex = (y * mainCanvas.width + x) * 4;

                            let sourceX, sourceY;

                            if (distance < innerLensRadius) {
                                // 렌즈 중심부: 왜곡 없이 원본 픽셀 사용
                                sourceX = x;
                                sourceY = y;
                            } else if (distance >= innerLensRadius && distance < lensRadius) {
                                // 왜곡 적용될 테두리 영역
                                // 테두리 내에서의 정규화된 위치 (0: 내부 경계, 1: 외부 경계)
                                const normalizedBorderPos = (distance - innerLensRadius) / distortionBorderWidth;

                                // 현재 픽셀 위치에서의 유효 왜곡 강도 (1에서 distortionStrength로 보간)
                                // 1은 왜곡 없음, distortionStrength는 슬라이더로 조절되는 최종 왜곡 강도
                                // 예를 들어, distortionStrength가 0.5이고 normalizedBorderPos가 0.5라면,
                                // effectiveDistortionFactor는 1 + (0.5 - 1) * 0.5 = 1 - 0.25 = 0.75
                                const effectiveDistortionFactor = 1 + (distortionStrength - 1) * normalizedBorderPos;

                                // 왜곡된 픽셀의 원본 위치 계산
                                // 렌즈 중심에서 해당 픽셀까지의 벡터를 effectiveDistortionFactor로 스케일링
                                sourceX = lensCenterX + dx * effectiveDistortionFactor;
                                sourceY = lensCenterY + dy * effectiveDistortionFactor;

                            } else {
                                // 렌즈 반경 바깥 영역: 왜곡 없이 원본 픽셀 사용
                                sourceX = x;
                                sourceY = y;
                            }

                            // 원본 이미지 데이터에서 픽셀 정보 가져오기
                            const srcXInt = Math.floor(sourceX);
                            const srcYInt = Math.floor(sourceY);
                            const srcPixelIndex = (srcYInt * sourceImageData.width + srcXInt) * 4;

                            // 원본 이미지 범위를 벗어나지 않도록 처리
                            if (srcXInt >= 0 && srcXInt < sourceImageData.width && srcYInt >= 0 && srcYInt < sourceImageData.height) {
                                distortedData[destPixelIndex] = sourceImageData.data[srcPixelIndex];
                                distortedData[destPixelIndex + 1] = sourceImageData.data[srcPixelIndex + 1];
                                distortedData[destPixelIndex + 2] = sourceImageData.data[srcPixelIndex + 2];
                                distortedData[destPixelIndex + 3] = sourceImageData.data[srcPixelIndex + 3];
                            } else {
                                // 캡처 영역 밖으로 매핑된 픽셀은 투명하게 처리
                                distortedData[destPixelIndex] = 0;
                                distortedData[destPixelIndex + 1] = 0;
                                distortedData[destPixelIndex + 2] = 0;
                                distortedData[destPixelIndex + 3] = 0;
                            }
                        }
                    }
                    ctx.putImageData(distortedImageData, 0, 0); // 왜곡된 전체 이미지 캔버스에 그리기

                }).catch(function(error) {
                    console.error('html2canvas capturing error:', error);
                    ctx.clearRect(0, 0, mainCanvas.width, mainCanvas.height); // 캔버스 초기화
                    ctx.fillStyle = 'red';
                    ctx.font = '20px Arial';
                    ctx.textAlign = 'center';
                    ctx.fillText('웹 페이지 캡처 중 치명적인 오류가 발생했습니다.', mainCanvas.width / 2, mainCanvas.height / 2 - 20);
                    ctx.fillText('(오류: ' + (error.message || '알 수 없음') + ')', mainCanvas.width / 2, mainCanvas.height / 2 + 10);
                    ctx.fillText('이 문제는 주로 외부 리소스(폰트, 이미지 등) 사용으로 인한', mainCanvas.width / 2, mainCanvas.height / 2 + 40);
                    ctx.fillText('브라우저 보안 제약 때문에 발생합니다. 콘솔을 확인하세요.', mainCanvas.width / 2, mainCanvas.height / 2 + 70);
                });
            }

            // 슬라이더 값 변경 시 효과 업데이트
            magnificationSlider.addEventListener('input', updateFisheyeEffect);
            radiusSlider.addEventListener('input', updateFisheyeEffect);
            distortionRangeSlider.addEventListener('input', updateFisheyeEffect);

            // 윈도우 크기 변경 시 캔버스 크기 및 효과 업데이트
            window.addEventListener('resize', () => {
                setCanvasSize();
                updateFisheyeEffect();
            });

            // 초기 로드 시 효과 업데이트
            updateFisheyeEffect();
        });
    </script>
</body>
</html>