Spaces:
Running
Running
| <html lang="de"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>VisionFX Studio - Pro Bild & Video Editor</title> | |
| <!-- Externe Bibliotheken (CDN) --> | |
| <!-- FontAwesome für Icons --> | |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> | |
| <!-- Math.js für Formelverarbeitung --> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/mathjs/11.8.0/math.min.js"></script> | |
| <!-- Fabric.js für Canvas Manipulation --> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/fabric.js/5.3.1/fabric.min.js"></script> | |
| <style> | |
| :root { | |
| --bg-dark: #121212; | |
| --bg-panel: #1e1e1e; | |
| --primary: #bb86fc; | |
| --secondary: #03dac6; | |
| --text-main: #e0e0e0; | |
| --text-muted: #a0a0a0; | |
| --border: #333; | |
| --danger: #cf6679; | |
| --font-family: 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; | |
| } | |
| * { | |
| box-sizing: border-box; | |
| margin: 0; | |
| padding: 0; | |
| outline: none; | |
| } | |
| body { | |
| font-family: var(--font-family); | |
| background-color: var(--bg-dark); | |
| color: var(--text-main); | |
| height: 100vh; | |
| display: flex; | |
| flex-direction: column; | |
| overflow: hidden; | |
| } | |
| /* Header */ | |
| header { | |
| background-color: var(--bg-panel); | |
| border-bottom: 1px solid var(--border); | |
| padding: 0.8rem 1.5rem; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| height: 60px; | |
| flex-shrink: 0; | |
| } | |
| .logo { | |
| font-size: 1.2rem; | |
| font-weight: 700; | |
| color: var(--primary); | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| } | |
| .header-actions { | |
| display: flex; | |
| gap: 15px; | |
| } | |
| .btn { | |
| background-color: var(--bg-dark); | |
| border: 1px solid var(--border); | |
| color: var(--text-main); | |
| padding: 0.5rem 1rem; | |
| border-radius: 4px; | |
| cursor: pointer; | |
| font-size: 0.9rem; | |
| transition: all 0.2s ease; | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| .btn:hover { | |
| border-color: var(--primary); | |
| color: var(--primary); | |
| } | |
| .btn-primary { | |
| background-color: var(--primary); | |
| color: #000; | |
| border: none; | |
| font-weight: 600; | |
| } | |
| .btn-primary:hover { | |
| background-color: #a370db; | |
| color: #000; | |
| } | |
| /* Main Layout */ | |
| main { | |
| display: flex; | |
| flex: 1; | |
| overflow: hidden; | |
| } | |
| /* Sidebar - Filter Controls */ | |
| .sidebar { | |
| width: 380px; | |
| background-color: var(--bg-panel); | |
| border-right: 1px solid var(--border); | |
| display: flex; | |
| flex-direction: column; | |
| flex-shrink: 0; | |
| transition: transform 0.3s ease; | |
| z-index: 10; | |
| } | |
| .sidebar-header { | |
| padding: 1rem; | |
| border-bottom: 1px solid var(--border); | |
| font-weight: 600; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| } | |
| .filter-list { | |
| flex: 1; | |
| overflow-y: auto; | |
| padding: 1rem; | |
| } | |
| .filter-item { | |
| background-color: var(--bg-dark); | |
| border: 1px solid var(--border); | |
| border-radius: 6px; | |
| margin-bottom: 1rem; | |
| padding: 1rem; | |
| transition: border-color 0.2s; | |
| } | |
| .filter-item.active { | |
| border-color: var(--secondary); | |
| } | |
| .filter-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 0.8rem; | |
| } | |
| .filter-title { | |
| font-size: 0.95rem; | |
| font-weight: 600; | |
| color: var(--secondary); | |
| } | |
| .toggle-switch { | |
| position: relative; | |
| display: inline-block; | |
| width: 34px; | |
| height: 20px; | |
| } | |
| .toggle-switch input { | |
| opacity: 0; | |
| width: 0; | |
| height: 0; | |
| } | |
| .slider { | |
| position: absolute; | |
| cursor: pointer; | |
| top: 0; | |
| left: 0; | |
| right: 0; | |
| bottom: 0; | |
| background-color: #444; | |
| transition: .4s; | |
| border-radius: 20px; | |
| } | |
| .slider:before { | |
| position: absolute; | |
| content: ""; | |
| height: 14px; | |
| width: 14px; | |
| left: 3px; | |
| bottom: 3px; | |
| background-color: white; | |
| transition: .4s; | |
| border-radius: 50%; | |
| } | |
| input:checked + .slider { | |
| background-color: var(--primary); | |
| } | |
| input:checked + .slider:before { | |
| transform: translateX(14px); | |
| } | |
| .control-group { | |
| margin-bottom: 0.8rem; | |
| } | |
| .control-group label { | |
| display: block; | |
| font-size: 0.8rem; | |
| color: var(--text-muted); | |
| margin-bottom: 4px; | |
| } | |
| .range-slider { | |
| width: 100%; | |
| -webkit-appearance: none; | |
| background: transparent; | |
| } | |
| .range-slider::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| height: 14px; | |
| width: 14px; | |
| border-radius: 50%; | |
| background: var(--secondary); | |
| cursor: pointer; | |
| margin-top: -5px; | |
| } | |
| .range-slider::-webkit-slider-runnable-track { | |
| width: 100%; | |
| height: 4px; | |
| cursor: pointer; | |
| background: #444; | |
| border-radius: 2px; | |
| } | |
| .math-input { | |
| width: 100%; | |
| background-color: #222; | |
| border: 1px solid var(--border); | |
| color: var(--primary); | |
| padding: 6px; | |
| border-radius: 4px; | |
| font-family: 'Courier New', monospace; | |
| font-size: 0.85rem; | |
| } | |
| .math-input:focus { | |
| border-color: var(--primary); | |
| } | |
| .math-hint { | |
| font-size: 0.7rem; | |
| color: var(--text-muted); | |
| margin-top: 2px; | |
| } | |
| /* Canvas Area */ | |
| .canvas-container { | |
| flex: 1; | |
| background-color: #000; | |
| background-image: | |
| linear-gradient(45deg, #151515 25%, transparent 25%), | |
| linear-gradient(-45deg, #151515 25%, transparent 25%), | |
| linear-gradient(45deg, transparent 75%, #151515 75%), | |
| linear-gradient(-45deg, transparent 75%, #151515 75%); | |
| background-size: 20px 20px; | |
| background-position: 0 0, 0 10px, 10px -10px, -10px 0px; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| canvas { | |
| box-shadow: 0 0 20px rgba(0,0,0,0.5); | |
| max-width: 95%; | |
| max-height: 95%; | |
| } | |
| /* Footer / Timeline */ | |
| .timeline { | |
| height: 50px; | |
| background-color: var(--bg-panel); | |
| border-top: 1px solid var(--border); | |
| display: flex; | |
| align-items: center; | |
| padding: 0 1rem; | |
| justify-content: space-between; | |
| } | |
| .playback-controls { | |
| display: flex; | |
| gap: 10px; | |
| align-items: center; | |
| } | |
| .time-display { | |
| font-family: monospace; | |
| color: var(--secondary); | |
| } | |
| /* Mobile Responsive */ | |
| @media (max-width: 768px) { | |
| .sidebar { | |
| position: absolute; | |
| height: 100%; | |
| transform: translateX(-100%); | |
| } | |
| .sidebar.open { | |
| transform: translateX(0); | |
| } | |
| .menu-toggle { | |
| display: block; | |
| } | |
| } | |
| /* Helper Classes */ | |
| .hidden { display: none; } | |
| /* Loading Overlay */ | |
| #loading { | |
| position: absolute; | |
| top: 0; left: 0; right: 0; bottom: 0; | |
| background: rgba(0,0,0,0.8); | |
| color: white; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| z-index: 100; | |
| flex-direction: column; | |
| gap: 15px; | |
| } | |
| .spinner { | |
| width: 40px; | |
| height: 40px; | |
| border: 4px solid rgba(255,255,255,0.1); | |
| border-top: 4px solid var(--primary); | |
| border-radius: 50%; | |
| animation: spin 1s linear infinite; | |
| } | |
| @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } | |
| .drop-zone { | |
| position: absolute; | |
| top: 20px; left: 20px; right: 20px; bottom: 20px; | |
| border: 2px dashed var(--text-muted); | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| flex-direction: column; | |
| color: var(--text-muted); | |
| pointer-events: none; | |
| opacity: 0; | |
| transition: opacity 0.3s; | |
| } | |
| .canvas-container.drag-over .drop-zone { | |
| opacity: 1; | |
| background: rgba(0,0,0,0.5); | |
| pointer-events: all; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- Header --> | |
| <header> | |
| <div class="logo"> | |
| <i class="fa-solid fa-layer-group"></i> VisionFX Studio | |
| </div> | |
| <div class="header-actions"> | |
| <button class="btn" id="toggleSidebarBtn" title="Einstellungen"> | |
| <i class="fa-solid fa-sliders"></i> | |
| </button> | |
| <button class="btn" onclick="document.getElementById('fileInput').click()"> | |
| <i class="fa-solid fa-upload"></i> Import | |
| </button> | |
| <input type="file" id="fileInput" class="hidden" accept="image/*,video/*"> | |
| <button class="btn btn-primary" id="exportBtn"> | |
| <i class="fa-solid fa-download"></i> Export | |
| </button> | |
| </div> | |
| </header> | |
| <!-- Main Content --> | |
| <main> | |
| <!-- Sidebar --> | |
| <aside class="sidebar" id="sidebar"> | |
| <div class="sidebar-header"> | |
| <span>Effekte & Filter</span> | |
| <button class="btn" style="padding: 2px 8px; font-size: 0.8rem;" id="resetAllBtn">Reset</button> | |
| </div> | |
| <div class="filter-list" id="filterList"> | |
| <!-- Filter items werden hier per JS generiert --> | |
| </div> | |
| <div style="padding: 1rem; border-top: 1px solid var(--border); font-size: 0.8rem; color: var(--text-muted);"> | |
| <p><strong>Verfügbare Variablen (Math.js):</strong></p> | |
| <ul style="margin-left: 20px; margin-top: 5px;"> | |
| <li><code>t</code>: Zeit in ms (für Animation)</li> | |
| <li><code>x</code>: Horizontaler Wert (0-1)</li> | |
| <li><code>v</code>: Slider Wert</li> | |
| </ul> | |
| <p style="margin-top: 5px;">Beispiel: <code>sin(t/500) * v</code></p> | |
| </div> | |
| </aside> | |
| <!-- Canvas Area --> | |
| <div class="canvas-container" id="canvasWrapper"> | |
| <div id="loading" class="hidden"> | |
| <div class="spinner"></div> | |
| <span>Verarbeite...</span> | |
| </div> | |
| <div class="drop-zone" id="dropZone"> | |
| <i class="fa-solid fa-cloud-arrow-down fa-3x"></i> | |
| <p style="margin-top: 10px;">Datei hier ablegen</p> | |
| </div> | |
| <canvas id="mainCanvas"></canvas> | |
| </div> | |
| </main> | |
| <!-- Timeline / Playback --> | |
| <div class="timeline"> | |
| <div class="playback-controls"> | |
| <button class="btn" id="playPauseBtn"><i class="fa-solid fa-play"></i></button> | |
| <button class="btn" id="stopBtn"><i class="fa-solid fa-stop"></i></button> | |
| <span class="time-display" id="timeDisplay">00:00:00</span> | |
| </div> | |
| <div> | |
| <span style="font-size: 0.8rem; color: var(--text-muted);">Built with <a href="https://huggingface.co/spaces/akhaliq/anycoder" target="_blank" style="color: var(--primary); text-decoration: none;">anycoder</a></span> | |
| </div> | |
| </div> | |
| <script> | |
| /** | |
| * VisionFX Studio - Core Logic | |
| * Verwendet Fabric.js für Basis-Canvas-Operationen, | |
| * aber implementiert einen benutzerdefinierten Render-Loop für "Math.js" Effekte. | |
| */ | |
| // --- Konfiguration & State --- | |
| const config = { | |
| width: 1920, | |
| height: 1080, | |
| bgColor: '#000000' | |
| }; | |
| const state = { | |
| isPlaying: false, | |
| startTime: 0, | |
| elapsedTime: 0, | |
| mediaType: 'none', // 'image' | 'video' | |
| mediaSource: null, // Image Objekt oder Video Element | |
| animationId: null, | |
| filters: {} // Speichert aktuelle Filterzustände | |
| }; | |
| // --- DOM Elemente --- | |
| const canvasEl = document.getElementById('mainCanvas'); | |
| const ctx = canvasEl.getContext('2d'); | |
| const filterListEl = document.getElementById('filterList'); | |
| const fileInput = document.getElementById('fileInput'); | |
| const playPauseBtn = document.getElementById('playPauseBtn'); | |
| const stopBtn = document.getElementById('stopBtn'); | |
| const timeDisplay = document.getElementById('timeDisplay'); | |
| const loadingEl = document.getElementById('loading'); | |
| const dropZone = document.getElementById('dropZone'); | |
| const canvasWrapper = document.getElementById('canvasWrapper'); | |
| const sidebar = document.getElementById('sidebar'); | |
| // --- Filter Registry (Alle bekannten Effekte) --- | |
| // Definiert die Struktur für UI-Generierung und Logik | |
| const filterRegistry = [ | |
| { | |
| id: 'brightness', | |
| name: 'Helligkeit', | |
| type: 'basic', | |
| min: -100, max: 100, val: 0, | |
| formula: 'v', | |
| apply: (ctx, val) => { ctx.filter = `brightness(${100 + val}%)`; } | |
| }, | |
| { | |
| id: 'contrast', | |
| name: 'Kontrast', | |
| type: 'basic', | |
| min: -100, max: 100, val: 0, | |
| formula: 'v', | |
| apply: (ctx, val) => { ctx.filter = `contrast(${100 + val}%)`; } | |
| }, | |
| { | |
| id: 'grayscale', | |
| name: 'Schwarz Weiß', | |
| type: 'basic', | |
| min: 0, max: 100, val: 0, | |
| formula: 'v', | |
| apply: (ctx, val) => { ctx.filter = `grayscale(${val}%)`; } | |
| }, | |
| { | |
| id: 'sepia', | |
| name: 'Sepia', | |
| type: 'basic', | |
| min: 0, max: 100, val: 0, | |
| formula: 'v', | |
| apply: (ctx, val) => { ctx.filter = `sepia(${val}%)`; } | |
| }, | |
| { | |
| id: 'hueRotate', | |
| name: 'Farbrotation (Soul)', | |
| type: 'basic', | |
| min: 0, max: 360, val: 0, | |
| formula: 'v', | |
| apply: (ctx, val) => { ctx.filter = `hue-rotate(${val}deg)`; } | |
| }, | |
| { | |
| id: 'invert', | |
| name: 'Invertieren', | |
| type: 'basic', | |
| min: 0, max: 100, val: 0, | |
| formula: 'v', | |
| apply: (ctx, val) => { ctx.filter = `invert(${val}%)`; } | |
| }, | |
| { | |
| id: 'blur', | |
| name: 'Weichzeichner', | |
| type: 'basic', | |
| min: 0, max: 20, val: 0, | |
| formula: 'v', | |
| apply: (ctx, val) => { ctx.filter = `blur(${val}px)`; } | |
| }, | |
| { | |
| id: 'pixelate', | |
| name: 'Pixelate (8-Bit)', | |
| type: 'custom', | |
| min: 1, max: 50, val: 1, | |
| formula: 'v', | |
| apply: (ctx, val, width, height) => { | |
| if(val <= 1) return; | |
| // Pixelate Trick: Skaliere runter und zeichne mit smoothing=false | |
| const w = width / val; | |
| const h = height / val; | |
| // Wir speichern das aktuelle canvas image, manipulieren es und geben es zurück? | |
| // Da dies ein Post-Process ist, machen wir es im Haupt-Loop. | |
| } | |
| }, | |
| { | |
| id: 'glitch', | |
| name: 'Glitch Verzerrung', | |
| type: 'anim', | |
| min: 0, max: 100, val: 0, | |
| formula: 'v', // oder 'sin(t/100)*v' für automatisches Glitch | |
| apply: (ctx, val, w, h, t) => { | |
| // Wird im Render Loop manuell angewendet | |
| } | |
| }, | |
| { | |
| id: 'strobe', | |
| name: 'Strobe Light', | |
| type: 'anim', | |
| min: 0, max: 100, val: 0, | |
| formula: 'sin(t/50) * v', // Schnelles Blitzen | |
| apply: (ctx, val) => { | |
| // Wird im Overlay angewendet | |
| } | |
| }, | |
| { | |
| id: 'scanlines', | |
| name: 'Video Scanlines', | |
| type: 'overlay', | |
| min: 0, max: 100, val: 0, | |
| formula: 'v', | |
| apply: (ctx, val) => {} | |
| }, | |
| { | |
| id: 'vignette', | |
| name: 'Vignette', | |
| type: 'overlay', | |
| min: 0, max: 100, val: 0, | |
| formula: 'v', | |
| apply: (ctx, val) => {} | |
| }, | |
| { | |
| id: 'rgbSplit', | |
| name: 'RGB Shift (Chroma)', | |
| type: 'custom', | |
| min: 0, max: 50, val: 0, | |
| formula: 'v', | |
| apply: (ctx, val) => {} | |
| } | |
| ]; | |
| // --- Initialisierung --- | |
| function init() { | |
| // Setup Events | |
| fileInput.addEventListener('change', handleFileSelect); | |
| playPauseBtn.addEventListener('click', togglePlay); | |
| stopBtn.addEventListener('click', stopMedia); | |
| document.getElementById('toggleSidebarBtn').addEventListener('click', () => { | |
| sidebar.classList.toggle('open'); | |
| }); | |
| document.getElementById('resetAllBtn').addEventListener('click', resetFilters); | |
| document.getElementById('exportBtn').addEventListener('click', exportMedia); | |
| // Drag & Drop | |
| canvasWrapper.addEventListener('dragover', (e) => { e.preventDefault(); canvasWrapper.classList.add('drag-over'); }); | |
| canvasWrapper.addEventListener('dragleave', () => canvasWrapper.classList.remove('drag-over')); | |
| canvasWrapper.addEventListener('drop', handleDrop); | |
| // UI Generierung | |
| generateFilterUI(); | |
| // Canvas Startzustand | |
| canvasEl.width = config.width; | |
| canvasEl.height = config.height; | |
| ctx.fillStyle = config.bgColor; | |
| ctx.fillRect(0, 0, canvasEl.width, canvasEl.height); | |
| // Start Render Loop | |
| requestAnimationFrame(renderLoop); | |
| // Libraries loggen | |
| console.log("Verfügbare Libraries:"); | |
| console.log("Fabric.js:", typeof fabric !== 'undefined'); | |
| console.log("Math.js:", typeof math !== 'undefined'); | |
| } | |
| // --- UI Generierung --- | |
| function generateFilterUI() { | |
| filterListEl.innerHTML = ''; | |
| filterRegistry.forEach(f => { | |
| const item = document.createElement('div'); | |
| item.className = 'filter-item'; | |
| item.id = `filter-${f.id}`; | |
| item.innerHTML = ` | |
| <div class="filter-header"> | |
| <span class="filter-title">${f.name}</span> | |
| <label class="toggle-switch"> | |
| <input type="checkbox" class="filter-toggle" data-id="${f.id}"> | |
| <span class="slider"></span> | |
| </label> | |
| </div> | |
| <div class="control-group"> | |
| <label>Intensität (Slider)</label> | |
| <input type="range" class="range-slider param-slider" | |
| data-id="${f.id}" min="${f.min}" max="${f.max}" value="${f.val}" step="0.1"> | |
| </div> | |
| <div class="control-group"> | |
| <label>Math.js Formel (t=Zeit, v=Wert)</label> | |
| <input type="text" class="math-input param-formula" | |
| data-id="${f.id}" value="${f.formula}"> | |
| <div class="math-hint">z.B. sin(t/1000) * 50</div> | |
| </div> | |
| `; | |
| filterListEl.appendChild(item); | |
| // State Initialisierung | |
| state.filters[f.id] = { | |
| active: false, | |
| value: f.val, | |
| formula: f.formula, | |
| computedValue: f.val | |
| }; | |
| // Event Listeners für Controls | |
| const toggle = item.querySelector('.filter-toggle'); | |
| const slider = item.querySelector('.param-slider'); | |
| const formulaInput = item.querySelector('.param-formula'); | |
| toggle.addEventListener('change', (e) => { | |
| state.filters[f.id].active = e.target.checked; | |
| item.classList.toggle('active', e.target.checked); | |
| }); | |
| slider.addEventListener('input', (e) => { | |
| state.filters[f.id].value = parseFloat(e.target.value); | |
| }); | |
| formulaInput.addEventListener('input', (e) => { | |
| state.filters[f.id].formula = e.target.value; | |
| }); | |
| }); | |
| } | |
| function resetFilters() { | |
| document.querySelectorAll('.filter-toggle').forEach(el => el.checked = false); | |
| Object.keys(state.filters).forEach(key => { | |
| state.filters[key].active = false; | |
| state.filters[key].value = filterRegistry.find(f => f.id === key).val; | |
| document.querySelector(`.filter-item#filter-${key}`).classList.remove('active'); | |
| document.querySelector(`.filter-item#filter-${key} .param-slider`).value = state.filters[key].value; | |
| }); | |
| } | |
| // --- Math Engine --- | |
| function evaluateFilterFormula(filterState, time) { | |
| try { | |
| // Scope: t (zeit in ms), v (slider wert) | |
| const scope = { | |
| t: time, | |
| v: filterState.value, | |
| x: Math.random(), // Zufall für Glitch | |
| pi: Math.PI, | |
| e: Math.E | |
| }; | |
| const result = math.evaluate(filterState.formula, scope); | |
| return result; | |
| } catch (err) { | |
| // Fallback bei Syntaxfehler | |
| return filterState.value; | |
| } | |
| } | |
| // --- File Handling --- | |
| function handleFileSelect(e) { | |
| const file = e.target.files[0]; | |
| if (file) loadMedia(file); | |
| } | |
| function handleDrop(e) { | |
| e.preventDefault(); | |
| canvasWrapper.classList.remove('drag-over'); | |
| if (e.dataTransfer.files.length) { | |
| loadMedia(e.dataTransfer.files[0]); | |
| } | |
| } | |
| function loadMedia(file) { | |
| loadingEl.classList.remove('hidden'); | |
| const url = URL.createObjectURL(file); | |
| if (file.type.startsWith('image/')) { | |
| const img = new Image(); | |
| img.onload = () => { | |
| state.mediaType = 'image'; | |
| state.mediaSource = img; | |
| // Canvas Größe anpassen | |
| canvasEl.width = img.width; | |
| canvasEl.height = img.height; | |
| loadingEl.classList.add('hidden'); | |
| renderFrame(); // Einmal rendern | |
| }; | |
| img.src = url; | |
| } else if (file.type.startsWith('video/')) { | |
| const video = document.createElement('video'); | |
| video.src = url; | |
| video.muted = true; | |
| video.loop = true; | |
| video.onloadeddata = () => { | |
| state.mediaType = 'video'; | |
| state.mediaSource = video; | |
| canvasEl.width = video.videoWidth; | |
| canvasEl.height = video.videoHeight; | |
| loadingEl.classList.add('hidden'); | |
| video.play(); | |
| state.isPlaying = true; | |
| updatePlayIcon(); | |
| }; | |
| } | |
| } | |
| // --- Rendering Engine --- | |
| function togglePlay() { | |
| if (state.mediaType !== 'video') return; | |
| if (state.isPlaying) { | |
| state.mediaSource.pause(); | |
| state.isPlaying = false; | |
| } else { | |
| state.mediaSource.play(); | |
| state.isPlaying = true; | |
| } | |
| updatePlayIcon(); | |
| } | |
| function stopMedia() { | |
| if (state.mediaType === 'video') { | |
| state.mediaSource.pause(); | |
| state.mediaSource.currentTime = 0; | |
| state.isPlaying = false; | |
| } | |
| state.elapsedTime = 0; | |
| updatePlayIcon(); | |
| } | |
| function updatePlayIcon() { | |
| playPauseBtn.innerHTML = state.isPlaying ? '<i class="fa-solid fa-pause"></i>' : '<i class="fa-solid fa-play"></i>'; | |
| } | |
| function renderLoop(timestamp) { | |
| if (!state.startTime) state.startTime = timestamp; | |
| const current = timestamp - state.startTime; | |
| if (state.isPlaying || state.mediaType === 'image') { | |
| renderFrame(timestamp); | |
| } | |
| // Update Zeit Anzeige | |
| if (state.mediaType === 'video' && state.mediaSource) { | |
| const secs = state.mediaSource.currentTime; | |
| const m = Math.floor(secs / 60).toString().padStart(2, '0'); | |
| const s = Math.floor(secs % 60).toString().padStart(2, '0'); | |
| const ms = Math.floor((secs % 1) * 100).toString().padStart(2, '0'); | |
| timeDisplay.innerText = `${m}:${s}:${ms}`; | |
| } | |
| requestAnimationFrame(renderLoop); | |
| } | |
| function renderFrame(timestamp = 0) { | |
| // 1. Berechne alle Werte basierend auf Formeln | |
| filterRegistry.forEach(f => { | |
| const s = state.filters[f.id]; | |
| if (s.active) { | |
| s.computedValue = evaluateFilterFormula(s, timestamp); | |
| } else { | |
| s.computedValue = 0; // oder neutraler wert | |
| } | |
| }); | |
| // 2. Canvas Reset | |
| ctx.clearRect(0, 0, canvasEl.width, canvasEl.height); | |
| ctx.save(); | |
| // 3. Basis-Filter (CSS Context Filter) anwenden | |
| // Wir sammeln alle 'basic' Filter | |
| let basicFilterString = ''; | |
| const brightness = state.filters['brightness'].active ? `brightness(${100 + state.filters['brightness'].computedValue}%)` : `brightness(100%)`; | |
| const contrast = state.filters['contrast'].active ? `contrast(${100 + state.filters['contrast'].computedValue}%)` : `contrast(100%)`; | |
| const grayscale = state.filters['grayscale'].active ? `grayscale(${state.filters['grayscale'].computedValue}%)` : `grayscale(0%)`; | |
| const sepia = state.filters['sepia'].active ? `sepia(${state.filters['sepia'].computedValue}%)` : `sepia(0%)`; | |
| const hue = state.filters['hueRotate'].active ? `hue-rotate(${state.filters['hueRotate'].computedValue}deg)` : `hue-rotate(0deg)`; | |
| const invert = state.filters['invert'].active ? `invert(${state.filters['invert'].computedValue}%)` : `invert(0%)`; | |
| const blur = state.filters['blur'].active ? `blur(${state.filters['blur'].computedValue}px)` : `blur(0px)`; | |
| ctx.filter = `${brightness} ${contrast} ${grayscale} ${sepia} ${hue} ${invert} ${blur}`; | |
| // 4. Pixelate (Special Handling vor dem Zeichnen) | |
| const pixelateVal = state.filters['pixelate'].computedValue; | |
| if (state.mediaSource) { | |
| if (state.mediaType === 'image') { | |
| if (pixelateVal > 1) { | |
| // Pixelate Effekt durch Downscaling | |
| const w = canvasEl.width / pixelateVal; | |
| const h = canvasEl.height / pixelateVal; | |
| // Zeichne verkleinert in Offscreen Canvas (oder direkt hier mit Trick) | |
| // Einfacher Trick: imageSmoothingEnabled = false und skalierung | |
| ctx.imageSmoothingEnabled = false; | |
| ctx.drawImage(state.mediaSource, 0, 0, w, h); | |
| ctx.drawImage(canvasEl, 0, 0, w, h, 0, 0, canvasEl.width, canvasEl.height); | |
| } else { | |
| ctx.drawImage(state.mediaSource, 0, 0); | |
| } | |
| } else if (state.mediaType === 'video') { | |
| if (pixelateVal > 1) { | |
| const w = canvasEl.width / pixelateVal; | |
| const h = canvasEl.height / pixelateVal; | |
| ctx.imageSmoothingEnabled = false; | |
| ctx.drawImage(state.mediaSource, 0, 0, w, h); | |
| ctx.drawImage(canvasEl, 0, 0, w, h, 0, 0, canvasEl.width, canvasEl.height); | |
| } else { | |
| ctx.drawImage(state.mediaSource, 0, 0, canvasEl.width, canvasEl.height); | |
| } | |
| } | |
| } | |
| ctx.restore(); // Filter resetten für Overlays | |
| // 5. Custom Effekte & Overlays (Pixel Manipulation / Compositing) | |
| // RGB Split | |
| if (state.filters['rgbSplit'].active && state.filters['rgbSplit'].computedValue > 0) { | |
| const offset = state.filters['rgbSplit'].computedValue; | |
| const w = canvasEl.width; | |
| const h = canvasEl.height; | |
| // Erstelle临时 Kopien | |
| // Da wir in JS keine performante Pixel-Manipulation pro Frame ohne WebGL wollen, | |
| // nutzen wir Composite Operations für einen schnellen RGB Shift Trick | |
| ctx.save(); | |
| ctx.globalCompositeOperation = 'screen'; | |
| // Rot Kanal simulieren (tint) | |
| // Echter RGB Shift braucht imageData, was langsam ist. Wir machen einen "Shift" Versatz. | |
| // Zeichne Canvas leicht versetzt mit Colorize (via Filter) wäre besser, aber komplex. | |
| // Wir nutzen hier einen einfachen Slice-Shift Trick: | |
| ctx.globalAlpha = 0.5; | |
| ctx.translate(offset, 0); | |
| ctx.drawImage(canvasEl, 0, 0); // Rot-ish shift | |
| ctx.translate(-offset*2, 0); | |
| ctx.drawImage(canvasEl, 0, 0); // Cyan-ish shift | |
| ctx.restore(); | |
| } | |
| // Strobe Flash | |
| if (state.filters['strobe'].active) { | |
| const val = state.filters['strobe'].computedValue; // -100 bis 100 | |
| if (val > 10) { | |
| ctx.fillStyle = `rgba(255, 255, 255, ${Math.min(val / 100, 1)})`; | |
| ctx.fillRect(0, 0, canvasEl.width, canvasEl.height); | |
| } else if (val < -10) { | |
| ctx.fillStyle = `rgba(0, 0, 0, ${Math.min(Math.abs(val) / 100, 1)})`; | |
| ctx.fillRect(0, 0, canvasEl.width, canvasEl.height); | |
| } | |
| } | |
| // Scanlines Overlay | |
| if (state.filters['scanlines'].active) { | |
| const opacity = state.filters['scanlines'].computedValue / 100; | |
| ctx.fillStyle = `rgba(0, 0, 0, ${opacity})`; | |
| for (let y = 0; y < canvasEl.height; y += 4) { | |
| ctx.fillRect(0, y, canvasEl.width, 2); | |
| } | |
| } | |
| // Vignette | |
| if (state.filters['vignette'].active) { | |
| const val = state.filters['vignette'].computedValue; | |
| const gradient = ctx.createRadialGradient( | |
| canvasEl.width / 2, canvasEl.height / 2, canvasEl.height / 3, | |
| canvasEl.width / 2, canvasEl.height / 2, canvasEl.height | |
| ); | |
| gradient.addColorStop(0, "rgba(0,0,0,0)"); | |
| gradient.addColorStop(1, `rgba(0,0,0,${val / 100})`); | |
| ctx.fillStyle = gradient; | |
| ctx.fillRect(0, 0, canvasEl.width, canvasEl.height); | |
| } | |
| // Glitch (Horizontal Slices) | |
| if (state.filters['glitch'].active) { | |
| const intensity = state.filters['glitch'].computedValue; | |
| if (intensity > 0.1 && Math.random() < 0.2) { // Nur manchmal triggern für Glitch Look | |
| const sliceHeight = Math.random() * 50 + 10; | |
| const sliceY = Math.random() * canvasEl.height; | |
| const offset = (Math.random() - 0.5) * intensity * 4; | |
| try { | |
| // Slice ausschneiden | |
| const sliceData = ctx.getImageData |