Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>LUXE | Professional Studio Suite</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;700&family=Inter:wght@300;400;600&display=swap" rel="stylesheet"> | |
| <style> | |
| :root { | |
| --primary-red: #8B0000; | |
| --accent-gold: #D4AF37; | |
| --deep-grey: #1a1a1a; | |
| --ui-green: #10B981; | |
| } | |
| body { | |
| font-family: 'Inter', sans-serif; | |
| background-color: #0f0f0f; | |
| color: #e5e5e5; | |
| overflow-x: hidden; | |
| } | |
| h1, h2, h3, .serif { | |
| font-family: 'Playfair Display', serif; | |
| } | |
| /* Custom Scrollbar */ | |
| ::-webkit-scrollbar { | |
| width: 8px; | |
| height: 8px; | |
| } | |
| ::-webkit-scrollbar-track { | |
| background: #1a1a1a; | |
| } | |
| ::-webkit-scrollbar-thumb { | |
| background: #333; | |
| border-radius: 4px; | |
| } | |
| ::-webkit-scrollbar-thumb:hover { | |
| background: var(--primary-red); | |
| } | |
| /* Glassmorphism */ | |
| .glass-panel { | |
| background: rgba(30, 30, 30, 0.7); | |
| backdrop-filter: blur(12px); | |
| -webkit-backdrop-filter: blur(12px); | |
| border: 1px solid rgba(255, 255, 255, 0.08); | |
| box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37); | |
| } | |
| /* Neon Glows */ | |
| .glow-gold { | |
| box-shadow: 0 0 15px rgba(212, 175, 55, 0.3); | |
| } | |
| .glow-red { | |
| box-shadow: 0 0 15px rgba(139, 0, 0, 0.4); | |
| } | |
| .text-glow { | |
| text-shadow: 0 0 10px rgba(212, 175, 55, 0.5); | |
| } | |
| /* Range Slider Styling */ | |
| input[type=range] { | |
| -webkit-appearance: none; | |
| background: transparent; | |
| } | |
| input[type=range]::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| height: 16px; | |
| width: 16px; | |
| border-radius: 50%; | |
| background: var(--accent-gold); | |
| cursor: pointer; | |
| margin-top: -6px; | |
| box-shadow: 0 0 10px rgba(212, 175, 55, 0.8); | |
| } | |
| input[type=range]::-webkit-slider-runnable-track { | |
| width: 100%; | |
| height: 4px; | |
| cursor: pointer; | |
| background: #333; | |
| border-radius: 2px; | |
| } | |
| /* Loader */ | |
| .loader { | |
| border: 3px solid rgba(255,255,255,0.1); | |
| border-left-color: var(--accent-gold); | |
| border-radius: 50%; | |
| width: 40px; | |
| height: 40px; | |
| animation: spin 1s linear infinite; | |
| } | |
| @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } | |
| /* Canvas Container */ | |
| #canvas-container { | |
| background-image: | |
| linear-gradient(45deg, #1a1a1a 25%, transparent 25%), | |
| linear-gradient(-45deg, #1a1a1a 25%, transparent 25%), | |
| linear-gradient(45deg, transparent 75%, #1a1a1a 75%), | |
| linear-gradient(-45deg, transparent 75%, #1a1a1a 75%); | |
| background-size: 20px 20px; | |
| background-position: 0 0, 0 10px, 10px -10px, -10px 0px; | |
| background-color: #0f0f0f; | |
| } | |
| .tool-btn.active { | |
| background-color: rgba(212, 175, 55, 0.1); | |
| border-left: 3px solid var(--accent-gold); | |
| color: var(--accent-gold); | |
| } | |
| /* Custom Checkbox */ | |
| .toggle-checkbox:checked { | |
| right: 0; | |
| border-color: var(--ui-green); | |
| } | |
| .toggle-checkbox:checked + .toggle-label { | |
| background-color: var(--ui-green); | |
| } | |
| /* Grain Overlay */ | |
| .noise-overlay { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| pointer-events: none; | |
| z-index: 9999; | |
| opacity: 0.03; | |
| background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E"); | |
| } | |
| </style> | |
| </head> | |
| <body class="flex flex-col h-screen"> | |
| <!-- Noise Texture --> | |
| <div class="noise-overlay"></div> | |
| <!-- Header --> | |
| <header class="h-16 border-b border-white/10 flex items-center justify-between px-6 bg-[#0f0f0f] z-50 relative"> | |
| <div class="flex items-center gap-3"> | |
| <div class="w-8 h-8 bg-gradient-to-br from-[#8B0000] to-[#D4AF37] rounded-sm flex items-center justify-center shadow-lg shadow-red-900/20"> | |
| <span class="font-serif font-bold text-white text-lg">L</span> | |
| </div> | |
| <div> | |
| <h1 class="text-xl font-serif tracking-widest text-white">LUXE <span class="text-[#D4AF37] text-xs align-top">STUDIO</span></h1> | |
| <p class="text-[10px] text-gray-500 tracking-widest uppercase">Professional Post-Production Suite</p> | |
| </div> | |
| </div> | |
| <div class="flex items-center gap-6"> | |
| <a href="https://huggingface.co/spaces/akhaliq/anycoder" target="_blank" class="text-xs text-gray-400 hover:text-[#D4AF37] transition-colors uppercase tracking-wider border-b border-transparent hover:border-[#D4AF37]"> | |
| Built with anycoder | |
| </a> | |
| <div class="h-4 w-[1px] bg-white/10"></div> | |
| <div class="flex items-center gap-2 text-xs text-gray-400"> | |
| <span class="w-2 h-2 rounded-full bg-[#10B981] animate-pulse"></span> | |
| System Online | |
| </div> | |
| <button onclick="exportImage()" class="bg-[#D4AF37] hover:bg-[#b5952f] text-black px-6 py-2 text-sm font-bold tracking-wide transition-all shadow-[0_0_15px_rgba(212,175,55,0.3)]"> | |
| EXPORT FINAL CUT | |
| </button> | |
| </div> | |
| </header> | |
| <!-- Main Workspace --> | |
| <div class="flex-1 flex overflow-hidden relative"> | |
| <!-- Left Sidebar: Tools --> | |
| <aside class="w-80 bg-[#141414] border-r border-white/5 flex flex-col z-40 overflow-y-auto"> | |
| <!-- File Upload Zone --> | |
| <div class="p-6 border-b border-white/5"> | |
| <label for="file-upload" class="group cursor-pointer flex flex-col items-center justify-center w-full h-32 border-2 border-dashed border-white/10 rounded-lg hover:border-[#D4AF37]/50 hover:bg-white/5 transition-all"> | |
| <div class="flex flex-col items-center justify-center pt-5 pb-6"> | |
| <svg class="w-8 h-8 mb-3 text-gray-400 group-hover:text-[#D4AF37]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"></path></svg> | |
| <p class="text-xs text-gray-400">Click to upload RAW</p> | |
| </div> | |
| <input id="file-upload" type="file" class="hidden" accept="image/*" /> | |
| </label> | |
| </div> | |
| <!-- Tool Categories --> | |
| <div class="flex-1"> | |
| <!-- Section: Retouch --> | |
| <div class="p-4 border-b border-white/5"> | |
| <h3 class="text-xs font-bold text-gray-500 uppercase tracking-widest mb-4">Skin & Tone</h3> | |
| <div class="space-y-5"> | |
| <div> | |
| <div class="flex justify-between mb-1"> | |
| <label class="text-xs text-gray-300">Smoothing (Frequency Sep)</label> | |
| <span id="val-smooth" class="text-xs text-[#D4AF37]">0%</span> | |
| </div> | |
| <input type="range" id="smooth" min="0" max="100" value="0" class="w-full"> | |
| </div> | |
| <div> | |
| <div class="flex justify-between mb-1"> | |
| <label class="text-xs text-gray-300">Tan / Bronze</label> | |
| <span id="val-tan" class="text-xs text-[#D4AF37]">0%</span> | |
| </div> | |
| <input type="range" id="tan" min="0" max="100" value="0" class="w-full"> | |
| </div> | |
| <div> | |
| <div class="flex justify-between mb-1"> | |
| <label class="text-xs text-gray-300">Highlights (Oily Skin Fix)</label> | |
| <span id="val-highlight" class="text-xs text-[#D4AF37]">0%</span> | |
| </div> | |
| <input type="range" id="highlight" min="0" max="100" value="0" class="w-full"> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Section: Color Grading --> | |
| <div class="p-4 border-b border-white/5"> | |
| <h3 class="text-xs font-bold text-gray-500 uppercase tracking-widest mb-4">Cinematic Grade</h3> | |
| <div class="space-y-5"> | |
| <div> | |
| <div class="flex justify-between mb-1"> | |
| <label class="text-xs text-gray-300">Teal & Orange Shift</label> | |
| <span id="val-teal" class="text-xs text-[#D4AF37]">0%</span> | |
| </div> | |
| <input type="range" id="teal" min="0" max="100" value="0" class="w-full"> | |
| </div> | |
| <div> | |
| <div class="flex justify-between mb-1"> | |
| <label class="text-xs text-gray-300">Vignette (Spotlight)</label> | |
| <span id="val-vignette" class="text-xs text-[#D4AF37]">0%</span> | |
| </div> | |
| <input type="range" id="vignette" min="0" max="100" value="0" class="w-full"> | |
| </div> | |
| <div> | |
| <div class="flex justify-between mb-1"> | |
| <label class="text-xs text-gray-300">Film Grain</label> | |
| <span id="val-grain" class="text-xs text-[#D4AF37]">0%</span> | |
| </div> | |
| <input type="range" id="grain" min="0" max="100" value="0" class="w-full"> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Section: Lighting --> | |
| <div class="p-4 border-b border-white/5"> | |
| <h3 class="text-xs font-bold text-gray-500 uppercase tracking-widest mb-4">Studio Lighting</h3> | |
| <div class="space-y-5"> | |
| <div> | |
| <div class="flex justify-between mb-1"> | |
| <label class="text-xs text-gray-300">Key Light (Exposure)</label> | |
| <span id="val-exposure" class="text-xs text-[#D4AF37]">0</span> | |
| </div> | |
| <input type="range" id="exposure" min="-50" max="50" value="0" class="w-full"> | |
| </div> | |
| <div class="flex items-center justify-between"> | |
| <label class="text-xs text-gray-300">Neon Rim Light (Red)</label> | |
| <div class="relative inline-block w-10 mr-2 align-middle select-none transition duration-200 ease-in"> | |
| <input type="checkbox" name="toggle" id="neon-toggle" class="toggle-checkbox absolute block w-5 h-5 rounded-full bg-white border-4 appearance-none cursor-pointer border-gray-600"/> | |
| <label for="neon-toggle" class="toggle-label block overflow-hidden h-5 rounded-full bg-gray-700 cursor-pointer"></label> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </aside> | |
| <!-- Center: Canvas Area --> | |
| <main class="flex-1 bg-[#0f0f0f] relative flex flex-col"> | |
| <!-- Toolbar --> | |
| <div class="h-12 bg-[#1a1a1a] border-b border-white/5 flex items-center justify-center gap-4 px-4"> | |
| <span class="text-xs text-gray-500 uppercase tracking-widest mr-4">View Mode:</span> | |
| <button onclick="setZoom(0.5)" class="text-gray-400 hover:text-white text-xs px-2 py-1 rounded hover:bg-white/5">50%</button> | |
| <button onclick="setZoom(1)" class="text-[#D4AF37] text-xs px-2 py-1 rounded bg-white/5 border border-[#D4AF37]/30">100%</button> | |
| <button onclick="setZoom(2)" class="text-gray-400 hover:text-white text-xs px-2 py-1 rounded hover:bg-white/5">200%</button> | |
| <div class="w-[1px] h-4 bg-white/10 mx-2"></div> | |
| <button onclick="resetFilters()" class="text-[#8B0000] hover:text-red-500 text-xs uppercase tracking-wider font-bold flex items-center gap-1"> | |
| <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path></svg> | |
| Reset All | |
| </button> | |
| </div> | |
| <!-- Canvas Wrapper --> | |
| <div id="canvas-container" class="flex-1 overflow-auto flex items-center justify-center p-8 relative"> | |
| <div id="loader" class="hidden absolute inset-0 bg-black/80 z-50 flex flex-col items-center justify-center"> | |
| <div class="loader mb-4"></div> | |
| <p class="text-[#D4AF37] text-sm tracking-widest animate-pulse">PROCESSING LAYERS...</p> | |
| </div> | |
| <!-- The Canvas --> | |
| <canvas id="editor" class="shadow-2xl shadow-black max-w-full max-h-full"></canvas> | |
| <!-- Empty State --> | |
| <div id="empty-state" class="absolute inset-0 flex flex-col items-center justify-center pointer-events-none"> | |
| <div class="w-24 h-24 border border-white/10 rounded-full flex items-center justify-center mb-4"> | |
| <svg class="w-10 h-10 text-white/20" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg> | |
| </div> | |
| <p class="text-gray-600 font-serif text-lg">No Image Loaded</p> | |
| <p class="text-gray-700 text-xs mt-2">Upload a RAW file to begin editing</p> | |
| </div> | |
| </div> | |
| </main> | |
| <!-- Right Sidebar: Details & History --> | |
| <aside class="w-72 bg-[#141414] border-l border-white/5 flex flex-col z-40"> | |
| <div class="p-5 border-b border-white/5"> | |
| <h3 class="text-xs font-bold text-gray-500 uppercase tracking-widest mb-4">Asset Info</h3> | |
| <div class="space-y-3 text-xs text-gray-400 font-mono"> | |
| <div class="flex justify-between"> | |
| <span>Resolution</span> | |
| <span id="meta-res" class="text-gray-200">---</span> | |
| </div> | |
| <div class="flex justify-between"> | |
| <span>Format</span> | |
| <span class="text-gray-200">RAW / JPG</span> | |
| </div> | |
| <div class="flex justify-between"> | |
| <span>Color Space</span> | |
| <span class="text-gray-200">sRGB IEC</span> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="flex-1 p-5 overflow-y-auto"> | |
| <h3 class="text-xs font-bold text-gray-500 uppercase tracking-widest mb-4">Action History</h3> | |
| <ul id="history-list" class="space-y-2 text-xs"> | |
| <li class="text-gray-600 italic">No actions recorded...</li> | |
| </ul> | |
| </div> | |
| <div class="p-5 border-t border-white/5 bg-[#0f0f0f]"> | |
| <div class="bg-[#8B0000]/10 border border-[#8B0000]/30 p-3 rounded"> | |
| <h4 class="text-[#8B0000] text-xs font-bold uppercase mb-1">Pro Tip</h4> | |
| <p class="text-gray-400 text-[10px] leading-relaxed"> | |
| For skin retouching, keep smoothing below 40% to maintain texture detail. Use the "Oily Skin Fix" to matte down highlights. | |
| </p> | |
| </div> | |
| </div> | |
| </aside> | |
| </div> | |
| <script> | |
| // --- Configuration & State --- | |
| const canvas = document.getElementById('editor'); | |
| const ctx = canvas.getContext('2d', { willReadFrequently: true }); | |
| const fileInput = document.getElementById('file-upload'); | |
| const emptyState = document.getElementById('empty-state'); | |
| const loader = document.getElementById('loader'); | |
| const historyList = document.getElementById('history-list'); | |
| let originalImage = new Image(); | |
| let currentImage = null; // Will hold the processed image data for base | |
| let fileName = 'luxe-edit.jpg'; | |
| let isProcessing = false; | |
| // Filter State | |
| const filters = { | |
| smooth: 0, | |
| tan: 0, | |
| highlight: 0, | |
| teal: 0, | |
| vignette: 0, | |
| grain: 0, | |
| exposure: 0, | |
| neon: false | |
| }; | |
| // --- Event Listeners --- | |
| // File Upload | |
| fileInput.addEventListener('change', (e) => { | |
| const file = e.target.files[0]; | |
| if (!file) return; | |
| fileName = file.name; | |
| const reader = new FileReader(); | |
| reader.onload = (event) => { | |
| originalImage.src = event.target.result; | |
| }; | |
| reader.readAsDataURL(file); | |
| }); | |
| originalImage.onload = () => { | |
| // Resize canvas to match image but cap max size for performance | |
| const maxWidth = 1920; | |
| let width = originalImage.width; | |
| let height = originalImage.height; | |
| if (width > maxWidth) { | |
| height *= maxWidth / width; | |
| width = maxWidth; | |
| } | |
| canvas.width = width; | |
| canvas.height = height; | |
| // Draw original | |
| ctx.drawImage(originalImage, 0, 0, width, height); | |
| // Store original pixel data for non-destructive editing base | |
| // Note: For a real app we'd store the image object, here we redraw from source | |
| // But for pixel manipulation we need the buffer. | |
| emptyState.style.display = 'none'; | |
| updateMeta(); | |
| addHistory('Image Loaded'); | |
| render(); | |
| }; | |
| // Sliders | |
| const sliderIds = ['smooth', 'tan', 'highlight', 'teal', 'vignette', 'grain', 'exposure']; | |
| sliderIds.forEach(id => { | |
| const el = document.getElementById(id); | |
| el.addEventListener('input', (e) => { | |
| filters[id] = parseInt(e.target.value); | |
| document.getElementById(`val-${id}`).innerText = filters[id] + (id === 'exposure' ? '' : '%'); | |
| requestAnimationFrame(render); | |
| }); | |
| // Add history on change (mouse up) | |
| el.addEventListener('change', () => { | |
| addHistory(`${id.charAt(0).toUpperCase() + id.slice(1)} adjusted to ${filters[id]}`); | |
| }); | |
| }); | |
| // Toggles | |
| document.getElementById('neon-toggle').addEventListener('change', (e) => { | |
| filters.neon = e.target.checked; | |
| addHistory(filters.neon ? 'Neon Rim Light ON' : 'Neon Rim Light OFF'); | |
| render(); | |
| }); | |
| // --- Core Rendering Logic --- | |
| function render() { | |
| if (!originalImage.src) return; | |
| // 1. Draw Base Image | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| ctx.drawImage(originalImage, 0, 0, canvas.width, canvas.height); | |
| // Get image data for pixel manipulation | |
| // Note: getImageData is expensive. In a high-perf app, use WebGL. | |
| // For this demo, we use it for specific effects. | |
| // Helper to apply filters | |
| ctx.save(); | |
| // Global Exposure (Brightness/Contrast composite) | |
| // We simulate exposure by adjusting brightness and contrast slightly | |
| const exposureVal = filters.exposure; | |
| // Simple exposure simulation using filter string | |
| let filterString = `brightness(${100 + exposureVal}%)`; | |
| // Teal & Orange | |
| if (filters.teal > 0) { | |
| // Sepia adds the orange, Hue-rotate shifts to teal shadows (simplified) | |
| filterString += ` sepia(${filters.teal * 0.3}%) contrast(${100 + filters.teal * 0.1}%)`; | |
| } | |
| ctx.filter = filterString; | |
| ctx.drawImage(originalImage, 0, 0, canvas.width, canvas.height); | |
| ctx.filter = 'none'; // Reset for overlays | |
| // 2. Skin Smoothing (Simulated via Blur + Blend) | |
| if (filters.smooth > 0) { | |
| ctx.globalAlpha = filters.smooth / 100; | |
| ctx.filter = `blur(${filters.smooth / 10}px)`; | |
| ctx.drawImage(originalImage, 0, 0, canvas.width, canvas.height); | |
| ctx.filter = 'none'; | |
| ctx.globalAlpha = 1.0; | |
| } | |
| // 3. Tan / Bronze (Overlay Color) | |
| if (filters.tan > 0) { | |
| ctx.globalCompositeOperation = 'overlay'; | |
| ctx.fillStyle = `rgba(205, 127, 50, ${filters.tan / 150})`; // Bronze color | |
| ctx.fillRect(0, 0, canvas.width, canvas.height); | |
| ctx.globalCompositeOperation = 'source-over'; | |
| } | |
| // 4. Highlights / Matte (White overlay with mask logic simulated) | |
| if (filters.highlight > 0) { | |
| ctx.globalCompositeOperation = 'screen'; // Lighten | |
| // This is a rough approximation. Real high-pass filter is better. | |
| // We just reduce opacity of a white layer where it's bright? Hard to do without pixel loop. | |
| // Let's use a simpler approach: Draw image lighter, mix it. | |
| ctx.globalAlpha = filters.highlight / 200; | |
| ctx.filter = 'brightness(150%)'; | |
| ctx.drawImage(originalImage, 0, 0, canvas.width, canvas.height); | |
| ctx.filter = 'none'; | |
| ctx.globalAlpha = 1.0; | |
| ctx.globalCompositeOperation = 'source-over'; | |
| } | |
| // 5. Vignette | |
| if (filters.vignette > 0) { | |
| const gradient = ctx.createRadialGradient( | |
| canvas.width / 2, canvas.height / 2, canvas.width * 0.3, | |
| canvas.width / 2, canvas.height / 2, canvas.width * 0.8 | |
| ); | |
| gradient.addColorStop(0, 'rgba(0,0,0,0)'); | |
| gradient.addColorStop(1, `rgba(0,0,0,${filters.vignette / 100})`); | |
| ctx.fillStyle = gradient; | |
| ctx.fillRect(0, 0, canvas.width, canvas.height); | |
| } | |
| // 6. Neon Rim Light (Simulated) | |
| if (filters.neon) { | |
| ctx.shadowColor = "#ff0000"; | |
| ctx.shadowBlur = 40; | |
| ctx.globalCompositeOperation = 'screen'; | |
| ctx.globalAlpha = 0.6; | |
| ctx.drawImage(originalImage, 0, 0, canvas.width, canvas.height); | |
| ctx.globalAlpha = 1.0; | |
| ctx.globalCompositeOperation = 'source-over'; | |
| ctx.shadowBlur = 0; | |
| } | |
| // 7. Film Grain | |
| if (filters.grain > 0) { | |
| drawNoise(filters.grain); | |
| } | |
| ctx.restore(); | |
| } | |
| // Helper: Procedural Noise | |
| function drawNoise(amount) { | |
| const w = canvas.width; | |
| const h = canvas.height; | |
| const idata = ctx.getImageData(0, 0, w, h); | |
| const buffer32 = new Uint32Array(idata.data.buffer); | |
| const len = buffer32.length; | |
| for (let i = 0; i < len; i++) { | |
| if (Math.random() < 0.5) continue; // Skip some pixels for performance | |
| // Generate noise | |
| let noise = (Math.random() - 0.5) * amount; | |
| // Apply noise to pixel (simplified) | |
| // This is a very basic noise implementation | |
| // Real film grain is more complex | |
| let val = buffer32[i]; | |
| // Extract alpha to ensure we don't mess with transparent pixels if any | |
| let a = (val >> 24) & 0xff; | |
| if (a === 0) continue; | |
| // We can't easily edit the Uint32 directly without bit shifting for RGB separately | |
| // So we fallback to a canvas overlay method for grain to be faster | |
| // Better Grain Approach: Create small noise canvas and overlay | |
| } | |
| // Re-doing grain via overlay for performance | |
| ctx.save(); | |
| ctx.globalAlpha = amount / 300; // subtle | |
| ctx.globalCompositeOperation = 'overlay'; | |
| // We would need a pre-generated noise image. | |
| // Since we can't load external assets easily without CORS/Reliability issues in this specific prompt context, | |
| // We will draw random rects (very fast) to simulate noise. | |
| ctx.fillStyle = '#808080'; // Neutral grey for overlay blend | |
| // Optimization: Draw a few thousand random dots | |
| for(let i=0; i<5000; i++) { | |
| const x = Math.random() * w; | |
| const y = Math.random() * h; | |
| const s = Math.random() * 2; | |
| ctx.fillStyle = Math.random() > 0.5 ? '#ffffff' : '#000000'; | |
| ctx.globalAlpha = Math.random() * (amount / 100); | |
| ctx.fillRect(x, y, s, s); | |
| } | |
| ctx.restore(); | |
| } | |
| // --- Utilities --- | |
| function setZoom(level) { | |
| // CSS Transform for zooming the canvas container visually | |
| // Note: This doesn't change canvas resolution, just view | |
| const container = document.getElementById('canvas-container'); | |
| // Simple implementation: Just width/height css | |
| // For a robust app, use transform: scale() | |
| canvas.style.transform = `scale(${level})`; | |
| canvas.style.transition = 'transform 0.3s ease'; | |
| } | |
| function resetFilters() { | |
| sliderIds.forEach(id => { | |
| document.getElementById(id).value = 0; | |
| document.getElementById(`val-${id}`).innerText = id === 'exposure' ? '0' : '0%'; | |
| filters[id] = 0; | |
| }); | |
| document.getElementById('neon-toggle').checked = false; | |
| filters.neon = false; | |
| addHistory('Reset all filters'); | |
| render(); | |
| } | |
| function updateMeta() { | |
| document.getElementById('meta-res').innerText = `${canvas.width} x ${canvas.height}`; | |
| } | |
| function addHistory(action) { | |
| const li = document.createElement('li'); | |
| li.className = "flex justify-between items-center text-gray-300 border-b border-white/5 pb-1 animate-[fadeIn_0.3s_ease-in]"; | |
| const time = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); | |
| li.innerHTML = ` | |
| <span>${action}</span> | |
| <span class="text-[10px] text-gray-600 font-mono">${time}</span> | |
| `; | |
| // Remove "No actions" text if present | |
| if (historyList.children[0] && historyList.children[0].innerText.includes('No actions')) { | |
| historyList.innerHTML = ''; | |
| } | |
| historyList.prepend(li); | |
| } | |
| function exportImage() { | |
| if (!originalImage.src) return alert("Please load an image first"); | |
| // Simulate processing delay | |
| loader.classList.remove('hidden'); | |
| setTimeout(() => { | |
| const link = document.createElement('a'); | |
| link.download = 'luxe-final-cut-' + Date.now() + '.jpg'; | |
| link.href = canvas.toDataURL('image/jpeg', 0.9); | |
| link.click(); | |
| loader.classList.add('hidden'); | |
| addHistory('Exported Final Cut'); | |
| }, 1500); | |
| } | |
| // Initial Animation | |
| window.addEventListener('DOMContentLoaded', () => { | |
| document.body.style.opacity = 0; | |
| setTimeout(() => { | |
| document.body.style.transition = 'opacity 1s ease'; | |
| document.body.style.opacity = 1; | |
| }, 100); | |
| }); | |
| </script> | |
| </body> | |
| </html> |