Download index.html from gp01002/AI-ART: direct link, hf CLI and curl.
- Browser
- Download file 33.2 kB
-
https://huggingface.co/spaces/gp01002/AI-ART/resolve/main/index.html
- Command line
-
hf download hf://spaces/gp01002/AI-ART/index.html
-
curl -L -o index.html https://huggingface.co/spaces/gp01002/AI-ART/resolve/main/index.html
33.2 kB
| <html lang="zh-TW"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>記憶的編織 - 水墨投影系統</title> | |
| <style> | |
| /* CSS Reset and Base Styles */ | |
| * { | |
| margin: 0; | |
| padding: 0; | |
| box-sizing: border-box; | |
| } | |
| body { | |
| font-family: 'Inter', sans-serif; /* Using Inter font as per guidelines */ | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| color: white; | |
| min-height: 100vh; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| overflow: hidden; /* Prevent body scroll */ | |
| } | |
| /* Main Container for the Application */ | |
| .container { | |
| display: flex; | |
| width: 95%; /* Responsive width */ | |
| max-width: 1400px; | |
| height: 95vh; /* Responsive height */ | |
| border-radius: 15px; | |
| overflow: hidden; | |
| box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4); | |
| background: rgba(0, 0, 0, 0.2); | |
| } | |
| /* Left and Right Panels Styling */ | |
| .left-panel, .right-panel { | |
| flex: 1; /* Equal width for both panels initially */ | |
| padding: 20px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 15px; | |
| background: rgba(0, 0, 0, 0.2); | |
| backdrop-filter: blur(5px); | |
| margin: 8px; /* Margin around inner content of container */ | |
| border-radius: 12px; | |
| } | |
| /* Title Styling */ | |
| .title { | |
| text-align: center; | |
| font-size: 1.8rem; | |
| font-weight: 800; | |
| color: #e0e0e6; | |
| margin-bottom: 15px; | |
| } | |
| /* Status Panel Styling */ | |
| .status-panel, .info-panel { | |
| background: rgba(255, 255, 255, 0.08); | |
| border-radius: 8px; | |
| padding: 12px; | |
| margin-bottom: 10px; | |
| } | |
| .status-item { | |
| display: flex; | |
| justify-content: space-between; | |
| margin-bottom: 8px; | |
| font-size: 0.9rem; | |
| } | |
| .status-item:last-child { | |
| margin-bottom: 0; /* No margin for the last item */ | |
| } | |
| /* Controls Button Styling */ | |
| .controls { | |
| display: flex; | |
| gap: 8px; | |
| margin-bottom: 15px; | |
| } | |
| .btn { | |
| flex: 1; | |
| padding: 10px 15px; /* Slightly larger padding for better touch targets */ | |
| border: none; | |
| border-radius: 8px; /* More rounded corners */ | |
| color: white; | |
| font-weight: bold; | |
| font-size: 0.9rem; /* Slightly larger font */ | |
| cursor: pointer; | |
| transition: transform 0.2s ease, box-shadow 0.2s ease; /* Add box-shadow transition */ | |
| box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2); | |
| } | |
| .btn:hover { | |
| transform: translateY(-2px); /* More pronounced lift effect */ | |
| box-shadow: 0 6px 15px rgba(0, 0, 0, 0.3); | |
| } | |
| .btn-primary { background: #4CAF50; } /* Green */ | |
| .btn-secondary { background: #2196F3; } /* Blue */ | |
| .btn-tertiary { background: #9C27B0; } /* Purple */ | |
| .btn-special { background: #FF6B35; } /* Orange */ | |
| /* Canvas Container */ | |
| .canvas-container { | |
| flex: 1; | |
| background: #000; | |
| border-radius: 8px; | |
| border: 2px solid rgba(255, 107, 53, 0.5); | |
| overflow: hidden; | |
| position: relative; | |
| display: flex; /* Use flex to center canvas content if needed */ | |
| align-items: center; | |
| justify-content: center; | |
| min-height: 300px; /* Ensure a minimum height for the canvas area */ | |
| } | |
| canvas { | |
| width: 100%; | |
| height: 100%; | |
| display: block; | |
| cursor: crosshair; | |
| background-color: #f8f8f8; /* Ensure canvas background is light for ink effect */ | |
| } | |
| .overlay { | |
| position: absolute; | |
| top: 10px; /* Adjusted position */ | |
| left: 10px; | |
| background: rgba(0, 0, 0, 0.7); | |
| padding: 6px 10px; /* Slightly larger padding */ | |
| border-radius: 5px; /* More rounded */ | |
| font-size: 0.8rem; | |
| color: #fff; | |
| } | |
| /* AI Area Styling */ | |
| .ai-area { | |
| flex: 4; /* Takes more space in the right panel */ | |
| background: rgba(75, 255, 181, 0.1); | |
| border-radius: 8px; | |
| padding: 15px; /* Increased padding */ | |
| border: 2px solid rgba(75, 255, 181, 0.3); | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| min-height: 350px; /* Ensure it's tall enough */ | |
| position: relative; /* For loading overlay */ | |
| } | |
| .ai-area h3 { | |
| color: #4bffb5; | |
| margin-bottom: 10px; | |
| font-size: 1.2rem; /* Larger font */ | |
| } | |
| .ai-area img { | |
| width: 100%; | |
| height: 100%; | |
| object-fit: contain; /* Use contain to fit the image without cropping */ | |
| border-radius: 8px; | |
| box-shadow: 0 8px 25px rgba(0, 0, 0, 0.4); | |
| display: none; /* Hidden by default */ | |
| } | |
| #aiStatus { | |
| font-size: 1.1rem; /* Larger font */ | |
| color: #4bffb5; | |
| text-align: center; | |
| padding: 20px; | |
| position: absolute; /* Center within ai-area */ | |
| background: rgba(0, 0, 0, 0.7); | |
| border-radius: 8px; | |
| width: calc(100% - 30px); /* Adjust width to account for padding */ | |
| height: calc(100% - 30px); /* Adjust height */ | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| top: 15px; | |
| left: 15px; | |
| } | |
| /* Info Panel within Right Panel */ | |
| .info-panel h4 { | |
| color: #b0e0e6; | |
| margin-bottom: 8px; | |
| font-size: 1.05rem; | |
| } | |
| .prompt-display { | |
| background: rgba(0, 0, 0, 0.4); | |
| padding: 10px; | |
| border-radius: 6px; | |
| font-size: 0.85rem; | |
| word-break: break-all; | |
| min-height: 60px; /* Ensure visibility for longer prompts */ | |
| display: flex; | |
| align-items: center; | |
| } | |
| /* Message Box */ | |
| .message { | |
| position: fixed; | |
| top: 20px; | |
| right: 20px; | |
| padding: 10px 20px; | |
| border-radius: 6px; | |
| color: white; | |
| font-weight: bold; | |
| z-index: 1000; | |
| transform: translateX(120%); | |
| transition: transform 0.3s ease, opacity 0.3s ease; | |
| opacity: 0; | |
| box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); | |
| } | |
| .message.show { | |
| transform: translateX(0); | |
| opacity: 1; | |
| } | |
| .message.success { background: #4CAF50; } | |
| .message.error { background: #f44336; } | |
| .message.info { background: #2196F3; } | |
| /* Loading Spinner */ | |
| .spinner { | |
| border: 4px solid rgba(255, 255, 255, 0.3); | |
| border-top: 4px solid #4bffb5; | |
| border-radius: 50%; | |
| width: 40px; | |
| height: 40px; | |
| animation: spin 1s linear infinite; | |
| margin-bottom: 10px; | |
| } | |
| @keyframes spin { | |
| 0% { transform: rotate(0deg); } | |
| 100% { transform: rotate(360deg); } | |
| } | |
| /* Responsive adjustments */ | |
| @media (max-width: 900px) { | |
| .container { | |
| flex-direction: column; | |
| height: auto; | |
| min-height: 95vh; | |
| } | |
| .left-panel, .right-panel { | |
| width: calc(100% - 16px); /* Full width minus margin */ | |
| margin-bottom: 16px; | |
| } | |
| .controls { | |
| flex-wrap: wrap; /* Allow buttons to wrap */ | |
| } | |
| .btn { | |
| flex: none; | |
| width: 48%; /* Two buttons per row */ | |
| } | |
| .ai-area { | |
| min-height: 250px; /* Adjust min-height for smaller screens */ | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <!-- Left Panel: Canvas and Controls --> | |
| <div class="left-panel"> | |
| <h1 class="title">記憶的編織</h1> | |
| <div class="status-panel"> | |
| <div class="status-item"> | |
| <span>系統狀態:</span> | |
| <span id="systemStatus">待機</span> | |
| </div> | |
| <div class="status-item"> | |
| <span>FPS:</span> | |
| <span id="fpsDisplay">0</span> | |
| </div> | |
| <div class="status-item"> | |
| <span>漣漪數:</span> | |
| <span id="rippleCount">0</span> | |
| </div> | |
| </div> | |
| <div class="controls"> | |
| <button class="btn btn-primary" onclick="startSystem()">啟動</button> | |
| <button class="btn btn-secondary" onclick="resetSystem()">重置</button> | |
| <button class="btn btn-tertiary" onclick="togglePause()">暫停</button> | |
| <button class="btn btn-special" onclick="generateAIImage()">生成</button> | |
| </div> | |
| <div class="canvas-container"> | |
| <canvas id="mainCanvas"></canvas> | |
| <div class="overlay">水墨投影 - 移動滑鼠體驗</div> | |
| </div> | |
| </div> | |
| <!-- Right Panel: AI Generation --> | |
| <div class="right-panel"> | |
| <h1 class="title">AI 生成</h1> | |
| <div class="info-panel"> | |
| <h4>🌊 狀態信息</h4> | |
| <div class="status-item"> | |
| <span>滑鼠速度:</span> | |
| <span id="mouseSpeed">0</span> | |
| </div> | |
| <div class="status-item"> | |
| <span>生成狀態:</span> | |
| <span id="aiGenStatus">待機</span> | |
| </div> | |
| </div> | |
| <div class="info-panel"> | |
| <h4>🎨 提示詞</h4> | |
| <div class="prompt-display" id="promptText"> | |
| 點擊「生成」按鈕以產生AI圖像... | |
| </div> | |
| </div> | |
| <div class="ai-area"> | |
| <h3>🖼️ 生成結果</h3> | |
| <div id="aiStatus"> | |
| <div class="spinner" style="display:none;"></div> | |
| 點擊生成按鈕開始創作 | |
| </div> | |
| <img id="aiImage" alt="Generated AI Image" style="display: none;"> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="message" id="messageBox"></div> | |
| <script> | |
| console.log('🎨 啟動水墨系統...'); | |
| // Global variables for canvas and system state | |
| let canvas, ctx; | |
| let isRunning = false; | |
| let isPaused = false; | |
| let mouseX = 0, mouseY = 0, mouseSpeed = 0; | |
| let time = 0; | |
| let nodes = []; | |
| let ripples = []; | |
| let frameCount = 0; | |
| let lastFpsTime = Date.now(); | |
| /** | |
| * Initializes the main canvas and sets up event listeners. | |
| * This function is called once the DOM is fully loaded. | |
| */ | |
| function init() { | |
| console.log('初始化系統...'); | |
| canvas = document.getElementById('mainCanvas'); | |
| if (!canvas) { | |
| console.error('找不到canvas元素'); | |
| return; | |
| } | |
| ctx = canvas.getContext('2d'); | |
| setupCanvas(); | |
| setupEvents(); | |
| createNodes(); // Initialize nodes for the ink effect | |
| showMessage('系統初始化完成!', 'success'); | |
| console.log('✅ 初始化完成'); | |
| } | |
| /** | |
| * Sets the canvas dimensions to match its parent container | |
| * and initializes mouse coordinates. | |
| */ | |
| function setupCanvas() { | |
| const container = canvas.parentElement; | |
| // Get the bounding rectangle of the parent to set canvas size | |
| const rect = container.getBoundingClientRect(); | |
| // Set canvas dimensions based on container, ensuring minimums | |
| canvas.width = Math.max(400, rect.width - 10); | |
| canvas.height = Math.max(300, rect.height - 10); | |
| // Center mouse initially | |
| mouseX = canvas.width / 2; | |
| mouseY = canvas.height / 2; | |
| console.log(`畫布: ${canvas.width}x${canvas.height}`); | |
| // Draw initial message on canvas | |
| ctx.fillStyle = '#f5f5f5'; // Light background for ink effect | |
| ctx.fillRect(0, 0, canvas.width, canvas.height); | |
| ctx.fillStyle = '#333'; | |
| ctx.font = '16px Inter'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText('系統準備就緒', canvas.width/2, canvas.height/2); | |
| } | |
| /** | |
| * Sets up event listeners for mouse movement and clicks on the canvas. | |
| */ | |
| function setupEvents() { | |
| // Mouse movement handler | |
| canvas.addEventListener('mousemove', (e) => { | |
| const rect = canvas.getBoundingClientRect(); | |
| // Calculate scale factors for accurate mouse coordinates on high-DPI screens | |
| const scaleX = canvas.width / rect.width; | |
| const scaleY = canvas.height / rect.height; | |
| const newX = (e.clientX - rect.left) * scaleX; | |
| const newY = (e.clientY - rect.top) * scaleY; | |
| // Calculate mouse speed for dynamic ripple generation | |
| mouseSpeed = Math.sqrt((newX - mouseX)**2 + (newY - mouseY)**2); | |
| mouseX = newX; | |
| mouseY = newY; | |
| // Update UI display for mouse speed | |
| document.getElementById('mouseSpeed').textContent = Math.round(mouseSpeed); | |
| // Create ripples based on mouse speed | |
| // Only create a new ripple if speed is above a threshold and ripple count is not too high | |
| if (mouseSpeed > 3 && ripples.length < 20) { // Increased ripple limit for more dynamism | |
| createRipple(mouseX, mouseY, mouseSpeed); | |
| } | |
| }); | |
| // Click handler to create a strong ripple and trigger image generation | |
| canvas.addEventListener('click', () => { | |
| createRipple(mouseX, mouseY, 20); // Stronger ripple on click | |
| generateAIImage(); // Trigger AI image generation on click | |
| }); | |
| // Handle window resizing to keep canvas responsive | |
| window.addEventListener('resize', setupCanvas); | |
| console.log('事件設置完成'); | |
| } | |
| /** | |
| * Populates the `nodes` array, which forms the basis of the ink effect. | |
| * Nodes are distributed in a somewhat organic pattern. | |
| */ | |
| function createNodes() { | |
| nodes = []; | |
| const nodeCount = 40; // Increased node count for denser ink effect | |
| for (let i = 0; i < nodeCount; i++) { | |
| const angle = (i / nodeCount) * Math.PI * 4; // Multiple rotations for spread | |
| const radius = Math.random() * Math.min(canvas.width, canvas.height) * 0.3; // Wider distribution | |
| // Randomize initial positions slightly | |
| const x = canvas.width/2 + Math.cos(angle) * radius + (Math.random() - 0.5) * 100; | |
| const y = canvas.height/2 + Math.sin(angle) * radius + (Math.random() - 0.5) * 100; | |
| nodes.push({ | |
| x: Math.max(10, Math.min(canvas.width - 10, x)), // Keep nodes within canvas bounds | |
| y: Math.max(10, Math.min(canvas.height - 10, y)), | |
| originalX: x, // Store original position for spring back effect | |
| originalY: y, | |
| vx: 0, // Velocity X | |
| vy: 0, // Velocity Y | |
| phase: Math.random() * Math.PI * 2 // Random phase for independent motion | |
| }); | |
| } | |
| console.log(`創建了 ${nodes.length} 個節點`); | |
| } | |
| /** | |
| * Adds a new ripple effect to the `ripples` array. | |
| * @param {number} x - X coordinate of the ripple center. | |
| * @param {number} y - Y coordinate of the ripple center. | |
| * @param {number} intensity - The initial strength of the ripple. | |
| */ | |
| function createRipple(x, y, intensity) { | |
| ripples.push({ | |
| x: x, | |
| y: y, | |
| radius: 0, // Current radius of the ripple | |
| maxRadius: 30 + intensity * 2, // Max radius based on intensity | |
| life: 1, // Life factor from 1 (full) to 0 (dead) | |
| intensity: Math.min(intensity * 0.05, 1.0) // Clamped intensity for visual effect | |
| }); | |
| document.getElementById('rippleCount').textContent = ripples.length; | |
| } | |
| /** | |
| * Starts the ink simulation system. | |
| */ | |
| function startSystem() { | |
| console.log('啟動系統'); | |
| isRunning = true; | |
| isPaused = false; | |
| document.getElementById('systemStatus').textContent = '運行中'; | |
| showMessage('水墨系統已啟動!', 'success'); | |
| animate(); // Start the animation loop | |
| } | |
| /** | |
| * Resets the ink simulation to its initial state. | |
| */ | |
| function resetSystem() { | |
| console.log('重置系統'); | |
| time = 0; | |
| ripples = []; | |
| createNodes(); // Recreate nodes | |
| document.getElementById('rippleCount').textContent = '0'; | |
| showMessage('系統已重置', 'info'); | |
| // Clear the AI image area on reset | |
| document.getElementById('aiImage').style.display = 'none'; | |
| document.getElementById('aiStatus').style.display = 'flex'; // Show default status | |
| document.getElementById('aiStatus').innerHTML = '點擊生成按鈕開始創作'; | |
| document.getElementById('aiGenStatus').textContent = '待機'; | |
| document.getElementById('promptText').textContent = '點擊「生成」按鈕以產生AI圖像...'; | |
| } | |
| /** | |
| * Toggles the pause state of the simulation. | |
| */ | |
| function togglePause() { | |
| isPaused = !isPaused; | |
| const status = isPaused ? '暫停' : '繼續'; | |
| document.getElementById('systemStatus').textContent = status; | |
| showMessage(`系統已${status}`, 'info'); | |
| if (!isPaused && isRunning) { | |
| animate(); // Resume animation if not paused and running | |
| } | |
| } | |
| /** | |
| * Updates the physical properties of nodes and ripples. | |
| * This includes basic wave motion, mouse interaction, and ripple forces. | |
| */ | |
| function updatePhysics() { | |
| // Update node positions and velocities | |
| nodes.forEach(node => { | |
| // Basic fluid motion for nodes | |
| const waveX = Math.sin(time * 1.5 + node.phase) * 2; // Increased amplitude and speed | |
| const waveY = Math.cos(time * 1.2 + node.phase) * 2; | |
| const targetX = node.originalX + waveX; | |
| const targetY = node.originalY + waveY; | |
| // Spring-like force pulling nodes back to target position | |
| node.vx += (targetX - node.x) * 0.02; | |
| node.vy += (targetY - node.y) * 0.02; | |
| // Mouse influence on nodes - stronger effect | |
| const distToMouse = Math.sqrt((node.x - mouseX)**2 + (node.y - mouseY)**2); | |
| if (distToMouse < 100) { // Larger influence radius | |
| const influence = (1 - distToMouse / 100) * 0.5; // Stronger influence | |
| const mouseAngle = Math.atan2(node.y - mouseY, node.x - mouseX); | |
| node.vx += Math.cos(mouseAngle) * influence * mouseSpeed * 0.01; // Influence scales with mouse speed | |
| node.vy += Math.sin(mouseAngle) * influence * mouseSpeed * 0.01; | |
| } | |
| // Ripple influence on nodes | |
| ripples.forEach(ripple => { | |
| const distToRipple = Math.sqrt((node.x - ripple.x)**2 + (node.y - ripple.y)**2); | |
| // Check if node is near the ripple's expanding edge | |
| if (Math.abs(distToRipple - ripple.radius) < 10 && distToRipple > 0) { // Wider active band | |
| const rippleForce = ripple.intensity * ripple.life * 0.5; // Stronger ripple force | |
| const rippleAngle = Math.atan2(node.y - ripple.y, node.x - ripple.x); | |
| node.vx += Math.cos(rippleAngle) * rippleForce; | |
| node.vy += Math.sin(rippleAngle) * rippleForce; | |
| } | |
| }); | |
| // Apply friction and update position | |
| node.vx *= 0.88; // More damping | |
| node.vy *= 0.88; | |
| node.x += node.vx; | |
| node.y += node.vy; | |
| // Boundary collision (simple bounce) | |
| if (node.x < 0 || node.x > canvas.width) node.vx *= -0.8; | |
| if (node.y < 0 || node.y > canvas.height) node.vy *= -0.8; | |
| }); | |
| // Update ripples: expand and decay | |
| ripples = ripples.filter(ripple => { | |
| ripple.radius += 2.0; // Faster expansion | |
| ripple.life -= 0.03; // Faster decay | |
| return ripple.life > 0 && ripple.radius < ripple.maxRadius; | |
| }); | |
| document.getElementById('rippleCount').textContent = ripples.length; | |
| } | |
| /** | |
| * Renders the ink effect on the canvas. | |
| */ | |
| function render() { | |
| if (!ctx) return; | |
| // Clear canvas with a light background for ink effect | |
| ctx.fillStyle = '#f8f8f8'; | |
| ctx.fillRect(0, 0, canvas.width, canvas.height); | |
| // Apply blend mode for ink-like transparency | |
| ctx.globalCompositeOperation = 'multiply'; // Makes darker colors multiply | |
| // Render ripples | |
| ripples.forEach(ripple => { | |
| const opacity = ripple.life * ripple.intensity; | |
| ctx.strokeStyle = `rgba(0, 0, 0, ${opacity * 0.6})`; // Darker ink color | |
| ctx.lineWidth = 2; // Thicker lines | |
| ctx.beginPath(); | |
| ctx.arc(ripple.x, ripple.y, ripple.radius, 0, Math.PI * 2); | |
| ctx.stroke(); | |
| // Inner ripple for layered effect | |
| if (ripple.radius > 10) { | |
| ctx.strokeStyle = `rgba(0, 0, 0, ${opacity * 0.3})`; | |
| ctx.lineWidth = 1; | |
| ctx.beginPath(); | |
| ctx.arc(ripple.x, ripple.y, ripple.radius * 0.7, 0, Math.PI * 2); // Inner ripple expands slower | |
| ctx.stroke(); | |
| } | |
| }); | |
| // Render node connections (lines) | |
| for (let i = 0; i < nodes.length; i++) { | |
| for (let j = i + 1; j < nodes.length; j++) { | |
| const nodeA = nodes[i]; | |
| const nodeB = nodes[j]; | |
| const distance = Math.sqrt((nodeA.x - nodeB.x)**2 + (nodeA.y - nodeB.y)**2); | |
| if (distance < 120) { // Larger connection distance | |
| const opacity = (1 - distance / 120) * 0.4; // More opaque for closer nodes | |
| ctx.strokeStyle = `rgba(0, 0, 0, ${opacity})`; | |
| ctx.lineWidth = 0.8; // Thinner lines for connections | |
| ctx.beginPath(); | |
| ctx.moveTo(nodeA.x, nodeA.y); | |
| ctx.lineTo(nodeB.x, nodeB.y); | |
| ctx.stroke(); | |
| } | |
| } | |
| } | |
| // Render nodes | |
| nodes.forEach(node => { | |
| const opacity = 0.5 + Math.sin(time * 2 + node.phase) * 0.2; // Pulsating opacity | |
| ctx.fillStyle = `rgba(0, 0, 0, ${opacity})`; | |
| ctx.beginPath(); | |
| ctx.arc(node.x, node.y, 2, 0, Math.PI * 2); // Slightly larger nodes | |
| ctx.fill(); | |
| }); | |
| // Render mouse indicator | |
| ctx.fillStyle = 'rgba(0, 0, 0, 0.9)'; | |
| ctx.beginPath(); | |
| ctx.arc(mouseX, mouseY, 5, 0, Math.PI * 2); // Slightly larger mouse indicator | |
| ctx.fill(); | |
| // Mouse pulse effect | |
| const pulseSize = 7 + Math.sin(time * 6) * 3; // Faster and more pronounced pulse | |
| ctx.strokeStyle = 'rgba(0, 0, 0, 0.6)'; | |
| ctx.lineWidth = 2; | |
| ctx.beginPath(); | |
| ctx.arc(mouseX, mouseY, pulseSize, 0, Math.PI * 2); | |
| ctx.stroke(); | |
| // Reset blend mode and filters | |
| ctx.filter = 'none'; | |
| ctx.globalCompositeOperation = 'source-over'; | |
| } | |
| /** | |
| * The main animation loop. | |
| */ | |
| function animate() { | |
| if (!isRunning) return; | |
| if (!isPaused) { | |
| time += 0.02; // Advance time for physics | |
| updatePhysics(); // Update node and ripple physics | |
| render(); // Render the canvas | |
| // Calculate and display FPS | |
| frameCount++; | |
| const now = Date.now(); | |
| if (now - lastFpsTime >= 1000) { | |
| document.getElementById('fpsDisplay').textContent = frameCount; | |
| frameCount = 0; | |
| lastFpsTime = now; | |
| } | |
| } | |
| requestAnimationFrame(animate); // Request next frame | |
| } | |
| /** | |
| * Dynamically generates a prompt for the AI image based on the current canvas state. | |
| * The prompt is tailored for a more "programmatic" or "digital" abstract art style. | |
| * @returns {string} The generated prompt. | |
| */ | |
| function generatePromptFromCanvasState() { | |
| let prompt = "An abstract digital art piece, visualizing interconnected data streams,"; | |
| // Describe ripples as wave-like patterns or energy pulses | |
| if (ripples.length > 8) { // More intense ripple effect | |
| prompt += " with complex, expanding energy rings creating intricate network patterns,"; | |
| } else if (ripples.length > 3) { | |
| prompt += " with subtle, fluid wave-like disturbances,"; | |
| } else if (ripples.length > 0) { | |
| prompt += " featuring soft, concentric pulses,"; | |
| } | |
| // Describe mouse speed as density or intensity of lines/connections | |
| if (mouseSpeed > 70) { // Very fast mouse implies high energy, dense connections | |
| prompt += " bursting with dense, energetic nodes and thick, dynamic connections,"; | |
| } else if (mouseSpeed > 30) { // Moderate speed implies flowing, active lines | |
| prompt += " showcasing active, flowing lines and pulsating nodes,"; | |
| } else { // Slow speed implies subtle, minimalist lines | |
| prompt += " with delicate, subtle linear pathways and softly glowing points,"; | |
| } | |
| prompt += " minimalist aesthetic, monochromatic with dark grey and black lines on a very light, almost white background, clean abstract forms, high detail, algorithmic art, generative design, flowing liquid metal patterns."; | |
| return prompt; | |
| } | |
| /** | |
| * Calls the Gemini Image Generation API to create an AI image. | |
| * The prompt is dynamically generated based on the current canvas state. | |
| */ | |
| async function generateAIImage() { | |
| console.log('正在生成AI圖像...'); | |
| const aiImageElement = document.getElementById('aiImage'); | |
| const aiStatusElement = document.getElementById('aiStatus'); | |
| const aiGenStatusDisplay = document.getElementById('aiGenStatus'); | |
| const promptTextDisplay = document.getElementById('promptText'); | |
| const spinnerElement = aiStatusElement.querySelector('.spinner'); | |
| // Show loading state | |
| aiGenStatusDisplay.textContent = '生成中...'; | |
| aiImageElement.style.display = 'none'; | |
| aiStatusElement.style.display = 'flex'; // Show loading text and spinner | |
| aiStatusElement.innerHTML = `<div class="spinner"></div><p>正在生成圖像,請稍候...</p>`; | |
| spinnerElement.style.display = 'block'; // Ensure spinner is visible | |
| const prompt = generatePromptFromCanvasState(); | |
| promptTextDisplay.textContent = prompt; // Display the prompt being sent | |
| try { | |
| // Gemini API call structure | |
| const payload = { | |
| instances: { prompt: prompt }, | |
| parameters: { "sampleCount": 1 } | |
| }; | |
| // __app_id and __api_key are globally available variables in the Canvas environment | |
| const apiKey = ""; // Leave empty, Canvas will inject it | |
| const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/imagen-3.0-generate-002:predict?key=${apiKey}`; | |
| const response = await fetch(apiUrl, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(payload) | |
| }); | |
| if (!response.ok) { | |
| const errorData = await response.json(); | |
| throw new Error(`API Error: ${response.status} - ${errorData.error.message || 'Unknown error'}`); | |
| } | |
| const result = await response.json(); | |
| if (result.predictions && result.predictions.length > 0 && result.predictions[0].bytesBase64Encoded) { | |
| const imageUrl = `data:image/png;base64,${result.predictions[0].bytesBase64Encoded}`; | |
| aiImageElement.src = imageUrl; | |
| aiImageElement.style.display = 'block'; // Show the image | |
| aiStatusElement.style.display = 'none'; // Hide loading status | |
| aiGenStatusDisplay.textContent = '完成'; | |
| showMessage('AI圖像生成完成!', 'success'); | |
| } else { | |
| throw new Error('API response did not contain expected image data.'); | |
| } | |
| } catch (error) { | |
| console.error('生成AI圖像失敗:', error); | |
| aiStatusElement.style.display = 'flex'; // Show error status | |
| aiStatusElement.innerHTML = `生成失敗: ${error.message}`; | |
| aiImageElement.style.display = 'none'; // Hide the image | |
| aiGenStatusDisplay.textContent = '失敗'; | |
| showMessage(`生成失敗: ${error.message}`, 'error'); | |
| } finally { | |
| if (spinnerElement) { | |
| spinnerElement.style.display = 'none'; // Ensure spinner is hidden | |
| } | |
| } | |
| } | |
| /** | |
| * Displays a temporary message box to the user. | |
| * @param {string} text - The message text. | |
| * @param {string} type - The type of message ('success', 'error', 'info'). | |
| */ | |
| function showMessage(text, type) { | |
| const msg = document.getElementById('messageBox'); | |
| msg.textContent = text; | |
| msg.className = `message show ${type}`; | |
| // Message fades out after 4 seconds | |
| setTimeout(() => { | |
| msg.classList.remove('show'); | |
| }, 4000); | |
| } | |
| // Initialize the system when the DOM content is fully loaded | |
| document.addEventListener('DOMContentLoaded', () => { | |
| console.log('DOM載入完成'); | |
| init(); | |
| }); | |
| </script> | |
| </body> | |
| </html> | |