Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> | |
| <title>UltraFX: Dynamic Image & Video Processor</title> | |
| <!-- Core Libraries via CDN --> | |
| <script src="https://cdn.jsdelivr.net/npm/mathjs@11.8.0/lib/browser/math.js"></script> | |
| <!-- p5 and Three/Pixi are loaded as requested, though glfx handles the heavy WebGL lifting here --> | |
| <script src="https://cdn.jsdelivr.net/npm/p5@1.6.0/lib/p5.min.js"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/glfx.js@0.0.4/glfx.min.js"></script> | |
| <!-- UI Styling --> | |
| <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> | |
| <link href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.4.0/css/all.min.css" rel="stylesheet"> | |
| <link href="https://fonts.googleapis.com/css2?family=Rajdhani:wght@400;600;700&display=swap" rel="stylesheet"> | |
| <style> | |
| :root { | |
| --neon-blue: #00f3ff; | |
| --neon-pink: #ff00ff; | |
| --dark-bg: #0a0a12; | |
| --panel-bg: rgba(15, 15, 20, 0.95); | |
| --border-color: rgba(0, 243, 255, 0.3); | |
| } | |
| body { | |
| background-color: var(--dark-bg); | |
| color: #fff; | |
| font-family: 'Rajdhani', sans-serif; | |
| overflow: hidden; | |
| height: 100vh; | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| /* --- Header / Nav --- */ | |
| .top-bar { | |
| height: 50px; | |
| background: rgba(0, 0, 0, 0.8); | |
| border-bottom: 1px solid var(--border-color); | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| padding: 0 1rem; | |
| z-index: 200; | |
| } | |
| .brand { | |
| font-weight: 700; | |
| font-size: 1.2rem; | |
| color: #fff; | |
| letter-spacing: 1px; | |
| } | |
| .brand span { | |
| color: var(--neon-blue); | |
| } | |
| .anycoder-link { | |
| font-size: 0.8rem; | |
| color: var(--text-muted); | |
| text-decoration: none; | |
| border-bottom: 1px dashed #555; | |
| margin-left: auto; | |
| margin-right: 15px; | |
| } | |
| .anycoder-link:hover { | |
| color: var(--neon-blue); | |
| border-color: var(--neon-blue); | |
| } | |
| /* --- Main Layout --- */ | |
| .app-wrapper { | |
| display: flex; | |
| flex: 1; | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| /* --- Canvas Area --- */ | |
| #canvas-wrapper { | |
| flex-grow: 1; | |
| position: relative; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| background: radial-gradient(circle at center, #1a1a2e 0%, #000 100%); | |
| overflow: hidden; | |
| padding: 20px; | |
| } | |
| canvas { | |
| box-shadow: 0 0 30px rgba(0, 243, 255, 0.1); | |
| max-width: 100%; | |
| max-height: 100%; | |
| border: 1px solid #333; | |
| } | |
| /* --- Controls Sidebar --- */ | |
| #controls { | |
| width: 320px; | |
| background: var(--panel-bg); | |
| border-left: 1px solid var(--border-color); | |
| display: flex; | |
| flex-direction: column; | |
| backdrop-filter: blur(10px); | |
| z-index: 100; | |
| transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); | |
| } | |
| .sidebar-header { | |
| padding: 15px; | |
| border-bottom: 1px solid #333; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| } | |
| .sidebar-title { | |
| font-weight: 600; | |
| color: var(--neon-blue); | |
| font-size: 1rem; | |
| } | |
| #dynamic-controls { | |
| flex: 1; | |
| overflow-y: auto; | |
| padding: 15px; | |
| } | |
| /* --- Control Groups --- */ | |
| .control-group { | |
| background: rgba(255, 255, 255, 0.03); | |
| border-radius: 6px; | |
| padding: 10px; | |
| margin-bottom: 12px; | |
| border: 1px solid rgba(255, 255, 255, 0.05); | |
| transition: border-color 0.2s; | |
| } | |
| .control-group:hover { | |
| border-color: rgba(0, 243, 255, 0.2); | |
| } | |
| .control-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 8px; | |
| } | |
| .control-label { | |
| font-weight: 600; | |
| color: #ddd; | |
| font-size: 0.85rem; | |
| text-transform: uppercase; | |
| } | |
| /* --- Inputs & Sliders --- */ | |
| input[type=range] { | |
| width: 100%; | |
| height: 4px; | |
| background: #333; | |
| border-radius: 2px; | |
| outline: none; | |
| -webkit-appearance: none; | |
| } | |
| input[type=range]::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| width: 14px; | |
| height: 14px; | |
| background: var(--neon-blue); | |
| border-radius: 50%; | |
| cursor: pointer; | |
| box-shadow: 0 0 10px var(--neon-blue); | |
| } | |
| .math-input { | |
| background: #000; | |
| border: 1px solid #333; | |
| color: var(--neon-pink); | |
| font-family: 'Courier New', monospace; | |
| width: 100%; | |
| font-size: 0.75rem; | |
| padding: 6px; | |
| margin-top: 6px; | |
| border-radius: 4px; | |
| } | |
| .math-input:focus { | |
| border-color: var(--neon-pink); | |
| outline: none; | |
| } | |
| /* --- Buttons --- */ | |
| .btn-neon { | |
| background: rgba(0, 243, 255, 0.1); | |
| border: 1px solid var(--neon-blue); | |
| color: var(--neon-blue); | |
| text-transform: uppercase; | |
| font-weight: 700; | |
| font-size: 0.8rem; | |
| letter-spacing: 1px; | |
| transition: all 0.2s; | |
| width: 100%; | |
| margin-bottom: 8px; | |
| padding: 8px; | |
| border-radius: 4px; | |
| cursor: pointer; | |
| } | |
| .btn-neon:hover { | |
| background: var(--neon-blue); | |
| color: #000; | |
| box-shadow: 0 0 15px rgba(0, 243, 255, 0.4); | |
| } | |
| .btn-neon i { | |
| margin-right: 6px; | |
| } | |
| /* --- Toggle Switch --- */ | |
| .toggle-switch { | |
| position: relative; | |
| display: inline-block; | |
| width: 34px; | |
| height: 18px; | |
| } | |
| .toggle-switch input { | |
| opacity: 0; | |
| width: 0; | |
| height: 0; | |
| } | |
| .slider { | |
| position: absolute; | |
| cursor: pointer; | |
| top: 0; | |
| left: 0; | |
| right: 0; | |
| bottom: 0; | |
| background-color: #333; | |
| transition: .4s; | |
| border-radius: 18px; | |
| } | |
| .slider:before { | |
| position: absolute; | |
| content: ""; | |
| height: 14px; | |
| width: 14px; | |
| left: 2px; | |
| bottom: 2px; | |
| background-color: white; | |
| transition: .4s; | |
| border-radius: 50%; | |
| } | |
| input:checked+.slider { | |
| background-color: var(--neon-pink); | |
| } | |
| input:checked+.slider:before { | |
| transform: translateX(16px); | |
| } | |
| /* --- Floating Action Buttons (Mobile/Desktop) --- */ | |
| .fab-container { | |
| position: absolute; | |
| top: 20px; | |
| left: 20px; | |
| z-index: 50; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 10px; | |
| } | |
| /* --- Scrollbar --- */ | |
| ::-webkit-scrollbar { | |
| width: 6px; | |
| } | |
| ::-webkit-scrollbar-track { | |
| background: #0a0a12; | |
| } | |
| ::-webkit-scrollbar-thumb { | |
| background: #333; | |
| border-radius: 3px; | |
| } | |
| ::-webkit-scrollbar-thumb:hover { | |
| background: var(--neon-blue); | |
| } | |
| /* --- Responsive --- */ | |
| .menu-toggle-btn { | |
| display: none; | |
| background: none; | |
| border: none; | |
| color: #fff; | |
| font-size: 1.2rem; | |
| cursor: pointer; | |
| } | |
| @media (max-width: 768px) { | |
| #controls { | |
| position: absolute; | |
| right: 0; | |
| top: 0; | |
| bottom: 0; | |
| transform: translateX(100%); | |
| box-shadow: -5px 0 15px rgba(0,0,0,0.5); | |
| } | |
| #controls.active { | |
| transform: translateX(0); | |
| } | |
| .menu-toggle-btn { | |
| display: block; | |
| } | |
| .brand { | |
| font-size: 1rem; | |
| } | |
| .anycoder-link { | |
| display: none; | |
| } | |
| } | |
| .hidden-file { | |
| display: none; | |
| } | |
| #fps-counter { | |
| position: absolute; | |
| bottom: 10px; | |
| left: 10px; | |
| color: #0f0; | |
| font-family: monospace; | |
| background: rgba(0,0,0,0.6); | |
| padding: 4px 8px; | |
| border-radius: 4px; | |
| font-size: 0.75rem; | |
| pointer-events: none; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- Top Navigation --> | |
| <div class="top-bar"> | |
| <div class="brand"> | |
| <button class="menu-toggle-btn me-2" id="menuToggle"><i class="fas fa-bars"></i></button> | |
| ULTRA<span>FX</span> | |
| </div> | |
| <!-- Mandatory Link --> | |
| <a href="https://huggingface.co/spaces/akhaliq/anycoder" target="_blank" class="anycoder-link"> | |
| Built with anycoder | |
| </a> | |
| </div> | |
| <div class="app-wrapper"> | |
| <!-- Main Canvas Area --> | |
| <div id="canvas-wrapper"> | |
| <!-- Floating Action Buttons --> | |
| <div class="fab-container"> | |
| <button class="btn-neon" onclick="document.getElementById('file-input').click()"> | |
| <i class="fas fa-upload"></i> Import Media | |
| </button> | |
| <button class="btn-neon" id="export-btn"> | |
| <i class="fas fa-camera"></i> Snapshot | |
| </button> | |
| <button class="btn-neon" id="auto-detect-btn"> | |
| <i class="fas fa-magic"></i> Reset FX | |
| </button> | |
| <input type="file" id="file-input" class="hidden-file" accept="image/*,video/*"> | |
| </div> | |
| <!-- Canvas injected here --> | |
| <div id="canvas-container"></div> | |
| <div id="fps-counter">FPS: 0</div> | |
| </div> | |
| <!-- Sidebar Controls --> | |
| <div id="controls"> | |
| <div class="sidebar-header"> | |
| <span class="sidebar-title"><i class="fas fa-sliders-h"></i> FX RACK</span> | |
| <button class="btn-neon" style="width: auto; padding: 4px 8px; font-size: 0.7rem;" id="clear-all-btn">Clear All</button> | |
| </div> | |
| <div id="dynamic-controls"> | |
| <!-- Controls injected here --> | |
| <div style="text-align: center; color: #666; margin-top: 50px;"> | |
| <i class="fas fa-photo-video fa-3x mb-3"></i> | |
| <p>Import an image or video to start processing.</p> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| /** | |
| * UltraFX Core Engine | |
| * Handles WebGL context, Library Introspection, and Rendering Pipeline | |
| */ | |
| // --- Global State --- | |
| const state = { | |
| canvas: null, | |
| ctx: null, | |
| glfxCanvas: null, | |
| texture: null, | |
| mediaType: null, // 'image' or 'video' | |
| videoElement: null, | |
| sourceImage: null, | |
| filters: {}, // Stores active filter configurations | |
| animationId: null, | |
| time: 0, | |
| width: 800, | |
| height: 600, | |
| isPlaying: true | |
| }; | |
| // --- Library Definitions & Metadata --- | |
| // Defines which methods from glfx.js act as filters and their parameters | |
| const libraryMeta = { | |
| glfx: { | |
| instance: null, | |
| methods: [ | |
| { name: 'ink', params: [{ name: 'strength', min: 0, max: 1, def: 0.25 }] }, | |
| { name: 'vignette', params: [{ name: 'size', min: 0, max: 1, def: 0.5 }, { name: 'amount', min: 0, max: 1, def: 0.5 }] }, | |
| { name: 'zoomBlur', params: [{ name: 'centerX', min: 0, max: 1, def: 0.5 }, { name: 'centerY', min: 0, max: 1, def: 0.5 }, { name: 'strength', min: 0, max: 1, def: 0.3 }] }, | |
| { name: 'colorHalftone', params: [{ name: 'centerX', min: 0, max: 1, def: 0.5 }, { name: 'centerY', min: 0, max: 1, def: 0.5 }, { name: 'angle', min: 0, max: 1.57, def: 0.25 }, { name: 'size', min: 3, max: 20, def: 4 }] }, | |
| { name: 'hexagonalPixelate', params: [{ name: 'centerX', min: 0, max: 1, def: 0.5 }, { name: 'centerY', min: 0, max: 1, def: 0.5 }, { name: 'scale', min: 5, max: 50, def: 20 }] }, | |
| { name: 'noise', params: [{ name: 'amount', min: 0, max: 1, def: 0.5 }] }, | |
| { name: 'sepia', params: [{ name: 'amount', min: 0, max: 1, def: 1 }] }, | |
| { name: 'brightnessContrast', params: [{ name: 'brightness', min: -1, max: 1, def: 0 }, { name: 'contrast', min: -1, max: 1, def: 0 }] }, | |
| { name: 'hueSaturation', params: [{ name: 'hue', min: -1, max: 1, def: 0 }, { name: 'saturation', min: -1, max: 1, def: 0 }] }, | |
| { name: 'denoise', params: [{ name: 'exponent', min: 0, max: 20, def: 10 }] } | |
| ] | |
| }, | |
| custom: { | |
| methods: [ | |
| { name: 'glitch', params: [{ name: 'intensity', min: 0, max: 100, def: 0 }, { name: 'speed', min: 0, max: 50, def: 10 }] }, | |
| { name: 'strobo', params: [{ name: 'freq', min: 0, max: 60, def: 0 }] }, | |
| { name: 'rgbSplit', params: [{ name: 'amount', min: 0, max: 20, def: 0 }] } | |
| ] | |
| } | |
| }; | |
| // --- Initialization --- | |
| window.onload = function () { | |
| initCanvas(); | |
| setupEventListeners(); | |
| renderLoop(); | |
| }; | |
| function initCanvas() { | |
| const container = document.getElementById('canvas-container'); | |
| // Initialize glfx.js canvas (WebGL) | |
| try { | |
| state.glfxCanvas = fx.canvas(); | |
| libraryMeta.glfx.instance = state.glfxCanvas; | |
| container.appendChild(state.glfxCanvas); | |
| state.canvas = state.glfxCanvas; | |
| } catch (e) { | |
| console.error("WebGL not supported", e); | |
| alert("WebGL is not supported in this browser. UltraFX cannot run."); | |
| return; | |
| } | |
| } | |
| function resizeCanvas(w, h) { | |
| // Limit max size for performance | |
| const max = 1280; | |
| let width = w; | |
| let height = h; | |
| if (width > max) { | |
| const ratio = height / width; | |
| width = max; | |
| height = max * ratio; | |
| } | |
| state.width = width; | |
| state.height = height; | |
| } | |
| // --- UI Generation --- | |
| function generateControls() { | |
| const container = document.getElementById('dynamic-controls'); | |
| container.innerHTML = ''; // Clear existing | |
| // 1. GLFX Library | |
| const glfxHeader = document.createElement('div'); | |
| glfxHeader.innerHTML = '<h6 style="color:var(--neon-blue); margin: 10px 0;">WEBGL FILTERS</h6>'; | |
| container.appendChild(glfxHeader); | |
| libraryMeta.glfx.methods.forEach(effect => { | |
| createControlGroup(container, effect.name, effect.name, effect.params, 'glfx'); | |
| }); | |
| // 2. Custom JS Effects | |
| const customHeader = document.createElement('div'); | |
| customHeader.innerHTML = '<h6 style="color:var(--neon-pink); margin: 20px 0 10px 0;">CUSTOM FX</h6>'; | |
| container.appendChild(customHeader); | |
| libraryMeta.custom.methods.forEach(effect => { | |
| createControlGroup(container, effect.name, effect.name, effect.params, 'custom'); | |
| }); | |
| } | |
| function createControlGroup(parent, label, key, params, type) { | |
| const group = document.createElement('div'); | |
| group.className = 'control-group'; | |
| // Header with Toggle | |
| const header = document.createElement('div'); | |
| header.className = 'control-header'; | |
| header.innerHTML = ` | |
| <span class="control-label">${label}</span> | |
| <label class="toggle-switch"> | |
| <input type="checkbox" id="toggle-${key}"> | |
| <span class="slider"></span> | |
| </label> | |
| `; | |
| group.appendChild(header); | |
| // Initialize State | |
| state.filters[key] = { | |
| active: false, | |
| type: type, | |
| params: {} | |
| }; | |
| // Toggle Listener | |
| header.querySelector('input').addEventListener('change', (e) => { | |
| state.filters[key].active = e.target.checked; | |
| }); | |
| // Parameters | |
| params.forEach(param => { | |
| state.filters[key].params[param.name] = { | |
| value: param.def, | |
| formula: '' | |
| }; | |
| const paramWrapper = document.createElement('div'); | |
| paramWrapper.style.marginBottom = '10px'; | |
| // Label & Value Display | |
| const info = document.createElement('div'); | |
| info.style.display = 'flex'; | |
| info.style.justifyContent = 'space-between'; | |
| info.style.fontSize = '0.75rem'; | |
| info.style.color = '#aaa'; | |
| info.innerHTML = `<span>${param.name}</span><span id="val-${key}-${param.name}">${param.def}</span>`; | |
| paramWrapper.appendChild(info); | |
| // Slider | |
| const slider = document.createElement('input'); | |
| slider.type = 'range'; | |
| slider.min = param.min; | |
| slider.max = param.max; | |
| slider.step = (param.max - param.min) / 100; | |
| slider.value = param.def; | |
| slider.addEventListener('input', (e) => { | |
| const val = parseFloat(e.target.value); | |
| state.filters[key].params[param.name].value = val; | |
| document.getElementById(`val-${key}-${param.name}`).innerText = val.toFixed(2); | |
| }); | |
| paramWrapper.appendChild(slider); | |
| // Math Formula Input | |
| const formulaInput = document.createElement('input'); | |
| formulaInput.type = 'text'; | |
| formulaInput.className = 'math-input'; | |
| formulaInput.placeholder = `Math.js (e.g. sin(t)*${param.max})`; | |
| formulaInput.title = "Use variables: t (time), random (0-1)"; | |
| formulaInput.addEventListener('change', (e) => { | |
| state.filters[key].params[param.name].formula = e.target.value; | |
| }); | |
| paramWrapper.appendChild(formulaInput); | |
| group.appendChild(paramWrapper); | |
| }); | |
| parent.appendChild(group); | |
| } | |
| // --- Event Listeners --- | |
| function setupEventListeners() { | |
| const fileInput = document.getElementById('file-input'); | |
| fileInput.addEventListener('change', handleFileSelect); | |
| document.getElementById('export-btn').addEventListener('click', exportCanvas); | |
| document.getElementById('clear-all-btn').addEventListener('click', clearAllFilters); | |
| document.getElementById('auto-detect-btn').addEventListener('click', () => { | |
| clearAllFilters(); | |
| showToast("FX Reset complete"); | |
| }); | |
| // Mobile Menu | |
| document.getElementById('menuToggle').addEventListener('click', () => { | |
| document.getElementById('controls').classList.toggle('active'); | |
| }); | |
| // Math.js Scope | |
| window.mathScope = { | |
| t: 0, | |
| sin: Math.sin, | |
| cos: Math.cos, | |
| tan: Math.tan, | |
| abs: Math.abs, | |
| random: Math.random, | |
| PI: Math.PI | |
| }; | |
| } | |
| function handleFileSelect(e) { | |
| const file = e.target.files[0]; | |
| if (!file) return; | |
| const url = URL.createObjectURL(file); | |
| if (file.type.startsWith('image')) { | |
| state.mediaType = 'image'; | |
| const img = new Image(); | |
| img.onload = () => { | |
| resizeCanvas(img.width, img.height); | |
| state.sourceImage = img; | |
| state.texture = state.glfxCanvas.texture(img); | |
| generateControls(); | |
| }; | |
| img.src = url; | |
| } else if (file.type.startsWith('video')) { | |
| state.mediaType = 'video'; | |
| const vid = document.createElement('video'); | |
| vid.src = url; | |
| vid.loop = true; | |
| vid.muted = true; | |
| vid.play(); | |
| vid.crossOrigin = "anonymous"; | |
| vid.onloadedmetadata = () => { | |
| resizeCanvas(vid.videoWidth, vid.videoHeight); | |
| state.videoElement = vid; | |
| state.texture = state.glfxCanvas.texture(vid); | |
| generateControls(); | |
| }; | |
| } | |
| } | |
| function clearAllFilters() { | |
| // Uncheck all toggles visually | |
| document.querySelectorAll('.toggle-switch input').forEach(input => input.checked = false); | |
| // Reset internal state | |
| Object.keys(state.filters).forEach(key => { | |
| state.filters[key].active = false; | |
| }); | |
| } | |
| // --- Rendering Pipeline --- | |
| let lastTime = 0; | |
| let frameCount = 0; | |
| const fpsElem = document.getElementById('fps-counter'); | |
| function renderLoop(timestamp) { | |
| requestAnimationFrame(renderLoop); | |
| // FPS Calculation | |
| if (timestamp - lastTime >= 1000) { | |
| fpsElem.innerText = `FPS: ${frameCount}`; | |
| frameCount = 0; | |
| lastTime = timestamp; | |
| } | |
| frameCount++; | |
| // Update Time | |
| state.time += 0.05; | |
| window.mathScope.t = state.time; | |
| if (!state.texture) return; | |
| // Update Texture if Video | |
| if (state.mediaType === 'video' && state.videoElement) { | |
| try { | |
| state.texture.loadContentsOf(state.videoElement); | |
| } catch (e) { } | |
| } | |
| // Start Drawing Chain | |
| state.glfxCanvas.draw(state.texture); | |
| // Apply Active Filters | |
| for (const [key, filter] of Object.entries(state.filters)) { | |
| if (!filter.active) continue; | |
| const appliedParams = []; | |
| const metaParams = libraryMeta[filter.type].methods.find(m => m.name === key).params; | |
| metaParams.forEach(p => { | |
| let val = filter.params[p.name].value; | |
| const formula = filter.params[p.name].formula; | |
| if (formula && formula.trim() !== "") { | |
| try { | |
| // Evaluate Math.js expression | |
| val = math.evaluate(formula, window.mathScope); | |
| // Clamp value to slider range for safety | |
| val = Math.max(p.min, Math.min(p.max, val)); | |
| } catch (err) { | |
| // Silent fail on math errors to prevent loop crash | |
| } | |
| } | |
| appliedParams.push(val); | |
| }); | |
| // Apply GLFX Filters | |
| if (filter.type === 'glfx') { | |
| if (state.glfxCanvas[key]) { | |
| state.glfxCanvas[key](...appliedParams); | |
| } | |
| } | |
| // Apply Custom Filters (Simulated using GLFX primitives) | |
| if (filter.type === 'custom') { | |
| if (key === 'glitch') { | |
| const intensity = appliedParams[0] / 100; | |
| // Random slices simulation using hue/saturation shift and noise | |
| if (Math.random() < intensity) { | |
| state.glfxCanvas.hueSaturation(Math.random() - 0.5, 0); | |
| } | |
| if (Math.random() < intensity * 0.5) { | |
| state.glfxCanvas.noise(0.1); | |
| } | |
| } | |
| if (key === 'strobo') { | |
| const freq = appliedParams[0]; | |
| if (freq > 0 && Math.floor(state.time * freq) % 2 === 0) { | |
| state.glfxCanvas.brightnessContrast(-1, 0); // Black out | |
| } | |
| } | |
| if (key === 'rgbSplit') { | |
| // Simulated shift by modifying hue drastically on specific channels (hack via colorHalftone or similar effects if available) | |
| // Since glfx doesn't have direct channel split, we simulate via chromatic aberration approximation or just noise | |
| const amt = appliedParams[0]; | |
| if(amt > 0) state.glfxCanvas.hexagonalPixelate(0.5, 0.5, amt + 10); | |
| } | |
| } | |
| } | |
| // Finalize | |
| state.glfxCanvas.update(); | |
| } | |
| // --- Export --- | |
| function exportCanvas() { | |
| if (!state.texture) { | |
| showToast("No media loaded!", "error"); | |
| return; | |
| } | |
| state.glfxCanvas.update(); | |
| const dataURL = state.glfxCanvas.toDataURL('image/png'); | |
| const link = document.createElement('a'); | |
| link.download = `ultrafx_${Date.now()}.png`; | |
| link.href = dataURL; | |
| link.click(); | |
| showToast("Snapshot saved!"); | |
| } | |
| // --- Toast Notification --- | |
| function showToast(msg, type = 'success') { | |
| const toast = document.createElement('div'); | |
| toast.style.position = 'fixed'; | |
| toast.style.bottom = '20px'; | |
| toast.style.left = '50%'; | |
| toast.style.transform = 'translateX(-50%)'; | |
| toast.style.background = type === 'error' ? '#ef4444' : 'var(--neon-blue)'; | |
| toast.style.color = type === 'error' ? '#fff' : '#000'; | |
| toast.style.padding = '10px 20px'; | |
| toast.style.borderRadius = '30px'; | |
| toast.style.fontWeight = 'bold'; | |
| toast.style.zIndex = '9999'; | |
| toast.style.boxShadow = '0 5px 15px rgba(0,0,0,0.5)'; | |
| toast.style.opacity = '0'; | |
| toast.style.transition = 'opacity 0.3s'; | |
| toast.innerText = msg; | |
| document.body.appendChild(toast); | |
| // Animate in | |
| setTimeout(() => toast.style.opacity = '1', 10); | |
| // Remove | |
| setTimeout(() => { | |
| toast.style.opacity = '0'; | |
| setTimeout(() => toast.remove(), 300); | |
| }, 3000); | |
| } | |
| </script> | |
| </body> | |
| </html> |